Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/liveness-lint-null-collection-item-guard.md
Original file line numberDiff line numberDiff line change
@@ -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).
45 changes: 45 additions & 0 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ──────────────
Expand Down
6 changes: 6 additions & 0 deletions packages/lint/src/lint-liveness-properties.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
Expand DownExpand Up@@ -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
Expand Down
Loading