From e51eeb250d492cc7be502f7f8cb3df475383c2af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 09:15:00 +0000 Subject: [PATCH 1/4] fix(lint): read the remaining collection copies through the shared guarded readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- .../collection-coercion-single-copy.test.ts | 42 ++++++++----------- .../lint/src/validate-action-body-writes.ts | 17 ++------ .../lint/src/validate-ai-agent-authoring.ts | 14 ++----- .../lint/src/validate-ai-surface-affinity.ts | 14 ++----- .../lint/src/validate-ai-tool-references.ts | 22 ++++------ .../lint/src/validate-flow-node-writes.ts | 15 +------ .../lint/src/validate-hook-body-writes.ts | 19 ++------- packages/lint/src/validate-jsx-pages.ts | 12 +++--- .../src/validate-nav-object-servability.ts | 14 ++----- packages/lint/src/validate-nav-target-refs.ts | 13 ++---- .../lint/src/validate-page-source-styling.ts | 11 +++-- .../validate-page-visualization-bindings.ts | 16 +++---- .../lint/src/validate-react-page-props.ts | 12 +++--- packages/lint/src/validate-react-pages.ts | 12 +++--- .../src/validate-readonly-action-writes.ts | 12 +----- .../lint/src/validate-rule-compilability.ts | 16 ++----- packages/lint/src/validate-view-page-refs.ts | 12 ++---- 17 files changed, 82 insertions(+), 191 deletions(-) diff --git a/packages/lint/src/collection-coercion-single-copy.test.ts b/packages/lint/src/collection-coercion-single-copy.test.ts index 747d754d3f..762d21c408 100644 --- a/packages/lint/src/collection-coercion-single-copy.test.ts +++ b/packages/lint/src/collection-coercion-single-copy.test.ts @@ -32,6 +32,23 @@ // fix one and leave N-1, and no reviewer counts to 39. So the count is asserted // here instead. // +// ## Two destinations, not one +// +// Deleting a copy means re-pointing its call sites, and there are two places to +// point them. A reader that reports no location takes `recordsOf` directly. A +// reader that reports a POSITIONAL path cannot: `recordsOf` drops a non-record +// member, so every index after it shifts and the finding names an entry the +// author's file does not have there (#15740). Those readers take +// `collectionEntries` (`collection-entries.ts`) instead — the same drop, made +// in the reader, with each survivor's real path carried out beside it +// (`pages[3]` on the array shape, `pages.contact_page` on the map). #15728 +// moved the four page walks that way; the two reference-integrity members +// below still hold their own copy because the paths #15494 pinned on them are +// #15740's call, not a developer's. +// +// Either destination satisfies this file: neither declares a second +// `(v: unknown) => AnyRec[]`, which is the shape all three clauses count. +// // ## The three clauses, and what each one refuses // // 1. `object-graph.ts` declares exactly one such coercion, named `recordsOf`. @@ -102,26 +119,6 @@ const COPY_LEDGER: Readonly> = { // index-preserving reader, not on anyone's attention (#15740). 'validate-object-field-refs.ts': '#15740', 'validate-list-view-field-refs.ts': '#15740', - // 2026-09-05 — the sixteen copies that do not crash today: twelve grew a - // local array-branch filter and four read only the list shape behind a - // call-site `if (!page) continue`. They are not #15636's defect; they are its - // cause, and re-pointing them is bookkeeping this ledger now forces. - 'validate-action-body-writes.ts': '#15728', - 'validate-ai-agent-authoring.ts': '#15728', - 'validate-ai-surface-affinity.ts': '#15728', - 'validate-ai-tool-references.ts': '#15728', - 'validate-flow-node-writes.ts': '#15728', - 'validate-hook-body-writes.ts': '#15728', - 'validate-jsx-pages.ts': '#15728', - 'validate-nav-object-servability.ts': '#15728', - 'validate-nav-target-refs.ts': '#15728', - 'validate-page-source-styling.ts': '#15728', - 'validate-page-visualization-bindings.ts': '#15728', - 'validate-react-page-props.ts': '#15728', - 'validate-react-pages.ts': '#15728', - 'validate-readonly-action-writes.ts': '#15728', - 'validate-rule-compilability.ts': '#15728', - 'validate-view-page-refs.ts': '#15728', }; /** @@ -139,11 +136,6 @@ const UNGUARDED_ALLOWANCE: Readonly> = { // first; both guard every member with `isRec` at the call site. 'validate-object-field-refs.ts': '#15740', 'validate-list-view-field-refs.ts': '#15740', - // 2026-09-05 — removed by #15728. - 'validate-jsx-pages.ts': '#15728', - 'validate-page-source-styling.ts': '#15728', - 'validate-react-page-props.ts': '#15728', - 'validate-react-pages.ts': '#15728', }; /** Every rule/reader module — tests excluded, this file excluded. */ diff --git a/packages/lint/src/validate-action-body-writes.ts b/packages/lint/src/validate-action-body-writes.ts index fad927f288..3ad2d084c6 100644 --- a/packages/lint/src/validate-action-body-writes.ts +++ b/packages/lint/src/validate-action-body-writes.ts @@ -99,6 +99,7 @@ import { type BodyWritePatternExclusion, type HookBodyWritePattern, } from './validate-hook-body-writes.js'; +import { recordsOf } from './object-graph.js'; export type ActionBodyWriteSeverity = 'warning'; @@ -199,18 +200,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x)); - if (isRec(v)) { - return Object.entries(v).map(([name, def]) => ({ - name, - ...(isRec(def) ? def : {}), - })); - } - return []; -} - /** * One L2 action body found in the stack, with the location to report it at. * @@ -284,7 +273,7 @@ export function collectActionBodies(stack: AnyRec): ActionBodySite[] { const seen = new Set(); const collect = (actions: unknown, pathPrefix: string, parentObject?: string): void => { - asArray(actions).forEach((action, index) => { + recordsOf(actions).forEach((action, index) => { // Same default the spec declares, and the same one the runtime gate // applies — a stack may reach lint unparsed, so an omitted `type` is // `'script'`, not "unknown". @@ -303,7 +292,7 @@ export function collectActionBodies(stack: AnyRec): ActionBodySite[] { }; collect(stack.actions, 'actions'); - asArray(stack.objects).forEach((obj, objIndex) => { + recordsOf(stack.objects).forEach((obj, objIndex) => { const parentObject = typeof obj.name === 'string' && obj.name ? obj.name : undefined; collect(obj.actions, `objects[${objIndex}].actions`, parentObject); }); diff --git a/packages/lint/src/validate-ai-agent-authoring.ts b/packages/lint/src/validate-ai-agent-authoring.ts index e4517f2de7..3bb8a9509f 100644 --- a/packages/lint/src/validate-ai-agent-authoring.ts +++ b/packages/lint/src/validate-ai-agent-authoring.ts @@ -73,6 +73,8 @@ * the signal has to be an authoring-time nudge rather than a build break. */ +import { recordsOf } from './object-graph.js'; + export const AGENT_AUTHORING_WITHDRAWN = 'agent-authoring-withdrawn'; /** `app.defaultAgent` names something outside the platform agent roster. */ @@ -100,14 +102,6 @@ export interface AiAgentAuthoringFinding { type AnyRec = Record; -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object'); - if (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -154,7 +148,7 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding const findings: AiAgentAuthoringFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - const agents = asArray(stack.agents); + const agents = recordsOf(stack.agents); for (let ai = 0; ai < agents.length; ai++) { const agent = agents[ai]; const name = strName(agent.name) ?? `#${ai}`; @@ -189,7 +183,7 @@ export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding } const roster = CANONICAL_AGENT_NAMES.join(', '); - const apps = asArray(stack.apps); + const apps = recordsOf(stack.apps); for (let appIdx = 0; appIdx < apps.length; appIdx++) { const app = apps[appIdx]; const defaultAgent = strName(app.defaultAgent); diff --git a/packages/lint/src/validate-ai-surface-affinity.ts b/packages/lint/src/validate-ai-surface-affinity.ts index b3d35e0637..eca5ef4dbd 100644 --- a/packages/lint/src/validate-ai-surface-affinity.ts +++ b/packages/lint/src/validate-ai-surface-affinity.ts @@ -28,6 +28,8 @@ * contract is reported. */ +import { recordsOf } from './object-graph.js'; + export const AI_SKILL_SURFACE_MISMATCH = 'ai-skill-surface-mismatch'; export type AiSurfaceAffinitySeverity = 'error' | 'warning'; @@ -49,14 +51,6 @@ export interface AiSurfaceAffinityFinding { type AnyRec = Record; -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object'); - if (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -75,12 +69,12 @@ export function validateAiSurfaceAffinity(stack: AnyRec): AiSurfaceAffinityFindi if (!stack || typeof stack !== 'object') return findings; const skillsByName = new Map(); - for (const skill of asArray(stack.skills)) { + for (const skill of recordsOf(stack.skills)) { const n = strName(skill.name); if (n) skillsByName.set(n, skill); } - const agents = asArray(stack.agents); + const agents = recordsOf(stack.agents); for (let ai = 0; ai < agents.length; ai++) { const agent = agents[ai]; const agentName = strName(agent.name) ?? `#${ai}`; diff --git a/packages/lint/src/validate-ai-tool-references.ts b/packages/lint/src/validate-ai-tool-references.ts index 4509e751ff..4d34eae3a2 100644 --- a/packages/lint/src/validate-ai-tool-references.ts +++ b/packages/lint/src/validate-ai-tool-references.ts @@ -34,7 +34,7 @@ import { PLATFORM_PROVIDED_TOOL_NAMES, PLATFORM_TOOL_FAMILY_PREFIXES } from '@objectstack/spec/system'; -import { suggestName } from './object-graph.js'; +import { recordsOf, suggestName } from './object-graph.js'; export const AI_SKILL_TOOL_UNRESOLVED = 'ai-skill-tool-unresolved'; @@ -57,14 +57,6 @@ export interface AiToolRefFinding { type AnyRec = Record; -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object'); - if (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -152,19 +144,19 @@ function materialisesAsTool(action: AnyRec): boolean { function collectToolUniverse(stack: AnyRec): Set { const universe = new Set(PLATFORM_PROVIDED_TOOL_NAMES); - for (const tool of asArray(stack.tools)) { + for (const tool of recordsOf(stack.tools)) { const n = strName(tool.name); if (n) universe.add(n); } const addActionFamily = (actions: unknown) => { - for (const action of asArray(actions)) { + for (const action of recordsOf(actions)) { const n = strName(action.name); if (n && materialisesAsTool(action)) universe.add(`action_${n}`); } }; addActionFamily(stack.actions); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { addActionFamily(obj.actions); } @@ -179,13 +171,13 @@ function collectToolUniverse(stack: AnyRec): Set { function collectUnexposedActionNames(stack: AnyRec): Set { const names = new Set(); const scan = (actions: unknown) => { - for (const action of asArray(actions)) { + for (const action of recordsOf(actions)) { const n = strName(action.name); if (n && !materialisesAsTool(action)) names.add(n); } }; scan(stack.actions); - for (const obj of asArray(stack.objects)) scan(obj.actions); + for (const obj of recordsOf(stack.objects)) scan(obj.actions); return names; } @@ -211,7 +203,7 @@ export function validateAiToolReferences(stack: AnyRec): AiToolRefFinding[] { return universe.has(ref); }; - const skills = asArray(stack.skills); + const skills = recordsOf(stack.skills); for (let si = 0; si < skills.length; si++) { const skill = skills[si]; const skillName = strName(skill.name) ?? `#${si}`; diff --git a/packages/lint/src/validate-flow-node-writes.ts b/packages/lint/src/validate-flow-node-writes.ts index fae867443a..e48eff35d0 100644 --- a/packages/lint/src/validate-flow-node-writes.ts +++ b/packages/lint/src/validate-flow-node-writes.ts @@ -100,6 +100,7 @@ import { unprovisionedAnchorHint, } from './system-fields.js'; import { walkFlowNodes, flowNodeLabel } from './flow-walk.js'; +import { recordsOf } from './object-graph.js'; /** * `error` for the existence verdict — a literal key against a literal object is @@ -182,18 +183,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x)); - if (isRec(v)) { - return Object.entries(v).map(([name, def]) => ({ - name, - ...(isRec(def) ? def : {}), - })); - } - return []; -} - /** * The target object of a CRUD node, when statically knowable. Reads the * canonical `objectName` and its historical `object` alias — a pre-parse source @@ -220,7 +209,7 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] { const findings: FlowNodeWriteFinding[] = []; if (!isRec(stack)) return findings; - const flows = asArray(stack.flows); + const flows = recordsOf(stack.flows); if (flows.length === 0) return findings; // Built lazily: a stack whose flows carry no write node never pays it. diff --git a/packages/lint/src/validate-hook-body-writes.ts b/packages/lint/src/validate-hook-body-writes.ts index e5cc5a839c..d1c60f0eff 100644 --- a/packages/lint/src/validate-hook-body-writes.ts +++ b/packages/lint/src/validate-hook-body-writes.ts @@ -95,6 +95,7 @@ import { unprovisionedAnchorCause, unprovisionedAnchorHint, } from './system-fields.js'; +import { recordsOf } from './object-graph.js'; // The TypeScript compiler must NOT be imported at module top level: it is // ~9 MB of CJS, and @objectstack/lint sits on the kernel boot path — while @@ -382,18 +383,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x)); - if (isRec(v)) { - return Object.entries(v).map(([name, def]) => ({ - name, - ...(isRec(def) ? def : {}), - })); - } - return []; -} - /** * object name → its declared field names (both `fields` authoring shapes). * @@ -402,11 +391,11 @@ function asArray(v: unknown): AnyRec[] { */ export function indexObjectFields(stack: AnyRec): Map> { const out = new Map>(); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const name = typeof obj.name === 'string' ? obj.name : undefined; if (!name) continue; const names = new Set(); - for (const f of asArray(obj.fields)) { + for (const f of recordsOf(obj.fields)) { if (typeof f.name === 'string' && f.name) names.add(f.name); } out.set(name, names); @@ -725,7 +714,7 @@ export function extractHookBodyWriteSet(source: string): ExtractedHookBodyWriteS */ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] { const findings: HookBodyWriteFinding[] = []; - const hooks = asArray(stack.hooks); + const hooks = recordsOf(stack.hooks); if (hooks.length === 0) return findings; // Built lazily: a stack whose hooks are all L1/handler-based never pays it. diff --git a/packages/lint/src/validate-jsx-pages.ts b/packages/lint/src/validate-jsx-pages.ts index c85ea61203..1767db5a50 100644 --- a/packages/lint/src/validate-jsx-pages.ts +++ b/packages/lint/src/validate-jsx-pages.ts @@ -21,6 +21,7 @@ // class of error an AI author is most likely to emit. import { parseJsx, compile, type Manifest } from '@objectstack/sdui-parser'; +import { collectionEntries } from './collection-entries.js'; export type JsxPageSeverity = 'error' | 'warning'; @@ -36,16 +37,13 @@ export interface JsxPageFinding { } type AnyRec = Record; -const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } = {}): JsxPageFinding[] { const findings: JsxPageFinding[] = []; - const pages = asArray(stack.pages); - for (let p = 0; p < pages.length; p++) { - const page = pages[p]; + for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { // html tier (+ deprecated 'jsx' alias). react pages are not constrained JSX. if (!page || (page.kind !== 'html' && page.kind !== 'jsx')) continue; - const name = String(page.name ?? `#${p}`); + const name = String(page.name ?? pagePath); const source = page.source; if (typeof source !== 'string' || source.trim() === '') { // (PageSchema's superRefine also covers this; keep it for the build path.) @@ -53,7 +51,7 @@ export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } = severity: 'error', rule: 'jsx-page-empty-source', where: `page "${name}"`, - path: `pages[${p}].source`, + path: `${pagePath}.source`, message: `kind:'${page.kind}' page has no \`source\`.`, hint: 'Author the page as a constrained JSX/Tailwind string in `source`.', }); @@ -67,7 +65,7 @@ export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } = severity: d.severity, rule: `jsx-${d.code}`, where: d.tag ? `page "${name}" › <${d.tag}>` : `page "${name}"`, - path: `pages[${p}].source`, + path: `${pagePath}.source`, message: d.message, hint: 'The source is parsed (never executed) and compiled to the SDUI tree at save time — fix the JSX.', }); diff --git a/packages/lint/src/validate-nav-object-servability.ts b/packages/lint/src/validate-nav-object-servability.ts index 6fc7ff4291..c26e337fb6 100644 --- a/packages/lint/src/validate-nav-object-servability.ts +++ b/packages/lint/src/validate-nav-object-servability.ts @@ -70,6 +70,7 @@ import { canServeApiOperation, type EnableLike } from '@objectstack/spec/data'; import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; +import { recordsOf } from './object-graph.js'; export type NavObjectServabilityFinding = ReferenceIntegrityFinding; @@ -80,13 +81,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** Both authoring carriers: an array of documents, or a name-keyed map. */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter(isRec); - if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name })); - return []; -} - function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -102,7 +96,7 @@ export function validateNavObjectServability(stack: unknown): NavObjectServabili const findings: NavObjectServabilityFinding[] = []; if (!isRec(stack)) return findings; - const apps = asArray(stack.apps); + const apps = recordsOf(stack.apps); if (apps.length === 0) return findings; // Only objects THIS stack declares can be judged — see the header. The map @@ -110,7 +104,7 @@ export function validateNavObjectServability(stack: unknown): NavObjectServabili // key that is actually editable, not merely at the nav entry that tripped on // it. const ownEnable = new Map(); - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); for (const [oi, obj] of objects.entries()) { const n = strName(obj.name); if (!n) continue; @@ -192,7 +186,7 @@ export function validateNavObjectServability(stack: unknown): NavObjectServabili // `areas[]` is the other nav container, and the server gates it through the // very same walk (#4722) — so this rule must see it too, or it would pass a // stack whose served payload the runtime prunes. - for (const [ari, area] of asArray(app.areas).entries()) { + for (const [ari, area] of recordsOf(app.areas).entries()) { walk(area.items, `apps[${ai}].areas[${ari}].items`); walk(area.navigation, `apps[${ai}].areas[${ari}].navigation`); } diff --git a/packages/lint/src/validate-nav-target-refs.ts b/packages/lint/src/validate-nav-target-refs.ts index 9daec0b113..9c056f582b 100644 --- a/packages/lint/src/validate-nav-target-refs.ts +++ b/packages/lint/src/validate-nav-target-refs.ts @@ -59,6 +59,7 @@ */ import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; +import { recordsOf } from './object-graph.js'; export type NavTargetRefSeverity = 'error' | 'warning'; export type NavTargetRefFinding = ReferenceIntegrityFinding; @@ -70,12 +71,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter(isRec); - if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name })); - return []; -} - function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -96,7 +91,7 @@ const NAV_TARGETS: ReadonlyArray = [ function namesOf(collection: unknown): Set { const out = new Set(); - for (const entry of asArray(collection)) { + for (const entry of recordsOf(collection)) { const n = strName(entry.name); if (n) out.add(n); } @@ -107,7 +102,7 @@ export function validateNavTargetRefs(stack: unknown): NavTargetRefFinding[] { const findings: NavTargetRefFinding[] = []; if (!isRec(stack)) return findings; - const apps = asArray(stack.apps); + const apps = recordsOf(stack.apps); if (apps.length === 0) return findings; const declared = new Map>(); @@ -165,7 +160,7 @@ export function validateNavTargetRefs(stack: unknown): NavTargetRefFinding[] { walk(app.navigation, `apps[${ai}].navigation`); // `areas[]` is the other nav container; it was once skipped wholesale in // `stack.zod.ts`, so an areas-based app got no nav validation at all. - for (const [ari, area] of asArray(app.areas).entries()) { + for (const [ari, area] of recordsOf(app.areas).entries()) { walk(area.items, `apps[${ai}].areas[${ari}].items`); walk(area.navigation, `apps[${ai}].areas[${ari}].navigation`); } diff --git a/packages/lint/src/validate-page-source-styling.ts b/packages/lint/src/validate-page-source-styling.ts index 997864f72b..9664499c90 100644 --- a/packages/lint/src/validate-page-source-styling.ts +++ b/packages/lint/src/validate-page-source-styling.ts @@ -13,6 +13,8 @@ // `(stack) => Finding[]` rule (ADR-0019), run from `os validate`/`compile` and // reusable by AI authoring so the agent self-corrects. +import { collectionEntries } from './collection-entries.js'; + export type SourceStyleSeverity = 'error' | 'warning'; export interface SourceStyleFinding { @@ -27,22 +29,19 @@ export interface SourceStyleFinding { export const PAGE_SOURCE_CLASSNAME = 'page-source-className-tailwind'; type AnyRec = Record; -const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); // `className=` as a JSX attribute: name, optional ws, `=`, then `"`/`'`/`{`. const CLASSNAME_ATTR = /\bclassName\s*=\s*["'{]/g; export function validatePageSourceStyling(stack: AnyRec): SourceStyleFinding[] { const findings: SourceStyleFinding[] = []; - const pages = asArray(stack.pages); - for (let p = 0; p < pages.length; p++) { - const page = pages[p]; + for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { if (!page) continue; const kind = page.kind; if (kind !== 'html' && kind !== 'react' && kind !== 'jsx') continue; const source = page.source; if (typeof source !== 'string' || source.trim() === '') continue; - const name = String(page.name ?? `#${p}`); + const name = String(page.name ?? pagePath); CLASSNAME_ATTR.lastIndex = 0; let count = 0; @@ -53,7 +52,7 @@ export function validatePageSourceStyling(stack: AnyRec): SourceStyleFinding[] { severity: 'warning', rule: PAGE_SOURCE_CLASSNAME, where: `page "${name}"`, - path: `pages[${p}].source`, + path: `${pagePath}.source`, message: `${count} \`className\` attribute${count > 1 ? 's' : ''} in ${String(kind)}-source page — Tailwind utilities in page source silently produce no CSS (the build never scans authored metadata; ADR-0065).`, hint: kind === 'react' diff --git a/packages/lint/src/validate-page-visualization-bindings.ts b/packages/lint/src/validate-page-visualization-bindings.ts index 2e265aef9a..6a22965eef 100644 --- a/packages/lint/src/validate-page-visualization-bindings.ts +++ b/packages/lint/src/validate-page-visualization-bindings.ts @@ -101,6 +101,7 @@ */ import { SYSTEM_FIELDS } from './system-fields.js'; +import { recordsOf } from './object-graph.js'; export const PAGE_VISUALIZATION_WITHOUT_BINDING = 'page/visualization-without-binding'; @@ -230,13 +231,6 @@ function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } -/** Coerce a collection (array or name-keyed map) to an array of records. */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter(isRec); - if (isRec(v)) return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); - return []; -} - /** The slice of one field the predicates read, in DECLARATION order. */ interface DerivableField { name: string; @@ -258,7 +252,7 @@ function derivableFields(obj: AnyRec): DerivableField[] | undefined { if (!isRec(declared) && !Array.isArray(declared)) return undefined; let declaredNames = 0; const out: DerivableField[] = []; - for (const f of asArray(declared)) { + for (const f of recordsOf(declared)) { const name = strName(f.name); if (!name) continue; declaredNames++; @@ -339,7 +333,7 @@ function namedViewsFor(stack: AnyRec, obj: AnyRec | undefined, objectName: strin // `defineView` aggregates that target this object. A per-view // `data.object` binding wins over the aggregate's own, the resolution order // `validate-list-view-field-refs` reads. - for (const view of asArray(stack.views)) { + for (const view of recordsOf(stack.views)) { const aggregateObject = strName(view.objectName) ?? strName(view.object); const boundTo = (lv: AnyRec): string | undefined => (isRec(lv.data) ? strName(lv.data.object) : undefined) ?? aggregateObject; @@ -413,12 +407,12 @@ export function validatePageVisualizationBindings(stack: AnyRec): PageVisualizat if (!isRec(stack)) return findings; const objects = new Map(); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const name = strName(obj.name); if (name && !objects.has(name)) objects.set(name, obj); } - const pages = asArray(stack.pages); + const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { const page = pages[pi]; // Skip 1 — only a `list` page mounts `InterfaceListPage`. diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index e94f237040..edaa9f98cf 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -70,6 +70,7 @@ import { unprovisionedAnchorCause, unprovisionedAnchorHint, } from './system-fields.js'; +import { collectionEntries } from './collection-entries.js'; // The TypeScript compiler must NOT be imported at module top level: it is // ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and @@ -117,7 +118,6 @@ export interface ReactPropFinding { } type AnyRec = Record; -const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); interface BlockSpec { requiredBindings: string[]; @@ -1084,13 +1084,11 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { // map (external / datasource-introspected), a distinction `indexObjectFields` // flattens — and one this check must honor so both surfaces skip alike. const searchTargets = indexObjectSearchTargets(stack); - const pages = asArray(stack.pages); - for (let p = 0; p < pages.length; p++) { - const page = pages[p]; + for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { if (!page || page.kind !== 'react') continue; const source = page.source; if (typeof source !== 'string' || source.trim() === '') continue; - const name = String(page.name ?? `#${p}`); + const name = String(page.name ?? pagePath); // A missing compiler must surface as an error, not as "unparseable source": // one is this deployment's problem, the other is the author's. @@ -1116,7 +1114,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { severity: 'warning', rule: REACT_PAGE_SOURCE_UNPARSEABLE, where: `page "${name}"`, - path: `pages[${p}].source`, + path: `${pagePath}.source`, message: `kind:'react' source did not parse (${describeParseFailure(failure)}), so the component-contract ` + `checks read a partially recovered tree and may have missed real problems.`, @@ -1130,7 +1128,7 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] { if (tsc.isJsxOpeningElement(node) || tsc.isJsxSelfClosingElement(node)) { const tag = node.tagName.getText(sf); const where = `page "${name}" › <${tag}>`; - const path = `pages[${p}].source`; + const path = `${pagePath}.source`; // A withdrawn `record:*` block, reached by its injected tag. Reported // and then dropped: the prop checks below have nothing useful to add // about a block that cannot render here at all. diff --git a/packages/lint/src/validate-react-pages.ts b/packages/lint/src/validate-react-pages.ts index dc77204574..66f2a8202b 100644 --- a/packages/lint/src/validate-react-pages.ts +++ b/packages/lint/src/validate-react-pages.ts @@ -12,6 +12,7 @@ import { createRequire } from 'node:module'; import type { transform as sucraseTransform } from 'sucrase'; +import { collectionEntries } from './collection-entries.js'; // Sucrase must NOT be imported at module top level: it is ~1.5 MB of CJS // (~16 ms cold require), and @objectstack/lint sits on the kernel boot path — @@ -57,22 +58,19 @@ export interface ReactPageFinding { } type AnyRec = Record; -const asArray = (v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : []); export function validateReactPages(stack: AnyRec): ReactPageFinding[] { const findings: ReactPageFinding[] = []; - const pages = asArray(stack.pages); - for (let p = 0; p < pages.length; p++) { - const page = pages[p]; + for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { if (!page || page.kind !== 'react') continue; - const name = String(page.name ?? `#${p}`); + const name = String(page.name ?? pagePath); const source = page.source; if (typeof source !== 'string' || source.trim() === '') { findings.push({ severity: 'error', rule: 'react-page-empty-source', where: `page "${name}"`, - path: `pages[${p}].source`, + path: `${pagePath}.source`, message: "kind:'react' page has no `source`.", hint: 'Author the page as a real React component string in `source`.', }); @@ -90,7 +88,7 @@ export function validateReactPages(stack: AnyRec): ReactPageFinding[] { severity: 'error', rule: 'react-page-syntax', where: `page "${name}"`, - path: `pages[${p}].source`, + path: `${pagePath}.source`, message: `kind:'react' source has a syntax error: ${message.split('\n')[0]}`, hint: 'The source is transpiled (never executed) at build to catch syntax errors early — fix the JS/JSX.', }); diff --git a/packages/lint/src/validate-readonly-action-writes.ts b/packages/lint/src/validate-readonly-action-writes.ts index 57877afa64..ba6b7733cb 100644 --- a/packages/lint/src/validate-readonly-action-writes.ts +++ b/packages/lint/src/validate-readonly-action-writes.ts @@ -109,6 +109,7 @@ import { type BodyWritePatternExclusion, } from './validate-hook-body-writes.js'; import { buildReadonlyIndex } from './validate-readonly-flow-writes.js'; +import { recordsOf } from './object-graph.js'; export type ReadonlyActionWriteSeverity = 'warning'; @@ -223,15 +224,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter((x): x is AnyRec => isRec(x)); - if (isRec(v)) { - return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); - } - return []; -} - /** * Validate L2 action-body `ctx.api` writes against target-object `readonlyWhen` * declarations. Pure `(stack) => Finding[]` (ADR-0019); safe on pre- or @@ -272,7 +264,7 @@ export function validateReadonlyActionWrites(stack: AnyRec): ReadonlyActionWrite ); if (writes.length === 0) continue; - roIndex ??= buildReadonlyIndex(asArray(stack.objects)); + roIndex ??= buildReadonlyIndex(recordsOf(stack.objects)); const where = `action "${site.name}" > body`; const reported = new Set(); diff --git a/packages/lint/src/validate-rule-compilability.ts b/packages/lint/src/validate-rule-compilability.ts index 64435fd40b..23f2bc9b8b 100644 --- a/packages/lint/src/validate-rule-compilability.ts +++ b/packages/lint/src/validate-rule-compilability.ts @@ -144,6 +144,7 @@ import { createRequire } from 'node:module'; // a sharper reason: it `require`s `ajv/dist/compile/codegen`, so importing it // eagerly would drag ajv onto the boot path through the back door. import type { Options as AjvOptions } from 'ajv'; +import { recordsOf } from './object-graph.js'; export type RuleCompilabilitySeverity = 'error'; @@ -176,17 +177,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** Coerce an array-or-name-keyed-map collection to an array of records. */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter(isRec); - if (isRec(v)) { - return Object.entries(v) - .filter(([, def]) => isRec(def)) - .map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - /** * Minimal structural type for the slice of ajv this gate uses. Declared here * rather than imported as a value so no import statement can accidentally become @@ -396,7 +386,7 @@ export function walkObjectValidationRules(stack: unknown): WalkedValidationRule[ const walked: WalkedValidationRule[] = []; if (!isRec(stack)) return walked; - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; // `validations` is the key `ObjectSchema` declares; `validationRules` is a // rejected alias of it (#5096) — see the `### The keys read` note above. @@ -404,7 +394,7 @@ export function walkObjectValidationRules(stack: unknown): WalkedValidationRule[ // list, because `validate-expressions.ts` reads the same single key (#5017). const validations = obj.validations; - for (const authored of asArray(validations)) { + for (const authored of recordsOf(validations)) { for (const { rule, label, path } of flattenRules(authored, '', '')) { walked.push({ rule, diff --git a/packages/lint/src/validate-view-page-refs.ts b/packages/lint/src/validate-view-page-refs.ts index 16d2821502..7b9849d3fe 100644 --- a/packages/lint/src/validate-view-page-refs.ts +++ b/packages/lint/src/validate-view-page-refs.ts @@ -79,6 +79,7 @@ */ import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; +import { recordsOf } from './object-graph.js'; export type ViewPageRefSeverity = 'error' | 'warning'; export type ViewPageRefFinding = ReferenceIntegrityFinding; @@ -94,19 +95,12 @@ function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v.filter(isRec); - if (isRec(v)) return Object.entries(v).map(([name, def]) => (isRec(def) ? { name, ...def } : { name })); - return []; -} - /** See the module docblock — an interpolated target resolves at render time. */ const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{'); function declaredPageNames(stack: AnyRec): Set { const out = new Set(); - for (const page of asArray(stack.pages)) { + for (const page of recordsOf(stack.pages)) { const n = strName(page.name); if (n) out.add(n); } @@ -148,7 +142,7 @@ export function validateViewPageRefs(stack: unknown): ViewPageRefFinding[] { }; // ── The object's built-in named list views ── - for (const [oi, obj] of asArray(stack.objects).entries()) { + for (const [oi, obj] of recordsOf(stack.objects).entries()) { const objName = strName(obj.name); const label = objName ? `object "${objName}"` : `objects[${oi}]`; if (!isRec(obj.listViews)) continue; From 769c5aa38d9be98c5c842d1aa7c5cc4533fc0275 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 09:21:10 +0000 Subject: [PATCH 2/4] test(lint): discharge the two both-directions pins the re-pointing satisfied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- .../lint/src/non-record-object-entry.test.ts | 18 +++++++++--------- .../src/validate-rule-compilability.test.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/lint/src/non-record-object-entry.test.ts b/packages/lint/src/non-record-object-entry.test.ts index e57e02a35a..a629184778 100644 --- a/packages/lint/src/non-record-object-entry.test.ts +++ b/packages/lint/src/non-record-object-entry.test.ts @@ -362,16 +362,16 @@ const RESIDUAL_THROWS: Readonly> = { * phantom half of the same defect, and the shape `validateSecurityPosture` was * caught in for `objects` (#15552). * - * `stack.agents · an array`: the agent readers filter their array branch with - * `!!x && typeof x === 'object'`, and `[]` passes that test — so an empty list - * item survives as an agent with no name and draws one reference-integrity - * finding at a position nobody wrote. `recordsOf` uses `isRec`, which excludes - * an array, so re-pointing those readers closes this too. They are among the - * sixteen copies in #15728. + * Empty since #15728. The one row it held was `stack.agents · an array`: the + * agent readers filtered their array branch with `!!x && typeof x === 'object'` + * and `[]` passes that test, so an empty list item survived as an agent with no + * name and drew one reference-integrity finding at a position nobody wrote. + * `recordsOf` filters with `isRec`, which excludes an array, and re-pointing + * those readers closed it. The row came out because this assertion went red + * demanding an invented finding that no longer happens — the both-directions + * half earning its keep, the same way `stack.datasets` left `RESIDUAL_THROWS`. */ -const RESIDUAL_INVENTED: Readonly> = { - 'stack.agents · an array': 1, -}; +const RESIDUAL_INVENTED: Readonly> = {}; describe('a non-record entry in any other stack collection (#15636)', () => { describe.each(SWEPT_COLLECTIONS.map((c) => [c.label, c] as const))('%s', (_label, collection) => { diff --git a/packages/lint/src/validate-rule-compilability.test.ts b/packages/lint/src/validate-rule-compilability.test.ts index 1b819be9a4..88ec703b49 100644 --- a/packages/lint/src/validate-rule-compilability.test.ts +++ b/packages/lint/src/validate-rule-compilability.test.ts @@ -837,7 +837,7 @@ describe('validateRuleCompilability — reads only keys the spec declares (meta- // the rule walk and the registered-format vocabulary became shared handles // (`walkObjectValidationRules`, `registeredFormatNames`) — array `.push` / // `.length` / `.sort`, never a key off authored metadata. - const PLUMBING = new Set(['findings', 'v', 'out', 'walked', 'names']); + const PLUMBING = new Set(['findings', 'out', 'walked', 'names']); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); // …and no excuse outlives the read it excuses. A stale name in either list From a1950e263cd1703a5c1cf98ed8f9bf117a03bfd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 09:21:23 +0000 Subject: [PATCH 3/4] chore: add changeset for the lint collection-reader cleanup Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- .../lint-collection-copies-guarded-readers.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/lint-collection-copies-guarded-readers.md diff --git a/.changeset/lint-collection-copies-guarded-readers.md b/.changeset/lint-collection-copies-guarded-readers.md new file mode 100644 index 0000000000..b0c935420d --- /dev/null +++ b/.changeset/lint-collection-copies-guarded-readers.md @@ -0,0 +1,24 @@ +--- +'@objectstack/lint': patch +--- + +Read the last sixteen collection copies through the package's shared, guarded readers. + +Sixteen rule modules still declared their own `(v: unknown) => AnyRec[]` +collection coercion. Twelve — the `function` form, which had already grown the +non-record filter locally in two different spellings — now read `recordsOf` +from `object-graph.ts`. The four page walks (`validate-jsx-pages`, +`validate-page-source-styling`, `validate-react-page-props`, +`validate-react-pages`) carried the arrow form, which cast its array branch +unchecked; they now read `collectionEntries` from `collection-entries.ts`, +which drops a non-record member inside the reader while carrying each +survivor's real config path, so a `pages:` list with an empty item no longer +renumbers the `pages[N].source` path a finding points an editor at. + +Two behaviour changes fall out, both on input that was already malformed. An +array-typed member of `agents:` / `skills:` / `tools:` used to survive the +looser local filter and draw one reference-integrity finding at a position +nobody authored; it is now dropped. And a member of a name-keyed `validations:` +map whose value is not a record is now carried as `{ name }` rather than +discarded, which reaches no check that reads it. No rule id, message or +severity changes, and every finding path on well-formed metadata is unchanged. From 173afb20e95e68bad79f20b5b45433912624da3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:46:31 +0000 Subject: [PATCH 4/4] fix(lint): a map-shaped `pages:` reaches the four source-page lints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- .../lint-collection-copies-guarded-readers.md | 41 ++++++----- packages/lint/src/page-walk-map-shape.test.ts | 73 +++++++++++++++++++ 2 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 packages/lint/src/page-walk-map-shape.test.ts diff --git a/.changeset/lint-collection-copies-guarded-readers.md b/.changeset/lint-collection-copies-guarded-readers.md index b0c935420d..5729e5e2d5 100644 --- a/.changeset/lint-collection-copies-guarded-readers.md +++ b/.changeset/lint-collection-copies-guarded-readers.md @@ -2,23 +2,28 @@ '@objectstack/lint': patch --- -Read the last sixteen collection copies through the package's shared, guarded readers. +Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously. -Sixteen rule modules still declared their own `(v: unknown) => AnyRec[]` -collection coercion. Twelve — the `function` form, which had already grown the -non-record filter locally in two different spellings — now read `recordsOf` -from `object-graph.ts`. The four page walks (`validate-jsx-pages`, -`validate-page-source-styling`, `validate-react-page-props`, -`validate-react-pages`) carried the arrow form, which cast its array branch -unchecked; they now read `collectionEntries` from `collection-entries.ts`, -which drops a non-record member inside the reader while carrying each -survivor's real config path, so a `pages:` list with an empty item no longer -renumbers the `pages[N].source` path a finding points an editor at. +`pages` has two authoring carriers — a list, or a map keyed by page name that +`normalizeStackInput` folds into a list before the schema sees it. Four rules +(`validate-jsx-pages`, `validate-page-source-styling`, +`validate-react-page-props`, `validate-react-pages`) read the collection through +a private coercion that answered a map with an empty list, and they run on the +raw `os lint` path where nothing has normalized it yet. On a map-shaped stack +all four therefore returned no findings by never walking a single page: an +empty source, a syntax error, an unparseable component and a Tailwind +`className` were all reported as clean. They now read `collectionEntries`, +which handles both carriers, and a finding on the map carrier is located by the +author's own key (`pages.home.source`) rather than a synthetic index. -Two behaviour changes fall out, both on input that was already malformed. An -array-typed member of `agents:` / `skills:` / `tools:` used to survive the -looser local filter and draw one reference-integrity finding at a position -nobody authored; it is now dropped. And a member of a name-keyed `validations:` -map whose value is not a record is now carried as `{ name }` rather than -discarded, which reaches no check that reads it. No rule id, message or -severity changes, and every finding path on well-formed metadata is unchanged. +The same change removes the last sixteen private copies of the collection +coercion in this package. Twelve rules — the `function` form, which had already +grown the non-record filter locally in two different spellings — now read +`recordsOf` from `object-graph.ts`. Two behaviour changes fall out, both on +input that was already malformed: an array-typed member of `agents:` / +`skills:` / `tools:` used to survive the looser local filter and draw one +reference-integrity finding at a position nobody authored, and is now dropped; +a member of a name-keyed `validations:` map whose value is not a record is now +carried as `{ name }` rather than discarded, which reaches no check that reads +it. No rule id, message or severity changes, and every finding path on the list +carrier is unchanged. diff --git a/packages/lint/src/page-walk-map-shape.test.ts b/packages/lint/src/page-walk-map-shape.test.ts new file mode 100644 index 0000000000..eaee35f0f1 --- /dev/null +++ b/packages/lint/src/page-walk-map-shape.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A name-keyed `pages:` map reaches the four source-page lints (#15728). +// +// ## What was vacuous, and why it read as green +// +// `pages` is an authoring surface with TWO carriers. `MAP_SUPPORTED_FIELDS` +// (`packages/spec/src/shared/metadata-collection.zod.ts`) lists it, and +// `normalizeStackInput` folds the map into an array — injecting the map key as +// `name` — BEFORE `ObjectStackDefinitionSchema` sees it, which is why +// `stack.zod.ts` declares the post-normalization form `z.array(PageSchema)` +// and a raw map fails a bare `safeParse`. Reading that declaration alone says +// "map is not authorable", and that reading is wrong. +// +// These four rules are pure `(stack) => Finding[]` (ADR-0019) and run on the +// RAW `os lint` path as well as the parsed one, so on the raw path they see +// exactly what the author's file deserialised to — the map. Each of them used +// to coerce it with a private +// `(v: unknown): AnyRec[] => (Array.isArray(v) ? (v as AnyRec[]) : [])`, which +// answers a map with `[]`. So every page lint below passed on a map-shaped +// stack by never running: no finding, no error, nothing to notice. That is the +// failure mode this file exists to keep closed — a lint whose green means it +// looked, not a lint whose green means it never did. +// +// Each case therefore asserts a SPECIFIC rule id and the finding's path. The +// path is the second half of the fix: `collectionEntries` reports the map key +// (`pages.home.source`), not a synthetic array index nobody can look up, so +// the finding stays usable as an edit target on either carrier. +import { describe, expect, it } from 'vitest'; +import { validateJsxPages } from './validate-jsx-pages.js'; +import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js'; +import { validateReactPageProps, REACT_PAGE_SOURCE_UNPARSEABLE } from './validate-react-page-props.js'; +import { validateReactPages } from './validate-react-pages.js'; + +/** The same page, authored both ways. `home` is the map key and the `name`. */ +const asMap = (page: Record) => ({ pages: { home: page } }); +const asList = (page: Record) => ({ pages: [{ name: 'home', ...page }] }); + +describe('a name-keyed `pages:` map reaches every source-page lint (#15728)', () => { + it('validateReactPages reports the empty source it used to walk past', () => { + const f = validateReactPages(asMap({ kind: 'react' })); + expect(f.map((x) => x.rule)).toContain('react-page-empty-source'); + expect(f.find((x) => x.rule === 'react-page-empty-source')?.path).toBe('pages.home.source'); + }); + + it('validateJsxPages reports the empty source it used to walk past', () => { + const f = validateJsxPages(asMap({ kind: 'html' })); + expect(f.map((x) => x.rule)).toContain('jsx-page-empty-source'); + expect(f.find((x) => x.rule === 'jsx-page-empty-source')?.path).toBe('pages.home.source'); + }); + + it('validatePageSourceStyling reports the Tailwind className it used to walk past', () => { + const f = validatePageSourceStyling(asMap({ kind: 'react', source: 'function Page(){ return
; }' })); + expect(f.map((x) => x.rule)).toContain(PAGE_SOURCE_CLASSNAME); + expect(f.find((x) => x.rule === PAGE_SOURCE_CLASSNAME)?.path).toBe('pages.home.source'); + }); + + it('validateReactPageProps reports the unparseable source it used to walk past', () => { + const wrecked = 'function Page(){\n /* TODO\n return ;\n}\n'; + const f = validateReactPageProps(asMap({ kind: 'react', source: wrecked })); + expect(f.map((x) => x.rule)).toContain(REACT_PAGE_SOURCE_UNPARSEABLE); + expect(f.find((x) => x.rule === REACT_PAGE_SOURCE_UNPARSEABLE)?.path).toBe('pages.home.source'); + }); + + // The array carrier is the control: the same page authored as a list still + // reports the same rule at the positional path, so the map cases above are a + // carrier the rules GAINED and not a path spelling they swapped to. + it('POSITIVE CONTROL — the list carrier still reports at its positional path', () => { + const f = validateReactPages(asList({ kind: 'react' })); + expect(f.map((x) => x.rule)).toContain('react-page-empty-source'); + expect(f.find((x) => x.rule === 'react-page-empty-source')?.path).toBe('pages[0].source'); + }); +});