Skip to content

Commit 9b30cc1

Browse files
os-muskclaude
andauthored
fix(lint): teach describe() the live-elsewhere verdict instead of throwing (#14632)
* fix(lint): teach describe() the live-elsewhere verdict instead of throwing `describe()` in `lint-liveness-properties.ts` mapped three ledger verdicts to author-facing findings and threw, by design, on anything else. #13483 shipped the ledger's fifth status — `live-elsewhere` (dead here by measurement, genuinely enforced in a sibling repo; first row `manifest.runtime`, enforced at the cloud marketplace publish gate) — without teaching this function about it, so the day such a row opts into `authorWarn: true` the author gets a crash instead of an advisory warning. `live-elsewhere` now has its own branch and its own rule id (`liveness-live-elsewhere-property`), a message that says the property is enforced in a sibling repo rather than here, and a default hint that keeps the property and points at the ledger row's evidence. It must never fall into the `dead` branch: per #11384 the verdicts imply opposite author actions, and "Remove it" is exactly the wrong sentence about a key whose enforcement is real and remote — deleting it tears out a live gate's input. The sentinel throw stays for genuinely unknown statuses, its enumeration updated. The suite gains a coverage pin derived from the shipped ledger rows — not a hand-list — so a sixth status fails by name instead of repeating this card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(lint): move the tracker id out of the sentinel's runtime string `check:doc-authoring` (sibling-package prose-id leg) fails a NEW `#NNNN` inside a runtime string: an author, an operator or a generated surface reads that message and cannot resolve a tracker id. The reference moves to the adjacent `//` comment, where the reader who can resolve it is already looking. Maintainer ruling 2026-08-12, quoted by the gate: 处理 issue 时犯的错应该总结成经验,保留 issue id没有意义 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(changeset): restore #13483's pending spec changeset, add lint's own at minor Two defects in the previous commit's changeset, neither in the implementation. RESTORED. `.changeset/liveness-live-elsewhere-verdict.md` already existed on `origin/main` — it is #13483's, declaring `@objectstack/spec: patch` and describing the fifth verdict, its gate-executable criteria, the 180-day re-attestation discipline and the `manifest.runtime` migration. Writing this package's note to that path truncated it, which would have dropped the pending `@objectstack/spec` bump and taken all of that out of the next release notes. `packages/spec/CHANGELOG.md` has zero hits for `live-elsewhere`, so it is unconsumed and still owed, not a stale leftover. The file is restored byte for byte from `origin/main` (blob 5321f1b) and this package's note moves to `.changeset/lint-liveness-live-elsewhere-rule-id.md`. LEVEL. `minor`, not `patch`: `LIVENESS_LIVE_ELSEWHERE_PROPERTY` is a new export on `packages/lint/src/index.ts`, the public entry, and a new public-entry export is `minor` under the precedent this lane applied today (#14606 took `@objectstack/types: minor` for three new exports). Nothing narrows, so no BREAKING banner and no ADR-0087 marker are owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbbac0f commit 9b30cc1

4 files changed

Lines changed: 278 additions & 29 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`lintLivenessProperties` no longer crashes on the `live-elsewhere` verdict — and never tells an author to remove a key a sibling repo enforces
6+
7+
`describe()` in `lint-liveness-properties.ts` knew three verdicts
8+
(`experimental`, `planned`, `dead`) and threw, loudly and by design, on any
9+
other. #13483 then shipped the ledger's fifth status — `live-elsewhere`: dead
10+
HERE by measurement, genuinely enforced in a sibling repo — and migrated
11+
`manifest.runtime` onto it (its enforcer is the cloud marketplace publish
12+
gate). Nothing taught `describe()` about it, so the day any `live-elsewhere`
13+
row opts into `authorWarn: true`, `os lint` would raise that
14+
shipped-ledger-integrity error instead of the advisory warning the author
15+
should get. No shipped row carries `authorWarn` today, so this was a fuse
16+
rather than a fire.
17+
18+
`describe()` now has a fourth branch. `live-elsewhere` gets its own rule id —
19+
`liveness-live-elsewhere-property`, exported as `LIVENESS_LIVE_ELSEWHERE_PROPERTY`
20+
beside `LIVENESS_DEAD_PROPERTY` / `LIVENESS_EXPERIMENTAL_PROPERTY` /
21+
`LIVENESS_PLANNED_PROPERTY` and advisory-only like them — plus its own message
22+
(`is enforced in a sibling repo, not here`) and its own default hint, which keeps
23+
the property and points at the ledger row's `evidence` for the enforcer. It
24+
deliberately does **not** reuse the `dead` branch: that is the #11384 lesson,
25+
which is that verdicts imply OPPOSITE author actions, and "Remove it" is the
26+
single most damaging sentence available about a key whose enforcement is real
27+
and remote — deleting it tears out a live gate's input. The sentinel throw
28+
stays for genuinely unknown statuses, with its enumeration of the known ones
29+
updated.
30+
31+
The suite gains a coverage pin derived from the shipped ledgers rather than from
32+
a hand-written list: every distinct `status` those ledgers actually carry must be
33+
answered by `describe()` with a rule id of its own, or (for `live`, which reaches
34+
`describe()` only through a ledger-authoring mistake) must still fail loud. A
35+
sixth status now fails that pin by name instead of waiting for an author to trip
36+
the sentinel.

packages/lint/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,10 @@ export {
748748
LIVENESS_DEAD_PROPERTY,
749749
LIVENESS_EXPERIMENTAL_PROPERTY,
750750
LIVENESS_PLANNED_PROPERTY,
751+
// #14057 — the fifth ledger verdict's own rule id. `live-elsewhere` is dead
752+
// HERE by measurement but genuinely enforced in a sibling repo, so it must
753+
// never share the `dead` id: the two ask the author for opposite actions.
754+
LIVENESS_LIVE_ELSEWHERE_PROPERTY,
751755
} from './lint-liveness-properties.js';
752756

753757
export { lintAutonumberFormats } from './lint-autonumber-formats.js';

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

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
// source for why this ONE property is tested off the ledger.
1010
checkItemAgainstWarnMap,
1111
getNested,
12+
// #14057 coverage seam — the statuses the shipped ledgers actually carry, so
13+
// the coverage pin below is derived from the ledgers rather than hand-listed.
14+
shippedLedgerStatuses,
1215
} from './lint-liveness-properties.js';
1316

