mandrel 1.57.0 → 1.59.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 +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +21 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- package/.agents/scripts/story-deliver-prepare.js +0 -267
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* auto-refresh-runner.js — bounded baseline auto-refresh at story-close
|
|
3
3
|
* (Story #1398, Epic #1386; rerouted to `refreshBaseline()` by Story
|
|
4
|
-
* #2205
|
|
4
|
+
* #2205; collapsed onto the single `runRefreshCommit` funnel by Story
|
|
5
|
+
* #4017).
|
|
5
6
|
*
|
|
6
7
|
* Runs *after* `runPreMergeGatesWithAttribution` returns `{ status: 'ok' }`
|
|
7
8
|
* and *before* the merge into `epic/<id>`. For each baseline kind
|
|
@@ -9,64 +10,52 @@
|
|
|
9
10
|
*
|
|
10
11
|
* 1. Snapshots the prior on-disk envelope (so cap evaluation can compare
|
|
11
12
|
* regenerated rows against the pre-refresh baseline).
|
|
12
|
-
* 2.
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* against the configured caps via `evaluateAutoRefresh`.
|
|
13
|
+
* 2. Delegates the refresh → stage → commit sequence to
|
|
14
|
+
* `runRefreshCommit()` (`baseline-attribution/phases/refresh-commit.js`)
|
|
15
|
+
* — the **single** story-close refresh funnel — injecting a `capCheck`
|
|
16
|
+
* that re-reads the refreshed envelope and evaluates the row deltas
|
|
17
|
+
* against the configured caps via {@link evaluateAutoRefresh}.
|
|
18
18
|
*
|
|
19
|
-
* - **Under-cap path** —
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
* commit entirely; OR
|
|
23
|
-
* · non-empty diff → emits one canonical commit
|
|
24
|
-
* `chore(baselines): refresh <kind> for story-<id>`. NO `--amend`,
|
|
25
|
-
* NO `--allow-empty`.
|
|
19
|
+
* - **Under-cap path** — the funnel emits one canonical commit
|
|
20
|
+
* `chore(baselines): refresh <kind> for story-<id>` per kind that
|
|
21
|
+
* actually drifted. NO `--amend`, NO `--allow-empty`.
|
|
26
22
|
*
|
|
27
|
-
* - **Over-cap path** — restores the baseline
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
23
|
+
* - **Over-cap path** — the funnel restores the kind's baseline file to
|
|
24
|
+
* HEAD; the runner appends a single `baseline-refresh-regression`
|
|
25
|
+
* friction signal to the per-Story NDJSON and returns
|
|
26
|
+
* `{ status: 'refused', ... }`.
|
|
31
27
|
*
|
|
32
|
-
* - **Skipped paths** — `enabled: false` in `quality.autoRefresh`,
|
|
33
|
-
*
|
|
34
|
-
* kind, or staging produced an empty diff. The runner returns
|
|
28
|
+
* - **Skipped paths** — `enabled: false` in `quality.autoRefresh`, or
|
|
29
|
+
* no configured kind produced drift. The runner returns
|
|
35
30
|
* `{ status: 'skipped', reason }` without touching the branch tip.
|
|
36
31
|
*
|
|
37
|
-
* Story #
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
32
|
+
* Each-kind-once contract (Story #4017): the caller threads the close
|
|
33
|
+
* cycle's shared `cycleState` (created in `runGatesAndRefresh`) into the
|
|
34
|
+
* funnel, so a kind already refreshed by the gate-failure attribution
|
|
35
|
+
* retry (`gate-failure.js` → `runRefreshCommit`) is **not re-scored or
|
|
36
|
+
* re-committed** here — the funnel short-circuits on the idempotency
|
|
37
|
+
* token. A clean close therefore computes each baseline kind exactly once
|
|
38
|
+
* and emits at most one `chore(baselines): refresh` subject per kind.
|
|
44
39
|
*
|
|
45
40
|
* Dedup contract (AC3 — idempotent re-run):
|
|
46
41
|
* On re-entry after an over-cap refusal, the runner scans the per-Story
|
|
47
42
|
* `signals.ndjson` for any prior `baseline-refresh-regression` signal
|
|
48
43
|
* tagged `source.tool === 'auto-refresh-runner'` and skips the append if
|
|
49
|
-
* one exists. The
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* - First run, over-cap → friction signal appended.
|
|
53
|
-
* - Second run, same caps + same diff → friction signal NOT re-appended.
|
|
54
|
-
*
|
|
55
|
-
* The on-disk friction-signal file therefore carries one row per
|
|
56
|
-
* (story, refusal-cause) regardless of how many times story-close runs.
|
|
44
|
+
* one exists. The on-disk friction-signal file therefore carries one row
|
|
45
|
+
* per (story, refusal-cause) regardless of how many times story-close
|
|
46
|
+
* runs.
|
|
57
47
|
*
|
|
58
48
|
* The runner is dependency-injection-friendly: every git invocation, every
|
|
59
49
|
* fs touch, the refresh-service handle, the evaluator, and the signal
|
|
60
50
|
* writer are injectable seams. Production callers omit the seams; tests
|
|
61
51
|
* inject mocks.
|
|
62
52
|
*
|
|
63
|
-
* @see .agents/scripts/lib/baselines/refresh-service.js (the unified write
|
|
64
|
-
* @see
|
|
53
|
+
* @see .agents/scripts/lib/baselines/refresh-service.js (the unified write path)
|
|
54
|
+
* @see ./baseline-attribution/phases/refresh-commit.js (the commit funnel)
|
|
65
55
|
*/
|
|
66
56
|
|
|
67
57
|
import fs from 'node:fs';
|
|
68
58
|
import path from 'node:path';
|
|
69
|
-
import { evaluateAutoRefresh as defaultEvaluateAutoRefresh } from '../../auto-refresh-baselines.js';
|
|
70
59
|
import { loadFile as defaultReaderLoadFile } from '../../baselines/reader.js';
|
|
71
60
|
import { refreshBaseline as defaultRefreshBaseline } from '../../baselines/refresh-service.js';
|
|
72
61
|
import {
|
|
@@ -82,12 +71,261 @@ import {
|
|
|
82
71
|
import {
|
|
83
72
|
buildKindScorer,
|
|
84
73
|
computeStoryDiffPaths,
|
|
85
|
-
|
|
74
|
+
runRefreshCommit as defaultRunRefreshCommit,
|
|
86
75
|
} from './baseline-attribution-wiring.js';
|
|
87
76
|
|
|
88
77
|
const RUNNER_SOURCE_TOOL = 'auto-refresh-runner';
|
|
89
78
|
const FRICTION_CATEGORY = 'baseline-refresh-regression';
|
|
90
79
|
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Pure delta-cap evaluator (Story #1398; folded in from the deleted
|
|
82
|
+
// standalone evaluator module by Story #4017).
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Numeric guard — accepts finite numbers only. Strings, NaN, Infinity, null,
|
|
87
|
+
* undefined all fail. The evaluator runs against scored rows produced by the
|
|
88
|
+
* MI / CRAP scanners (which always emit numeric scores) and baseline rows
|
|
89
|
+
* loaded from the on-disk JSON (which JSON-parses numeric fields), so a
|
|
90
|
+
* non-finite value here signals upstream corruption — we exclude the row
|
|
91
|
+
* conservatively rather than coercing.
|
|
92
|
+
*/
|
|
93
|
+
function isFiniteNumber(value) {
|
|
94
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Index `baseline.mi` rows by `path` for O(1) lookup. Bad rows (missing
|
|
99
|
+
* `path`, non-string `path`, non-finite `mi`) are skipped — their absence
|
|
100
|
+
* causes the matching scored row to be treated as "new", which never blocks
|
|
101
|
+
* auto-refresh.
|
|
102
|
+
*/
|
|
103
|
+
function indexMiBaseline(rows) {
|
|
104
|
+
const byPath = new Map();
|
|
105
|
+
if (!Array.isArray(rows)) return byPath;
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (!row || typeof row.path !== 'string' || row.path.length === 0) continue;
|
|
108
|
+
if (!isFiniteNumber(row.mi)) continue;
|
|
109
|
+
byPath.set(row.path, row);
|
|
110
|
+
}
|
|
111
|
+
return byPath;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Index `baseline.crap` rows by `${file}::${method}` for O(1) lookup.
|
|
116
|
+
* `startLine` is *not* part of the key — the scored row may have shifted
|
|
117
|
+
* lines vs the baseline (legitimate refactor), and we want the closest match
|
|
118
|
+
* by method name. When the same method appears multiple times in the same
|
|
119
|
+
* file (e.g. nested helpers), we pick the closest startLine at lookup time.
|
|
120
|
+
*
|
|
121
|
+
* Bad rows (missing `file`/`method`, non-finite `crap`) are skipped — their
|
|
122
|
+
* absence causes the matching scored row to be treated as "new".
|
|
123
|
+
*/
|
|
124
|
+
function indexCrapBaseline(rows) {
|
|
125
|
+
const byMethod = new Map();
|
|
126
|
+
if (!Array.isArray(rows)) return byMethod;
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
if (!row || typeof row.file !== 'string' || row.file.length === 0) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (typeof row.method !== 'string' || row.method.length === 0) continue;
|
|
132
|
+
if (!isFiniteNumber(row.crap)) continue;
|
|
133
|
+
const key = `${row.file}::${row.method}`;
|
|
134
|
+
if (!byMethod.has(key)) byMethod.set(key, []);
|
|
135
|
+
byMethod.get(key).push(row);
|
|
136
|
+
}
|
|
137
|
+
return byMethod;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pick the closest baseline candidate by `startLine` distance. When the
|
|
142
|
+
* scored row's `startLine` is missing or all candidates have missing line
|
|
143
|
+
* info, returns the first candidate — matches `baseline-attribution-wiring`'s
|
|
144
|
+
* `diffCrapBaselines` resolution policy.
|
|
145
|
+
*/
|
|
146
|
+
function pickClosestBaseline(candidates, scoredStartLine) {
|
|
147
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
148
|
+
if (candidates.length === 1) return candidates[0];
|
|
149
|
+
const target = isFiniteNumber(scoredStartLine) ? scoredStartLine : 0;
|
|
150
|
+
let best = candidates[0];
|
|
151
|
+
let bestDist = Math.abs((best.startLine ?? 0) - target);
|
|
152
|
+
for (let i = 1; i < candidates.length; i += 1) {
|
|
153
|
+
const c = candidates[i];
|
|
154
|
+
const dist = Math.abs((c?.startLine ?? 0) - target);
|
|
155
|
+
if (dist < bestDist) {
|
|
156
|
+
bestDist = dist;
|
|
157
|
+
best = c;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return best;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Evaluate every MI scored row against the MI cap. Returns the over-cap
|
|
165
|
+
* subset; rows under the cap (or new) are simply omitted from the result.
|
|
166
|
+
*
|
|
167
|
+
* MI is higher-is-better, so drift = baseline.mi − scored.mi. A positive
|
|
168
|
+
* drift is a regression; a drift greater than `miDropCap` breaches the cap.
|
|
169
|
+
*/
|
|
170
|
+
function evaluateMiRows({ scoredRows, baselineIndex, miDropCap }) {
|
|
171
|
+
const overCap = [];
|
|
172
|
+
if (!Array.isArray(scoredRows)) return overCap;
|
|
173
|
+
for (const row of scoredRows) {
|
|
174
|
+
if (!row || typeof row.path !== 'string' || row.path.length === 0) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!isFiniteNumber(row.mi)) continue;
|
|
178
|
+
const baselineRow = baselineIndex.get(row.path);
|
|
179
|
+
if (!baselineRow) continue; // new path — never breaches
|
|
180
|
+
const drop = baselineRow.mi - row.mi;
|
|
181
|
+
if (drop > miDropCap) {
|
|
182
|
+
overCap.push({
|
|
183
|
+
path: row.path,
|
|
184
|
+
baseline: baselineRow.mi,
|
|
185
|
+
scored: row.mi,
|
|
186
|
+
delta: drop,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return overCap;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Evaluate every CRAP scored row against the CRAP cap. Returns the over-cap
|
|
195
|
+
* subset; rows under the cap (or new) are simply omitted from the result.
|
|
196
|
+
*
|
|
197
|
+
* CRAP is lower-is-better, so jump = scored.crap − baseline.crap. A positive
|
|
198
|
+
* jump is a regression; a jump greater than `crapJumpCap` breaches the cap.
|
|
199
|
+
*/
|
|
200
|
+
function evaluateCrapRows({ scoredRows, baselineIndex, crapJumpCap }) {
|
|
201
|
+
const overCap = [];
|
|
202
|
+
if (!Array.isArray(scoredRows)) return overCap;
|
|
203
|
+
for (const row of scoredRows) {
|
|
204
|
+
if (!row || typeof row.file !== 'string' || row.file.length === 0) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (typeof row.method !== 'string' || row.method.length === 0) continue;
|
|
208
|
+
if (!isFiniteNumber(row.crap)) continue;
|
|
209
|
+
const candidates = baselineIndex.get(`${row.file}::${row.method}`);
|
|
210
|
+
const baselineRow = pickClosestBaseline(candidates, row.startLine);
|
|
211
|
+
if (!baselineRow) continue; // new method — never breaches
|
|
212
|
+
const jump = row.crap - baselineRow.crap;
|
|
213
|
+
if (jump > crapJumpCap) {
|
|
214
|
+
overCap.push({
|
|
215
|
+
file: row.file,
|
|
216
|
+
method: row.method,
|
|
217
|
+
startLine: row.startLine,
|
|
218
|
+
baseline: baselineRow.crap,
|
|
219
|
+
scored: row.crap,
|
|
220
|
+
delta: jump,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return overCap;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the human-readable refusal reasons array. Stable formatting so the
|
|
229
|
+
* friction-signal renderer (and unit tests) can pin the strings exactly.
|
|
230
|
+
*
|
|
231
|
+
* Each reason names the kind, the file/path/method, and the absolute delta
|
|
232
|
+
* vs the cap. Numbers are formatted to 3 decimal places to match the
|
|
233
|
+
* baseline JSON's float precision without trailing-zero noise.
|
|
234
|
+
*/
|
|
235
|
+
function buildRefusalReasons({ miOverCap, crapOverCap, caps }) {
|
|
236
|
+
const reasons = [];
|
|
237
|
+
for (const r of miOverCap) {
|
|
238
|
+
reasons.push(
|
|
239
|
+
`MI drop ${r.delta.toFixed(3)} > cap ${caps.miDropCap} on ${r.path} (baseline ${r.baseline.toFixed(3)} → scored ${r.scored.toFixed(3)})`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
for (const r of crapOverCap) {
|
|
243
|
+
reasons.push(
|
|
244
|
+
`CRAP jump ${r.delta.toFixed(3)} > cap ${caps.crapJumpCap} on ${r.file}::${r.method} (baseline ${r.baseline.toFixed(3)} → scored ${r.scored.toFixed(3)})`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return reasons;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pure delta-cap evaluator. Decides whether the regenerated rows can be
|
|
252
|
+
* silently committed (under-cap) or whether the close must refuse the
|
|
253
|
+
* refresh and surface a `baseline-refresh-regression` friction signal
|
|
254
|
+
* (over-cap).
|
|
255
|
+
*
|
|
256
|
+
* Cap semantics:
|
|
257
|
+
*
|
|
258
|
+
* - MI is "higher is better". A *drop* (baseline.mi − scored.mi) greater
|
|
259
|
+
* than `miDropCap` breaches the cap. Improvements never breach.
|
|
260
|
+
* - CRAP is "lower is better". A *jump* (scored.crap − baseline.crap)
|
|
261
|
+
* greater than `crapJumpCap` breaches the cap. Improvements never
|
|
262
|
+
* breach.
|
|
263
|
+
* - Equality at the cap (delta === cap) is *under* the cap — the cap is
|
|
264
|
+
* the maximum allowed delta, not the strict maximum.
|
|
265
|
+
* - Missing baseline rows (path/method new in the scored set) never push
|
|
266
|
+
* `canAutoRefresh` to `false` and are not surfaced in the over-cap
|
|
267
|
+
* arrays.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} input
|
|
270
|
+
* @param {{
|
|
271
|
+
* mi?: Array<{ path: string, mi: number }>,
|
|
272
|
+
* crap?: Array<{ file: string, method: string, startLine?: number, crap: number }>,
|
|
273
|
+
* }} input.scoredRows Just-regenerated rows for the Story diff.
|
|
274
|
+
* @param {{
|
|
275
|
+
* mi?: Array<{ path: string, mi: number }>,
|
|
276
|
+
* crap?: Array<{ file: string, method: string, startLine?: number, crap: number }>,
|
|
277
|
+
* }} input.baseline Previously committed rows.
|
|
278
|
+
* @param {{ miDropCap: number, crapJumpCap: number }} input.caps
|
|
279
|
+
* Bounded delta caps (defaults: miDropCap=1.5, crapJumpCap=5 — see
|
|
280
|
+
* `.agents/docs/agentrc-reference.json` under `delivery.quality.autoRefresh`).
|
|
281
|
+
* @returns {{
|
|
282
|
+
* canAutoRefresh: boolean,
|
|
283
|
+
* miOverCap: Array<{ path: string, baseline: number, scored: number, delta: number }>,
|
|
284
|
+
* crapOverCap: Array<{ file: string, method: string, startLine?: number, baseline: number, scored: number, delta: number }>,
|
|
285
|
+
* refusalReasons: string[],
|
|
286
|
+
* }}
|
|
287
|
+
*/
|
|
288
|
+
export function evaluateAutoRefresh({
|
|
289
|
+
scoredRows = {},
|
|
290
|
+
baseline = {},
|
|
291
|
+
caps,
|
|
292
|
+
} = {}) {
|
|
293
|
+
if (
|
|
294
|
+
!caps ||
|
|
295
|
+
!isFiniteNumber(caps.miDropCap) ||
|
|
296
|
+
!isFiniteNumber(caps.crapJumpCap)
|
|
297
|
+
) {
|
|
298
|
+
throw new TypeError(
|
|
299
|
+
'evaluateAutoRefresh: caps.{miDropCap,crapJumpCap} must be finite numbers',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const miBaselineIdx = indexMiBaseline(baseline?.mi);
|
|
304
|
+
const crapBaselineIdx = indexCrapBaseline(baseline?.crap);
|
|
305
|
+
|
|
306
|
+
const miOverCap = evaluateMiRows({
|
|
307
|
+
scoredRows: scoredRows?.mi,
|
|
308
|
+
baselineIndex: miBaselineIdx,
|
|
309
|
+
miDropCap: caps.miDropCap,
|
|
310
|
+
});
|
|
311
|
+
const crapOverCap = evaluateCrapRows({
|
|
312
|
+
scoredRows: scoredRows?.crap,
|
|
313
|
+
baselineIndex: crapBaselineIdx,
|
|
314
|
+
crapJumpCap: caps.crapJumpCap,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const canAutoRefresh = miOverCap.length === 0 && crapOverCap.length === 0;
|
|
318
|
+
const refusalReasons = canAutoRefresh
|
|
319
|
+
? []
|
|
320
|
+
: buildRefusalReasons({ miOverCap, crapOverCap, caps });
|
|
321
|
+
|
|
322
|
+
return { canAutoRefresh, miOverCap, crapOverCap, refusalReasons };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Runner plumbing
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
91
329
|
/**
|
|
92
330
|
* Load + parse the baseline envelope at `absPath` via the injected
|
|
93
331
|
* reader. Returns `null` when the file is missing, unreadable, or fails
|
|
@@ -99,13 +337,7 @@ function readEnvelope({ absPath, kind, readerLoadFile }) {
|
|
|
99
337
|
try {
|
|
100
338
|
const parsed = readerLoadFile(absPath, { kind });
|
|
101
339
|
if (!parsed || !Array.isArray(parsed.rows)) return null;
|
|
102
|
-
return {
|
|
103
|
-
$schema: `.agents/schemas/baselines/${kind}.schema.json`,
|
|
104
|
-
kernelVersion: parsed.kernelVersion,
|
|
105
|
-
generatedAt: parsed.generatedAt,
|
|
106
|
-
rollup: parsed.rollup,
|
|
107
|
-
rows: parsed.rows,
|
|
108
|
-
};
|
|
340
|
+
return { rows: parsed.rows };
|
|
109
341
|
} catch {
|
|
110
342
|
return null;
|
|
111
343
|
}
|
|
@@ -142,25 +374,6 @@ function filterToStoryDiff({ miRows, crapRows, storyDiffPaths }) {
|
|
|
142
374
|
return { mi, crap };
|
|
143
375
|
}
|
|
144
376
|
|
|
145
|
-
function normalizeTargetDir(dir) {
|
|
146
|
-
if (typeof dir !== 'string' || dir.length === 0) return null;
|
|
147
|
-
return dir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function buildRequiredScopeFilePredicate({
|
|
151
|
-
kind,
|
|
152
|
-
config,
|
|
153
|
-
getQuality = defaultGetQuality,
|
|
154
|
-
}) {
|
|
155
|
-
const quality = getQuality(config) ?? {};
|
|
156
|
-
const targetDirs = Array.isArray(quality?.[kind]?.targetDirs)
|
|
157
|
-
? quality[kind].targetDirs.map(normalizeTargetDir).filter(Boolean)
|
|
158
|
-
: [];
|
|
159
|
-
if (targetDirs.length === 0) return () => true;
|
|
160
|
-
return (file) =>
|
|
161
|
-
targetDirs.some((dir) => file === dir || file.startsWith(`${dir}/`));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
377
|
/**
|
|
165
378
|
* Check whether a `baseline-refresh-regression` signal tagged with the
|
|
166
379
|
* runner's `source.tool === 'auto-refresh-runner'` already exists in the
|
|
@@ -218,14 +431,6 @@ function resolveBaselineAbs(cwd, p) {
|
|
|
218
431
|
return path.isAbsolute(p) ? p : path.resolve(cwd, p);
|
|
219
432
|
}
|
|
220
433
|
|
|
221
|
-
function resolveBaselineAbsPaths({ cwd, config, getBaselines }) {
|
|
222
|
-
const baselines = getBaselines(config);
|
|
223
|
-
return {
|
|
224
|
-
miAbs: resolveBaselineAbs(cwd, baselines?.maintainability?.path),
|
|
225
|
-
crapAbs: resolveBaselineAbs(cwd, baselines?.crap?.path),
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
434
|
async function probeDedup({ epicId, storyId, forEachLine, logger }) {
|
|
230
435
|
try {
|
|
231
436
|
return await priorRefusalSignalExists({ epicId, storyId, forEachLine });
|
|
@@ -266,160 +471,15 @@ async function maybeAppendRefusalSignal({
|
|
|
266
471
|
}
|
|
267
472
|
}
|
|
268
473
|
|
|
269
|
-
/**
|
|
270
|
-
* Restore the baseline files to HEAD's content. Used on over-cap
|
|
271
|
-
* refusal to drop the refresh's write so the merge consumes the
|
|
272
|
-
* pre-refresh baseline unchanged.
|
|
273
|
-
*/
|
|
274
|
-
function rollbackBaselineFiles({ cwd, baselineFiles, gitRunner, logger }) {
|
|
275
|
-
for (const filePath of baselineFiles) {
|
|
276
|
-
const rel = path.isAbsolute(filePath)
|
|
277
|
-
? path.relative(cwd, filePath)
|
|
278
|
-
: filePath;
|
|
279
|
-
const posixRel = rel.split(path.sep).join('/');
|
|
280
|
-
const res = gitRunner.gitSpawn(cwd, 'checkout', 'HEAD', '--', posixRel);
|
|
281
|
-
if (res.status !== 0) {
|
|
282
|
-
logger.warn?.(
|
|
283
|
-
`[auto-refresh-runner] failed to restore ${rel} after refusal: ${res.stderr || res.stdout}`,
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
async function handleRefusal({
|
|
290
|
-
verdict,
|
|
291
|
-
caps,
|
|
292
|
-
epicId,
|
|
293
|
-
storyId,
|
|
294
|
-
cwd,
|
|
295
|
-
baselineFiles,
|
|
296
|
-
gitRunner,
|
|
297
|
-
appendSignal,
|
|
298
|
-
forEachLine,
|
|
299
|
-
config,
|
|
300
|
-
logger,
|
|
301
|
-
}) {
|
|
302
|
-
const dedup = await probeDedup({ epicId, storyId, forEachLine, logger });
|
|
303
|
-
const signalAppended = await maybeAppendRefusalSignal({
|
|
304
|
-
dedup,
|
|
305
|
-
epicId,
|
|
306
|
-
storyId,
|
|
307
|
-
verdict,
|
|
308
|
-
caps,
|
|
309
|
-
appendSignal,
|
|
310
|
-
config,
|
|
311
|
-
logger,
|
|
312
|
-
});
|
|
313
|
-
rollbackBaselineFiles({ cwd, baselineFiles, gitRunner, logger });
|
|
314
|
-
logger.info?.(
|
|
315
|
-
`[auto-refresh-runner] refused — ${verdict.refusalReasons.length} cap breach(es); friction signal ${dedup ? 'already present (dedup)' : signalAppended ? 'appended' : 'append failed'}.`,
|
|
316
|
-
);
|
|
317
|
-
return {
|
|
318
|
-
status: 'refused',
|
|
319
|
-
refusalReasons: verdict.refusalReasons,
|
|
320
|
-
signalAppended,
|
|
321
|
-
dedup,
|
|
322
|
-
miOverCap: verdict.miOverCap,
|
|
323
|
-
crapOverCap: verdict.crapOverCap,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Run `refreshBaseline()` for a single kind. Returns the resolved write
|
|
329
|
-
* path and a flag noting whether the service actually persisted bytes.
|
|
330
|
-
* Throws on a service error (the caller surfaces it as a `failed` status).
|
|
331
|
-
*/
|
|
332
|
-
async function runRefreshForKind({
|
|
333
|
-
kind,
|
|
334
|
-
cwd,
|
|
335
|
-
epicBranch,
|
|
336
|
-
storyBranch,
|
|
337
|
-
writePath,
|
|
338
|
-
config,
|
|
339
|
-
getQuality,
|
|
340
|
-
refreshBaseline,
|
|
341
|
-
scorer,
|
|
342
|
-
fsImpl,
|
|
343
|
-
}) {
|
|
344
|
-
if (!writePath) return { writePath: null, wrote: false };
|
|
345
|
-
const baseRef = epicBranch ? `origin/${epicBranch}` : 'origin/main';
|
|
346
|
-
const headRef = storyBranch ?? 'HEAD';
|
|
347
|
-
const result = await refreshBaseline({
|
|
348
|
-
kind,
|
|
349
|
-
baseRef,
|
|
350
|
-
headRef,
|
|
351
|
-
scopeFiles: null,
|
|
352
|
-
fullScope: false,
|
|
353
|
-
writePath,
|
|
354
|
-
scorer,
|
|
355
|
-
fs: fsImpl,
|
|
356
|
-
cwd,
|
|
357
|
-
requireRowsForScopeFiles: true,
|
|
358
|
-
requiredScopeFilePredicate: buildRequiredScopeFilePredicate({
|
|
359
|
-
kind,
|
|
360
|
-
config,
|
|
361
|
-
getQuality,
|
|
362
|
-
}),
|
|
363
|
-
});
|
|
364
|
-
return { writePath, wrote: result?.wrote === true };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Commit hygiene (AC-8): stage every refreshed baseline file, ask
|
|
369
|
-
* `git diff --cached --exit-code` whether any drift survived. Drift →
|
|
370
|
-
* emit one canonical commit per kind. No drift → log + skip. No
|
|
371
|
-
* `--amend`, no `--allow-empty`.
|
|
372
|
-
*/
|
|
373
|
-
function commitRefreshedBaselines({
|
|
374
|
-
cwd,
|
|
375
|
-
storyId,
|
|
376
|
-
refreshed,
|
|
377
|
-
gitRunner,
|
|
378
|
-
logger,
|
|
379
|
-
}) {
|
|
380
|
-
const committed = [];
|
|
381
|
-
let lastSha = '';
|
|
382
|
-
for (const { kind, writePath } of refreshed) {
|
|
383
|
-
if (!writePath) continue;
|
|
384
|
-
const drift = stageAndCheckBaselineDrift({
|
|
385
|
-
cwd,
|
|
386
|
-
baselineFile: writePath,
|
|
387
|
-
gitRunner,
|
|
388
|
-
});
|
|
389
|
-
if (drift.error) {
|
|
390
|
-
return { ok: false, error: drift.error };
|
|
391
|
-
}
|
|
392
|
-
if (!drift.hasDrift) {
|
|
393
|
-
logger?.info?.(
|
|
394
|
-
`[auto-refresh-runner] no baseline drift to fold in for kind=${kind} (story-${storyId}).`,
|
|
395
|
-
);
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
const subject = `chore(baselines): refresh ${kind} for story-${storyId}`;
|
|
399
|
-
const commitRes = gitRunner.gitSpawn(cwd, 'commit', '-m', subject);
|
|
400
|
-
if (commitRes.status !== 0) {
|
|
401
|
-
return {
|
|
402
|
-
ok: false,
|
|
403
|
-
error: `git commit failed for kind=${kind}: ${commitRes.stderr || commitRes.stdout}`,
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
const headRes = gitRunner.gitSpawn(cwd, 'rev-parse', '--short', 'HEAD');
|
|
407
|
-
const sha = headRes.status === 0 ? (headRes.stdout || '').trim() : '';
|
|
408
|
-
lastSha = sha;
|
|
409
|
-
committed.push({ kind, sha });
|
|
410
|
-
logger?.info?.(`[auto-refresh-runner] committed ${subject} (${sha}).`);
|
|
411
|
-
}
|
|
412
|
-
return { ok: true, committed, lastSha };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
474
|
function resolveAutoRefreshDeps(deps) {
|
|
416
475
|
return {
|
|
417
476
|
logger: deps.logger ?? DefaultLogger,
|
|
418
477
|
getQuality: deps.getQuality ?? defaultGetQuality,
|
|
419
478
|
getBaselines: deps.getBaselines ?? defaultGetBaselines,
|
|
420
|
-
evaluateAutoRefresh: deps.evaluateAutoRefresh ??
|
|
479
|
+
evaluateAutoRefresh: deps.evaluateAutoRefresh ?? evaluateAutoRefresh,
|
|
421
480
|
refreshBaseline: deps.refreshBaseline ?? defaultRefreshBaseline,
|
|
422
481
|
scorerBuilder: deps.scorerBuilder ?? buildKindScorer,
|
|
482
|
+
runRefreshCommit: deps.runRefreshCommit ?? defaultRunRefreshCommit,
|
|
423
483
|
gitRunner: deps.gitRunner ?? { gitSpawn: defaultGitSpawn },
|
|
424
484
|
fsImpl: deps.fsImpl ?? fs,
|
|
425
485
|
appendSignal: deps.appendSignal ?? defaultAppendSignal,
|
|
@@ -430,274 +490,134 @@ function resolveAutoRefreshDeps(deps) {
|
|
|
430
490
|
}
|
|
431
491
|
|
|
432
492
|
/**
|
|
433
|
-
*
|
|
434
|
-
* and
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* snapshot envelopes, and the per-kind refresh result records. Callers
|
|
439
|
-
* never inspect the shape directly; they pass it through to `validate`
|
|
440
|
-
* and `commit`.
|
|
441
|
-
*
|
|
442
|
-
* Failure mode: a thrown `refreshBaseline` propagates here as a
|
|
443
|
-
* `{ ok: false, status: 'failed', reason: 'refresh-service-threw' }`
|
|
444
|
-
* envelope so the caller can short-circuit without try/catching at the
|
|
445
|
-
* top of `runAutoRefresh`.
|
|
493
|
+
* Build the per-kind `capCheck` closure the funnel invokes after drift is
|
|
494
|
+
* staged and before the commit lands. Re-reads the refreshed envelope,
|
|
495
|
+
* narrows to the Story diff (unless `quality.autoRefresh.scope === 'full'`),
|
|
496
|
+
* and evaluates the single kind's rows against the configured caps with the
|
|
497
|
+
* prior (pre-refresh) snapshot as the baseline.
|
|
446
498
|
*/
|
|
447
|
-
|
|
499
|
+
function buildCapCheck({
|
|
500
|
+
kind,
|
|
501
|
+
priorEnv,
|
|
502
|
+
autoRefresh,
|
|
503
|
+
caps,
|
|
448
504
|
cwd,
|
|
449
505
|
epicBranch,
|
|
450
506
|
storyBranch,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
refreshBaseline,
|
|
455
|
-
scorerBuilder,
|
|
456
|
-
fsImpl,
|
|
507
|
+
evaluate,
|
|
508
|
+
gitRunner,
|
|
509
|
+
computeDiffPaths,
|
|
457
510
|
readerLoadFile,
|
|
458
511
|
}) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
: null;
|
|
475
|
-
const priorCrapEnv = crapAbs
|
|
476
|
-
? readEnvelope({ absPath: crapAbs, kind: 'crap', readerLoadFile })
|
|
477
|
-
: null;
|
|
478
|
-
|
|
479
|
-
// Dispatch one refreshBaseline() call per configured kind. The service
|
|
480
|
-
// handles diff-scope derivation, scope-merge with out-of-scope prior
|
|
481
|
-
// rows (Task #2209), and atomic envelope persistence.
|
|
482
|
-
let miRefreshed;
|
|
483
|
-
let crapRefreshed;
|
|
484
|
-
try {
|
|
485
|
-
if (miAbs) {
|
|
486
|
-
const scorer = scorerBuilder({
|
|
487
|
-
kind: 'maintainability',
|
|
488
|
-
cwd,
|
|
489
|
-
config,
|
|
490
|
-
});
|
|
491
|
-
miRefreshed = await runRefreshForKind({
|
|
492
|
-
kind: 'maintainability',
|
|
512
|
+
return ({ writePath }) => {
|
|
513
|
+
const finalEnv = readEnvelope({ absPath: writePath, kind, readerLoadFile });
|
|
514
|
+
const isMi = kind === 'maintainability';
|
|
515
|
+
const finalRows = isMi
|
|
516
|
+
? (finalEnv?.rows ?? [])
|
|
517
|
+
: adaptCrapRowsForEvaluator(finalEnv?.rows ?? []);
|
|
518
|
+
const priorRows = isMi
|
|
519
|
+
? (priorEnv?.rows ?? [])
|
|
520
|
+
: adaptCrapRowsForEvaluator(priorEnv?.rows ?? []);
|
|
521
|
+
|
|
522
|
+
let scoped;
|
|
523
|
+
if ((autoRefresh.scope ?? 'diff') === 'full') {
|
|
524
|
+
scoped = isMi ? { mi: finalRows } : { crap: finalRows };
|
|
525
|
+
} else {
|
|
526
|
+
const storyDiffPaths = computeDiffPaths({
|
|
493
527
|
cwd,
|
|
494
528
|
epicBranch,
|
|
495
529
|
storyBranch,
|
|
496
|
-
|
|
497
|
-
config,
|
|
498
|
-
getQuality,
|
|
499
|
-
refreshBaseline,
|
|
500
|
-
scorer,
|
|
501
|
-
fsImpl,
|
|
530
|
+
gitRunner,
|
|
502
531
|
});
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
kind: 'crap',
|
|
508
|
-
cwd,
|
|
509
|
-
epicBranch,
|
|
510
|
-
storyBranch,
|
|
511
|
-
writePath: crapAbs,
|
|
512
|
-
config,
|
|
513
|
-
getQuality,
|
|
514
|
-
refreshBaseline,
|
|
515
|
-
scorer,
|
|
516
|
-
fsImpl,
|
|
532
|
+
const filtered = filterToStoryDiff({
|
|
533
|
+
miRows: isMi ? finalRows : [],
|
|
534
|
+
crapRows: isMi ? [] : finalRows,
|
|
535
|
+
storyDiffPaths,
|
|
517
536
|
});
|
|
537
|
+
scoped = isMi ? { mi: filtered.mi } : { crap: filtered.crap };
|
|
518
538
|
}
|
|
519
|
-
} catch (err) {
|
|
520
|
-
return {
|
|
521
|
-
ok: false,
|
|
522
|
-
status: 'failed',
|
|
523
|
-
reason: 'refresh-service-threw',
|
|
524
|
-
detail: err?.message ?? String(err),
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
539
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
priorMiEnv,
|
|
533
|
-
priorCrapEnv,
|
|
534
|
-
miRefreshed,
|
|
535
|
-
crapRefreshed,
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Step 2 of the four-step pipeline: re-read the (scope-merged) envelopes
|
|
541
|
-
* the refresh service just wrote and evaluate whether the row deltas sit
|
|
542
|
-
* at or below the configured caps. Returns `{ accepted, verdict, baselineFiles }`:
|
|
543
|
-
*
|
|
544
|
-
* - `accepted: true` → caps are satisfied; commitRefresh writes one
|
|
545
|
-
* canonical commit per kind that actually drifted.
|
|
546
|
-
* - `accepted: false` → at least one row breaches a cap; pushRefresh
|
|
547
|
-
* rolls back the working-tree edits + appends a friction signal.
|
|
548
|
-
*
|
|
549
|
-
* The function also folds in the early-exit when no kind wrote — when
|
|
550
|
-
* every `refreshBaseline()` reports `wrote:false` there's nothing to
|
|
551
|
-
* validate, the caller short-circuits via the `noDrift: true` flag.
|
|
552
|
-
*/
|
|
553
|
-
function validateRefreshAccepted({
|
|
554
|
-
stage,
|
|
555
|
-
autoRefresh,
|
|
556
|
-
caps,
|
|
557
|
-
cwd,
|
|
558
|
-
epicBranch,
|
|
559
|
-
storyBranch,
|
|
560
|
-
evaluateAutoRefresh,
|
|
561
|
-
gitRunner,
|
|
562
|
-
computeDiffPaths,
|
|
563
|
-
readerLoadFile,
|
|
564
|
-
}) {
|
|
565
|
-
const {
|
|
566
|
-
miAbs,
|
|
567
|
-
crapAbs,
|
|
568
|
-
priorMiEnv,
|
|
569
|
-
priorCrapEnv,
|
|
570
|
-
miRefreshed,
|
|
571
|
-
crapRefreshed,
|
|
572
|
-
} = stage;
|
|
573
|
-
|
|
574
|
-
const anyWrote = miRefreshed?.wrote === true || crapRefreshed?.wrote === true;
|
|
575
|
-
if (!anyWrote) return { noDrift: true };
|
|
576
|
-
|
|
577
|
-
// Re-read the (scope-merged) envelopes for verdict evaluation.
|
|
578
|
-
const finalMiEnv = miAbs
|
|
579
|
-
? readEnvelope({
|
|
580
|
-
absPath: miAbs,
|
|
581
|
-
kind: 'maintainability',
|
|
582
|
-
readerLoadFile,
|
|
583
|
-
})
|
|
584
|
-
: null;
|
|
585
|
-
const finalCrapEnv = crapAbs
|
|
586
|
-
? readEnvelope({ absPath: crapAbs, kind: 'crap', readerLoadFile })
|
|
587
|
-
: null;
|
|
588
|
-
|
|
589
|
-
const finalMiRows = finalMiEnv?.rows ?? [];
|
|
590
|
-
const finalCrapRows = adaptCrapRowsForEvaluator(finalCrapEnv?.rows ?? []);
|
|
591
|
-
const priorMiRows = priorMiEnv?.rows ?? [];
|
|
592
|
-
const priorCrapRows = adaptCrapRowsForEvaluator(priorCrapEnv?.rows ?? []);
|
|
593
|
-
|
|
594
|
-
let scoped;
|
|
595
|
-
if ((autoRefresh.scope ?? 'diff') === 'full') {
|
|
596
|
-
scoped = { mi: finalMiRows, crap: finalCrapRows };
|
|
597
|
-
} else {
|
|
598
|
-
const storyDiffPaths = computeDiffPaths({
|
|
599
|
-
cwd,
|
|
600
|
-
epicBranch,
|
|
601
|
-
storyBranch,
|
|
602
|
-
gitRunner,
|
|
603
|
-
});
|
|
604
|
-
scoped = filterToStoryDiff({
|
|
605
|
-
miRows: finalMiRows,
|
|
606
|
-
crapRows: finalCrapRows,
|
|
607
|
-
storyDiffPaths,
|
|
540
|
+
return evaluate({
|
|
541
|
+
scoredRows: scoped,
|
|
542
|
+
baseline: isMi ? { mi: priorRows } : { crap: priorRows },
|
|
543
|
+
caps,
|
|
608
544
|
});
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const verdict = evaluateAutoRefresh({
|
|
612
|
-
scoredRows: scoped,
|
|
613
|
-
baseline: { mi: priorMiRows, crap: priorCrapRows },
|
|
614
|
-
caps,
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
return {
|
|
618
|
-
noDrift: false,
|
|
619
|
-
accepted: verdict.canAutoRefresh === true,
|
|
620
|
-
verdict,
|
|
621
|
-
baselineFiles: [miAbs, crapAbs].filter(Boolean),
|
|
622
545
|
};
|
|
623
546
|
}
|
|
624
547
|
|
|
625
548
|
/**
|
|
626
|
-
*
|
|
627
|
-
* `
|
|
628
|
-
* actually drifted (AC-8 commit hygiene). Empty diff → no commit. No
|
|
629
|
-
* `--amend`, no `--allow-empty`.
|
|
630
|
-
*
|
|
631
|
-
* Returns the canonical close-result envelope `runAutoRefresh` returns
|
|
632
|
-
* to its caller — `committed` / `failed` / `skipped` — so the pipeline
|
|
633
|
-
* top stays at one level of abstraction.
|
|
549
|
+
* Map a funnel failure string back onto the runner's historical failure
|
|
550
|
+
* vocabulary so `phases/refresh.js` keeps logging the same reason labels.
|
|
634
551
|
*/
|
|
635
|
-
function
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
? { kind: 'maintainability', writePath: miAbs }
|
|
640
|
-
: null,
|
|
641
|
-
crapRefreshed?.wrote === true ? { kind: 'crap', writePath: crapAbs } : null,
|
|
642
|
-
].filter(Boolean);
|
|
643
|
-
const commit = commitRefreshedBaselines({
|
|
644
|
-
cwd,
|
|
645
|
-
storyId,
|
|
646
|
-
refreshed,
|
|
647
|
-
gitRunner,
|
|
648
|
-
logger,
|
|
649
|
-
});
|
|
650
|
-
if (!commit.ok) {
|
|
651
|
-
return { status: 'failed', reason: 'commit-failed', detail: commit.error };
|
|
652
|
-
}
|
|
653
|
-
if (commit.committed.length === 0) {
|
|
654
|
-
return { status: 'skipped', reason: 'no-baseline-drift' };
|
|
655
|
-
}
|
|
656
|
-
return {
|
|
657
|
-
status: 'committed',
|
|
658
|
-
sha: commit.lastSha,
|
|
659
|
-
files: [miAbs, crapAbs].filter(Boolean),
|
|
660
|
-
committed: commit.committed,
|
|
661
|
-
};
|
|
552
|
+
function classifyFunnelError(error) {
|
|
553
|
+
return typeof error === 'string' && error.startsWith('refreshBaseline(')
|
|
554
|
+
? 'refresh-service-threw'
|
|
555
|
+
: 'commit-failed';
|
|
662
556
|
}
|
|
663
557
|
|
|
664
558
|
/**
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* is the friction-signal write + the rollback that publishes the refusal
|
|
670
|
-
* outcome past the in-process pipeline boundary.
|
|
671
|
-
*
|
|
672
|
-
* Returns the canonical `{ status: 'refused', ... }` envelope.
|
|
559
|
+
* Aggregate per-kind refused verdicts into the single refusal envelope the
|
|
560
|
+
* close pipeline consumes, appending the friction signal (dedup-aware,
|
|
561
|
+
* AC3) as the publish step. The funnel already rolled the refused kinds'
|
|
562
|
+
* files back to HEAD.
|
|
673
563
|
*/
|
|
674
|
-
async function
|
|
675
|
-
|
|
564
|
+
async function publishRefusal({
|
|
565
|
+
refusedVerdicts,
|
|
676
566
|
caps,
|
|
677
567
|
epicId,
|
|
678
568
|
storyId,
|
|
679
|
-
cwd,
|
|
680
|
-
gitRunner,
|
|
681
569
|
appendSignal,
|
|
682
570
|
forEachLine,
|
|
683
571
|
config,
|
|
684
572
|
logger,
|
|
685
573
|
}) {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
574
|
+
const verdict = {
|
|
575
|
+
miOverCap: refusedVerdicts.flatMap((v) => v.miOverCap ?? []),
|
|
576
|
+
crapOverCap: refusedVerdicts.flatMap((v) => v.crapOverCap ?? []),
|
|
577
|
+
refusalReasons: refusedVerdicts.flatMap((v) => v.refusalReasons ?? []),
|
|
578
|
+
};
|
|
579
|
+
const dedup = await probeDedup({ epicId, storyId, forEachLine, logger });
|
|
580
|
+
const signalAppended = await maybeAppendRefusalSignal({
|
|
581
|
+
dedup,
|
|
689
582
|
epicId,
|
|
690
583
|
storyId,
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
gitRunner,
|
|
584
|
+
verdict,
|
|
585
|
+
caps,
|
|
694
586
|
appendSignal,
|
|
695
|
-
forEachLine,
|
|
696
587
|
config,
|
|
697
588
|
logger,
|
|
698
589
|
});
|
|
590
|
+
logger.info?.(
|
|
591
|
+
`[auto-refresh-runner] refused — ${verdict.refusalReasons.length} cap breach(es); friction signal ${dedup ? 'already present (dedup)' : signalAppended ? 'appended' : 'append failed'}.`,
|
|
592
|
+
);
|
|
593
|
+
return {
|
|
594
|
+
status: 'refused',
|
|
595
|
+
refusalReasons: verdict.refusalReasons,
|
|
596
|
+
signalAppended,
|
|
597
|
+
dedup,
|
|
598
|
+
miOverCap: verdict.miOverCap,
|
|
599
|
+
crapOverCap: verdict.crapOverCap,
|
|
600
|
+
};
|
|
699
601
|
}
|
|
700
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Bounded baseline auto-refresh. Delegates the per-kind refresh → stage →
|
|
605
|
+
* commit mechanics to the single `runRefreshCommit` funnel; this function
|
|
606
|
+
* owns only the config gating, the prior-envelope snapshot, the cap-check
|
|
607
|
+
* closure, and the refusal publication.
|
|
608
|
+
*
|
|
609
|
+
* @param {object} args
|
|
610
|
+
* @param {{ refreshedKinds?: Set<string>, lastRefreshSha?: string|null } | null} [args.cycleState]
|
|
611
|
+
* The close cycle's shared idempotency token (Story #4017). When the
|
|
612
|
+
* gate-failure attribution retry already refreshed a kind this cycle,
|
|
613
|
+
* the funnel short-circuits and the kind is not re-scored here.
|
|
614
|
+
* @returns {Promise<
|
|
615
|
+
* | { status: 'committed', sha: string, files: string[], committed: Array<{ kind: string, sha: string }> }
|
|
616
|
+
* | { status: 'refused', refusalReasons: string[], signalAppended: boolean, dedup: boolean, miOverCap: Array, crapOverCap: Array }
|
|
617
|
+
* | { status: 'skipped', reason: string }
|
|
618
|
+
* | { status: 'failed', reason: string, detail?: string }
|
|
619
|
+
* >}
|
|
620
|
+
*/
|
|
701
621
|
export async function runAutoRefresh({
|
|
702
622
|
storyId,
|
|
703
623
|
epicId,
|
|
@@ -705,22 +625,25 @@ export async function runAutoRefresh({
|
|
|
705
625
|
epicBranch,
|
|
706
626
|
storyBranch,
|
|
707
627
|
config,
|
|
628
|
+
cycleState = null,
|
|
708
629
|
deps = {},
|
|
709
630
|
} = {}) {
|
|
631
|
+
const resolved = resolveAutoRefreshDeps(deps);
|
|
710
632
|
const {
|
|
711
633
|
logger,
|
|
712
634
|
getQuality,
|
|
713
635
|
getBaselines,
|
|
714
|
-
evaluateAutoRefresh,
|
|
636
|
+
evaluateAutoRefresh: evaluate,
|
|
715
637
|
refreshBaseline,
|
|
716
638
|
scorerBuilder,
|
|
639
|
+
runRefreshCommit,
|
|
717
640
|
gitRunner,
|
|
718
641
|
fsImpl,
|
|
719
642
|
appendSignal,
|
|
720
643
|
forEachLine,
|
|
721
644
|
computeDiffPaths,
|
|
722
645
|
readerLoadFile,
|
|
723
|
-
} =
|
|
646
|
+
} = resolved;
|
|
724
647
|
|
|
725
648
|
const autoRefresh = getQuality(config)?.autoRefresh;
|
|
726
649
|
if (!autoRefresh || autoRefresh.enabled === false) {
|
|
@@ -731,67 +654,92 @@ export async function runAutoRefresh({
|
|
|
731
654
|
crapJumpCap: autoRefresh.crapJumpCap,
|
|
732
655
|
};
|
|
733
656
|
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
refreshBaseline,
|
|
743
|
-
scorerBuilder,
|
|
744
|
-
fsImpl,
|
|
745
|
-
readerLoadFile,
|
|
746
|
-
});
|
|
747
|
-
if (stage.ok !== true) {
|
|
748
|
-
return {
|
|
749
|
-
status: stage.status,
|
|
750
|
-
reason: stage.reason,
|
|
751
|
-
detail: stage.detail,
|
|
752
|
-
};
|
|
753
|
-
}
|
|
657
|
+
const baselines = getBaselines(config);
|
|
658
|
+
const kinds = [
|
|
659
|
+
{
|
|
660
|
+
kind: 'maintainability',
|
|
661
|
+
abs: resolveBaselineAbs(cwd, baselines?.maintainability?.path),
|
|
662
|
+
},
|
|
663
|
+
{ kind: 'crap', abs: resolveBaselineAbs(cwd, baselines?.crap?.path) },
|
|
664
|
+
].filter((k) => k.abs);
|
|
754
665
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
666
|
+
const committed = [];
|
|
667
|
+
const refusedVerdicts = [];
|
|
668
|
+
let lastSha = '';
|
|
669
|
+
|
|
670
|
+
for (const { kind, abs } of kinds) {
|
|
671
|
+
// Snapshot the prior envelope BEFORE the funnel's refreshBaseline()
|
|
672
|
+
// overwrites it — the cap evaluator compares against the pre-refresh
|
|
673
|
+
// rows. Reader-routed: schema-validated via `reader.loadFile`.
|
|
674
|
+
const priorEnv = readEnvelope({ absPath: abs, kind, readerLoadFile });
|
|
675
|
+
const capCheck = buildCapCheck({
|
|
676
|
+
kind,
|
|
677
|
+
priorEnv,
|
|
678
|
+
autoRefresh,
|
|
679
|
+
caps,
|
|
680
|
+
cwd,
|
|
681
|
+
epicBranch,
|
|
682
|
+
storyBranch,
|
|
683
|
+
evaluate,
|
|
684
|
+
gitRunner,
|
|
685
|
+
computeDiffPaths,
|
|
686
|
+
readerLoadFile,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const res = await runRefreshCommit({
|
|
690
|
+
cwd,
|
|
691
|
+
kind,
|
|
692
|
+
storyId,
|
|
693
|
+
epicBranch,
|
|
694
|
+
storyBranch,
|
|
695
|
+
config,
|
|
696
|
+
cycleState,
|
|
697
|
+
capCheck,
|
|
698
|
+
refreshBaseline,
|
|
699
|
+
scorerBuilder,
|
|
700
|
+
getBaselines,
|
|
701
|
+
getQuality,
|
|
702
|
+
fsImpl,
|
|
703
|
+
gitRunner,
|
|
704
|
+
logger,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
if (res.ok !== true) {
|
|
708
|
+
return {
|
|
709
|
+
status: 'failed',
|
|
710
|
+
reason: classifyFunnelError(res.error),
|
|
711
|
+
detail: res.error,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
if (res.refused) {
|
|
715
|
+
refusedVerdicts.push(res.verdict);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (!res.skipped && res.sha) {
|
|
719
|
+
committed.push({ kind, sha: res.sha });
|
|
720
|
+
lastSha = res.sha;
|
|
721
|
+
}
|
|
771
722
|
}
|
|
772
723
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
return pushRefresh({
|
|
777
|
-
validation,
|
|
724
|
+
if (refusedVerdicts.length > 0) {
|
|
725
|
+
return publishRefusal({
|
|
726
|
+
refusedVerdicts,
|
|
778
727
|
caps,
|
|
779
728
|
epicId,
|
|
780
729
|
storyId,
|
|
781
|
-
cwd,
|
|
782
|
-
gitRunner,
|
|
783
730
|
appendSignal,
|
|
784
731
|
forEachLine,
|
|
785
732
|
config,
|
|
786
733
|
logger,
|
|
787
734
|
});
|
|
788
735
|
}
|
|
789
|
-
|
|
736
|
+
if (committed.length === 0) {
|
|
737
|
+
return { status: 'skipped', reason: 'no-baseline-drift' };
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
status: 'committed',
|
|
741
|
+
sha: lastSha,
|
|
742
|
+
files: kinds.map((k) => k.abs),
|
|
743
|
+
committed,
|
|
744
|
+
};
|
|
790
745
|
}
|
|
791
|
-
|
|
792
|
-
export {
|
|
793
|
-
commitRefresh,
|
|
794
|
-
pushRefresh,
|
|
795
|
-
stageRefreshArtifacts,
|
|
796
|
-
validateRefreshAccepted,
|
|
797
|
-
};
|