Skip to content

Commit 327391c

Browse files
fix(lint): a liveness finding's fix text never comes from the ledger note, for any row class (#21169)
Fixes #21096 Clause-②: no ## What `checkItem` in `packages/lint/src/lint-liveness-properties.ts` now builds the fix text as `entry.authorHint ?? defaultHint`. The row's ledger `note` is never author-facing, for every row class (opted-in `authorWarn`, `experimental`, `dead`, `live-elsewhere`, `planned`). The now-unused `isVerdictTriggered` helper and the comments describing the old split are removed. `describe()` already carries a default hint for all four verdicts a finding can carry (`experimental`, `planned`, `dead`, `live-elsewhere`), so no prose was invented. A `live` row with `authorWarn` still throws the integrity sentinel, unchanged. No ledger row under `packages/spec/liveness/**` is edited: this is the structural direction from triage, not per-row `authorHint` discipline. ## Measured end to end `os lint --json` on `examples/app-showcase` (built from this tree, before and after rebuilding `@objectstack/lint`), the two `liveness-planned-property` findings, `fix` field: - Before, both findings: "[ADR-0090 D11] P1 lands the SPEC SHAPE only (validated at authoring that the external dial is no wider than the internal one; ...) ... tracked on #2696. `planned` + authorWarn per enforce-or-mark ... Re-verified 2026-07-30: ..." (about 700 characters of maintainer prose, one tracker id). - After, both findings: "Keep it — a consumer is being built against this property; it has no runtime effect yet." The two `liveness-dead-property` findings on `showcase_contributor` are unchanged ("Remove it — it is declared in the spec but not consumed at runtime."). Exit code 0 before and after. ## Tests (`lint-liveness-properties.test.ts`) - The real-ledger `object.externalSharingModel` pin now asserts the planned default hint, no `#` followed by digits, and that the row's note text is absent (with an anti-vacuity guard that the row is still opted-in, has no `authorHint` and a note with a tracker id). - New real-ledger pin over every shipped opted-in and `experimental` row: a row with an `authorHint` prints it exactly (control); a row without one prints neither its note nor a tracker id. - New synthetic pin: authorHint wins, otherwise the verdict default, never the note, for `dead`, `live-elsewhere`, `planned` and `experimental`, with and without `authorWarn`. - Two #21092 pins that encoded the old "opted-in rows show the note" behavior are rewritten. Results: `pnpm --filter @objectstack/lint test` 118 files / 5486 tests pass; the liveness file alone 93 pass; `pnpm --filter @objectstack/lint typecheck` green. Ablation (one-line revert to `entry.authorHint ?? entry.note ?? defaultHint`, through `scripts/ablation-replace.mjs`, anchor hit 1 time, blob moved, restore verified byte-identical to HEAD): 6 tests go red (the two real-ledger pins, the two synthetic pins, the live-elsewhere precedence pin, the dead RLS end-to-end pin); 87 stay green. Restored; `git diff HEAD` empty. ## Other sites where a ledger note could reach an author None found in `packages/lint/src` or `packages/cli/src` besides this expression. The other `.note` reads in `validate-react-page-props.ts` are deprecation records, not ledger rows. Nothing else reads the ledger's `note` as author text. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 60 families; each was run and recorded with its exit code, then reconciled with `--ran`: 59 exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: eight unrelated packages have no `dist/` in this worktree; nothing was measured, and the diff touches no `exports` or build output). CI owns the rest. ## Coordination #21135 (the liveness README) states the same model. It has no open PR and no branch at this time; whichever lands second keeps the two consistent. This PR does not touch the README. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e35c40a commit 327391c

3 files changed