1417
/**
@@ -933,7 +936,7 @@ describe('the array fan-out, against a synthetic warn map (#10262)', () => {
933936
// seam (#10262) — exactly the kind of verdict-level testing that seam exists
934937
// for; the PLANNED branch is pinned against BOTH the real ledgers (so it stays
935938
// a contract test) and a synthetic no-hint entry (to pin the DEFAULT wording).
936-
describe('dead / experimental / planned verdicts are distinct, and unknown statuses fail loud (#11384)', () => {
939+
describe('dead / experimental / planned / live-elsewhere verdicts are distinct, and unknown statuses fail loud (#11384, #14057)', () => {
937940
const oneEntry = (entry: Record<string, unknown>) => new Map([['gizmo', entry]]);
938941

939942
// ── REAL LEDGER: the three rows the card captured ──────────────────────
@@ -1017,6 +1020,100 @@ describe('dead / experimental / planned verdicts are distinct, and unknown statu
10171020
expect(findings[0].hint).not.toContain('Remove it');
10181021
});
10191022

1023+
// ── #14057: `live-elsewhere`, the fifth verdict — and the one whose wrong
1024+
// branch is the most expensive. #13483 added the status to the ledger and
1025+
// migrated `manifest.runtime` onto it (dead here by measurement, enforced by
1026+
// the cloud marketplace publish gate); `describe()` was not taught it, so the
1027+
// day any such row opts into `authorWarn` the author got a CRASH instead of
1028+
// the advisory finding — and the `dead` fallthrough it replaced would have
1029+
// been worse than the crash: "Remove it" about a key that is a live gate's
1030+
// input. ────────────────────────────────────────────────────────────────
1031+
it('SYNTHETIC: a live-elsewhere entry gets its OWN rule id and a keep-it hint — never the dead branch', () => {
1032+
const findings = checkItemAgainstWarnMap(
1033+
'gadget',
1034+
{ name: 'g1', gizmo: 'x' },
1035+
"gadget 'g1'",
1036+
oneEntry({ status: 'live-elsewhere', authorWarn: true }),
1037+
);
1038+
expect(findings).toHaveLength(1);
1039+
const [f] = findings;
1040+
expect(f.rule).toBe('liveness-live-elsewhere-property');
1041+
expect(f.rule).not.toBe('liveness-dead-property');
1042+
// The message must not read as a dead verdict...
1043+
expect(f.message).toContain('is enforced in a sibling repo');
1044+
expect(f.message).not.toContain('liveness: dead');
1045+
expect(f.message).not.toContain('has no runtime effect');
1046+
// ...and the default hint must point at the enforcer, not at a delete key.
1047+
expect(f.hint).not.toContain('Remove it');
1048+
expect(f.hint.toLowerCase()).toContain('keep it');
1049+
expect(f.hint).toContain('evidence');
1050+
});
1051+
1052+
it('SYNTHETIC: live-elsewhere keeps the shared hint precedence — authorHint over note over the default', () => {
1053+
const hintOf = (entry: Record<string, unknown>) =>
1054+
checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", oneEntry(entry))[0].hint;
1055+
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, authorHint: 'H', note: 'N' })).toBe('H');
1056+
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: 'N' })).toBe('N');
1057+
expect(hintOf({ status: 'live-elsewhere', authorWarn: true })).toContain('sibling repo');
1058+
});
1059+
1060+
// NEGATIVE CONTROL, on the REAL ledger, through the production path. The card
1061+
// is a fuse, not a fire: `shouldWarn()` gates entry to `describe()`, and the
1062+
// shipped `manifest.runtime` row does not carry `authorWarn`, so nothing
1063+
// reaches the new branch today. This pin holds that reading honest in both
1064+
// directions — if the row ever opts in, this goes red and the reviewer should
1065+
// UPDATE THIS PIN (the branch above is what makes that flip safe), never
1066+
// remove the branch.
1067+
//
1068+
// Anti-vacuity: `authorWarnedProperties('manifest')` would also be empty if
1069+
// the ledger were unreadable, so the guard is that `shippedLedgerStatuses()`
1070+
// sees `live-elsewhere` at all — the ONE row carrying it lives in that very
1071+
// file, so seeing the status proves the file was read.
1072+
it('REAL LEDGER: the live-elsewhere row exists and does NOT warn yet (the fuse, unlit)', () => {
1073+
expect(shippedLedgerStatuses().has('live-elsewhere')).toBe(true);
1074+
expect(authorWarnedProperties('manifest').has('runtime')).toBe(false);
1075+
});
1076+
1077+
// ── COVERAGE (#14057): describe() answers for every status the ledgers ship.
1078+
//
1079+
// Patching one status is what let this card repeat #11384 — so the pin is
1080+
// derived from the shipped rows rather than from a list somebody has to
1081+
// remember to edit. A sixth status appearing in any ledger fails HERE, by
1082+
// name, instead of waiting for an author to trip the sentinel throw.
1083+
//
1084+
// `live` is the one member that must NOT get a branch, and the source header
1085+
// says why: an entry only reaches `describe()` once `shouldWarn()` has said
1086+
// yes, so "`live` can in principle arrive here too (an entry marked
1087+
// `authorWarn: true` on a `live` row would be a ledger authoring mistake, not
1088+
// a user error)". A mistake in our own shipped data is exactly what the
1089+
// sentinel is for, so `live` is asserted LOUD here rather than handled.
1090+
it('COVERAGE: every status the shipped ledgers carry is answered by describe() — or is loud by design', () => {
1091+
const statuses = [...shippedLedgerStatuses()].sort();
1092+
// Anti-vacuity: an unreadable ledger dir returns the empty set, which would
1093+
// pass every assertion below without measuring anything.
1094+
expect(statuses.length).toBeGreaterThanOrEqual(4);
1095+
expect(statuses).toContain('live-elsewhere');
1096+
1097+
const rulesByStatus = new Map<string, string>();
1098+
for (const status of statuses) {
1099+
const run = () =>
1100+
checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", oneEntry({ status, authorWarn: true }));
1101+
if (status === 'live') {
1102+
expect(run).toThrow(/live/);
1103+
continue;
1104+
}
1105+
const findings = run();
1106+
expect(findings, `status ${status} produced no finding`).toHaveLength(1);
1107+
expect(findings[0].rule, `status ${status} has no rule id of its own`).toMatch(/^liveness-[a-z-]+-property$/);
1108+
expect(findings[0].hint.length, `status ${status} has an empty hint`).toBeGreaterThan(0);
1109+
rulesByStatus.set(status, findings[0].rule);
1110+
}
1111+
1112+
// #11384's lesson as an assertion: verdicts imply different author actions,
1113+
// so no two of them may share a rule id.
1114+
expect(new Set(rulesByStatus.values()).size).toBe(rulesByStatus.size);
1115+
});
1116+
10201117
// ── SYNTHETIC: the unknown-status boundary — loud, never silently `dead` ──
10211118
it('SYNTHETIC: an unrecognised status fails LOUD, naming the status, instead of silently grading as dead', () => {
10221119
expect(() =>
@@ -1027,6 +1124,16 @@ describe('dead / experimental / planned verdicts are distinct, and unknown statu
10271124
oneEntry({ status: 'quantum', authorWarn: true }),
10281125
),
10291126
).toThrow(/quantum/);
1127+
// The message enumerates what describe() DOES know — #14057 is what happens
1128+
// when that list falls behind the branches, so pin them equal.
1129+
expect(() =>
1130+
checkItemAgainstWarnMap(
1131+
'gadget',
1132+
{ name: 'g1', gizmo: 'x' },
1133+
"gadget 'g1'",
1134+
oneEntry({ status: 'quantum', authorWarn: true }),
1135+
),
1136+
).toThrow(/'experimental' \| 'planned' \| 'dead' \| 'live-elsewhere'/);
10301137
});
10311138

10321139
it('SYNTHETIC: a `live` row mistakenly marked authorWarn also fails LOUD rather than being graded dead', () => {

0 commit comments

Comments
 (0)