diff --git a/.changeset/lint-non-record-objects-readers.md b/.changeset/lint-non-record-objects-readers.md new file mode 100644 index 0000000000..81d217ab75 --- /dev/null +++ b/.changeset/lint-non-record-objects-readers.md @@ -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. diff --git a/packages/lint/src/filter-walk.ts b/packages/lint/src/filter-walk.ts index 50a3c758f3..6848404f34 100644 --- a/packages/lint/src/filter-walk.ts +++ b/packages/lint/src/filter-walk.ts @@ -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; @@ -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; } @@ -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') { diff --git a/packages/lint/src/lint-autonumber-formats.ts b/packages/lint/src/lint-autonumber-formats.ts index 455c510176..dfe3e27f60 100644 --- a/packages/lint/src/lint-autonumber-formats.ts +++ b/packages/lint/src/lint-autonumber-formats.ts @@ -22,6 +22,7 @@ */ import { parseAutonumberFormat, referencedFields } from '@objectstack/spec/data'; +import { recordsOf } from './object-graph.js'; export interface AutonumberLintFinding { where: string; @@ -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(); for (const f of fields) { diff --git a/packages/lint/src/lint-view-refs.ts b/packages/lint/src/lint-view-refs.ts index 5981ca029f..2c0b127ccf 100644 --- a/packages/lint/src/lint-view-refs.ts +++ b/packages/lint/src/lint-view-refs.ts @@ -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; @@ -90,14 +90,6 @@ export interface ViewRefFinding { type AnyRec = Record; -/** 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'; @@ -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'; @@ -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) { @@ -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 @@ -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); } diff --git a/packages/lint/src/non-record-object-entry.test.ts b/packages/lint/src/non-record-object-entry.test.ts new file mode 100644 index 0000000000..e3d9edac15 --- /dev/null +++ b/packages/lint/src/non-record-object-entry.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A non-record entry in `stack.objects` must not throw out of ANY authoring + * rule (#15552) — the family-level counterpart to `object-graph.test.ts`'s + * seam case (#15494). + * + * ## Why this is a family sweep and not thirteen per-rule cases + * + * The defect is not a rule's, it is a READER's, and readers are shared: one + * hand-copied `asArray` sat in front of several rules at once, and two more + * sat inside the reference-integrity suite where the first throwing member + * hides every member behind it. A per-rule test therefore cannot see the + * population it is protecting — the first repair (#15494) fixed the seam every + * field-path rule opens with and still left 13 of 42 rules throwing on the + * identical input, through eleven more reader sites plus the two indexers the + * suite reaches. So the assertion is written over the TABLE: every rule the + * three authoring commands and the runtime publish gate run, driven over each + * non-record shape, must return rather than throw. + * + * ## Why the shapes are three, and why only one of them ever threw + * + * `null` is what a YAML list item left empty deserialises to, and it is the one + * that crashed: `null.name` throws where `({}).name` and `'x'.name` are merely + * `undefined`. `{}` and `'x'` are kept in the sweep as the boundary — they pin + * that "reads a property off a non-record" stays harmless in BOTH directions, + * so a future reader that starts asserting on the entry's shape fails here + * rather than on a tenant's stack. `{}` is itself a record and is deliberately + * NOT dropped: the guard drops what cannot be read, not what is empty. + * + * ## Why the population pin is here + * + * A guard that skips a junk entry could just as easily skip everything and + * still pass a "no throw" assertion. The valid object standing beside the junk + * one must still be JUDGED, so each shape also asserts the rule table emits the + * same number of findings as it does for the valid object alone. Its path index + * is allowed to differ: a rule that indexes `objects` raw (with a per-object + * guard of its own, as `validateSecurityPosture` does) honestly reports + * `objects[1]` because that is where the author wrote it, while a rule reading + * through `recordsOf` reports `objects[0]`. That is a difference in the path, + * never in whether the object was judged. + */ + +import { describe, expect, it } from 'vitest'; + +import { AUTHORING_RULES } from './authoring-rules.js'; +import { recordsOf } from './object-graph.js'; +import { indexObjectFieldGroups } from './object-field-groups.js'; +import { walkAuthoredFilters } from './filter-walk.js'; +import { lintAutonumberFormats } from './lint-autonumber-formats.js'; +import { lintViewRefs } from './lint-view-refs.js'; +import { validateFormLayout } from './validate-form-layout.js'; +import { validateListViewMode } from './validate-list-view-mode.js'; +import { validateObjectReferences } from './validate-object-references.js'; +import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js'; +import { indexObjectFields } from './validate-page-field-bindings.js'; +import { validateRecordTitle } from './validate-record-title.js'; +import { indexObjectSearchTargets } from './validate-searchable-fields.js'; +import { validateSecurityPosture } from './validate-security-posture.js'; +import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; +import { validateStackExpressions } from './validate-expressions.js'; +import { validateWidgetBindings } from './validate-widget-bindings.js'; + +type AnyRec = Record; + +/** + * A judgeable object: named, with a readable field map, a field group and NO + * `sharingModel` — the last one on purpose, so the table emits at least one + * finding (`security-owd-unset`) and the population pin below has something to + * count. + */ +const VALID_OBJECT: AnyRec = { + name: 'crm_account', + label: 'Account', + fields: [{ name: 'name', type: 'text', label: 'Name' }], + fieldGroups: [{ key: 'general', label: 'General' }], +}; + +/** The three shapes a raw `objects` list can hold that are not a record. */ +const NON_RECORD_ENTRIES: readonly (readonly [string, unknown])[] = [ + ['null', null], + ['undefined', undefined], + ['a string', 'x'], + ['a number', 42], + ['an array', []], +]; + +const stackWith = (junk: unknown): AnyRec => ({ objects: [junk, VALID_OBJECT] }); +const validOnly: AnyRec = { objects: [VALID_OBJECT] }; + +const findingCount = (stack: AnyRec): number => + AUTHORING_RULES.reduce((n, rule) => n + rule.run(stack, {}).length, 0); + +describe('a non-record entry in `stack.objects` (#15552)', () => { + /** + * The floor first: every assertion below sweeps `AUTHORING_RULES`, so a table + * that shrank (or failed to load) would pass the sweep vacuously. 42 is what + * this repair was measured against; the pin is a floor, not an equality, so + * registering a rule never edits this file. + */ + it('sweeps the whole authoring-rule table, and the table is not empty', () => { + expect(AUTHORING_RULES.length).toBeGreaterThanOrEqual(42); + }); + + describe.each(NON_RECORD_ENTRIES)('with %s beside a valid object', (_label, junk) => { + it('no authoring rule throws', () => { + const threw: string[] = []; + for (const rule of AUTHORING_RULES) { + try { + rule.run(stackWith(junk), {}); + } catch (e) { + threw.push(`${rule.name}: ${(e as Error).message}`); + } + } + expect(threw).toEqual([]); + }); + + it('still judges the valid object standing beside it', () => { + const baseline = findingCount(validOnly); + expect(baseline).toBeGreaterThan(0); + expect(findingCount(stackWith(junk))).toBe(baseline); + }); + }); + + it('keeps an empty record — the guard drops what it cannot read, not what is empty', () => { + expect(recordsOf([{}, VALID_OBJECT])).toEqual([{}, VALID_OBJECT]); + const threw: string[] = []; + for (const rule of AUTHORING_RULES) { + try { + rule.run({ objects: [{}, VALID_OBJECT] }, {}); + } catch (e) { + threw.push(`${rule.name}: ${(e as Error).message}`); + } + } + expect(threw).toEqual([]); + }); +}); + +/** + * One case per READER seam re-pointed onto `recordsOf`, so a seam that forks + * its own copy again fails here by name rather than only inside the sweep. + * Each drives the seam directly with the junk entry in front of the valid one + * and asserts the valid object still reaches the other side. + */ +describe('each `stack.objects` reader seam skips a non-record entry (#15552)', () => { + const junked = stackWith(null); + + it('recordsOf — the shared reader itself', () => { + expect(recordsOf([null, undefined, 'x', 42, [], VALID_OBJECT])).toEqual([VALID_OBJECT]); + // The map shape keeps the author's key even when its body is unreadable. + expect(recordsOf({ a: null, b: { x: 1 } })).toEqual([{ name: 'a' }, { name: 'b', x: 1 }]); + }); + + it('validate-expressions — buildFieldIndex', () => { + expect(() => validateStackExpressions(junked)).not.toThrow(); + }); + + it('validate-list-view-mode', () => { + expect(() => validateListViewMode(junked)).not.toThrow(); + }); + + it('validate-widget-bindings — the aggregate-coherence pass ahead of the graph seam', () => { + expect(() => validateWidgetBindings(junked)).not.toThrow(); + }); + + it('filter-walk — walkAuthoredFilters over any collection', () => { + const seen: string[] = []; + expect(() => + walkAuthoredFilters( + { objects: [null, { ...VALID_OBJECT, filter: "name = 'acme'" }] }, + [{ key: 'objects', kind: 'object' }], + (f) => seen.push(f.where), + ), + ).not.toThrow(); + expect(seen).toEqual(['object "crm_account"']); + }); + + it('validate-object-references — the reference-integrity suite member that threw first', () => { + expect(() => validateObjectReferences(junked)).not.toThrow(); + }); + + it('validate-record-title', () => { + expect(() => validateRecordTitle(junked)).not.toThrow(); + }); + + it('validate-form-layout', () => { + expect(() => validateFormLayout(junked)).not.toThrow(); + }); + + it('lint-autonumber-formats', () => { + expect(() => lintAutonumberFormats(junked)).not.toThrow(); + }); + + it('lint-view-refs', () => { + expect(() => lintViewRefs(junked)).not.toThrow(); + }); + + it('validate-org-axis-red-lines', () => { + expect(() => validateOrgAxisRedLines(junked)).not.toThrow(); + }); + + it('validate-security-posture — reported the junk entry rather than skipping it', () => { + // The one seam the re-measure added that did not CRASH: an `[]` member + // survived its `typeof v === 'object'` read and drew a second + // `security-owd-unset` at `object "(object 0)"` — a phantom `error` about + // an entry no author wrote, on the same publish door. Same class, same + // verdict: skip it. + const posture = validateSecurityPosture({ objects: [[], VALID_OBJECT] }); + expect(posture.map((f) => f.where)).toEqual(['object "crm_account"']); + }); + + it('validate-sharing-rule-enforceability', () => { + expect(() => validateSharingRuleEnforceability(junked)).not.toThrow(); + }); + + it('validate-searchable-fields — indexObjectSearchTargets', () => { + expect(() => indexObjectSearchTargets(junked)).not.toThrow(); + expect(indexObjectSearchTargets(junked).has('crm_account')).toBe(true); + }); + + it('validate-page-field-bindings — indexObjectFields', () => { + expect(() => indexObjectFields(junked)).not.toThrow(); + expect(indexObjectFields(junked).get('crm_account')).toEqual(new Set(['name'])); + }); + + it('object-field-groups — indexObjectFieldGroups', () => { + expect(() => indexObjectFieldGroups(junked)).not.toThrow(); + expect(indexObjectFieldGroups(junked).get('crm_account')).toEqual(new Set(['general'])); + }); +}); diff --git a/packages/lint/src/object-field-groups.ts b/packages/lint/src/object-field-groups.ts index 6779ac6870..89e3ac433d 100644 --- a/packages/lint/src/object-field-groups.ts +++ b/packages/lint/src/object-field-groups.ts @@ -39,6 +39,8 @@ * #13855, where it is offered for contract review.) */ +import { recordsOf } from './object-graph.js'; + type AnyRec = Record; function isRec(v: unknown): v is AnyRec { @@ -49,13 +51,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 as AnyRec[]; - if (isRec(v)) return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); - return []; -} - /** * object name → its DECLARED field-group keys. * @@ -64,19 +59,19 @@ function asArray(v: unknown): AnyRec[] { * data-dependent) finding from a reference to a group that was never declared. * This index answers only the second question — the one with a closed oracle. * - * `fieldGroups` is an ARRAY on `ObjectSchema`, and `asArray` additionally + * `fieldGroups` is an ARRAY on `ObjectSchema`, and `recordsOf` additionally * resolves the name-keyed map shape that raw (non-`defineStack`) metadata can * carry, the same tolerance every other index in this package extends. */ export function indexObjectFieldGroups(stack: unknown): Map> { const index = new Map>(); if (!isRec(stack)) return index; - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const name = strName(obj.name); if (!name) continue; const keys = new Set(); - for (const group of asArray(obj.fieldGroups)) { - // `asArray` supplies `name` for the map shape; the declared key spelling + for (const group of recordsOf(obj.fieldGroups)) { + // `recordsOf` supplies `name` for the map shape; the declared key spelling // is `key`, so read it first and fall back to the synthesized map key. const key = strName(group.key) ?? strName(group.name); if (key) keys.add(key); diff --git a/packages/lint/src/object-graph.ts b/packages/lint/src/object-graph.ts index 9e31675a9d..10f1b138e3 100644 --- a/packages/lint/src/object-graph.ts +++ b/packages/lint/src/object-graph.ts @@ -126,16 +126,18 @@ function isRec(v: unknown): v is AnyRec { * * The drop is the whole point, and it is a SKIP rather than a finding. * - * This seam is the first statement of every rule that resolves a field path, - * so an entry it cannot read decides the fate of the entire family: an - * unguarded read here threw `TypeError: Cannot read properties of null` out of - * `indexObjectGraph` before any member's own per-object guard could run, which - * on the runtime publish door is an exception on a WRITE path rather than the - * silent miss this family exists to end. These rules are pure - * `(stack) => Finding[]` (ADR-0019) and run on the RAW `lint` path as well as - * the parsed one, so `objects` here is whatever the author's files deserialised - * to — a YAML list item left empty is `null`, and nothing upstream of the raw - * path has judged the shape. + * This is the package's ONE collection reader. It began as this module's + * private `asArray`, guarding the seam every field-path rule opens with, and + * is exported because the identical statement stands in front of thirteen more + * rules — see "Why it is exported" below. An entry it cannot read decides the + * fate of a whole rule family: an unguarded read here threw + * `TypeError: Cannot read properties of null` before any member's own + * per-object guard could run, which on the runtime publish door is an exception + * on a WRITE path rather than the silent miss this family exists to end. These + * rules are pure `(stack) => Finding[]` (ADR-0019) and run on the RAW `lint` + * path as well as the parsed one, so `objects` here is whatever the author's + * files deserialised to — a YAML list item left empty is `null`, and nothing + * upstream of the raw path has judged the shape. * * Skipping, not reporting, for three reasons that all point the same way: * @@ -155,8 +157,28 @@ function isRec(v: unknown): v is AnyRec { * the module note). A junk `objects` member is a SHAPE defect — the * schema's subject, not reference integrity's — and reporting it here * would emit the same finding once per member for one bad entry. + * + * ## Why it is exported + * + * Guarding this seam alone left the crash standing at thirteen more readers of + * `stack.objects`, each a hand-copied `asArray` that spelled the array branch + * as an unchecked `v as AnyRec[]`. Re-measuring the whole `AUTHORING_RULES` + * table over `{ objects: [null, validObject] }` after the first repair still + * counted 13 of 42 rules throwing, through eleven distinct reader sites plus + * two shared indexers inside the reference-integrity suite + * (`indexObjectSearchTargets`, `indexObjectFields`). Thirteen copies of one + * predicate is thirteen chances to fix one and leave twelve, which is the + * mechanism that produced this defect in the first place — so the copies were + * deleted and their call sites re-pointed here rather than each being taught + * the same `filter`. + * + * The map branch keeps a member whose VALUE is not a record, because on that + * shape the key is the author's own name for the entry and dropping it would + * lose a real declaration; only its unreadable body is dropped (`{ name }`). + * The array shape carries no such key, so a non-record member there is nothing + * at all and is dropped whole. */ -function asArray(v: unknown): AnyRec[] { +export function recordsOf(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 : {}) })); @@ -174,7 +196,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null { if (!declared || typeof declared !== 'object') return null; const names = new Set(); const fields = new Map(); - for (const f of asArray(declared)) { + for (const f of recordsOf(declared)) { const n = strName(f.name); if (!n) continue; names.add(n); @@ -195,7 +217,7 @@ function graphObjectOf(obj: AnyRec): GraphObject | null { export function indexObjectGraph(stack: unknown): ObjectGraph { const graph = new Map(); if (!stack || typeof stack !== 'object') return graph; - for (const obj of asArray((stack as AnyRec).objects)) { + for (const obj of recordsOf((stack as AnyRec).objects)) { const name = strName(obj.name); if (name) graph.set(name, graphObjectOf(obj)); } diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 49411669ec..fbce4dedac 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -93,6 +93,7 @@ import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } fr import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js'; import type { NullGuardOutcome } from './validate-null-guards.js'; +import { recordsOf } from './object-graph.js'; export interface ExprIssue { where: string; @@ -108,15 +109,6 @@ export interface ExprIssue { type AnyRec = Record; -/** Coerce an `objects` collection (array or name-keyed map) to an array. */ -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 []; -} - /** * object name → set of its field names, for schema-aware field checks. * @@ -883,7 +875,7 @@ function isBareReferenceToAny(diagnostic: string, roots: readonly string[]): boo */ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { const issues: ExprIssue[] = []; - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); const fieldIndex = buildFieldIndex(objects); const fieldTypeIndex = buildFieldTypeIndex(objects); const nullableIndex = buildNullableFieldIndex(objects); @@ -1104,7 +1096,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { }; // ── Flows ────────────────────────────────────────────────────────── - for (const flow of asArray(stack.flows)) { + for (const flow of recordsOf(stack.flows)) { const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)'; const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; // The record-change target object — `record.*` refs resolve against it. @@ -1305,7 +1297,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // `validations` is the key `ObjectSchema` declares; `validationRules` is a // rejected alias of it (#5017) — see the `## Scope` table above. const validations = obj.validations; - for (const rule of asArray(validations)) { + for (const rule of recordsOf(validations)) { const where = `object '${objectName}' · validation '${(rule.name as string) ?? '?'}'`; // The declared predicate key is `condition` (see `rulePredicates`). // Validation predicates are `record`-scoped — no field flattening — so @@ -1382,7 +1374,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // `checkFieldRuleRoot` above rejects it one level up, where nothing // binds it. Same helper, two verdicts, because the two surfaces have two // evaluators; neither verdict is a side effect of a shared root list. - for (const [oi, opt] of asArray(f.options).entries()) { + for (const [oi, opt] of recordsOf(f.options).entries()) { const label = typeof opt.value === 'string' ? `'${opt.value}'` : `#${oi}`; check( `object '${objectName}' · field '${fname}' option ${label} visibleWhen`, @@ -1559,12 +1551,12 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // `$select` projection (and, through `&&` short-circuiting, on row data), // neither of which this pass can see. Full reasoning in the ledger. }; - for (const action of asArray(stack.actions)) { + for (const action of recordsOf(stack.actions)) { checkAction('stack', action); } for (const obj of objects) { const objectName = typeof obj.name === 'string' ? obj.name : undefined; - for (const action of asArray(obj.actions)) { + for (const action of recordsOf(obj.actions)) { checkAction(`object '${objectName}'`, action, objectName); } } @@ -1576,7 +1568,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // test can tell this receiver apart from the VALIDATION rule one — the two // are governed by different schemas, and a scan that merged them would let a // key declared by either schema pass on both (#5017). - for (const sharingRule of asArray(stack.sharingRules)) { + for (const sharingRule of recordsOf(stack.sharingRules)) { const ruleObj = typeof sharingRule.object === 'string' ? sharingRule.object : undefined; const where = `sharingRule '${(sharingRule.name as string) ?? '?'}'${ruleObj ? ` (${ruleObj})` : ''} condition`; // `condition` is the authored key `SharingRuleSchema` declares. `criteria` @@ -1591,7 +1583,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { // A lifecycle hook's `condition` skips the handler when false; it is // evaluated against the record, so a bare ref silently makes the hook // run on every record (or never) instead of the intended subset. - for (const hook of asArray(stack.hooks)) { + for (const hook of recordsOf(stack.hooks)) { const hookName = (hook.name as string) ?? '?'; if (typeof hook.object === 'string') { check(`hook '${hookName}' (${hook.object}) condition`, hook.condition, hook.object, 'record'); diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts index 68da1f742c..2e68ad0dfb 100644 --- a/packages/lint/src/validate-form-layout.ts +++ b/packages/lint/src/validate-form-layout.ts @@ -39,6 +39,7 @@ import { sectionGroupRefs, } from './object-field-groups.js'; import { formViewSites, viewObjectName } from './view-walk.js'; +import { recordsOf } from './object-graph.js'; export const FORM_FIELD_UNKNOWN = 'form-field-unknown'; export const FORM_COLSPAN_ABSOLUTE = 'absolute-colspan-discouraged'; @@ -69,15 +70,6 @@ export interface FormLayoutFinding { type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array of records. */ -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 isRec(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } @@ -122,7 +114,7 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { // object name → its field-name set, for reference checking. const objectFields = 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 fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields)) diff --git a/packages/lint/src/validate-list-view-mode.ts b/packages/lint/src/validate-list-view-mode.ts index 425f796b77..3705a11750 100644 --- a/packages/lint/src/validate-list-view-mode.ts +++ b/packages/lint/src/validate-list-view-mode.ts @@ -34,6 +34,8 @@ // tsc rejects it at author time on top of all of that. See objectui #2338 and // ADR-0047. +import { recordsOf } from './object-graph.js'; + export type ListViewModeSeverity = 'error' | 'warning'; export interface ListViewModeFinding { @@ -52,18 +54,6 @@ export const LIST_VIEW_FILTERS_IN_VIEWS_MODE = 'list-view-filters-in-views-mode' type AnyRec = Record; -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -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 []; -} - /** Emit a finding for each wrong-context filter control on a single list-view def. */ function scanView( view: unknown, @@ -145,13 +135,13 @@ export function validateListViewMode(stack: AnyRec): ListViewModeFinding[] { const out: ListViewModeFinding[] = []; // Object built-in named views (object.zod.ts `listViews`). - asArray(stack.objects).forEach((obj, i) => { + recordsOf(stack.objects).forEach((obj, i) => { const label = typeof obj.name === 'string' ? `object "${obj.name}"` : `objects[${i}]`; scanListViews(obj.listViews, label, `objects[${i}]`, out); }); // `defineView` aggregates (stack `views`: default `list` + named `listViews`). - asArray(stack.views).forEach((view, i) => { + recordsOf(stack.views).forEach((view, i) => { const named = typeof view.objectName === 'string' ? view.objectName diff --git a/packages/lint/src/validate-object-references.ts b/packages/lint/src/validate-object-references.ts index e4f9de9dd8..f8cb564cac 100644 --- a/packages/lint/src/validate-object-references.ts +++ b/packages/lint/src/validate-object-references.ts @@ -69,7 +69,7 @@ import { PLATFORM_PROVIDED_OBJECT_NAMES, } from '@objectstack/spec/system'; -import { suggestName } from './object-graph.js'; +import { recordsOf, suggestName } from './object-graph.js'; /** Materialized once for the repeated edit-distance scans in `suggestName`. */ const PLATFORM_NAMES: readonly string[] = [...PLATFORM_PROVIDED_OBJECT_NAMES]; @@ -96,17 +96,6 @@ export interface ObjectRefFinding { type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array of records, - * injecting `name` from the map key — mirrors the sibling authoring lints so - * the 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 strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -136,7 +125,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { const findings: ObjectRefFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); const ownObjects = new Set(); for (const obj of objects) { const n = strName(obj.name); @@ -202,7 +191,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { // ── Actions (global + object-embedded) → param object targets ── const checkActionParams = (action: AnyRec, actionPath: string, actionLabel: string) => { - const params = asArray(action.params); + const params = recordsOf(action.params); for (let pi = 0; pi < params.length; pi++) { const param = params[pi]; if (!param || typeof param !== 'object') continue; @@ -225,7 +214,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { } }; - const globalActions = asArray(stack.actions); + const globalActions = recordsOf(stack.actions); for (let ai = 0; ai < globalActions.length; ai++) { const action = globalActions[ai]; if (!action || typeof action !== 'object') continue; @@ -236,7 +225,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { const obj = objects[oi]; if (!obj || typeof obj !== 'object') continue; const objName = strName(obj.name) ?? `#${oi}`; - const objActions = asArray(obj.actions); + const objActions = recordsOf(obj.actions); for (let ai = 0; ai < objActions.length; ai++) { const action = objActions[ai]; if (!action || typeof action !== 'object') continue; @@ -249,12 +238,12 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { } // ── Dashboard global filters → optionsFrom.object ── - const dashboards = asArray(stack.dashboards); + const dashboards = recordsOf(stack.dashboards); for (let di = 0; di < dashboards.length; di++) { const dash = dashboards[di]; if (!dash || typeof dash !== 'object') continue; const dashName = strName(dash.name) ?? `#${di}`; - const filters = asArray(dash.globalFilters); + const filters = recordsOf(dash.globalFilters); for (let fi = 0; fi < filters.length; fi++) { const filter = filters[fi]; if (!filter || typeof filter !== 'object') continue; @@ -281,7 +270,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { // which live in packages a stack compiling plugin-auth alone cannot see. All // five resolve through `PLATFORM_PROVIDED_OBJECT_NAMES` (rung ③); a local // "not in this stack ⇒ error" check would have reported every one of them. - const datasets = asArray(stack.datasets); + const datasets = recordsOf(stack.datasets); for (let dsi = 0; dsi < datasets.length; dsi++) { const ds = datasets[dsi]; if (!ds || typeof ds !== 'object') continue; @@ -296,14 +285,14 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { } // ── App navigation → requiresObject gates (and gated objectName) ── - const apps = asArray(stack.apps); + const apps = recordsOf(stack.apps); for (let ai = 0; ai < apps.length; ai++) { const app = apps[ai]; if (!app || typeof app !== 'object') continue; const appName = strName(app.name) ?? `#${ai}`; const walkNav = (items: unknown, basePath: string) => { - const navItems = asArray(items); + const navItems = recordsOf(items); for (let ni = 0; ni < navItems.length; ni++) { const nav = navItems[ni]; if (!nav || typeof nav !== 'object') continue; @@ -337,7 +326,7 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] { }; walkNav(app.navigation, `apps[${ai}].navigation`); - const areas = asArray(app.areas); + const areas = recordsOf(app.areas); for (let ri = 0; ri < areas.length; ri++) { walkNav(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`); } diff --git a/packages/lint/src/validate-org-axis-red-lines.ts b/packages/lint/src/validate-org-axis-red-lines.ts index 8e44550a97..d101b36130 100644 --- a/packages/lint/src/validate-org-axis-red-lines.ts +++ b/packages/lint/src/validate-org-axis-red-lines.ts @@ -87,6 +87,8 @@ * that passed `safeParse`. */ +import { recordsOf } from './object-graph.js'; + export const ORG_AXIS_PERMISSION_INHERITANCE = 'org-axis-permission-inheritance'; export const ORG_AXIS_CROSS_ORG_BU_GRANT = 'org-axis-cross-org-bu-grant'; @@ -148,15 +150,6 @@ const ORG_PARENT_FIELD = 'parent_organization_id'; */ const BU_TREE_RECIPIENT_TYPES = new Set(['business_unit', 'unit_and_subordinates']); -/** Coerce a collection (array or name-keyed map) to an array of records. */ -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 str(v: unknown): string { return typeof v === 'string' ? v : ''; } @@ -214,9 +207,9 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] { // the one place `ObjectSchema` does not offer and `PermissionSetSchema` does. // See the `## Scope` table above for the object-level surface that looked // like a second home for them and never was (#5009). - const permissionSets = asArray(cfg.permissions); + const permissionSets = recordsOf(cfg.permissions); permissionSets.forEach((ps, psIndex) => { - asArray(ps.rowLevelSecurity).forEach((policy, pIndex) => { + recordsOf(ps.rowLevelSecurity).forEach((policy, pIndex) => { for (const clause of ['using', 'check'] as const) { if (!str(policy[clause]).includes(ORG_PARENT_FIELD)) continue; findings.push({ @@ -246,7 +239,7 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] { // belongs at the schema's refusal, not in a consumer (Prime Directive #12). // The collection itself is `sharingRules`; `sharing` was the same mistake one // level up, and is gone with the rest of them (#5009). - asArray(cfg.sharingRules).forEach((rule, rIndex) => { + recordsOf(cfg.sharingRules).forEach((rule, rIndex) => { const slots: Array<{ key: string; text: string }> = [ { key: 'condition', text: expressionText(rule.condition) }, { key: 'sharedWith', text: JSON.stringify(rule.sharedWith ?? '') ?? '' }, @@ -273,9 +266,9 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] { // Both BU recipients count — see `BU_TREE_RECIPIENT_TYPES` for why that word // list is two long and which three of `ShareRecipientType` it lets past. const tenancyDisabledObjects = new Set( - asArray(cfg.objects).filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean), + recordsOf(cfg.objects).filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean), ); - asArray(cfg.sharingRules).forEach((rule, rIndex) => { + recordsOf(cfg.sharingRules).forEach((rule, rIndex) => { // `object` is REQUIRED by `SharingRuleSchema`, so a rule that parsed always // has it; `objectName` is not a spelling the schema accepts (#5009). const target = str(rule.object); diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 704c7d4c19..fb75fed3aa 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -115,14 +115,7 @@ import { sectionGroupRefs, type SectionGroupRef, } from './object-field-groups.js'; - -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 []; -} +import { recordsOf } from './object-graph.js'; function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; @@ -354,11 +347,11 @@ export function relatedListFieldRefs( export function indexObjectFields(stack: AnyRec): Map> { const objectFields = new Map>(); if (!isRec(stack)) return objectFields; - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const name = strName(obj.name); if (!name) continue; const names = new Set(); - for (const f of asArray(obj.fields)) { + for (const f of recordsOf(obj.fields)) { const fn = strName(f.name); if (fn) names.add(fn); } @@ -477,7 +470,7 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const findings: PageFieldFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - // object name → its declared field names. Built with `asArray` so BOTH + // object name → its declared field names. Built with `recordsOf` so BOTH // `fields` shapes (array of `{name}` and name-keyed map) resolve. const objectFields = indexObjectFields(stack); // [#8340] The provenance index alongside the existence one — same keying, @@ -486,7 +479,7 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { // [#13855] object name → its declared field-group keys, for `section.group`. const objectFieldGroups = indexObjectFieldGroups(stack); - const pages = asArray(stack.pages); + const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { const page = pages[pi]; if (!page || typeof page !== 'object') continue; diff --git a/packages/lint/src/validate-record-title.ts b/packages/lint/src/validate-record-title.ts index 0b117cda03..9e9ab505a2 100644 --- a/packages/lint/src/validate-record-title.ts +++ b/packages/lint/src/validate-record-title.ts @@ -2,6 +2,7 @@ import { objectTitleCompleteness } from '@objectstack/spec/data'; import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; +import { recordsOf } from './object-graph.js'; /** * Build-time record-title diagnostics (ADR-0079). @@ -51,15 +52,6 @@ export interface RecordTitleFinding { type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array of records. */ -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 []; -} - /** * Validate every object's record-title declaration. Returns the list of * findings (empty = clean). Both rules are advisory (`warning`): the caller @@ -69,7 +61,7 @@ function asArray(v: unknown): AnyRec[] { export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { const findings: RecordTitleFinding[] = []; - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); for (let i = 0; i < objects.length; i++) { const obj = objects[i]; const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; diff --git a/packages/lint/src/validate-searchable-fields.ts b/packages/lint/src/validate-searchable-fields.ts index fa1f04e518..03309fc480 100644 --- a/packages/lint/src/validate-searchable-fields.ts +++ b/packages/lint/src/validate-searchable-fields.ts @@ -127,7 +127,7 @@ import { SEARCH_AUTO_EXCLUDED_FIELDS, type SearchFieldMeta, } from '@objectstack/spec/data'; -import { suggestName } from './object-graph.js'; +import { recordsOf, suggestName } from './object-graph.js'; import { SYSTEM_FIELDS, indexUnprovisionedAnchors, @@ -172,15 +172,6 @@ export type SearchableFieldRole = 'canonical' | 'narrowing'; type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array of records. */ -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 isRec(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } @@ -216,7 +207,7 @@ function declaredFieldTarget(obj: AnyRec): ObjectSearchTarget | null { if (!fields || typeof fields !== 'object') return null; const names = new Set(); const metas: Record = {}; - for (const f of asArray(fields)) { + for (const f of recordsOf(fields)) { const n = strName(f.name); if (!n) continue; names.add(n); @@ -283,7 +274,7 @@ export function indexObjectSearchTargets( ): Map { const fieldsByObject = new Map(); if (!isRec(stack)) return fieldsByObject; - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const name = strName(obj.name); if (name) fieldsByObject.set(name, declaredFieldTarget(obj)); } @@ -514,7 +505,7 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ const findings: SearchableFieldFinding[] = []; if (!isRec(stack)) return findings; - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); const fieldsByObject = indexObjectSearchTargets(stack); const unprovisionedAnchors = indexUnprovisionedAnchors(stack); @@ -574,7 +565,7 @@ export function validateSearchableFields(stack: AnyRec): SearchableFieldFinding[ } // ── `defineView` aggregates: the default `list` + named `listViews` ── - const views = asArray(stack.views); + const views = recordsOf(stack.views); for (let vi = 0; vi < views.length; vi++) { const view = views[vi]; if (!isRec(view)) continue; diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index e8e1c2b465..4c6afaec6d 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -877,7 +877,16 @@ describe('validateSecurityPosture · book audience (ADR-0046 §6.7 / ADR-0090)', const RULE_SOURCE = readFileSync(new URL('./validate-security-posture.ts', import.meta.url), 'utf8'); /** The rule's CODE — comments stripped, since the guards scan reads, not prose. */ -const RULE_CODE = RULE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +const RULE_CODE = RULE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\n]*/g, '') + // #15552: an import SPECIFIER is a module path, not a metadata receiver. The + // receiver-coverage scan below reads `.` out of this text, so + // `'./object-graph.js'` presents as a receiver `graph` reading a key `js` — + // and the only way to silence that would be to file a module path under + // PLUMBING as though it were a local variable, which is a lie the next + // author would have to re-derive. Strip the import block instead; nothing in + // it is a read off a metadata record. + .replace(/^import[\s\S]*?from '[^']*';$/gm, ''); /** Distinct property names read off `receiver` in the rule's code. */ function keysReadOff(receiver: string): string[] { diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index c9d81eed14..467b2dbe39 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -70,6 +70,7 @@ */ import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; +import { recordsOf } from './object-graph.js'; export const SECURITY_OWD_UNSET = 'security-owd-unset'; export const SECURITY_OWD_ALIAS = 'security-owd-alias'; @@ -119,15 +120,6 @@ const OWD_WIDTH: Record = { public_read_write: 2, }; -/** Coerce a collection (array or name-keyed map) to an array of records. */ -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 []; -} - /** * The object's org-wide default. * @@ -188,11 +180,11 @@ function refOf(def: AnyRec): string | undefined { /** * The first `master_detail` field on an object, if any — its presence is what * makes the object a DETAIL (the child side of a master-detail; ADR-0055). - * Works for both the array and name-keyed-map field forms (`asArray` folds the + * Works for both the array and name-keyed-map field forms (`recordsOf` folds the * map key into `name`). */ function firstMasterDetailField(obj: AnyRec): { name: string; parent?: string } | undefined { - for (const f of asArray(obj.fields)) { + for (const f of recordsOf(obj.fields)) { if (f.type === 'master_detail') { return { name: String(f.name ?? '?'), parent: refOf(f) }; } @@ -259,7 +251,7 @@ const CBP_TIERS: ReadonlyArray<{ label: string; pred: (f: AnyRec) => boolean }> * nothing, and reporting it would be reporting a non-defect. */ function cbpMasterCandidates(obj: AnyRec): { tier: string; candidates: CbpRelation[] } | undefined { - const entries = asArray(obj.fields); + const entries = recordsOf(obj.fields); for (const { label, pred } of CBP_TIERS) { const matched = entries.filter((f) => pred(f) && refOf(f)); if (matched.length > 0) { @@ -318,8 +310,8 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } const findings: SecurityFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - const objects = asArray(stack.objects); - const permissionSets = asArray(stack.permissions); + const objects = recordsOf(stack.objects); + const permissionSets = recordsOf(stack.permissions); // ── D1/D4/D11: per-object OWD posture ──────────────────────────────── for (let i = 0; i < objects.length; i++) { @@ -583,7 +575,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } .map((ps) => (typeof ps.name === 'string' ? ps.name : undefined)) .filter((n): n is string => !!n), ); - for (const [i, book] of asArray(stack.books).entries()) { + for (const [i, book] of recordsOf(stack.books).entries()) { const audience = (book as AnyRec).audience; if (!audience || typeof audience !== 'object') continue; const setName = (audience as AnyRec).permissionSet; @@ -725,7 +717,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } const GRANT_SEED_OBJECTS = new Set(['sys_user_position', 'sys_user_permission_set']); const DELEGATION_SEED_OBJECTS = new Set(['sys_user_position']); const nowMs = opts?.nowMs ?? Date.now(); - for (const [i, seed] of asArray(stack.data).entries()) { + for (const [i, seed] of recordsOf(stack.data).entries()) { const seedObject = typeof seed.object === 'string' ? seed.object : ''; if (!GRANT_SEED_OBJECTS.has(seedObject)) continue; const records = Array.isArray(seed.records) ? (seed.records as AnyRec[]) : []; @@ -821,8 +813,8 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { const findings: SecurityFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - const objects = asArray(stack.objects); - const permissionSets = asArray(stack.permissions); + const objects = recordsOf(stack.objects); + const permissionSets = recordsOf(stack.permissions); const flagRole = (kind: string, name: unknown, label: unknown, where: string, path: string) => { if (identifierHasRoleToken(name)) { @@ -853,10 +845,10 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { if (!obj || typeof obj !== 'object' || isSystemObject(obj)) continue; const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; flagRole('object', obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`); - for (const f of asArray(obj.fields)) { + for (const f of recordsOf(obj.fields)) { flagRole('field', f.name, f.label, `field "${objName}.${String(f.name ?? '?')}"`, `objects[${i}].fields.${String(f.name ?? '?')}.name`); } - for (const [ai, action] of asArray(obj.actions).entries()) { + for (const [ai, action] of recordsOf(obj.actions).entries()) { flagRole('action', action.name, action.label, `action "${objName}.${String(action.name ?? '?')}"`, `objects[${i}].actions[${ai}].name`); } } @@ -865,13 +857,13 @@ export function validateSecurityRoleWord(stack: AnyRec): SecurityFinding[] { if (!ps || typeof ps !== 'object') continue; flagRole('permission set', ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`); } - for (const [i, pos] of asArray(stack.positions).entries()) { + for (const [i, pos] of recordsOf(stack.positions).entries()) { flagRole('position', pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`); } - for (const [i, app] of asArray(stack.apps).entries()) { + for (const [i, app] of recordsOf(stack.apps).entries()) { flagRole('app', app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`); } - for (const [i, book] of asArray(stack.books).entries()) { + for (const [i, book] of recordsOf(stack.books).entries()) { flagRole('book', book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`); } diff --git a/packages/lint/src/validate-sharing-rule-enforceability.ts b/packages/lint/src/validate-sharing-rule-enforceability.ts index ad56bc4704..2ce88c0cb1 100644 --- a/packages/lint/src/validate-sharing-rule-enforceability.ts +++ b/packages/lint/src/validate-sharing-rule-enforceability.ts @@ -137,6 +137,7 @@ */ import { compileCelToFilter } from '@objectstack/formula'; +import { recordsOf } from './object-graph.js'; /** A `condition` outside the pushdown subset — the rule is never seeded. */ export const SHARING_RULE_UNLOWERABLE_CONDITION = 'sharing-rule-unlowerable-condition'; @@ -165,15 +166,6 @@ export interface SharingRuleEnforceabilityFinding { type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array of records. */ -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 str(v: unknown): string { return typeof v === 'string' ? v : ''; } @@ -264,7 +256,7 @@ function effectiveSharingModelOf(obj: AnyRec): 'private' | 'read' | 'public' { /** The master a `controlled_by_parent` detail derives its access from, if named. */ function masterOf(obj: AnyRec): string | undefined { - for (const f of asArray(obj.fields)) { + for (const f of recordsOf(obj.fields)) { if (f.type === 'master_detail') { const ref = f.reference; if (typeof ref === 'string' && ref) return ref; @@ -428,12 +420,12 @@ export function validateSharingRuleEnforceability(stack: unknown): SharingRuleEn const cfg = (stack ?? {}) as AnyRec; const objectsByName = new Map(); - for (const obj of asArray(cfg.objects)) { + for (const obj of recordsOf(cfg.objects)) { const name = str(obj.name); if (name) objectsByName.set(name, obj); } - asArray(cfg.sharingRules).forEach((rule, index) => { + recordsOf(cfg.sharingRules).forEach((rule, index) => { anchorFindings(rule, index, objectsByName).forEach((f) => findings.push(f)); const input = toCompilerInput(rule.condition); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index f1e5e6c78b..ba6cb1eaa1 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -9,6 +9,7 @@ import { indexObjectGraph, isUnjudgeable, joinablePrefixes, + recordsOf, resolveFieldPath, suggestName, type ObjectGraph, @@ -432,15 +433,6 @@ export interface WidgetBindingFinding { type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array. */ -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 asStrings(v: unknown): string[] { return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : []; } @@ -559,7 +551,7 @@ function dashboardFilterDefs(dash: AnyRec): DashFilterDef[] { byName.set(DATE_RANGE_FILTER_NAME, { name: DATE_RANGE_FILTER_NAME, field }); } - for (const f of asArray(dash.globalFilters)) { + for (const f of recordsOf(dash.globalFilters)) { if (typeof f.field !== 'string' || !f.field) continue; const name = typeof f.name === 'string' && f.name ? f.name : f.field; const targetWidgets = Array.isArray(f.targetWidgets) @@ -607,7 +599,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { const findings: WidgetBindingFinding[] = []; const datasets = new Map(); - for (const ds of asArray(stack.datasets)) { + for (const ds of recordsOf(stack.datasets)) { if (typeof ds.name === 'string') datasets.set(ds.name, ds); } @@ -617,10 +609,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // does not depend on any widget), so it is checked once over every dataset // whose object's field types are known. Advisory — the page still renders. const objectFieldTypes = new Map>(); - for (const o of asArray(stack.objects)) { + for (const o of recordsOf(stack.objects)) { if (typeof o.name !== 'string') continue; const fm = new Map(); - for (const f of asArray(o.fields)) { + for (const f of recordsOf(o.fields)) { if (typeof f.name === 'string' && typeof f.type === 'string') fm.set(f.name, f.type); } objectFieldTypes.set(o.name, fm); @@ -634,12 +626,12 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // `validate-dataset-references.ts` does one level down — the same seam, so // the two positions cannot drift into two accounts of one object graph. const graph: ObjectGraph = indexObjectGraph(stack); - const datasetList = asArray(stack.datasets); + const datasetList = recordsOf(stack.datasets); for (let i = 0; i < datasetList.length; i++) { const ds = datasetList[i]; const fieldTypes = typeof ds.object === 'string' ? objectFieldTypes.get(ds.object) : undefined; if (!fieldTypes) continue; // cannot judge without the object's field types - const dsMeasures = asArray(ds.measures); + const dsMeasures = recordsOf(ds.measures); for (let k = 0; k < dsMeasures.length; k++) { const m = dsMeasures[k]; const field = typeof m.field === 'string' ? m.field : undefined; @@ -664,7 +656,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { } } - const dashboards = asArray(stack.dashboards); + const dashboards = recordsOf(stack.dashboards); for (let i = 0; i < dashboards.length; i++) { const dash = dashboards[i]; const dashName = typeof dash.name === 'string' ? dash.name : `(dashboard ${i})`; @@ -987,11 +979,11 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { } const dimensionNames = new Set(); - for (const d of asArray(dataset.dimensions)) { + for (const d of recordsOf(dataset.dimensions)) { if (typeof d.name === 'string') dimensionNames.add(d.name); } const measures = new Map(); - for (const m of asArray(dataset.measures)) { + for (const m of recordsOf(dataset.measures)) { if (typeof m.name === 'string') measures.set(m.name, m); }