Lines changed: 73 additions & 54 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a liveness finding's fix text is the row's `authorHint`, else the verdict's default hint, and never the row's internal ledger `note`, for every row class. Before this, a row that opted in with `authorWarn`, and an `experimental` row, with no `authorHint` printed its `note` as the fix text: on `app-showcase`, the two `liveness-planned-property` findings for `externalSharingModel` printed a 728-character maintainer note that cites a tracker id. They now print "Keep it — a consumer is being built against this property; it has no runtime effect yet." No rule id, message, severity or exit code changes, and a row that has an `authorHint` prints it exactly as before (#21096)
6+
7+
Clause-②: no

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

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,13 +1138,23 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct,
11381138
// contract test on the two rows that are still planned — `translation.flows`
11391139
// just above and `object.externalSharingModel` just below.
11401140

1141-
it('REAL LEDGER: object.externalSharingModel (planned, no authorHint — falls back to `note`) — planned rule id, note hint does not say Remove it', () => {
1141+
it('REAL LEDGER: object.externalSharingModel (planned, no authorHint) — planned rule id, the planned DEFAULT hint, never its ledger note', () => {
11421142
const findings = lintLivenessProperties({ objects: [{ name: 'widget', externalSharingModel: 'read' }] });
11431143
const f = findings.find((x) => x.message.includes('externalSharingModel'));
11441144
expect(f).toBeDefined();
11451145
expect(f!.rule).toBe('liveness-planned-property');
11461146
expect(f!.message).not.toContain('dead');
1147-
expect(f!.hint).not.toMatch(/^Remove it/);
1147+
const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), 'object.json'), 'utf8'));
1148+
const row = ledger.props.externalSharingModel;
1149+
// Anti-vacuity: the row is still the leaking shape this pin exists for.
1150+
expect(row.authorWarn).toBe(true);
1151+
expect(row.authorHint).toBeUndefined();
1152+
expect(row.note).toMatch(/#\d+/);
1153+
expect(f!.hint).toBe(
1154+
checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", oneEntry({ status: 'planned', authorWarn: true }))[0].hint,
1155+
);
1156+
expect(f!.hint).not.toMatch(/#\d+/);
1157+
expect(f!.hint).not.toContain(row.note);
11481158
});
11491159

11501160
// ── SYNTHETIC: the default hint per verdict, when neither authorHint nor
@@ -1218,11 +1228,11 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct,
12181228
expect(f.hint).toContain('evidence');
12191229
});
12201230

1221-
it('SYNTHETIC: live-elsewhere keeps the shared hint precedence — authorHint over note over the default', () => {
1231+
it('SYNTHETIC: live-elsewhere shares the hint precedence — authorHint over the default, never the note', () => {
12221232
const hintOf = (entry: Record<string, unknown>) =>
12231233
checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", oneEntry(entry))[0].hint;
12241234
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, authorHint: 'H', note: 'N' })).toBe('H');
1225-
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: 'N' })).toBe('N');
1235+
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: 'N' })).toContain('sibling repo');
12261236
expect(hintOf({ status: 'live-elsewhere', authorWarn: true })).toContain('sibling repo');
12271237
});
12281238

