Skip to content

Commit 434ca2d

Browse files
claude[bot]claude
andauthored
fix(lint): validateStackExpressions reads an object's fields through the guarded reader instead of an inline cast (#15742) (#15791)
* fix(lint): read an object's fields through the guarded reader in buildFieldIndex `buildFieldIndex` in `validate-expressions.ts` cast every member of an object's `fields:` list inline (`fields.map(f => (f as AnyRec).name)`). `Array.isArray` proves the LIST, not its MEMBERS: an empty YAML list item deserialises to `null`, and the dereference threw out of the whole rule before the `.filter` two calls later could drop it. The list is now read through `recordsOf` — the single home of that coercion — which drops a non-record array member whole and in silence, the same disposition the two sibling readers in this file (`buildFieldTypeIndex`, `fieldEntries`) already had. The map shape keeps `Object.keys`: there the author's key IS the field name. The sweep's `RESIDUAL_THROWS` rows for `objects[].fields` come out in the same change — it is exact in both directions, so it now asserts the throw is gone. `RESIDUAL_INVENTED` is unchanged, measured: the repaired reader raises no finding about the dropped member. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * test(lint): keep the #15742 arms in their own top-level describe They were appended inside the `#15137` assignment-value describe, which reads as a claim about that suite rather than about `buildFieldIndex`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eda6a60 commit 434ca2d

4 files changed

Lines changed: 75 additions & 15 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list.
6+
7+
An empty item in a YAML `fields:` list deserialises to `null`, and `buildFieldIndex` cast each member of the list inline (`fields.map(f => (f as AnyRec).name)`) before the `.filter` two calls later could drop it. `Array.isArray` proves the LIST, never its MEMBERS, so linting such a stack failed with `TypeError: Cannot read properties of null (reading 'name')` out of the whole rule instead of reporting anything about the file.
8+
9+
The list is now read through `recordsOf` — the one place that coercion is decided — which drops a non-record member of the array shape whole and in **silence**: it carries no author-written name, so there is nothing to report about it. That matches what the two sibling field readers in the same module (`buildFieldTypeIndex`, `fieldEntries`) already did with the same member, so the three readers now agree. The readable siblings of the junk member are still indexed, so unknown-field findings on that object continue to be reported.
10+
11+
The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs.

