mandrel 2.47.0 → 2.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +49 -49
- package/.agents/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +48 -0
- package/.agents/scripts/lib/baselines/kernel.js +19 -0
- package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
- package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/merge-baseline.js +238 -0
- package/.agents/scripts/providers/github/errors.js +66 -10
- package/.agents/scripts/providers/github/sub-issues.js +8 -1
- package/.agents/workflows/helpers/deliver-digest.md +30 -26
- package/.agents/workflows/helpers/parallel-tooling.md +17 -0
- package/docs/CHANGELOG.md +25 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* baseline-upward-writeback.js — persist improved maintainability rows on the
|
|
3
|
+
* branch that earned them (Story #5224).
|
|
4
|
+
*
|
|
5
|
+
* The diff-scoped baseline ratchet only ever reds on a REGRESSION. A branch
|
|
6
|
+
* that *improves* a file it touched is therefore waved through with its
|
|
7
|
+
* committed row left describing the worse, older tree — and nothing on the
|
|
8
|
+
* per-PR path ever writes it back. The only thing that notices is the nightly
|
|
9
|
+
* full-scope re-score (`check-baseline-drift.js`), which had filed the same
|
|
10
|
+
* one-command chore seven times before this module existed.
|
|
11
|
+
*
|
|
12
|
+
* The classifier already does the hard half: `kinds/kind-factory.js#classify`
|
|
13
|
+
* partitions every compared row into `regressions` / `improvements` /
|
|
14
|
+
* `unchanged` / `additions`, and the enforcement path forwards `improvements`
|
|
15
|
+
* all the way to the report. Nothing persisted it. This module is that
|
|
16
|
+
* missing half — run from the close's `close-validation` phase, ahead of the
|
|
17
|
+
* gate chain, so the refreshed row lands in the branch's own PR.
|
|
18
|
+
*
|
|
19
|
+
* Shape borrowed from {@link ../story-close/format-autofix.js#runScopedFormatAutofix}:
|
|
20
|
+
* scope to the branch's changed-file set, fold the writes into one dedicated
|
|
21
|
+
* commit ahead of the gates, log the paths touched, and inject every
|
|
22
|
+
* git / baseline / scoring collaborator so the unit tests never spawn git
|
|
23
|
+
* (`.agents/rules/test-seams.md`).
|
|
24
|
+
*
|
|
25
|
+
* Four constraints bind the design, and every one of them is a "must not":
|
|
26
|
+
*
|
|
27
|
+
* 1. **Only `improvements` are written.** A regression must still fail the
|
|
28
|
+
* gate exactly as it does today. A write-back that could launder one
|
|
29
|
+
* makes the baseline actively worse than leaving it stale, so a regressed
|
|
30
|
+
* row is never in the written set — it is not filtered out downstream, it
|
|
31
|
+
* never enters.
|
|
32
|
+
* 2. **`maintainability` only.** CRAP's drift identity is
|
|
33
|
+
* `path::method@startLine`, which re-keys whenever anything above a
|
|
34
|
+
* method moves, so the same treatment there is churn rather than signal.
|
|
35
|
+
* That is exactly why the nightly watches maintainability alone.
|
|
36
|
+
* 3. **Changed files only — never a full-scope regeneration.** A full-scope
|
|
37
|
+
* write at land time would absorb unrelated drift from other branches
|
|
38
|
+
* into whichever PR happened to land next, and would fight the
|
|
39
|
+
* row-identity merge driver that exists to keep concurrent refreshes
|
|
40
|
+
* apart (Story #5215).
|
|
41
|
+
* 4. **Idempotent and silent.** No empty commit; a second close over the
|
|
42
|
+
* same tree finds nothing left to improve and commits nothing.
|
|
43
|
+
*
|
|
44
|
+
* The authored commit carries the `baseline-refresh:` marker
|
|
45
|
+
* `phases/refresh-ack.js` recognises, so the refreshed rows are vouched for
|
|
46
|
+
* rather than read as fresh drift, and its subject is conventional so
|
|
47
|
+
* commitlint accepts it.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
compare as compareMaintainability,
|
|
54
|
+
projectRow as projectMaintainabilityRow,
|
|
55
|
+
} from '../../baselines/kinds/maintainability.js';
|
|
56
|
+
import {
|
|
57
|
+
_internals as baselineReaderInternals,
|
|
58
|
+
load as loadBaselineEnvelope,
|
|
59
|
+
} from '../../baselines/reader.js';
|
|
60
|
+
import {
|
|
61
|
+
refreshBaseline as defaultRefreshBaseline,
|
|
62
|
+
resolveDefaultScorer,
|
|
63
|
+
} from '../../baselines/refresh-service.js';
|
|
64
|
+
import { getQuality } from '../../config-resolver.js';
|
|
65
|
+
import { gitSync as defaultGitSync } from '../../git-utils.js';
|
|
66
|
+
import { Logger as DefaultLogger } from '../../Logger.js';
|
|
67
|
+
import { currentBranch, listChangedFiles } from './format-autofix.js';
|
|
68
|
+
|
|
69
|
+
const TAG = '[baseline-writeback]';
|
|
70
|
+
|
|
71
|
+
/** The one kind this module touches. See constraint 2 in the preamble. */
|
|
72
|
+
const KIND = 'maintainability';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Absolute drift tolerance when the gate configures none. Mirrors
|
|
76
|
+
* `drift-detector.js`'s `KIND_SPECS.maintainability.defaultTolerance`, so the
|
|
77
|
+
* per-PR write-back and the nightly full-scope check agree on what counts as
|
|
78
|
+
* movement rather than float noise.
|
|
79
|
+
*/
|
|
80
|
+
const DEFAULT_TOLERANCE = 0.5;
|
|
81
|
+
|
|
82
|
+
/** Files the maintainability scorer can measure at all. */
|
|
83
|
+
const SCORABLE = /\.(?:m?[jt]sx?)$/i;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reasons reported before the step scored anything, so `ran: false` means
|
|
87
|
+
* exactly "a guard stopped this before any work happened" rather than the
|
|
88
|
+
* softer "nothing came of it". `no-scored-rows`, `no-improvements` and
|
|
89
|
+
* `unchanged` are deliberately absent: those are outcomes of a run.
|
|
90
|
+
*/
|
|
91
|
+
const GUARD_REASONS = new Set([
|
|
92
|
+
'gate-disabled',
|
|
93
|
+
'no-changed-files',
|
|
94
|
+
'wrong-branch',
|
|
95
|
+
'no-baseline',
|
|
96
|
+
'no-scorer',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the gate's absolute tolerance. Anything below it is float noise the
|
|
101
|
+
* ratchet already refuses to red on, so writing it back would be churn — and
|
|
102
|
+
* churn on a file every concurrent branch also touches is the one cost this
|
|
103
|
+
* step must not add.
|
|
104
|
+
*
|
|
105
|
+
* @param {object|undefined} gate resolved `delivery.quality.gates.maintainability`
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
function resolveTolerance(gate) {
|
|
109
|
+
const configured = gate?.tolerance;
|
|
110
|
+
if (configured?.kind === 'absolute') {
|
|
111
|
+
const value = Number(configured.value);
|
|
112
|
+
if (Number.isFinite(value)) return Math.abs(value);
|
|
113
|
+
}
|
|
114
|
+
return DEFAULT_TOLERANCE;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read the maintainability gate block as DECLARED — `quality.gates[kind]`, not
|
|
119
|
+
* the sibling `quality[kind]` projection. The two differ in exactly the two
|
|
120
|
+
* fields this module reads: the projection drops `enabled` entirely and
|
|
121
|
+
* flattens `tolerance` to a bare number, so reading it would silently make the
|
|
122
|
+
* gate un-disablable and every configured tolerance unreadable. `evaluate.js`
|
|
123
|
+
* receives this same declared block as its `gateBlock`, which is what keeps
|
|
124
|
+
* the write-back's notion of "moved" identical to the gate's.
|
|
125
|
+
*
|
|
126
|
+
* Tolerates a resolver that throws (a malformed config under a tmp cwd). An
|
|
127
|
+
* unresolvable config reads as "framework defaults", which enable the gate —
|
|
128
|
+
* the same reading `projections/advisories.js#isEnabled` applies.
|
|
129
|
+
*
|
|
130
|
+
* @param {object|undefined} config
|
|
131
|
+
* @returns {object|undefined}
|
|
132
|
+
*/
|
|
133
|
+
function resolveGate(config) {
|
|
134
|
+
try {
|
|
135
|
+
return getQuality(config)?.gates?.[KIND];
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalise scorer output into the on-disk row shape so scored rows and
|
|
143
|
+
* committed rows are directly comparable. `projectRow` is the same projection
|
|
144
|
+
* the writer applies, which is what makes the two sides comparable at all.
|
|
145
|
+
* A row the writer itself would refuse is dropped rather than compared.
|
|
146
|
+
*
|
|
147
|
+
* @param {Array<object>} rows
|
|
148
|
+
* @returns {Array<{ path: string, mi: number }>}
|
|
149
|
+
*/
|
|
150
|
+
function projectRows(rows) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const row of rows ?? []) {
|
|
153
|
+
try {
|
|
154
|
+
const projected = projectMaintainabilityRow(row);
|
|
155
|
+
if (Number.isFinite(projected.mi)) out.push(projected);
|
|
156
|
+
} catch {
|
|
157
|
+
// Unprojectable row → not evidence of an improvement.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Select the rows this branch has genuinely improved.
|
|
165
|
+
*
|
|
166
|
+
* The base side is deliberately narrowed to the rows the head side actually
|
|
167
|
+
* scored. `compare()` classifies a base row with no head row through the
|
|
168
|
+
* kind's `removedRowPolicy`, which for maintainability pushes an
|
|
169
|
+
* **improvement** ("the file is gone, so its debt is gone too"). That policy
|
|
170
|
+
* is correct for a full-scope compare and catastrophic for a scoped one: every
|
|
171
|
+
* untouched file in the repo would arrive here as an improvement and be
|
|
172
|
+
* rewritten from a score nobody computed. Narrowing the base to the scored
|
|
173
|
+
* keys means every comparison has both sides, so the removed-row arm cannot
|
|
174
|
+
* fire at all — and the `head === null` guard below makes that structural
|
|
175
|
+
* rather than incidental.
|
|
176
|
+
*
|
|
177
|
+
* `additions` — a scored file with no committed row — is likewise excluded:
|
|
178
|
+
* constraint 1 admits only what the classifier calls an improvement, and a new
|
|
179
|
+
* file's row is the ordinary refresh path's business, not this step's.
|
|
180
|
+
*
|
|
181
|
+
* Pure.
|
|
182
|
+
*
|
|
183
|
+
* @param {{ scoredRows: Array<object>, baselineRows: Array<object>, tolerance: number }} opts
|
|
184
|
+
* @returns {Array<{ path: string, mi: number }>} the head rows to persist
|
|
185
|
+
*/
|
|
186
|
+
function selectImprovedRows({ scoredRows, baselineRows, tolerance }) {
|
|
187
|
+
const scoredPaths = new Set(scoredRows.map((row) => row.path));
|
|
188
|
+
const baseSubset = (baselineRows ?? []).filter((row) =>
|
|
189
|
+
scoredPaths.has(row?.path),
|
|
190
|
+
);
|
|
191
|
+
if (baseSubset.length === 0) return [];
|
|
192
|
+
|
|
193
|
+
const result = compareMaintainability(
|
|
194
|
+
{ rows: scoredRows },
|
|
195
|
+
{ rows: baseSubset },
|
|
196
|
+
);
|
|
197
|
+
const improved = [];
|
|
198
|
+
for (const entry of result?.improvements ?? []) {
|
|
199
|
+
const head = entry?.head;
|
|
200
|
+
const base = entry?.base;
|
|
201
|
+
if (!head || !base) continue;
|
|
202
|
+
if (head.mi - base.mi <= tolerance) continue;
|
|
203
|
+
improved.push(head);
|
|
204
|
+
}
|
|
205
|
+
return improved;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render the commit body: one line per rewritten row, before → after. The body
|
|
210
|
+
* is the durable record of what the step touched — the `Logger` line scrolls
|
|
211
|
+
* out of a close transcript, this does not — and `check-baseline-drift.js`'s
|
|
212
|
+
* remedy text asks a `baseline-refresh:` commit to carry a non-empty body.
|
|
213
|
+
*
|
|
214
|
+
* @param {Array<{ path: string, mi: number }>} improved
|
|
215
|
+
* @param {Array<object>} baselineRows
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
function buildCommitBody(improved, baselineRows) {
|
|
219
|
+
const priorByPath = new Map(
|
|
220
|
+
(baselineRows ?? []).map((row) => [row?.path, row?.mi]),
|
|
221
|
+
);
|
|
222
|
+
const lines = [
|
|
223
|
+
'Rows the branch improved on files it touched, written back so the',
|
|
224
|
+
'committed baseline stops falling behind the tree in the upward',
|
|
225
|
+
'direction. Scoped to the branch changed set; no regression is rewritten.',
|
|
226
|
+
'',
|
|
227
|
+
];
|
|
228
|
+
for (const row of improved) {
|
|
229
|
+
const before = Number(priorByPath.get(row.path) ?? 0).toFixed(2);
|
|
230
|
+
lines.push(`- ${row.path}: ${before} -> ${row.mi.toFixed(2)}`);
|
|
231
|
+
}
|
|
232
|
+
return lines.join('\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build the commit subject. Conventional (`chore(baselines): …`) so commitlint
|
|
237
|
+
* accepts it, carrying the `baseline-refresh:` marker as a plain substring so
|
|
238
|
+
* `refresh-ack.js#resolveRefreshTrigger` recognises it, and fixed-length in
|
|
239
|
+
* everything but the Story id so it cannot drift past commitlint's 100-char
|
|
240
|
+
* subject cap.
|
|
241
|
+
*
|
|
242
|
+
* @param {number|string} storyId
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
function buildCommitSubject(storyId) {
|
|
246
|
+
return `chore(baselines): baseline-refresh: improved maintainability rows (story #${storyId})`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Stage the single baseline file and commit it. Hooks must run; never pass
|
|
251
|
+
* `--no-verify` (project policy).
|
|
252
|
+
*
|
|
253
|
+
* On a commit failure the written file is restored, because everything
|
|
254
|
+
* downstream — base-sync, the push, the gate chain's own reads — assumes the
|
|
255
|
+
* close left the worktree clean. A half-applied write-back that survives as an
|
|
256
|
+
* uncommitted edit would silently change what the gates score without ever
|
|
257
|
+
* reaching the PR.
|
|
258
|
+
*
|
|
259
|
+
* @param {{ cwd: string, git: Function, relPath: string, subject: string, body: string }} opts
|
|
260
|
+
* @returns {{ sha: string }}
|
|
261
|
+
*/
|
|
262
|
+
function commitBaseline({ cwd, git, relPath, subject, body }) {
|
|
263
|
+
git(['add', '--', relPath], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
264
|
+
try {
|
|
265
|
+
git(['commit', '-m', subject, '-m', body], {
|
|
266
|
+
cwd,
|
|
267
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
|
+
});
|
|
269
|
+
} catch (err) {
|
|
270
|
+
git(['checkout', '--', relPath], {
|
|
271
|
+
cwd,
|
|
272
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
273
|
+
});
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
const sha = git(['rev-parse', '--short', 'HEAD'], {
|
|
277
|
+
cwd,
|
|
278
|
+
encoding: 'utf8',
|
|
279
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
280
|
+
});
|
|
281
|
+
return { sha: String(sha ?? '').trim() };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Everything that must hold before the step is allowed to score anything.
|
|
286
|
+
* Returns a skip reason, or `null` to proceed.
|
|
287
|
+
*
|
|
288
|
+
* The branch assertion runs here — before the write, not before the commit —
|
|
289
|
+
* so a mis-wired `worktreePath` can never leave a modified baseline in a tree
|
|
290
|
+
* whose history we then refuse to touch.
|
|
291
|
+
*
|
|
292
|
+
* @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[] }} ctx
|
|
293
|
+
* @returns {string|null}
|
|
294
|
+
*/
|
|
295
|
+
function precheck({ gate, workTree, storyBranch, git, changed }) {
|
|
296
|
+
if (gate?.enabled === false) return 'gate-disabled';
|
|
297
|
+
if (changed.length === 0) return 'no-changed-files';
|
|
298
|
+
const onBranch = currentBranch(workTree, git);
|
|
299
|
+
if (onBranch !== storyBranch) return 'wrong-branch';
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Persist improved maintainability rows for the files this branch changed, and
|
|
305
|
+
* fold them into one `baseline-refresh:` commit on the Story branch.
|
|
306
|
+
*
|
|
307
|
+
* Every no-op is reported by name rather than silently: `gate-disabled`,
|
|
308
|
+
* `no-changed-files`, `wrong-branch`, `no-baseline`, `no-scored-rows`,
|
|
309
|
+
* `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
|
|
310
|
+
* this step is never allowed to fail a close, because `check-baselines` is
|
|
311
|
+
* still the gate and this is only the refresh half of the loop.
|
|
312
|
+
*
|
|
313
|
+
* @param {{
|
|
314
|
+
* cwd: string,
|
|
315
|
+
* worktreePath?: string,
|
|
316
|
+
* storyId: number|string,
|
|
317
|
+
* baseBranch: string,
|
|
318
|
+
* storyBranch: string,
|
|
319
|
+
* config?: object,
|
|
320
|
+
* logger?: object,
|
|
321
|
+
* gitSync?: (cwd: string, ...args: string[]) => string,
|
|
322
|
+
* loadBaselineRows?: (opts: { cwd: string }) => Array<object>|null,
|
|
323
|
+
* scoreFiles?: (files: string[], opts: object) => Promise<Array<object>>|Array<object>,
|
|
324
|
+
* refreshBaseline?: typeof defaultRefreshBaseline,
|
|
325
|
+
* resolveWritePath?: (opts: { cwd: string }) => string,
|
|
326
|
+
* }} opts
|
|
327
|
+
* @returns {Promise<{
|
|
328
|
+
* ran: boolean,
|
|
329
|
+
* committed: boolean,
|
|
330
|
+
* sha?: string,
|
|
331
|
+
* improvedPaths?: string[],
|
|
332
|
+
* reason?: string,
|
|
333
|
+
* }>}
|
|
334
|
+
*/
|
|
335
|
+
export async function runBaselineUpwardWriteback({
|
|
336
|
+
cwd,
|
|
337
|
+
worktreePath,
|
|
338
|
+
storyId,
|
|
339
|
+
baseBranch,
|
|
340
|
+
storyBranch,
|
|
341
|
+
config,
|
|
342
|
+
logger = DefaultLogger,
|
|
343
|
+
gitSync = defaultGitSync,
|
|
344
|
+
loadBaselineRows = defaultLoadBaselineRows,
|
|
345
|
+
scoreFiles,
|
|
346
|
+
refreshBaseline = defaultRefreshBaseline,
|
|
347
|
+
resolveWritePath = defaultResolveWritePath,
|
|
348
|
+
} = {}) {
|
|
349
|
+
if (!cwd) throw new Error('runBaselineUpwardWriteback: cwd is required');
|
|
350
|
+
if (!baseBranch)
|
|
351
|
+
throw new Error('runBaselineUpwardWriteback: baseBranch is required');
|
|
352
|
+
if (!storyBranch)
|
|
353
|
+
throw new Error('runBaselineUpwardWriteback: storyBranch is required');
|
|
354
|
+
|
|
355
|
+
const workTree = worktreePath || cwd;
|
|
356
|
+
// The two format-autofix helpers below take git as `(args, opts) => stdout`;
|
|
357
|
+
// `git-utils.gitSync` is `(cwd, ...args) => trimmed stdout` and throws on a
|
|
358
|
+
// non-zero exit. Adapt rather than reach for `node:child_process` directly —
|
|
359
|
+
// the shared surface owns the stdout ceiling, `shell: false` and error
|
|
360
|
+
// normalisation, and `tests/enforcement/child-process-imports.test.js`
|
|
361
|
+
// enforces that.
|
|
362
|
+
const git = (args, opts = {}) => gitSync(opts.cwd ?? workTree, ...args);
|
|
363
|
+
const gate = resolveGate(config);
|
|
364
|
+
|
|
365
|
+
const changed = listChangedFiles({
|
|
366
|
+
cwd: workTree,
|
|
367
|
+
baseBranch,
|
|
368
|
+
storyBranch,
|
|
369
|
+
git,
|
|
370
|
+
}).filter((file) => SCORABLE.test(file));
|
|
371
|
+
|
|
372
|
+
const blocked = precheck({ gate, workTree, storyBranch, git, changed });
|
|
373
|
+
if (blocked) return skip(logger, blocked);
|
|
374
|
+
|
|
375
|
+
const baselineRows = loadBaselineRows({ cwd: workTree });
|
|
376
|
+
if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
|
|
377
|
+
return skip(logger, 'no-baseline');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const score = scoreFiles ?? resolveDefaultScorer(KIND, { cwd: workTree });
|
|
381
|
+
if (typeof score !== 'function') return skip(logger, 'no-scorer');
|
|
382
|
+
const scoredRows = projectRows(
|
|
383
|
+
await score(changed, { kind: KIND, fullScope: false, cwd: workTree }),
|
|
384
|
+
);
|
|
385
|
+
if (scoredRows.length === 0) return skip(logger, 'no-scored-rows');
|
|
386
|
+
|
|
387
|
+
const improved = selectImprovedRows({
|
|
388
|
+
scoredRows,
|
|
389
|
+
baselineRows,
|
|
390
|
+
tolerance: resolveTolerance(gate),
|
|
391
|
+
});
|
|
392
|
+
if (improved.length === 0) return skip(logger, 'no-improvements');
|
|
393
|
+
|
|
394
|
+
return await persist({
|
|
395
|
+
improved,
|
|
396
|
+
baselineRows,
|
|
397
|
+
workTree,
|
|
398
|
+
git,
|
|
399
|
+
storyId,
|
|
400
|
+
logger,
|
|
401
|
+
refreshBaseline,
|
|
402
|
+
resolveWritePath,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Write the selected rows through the one sanctioned write funnel and commit
|
|
408
|
+
* them. The already-computed rows are handed to `refreshBaseline` as its
|
|
409
|
+
* scorer so the files are scored exactly once — the service still owns path
|
|
410
|
+
* canonicalization, `mergeRows` (which preserves every out-of-scope row
|
|
411
|
+
* byte-for-byte), rollup, envelope stamping and the atomic write.
|
|
412
|
+
*
|
|
413
|
+
* @returns {Promise<{ ran: boolean, committed: boolean, sha?: string, improvedPaths?: string[], reason?: string }>}
|
|
414
|
+
*/
|
|
415
|
+
async function persist({
|
|
416
|
+
improved,
|
|
417
|
+
baselineRows,
|
|
418
|
+
workTree,
|
|
419
|
+
git,
|
|
420
|
+
storyId,
|
|
421
|
+
logger,
|
|
422
|
+
refreshBaseline,
|
|
423
|
+
resolveWritePath,
|
|
424
|
+
}) {
|
|
425
|
+
const improvedPaths = improved.map((row) => row.path);
|
|
426
|
+
const writePath = resolveWritePath({ cwd: workTree });
|
|
427
|
+
const { wrote } = await refreshBaseline({
|
|
428
|
+
kind: KIND,
|
|
429
|
+
cwd: workTree,
|
|
430
|
+
writePath,
|
|
431
|
+
scopeFiles: improvedPaths,
|
|
432
|
+
scorer: () => improved,
|
|
433
|
+
});
|
|
434
|
+
// The writer short-circuits on structural equality, so `wrote: false` means
|
|
435
|
+
// the committed rows already carried these values — nothing to commit, and
|
|
436
|
+
// nothing worth a log line above debug.
|
|
437
|
+
if (!wrote) return skip(logger, 'unchanged');
|
|
438
|
+
|
|
439
|
+
const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
|
|
440
|
+
const { sha } = commitBaseline({
|
|
441
|
+
cwd: workTree,
|
|
442
|
+
git,
|
|
443
|
+
relPath,
|
|
444
|
+
subject: buildCommitSubject(storyId),
|
|
445
|
+
body: buildCommitBody(improved, baselineRows),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
logger.warn?.(
|
|
449
|
+
`${TAG} wrote back ${improvedPaths.length} improved ${KIND} row(s) ` +
|
|
450
|
+
`on story #${storyId}: ${improvedPaths.join(', ')}; committed as ${sha}.`,
|
|
451
|
+
);
|
|
452
|
+
return { ran: true, committed: true, sha, improvedPaths };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Report a no-op by name. Every skip is `info`-level: none of them is a
|
|
457
|
+
* problem, and the close transcript already carries one line per phase.
|
|
458
|
+
*
|
|
459
|
+
* @param {object} logger
|
|
460
|
+
* @param {string} reason
|
|
461
|
+
*/
|
|
462
|
+
function skip(logger, reason) {
|
|
463
|
+
logger.info?.(`${TAG} no write-back (${reason}).`);
|
|
464
|
+
return { ran: !GUARD_REASONS.has(reason), committed: false, reason };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Default committed-row loader — the schema-validating reader, so a baseline
|
|
469
|
+
* this module would refuse to compare against is reported as `no-baseline`
|
|
470
|
+
* rather than half-read.
|
|
471
|
+
*/
|
|
472
|
+
function defaultLoadBaselineRows({ cwd }) {
|
|
473
|
+
try {
|
|
474
|
+
return loadBaselineEnvelope(KIND, { cwd })?.rows ?? null;
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Default on-disk location of the maintainability baseline. */
|
|
481
|
+
function defaultResolveWritePath({ cwd }) {
|
|
482
|
+
return baselineReaderInternals.resolveBaselinePath(KIND, { cwd });
|
|
483
|
+
}
|
|
@@ -333,10 +333,15 @@ export function runFormatAutofix({
|
|
|
333
333
|
* `(args: string[], opts: object) => string`. A bridge adapter wraps it into
|
|
334
334
|
* the `gitSpawn(cwd, ...args)` shape that `diffNameOnly` expects.
|
|
335
335
|
*
|
|
336
|
+
* Exported since Story #5224: the sibling `baseline-upward-writeback.js` step
|
|
337
|
+
* scopes to the same branch changed-file set, and a second copy of this
|
|
338
|
+
* `(args, opts)` → `gitSpawn` bridge is exactly the kind of near-duplicate the
|
|
339
|
+
* duplication gate exists to refuse.
|
|
340
|
+
*
|
|
336
341
|
* @param {{ cwd: string, baseBranch: string, storyBranch: string, git: Function }} opts
|
|
337
342
|
* @returns {string[]}
|
|
338
343
|
*/
|
|
339
|
-
function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
|
|
344
|
+
export function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
|
|
340
345
|
// Bridge the (args, opts) → string interface into gitSpawn(cwd, ...args).
|
|
341
346
|
const gitSpawn = (_cwd, ...args) => {
|
|
342
347
|
try {
|