@@ -1714,9 +1724,10 @@ describe('the dead and live-elsewhere verdicts warn on their own (#16094)', () =
17141724
// the 105 newly warned entries' notes cite a tracker id, and three of the four
17151725
// an author can reach say the row was deliberately not warned. AGENTS.md keeps
17161726
// tracker numbers out of anything an author is shown. So such a row shows its
1717-
// `authorHint`, else the verdict's default hint; a row that opted in, and an
1718-
// `experimental` row, keep the hint they had before the ruling, byte for byte.
1719-
describe('the hint a verdict-triggered row shows an author (#16094)', () => {
1727+
// `authorHint`, else the verdict's default hint. The same rule holds for every
1728+
// other row class (an opted-in `planned`/`dead`/`live-elsewhere` row and an
1729+
// `experimental` row), pinned below (#21096).
1730+
describe('the hint a warned row shows an author (#16094, #21096)', () => {
17201731
type ShippedRow = { type: string; path: string; entry: Record<string, unknown> };
17211732

17221733
/** Every shipped row at the depth the warn map reads (props + one level of `children`). */
@@ -1774,38 +1785,56 @@ describe('the hint a verdict-triggered row shows an author (#16094)', () => {
17741785
}
17751786
});
17761787

1777-
it('CONTROL, REAL LEDGER: every row that warned before the ruling keeps its hint byte for byte', () => {
1788+
it('REAL LEDGER: no opted-in or experimental row shows its note either, and none without an authorHint carries a tracker id', () => {
17781789
// A `live` row that opts in is outside this population: `describe()` throws
1779-
// on it before and after the ruling (the COVERAGE pin above holds that it
1780-
// stays loud), so it has no hint to keep.
1781-
const before = shippedRows().filter(
1790+
// on it (the COVERAGE pin above holds that it stays loud).
1791+
const warned = shippedRows().filter(
17821792
(r) => (r.entry.authorWarn === true || r.entry.status === 'experimental') && r.entry.status !== 'live',
17831793
);
1784-
// Anti-vacuity: an opted-in row and an experimental row both fall back to
1785-
// their note today, so the control would see a change in either.
1786-
expect(before.some((r) => r.entry.authorWarn === true && r.entry.authorHint === undefined && typeof r.entry.note === 'string')).toBe(true);
1787-
expect(before.some((r) => r.entry.status === 'experimental' && r.entry.authorHint === undefined && typeof r.entry.note === 'string')).toBe(true);
1788-
for (const row of before) {
1789-
const expected = row.entry.authorHint ?? row.entry.note;
1790-
if (typeof expected !== 'string') continue; // only the default remains — unchanged by construction
1791-
expect(hintFor(row), `${row.type}/${row.path}`).toBe(expected);
1794+
// Anti-vacuity: an opted-in row and an experimental row, each without an
1795+
// authorHint and each with a note, exist, so the leak this pin forbids is a real one.
1796+
const leakers = (pred: (r: ShippedRow) => boolean) =>
1797+
warned.filter((r) => pred(r) && r.entry.authorHint === undefined && typeof r.entry.note === 'string');
1798+
expect(leakers((r) => r.entry.authorWarn === true).length).toBeGreaterThan(0);
1799+
expect(leakers((r) => r.entry.status === 'experimental').length).toBeGreaterThan(0);
1800+
expect(leakers((r) => typeof r.entry.note === 'string' && TRACKER_ID.test(r.entry.note as string)).length).toBeGreaterThan(0);
1801+
for (const row of warned) {
1802+
const where = `${row.type}/${row.path}`;
1803+
const hint = hintFor(row);
1804+
if (typeof row.entry.authorHint === 'string') {
1805+
expect(hint, where).toBe(row.entry.authorHint); // CONTROL: the authored hint, exactly
1806+
} else {
1807+
expect(hint, where).not.toBe(row.entry.note);
1808+
expect(TRACKER_ID.test(hint), `${where} hint carries a tracker id`).toBe(false);
1809+
}
17921810
}
17931811
});
17941812

1795-
it('SYNTHETIC: the opt-in, not the verdict, decides whether the note may be shown', () => {
1813+
it('SYNTHETIC: authorHint wins, otherwise the verdict default — for every row class, never the note', () => {
17961814
const hintOf = (entry: Record<string, unknown>) =>
17971815
checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", gizmoEntry(entry))[0].hint;
17981816
const deadDefault = hintOf({ status: 'dead' });
17991817
const elsewhereDefault = hintOf({ status: 'live-elsewhere' });
1800-
// Verdict-triggered: authorHint wins, otherwise the default — never the note.
1801-
expect(hintOf({ status: 'dead', note: 'N #123' })).toBe(deadDefault);
1802-
expect(hintOf({ status: 'live-elsewhere', note: 'N #123' })).toBe(elsewhereDefault);
1803-
expect(hintOf({ status: 'dead', authorHint: 'H', note: 'N' })).toBe('H');
1804-
// Opted in: today's precedence, unchanged — the note is shown.
1805-
expect(hintOf({ status: 'dead', authorWarn: true, note: 'N' })).toBe('N');
1806-
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: 'N' })).toBe('N');
1807-
// Experimental needs no opt-in and keeps its note, as before the ruling.
1808-
expect(hintOf({ status: 'experimental', note: 'N' })).toBe('N');
1818+
const plannedDefault = hintOf({ status: 'planned', authorWarn: true });
1819+
const experimentalDefault = hintOf({ status: 'experimental' });
1820+
const NOTE = 'N #123';
1821+
// Verdict-triggered, with or without the opt-in: the default, never the note.
1822+
expect(hintOf({ status: 'dead', note: NOTE })).toBe(deadDefault);
1823+
expect(hintOf({ status: 'live-elsewhere', note: NOTE })).toBe(elsewhereDefault);
1824+
expect(hintOf({ status: 'dead', authorWarn: true, note: NOTE })).toBe(deadDefault);
1825+
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: NOTE })).toBe(elsewhereDefault);
1826+
// Opted-in planned and experimental rows: the default, never the note.
1827+
expect(hintOf({ status: 'planned', authorWarn: true, note: NOTE })).toBe(plannedDefault);
1828+
expect(hintOf({ status: 'experimental', note: NOTE })).toBe(experimentalDefault);
1829+
expect(hintOf({ status: 'experimental', authorWarn: true, note: NOTE })).toBe(experimentalDefault);
1830+
for (const hint of [deadDefault, elsewhereDefault, plannedDefault, experimentalDefault]) {
1831+
expect(hint).not.toMatch(/#\d+/);
1832+
}
1833+
// Control: an authorHint is printed exactly, note or no note.
1834+
for (const status of ['dead', 'live-elsewhere', 'planned', 'experimental']) {
1835+
expect(hintOf({ status, authorWarn: true, authorHint: 'H', note: NOTE }), status).toBe('H');
1836+
expect(hintOf({ status, authorWarn: true, authorHint: 'H' }), status).toBe('H');
1837+
}
18091838
});
18101839

18111840
it('END TO END: the authored dead RLS keys show the dead default hint, not the ledger note', () => {

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

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@
2020
* `dead`, `live-elsewhere` and `experimental` warn on their own, because the
2121
* verdict itself is the author-facing warning (#16094, decision batch #60,
2222
* option A). `planned` and `live` warn only when the row opts in via
23-
* `"authorWarn": true`. The hint is the row's `"authorHint"`; failing that, a
24-
* row that opted in (or an `experimental` one) shows its ledger `note`, while a
25-
* row warning only by its `dead` / `live-elsewhere` verdict shows the verdict's
26-
* default hint and never its note (see `isVerdictTriggered`). Booleans warn
23+
* `"authorWarn": true`. The hint is the row's `"authorHint"`; failing that, the
24+
* verdict's default hint (see `describe`). Never the row's ledger `note`, for
25+
* any row class: that text is written for the ledger's maintainers. Booleans warn
2726
* only when set truthy (so schema defaults like `enable.searchable` never trip
2827
* it); object/string/array props warn when present at all.
2928
*/
@@ -176,24 +175,6 @@ function shouldWarn(entry: LedgerEntry | undefined): boolean {
176175
return (typeof entry.status === 'string' && VERDICTS_THAT_WARN.has(entry.status)) || entry.authorWarn === true;
177176
}
178177

179-
/**
180-
* Is this row warning ONLY because of its ruled verdict — `dead` or
181-
* `live-elsewhere`, with no `authorWarn` opt-in?
182-
*
183-
* Such a row never chose to address an author, so its `note` was written for
184-
* the ledger's maintainers: audit prose, measured commit shas, tracker ids. A
185-
* finding shows an author what the row WROTE for authors (`authorHint`) or the
186-
* verdict's own default hint, never that note. AGENTS.md keeps tracker numbers
187-
* out of anything an author is shown, and the ruling is what began routing these
188-
* rows to authors, so the hint selection in `checkItem` closes the door it
189-
* opened. A row that opts in with `authorWarn`, and an `experimental` row, keep
190-
* the hint they had before the ruling (`authorHint`, else `note`, else the
191-
* default) byte for byte.
192-
*/
193-
function isVerdictTriggered(entry: LedgerEntry): boolean {
194-
return entry.authorWarn !== true && typeof entry.status === 'string' && RULED_VERDICTS.has(entry.status);
195-
}
196-
197178
/** A value that signals authoring intent: booleans only when truthy; everything else when present. */
198179
function isAuthored(value: unknown): boolean {
199180
if (value === undefined || value === null) return false;
@@ -226,8 +207,8 @@ function isAuthored(value: unknown): boolean {
226207
* `evidence` — the enforcer's address — rather than at a delete key.
227208
*
228209
* Each verdict below also carries its own DEFAULT hint (used when the ledger
229-
* entry has no `authorHint` and either has no `note` or warns only by its
230-
* `dead` / `live-elsewhere` verdict — see `isVerdictTriggered`): the `dead`
210+
* entry has no `authorHint`; the row's `note` is never shown to an author, since
211+
* it is written for the ledger's maintainers): the `dead`
231212
* default says "Remove it"; `planned`'s and `live-elsewhere`'s must not,
232213
* because removing a planned or elsewhere-enforced property is exactly the
233214
* wrong author action.
@@ -424,7 +405,9 @@ function checkItem(
424405
for (const value of values instanceof Array ? values : [values]) {
425406
if (!isAuthored(value)) continue;
426407
const { kind, rule, defaultHint } = describe(entry);
427-
const hint = entry.authorHint ?? (isVerdictTriggered(entry) ? undefined : entry.note) ?? defaultHint;
408+
// Author-facing text is `authorHint` or the verdict's default — never the
409+
// row's `note`, which is maintainer prose (audit evidence, tracker ids).
410+
const hint = entry.authorHint ?? defaultHint;
428411
findings.push({
429412
where: whereBase,
430413
message: `sets \`${path}\` but this ${type} property ${kind}.`,

0 commit comments

Comments
 (0)