* 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:
280
.github/workflows/ack-fragment-sweep.yml
vendored
Normal file
280
.github/workflows/ack-fragment-sweep.yml
vendored
Normal file
@@ -0,0 +1,280 @@
|
||||
name: Sweep spent ack fragments
|
||||
|
||||
# Companion to the `guard-no-ack-on-next` job in test.yml. That job DETECTS a
|
||||
# fully-spent emitted-drift-ack fragment surviving on `next` and reds the branch;
|
||||
# until #3875 the only remedy was a human reading CI prose and hand-authoring a
|
||||
# `git rm` PR. That remedy is structurally unable to keep up: the guard evaluates
|
||||
# dynamically at MERGE time, while a hand-authored sweep is a static set of
|
||||
# deletions fixed at BRANCH time, so any ack-carrying PR that merges in between
|
||||
# invalidates it. #3823 lost that race to #3809 on its own merge commit and left
|
||||
# `next` red for 24 consecutive pushes.
|
||||
#
|
||||
# This sweep closes that window by deriving the deletion list from the guard
|
||||
# ITSELF (`--sweep-plan`), on a timer, immediately before acting on it. It opens a
|
||||
# PR rather than pushing to `next` directly: `next` is protected, and an
|
||||
# acknowledgment is a reviewed artifact, so a human still approves the deletion.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ack-fragment-sweep
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep all-spent ack fragments on next
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: next
|
||||
# The guard reads each surviving fragment at the commit before HEAD.
|
||||
# Full history, not depth 2: the sweep branch is pushed from here, and
|
||||
# a shallow clone cannot be pushed to a protected-branch repo cleanly.
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Compute the sweep plan
|
||||
id: plan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# stdout is the machine-readable plan, stderr the guard's own prose —
|
||||
# the prose is teed into the job log so a run that sweeps nothing still
|
||||
# explains why (including which fragments the #3842 open-PR hold kept).
|
||||
#
|
||||
# `--defer-to-open-prs` is NOT optional here. Without it the plan names
|
||||
# every all-spent fragment including ones an open PR still modifies, and
|
||||
# deleting one of those hands that PR a modify/delete conflict it did not
|
||||
# cause — the exact failure #3842 exists to prevent.
|
||||
#
|
||||
# The hold is evaluated at PLAN time, not at merge time, so it narrows but
|
||||
# does not eliminate the conflict window: a PR opened after this step runs
|
||||
# that re-arms a planned fragment by appending prose to it can still meet a
|
||||
# modify/delete when this sweep lands. That residue is why the sweep opens a
|
||||
# reviewable PR rather than pushing to `next` — a human sees the diff, and
|
||||
# a conflict surfaces as a normal merge conflict on a bot PR rather than as
|
||||
# a surprise on someone else's.
|
||||
#
|
||||
# No `--base-ref`: a scheduled run has no `github.event.before`, so the
|
||||
# script's own `HEAD^` fallback applies. Note what that fallback actually
|
||||
# does — it is NOT simply conservative. On a rebase-merge landing N>=2
|
||||
# commits whose first adds a fragment, `HEAD^` lands on that first commit,
|
||||
# reads the fragment as already present, and plans it (the script says so
|
||||
# itself, at `resolveBaseRef`). That is acceptable HERE, and only here: a
|
||||
# fragment that has reached `next` is spent by the lifecycle definition
|
||||
# regardless of which commit of the push carried it, and the open-PR hold
|
||||
# below still protects any PR that is still touching it. The same fallback
|
||||
# would be wrong for the push-lane guard, which is exactly why test.yml
|
||||
# passes `github.event.before` explicitly instead.
|
||||
# `|| true` would be wrong here. In plan mode the script exits 0 for
|
||||
# EVERY verdict, so a non-zero status is a real fault — a crash, an
|
||||
# unreadable base ref, a rejected flag — and must fail the job loudly.
|
||||
# Swallowing it would leave plan.txt empty and report the fault as the
|
||||
# cheerful "nothing to sweep", which is the silent-failure class this
|
||||
# whole ack seam exists to end.
|
||||
set +e
|
||||
node scripts/lint-emitted-drift-ack.cjs \
|
||||
--guard-next --sweep-plan --defer-to-open-prs \
|
||||
> plan.txt 2> prose.txt
|
||||
guard_status=$?
|
||||
set -e
|
||||
echo '--- guard output ---'
|
||||
cat prose.txt
|
||||
if [ "$guard_status" -ne 0 ]; then
|
||||
echo "::error::guard exited ${guard_status} in plan mode — that is a fault, not a verdict"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s plan.txt ]; then
|
||||
# An empty plan has two very different causes, and reporting both as
|
||||
# "nothing to sweep" is how this automation would go quietly inert.
|
||||
#
|
||||
# Cause A: `next` is clean. Nothing to do, and silence is right.
|
||||
#
|
||||
# Cause B: every all-spent fragment is HELD by an open PR — and the
|
||||
# commonest such PR is the sweep PR THIS WORKFLOW opened last run, which
|
||||
# touches exactly the fragments it proposed to delete. So run #2 onward
|
||||
# would report a cheerful green while `next` stays red and the sweep PR
|
||||
# rots unmerged. Re-running the guard WITHOUT the hold separates the two:
|
||||
# a non-empty unheld set with an empty plan means "blocked, not done".
|
||||
set +e
|
||||
node scripts/lint-emitted-drift-ack.cjs \
|
||||
--guard-next --sweep-plan > unheld.txt 2>/dev/null
|
||||
unheld_status=$?
|
||||
set -e
|
||||
if [ "$unheld_status" -eq 0 ] && [ -s unheld.txt ]; then
|
||||
open_sweep=$(gh pr list --base next --state open \
|
||||
--json number,headRefName \
|
||||
--jq '[.[] | select(.headRefName | startswith("chore/ack-sweep-"))] | .[0].number // empty' \
|
||||
2>/dev/null || echo "")
|
||||
if [ -n "$open_sweep" ]; then
|
||||
echo "::warning::next still carries spent ack fragment(s); sweep PR #${open_sweep} is open and awaiting merge. Nothing further this run."
|
||||
else
|
||||
echo '::warning::next still carries spent ack fragment(s), but every one is held by an open PR that touches it (#3842). They will be swept once those PRs merge or close.'
|
||||
fi
|
||||
echo 'held fragments:'
|
||||
cat unheld.txt
|
||||
else
|
||||
echo 'nothing to sweep'
|
||||
fi
|
||||
echo 'empty=true' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Every planned path is re-validated before anything is deleted, against
|
||||
# a strict allowlist rather than a shape check. The plan comes from our
|
||||
# own script, but the fragment BASENAMES in it come from readdirSync and
|
||||
# are filtered only on the `.json` suffix, so any filename a merged PR
|
||||
# can land in that directory reaches this point.
|
||||
#
|
||||
# Two concrete attacks this closes. (1) A file literally named `*.json`
|
||||
# is a valid filename and passes a `tests/emitted-drift-acks/*.json`
|
||||
# glob test — and `git rm` treats each argument as a PATHSPEC, so it
|
||||
# would then expand to every fragment in the tree, including ones the
|
||||
# #3842 open-PR hold deliberately withheld. Verified locally: one such
|
||||
# file deletes all of them. (2) A filename containing backticks or
|
||||
# newlines is interpolated into the PR body below, injecting markdown
|
||||
# into a document this repo's review agents read.
|
||||
#
|
||||
# The allowlist admits only a leading alphanumeric followed by
|
||||
# alphanumerics, dot, underscore and hyphen — no glob metacharacters, no
|
||||
# whitespace, no backticks, no slash, so no traversal and no leading `-`
|
||||
# for git to read as an option. A filename containing a newline arrives
|
||||
# here as two lines, each of which fails the pattern: it fails closed.
|
||||
#
|
||||
# The plan carries two shapes: the legacy single document at a fixed path
|
||||
# (`tests/emitted-drift-ack.json`), whose mere PRESENCE reds `next`, and
|
||||
# fragment basenames under `tests/emitted-drift-acks/`. The legacy path is
|
||||
# matched exactly and literally; only the fragment arm takes a name pattern.
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
if ! printf '%s' "$line" \
|
||||
| grep -qE '^(tests/emitted-drift-ack\.json|tests/emitted-drift-acks/[A-Za-z0-9][A-Za-z0-9._-]*\.json)$'; then
|
||||
echo "::error::refusing to sweep unexpected path: $line"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$line" ]; then
|
||||
echo "::error::planned path is not a file: $line"
|
||||
exit 1
|
||||
fi
|
||||
done < plan.txt
|
||||
|
||||
echo 'empty=false' >> "$GITHUB_OUTPUT"
|
||||
echo 'planned fragments:'
|
||||
cat plan.txt
|
||||
|
||||
- name: Open the sweep PR
|
||||
if: steps.plan.outputs.empty == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
HAS_BOT_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN != '' && '1' || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `GITHUB_TOKEN` cannot raise workflow runs for the events it creates, so a
|
||||
# PR opened under the fallback never reports a single required check and
|
||||
# sits permanently pending. That is a silent, confusing failure, so it is
|
||||
# announced rather than discovered.
|
||||
if [ -z "${HAS_BOT_TOKEN:-}" ]; then
|
||||
echo '::warning::GSD_BOT_PR_TOKEN is unset; opening the sweep PR with GITHUB_TOKEN. No required checks will run on it and it will not be mergeable until a maintainer pushes to the branch.'
|
||||
fi
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
BR="chore/ack-sweep-${SHORT_SHA}"
|
||||
|
||||
# An earlier run may already have opened a sweep PR for this same tip.
|
||||
EXISTING=$(gh pr list --base next --head "$BR" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "sweep PR #${EXISTING} already open for ${SHORT_SHA}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git checkout -b "$BR"
|
||||
|
||||
# `:(literal)` pathspec magic, one path per call. `--` stops OPTION
|
||||
# parsing but git still reads each remaining argument as a pathspec with
|
||||
# wildmatch semantics, so a fragment named `*.json` would expand to every
|
||||
# fragment in the directory. `:(literal)` disables that globbing and makes
|
||||
# each argument mean exactly the bytes it contains. The allowlist in the
|
||||
# plan step already rejects such a name; this is the second, independent
|
||||
# layer, because over-deletion here is unrecoverable within the run.
|
||||
while IFS= read -r frag; do
|
||||
[ -n "$frag" ] || continue
|
||||
git rm -q -- ":(literal)${frag}"
|
||||
done < plan.txt
|
||||
|
||||
COUNT=$(grep -c . plan.txt)
|
||||
LIST=$(sed 's/^/- /' plan.txt)
|
||||
|
||||
git commit -q -m "chore(#3875): sweep ${COUNT} spent ack fragment(s) from next"
|
||||
|
||||
# A previous run can have pushed this branch and then died before
|
||||
# `gh pr create`, or a human can have closed the PR without deleting the
|
||||
# branch. In both cases the open-PR probe above finds nothing, the rebuilt
|
||||
# commit gets a fresh committer timestamp and therefore a different sha,
|
||||
# and a plain push is rejected as non-fast-forward — every six hours,
|
||||
# forever, with no path to sweep that tip. Re-point the stale branch
|
||||
# instead, under a lease so a branch someone else has moved is never
|
||||
# clobbered blind.
|
||||
if git ls-remote --exit-code --heads origin "$BR" >/dev/null 2>&1; then
|
||||
git fetch -q origin "$BR"
|
||||
git push -q --force-with-lease="${BR}:$(git rev-parse FETCH_HEAD)" origin "$BR"
|
||||
else
|
||||
git push -q origin "$BR"
|
||||
fi
|
||||
|
||||
# No apostrophes in this heredoc body: bash scans $( ) for quotes before
|
||||
# it recognises the nested heredoc, so a lone ' here is an unterminated
|
||||
# quote and a hard syntax error at runtime, not just under `bash -n`.
|
||||
BODY=$(cat <<EOF
|
||||
Automated sweep of fully-spent emitted-drift-ack fragment(s) surviving on \`next\`.
|
||||
|
||||
Every entry in each fragment below is already at the base, so it is spent and
|
||||
gates nothing (#2789) — but it still OWNS its path keys, which walls off the
|
||||
next PR that grows one of them (#3078). Leaving them in place reds \`next\` on
|
||||
every push.
|
||||
|
||||
${LIST}
|
||||
|
||||
The list was produced by \`lint-emitted-drift-ack.cjs --guard-next --sweep-plan
|
||||
--defer-to-open-prs\` — the same computation the \`guard-no-ack-on-next\` job
|
||||
runs — so it reflects the verdict the guard itself produced at \`${SHORT_SHA}\`, not a
|
||||
re-derived or hand-maintained list. Any fragment an open PR still touches was
|
||||
held back rather than swept (#3842).
|
||||
|
||||
Refs #3875. Generated by \`.github/workflows/ack-fragment-sweep.yml\`.
|
||||
EOF
|
||||
)
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--base next \
|
||||
--head "$BR" \
|
||||
--title "chore: sweep spent ack fragments from next (${SHORT_SHA})" \
|
||||
--body "$BODY")
|
||||
PR="${PR_URL##*/}"
|
||||
echo "opened ${PR_URL}"
|
||||
|
||||
# no-changelog: deleting spent acknowledgment paperwork is not a
|
||||
# user-facing change, so it carries no changeset fragment.
|
||||
#
|
||||
# Not `|| true`: `no-changelog` is what exempts this PR from the changeset
|
||||
# gate, so losing it leaves the PR red for a reason that has nothing to do
|
||||
# with its contents. Still non-fatal — the PR itself is already open and
|
||||
# useful — but it must be visible.
|
||||
if ! gh pr edit "$PR" --add-label automation --add-label no-changelog; then
|
||||
echo "::warning::could not label PR #${PR}; the changeset gate will need the no-changelog label applied by hand."
|
||||
fi
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -153,12 +153,16 @@ The differential attribution check reports the file and the byte delta. To resol
|
||||
ack sources naming the same path is a hard, loudly-reported error.
|
||||
The legacy single `tests/emitted-drift-ack.json` is still read and unioned
|
||||
in for branches that carry it, but new acknowledgments never go there.
|
||||
3. **Delete your fragment once it has merged (#3078).** A fragment on `next` is
|
||||
spent by definition — its prose is already at the base, so it can no longer
|
||||
3. **Your fragment is deleted once it has merged (#3078).** A fragment on `next`
|
||||
is spent by definition — its prose is already at the base, so it can no longer
|
||||
clear anything — while still owning its path keys, which walls off the next PR
|
||||
that grows one of them. The `guard-no-ack-on-next` job reds `next` and prints
|
||||
the exact `git rm` for every fully-spent fragment. A *partially* spent fragment
|
||||
is deliberately left alone.
|
||||
is deliberately left alone. Since #3875 you do not have to run that `git rm`:
|
||||
the `ack-fragment-sweep` workflow (`.github/workflows/ack-fragment-sweep.yml`)
|
||||
asks the guard for its own sweep list every six hours and opens a PR deleting
|
||||
exactly what it named, holding back any fragment an open PR still touches
|
||||
(#3842).
|
||||
4. **Or shrink it instead of acknowledging.** Prefer extraction when the growth
|
||||
is incidental: for a workflow, move per-mode bodies to
|
||||
`workflows/<name>/modes/`, templates to `workflows/<name>/templates/`, and
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -732,11 +732,24 @@ function fetchOpenPrTouchedAckPaths({ cwd = REPO_ROOT, execGh = execGhDefault, l
|
||||
* @param {() => Set<string>} [opts.fetchOpenPrPaths] defaults to `fetchOpenPrTouchedAckPaths`.
|
||||
* Injectable so a test can supply canned open-PR data (or a throwing stub, to exercise
|
||||
* the fail-closed "hold everything" path) without shelling out to a real `gh`.
|
||||
* @returns {{ ok: boolean, lines: string[] }}
|
||||
* `sweepable` is the fragment basenames the guard would have the caller `git rm` — the
|
||||
* same set the prose names, already narrowed by the #3842 open-PR hold, so a held
|
||||
* fragment never appears in it. Surfaced as DATA rather than left to be scraped back out
|
||||
* of `lines`, because the sweeper (#3875) must act on exactly the set the guard reasoned
|
||||
* about: a sweeper that re-derives the list, or greps it out of the message text, can
|
||||
* drift from the guard and delete a fragment the hold was protecting.
|
||||
* `legacyPresent` is reported separately from `sweepable` because the two are different
|
||||
* KINDS of cruft with the same remedy: `sweepable` holds fragment basenames under
|
||||
* `ACK_DIR_REPO_PATH`, whereas the legacy document is one fixed path. Folding it into
|
||||
* `sweepable` would make a consumer prefix it with the fragment directory and try to
|
||||
* delete a path that does not exist. Without it the sweeper would be blind to exactly
|
||||
* one of the two ways this guard can red `next`.
|
||||
* @returns {{ ok: boolean, lines: string[], sweepable: string[], legacyPresent: boolean }}
|
||||
*/
|
||||
function runGuardNext({ argv = process.argv, cwd = REPO_ROOT, fetchOpenPrPaths = fetchOpenPrTouchedAckPaths } = {}) {
|
||||
const legacyFile = path.join(cwd, ...ACK_REPO_PATH.split('/'));
|
||||
const legacy = assertAbsentOnNext(fs.existsSync(legacyFile));
|
||||
const legacyPresent = fs.existsSync(legacyFile);
|
||||
const legacy = assertAbsentOnNext(legacyPresent);
|
||||
const lines = [legacy.message];
|
||||
|
||||
// The fragment half (#3078). CI always passes `--base-ref` (the pre-push tip of `next`,
|
||||
@@ -776,20 +789,58 @@ function runGuardNext({ argv = process.argv, cwd = REPO_ROOT, fetchOpenPrPaths =
|
||||
const sweep = assertNoAllSpentFragments(fragments, { openPrTouchedPaths });
|
||||
lines.push(sweep.message);
|
||||
|
||||
return { ok: legacy.ok && sweep.ok, lines };
|
||||
return { ok: legacy.ok && sweep.ok, lines, sweepable: sweep.sweepable, legacyPresent };
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.argv.includes('--guard-next')) {
|
||||
const result = runGuardNext();
|
||||
for (const line of result.lines) console.log(line);
|
||||
/**
|
||||
* CLI entry. Dependencies are injectable so the `--guard-next` / `--sweep-plan` argv
|
||||
* routing is testable in-process: `main()` otherwise reads `process.argv` and writes
|
||||
* through `console`, and the only way to observe it would be a subprocess run against
|
||||
* the REAL repository — which cannot exhibit an arbitrary sweep set on demand, so the
|
||||
* interesting cases would go uncovered.
|
||||
*
|
||||
* `cwd`, `out` and `err` are honoured by BOTH lanes, not just the guard lane. A seam
|
||||
* that is injected halfway is worse than one that is not injected at all: a test
|
||||
* passing `cwd` to the validation lane would silently read the real repository and
|
||||
* report on whatever happens to be checked in, which is a pass-always test wearing the
|
||||
* costume of a real one.
|
||||
*/
|
||||
function main({
|
||||
argv = process.argv,
|
||||
cwd = REPO_ROOT,
|
||||
out = console.log,
|
||||
err = console.error,
|
||||
guard = runGuardNext,
|
||||
} = {}) {
|
||||
if (argv.includes('--guard-next')) {
|
||||
const result = guard({ argv });
|
||||
|
||||
// `--sweep-plan` (#3875) turns the guard from a VERDICT into a WORK LIST. The
|
||||
// sweeper workflow needs the exact set the guard reasoned about, so the plan goes
|
||||
// to stdout alone and the prose is diverted to stderr — a caller doing
|
||||
// `xargs git rm` on stdout must never receive an explanatory sentence as a
|
||||
// filename. Plan mode also exits 0 even when the verdict is a failure: a
|
||||
// non-empty plan is the NORMAL case it exists to report, and a non-zero exit
|
||||
// would fail the workflow step before it could act on the very list it asked for.
|
||||
if (argv.includes('--sweep-plan')) {
|
||||
for (const line of result.lines) err(line);
|
||||
// The legacy document leads the plan: it is a fixed path rather than a name
|
||||
// under the fragment directory, and `assertAbsentOnNext` reds `next` on its
|
||||
// PRESENCE alone. Omitting it would leave the sweeper able to fix only one of
|
||||
// the two conditions that make this guard fail.
|
||||
if (result.legacyPresent) out(ACK_REPO_PATH);
|
||||
for (const name of result.sweepable) out(`${ACK_DIR_REPO_PATH}/${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of result.lines) out(line);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyFile = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/'));
|
||||
const legacyFile = path.join(cwd, ...ACK_REPO_PATH.split('/'));
|
||||
|
||||
const fragmentsDir = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/'));
|
||||
const fragmentsDir = path.join(cwd, ...ACK_DIR_REPO_PATH.split('/'));
|
||||
const sources = [
|
||||
{ label: ACK_REPO_PATH, raw: readIfPresent(legacyFile) },
|
||||
...listFragmentFiles(fragmentsDir).map((name) => ({
|
||||
@@ -835,9 +886,9 @@ function main() {
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`lint-emitted-drift-ack: ${problems.length} problem(s)\n`);
|
||||
for (const e of problems) console.error(` - ${e}`);
|
||||
console.error(
|
||||
err(`lint-emitted-drift-ack: ${problems.length} problem(s)\n`);
|
||||
for (const e of problems) err(` - ${e}`);
|
||||
err(
|
||||
'\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, and a duplicate across two sources is exactly the silent-drift class '
|
||||
@@ -847,7 +898,7 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
out(
|
||||
anyPresent
|
||||
? 'ok lint-emitted-drift-ack: all acknowledgment sources are well-formed'
|
||||
: 'ok lint-emitted-drift-ack: no acknowledgment sources present (the healthy steady state)',
|
||||
@@ -881,4 +932,5 @@ module.exports = {
|
||||
GITHUB_MAX_PR_FILES,
|
||||
GH_TIMEOUT_MS,
|
||||
runGuardNext,
|
||||
main,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user