mandrel 2.55.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/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +19 -0
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/file-ci-gap.js +306 -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 +40 -52
- 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/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/config-settings-schema.js +33 -0
- 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 +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- 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 +1 -0
- 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/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
- package/.agents/workflows/helpers/plan-reference.md +23 -0
- package/.agents/workflows/mandrel-plan.md +6 -6
- package/docs/CHANGELOG.md +10 -0
- package/package.json +1 -1
|
@@ -103,6 +103,9 @@ GitHub provider identity plus the remote stance the bootstrap enforces. `owner`,
|
|
|
103
103
|
| `projectOwner` | No | `string` \| `null` | `null` | Owner of the Projects V2 board when it lives outside `owner` (an org board fed by a user repo). `null` means the board shares `owner`. |
|
|
104
104
|
| `operatorHandle` | Yes | `string` | `"@[USERNAME]"` | The human the framework escalates to, `@`-prefixed. Used for HITL @-mentions on `agent::blocked`. |
|
|
105
105
|
| `defaultTimeoutMs` | No | `integer` | `60000` | Default `timeoutMs` applied to every `gh` subprocess the provider facade spawns, so a stalled socket or long-poll cannot hang an orchestration indefinitely. A `GhExecTimeoutError` from a hit ceiling is classified `transient` and retried by `withTransientRetry`. Story #2860. |
|
|
106
|
+
| `followUpRepos` | No | `object` | — | Repository slugs for the non-consumer follow-up ownership buckets, used when a CI gap, retro proposal, or audit finding belongs to someone other than the repo that surfaced it. |
|
|
107
|
+
| `followUpRepos.framework` | No | `string` | `"dsj1984/mandrel"` | `<owner>/<repo>` that owns framework-level defects. Defaults to the Mandrel mirror — the one bucket with a knowable default. |
|
|
108
|
+
| `followUpRepos.platform` | No | `string` \| `null` | `null` | `<owner>/<repo>` for a shared platform or infrastructure tracker (a shared base config, a runner fleet, a cross-repo toolchain). No default — nothing can guess a shared repo. Left unset, platform-owned findings file locally and say so. |
|
|
106
109
|
| `branchProtection` | No | `object` | — | Branch-protection stance applied to `project.baseBranch` by the GitHub bootstrap, and reproduced locally before every push. |
|
|
107
110
|
| `branchProtection.enforce` | No | `boolean` | `true` | When true, the GitHub bootstrap writes the required-check ruleset. False leaves the remote stance alone. |
|
|
108
111
|
| `branchProtection.requiredChecks[]` | No | `array<object>` | `[{"name":"lint","cmd":["npm","run","lint"]},{"name":"test","cmd":["npm","test"]},{"name":"baselines","cmd":["node",".agents/scripts/check-baselines.js"]}]` | Checks that must pass before a Story PR merges. Each entry carries both the remote context name and the local argv. Each item has: name, cmd. |
|
|
@@ -27,13 +27,29 @@ exactly one of two ways, and no others:
|
|
|
27
27
|
through the fix table in
|
|
28
28
|
[`deliver-story-reference.md` § Step 4](../workflows/helpers/deliver-story-reference.md#step-4--ci-watch--fix-recovery);
|
|
29
29
|
refresh a baseline only when the diff demonstrably can't be covered.
|
|
30
|
-
2. **File
|
|
30
|
+
2. **File the CI-gap intake issue** when the root cause is outside this
|
|
31
31
|
delivery's scope — a pre-existing flaky test, a runner/infra weakness, a
|
|
32
|
-
framework-level environment gap.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
framework-level environment gap. One command does it, and it is the only
|
|
33
|
+
sanctioned filing surface:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
node .agents/scripts/file-ci-gap.js --story <id> --verdict <verdict> \
|
|
37
|
+
--owner <consumer|framework|platform> --evidence "<proof reading>" [--block]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It reads the digest for the run link and failure signature, routes the
|
|
41
|
+
filing to the repository that **owns** the fault, dedups by signature so
|
|
42
|
+
the Nth occurrence updates the existing ticket, posts the `friction`
|
|
43
|
+
comment, and (with `--block`) flips the Story. Hand-running `gh issue
|
|
44
|
+
create` is not the fallback: it files an issue no `/mandrel-plan` pass can
|
|
45
|
+
graduate, in whichever repo you happen to be standing in. Remediate this
|
|
46
|
+
delivery only if the pre-existing defect is genuinely blocking it.
|
|
47
|
+
|
|
48
|
+
**`--owner` is the judgement call**, and it is yours to make from the
|
|
49
|
+
evidence: `consumer` for this repository's own code, `framework` for a
|
|
50
|
+
Mandrel defect, `platform` for a shared base config, runner fleet, or
|
|
51
|
+
cross-repo toolchain that neither owns. An unconfigured bucket files
|
|
52
|
+
locally and says so in the issue body — it never pretends to be routed.
|
|
37
53
|
|
|
38
54
|
Infra, transient, and flaky failures are root-cause defects too — a flaky test
|
|
39
55
|
that passes on a rerun is still a bug that will fail a future run. They route
|
|
@@ -49,9 +65,9 @@ the two options above. Name the verdict you reached in the `friction` comment.
|
|
|
49
65
|
| Verdict | Evidence | Routes to |
|
|
50
66
|
| --- | --- | --- |
|
|
51
67
|
| **defect-in-diff** | The failure reproduces on the branch and not on an unmodified `main` | Option 1 — fix at source |
|
|
52
|
-
| **pre-existing** | The same check fails on an unmodified `main` too | Option 2 — file
|
|
53
|
-
| **capacity** | Proven exhaustion of a runner resource, not a property of the diff (see below) | Option 2 — file `meta::framework-gap` **and** escalate to the operator |
|
|
54
|
-
| **unreproducible-tier** | The tier cannot be exercised in this sandbox at all, proven by an attempted attach (see below) | Option 2 — file `meta::framework-gap` **and** escalate on first encounter |
|
|
68
|
+
| **pre-existing** | The same check fails on an unmodified `main` too | Option 2 — `file-ci-gap.js --verdict pre-existing`; remediate here only if it blocks this delivery |
|
|
69
|
+
| **capacity** | Proven exhaustion of a runner resource, not a property of the diff (see below) | Option 2 — `file-ci-gap.js --verdict capacity` (`meta::framework-gap` unless `--owner` routes it elsewhere) **and** escalate to the operator |
|
|
70
|
+
| **unreproducible-tier** | The tier cannot be exercised in this sandbox at all, proven by an attempted attach (see below) | Option 2 — `file-ci-gap.js --verdict unreproducible-tier` (`meta::framework-gap` unless `--owner` routes it elsewhere) **and** escalate on first encounter |
|
|
55
71
|
|
|
56
72
|
Why the verdict set carries these last two is recorded in
|
|
57
73
|
[`docs/decisions.md` ADR 20260906-5160a](../../docs/decisions.md).
|
|
@@ -72,10 +88,12 @@ line naming the exhausted limit (an OOM kill, `ENOSPC`, `EMFILE`,
|
|
|
72
88
|
timeout), plus the fact that the failure is not specific to this diff. Absent
|
|
73
89
|
that reading the verdict is **flaky, not capacity**, and it routes to Option 1.
|
|
74
90
|
|
|
75
|
-
On a `capacity` verdict:
|
|
76
|
-
the
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
On a `capacity` verdict: run `file-ci-gap.js --verdict capacity --block`, passing
|
|
92
|
+
the resource reading as `--evidence` (the run link and failure signature come
|
|
93
|
+
from the digest). That files the intake issue — `meta::framework-gap`, or
|
|
94
|
+
`meta::platform-gap` when `--owner platform` names a shared runner fleet — posts
|
|
95
|
+
the `friction` comment and flips the Story in one call; then hand back to the
|
|
96
|
+
operator, who owns the runner pool. Do not sit in a retry loop waiting for
|
|
79
97
|
capacity to return.
|
|
80
98
|
|
|
81
99
|
**Rerunning a failed job to reach green stays forbidden under every verdict,
|
|
@@ -106,10 +124,9 @@ both:
|
|
|
106
124
|
failure in the app under test.
|
|
107
125
|
|
|
108
126
|
Absent both readings the verdict is unavailable and the failure routes as it did
|
|
109
|
-
before. On the verdict:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
the operator, who owns the sandbox. Do not author a fix for a tier you could not
|
|
127
|
+
before. On the verdict: run
|
|
128
|
+
`file-ci-gap.js --verdict unreproducible-tier --block`, passing the failed attach
|
|
129
|
+
as `--evidence`; then hand back to the operator, who owns the sandbox. Do not author a fix for a tier you could not
|
|
113
130
|
run — a blind fix to a suite nobody exercised is how the gap compounds.
|
|
114
131
|
|
|
115
132
|
## Verifier
|
|
@@ -131,8 +148,9 @@ alongside the failing check-run identity. On green it adjudicates:
|
|
|
131
148
|
|
|
132
149
|
- **Same head SHA** → the green came from re-running the failed job. The
|
|
133
150
|
watcher exits non-zero, flips the Story to `agent::blocked` with a
|
|
134
|
-
`friction` comment, and requires the
|
|
135
|
-
failure signature
|
|
151
|
+
`friction` comment, and requires the CI-gap intake issue
|
|
152
|
+
(`file-ci-gap.js` — run link and failure signature are already in the
|
|
153
|
+
digest) before the delivery proceeds.
|
|
136
154
|
- **New head SHA** → fix at source. The digest is retired, auto-merge is
|
|
137
155
|
re-armed, and the delivery continues unobstructed.
|
|
138
156
|
|
|
@@ -153,8 +171,8 @@ operator under **any** of:
|
|
|
153
171
|
- **Clearly-environmental → escalate immediately.** An unambiguously
|
|
154
172
|
environmental failure outside your control (runner provisioning, a persistent
|
|
155
173
|
registry/network outage, a branch-protection or CI misconfiguration, an
|
|
156
|
-
expired credential) —
|
|
157
|
-
|
|
174
|
+
expired credential) — run `file-ci-gap.js --block` and escalate on the
|
|
175
|
+
first encounter rather than burning iterations
|
|
158
176
|
trying to code around it. A proven-capacity failure is this case: reach the
|
|
159
177
|
`capacity` verdict above and escalate on the first encounter.
|
|
160
178
|
- **Unrunnable tier → escalate immediately.** A tier the sandbox cannot host at
|
|
@@ -169,6 +169,25 @@
|
|
|
169
169
|
"description": "Default `timeoutMs` applied to every `gh` subprocess the provider facade spawns, so a stalled socket or long-poll cannot hang an orchestration indefinitely. A `GhExecTimeoutError` from a hit ceiling is classified `transient` and retried by `withTransientRetry`. Story #2860.",
|
|
170
170
|
"default": 60000
|
|
171
171
|
},
|
|
172
|
+
"followUpRepos": {
|
|
173
|
+
"type": "object",
|
|
174
|
+
"description": "Repository slugs for the non-consumer follow-up ownership buckets, used when a CI gap, retro proposal, or audit finding belongs to someone other than the repo that surfaced it.",
|
|
175
|
+
"properties": {
|
|
176
|
+
"framework": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"pattern": "^[^/\\s]+/[^/\\s]+$",
|
|
179
|
+
"description": "`<owner>/<repo>` that owns framework-level defects. Defaults to the Mandrel mirror — the one bucket with a knowable default.",
|
|
180
|
+
"default": "dsj1984/mandrel"
|
|
181
|
+
},
|
|
182
|
+
"platform": {
|
|
183
|
+
"type": ["string", "null"],
|
|
184
|
+
"pattern": "^[^/\\s]+/[^/\\s]+$",
|
|
185
|
+
"description": "`<owner>/<repo>` for a shared platform or infrastructure tracker (a shared base config, a runner fleet, a cross-repo toolchain). No default — nothing can guess a shared repo. Left unset, platform-owned findings file locally and say so.",
|
|
186
|
+
"default": null
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"additionalProperties": false
|
|
190
|
+
},
|
|
172
191
|
"branchProtection": {
|
|
173
192
|
"type": "object",
|
|
174
193
|
"description": "Branch-protection stance applied to `project.baseBranch` by the GitHub bootstrap, and reproduced locally before every push.",
|
|
@@ -36,18 +36,20 @@ import { parseArgs } from 'node:util';
|
|
|
36
36
|
import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
|
|
37
37
|
import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
|
|
38
38
|
import { formatEpicGrouping } from './lib/audit-to-stories/epic-grouping-directive.js';
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
toCanonicalFinding,
|
|
41
|
+
withFingerprints,
|
|
42
|
+
} from './lib/audit-to-stories/finding-adapter.js';
|
|
40
43
|
import { groupFindings } from './lib/audit-to-stories/group-findings.js';
|
|
41
44
|
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
writeLedger,
|
|
46
|
-
} from './lib/audit-to-stories/ledger.js';
|
|
45
|
+
loadIssuesFile,
|
|
46
|
+
normaliseIssueHit,
|
|
47
|
+
} from './lib/audit-to-stories/issues-file.js';
|
|
47
48
|
import {
|
|
48
49
|
resolveLedgerSummary,
|
|
49
50
|
runLedgerCommit,
|
|
50
51
|
} from './lib/audit-to-stories/ledger-commit.js';
|
|
52
|
+
import { recordFiledIssues } from './lib/audit-to-stories/ledger-record.js';
|
|
51
53
|
import {
|
|
52
54
|
parseAuditReports,
|
|
53
55
|
readSeverityTally,
|
|
@@ -55,6 +57,12 @@ import {
|
|
|
55
57
|
import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
|
|
56
58
|
import { wireAuditStoryEdges } from './lib/audit-to-stories/wire-dependencies.js';
|
|
57
59
|
import { runAsCli } from './lib/cli-utils.js';
|
|
60
|
+
import {
|
|
61
|
+
DEFAULT_LEDGER_PATH,
|
|
62
|
+
readLedger,
|
|
63
|
+
reconcileLedger,
|
|
64
|
+
writeLedger,
|
|
65
|
+
} from './lib/findings/audit-ledger.js';
|
|
58
66
|
import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
|
|
59
67
|
import {
|
|
60
68
|
normalizeSeverity,
|
|
@@ -366,28 +374,6 @@ class ProviderUnavailableError extends Error {
|
|
|
366
374
|
}
|
|
367
375
|
}
|
|
368
376
|
|
|
369
|
-
/**
|
|
370
|
-
* Flatten one raw `searchIssues` hit onto the `{ number, state, title, body }`
|
|
371
|
-
* shape the dedupe module reads, collapsing every closed-ish state spelling
|
|
372
|
-
* (`CLOSED`, `state_reason: not_planned`, …) onto `'closed'`.
|
|
373
|
-
*
|
|
374
|
-
* @param {object} hit
|
|
375
|
-
* @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
|
|
376
|
-
*/
|
|
377
|
-
function normaliseIssueHit(hit) {
|
|
378
|
-
return {
|
|
379
|
-
number: hit.number,
|
|
380
|
-
state: (hit.state ?? hit.state_reason ?? 'open')
|
|
381
|
-
.toString()
|
|
382
|
-
.toLowerCase()
|
|
383
|
-
.includes('closed')
|
|
384
|
-
? 'closed'
|
|
385
|
-
: 'open',
|
|
386
|
-
title: hit.title ?? '',
|
|
387
|
-
body: hit.body ?? '',
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
377
|
/**
|
|
392
378
|
* Walk the list endpoint once per label and merge the pages into one
|
|
393
379
|
* deduplicated, normalised issue list.
|
|
@@ -649,6 +635,136 @@ function dedupDegradedWarning(entries) {
|
|
|
649
635
|
);
|
|
650
636
|
}
|
|
651
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Render the operator-visible line naming a host-supplied index and its size.
|
|
640
|
+
*
|
|
641
|
+
* Always emitted for a `--issues-file` run, because "how many issues did you
|
|
642
|
+
* actually check against" is the one number that separates a real dedup from a
|
|
643
|
+
* plan that merely looks checked. At zero it is the load-bearing case: an empty
|
|
644
|
+
* corpus is a legitimate first sweep AND exactly what a broken fetch writes, so
|
|
645
|
+
* the operator — not the run — decides which this was. Deliberately distinct in
|
|
646
|
+
* wording from both `dedupSkippedWarning` and `dedupDegradedWarning` so the
|
|
647
|
+
* three are never confused in a scrollback.
|
|
648
|
+
*
|
|
649
|
+
* @param {{ source?: string, size?: number }} dedupIndex
|
|
650
|
+
* @returns {string}
|
|
651
|
+
*/
|
|
652
|
+
function dedupIndexWarning({ size = 0 } = {}) {
|
|
653
|
+
if (size === 0) {
|
|
654
|
+
return (
|
|
655
|
+
'dedup index: 0 issues supplied via --issues-file. Dedup DID run and ' +
|
|
656
|
+
'every group is correctly "create" — but that is also what a fetch that ' +
|
|
657
|
+
'returned nothing looks like. If audit issues already exist, the fetch ' +
|
|
658
|
+
'that wrote this file is broken and this run will re-file them.'
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return `dedup index: ${size} issue(s) supplied via --issues-file; every exact-fingerprint lookup was answered from it.`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Render the warning for a pre-fetch of the issue index that could not
|
|
666
|
+
* complete. The run still dedups — it falls back to a per-finding search — but
|
|
667
|
+
* it loses the one-list saving, and until Story #5301 this failure was
|
|
668
|
+
* swallowed whole: the operator saw only the downstream per-group degradation
|
|
669
|
+
* and could not tell that the pre-fetch itself was the cause.
|
|
670
|
+
*
|
|
671
|
+
* @param {string} reason
|
|
672
|
+
* @returns {string}
|
|
673
|
+
*/
|
|
674
|
+
function dedupIndexDegradedWarning(reason) {
|
|
675
|
+
return (
|
|
676
|
+
`dedup index unavailable: ${reason}. Dedup fell back to a per-finding ` +
|
|
677
|
+
'search, which is slower and rate-limited — if those searches also fail, ' +
|
|
678
|
+
'every affected group is classified "create" WITHOUT a dedup check.'
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Phase 6: classify every group against GitHub, and say loudly whichever way
|
|
684
|
+
* it went.
|
|
685
|
+
*
|
|
686
|
+
* Extracted from `buildPlan` because the gate has three outcomes, not two, and
|
|
687
|
+
* inlining them pushed the caller past its complexity ceiling. The three:
|
|
688
|
+
*
|
|
689
|
+
* - **deduped** — a provider resolved, or the host supplied a corpus, or
|
|
690
|
+
* both. A host-supplied corpus is a dedup source in its own right, which is
|
|
691
|
+
* the whole point: the gate asks "can we dedup at all", not "did a provider
|
|
692
|
+
* resolve". While it asked the latter, `--no-provider --issues-file` — the
|
|
693
|
+
* one invocation a `gh`-less host can run — short-circuited to the seeded
|
|
694
|
+
* all-`create` classifications however well the dedupe module worked.
|
|
695
|
+
* - **skipped, no port** — a provider was wanted but could not be adapted.
|
|
696
|
+
* - **skipped, disabled** — `--no-provider` with no corpus to fall back on.
|
|
697
|
+
*
|
|
698
|
+
* Every outcome warns on stderr, so the `--scan` JSON on stdout stays clean and
|
|
699
|
+
* a create-only plan is never read as "checked, found nothing".
|
|
700
|
+
*
|
|
701
|
+
* @param {{ groups: Array<object>, useProvider?: boolean,
|
|
702
|
+
* issues?: Array<object>|null }} params
|
|
703
|
+
* @param {{ loadProviderImpl: Function, classifyGroupsImpl: Function,
|
|
704
|
+
* logger: { warn: Function } }} deps
|
|
705
|
+
* @returns {Promise<{ classifications: Array<object>, summary: object,
|
|
706
|
+
* dedupApplied: boolean }>}
|
|
707
|
+
*/
|
|
708
|
+
async function runDedupPhase(
|
|
709
|
+
{ groups, useProvider, issues },
|
|
710
|
+
{ loadProviderImpl, classifyGroupsImpl, logger },
|
|
711
|
+
) {
|
|
712
|
+
const provider = useProvider ? await loadProviderImpl() : null;
|
|
713
|
+
if (!provider && !issues) {
|
|
714
|
+
logger.warn(
|
|
715
|
+
dedupSkippedWarning(useProvider ? 'no-provider-port' : 'disabled'),
|
|
716
|
+
);
|
|
717
|
+
return {
|
|
718
|
+
classifications: groups.map((group) => ({
|
|
719
|
+
group,
|
|
720
|
+
action: 'create',
|
|
721
|
+
matchedIssues: [],
|
|
722
|
+
matchedFingerprints: [],
|
|
723
|
+
})),
|
|
724
|
+
summary: { create: groups.length, skipOpen: 0, skipReoccurring: 0 },
|
|
725
|
+
dedupApplied: false,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const { classifications, summary } = await classifyGroupsImpl({
|
|
730
|
+
groups,
|
|
731
|
+
provider,
|
|
732
|
+
searchCandidates: provider?.searchCandidates,
|
|
733
|
+
listAuditIssues: provider?.listAuditIssues,
|
|
734
|
+
issues,
|
|
735
|
+
});
|
|
736
|
+
for (const warning of dedupPhaseWarnings({ issues, summary })) {
|
|
737
|
+
logger.warn(warning);
|
|
738
|
+
}
|
|
739
|
+
return { classifications, summary, dedupApplied: true };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Every warning a completed dedup pass owes the operator, in order. Pure, so
|
|
744
|
+
* the wording stays unit-testable and `runDedupPhase` keeps one write site.
|
|
745
|
+
*
|
|
746
|
+
* @param {{ issues?: Array<object>|null, summary: object }} params
|
|
747
|
+
* @returns {string[]}
|
|
748
|
+
*/
|
|
749
|
+
function dedupPhaseWarnings({ issues, summary }) {
|
|
750
|
+
const warnings = [];
|
|
751
|
+
if (issues) warnings.push(dedupIndexWarning(summary.dedupIndex));
|
|
752
|
+
// The pre-fetch failing is a distinct fact from any group's lookup failing,
|
|
753
|
+
// and used to be invisible: the operator saw only the downstream per-group
|
|
754
|
+
// degradation and could not tell what had caused it.
|
|
755
|
+
if (summary.dedupDegraded?.indexPrefetch) {
|
|
756
|
+
warnings.push(
|
|
757
|
+
dedupIndexDegradedWarning(summary.dedupDegraded.indexPrefetch),
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
// A partially-checked plan is a useful result — name the groups that degraded
|
|
761
|
+
// to create because their lookup could not complete (Story #4678).
|
|
762
|
+
if (summary.dedupDegraded?.count > 0) {
|
|
763
|
+
warnings.push(dedupDegradedWarning(summary.dedupDegraded.groups));
|
|
764
|
+
}
|
|
765
|
+
return warnings;
|
|
766
|
+
}
|
|
767
|
+
|
|
652
768
|
/**
|
|
653
769
|
* Scan → group → dedup → (optionally) reconcile the cross-run ledger, and
|
|
654
770
|
* return the plan envelope.
|
|
@@ -657,12 +773,14 @@ function dedupDegradedWarning(entries) {
|
|
|
657
773
|
* implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main`,
|
|
658
774
|
* `runAuto`, and every production caller are unchanged.
|
|
659
775
|
*
|
|
660
|
-
* @param {{ glob?: string, severity?: string, useProvider?: boolean,
|
|
776
|
+
* @param {{ glob?: string, severity?: string, useProvider?: boolean,
|
|
777
|
+
* issuesFile?: string, ledger?: object }} params
|
|
661
778
|
* @param {{
|
|
662
779
|
* collectReportPathsImpl?: typeof collectReportPaths,
|
|
663
780
|
* readReportsImpl?: typeof readReports,
|
|
664
781
|
* loadProviderImpl?: typeof loadProviderOrNull,
|
|
665
782
|
* classifyGroupsImpl?: typeof classifyGroupsAgainstGitHub,
|
|
783
|
+
* loadIssuesFileImpl?: typeof loadIssuesFile,
|
|
666
784
|
* reconcileScanLedgerImpl?: typeof reconcileScanLedger,
|
|
667
785
|
* logger?: { warn: Function },
|
|
668
786
|
* }} [deps]
|
|
@@ -673,6 +791,7 @@ async function buildPlan(
|
|
|
673
791
|
glob: pattern,
|
|
674
792
|
severity,
|
|
675
793
|
useProvider,
|
|
794
|
+
issuesFile,
|
|
676
795
|
ledger,
|
|
677
796
|
allowMissingTally,
|
|
678
797
|
failOnReportFailures,
|
|
@@ -684,9 +803,14 @@ async function buildPlan(
|
|
|
684
803
|
readReportsImpl = readReports,
|
|
685
804
|
loadProviderImpl = loadProviderOrNull,
|
|
686
805
|
classifyGroupsImpl = classifyGroupsAgainstGitHub,
|
|
806
|
+
loadIssuesFileImpl = loadIssuesFile,
|
|
687
807
|
reconcileScanLedgerImpl = reconcileScanLedger,
|
|
688
808
|
logger = Logger,
|
|
689
809
|
} = deps;
|
|
810
|
+
// Deliberately BEFORE the reports are read: an unusable corpus is a usage
|
|
811
|
+
// error, and failing fast costs the operator nothing, where failing late
|
|
812
|
+
// would tempt a fallback that silently dedups nothing.
|
|
813
|
+
const issues = issuesFile ? loadIssuesFileImpl(issuesFile) : null;
|
|
690
814
|
const reportPaths = await collectReportPathsImpl(pattern ?? DEFAULT_GLOB);
|
|
691
815
|
if (reportPaths.length === 0) {
|
|
692
816
|
return {
|
|
@@ -724,45 +848,10 @@ async function buildPlan(
|
|
|
724
848
|
const stamped = withFingerprints(filtered.filter((f) => Boolean(f.severity)));
|
|
725
849
|
const { groups, edges } = groupFindings(stamped);
|
|
726
850
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
matchedFingerprints: [],
|
|
732
|
-
}));
|
|
733
|
-
let summary = { create: groups.length, skipOpen: 0, skipReoccurring: 0 };
|
|
734
|
-
let dedupApplied = false;
|
|
735
|
-
|
|
736
|
-
if (useProvider) {
|
|
737
|
-
const provider = await loadProviderImpl();
|
|
738
|
-
if (provider) {
|
|
739
|
-
const result = await classifyGroupsImpl({
|
|
740
|
-
groups,
|
|
741
|
-
provider,
|
|
742
|
-
searchCandidates: provider.searchCandidates,
|
|
743
|
-
listAuditIssues: provider.listAuditIssues,
|
|
744
|
-
});
|
|
745
|
-
classifications = result.classifications;
|
|
746
|
-
summary = result.summary;
|
|
747
|
-
dedupApplied = true;
|
|
748
|
-
// A partially-checked plan is a useful result — warn loudly (stderr, so
|
|
749
|
-
// the --scan JSON on stdout stays clean) naming the groups that degraded
|
|
750
|
-
// to create because their lookup could not complete (Story #4678).
|
|
751
|
-
if (summary.dedupDegraded?.count > 0) {
|
|
752
|
-
logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
|
|
753
|
-
}
|
|
754
|
-
} else {
|
|
755
|
-
// The provider could not resolve a searchIssues port — the dedup gate
|
|
756
|
-
// is silently a no-op without this. Surface it loudly (stderr, so the
|
|
757
|
-
// --scan JSON on stdout stays clean) so the operator does not read a
|
|
758
|
-
// create-only plan as "no duplicates found".
|
|
759
|
-
logger.warn(dedupSkippedWarning('no-provider-port'));
|
|
760
|
-
}
|
|
761
|
-
} else {
|
|
762
|
-
// Operator explicitly opted out via --no-provider. Still warn so a
|
|
763
|
-
// duplicate-opening re-run is never a surprise.
|
|
764
|
-
logger.warn(dedupSkippedWarning('disabled'));
|
|
765
|
-
}
|
|
851
|
+
const { classifications, summary, dedupApplied } = await runDedupPhase(
|
|
852
|
+
{ groups, useProvider, issues },
|
|
853
|
+
{ loadProviderImpl, classifyGroupsImpl, logger },
|
|
854
|
+
);
|
|
766
855
|
|
|
767
856
|
// Cross-run ledger (Story #4626): fold this scan onto the committed memory,
|
|
768
857
|
// suppress findings a prior run recorded as accepted-risk, and (unless the
|
|
@@ -832,6 +921,9 @@ function reconcileScanLedger({ ledgerPath, findings, classifications, write }) {
|
|
|
832
921
|
ledger: prior,
|
|
833
922
|
findings,
|
|
834
923
|
issueStates,
|
|
924
|
+
// The ledger lives in the shared findings layer and cannot import the
|
|
925
|
+
// audit adapter without closing a cycle, so the projection is ours to pass.
|
|
926
|
+
toCanonical: toCanonicalFinding,
|
|
835
927
|
});
|
|
836
928
|
if (write !== false) writeLedger(ledgerPath, next);
|
|
837
929
|
return new Set(
|
|
@@ -918,6 +1010,7 @@ async function runAuto({
|
|
|
918
1010
|
severity,
|
|
919
1011
|
dryRun,
|
|
920
1012
|
useProvider,
|
|
1013
|
+
issuesFile,
|
|
921
1014
|
ledgerPath,
|
|
922
1015
|
ledgerCommit,
|
|
923
1016
|
git,
|
|
@@ -930,6 +1023,7 @@ async function runAuto({
|
|
|
930
1023
|
glob,
|
|
931
1024
|
severity: floor,
|
|
932
1025
|
useProvider,
|
|
1026
|
+
issuesFile,
|
|
933
1027
|
ledger: { path: resolvedLedgerPath, write: !dryRun },
|
|
934
1028
|
// `--auto` never accepts `--allow-missing-tally`: an unattended sweep has
|
|
935
1029
|
// no operator to read a warning, so every report failure is fatal here.
|
|
@@ -967,6 +1061,11 @@ async function runAuto({
|
|
|
967
1061
|
skipReoccurring: byAction.skipReoccurring.length,
|
|
968
1062
|
suppressedByLedger: byAction.suppressed.length,
|
|
969
1063
|
},
|
|
1064
|
+
// `--auto` opens no Issues itself — the caller does, from the `--emit-stories`
|
|
1065
|
+
// drafts — so these keys are the only thing standing between its summary and
|
|
1066
|
+
// the `--wire-edges --ids` map. Without them an unattended sweep cannot
|
|
1067
|
+
// record what it filed, and the ledger stays empty however well it works.
|
|
1068
|
+
createGroupKeys: eligible.map((g) => g?.groupKey).filter(Boolean),
|
|
970
1069
|
// Re-detected open Issues the operator may want a "re-detected" comment on.
|
|
971
1070
|
reDetected: byAction.skipOpen
|
|
972
1071
|
.flatMap((c) => c.matchedIssues ?? [])
|
|
@@ -1076,20 +1175,50 @@ function wireEdgesPreconditionError(reason, detail) {
|
|
|
1076
1175
|
* re-rendered with a canonical `blocked by #N` footer and the same edges are
|
|
1077
1176
|
* mirrored as native `blocked_by` relations.
|
|
1078
1177
|
*
|
|
1178
|
+
* The same map is what the cross-run ledger needs to record what this run
|
|
1179
|
+
* filed, so the record rides along here rather than arriving as a second
|
|
1180
|
+
* command an operator must remember (Story #5305).
|
|
1181
|
+
*
|
|
1079
1182
|
* @param {object} params
|
|
1080
1183
|
* @param {object} params.plan A `--scan` plan envelope.
|
|
1081
1184
|
* @param {Record<string, number>} params.issueByGroupKey
|
|
1185
|
+
* @param {string} [params.ledgerPath] — ledger to record into; defaults to
|
|
1186
|
+
* `DEFAULT_LEDGER_PATH` inside the record.
|
|
1187
|
+
* @param {boolean} [params.write] — `false` computes the record without
|
|
1188
|
+
* persisting it (what `--dry-run` passes).
|
|
1082
1189
|
* @param {object} [deps]
|
|
1083
1190
|
* @param {Function} [deps.loadProviderImpl]
|
|
1084
1191
|
* @param {Function} [deps.wireImpl]
|
|
1085
|
-
* @
|
|
1192
|
+
* @param {Function} [deps.recordFiledIssuesImpl]
|
|
1193
|
+
* @returns {Promise<object>} the wiring summary, with the ledger record on
|
|
1194
|
+
* `ledger`.
|
|
1086
1195
|
*/
|
|
1087
|
-
async function wireEdges(
|
|
1088
|
-
|
|
1089
|
-
|
|
1196
|
+
async function wireEdges(
|
|
1197
|
+
{ plan, issueByGroupKey, ledgerPath, write },
|
|
1198
|
+
deps = {},
|
|
1199
|
+
) {
|
|
1200
|
+
const {
|
|
1201
|
+
loadProviderImpl = loadProvider,
|
|
1202
|
+
wireImpl = wireAuditStoryEdges,
|
|
1203
|
+
recordFiledIssuesImpl = recordFiledIssues,
|
|
1204
|
+
} = deps;
|
|
1090
1205
|
const groups = (plan.classifications ?? [])
|
|
1091
1206
|
.filter((c) => c.action === 'create')
|
|
1092
1207
|
.map((c) => c.group);
|
|
1208
|
+
|
|
1209
|
+
// Record BEFORE the provider is loaded. The record is pure local filesystem
|
|
1210
|
+
// work, while the wiring below needs a provider exposing `updateTicket` — and
|
|
1211
|
+
// the host most likely to lack one is the `gh`-less host where the ledger is
|
|
1212
|
+
// the only duplicate protection there is. Recording first means such a run
|
|
1213
|
+
// still remembers what it filed, and the precondition error below still
|
|
1214
|
+
// surfaces unchanged afterwards.
|
|
1215
|
+
const ledger = recordFiledIssuesImpl({
|
|
1216
|
+
ledgerPath,
|
|
1217
|
+
groups,
|
|
1218
|
+
issueByGroupKey,
|
|
1219
|
+
write,
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1093
1222
|
let provider;
|
|
1094
1223
|
try {
|
|
1095
1224
|
provider = await loadProviderImpl();
|
|
@@ -1099,7 +1228,7 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
|
|
|
1099
1228
|
if (typeof provider?.updateTicket !== 'function') {
|
|
1100
1229
|
throw wireEdgesPreconditionError('fixture-no-write-port');
|
|
1101
1230
|
}
|
|
1102
|
-
|
|
1231
|
+
const wired = await wireImpl({
|
|
1103
1232
|
groups,
|
|
1104
1233
|
edges: plan.edges ?? [],
|
|
1105
1234
|
issueByGroupKey,
|
|
@@ -1107,6 +1236,7 @@ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
|
|
|
1107
1236
|
updateBody: (issueNumber, body) =>
|
|
1108
1237
|
provider.updateTicket(issueNumber, { body }),
|
|
1109
1238
|
});
|
|
1239
|
+
return { ...wired, ledger };
|
|
1110
1240
|
}
|
|
1111
1241
|
|
|
1112
1242
|
/**
|
|
@@ -1161,6 +1291,8 @@ export const __testing = {
|
|
|
1161
1291
|
loadProviderOrNull,
|
|
1162
1292
|
dedupSkippedWarning,
|
|
1163
1293
|
dedupDegradedWarning,
|
|
1294
|
+
dedupIndexWarning,
|
|
1295
|
+
dedupIndexDegradedWarning,
|
|
1164
1296
|
buildAndGateStories,
|
|
1165
1297
|
runAuto,
|
|
1166
1298
|
resolveSeverityFloor,
|
|
@@ -1291,6 +1423,7 @@ export async function runAuditToStories(
|
|
|
1291
1423
|
plan: { type: 'string' },
|
|
1292
1424
|
out: { type: 'string' },
|
|
1293
1425
|
'no-provider': { type: 'boolean' },
|
|
1426
|
+
'issues-file': { type: 'string' },
|
|
1294
1427
|
'allow-missing-tally': { type: 'boolean' },
|
|
1295
1428
|
json: { type: 'boolean' },
|
|
1296
1429
|
},
|
|
@@ -1308,6 +1441,7 @@ export async function runAuditToStories(
|
|
|
1308
1441
|
severity: values.severity,
|
|
1309
1442
|
dryRun: values['dry-run'],
|
|
1310
1443
|
useProvider: !values['no-provider'],
|
|
1444
|
+
issuesFile: values['issues-file'],
|
|
1311
1445
|
ledgerPath: values.ledger,
|
|
1312
1446
|
ledgerCommit: values['ledger-commit'],
|
|
1313
1447
|
})
|
|
@@ -1334,6 +1468,7 @@ export async function runAuditToStories(
|
|
|
1334
1468
|
glob: values.glob,
|
|
1335
1469
|
severity: values.severity,
|
|
1336
1470
|
useProvider: !values['no-provider'],
|
|
1471
|
+
issuesFile: values['issues-file'],
|
|
1337
1472
|
allowMissingTally: values['allow-missing-tally'],
|
|
1338
1473
|
});
|
|
1339
1474
|
|
|
@@ -1359,6 +1494,11 @@ export async function runAuditToStories(
|
|
|
1359
1494
|
wireEdgesImpl({
|
|
1360
1495
|
plan: loadPlanImpl(values.plan),
|
|
1361
1496
|
issueByGroupKey: parseIssueMapImpl(values.ids),
|
|
1497
|
+
ledgerPath: values.ledger,
|
|
1498
|
+
// `--scan` still never writes the ledger; `--wire-edges` runs only after
|
|
1499
|
+
// the Issues were really opened, where recording is never wrong — so the
|
|
1500
|
+
// record is on by default here and `--dry-run` is what suppresses it.
|
|
1501
|
+
write: !values['dry-run'],
|
|
1362
1502
|
});
|
|
1363
1503
|
|
|
1364
1504
|
// One table, not a chain of `if (values.X) { …; return; }`. Each entry
|
|
@@ -1435,7 +1575,7 @@ runAsCli(import.meta.url, main, {
|
|
|
1435
1575
|
['--emit-stories', 'Emit the Story drafts as JSON.'],
|
|
1436
1576
|
[
|
|
1437
1577
|
'--wire-edges',
|
|
1438
|
-
'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations. Needs --plan and --ids.',
|
|
1578
|
+
'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations, and record the mapped issues in the cross-run ledger as filed. Needs --plan and --ids; --dry-run suppresses the ledger write.',
|
|
1439
1579
|
],
|
|
1440
1580
|
[
|
|
1441
1581
|
'--ids <json|path>',
|
|
@@ -1443,7 +1583,10 @@ runAsCli(import.meta.url, main, {
|
|
|
1443
1583
|
],
|
|
1444
1584
|
['--glob <pattern>', 'Override the audit-results glob.'],
|
|
1445
1585
|
['--severity <level>', 'Lowest severity to include (high|medium|low).'],
|
|
1446
|
-
[
|
|
1586
|
+
[
|
|
1587
|
+
'--ledger <path>',
|
|
1588
|
+
`Path to the cross-run dedup ledger (default ${DEFAULT_LEDGER_PATH}).`,
|
|
1589
|
+
],
|
|
1447
1590
|
[
|
|
1448
1591
|
'--ledger-commit',
|
|
1449
1592
|
'After the --auto summary prints, commit a changed ledger onto chore/audit-ledger-<date>, push it, and open a PR against the base branch (never auto-merged). Ignored under --dry-run.',
|
|
@@ -1454,6 +1597,10 @@ runAsCli(import.meta.url, main, {
|
|
|
1454
1597
|
],
|
|
1455
1598
|
['--out <path>', 'Write output to a file instead of stdout.'],
|
|
1456
1599
|
['--no-provider', 'Skip live GitHub dedup lookups (offline).'],
|
|
1600
|
+
[
|
|
1601
|
+
'--issues-file <path>',
|
|
1602
|
+
'Dedup against a JSON array of issues the host already fetched (every issue labelled audit::*, state all) instead of listing them through the provider. Lets dedup run where there is no gh CLI; composes with --no-provider.',
|
|
1603
|
+
],
|
|
1457
1604
|
[
|
|
1458
1605
|
'--allow-missing-tally',
|
|
1459
1606
|
'Downgrade a missing "Severity tally:" line to a warning (--scan only; --auto ignores it).',
|