Skip to content

Commit e2bb237

Browse files
claude[bot]claude
andauthored
feat(lint): ask the #8116 provenance question on the SORT axis (#10474) (#10745)
* feat(lint): ask the #8116 provenance question on the SORT axis (#10474) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * test(lint): keep the new SORT provenance cases inside the TEST_DEBT ratchet The tuple cast moved packages/lint's TEST_DEBT count 19 -> 20 (TS2352). The package tsconfig excludes *.test.ts, so `pnpm typecheck` could not see it — only the check:type-check-coverage --re-measure program can. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 243218a commit e2bb237

5 files changed

Lines changed: 438 additions & 12 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
The SORT axis now asks the #8116 provenance question about a name the blanket
6+
`SYSTEM_FIELDS` union told it not to flag — new rule `sort-field-unprovisioned`
7+
(#10474), the twin of `searchable-field-unprovisioned` on the identical index
8+
(#8404).
9+
10+
`validate-sortable-fields` consulted the union and stopped there, so a list view
11+
ordering by a registry-injected anchor on an ADR-0015 `external` object was
12+
skipped in silence. The #8999 consumer census recorded that gap with the reason
13+
that such an object never reaches the union branch at all — skip (2) was believed
14+
to catch it. **That reason was measured wrong.** `declaredFieldTarget` returns
15+
`null` on exactly one condition (`fields` missing, unreadable, or naming
16+
nothing) and nothing in it tests `external`, so the shipped shape — a federated
17+
object that declares a mapped field map, as `examples/app-showcase`'s
18+
`showcase_ext_customer` does — is indexed like any other object and lands
19+
squarely in the skip. The census ledger entry now carries the correction rather
20+
than the inherited reason.
21+
22+
Why the authoring gate is the only door available for it: both runtime doors on
23+
this axis judge `formula` alone (`UNMATERIALIZED_SORT_TYPES`) — the REST ingress
24+
`assertSortFieldsExist` (#6994) and the engine's `assertOrderByIsMaterializable`
25+
(#7095). An injected anchor is a `datetime` or `lookup`, it *is* in `gate.known`
26+
because the registry injected it into the served schema, and it is undotted, so
27+
it clears every verdict and reaches the driver. Measured with a real `SqlDriver`
28+
over better-sqlite3, the object declared exactly as the showcase declares it,
29+
against a remote `customers` table carrying `[id, name, email, region,
30+
lifetime_value]` and none of the seven injected anchors:
31+
32+
```
33+
orderBy name asc -> [c1,c2,c3] desc -> [c3,c2,c1] (a real column: reverses)
34+
orderBy created_at asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error
35+
orderBy owner_id asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error
36+
```
37+
38+
`asc` and `desc` byte-identical while the baseline reverses is what makes it a
39+
dropped sort rather than a coincidence — the same signature this rule already
40+
records for `formula`, reached by a second route, except that a formula sort is
41+
refused at both doors and this one is not. A list view ordered by an anchor with
42+
no storage answers `200` with the rows in the driver's arbitrary order, on the
43+
view's first fetch and every fetch after it, which `limit`/`offset` then slice
44+
into an arbitrary page.
45+
46+
`warning`, never `error` and never gating (#4330's cost asymmetry, the call every
47+
sibling makes): the remote schema is invisible to this pass, so the remote table
48+
may genuinely carry a `created_at` of its own. Declaring that column — the first
49+
remedy the shared hint prescribes — silences the finding, because
50+
`unprovisionedInjectedColumnsFor` excludes an author-declared column of the same
51+
name (#7859's security direction). The runtime publish gate sorts on severity, so
52+
this lands as an advisory and refuses no write.
53+
54+
Two deliberate narrowings, both pinned:
55+
56+
- **Undotted names only** — the one place this axis departs from the SEARCH twin.
57+
`resolveSearchFields` matches by exact string and drops a dotted entry like a
58+
typo, but a dotted SORT name is refused by the ingress gate as its own verdict
59+
(`400 INVALID_SORT`, loudly, on every fetch), so the silent degradation this
60+
finding reports cannot happen there. Answering would give the SORT axis its own
61+
dotted verdict, which is exactly the posture the rule shares with the FILTER
62+
and PROJECTION axes (#4256 / #7532 / #7589) and declines to break.
63+
- **`checkSortDeclaration`'s new anchor-index parameter is optional**, with the
64+
same meaning `checkSearchableFieldList`'s carries: an out-of-repo caller that
65+
never built the index keeps its pre-#10474 answers. Every in-repo caller passes
66+
it.
67+
68+
Also re-ruled, with fresh eyes and on evidence rather than inheritance:
69+
`validate-translation-references` still correctly asks nothing. It reads the
70+
union at exactly one site (the `fields.<name>` orphan test), and the key it
71+
decides about is derived from the *registered* metadata, into which the registry
72+
injects the anchor on a federated object just as on a local one — so the key
73+
resolves and the label renders. Warning there would flag a translation that
74+
works. The blank-column consequence belongs to the surface that renders the
75+
anchor (`validate-page-field-bindings`, #8340), not to the bundle that names it.

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ export {
383383
checkSortDeclaration,
384384
SORT_FIELD_UNKNOWN,
385385
SORT_FIELD_UNSORTABLE,
386+
SORT_FIELD_UNPROVISIONED,
386387
} from './validate-sortable-fields.js';
387388
export type {
388389
SortableFieldFinding,

packages/lint/src/system-fields-consumers.test.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,13 +422,17 @@ const LEDGER: Record<string, LedgerRow> = {
422422
'validate-sortable-fields.ts': {
423423
kind: 'rule',
424424
reach: ['direct'],
425-
asksProvenance: false,
425+
asksProvenance: true,
426426
why:
427427
'Landed 2026-08-17 (#9314), after the #8996 sweep — it is the arrival that released this card\'s hold. ' +
428-
'Recorded as NOT asking: the rule returns before the union branch for any object with no authored field ' +
429-
'map (skip ②), which is where an ADR-0015 external object normally lands, so today there is no path on ' +
430-
'which the warning could fire. Whether an external object that DOES declare a mapped field map should ' +
431-
'get the sort-axis warning is a rule-shape question, not this census\'s to decide — filed separately.',
428+
'Wired by #10474, which also OVERTURNED the reason this row first carried. That reason claimed the rule ' +
429+
'"returns before the union branch for any object with no authored field map (skip ②), which is where an ' +
430+
'ADR-0015 external object normally lands, so today there is no path on which the warning could fire". ' +
431+
'Measured on the shipped shape (examples/app-showcase\'s showcase_ext_customer, an external object that ' +
432+
'DOES declare a mapped field map): declaredFieldTarget returns NON-null for it, so it is indexed like any ' +
433+
'other object and reaches the union branch. The path existed and was shipped; only the warning was ' +
434+
'missing. Recorded here rather than silently corrected because a census whose stated reasons are not ' +
435+
'the reasons the code holds is the failure this ledger exists to prevent.',
432436
},
433437
'validate-translation-references.ts': {
434438
kind: 'rule',
@@ -438,7 +442,14 @@ const LEDGER: Record<string, LedgerRow> = {
438442
'Spreads the union into its own rule-local IMPLICIT_FIELDS, and has done since before #8340 — a spread ' +
439443
'consumer no sweep ever listed. Recorded as NOT asking on purpose: a translation bundle supplies a LABEL ' +
440444
'for a column, and never reads the value, so "this anchor has no storage" says nothing about whether the ' +
441-
'label resolves. The #8116 warning is about predicates and pointers over the value.',
445+
'label resolves. The #8116 warning is about predicates and pointers over the value. ' +
446+
'RE-RULED and UPHELD by #10474 with fresh eyes, on evidence rather than inheritance: the union is read at ' +
447+
'exactly ONE site (the fields.<name> orphan test), the key it decides about is derived from the ' +
448+
'REGISTERED metadata, and the registry injects the anchor into that metadata on an external object just ' +
449+
'as it does on a local one — so the derived key resolves and the label renders. Asking provenance here ' +
450+
'would warn about a translation that works, which is the ADR-0072 D1 false finding the union exists to ' +
451+
'prevent. The blank-column consequence belongs to the surface that RENDERS the anchor ' +
452+
'(validate-page-field-bindings, #8340), not to the bundle that names it.',
442453
},
443454
'validate-widget-bindings.ts': {
444455
kind: 'rule',

packages/lint/src/validate-sortable-fields.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
checkSortDeclaration,
88
SORT_FIELD_UNKNOWN,
99
SORT_FIELD_UNSORTABLE,
10+
SORT_FIELD_UNPROVISIONED,
1011
} from './validate-sortable-fields.js';
1112
import { indexObjectSearchTargets } from './validate-searchable-fields.js';
13+
import { indexUnprovisionedAnchors } from './system-fields.js';
1214

1315
/**
1416
* The object the whole file judges against. It carries one field of each of the
@@ -489,3 +491,199 @@ describe('checkSortDeclaration — the shared core', () => {
489491
).toEqual([]);
490492
});
491493
});
494+
495+
// ── [#10474] PROVENANCE — the SORT twin of #8404's SEARCH wiring ────────────
496+
//
497+
// The census (#8999) recorded this rule as not asking the #8116 provenance
498+
// question, on the reason that an ADR-0015 external object never reaches the
499+
// union branch (skip ② was believed to catch it). That reason was measured
500+
// wrong: `declaredFieldTarget` keys on "declares no field map", never on
501+
// `external`, so the SHIPPED shape — an external object with a mapped field
502+
// map — is indexed like any other and lands in skip ③.
503+
//
504+
// ⚠️ The LOCAL twin is asserted in every case below, and it is the load-bearing
505+
// half. A wiring that warned on `created_at` for EVERY object would satisfy the
506+
// positive direction alone while flagging the single most common list-view
507+
// ordering in the platform's own objects — the ADR-0072 D1 false finding this
508+
// package's whole system-fields indirection exists to prevent. Only the
509+
// negative direction can catch that, so it is asserted every time.
510+
511+
/** The showcase's own federated object: `external` + a mapped field map. */
512+
const externalObject = {
513+
name: 'showcase_ext_customer',
514+
datasource: 'showcase_external',
515+
external: { remoteName: 'customers' },
516+
fields: {
517+
name: { type: 'text', label: 'Name' },
518+
email: { type: 'text', label: 'Email' },
519+
region: { type: 'text', label: 'Region' },
520+
},
521+
};
522+
523+
/** Its local twin — identical in every way EXCEPT `external`. */
524+
const localTwin = {
525+
name: 'showcase_customer',
526+
fields: {
527+
name: { type: 'text', label: 'Name' },
528+
email: { type: 'text', label: 'Email' },
529+
region: { type: 'text', label: 'Region' },
530+
},
531+
};
532+
533+
/** Both objects, each with a list view ordering by the same injected anchor. */
534+
const twinStack = (sort: unknown) => ({
535+
objects: [
536+
{ ...externalObject, listViews: { recent: { type: 'grid', sort } } },
537+
{ ...localTwin, listViews: { recent: { type: 'grid', sort } } },
538+
],
539+
});
540+
541+
describe('validateSortableFields — the provenance verdict (#10474)', () => {
542+
it('warns on a list-view sort ordering by an unprovisioned injected anchor', () => {
543+
const findings = validateSortableFields(twinStack([{ field: 'created_at', order: 'desc' }]));
544+
545+
expect(findings).toHaveLength(1);
546+
const f = findings[0];
547+
expect(f.rule).toBe(SORT_FIELD_UNPROVISIONED);
548+
// WARNING, not error: no runtime door refuses this, and the remote schema
549+
// is invisible to this pass. The runtime publish gate sorts on severity —
550+
// `error` would turn an unprovable suspicion into a refused write.
551+
expect(f.severity).toBe('warning');
552+
expect(f.where).toBe('object "showcase_ext_customer" › listViews.recent');
553+
expect(f.path).toBe('objects[0].listViews.recent.sort[0]');
554+
expect(f.message).toContain('created_at');
555+
// The CAUSE clause is the package-shared sentence, not a re-typed one:
556+
// a rule that re-words it drifts from the runtime guards whose verdict it
557+
// reports (`unprovisionedAnchorCause`).
558+
expect(f.message).toContain('injected system column with NO storage behind it');
559+
expect(f.message).toContain('ADR-0015');
560+
// The SORT-axis consequence, which is this rule's own half of the sentence.
561+
expect(f.message).toContain('ORDER BY');
562+
expect(f.hint).toContain('columnMap');
563+
});
564+
565+
it('THE NEGATIVE DIRECTION: says nothing about the identical sort on the LOCAL twin', () => {
566+
// `objects[1]` is the local twin and carries the identical declaration.
567+
// The single finding above is proof enough only alongside this.
568+
const findings = validateSortableFields(twinStack([{ field: 'created_at', order: 'desc' }]));
569+
expect(findings.map((x) => x.path)).not.toContain('objects[1].listViews.recent.sort[0]');
570+
expect(
571+
validateSortableFields({
572+
objects: [{ ...localTwin, listViews: { recent: { type: 'grid', sort: 'created_at desc' } } }],
573+
}),
574+
).toEqual([]);
575+
});
576+
577+
it('covers every anchor the injection registers, not just the audit family', () => {
578+
// `owner_id` is the one no managed DDL ever creates either, so it is the
579+
// clearest case; asserting the set keeps a narrowing of the derivation
580+
// visible here rather than only in the spec's own test.
581+
for (const anchor of ['created_at', 'created_by', 'updated_at', 'owner_id', 'organization_id']) {
582+
const findings = validateSortableFields(twinStack([{ field: anchor, order: 'asc' }]));
583+
expect(findings.map((x) => x.rule), anchor).toEqual([SORT_FIELD_UNPROVISIONED]);
584+
expect(findings[0].message, anchor).toContain(anchor);
585+
}
586+
});
587+
588+
it('reads the legacy string sort form too, not only the structured array', () => {
589+
const findings = validateSortableFields(twinStack('created_at desc'));
590+
expect(findings).toHaveLength(1);
591+
expect(findings[0].rule).toBe(SORT_FIELD_UNPROVISIONED);
592+
// The string form has no index suffix.
593+
expect(findings[0].path).toBe('objects[0].listViews.recent.sort');
594+
});
595+
596+
it("SECURITY DIRECTION: an author-DECLARED anchor on the federated object is silent", () => {
597+
// #7859's recorded reasoning — a federated object may expose a REAL remote
598+
// `created_at`, which the author vouches for through the binding's
599+
// columnMap. `unprovisionedInjectedColumnsFor` excludes it, so declaring
600+
// the column is the first remedy the shared hint prescribes AND the thing
601+
// that silences the finding.
602+
const declared = {
603+
...externalObject,
604+
fields: { ...externalObject.fields, created_at: { type: 'datetime', label: 'Remote Created' } },
605+
listViews: { recent: { type: 'grid', sort: [{ field: 'created_at', order: 'desc' }] } },
606+
};
607+
expect(validateSortableFields({ objects: [declared] })).toEqual([]);
608+
});
609+
610+
it('respects the injection opt-outs — `systemFields: false` leaves no anchor to warn about', () => {
611+
const optedOut = {
612+
...externalObject,
613+
systemFields: false,
614+
listViews: { recent: { type: 'grid', sort: [{ field: 'created_at', order: 'desc' }] } },
615+
};
616+
expect(validateSortableFields({ objects: [optedOut] })).toEqual([]);
617+
});
618+
619+
it('DOTTED heads are NOT asked — the ingress gate already refuses them loudly', () => {
620+
// The one place this axis departs from the SEARCH twin, deliberately: a
621+
// dotted SORT name is a `400 INVALID_SORT` on every fetch, so the silent
622+
// degradation this finding reports cannot happen there, and answering
623+
// would give the SORT axis its own dotted verdict (the posture the module
624+
// note records as shared with FILTER/PROJECTION).
625+
const findings = validateSortableFields(twinStack([{ field: 'created_at.year', order: 'asc' }]));
626+
expect(findings).toEqual([]);
627+
});
628+
629+
it('is additive: the existence verdict on a real typo still fires beside it', () => {
630+
const findings = validateSortableFields(
631+
twinStack([{ field: 'created_at', order: 'desc' }, { field: 'nope', order: 'asc' }]),
632+
);
633+
const external = findings.filter((x) => x.path.startsWith('objects[0]'));
634+
expect(external.map((x) => x.rule)).toEqual([SORT_FIELD_UNPROVISIONED, SORT_FIELD_UNKNOWN]);
635+
// …and the local twin still gets the typo, and ONLY the typo.
636+
const local = findings.filter((x) => x.path.startsWith('objects[1]'));
637+
expect(local.map((x) => x.rule)).toEqual([SORT_FIELD_UNKNOWN]);
638+
});
639+
640+
it('reaches the `defineView` aggregate and standalone list-view rungs too', () => {
641+
const base = { objects: [externalObject] };
642+
const sort = [{ field: 'created_at', order: 'desc' }];
643+
const rungs: Array<[string, unknown[]]> = [
644+
['aggregate list', [{ name: 'v', objectName: 'showcase_ext_customer', list: { sort } }]],
645+
['aggregate listViews', [{ name: 'v', objectName: 'showcase_ext_customer', listViews: { a: { sort } } }]],
646+
['flattened overlay', [{ name: 'v', object: 'showcase_ext_customer', viewKind: 'list', sort }]],
647+
['ViewItem record', [{ name: 'v', object: 'showcase_ext_customer', viewKind: 'list', config: { sort } }]],
648+
];
649+
for (const [label, views] of rungs) {
650+
const findings = validateSortableFields({ ...base, views });
651+
expect(findings.map((x) => x.rule), label).toEqual([SORT_FIELD_UNPROVISIONED]);
652+
}
653+
});
654+
});
655+
656+
describe('checkSortDeclaration — the provenance parameter is OPTIONAL (#10474)', () => {
657+
const stack = { objects: [externalObject] };
658+
659+
it('asks nothing when the caller does not build the index (pre-#10474 behaviour)', () => {
660+
// The exported core is public surface; an out-of-repo caller that never
661+
// built the index must keep the answers it had.
662+
expect(
663+
checkSortDeclaration(
664+
[{ field: 'created_at', order: 'desc' }],
665+
'showcase_ext_customer',
666+
indexObjectSearchTargets(stack),
667+
'page "customers"',
668+
'pages[0].sort',
669+
'page sort',
670+
),
671+
).toEqual([]);
672+
});
673+
674+
it('asks once the caller passes it', () => {
675+
const findings = checkSortDeclaration(
676+
[{ field: 'created_at', order: 'desc' }],
677+
'showcase_ext_customer',
678+
indexObjectSearchTargets(stack),
679+
'page "customers"',
680+
'pages[0].sort',
681+
'page sort',
682+
indexUnprovisionedAnchors(stack),
683+
);
684+
expect(findings).toHaveLength(1);
685+
expect(findings[0].rule).toBe(SORT_FIELD_UNPROVISIONED);
686+
expect(findings[0].where).toBe('page "customers"');
687+
expect(findings[0].message).toContain('page sort');
688+
});
689+
});

0 commit comments

Comments
 (0)