Found while implementing #15636; filed as a finding only, not claimed.
validateStackExpressions throws on a non-record entry of a per-object fields: list. It is the same defect #15552 and #15636 closed elsewhere, but neither card could see it: both were scoped by a grep for the hand-copied asArray helper, and this reader has no helper — it casts inline.
Measured
On 1c1421401, sweeping the whole AUTHORING_RULES table (42 rules) over five non-record shapes per collection:
objects[].fields throwers=2 [null=2 undefined=2 a string=0 a number=0 an array=0]
validateCapabilityReferences, validateStackExpressions
#15636 re-points validate-capability-references.ts, leaving validateStackExpressions as the last thrower on that collection — and it is still there after that change lands:
objects[].fields throwers=1 [null=1 undefined=1 ...] validateStackExpressions
Reproduce with lintStackExpressions({ objects: [{ name: 'crm_account', fields: [null, { name: 'amount', type: 'number' }] }] }):
TypeError: Cannot read properties of null (reading 'name')
at packages/lint/src/validate-expressions.ts:137:70
The line
buildFieldIndex, packages/lint/src/validate-expressions.ts:137:
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
Array.isArray proves the LIST; the cast then asserts each MEMBER is a record, which a list out of YAML does not promise — an empty list item deserialises to null, and .name on it throws. The .filter two calls later would have dropped the junk, but it runs after the dereference.
The object level above it is already guarded (buildFieldIndex is fed through recordsOf); only the field level is not. Two sibling readers in the same file get this right and are worth copying rather than reinventing — validate-expressions.ts:235 reads (f as AnyRec)?.name with optional chaining, and fieldEntries at :255 filters before mapping.
Suggested shape
Read the field list through recordsOf from ./object-graph.js, which is where this predicate lives (its docblock argues why an unreadable ARRAY member is dropped whole). That also removes the inline cast, so the module stops re-deciding a settled question.
Worth sweeping the rest of the file in the same pass: :1112 and :1161 cast flow.nodes and graph.nodes the same way, and were not measured here because this sweep drives collections rather than flow graphs.
Context
Generated by Claude Code
Found while implementing #15636; filed as a finding only, not claimed.
validateStackExpressionsthrows on a non-record entry of a per-objectfields:list. It is the same defect #15552 and #15636 closed elsewhere, but neither card could see it: both were scoped by a grep for the hand-copiedasArrayhelper, and this reader has no helper — it casts inline.Measured
On
1c1421401, sweeping the wholeAUTHORING_RULEStable (42 rules) over five non-record shapes per collection:#15636 re-points
validate-capability-references.ts, leavingvalidateStackExpressionsas the last thrower on that collection — and it is still there after that change lands:Reproduce with
lintStackExpressions({ objects: [{ name: 'crm_account', fields: [null, { name: 'amount', type: 'number' }] }] }):The line
buildFieldIndex,packages/lint/src/validate-expressions.ts:137:Array.isArrayproves the LIST; the cast then asserts each MEMBER is a record, which a list out of YAML does not promise — an empty list item deserialises tonull, and.nameon it throws. The.filtertwo calls later would have dropped the junk, but it runs after the dereference.The object level above it is already guarded (
buildFieldIndexis fed throughrecordsOf); only the field level is not. Two sibling readers in the same file get this right and are worth copying rather than reinventing —validate-expressions.ts:235reads(f as AnyRec)?.namewith optional chaining, andfieldEntriesat :255 filters before mapping.Suggested shape
Read the field list through
recordsOffrom./object-graph.js, which is where this predicate lives (its docblock argues why an unreadable ARRAY member is dropped whole). That also removes the inline cast, so the module stops re-deciding a settled question.Worth sweeping the rest of the file in the same pass:
:1112and:1161castflow.nodesandgraph.nodesthe same way, and were not measured here because this sweep drives collections rather than flow graphs.Context
stack.objectsandrecordsOf.recordsOfcoercion #15728 owns the sixteen remaining helper copies.Generated by Claude Code