mandrel 2.55.0 → 2.57.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/plan-critic.md +13 -18
- package/.agents/agents/story-worker.md +25 -34
- package/.agents/docs/agentrc-reference.json +4 -30
- package/.agents/docs/configuration.md +11 -28
- package/.agents/docs/execution-reference.md +5 -5
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/instructions.md +9 -10
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +28 -185
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +107 -17
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/ceremony-derive.js +191 -0
- package/.agents/scripts/check-context-budget.js +28 -33
- package/.agents/scripts/check-cyclomatic.js +4 -3
- package/.agents/scripts/deliver-light.js +31 -94
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
- 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/baselines/coverage-updater-cli.js +110 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
- package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
- package/.agents/scripts/lib/close-validation/gates.js +52 -1
- package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
- package/.agents/scripts/lib/config/delivery-routing.js +7 -33
- package/.agents/scripts/lib/config/explain.js +0 -19
- package/.agents/scripts/lib/config/limits.js +18 -78
- package/.agents/scripts/lib/config/quality.js +6 -3
- package/.agents/scripts/lib/config/runners.js +3 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
- package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
- package/.agents/scripts/lib/config-settings-schema.js +49 -143
- package/.agents/scripts/lib/crap-engine.js +35 -4
- package/.agents/scripts/lib/crap-utils.js +17 -1
- package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
- 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/runtime-friction.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
- 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/complexity-gate.js +46 -212
- package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
- package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
- package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
- package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
- package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
- package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
- package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
- package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
- package/.agents/scripts/lib/story-body/story-body.js +17 -237
- package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
- package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
- package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
- package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
- package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
- package/.agents/scripts/lib/test-run-credit.js +266 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
- package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
- package/.agents/scripts/lib/workers/crap-worker.js +32 -41
- package/.agents/scripts/plan-context.js +7 -9
- package/.agents/scripts/plan-critics.js +28 -54
- package/.agents/scripts/plan-persist.js +25 -68
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/quality-preview.js +51 -0
- package/.agents/scripts/run-tests.js +12 -0
- package/.agents/scripts/stories-wave-tick.js +23 -45
- package/.agents/scripts/test-isolate.js +13 -180
- package/.agents/scripts/update-coverage-baseline.js +25 -70
- package/.agents/scripts/update-crap-baseline.js +19 -123
- package/.agents/skills/core/scope-triage/SKILL.md +3 -3
- package/.agents/workflows/audit-clean-code.md +4 -3
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
- package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
- package/.agents/workflows/helpers/code-review.md +2 -3
- package/.agents/workflows/helpers/deliver-digest.md +41 -57
- package/.agents/workflows/helpers/deliver-light.md +40 -105
- package/.agents/workflows/helpers/deliver-reference.md +1 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
- package/.agents/workflows/helpers/deliver-story.md +9 -13
- package/.agents/workflows/helpers/plan-reference.md +132 -196
- package/.agents/workflows/mandrel-plan.md +28 -41
- package/.agents/workflows/memory-consolidate.md +9 -13
- package/docs/CHANGELOG.md +33 -0
- package/lib/migrations/index.js +4 -0
- package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
- package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/framework-version.js +0 -39
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
- package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
- package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
|
@@ -14,9 +14,35 @@ import {
|
|
|
14
14
|
crapFormula,
|
|
15
15
|
} from './crap-coordinates.js';
|
|
16
16
|
import { deriveMethodIdentities } from './crap-method-identity.js';
|
|
17
|
+
import { install as installAstCompat } from './escomplex-ast-compat.js';
|
|
17
18
|
|
|
18
19
|
export { COORDINATE_ORIGINAL, COORDINATE_TRANSPILED, crapFormula };
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Sentinel returned by {@link calculateCrapForSource} for a source the kernel
|
|
23
|
+
* cannot parse. Deliberately **not** `[]`: a caller receiving an empty array
|
|
24
|
+
* cannot tell an unscorable file from one with no methods, which is how a
|
|
25
|
+
* parse failure used to reach the baseline as a silent zero (Story #5311).
|
|
26
|
+
*/
|
|
27
|
+
export const UNSCORABLE = null;
|
|
28
|
+
|
|
29
|
+
// The kernel's code generator predates the Babel AST its own parser emits, so
|
|
30
|
+
// ordinary modern syntax (`?.`, `await` or a regex in a loop head, object
|
|
31
|
+
// spread in a default parameter) aborts `analyzeModule` for the WHOLE file —
|
|
32
|
+
// see `escomplex-ast-compat.js` for the defect and the upstream status.
|
|
33
|
+
//
|
|
34
|
+
// Story #5311: the install belongs here, at the scoring kernel, because this
|
|
35
|
+
// is where both CRAP scorers converge — `calculateCrapForSource` (the worker
|
|
36
|
+
// path) and `crap-utils.js#analyzeOnce` (the serial path, which reaches this
|
|
37
|
+
// module for `methodRowsFromReport`). It used to be reached only as a side
|
|
38
|
+
// effect of `maintainability-engine.js` sitting somewhere in the serial path's
|
|
39
|
+
// import graph, which the worker's graph never included: 362 methods across
|
|
40
|
+
// 21 files scored zero via workers and scored fine serially, and
|
|
41
|
+
// `POOL_SERIAL_THRESHOLD` makes the worker path the only one a real repo
|
|
42
|
+
// takes. Anchoring it at the kernel makes the next worker entrypoint correct
|
|
43
|
+
// by construction rather than by an import nobody would guess is load-bearing.
|
|
44
|
+
installAstCompat();
|
|
45
|
+
|
|
20
46
|
/**
|
|
21
47
|
* Derive the raw per-method CRAP rows from an escomplex report.
|
|
22
48
|
*
|
|
@@ -199,8 +225,12 @@ export { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
|
|
|
199
225
|
* produce `coverage: null` and `crap: null`. Callers apply their own
|
|
200
226
|
* `requireCoverage` policy at the scanner level (`finalizeMethodRows`);
|
|
201
227
|
* this kernel never decides to skip.
|
|
202
|
-
* - A parse error returns
|
|
203
|
-
*
|
|
228
|
+
* - A parse error returns {@link UNSCORABLE} (`null`) — the file could not
|
|
229
|
+
* be scored at all, which is a different fact from "it has no methods"
|
|
230
|
+
* (`[]`). Story #5311: returning `[]` for both collapsed them, and every
|
|
231
|
+
* caller's drop path for an unscorable file became unreachable — the
|
|
232
|
+
* whole parse-failure class landed in the baseline as a clean zero.
|
|
233
|
+
* Callers MUST branch on `rows === null` before iterating.
|
|
204
234
|
*
|
|
205
235
|
* @param {string} source JavaScript source text (possibly transpiled).
|
|
206
236
|
* @param {object|null} coverageForFile The inner value from a
|
|
@@ -216,7 +246,8 @@ export { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
|
|
|
216
246
|
* coverage: number|null,
|
|
217
247
|
* crap: number|null,
|
|
218
248
|
* coordinateSystem: 'original'|'transpiled',
|
|
219
|
-
* }
|
|
249
|
+
* }>|null} The method rows, or {@link UNSCORABLE} when the source did not
|
|
250
|
+
* parse.
|
|
220
251
|
*/
|
|
221
252
|
export function calculateCrapForSource(
|
|
222
253
|
source,
|
|
@@ -227,7 +258,7 @@ export function calculateCrapForSource(
|
|
|
227
258
|
try {
|
|
228
259
|
report = escomplex.analyzeModule(source);
|
|
229
260
|
} catch {
|
|
230
|
-
return
|
|
261
|
+
return UNSCORABLE;
|
|
231
262
|
}
|
|
232
263
|
return methodRowsFromReport(report, coverageForFile, mapLine);
|
|
233
264
|
}
|
|
@@ -348,6 +348,12 @@ function projectScanRow(relPath, mr) {
|
|
|
348
348
|
* skipped from the returned rows so the baseline never contains
|
|
349
349
|
* partially-scored entries. Both counters surface for reporting.
|
|
350
350
|
*
|
|
351
|
+
* A file neither path could score at all — unreadable, untranspilable, or one
|
|
352
|
+
* the kernel could not parse — is dropped and counted in `unscorableFiles`
|
|
353
|
+
* (Story #5311). Unscorable is not scored-as-nothing: without this counter a
|
|
354
|
+
* dropped file is indistinguishable from a file with no methods, which is what
|
|
355
|
+
* let a whole parse-failure class leave the baseline silently.
|
|
356
|
+
*
|
|
351
357
|
* When `scopeFiles` is provided (the `--changed-since` code path) files
|
|
352
358
|
* discovered via directory walking are filtered against that set before any
|
|
353
359
|
* I/O or scoring happens — so pre-push / PR-CI runs never pay the
|
|
@@ -385,6 +391,7 @@ function projectScanRow(relPath, mr) {
|
|
|
385
391
|
* scannedFiles: number,
|
|
386
392
|
* skippedFilesNoCoverage: number,
|
|
387
393
|
* skippedMethodsNoCoverage: number,
|
|
394
|
+
* unscorableFiles: number,
|
|
388
395
|
* }}
|
|
389
396
|
*/
|
|
390
397
|
export async function scanAndScore({
|
|
@@ -442,9 +449,16 @@ export async function scanAndScore({
|
|
|
442
449
|
const rows = [];
|
|
443
450
|
let skippedFilesNoCoverage = 0;
|
|
444
451
|
let skippedMethodsNoCoverage = 0;
|
|
452
|
+
let unscorableFiles = 0;
|
|
445
453
|
const resolution = newResolutionAccumulator();
|
|
446
454
|
for (const { item, result } of perFile) {
|
|
447
|
-
if (!result)
|
|
455
|
+
if (!result) {
|
|
456
|
+
// Unrecoverable per-file failure (a pool-level error the worker never
|
|
457
|
+
// answered). Dropped like any other unscorable file, and counted — the
|
|
458
|
+
// point of the counter is that no drop is silent.
|
|
459
|
+
unscorableFiles += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
448
462
|
if (result.skippedFileNoCoverage) {
|
|
449
463
|
skippedFilesNoCoverage += 1;
|
|
450
464
|
continue;
|
|
@@ -453,6 +467,7 @@ export async function scanAndScore({
|
|
|
453
467
|
// read/transpile/parse failure: drop and move on, but if the worker
|
|
454
468
|
// attached an error message (calculateCrapForSource throw) surface it
|
|
455
469
|
// so the run isn't silent on the ops side.
|
|
470
|
+
unscorableFiles += 1;
|
|
456
471
|
if (result.error) {
|
|
457
472
|
Logger.warn(
|
|
458
473
|
`[crap-utils] failed to score ${item.relPath}: ${result.error}`,
|
|
@@ -479,6 +494,7 @@ export async function scanAndScore({
|
|
|
479
494
|
scannedFiles,
|
|
480
495
|
skippedFilesNoCoverage,
|
|
481
496
|
skippedMethodsNoCoverage,
|
|
497
|
+
unscorableFiles,
|
|
482
498
|
resolution: summarizeResolution(resolution),
|
|
483
499
|
};
|
|
484
500
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cyclomatic-ceiling.js — the enforcing core behind
|
|
3
|
-
*
|
|
2
|
+
* cyclomatic-ceiling.js — the enforcing core behind the cyclomatic ceiling
|
|
3
|
+
* ratchet (Story #4923; fixed ceiling since Story #5313).
|
|
4
4
|
*
|
|
5
5
|
* The two `codingGuardrails` cyclomatic knobs shipped schema-validated,
|
|
6
6
|
* bootstrap-defaulted and resolver-resolved, and were then read by nothing:
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* nothing enforces is worse than no ceiling, because the workflow docs promise
|
|
10
10
|
* the merge will be refused.
|
|
11
11
|
*
|
|
12
|
+
* Story #5313 retired the `cyclomaticMustFix` config key: the ratchet's
|
|
13
|
+
* ceiling is the fixed {@link CYCLOMATIC_CEILING} (12), so a consumer cannot
|
|
14
|
+
* bound this gate by tuning a number, and `cyclomaticFlag` is the one
|
|
15
|
+
* advisory knob — `quality-preview.js` reports over-flag methods and exits 0
|
|
16
|
+
* on them.
|
|
17
|
+
*
|
|
12
18
|
* This module is that enforcement, shaped as a **ratchet** rather than a
|
|
13
19
|
* cliff. The repository already carries dozens of functions above the
|
|
14
20
|
* must-fix ceiling; failing every one of them at once would have made the
|
|
@@ -37,6 +43,13 @@ import { scanDirectory } from './maintainability-utils.js';
|
|
|
37
43
|
/** Default location of the committed breach baseline. */
|
|
38
44
|
export const DEFAULT_CYCLOMATIC_BASELINE = 'baselines/cyclomatic.json';
|
|
39
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The per-function cyclomatic ceiling the ratchet enforces. Fixed — not a
|
|
48
|
+
* config key — since Story #5313.
|
|
49
|
+
* @type {number}
|
|
50
|
+
*/
|
|
51
|
+
export const CYCLOMATIC_CEILING = 12;
|
|
52
|
+
|
|
40
53
|
/** Baseline `$schema` marker, matching the sibling ratchet baselines. */
|
|
41
54
|
const CYCLOMATIC_BASELINE_SCHEMA =
|
|
42
55
|
'https://mandrel.dev/baselines/cyclomatic.schema.json';
|
|
@@ -44,10 +57,9 @@ const CYCLOMATIC_BASELINE_SCHEMA =
|
|
|
44
57
|
/**
|
|
45
58
|
* Resolve the enforcement policy from a resolved `delivery.quality` block.
|
|
46
59
|
*
|
|
47
|
-
* `mustFix`
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* maintainability gate (see the module note).
|
|
60
|
+
* `mustFix` is the fixed {@link CYCLOMATIC_CEILING}; `flag` comes from
|
|
61
|
+
* `resolveCodingGuardrails` (advisory only). `targetDirs` / `ignoreGlobs`
|
|
62
|
+
* are borrowed from the maintainability gate (see the module note).
|
|
51
63
|
*
|
|
52
64
|
* @param {object | null | undefined} quality resolved `delivery.quality`
|
|
53
65
|
* @returns {{ mustFix: number, flag: number, targetDirs: string[], ignoreGlobs: string[] }}
|
|
@@ -56,7 +68,7 @@ export function resolveCyclomaticPolicy(quality) {
|
|
|
56
68
|
const guardrails = quality?.codingGuardrails ?? {};
|
|
57
69
|
const mi = quality?.maintainability ?? {};
|
|
58
70
|
return {
|
|
59
|
-
mustFix:
|
|
71
|
+
mustFix: CYCLOMATIC_CEILING,
|
|
60
72
|
flag: Number(guardrails.cyclomaticFlag ?? 8),
|
|
61
73
|
targetDirs: Array.isArray(mi.targetDirs) ? mi.targetDirs : [],
|
|
62
74
|
ignoreGlobs: Array.isArray(mi.ignoreGlobs) ? mi.ignoreGlobs : [],
|
|
@@ -45,12 +45,18 @@
|
|
|
45
45
|
* - **Durable cross-repo deferral.** Cross-repo-deferred findings are
|
|
46
46
|
* upserted into a structured comment on the Epic instead of only a
|
|
47
47
|
* log line.
|
|
48
|
+
* - **Routed, never re-pointed.** Ownership routing is delegated to
|
|
49
|
+
* `github/framework-repo.js#routeOwnership`; an unroutable bucket is
|
|
50
|
+
* skipped `unroutable` and named in that same durable comment. The
|
|
51
|
+
* predecessor resolved an absent framework slug to the consumer's own
|
|
52
|
+
* repo, which silently mis-filed framework-owned work.
|
|
48
53
|
*/
|
|
49
54
|
|
|
50
55
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
51
56
|
import { createHash } from 'node:crypto';
|
|
52
57
|
|
|
53
58
|
import { inNodeTestContext } from '../config/temp-paths.js';
|
|
59
|
+
import { routeOwnership } from '../github/framework-repo.js';
|
|
54
60
|
import { LABEL_COLORS } from '../label-constants.js';
|
|
55
61
|
import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
|
|
56
62
|
import { upsertStructuredComment } from '../orchestration/ticketing.js';
|
|
@@ -531,7 +537,7 @@ async function findExistingFollowUp({
|
|
|
531
537
|
*
|
|
532
538
|
* @returns {Promise<{ url: string|null, error: string|null }>}
|
|
533
539
|
*/
|
|
534
|
-
async function updateFollowUpIssue({
|
|
540
|
+
export async function updateFollowUpIssue({
|
|
535
541
|
owner,
|
|
536
542
|
repo,
|
|
537
543
|
number,
|
|
@@ -675,7 +681,7 @@ async function readLiveLabelNames({
|
|
|
675
681
|
* @param {number} [opts.timeoutMs]
|
|
676
682
|
* @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
|
|
677
683
|
*/
|
|
678
|
-
async function ensureIssueLabels({
|
|
684
|
+
export async function ensureIssueLabels({
|
|
679
685
|
owner,
|
|
680
686
|
repo,
|
|
681
687
|
labels,
|
|
@@ -947,7 +953,7 @@ async function processGraduateFinding({
|
|
|
947
953
|
decorate,
|
|
948
954
|
epicId,
|
|
949
955
|
currentRepo,
|
|
950
|
-
|
|
956
|
+
repos,
|
|
951
957
|
classifier,
|
|
952
958
|
gitRef,
|
|
953
959
|
ghPath,
|
|
@@ -994,12 +1000,26 @@ async function processGraduateFinding({
|
|
|
994
1000
|
}
|
|
995
1001
|
|
|
996
1002
|
const source = classifier(finding.path, null);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
+
// Ownership routing is the shared SSOT's call, and an unroutable bucket is
|
|
1004
|
+
// an outcome rather than a fallback: the predecessor resolved an absent
|
|
1005
|
+
// framework slug to the CONSUMER's repo, silently filing framework-owned
|
|
1006
|
+
// work in the wrong place (see `github/framework-repo.js`). Unroutable
|
|
1007
|
+
// findings are deferred and named, never re-pointed.
|
|
1008
|
+
const routing = routeOwnership({ bucket: source, repos, currentRepo });
|
|
1009
|
+
if (!routing.routable) {
|
|
1010
|
+
const logLine = `[${spec.fnName}] unroutable ${source} finding (${routing.missingKey} is unset) — not filed: ${finding.title ?? finding.path ?? `finding ${finding.index}`}`;
|
|
1011
|
+
logger?.warn?.(logLine);
|
|
1012
|
+
crossRepoDeferred.push({
|
|
1013
|
+
finding,
|
|
1014
|
+
routedRepo: null,
|
|
1015
|
+
source,
|
|
1016
|
+
logLine,
|
|
1017
|
+
missingKey: routing.missingKey,
|
|
1018
|
+
});
|
|
1019
|
+
return skip('unroutable');
|
|
1020
|
+
}
|
|
1021
|
+
const routedRepo = routing.routedRepo;
|
|
1022
|
+
if (routing.crossRepo) {
|
|
1003
1023
|
const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
|
|
1004
1024
|
logger?.info?.(logLine);
|
|
1005
1025
|
crossRepoDeferred.push({ finding, routedRepo, source, logLine });
|
|
@@ -1165,13 +1185,18 @@ function renderCrossRepoDeferredBody(deferred, spec) {
|
|
|
1165
1185
|
const header =
|
|
1166
1186
|
spec.crossRepoCommentHeader ??
|
|
1167
1187
|
'### Cross-repo-deferred findings\n\nThese findings route to a different repository and were **not** filed here. They are recorded for a cross-repo follow-up pass.';
|
|
1168
|
-
const rows = deferred.map(({ finding, routedRepo, logLine }) => {
|
|
1188
|
+
const rows = deferred.map(({ finding, routedRepo, logLine, missingKey }) => {
|
|
1169
1189
|
const path =
|
|
1170
1190
|
typeof finding.path === 'string' && finding.path.length > 0
|
|
1171
1191
|
? `\`${finding.path}\``
|
|
1172
1192
|
: '_(no path)_';
|
|
1193
|
+
// An unroutable finding has no destination to name — say which config
|
|
1194
|
+
// key would give it one instead of inventing a repo for the row.
|
|
1195
|
+
const destination = routedRepo
|
|
1196
|
+
? `${routedRepo.owner}/${routedRepo.repo}`
|
|
1197
|
+
: `**unroutable** (\`${missingKey}\` is unset)`;
|
|
1173
1198
|
return [
|
|
1174
|
-
`- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${
|
|
1199
|
+
`- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${destination}`,
|
|
1175
1200
|
` - ${logLine}`,
|
|
1176
1201
|
].join('\n');
|
|
1177
1202
|
});
|
|
@@ -1244,7 +1269,12 @@ async function persistCrossRepoDeferred({
|
|
|
1244
1269
|
* the durable cross-repo-deferred persistence
|
|
1245
1270
|
* @param {object} [opts.config]
|
|
1246
1271
|
* @param {{owner: string, repo: string}} opts.currentRepo
|
|
1247
|
-
* @param {{owner: string, repo: string}} [opts.frameworkRepo]
|
|
1272
|
+
* @param {{owner: string, repo: string}} [opts.frameworkRepo] — the
|
|
1273
|
+
* `framework` ownership bucket. Absent means **unroutable**, never the
|
|
1274
|
+
* consumer's repo: a framework-classified finding is then deferred and
|
|
1275
|
+
* named rather than filed in the wrong tracker.
|
|
1276
|
+
* @param {{owner: string, repo: string}} [opts.platformRepo] — the shared
|
|
1277
|
+
* platform/infra bucket, for a caller whose classifier can reach it.
|
|
1248
1278
|
* @param {string} [opts.gitRef='HEAD']
|
|
1249
1279
|
* @param {Function} [opts.classifier=classifyPathSource]
|
|
1250
1280
|
* @param {string} [opts.ghPath='gh']
|
|
@@ -1279,6 +1309,7 @@ export async function graduate({
|
|
|
1279
1309
|
config,
|
|
1280
1310
|
currentRepo,
|
|
1281
1311
|
frameworkRepo,
|
|
1312
|
+
platformRepo,
|
|
1282
1313
|
gitRef = 'HEAD',
|
|
1283
1314
|
classifier = defaultClassifier,
|
|
1284
1315
|
ghPath = 'gh',
|
|
@@ -1347,6 +1378,15 @@ export async function graduate({
|
|
|
1347
1378
|
return envelope;
|
|
1348
1379
|
}
|
|
1349
1380
|
|
|
1381
|
+
// The ownership map the routing SSOT resolves against. `platform` is
|
|
1382
|
+
// absent for both graduators today (their classifier is binary) and is
|
|
1383
|
+
// threaded so a caller that does know a shared-infra repo routes there
|
|
1384
|
+
// rather than into the nearest plausible tracker.
|
|
1385
|
+
const repos = {
|
|
1386
|
+
consumer: currentRepo,
|
|
1387
|
+
framework: frameworkRepo ?? null,
|
|
1388
|
+
platform: platformRepo ?? null,
|
|
1389
|
+
};
|
|
1350
1390
|
const crossRepoDeferred = [];
|
|
1351
1391
|
for (const finding of findings) {
|
|
1352
1392
|
await processGraduateFinding({
|
|
@@ -1355,7 +1395,7 @@ export async function graduate({
|
|
|
1355
1395
|
decorate,
|
|
1356
1396
|
epicId,
|
|
1357
1397
|
currentRepo,
|
|
1358
|
-
|
|
1398
|
+
repos,
|
|
1359
1399
|
classifier,
|
|
1360
1400
|
gitRef,
|
|
1361
1401
|
ghPath,
|
|
@@ -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
|
|