Skip to content

Commit 52b59d6

Browse files
claude[bot]claude
andauthored
fix(lint): every stack.objects reader skips a non-record entry, so no authoring rule throws on the publish door (#15552) (#15635)
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1be26b0 commit 52b59d6

19 files changed

Lines changed: 426 additions & 231 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door
6+
7+
A `null` member of `stack.objects` — what an empty YAML list item
8+
deserialises to, and what a partial editor write leaves behind — crashed
9+
13 of the 42 `AUTHORING_RULES` with
10+
`TypeError: Cannot read properties of null (reading 'name')`. The
11+
authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the
12+
RAW `lint` path as well as the parsed one, so nothing upstream had judged
13+
the entry's shape. At the runtime publish gate they are called inside the
14+
gate rather than behind a try/catch of their own, so the throw was an
15+
exception on a WRITE path, not a skipped finding; on the CLI, `os lint` /
16+
`os validate` / `os compile` died on the first one instead of reporting
17+
the stack.
18+
19+
The repair before this one guarded ONE seam — the object-graph index every
20+
field-path rule opens with. The crash stood at fourteen more readers of
21+
the same collection, each a hand-copied `asArray` whose array branch was
22+
an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was
23+
already present in about a dozen siblings and absent in the rest, so
24+
fixing one left the others answering the old way.
25+
26+
So the copies are gone. `recordsOf` — the guarded reader, exported from
27+
`object-graph.ts` and package-private — is now the one coercion from a
28+
collection authored as an array OR as a name-keyed map into the records it
29+
holds, and fifteen files call it:
30+
31+
- `validate-expressions.ts`, `validate-list-view-mode.ts`,
32+
`validate-widget-bindings.ts`, `filter-walk.ts`,
33+
`validate-object-references.ts`, `validate-record-title.ts`,
34+
`validate-form-layout.ts`, `lint-autonumber-formats.ts`,
35+
`lint-view-refs.ts`, `validate-org-axis-red-lines.ts`,
36+
`validate-sharing-rule-enforceability.ts` — the eleven sites that threw.
37+
- `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and
38+
`validate-page-field-bindings.ts`'s `indexObjectFields` — two shared
39+
indexers inside the reference-integrity suite, each in front of two
40+
rules and both hidden behind whichever suite member threw first.
41+
- `object-field-groups.ts`'s `indexObjectFieldGroups`, which the
42+
re-measure surfaced only once the eleven above stopped throwing.
43+
- `validate-security-posture.ts`, the one that never threw: an `[]`
44+
member passed its `typeof v === 'object'` read and drew a second
45+
`security-owd-unset` at `object "(object 0)"` — an `error` about an
46+
entry no author wrote.
47+
48+
The verdict is a SKIP, not a finding, matching the seam it extends: a junk
49+
`objects` member is a SHAPE defect and belongs to the schema, every rule
50+
already re-answers the question in its own per-object guard, and reporting
51+
it at the reader would emit one finding per member for one bad entry. On
52+
the name-keyed map shape a member whose VALUE is unreadable keeps its key
53+
(`{ name }`) — the author named it, only its body is illegible.
54+
55+
No rule tier, id, message or accept-set changes. A valid object standing
56+
beside a junk one is judged exactly as it is judged alone; only a path
57+
index moves, and only for the rules that index `objects` raw, where
58+
`objects[1]` is the honest position.

packages/lint/src/filter-walk.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
*/
5050

5151
import { VALID_AST_OPERATORS } from '@objectstack/spec/data';
52+
import { recordsOf } from './object-graph.js';
5253

5354
/** Any plain metadata record. */
5455
type AnyRec = Record<string, unknown>;
@@ -84,19 +85,6 @@ export interface AuthoredFilter {
8485
where: string;
8586
}
8687

87-
/**
88-
* Coerce a collection (array or name-keyed map) to an array of records,
89-
* injecting `name` from the map key — so a rule works on both the parsed
90-
* (array) and normalized (map) stack shapes.
91-
*/
92-
function asArray(v: unknown): AnyRec[] {
93-
if (Array.isArray(v)) return v as AnyRec[];
94-
if (v && typeof v === 'object') {
95-
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
96-
}
97-
return [];
98-
}
99-
10088
function label(v: unknown, fallback: string): string {
10189
return typeof v === 'string' && v.length > 0 ? v : fallback;
10290
}
@@ -150,7 +138,7 @@ export function walkAuthoredFilters(
150138
if (!stack || typeof stack !== 'object') return;
151139

152140
for (const { key, kind } of surfaces) {
153-
const items = asArray((stack as AnyRec)[key]);
141+
const items = recordsOf((stack as AnyRec)[key]);
154142
items.forEach((item, i) => {
155143
const name = label(item.name ?? item.id, `#${i}`);
156144
if (kind === 'dashboard') {

packages/lint/src/lint-autonumber-formats.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import { parseAutonumberFormat, referencedFields } from '@objectstack/spec/data';
25+
import { recordsOf } from './object-graph.js';
2526

2627
export interface AutonumberLintFinding {
2728
where: string;
@@ -38,23 +39,15 @@ export const AUTONUMBER_OPTIONAL_FIELD = 'autonumber-references-optional-field';
3839
export const AUTONUMBER_SELF_REFERENCE = 'autonumber-references-self';
3940
export const AUTONUMBER_LITERAL_TOKEN = 'autonumber-unrecognized-token';
4041

41-
function asArray(v: unknown): AnyRec[] {
42-
if (Array.isArray(v)) return v as AnyRec[];
43-
if (v && typeof v === 'object') {
44-
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
45-
}
46-
return [];
47-
}
48-
4942
/**
5043
* Lint every `autonumber` field's format for unresolvable / fragile `{field}`
5144
* interpolation. Returns a (possibly empty) list of findings; never throws.
5245
*/
5346
export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
5447
const findings: AutonumberLintFinding[] = [];
55-
for (const obj of asArray(stack.objects)) {
48+
for (const obj of recordsOf(stack.objects)) {
5649
const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
57-
const fields = asArray(obj.fields);
50+
const fields = recordsOf(obj.fields);
5851
// name → required?, for schema-aware reference checks.
5952
const fieldMeta = new Map<string, { required: boolean }>();
6053
for (const f of fields) {

packages/lint/src/lint-view-refs.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
*/
7979

8080
import { expandViewContainerWithDiagnostics, isAggregatedViewContainer } from '@objectstack/spec';
81-
import { listNames, suggestName } from './object-graph.js';
81+
import { listNames, recordsOf, suggestName } from './object-graph.js';
8282

8383
export interface ViewRefFinding {
8484
where: string;
@@ -90,14 +90,6 @@ export interface ViewRefFinding {
9090

9191
type AnyRec = Record<string, any>;
9292

93-
/** Normalise a record-or-map metadata slot into an array, injecting `name` from
94-
* the map key (mirrors the helper in the sibling authoring lints). */
95-
function asArray(v: unknown): AnyRec[] {
96-
if (Array.isArray(v)) return v as AnyRec[];
97-
if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
98-
return [];
99-
}
100-
10193
export const VIEW_KEY_COLLISION = 'view-key-collision';
10294
export const VIEW_REF_FORM_TARGET_MISSING = 'view-ref-form-target-missing';
10395
export const VIEW_REF_FORM_TARGET_KIND = 'view-ref-form-target-kind';
@@ -185,7 +177,7 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {
185177

186178
// 1) Gather every aggregated container: top-level `views` + object-nested.
187179
const containers: Array<{ object: string; container: AnyRec }> = [];
188-
for (const v of asArray(stack.views)) {
180+
for (const v of recordsOf(stack.views)) {
189181
if (v.viewKind) {
190182
// Already an independent, expanded ViewItem — index it directly.
191183
const kind = v.viewKind === 'form' ? 'form' : 'list';
@@ -200,7 +192,7 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {
200192
const object = viewContainerObjectName(v);
201193
if (object) containers.push({ object, container: v });
202194
}
203-
for (const obj of asArray(stack.objects)) {
195+
for (const obj of recordsOf(stack.objects)) {
204196
const object = typeof obj.name === 'string' ? obj.name : undefined;
205197
if (!object) continue;
206198
if (obj.list || obj.form || obj.listViews || obj.formViews) {
@@ -281,11 +273,11 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {
281273
};
282274

283275
// Object-nested first so the retained (deduped) finding keeps object context.
284-
for (const obj of asArray(stack.objects)) {
276+
for (const obj of recordsOf(stack.objects)) {
285277
const object = typeof obj.name === 'string' ? obj.name : undefined;
286-
for (const action of asArray(obj.actions)) checkAction(action, object);
278+
for (const action of recordsOf(obj.actions)) checkAction(action, object);
287279
}
288-
for (const action of asArray(stack.actions)) checkAction(action);
280+
for (const action of recordsOf(stack.actions)) checkAction(action);
289281

290282
// 4) Validate every app-navigation `viewName` against its object's list views.
291283
// The second door into the same `listViews` namespace as (3) — see the
@@ -366,12 +358,12 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {
366358
}
367359
};
368360

369-
for (const [ai, app] of asArray(stack.apps).entries()) {
361+
for (const [ai, app] of recordsOf(stack.apps).entries()) {
370362
const appName = typeof app.name === 'string' && app.name ? app.name : `#${ai}`;
371363
walkNav(app.navigation, appName);
372364
// `areas[]` is the other nav container; it was once skipped wholesale in
373365
// `stack.zod.ts`, so an areas-based app got no nav validation at all.
374-
for (const area of asArray(app.areas)) {
366+
for (const area of recordsOf(app.areas)) {
375367
walkNav(area.items, appName);
376368
walkNav(area.navigation, appName);
377369
}

0 commit comments

Comments
 (0)