Files
msd-core/scripts/lint-emitted-drift-ack.cjs
Tom Boucher 1e3c995e6f fix(#2789): scope the emitted-drift ack to the diff that introduced it (#2803)
* fix(#2789): scope the emitted-drift ack to the diff that introduced it

Every input to `diffEmitted` is base-relative -- `baseline` vs `current`,
`changedPaths` from `git diff base...HEAD` -- except the ack set, which
was read absolutely, from the working tree only. A differential machine
consulting a non-differential input.

So `staleAcks` asks exactly one question, "did a delta consume you?", and
that cannot distinguish an ack that never explained anything (an
authoring mistake) from one whose ripple is now absorbed into the base
(the ack's SUCCESS condition). After merge an ack is in the second state
but reports as the first.

The trigger is ordinary. Actions sets GITHUB_BASE_REF on pull_request
events only, so a push to `next` falls through to origin/next -- the very
commit under test. Both sides build identical content, no deltas remain,
and every live ack is reported stale. PR #2768 acked a deliberate 40866
-> 42020 byte growth, was green on its own lane, and reddened `next` the
moment it merged. It also reds every PR branching off the poisoned base,
and since publish-emitted-baseline is gated on the test job, it blocked
baseline publication too.

Give the ack the base side it was missing. `diffEmitted` now takes
`baseAck` -- the same document at the base ref, via `readAckFileAtRef`.
An entry already present there is SPENT: it may no longer consume a delta
and is never reported stale, only surfaced as `spentAcks` for tidying. An
entry new or reworded in this diff stays live, and if nothing consumes it
that genuinely fails, with blame on the author who just wrote it.

This closes a hazard the IMPLEMENTATION named but could not prevent -- a
leftover ack silently pre-clearing the next ripple on its path. (ADR-2719
§3 asserted only that TOUCHING the file is the alarm; its residual-risk
list never covered pre-clearing, and §3 now carries an amendment.)
Verified against the two-PR laundering sequence -- land an innocuous ack,
then change the artifact -- which passed silently before and now fails on
both the hash pass and the size ratchet.

Three things the design has to get right, each of which was wrong first:

  - A read failure on the base document THROWS; only absence-at-the-ref
    returns null. Returning null on error LOOKS armed (every entry stays
    live) but a live entry's defining power is that it CONSUMES a delta,
    so null is armed on the staleness axis and DISARMED on consumption --
    silently the whole pre-#2789 gate. `git show` cannot tell absence
    from fault, so absence is established with `ls-tree`.
  - Re-arming a spent ack costs actual PROSE. Internal whitespace and the
    zero-width family collapse, and `runtime` is not compared: a doubled
    space, an invisible character, or a decorative field would otherwise
    re-arm an ack whose justification still describes the previous
    ripple, showing a reviewer nothing.
  - `baseAck` is REQUIRED once an ack declares entries -- omission is an
    error, not a silent "inherit nothing" -- so a dropped argument fails
    loudly instead of quietly restoring this bug with the suite green.

Because a corrupt document ON THE BASE is expensive (the loud base-side
failure reds every ack-carrying PR), scripts/lint-emitted-drift-ack.cjs
blocks one from landing. It is standalone rather than importing parseAck
-- scripts/ ships in the npm package and tests/ does not -- so a parity
test runs both surfaces over one corpus and fails on divergence; it
caught one immediately, a `null` document, now classed as policy rather
than schema. Deadlock is separately foreclosed: a tree carrying no ack
never reads the base, so the PR that DELETES a corrupt file still lands.

`readAckFileAtRef` takes an injected git runner so all four branches are
tested deterministically; it never executes in the remote runner, where
the real-tree test skips for want of a base ref. It also refuses an
option-shaped ref, since execFileSync's array form stops shell
metacharacters but not git's own option parsing.

Rejected: skipping the differential when base == HEAD. It treats the
symptom, costs real coverage on the push-to-next lane, and does nothing
about the downstream PRs the same flaw was reddening.

Deletes the now-spent tests/emitted-drift-ack.json, and updates the
CONTEXT.md canon and ADR-2719 §3: presence is no longer the alarm -- a
LIVE entry is, and a spent one is inert.

Closes #2789

* chore(#2789): backfill changeset PR number
2026-07-28 21:28:10 -04:00

150 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* lint-emitted-drift-ack — refuse to merge a broken emitted-drift acknowledgment (#2789).
*
* The ack document is read from TWO sides: the working tree (`readAckFile`) and the BASE
* REF (`readAckFileAtRef`), because an entry already present at the base is spent and may
* no longer clear a delta. The base-side read fails LOUDLY on a document it cannot parse
* — it has to, since silently inheriting nothing would leave every entry able to consume
* a delta, which is the pre-#2789 gate.
*
* That makes a corrupt document ON THE BASE BRANCH unusually expensive: it reds every PR
* that carries an ack until someone repairs it. The real-tree test avoids an outright
* deadlock (a tree with no ack never consults the base, so the repair PR still lands),
* but the cheaper answer is to never let a broken document reach the base at all. This
* runs in `lint:ci`, so a PR carrying one cannot go green and cannot merge.
*
* This validator is DELIBERATELY STANDALONE. `scripts/` ships in the npm package and
* `tests/` does not (package.json `files`), so requiring the gate's own `parseAck` from
* here would be a MODULE_NOT_FOUND in the published package. The duplication is bounded
* by a parity test — `tests/emitted-attribution.test.cjs` runs both surfaces over one
* corpus and fails if they ever disagree about what is schema-valid.
*/
const fs = require('node:fs');
const path = require('node:path');
const ACK_VERSION = 1;
const ACK_REPO_PATH = 'tests/emitted-drift-ack.json';
const REPO_ROOT = path.join(__dirname, '..');
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/**
* Validate an ack document's raw text.
*
* `schemaErrors` are the ones that must agree with the gate's `parseAck` — the shape
* contract. `policyErrors` are lint-only rules that `parseAck` deliberately does NOT
* enforce, because they are about what may be COMMITTED rather than what may be parsed:
* a present-but-entryless document parses fine and signals nothing, so it must be deleted
* rather than left behind.
*
* @param {string|null} raw file contents, or null when the file is absent
* @returns {{ schemaErrors: string[], policyErrors: string[], ok: boolean }}
*/
function validateAckText(raw) {
const schemaErrors = [];
const policyErrors = [];
const done = () => ({ schemaErrors, policyErrors, ok: schemaErrors.length === 0 && policyErrors.length === 0 });
if (raw === null) return done(); // absent is the healthy steady state
if (raw.trim() === '') {
schemaErrors.push(`${ACK_REPO_PATH} is present but empty`);
return done();
}
let doc;
try {
doc = JSON.parse(raw);
} catch (err) {
schemaErrors.push(`${ACK_REPO_PATH} is not valid JSON: ${err.message}`);
return done();
}
// A document that is literally `null` is POLICY, not schema. The gate's `parseAck` uses
// `null` as its "absent == no acks" sentinel, so it reads such a file as legal and
// harmless — and the parity test holds us to that. It is still not something to commit:
// it declares nothing, so the remedy is the same as an entryless document.
if (doc === null) {
policyErrors.push(
`${ACK_REPO_PATH} contains "null" and declares no acknowledgments. Delete the file — `
+ 'the healthy steady state is no file at all.',
);
return done();
}
if (!isPlainObject(doc)) {
schemaErrors.push(
`${ACK_REPO_PATH}: must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`,
);
return done();
}
if (doc.version !== undefined && doc.version !== ACK_VERSION) {
schemaErrors.push(
`${ACK_REPO_PATH}: unsupported version ${JSON.stringify(doc.version)} (expected ${ACK_VERSION})`,
);
}
const paths = doc.paths;
if (paths !== undefined && !isPlainObject(paths)) {
schemaErrors.push(`${ACK_REPO_PATH}: "paths" must be an object of <emitted path> -> { reason }`);
return done();
}
const entries = paths === undefined ? [] : Object.entries(paths);
for (const [rel, value] of entries) {
const reason = isPlainObject(value) ? value.reason : value;
if (typeof reason !== 'string' || reason.trim() === '') {
schemaErrors.push(`${ACK_REPO_PATH}: ack for "${rel}" has no non-empty "reason"`);
}
}
// Lint-only. An entryless document parses cleanly and acknowledges nothing, so it is
// pure confusion on the base branch — and it is exactly what a contributor leaves
// behind after removing the last entry by hand.
if (entries.length === 0) {
policyErrors.push(
`${ACK_REPO_PATH} is present but declares no acknowledgments. Delete the file — an `
+ 'empty one signals nothing, and the healthy steady state is no file at all.',
);
}
return done();
}
function readIfPresent(file) {
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
}
function main() {
const file = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/'));
const result = validateAckText(readIfPresent(file));
const all = [...result.schemaErrors, ...result.policyErrors];
if (all.length) {
console.error(`lint-emitted-drift-ack: ${all.length} problem(s) in ${ACK_REPO_PATH}\n`);
for (const e of all) console.error(` - ${e}`);
console.error(
'\nThis blocks the merge on purpose. The base-side reader fails loudly on a document '
+ 'it cannot parse, so a broken one on the base branch reds every PR that carries an '
+ 'acknowledgment. Fix or delete the file here, where it is cheap.',
);
process.exitCode = 1;
return;
}
console.log(
fs.existsSync(file)
? `ok lint-emitted-drift-ack: ${ACK_REPO_PATH} is well-formed`
: `ok lint-emitted-drift-ack: ${ACK_REPO_PATH} absent (the healthy steady state)`,
);
}
if (require.main === module) main();
module.exports = { validateAckText, ACK_VERSION, ACK_REPO_PATH };