Skip to content

Commit 2aca1bc

Browse files
Jack Qclaude
andauthored
test(lint): pin getNested's array fan-out to a synthetic warn map (#10262) (#10399)
The fan-out — a dotted warn-map path resolved over an ARRAY container level must visit EVERY element, not just index 0 — is reachable only from a DOTTED warned entry, because `checkItem` takes the `path.includes('.') ? getNested(item, path) : [item[path]]` branch. Its subject was therefore always a ledger VERDICT, and verdicts move: twice a row correctly flipping to `live` deleted the only test of the walk (#6774#7079, then #10068#10262). Measured across all 30 shipped ledgers, every remaining warned entry is top-level, so there is nothing left to re-subject to. Adds a package-internal test seam — `getNested` and `checkItemAgainstWarnMap` exported from the MODULE only, neither re-exported by `src/index.ts` nor reachable through the package's `exports` map — and ten assertions driving it with a synthetic warn map, including an `it.each` that authors the warned key on exactly one entry of a four-entry container so a walk stopping at index 0 fails three of four. Every other assertion in the file stays ledger-driven, and the #10068 silence pin plus its anti-vacuity guard are byte-identical (the only line removed from the test file is the import). Claude-Session: https://claude.ai/code/session_016gcKVsiywU9CcS96S5t9qD Co-authored-by: Claude <noreply@anthropic.com>
1 parent e502a6a commit 2aca1bc

2 files changed

Lines changed: 162 additions & 3 deletions

File tree

packages/lint/src/lint-liveness-properties.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { lintLivenessProperties } from './lint-liveness-properties.js';
4+
import {
5+
lintLivenessProperties,
6+
// #10262 test seam — package-internal (not re-exported by `src/index.ts`, not
7+
// in the package's `exports` map). See the block below `getNested` in the
8+
// source for why this ONE property is tested off the ledger.
9+
checkItemAgainstWarnMap,
10+
getNested,
11+
} from './lint-liveness-properties.js';
512

613
/**
714
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
@@ -645,3 +652,107 @@ describe('lintLivenessProperties', () => {
645652
});
646653
});
647654
});
655+
656+
// ── #10262: the array fan-out, tested at the WALKER's own level ──────────────
657+
//
658+
// Everything above this line is deliberately ledger-driven: it asserts against
659+
// the REAL ledgers shipped by `@objectstack/spec`, which is what makes those
660+
// assertions contract tests. This block is the one exception, and the reason is
661+
// recorded twice over in the comments above.
662+
//
663+
// `getNested`'s array fan-out — a dotted warn-map path resolved over an ARRAY
664+
// container level must visit EVERY element, not just index 0 — is reachable
665+
// only from a DOTTED warned entry, because `checkItem` takes the
666+
// `path.includes('.') ? getNested(item, path) : [item[path]]` branch. Its
667+
// subject was therefore always "whichever row happens to carry `authorWarn`
668+
// under an array container today", and that is a ledger verdict: verdicts move.
669+
// Twice a row correctly flipping to `live` deleted this coverage —
670+
// `dashboard.widgets.colorVariant` (#6774, filed as #7079) and then
671+
// `app.…navigation.children.runAction` (#10068, filed as #10262) — and as of
672+
// #10262 every warned entry in all 30 shipped ledgers is top-level, so there is
673+
// nothing left to re-subject to and no reason to expect a third subject to last.
674+
//
675+
// So this block drives the walker with a SYNTHETIC warn map through the
676+
// package-internal seam (`checkItemAgainstWarnMap`, `getNested` — module
677+
// exports, not re-exported by `src/index.ts`, not in the package's `exports`
678+
// map). No ledger flip can empty it. The cost is honest and bounded: these
679+
// assertions say nothing about which properties the ledger warns on — that
680+
// stays the job of every other block in this file.
681+
describe('the array fan-out, against a synthetic warn map (#10262)', () => {
682+
const warnOn = (...paths: string[]) =>
683+
new Map(paths.map((p) => [p, { authorWarn: true, authorHint: 'synthetic (#10262)' }] as const));
684+
685+
/** `n` navigation entries; those at `authored` set the warned key. */
686+
const navItems = (n: number, authored: number[]) =>
687+
Array.from({ length: n }, (_, i) => ({
688+
id: `nav_${i}`,
689+
type: 'object',
690+
objectName: 'crm_lead',
691+
...(authored.includes(i) ? { runAction: `create_${i}` } : {}),
692+
}));
693+
694+
describe('getNested', () => {
695+
it('resolves one value per element of an array container, in order', () => {
696+
expect(getNested({ navigation: navItems(3, [0, 1, 2]) }, 'navigation.runAction'))
697+
.toEqual(['create_0', 'create_1', 'create_2']);
698+
});
699+
700+
// The load-bearing shape: a walk that stopped at index 0 returns
701+
// `[undefined]` here — one entry, not three — while every fixture that
702+
// authors the key on the FIRST element keeps passing. That asymmetry is
703+
// exactly why a positive assertion on a single-entry fixture is not a test
704+
// of the fan-out (#7079's original reasoning).
705+
it('visits elements that do NOT set the key rather than filtering them out', () => {
706+
expect(getNested({ navigation: navItems(3, [2]) }, 'navigation.runAction'))
707+
.toEqual([undefined, undefined, 'create_2']);
708+
});
709+
710+
it('flattens a trailing array container one step (`nodes.tags` → every tag)', () => {
711+
expect(getNested({ nodes: [{ tags: ['a', 'b'] }, { tags: ['c'] }] }, 'nodes.tags'))
712+
.toEqual(['a', 'b', 'c']);
713+
});
714+
715+
it('treats a missing parent level as absent instead of throwing', () => {
716+
expect(getNested({}, 'navigation.runAction')).toEqual([]);
717+
expect(getNested({ navigation: null }, 'navigation.runAction')).toEqual([]);
718+
});
719+
});
720+
721+
describe('checkItem via the dotted branch', () => {
722+
// The anti-index-0 assertion, restored as a property of the walker: the
723+
// warned key is authored on exactly ONE entry of a four-entry container,
724+
// and the walk must find it wherever that entry sits. A `getNested` that
725+
// stopped at index 0 passes case 0 and fails 1, 2 and 3.
726+
it.each([0, 1, 2, 3])('finds a warned key authored on navigation[%i] alone', (index) => {
727+
const findings = checkItemAgainstWarnMap(
728+
'app',
729+
{ name: 'crm_app', navigation: navItems(4, [index]) },
730+
"app 'crm_app'",
731+
warnOn('navigation.runAction'),
732+
);
733+
expect(findings).toHaveLength(1);
734+
expect(findings[0].message).toContain('navigation.runAction');
735+
expect(findings[0].where).toBe("app 'crm_app'");
736+
});
737+
738+
it('reports once per (item, path) however many entries author the key', () => {
739+
const findings = checkItemAgainstWarnMap(
740+
'app',
741+
{ name: 'crm_app', navigation: navItems(4, [0, 1, 2, 3]) },
742+
"app 'crm_app'",
743+
warnOn('navigation.runAction'),
744+
);
745+
expect(findings).toHaveLength(1);
746+
});
747+
748+
it('stays silent when no entry authors the warned key', () => {
749+
const findings = checkItemAgainstWarnMap(
750+
'app',
751+
{ name: 'crm_app', navigation: navItems(4, []) },
752+
"app 'crm_app'",
753+
warnOn('navigation.runAction'),
754+
);
755+
expect(findings).toEqual([]);
756+
});
757+
});
758+
});

