* fix(#2778): exempt intentionally-absent paths from the glossary gate check-glossary-refs asserts that every backticked tests/ token in CONTEXT.md resolves on disk. tests/emitted-drift-ack.json (ADR-2719 section 3) is absent on a healthy next BY DESIGN — it appears only inside a PR that needs it, which is what makes touching it the alarm. It passed before only by accident of backtick pairing: CONTEXT.md's RULESET entries are themselves backtick-wrapped and contain backticks, so the token happened to fall outside a code span. Any edit that shifted the parity exposed it. A gate that passes by luck is not passing. The exemption is exact, not a prefix hole: a sibling missing tests/ path still fails, and a test locks that. * feat(#2778): make the size-ratchet failure name its own remedy The growth branch stated a requirement and withheld the means of satisfying it: no ack file named, no schema, no key format, and no do-not-regenerate line — so the likeliest guess was to hunt for a baseline that #2724 deleted. Observed live on #2543. All remediation now comes from one frozen REMEDIATION export whose example document is rendered from ACK_VERSION, so the taught schema cannot drift from the schema parseAck accepts. A round-trip test feeds the printed document back through parseAck. The report is now built as a typed IR (buildReport) that formatReport renders, so tests assert on structure rather than prose, per CONTRIBUTING.md's raw-text-matching rule. Two defects found and fixed inline while building: - diffEmitted's validation early-return omitted newFileCapExceeded while formatReport reads its length, so the branch that reports a failed git diff threw a TypeError instead of naming the problem. - Printing one complete ack document per failing branch made each read as the whole file, so pasting the second over the first silently lost an acknowledgment. One document now covers the whole report. Closes #2778 * chore(#2778): backfill changeset pr number to 2780
This commit is contained in:
@@ -143,6 +143,48 @@ test('a reference to a nonexistent gsd-core/bin/lib/*.cjs path is skipped (gener
|
||||
assert.equal(res.status, 0, `generated bin/lib path must be skipped, not asserted missing: ${res.stderr}`);
|
||||
});
|
||||
|
||||
test('an intentionally-absent documented path is skipped, not asserted missing', (t) => {
|
||||
// #2778. `tests/emitted-drift-ack.json` (ADR-2719 §3) is absent on a healthy
|
||||
// `next` BY DESIGN — it appears only inside a PR that needs it, which is what
|
||||
// makes touching it the alarm. Asserting its existence turns the correct state
|
||||
// into a drift finding. Under `tests/` it would otherwise be tracked, so this
|
||||
// is a real exemption rather than a prefix accident.
|
||||
const context = [
|
||||
'# Context',
|
||||
'',
|
||||
'The escape hatch is a committed acknowledgment (`tests/emitted-drift-ack.json`).',
|
||||
'',
|
||||
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
|
||||
'',
|
||||
].join('\n');
|
||||
const root = makeRepo(t, { contextBody: context });
|
||||
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(
|
||||
res.status, 0,
|
||||
`an intentionally-absent path must be skipped, not asserted missing: ${res.stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a sibling tests/ path that is merely missing is still caught', (t) => {
|
||||
// The exemption must be exact, not a blanket hole under `tests/`. A different
|
||||
// missing tests/ file must still fail, or #2778's fix would disarm the gate for
|
||||
// the whole directory.
|
||||
const context = [
|
||||
'# Context',
|
||||
'',
|
||||
'See `tests/emitted-drift-ack-typo.json` for details.',
|
||||
'',
|
||||
`${allRuntimesSentence(17, REAL_RUNTIMES)}.`,
|
||||
'',
|
||||
].join('\n');
|
||||
const root = makeRepo(t, { contextBody: context });
|
||||
|
||||
const res = run(root, ['--check']);
|
||||
assert.equal(res.status, 1, 'a genuinely missing tests/ path must still be a finding');
|
||||
assert.match(`${res.stdout}${res.stderr}`, /emitted-drift-ack-typo\.json/);
|
||||
});
|
||||
|
||||
test('a ~/-rooted path and a bare filename are both skipped', (t) => {
|
||||
const context = [
|
||||
'# Context',
|
||||
|
||||
@@ -60,10 +60,13 @@ const {
|
||||
const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs');
|
||||
const {
|
||||
ACK_VERSION,
|
||||
ACK_FILE,
|
||||
NEW_FILE_CAP,
|
||||
REMEDIATION,
|
||||
sourceSatisfiedBy,
|
||||
parseAck,
|
||||
diffEmitted,
|
||||
buildReport,
|
||||
formatReport,
|
||||
} = require('./helpers/emitted-diff.cjs');
|
||||
|
||||
@@ -560,6 +563,289 @@ test('shrinkage is reported but needs no ack', () => {
|
||||
assert.ok(r.ok, 'shrinkage is not creep — gating it would punish what the ratchet wants');
|
||||
});
|
||||
|
||||
// ─── The failure must name its own remedy (#2778, ADR-2719 §3) ───────────────
|
||||
//
|
||||
// A gate that states a requirement and withholds the means of satisfying it is not a
|
||||
// gate, it is a maintainer round-trip. ADR-2719 §3 makes the acknowledgment a
|
||||
// *conspicuous declaration a contributor makes deliberately* — which only works if the
|
||||
// contributor can discover how to make it. Observed live on #2543: real growth from a
|
||||
// legitimate feature change, a red lane, and no self-serve path out of it.
|
||||
//
|
||||
// These assert on `buildReport`'s typed IR, not on rendered prose — CONTRIBUTING.md
|
||||
// ("Prohibited: Raw Text Matching on Test Outputs") requires a human formatter to expose
|
||||
// a structured surface so a reworded sentence is never a failing test. Exactly two tests
|
||||
// below touch the rendered string, and only to prove the renderer emits the IR at all.
|
||||
//
|
||||
// They also use the bare `buildReport(r)` / `formatReport(r)` form, because that is what
|
||||
// the real-tree test at the bottom of this file calls: a row that only ever passed an
|
||||
// explicit `sampleLimit` would prove a property no shipping caller exercises.
|
||||
|
||||
/** The growth-only shape: a size ratchet trip with NO unattributable hash movement. */
|
||||
const growthOnly = (extra = {}) => diffEmitted({
|
||||
baseline: mf({}),
|
||||
current: mf({}),
|
||||
changedPaths: [],
|
||||
sizeBaseline: { 'explore.md': 11127 },
|
||||
sizeCurrent: { 'explore.md': 13230 },
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** The one block of `kind`, or undefined. */
|
||||
const blockOf = (report, kind) => report.blocks.find((b) => b.kind === kind);
|
||||
|
||||
test('a growth-only failure carries the byte delta, the key rule, and an ack entry', () => {
|
||||
// The pre-#2778 report stopped after the byte delta. The suite's only coverage of the
|
||||
// remediation reached it through the UNATTRIBUTABLE branch, so a growth-only regression
|
||||
// was invisible — which is why this fixture carries no hash movement at all.
|
||||
const r = growthOnly();
|
||||
assert.equal(r.unattributable.length, 0, 'this fixture must isolate the growth branch');
|
||||
assert.ok(!r.ok);
|
||||
|
||||
const report = buildReport(r);
|
||||
const growth = blockOf(report, 'unacked-growth');
|
||||
assert.ok(growth, 'the growth branch must produce a block');
|
||||
assert.equal(growth.count, 1);
|
||||
assert.deepEqual(growth.items[0], {
|
||||
name: 'explore.md', from: 11127, to: 13230, delta: 2103, acked: false,
|
||||
});
|
||||
assert.equal(growth.keyRule, REMEDIATION.growthKeyRule, 'growth keys on the bare filename');
|
||||
|
||||
assert.deepEqual(report.ackable, [
|
||||
{ key: 'explore.md', reason: REMEDIATION.growthReason },
|
||||
], 'the ack entry must be keyed on the file that actually grew');
|
||||
});
|
||||
|
||||
test('the renderer emits the ack file, the document, and the do-not-regenerate line', () => {
|
||||
// The one place rendered text is the object of the test: proving the IR above actually
|
||||
// reaches the contributor. Everything it asserts is an identity comparison against the
|
||||
// frozen surface, so rewording any sentence cannot fail this.
|
||||
const msg = formatReport(growthOnly());
|
||||
assert.ok(msg.includes('explore.md grew 2103 bytes (11127 -> 13230)'), 'the delta still leads');
|
||||
assert.ok(msg.includes(REMEDIATION.ackFile), 'the message must name the ack file');
|
||||
assert.ok(msg.includes(REMEDIATION.createIfAbsent), 'it must say the file may not exist yet');
|
||||
assert.ok(msg.includes(REMEDIATION.growthKeyRule), 'it must state the bare-filename key rule');
|
||||
assert.ok(msg.includes(REMEDIATION.doNotRegenerate), 'it must say not to regenerate');
|
||||
assert.ok(
|
||||
msg.includes(REMEDIATION.ackDocument([{ key: 'explore.md', reason: REMEDIATION.growthReason }])),
|
||||
'the printed document must be the one the IR describes',
|
||||
);
|
||||
});
|
||||
|
||||
test('the document the report teaches is accepted by parseAck', () => {
|
||||
// The divergence killer. A report that teaches a schema the parser rejects is worse
|
||||
// than no report: the contributor follows it, is rejected anyway, and now distrusts the
|
||||
// gate. This pins the taught shape to the accepted shape in one assertion.
|
||||
const taught = REMEDIATION.ackDocument([{ key: 'explore.md', reason: 'a real reason' }]);
|
||||
const { entries, errors } = parseAck(JSON.parse(taught));
|
||||
assert.deepEqual(errors, [], 'the taught document must parse with zero errors');
|
||||
assert.equal(entries.get('explore.md').reason, 'a real reason');
|
||||
|
||||
// And it must actually clear the gate it is offered to clear.
|
||||
const r = growthOnly({ ack: JSON.parse(taught) });
|
||||
assert.equal(r.grown[0].acked, true);
|
||||
assert.deepEqual(r.staleAcks, []);
|
||||
assert.ok(r.ok, 'following the printed instructions must turn the lane green');
|
||||
});
|
||||
|
||||
test('the taught document derives its version from ACK_VERSION', () => {
|
||||
// A hand-typed `"version": 1` beside a live ACK_VERSION is the generative-fix-divergence
|
||||
// class: bump one, the other lies. Asserting the relationship — not the literal — is
|
||||
// what makes the bump safe.
|
||||
assert.equal(JSON.parse(REMEDIATION.ackDocument([{ key: 'x.md', reason: 'r' }])).version, ACK_VERSION);
|
||||
});
|
||||
|
||||
test('the remediation surface is frozen and names the ack file once', () => {
|
||||
assert.ok(Object.isFrozen(REMEDIATION), 'the exported surface must not be mutable');
|
||||
assert.equal(REMEDIATION.ackFile, ACK_FILE, 'one definition, not a second literal');
|
||||
});
|
||||
|
||||
test('a ripple and a growth in one report share ONE document', () => {
|
||||
// The combination nobody writes down, and the most likely real shape: a feature PR that
|
||||
// both grows a workflow AND ripples an emitted path.
|
||||
//
|
||||
// Caught in review: printing a complete document per branch made each read as "the file
|
||||
// to create", so a contributor pasting the second over the first silently loses the
|
||||
// first acknowledgment — an ack-lost failure with no signal. One document, one file.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||||
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
||||
changedPaths: ['README.md'],
|
||||
sizeBaseline: { 'explore.md': 11127 },
|
||||
sizeCurrent: { 'explore.md': 13230 },
|
||||
});
|
||||
const report = buildReport(r);
|
||||
assert.equal(blockOf(report, 'unattributable').keyRule, REMEDIATION.rippleKeyRule);
|
||||
assert.equal(blockOf(report, 'unacked-growth').keyRule, REMEDIATION.growthKeyRule);
|
||||
|
||||
// Both key spaces, one ack set, in list order.
|
||||
assert.deepEqual(report.ackable, [
|
||||
{ key: WORKFLOW_KEY, reason: REMEDIATION.rippleReason },
|
||||
{ key: 'explore.md', reason: REMEDIATION.growthReason },
|
||||
]);
|
||||
|
||||
// And the rendered document is genuinely one object holding both.
|
||||
const doc = JSON.parse(REMEDIATION.ackDocument(report.ackable));
|
||||
assert.deepEqual(Object.keys(doc.paths).sort(), [WORKFLOW_KEY, 'explore.md'].sort());
|
||||
const { errors } = parseAck(doc);
|
||||
assert.deepEqual(errors, [], 'the combined document must parse');
|
||||
|
||||
const msg = formatReport(r);
|
||||
assert.equal(
|
||||
msg.split('{"version"').length - 1, 1,
|
||||
'exactly one document may be printed — two would invite pasting one over the other',
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale ack names the file it lives in and the delete-the-file case', () => {
|
||||
// Pre-#2778 this said acks "must be deleted" without naming the file they live in. It
|
||||
// also never said what to do when the last entry goes: an empty-but-present ack file
|
||||
// parses fine and is "legal", but it destroys the ADR-2719 §3 property that the file's
|
||||
// PRESENCE is the alarm.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||||
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
||||
changedPaths: [],
|
||||
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } },
|
||||
});
|
||||
const stale = blockOf(buildReport(r), 'stale-acks');
|
||||
assert.deepEqual(stale.items, [WORKFLOW_KEY]);
|
||||
assert.equal(stale.fix, REMEDIATION.staleAckFix);
|
||||
assert.match(stale.fix, /delete the file/, 'the last-entry case must be covered');
|
||||
|
||||
// A stale-only report has nothing to acknowledge — it must NOT offer a document.
|
||||
assert.deepEqual(buildReport(r).ackable, [], 'deleting an ack is not acknowledging one');
|
||||
});
|
||||
|
||||
test('growth and a stale ack in one report keep both remedies', () => {
|
||||
// The contributor is adding one entry and removing another in the same file.
|
||||
const r = growthOnly({ ack: { version: ACK_VERSION, paths: { 'gone.md': { reason: 'outlived' } } } });
|
||||
assert.deepEqual(r.staleAcks, ['gone.md']);
|
||||
assert.equal(r.grown[0].acked, false);
|
||||
|
||||
const report = buildReport(r);
|
||||
assert.ok(blockOf(report, 'unacked-growth'), 'the growth still needs an ack');
|
||||
assert.ok(blockOf(report, 'stale-acks'), 'the stale entry still needs deleting');
|
||||
assert.deepEqual(report.ackable, [{ key: 'explore.md', reason: REMEDIATION.growthReason }],
|
||||
'only the growth is ackable; the stale entry is removed, not added');
|
||||
});
|
||||
|
||||
test('the validation early-return renders instead of throwing', () => {
|
||||
// Found while building #2778. diffEmitted's input-validation early return omitted
|
||||
// `newFileCapExceeded`, and formatReport reads `result.newFileCapExceeded.length`
|
||||
// unconditionally — so this path threw `TypeError: Cannot read properties of
|
||||
// undefined` instead of printing its errors.
|
||||
//
|
||||
// Worst possible place for it: this branch is what runs when `git diff` failed or a
|
||||
// manifest came back malformed. The crash replaced the only message that would have
|
||||
// named the infrastructure problem, and a TypeError in a test helper reads like a
|
||||
// broken test rather than a broken environment.
|
||||
for (const bad of [
|
||||
{ baseline: null, current: {}, changedPaths: [] },
|
||||
{ baseline: {}, current: null, changedPaths: [] },
|
||||
{ baseline: {}, current: {}, changedPaths: null },
|
||||
{ baseline: [], current: {}, changedPaths: [] },
|
||||
]) {
|
||||
const r = diffEmitted(bad);
|
||||
assert.ok(!r.ok);
|
||||
assert.ok(r.errors.length > 0);
|
||||
assert.deepEqual(r.newFileCapExceeded, [], 'every returned shape must carry every bucket');
|
||||
|
||||
const report = buildReport(r);
|
||||
assert.equal(blockOf(report, 'errors').count, r.errors.length, 'the errors must render');
|
||||
assert.deepEqual(report.ackable, [], 'a malformed input is not something to acknowledge');
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed git diff renders as an error, never as "nothing changed"', () => {
|
||||
// The comment on that validation branch says a failed `git diff` must never be read as
|
||||
// an empty change set. That contract is only worth anything if the resulting report is
|
||||
// renderable — which it was not until the bucket above was restored.
|
||||
const r = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: null });
|
||||
assert.match(r.errors.join('\n'), /changedPaths must be an array/);
|
||||
assert.match(formatReport(r), /changedPaths must be an array/);
|
||||
});
|
||||
|
||||
test('a passing result produces no blocks and nothing to acknowledge', () => {
|
||||
// Remediation must never leak into a green run — it is failure text, not advice.
|
||||
const report = buildReport(diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [] }));
|
||||
assert.deepEqual(report.blocks, []);
|
||||
assert.deepEqual(report.ackable, []);
|
||||
assert.equal(formatReport(diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [] })), '');
|
||||
});
|
||||
|
||||
test('an acked growth produces no block and nothing to acknowledge', () => {
|
||||
// The contributor already did the thing the remediation asks for; repeating it is noise.
|
||||
const r = growthOnly({
|
||||
ack: { version: ACK_VERSION, paths: { 'explore.md': { reason: 'new mode section' } } },
|
||||
});
|
||||
assert.ok(r.ok);
|
||||
const report = buildReport(r);
|
||||
assert.equal(blockOf(report, 'unacked-growth'), undefined, 'an acknowledged growth is not a failure');
|
||||
assert.deepEqual(report.ackable, []);
|
||||
});
|
||||
|
||||
test('shrinkage produces no block and nothing to acknowledge', () => {
|
||||
const r = diffEmitted({
|
||||
baseline: mf({}), current: mf({}), changedPaths: [],
|
||||
sizeBaseline: { 'a.md': 9000 }, sizeCurrent: { 'a.md': 8000 },
|
||||
});
|
||||
assert.deepEqual(buildReport(r).blocks, [], 'shrinkage is reported in the result, never as failure');
|
||||
assert.deepEqual(buildReport(r).ackable, []);
|
||||
});
|
||||
|
||||
test('a mixed grown set offers an ack entry only for the unacked files', () => {
|
||||
const r = diffEmitted({
|
||||
baseline: mf({}), current: mf({}), changedPaths: [],
|
||||
sizeBaseline: { 'kept.md': 100, 'loud.md': 100 },
|
||||
sizeCurrent: { 'kept.md': 200, 'loud.md': 200 },
|
||||
ack: { version: ACK_VERSION, paths: { 'kept.md': { reason: 'declared' } } },
|
||||
});
|
||||
const report = buildReport(r);
|
||||
const growth = blockOf(report, 'unacked-growth');
|
||||
assert.equal(growth.count, 1, 'only the unacked one is counted');
|
||||
assert.deepEqual(growth.items.map((g) => g.name), ['loud.md']);
|
||||
assert.deepEqual(report.ackable, [{ key: 'loud.md', reason: REMEDIATION.growthReason }],
|
||||
'the document must key on the unacked file, not the acked one');
|
||||
});
|
||||
|
||||
test('the ack set is capped at the sample limit at limit-1 / limit / limit+1', () => {
|
||||
// CLAUDE.md's boundary rule. The document must not name rows the report chose not to
|
||||
// print — a contributor cannot acknowledge a path they were never shown.
|
||||
const build = (n) => {
|
||||
const sizeBaseline = {}; const sizeCurrent = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const k = `g${String(i).padStart(3, '0')}.md`;
|
||||
sizeBaseline[k] = 100; sizeCurrent[k] = 200;
|
||||
}
|
||||
return diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent });
|
||||
};
|
||||
for (const [n, expected] of [[19, 19], [20, 20], [21, 20]]) {
|
||||
const report = buildReport(build(n), { sampleLimit: 20 });
|
||||
assert.equal(blockOf(report, 'unacked-growth').count, n, `count reports all ${n}`);
|
||||
assert.equal(report.ackable.length, expected, `the document names ${expected} at n=${n}`);
|
||||
assert.ok(
|
||||
formatReport(build(n), { sampleLimit: 20 }).includes(REMEDIATION.growthKeyRule),
|
||||
`the key rule must survive n=${n}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the new-file cap block carries no ack affordance', () => {
|
||||
// The cap is NOT ack-able — the fix is extraction. Offering a document here would teach
|
||||
// a contributor to write an entry that cannot clear the gate, which is worse than the
|
||||
// silence it replaced.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({}), current: mf({}), changedPaths: [],
|
||||
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
|
||||
});
|
||||
const report = buildReport(r);
|
||||
const cap = blockOf(report, 'new-file-cap');
|
||||
assert.equal(cap.count, 1);
|
||||
assert.equal(cap.keyRule, undefined, 'the cap has no key rule because it has no ack');
|
||||
assert.deepEqual(report.ackable, [], 'the cap must never offer an acknowledgment');
|
||||
assert.ok(!formatReport(r).includes(REMEDIATION.ackFile), 'and must not point at the ack file');
|
||||
});
|
||||
|
||||
// ─── New-file cap (ADR-1610 Decision point 3, revived after #2724) ───────────
|
||||
//
|
||||
// tests/workflow-size-baseline.json used to double as the "has this file been
|
||||
|
||||
@@ -34,6 +34,17 @@ const { attributeEmittedPath } = require('./emitted-provenance.cjs');
|
||||
* shape is public the moment it ships (Hyrum). Loosening later is easy; tightening is not. */
|
||||
const ACK_VERSION = 1;
|
||||
|
||||
/**
|
||||
* The acknowledgment file, named ONCE (#2778).
|
||||
*
|
||||
* This string was previously typed by hand in `formatReport`'s unattributable branch, in
|
||||
* `parseAck`'s default `source`, and again as `ACK_PATH` in emitted-runtime.cjs. Adding a
|
||||
* fourth copy for the growth branch is the *generative fix divergence* class this repo
|
||||
* records: parallel surfaces reading one shared value must not be able to drift. One
|
||||
* definition consumed by every branch is cheaper than a parity test over four literals.
|
||||
*/
|
||||
const ACK_FILE = 'tests/emitted-drift-ack.json';
|
||||
|
||||
/**
|
||||
* A brand-new workflow/agent file — absent from the baseline, present now — must
|
||||
* still stay under the Codex `project_doc_max_bytes` anchor (ADR-1610 Decision
|
||||
@@ -57,6 +68,64 @@ const ACK_VERSION = 1;
|
||||
*/
|
||||
const NEW_FILE_CAP = 32768;
|
||||
|
||||
/**
|
||||
* Render the minimal valid acknowledgment document for a set of entries (#2778).
|
||||
*
|
||||
* Built with `JSON.stringify` from `ACK_VERSION` rather than typed out, for two reasons: the
|
||||
* printed document is guaranteed to be syntactically valid JSON, and it cannot fall out of
|
||||
* step with the version `parseAck` enforces. A hand-typed `"version": 1` sitting beside a live
|
||||
* `ACK_VERSION` is the drift this module warns about everywhere else.
|
||||
*
|
||||
* It teaches exactly ONE shape. `parseAck` is deliberately more liberal — it accepts a bare
|
||||
* string as the reason and tolerates a missing `version` or `paths`. Be liberal in what you
|
||||
* accept, conservative in what you send: advertising those tolerances would spread a quirk
|
||||
* into hand-written contributor files and make the canonical form look optional.
|
||||
*
|
||||
* It takes ALL the entries at once and renders ONE document, which is not a convenience:
|
||||
* a report can trip the hash branch and the size branch together (a feature PR that both
|
||||
* ripples an emitted path and grows a workflow). Printing a complete document per branch
|
||||
* made each look like "the file to create", so a contributor pasting the second over the
|
||||
* first would silently lose the first acknowledgment — an ack-lost failure with no signal.
|
||||
* One document, one file, one paste.
|
||||
*/
|
||||
function ackDocument(entries) {
|
||||
const paths = {};
|
||||
for (const { key, reason } of entries) paths[key] = { reason };
|
||||
return JSON.stringify({ version: ACK_VERSION, paths });
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-serve remediation, as data rather than prose scattered across branches (#2778).
|
||||
*
|
||||
* ADR-2719 §3 makes the acknowledgment a *conspicuous declaration a contributor makes
|
||||
* deliberately*. That only works if the contributor can discover how to make it. Before this,
|
||||
* the growth branch stated a requirement and withheld the means: it said "without an
|
||||
* acknowledgment" without naming the file, saying the file does not exist yet, giving the
|
||||
* schema, or saying which of the two key spaces applies — and it omitted the "do not
|
||||
* regenerate" instruction too, so the likeliest guess was to go hunting for a baseline file
|
||||
* #2724 deleted. Observed live on #2543.
|
||||
*
|
||||
* Exported as one frozen object rather than loose strings so the observable surface is
|
||||
* deliberate (Hyrum), and so tests can assert on identity instead of prose — rewording the
|
||||
* help text must not be a breaking change.
|
||||
*/
|
||||
const REMEDIATION = Object.freeze({
|
||||
ackFile: ACK_FILE,
|
||||
createIfAbsent: 'create the file if absent — it exists only when something needs acknowledging',
|
||||
doNotRegenerate:
|
||||
'Do NOT regenerate anything to silence this — there is nothing left to regenerate.',
|
||||
/** The size ratchet keys on `entry.name` from readdirSync (emitted-runtime.cjs `currentSizes`). */
|
||||
growthKeyRule: 'Key on the BARE FILENAME as it appears under gsd-core/workflows/ or agents/',
|
||||
/** The hash pass keys on the emitted manifest path, which always carries a `/`. */
|
||||
rippleKeyRule: 'Key on the EMITTED PATH exactly as printed above',
|
||||
rippleReason: '<why this ripple is deliberate>',
|
||||
growthReason: '<why this growth is deliberate>',
|
||||
staleAckFix:
|
||||
`Delete those entries from ${ACK_FILE}. If that leaves no entries, delete the file `
|
||||
+ 'itself — its PRESENCE is the alarm, so an empty one signals nothing.',
|
||||
ackDocument,
|
||||
});
|
||||
|
||||
/**
|
||||
* Does a changed repo path satisfy a provenance `sources` entry?
|
||||
*
|
||||
@@ -166,8 +235,14 @@ function diffEmitted({
|
||||
}
|
||||
if (errors.length) {
|
||||
return {
|
||||
// `newFileCapExceeded` MUST be present here. formatReport reads
|
||||
// `result.newFileCapExceeded.length` unconditionally, so omitting it made this
|
||||
// early return throw a TypeError instead of rendering the errors it was built to
|
||||
// report — and this is precisely the path taken when `git diff` failed or a
|
||||
// manifest came back malformed, so the crash replaced the one message that would
|
||||
// have explained the infrastructure problem (#2778).
|
||||
moved: 0, attributed: [], unattributable: [], acked: [], removed: [],
|
||||
grown: [], shrunk: [], staleAcks: [], errors, ok: false,
|
||||
grown: [], shrunk: [], newFileCapExceeded: [], staleAcks: [], errors, ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,9 +393,88 @@ function diffEmitted({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The report as a typed intermediate representation, before any rendering (#2778).
|
||||
*
|
||||
* `formatReport`'s output is the human-facing deliverable ADR-2719 §1 sells the design
|
||||
* on — but CONTRIBUTING.md ("Prohibited: Raw Text Matching on Test Outputs") is explicit
|
||||
* that a human formatter must expose a structured surface for tests to assert on, so a
|
||||
* reworded sentence is never a failing test and a passing test never depends on prose.
|
||||
* `formatReport` is a pure rendering of this; assert on this.
|
||||
*
|
||||
* @returns {{ blocks: Array<{kind: string} & object>, ackable: Array<{key: string, reason: string}> }}
|
||||
*/
|
||||
function buildReport(result, { sampleLimit = 20 } = {}) {
|
||||
const blocks = [];
|
||||
|
||||
if (result.errors.length) {
|
||||
blocks.push({
|
||||
kind: 'errors',
|
||||
count: result.errors.length,
|
||||
items: result.errors.slice(0, sampleLimit),
|
||||
});
|
||||
}
|
||||
|
||||
const unackedGrowth = result.grown.filter((g) => !g.acked);
|
||||
|
||||
if (result.unattributable.length) {
|
||||
blocks.push({
|
||||
kind: 'unattributable',
|
||||
count: result.unattributable.length,
|
||||
items: result.unattributable.slice(0, sampleLimit),
|
||||
truncated: Math.max(0, result.unattributable.length - sampleLimit),
|
||||
keyRule: REMEDIATION.rippleKeyRule,
|
||||
});
|
||||
}
|
||||
|
||||
if (unackedGrowth.length) {
|
||||
blocks.push({
|
||||
kind: 'unacked-growth',
|
||||
count: unackedGrowth.length,
|
||||
items: unackedGrowth.slice(0, sampleLimit),
|
||||
keyRule: REMEDIATION.growthKeyRule,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.newFileCapExceeded.length) {
|
||||
// Deliberately carries NO ack affordance: the new-file cap is not ack-able, and the
|
||||
// fix is extraction. Offering a document here would teach an entry that cannot clear
|
||||
// the gate — worse than the silence it replaced.
|
||||
blocks.push({
|
||||
kind: 'new-file-cap',
|
||||
count: result.newFileCapExceeded.length,
|
||||
items: result.newFileCapExceeded.slice(0, sampleLimit),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.staleAcks.length) {
|
||||
blocks.push({
|
||||
kind: 'stale-acks',
|
||||
count: result.staleAcks.length,
|
||||
items: result.staleAcks.slice(0, sampleLimit),
|
||||
fix: REMEDIATION.staleAckFix,
|
||||
});
|
||||
}
|
||||
|
||||
// ONE ack set for the whole report, not one per branch. A report can trip the hash
|
||||
// branch and the size branch at once, and two complete documents each reading as "the
|
||||
// file to create" invites pasting the second over the first — losing an acknowledgment
|
||||
// with no signal. Capped at `sampleLimit` per branch so the document stays consistent
|
||||
// with the lists above it rather than naming rows the report chose not to print.
|
||||
const ackable = [
|
||||
...result.unattributable.slice(0, sampleLimit)
|
||||
.map((u) => ({ key: u.rel, reason: REMEDIATION.rippleReason })),
|
||||
...unackedGrowth.slice(0, sampleLimit)
|
||||
.map((g) => ({ key: g.name, reason: REMEDIATION.growthReason })),
|
||||
];
|
||||
|
||||
return { blocks, ackable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a report as the failure message ADR-2719 §1 specifies — it sells the whole
|
||||
* design on this text, so it is a deliverable, not a detail.
|
||||
* design on this text, so it is a deliverable, not a detail. Pure rendering of
|
||||
* `buildReport`; tests assert on that IR, not on these sentences.
|
||||
*/
|
||||
function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
const parts = [];
|
||||
@@ -348,8 +502,7 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
(result.unattributable.length > sampleLimit
|
||||
? `\n …and ${result.unattributable.length - sampleLimit} more`
|
||||
: '') +
|
||||
'\n\nIf this ripple is intended, record it in tests/emitted-drift-ack.json naming each\n' +
|
||||
'path and why. Do NOT regenerate anything to silence this.',
|
||||
`\n\n${REMEDIATION.rippleKeyRule}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,7 +511,8 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
const list = unackedGrowth.slice(0, sampleLimit)
|
||||
.map((g) => ` ${g.name} grew ${g.delta} bytes (${g.from} -> ${g.to})`);
|
||||
parts.push(
|
||||
`${unackedGrowth.length} file(s) grew without an acknowledgment:\n${list.join('\n')}`,
|
||||
`${unackedGrowth.length} file(s) grew without an acknowledgment:\n${list.join('\n')}\n\n` +
|
||||
`${REMEDIATION.growthKeyRule}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,7 +528,20 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
parts.push(
|
||||
`${result.staleAcks.length} stale acknowledgment(s) — the ripple they explained is gone, ` +
|
||||
'so they must be deleted (an ack that outlives its ripple pre-clears the next one):\n ' +
|
||||
result.staleAcks.slice(0, sampleLimit).join('\n '),
|
||||
result.staleAcks.slice(0, sampleLimit).join('\n ') +
|
||||
`\n\n${REMEDIATION.staleAckFix}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The remedy, once, at the end — one file, one document, one paste.
|
||||
const { ackable } = buildReport(result, { sampleLimit });
|
||||
if (ackable.length) {
|
||||
parts.push(
|
||||
`To acknowledge, create ${REMEDIATION.ackFile}\n` +
|
||||
`(${REMEDIATION.createIfAbsent})\n` +
|
||||
'containing ONE document that names every path listed above and why:\n\n' +
|
||||
` ${REMEDIATION.ackDocument(ackable)}\n\n` +
|
||||
REMEDIATION.doNotRegenerate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +550,12 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
|
||||
module.exports = {
|
||||
ACK_VERSION,
|
||||
ACK_FILE,
|
||||
NEW_FILE_CAP,
|
||||
REMEDIATION,
|
||||
sourceSatisfiedBy,
|
||||
parseAck,
|
||||
diffEmitted,
|
||||
buildReport,
|
||||
formatReport,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user