chore(#3875): sweep the spent ack fragments and automate the sweep (#3877)

* chore(#3875): sweep the spent ack fragments and automate the sweep

next has been red on every push since a84f75630 (#3823) — 24 consecutive pushes
over two days — on two fully-spent emitted-drift-ack fragments nobody swept.

#3823 introduced guard-no-ack-on-next together with a 45-fragment sweep, but
computed that sweep as a static set of deletions fixed at its branch point.
#3809's fragment merged to next while #3823 was in flight, so the guard reds on
its own merge commit. The condition is evaluated dynamically at merge time and
remediated statically at branch time; on a moving branch the second can never
reliably satisfy the first.

- delete tests/emitted-drift-acks/3809-* and 3866-* (3034-* and 3172-* stay --
  the #3842 open-PR hold correctly defers them)
- runGuardNext returns `sweepable`, the set the guard actually reasoned about,
  plus `legacyPresent` for the legacy document, which is a fixed path rather
  than a fragment basename and would otherwise be invisible to any sweeper
- new --sweep-plan mode turns the guard into a work list: plan on stdout, prose
  on stderr, exit 0 so a non-empty plan does not fail the step that asked for it
- main() is injectable in BOTH lanes; a half-injected seam lets a test that
  passes cwd silently read the real repository instead of its fixture
- ack-fragment-sweep.yml derives its deletion list from that plan on a timer and
  opens a reviewable PR, so the sweep can no longer go stale between branch
  and merge

Hardening found in review, each verified against a live reproduction:

- git rm reads its arguments as PATHSPECS with wildmatch semantics, so a
  fragment named a bare-star .json name -- legal, and admitted by
  listFragmentFiles since it filters only on the suffix -- expanded to every
  fragment in the directory, including ones the #3842 hold withheld. Confirmed
  in a scratch repo: one such file deleted all three. Closed with a literal
  allowlist and a :(literal) pathspec, two independent layers.
- an apostrophe inside a heredoc nested in a command substitution is an
  unterminated quote and a hard syntax error at runtime, not just under bash -n.
- an empty plan no longer reports success unconditionally: the guard is re-run
  without the hold to tell "next is clean" from "everything is held", the
  commonest holder being the sweep PR from the previous run, which touches
  exactly the fragments it proposed to delete.
- a branch pushed by a run that died before it could open the PR wedged every
  later run on a non-fast-forward push; re-pointed under a lease instead.
- a guard crash in plan mode no longer reads as "nothing to sweep".

Refs #3875

* chore(#3875): regenerate CONTEXT-INDEX.json for the glossary entry

lint:generated-sync failed on CI: gen-context-index.cjs derives
docs/CONTEXT-INDEX.json from CONTEXT.md, and the RULESET.EMITTED_ATTRIBUTION
entry added in the previous commit left it stale.

Refs #3875

* chore(#3875): regenerate the example CONTEXT-INDEX for the glossary entry

CONTEXT.md feeds TWO committed indexes, not one: docs/CONTEXT-INDEX.json via
scripts/gen-context-index.cjs, and the examples/dynamic-context-management copy
that lint-example-parser-parity holds to a fresh parse. The previous commit
regenerated only the first, so the parity check stayed red.

Refs #3875

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-25 23:17:29 -04:00
committed by GitHub
parent 1863f5569c
commit 8f674281fd
10 changed files with 952 additions and 301 deletions

View File

@@ -91,6 +91,7 @@ const {
MAX_PR_FILES,
GITHUB_MAX_PR_FILES,
runGuardNext,
main,
} = require('../scripts/lint-emitted-drift-ack.cjs');
const {
ACK_VERSION,
@@ -4720,3 +4721,322 @@ describe('#3842: runGuardNext wires --defer-to-open-prs end to end against a rea
}
});
});
// ── #3875: the sweep set as DATA, and the --sweep-plan work-list mode ───────
//
// `next` sat red for 24 consecutive pushes (2026-08-24 → 2026-08-26) on two
// all-spent fragments nobody swept. The guard computed the right answer every
// time; what was missing was a way for anything except a human reading CI prose
// to ACT on it. #3823 shipped the guard already-failing for the same reason: its
// own sweep was a static list of deletions fixed at branch time, and #3809's
// fragment merged while it was in flight, so the guard reds on its own merge
// commit. A sweeper has to read the set the guard actually reasoned about — not
// re-derive it, and not scrape it back out of the message text.
describe('#3875: runGuardNext surfaces the sweepable set as data', () => {
test('sweepable names the spent fragment the prose names', () => {
const repo = makeGuardNextRepo();
try {
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
const c2 = repo.commit('add fragment');
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
repo.commit('unrelated change');
const result = runGuardNext({
argv: ['node', 'script', '--guard-next', '--base-ref', c2],
cwd: repo.dir,
});
assert.ok(!result.ok, 'the fully-spent fragment must fail the guard');
assert.deepEqual(result.sweepable, ['a.json']);
} finally {
cleanup(repo.dir);
}
});
test('a fragment held by the #3842 open-PR deferral never appears in sweepable', () => {
const repo = makeGuardNextRepo();
try {
repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } });
const c2 = repo.commit('add fragment');
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
repo.commit('unrelated change');
const result = runGuardNext({
argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'],
cwd: repo.dir,
fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/a.json`]),
});
assert.ok(result.ok, 'a held fragment must not fail the guard');
assert.deepEqual(
result.sweepable, [],
'a sweeper acting on this list must never delete a fragment the hold was protecting',
);
} finally {
cleanup(repo.dir);
}
});
test('a clean tree yields an empty sweepable set', () => {
const repo = makeGuardNextRepo();
try {
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
const c1 = repo.commit('root');
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'again\n');
repo.commit('second');
const result = runGuardNext({
argv: ['node', 'script', '--guard-next', '--base-ref', c1],
cwd: repo.dir,
});
assert.ok(result.ok);
assert.deepEqual(result.sweepable, []);
} finally {
cleanup(repo.dir);
}
});
});
describe('#3875: --sweep-plan emits a work list, not a verdict', () => {
// `main()` reads process.argv and writes through console, so its argv routing is
// observable in-process only via the injected seams. A subprocess run against the
// real repository cannot exhibit an arbitrary sweep set on demand, which is exactly
// the interesting case.
const runMain = (argv, guardResult) => {
const out = [];
const err = [];
const saved = process.exitCode;
process.exitCode = undefined;
try {
main({
argv,
out: (line) => out.push(String(line)),
err: (line) => err.push(String(line)),
guard: () => guardResult,
});
return { out, err, exitCode: process.exitCode };
} finally {
process.exitCode = saved;
}
};
const spent = {
ok: false,
lines: ['guard prose line'],
sweepable: ['a.json', 'b.json'],
legacyPresent: false,
};
test('the plan is repo-relative fragment paths on stdout, one per line', () => {
const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent);
assert.deepEqual(r.out, [
`${ACK_DIR_REPO_PATH}/a.json`,
`${ACK_DIR_REPO_PATH}/b.json`,
]);
});
test('the guard prose is diverted to stderr so stdout is safe to pipe', () => {
const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent);
assert.deepEqual(
r.err, ['guard prose line'],
'a caller doing `xargs git rm` on stdout must never receive a sentence as a filename',
);
});
test('a non-empty plan still exits 0 — a work list is not a failure', () => {
const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent);
// `equal(..., undefined)`, not `notEqual(..., 1)`: the weaker form passes for
// ANY value that is not 1, so an implementation setting exitCode to 2 — or to
// anything at all — would survive it.
assert.equal(
r.exitCode, undefined,
'plan mode must leave the exit code untouched; a non-zero exit would fail the sweeper step before it could act on the list it asked for',
);
});
test('a clean tree emits an empty plan, still reports the prose, and exits 0', () => {
const r = runMain(
['node', 'script', '--guard-next', '--sweep-plan'],
{ ok: true, lines: ['all clear'], sweepable: [], legacyPresent: false },
);
assert.deepEqual(r.out, []);
// Asserting only the empty stdout would be vacuous — it holds for an
// implementation that emits nothing anywhere. The prose must still reach
// stderr, or a silent run is indistinguishable from a broken one.
assert.deepEqual(r.err, ['all clear']);
assert.equal(r.exitCode, undefined);
});
test('without --sweep-plan the guard lane is unchanged: prose on stdout, exit 1', () => {
const r = runMain(['node', 'script', '--guard-next'], spent);
assert.deepEqual(r.out, ['guard prose line']);
assert.deepEqual(r.err, []);
assert.equal(r.exitCode, 1, 'the verdict lane must keep failing on a surviving spent fragment');
});
test('the legacy ack document leads the plan when present', () => {
const r = runMain(
['node', 'script', '--guard-next', '--sweep-plan'],
{ ok: false, lines: ['prose'], sweepable: ['a.json'], legacyPresent: true },
);
assert.deepEqual(r.out, [ACK_REPO_PATH, `${ACK_DIR_REPO_PATH}/a.json`]);
});
test('the legacy ack document is absent from the plan when it is not on the tree', () => {
const r = runMain(
['node', 'script', '--guard-next', '--sweep-plan'],
{ ok: false, lines: ['prose'], sweepable: ['a.json'], legacyPresent: false },
);
assert.deepEqual(r.out, [`${ACK_DIR_REPO_PATH}/a.json`]);
});
test('--sweep-plan without --guard-next falls through to the validation lane', () => {
const repo = makeGuardNextRepo();
try {
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
repo.commit('root');
const out = [];
const err = [];
main({
argv: ['node', 'script', '--sweep-plan'],
cwd: repo.dir,
out: (line) => out.push(String(line)),
err: (line) => err.push(String(line)),
});
// `--sweep-plan` is meaningful only alongside `--guard-next`. Pinned so the
// routing cannot quietly change into "plan mode implies guard mode", which
// would make a bare --sweep-plan print a work list computed against a base
// ref nobody asked for.
assert.equal(err.length, 0);
assert.equal(out.length, 1);
assert.ok(out[0].startsWith('ok lint-emitted-drift-ack:'), `got: ${out[0]}`);
} finally {
cleanup(repo.dir);
}
});
});
describe('#3875: the legacy document and the fragment directory are separate sweep inputs', () => {
test('runGuardNext reports a revived legacy ack document as present', () => {
const repo = makeGuardNextRepo();
try {
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
const c1 = repo.commit('root');
const legacy = path.join(repo.dir, ...ACK_REPO_PATH.split('/'));
fs.mkdirSync(path.dirname(legacy), { recursive: true });
fs.writeFileSync(legacy, JSON.stringify({ version: ACK_VERSION, paths: {} }));
repo.commit('revive the legacy file');
const result = runGuardNext({
argv: ['node', 'script', '--guard-next', '--base-ref', c1],
cwd: repo.dir,
});
assert.equal(result.ok, false, 'a legacy ack document on next must fail the guard');
assert.equal(
result.legacyPresent, true,
'without this the sweeper is blind to one of the two ways this guard reds next',
);
} finally {
cleanup(repo.dir);
}
});
test('runGuardNext reports legacyPresent false on a tree that has none', () => {
const repo = makeGuardNextRepo();
try {
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
const c1 = repo.commit('root');
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'again\n');
repo.commit('second');
const result = runGuardNext({
argv: ['node', 'script', '--guard-next', '--base-ref', c1],
cwd: repo.dir,
});
assert.equal(result.legacyPresent, false);
} finally {
cleanup(repo.dir);
}
});
// The live shape on `next` when #3875 was written: four all-spent fragments, two
// of them held by an open PR. An implementation that always returned an empty
// `sweepable` would pass the all-held and clean-tree cases; only a mixed one
// proves the partition is real.
test('a mixed tree partitions into swept and held, and reports both', () => {
const repo = makeGuardNextRepo();
try {
repo.writeFrag('held.json', { version: ACK_VERSION, paths: { 'h.md': { reason: 'held' } } });
repo.writeFrag('sweep.json', { version: ACK_VERSION, paths: { 's.md': { reason: 'sweep' } } });
const c2 = repo.commit('add two fragments');
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n');
repo.commit('unrelated change');
const result = runGuardNext({
argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'],
cwd: repo.dir,
fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/held.json`]),
});
assert.deepEqual(result.sweepable, ['sweep.json'], 'only the unheld fragment is sweepable');
assert.ok(!result.ok, 'an unheld all-spent fragment still fails the guard');
const prose = result.lines.join('\n');
assert.ok(prose.includes('held.json') && prose.includes('held'), 'the held fragment must still be reported, not silently dropped');
assert.ok(prose.includes('git rm') && prose.includes('sweep.json'), 'the sweepable fragment must name its remedy');
} finally {
cleanup(repo.dir);
}
});
test('main() honours injected cwd and out in the validation lane, not just the guard lane', () => {
const repo = makeGuardNextRepo();
try {
fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n');
repo.commit('root');
const out = [];
const err = [];
main({
argv: ['node', 'script'],
cwd: repo.dir,
out: (line) => out.push(String(line)),
err: (line) => err.push(String(line)),
});
// A half-injected seam is worse than none: with `cwd` ignored this reads the
// REAL repository and reports on whatever is checked in, which passes for
// reasons entirely unrelated to the fixture.
assert.equal(err.length, 0, 'a clean fixture tree has no problems to report');
assert.equal(out.length, 1, 'exactly one summary line, and on the injected sink');
assert.ok(out[0].includes('no acknowledgment sources present'), `got: ${out[0]}`);
} finally {
cleanup(repo.dir);
}
});
test('main() reports a malformed fragment through the injected err sink', () => {
const repo = makeGuardNextRepo();
try {
repo.writeFrag('bad.json', {});
fs.writeFileSync(
path.join(repo.dir, ...ACK_DIR_REPO_PATH.split('/'), 'bad.json'),
'not json at all',
);
repo.commit('add a malformed fragment');
const out = [];
const err = [];
const saved = process.exitCode;
process.exitCode = undefined;
try {
main({
argv: ['node', 'script'],
cwd: repo.dir,
out: (line) => out.push(String(line)),
err: (line) => err.push(String(line)),
});
assert.ok(err.length > 0, 'the malformed fragment must reach the injected err sink');
assert.ok(err.join('\n').includes('bad.json'), 'the report must name the offending fragment');
} finally {
process.exitCode = saved;
}
} finally {
cleanup(repo.dir);
}
});
});

