Skip to content

Commit d77c15a

Browse files
committed
fix(lint): judge an injected column on the same axis as an authored one in the dotted-filter and include rules (#16340)
Two more consumers of a resolved leaf's `meta` inherit the registry type the object graph now carries, which is why the fix went to the seam rather than into `filter-preset-comparand`. `list-view-field-dotted` refuses `created_at.x` (a `datetime` scalar has nothing beneath it) and `owner_id.name` (a `lookup` stores an id, not an embedded document) — both already refused at the door by `assertFilterIsMaterializable` with the registry's field map in hand, so the linter's silence was the miss. `dataset-include-unknown` drops its `verdict.injected` bail, whose stated reason ("its type is registry-owned and invisible here") this change makes false: `include: ['owner_id']` joins, `include: ['created_at']` derives no join and is refused. `id` falls through the untyped branch of all three rules — the driver provisions the primary key and no definition table describes it. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7bc5209 commit d77c15a

5 files changed

Lines changed: 93 additions & 15 deletions

.changeset/lint-injected-temporal-column-types.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,13 @@ The engine already refused all three of those at query time (`INVALID_FILTER` /
2929

3030
**Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet<string>` to `ReadonlyMap<string, GraphField>`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention.
3131

32+
## Two more rules inherit it, in the same edit
33+
34+
The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`:
35+
36+
- **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here.
37+
- **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing).
38+
39+
`id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there.
40+
3241
A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure.

packages/lint/src/validate-dataset-references.test.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,36 @@ describe('validateDatasetReferences — include[] must name a RELATIONSHIP', ()
245245
expect(rules(stackWith({ include: ['duty'], dimensions: [], measures: [] }))).toEqual([]);
246246
});
247247

248+
// [#16340] An injected column is judged on the same axis as an authored one:
249+
// the graph carries the registry's own definition for it. `owner_id` IS the
250+
// `lookup` the registry declares, so an include naming it joins; `created_at`
251+
// is a `datetime`, so an include naming it derives no join and every
252+
// dimension written against that prefix addresses nothing — which is the
253+
// finding, not a guess. Before this the rule bailed on the marker and said
254+
// neither thing.
255+
it('accepts an include naming an INJECTED relationship anchor', () => {
256+
expect(rules(stackWith({ include: ['owner_id'], dimensions: [], measures: [] }))).toEqual([]);
257+
});
258+
259+
it('refuses an include naming an injected column that is not a relationship', () => {
260+
const findings = validateDatasetReferences(
261+
stackWith({ include: ['created_at'], dimensions: [], measures: [] }),
262+
);
263+
expect(findings).toHaveLength(1);
264+
expect(findings[0].rule).toBe(DATASET_INCLUDE_UNKNOWN);
265+
expect(findings[0].message).toContain('`datetime` field');
266+
expect(findings[0].message).toContain('not a relationship');
267+
});
268+
269+
it('⛔ leaves the primary key untyped — the driver provisions it, no table describes it', () => {
270+
const findings = validateDatasetReferences(
271+
stackWith({ include: ['id'], dimensions: [], measures: [] }),
272+
);
273+
expect(findings).toHaveLength(1);
274+
// The untyped branch: "an ordinary field", not a guessed type.
275+
expect(findings[0].message).toContain('an ordinary field');
276+
});
277+
248278
it('refuses a multi-hop include whose intermediate hop is not traversable', () => {
249279
const findings = validateDatasetReferences(
250280
stackWith({ include: ['status.owner'], dimensions: [], measures: [] }),
@@ -371,12 +401,15 @@ describe('validateDatasetReferences — the three skips', () => {
371401
).toEqual([]);
372402
});
373403

374-
it('skips a hop THROUGH an injected column, whose target is registry-owned', () => {
404+
it('skips a hop THROUGH an injected column, whose target the seam does not traverse', () => {
375405
// `owner_id` IS injected on this object (`ownership` omitted ⇒ both anchors)
376-
// and IS a lookup at the registry — but its type and target are invisible
377-
// here, so `owner_id.name` is unanswerable rather than a miss. Reporting it
378-
// would be the false positive skip 3 exists to avoid; assuming it resolves
379-
// would be the fail-open on the other side.
406+
// and IS a lookup at the registry. [#16340] The slice now carries that
407+
// target, but `resolveFieldPath` still answers `injected-hop` rather than
408+
// walking it: traversing would newly judge every path through a platform
409+
// anchor wherever `sys_user` is compiled in, which is a widening with its
410+
// own findings to measure. Reporting the hop would be the false positive
411+
// skip 3 exists to avoid; assuming it resolves would be the fail-open on
412+
// the other side.
380413
expect(rules(stackWith({ dimensions: [{ name: 'o', field: 'owner_id.name' }], measures: [] }))).toEqual([]);
381414
});
382415

packages/lint/src/validate-dataset-references.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,14 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
214214

215215
if (verdict.kind === 'ok') {
216216
// The entry resolves to a real field — but `include` joins, so the
217-
// field must BE a relationship. An injected column's type is
218-
// registry-owned and invisible here, so it is unanswerable, not a miss.
219-
if (verdict.injected) return;
217+
// field must BE a relationship. [#16340] An injected column is judged
218+
// on the same axis as an authored one: the graph carries the
219+
// registry's own definition, so `owner_id` reads as the `lookup` it is
220+
// and `created_at` as the `datetime` it is. The bail that used to sit
221+
// here ("its type is registry-owned and invisible") is gone with the
222+
// limitation that justified it. The primary key still falls through
223+
// the untyped branch below — the DRIVER provisions it and no
224+
// definition table describes it.
220225
const type = verdict.meta?.type;
221226
if (type && RELATIONSHIP_FIELD_TYPES.has(type)) return;
222227
findings.push({

packages/lint/src/validate-list-view-field-refs.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,32 @@ describe('#14282 — a dotted key the FILTER door refuses, and the ones it serve
427427
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('tags.0'))))).toEqual([]);
428428
});
429429

430-
it('a registry-injected head is NOT refused at a filter — its type is invisible here', () => {
431-
// `created_at` resolves through skip 3 with no readable type, and
432-
// `classifyDottedFilterHead` answers `null` for an unreadable head.
433-
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))))).toEqual([]);
430+
// [#16340] A registry-injected head IS judged now: the graph carries the
431+
// registry's own definition for it, so the classifier reads the same
432+
// `datetime` the DOOR reads. `assertFilterIsMaterializable` has always
433+
// refused `created_at.x` with `400 INVALID_FIELD` — the linter was silent
434+
// only because the type was missing here, which is the miss #16340 closed.
435+
it('a registry-injected scalar head is refused at a filter, as the door refuses it', () => {
436+
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('created_at.x'))));
437+
expect(findings).toHaveLength(1);
438+
expect(findings[0].rule).toBe(LIST_VIEW_FIELD_DOTTED);
439+
expect(findings[0].severity).toBe('error');
440+
expect(findings[0].message).toContain('`datetime` field');
441+
expect(findings[0].message).toContain('single scalar value');
442+
});
443+
444+
it('an injected RELATION head is refused on the same axis as an authored one', () => {
445+
const findings = validateListViewFieldRefs(stackWith(mutate(filterOn('owner_id.name'))));
446+
expect(findings).toHaveLength(1);
447+
expect(findings[0].message).toContain('`lookup` field');
448+
expect(findings[0].message).toContain("stores the related record's id");
449+
});
450+
451+
it('⛔ the primary key is NOT refused — the driver provisions it and no table types it', () => {
452+
// The one injected column with no definition behind it. An unreadable head
453+
// is what `classifyDottedFilterHead` answers `null` for, and the door
454+
// serves it, so the linter must not invent a refusal here.
455+
expect(validateListViewFieldRefs(stackWith(mutate(filterOn('id.x'))))).toEqual([]);
434456
});
435457

436458
it('the tab and user-filter tab presets are judged on the same axis', () => {

packages/lint/src/validate-list-view-field-refs.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,18 @@
204204
* never reported (ADR-0072 D1: one dead finding and authors stop trusting the
205205
* linter): an object this stack does not define, an object that declares no
206206
* readable field map (ADR-0015 `external`, datasource-introspected schemas),
207-
* and a registry-injected system column. A fourth skip is this surface's own:
208-
* a list view whose `data.provider` is not `object` binds to no object graph
209-
* at all, so none of its field names is resolvable here.
207+
* and a hop THROUGH a registry-injected system column. A fourth skip is this
208+
* surface's own: a list view whose `data.provider` is not `object` binds to no
209+
* object graph at all, so none of its field names is resolvable here.
210+
*
211+
* [#16340] An injected column at the HEAD of a dotted filter key is not a skip
212+
* and never was — it resolves. What used to be missing was its TYPE, so
213+
* `classifyDottedFilterHead` read an unreadable head and this rule stayed
214+
* silent on `created_at.x` while `assertFilterIsMaterializable` refused it at
215+
* the door with the registry's own field map in hand. The graph now carries
216+
* the registry's definition for each injected column, so the two answer alike.
217+
* `id` remains unreadable — the DRIVER provisions the primary key and no
218+
* definition table describes it — and the door serves it, so this rule does too.
210219
*/
211220

212221
import { classifyDottedFilterHead } from '@objectstack/spec/data';

0 commit comments

Comments
 (0)