Skip to content

Commit 20a452e

Browse files
os-warrenclaude
andauthored
fix(lint): lintLivenessProperties skips malformed collection items instead of throwing (#11734)
The docblock's own contract — "Advisory only — returns findings, never throws" — was not held by three walks: the flat TYPE_COLLECTIONS loop, the object walk, and the field walk nested under it. Each read `item.name` (or `item.object`) off every collection element with no record guard, so a null element threw TypeError instead of being skipped. The translation bundle walk already guarded its two levels this way (#11383); this adds the same `isRecord()` guard to the three that did not. Measured before the fix: all three walks throw on a null element, not just the flat loop the card's suggested shape named. Three new regression tests (one per guarded walk) reverse-verified red against the unguarded source. Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee7a016 commit 20a452e

3 files changed

Lines changed: 65 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`lintLivenessProperties` now honours its own docblock contract ("Advisory only
6+
— returns findings, never throws") when a collection item is `null` or
7+
otherwise not an object. The object walk, the field walk nested under it, and
8+
the flat `TYPE_COLLECTIONS` loop that covers every other governed type (flow,
9+
action, agent, tool, …) each read `item.name`/`item.object` straight off every
10+
element with no record guard, throwing `TypeError: Cannot read properties of
11+
null (reading 'name')` on a malformed item instead of skipping it — reachable
12+
via the exported `stack: AnyRec` signature on an unparsed or hand-built stack.
13+
The translation bundle walk already guarded its two levels (#11383); this
14+
closes the same hole on the three walks that did not (#11385).

packages/lint/src/lint-liveness-properties.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,51 @@ describe('lintLivenessProperties', () => {
755755
expect(findings.map((f) => f.where)).toEqual(["translation bundle #2 · locale 'en'"]);
756756
});
757757
});
758+
759+
// ── #11385: the "never throws" contract also covers object/field items and
760+
// every flat TYPE_COLLECTIONS entry ─────────────────────────────────────────
761+
//
762+
// The translation bundle walk above guards its two levels (#11383,
763+
// `isRecord(bundle)` / `isRecord(data)`). Three other walks read
764+
// `item.name` (or `item.object`) straight off every collection element with
765+
// no such guard: the object walk, the field walk nested under it, and the
766+
// flat loop that covers every OTHER governed type in TYPE_COLLECTIONS
767+
// (flow/action/agent/tool/…). A `null` element — a malformed hand-built or
768+
// unparsed stack, which the exported `stack: AnyRec` signature permits —
769+
// threw `TypeError: Cannot read properties of null (reading 'name')`
770+
// instead of being skipped, breaking the docblock's own "Advisory only —
771+
// returns findings, never throws" promise. Each case below pairs the
772+
// malformed element with a well-formed one carrying a REAL still-`authorWarn`
773+
// ledger row, so the assertion proves two things at once: no throw, and the
774+
// walk kept going past the bad element instead of aborting silently.
775+
describe('never throws on a malformed collection item (#11385)', () => {
776+
it('flat TYPE_COLLECTIONS loop: skips a null item and keeps walking past it', () => {
777+
const findings = lintLivenessProperties({
778+
// agent.memory is a real, currently-`experimental` ledger row (see
779+
// "warns on an experimental prop" above) — a real ledger witness,
780+
// not a synthetic one.
781+
agents: [null, { name: 'ag1', memory: { kind: 'buffer' } }],
782+
});
783+
expect(paths(findings).some((m) => m.includes('`memory`'))).toBe(true);
784+
});
785+
786+
it('object walk: skips a null item and keeps walking past it', () => {
787+
const findings = lintLivenessProperties({
788+
objects: [null, { name: 'widget', externalSharingModel: 'read' }],
789+
});
790+
expect(paths(findings).some((m) => m.includes('externalSharingModel'))).toBe(true);
791+
});
792+
793+
it('field walk: skips a null item (nested under a well-formed object) and keeps walking past it', () => {
794+
const findings = lintLivenessProperties({
795+
objects: [{
796+
name: 'widget',
797+
fields: [null, { name: 'related_orders', type: 'text', relatedListFilter: { field: 'account_id' } }],
798+
}],
799+
});
800+
expect(paths(findings).some((m) => m.includes('relatedListFilter'))).toBe(true);
801+
});
802+
});
758803
});
759804

760805
// ── #10262: the array fan-out, tested at the WALKER's own level ──────────────

packages/lint/src/lint-liveness-properties.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,14 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] {
355355
const objectWarn = loadWarnMap(dir, 'object');
356356
const fieldWarn = loadWarnMap(dir, 'field');
357357
for (const obj of asArray(stack.objects)) {
358+
// Malformed collection item — same "never throws" contract as the flat
359+
// TYPE_COLLECTIONS loop and the translation bundle walk below (#11385).
360+
if (!isRecord(obj)) continue;
358361
const objName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
359362
if (objectWarn.size > 0) checkItem('object', obj, `object '${objName}'`, objectWarn, findings);
360363
if (fieldWarn.size > 0) {
361364
for (const field of asArray(obj.fields)) {
365+
if (!isRecord(field)) continue;
362366
const fieldName = typeof field.name === 'string' ? field.name : '(unnamed field)';
363367
checkItem('field', field, `object '${objName}' · field '${fieldName}'`, fieldWarn, findings);
364368
}
@@ -402,6 +406,8 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] {
402406
const warnMap = loadWarnMap(dir, type);
403407
if (warnMap.size === 0) continue;
404408
for (const item of asArray(stack[key])) {
409+
// Malformed collection item — "never throws" contract (#11385).
410+
if (!isRecord(item)) continue;
405411
// view containers bind via `object`, not `name`
406412
const name = typeof item.name === 'string' ? item.name
407413
: typeof item.object === 'string' ? item.object

0 commit comments

Comments
 (0)