Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/lint-validate-expressions-non-record-field-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/lint": patch
---

`validateStackExpressions` no longer throws on a non-record entry in an object's `fields:` list.

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.

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.

The map shape (`fields: { amount: { … } }`) is unchanged: there the author's key is the field name, which is what this index needs.
28 changes: 14 additions & 14 deletions packages/lint/src/non-record-object-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,22 +340,22 @@ const SWEPT_COLLECTIONS: readonly SweptCollection[] = [
* "nothing throws" would have had to be deleted or weakened on the day it was
* written, and would then never have caught the next one.
*
* Every entry names a reader OUTSIDE what #15636 could touch:
* It is EMPTY today, and that is a measurement, not an aspiration: no rule in
* the table throws on a non-record member of any collection swept here. Two
* rows have come out since it was written, each because the sweep went red
* demanding a throw that no longer happens — which is the both-directions half
* earning its keep, since neither removal started with anyone going looking:
*
* - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts:137`,
* which casts inline instead of through a helper, so the `asArray` sweeps
* that produced #15552 and #15636 never saw it. Filed as #15742.
*
* `stack.datasets` was here too, for `indexDatasets` in
* `validate-chart-bindings.ts`. #15741 re-pointed that reader and these
* assertions went red demanding a throw that no longer happens, which is the
* both-directions half earning its keep: the rows came out because the sweep
* failed, not because anyone went looking for them.
* - `stack.datasets` — `indexDatasets` in `validate-chart-bindings.ts`,
* re-pointed by #15741.
* - `objects[].fields` — `buildFieldIndex` in `validate-expressions.ts`, which
* cast each member inline instead of reading through a helper, so the
* `asArray` greps that produced #15552 and #15636 never saw it. It now reads
* the list through `recordsOf` (#15742), which drops a non-record member of
* the array shape whole and in silence, exactly as the file's two sibling
* field readers already did.
*/
const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {
'objects[].fields · null': ['validateStackExpressions'],
'objects[].fields · undefined': ['validateStackExpressions'],
};
const RESIDUAL_THROWS: Readonly<Record<string, readonly string[]>> = {};

/**
* Where a junk member still draws a finding no author's file justifies — the
Expand Down
39 changes: 39 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3920,3 +3920,42 @@ describe('structural condition shape (#15662)', () => {
});
});
});

describe("validateStackExpressions — a non-record entry in an object's `fields:` list (#15742)", () => {
// `buildFieldIndex` used to cast each member inline
// (`fields.map(f => (f as AnyRec).name)`), so an empty YAML list item —
// which deserialises to `null` — threw `Cannot read properties of null`
// out of the whole rule. It reads the list through `recordsOf` now, which
// is the one place that decision is made: an array member that is not a
// record carries no author-written name, so it is dropped WHOLE and in
// silence, the same disposition the file's two sibling field readers
// (`buildFieldTypeIndex`, `fieldEntries`) already had. The sweep in
// `non-record-object-entry.test.ts` pins the absence of the throw across
// every rule; these two arms pin what this rule does INSTEAD, which a
// crash-only sweep cannot say.
const stackWith = (fields: unknown[], condition: string): Record<string, unknown> => ({
objects: [{ name: 'crm_account', fields }],
flows: [{
name: 'account_flow',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'crm_account' } },
{ id: 'check', type: 'decision', config: { condition } },
],
edges: [],
}],
});

it('is dropped in silence rather than thrown on, and invents no finding', () => {
expect(validateStackExpressions(stackWith([null, { name: 'amount', type: 'number' }], 'record.amount > 0'))).toHaveLength(0);
expect(validateStackExpressions(stackWith([undefined, { name: 'amount', type: 'number' }], 'record.amount > 0'))).toHaveLength(0);
});

it('still indexes the readable siblings — the junk member does not blank the index', () => {
// The failure mode a bare `try/catch` repair would have produced: no
// crash, and no field knowledge either, so every unknown-field finding
// on the object silently stops being reported.
const issues = validateStackExpressions(stackWith([null, { name: 'amount', type: 'number' }], 'record.amont > 0'));
expect(issues).toHaveLength(1);
expect(issues[0].message).toContain('amount');
});
});
12 changes: 11 additions & 1 deletion packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,17 @@ function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
if (!name) continue;
const fields = obj.fields;
let names: string[] = [];
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
// The LIST shape is read through `recordsOf` (#15742). `Array.isArray`
// proves the list, never its members: an empty item in a YAML `fields:`
// list deserialises to `null`, and the cast this replaced dereferenced it
// before the `.filter` two calls later could drop it. The two sibling
// readers below already guard (`buildFieldTypeIndex` reads `(f)?.name`,
// `fieldEntries` filters before mapping) and both drop such a member in
// SILENCE — it carries no author-written name, so there is nothing to
// report about it — which is what `recordsOf` does for the array shape too.
// The MAP shape keeps `Object.keys`: there the author's KEY is the field
// name, which is exactly what this "did you mean?" index needs.
if (Array.isArray(fields)) names = recordsOf(fields).map(f => f.name).filter((n): n is string => typeof n === 'string');
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
// Injected columns come second, de-duplicated by insertion order: a DECLARED
// `owner_id` is the author's field (the registry lets it win), so the
Expand Down
Loading