Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/lint-collection-copies-guarded-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/lint': patch
---

Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously.

`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.

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.
42 changes: 17 additions & 25 deletions packages/lint/src/collection-coercion-single-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -102,26 +119,6 @@ const COPY_LEDGER: Readonly<Record<string, string>> = {
// 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',
};

/**
Expand All @@ -139,11 +136,6 @@ const UNGUARDED_ALLOWANCE: Readonly<Record<string, string>> = {
// 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. */
Expand Down
18 changes: 9 additions & 9 deletions packages/lint/src/non-record-object-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,16 +362,16 @@ const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {
* 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<Record<string, number>> = {
'stack.agents · an array': 1,
};
const RESIDUAL_INVENTED: Readonly<Record<string, number>> = {};

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) => {
Expand Down
73 changes: 73 additions & 0 deletions packages/lint/src/page-walk-map-shape.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({ pages: { home: page } });
const asList = (page: Record<string, unknown>) => ({ 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 <div className="p-4" />; }' }));
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 <ObjectForm mode="edit" />;\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');
});
});
17 changes: 3 additions & 14 deletions packages/lint/src/validate-action-body-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

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.
*
Expand Down Expand Up @@ -284,7 +273,7 @@ export function collectActionBodies(stack: AnyRec): ActionBodySite[] {
const seen = new Set<string>();

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".
Expand All @@ -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);
});
Expand Down
14 changes: 4 additions & 10 deletions packages/lint/src/validate-ai-agent-authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -100,14 +102,6 @@ export interface AiAgentAuthoringFinding {

type AnyRec = Record<string, unknown>;

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;
}
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 4 additions & 10 deletions packages/lint/src/validate-ai-surface-affinity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -49,14 +51,6 @@ export interface AiSurfaceAffinityFinding {

type AnyRec = Record<string, unknown>;

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;
}
Expand All @@ -75,12 +69,12 @@ export function validateAiSurfaceAffinity(stack: AnyRec): AiSurfaceAffinityFindi
if (!stack || typeof stack !== 'object') return findings;

const skillsByName = new Map<string, AnyRec>();
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}`;
Expand Down
22 changes: 7 additions & 15 deletions packages/lint/src/validate-ai-tool-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -57,14 +57,6 @@ export interface AiToolRefFinding {

type AnyRec = Record<string, unknown>;

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;
}
Expand Down Expand Up @@ -152,19 +144,19 @@ function materialisesAsTool(action: AnyRec): boolean {
function collectToolUniverse(stack: AnyRec): Set<string> {
const universe = new Set<string>(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);
}

Expand All @@ -179,13 +171,13 @@ function collectToolUniverse(stack: AnyRec): Set<string> {
function collectUnexposedActionNames(stack: AnyRec): Set<string> {
const names = new Set<string>();
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;
}

Expand All @@ -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}`;
Expand Down
Loading
Loading