Skip to content

Commit 7a01847

Browse files
claude[bot]claude
andauthored
fix(lint): the remaining collection readers use the guarded recordsOf instead of private copies (#15728) (#16286)
* fix(lint): read the remaining collection copies through the shared guarded readers Sixteen modules in `packages/lint/src` still declared a private `(v: unknown) => AnyRec[]` collection coercion. Twelve of them (the `function` form, which already filtered non-records locally in two spellings) now read `recordsOf` from `object-graph.js`. The four page walks (the arrow form, which cast the array branch unchecked and read only the list shape) now read `collectionEntries` from `collection-entries.js`, which makes the same drop inside the reader while carrying each survivor's real config path — `recordsOf` would renumber the positional `pages[N].source` paths those four emit, which is #15740's open question and not this change's to answer. Their sixteen `COPY_LEDGER` rows and the four `UNGUARDED_ALLOWANCE` rows are deleted with them; the gate is exact in both directions, so it was red on the stale rows until they went. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 * test(lint): discharge the two both-directions pins the re-pointing satisfied `non-record-object-entry.test.ts` recorded one phantom finding still invented for `stack.agents · an array`; `recordsOf` filters with `isRec`, so the row is now a lie and comes out. `validate-rule- compilability.test.ts` excused the receiver `v`, which only existed inside the private `asArray` this change deleted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 * chore: add changeset for the lint collection-reader cleanup Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 * fix(lint): a map-shaped `pages:` reaches the four source-page lints `pages` is authorable as a name-keyed map — `MAP_SUPPORTED_FIELDS` lists it and `normalizeStackInput` folds it into a list before the schema parses it, which is why `stack.zod.ts` declares only the post-normalization `z.array(PageSchema)`. These four rules run on the raw `os lint` path, where nothing has normalized anything, and their private coercion answered a map with `[]` — so every page lint passed by never running. Pins the closure per rule with a specific rule id and the finding path, plus the list carrier as the positive control. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7beaaa3 commit 7a01847

21 files changed

Lines changed: 194 additions & 201 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously.
6+
7+
`pages` has two authoring carriers — a list, or a map keyed by page name that
8+
`normalizeStackInput` folds into a list before the schema sees it. Four rules
9+
(`validate-jsx-pages`, `validate-page-source-styling`,
10+
`validate-react-page-props`, `validate-react-pages`) read the collection through
11+
a private coercion that answered a map with an empty list, and they run on the
12+
raw `os lint` path where nothing has normalized it yet. On a map-shaped stack
13+
all four therefore returned no findings by never walking a single page: an
14+
empty source, a syntax error, an unparseable component and a Tailwind
15+
`className` were all reported as clean. They now read `collectionEntries`,
16+
which handles both carriers, and a finding on the map carrier is located by the
17+
author's own key (`pages.home.source`) rather than a synthetic index.
18+
19+
The same change removes the last sixteen private copies of the collection
20+
coercion in this package. Twelve rules — the `function` form, which had already
21+
grown the non-record filter locally in two different spellings — now read
22+
`recordsOf` from `object-graph.ts`. Two behaviour changes fall out, both on
23+
input that was already malformed: an array-typed member of `agents:` /
24+
`skills:` / `tools:` used to survive the looser local filter and draw one
25+
reference-integrity finding at a position nobody authored, and is now dropped;
26+
a member of a name-keyed `validations:` map whose value is not a record is now
27+
carried as `{ name }` rather than discarded, which reaches no check that reads
28+
it. No rule id, message or severity changes, and every finding path on the list
29+
carrier is unchanged.

packages/lint/src/collection-coercion-single-copy.test.ts

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@
3232
// fix one and leave N-1, and no reviewer counts to 39. So the count is asserted
3333
// here instead.
3434
//
35+
// ## Two destinations, not one
36+
//
37+
// Deleting a copy means re-pointing its call sites, and there are two places to
38+
// point them. A reader that reports no location takes `recordsOf` directly. A
39+
// reader that reports a POSITIONAL path cannot: `recordsOf` drops a non-record
40+
// member, so every index after it shifts and the finding names an entry the
41+
// author's file does not have there (#15740). Those readers take
42+
// `collectionEntries` (`collection-entries.ts`) instead — the same drop, made
43+
// in the reader, with each survivor's real path carried out beside it
44+
// (`pages[3]` on the array shape, `pages.contact_page` on the map). #15728
45+
// moved the four page walks that way; the two reference-integrity members
46+
// below still hold their own copy because the paths #15494 pinned on them are
47+
// #15740's call, not a developer's.
48+
//
49+
// Either destination satisfies this file: neither declares a second
50+
// `(v: unknown) => AnyRec[]`, which is the shape all three clauses count.
51+
//
3552
// ## The three clauses, and what each one refuses
3653
//
3754
// 1. `object-graph.ts` declares exactly one such coercion, named `recordsOf`.
@@ -102,26 +119,6 @@ const COPY_LEDGER: Readonly<Record<string, string>> = {
102119
// index-preserving reader, not on anyone's attention (#15740).
103120
'validate-object-field-refs.ts': '#15740',
104121
'validate-list-view-field-refs.ts': '#15740',
105-
// 2026-09-05 — the sixteen copies that do not crash today: twelve grew a
106-
// local array-branch filter and four read only the list shape behind a
107-
// call-site `if (!page) continue`. They are not #15636's defect; they are its
108-
// cause, and re-pointing them is bookkeeping this ledger now forces.
109-
'validate-action-body-writes.ts': '#15728',
110-
'validate-ai-agent-authoring.ts': '#15728',
111-
'validate-ai-surface-affinity.ts': '#15728',
112-
'validate-ai-tool-references.ts': '#15728',
113-
'validate-flow-node-writes.ts': '#15728',
114-
'validate-hook-body-writes.ts': '#15728',
115-
'validate-jsx-pages.ts': '#15728',
116-
'validate-nav-object-servability.ts': '#15728',
117-
'validate-nav-target-refs.ts': '#15728',
118-
'validate-page-source-styling.ts': '#15728',
119-
'validate-page-visualization-bindings.ts': '#15728',
120-
'validate-react-page-props.ts': '#15728',
121-
'validate-react-pages.ts': '#15728',
122-
'validate-readonly-action-writes.ts': '#15728',
123-
'validate-rule-compilability.ts': '#15728',
124-
'validate-view-page-refs.ts': '#15728',
125122
};
126123

127124
/**
@@ -139,11 +136,6 @@ const UNGUARDED_ALLOWANCE: Readonly<Record<string, string>> = {
139136
// first; both guard every member with `isRec` at the call site.
140137
'validate-object-field-refs.ts': '#15740',
141138
'validate-list-view-field-refs.ts': '#15740',
142-
// 2026-09-05 — removed by #15728.
143-
'validate-jsx-pages.ts': '#15728',
144-
'validate-page-source-styling.ts': '#15728',
145-
'validate-react-page-props.ts': '#15728',
146-
'validate-react-pages.ts': '#15728',
147139
};
148140

149141
/** Every rule/reader module — tests excluded, this file excluded. */

packages/lint/src/non-record-object-entry.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -362,16 +362,16 @@ const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {
362362
* phantom half of the same defect, and the shape `validateSecurityPosture` was
363363
* caught in for `objects` (#15552).
364364
*
365-
* `stack.agents · an array`: the agent readers filter their array branch with
366-
* `!!x && typeof x === 'object'`, and `[]` passes that test — so an empty list
367-
* item survives as an agent with no name and draws one reference-integrity
368-
* finding at a position nobody wrote. `recordsOf` uses `isRec`, which excludes
369-
* an array, so re-pointing those readers closes this too. They are among the
370-
* sixteen copies in #15728.
365+
* Empty since #15728. The one row it held was `stack.agents · an array`: the
366+
* agent readers filtered their array branch with `!!x && typeof x === 'object'`
367+
* and `[]` passes that test, so an empty list item survived as an agent with no
368+
* name and drew one reference-integrity finding at a position nobody wrote.
369+
* `recordsOf` filters with `isRec`, which excludes an array, and re-pointing
370+
* those readers closed it. The row came out because this assertion went red
371+
* demanding an invented finding that no longer happens — the both-directions
372+
* half earning its keep, the same way `stack.datasets` left `RESIDUAL_THROWS`.
371373
*/
372-
const RESIDUAL_INVENTED: Readonly<Record<string, number>> = {
373-
'stack.agents · an array': 1,
374-
};
374+
const RESIDUAL_INVENTED: Readonly<Record<string, number>> = {};
375375

376376
describe('a non-record entry in any other stack collection (#15636)', () => {
377377
describe.each(SWEPT_COLLECTIONS.map((c) => [c.label, c] as const))('%s', (_label, collection) => {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// A name-keyed `pages:` map reaches the four source-page lints (#15728).
4+
//
5+
// ## What was vacuous, and why it read as green
6+
//
7+
// `pages` is an authoring surface with TWO carriers. `MAP_SUPPORTED_FIELDS`
8+
// (`packages/spec/src/shared/metadata-collection.zod.ts`) lists it, and
9+
// `normalizeStackInput` folds the map into an array — injecting the map key as
10+
// `name` — BEFORE `ObjectStackDefinitionSchema` sees it, which is why
11+
// `stack.zod.ts` declares the post-normalization form `z.array(PageSchema)`
12+
// and a raw map fails a bare `safeParse`. Reading that declaration alone says
13+
// "map is not authorable", and that reading is wrong.
14+
//
15+
// These four rules are pure `(stack) => Finding[]` (ADR-0019) and run on the
16+
// RAW `os lint` path as well as the parsed one, so on the raw path they see
17+
// exactly what the author's file deserialised to — the map. Each of them used
18+
// to coerce it with a private
19+
// `(v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : [])`, which
20+
// answers a map with `[]`. So every page lint below passed on a map-shaped
21+
// stack by never running: no finding, no error, nothing to notice. That is the
22+
// failure mode this file exists to keep closed — a lint whose green means it
23+
// looked, not a lint whose green means it never did.
24+
//
25+
// Each case therefore asserts a SPECIFIC rule id and the finding's path. The
26+
// path is the second half of the fix: `collectionEntries` reports the map key
27+
// (`pages.home.source`), not a synthetic array index nobody can look up, so
28+
// the finding stays usable as an edit target on either carrier.
29+
import { describe, expect, it } from 'vitest';
30+
import { validateJsxPages } from './validate-jsx-pages.js';
31+
import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js';
32+
import { validateReactPageProps, REACT_PAGE_SOURCE_UNPARSEABLE } from './validate-react-page-props.js';
33+
import { validateReactPages } from './validate-react-pages.js';
34+
35+
/** The same page, authored both ways. `home` is the map key and the `name`. */
36+
const asMap = (page: Record<string, unknown>) => ({ pages: { home: page } });
37+
const asList = (page: Record<string, unknown>) => ({ pages: [{ name: 'home', ...page }] });
38+
39+
describe('a name-keyed `pages:` map reaches every source-page lint (#15728)', () => {
40+
it('validateReactPages reports the empty source it used to walk past', () => {
41+
const f = validateReactPages(asMap({ kind: 'react' }));
42+
expect(f.map((x) => x.rule)).toContain('react-page-empty-source');
43+
expect(f.find((x) => x.rule === 'react-page-empty-source')?.path).toBe('pages.home.source');
44+
});
45+
46+
it('validateJsxPages reports the empty source it used to walk past', () => {
47+
const f = validateJsxPages(asMap({ kind: 'html' }));
48+
expect(f.map((x) => x.rule)).toContain('jsx-page-empty-source');
49+
expect(f.find((x) => x.rule === 'jsx-page-empty-source')?.path).toBe('pages.home.source');
50+
});
51+
52+
it('validatePageSourceStyling reports the Tailwind className it used to walk past', () => {
53+
const f = validatePageSourceStyling(asMap({ kind: 'react', source: 'function Page(){ return <div className="p-4" />; }' }));
54+
expect(f.map((x) => x.rule)).toContain(PAGE_SOURCE_CLASSNAME);
55+
expect(f.find((x) => x.rule === PAGE_SOURCE_CLASSNAME)?.path).toBe('pages.home.source');
56+
});
57+
58+
it('validateReactPageProps reports the unparseable source it used to walk past', () => {
59+
const wrecked = 'function Page(){\n /* TODO\n return <ObjectForm mode="edit" />;\n}\n';
60+
const f = validateReactPageProps(asMap({ kind: 'react', source: wrecked }));
61+
expect(f.map((x) => x.rule)).toContain(REACT_PAGE_SOURCE_UNPARSEABLE);
62+
expect(f.find((x) => x.rule === REACT_PAGE_SOURCE_UNPARSEABLE)?.path).toBe('pages.home.source');
63+
});
64+
65+
// The array carrier is the control: the same page authored as a list still
66+
// reports the same rule at the positional path, so the map cases above are a
67+
// carrier the rules GAINED and not a path spelling they swapped to.
68+
it('POSITIVE CONTROL — the list carrier still reports at its positional path', () => {
69+
const f = validateReactPages(asList({ kind: 'react' }));
70+
expect(f.map((x) => x.rule)).toContain('react-page-empty-source');
71+
expect(f.find((x) => x.rule === 'react-page-empty-source')?.path).toBe('pages[0].source');
72+
});
73+
});

packages/lint/src/validate-action-body-writes.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import {
9999
type BodyWritePatternExclusion,
100100
type HookBodyWritePattern,
101101
} from './validate-hook-body-writes.js';
102+
import { recordsOf } from './object-graph.js';
102103

103104
export type ActionBodyWriteSeverity = 'warning';
104105

@@ -199,18 +200,6 @@ type AnyRec = Record<string, unknown>;
199200

200201
const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);
201202

202-
/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
203-
function asArray(v: unknown): AnyRec[] {
204-
if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x));
205-
if (isRec(v)) {
206-
return Object.entries(v).map(([name, def]) => ({
207-
name,
208-
...(isRec(def) ? def : {}),
209-
}));
210-
}
211-
return [];
212-
}
213-
214203
/**
215204
* One L2 action body found in the stack, with the location to report it at.
216205
*
@@ -284,7 +273,7 @@ export function collectActionBodies(stack: AnyRec): ActionBodySite[] {
284273
const seen = new Set<string>();
285274

286275
const collect = (actions: unknown, pathPrefix: string, parentObject?: string): void => {
287-
asArray(actions).forEach((action, index) => {
276+
recordsOf(actions).forEach((action, index) => {
288277
// Same default the spec declares, and the same one the runtime gate
289278
// applies — a stack may reach lint unparsed, so an omitted `type` is
290279
// `'script'`, not "unknown".
@@ -303,7 +292,7 @@ export function collectActionBodies(stack: AnyRec): ActionBodySite[] {
303292
};
304293

305294
collect(stack.actions, 'actions');
306-
asArray(stack.objects).forEach((obj, objIndex) => {
295+
recordsOf(stack.objects).forEach((obj, objIndex) => {
307296
const parentObject = typeof obj.name === 'string' && obj.name ? obj.name : undefined;
308297
collect(obj.actions, `objects[${objIndex}].actions`, parentObject);
309298
});

packages/lint/src/validate-ai-agent-authoring.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
* the signal has to be an authoring-time nudge rather than a build break.
7474
*/
7575

76+
import { recordsOf } from './object-graph.js';
77+
7678
export const AGENT_AUTHORING_WITHDRAWN = 'agent-authoring-withdrawn';
7779

7880
/** `app.defaultAgent` names something outside the platform agent roster. */
@@ -100,14 +102,6 @@ export interface AiAgentAuthoringFinding {
100102

101103
type AnyRec = Record<string, unknown>;
102104

103-
function asArray(v: unknown): AnyRec[] {
104-
if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object');
105-
if (v && typeof v === 'object') {
106-
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
107-
}
108-
return [];
109-
}
110-
111105
function strName(v: unknown): string | undefined {
112106
return typeof v === 'string' && v.length > 0 ? v : undefined;
113107
}
@@ -154,7 +148,7 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding
154148
const findings: AiAgentAuthoringFinding[] = [];
155149
if (!stack || typeof stack !== 'object') return findings;
156150

157-
const agents = asArray(stack.agents);
151+
const agents = recordsOf(stack.agents);
158152
for (let ai = 0; ai < agents.length; ai++) {
159153
const agent = agents[ai];
160154
const name = strName(agent.name) ?? `#${ai}`;
@@ -189,7 +183,7 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding
189183
}
190184

191185
const roster = CANONICAL_AGENT_NAMES.join(', ');
192-
const apps = asArray(stack.apps);
186+
const apps = recordsOf(stack.apps);
193187
for (let appIdx = 0; appIdx < apps.length; appIdx++) {
194188
const app = apps[appIdx];
195189
const defaultAgent = strName(app.defaultAgent);

packages/lint/src/validate-ai-surface-affinity.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
* contract is reported.
2929
*/
3030

31+
import { recordsOf } from './object-graph.js';
32+
3133
export const AI_SKILL_SURFACE_MISMATCH = 'ai-skill-surface-mismatch';
3234

3335
export type AiSurfaceAffinitySeverity = 'error' | 'warning';
@@ -49,14 +51,6 @@ export interface AiSurfaceAffinityFinding {
4951

5052
type AnyRec = Record<string, unknown>;
5153

52-
function asArray(v: unknown): AnyRec[] {
53-
if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object');
54-
if (v && typeof v === 'object') {
55-
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
56-
}
57-
return [];
58-
}
59-
6054
function strName(v: unknown): string | undefined {
6155
return typeof v === 'string' && v.length > 0 ? v : undefined;
6256
}
@@ -75,12 +69,12 @@ export function validateAiSurfaceAffinity(stack: AnyRec): AiSurfaceAffinityFindi
7569
if (!stack || typeof stack !== 'object') return findings;
7670

7771
const skillsByName = new Map<string, AnyRec>();
78-
for (const skill of asArray(stack.skills)) {
72+
for (const skill of recordsOf(stack.skills)) {
7973
const n = strName(skill.name);
8074
if (n) skillsByName.set(n, skill);
8175
}
8276

83-
const agents = asArray(stack.agents);
77+
const agents = recordsOf(stack.agents);
8478
for (let ai = 0; ai < agents.length; ai++) {
8579
const agent = agents[ai];
8680
const agentName = strName(agent.name) ?? `#${ai}`;

packages/lint/src/validate-ai-tool-references.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
import { PLATFORM_PROVIDED_TOOL_NAMES, PLATFORM_TOOL_FAMILY_PREFIXES } from '@objectstack/spec/system';
3636

37-
import { suggestName } from './object-graph.js';
37+
import { recordsOf, suggestName } from './object-graph.js';
3838

3939
export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved';
4040

@@ -57,14 +57,6 @@ export interface AiToolRefFinding {
5757

5858
type AnyRec = Record<string, unknown>;
5959

60-
function asArray(v: unknown): AnyRec[] {
61-
if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object');
62-
if (v && typeof v === 'object') {
63-
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
64-
}
65-
return [];
66-
}
67-
6860
function strName(v: unknown): string | undefined {
6961
return typeof v === 'string' && v.length > 0 ? v : undefined;
7062
}
@@ -152,19 +144,19 @@ function materialisesAsTool(action: AnyRec): boolean {
152144
function collectToolUniverse(stack: AnyRec): Set<string> {
153145
const universe = new Set<string>(PLATFORM_PROVIDED_TOOL_NAMES);
154146

155-
for (const tool of asArray(stack.tools)) {
147+
for (const tool of recordsOf(stack.tools)) {
156148
const n = strName(tool.name);
157149
if (n) universe.add(n);
158150
}
159151

160152
const addActionFamily = (actions: unknown) => {
161-
for (const action of asArray(actions)) {
153+
for (const action of recordsOf(actions)) {
162154
const n = strName(action.name);
163155
if (n && materialisesAsTool(action)) universe.add(`action_${n}`);
164156
}
165157
};
166158
addActionFamily(stack.actions);
167-
for (const obj of asArray(stack.objects)) {
159+
for (const obj of recordsOf(stack.objects)) {
168160
addActionFamily(obj.actions);
169161
}
170162

@@ -179,13 +171,13 @@ function collectToolUniverse(stack: AnyRec): Set<string> {
179171
function collectUnexposedActionNames(stack: AnyRec): Set<string> {
180172
const names = new Set<string>();
181173
const scan = (actions: unknown) => {
182-
for (const action of asArray(actions)) {
174+
for (const action of recordsOf(actions)) {
183175
const n = strName(action.name);
184176
if (n && !materialisesAsTool(action)) names.add(n);
185177
}
186178
};
187179
scan(stack.actions);
188-
for (const obj of asArray(stack.objects)) scan(obj.actions);
180+
for (const obj of recordsOf(stack.objects)) scan(obj.actions);
189181
return names;
190182
}
191183

@@ -211,7 +203,7 @@ export function validateAiToolReferences(stack: AnyRec): AiToolRefFinding[] {
211203
return universe.has(ref);
212204
};
213205

214-
const skills = asArray(stack.skills);
206+
const skills = recordsOf(stack.skills);
215207
for (let si = 0; si < skills.length; si++) {
216208
const skill = skills[si];
217209
const skillName = strName(skill.name) ?? `#${si}`;

0 commit comments

Comments
 (0)