Skip to content

Commit e51eeb2

Browse files
committed
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
1 parent 6a1e382 commit e51eeb2

17 files changed

Lines changed: 82 additions & 191 deletions

‎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/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}`;

‎packages/lint/src/validate-flow-node-writes.ts‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
unprovisionedAnchorHint,
101101
} from './system-fields.js';
102102
import { walkFlowNodes, flowNodeLabel } from './flow-walk.js';
103+
import { recordsOf } from './object-graph.js';
103104

104105
/**
105106
* `error` for the existence verdict — a literal key against a literal object is
@@ -182,18 +183,6 @@ type AnyRec = Record<string, unknown>;
182183

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

185-
/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
186-
function asArray(v: unknown): AnyRec[] {
187-
if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x));
188-
if (isRec(v)) {
189-
return Object.entries(v).map(([name, def]) => ({
190-
name,
191-
...(isRec(def) ? def : {}),
192-
}));
193-
}
194-
return [];
195-
}
196-
197186
/**
198187
* The target object of a CRUD node, when statically knowable. Reads the
199188
* canonical `objectName` and its historical `object` alias — a pre-parse source
@@ -220,7 +209,7 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
220209
const findings: FlowNodeWriteFinding[] = [];
221210
if (!isRec(stack)) return findings;
222211

223-
const flows = asArray(stack.flows);
212+
const flows = recordsOf(stack.flows);
224213
if (flows.length === 0) return findings;
225214

226215
// Built lazily: a stack whose flows carry no write node never pays it.

‎packages/lint/src/validate-hook-body-writes.ts‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
unprovisionedAnchorCause,
9696
unprovisionedAnchorHint,
9797
} from './system-fields.js';
98+
import { recordsOf } from './object-graph.js';
9899

99100
// The TypeScript compiler must NOT be imported at module top level: it is
100101
// ~9 MB of CJS, and @objectstack/lint sits on the kernel boot path — while
@@ -382,18 +383,6 @@ type AnyRec = Record<string, unknown>;
382383

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

385-
/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
386-
function asArray(v: unknown): AnyRec[] {
387-
if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x));
388-
if (isRec(v)) {
389-
return Object.entries(v).map(([name, def]) => ({
390-
name,
391-
...(isRec(def) ? def : {}),
392-
}));
393-
}
394-
return [];
395-
}
396-
397386
/**
398387
* object name → its declared field names (both `fields` authoring shapes).
399388
*
@@ -402,11 +391,11 @@ function asArray(v: unknown): AnyRec[] {
402391
*/
403392
export function indexObjectFields(stack: AnyRec): Map<string, Set<string>> {
404393
const out = new Map<string, Set<string>>();
405-
for (const obj of asArray(stack.objects)) {
394+
for (const obj of recordsOf(stack.objects)) {
406395
const name = typeof obj.name === 'string' ? obj.name : undefined;
407396
if (!name) continue;
408397
const names = new Set<string>();
409-
for (const f of asArray(obj.fields)) {
398+
for (const f of recordsOf(obj.fields)) {
410399
if (typeof f.name === 'string' && f.name) names.add(f.name);
411400
}
412401
out.set(name, names);
@@ -725,7 +714,7 @@ export function extractHookBodyWriteSet(source: string): ExtractedHookBodyWriteS
725714
*/
726715
export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
727716
const findings: HookBodyWriteFinding[] = [];
728-
const hooks = asArray(stack.hooks);
717+
const hooks = recordsOf(stack.hooks);
729718
if (hooks.length === 0) return findings;
730719

731720
// Built lazily: a stack whose hooks are all L1/handler-based never pays it.

‎packages/lint/src/validate-jsx-pages.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
// class of error an AI author is most likely to emit.
2222

2323
import { parseJsx, compile, type Manifest } from '@objectstack/sdui-parser';
24+
import { collectionEntries } from './collection-entries.js';
2425

2526
export type JsxPageSeverity = 'error' | 'warning';
2627

@@ -36,24 +37,21 @@ export interface JsxPageFinding {
3637
}
3738

3839
type AnyRec = Record<string, unknown>;
39-
const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []);
4040

4141
export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } = {}): JsxPageFinding[] {
4242
const findings: JsxPageFinding[] = [];
43-
const pages = asArray(stack.pages);
44-
for (let p = 0; p < pages.length; p++) {
45-
const page = pages[p];
43+
for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) {
4644
// html tier (+ deprecated 'jsx' alias). react pages are not constrained JSX.
4745
if (!page || (page.kind !== 'html' && page.kind !== 'jsx')) continue;
48-
const name = String(page.name ?? `#${p}`);
46+
const name = String(page.name ?? pagePath);
4947
const source = page.source;
5048
if (typeof source !== 'string' || source.trim() === '') {
5149
// (PageSchema's superRefine also covers this; keep it for the build path.)
5250
findings.push({
5351
severity: 'error',
5452
rule: 'jsx-page-empty-source',
5553
where: `page "${name}"`,
56-
path: `pages[${p}].source`,
54+
path: `${pagePath}.source`,
5755
message: `kind:'${page.kind}' page has no \`source\`.`,
5856
hint: 'Author the page as a constrained JSX/Tailwind string in `source`.',
5957
});
@@ -67,7 +65,7 @@ export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } =
6765
severity: d.severity,
6866
rule: `jsx-${d.code}`,
6967
where: d.tag ? `page "${name}" › <${d.tag}>` : `page "${name}"`,
70-
path: `pages[${p}].source`,
68+
path: `${pagePath}.source`,
7169
message: d.message,
7270
hint: 'The source is parsed (never executed) and compiled to the SDUI tree at save time — fix the JSX.',
7371
});

0 commit comments

Comments
 (0)