packages/lint/src/lint-liveness-properties.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const LIVENESS_EXPERIMENTAL_PROPERTY = 'liveness-experimental-property';
3636

3737
type AnyRec = Record<string, unknown>;
3838

39-
interface LedgerEntry {
39+
export interface LedgerEntry {
4040
status?: string;
4141
authorWarn?: boolean;
4242
authorHint?: string;
@@ -142,8 +142,11 @@ function checkItem(
142142
* absent. A container level that is an ARRAY fans out over its elements
143143
* (e.g. `nodes.outputSchema` on a flow checks every node), returning the
144144
* list of resolved values.
145+
*
146+
* Exported as a test seam — see the block below `getNested` for why this one
147+
* property cannot stay ledger-driven.
145148
*/
146-
function getNested(obj: AnyRec, path: string): unknown[] {
149+
export function getNested(obj: AnyRec, path: string): unknown[] {
147150
let cur: unknown[] = [obj];
148151
for (const seg of path.split('.')) {
149152
const next: unknown[] = [];
@@ -165,6 +168,51 @@ function getNested(obj: AnyRec, path: string): unknown[] {
165168
return cur.flatMap((v) => (Array.isArray(v) ? v : [v]));
166169
}
167170

171+
/**
172+
* ── Test seam (#10262). Package-internal: NOT part of the published surface ──
173+
*
174+
* `getNested` above and this wrapper are exported for
175+
* `lint-liveness-properties.test.ts` to drive the array fan-out against a
176+
* SYNTHETIC warn map. They are exported from the MODULE only — neither is
177+
* re-exported by `src/index.ts`, and this package's `exports` map publishes
178+
* exactly two subpaths (`.` → `dist/index.js`, `./runtime` → `dist/runtime.js`,
179+
* both bundled by tsup from those two entries). So no consumer can reach either
180+
* symbol and the built `.d.ts` surface is unchanged; the test reaches them the
181+
* way every other test in this package reaches its subject, by importing
182+
* `./lint-liveness-properties.js` directly.
183+
*
184+
* WHY the fan-out needs a seam when everything else in this file is (rightly)
185+
* ledger-driven: its subject is a ledger VERDICT, and verdicts are supposed to
186+
* move. A dotted warn-map path is the only thing that reaches `getNested` at
187+
* all — `checkItem` takes the `path.includes('.') ? getNested(item, path) :
188+
* [item[path]]` branch — and twice now a row correctly flipping to `live`
189+
* deleted the only test of the walk:
190+
*
191+
* - #6774 flipped `dashboard.widgets.colorVariant` live → subject lost, filed
192+
* as #7079;
193+
* - #7079 was closed by re-subjecting to `app.…navigation.children.runAction`;
194+
* - #10068 flipped THAT live → subject lost again, and measured across all 30
195+
* shipped ledgers every remaining warned entry is top-level, so there is
196+
* nothing left to re-subject to. Filed as #10262 (this seam).
197+
*
198+
* A broken walk is invisible without it: a `getNested` that stopped at index 0
199+
* "still warns on every single-entry fixture, on every top-level warned key,
200+
* and on the first item of every real app", so nothing else in this file would
201+
* go red. The seam moves ONLY that one property to the walker's own level;
202+
* every other assertion in the test file stays a real contract test against the
203+
* shipped ledgers, including the #10068 silence pin and its anti-vacuity guard.
204+
*/
205+
export function checkItemAgainstWarnMap(
206+
type: string,
207+
item: AnyRec,
208+
whereBase: string,
209+
warnMap: Iterable<readonly [string, LedgerEntry]>,
210+
): LivenessLintFinding[] {
211+
const findings: LivenessLintFinding[] = [];
212+
checkItem(type, item, whereBase, new Map(warnMap), findings);
213+
return findings;
214+
}
215+
168216
/**
169217
* The compiled-stack collection each governed metadata type lives in.
170218
* `object`/`field` keep their bespoke walk (fields nest under objects);

0 commit comments

Comments
 (0)