mandrel 2.54.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/ledger-pr.js — everything the ledger PR is made of.
|
|
3
|
+
*
|
|
4
|
+
* The mechanical half of `--ledger-commit`: how the branch is named, when the
|
|
5
|
+
* checkout is refused, the commit sequence itself, the push, and the PR body.
|
|
6
|
+
* `ledger-commit.js` next door keeps only the two entry points and the
|
|
7
|
+
* persistence assessment they share, so the run sequence there reads as a
|
|
8
|
+
* sequence rather than as a git driver.
|
|
9
|
+
*
|
|
10
|
+
* Every refusal in this module happens **before** its first write, so a refused
|
|
11
|
+
* run cannot have left a branch or a commit behind. The git and `gh` seams are
|
|
12
|
+
* injected (`.agents/rules/test-seams.md`), so the whole retry matrix is
|
|
13
|
+
* assertable without a live remote.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run a read-only git probe that must never throw: a checkout with no commits
|
|
18
|
+
* (or no repository at all) is a legitimate answer of "nothing to report", not
|
|
19
|
+
* a crash. The write path uses `runStep` instead, where a failure IS fatal.
|
|
20
|
+
*
|
|
21
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
22
|
+
* @param {string} cwd
|
|
23
|
+
* @param {string[]} args
|
|
24
|
+
* @returns {string} trimmed stdout, or `''` when git failed.
|
|
25
|
+
*/
|
|
26
|
+
export function probeGit(git, cwd, args) {
|
|
27
|
+
try {
|
|
28
|
+
const out = git(cwd, ...args);
|
|
29
|
+
return typeof out === 'string' ? out.trim() : '';
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Does a ref resolve in this checkout? Read-only, and never fatal — an absent
|
|
37
|
+
* ref is the answer, not an error.
|
|
38
|
+
*
|
|
39
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
40
|
+
* @param {string} cwd
|
|
41
|
+
* @param {string} ref
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function refExists(git, cwd, ref) {
|
|
45
|
+
return (
|
|
46
|
+
probeGit(git, cwd, ['rev-parse', '--verify', '--quiet', ref]).length > 0
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The short sha the branch name carries.
|
|
52
|
+
*
|
|
53
|
+
* Dating the branch alone was not enough to make a retry safe: a second run on
|
|
54
|
+
* the same day found `chore/audit-ledger-<date>` already present and failed at
|
|
55
|
+
* `create-branch`, so the *first* failure (usually a push) permanently poisoned
|
|
56
|
+
* every retry that day. Qualifying the name with the base commit makes it
|
|
57
|
+
* unique across bases while staying **deterministic** for the same base — which
|
|
58
|
+
* is exactly what lets a retry recognise its own half-finished branch.
|
|
59
|
+
*
|
|
60
|
+
* @param {(cwd: string, ...args: string[]) => string} git
|
|
61
|
+
* @param {string} cwd
|
|
62
|
+
* @param {string} baseRef — `origin/<base>`.
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function shortSha(git, cwd, baseRef) {
|
|
66
|
+
for (const ref of [baseRef, 'HEAD']) {
|
|
67
|
+
const sha = probeGit(git, cwd, ['rev-parse', '--short', ref]);
|
|
68
|
+
if (sha) return sha;
|
|
69
|
+
}
|
|
70
|
+
return 'initial';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the ledger branch for this run, and whether it is a **resume**.
|
|
75
|
+
*
|
|
76
|
+
* A ledger branch that exists locally and has never been pushed is the wreckage
|
|
77
|
+
* of a failed run, not a landed one: its commit is already made, so the work
|
|
78
|
+
* left is the push and the PR. Recognising it is what turns a failed push plus
|
|
79
|
+
* its retry into exactly one PR instead of a stranded branch and a run
|
|
80
|
+
* reporting `ledger-unchanged` — which is what the ledger file honestly is once
|
|
81
|
+
* its change has been committed onto that branch.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ git: Function, cwd: string, base: string, date: string }} params
|
|
84
|
+
* @returns {{ branch: string, resuming: boolean }}
|
|
85
|
+
*/
|
|
86
|
+
function resolveLedgerBranch({ git, cwd, base, date }) {
|
|
87
|
+
const branch = `chore/audit-ledger-${date}-${shortSha(git, cwd, `origin/${base}`)}`;
|
|
88
|
+
return {
|
|
89
|
+
branch,
|
|
90
|
+
resuming:
|
|
91
|
+
refExists(git, cwd, `refs/heads/${branch}`) &&
|
|
92
|
+
!refExists(git, cwd, `refs/remotes/origin/${branch}`),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refuse, naming the step, when the checkout cannot legitimately produce a
|
|
98
|
+
* ledger PR. Both refusals happen **before** any write, so a refused run leaves
|
|
99
|
+
* no branch and no commit behind.
|
|
100
|
+
*
|
|
101
|
+
* HEAD parked off the base branch is the one an unattended sweep actually
|
|
102
|
+
* meets: a job that has already checked out a feature branch would otherwise
|
|
103
|
+
* cut its ledger branch from that branch's tip and open a PR carrying every
|
|
104
|
+
* unrelated commit on it.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ hasOrigin: boolean, onBaseBranch: boolean, headBranch: string,
|
|
107
|
+
* baseBranch: string }} state
|
|
108
|
+
* @param {string} branch — the ledger branch name, allowed as a resume HEAD.
|
|
109
|
+
*/
|
|
110
|
+
function assertCommittable(state, branch) {
|
|
111
|
+
if (!state.hasOrigin) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'--ledger-commit failed at step "verify-origin": this checkout has no "origin" remote, ' +
|
|
114
|
+
'so the ledger branch could never be pushed. Add the remote, or commit the ledger by hand.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!state.onBaseBranch && state.headBranch !== branch) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`--ledger-commit failed at step "verify-base-branch": HEAD is on "${state.headBranch || '(detached)'}", ` +
|
|
120
|
+
`not the base branch "${state.baseBranch}". Nothing was committed — the ledger branch is cut from ` +
|
|
121
|
+
`origin/${state.baseBranch}, and running from a feature branch would carry its commits into the ledger PR. ` +
|
|
122
|
+
`Check out ${state.baseBranch} and re-run.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract the PR URL `gh pr create` prints, so the caller can name it in the
|
|
129
|
+
* run summary. A wrapper that returns something else yields `null` rather than
|
|
130
|
+
* a fabricated link.
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} result
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
function pullRequestUrl(result) {
|
|
136
|
+
const text = typeof result === 'string' ? result : (result?.stdout ?? '');
|
|
137
|
+
const match = /https?:\/\/\S+/.exec(String(text ?? ''));
|
|
138
|
+
return match ? match[0] : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Wrap one write step so a git or `gh` failure surfaces as a fatal error that
|
|
143
|
+
* names the step that broke. Accepts sync and async steps alike.
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @param {() => unknown} fn
|
|
146
|
+
* @returns {Promise<unknown>}
|
|
147
|
+
*/
|
|
148
|
+
async function runStep(name, fn) {
|
|
149
|
+
try {
|
|
150
|
+
return await fn();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
|
|
154
|
+
{ cause: error },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compose the ledger PR body. Kept separate so the step sequence below reads
|
|
161
|
+
* as a sequence and not as a string-building exercise.
|
|
162
|
+
* @param {string} ledgerPath
|
|
163
|
+
* @param {string} date
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function pullRequestBody(ledgerPath, date) {
|
|
167
|
+
return [
|
|
168
|
+
`Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
|
|
169
|
+
`unattended \`audit-to-stories --auto\` sweep on ${date}.`,
|
|
170
|
+
'',
|
|
171
|
+
'Ledger-only change — no source, workflow or documentation file is touched.',
|
|
172
|
+
'Merging it is what gives the next sweep a memory: without it the ledger',
|
|
173
|
+
'dies with the checkout and every later run re-proposes findings this one',
|
|
174
|
+
'already filed, and re-surfaces findings a human already rejected.',
|
|
175
|
+
'',
|
|
176
|
+
'Auto-merge is deliberately not requested: the ledger records machine-derived',
|
|
177
|
+
'lifecycle state, and a human glance before it lands is the point.',
|
|
178
|
+
].join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Cut the ledger branch from `origin/<base>` and commit the ledger onto it —
|
|
183
|
+
* or, when `resuming`, simply check out the branch a failed run already
|
|
184
|
+
* committed onto, because those steps have already succeeded.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} ctx
|
|
187
|
+
* @returns {Promise<void>}
|
|
188
|
+
*/
|
|
189
|
+
async function commitLedgerOnto({
|
|
190
|
+
git,
|
|
191
|
+
cwd,
|
|
192
|
+
branch,
|
|
193
|
+
base,
|
|
194
|
+
ledgerPath,
|
|
195
|
+
subject,
|
|
196
|
+
resuming = false,
|
|
197
|
+
}) {
|
|
198
|
+
if (resuming) {
|
|
199
|
+
// The commit already exists on that branch; all it is missing is a push.
|
|
200
|
+
await runStep('resume-branch', () => git(cwd, 'checkout', branch));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await runStep('fetch-base', () => git(cwd, 'fetch', 'origin', base));
|
|
204
|
+
await runStep('create-branch', () =>
|
|
205
|
+
git(cwd, 'checkout', '-b', branch, `origin/${base}`),
|
|
206
|
+
);
|
|
207
|
+
await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
|
|
208
|
+
// The `-- <path>` pathspec is what keeps the commit ledger-only even when
|
|
209
|
+
// the sweep's checkout carries unrelated dirt.
|
|
210
|
+
await runStep('commit-ledger', () =>
|
|
211
|
+
git(cwd, 'commit', '-m', subject, '--', ledgerPath),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Push the ledger branch and open its PR, returning the PR URL.
|
|
217
|
+
*
|
|
218
|
+
* Auto-merge is never requested: the ledger records machine-derived lifecycle
|
|
219
|
+
* state a human should glance at, so landing it stays an operator decision.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} ctx
|
|
222
|
+
* @returns {Promise<string|null>}
|
|
223
|
+
*/
|
|
224
|
+
async function pushAndOpenPullRequest({
|
|
225
|
+
git,
|
|
226
|
+
cwd,
|
|
227
|
+
gh,
|
|
228
|
+
branch,
|
|
229
|
+
base,
|
|
230
|
+
subject,
|
|
231
|
+
ledgerPath,
|
|
232
|
+
date,
|
|
233
|
+
}) {
|
|
234
|
+
await runStep('push-branch', () =>
|
|
235
|
+
git(cwd, 'push', '--set-upstream', 'origin', branch),
|
|
236
|
+
);
|
|
237
|
+
return pullRequestUrl(
|
|
238
|
+
await runStep('open-pull-request', () =>
|
|
239
|
+
gh.pr.create([
|
|
240
|
+
'--base',
|
|
241
|
+
base,
|
|
242
|
+
'--head',
|
|
243
|
+
branch,
|
|
244
|
+
'--title',
|
|
245
|
+
subject,
|
|
246
|
+
'--body',
|
|
247
|
+
pullRequestBody(ledgerPath, date),
|
|
248
|
+
]),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Put the checkout back on the branch the run started on.
|
|
255
|
+
*
|
|
256
|
+
* Best-effort by design, and called from a `finally`: on the failure path
|
|
257
|
+
* especially — where the next thing the operator runs is the retry — leaving
|
|
258
|
+
* them parked on a half-finished ledger branch is its own defect, but a failure
|
|
259
|
+
* to restore must never mask the failure that caused it.
|
|
260
|
+
*
|
|
261
|
+
* @param {{ git: Function, cwd: string, startBranch: string, branch: string }} params
|
|
262
|
+
*/
|
|
263
|
+
function restoreBranch({ git, cwd, startBranch, branch }) {
|
|
264
|
+
if (!startBranch || startBranch === branch) return;
|
|
265
|
+
try {
|
|
266
|
+
git(cwd, 'checkout', startBranch);
|
|
267
|
+
} catch (_) {
|
|
268
|
+
// Deliberately swallowed — see the contract above.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Run the whole `--ledger-commit` write sequence against an assessed checkout:
|
|
274
|
+
* refuse or skip, cut (or resume) the branch, push, open the PR, and put the
|
|
275
|
+
* checkout back where it started.
|
|
276
|
+
*
|
|
277
|
+
* **Re-runnable**, which is the property an unattended sweep needs. The two
|
|
278
|
+
* ways a retry used to misbehave are both closed here:
|
|
279
|
+
*
|
|
280
|
+
* - The branch name is qualified by the base commit, so a same-day retry no
|
|
281
|
+
* longer collides with the branch a failed run left behind.
|
|
282
|
+
* - A ledger already committed on an **unpushed** ledger branch resumes at
|
|
283
|
+
* the push rather than reporting `ledger-unchanged` (the ledger file is
|
|
284
|
+
* clean — it is committed, just not pushed) and abandoning the work.
|
|
285
|
+
* Across a failed push and its retry that yields exactly one PR.
|
|
286
|
+
*
|
|
287
|
+
* The branch the run started on is restored in a `finally`, so a failure
|
|
288
|
+
* anywhere in the sequence — and success alike — leaves the operator's checkout
|
|
289
|
+
* where they left it rather than parked on a ledger branch.
|
|
290
|
+
*
|
|
291
|
+
* @param {{ state: object, ledgerPath: string, cwd: string, git: Function,
|
|
292
|
+
* gh: object, date: string }} params
|
|
293
|
+
* @returns {Promise<object>} the result the CLI summarises.
|
|
294
|
+
*/
|
|
295
|
+
export async function openLedgerPullRequest({
|
|
296
|
+
state,
|
|
297
|
+
ledgerPath,
|
|
298
|
+
cwd,
|
|
299
|
+
git,
|
|
300
|
+
gh,
|
|
301
|
+
date,
|
|
302
|
+
}) {
|
|
303
|
+
const base = state.baseBranch;
|
|
304
|
+
const subject = `chore(audit): reconcile audit ledger ${date}`;
|
|
305
|
+
const { branch, resuming } = resolveLedgerBranch({ git, cwd, base, date });
|
|
306
|
+
|
|
307
|
+
if (!state.changed && !resuming) {
|
|
308
|
+
return { committed: false, reason: 'ledger-unchanged', ledgerPath };
|
|
309
|
+
}
|
|
310
|
+
assertCommittable(state, branch);
|
|
311
|
+
|
|
312
|
+
const startBranch = state.headBranch;
|
|
313
|
+
let prUrl = null;
|
|
314
|
+
try {
|
|
315
|
+
await commitLedgerOnto({
|
|
316
|
+
git,
|
|
317
|
+
cwd,
|
|
318
|
+
branch,
|
|
319
|
+
base,
|
|
320
|
+
ledgerPath,
|
|
321
|
+
subject,
|
|
322
|
+
resuming,
|
|
323
|
+
});
|
|
324
|
+
prUrl = await pushAndOpenPullRequest({
|
|
325
|
+
git,
|
|
326
|
+
cwd,
|
|
327
|
+
gh,
|
|
328
|
+
branch,
|
|
329
|
+
base,
|
|
330
|
+
subject,
|
|
331
|
+
ledgerPath,
|
|
332
|
+
date,
|
|
333
|
+
});
|
|
334
|
+
} finally {
|
|
335
|
+
restoreBranch({ git, cwd, startBranch, branch });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
committed: true,
|
|
340
|
+
resumed: resuming,
|
|
341
|
+
branch,
|
|
342
|
+
subject,
|
|
343
|
+
baseBranch: base,
|
|
344
|
+
prUrl,
|
|
345
|
+
ledgerPath,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit-to-stories/ledger-record.js — record what a run actually filed.
|
|
3
|
+
*
|
|
4
|
+
* The cross-run ledger can only suppress an already-filed finding if something
|
|
5
|
+
* tells it the finding was filed. Until Story #5305 nothing did: the reconcile
|
|
6
|
+
* ran *during* the scan, before any Issue existed, and its `issueStates` were
|
|
7
|
+
* derived from `matchedIssues` — the Issues dedup FOUND. A group classified
|
|
8
|
+
* `create` has none, so a freshly opened Issue contributed nothing and its
|
|
9
|
+
* entry persisted as `status: "new", issue: null` forever. On a host where
|
|
10
|
+
* GitHub-search dedup works that is invisible; on one where it cannot run,
|
|
11
|
+
* nothing suppresses anything and every sweep re-files everything.
|
|
12
|
+
*
|
|
13
|
+
* The missing input is the `groupKey → issueNumber` map, which exists only
|
|
14
|
+
* after the Issues are opened. `--wire-edges` already takes exactly that map
|
|
15
|
+
* and is already a required pass, which is why the record rides along with it
|
|
16
|
+
* rather than arriving as a second command an operator must remember.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reaches the network: the caller hands over the map it already
|
|
19
|
+
* holds, and the only I/O is the ledger read/write this module delegates to
|
|
20
|
+
* `ledger.js`. That is what lets the record run BEFORE the provider is loaded,
|
|
21
|
+
* so a host whose provider cannot be constructed still records what it filed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_LEDGER_PATH,
|
|
26
|
+
readLedger,
|
|
27
|
+
reconcileLedger,
|
|
28
|
+
writeLedger,
|
|
29
|
+
} from '../findings/audit-ledger.js';
|
|
30
|
+
import {
|
|
31
|
+
fingerprintAuditFinding,
|
|
32
|
+
toCanonicalFinding,
|
|
33
|
+
} from './finding-adapter.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Project the opened-issue map onto the `{ fingerprint → issueState }` shape
|
|
37
|
+
* `reconcileLedger` reads, and collect the findings those groups carry.
|
|
38
|
+
*
|
|
39
|
+
* Only groups present in the map contribute. A group the map does not mention
|
|
40
|
+
* was not opened — deduped, ledger-suppressed, or simply skipped — and
|
|
41
|
+
* inventing an Issue state for it is how a finding gets suppressed against an
|
|
42
|
+
* Issue that does not exist.
|
|
43
|
+
*
|
|
44
|
+
* Every recorded state is `open`: the map names Issues this run just created,
|
|
45
|
+
* and a just-created Issue is open. A later close is learned from the live
|
|
46
|
+
* lookup on the next run, where it outranks this record (`decideStatus` reads
|
|
47
|
+
* the closed-Issue branch first).
|
|
48
|
+
*
|
|
49
|
+
* @param {object} params
|
|
50
|
+
* @param {Array<object>} params.groups — the `create`-eligible groups.
|
|
51
|
+
* @param {Record<string, number>} params.issueByGroupKey — group key → issue number.
|
|
52
|
+
* Module-internal: `recordFiledIssues` is the only production entrypoint, and
|
|
53
|
+
* exporting this for tests alone trips the CI-only `dead-exports:production`
|
|
54
|
+
* gate. It is covered through `recordFiledIssues`, which is the seam that
|
|
55
|
+
* actually ships.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ findings: Array<object>, issueStates: Record<string, { state: string, number: number }>, groupsRecorded: number }}
|
|
58
|
+
*/
|
|
59
|
+
function issueStatesFromIssueMap({ groups, issueByGroupKey }) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const issueStates = {};
|
|
62
|
+
let groupsRecorded = 0;
|
|
63
|
+
|
|
64
|
+
for (const group of groups ?? []) {
|
|
65
|
+
const number = issueByGroupKey?.[group?.groupKey];
|
|
66
|
+
if (typeof number !== 'number') continue;
|
|
67
|
+
groupsRecorded += 1;
|
|
68
|
+
for (const finding of group.findings ?? []) {
|
|
69
|
+
findings.push(finding);
|
|
70
|
+
issueStates[fingerprintAuditFinding(finding).full] = {
|
|
71
|
+
state: 'open',
|
|
72
|
+
number,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { findings, issueStates, groupsRecorded };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fold the just-opened Issues onto the committed ledger.
|
|
82
|
+
*
|
|
83
|
+
* Only the mapped groups' findings are passed to `reconcileLedger`, so every
|
|
84
|
+
* other entry survives untouched — `reconcileLedger` copies the prior index
|
|
85
|
+
* forward and rewrites only what this scan hands it.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} params
|
|
88
|
+
* @param {string} [params.ledgerPath]
|
|
89
|
+
* @param {Array<object>} params.groups — the `create`-eligible groups.
|
|
90
|
+
* @param {Record<string, number>} params.issueByGroupKey
|
|
91
|
+
* @param {boolean} [params.write=true] — `false` computes the record without
|
|
92
|
+
* persisting it, which is what `--dry-run` needs.
|
|
93
|
+
* @param {{ readLedgerImpl?: Function, writeLedgerImpl?: Function }} [seams]
|
|
94
|
+
* @returns {{ path: string, written: boolean, groupsRecorded: number, findingsRecorded: number, filed: number }}
|
|
95
|
+
*/
|
|
96
|
+
export function recordFiledIssues(
|
|
97
|
+
{ ledgerPath, groups, issueByGroupKey, write = true },
|
|
98
|
+
{ readLedgerImpl = readLedger, writeLedgerImpl = writeLedger } = {},
|
|
99
|
+
) {
|
|
100
|
+
const path = ledgerPath ?? DEFAULT_LEDGER_PATH;
|
|
101
|
+
const { findings, issueStates, groupsRecorded } = issueStatesFromIssueMap({
|
|
102
|
+
groups,
|
|
103
|
+
issueByGroupKey,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const { ledger: next, classifications } = reconcileLedger({
|
|
107
|
+
ledger: readLedgerImpl(path),
|
|
108
|
+
findings,
|
|
109
|
+
issueStates,
|
|
110
|
+
toCanonical: toCanonicalFinding,
|
|
111
|
+
});
|
|
112
|
+
// Nothing to record means nothing to write. The ledger is committed consumer
|
|
113
|
+
// state and `--ledger-commit` opens a PR only when it changed, so rewriting
|
|
114
|
+
// it with a fresh `generatedAt` and no new memory would manufacture a diff
|
|
115
|
+
// that says nothing.
|
|
116
|
+
const wrote = Boolean(write) && findings.length > 0;
|
|
117
|
+
if (wrote) writeLedgerImpl(path, next);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
path,
|
|
121
|
+
written: wrote,
|
|
122
|
+
groupsRecorded,
|
|
123
|
+
findingsRecorded: findings.length,
|
|
124
|
+
filed: classifications.filter((c) => c.status === 'filed').length,
|
|
125
|
+
};
|
|
126
|
+
}
|