Skip to content

Commit 173afb2

Browse files
committed
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
1 parent a1950e2 commit 173afb2

2 files changed

Lines changed: 96 additions & 18 deletions

File tree

.changeset/lint-collection-copies-guarded-readers.md

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,28 @@
22
'@objectstack/lint': patch
33
---
44

5-
Read the last sixteen collection copies through the package's shared, guarded readers.
5+
Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously.
66

7-
Sixteen rule modules still declared their own `(v: unknown) => AnyRec[]`
8-
collection coercion. Twelve — the `function` form, which had already grown the
9-
non-record filter locally in two different spellings — now read `recordsOf`
10-
from `object-graph.ts`. The four page walks (`validate-jsx-pages`,
11-
`validate-page-source-styling`, `validate-react-page-props`,
12-
`validate-react-pages`) carried the arrow form, which cast its array branch
13-
unchecked; they now read `collectionEntries` from `collection-entries.ts`,
14-
which drops a non-record member inside the reader while carrying each
15-
survivor's real config path, so a `pages:` list with an empty item no longer
16-
renumbers the `pages[N].source` path a finding points an editor at.
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.
1718

18-
Two behaviour changes fall out, both on input that was already malformed. An
19-
array-typed member of `agents:` / `skills:` / `tools:` used to survive the
20-
looser local filter and draw one reference-integrity finding at a position
21-
nobody authored; it is now dropped. And a member of a name-keyed `validations:`
22-
map whose value is not a record is now carried as `{ name }` rather than
23-
discarded, which reaches no check that reads it. No rule id, message or
24-
severity changes, and every finding path on well-formed metadata is unchanged.
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.
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+
});

0 commit comments

Comments
 (0)