packages/lint/src/non-record-object-entry.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -340,22 +340,22 @@ const SWEPT_COLLECTIONS: readonly SweptCollection[] = [
340340
* "nothing throws" would have had to be deleted or weakened on the day it was
341341
* written, and would then never have caught the next one.
342342
*
343-
* Every entry names a reader OUTSIDE what #15636 could touch:
343+
* It is EMPTY today, and that is a measurement, not an aspiration: no rule in
344+
* the table throws on a non-record member of any collection swept here. Two
345+
* rows have come out since it was written, each because the sweep went red
346+
* demanding a throw that no longer happens — which is the both-directions half
347+
* earning its keep, since neither removal started with anyone going looking:
344348
*
345-
* - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts:137`,
346-
* which casts inline instead of through a helper, so the `asArray` sweeps
347-
* that produced #15552 and #15636 never saw it. Filed as #15742.
348-
*
349-
* `stack.datasets` was here too, for `indexDatasets` in
350-
* `validate-chart-bindings.ts`. #15741 re-pointed that reader and these
351-
* assertions went red demanding a throw that no longer happens, which is the
352-
* both-directions half earning its keep: the rows came out because the sweep
353-
* failed, not because anyone went looking for them.
349+
* - `stack.datasets` — `indexDatasets` in `validate-chart-bindings.ts`,
350+
* re-pointed by #15741.
351+
* - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts`, which
352+
* cast each member inline instead of reading through a helper, so the
353+
* `asArray` greps that produced #15552 and #15636 never saw it. It now reads
354+
* the list through `recordsOf` (#15742), which drops a non-record member of
355+
* the array shape whole and in silence, exactly as the file's two sibling
356+
* field readers already did.
354357
*/
355-
const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {
356-
'objects[].fields · null': ['validateStackExpressions'],
357-
'objects[].fields · undefined': ['validateStackExpressions'],
358-
};
358+
const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {};
359359

360360
/**
361361
* Where a junk member still draws a finding no author's file justifies — the

packages/lint/src/validate-expressions.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3931,3 +3931,42 @@ describe('structural condition shape (#15662)', () => {
39313931
});
39323932
});
39333933
});
3934+
3935+
describe("validateStackExpressions — a non-record entry in an object's `fields:` list (#15742)", () => {
3936+
// `buildFieldIndex` used to cast each member inline
3937+
// (`fields.map(f => (f as AnyRec).name)`), so an empty YAML list item —
3938+
// which deserialises to `null` — threw `Cannot read properties of null`
3939+
// out of the whole rule. It reads the list through `recordsOf` now, which
3940+
// is the one place that decision is made: an array member that is not a
3941+
// record carries no author-written name, so it is dropped WHOLE and in
3942+
// silence, the same disposition the file's two sibling field readers
3943+
// (`buildFieldTypeIndex`, `fieldEntries`) already had. The sweep in
3944+
// `non-record-object-entry.test.ts` pins the absence of the throw across
3945+
// every rule; these two arms pin what this rule does INSTEAD, which a
3946+
// crash-only sweep cannot say.
3947+
const stackWith = (fields: unknown[], condition: string): Record<string, unknown> => ({
3948+
objects: [{ name: 'crm_account', fields }],
3949+
flows: [{
3950+
name: 'account_flow',
3951+
nodes: [
3952+
{ id: 'start', type: 'start', config: { objectName: 'crm_account' } },
3953+
{ id: 'check', type: 'decision', config: { condition } },
3954+
],
3955+
edges: [],
3956+
}],
3957+
});
3958+
3959+
it('is dropped in silence rather than thrown on, and invents no finding', () => {
3960+
expect(validateStackExpressions(stackWith([null, { name: 'amount', type: 'number' }], 'record.amount > 0'))).toHaveLength(0);
3961+
expect(validateStackExpressions(stackWith([undefined, { name: 'amount', type: 'number' }], 'record.amount > 0'))).toHaveLength(0);
3962+
});
3963+
3964+
it('still indexes the readable siblings — the junk member does not blank the index', () => {
3965+
// The failure mode a bare `try/catch` repair would have produced: no
3966+
// crash, and no field knowledge either, so every unknown-field finding
3967+
// on the object silently stops being reported.
3968+
const issues = validateStackExpressions(stackWith([null, { name: 'amount', type: 'number' }], 'record.amont > 0'));
3969+
expect(issues).toHaveLength(1);
3970+
expect(issues[0].message).toContain('amount');
3971+
});
3972+
});

packages/lint/src/validate-expressions.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,17 @@ function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
139139
if (!name) continue;
140140
const fields = obj.fields;
141141
let names: string[] = [];
142-
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
142+
// The LIST shape is read through `recordsOf` (#15742). `Array.isArray`
143+
// proves the list, never its members: an empty item in a YAML `fields:`
144+
// list deserialises to `null`, and the cast this replaced dereferenced it
145+
// before the `.filter` two calls later could drop it. The two sibling
146+
// readers below already guard (`buildFieldTypeIndex` reads `(f)?.name`,
147+
// `fieldEntries` filters before mapping) and both drop such a member in
148+
// SILENCE — it carries no author-written name, so there is nothing to
149+
// report about it — which is what `recordsOf` does for the array shape too.
150+
// The MAP shape keeps `Object.keys`: there the author's KEY is the field
151+
// name, which is exactly what this "did you mean?" index needs.
152+
if (Array.isArray(fields)) names = recordsOf(fields).map(f => f.name).filter((n): n is string => typeof n === 'string');
143153
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
144154
// Injected columns come second, de-duplicated by insertion order: a DECLARED
145155
// `owner_id` is the author's field (the registry lets it win), so the

0 commit comments

Comments
 (0)