mandrel 2.20.0 → 2.22.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/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
48
48
|
import { createHash } from 'node:crypto';
|
|
49
49
|
|
|
50
|
+
import { inNodeTestContext } from '../config/temp-paths.js';
|
|
51
|
+
import { LABEL_COLORS } from '../label-constants.js';
|
|
50
52
|
import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
|
|
51
53
|
import {
|
|
52
54
|
structuredCommentMarker,
|
|
@@ -96,6 +98,91 @@ export const DEFAULT_MAX_FILINGS_PER_RUN = 20;
|
|
|
96
98
|
*/
|
|
97
99
|
export const CROSS_REPO_DEFERRED_COMMENT_TYPE = 'cross-repo-deferred';
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Explicit, greppable opt-back-in to live issue filing from a context the
|
|
103
|
+
* guard below refuses (Story #4837). Deliberately shaped like
|
|
104
|
+
* `MANDREL_TEST_ALLOW_REAL_TEMP` (`config/temp-paths.js`) rather than a new
|
|
105
|
+
* bespoke flag: same env-var idiom, same "an escape hatch must be visible in
|
|
106
|
+
* a grep" posture.
|
|
107
|
+
*/
|
|
108
|
+
const ALLOW_LIVE_FILING_ENV = 'MANDREL_ALLOW_LIVE_ISSUE_FILING';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Skip reason recorded for every finding a refused filing context drops.
|
|
112
|
+
* Module-local: it reaches callers as data on the returned envelope, so
|
|
113
|
+
* exporting the constant would only add a symbol nothing imports.
|
|
114
|
+
*/
|
|
115
|
+
const LIVE_FILING_BLOCKED_REASON = 'live-api-guard';
|
|
116
|
+
|
|
117
|
+
/** `NODE_ENV` values that declare this process is not a production run. */
|
|
118
|
+
const NON_PRODUCTION_NODE_ENVS = new Set(['test', 'development']);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decide whether this process may let the graduator walk reach the live
|
|
122
|
+
* GitHub API (Story #4837).
|
|
123
|
+
*
|
|
124
|
+
* **Why this exists.** Issues #4833 and #4834 were created against the live
|
|
125
|
+
* `dsj1984/mandrel` tracker — from fixture findings anchored to `epic-101`
|
|
126
|
+
* and `epic-777` — by a development run of the filing path. Nothing in the
|
|
127
|
+
* walk distinguished "a real close is filing a real follow-up" from "someone
|
|
128
|
+
* is exercising this module", so the only thing standing between a test and
|
|
129
|
+
* the production tracker was the author remembering to stub `spawnImpl`.
|
|
130
|
+
*
|
|
131
|
+
* **The seam.** There are exactly two ways to be safe, and this returns
|
|
132
|
+
* `allowed` only for them:
|
|
133
|
+
*
|
|
134
|
+
* 1. `spawnImpl` was injected — the walk then spawns the caller's stub and
|
|
135
|
+
* no child process reaches `gh` at all. This is the seam
|
|
136
|
+
* `.agents/rules/test-seams.md` already mandates, so a well-behaved test
|
|
137
|
+
* is unaffected.
|
|
138
|
+
* 2. The process provably is **not** a test or development context — not a
|
|
139
|
+
* node:test run (per the single shared detector in
|
|
140
|
+
* `config/temp-paths.js#inNodeTestContext`) and not a run that declared
|
|
141
|
+
* itself non-production via `NODE_ENV`.
|
|
142
|
+
*
|
|
143
|
+
* **Fail closed.** Anything else refuses: a test context with the real
|
|
144
|
+
* `spawn` (the #4833/#4834 shape), and — critically — a context that cannot
|
|
145
|
+
* be *decided*, because an unreadable env is not evidence of production. The
|
|
146
|
+
* refusal is a skip, not a throw: observability must never fail a close.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} opts
|
|
149
|
+
* @param {Function} [opts.spawnImpl]
|
|
150
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
151
|
+
* @param {string[]} [opts.execArgv]
|
|
152
|
+
* @returns {{ allowed: boolean, reason: string|null }}
|
|
153
|
+
*/
|
|
154
|
+
function resolveFilingContext({
|
|
155
|
+
spawnImpl,
|
|
156
|
+
env = process.env,
|
|
157
|
+
execArgv = process.execArgv,
|
|
158
|
+
} = {}) {
|
|
159
|
+
const refuse = { allowed: false, reason: LIVE_FILING_BLOCKED_REASON };
|
|
160
|
+
const allow = { allowed: true, reason: null };
|
|
161
|
+
|
|
162
|
+
// An injected seam cannot reach the live API by construction.
|
|
163
|
+
if (typeof spawnImpl === 'function') return allow;
|
|
164
|
+
|
|
165
|
+
// Undecidable context → refuse. Both inputs must be readable for the
|
|
166
|
+
// detector's answer to mean anything.
|
|
167
|
+
if (env === null || typeof env !== 'object' || !Array.isArray(execArgv)) {
|
|
168
|
+
return refuse;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
if (env[ALLOW_LIVE_FILING_ENV] === '1') return allow;
|
|
172
|
+
if (inNodeTestContext(env, execArgv)) return refuse;
|
|
173
|
+
// A declared non-production environment is the other half of "a test or
|
|
174
|
+
// development context". `lib/test-env.js` stamps `NODE_ENV=test` on the
|
|
175
|
+
// whole suite environment, and `development` is the universal marker for
|
|
176
|
+
// a hand-run session; nothing on the close path sets either.
|
|
177
|
+
return NON_PRODUCTION_NODE_ENVS.has(String(env.NODE_ENV ?? ''))
|
|
178
|
+
? refuse
|
|
179
|
+
: allow;
|
|
180
|
+
} catch {
|
|
181
|
+
// An env we cannot even read is not evidence of production.
|
|
182
|
+
return refuse;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
99
186
|
/**
|
|
100
187
|
* Compute a stable content fingerprint for a finding from its
|
|
101
188
|
* `category|path|title` triple. Pure — the digest depends only on the
|
|
@@ -281,7 +368,7 @@ export async function probePathStatus({
|
|
|
281
368
|
* inside an HTML comment, yet a query that carries the `<!--` / `-->`
|
|
282
369
|
* delimiters never matches that indexed text (measured against this repo,
|
|
283
370
|
* Story #4657). Stripping the delimiters and trimming yields the bare marker
|
|
284
|
-
* text — `retro-proposal-followup:
|
|
371
|
+
* text — `retro-proposal-followup: <fp>` — which the index matches.
|
|
285
372
|
* The caller-facing marker is left untouched; normalization is the probe's
|
|
286
373
|
* own concern.
|
|
287
374
|
*
|
|
@@ -294,15 +381,38 @@ function normalizeMarkerQuery(marker) {
|
|
|
294
381
|
}
|
|
295
382
|
|
|
296
383
|
/**
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
* the
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
384
|
+
* Normalize one `gh` issue row (from `search issues` or `issue list`) into
|
|
385
|
+
* the identity the update path needs. `state` is lowercased and defaults to
|
|
386
|
+
* the empty string — deliberately NOT to `'open'`: an unknown state must
|
|
387
|
+
* never authorize editing somebody's issue (see
|
|
388
|
+
* {@link resolveFollowUpRecurrence}).
|
|
389
|
+
*
|
|
390
|
+
* @param {object} row
|
|
391
|
+
* @returns {{ number: number|null, state: string, url: string }}
|
|
304
392
|
*/
|
|
305
|
-
|
|
393
|
+
function toFollowUpRef(row) {
|
|
394
|
+
const number = Number(row?.number);
|
|
395
|
+
return {
|
|
396
|
+
number: Number.isInteger(number) && number > 0 ? number : null,
|
|
397
|
+
state: String(row?.state ?? '').toLowerCase(),
|
|
398
|
+
url: typeof row?.url === 'string' ? row.url : '',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Search the routed repo for a follow-up carrying `marker`, resolving the
|
|
404
|
+
* matched issue's identity rather than a bare yes/no. Uses `gh search issues`
|
|
405
|
+
* so we hit the body field directly, querying the delimiter-stripped marker
|
|
406
|
+
* text (see {@link normalizeMarkerQuery}) — the raw `<!-- … -->` form never
|
|
407
|
+
* matches the index.
|
|
408
|
+
*
|
|
409
|
+
* Returns `null` when nothing matched OR when the probe could not decide
|
|
410
|
+
* (spawn/parse error): the deliberate degrade-toward-filing posture, better
|
|
411
|
+
* to risk a duplicate than swallow the finding entirely.
|
|
412
|
+
*
|
|
413
|
+
* @returns {Promise<{ number: number|null, state: string, url: string }|null>}
|
|
414
|
+
*/
|
|
415
|
+
async function searchFollowUpByMarker({
|
|
306
416
|
marker,
|
|
307
417
|
owner,
|
|
308
418
|
repo,
|
|
@@ -318,40 +428,60 @@ export async function probeMarkerExists({
|
|
|
318
428
|
'--repo',
|
|
319
429
|
`${owner}/${repo}`,
|
|
320
430
|
'--json',
|
|
321
|
-
'number',
|
|
431
|
+
'number,state,url',
|
|
322
432
|
'--limit',
|
|
323
433
|
'1',
|
|
324
434
|
];
|
|
325
435
|
const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
|
|
326
436
|
if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
|
|
327
|
-
return
|
|
437
|
+
return null;
|
|
328
438
|
}
|
|
329
439
|
try {
|
|
330
440
|
const parsed = JSON.parse(res.stdout || '[]');
|
|
331
|
-
|
|
441
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
442
|
+
return toFollowUpRef(parsed[0]);
|
|
332
443
|
} catch {
|
|
333
|
-
return
|
|
444
|
+
return null;
|
|
334
445
|
}
|
|
335
446
|
}
|
|
336
447
|
|
|
337
448
|
/**
|
|
338
|
-
*
|
|
339
|
-
* already exists
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
449
|
+
* Probe whether a follow-up issue carrying the given idempotency marker
|
|
450
|
+
* already exists in the routed repo. Thin boolean façade over
|
|
451
|
+
* {@link searchFollowUpByMarker} — kept as the module's exported probe seam
|
|
452
|
+
* so callers that only need the yes/no do not have to know about the issue
|
|
453
|
+
* identity the recurrence path resolves.
|
|
454
|
+
*
|
|
455
|
+
* @returns {Promise<boolean>}
|
|
456
|
+
*/
|
|
457
|
+
export async function probeMarkerExists(opts) {
|
|
458
|
+
return (await searchFollowUpByMarker(opts)) !== null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Strongly-consistent lookup of an already-filed follow-up, run ONLY on the
|
|
463
|
+
* would-file path as the last gate before creating. `gh search issues` reads
|
|
464
|
+
* an eventually-consistent index whose catch-up latency (measured under 20s
|
|
465
|
+
* against this repo, Story #4657) is exactly wide enough to miss a
|
|
466
|
+
* byte-identical duplicate filed seconds earlier in the same rollup. A
|
|
467
|
+
* label-scoped `gh issue list … --state all` is strongly consistent, so it
|
|
468
|
+
* closes that window. The list is narrowed by the follow-up's own labels
|
|
469
|
+
* (supplied by the same `spec.buildFollowUp` that writes the marker, so the
|
|
470
|
+
* two agree by construction) to keep the read bounded.
|
|
471
|
+
*
|
|
472
|
+
* `markers` is a LIST of body substrings, any one of which identifies a prior
|
|
473
|
+
* filing (Story #4837). One finding can have been filed under more than one
|
|
474
|
+
* marker shape — the retro graduator's pre-cutover marker embedded the run
|
|
475
|
+
* anchor, so the same finding sits behind `epic-<anchor>-<fp>` on already-
|
|
476
|
+
* filed issues and behind the anchor-free `<fp>` on new ones. Matching any of
|
|
477
|
+
* them is what stops a marker-format change from re-filing the whole backlog.
|
|
478
|
+
*
|
|
479
|
+
* Returns `null` when nothing matched, or on any spawn/parse error — the
|
|
350
480
|
* deliberate degrade-toward-filing posture: an undecidable probe risks a
|
|
351
481
|
* duplicate rather than swallowing the finding.
|
|
352
482
|
*
|
|
353
483
|
* @param {object} opts
|
|
354
|
-
* @param {string} opts.
|
|
484
|
+
* @param {string[]} opts.markers — body substrings identifying a prior filing.
|
|
355
485
|
* @param {string} opts.owner
|
|
356
486
|
* @param {string} opts.repo
|
|
357
487
|
* @param {string[]} [opts.labels] — the follow-up's labels; scopes the list.
|
|
@@ -359,10 +489,10 @@ export async function probeMarkerExists({
|
|
|
359
489
|
* @param {Function} [opts.spawnImpl]
|
|
360
490
|
* @param {string} [opts.cwd]
|
|
361
491
|
* @param {number} [opts.timeoutMs]
|
|
362
|
-
* @returns {Promise<
|
|
492
|
+
* @returns {Promise<{ number: number|null, state: string, url: string }|null>}
|
|
363
493
|
*/
|
|
364
|
-
async function
|
|
365
|
-
|
|
494
|
+
async function findExistingFollowUp({
|
|
495
|
+
markers,
|
|
366
496
|
owner,
|
|
367
497
|
repo,
|
|
368
498
|
labels,
|
|
@@ -371,6 +501,10 @@ async function confirmMarkerFiled({
|
|
|
371
501
|
cwd,
|
|
372
502
|
timeoutMs,
|
|
373
503
|
}) {
|
|
504
|
+
const tokens = (Array.isArray(markers) ? markers : []).filter(
|
|
505
|
+
(m) => typeof m === 'string' && m.length > 0,
|
|
506
|
+
);
|
|
507
|
+
if (tokens.length === 0) return null;
|
|
374
508
|
const args = [
|
|
375
509
|
'issue',
|
|
376
510
|
'list',
|
|
@@ -379,24 +513,261 @@ async function confirmMarkerFiled({
|
|
|
379
513
|
'--state',
|
|
380
514
|
'all',
|
|
381
515
|
'--json',
|
|
382
|
-
'number,body',
|
|
516
|
+
'number,body,state,url',
|
|
383
517
|
];
|
|
384
518
|
for (const label of Array.isArray(labels) ? labels : []) {
|
|
385
519
|
args.push('--label', label);
|
|
386
520
|
}
|
|
387
521
|
const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
|
|
388
522
|
if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
|
|
389
|
-
return
|
|
523
|
+
return null;
|
|
390
524
|
}
|
|
525
|
+
let parsed;
|
|
391
526
|
try {
|
|
392
|
-
|
|
393
|
-
if (!Array.isArray(parsed)) return false;
|
|
394
|
-
return parsed.some(
|
|
395
|
-
(issue) => typeof issue?.body === 'string' && issue.body.includes(marker),
|
|
396
|
-
);
|
|
527
|
+
parsed = JSON.parse(res.stdout || '[]');
|
|
397
528
|
} catch {
|
|
398
|
-
return
|
|
529
|
+
return null;
|
|
399
530
|
}
|
|
531
|
+
if (!Array.isArray(parsed)) return null;
|
|
532
|
+
const matches = parsed.filter(
|
|
533
|
+
(issue) =>
|
|
534
|
+
typeof issue?.body === 'string' &&
|
|
535
|
+
tokens.some((token) => issue.body.includes(token)),
|
|
536
|
+
);
|
|
537
|
+
if (matches.length === 0) return null;
|
|
538
|
+
// Prefer an OPEN match: a recurrence updates the live issue, while a closed
|
|
539
|
+
// one is a decided follow-up. When several match, the open one is the one a
|
|
540
|
+
// human is still looking at.
|
|
541
|
+
const open = matches.find(
|
|
542
|
+
(issue) => String(issue?.state ?? '').toLowerCase() === 'open',
|
|
543
|
+
);
|
|
544
|
+
return toFollowUpRef(open ?? matches[0]);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Refresh an existing follow-up's body in place — the recurrence path
|
|
549
|
+
* (Story #4837). A finding that recurs is the SAME finding: the loop's job is
|
|
550
|
+
* to keep one issue current, not to mint issue #N+1 whose only new
|
|
551
|
+
* information is that the count went up.
|
|
552
|
+
*
|
|
553
|
+
* Body-only: labels, title, assignees and state are left exactly as a human
|
|
554
|
+
* may have curated them.
|
|
555
|
+
*
|
|
556
|
+
* @returns {Promise<{ url: string|null, error: string|null }>}
|
|
557
|
+
*/
|
|
558
|
+
async function updateFollowUpIssue({
|
|
559
|
+
owner,
|
|
560
|
+
repo,
|
|
561
|
+
number,
|
|
562
|
+
body,
|
|
563
|
+
ghPath,
|
|
564
|
+
spawnImpl,
|
|
565
|
+
cwd,
|
|
566
|
+
timeoutMs,
|
|
567
|
+
}) {
|
|
568
|
+
const res = await runChild({
|
|
569
|
+
cmd: ghPath,
|
|
570
|
+
args: [
|
|
571
|
+
'issue',
|
|
572
|
+
'edit',
|
|
573
|
+
String(number),
|
|
574
|
+
'--repo',
|
|
575
|
+
`${owner}/${repo}`,
|
|
576
|
+
'--body',
|
|
577
|
+
body,
|
|
578
|
+
],
|
|
579
|
+
spawnImpl,
|
|
580
|
+
cwd,
|
|
581
|
+
timeoutMs,
|
|
582
|
+
});
|
|
583
|
+
if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
|
|
584
|
+
return {
|
|
585
|
+
url: null,
|
|
586
|
+
error: res.spawnError
|
|
587
|
+
? `gh issue edit spawn failed: ${res.spawnError.message}`
|
|
588
|
+
: `gh issue edit exited ${res.code}: ${(res.stderr || '').trim()}`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
return { url: (res.stdout || '').trim(), error: null };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Prefix of the per-category friction axis the feedback loop mints. */
|
|
595
|
+
const FRICTION_LABEL_PREFIX = 'friction::';
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Resolve the color + description for a label the feedback loop is about to
|
|
599
|
+
* mint. Only two axes reach this path — `meta::*` (routing) and
|
|
600
|
+
* `friction::<category>` (the telemetry bucket) — so the mapping is a
|
|
601
|
+
* two-branch lookup rather than a registry.
|
|
602
|
+
*
|
|
603
|
+
* @param {string} name
|
|
604
|
+
* @returns {{ color: string, description: string }}
|
|
605
|
+
*/
|
|
606
|
+
function describeMintedLabel(name) {
|
|
607
|
+
if (name.startsWith(FRICTION_LABEL_PREFIX)) {
|
|
608
|
+
return {
|
|
609
|
+
color: LABEL_COLORS.FRICTION,
|
|
610
|
+
description: `Recurring friction category "${name.slice(FRICTION_LABEL_PREFIX.length)}" (minted by the feedback loop)`,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
color: LABEL_COLORS.META,
|
|
615
|
+
description: 'Feedback-loop routing axis (minted by the feedback loop)',
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Read the routed repo's live label names once per repo, memoized in
|
|
621
|
+
* `labelCache`. Returns `null` when the set could not be established —
|
|
622
|
+
* "verification unavailable", which is deliberately NOT the same as "the repo
|
|
623
|
+
* has no labels": an unreadable set must not be read as proof that every
|
|
624
|
+
* label is missing.
|
|
625
|
+
*
|
|
626
|
+
* @returns {Promise<{ known: Set<string>|null, error: string|null }>}
|
|
627
|
+
*/
|
|
628
|
+
async function readLiveLabelNames({
|
|
629
|
+
owner,
|
|
630
|
+
repo,
|
|
631
|
+
labelCache,
|
|
632
|
+
ghPath,
|
|
633
|
+
spawnImpl,
|
|
634
|
+
cwd,
|
|
635
|
+
timeoutMs,
|
|
636
|
+
}) {
|
|
637
|
+
const key = `${owner}/${repo}`;
|
|
638
|
+
const cached = labelCache?.get(key);
|
|
639
|
+
if (cached) return { known: cached, error: null };
|
|
640
|
+
const res = await runChild({
|
|
641
|
+
cmd: ghPath,
|
|
642
|
+
args: ['label', 'list', '--repo', key, '--limit', '500', '--json', 'name'],
|
|
643
|
+
spawnImpl,
|
|
644
|
+
cwd,
|
|
645
|
+
timeoutMs,
|
|
646
|
+
});
|
|
647
|
+
if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
|
|
648
|
+
return {
|
|
649
|
+
known: null,
|
|
650
|
+
error: `gh label list ${key} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
let parsed;
|
|
654
|
+
try {
|
|
655
|
+
parsed = JSON.parse(res.stdout || '[]');
|
|
656
|
+
} catch {
|
|
657
|
+
return {
|
|
658
|
+
known: null,
|
|
659
|
+
error: `gh label list ${key} returned unparseable JSON`,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (!Array.isArray(parsed)) {
|
|
663
|
+
return { known: null, error: `gh label list ${key} returned a non-array` };
|
|
664
|
+
}
|
|
665
|
+
const known = new Set();
|
|
666
|
+
for (const row of parsed) {
|
|
667
|
+
if (row && typeof row.name === 'string') known.add(row.name);
|
|
668
|
+
}
|
|
669
|
+
labelCache?.set(key, known);
|
|
670
|
+
return { known, error: null };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Mint any of `labels` the routed repo does not already carry, so
|
|
675
|
+
* `gh issue create --label …` can attach them (Story #4828).
|
|
676
|
+
*
|
|
677
|
+
* **Why this exists.** `gh issue create` resolves every `--label` name against
|
|
678
|
+
* the repo before it creates anything, and fails the whole call when one is
|
|
679
|
+
* absent. The feedback loop mints two axes that no bootstrap can enumerate
|
|
680
|
+
* ahead of time — `meta::consumer-improvement` is absent from `LABEL_TAXONOMY`
|
|
681
|
+
* outright, and `friction::<category>` names come from live telemetry — so on
|
|
682
|
+
* a repo that never had them, *every* filing failed. The failure landed in the
|
|
683
|
+
* graduator's `errors[]`, which the run epilogue did not surface, so a
|
|
684
|
+
* hard-failing feedback loop rendered as `filed: 0`: indistinguishable from
|
|
685
|
+
* "nothing was actionable".
|
|
686
|
+
*
|
|
687
|
+
* Degrades rather than blocks: when the live set cannot be read, this returns
|
|
688
|
+
* no `missing` entries so the caller still attempts the filing (the old
|
|
689
|
+
* behaviour) instead of refusing on an unproven premise.
|
|
690
|
+
*
|
|
691
|
+
* @param {object} opts
|
|
692
|
+
* @param {string} opts.owner
|
|
693
|
+
* @param {string} opts.repo
|
|
694
|
+
* @param {string[]} opts.labels
|
|
695
|
+
* @param {Map<string, Set<string>>} [opts.labelCache] — per-repo live-set memo.
|
|
696
|
+
* @param {string} [opts.ghPath]
|
|
697
|
+
* @param {Function} [opts.spawnImpl]
|
|
698
|
+
* @param {string} [opts.cwd]
|
|
699
|
+
* @param {number} [opts.timeoutMs]
|
|
700
|
+
* @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
|
|
701
|
+
*/
|
|
702
|
+
async function ensureIssueLabels({
|
|
703
|
+
owner,
|
|
704
|
+
repo,
|
|
705
|
+
labels,
|
|
706
|
+
labelCache,
|
|
707
|
+
ghPath = 'gh',
|
|
708
|
+
spawnImpl,
|
|
709
|
+
cwd,
|
|
710
|
+
timeoutMs,
|
|
711
|
+
}) {
|
|
712
|
+
const wanted = (Array.isArray(labels) ? labels : []).filter(
|
|
713
|
+
(name) => typeof name === 'string' && name.trim().length > 0,
|
|
714
|
+
);
|
|
715
|
+
if (wanted.length === 0) return { created: [], missing: [], errors: [] };
|
|
716
|
+
|
|
717
|
+
const { known, error } = await readLiveLabelNames({
|
|
718
|
+
owner,
|
|
719
|
+
repo,
|
|
720
|
+
labelCache,
|
|
721
|
+
ghPath,
|
|
722
|
+
spawnImpl,
|
|
723
|
+
cwd,
|
|
724
|
+
timeoutMs,
|
|
725
|
+
});
|
|
726
|
+
// Verification unavailable — attempt the filing anyway rather than refuse.
|
|
727
|
+
if (!known) return { created: [], missing: [], errors: error ? [error] : [] };
|
|
728
|
+
|
|
729
|
+
const created = [];
|
|
730
|
+
const missing = [];
|
|
731
|
+
const errors = [];
|
|
732
|
+
for (const name of wanted) {
|
|
733
|
+
if (known.has(name)) continue;
|
|
734
|
+
const { color, description } = describeMintedLabel(name);
|
|
735
|
+
const res = await runChild({
|
|
736
|
+
cmd: ghPath,
|
|
737
|
+
args: [
|
|
738
|
+
'label',
|
|
739
|
+
'create',
|
|
740
|
+
name,
|
|
741
|
+
'--repo',
|
|
742
|
+
`${owner}/${repo}`,
|
|
743
|
+
'--color',
|
|
744
|
+
color.replace(/^#/, ''),
|
|
745
|
+
'--description',
|
|
746
|
+
description,
|
|
747
|
+
],
|
|
748
|
+
spawnImpl,
|
|
749
|
+
cwd,
|
|
750
|
+
timeoutMs,
|
|
751
|
+
});
|
|
752
|
+
const failed =
|
|
753
|
+
Boolean(res.spawnError) ||
|
|
754
|
+
(typeof res.code === 'number' && res.code !== 0);
|
|
755
|
+
// A concurrent roll-up may have minted it between the list and the create;
|
|
756
|
+
// that is the idempotent outcome, not a failure.
|
|
757
|
+
const raced = /label\b[\s\S]*?already exists/i.test(
|
|
758
|
+
`${res.stderr ?? ''}${res.spawnError?.message ?? ''}`,
|
|
759
|
+
);
|
|
760
|
+
if (!failed || raced) {
|
|
761
|
+
known.add(name);
|
|
762
|
+
if (!raced) created.push(name);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
missing.push(name);
|
|
766
|
+
errors.push(
|
|
767
|
+
`gh label create "${name}" in ${owner}/${repo} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
return { created, missing, errors };
|
|
400
771
|
}
|
|
401
772
|
|
|
402
773
|
/**
|
|
@@ -510,8 +881,12 @@ async function loadGraduateFindings({ epicId, provider, spec }) {
|
|
|
510
881
|
* content-hash marker AND the legacy `(epicId, parse-index)` marker so
|
|
511
882
|
* findings filed before the fingerprint cutover are not re-filed. The
|
|
512
883
|
* content-hash marker is passed in precomputed so a caller can consult an
|
|
513
|
-
* in-process memo before spending a spawn.
|
|
514
|
-
*
|
|
884
|
+
* in-process memo before spending a spawn.
|
|
885
|
+
*
|
|
886
|
+
* Resolves `{ alreadyFiled, existing }` — `existing` carries the matched
|
|
887
|
+
* issue's `{ number, state, url }` when the search index surfaced it, so a
|
|
888
|
+
* recurrence can update that issue rather than only knowing that one exists.
|
|
889
|
+
* `existing` is `null` on the not-found path.
|
|
515
890
|
*/
|
|
516
891
|
async function resolveAlreadyFiled({
|
|
517
892
|
finding,
|
|
@@ -525,7 +900,7 @@ async function resolveAlreadyFiled({
|
|
|
525
900
|
spec,
|
|
526
901
|
}) {
|
|
527
902
|
const probe = (marker) =>
|
|
528
|
-
|
|
903
|
+
searchFollowUpByMarker({
|
|
529
904
|
marker,
|
|
530
905
|
owner: routedRepo.owner,
|
|
531
906
|
repo: routedRepo.repo,
|
|
@@ -535,18 +910,86 @@ async function resolveAlreadyFiled({
|
|
|
535
910
|
timeoutMs,
|
|
536
911
|
});
|
|
537
912
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
913
|
+
const hit = await probe(contentMarker);
|
|
914
|
+
if (hit) return { alreadyFiled: true, existing: hit };
|
|
541
915
|
// Legacy recognition — a pre-cutover follow-up carries the ordinal
|
|
542
916
|
// marker, not the content hash. Skip re-filing when it is present.
|
|
543
917
|
if (typeof spec.buildLegacyMarker === 'function') {
|
|
544
918
|
const legacyMarker = spec.buildLegacyMarker(epicId, finding.index);
|
|
545
|
-
if (legacyMarker
|
|
546
|
-
|
|
919
|
+
if (legacyMarker) {
|
|
920
|
+
const legacyHit = await probe(legacyMarker);
|
|
921
|
+
if (legacyHit) return { alreadyFiled: true, existing: legacyHit };
|
|
547
922
|
}
|
|
548
923
|
}
|
|
549
|
-
return { alreadyFiled: false };
|
|
924
|
+
return { alreadyFiled: false, existing: null };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Resolve what a recurrence of an already-identified follow-up should do
|
|
929
|
+
* (Story #4837), and fold the outcome into the running envelope.
|
|
930
|
+
*
|
|
931
|
+
* An **open** issue with a resolvable number is refreshed in place and
|
|
932
|
+
* recorded on `envelope.filed` with `action: 'updated'` — same array as a
|
|
933
|
+
* creation, because both are live writes this run performed and the roll-up
|
|
934
|
+
* that reports "filed" must count them alike.
|
|
935
|
+
*
|
|
936
|
+
* Anything else records `already-filed` and touches nothing: a closed
|
|
937
|
+
* follow-up is a decided one (reopening it would relitigate a human's call),
|
|
938
|
+
* and an unresolvable number or state is not licence to edit an issue we
|
|
939
|
+
* cannot identify.
|
|
940
|
+
*
|
|
941
|
+
* @returns {Promise<boolean>} `true` when the recurrence was handled here.
|
|
942
|
+
*/
|
|
943
|
+
async function resolveFollowUpRecurrence({
|
|
944
|
+
existing,
|
|
945
|
+
body,
|
|
946
|
+
finding,
|
|
947
|
+
source,
|
|
948
|
+
routedRepo,
|
|
949
|
+
envelope,
|
|
950
|
+
decorate,
|
|
951
|
+
skip,
|
|
952
|
+
ghPath,
|
|
953
|
+
spawnImpl,
|
|
954
|
+
cwd,
|
|
955
|
+
timeoutMs,
|
|
956
|
+
}) {
|
|
957
|
+
if (existing.state !== 'open' || existing.number === null) {
|
|
958
|
+
skip('already-filed');
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
const updated = await updateFollowUpIssue({
|
|
962
|
+
owner: routedRepo.owner,
|
|
963
|
+
repo: routedRepo.repo,
|
|
964
|
+
number: existing.number,
|
|
965
|
+
body,
|
|
966
|
+
ghPath,
|
|
967
|
+
spawnImpl,
|
|
968
|
+
cwd,
|
|
969
|
+
timeoutMs,
|
|
970
|
+
});
|
|
971
|
+
if (updated.error) {
|
|
972
|
+
envelope.errors.push(
|
|
973
|
+
`finding ${finding.index} (${finding.path}): ${updated.error}`,
|
|
974
|
+
);
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
envelope.filed.push(
|
|
978
|
+
decorate(
|
|
979
|
+
{
|
|
980
|
+
index: finding.index,
|
|
981
|
+
action: 'updated',
|
|
982
|
+
issueNumber: existing.number,
|
|
983
|
+
severity: finding.severity,
|
|
984
|
+
path: finding.path,
|
|
985
|
+
source,
|
|
986
|
+
repo: `${routedRepo.owner}/${routedRepo.repo}`,
|
|
987
|
+
url: updated.url || existing.url || null,
|
|
988
|
+
},
|
|
989
|
+
finding,
|
|
990
|
+
),
|
|
991
|
+
);
|
|
992
|
+
return true;
|
|
550
993
|
}
|
|
551
994
|
|
|
552
995
|
/**
|
|
@@ -572,6 +1015,7 @@ async function processGraduateFinding({
|
|
|
572
1015
|
maxFilingsPerRun,
|
|
573
1016
|
crossRepoDeferred,
|
|
574
1017
|
filedMarkers,
|
|
1018
|
+
labelCache,
|
|
575
1019
|
logger,
|
|
576
1020
|
spec,
|
|
577
1021
|
}) {
|
|
@@ -629,7 +1073,7 @@ async function processGraduateFinding({
|
|
|
629
1073
|
// race the eventually-consistent search index cannot.
|
|
630
1074
|
if (filedMarkers?.has(contentMarker)) return skip('already-filed');
|
|
631
1075
|
|
|
632
|
-
const { alreadyFiled } = await resolveAlreadyFiled({
|
|
1076
|
+
const { alreadyFiled, existing } = await resolveAlreadyFiled({
|
|
633
1077
|
finding,
|
|
634
1078
|
epicId,
|
|
635
1079
|
routedRepo,
|
|
@@ -640,16 +1084,11 @@ async function processGraduateFinding({
|
|
|
640
1084
|
timeoutMs,
|
|
641
1085
|
spec,
|
|
642
1086
|
});
|
|
643
|
-
if (alreadyFiled) return skip('already-filed');
|
|
644
|
-
|
|
645
|
-
// Per-run filing cap — count only actual filings (already-filed and
|
|
646
|
-
// skipped findings do not consume the budget). The excess is surfaced so
|
|
647
|
-
// a re-run picks it up next time.
|
|
648
|
-
if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
|
|
649
1087
|
|
|
650
1088
|
// Resolve the follow-up (title/body/labels) BEFORE the dedup decision so
|
|
651
1089
|
// the strong read can scope its `gh issue list` by the very labels this
|
|
652
|
-
// filing would carry (they agree with the marker by construction)
|
|
1090
|
+
// filing would carry (they agree with the marker by construction), and so
|
|
1091
|
+
// the recurrence path has the refreshed body to write.
|
|
653
1092
|
const { title, body, labels } = spec.buildFollowUp({
|
|
654
1093
|
finding,
|
|
655
1094
|
source,
|
|
@@ -657,14 +1096,62 @@ async function processGraduateFinding({
|
|
|
657
1096
|
idMarker: contentMarker,
|
|
658
1097
|
});
|
|
659
1098
|
|
|
1099
|
+
if (alreadyFiled) {
|
|
1100
|
+
filedMarkers?.add(contentMarker);
|
|
1101
|
+
await resolveFollowUpRecurrence({
|
|
1102
|
+
existing,
|
|
1103
|
+
body,
|
|
1104
|
+
finding,
|
|
1105
|
+
source,
|
|
1106
|
+
routedRepo,
|
|
1107
|
+
envelope,
|
|
1108
|
+
decorate,
|
|
1109
|
+
skip,
|
|
1110
|
+
ghPath,
|
|
1111
|
+
spawnImpl,
|
|
1112
|
+
cwd,
|
|
1113
|
+
timeoutMs,
|
|
1114
|
+
});
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Per-run filing cap — bounds the live writes this run performs. Checked
|
|
1119
|
+
// only on the would-file path: an `already-filed` finding resolved above
|
|
1120
|
+
// never reaches here. The excess is surfaced so a re-run picks it up.
|
|
1121
|
+
if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
|
|
1122
|
+
|
|
1123
|
+
// Mint any routing label the repo does not carry yet (Story #4828). This
|
|
1124
|
+
// runs BEFORE the strong read as well as before the create: `gh issue list
|
|
1125
|
+
// --label <absent>` exits 0 with `[]`, so an absent label silently degrades
|
|
1126
|
+
// the dedup confirm too, not just the filing.
|
|
1127
|
+
const ensured = await ensureIssueLabels({
|
|
1128
|
+
owner: routedRepo.owner,
|
|
1129
|
+
repo: routedRepo.repo,
|
|
1130
|
+
labels,
|
|
1131
|
+
labelCache,
|
|
1132
|
+
ghPath,
|
|
1133
|
+
spawnImpl,
|
|
1134
|
+
cwd,
|
|
1135
|
+
timeoutMs,
|
|
1136
|
+
});
|
|
1137
|
+
envelope.errors.push(...ensured.errors);
|
|
1138
|
+
if (ensured.missing.length > 0) {
|
|
1139
|
+
// `gh issue create` would reject the whole call on the absent name; say
|
|
1140
|
+
// which label blocked it rather than replaying an opaque CLI failure.
|
|
1141
|
+
return skip('label-ensure-failed');
|
|
1142
|
+
}
|
|
1143
|
+
|
|
660
1144
|
// Strong read (would-file path only, Story #4657): the search probe reads
|
|
661
1145
|
// an eventually-consistent index that can miss a byte-identical duplicate
|
|
662
1146
|
// filed seconds earlier. Confirm against a strongly-consistent,
|
|
663
1147
|
// label-scoped `gh issue list` before creating. Skipped entirely on the
|
|
664
1148
|
// already-filed path above, so it never fires when the search probe
|
|
665
1149
|
// already matched.
|
|
666
|
-
const confirmed = await
|
|
667
|
-
|
|
1150
|
+
const confirmed = await findExistingFollowUp({
|
|
1151
|
+
markers:
|
|
1152
|
+
typeof spec.buildMatchTokens === 'function'
|
|
1153
|
+
? spec.buildMatchTokens({ epicId, finding, contentMarker })
|
|
1154
|
+
: [contentMarker],
|
|
668
1155
|
owner: routedRepo.owner,
|
|
669
1156
|
repo: routedRepo.repo,
|
|
670
1157
|
labels,
|
|
@@ -675,7 +1162,21 @@ async function processGraduateFinding({
|
|
|
675
1162
|
});
|
|
676
1163
|
if (confirmed) {
|
|
677
1164
|
filedMarkers?.add(contentMarker);
|
|
678
|
-
|
|
1165
|
+
await resolveFollowUpRecurrence({
|
|
1166
|
+
existing: confirmed,
|
|
1167
|
+
body,
|
|
1168
|
+
finding,
|
|
1169
|
+
source,
|
|
1170
|
+
routedRepo,
|
|
1171
|
+
envelope,
|
|
1172
|
+
decorate,
|
|
1173
|
+
skip,
|
|
1174
|
+
ghPath,
|
|
1175
|
+
spawnImpl,
|
|
1176
|
+
cwd,
|
|
1177
|
+
timeoutMs,
|
|
1178
|
+
});
|
|
1179
|
+
return;
|
|
679
1180
|
}
|
|
680
1181
|
|
|
681
1182
|
const created = await createFollowUpIssue({
|
|
@@ -700,6 +1201,7 @@ async function processGraduateFinding({
|
|
|
700
1201
|
decorate(
|
|
701
1202
|
{
|
|
702
1203
|
index: finding.index,
|
|
1204
|
+
action: 'created',
|
|
703
1205
|
severity: finding.severity,
|
|
704
1206
|
path: finding.path,
|
|
705
1207
|
source,
|
|
@@ -780,6 +1282,11 @@ async function persistCrossRepoDeferred({
|
|
|
780
1282
|
* marker embedded in (and searched for in) follow-up bodies.
|
|
781
1283
|
* - `buildLegacyMarker(epicId, index)` — the pre-cutover ordinal marker,
|
|
782
1284
|
* probed for idempotency so legacy filings are not duplicated.
|
|
1285
|
+
* - `buildMatchTokens({ epicId, finding, contentMarker })` — optional; the
|
|
1286
|
+
* body substrings the strong read accepts as proof of a prior filing.
|
|
1287
|
+
* Defaults to `[contentMarker]`. A graduator whose marker format changed
|
|
1288
|
+
* returns the old shape here too, so the cutover recognizes the backlog
|
|
1289
|
+
* it already filed instead of duplicating it.
|
|
783
1290
|
* - `buildFollowUp({ finding, source, epicId, idMarker })` — returns
|
|
784
1291
|
* `{ title, body, labels }` for the issue to file.
|
|
785
1292
|
* - `buildCrossRepoLog({ finding, routedRepo, source })` — returns the
|
|
@@ -811,9 +1318,18 @@ async function persistCrossRepoDeferred({
|
|
|
811
1318
|
* calls in one logical invocation (e.g. the retro graduator's two source
|
|
812
1319
|
* buckets) so a marker filed in one call short-circuits a repeat in the
|
|
813
1320
|
* next without a spawn. Defaults to a fresh per-call Set.
|
|
1321
|
+
* @param {Map<string, Set<string>>} [opts.labelCache] — per-repo memo of the
|
|
1322
|
+
* live label set, so the just-in-time label mint (Story #4828) costs one
|
|
1323
|
+
* `gh label list` per routed repo rather than one per finding. Share it
|
|
1324
|
+
* across the calls of one logical invocation, like `filedMarkers`.
|
|
1325
|
+
* @param {NodeJS.ProcessEnv} [opts.env] — injectable for the blast-radius
|
|
1326
|
+
* guard's context decision; defaults to `process.env`.
|
|
1327
|
+
* @param {string[]} [opts.execArgv] — ditto; defaults to `process.execArgv`.
|
|
814
1328
|
* @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
|
|
815
1329
|
* @param {object} opts.spec — the per-graduator behaviour bundle
|
|
816
1330
|
* @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
|
|
1331
|
+
* Each `filed` record carries `action: 'created'|'updated'` — both are live
|
|
1332
|
+
* writes this run performed, so a roll-up counting "filed" counts both.
|
|
817
1333
|
*/
|
|
818
1334
|
export async function graduate({
|
|
819
1335
|
epicId,
|
|
@@ -830,6 +1346,9 @@ export async function graduate({
|
|
|
830
1346
|
maxFilingsPerRun = DEFAULT_MAX_FILINGS_PER_RUN,
|
|
831
1347
|
findings: preParsedFindings,
|
|
832
1348
|
filedMarkers = new Set(),
|
|
1349
|
+
labelCache = new Map(),
|
|
1350
|
+
env,
|
|
1351
|
+
execArgv,
|
|
833
1352
|
logger,
|
|
834
1353
|
spec,
|
|
835
1354
|
}) {
|
|
@@ -859,6 +1378,33 @@ export async function graduate({
|
|
|
859
1378
|
findings = loaded.findings;
|
|
860
1379
|
}
|
|
861
1380
|
|
|
1381
|
+
// Blast-radius guard (Story #4837): decided ONCE per walk, before the
|
|
1382
|
+
// first spawn, so a refused context costs no child process at all.
|
|
1383
|
+
const filing = resolveFilingContext({
|
|
1384
|
+
spawnImpl,
|
|
1385
|
+
...(env === undefined ? {} : { env }),
|
|
1386
|
+
...(execArgv === undefined ? {} : { execArgv }),
|
|
1387
|
+
});
|
|
1388
|
+
if (!filing.allowed) {
|
|
1389
|
+
logger?.warn?.(
|
|
1390
|
+
`[${spec.fnName}] refusing to reach the live GitHub API: no injected spawn seam in a test or undecidable context. Set ${ALLOW_LIVE_FILING_ENV}=1 to override deliberately.`,
|
|
1391
|
+
);
|
|
1392
|
+
for (const finding of findings) {
|
|
1393
|
+
envelope.skipped.push(
|
|
1394
|
+
decorate(
|
|
1395
|
+
{
|
|
1396
|
+
index: finding.index,
|
|
1397
|
+
reason: filing.reason,
|
|
1398
|
+
path: finding.path,
|
|
1399
|
+
severity: finding.severity,
|
|
1400
|
+
},
|
|
1401
|
+
finding,
|
|
1402
|
+
),
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
return envelope;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
862
1408
|
const crossRepoDeferred = [];
|
|
863
1409
|
for (const finding of findings) {
|
|
864
1410
|
await processGraduateFinding({
|
|
@@ -877,6 +1423,7 @@ export async function graduate({
|
|
|
877
1423
|
maxFilingsPerRun,
|
|
878
1424
|
crossRepoDeferred,
|
|
879
1425
|
filedMarkers,
|
|
1426
|
+
labelCache,
|
|
880
1427
|
logger,
|
|
881
1428
|
spec,
|
|
882
1429
|
});
|