View File

@@ -1,9 +0,0 @@
{
"$comment": "Growth ack (#2914 fragment). Reason: #3809 routes every command-position shim reference in runtime-loaded markdown through the canonical gsd_run launcher. The substitution SHRANK 19 emitted files; this is the only one that grew. Its line 65 is a descriptive comment inside a fenced block, and rewriting it to name a command at all would place a gsd_run token ahead of the file's canonical preamble at line 158, which runtime-launcher-parity's (B-agents) arm correctly rejects. The comment therefore names no command and explains where the config is actually loaded instead, costing 3 bytes. gsd-research-synthesizer.md 13847 -> 13850 LF bytes (+3).",
"version": 1,
"paths": {
"gsd-research-synthesizer.md": {
"reason": "descriptive comment reworded to name no command, so the file's first gsd_run token stays behind its canonical preamble (runtime-launcher-parity B-agents); +3 bytes"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"$comment": "Growth ack (#2914 fragment). Reason: #3866 opens the verify lane to capabilities. verify-work.md's verify_pre_hooks step dispatched `kind == \"gate\"` only, so getWiredKinds reported verify:pre -> {gate} and gen-capability-registry's validateHooksWired rejected any capability declaring a step or contribution there — a capability could refuse to let UAT start but never contribute to what UAT covers. The growth is the two new dispatch arms (contribution + step, deferring to gsd-core/references/loop-hook-dispatch.md and carrying its ref.command in-context validation guard) plus the additive extract_tests consumption seam for the produces[] artefact names those steps declare. Prose is the product here: the arms ARE the dispatch contract an executing agent reads, so there is no smaller form. Includes the in-context allowlist for manifest-supplied produces names (isolated security review) and the artefact-shape contract (spec review), both of which a capability author must be able to read at the point of use. verify-work.md 35973 -> 39212 LF bytes (+3239).",
"version": 1,
"paths": {
"verify-work.md": {
"reason": "#3866: verify:pre gains contribution + step dispatch arms and extract_tests gains the produces[] consumption seam with its in-context name allowlist and artefact-shape contract; the dispatch contract is executable prose, so the arms cannot be expressed shorter; +3239 bytes"
}
}
}

View File

@@ -21,11 +21,24 @@ survives the sweep that empties it.
unattributable **hash** ripple is keyed on the emitted path
(`skills/gsd-add-tests/SKILL.md`); **growth** is keyed on the bare filename as
it appears under `gsd-core/workflows/` or `agents/` (`explore.md`).
3. **Delete the fragment once it has merged (#3078).** Every entry is scoped to
the diff that introduced it, so the moment it lands on `next` its prose is
3. **The fragment is deleted once it has merged (#3078).** Every entry is scoped
to the diff that introduced it, so the moment it lands on `next` its prose is
already at the base — it is spent and can no longer clear anything, while
still owning its path keys. The `guard-no-ack-on-next` job reds `next` and
prints the exact `git rm`. Run it.
prints the exact `git rm` for every fully-spent fragment.
You no longer have to run that `git rm` yourself (#3875). The
`ack-fragment-sweep` workflow runs every six hours, asks the guard for its own
sweep list (`--sweep-plan`), and opens a PR deleting exactly what the guard
named. Deleting the fragment in a follow-up PR by hand still works and is
still welcome — it is simply no longer the only thing standing between a
merged fragment and a red `next`.
The sweep is automated because the manual remedy could not keep up. The guard
evaluates at MERGE time; a hand-authored `git rm` is fixed at BRANCH time, so
any ack-carrying PR that merges in between invalidates it. #3823 lost exactly
that race to #3809 on its own merge commit and left `next` red for 24
consecutive pushes.
## Why the sweep exists