* 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
150 lines
5.8 KiB
JavaScript
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 };
|