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
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { META_LABELS } from '../label-constants.js';
|
|
28
|
+
import { CI_GAP_INTAKE_MARKER } from '../orchestration/ci-gap-intake.js';
|
|
28
29
|
import { runChild } from './graduator-core.js';
|
|
29
30
|
|
|
30
31
|
const DEFAULT_LIMIT = 50;
|
|
@@ -135,8 +136,15 @@ function formatGhError(label, { code, stderr, spawnError }) {
|
|
|
135
136
|
* already-budgeted envelope, and trimming early avoids any ambient assumption
|
|
136
137
|
* that downstream consumers can rely on extra fields.
|
|
137
138
|
*
|
|
139
|
+
* `intake` is the one field derived rather than copied: an issue whose body
|
|
140
|
+
* carries the CI-gap intake marker is a filing awaiting graduation, not a
|
|
141
|
+
* finished report, and `/mandrel-plan` offers those a `/mandrel-plan <id>`
|
|
142
|
+
* rewrite. The body itself is NOT carried onto the envelope — the marker
|
|
143
|
+
* check is the whole reason it was fetched, and a planner payload does not
|
|
144
|
+
* need every intake issue's full text.
|
|
145
|
+
*
|
|
138
146
|
* @param {object} raw
|
|
139
|
-
* @returns {{ number: number, title: string, url: string, labels: string[] }|null}
|
|
147
|
+
* @returns {{ number: number, title: string, url: string, labels: string[], intake: boolean }|null}
|
|
140
148
|
*/
|
|
141
149
|
function normalizeIssue(raw) {
|
|
142
150
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -144,12 +152,23 @@ function normalizeIssue(raw) {
|
|
|
144
152
|
if (number === null) return null;
|
|
145
153
|
const title = typeof raw.title === 'string' ? raw.title : '';
|
|
146
154
|
const url = typeof raw.url === 'string' ? raw.url : '';
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
const intake =
|
|
156
|
+
typeof raw.body === 'string' && raw.body.includes(CI_GAP_INTAKE_MARKER);
|
|
157
|
+
return { number, title, url, labels: normalizeLabels(raw.labels), intake };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Flatten `gh issue list --json labels` into plain names. `gh` returns label
|
|
162
|
+
* objects; a hand-built fixture may return strings. Pure.
|
|
163
|
+
*
|
|
164
|
+
* @param {unknown} raw
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
function normalizeLabels(raw) {
|
|
168
|
+
if (!Array.isArray(raw)) return [];
|
|
169
|
+
return raw
|
|
170
|
+
.map((l) => (l && typeof l === 'object' ? l.name : l))
|
|
171
|
+
.filter((name) => typeof name === 'string');
|
|
153
172
|
}
|
|
154
173
|
|
|
155
174
|
/**
|
|
@@ -176,7 +195,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
176
195
|
'--label',
|
|
177
196
|
label,
|
|
178
197
|
'--json',
|
|
179
|
-
'number,title,labels,url',
|
|
198
|
+
'number,title,labels,url,body',
|
|
180
199
|
'--limit',
|
|
181
200
|
String(limit),
|
|
182
201
|
];
|
|
@@ -209,10 +228,32 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
231
|
+
* Append every not-yet-seen issue to one bucket, marking it seen.
|
|
232
|
+
*
|
|
233
|
+
* An issue carrying more than one meta label must reach the planner exactly
|
|
234
|
+
* once, so the `seen` set spans all three buckets and the first bucket to
|
|
235
|
+
* claim a number keeps it. Mutates both arguments — one walk, three buckets.
|
|
236
|
+
*
|
|
237
|
+
* @param {object[]} bucket
|
|
238
|
+
* @param {object[]} issues
|
|
239
|
+
* @param {Set<number>} seen
|
|
240
|
+
* @returns {void}
|
|
241
|
+
*/
|
|
242
|
+
function dedupeInto(bucket, issues, seen) {
|
|
243
|
+
for (const issue of issues) {
|
|
244
|
+
if (seen.has(issue.number)) continue;
|
|
245
|
+
seen.add(issue.number);
|
|
246
|
+
bucket.push(issue);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fetch the union of open issues carrying `meta::framework-gap`,
|
|
252
|
+
* `meta::consumer-improvement` or `meta::platform-gap` and split them into
|
|
253
|
+
* three arrays — one per ownership bucket in `github/framework-repo.js`, so
|
|
254
|
+
* a filing's bucket survives all the way to the planner. An issue carrying
|
|
255
|
+
* more than one label appears once, in that precedence order; dedupe-by-number
|
|
256
|
+
* runs across all three arrays so the planner sees each issue exactly once.
|
|
216
257
|
*
|
|
217
258
|
* The returned envelope is best-effort: every failure mode (gh missing, repo
|
|
218
259
|
* not found, non-zero exit, malformed JSON) is captured as a string in
|
|
@@ -227,6 +268,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
|
|
|
227
268
|
* @returns {Promise<{
|
|
228
269
|
* frameworkGaps: object[],
|
|
229
270
|
* consumerImprovements: object[],
|
|
271
|
+
* platformGaps: object[],
|
|
230
272
|
* recurringDefectClasses: Array<{ class: string, count: number, issues: number[] }>,
|
|
231
273
|
* fetchedAt: string,
|
|
232
274
|
* errors: string[],
|
|
@@ -251,6 +293,7 @@ export async function fetchPriorFeedback({
|
|
|
251
293
|
const envelope = {
|
|
252
294
|
frameworkGaps: [],
|
|
253
295
|
consumerImprovements: [],
|
|
296
|
+
platformGaps: [],
|
|
254
297
|
recurringDefectClasses: [],
|
|
255
298
|
fetchedAt: new Date().toISOString(),
|
|
256
299
|
errors,
|
|
@@ -258,7 +301,7 @@ export async function fetchPriorFeedback({
|
|
|
258
301
|
|
|
259
302
|
if (errors.length > 0) return envelope;
|
|
260
303
|
|
|
261
|
-
const [gapsResult, improvementsResult] = await Promise.all([
|
|
304
|
+
const [gapsResult, improvementsResult, platformResult] = await Promise.all([
|
|
262
305
|
fetchByLabel({
|
|
263
306
|
owner,
|
|
264
307
|
repo,
|
|
@@ -275,25 +318,27 @@ export async function fetchPriorFeedback({
|
|
|
275
318
|
limit,
|
|
276
319
|
spawnImpl,
|
|
277
320
|
}),
|
|
321
|
+
fetchByLabel({
|
|
322
|
+
owner,
|
|
323
|
+
repo,
|
|
324
|
+
label: META_LABELS.PLATFORM_GAP,
|
|
325
|
+
ghPath,
|
|
326
|
+
limit,
|
|
327
|
+
spawnImpl,
|
|
328
|
+
}),
|
|
278
329
|
]);
|
|
279
330
|
|
|
280
|
-
|
|
281
|
-
|
|
331
|
+
for (const { error } of [gapsResult, improvementsResult, platformResult]) {
|
|
332
|
+
if (error) errors.push(error);
|
|
333
|
+
}
|
|
282
334
|
|
|
283
335
|
// Dedupe by issue number across both arrays. Issues that carry both labels
|
|
284
336
|
// land in frameworkGaps first (deterministic) and are filtered out of
|
|
285
337
|
// consumerImprovements.
|
|
286
338
|
const seen = new Set();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
envelope.frameworkGaps.push(issue);
|
|
291
|
-
}
|
|
292
|
-
for (const issue of improvementsResult.issues) {
|
|
293
|
-
if (seen.has(issue.number)) continue;
|
|
294
|
-
seen.add(issue.number);
|
|
295
|
-
envelope.consumerImprovements.push(issue);
|
|
296
|
-
}
|
|
339
|
+
dedupeInto(envelope.frameworkGaps, gapsResult.issues, seen);
|
|
340
|
+
dedupeInto(envelope.consumerImprovements, improvementsResult.issues, seen);
|
|
341
|
+
dedupeInto(envelope.platformGaps, platformResult.issues, seen);
|
|
297
342
|
|
|
298
343
|
// Story #4135 (Epic #4131, F11) — close the retro→planner loop: derive the
|
|
299
344
|
// recurring defect classes from the `friction::<class>` labels carried by
|
|
@@ -303,6 +348,7 @@ export async function fetchPriorFeedback({
|
|
|
303
348
|
envelope.recurringDefectClasses = extractRecurringDefectClasses([
|
|
304
349
|
...envelope.frameworkGaps,
|
|
305
350
|
...envelope.consumerImprovements,
|
|
351
|
+
...envelope.platformGaps,
|
|
306
352
|
]);
|
|
307
353
|
|
|
308
354
|
return envelope;
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* Routing correctness: a routed item already knows its source
|
|
23
23
|
* (`framework` / `consumer`), so we file each source bucket with its own
|
|
24
24
|
* constant classifier and thread the graduator's per-run filing cap across
|
|
25
|
-
* the two buckets.
|
|
25
|
+
* the two buckets. Which repository a source routes to is decided once, in
|
|
26
|
+
* `github/framework-repo.js` — the silent consumer-repo fallback that used
|
|
27
|
+
* to live here (and mis-filed framework work into the consumer's tracker)
|
|
28
|
+
* is gone from the whole path, not just from this module. The `meta::<framework-gap|consumer-improvement>` +
|
|
26
29
|
* `friction::<category>` labels are lifted verbatim from the routed item.
|
|
27
30
|
*
|
|
28
31
|
* Behind the `delivery.feedbackLoop.retroProposals` toggle (default ON,
|
|
@@ -30,7 +33,10 @@
|
|
|
30
33
|
* failure path is captured in `errors[]`.
|
|
31
34
|
*/
|
|
32
35
|
|
|
33
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_FRAMEWORK_REPO,
|
|
38
|
+
parseRepoSlug,
|
|
39
|
+
} from '../github/framework-repo.js';
|
|
34
40
|
import { META_LABELS } from '../label-constants.js';
|
|
35
41
|
import {
|
|
36
42
|
contentFingerprint,
|
|
@@ -218,6 +224,8 @@ function toFinding(item, source, index) {
|
|
|
218
224
|
* @param {{owner: string, repo: string}} opts.currentRepo — the repo the
|
|
219
225
|
* retro is running inside (the consumer's own repo); the cross-repo guard's
|
|
220
226
|
* anchor.
|
|
227
|
+
* @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
|
|
228
|
+
* platform/infra bucket, forwarded to the walk's routing SSOT.
|
|
221
229
|
* @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
|
|
222
230
|
* framework-tagged proposals route.
|
|
223
231
|
* @param {{ framework?: object[], consumer?: object[] }} [opts.routedProposals]
|
|
@@ -239,6 +247,7 @@ export async function graduateRetroProposals({
|
|
|
239
247
|
config,
|
|
240
248
|
currentRepo,
|
|
241
249
|
frameworkRepo,
|
|
250
|
+
platformRepo,
|
|
242
251
|
routedProposals,
|
|
243
252
|
ghPath,
|
|
244
253
|
spawnImpl,
|
|
@@ -294,6 +303,7 @@ export async function graduateRetroProposals({
|
|
|
294
303
|
config,
|
|
295
304
|
currentRepo,
|
|
296
305
|
frameworkRepo,
|
|
306
|
+
platformRepo,
|
|
297
307
|
// Each bucket's source is known — a constant classifier routes the
|
|
298
308
|
// whole bucket to the correct repo and stamps the correct label.
|
|
299
309
|
classifier: () => source,
|
|
@@ -380,22 +390,6 @@ export function enrichRoutedProposalsWithFilings(routedProposals, filed) {
|
|
|
380
390
|
};
|
|
381
391
|
}
|
|
382
392
|
|
|
383
|
-
/**
|
|
384
|
-
* Parse an `"<owner>/<repo>"` slug into `{ owner, repo }`, or `null` when
|
|
385
|
-
* the slug is empty / malformed.
|
|
386
|
-
*
|
|
387
|
-
* @param {string|null|undefined} slug
|
|
388
|
-
* @returns {{ owner: string, repo: string } | null}
|
|
389
|
-
*/
|
|
390
|
-
function parseRepoSlug(slug) {
|
|
391
|
-
if (typeof slug !== 'string') return null;
|
|
392
|
-
const parts = slug.split('/');
|
|
393
|
-
if (parts.length !== 2) return null;
|
|
394
|
-
const [owner, repo] = parts;
|
|
395
|
-
if (!owner || !repo) return null;
|
|
396
|
-
return { owner, repo };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
393
|
/**
|
|
400
394
|
* Orchestrating seam invoked by the retro post-and-mirror phase: gate the
|
|
401
395
|
* toggle, file the routed proposals, and return the routed proposals
|
|
@@ -452,13 +446,12 @@ export async function fileRetroProposals({
|
|
|
452
446
|
);
|
|
453
447
|
return passthrough('no-current-repo');
|
|
454
448
|
}
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// because consumer === framework here).
|
|
449
|
+
// An unconfigured framework slug falls back to the Mandrel mirror
|
|
450
|
+
// constant, NEVER to the consumer's own repo: the retired consumer-repo
|
|
451
|
+
// fallback silently auto-filed framework-tagged proposals into the
|
|
452
|
+
// consumer's tracker while the retro body rendered them under "framework
|
|
453
|
+
// repo" (masked in this repo only because consumer === framework here).
|
|
454
|
+
// `github/framework-repo.js` is the SSOT for that rule now.
|
|
462
455
|
const frameworkRepoObj =
|
|
463
456
|
parseRepoSlug(frameworkRepo) ?? parseRepoSlug(DEFAULT_FRAMEWORK_REPO);
|
|
464
457
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/audit-
|
|
2
|
+
* lib/findings/audit-ledger.js — Cross-run audit findings ledger.
|
|
3
3
|
*
|
|
4
4
|
* Without a committed memory of what a prior sweep already saw, every
|
|
5
5
|
* `/audit-to-stories` run re-litigates the whole backlog from zero: it cannot
|
|
@@ -30,10 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import nodeFs from 'node:fs';
|
|
32
32
|
import nodePath from 'node:path';
|
|
33
|
-
import {
|
|
34
|
-
fingerprintAuditFinding,
|
|
35
|
-
semanticKeyForAuditFinding,
|
|
36
|
-
} from './finding-adapter.js';
|
|
33
|
+
import { fingerprintFinding, semanticKeyFor } from './route-finding.js';
|
|
37
34
|
|
|
38
35
|
export const DEFAULT_LEDGER_PATH = 'baselines/audit-ledger.json';
|
|
39
36
|
const LEDGER_SCHEMA_URL =
|
|
@@ -54,13 +51,24 @@ function createEmptyLedger(now = new Date().toISOString()) {
|
|
|
54
51
|
/**
|
|
55
52
|
* Compute a finding's stable identity: its fingerprint (title-sensitive) and
|
|
56
53
|
* its location-based semantic key (title-insensitive).
|
|
54
|
+
*
|
|
55
|
+
* The projection onto the canonical identity is the **caller's**, injected as
|
|
56
|
+
* `toCanonical`. That is what lets this module live beside `route-finding.js`
|
|
57
|
+
* in the shared findings layer: the audit pipeline's own adapter
|
|
58
|
+
* (`lib/audit-to-stories/finding-adapter.js`) imports *from* here, so importing
|
|
59
|
+
* it back would close a `findings → audit-to-stories → findings` cycle that
|
|
60
|
+
* `check-arch-cycles` rightly refuses. A caller that already holds canonical
|
|
61
|
+
* findings passes nothing.
|
|
62
|
+
*
|
|
57
63
|
* @param {object} finding — a parsed/stamped audit finding.
|
|
64
|
+
* @param {(finding: object) => object} [toCanonical]
|
|
58
65
|
* @returns {{ fingerprint: string, semanticKey: string }}
|
|
59
66
|
*/
|
|
60
|
-
function findingIdentity(finding) {
|
|
67
|
+
function findingIdentity(finding, toCanonical) {
|
|
68
|
+
const canonical = toCanonical ? toCanonical(finding) : finding;
|
|
61
69
|
return {
|
|
62
|
-
fingerprint:
|
|
63
|
-
semanticKey:
|
|
70
|
+
fingerprint: fingerprintFinding(canonical).full,
|
|
71
|
+
semanticKey: semanticKeyFor(canonical),
|
|
64
72
|
};
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -132,6 +140,19 @@ function resolveIssueState(id, existing, issueStates) {
|
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The four verdicts the policy can reach, spelled once. Deduping them keeps
|
|
145
|
+
* `decideStatus` readable as the decision table it is, rather than eight
|
|
146
|
+
* near-identical object literals.
|
|
147
|
+
* @type {Record<string, { status: string, action: string }>}
|
|
148
|
+
*/
|
|
149
|
+
const VERDICT = Object.freeze({
|
|
150
|
+
filed: Object.freeze({ status: 'filed', action: 'known' }),
|
|
151
|
+
propose: Object.freeze({ status: 'new', action: 'propose' }),
|
|
152
|
+
suppress: Object.freeze({ status: 'accepted-risk', action: 'suppress' }),
|
|
153
|
+
regressed: Object.freeze({ status: 'regressed', action: 'regressed' }),
|
|
154
|
+
});
|
|
155
|
+
|
|
135
156
|
/**
|
|
136
157
|
* Decide the finding's next status + action from its prior ledger state and
|
|
137
158
|
* the live Issue state. This is the whole reconciliation policy in one place.
|
|
@@ -140,31 +161,41 @@ function resolveIssueState(id, existing, issueStates) {
|
|
|
140
161
|
* @returns {{ status: string, action: 'propose'|'known'|'suppress'|'regressed' }}
|
|
141
162
|
*/
|
|
142
163
|
function decideStatus(existing, issue) {
|
|
143
|
-
// A closed Issue is the strongest signal — its close reason drives the
|
|
164
|
+
// A closed Issue is the strongest signal — its close reason drives the
|
|
165
|
+
// verdict, and it is read FIRST so a recorded `filed` can never outrank it.
|
|
144
166
|
if (issue && issue.state === 'closed') {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return { status: 'regressed', action: 'regressed' };
|
|
167
|
+
return issue.stateReason === 'not_planned'
|
|
168
|
+
? VERDICT.suppress
|
|
169
|
+
: // Closed as completed (or unspecified) but the finding is in this scan
|
|
170
|
+
// → it came back. That is a regression, not a fresh proposal.
|
|
171
|
+
VERDICT.regressed;
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
|
|
174
|
+
// An OPEN tracking Issue means the finding has been filed, whatever the prior
|
|
175
|
+
// entry said — including when there is no prior entry at all. Until Story
|
|
176
|
+
// #5305 nothing in the package ever assigned `filed`, so this fell through to
|
|
177
|
+
// `new`/`propose` on every run and the `filed` arm below was unreachable in
|
|
178
|
+
// production: the ledger suppressed nothing, and only the GitHub-search dedup
|
|
179
|
+
// stopped a sweep re-filing what it had already filed. Reading it before the
|
|
180
|
+
// `!existing` guard is what makes a record pass correct on its FIRST run
|
|
181
|
+
// rather than its second.
|
|
182
|
+
const unseen = issue?.state === 'open' ? VERDICT.filed : VERDICT.propose;
|
|
183
|
+
if (!existing) return unseen;
|
|
154
184
|
|
|
155
185
|
switch (existing.status) {
|
|
156
186
|
case 'accepted-risk':
|
|
157
|
-
return
|
|
187
|
+
return VERDICT.suppress;
|
|
158
188
|
case 'filed':
|
|
159
|
-
return
|
|
189
|
+
return VERDICT.filed;
|
|
190
|
+
// Recorded fixed, yet detected again with no closed-Issue evidence → treat
|
|
191
|
+
// as a regression the operator should look at. An open Issue does not
|
|
192
|
+
// soften that: the finding came back either way.
|
|
160
193
|
case 'fixed':
|
|
161
|
-
|
|
162
|
-
// treat as a regression the operator should look at.
|
|
163
|
-
return { status: 'regressed', action: 'regressed' };
|
|
194
|
+
return VERDICT.regressed;
|
|
164
195
|
case 'regressed':
|
|
165
|
-
return
|
|
196
|
+
return VERDICT.regressed;
|
|
166
197
|
default:
|
|
167
|
-
return
|
|
198
|
+
return unseen;
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
|
|
@@ -178,6 +209,8 @@ function decideStatus(existing, issue) {
|
|
|
178
209
|
* Live Issue state keyed by fingerprint (or semanticKey). Optional — when a
|
|
179
210
|
* prior entry already records the Issue, that is used.
|
|
180
211
|
* @param {string} [params.now] — ISO timestamp for firstSeen/lastSeen stamping.
|
|
212
|
+
* @param {(finding: object) => object} [params.toCanonical] — projection onto
|
|
213
|
+
* the canonical identity; omit when `findings` are already canonical.
|
|
181
214
|
* @returns {{
|
|
182
215
|
* ledger: { $schema: string, generatedAt: string, entries: object[] },
|
|
183
216
|
* classifications: Array<{ fingerprint: string, semanticKey: string, status: string, action: string, issue: object|null }>,
|
|
@@ -188,6 +221,7 @@ export function reconcileLedger({
|
|
|
188
221
|
findings,
|
|
189
222
|
issueStates = {},
|
|
190
223
|
now = new Date().toISOString(),
|
|
224
|
+
toCanonical,
|
|
191
225
|
} = {}) {
|
|
192
226
|
if (!Array.isArray(findings)) {
|
|
193
227
|
throw new Error('reconcileLedger: findings must be an array');
|
|
@@ -199,7 +233,7 @@ export function reconcileLedger({
|
|
|
199
233
|
const classifications = [];
|
|
200
234
|
|
|
201
235
|
for (const finding of findings) {
|
|
202
|
-
const id = findingIdentity(finding);
|
|
236
|
+
const id = findingIdentity(finding, toCanonical);
|
|
203
237
|
const existing =
|
|
204
238
|
byFingerprint.get(id.fingerprint) ??
|
|
205
239
|
(id.semanticKey ? bySemanticKey.get(id.semanticKey) : undefined) ??
|
|
@@ -254,3 +288,76 @@ export function reconcileLedger({
|
|
|
254
288
|
classifications,
|
|
255
289
|
};
|
|
256
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Record a set of already-known identities as filed against one Issue.
|
|
294
|
+
*
|
|
295
|
+
* The finding-shaped {@link reconcileLedger} cannot serve this caller:
|
|
296
|
+
* `plan-persist` never sees findings. It holds the provenance identities it
|
|
297
|
+
* stamped on a Story body — fingerprints and semantic keys, as strings — plus
|
|
298
|
+
* the issue number it just created. That is enough to record the filing, and
|
|
299
|
+
* demanding a finding it does not have would be the reason the recommended
|
|
300
|
+
* planning path never reached this ledger at all.
|
|
301
|
+
*
|
|
302
|
+
* An identity already carrying a **closed** Issue is left exactly as it is: a
|
|
303
|
+
* finding whose tracking Issue was closed `not_planned` is `accepted-risk` and
|
|
304
|
+
* must stay suppressed, and one closed as completed is a `regressed` the
|
|
305
|
+
* operator still needs to see. A fresh filing never overwrites either verdict.
|
|
306
|
+
*
|
|
307
|
+
* @param {object} params
|
|
308
|
+
* @param {{ entries?: object[] }} [params.ledger] — prior ledger (default empty).
|
|
309
|
+
* @param {Array<{ fingerprint: string, semanticKey?: string, title?: string, dimension?: string, primaryFile?: string }>} params.identities
|
|
310
|
+
* @param {{ number: number }} params.issue — the Issue these identities were filed as.
|
|
311
|
+
* @param {string} [params.now]
|
|
312
|
+
* @returns {{ ledger: object, recorded: number, skipped: number }}
|
|
313
|
+
*/
|
|
314
|
+
export function recordFiledIdentities({
|
|
315
|
+
ledger = createEmptyLedger(),
|
|
316
|
+
identities,
|
|
317
|
+
issue,
|
|
318
|
+
now = new Date().toISOString(),
|
|
319
|
+
} = {}) {
|
|
320
|
+
if (!Array.isArray(identities)) {
|
|
321
|
+
throw new Error('recordFiledIdentities: identities must be an array');
|
|
322
|
+
}
|
|
323
|
+
if (!issue || typeof issue.number !== 'number') {
|
|
324
|
+
throw new Error('recordFiledIdentities: issue.number must be a number');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const { byFingerprint } = indexLedger(ledger);
|
|
328
|
+
const next = new Map(byFingerprint);
|
|
329
|
+
let recorded = 0;
|
|
330
|
+
let skipped = 0;
|
|
331
|
+
|
|
332
|
+
for (const identity of identities) {
|
|
333
|
+
const fingerprint = identity?.fingerprint;
|
|
334
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) continue;
|
|
335
|
+
const existing = byFingerprint.get(fingerprint) ?? null;
|
|
336
|
+
if (existing?.issue && existing.issue.state === 'closed') {
|
|
337
|
+
skipped += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
next.set(fingerprint, {
|
|
341
|
+
fingerprint,
|
|
342
|
+
semanticKey: identity.semanticKey ?? existing?.semanticKey ?? '',
|
|
343
|
+
title: identity.title ?? existing?.title ?? '',
|
|
344
|
+
dimension: identity.dimension ?? existing?.dimension ?? '',
|
|
345
|
+
primaryFile: identity.primaryFile ?? existing?.primaryFile ?? '',
|
|
346
|
+
status: 'filed',
|
|
347
|
+
issue: { number: issue.number, state: 'open', stateReason: null },
|
|
348
|
+
firstSeen: existing?.firstSeen ?? now,
|
|
349
|
+
lastSeen: now,
|
|
350
|
+
});
|
|
351
|
+
recorded += 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
ledger: {
|
|
356
|
+
$schema: ledger?.$schema ?? LEDGER_SCHEMA_URL,
|
|
357
|
+
generatedAt: now,
|
|
358
|
+
entries: [...next.values()],
|
|
359
|
+
},
|
|
360
|
+
recorded,
|
|
361
|
+
skipped,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -36,6 +36,7 @@ import { fingerprintSeverity } from './severity.js';
|
|
|
36
36
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
37
37
|
const MARKER = 'audit-fingerprints:';
|
|
38
38
|
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
39
|
+
const LABEL_MARKER = 'audit-labels:';
|
|
39
40
|
export const SHA1_RE = /^[0-9a-f]{40}$/;
|
|
40
41
|
// A semantic key round-trips through a comma-joined footer, so it must not
|
|
41
42
|
// carry a comma or a `>` (which would truncate the HTML comment). Both are
|
|
@@ -160,12 +161,14 @@ export function semanticKeyFooter(keys) {
|
|
|
160
161
|
/**
|
|
161
162
|
* Extract semantic keys from an Issue body carrying the semantic-key footer.
|
|
162
163
|
* The audit filers stamp the footer via {@link semanticKeyFooter}; the
|
|
163
|
-
* confirmation path here
|
|
164
|
+
* confirmation path here, {@link carryProvenanceFooters} and the audit dedup's
|
|
165
|
+
* local issue index read it back. Exported alongside its writer so an indexer
|
|
166
|
+
* cannot drift into a second parse of the same footer.
|
|
164
167
|
*
|
|
165
168
|
* @param {string} body
|
|
166
169
|
* @returns {string[]}
|
|
167
170
|
*/
|
|
168
|
-
function parseSemanticKeyFooter(body) {
|
|
171
|
+
export function parseSemanticKeyFooter(body) {
|
|
169
172
|
return parseAllFooterValues(
|
|
170
173
|
body,
|
|
171
174
|
/<!--\s*audit-semantic-keys:\s*([^>]*?)\s*-->/g,
|
|
@@ -173,6 +176,43 @@ function parseSemanticKeyFooter(body) {
|
|
|
173
176
|
);
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Render the machine-readable audit-label footer
|
|
181
|
+
* (`<!-- audit-labels: audit::x,audit::y -->`).
|
|
182
|
+
*
|
|
183
|
+
* The dedup corpus is listed by `audit::*` label, so a Story carrying none is
|
|
184
|
+
* absent from the pool an indexed run matches against — and with an index in
|
|
185
|
+
* play the exact lookup is answered locally and never reaches the provider, so
|
|
186
|
+
* a fingerprint footer alone cannot rescue it. Carrying the labels through the
|
|
187
|
+
* seed is what lets the planning path stamp them without the authoring agent
|
|
188
|
+
* being asked to notice them (Story #5307).
|
|
189
|
+
*
|
|
190
|
+
* @param {string | string[]} labels
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function auditLabelFooter(labels) {
|
|
194
|
+
const list = (Array.isArray(labels) ? labels : [labels])
|
|
195
|
+
.filter((l) => typeof l === 'string' && l.startsWith('audit::'))
|
|
196
|
+
.map((l) => l.replace(/[,>]/g, ' ').trim())
|
|
197
|
+
.filter((l) => l.length > 0);
|
|
198
|
+
if (list.length === 0) return '';
|
|
199
|
+
return `<!-- ${LABEL_MARKER} ${[...new Set(list)].sort().join(',')} -->`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read every `audit-labels` footer out of a body, de-duplicated.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} body
|
|
206
|
+
* @returns {string[]}
|
|
207
|
+
*/
|
|
208
|
+
export function parseAuditLabelFooter(body) {
|
|
209
|
+
return parseAllFooterValues(
|
|
210
|
+
body,
|
|
211
|
+
/<!--\s*audit-labels:\s*([^>]*?)\s*-->/g,
|
|
212
|
+
(s) => s.startsWith('audit::'),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
176
216
|
/**
|
|
177
217
|
* Render the machine-readable fingerprint footer for one or more shas.
|
|
178
218
|
*
|