Skip to content
Merged
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
113 changes: 112 additions & 1 deletion packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* These run against the REAL ledgers shipped by `@objectstack/spec` (the same
Expand Down Expand Up @@ -645,3 +652,107 @@ describe('lintLivenessProperties', () => {
});
});
});

// ── #10262: the array fan-out, tested at the WALKER's own level ──────────────
//
// Everything above this line is deliberately ledger-driven: it asserts against
// the REAL ledgers shipped by `@objectstack/spec`, which is what makes those
// assertions contract tests. This block is the one exception, and the reason is
// recorded twice over in the comments above.
//
// `getNested`'s array 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 "whichever row happens to carry `authorWarn`
// under an array container today", and that is a ledger verdict: verdicts move.
// Twice a row correctly flipping to `live` deleted this coverage —
// `dashboard.widgets.colorVariant` (#6774, filed as #7079) and then
// `app.…navigation.children.runAction` (#10068, filed as #10262) — and as of
// #10262 every warned entry in all 30 shipped ledgers is top-level, so there is
// nothing left to re-subject to and no reason to expect a third subject to last.
//
// So this block drives the walker with a SYNTHETIC warn map through the
// package-internal seam (`checkItemAgainstWarnMap`, `getNested` — module
// exports, not re-exported by `src/index.ts`, not in the package's `exports`
// map). No ledger flip can empty it. The cost is honest and bounded: these
// assertions say nothing about which properties the ledger warns on — that
// stays the job of every other block in this file.
describe('the array fan-out, against a synthetic warn map (#10262)', () => {
const warnOn = (...paths: string[]) =>
new Map(paths.map((p) => [p, { authorWarn: true, authorHint: 'synthetic (#10262)' }] as const));

/** `n` navigation entries; those at `authored` set the warned key. */
const navItems = (n: number, authored: number[]) =>
Array.from({ length: n }, (_, i) => ({
id: `nav_${i}`,
type: 'object',
objectName: 'crm_lead',
...(authored.includes(i) ? { runAction: `create_${i}` } : {}),
}));

describe('getNested', () => {
it('resolves one value per element of an array container, in order', () => {
expect(getNested({ navigation: navItems(3, [0, 1, 2]) }, 'navigation.runAction'))
.toEqual(['create_0', 'create_1', 'create_2']);
});

// The load-bearing shape: a walk that stopped at index 0 returns
// `[undefined]` here — one entry, not three — while every fixture that
// authors the key on the FIRST element keeps passing. That asymmetry is
// exactly why a positive assertion on a single-entry fixture is not a test
// of the fan-out (#7079's original reasoning).
it('visits elements that do NOT set the key rather than filtering them out', () => {
expect(getNested({ navigation: navItems(3, [2]) }, 'navigation.runAction'))
.toEqual([undefined, undefined, 'create_2']);
});

it('flattens a trailing array container one step (`nodes.tags` → every tag)', () => {
expect(getNested({ nodes: [{ tags: ['a', 'b'] }, { tags: ['c'] }] }, 'nodes.tags'))
.toEqual(['a', 'b', 'c']);
});

it('treats a missing parent level as absent instead of throwing', () => {
expect(getNested({}, 'navigation.runAction')).toEqual([]);
expect(getNested({ navigation: null }, 'navigation.runAction')).toEqual([]);
});
});

describe('checkItem via the dotted branch', () => {
// The anti-index-0 assertion, restored as a property of the walker: the
// warned key is authored on exactly ONE entry of a four-entry container,
// and the walk must find it wherever that entry sits. A `getNested` that
// stopped at index 0 passes case 0 and fails 1, 2 and 3.
it.each([0, 1, 2, 3])('finds a warned key authored on navigation[%i] alone', (index) => {
const findings = checkItemAgainstWarnMap(
'app',
{ name: 'crm_app', navigation: navItems(4, [index]) },
"app 'crm_app'",
warnOn('navigation.runAction'),
);
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('navigation.runAction');
expect(findings[0].where).toBe("app 'crm_app'");
});

it('reports once per (item, path) however many entries author the key', () => {
const findings = checkItemAgainstWarnMap(
'app',
{ name: 'crm_app', navigation: navItems(4, [0, 1, 2, 3]) },
"app 'crm_app'",
warnOn('navigation.runAction'),
);
expect(findings).toHaveLength(1);
});

it('stays silent when no entry authors the warned key', () => {
const findings = checkItemAgainstWarnMap(
'app',
{ name: 'crm_app', navigation: navItems(4, []) },
"app 'crm_app'",
warnOn('navigation.runAction'),
);
expect(findings).toEqual([]);
});
});
});
52 changes: 50 additions & 2 deletions packages/lint/src/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const LIVENESS_EXPERIMENTAL_PROPERTY = 'liveness-experimental-property';

type AnyRec = Record<string, unknown>;

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

/**
* ── Test seam (#10262). Package-internal: NOT part of the published surface ──
*
* `getNested` above and this wrapper are exported for
* `lint-liveness-properties.test.ts` to drive the array fan-out against a
* SYNTHETIC warn map. They are exported from the MODULE only — neither is
* re-exported by `src/index.ts`, and this package's `exports` map publishes
* exactly two subpaths (`.` → `dist/index.js`, `./runtime` → `dist/runtime.js`,
* both bundled by tsup from those two entries). So no consumer can reach either
* symbol and the built `.d.ts` surface is unchanged; the test reaches them the
* way every other test in this package reaches its subject, by importing
* `./lint-liveness-properties.js` directly.
*
* WHY the fan-out needs a seam when everything else in this file is (rightly)
* ledger-driven: its subject is a ledger VERDICT, and verdicts are supposed to
* move. A dotted warn-map path is the only thing that reaches `getNested` at
* all — `checkItem` takes the `path.includes('.') ? getNested(item, path) :
* [item[path]]` branch — and twice now a row correctly flipping to `live`
* deleted the only test of the walk:
*
* - #6774 flipped `dashboard.widgets.colorVariant` live → subject lost, filed
* as #7079;
* - #7079 was closed by re-subjecting to `app.…navigation.children.runAction`;
* - #10068 flipped THAT live → subject lost again, and measured across all 30
* shipped ledgers every remaining warned entry is top-level, so there is
* nothing left to re-subject to. Filed as #10262 (this seam).
*
* A broken walk is invisible without it: a `getNested` that stopped at index 0
* "still warns on every single-entry fixture, on every top-level warned key,
* and on the first item of every real app", so nothing else in this file would
* go red. The seam moves ONLY that one property to the walker's own level;
* every other assertion in the test file stays a real contract test against the
* shipped ledgers, including the #10068 silence pin and its anti-vacuity guard.
*/
export function checkItemAgainstWarnMap(
type: string,
item: AnyRec,
whereBase: string,
warnMap: Iterable<readonly [string, LedgerEntry]>,
): LivenessLintFinding[] {
const findings: LivenessLintFinding[] = [];
checkItem(type, item, whereBase, new Map(warnMap), findings);
return findings;
}

/**
* The compiled-stack collection each governed metadata type lives in.
* `object`/`field` keep their bespoke walk (fields nest under objects);
Expand Down
Loading