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
58 changes: 58 additions & 0 deletions .changeset/lint-non-record-objects-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
"@objectstack/lint": patch
---

fix(lint): every `stack.objects` reader skips a non-record entry, so no authoring rule throws on the publish door

A `null` member of `stack.objects` — what an empty YAML list item
deserialises to, and what a partial editor write leaves behind — crashed
13 of the 42 `AUTHORING_RULES` with
`TypeError: Cannot read properties of null (reading 'name')`. The
authoring rules are pure `(stack) => Finding[]` (ADR-0019) and run on the
RAW `lint` path as well as the parsed one, so nothing upstream had judged
the entry's shape. At the runtime publish gate they are called inside the
gate rather than behind a try/catch of their own, so the throw was an
exception on a WRITE path, not a skipped finding; on the CLI, `os lint` /
`os validate` / `os compile` died on the first one instead of reporting
the stack.

The repair before this one guarded ONE seam — the object-graph index every
field-path rule opens with. The crash stood at fourteen more readers of
the same collection, each a hand-copied `asArray` whose array branch was
an unchecked `v as AnyRec[]`. Copies are why: the defensive spelling was
already present in about a dozen siblings and absent in the rest, so
fixing one left the others answering the old way.

So the copies are gone. `recordsOf` — the guarded reader, exported from
`object-graph.ts` and package-private — is now the one coercion from a
collection authored as an array OR as a name-keyed map into the records it
holds, and fifteen files call it:

- `validate-expressions.ts`, `validate-list-view-mode.ts`,
`validate-widget-bindings.ts`, `filter-walk.ts`,
`validate-object-references.ts`, `validate-record-title.ts`,
`validate-form-layout.ts`, `lint-autonumber-formats.ts`,
`lint-view-refs.ts`, `validate-org-axis-red-lines.ts`,
`validate-sharing-rule-enforceability.ts` — the eleven sites that threw.
- `validate-searchable-fields.ts`'s `indexObjectSearchTargets` and
`validate-page-field-bindings.ts`'s `indexObjectFields` — two shared
indexers inside the reference-integrity suite, each in front of two
rules and both hidden behind whichever suite member threw first.
- `object-field-groups.ts`'s `indexObjectFieldGroups`, which the
re-measure surfaced only once the eleven above stopped throwing.
- `validate-security-posture.ts`, the one that never threw: an `[]`
member passed its `typeof v === 'object'` read and drew a second
`security-owd-unset` at `object "(object 0)"` — an `error` about an
entry no author wrote.

The verdict is a SKIP, not a finding, matching the seam it extends: a junk
`objects` member is a SHAPE defect and belongs to the schema, every rule
already re-answers the question in its own per-object guard, and reporting
it at the reader would emit one finding per member for one bad entry. On
the name-keyed map shape a member whose VALUE is unreadable keeps its key
(`{ name }`) — the author named it, only its body is illegible.

No rule tier, id, message or accept-set changes. A valid object standing
beside a junk one is judged exactly as it is judged alone; only a path
index moves, and only for the rules that index `objects` raw, where
`objects[1]` is the honest position.
16 changes: 2 additions & 14 deletions packages/lint/src/filter-walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
*/

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

/** Any plain metadata record. */
type AnyRec = Record<string, unknown>;
Expand Down Expand Up @@ -84,19 +85,6 @@ export interface AuthoredFilter {
where: string;
}

/**
* Coerce a collection (array or name-keyed map) to an array of records,
* injecting `name` from the map key — so a rule works on both the parsed
* (array) and normalized (map) stack shapes.
*/
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}

function label(v: unknown, fallback: string): string {
return typeof v === 'string' && v.length > 0 ? v : fallback;
}
Expand Down Expand Up @@ -150,7 +138,7 @@ export function walkAuthoredFilters(
if (!stack || typeof stack !== 'object') return;

for (const { key, kind } of surfaces) {
const items = asArray((stack as AnyRec)[key]);
const items = recordsOf((stack as AnyRec)[key]);
items.forEach((item, i) => {
const name = label(item.name ?? item.id, `#${i}`);
if (kind === 'dashboard') {
Expand Down
13 changes: 3 additions & 10 deletions packages/lint/src/lint-autonumber-formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
*/

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

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

function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}

/**
* Lint every `autonumber` field's format for unresolvable / fragile `{field}`
* interpolation. Returns a (possibly empty) list of findings; never throws.
*/
export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
const findings: AutonumberLintFinding[] = [];
for (const obj of asArray(stack.objects)) {
for (const obj of recordsOf(stack.objects)) {
const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
const fields = asArray(obj.fields);
const fields = recordsOf(obj.fields);
// name → required?, for schema-aware reference checks.
const fieldMeta = new Map<string, { required: boolean }>();
for (const f of fields) {
Expand Down
24 changes: 8 additions & 16 deletions packages/lint/src/lint-view-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
*/

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

export interface ViewRefFinding {
where: string;
Expand All @@ -90,14 +90,6 @@ export interface ViewRefFinding {

type AnyRec = Record<string, any>;

/** Normalise a record-or-map metadata slot into an array, injecting `name` from
* the map key (mirrors the helper in the sibling authoring lints). */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
return [];
}

export const VIEW_KEY_COLLISION = 'view-key-collision';
export const VIEW_REF_FORM_TARGET_MISSING = 'view-ref-form-target-missing';
export const VIEW_REF_FORM_TARGET_KIND = 'view-ref-form-target-kind';
Expand Down Expand Up @@ -185,7 +177,7 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {

// 1) Gather every aggregated container: top-level `views` + object-nested.
const containers: Array<{ object: string; container: AnyRec }> = [];
for (const v of asArray(stack.views)) {
for (const v of recordsOf(stack.views)) {
if (v.viewKind) {
// Already an independent, expanded ViewItem — index it directly.
const kind = v.viewKind === 'form' ? 'form' : 'list';
Expand All @@ -200,7 +192,7 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {
const object = viewContainerObjectName(v);
if (object) containers.push({ object, container: v });
}
for (const obj of asArray(stack.objects)) {
for (const obj of recordsOf(stack.objects)) {
const object = typeof obj.name === 'string' ? obj.name : undefined;
if (!object) continue;
if (obj.list || obj.form || obj.listViews || obj.formViews) {
Expand Down Expand Up @@ -281,11 +273,11 @@ export function lintViewRefs(stack: AnyRec): ViewRefFinding[] {
};

// Object-nested first so the retained (deduped) finding keeps object context.
for (const obj of asArray(stack.objects)) {
for (const obj of recordsOf(stack.objects)) {
const object = typeof obj.name === 'string' ? obj.name : undefined;
for (const action of asArray(obj.actions)) checkAction(action, object);
for (const action of recordsOf(obj.actions)) checkAction(action, object);
}
for (const action of asArray(stack.actions)) checkAction(action);
for (const action of recordsOf(stack.actions)) checkAction(action);

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

for (const [ai, app] of asArray(stack.apps).entries()) {
for (const [ai, app] of recordsOf(stack.apps).entries()) {
const appName = typeof app.name === 'string' && app.name ? app.name : `#${ai}`;
walkNav(app.navigation, appName);
// `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 area of asArray(app.areas)) {
for (const area of recordsOf(app.areas)) {
walkNav(area.items, appName);
walkNav(area.navigation, appName);
}
Expand Down
Loading
Loading