diff --git a/.changeset/lint-non-record-collection-entry.md b/.changeset/lint-non-record-collection-entry.md new file mode 100644 index 0000000000..57da994b25 --- /dev/null +++ b/.changeset/lint-non-record-collection-entry.md @@ -0,0 +1,11 @@ +--- +"@objectstack/lint": patch +--- + +No authoring rule throws on a non-record entry of any stack collection. + +A collection is authored either as a list or as a name-keyed map, so every rule that reads one coerces `unknown` into an array of records first. That coercion had been hand-copied into 39 modules, and 23 of the copies spelled the array branch as an unchecked cast — every member was asserted to be a record. A YAML list item left empty deserialises to `null`, so a single stray `-` under `flows:`, `pages:`, `dashboards:`, `datasets:`, `apps:`, `permissions:`, `capabilities:`, `data:`, `hooks:`, `views:`, `actions:`, `translations:` (or a per-object `fields:` / `actions:` / `views:`) reached a property read on `null` and threw a stack trace out of `os lint` / `os validate` instead of reporting a finding. The rules are pure `(stack) => Finding[]` running on the raw path, so nothing upstream had judged the entry's shape. + +Twenty-two of those readers now read through the shared, guarded `recordsOf`, which drops a non-record member of the array shape whole and keeps the author's key on the map shape. Nothing else about what the rules judge changes: a valid entry standing beside a junk one is still read, and still draws exactly the findings it drew before. + +The remaining copies are pinned by a new source-text test in the package, so the predicate cannot be pasted back in: it asserts that `recordsOf` is the only collection coercion, that every module still holding a private one is named in a dated ledger that is exact in both directions, and that no coercion outside a dated single-file allowance casts its array branch unchecked. diff --git a/packages/lint/src/build-access-matrix.ts b/packages/lint/src/build-access-matrix.ts index 196ff8208e..08e7432c1b 100644 --- a/packages/lint/src/build-access-matrix.ts +++ b/packages/lint/src/build-access-matrix.ts @@ -16,31 +16,24 @@ */ import type { AccessMatrixParsed, AccessMatrixEntry } from '@objectstack/spec/security'; +import { recordsOf } from './object-graph.js'; type AnyRec = Record; -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 []; -} - /** Build the sorted access matrix for a normalized stack. */ export function buildAccessMatrix(stack: AnyRec): AccessMatrixParsed { const entries: AccessMatrixEntry[] = []; if (!stack || typeof stack !== 'object') return { version: 1, entries }; const owdByObject = new Map(); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const name = typeof obj.name === 'string' ? obj.name : ''; if (!name) continue; const owd = (obj.sharingModel ?? (obj.security as AnyRec | undefined)?.sharingModel) as string | undefined; if (typeof owd === 'string') owdByObject.set(name, owd); } - for (const ps of asArray(stack.permissions)) { + for (const ps of recordsOf(stack.permissions)) { const psName = typeof ps.name === 'string' ? ps.name : ''; if (!psName) continue; const objects = (ps.objects && typeof ps.objects === 'object' ? ps.objects : {}) as AnyRec; diff --git a/packages/lint/src/collection-coercion-single-copy.test.ts b/packages/lint/src/collection-coercion-single-copy.test.ts new file mode 100644 index 0000000000..747d754d3f --- /dev/null +++ b/packages/lint/src/collection-coercion-single-copy.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// One collection coercion, in one place (#15636). +// +// A stack collection is authored either as a list or as a name-keyed map, so +// every rule that reads one has to coerce `unknown` into `AnyRec[]` first. That +// coercion is ONE decision — what to do with a member that is not a record — +// and `recordsOf` in `object-graph.ts` is where it is made: a non-record member +// of the ARRAY shape is dropped whole (it carries no key, so it is nothing at +// all), while the map shape keeps the author's key and drops only its +// unreadable body. Its docblock argues both branches; this file only pins that +// the decision has one home. +// +// ## Why a source-text gate and not a code review +// +// The decision had 40 homes. #15494 guarded the seam every field-path rule +// opens with, and re-measuring the whole `AUTHORING_RULES` table over +// `{ objects: [null, validObject] }` still counted 13 of 42 rules throwing, +// through eleven more reader sites — every one of them a hand-copied `asArray` +// whose array branch was spelled `return v as AnyRec[]`, unchecked. #15552 +// re-pointed the `stack.objects` readers; #15636 re-pointed 22 more, one per +// collection family (`flows`, `pages`, `dashboards`, `datasets`, `apps`, +// `permissions`, `capabilities`, `data`, `hooks`, `views`, `actions`, +// `translations`, and the per-object sub-collections). +// +// The 39 copies were not identical, which is the part worth pinning. Twelve had +// already grown the array-branch filter LOCALLY, in two different spellings; +// four more read only the list shape and lean on an `if (!page) continue` three +// lines down; and two are load-bearing for a finding PATH rather than for a +// crash. A fix applied to some copies and not their siblings is the whole +// failure mode restated as evidence: a predicate with N copies is N chances to +// fix one and leave N-1, and no reviewer counts to 39. So the count is asserted +// here instead. +// +// ## The three clauses, and what each one refuses +// +// 1. `object-graph.ts` declares exactly one such coercion, named `recordsOf`. +// Without this the other two clauses could pass over a package that had +// lost the canonical one entirely. +// 2. Every OTHER module declaring one is in `COPY_LEDGER`. This is the ratchet +// and it is exact in BOTH directions: a new copy fails because it is not +// listed, and a copy that has been re-pointed fails because its ledger row +// is now a lie. The ledger may only shrink, and shrinking it is one line. +// 3. No coercion carries the UNCHECKED array branch — the spelling that +// actually crashes — outside `UNGUARDED_ALLOWANCE`. Clause 2 alone would let +// a re-introduced copy through as long as someone added a ledger row; clause +// 3 is what refuses the defect itself regardless of bookkeeping. +// +// Both allowances are DATED and name the change that deletes them, and both are +// exact in both directions: the day an allowed file is re-pointed, this test +// fails until its row goes, so an allowance cannot outlive its reason by being +// forgotten. That is load-bearing, not decoration — `validate-chart-bindings.ts` +// was carried in both lists for one change, #15741 re-pointed it, and both of +// its rows came out because this test went red, not because anyone remembered. +// +// Scope: `src/*.ts` excluding tests. A coercion inside a test file is a fixture, +// not a reader, and no tenant stack reaches it. +import { readFileSync, readdirSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const SRC_DIR = dirname(fileURLToPath(import.meta.url)); +const SELF = basename(fileURLToPath(import.meta.url)); + +/** + * A local collection coercion, matched by SHAPE rather than by name: a + * module-level `(v: unknown) => AnyRec[]`, in either the `function` or the + * arrow spelling. Matching the shape and not the identifier `asArray` is what + * makes clause 2 hold against a copy that renames itself. + */ +const COERCION = + /(?:function\s+(\w+)\s*\(\s*v:\s*unknown\s*\)\s*:\s*AnyRec\[\]|const\s+(\w+)\s*=\s*\(\s*v:\s*unknown\s*\)\s*:\s*AnyRec\[\])/g; + +/** + * The array branch that crashes: `Array.isArray` proves it is a LIST and the + * cast then asserts every MEMBER is a record, which a list out of YAML does not + * promise. The back-reference keeps this to a cast of the same binding that was + * just tested, and the test below is applied to a coercion's own body — never + * to a whole file, or every inline `x as AnyRec[]` a rule writes for a field it + * has already narrowed would answer for this predicate. + */ +const UNCHECKED_ARRAY_BRANCH = /Array\.isArray\((\w+)\)\s*\)?\s*(?:return|\?)\s*\(?\s*\1\s+as\s+AnyRec\[\]/; + +/** Where the one coercion lives. */ +const CANONICAL_MODULE = 'object-graph.ts'; +const CANONICAL_NAME = 'recordsOf'; + +/** + * Modules still holding a private copy, each with the issue that removes it. + * Rows may be DELETED as copies are re-pointed and must never be added: a new + * entry here is a new copy of the predicate, which is the defect this file + * exists to refuse. Every row is asserted to still be true below. + */ +const COPY_LEDGER: Readonly> = { + // 2026-09-05 — the two reference-integrity members #15494 deliberately left + // walking the RAW array. Their own loop guards each member with `isRec`, so + // neither ever threw; what the copy buys them is the INDEX, because + // `reference-integrity-suite.test.ts` pins their finding paths + // (`objects[1].highlightFields[1]`) against the author's file and `recordsOf` + // renumbers past a dropped member. Re-pointing them is blocked on an + // index-preserving reader, not on anyone's attention (#15740). + 'validate-object-field-refs.ts': '#15740', + 'validate-list-view-field-refs.ts': '#15740', + // 2026-09-05 — the sixteen copies that do not crash today: twelve grew a + // local array-branch filter and four read only the list shape behind a + // call-site `if (!page) continue`. They are not #15636's defect; they are its + // cause, and re-pointing them is bookkeeping this ledger now forces. + 'validate-action-body-writes.ts': '#15728', + 'validate-ai-agent-authoring.ts': '#15728', + 'validate-ai-surface-affinity.ts': '#15728', + 'validate-ai-tool-references.ts': '#15728', + 'validate-flow-node-writes.ts': '#15728', + 'validate-hook-body-writes.ts': '#15728', + 'validate-jsx-pages.ts': '#15728', + 'validate-nav-object-servability.ts': '#15728', + 'validate-nav-target-refs.ts': '#15728', + 'validate-page-source-styling.ts': '#15728', + 'validate-page-visualization-bindings.ts': '#15728', + 'validate-react-page-props.ts': '#15728', + 'validate-react-pages.ts': '#15728', + 'validate-readonly-action-writes.ts': '#15728', + 'validate-rule-compilability.ts': '#15728', + 'validate-view-page-refs.ts': '#15728', +}; + +/** + * The coercions still spelling the array branch unchecked, dated and named by + * the change that removes each. What remains is unchecked at the COERCION and + * guarded at the CALL SITE — the two reference-integrity members re-test every + * member with `isRec` inside their loop, and the four page walks skip on + * `if (!page) continue` three lines down — so a junk member costs none of them + * anything today. That is a guard standing somewhere the reader does not + * promise it, which is why they are allowed rather than accepted: each is still + * a copy of a predicate that has a home, and none may grow a sibling. + */ +const UNGUARDED_ALLOWANCE: Readonly> = { + // 2026-09-05 — removed by #15740, which needs an index-preserving reader + // first; both guard every member with `isRec` at the call site. + 'validate-object-field-refs.ts': '#15740', + 'validate-list-view-field-refs.ts': '#15740', + // 2026-09-05 — removed by #15728. + 'validate-jsx-pages.ts': '#15728', + 'validate-page-source-styling.ts': '#15728', + 'validate-react-page-props.ts': '#15728', + 'validate-react-pages.ts': '#15728', +}; + +/** Every rule/reader module — tests excluded, this file excluded. */ +const modules = (): string[] => + readdirSync(SRC_DIR) + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts') && f !== SELF) + .sort(); + +const read = (file: string): string => readFileSync(join(SRC_DIR, file), 'utf8'); + +/** + * One coercion's own body: from its declaration to the first line-initial `}` + * (the `function` form), capped at twelve lines (the arrow form is one + * statement, and no spelling of this helper in the package runs longer). + */ +const bodyAt = (src: string, index: number): string => { + const window = src.slice(index).split('\n').slice(0, 12); + const close = window.findIndex((line, i) => i > 0 && line === '}'); + return (close >= 0 ? window.slice(0, close + 1) : window).join('\n'); +}; + +/** The coercions a module declares, by name. */ +const coercionsIn = (file: string): string[] => + [...read(file).matchAll(COERCION)].map((m) => m[1] ?? m[2]); + +/** Whether any coercion this module declares casts its array branch unchecked. */ +const castsUnchecked = (file: string): boolean => { + const src = read(file); + return [...src.matchAll(COERCION)].some((m) => UNCHECKED_ARRAY_BRANCH.test(bodyAt(src, m.index ?? 0))); +}; + +describe('one collection coercion, in one place (#15636)', () => { + /** + * The floor first: a scan that found nothing would satisfy every assertion + * below vacuously. A reading under 50 means the discovery changed, not the + * package. + */ + it('reads the rule modules it claims to scan', () => { + expect(modules().length).toBeGreaterThanOrEqual(50); + expect(modules()).toContain(CANONICAL_MODULE); + }); + + it(`declares the coercion once, as \`${CANONICAL_NAME}\` in \`${CANONICAL_MODULE}\``, () => { + expect(coercionsIn(CANONICAL_MODULE)).toEqual([CANONICAL_NAME]); + }); + + it('holds no copy that the ledger does not name', () => { + const unlisted = modules() + .filter((f) => f !== CANONICAL_MODULE && coercionsIn(f).length > 0) + .filter((f) => !(f in COPY_LEDGER)); + expect( + unlisted, + `${unlisted.join(', ')} declares its own \`(v: unknown) => AnyRec[]\`. Read the collection ` + + `through \`recordsOf\` from './object-graph.js' instead — a second copy of this predicate ` + + `is a second place to forget the non-record filter (#15636).`, + ).toEqual([]); + }); + + it('names no copy the ledger has outlived', () => { + const stale = Object.keys(COPY_LEDGER) + .sort() + .filter((f) => coercionsIn(f).length === 0); + expect( + stale, + `${stale.join(', ')} no longer declares a private coercion. Delete its COPY_LEDGER row — ` + + `a ledger that outlives its subject stops describing the package and starts excusing it.`, + ).toEqual([]); + }); + + it('carries no unchecked array branch outside the dated allowance', () => { + const offenders = modules() + .filter((f) => castsUnchecked(f)) + .filter((f) => !(f in UNGUARDED_ALLOWANCE)); + expect( + offenders, + `${offenders.join(', ')} casts an array to \`AnyRec[]\` without filtering its members. ` + + `A YAML list item left empty deserialises to \`null\`, and the next property read throws ` + + `out of a rule that is contractually \`(stack) => Finding[]\` (#15636). Use \`recordsOf\`.`, + ).toEqual([]); + }); + + it('allows no unchecked branch the allowance has outlived', () => { + const stale = Object.keys(UNGUARDED_ALLOWANCE) + .sort() + .filter((f) => !castsUnchecked(f)); + expect( + stale, + `${stale.join(', ')} no longer casts unchecked. Delete its UNGUARDED_ALLOWANCE row — the ` + + `allowance was dated to the change that removes it, not granted to the file.`, + ).toEqual([]); + }); +}); diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 0ffd416a26..d7ba8f73c8 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -162,6 +162,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio // rather than hand-writing a fourth copy; see {@link filterCarriesNoCondition}. import { reduceFilterVerdict } from '@objectstack/spec/data'; import { stripRegions, REGION_SLOTS, MAX_REGION_DEPTH } from './flow-walk.js'; +import { recordsOf } from './object-graph.js'; export interface FlowLintFinding { where: string; @@ -184,12 +185,6 @@ export interface FlowLintFinding { type AnyRec = Record; -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 []; -} - /** Extract the raw predicate source from a `condition` (string or Expression envelope). */ function conditionSource(raw: unknown): string { if (typeof raw === 'string') return raw; @@ -1380,7 +1375,7 @@ function scanTryCatchWithoutCatch( */ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { const findings: FlowLintFinding[] = []; - 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[]) : []; const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : []; diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 83baa63391..49b614ca23 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -756,7 +756,14 @@ describe('lintLivenessProperties', () => { const findings = lintLivenessProperties({ translations: [null, { 'zh-CN': null }, { en: { flows: flowsGroup } }], }); - expect(findings.map((f) => f.where)).toEqual(["translation bundle #2 · locale 'en'"]); + // The bundle is the third list item the author wrote and is reported as + // `#1`: since #15636 this walk reads through `recordsOf`, which drops the + // two unreadable members before numbering. #15552 settled that trade — + // the positional index counts the entries a rule can READ, and what the + // contract pins is that the readable bundle is still judged, not where it + // sits. (Where a suite pins the author-file position instead, it keeps + // its raw walk: see `reference-integrity-suite.test.ts`.) + expect(findings.map((f) => f.where)).toEqual(["translation bundle #1 · locale 'en'"]); }); }); diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index cf0b2d045d..1bb124b906 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -27,6 +27,7 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { recordsOf } from './object-graph.js'; export interface LivenessLintFinding { where: string; @@ -57,12 +58,6 @@ function isRecord(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } -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 []; -} - /** Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or published files). */ function resolveLivenessDir(): string | null { try { @@ -489,14 +484,14 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { const objectWarn = loadWarnMap(dir, 'object'); const fieldWarn = loadWarnMap(dir, 'field'); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { // Malformed collection item — same "never throws" contract as the flat // TYPE_COLLECTIONS loop and the translation bundle walk below (#11385). if (!isRecord(obj)) continue; const objName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; if (objectWarn.size > 0) checkItem('object', obj, `object '${objName}'`, objectWarn, findings); if (fieldWarn.size > 0) { - for (const field of asArray(obj.fields)) { + for (const field of recordsOf(obj.fields)) { if (!isRecord(field)) continue; const fieldName = typeof field.name === 'string' ? field.name : '(unnamed field)'; checkItem('field', field, `object '${objName}' · field '${fieldName}'`, fieldWarn, findings); @@ -523,13 +518,13 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { // vocabulary, not the container; only the file-authored one is lintable. const translationWarn = loadWarnMap(dir, 'translation'); if (translationWarn.size > 0) { - const bundles = asArray(stack.translations); + const bundles = recordsOf(stack.translations); for (let i = 0; i < bundles.length; i++) { const bundle = bundles[i]; if (!isRecord(bundle)) continue; for (const [locale, data] of Object.entries(bundle)) { // Only a locale entry holds `TranslationData`. Anything else is either a - // malformed bundle or the `name` key `asArray` injects for a map-shaped + // malformed bundle or the `name` key `recordsOf` injects for a map-shaped // collection — skipping both keeps the "never throws" contract. if (!isRecord(data)) continue; checkItem('translation', data, `translation bundle #${i} · locale '${locale}'`, translationWarn, findings); @@ -540,7 +535,7 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { for (const { type, key } of TYPE_COLLECTIONS) { const warnMap = loadWarnMap(dir, type); if (warnMap.size === 0) continue; - for (const item of asArray(stack[key])) { + for (const item of recordsOf(stack[key])) { // Malformed collection item — "never throws" contract (#11385). if (!isRecord(item)) continue; // view containers bind via `object`, not `name` diff --git a/packages/lint/src/non-record-object-entry.test.ts b/packages/lint/src/non-record-object-entry.test.ts index e3d9edac15..e57e02a35a 100644 --- a/packages/lint/src/non-record-object-entry.test.ts +++ b/packages/lint/src/non-record-object-entry.test.ts @@ -1,9 +1,10 @@ // 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). + * A non-record entry in a stack collection must not throw out of ANY authoring + * rule — the family-level counterpart to `object-graph.test.ts`'s seam case + * (#15494). `stack.objects` first (#15552); every other collection in the + * parameterised sweep at the foot of this file (#15636). * * ## Why this is a family sweep and not thirteen per-rule cases * @@ -228,3 +229,183 @@ describe('each `stack.objects` reader seam skips a non-record entry (#15552)', ( expect(indexObjectFieldGroups(junked).get('crm_account')).toEqual(new Set(['general'])); }); }); + +/** + * The same sweep, every OTHER stack collection (#15636). + * + * ## Why the sweep had to be parameterised rather than trusted + * + * #15552 closed the class for ONE collection. The reader it fixed was not + * `stack.objects`'s reader, though — it was a hand-copied `asArray` that any + * rule pasted in front of any collection, and 23 more copies of it stood in + * front of `flows`, `pages`, `dashboards`, `datasets`, `apps`, `permissions`, + * `capabilities`, `data`, `hooks`, `views`, `actions`, `translations` and the + * per-object sub-collections. A `null` member of any of those reached the same + * dereference for the same reason: these rules are pure `(stack) => Finding[]` + * and run on the RAW `lint` path, so nothing upstream has judged an entry's + * shape, and a YAML list item left empty deserialises to `null`. + * + * The count per collection was UNKNOWN rather than zero when this was written — + * `lint-liveness-properties.test.ts` already pinned `translations: [null, …]` + * and `agents: [null, …]`, so some call sites were guarded downstream and some + * were not, and only a measurement could say which. This block is that + * measurement, kept as the pin. + * + * ## Where the collection list comes from + * + * Read off the readers, not off memory: every `recordsOf(stack.X)` in the 22 + * modules #15636 re-pointed, plus the `TYPE_COLLECTIONS` table + * `lintLivenessProperties` drives its dynamic `stack[key]` read from, plus + * `positions` and `books`, which `validateSecurityPosture` reads. Adding a + * collection to any of those readers and not to this list is the gap this + * comment exists to make visible. + * + * ## What each case pins, and why the second one matters + * + * Not throwing is half the contract. A guard that dropped the whole collection + * would satisfy it, and so would one that invented a finding about an entry no + * author wrote — the shape `validateSecurityPosture` was actually caught in for + * `objects`. So each case also pins the finding count against a control holding + * the SAME collection without the junk member: dropping a non-record member + * must be invisible to every rule, in both directions. + */ + +/** A collection under sweep: how to build a stack holding exactly these members. */ +interface SweptCollection { + readonly label: string; + readonly stack: (members: readonly unknown[]) => AnyRec; + /** A member this collection accepts, so the control is not merely empty. */ + readonly valid?: AnyRec; +} + +const topLevel = (key: string, valid?: AnyRec): SweptCollection => ({ + label: `stack.${key}`, + stack: (members) => (key === 'objects' ? { [key]: members } : { objects: [VALID_OBJECT], [key]: members }), + valid, +}); + +const VALID_FIELD: AnyRec = { name: 'amount', type: 'number', label: 'Amount' }; + +/** A stack whose single object carries `members` under one sub-collection key. */ +const underObject = (key: string, valid?: AnyRec): SweptCollection => ({ + label: `objects[].${key}`, + stack: (members) => ({ objects: [{ ...VALID_OBJECT, [key]: members }] }), + valid, +}); + +const SWEPT_COLLECTIONS: readonly SweptCollection[] = [ + // Top-level, read through `recordsOf(stack.X)` by the re-pointed readers. + topLevel('objects', VALID_OBJECT), + topLevel('flows'), + topLevel('pages'), + topLevel('dashboards'), + topLevel('datasets'), + topLevel('apps'), + topLevel('permissions'), + topLevel('capabilities'), + topLevel('actions'), + topLevel('views'), + topLevel('hooks'), + topLevel('data'), + topLevel('translations'), + // Top-level, reached by `lintLivenessProperties`'s dynamic `stack[key]` read. + topLevel('agents'), + topLevel('tools'), + topLevel('skills'), + topLevel('webhooks'), + topLevel('datasources'), + topLevel('books'), + topLevel('jobs'), + topLevel('emailTemplates'), + topLevel('mappings'), + // Top-level, read by `validateSecurityPosture` through `recordsOf`. + topLevel('positions'), + // Per-object sub-collections the same readers walk. + underObject('fields', VALID_FIELD), + underObject('actions'), + underObject('views'), + underObject('fieldGroups'), + underObject('validations'), +]; + +/** + * What is still broken, measured rather than assumed, keyed + * ` · `. + * + * These rows are not exceptions granted to the sweep — they are its FINDINGS, + * and each one is a filed card. Writing them down is what lets the assertions + * below be exact in BOTH directions: a rule that starts throwing on a + * collection reds because it is not listed, and a residual that gets fixed reds + * because its row is now a lie and has to go. A sweep that merely asserted + * "nothing throws" would have had to be deleted or weakened on the day it was + * written, and would then never have caught the next one. + * + * Every entry names a reader OUTSIDE what #15636 could touch: + * + * - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts:137`, + * which casts inline instead of through a helper, so the `asArray` sweeps + * that produced #15552 and #15636 never saw it. Filed as #15742. + * + * `stack.datasets` was here too, for `indexDatasets` in + * `validate-chart-bindings.ts`. #15741 re-pointed that reader and these + * assertions went red demanding a throw that no longer happens, which is the + * both-directions half earning its keep: the rows came out because the sweep + * failed, not because anyone went looking for them. + */ +const RESIDUAL_THROWS: Readonly> = { + 'objects[].fields · null': ['validateStackExpressions'], + 'objects[].fields · undefined': ['validateStackExpressions'], +}; + +/** + * Where a junk member still draws a finding no author's file justifies — the + * phantom half of the same defect, and the shape `validateSecurityPosture` was + * caught in for `objects` (#15552). + * + * `stack.agents · an array`: the agent readers filter their array branch with + * `!!x && typeof x === 'object'`, and `[]` passes that test — so an empty list + * item survives as an agent with no name and draws one reference-integrity + * finding at a position nobody wrote. `recordsOf` uses `isRec`, which excludes + * an array, so re-pointing those readers closes this too. They are among the + * sixteen copies in #15728. + */ +const RESIDUAL_INVENTED: Readonly> = { + 'stack.agents · an array': 1, +}; + +describe('a non-record entry in any other stack collection (#15636)', () => { + describe.each(SWEPT_COLLECTIONS.map((c) => [c.label, c] as const))('%s', (_label, collection) => { + const members = (junk: unknown): readonly unknown[] => + collection.valid ? [junk, collection.valid] : [junk]; + const control = (): AnyRec => collection.stack(collection.valid ? [collection.valid] : []); + + describe.each(NON_RECORD_ENTRIES)('with %s', (shape, junk) => { + const key = `${collection.label} · ${shape}`; + + it('throws out of no rule but the ones still filed as broken', () => { + const threw: string[] = []; + const detail: string[] = []; + for (const rule of AUTHORING_RULES) { + try { + rule.run(collection.stack(members(junk)), {}); + } catch (e) { + threw.push(rule.name); + detail.push(`${rule.name}: ${(e as Error).message}`); + } + } + expect(threw.sort(), detail.join(' | ')).toEqual([...(RESIDUAL_THROWS[key] ?? [])].sort()); + }); + + it('invents no finding about the entry no author wrote', () => { + // A rule listed in `RESIDUAL_THROWS` is excluded from BOTH sides rather + // than counted as zero: it returns nothing because it crashed, and + // folding that into the population count would let a crash read as + // "reported nothing", which is the confusion this file exists to end. + const skip = RESIDUAL_THROWS[key] ?? []; + const count = (stack: AnyRec): number => + AUTHORING_RULES.reduce((n, rule) => (skip.includes(rule.name) ? n : n + rule.run(stack, {}).length), 0); + expect(count(collection.stack(members(junk))) - count(control())).toBe(RESIDUAL_INVENTED[key] ?? 0); + }); + }); + }); +}); diff --git a/packages/lint/src/validate-action-locations.ts b/packages/lint/src/validate-action-locations.ts index 280fa136f0..667720a227 100644 --- a/packages/lint/src/validate-action-locations.ts +++ b/packages/lint/src/validate-action-locations.ts @@ -50,6 +50,8 @@ * `lintLivenessProperties`), it is high-signal and never fatal. */ +import { recordsOf } from './object-graph.js'; + export const ACTION_NO_PLACEMENT = 'action-no-placement'; export type ActionLocationsSeverity = 'error' | 'warning'; @@ -71,14 +73,6 @@ export interface ActionLocationsFinding { type AnyRec = Record; -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; } @@ -107,7 +101,7 @@ function collectNamePlacedActions(stack: AnyRec): Set { // dispatches. Inline field-patch defs (`operation: 'update'`) carry a name // that matches no action — harmless here, since an unmatched name simply // never exempts anything. - for (const def of asArray(list.bulkActionDefs)) { + for (const def of recordsOf(list.bulkActionDefs)) { const n = strName(def?.name); if (n) placed.add(n); } @@ -118,12 +112,12 @@ function collectNamePlacedActions(stack: AnyRec): Set { for (const lv of Object.values(listViews as AnyRec)) harvest(lv); }; - for (const view of asArray(stack.views)) { + for (const view of recordsOf(stack.views)) { if (!view || typeof view !== 'object') continue; harvest(view.list); harvestListViews(view.listViews); } - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { if (!obj || typeof obj !== 'object') continue; harvestListViews(obj.listViews); } @@ -167,14 +161,14 @@ export function validateActionLocations(stack: AnyRec): ActionLocationsFinding[] }); }; - const actions = asArray(stack.actions); + const actions = recordsOf(stack.actions); for (let i = 0; i < actions.length; i++) check(actions[i], `actions[${i}]`); - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); for (let oi = 0; oi < objects.length; oi++) { const obj = objects[oi]; if (!obj || typeof obj !== 'object') continue; - const own = asArray(obj.actions); + const own = recordsOf(obj.actions); for (let ai = 0; ai < own.length; ai++) check(own[ai], `objects[${oi}].actions[${ai}]`); } diff --git a/packages/lint/src/validate-action-name-refs.ts b/packages/lint/src/validate-action-name-refs.ts index 9b31888d8b..43b5ad7f52 100644 --- a/packages/lint/src/validate-action-name-refs.ts +++ b/packages/lint/src/validate-action-name-refs.ts @@ -42,7 +42,7 @@ * miss; it is called out in the hint rather than guessed at. */ -import { suggestName } from './object-graph.js'; +import { recordsOf, suggestName } from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; export const ACTION_NAME_UNDEFINED = 'action-name-undefined'; @@ -66,14 +66,6 @@ export interface ActionNameRefFinding { type AnyRec = Record; -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; } @@ -85,13 +77,13 @@ function strList(v: unknown): string[] { /** Every action name defined in the stack (global + object-embedded). */ function collectActionNames(stack: AnyRec): Set { const names = new Set(); - for (const action of asArray(stack.actions)) { + for (const action of recordsOf(stack.actions)) { const n = strName(action?.name); if (n) names.add(n); } - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { if (!obj || typeof obj !== 'object') continue; - for (const action of asArray(obj.actions)) { + for (const action of recordsOf(obj.actions)) { const n = strName(action?.name); if (n) names.add(n); } @@ -207,7 +199,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { }; // ── List views: `list` + each `listViews.`, on views AND on objects ── - const views = asArray(stack.views); + const views = recordsOf(stack.views); for (let vi = 0; vi < views.length; vi++) { const view = views[vi]; if (!view || typeof view !== 'object') continue; @@ -227,7 +219,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { // reference there is as dead as one in a standalone view — it was simply // never walked. Object-EMBEDDED actions were already collected as // definitions above; this is the consuming half. - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); for (let oi = 0; oi < objects.length; oi++) { const obj = objects[oi]; if (!obj || typeof obj !== 'object') continue; @@ -240,7 +232,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { } // ── Page components: record:quick_actions → properties.actionNames ── - 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; @@ -266,14 +258,14 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { } // ── App navigation: { type: 'action', actionDef: { actionName } } ── - 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; @@ -307,7 +299,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { }; 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-approval-approvers.ts b/packages/lint/src/validate-approval-approvers.ts index 9360fcde12..7b4ebfdc99 100644 --- a/packages/lint/src/validate-approval-approvers.ts +++ b/packages/lint/src/validate-approval-approvers.ts @@ -50,6 +50,7 @@ import { import { BUILTIN_MEMBERSHIP_ROLES } from '@objectstack/spec'; import { collectCelRootIdentifiers } from '@objectstack/formula'; import { walkFlowNodes } from './flow-walk.js'; +import { recordsOf } from './object-graph.js'; export const APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = 'approval-approver-not-membership-tier'; export const APPROVAL_APPROVER_TYPE_DEPRECATED = 'approval-approver-type-deprecated'; @@ -128,15 +129,6 @@ const TYPE_FIX: Record = { bu: 'department', }; -/** 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 the approvers of every Approval node in the stack's flows. * Returns findings (empty = clean). @@ -145,7 +137,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin const findings: ApprovalApproverFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - const flows = asArray(stack.flows); + const flows = recordsOf(stack.flows); const validTypes = new Set(ApproverType.options); for (let fi = 0; fi < flows.length; fi++) { diff --git a/packages/lint/src/validate-capability-references.ts b/packages/lint/src/validate-capability-references.ts index 751c916489..0d6e20a8c4 100644 --- a/packages/lint/src/validate-capability-references.ts +++ b/packages/lint/src/validate-capability-references.ts @@ -30,6 +30,7 @@ */ import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; +import { recordsOf } from './object-graph.js'; export const CAPABILITY_REFERENCE_UNKNOWN = 'capability-reference-unknown'; @@ -52,15 +53,6 @@ export interface CapabilityRefFinding { 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 []; -} - /** The capability strings in a `string[]` value. */ function asCapArray(v: unknown): string[] { return Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.length > 0) : []; @@ -95,13 +87,13 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin // ── Build the author-time "known capability" set ── const known = new Set(PLATFORM_CAPABILITY_NAMES); // [ADR-0066 D1] Capabilities the stack explicitly DECLARES via defineCapability. - for (const cap of asArray(stack.capabilities)) { + for (const cap of recordsOf(stack.capabilities)) { if (typeof cap.name === 'string' && cap.name.length > 0) known.add(cap.name); } - for (const ps of asArray(stack.permissions)) { + for (const ps of recordsOf(stack.permissions)) { for (const cap of asCapArray(ps.systemPermissions)) known.add(cap); } - for (const seed of asArray(stack.data)) { + for (const seed of recordsOf(stack.data)) { if (seed.object !== 'sys_capability') continue; for (const rec of Array.isArray(seed.records) ? seed.records : []) { const name = (rec as AnyRec | null)?.name; @@ -131,7 +123,7 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin }; // ── Objects (D3) + their fields (D3) + embedded actions (D4) ── - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); for (let i = 0; i < objects.length; i++) { const obj = objects[i]; if (!obj || typeof obj !== 'object') continue; @@ -142,7 +134,7 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ''}`); } - const fields = asArray(obj.fields); + const fields = recordsOf(obj.fields); for (const f of fields) { const fname = typeof f.name === 'string' ? f.name : '(field)'; for (const cap of asCapArray(f.requiredPermissions)) { @@ -150,7 +142,7 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin } } - for (const [ai, action] of asArray(obj.actions).entries()) { + for (const [ai, action] of recordsOf(obj.actions).entries()) { const aName = typeof action.name === 'string' ? action.name : `(action ${ai})`; for (const cap of asCapArray(action.requiredPermissions)) { flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`); @@ -159,7 +151,7 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin } // ── Top-level actions (D4) ── - for (const [i, action] of asArray(stack.actions).entries()) { + for (const [i, action] of recordsOf(stack.actions).entries()) { const aName = typeof action.name === 'string' ? action.name : `(action ${i})`; for (const cap of asCapArray(action.requiredPermissions)) { flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`); @@ -173,7 +165,7 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin // was a fail-open gate nothing enforced), so the generic check below no // longer fires on an area node. Dropping the traversal would strand every // area-nested item. ── - const apps = asArray(stack.apps); + const apps = recordsOf(stack.apps); for (let i = 0; i < apps.length; i++) { const app = apps[i]; if (!app || typeof app !== 'object') continue; diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index c7ee3bc104..2b6bcb7bda 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -107,6 +107,7 @@ import { ComponentPropsMap } from '@objectstack/spec/ui'; import { lintUnknownKeysAgainstSchema } from '@objectstack/spec'; import { walkPageComponents, type AnyRec } from './page-walk.js'; import { describeIssue, type LintZodIssue } from './zod-issue-format.js'; +import { recordsOf } from './object-graph.js'; /** A key authored in `properties` that the type's props schema does not declare. */ export const COMPONENT_PROPS_UNKNOWN_KEY = 'component-props-unknown-key'; @@ -143,15 +144,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 (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - /** A zod schema, as much of one as this file reads. */ interface PropsSchema { safeParse(value: unknown): { success: boolean; error?: { issues: ReadonlyArray } }; @@ -231,7 +223,7 @@ export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { const findings: ComponentPropsFinding[] = []; if (!isRec(stack)) return findings; - const pages = asArray(stack.pages); + const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { const page = pages[pi]; if (!isRec(page)) continue; diff --git a/packages/lint/src/validate-component-types.ts b/packages/lint/src/validate-component-types.ts index 2833f80b70..0e1088e952 100644 --- a/packages/lint/src/validate-component-types.ts +++ b/packages/lint/src/validate-component-types.ts @@ -53,6 +53,7 @@ import { } from '@objectstack/spec/ui'; import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared'; import { walkPageComponents, type AnyRec } from './page-walk.js'; +import { recordsOf } from './object-graph.js'; /** A component `type` inside a spec-reserved namespace that the vocabulary does not declare. */ export const COMPONENT_TYPE_UNKNOWN = 'component-type-unknown'; @@ -79,20 +80,11 @@ 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 (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - export function validateComponentTypes(stack: AnyRec): ComponentTypeFinding[] { const findings: ComponentTypeFinding[] = []; if (!isRec(stack)) return findings; - const pages = asArray(stack.pages); + const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { const page = pages[pi]; if (!isRec(page)) continue; diff --git a/packages/lint/src/validate-dashboard-action-refs.ts b/packages/lint/src/validate-dashboard-action-refs.ts index 9b5bcfcde8..96227dd632 100644 --- a/packages/lint/src/validate-dashboard-action-refs.ts +++ b/packages/lint/src/validate-dashboard-action-refs.ts @@ -81,6 +81,8 @@ * `validate-capability-references` rules. */ +import { recordsOf } from './object-graph.js'; + export const DASHBOARD_ACTION_TARGET_UNDEFINED = 'dashboard-action-target-undefined'; export const DASHBOARD_ACTION_ROUTE_UNRESOLVED = 'dashboard-action-route-unresolved'; @@ -103,17 +105,6 @@ export interface DashboardActionRefFinding { type AnyRec = Record; -/** Coerce a collection (array or name-keyed map) to an array of records, injecting - * `name` from the map key — mirrors the helper in 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; } @@ -169,7 +160,7 @@ function collectKnownTargets(stack: AnyRec): KnownTargets { const views = new Set(); const collectNames = (v: unknown, into: Set, name: (rec: AnyRec) => string | undefined) => { - for (const item of asArray(v)) { + for (const item of recordsOf(v)) { if (!item || typeof item !== 'object') continue; const n = name(item); if (n) into.add(n); @@ -177,7 +168,7 @@ function collectKnownTargets(stack: AnyRec): KnownTargets { }; collectNames(stack.actions, actions, (a) => strName(a.name)); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { if (!obj || typeof obj !== 'object') continue; const n = strName(obj.name); if (n) objects.add(n); @@ -258,7 +249,7 @@ export function validateDashboardActionRefs(stack: AnyRec): DashboardActionRefFi const findings: DashboardActionRefFinding[] = []; if (!stack || typeof stack !== 'object') return findings; - const dashboards = asArray(stack.dashboards); + const dashboards = recordsOf(stack.dashboards); if (dashboards.length === 0) return findings; const known = collectKnownTargets(stack); @@ -331,7 +322,7 @@ export function validateDashboardActionRefs(stack: AnyRec): DashboardActionRefFi const dashPath = `dashboards[${di}]`; // Header actions. - const headerActions = asArray((dash.header as AnyRec | undefined)?.actions); + const headerActions = recordsOf((dash.header as AnyRec | undefined)?.actions); for (let ai = 0; ai < headerActions.length; ai++) { const action = headerActions[ai] as HeaderAction | null; if (!action || typeof action !== 'object') continue; diff --git a/packages/lint/src/validate-dataset-references.ts b/packages/lint/src/validate-dataset-references.ts index 9209e559e8..31a1a0179a 100644 --- a/packages/lint/src/validate-dataset-references.ts +++ b/packages/lint/src/validate-dataset-references.ts @@ -117,11 +117,12 @@ import { walkFilterFieldKeys } from './filter-walk.js'; import { - RELATIONSHIP_FIELD_TYPES, describeFieldPathVerdict, indexObjectGraph, isUnjudgeable, joinablePrefixes, + recordsOf, + RELATIONSHIP_FIELD_TYPES, resolveFieldPath, type ObjectGraph, } from './object-graph.js'; @@ -162,15 +163,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 (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); - } - return []; -} - /** The shared consequence sentence — why an unresolved path is not merely inert. */ const SILENT_EMPTY = 'The path is compiled into the analytics query as written, so it addresses a column ' + @@ -187,7 +179,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { const findings: DatasetRefFinding[] = []; if (!isRec(stack)) return findings; - const datasets = asArray(stack.datasets); + const datasets = recordsOf(stack.datasets); if (datasets.length === 0) return findings; const graph: ObjectGraph = indexObjectGraph(stack); @@ -315,7 +307,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { }; // ── (2) `dimensions[].field` ── - asArray(ds.dimensions).forEach((dim, i) => { + recordsOf(ds.dimensions).forEach((dim, i) => { const name = strName(dim.name) ?? `#${i}`; checkFieldPath( dim.field, @@ -333,7 +325,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { // `DatasetSchema.superRefine` owns that graph, and `field` is legitimately // absent on a plain `count`. Both fall out of `checkFieldPath`'s own // "nothing written, nothing to resolve" guard. - asArray(ds.measures).forEach((measure, i) => { + recordsOf(ds.measures).forEach((measure, i) => { const name = strName(measure.name) ?? `#${i}`; checkFieldPath( measure.field, @@ -368,7 +360,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { }; checkFilter(ds.filter, `${where} › filter`, `${dsPath}.filter`); - asArray(ds.measures).forEach((measure, i) => { + recordsOf(ds.measures).forEach((measure, i) => { const name = strName(measure.name) ?? `#${i}`; checkFilter( measure.filter, diff --git a/packages/lint/src/validate-flow-template-paths.ts b/packages/lint/src/validate-flow-template-paths.ts index 6bed8ab2fe..e1a9575766 100644 --- a/packages/lint/src/validate-flow-template-paths.ts +++ b/packages/lint/src/validate-flow-template-paths.ts @@ -74,6 +74,7 @@ import { unprovisionedAnchorHint, } from './system-fields.js'; import { walkFlowNodes } from './flow-walk.js'; +import { recordsOf } from './object-graph.js'; export type FlowTemplatePathSeverity = 'error' | 'warning'; @@ -95,18 +96,6 @@ export const FLOW_TEMPLATE_FIELD_UNPROVISIONED = 'flow-template-field-unprovisio 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 []; -} - // Path heads addressable in a `{record.}` template without being authored // fields: the package-shared registry-injected columns (`system-fields.ts`, // #4330) plus three heads this rule has always exempted. `name`, `owner` and @@ -142,7 +131,7 @@ const FILTER_GUARDED_NODE_TYPES: ReadonlySet = new Set([ /** Build a `fieldName -> type` map for an object (declared fields only). */ function fieldTypesOf(obj: AnyRec): Map { const types = new Map(); - for (const f of asArray(obj.fields)) { + for (const f of recordsOf(obj.fields)) { if (typeof f.name === 'string') { types.set(f.name, typeof f.type === 'string' ? f.type : ''); } @@ -296,11 +285,11 @@ function declaredExpandOf(flow: AnyRec): Set { */ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFinding[] { const findings: FlowTemplatePathFinding[] = []; - const flows = asArray(stack.flows); + const flows = recordsOf(stack.flows); if (flows.length === 0) return findings; const objectsByName = new Map(); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { if (typeof obj.name === 'string') objectsByName.set(obj.name, obj); } diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index a741c07896..9a03303a74 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -104,6 +104,7 @@ // on the CLI surface, a package build whose stack contains one. import { TimeRelativeTriggerSchema, resolveFlowTriggerKind } from '@objectstack/spec/automation'; +import { recordsOf } from './object-graph.js'; export type FlowTriggerReadinessSeverity = 'error' | 'warning'; @@ -186,18 +187,6 @@ type AnyRec = Record; */ const VALID_RECORD_TRIGGER = /^record-(?:before|after)-(?:create|insert|update|delete|write)$/; -/** 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 []; -} - /** * Render a non-object `config.timeRelative` for the 1e message: the value AND * its type, because both halves of the mistake are informative — `'daily'` shows @@ -249,11 +238,11 @@ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined */ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadinessFinding[] { const findings: FlowTriggerReadinessFinding[] = []; - const flows = asArray(stack.flows); + const flows = recordsOf(stack.flows); if (flows.length === 0) return findings; const objectNames = new Set( - asArray(stack.objects) + recordsOf(stack.objects) .map((o) => (typeof o.name === 'string' ? o.name : undefined)) .filter((n): n is string => !!n), ); diff --git a/packages/lint/src/validate-nav-access.ts b/packages/lint/src/validate-nav-access.ts index 70dec7c02f..1057079f80 100644 --- a/packages/lint/src/validate-nav-access.ts +++ b/packages/lint/src/validate-nav-access.ts @@ -38,6 +38,7 @@ import { isPlatformProvidedObjectName } from '@objectstack/spec/system'; import { buildAccessMatrix } from './build-access-matrix.js'; +import { recordsOf } from './object-graph.js'; export const NAV_OBJECT_UNGRANTED = 'nav-object-ungranted'; @@ -60,14 +61,6 @@ export interface NavAccessFinding { type AnyRec = Record; -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; } @@ -82,7 +75,7 @@ interface NavExposure { /** Collect every object a stack's navigation exposes, across areas and children. */ function collectNavExposures(stack: AnyRec): NavExposure[] { const out: NavExposure[] = []; - const apps = asArray(stack.apps); + const apps = recordsOf(stack.apps); for (let ai = 0; ai < apps.length; ai++) { const app = apps[ai]; @@ -90,7 +83,7 @@ function collectNavExposures(stack: AnyRec): NavExposure[] { const appName = strName(app.name) ?? `#${ai}`; const walk = (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; @@ -108,7 +101,7 @@ function collectNavExposures(stack: AnyRec): NavExposure[] { }; walk(app.navigation, `apps[${ai}].navigation`); - const areas = asArray(app.areas); + const areas = recordsOf(app.areas); for (let ri = 0; ri < areas.length; ri++) { walk(areas[ri]?.navigation, `apps[${ai}].areas[${ri}].navigation`); } @@ -126,7 +119,7 @@ export function validateNavAccess(stack: AnyRec): NavAccessFinding[] { if (!stack || typeof stack !== 'object') return findings; // No permission sets in this stack ⇒ permissions are managed elsewhere. - const permissionSets = asArray(stack.permissions); + const permissionSets = recordsOf(stack.permissions); if (permissionSets.length === 0) return findings; const exposures = collectNavExposures(stack); @@ -136,7 +129,7 @@ export function validateNavAccess(stack: AnyRec): NavAccessFinding[] { // present here. A nav target that resolves nowhere is a different bug, owned // by `validate-object-references` / `defineStack`. const ownObjects = new Set(); - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const n = strName(obj.name); if (n) ownObjects.add(n); } diff --git a/packages/lint/src/validate-readonly-flow-writes.ts b/packages/lint/src/validate-readonly-flow-writes.ts index daace3043d..44cea2cf07 100644 --- a/packages/lint/src/validate-readonly-flow-writes.ts +++ b/packages/lint/src/validate-readonly-flow-writes.ts @@ -49,6 +49,7 @@ // flows are held to the same bar. import { walkFlowNodes, flowNodeLabel } from './flow-walk.js'; +import { recordsOf } from './object-graph.js'; export type ReadonlyFlowWriteSeverity = 'error' | 'warning'; @@ -69,18 +70,6 @@ export const FLOW_UPDATE_READONLY_WHEN_FIELD = 'flow-update-readonly-when-field' 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 []; -} - export interface FieldReadonlyMeta { /** Static `readonly: true`. */ readonly: boolean; @@ -148,10 +137,10 @@ function readLiteralObjectName(config: AnyRec): string | undefined { */ export function validateReadonlyFlowWrites(stack: AnyRec): ReadonlyFlowWriteFinding[] { const findings: ReadonlyFlowWriteFinding[] = []; - const flows = asArray(stack.flows); + const flows = recordsOf(stack.flows); if (flows.length === 0) return findings; - const roIndex = buildReadonlyIndex(asArray(stack.objects)); + const roIndex = buildReadonlyIndex(recordsOf(stack.objects)); flows.forEach((flow, flowIndex) => { // `runAs` defaults to 'user' (schema default). Only an explicit 'system' diff --git a/packages/lint/src/validate-readonly-hook-writes.ts b/packages/lint/src/validate-readonly-hook-writes.ts index 33c871e708..40b6fb75c1 100644 --- a/packages/lint/src/validate-readonly-hook-writes.ts +++ b/packages/lint/src/validate-readonly-hook-writes.ts @@ -141,6 +141,7 @@ import { type BodyWritePatternExclusion, } from './validate-hook-body-writes.js'; import { buildReadonlyIndex } from './validate-readonly-flow-writes.js'; +import { recordsOf } from './object-graph.js'; export type ReadonlyHookWriteSeverity = 'error' | 'warning'; @@ -223,18 +224,6 @@ function isRec(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } -/** Coerce an array-or-name-keyed-map collection to an array (name injected). */ -function asArray(v: unknown): AnyRec[] { - if (Array.isArray(v)) return v as AnyRec[]; - if (v && typeof v === 'object') { - return Object.entries(v as AnyRec).map(([name, def]) => ({ - name, - ...(def as AnyRec), - })); - } - return []; -} - /** * Validate L2 hook-body `ctx.api` writes against target-object readonly * declarations. Pure `(stack) => Finding[]` (ADR-0019); safe on pre- or @@ -242,7 +231,7 @@ function asArray(v: unknown): AnyRec[] { */ export function validateReadonlyHookWrites(stack: AnyRec): ReadonlyHookWriteFinding[] { const findings: ReadonlyHookWriteFinding[] = []; - const hooks = asArray(stack.hooks); + const hooks = recordsOf(stack.hooks); if (hooks.length === 0) return findings; // Built lazily: a stack whose hooks are all L1/handler-based never pays it. @@ -282,7 +271,7 @@ export function validateReadonlyHookWrites(stack: AnyRec): ReadonlyHookWriteFind ); if (writes.length === 0) return; - roIndex ??= buildReadonlyIndex(asArray(stack.objects)); + roIndex ??= buildReadonlyIndex(recordsOf(stack.objects)); const hookName = typeof hook.name === 'string' && hook.name ? hook.name : `#${hookIndex}`; const where = `hook "${hookName}" > body`; diff --git a/packages/lint/src/validate-responsive-styles.ts b/packages/lint/src/validate-responsive-styles.ts index 69da2e0f98..55106fb71d 100644 --- a/packages/lint/src/validate-responsive-styles.ts +++ b/packages/lint/src/validate-responsive-styles.ts @@ -12,6 +12,8 @@ // (contrast, balance, "is it ugly") is only catchable by rendering + a VLM gate, // which is a separate, render-time concern (ADR-0065 §Decision-5). +import { recordsOf } from './object-graph.js'; + export type StyleSeverity = 'error' | 'warning'; export interface StyleFinding { @@ -93,14 +95,6 @@ function looksLikeTailwind(className: string): boolean { }); } -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 []; -} - /** Child nodes can hang off `children`, `properties.children`, `body`, or * `properties.body` depending on block type — collect them all. */ function childrenOf(node: AnyRec): AnyRec[] { @@ -192,13 +186,13 @@ function checkNode(node: AnyRec, pageName: string, path: string, findings: Style */ export function validateResponsiveStyles(stack: AnyRec): StyleFinding[] { const findings: StyleFinding[] = []; - const pages = asArray(stack.pages); + const pages = recordsOf(stack.pages); for (let p = 0; p < pages.length; p++) { const page = pages[p]; const pageName = typeof page.name === 'string' ? page.name : `pages[${p}]`; - const regions = asArray(page.regions); + const regions = recordsOf(page.regions); for (let r = 0; r < regions.length; r++) { - const components = asArray(regions[r].components); + const components = recordsOf(regions[r].components); for (let c = 0; c < components.length; c++) { checkNode(components[c], pageName, `pages[${p}].regions[${r}].components[${c}]`, findings); } diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 98675a3ff7..70025bb36b 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -158,6 +158,7 @@ import { sqlPredicateToCel, } from '@objectstack/formula'; import type { CelBoundsOverrun } from '@objectstack/formula'; +import { recordsOf } from './object-graph.js'; /** A predicate outside the pushdown subset — the policy enforces nothing. */ export const RLS_PREDICATE_UNENFORCEABLE = 'rls-predicate-unenforceable'; @@ -184,15 +185,6 @@ export interface RlsPredicateFinding { 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 : ''; } @@ -261,8 +253,8 @@ export function validateRlsPredicateEnforceability(stack: unknown): RlsPredicate const findings: RlsPredicateFinding[] = []; const cfg = (stack ?? {}) as AnyRec; - asArray(cfg.permissions).forEach((ps, psIndex) => { - asArray(ps.rowLevelSecurity).forEach((policy, pIndex) => { + recordsOf(cfg.permissions).forEach((ps, psIndex) => { + recordsOf(ps.rowLevelSecurity).forEach((policy, pIndex) => { for (const clause of ['using', 'check'] as const) { const source = str(policy[clause]); // Absent / blank is Zod's to judge (`using` is required on the schema); diff --git a/packages/lint/src/validate-semantic-roles.ts b/packages/lint/src/validate-semantic-roles.ts index bb1385942b..e0cb862924 100644 --- a/packages/lint/src/validate-semantic-roles.ts +++ b/packages/lint/src/validate-semantic-roles.ts @@ -29,6 +29,7 @@ */ import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; +import { recordsOf } from './object-graph.js'; export const FIELD_GROUP_UNDECLARED = 'field-group-undeclared'; export const FIELD_GROUP_EMPTY = 'field-group-empty'; @@ -55,15 +56,6 @@ export interface SemanticRoleFinding { 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 semantic-role pointers. Returns the list of * findings (empty = clean). Advisory only — the caller must never fail the @@ -72,7 +64,7 @@ function asArray(v: unknown): AnyRec[] { export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] { const findings: SemanticRoleFinding[] = []; - const objects = asArray(stack.objects); + const objects = recordsOf(stack.objects); for (let i = 0; i < objects.length; i++) { const obj = objects[i]; if (!obj || typeof obj !== 'object') continue; // tolerate junk entries diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index b25878aa9f..0bb4a8c08d 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -102,7 +102,7 @@ import { expandViewContainer } from '@objectstack/spec'; import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from '@objectstack/spec/system'; import { walkFlowNodes } from './flow-walk.js'; -import { suggestName } from './object-graph.js'; +import { recordsOf, suggestName } from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; import { SYSTEM_FIELDS } from './system-fields.js'; import { viewObjectName } from './view-walk.js'; @@ -144,15 +144,6 @@ function isRec(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } -/** 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 (isRec(v)) return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); - return []; -} - function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } @@ -351,7 +342,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec */ const addSections = (container: AnyRec, binding: string | undefined) => { if (!binding) return; - for (const section of asArray(container.sections)) { + for (const section of recordsOf(container.sections)) { const sectionName = strName(section.name); if (sectionName) factsFor(binding).sections.add(sectionName); } @@ -445,7 +436,7 @@ function collectPageTabs(page: AnyRec, factsFor: (objectName: string) => ObjectF const objectName = strName(cfg.source) ?? strName(page.object); if (!objectName) return; - for (const tab of asArray(userFilters.tabs)) { + for (const tab of recordsOf(userFilters.tabs)) { const tabName = strName(tab.name); if (tabName) factsFor(objectName).tabs.add(tabName); } @@ -594,16 +585,16 @@ function buildUniverse(stack: AnyRec): Universe { }; // ── Objects: fields, embedded actions/views, fieldGroups (the `_sections` anchor) ── - for (const obj of asArray(stack.objects)) { + for (const obj of recordsOf(stack.objects)) { const objectName = strName(obj.name); if (!objectName) continue; const facts = factsFor(objectName); - for (const field of asArray(obj.fields)) { + for (const field of recordsOf(obj.fields)) { const fieldName = strName(field.name); if (fieldName) facts.fields.set(fieldName, field); } - for (const action of asArray(obj.actions)) { + for (const action of recordsOf(obj.actions)) { const actionName = strName(action.name); if (actionName) facts.actions.set(actionName, action); } @@ -611,12 +602,12 @@ function buildUniverse(stack: AnyRec): Universe { // container the chart rule also walks. `{ ...view, object: objectName }` // pins the binding: an embedded view inherits its owner, and nothing here // depends on the container repeating it. - for (const view of asArray(obj.views)) { + for (const view of recordsOf(obj.views)) { collectViewRecord({ ...view, object: strName(view.object) ?? objectName }, factsFor); } collectViewRecord({ object: objectName, listViews: obj.listViews }, factsFor); // ADR-0085: `fieldGroups[].key` is the i18n anchor for `_sections`. - for (const group of asArray(obj.fieldGroups)) { + for (const group of recordsOf(obj.fieldGroups)) { const key = strName(group.key) ?? strName(group.name); if (key) facts.sections.add(key); } @@ -634,19 +625,19 @@ function buildUniverse(stack: AnyRec): Universe { // name stays in the universe too — its message is structurally // unreachable at runtime, but #14518 keeps a bundle entry for it // deliberately so the bundle mirrors the declared rule set 1:1. - for (const rule of asArray(obj.validations)) { + for (const rule of recordsOf(obj.validations)) { collectValidationRuleNames(rule, facts.validations); } } // ── Stack-level views: `_views` names + form-section names ── - for (const view of asArray(stack.views)) { + for (const view of recordsOf(stack.views)) { collectViewRecord(view, factsFor); } // ── Pages: `record:details` sections are the other `_sections` anchor, and // `interfaceConfig.userFilters.tabs` is the ONE `_tabs` anchor ── - const pages = asArray(stack.pages); + const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { // Read off the page ROOT, before the component walk and independent of it: // `interfaceConfig` is not a component, and unlike `regions` it is authored @@ -656,7 +647,7 @@ function buildUniverse(stack: AnyRec): Universe { if (!walked.objectName) continue; const props = isRec(walked.component.properties) ? walked.component.properties : undefined; if (!props) continue; - for (const section of asArray(props.sections)) { + for (const section of recordsOf(props.sections)) { const sectionName = strName(section.name); if (sectionName) factsFor(walked.objectName).sections.add(sectionName); } @@ -666,7 +657,7 @@ function buildUniverse(stack: AnyRec): Universe { // ── Actions: object-bound ones join their object; the rest are global ── const globalActions = new Map(); const actionOwners = new Map(); - for (const action of asArray(stack.actions)) { + for (const action of recordsOf(stack.actions)) { const actionName = strName(action.name); if (!actionName) continue; const owner = strName(action.objectName) ?? strName(action.object); @@ -685,19 +676,19 @@ function buildUniverse(stack: AnyRec): Universe { // ── Apps: navigation item ids (`apps..navigation..label`) ── const apps = new Map>(); - for (const app of asArray(stack.apps)) { + for (const app of recordsOf(stack.apps)) { const appName = strName(app.name); if (!appName) continue; const navIds = apps.get(appName) ?? new Set(); const walkNav = (items: unknown) => { - for (const item of asArray(items)) { + for (const item of recordsOf(items)) { const id = strName(item.id); if (id) navIds.add(id); if (item.children) walkNav(item.children); } }; walkNav(app.navigation); - for (const area of asArray(app.areas)) { + for (const area of recordsOf(app.areas)) { const areaId = strName(area.id); if (areaId) navIds.add(areaId); walkNav(area.navigation); @@ -707,18 +698,18 @@ function buildUniverse(stack: AnyRec): Universe { // ── Dashboards: widget ids + header action urls ── const dashboards = new Map; actions: Set }>(); - for (const dash of asArray(stack.dashboards)) { + for (const dash of recordsOf(stack.dashboards)) { const dashName = strName(dash.name); if (!dashName) continue; const widgets = new Set(); - for (const widget of asArray(dash.widgets)) { + for (const widget of recordsOf(dash.widgets)) { const id = strName(widget.id) ?? strName(widget.name); if (id) widgets.add(id); } const actions = new Set(); const headerActions = [ - ...asArray(isRec(dash.header) ? dash.header.actions : undefined), - ...asArray(dash.actions), + ...recordsOf(isRec(dash.header) ? dash.header.actions : undefined), + ...recordsOf(dash.actions), ]; for (const action of headerActions) { const key = strName(action.actionUrl) ?? strName(action.url) ?? strName(action.name); @@ -738,7 +729,7 @@ function buildUniverse(stack: AnyRec): Universe { // orphan — a warning-severity false positive, which is exactly the // over-stating ADR-0072 D1 forbids. const flows = new Map(); - for (const flow of asArray(stack.flows)) { + for (const flow of recordsOf(stack.flows)) { const flowName = strName(flow.name); if (!flowName) continue; const screens = new Map(); @@ -753,7 +744,7 @@ function buildUniverse(stack: AnyRec): Universe { } const config = isRec(node.config) ? node.config : undefined; const fields = new Set(); - for (const field of asArray(config?.fields)) { + for (const field of recordsOf(config?.fields)) { const name = strName(field.name); if (name) fields.add(name); } @@ -1224,7 +1215,7 @@ function checkActionParams( if (rawParams.length === 0) return; const declared = new Set(); - for (const param of asArray(ctx.action.params)) { + for (const param of recordsOf(ctx.action.params)) { const name = strName(param.name) ?? strName(param.field); if (name) declared.add(name); }