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
7 changes: 7 additions & 0 deletions .changeset/21096-liveness-hint-never-note.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/lint': patch
---

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)

Clause-②: no
87 changes: 58 additions & 29 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,13 +1138,23 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct,
// contract test on the two rows that are still planned — `translation.flows`
// just above and `object.externalSharingModel` just below.

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

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

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

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

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

it('CONTROL, REAL LEDGER: every row that warned before the ruling keeps its hint byte for byte', () => {
it('REAL LEDGER: no opted-in or experimental row shows its note either, and none without an authorHint carries a tracker id', () => {
// A `live` row that opts in is outside this population: `describe()` throws
// on it before and after the ruling (the COVERAGE pin above holds that it
// stays loud), so it has no hint to keep.
const before = shippedRows().filter(
// on it (the COVERAGE pin above holds that it stays loud).
const warned = shippedRows().filter(
(r) => (r.entry.authorWarn === true || r.entry.status === 'experimental') && r.entry.status !== 'live',
);
// Anti-vacuity: an opted-in row and an experimental row both fall back to
// their note today, so the control would see a change in either.
expect(before.some((r) => r.entry.authorWarn === true && r.entry.authorHint === undefined && typeof r.entry.note === 'string')).toBe(true);
expect(before.some((r) => r.entry.status === 'experimental' && r.entry.authorHint === undefined && typeof r.entry.note === 'string')).toBe(true);
for (const row of before) {
const expected = row.entry.authorHint ?? row.entry.note;
if (typeof expected !== 'string') continue; // only the default remains — unchanged by construction
expect(hintFor(row), `${row.type}/${row.path}`).toBe(expected);
// Anti-vacuity: an opted-in row and an experimental row, each without an
// authorHint and each with a note, exist, so the leak this pin forbids is a real one.
const leakers = (pred: (r: ShippedRow) => boolean) =>
warned.filter((r) => pred(r) && r.entry.authorHint === undefined && typeof r.entry.note === 'string');
expect(leakers((r) => r.entry.authorWarn === true).length).toBeGreaterThan(0);
expect(leakers((r) => r.entry.status === 'experimental').length).toBeGreaterThan(0);
expect(leakers((r) => typeof r.entry.note === 'string' && TRACKER_ID.test(r.entry.note as string)).length).toBeGreaterThan(0);
for (const row of warned) {
const where = `${row.type}/${row.path}`;
const hint = hintFor(row);
if (typeof row.entry.authorHint === 'string') {
expect(hint, where).toBe(row.entry.authorHint); // CONTROL: the authored hint, exactly
} else {
expect(hint, where).not.toBe(row.entry.note);
expect(TRACKER_ID.test(hint), `${where} hint carries a tracker id`).toBe(false);
}
}
});

it('SYNTHETIC: the opt-in, not the verdict, decides whether the note may be shown', () => {
it('SYNTHETIC: authorHint wins, otherwise the verdict default — for every row class, never the note', () => {
const hintOf = (entry: Record<string, unknown>) =>
checkItemAgainstWarnMap('gadget', { name: 'g1', gizmo: 'x' }, "gadget 'g1'", gizmoEntry(entry))[0].hint;
const deadDefault = hintOf({ status: 'dead' });
const elsewhereDefault = hintOf({ status: 'live-elsewhere' });
// Verdict-triggered: authorHint wins, otherwise the default — never the note.
expect(hintOf({ status: 'dead', note: 'N #123' })).toBe(deadDefault);
expect(hintOf({ status: 'live-elsewhere', note: 'N #123' })).toBe(elsewhereDefault);
expect(hintOf({ status: 'dead', authorHint: 'H', note: 'N' })).toBe('H');
// Opted in: today's precedence, unchanged — the note is shown.
expect(hintOf({ status: 'dead', authorWarn: true, note: 'N' })).toBe('N');
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: 'N' })).toBe('N');
// Experimental needs no opt-in and keeps its note, as before the ruling.
expect(hintOf({ status: 'experimental', note: 'N' })).toBe('N');
const plannedDefault = hintOf({ status: 'planned', authorWarn: true });
const experimentalDefault = hintOf({ status: 'experimental' });
const NOTE = 'N #123';
// Verdict-triggered, with or without the opt-in: the default, never the note.
expect(hintOf({ status: 'dead', note: NOTE })).toBe(deadDefault);
expect(hintOf({ status: 'live-elsewhere', note: NOTE })).toBe(elsewhereDefault);
expect(hintOf({ status: 'dead', authorWarn: true, note: NOTE })).toBe(deadDefault);
expect(hintOf({ status: 'live-elsewhere', authorWarn: true, note: NOTE })).toBe(elsewhereDefault);
// Opted-in planned and experimental rows: the default, never the note.
expect(hintOf({ status: 'planned', authorWarn: true, note: NOTE })).toBe(plannedDefault);
expect(hintOf({ status: 'experimental', note: NOTE })).toBe(experimentalDefault);
expect(hintOf({ status: 'experimental', authorWarn: true, note: NOTE })).toBe(experimentalDefault);
for (const hint of [deadDefault, elsewhereDefault, plannedDefault, experimentalDefault]) {
expect(hint).not.toMatch(/#\d+/);
}
// Control: an authorHint is printed exactly, note or no note.
for (const status of ['dead', 'live-elsewhere', 'planned', 'experimental']) {
expect(hintOf({ status, authorWarn: true, authorHint: 'H', note: NOTE }), status).toBe('H');
expect(hintOf({ status, authorWarn: true, authorHint: 'H' }), status).toBe('H');
}
});

it('END TO END: the authored dead RLS keys show the dead default hint, not the ledger note', () => {
Expand Down
33 changes: 8 additions & 25 deletions packages/lint/src/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@
* `dead`, `live-elsewhere` and `experimental` warn on their own, because the
* verdict itself is the author-facing warning (#16094, decision batch #60,
* option A). `planned` and `live` warn only when the row opts in via
* `"authorWarn": true`. The hint is the row's `"authorHint"`; failing that, a
* row that opted in (or an `experimental` one) shows its ledger `note`, while a
* row warning only by its `dead` / `live-elsewhere` verdict shows the verdict's
* default hint and never its note (see `isVerdictTriggered`). Booleans warn
* `"authorWarn": true`. The hint is the row's `"authorHint"`; failing that, the
* verdict's default hint (see `describe`). Never the row's ledger `note`, for
* any row class: that text is written for the ledger's maintainers. Booleans warn
* only when set truthy (so schema defaults like `enable.searchable` never trip
* it); object/string/array props warn when present at all.
*/
Expand Down Expand Up @@ -176,24 +175,6 @@ function shouldWarn(entry: LedgerEntry | undefined): boolean {
return (typeof entry.status === 'string' && VERDICTS_THAT_WARN.has(entry.status)) || entry.authorWarn === true;
}

/**
* Is this row warning ONLY because of its ruled verdict — `dead` or
* `live-elsewhere`, with no `authorWarn` opt-in?
*
* Such a row never chose to address an author, so its `note` was written for
* the ledger's maintainers: audit prose, measured commit shas, tracker ids. A
* finding shows an author what the row WROTE for authors (`authorHint`) or the
* verdict's own default hint, never that note. AGENTS.md keeps tracker numbers
* out of anything an author is shown, and the ruling is what began routing these
* rows to authors, so the hint selection in `checkItem` closes the door it
* opened. A row that opts in with `authorWarn`, and an `experimental` row, keep
* the hint they had before the ruling (`authorHint`, else `note`, else the
* default) byte for byte.
*/
function isVerdictTriggered(entry: LedgerEntry): boolean {
return entry.authorWarn !== true && typeof entry.status === 'string' && RULED_VERDICTS.has(entry.status);
}

/** A value that signals authoring intent: booleans only when truthy; everything else when present. */
function isAuthored(value: unknown): boolean {
if (value === undefined || value === null) return false;
Expand Down Expand Up @@ -226,8 +207,8 @@ function isAuthored(value: unknown): boolean {
* `evidence` — the enforcer's address — rather than at a delete key.
*
* Each verdict below also carries its own DEFAULT hint (used when the ledger
* entry has no `authorHint` and either has no `note` or warns only by its
* `dead` / `live-elsewhere` verdict — see `isVerdictTriggered`): the `dead`
* entry has no `authorHint`; the row's `note` is never shown to an author, since
* it is written for the ledger's maintainers): the `dead`
* default says "Remove it"; `planned`'s and `live-elsewhere`'s must not,
* because removing a planned or elsewhere-enforced property is exactly the
* wrong author action.
Expand Down Expand Up @@ -424,7 +405,9 @@ function checkItem(
for (const value of values instanceof Array ? values : [values]) {
if (!isAuthored(value)) continue;
const { kind, rule, defaultHint } = describe(entry);
const hint = entry.authorHint ?? (isVerdictTriggered(entry) ? undefined : entry.note) ?? defaultHint;
// Author-facing text is `authorHint` or the verdict's default — never the
// row's `note`, which is maintainer prose (audit evidence, tracker ids).
const hint = entry.authorHint ?? defaultHint;
findings.push({
where: whereBase,
message: `sets \`${path}\` but this ${type} property ${kind}.`,
Expand Down
Loading