diff --git a/.changeset/liveness-lint-null-collection-item-guard.md b/.changeset/liveness-lint-null-collection-item-guard.md new file mode 100644 index 0000000000..4998eb6888 --- /dev/null +++ b/.changeset/liveness-lint-null-collection-item-guard.md @@ -0,0 +1,14 @@ +--- +"@objectstack/lint": patch +--- + +`lintLivenessProperties` now honours its own docblock contract ("Advisory only +— returns findings, never throws") when a collection item is `null` or +otherwise not an object. The object walk, the field walk nested under it, and +the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow, +action, agent, tool, …) each read `item.name`/`item.object` straight off every +element with no record guard, throwing `TypeError: Cannot read properties of +null (reading 'name')` on a malformed item instead of skipping it — reachable +via the exported `stack: AnyRec` signature on an unparsed or hand-built stack. +The translation bundle walk already guarded its two levels (#11383); this +closes the same hole on the three walks that did not (#11385). diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 9ca5e50905..40d8a2820e 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -755,6 +755,51 @@ describe('lintLivenessProperties', () => { expect(findings.map((f) => f.where)).toEqual(["translation bundle #2 · locale 'en'"]); }); }); + + // ── #11385: the "never throws" contract also covers object/field items and + // every flat TYPE_COLLECTIONS entry ───────────────────────────────────────── + // + // The translation bundle walk above guards its two levels (#11383, + // `isRecord(bundle)` / `isRecord(data)`). Three other walks read + // `item.name` (or `item.object`) straight off every collection element with + // no such guard: the object walk, the field walk nested under it, and the + // flat loop that covers every OTHER governed type in TYPE_COLLECTIONS + // (flow/action/agent/tool/…). A `null` element — a malformed hand-built or + // unparsed stack, which the exported `stack: AnyRec` signature permits — + // threw `TypeError: Cannot read properties of null (reading 'name')` + // instead of being skipped, breaking the docblock's own "Advisory only — + // returns findings, never throws" promise. Each case below pairs the + // malformed element with a well-formed one carrying a REAL still-`authorWarn` + // ledger row, so the assertion proves two things at once: no throw, and the + // walk kept going past the bad element instead of aborting silently. + describe('never throws on a malformed collection item (#11385)', () => { + it('flat TYPE_COLLECTIONS loop: skips a null item and keeps walking past it', () => { + const findings = lintLivenessProperties({ + // agent.memory is a real, currently-`experimental` ledger row (see + // "warns on an experimental prop" above) — a real ledger witness, + // not a synthetic one. + agents: [null, { name: 'ag1', memory: { kind: 'buffer' } }], + }); + expect(paths(findings).some((m) => m.includes('`memory`'))).toBe(true); + }); + + it('object walk: skips a null item and keeps walking past it', () => { + const findings = lintLivenessProperties({ + objects: [null, { name: 'widget', externalSharingModel: 'read' }], + }); + expect(paths(findings).some((m) => m.includes('externalSharingModel'))).toBe(true); + }); + + it('field walk: skips a null item (nested under a well-formed object) and keeps walking past it', () => { + const findings = lintLivenessProperties({ + objects: [{ + name: 'widget', + fields: [null, { name: 'related_orders', type: 'text', relatedListFilter: { field: 'account_id' } }], + }], + }); + expect(paths(findings).some((m) => m.includes('relatedListFilter'))).toBe(true); + }); + }); }); // ── #10262: the array fan-out, tested at the WALKER's own level ────────────── diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index 44061e1f97..cb80eee1e7 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -355,10 +355,14 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { const objectWarn = loadWarnMap(dir, 'object'); const fieldWarn = loadWarnMap(dir, 'field'); for (const obj of asArray(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)) { + if (!isRecord(field)) continue; const fieldName = typeof field.name === 'string' ? field.name : '(unnamed field)'; checkItem('field', field, `object '${objName}' · field '${fieldName}'`, fieldWarn, findings); } @@ -402,6 +406,8 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { const warnMap = loadWarnMap(dir, type); if (warnMap.size === 0) continue; for (const item of asArray(stack[key])) { + // Malformed collection item — "never throws" contract (#11385). + if (!isRecord(item)) continue; // view containers bind via `object`, not `name` const name = typeof item.name === 'string' ? item.name : typeof item.object === 'string' ? item.object