mandrel 2.22.0 → 2.24.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/docs/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/deliver-light.js +23 -45
- package/.agents/scripts/diagnose-friction.js +95 -4
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +37 -1
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +107 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +169 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +151 -46
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +18 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +23 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +76 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/plan-reference.md +40 -0
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +23 -0
- package/package.json +1 -1
|
@@ -291,6 +291,7 @@ top-level keys are validation errors.
|
|
|
291
291
|
| `feedbackLoop` | No | `object` | — | Nested configuration block. |
|
|
292
292
|
| `feedbackLoop.auditResultsAutoFile` | No | `boolean` | `true` | When true (default), the close-time audit-results graduator auto-files non-blocking audit-results findings as follow-up issues routed by source classification. Set to false to suppress auto-filing; findings remain accessible in the structured comments on the Story. |
|
|
293
293
|
| `feedbackLoop.retroProposals` | No | `boolean` | `true` | When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap\|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas. |
|
|
294
|
+
| `feedbackLoop.frictionWindowDays` | No | `integer` | `30` | How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30. |
|
|
294
295
|
| `auditToStories` | No | `object` | — | Nested configuration block. |
|
|
295
296
|
| `auditToStories.severityFloor` | No | `"critical"` \| `"high"` \| `"medium"` \| `"low"` \| `"all"` | `"high"` | Minimum severity a finding must meet to be proposed as a Story on an unattended `/audit-to-stories --auto` sweep (Story #4626). Default high. |
|
|
296
297
|
| `auditToStories.autoComment` | No | `boolean` | `true` | When true (default), `/audit-to-stories --auto` posts a re-detected comment on an already-open matched Issue instead of silently skipping it. |
|
|
@@ -1430,6 +1430,12 @@
|
|
|
1430
1430
|
"type": "boolean",
|
|
1431
1431
|
"default": true,
|
|
1432
1432
|
"description": "When true (default), the retro auto-files its actionable routed proposals as meta::<framework-gap|consumer-improvement> + friction::<category> issues via the graduator pre-parsed-findings seam, and the rendered retro sections list the filed issue numbers instead of paste-ready gh command stanzas. Set to false to fall back to the command stanzas."
|
|
1433
|
+
},
|
|
1434
|
+
"frictionWindowDays": {
|
|
1435
|
+
"type": "integer",
|
|
1436
|
+
"minimum": 1,
|
|
1437
|
+
"default": 30,
|
|
1438
|
+
"description": "How many days back the run-scope friction recurrence window reaches (Story #4850). The window spans every surviving per-Story signal stream rather than the triggering run's own Stories, so that a defect firing once per Story can reach the actionable threshold; this bounds it by age so a defect fixed weeks ago stops re-routing. Rows older than the bound — and rows carrying no readable timestamp — are excluded and counted on the roll-up step result. Default 30."
|
|
1433
1439
|
}
|
|
1434
1440
|
},
|
|
1435
1441
|
"additionalProperties": false
|
|
@@ -87,7 +87,8 @@
|
|
|
87
87
|
"statusResync",
|
|
88
88
|
"refCleanup",
|
|
89
89
|
"baseFastForward",
|
|
90
|
-
"tempPurge"
|
|
90
|
+
"tempPurge",
|
|
91
|
+
"leaseRelease"
|
|
91
92
|
],
|
|
92
93
|
"properties": {
|
|
93
94
|
"followUps": { "type": "boolean" },
|
|
@@ -98,6 +99,10 @@
|
|
|
98
99
|
"type": "boolean",
|
|
99
100
|
"description": "Story #4794 — the merged Story's spent temp artifacts (gate transcripts, validation evidence) were purged under delivery.tempRetention. A disabled policy reports true: the operator turned the purge off, so doing nothing IS the correct outcome. Only a real failure — an unreadable temp root, an undeletable artifact — reports false, and like every tail step that degrades the report, never the land."
|
|
100
101
|
},
|
|
102
|
+
"leaseRelease": {
|
|
103
|
+
"type": "boolean",
|
|
104
|
+
"description": "Story #4860 — the operator's assignee-lease on the Story was released now that the merge is confirmed. The close deliberately no longer releases it at PR creation, so the ticket stays assigned for the whole time its PR is open; every non-merged ending (merge.unlanded block, exhausted wait budget, --no-wait-merge, --no-auto-merge) retains the claim and never reaches this step. A no-op release — the operator is no longer the recorded owner, as on a re-run or a belated manual confirm — reports true: an already-unassigned ticket is the desired end state. Only a throw reports false, and like every tail step that degrades the report, never the land."
|
|
105
|
+
},
|
|
101
106
|
"details": {
|
|
102
107
|
"type": "object",
|
|
103
108
|
"description": "Per-step diagnostic detail — the reason a false step reported false.",
|
|
@@ -61,10 +61,10 @@ import { parseArgs } from 'node:util';
|
|
|
61
61
|
import { runAsCli } from './lib/cli-utils.js';
|
|
62
62
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
63
63
|
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
64
|
-
import {
|
|
64
|
+
import { resolveBackstopOutcome } from './lib/orchestration/light-backstop.js';
|
|
65
|
+
import { recordGateRefusal } from './lib/orchestration/light-escalation.js';
|
|
65
66
|
import {
|
|
66
67
|
buildReceiptStoryTicket,
|
|
67
|
-
checkLightDiffBackstop,
|
|
68
68
|
deriveLightSuitability,
|
|
69
69
|
resolveLightGateOutcome,
|
|
70
70
|
} from './lib/orchestration/light-suitability.js';
|
|
@@ -121,7 +121,11 @@ Gate options:
|
|
|
121
121
|
envelope and ENDS the session (no prompt, no fallback).
|
|
122
122
|
|
|
123
123
|
Backstop options:
|
|
124
|
-
--backstop Re-check the ACTUAL diff after implementation.
|
|
124
|
+
--backstop Re-check the ACTUAL diff after implementation. Bounds the
|
|
125
|
+
change's IMPLEMENTATION half by magnitude (changed lines +
|
|
126
|
+
file sprawl); test/doc/baseline companions are exempt from
|
|
127
|
+
the counts but still matched for sensitive paths. A block
|
|
128
|
+
emits a nextCommand recycling the receipt through /plan.
|
|
125
129
|
--story <id> Story issue number whose story-<id> branch to diff.
|
|
126
130
|
|
|
127
131
|
--pretty Pretty-print the JSON envelope.
|
|
@@ -130,8 +134,6 @@ Backstop options:
|
|
|
130
134
|
|
|
131
135
|
/** Exit code when the gate did not resolve to proceed-light. */
|
|
132
136
|
const EXIT_NOT_PROCEED = 2;
|
|
133
|
-
/** Exit code when the diff backstop blocked the land. */
|
|
134
|
-
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
139
|
* Split a comma-separated path list into trimmed, non-empty entries.
|
|
@@ -292,33 +294,6 @@ export function buildNextCommands(storyId) {
|
|
|
292
294
|
};
|
|
293
295
|
}
|
|
294
296
|
|
|
295
|
-
/**
|
|
296
|
-
* Run the diff backstop against a Story branch's actual change set.
|
|
297
|
-
*
|
|
298
|
-
* @param {{
|
|
299
|
-
* storyId: number,
|
|
300
|
-
* baseRef?: string,
|
|
301
|
-
* cwd?: string,
|
|
302
|
-
* computeFn?: typeof computeChangeSet,
|
|
303
|
-
* injectedRules?: object,
|
|
304
|
-
* }} args
|
|
305
|
-
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
306
|
-
*/
|
|
307
|
-
export function runDiffBackstop({
|
|
308
|
-
storyId,
|
|
309
|
-
baseRef = 'main',
|
|
310
|
-
cwd = process.cwd(),
|
|
311
|
-
computeFn = computeChangeSet,
|
|
312
|
-
injectedRules,
|
|
313
|
-
} = {}) {
|
|
314
|
-
const { files } = computeFn({
|
|
315
|
-
baseRef,
|
|
316
|
-
headRef: `story-${storyId}`,
|
|
317
|
-
cwd,
|
|
318
|
-
});
|
|
319
|
-
return checkLightDiffBackstop({ changedFiles: files, injectedRules });
|
|
320
|
-
}
|
|
321
|
-
|
|
322
297
|
/**
|
|
323
298
|
* Was a non-blank `--operator-proceed-light` supplied? The gate core decides
|
|
324
299
|
* whether it *applies*; this only asks whether the operator typed one, so the
|
|
@@ -348,27 +323,28 @@ function emit(envelope, pretty) {
|
|
|
348
323
|
}
|
|
349
324
|
|
|
350
325
|
/**
|
|
351
|
-
* Backstop mode — re-check the actual diff.
|
|
326
|
+
* Backstop mode — re-check the actual diff. The decision lives in
|
|
327
|
+
* {@link module:lib/orchestration/light-backstop}; this branches and prints.
|
|
352
328
|
*
|
|
353
|
-
* @param {
|
|
329
|
+
* @param {object} values Parsed CLI values.
|
|
330
|
+
* @param {{ resolveFn?: typeof resolveBackstopOutcome }} [deps]
|
|
354
331
|
* @returns {Promise<number>}
|
|
355
332
|
*/
|
|
356
|
-
async function runBackstopMode(values) {
|
|
333
|
+
async function runBackstopMode(values, deps = {}) {
|
|
334
|
+
const { resolveFn = resolveBackstopOutcome } = deps;
|
|
357
335
|
const storyId = Number.parseInt(String(values.story ?? ''), 10);
|
|
358
336
|
if (!Number.isInteger(storyId) || storyId <= 0) {
|
|
359
337
|
process.stderr.write(HELP);
|
|
360
338
|
throw new Error('[deliver-light] --backstop requires --story <id>');
|
|
361
339
|
}
|
|
362
|
-
const result =
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
Logger.info(`[deliver-light] diff backstop clean for Story #${storyId}.`);
|
|
371
|
-
return 0;
|
|
340
|
+
const { result, nextCommand, exitCode, message } = await resolveFn({
|
|
341
|
+
storyId,
|
|
342
|
+
});
|
|
343
|
+
const extra = nextCommand === null ? {} : { nextCommand };
|
|
344
|
+
emit({ mode: 'backstop', storyId, ...result, ...extra }, values.pretty);
|
|
345
|
+
if (result.blocked) Logger.warn(message);
|
|
346
|
+
else Logger.info(message);
|
|
347
|
+
return exitCode;
|
|
372
348
|
}
|
|
373
349
|
|
|
374
350
|
/**
|
|
@@ -407,6 +383,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
407
383
|
createReceiptFn = createLightReceipt,
|
|
408
384
|
emitFn = emit,
|
|
409
385
|
emitTerminalFn = emitTerminalEnvelope,
|
|
386
|
+
recordRefusalFn = recordGateRefusal,
|
|
410
387
|
} = deps;
|
|
411
388
|
|
|
412
389
|
if (!values.prompt || String(values.prompt).trim() === '') {
|
|
@@ -457,6 +434,7 @@ export async function runGateMode(values, deps = {}) {
|
|
|
457
434
|
{ mode: 'gate', action: gate.action, outcome: gate.outcome },
|
|
458
435
|
values.pretty,
|
|
459
436
|
);
|
|
437
|
+
await recordRefusalFn({ gate, amends: values.amends });
|
|
460
438
|
Logger.warn(
|
|
461
439
|
`[deliver-light] gate did not proceed light (${gate.action}): ${gate.outcome.reasons.join('; ')}`,
|
|
462
440
|
);
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import crypto from 'node:crypto';
|
|
34
|
+
import { constants as osConstants } from 'node:os';
|
|
34
35
|
import { getLimits, resolveConfig } from './lib/config-resolver.js';
|
|
35
36
|
import { Logger } from './lib/Logger.js';
|
|
36
37
|
import { appendSignal } from './lib/observability/signals-writer.js';
|
|
@@ -97,6 +98,72 @@ function classifyFrictionCategory(errorOutput) {
|
|
|
97
98
|
return { category: matched.category, remediation: matched.remediation };
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* `spawnSync`'s own `timeout` option kills the child with `SIGTERM`, so a
|
|
103
|
+
* SIGTERM observed here almost always means the interceptor's configured
|
|
104
|
+
* `executionTimeoutMs` bound fired. Any other signal — a `SIGKILL` from the
|
|
105
|
+
* OOM killer, an operator `kill -9` — originated outside this process.
|
|
106
|
+
*
|
|
107
|
+
* @type {string}
|
|
108
|
+
*/
|
|
109
|
+
const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
|
|
110
|
+
|
|
111
|
+
/** Shell convention for "the process died by signal N": exit `128 + N`. */
|
|
112
|
+
const SIGNAL_EXIT_BASE = 128;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Describe a child that never exited normally — `status === null`, Node's
|
|
116
|
+
* documented representation of "did not exit normally". The raw status must
|
|
117
|
+
* never reach `process.exit`, because `process.exit(null)` exits **0**: the
|
|
118
|
+
* interceptor would report success for a command it just watched get killed
|
|
119
|
+
* (Story #4851).
|
|
120
|
+
*
|
|
121
|
+
* Which signal fired is the diagnostic value: SIGTERM points at the
|
|
122
|
+
* interceptor's own bound, anything else at the host. Recording that plus the
|
|
123
|
+
* bound itself is what makes the row actionable to a consumer who cannot edit
|
|
124
|
+
* the materialized framework tree.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately module-local and pure — exporting it for tests would fail the
|
|
127
|
+
* `--production` dead-exports ratchet, and folding it into `main` would spend
|
|
128
|
+
* the file's per-file maintainability-delta headroom. The CLI contract is the
|
|
129
|
+
* seam the unit tests drive.
|
|
130
|
+
*
|
|
131
|
+
* @param {{signal: (string|null), error?: {message?: string}}} result A
|
|
132
|
+
* `spawnSync` result whose `status` is `null`.
|
|
133
|
+
* @param {number} executionTimeoutMs The resolved interceptor bound, in ms.
|
|
134
|
+
* @returns {{category: string, remediation: string, details: object,
|
|
135
|
+
* preview: string, exitCode: number}}
|
|
136
|
+
*/
|
|
137
|
+
function describeAbnormalExit(result, executionTimeoutMs) {
|
|
138
|
+
const signal = typeof result.signal === 'string' ? result.signal : null;
|
|
139
|
+
if (signal === null) {
|
|
140
|
+
return {
|
|
141
|
+
category: FRICTION_DEFAULT.category,
|
|
142
|
+
remediation: FRICTION_DEFAULT.remediation,
|
|
143
|
+
details: {
|
|
144
|
+
killedBySignal: null,
|
|
145
|
+
killOrigin: 'spawn-failure',
|
|
146
|
+
executionTimeoutMs,
|
|
147
|
+
},
|
|
148
|
+
preview: `Command did not exit normally and reported no signal: ${result.error?.message ?? 'spawn produced no exit status'}.`,
|
|
149
|
+
exitCode: 1,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const timedOut = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
|
|
154
|
+
const killOrigin = timedOut ? 'interceptor-timeout' : 'external';
|
|
155
|
+
const signum = osConstants.signals[signal];
|
|
156
|
+
return {
|
|
157
|
+
category: timedOut ? 'Execution Timeout' : 'Execution Killed',
|
|
158
|
+
remediation: timedOut
|
|
159
|
+
? ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`
|
|
160
|
+
: ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
|
|
161
|
+
details: { killedBySignal: signal, killOrigin, executionTimeoutMs },
|
|
162
|
+
preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
|
|
163
|
+
exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
100
167
|
function toIntOrNull(value) {
|
|
101
168
|
if (value == null) return null;
|
|
102
169
|
const n = Number.parseInt(String(value), 10);
|
|
@@ -121,6 +188,7 @@ function buildFrictionSignal({
|
|
|
121
188
|
category,
|
|
122
189
|
commandStr,
|
|
123
190
|
errorPreview,
|
|
191
|
+
terminationDetails = null,
|
|
124
192
|
}) {
|
|
125
193
|
return {
|
|
126
194
|
kind: 'friction',
|
|
@@ -133,11 +201,14 @@ function buildFrictionSignal({
|
|
|
133
201
|
// and always null.
|
|
134
202
|
taskId: null,
|
|
135
203
|
category,
|
|
204
|
+
// `emitter.command` is what `classifySignalSource` step 1 scans, so the
|
|
205
|
+
// command-scan stays authoritative for `source`: a consumer command killed
|
|
206
|
+
// by its own host remains consumer-actionable (Story #4851).
|
|
136
207
|
emitter: {
|
|
137
208
|
tool: 'diagnose-friction.js',
|
|
138
209
|
command: commandStr,
|
|
139
210
|
},
|
|
140
|
-
details: { errorPreview },
|
|
211
|
+
details: { errorPreview, ...(terminationDetails ?? {}) },
|
|
141
212
|
};
|
|
142
213
|
}
|
|
143
214
|
|
|
@@ -176,10 +247,22 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
176
247
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
177
248
|
|
|
178
249
|
if (result.status !== 0) {
|
|
250
|
+
// A `null` status means the child never exited normally; the cause lives in
|
|
251
|
+
// `result.signal`, not in the status.
|
|
252
|
+
const abnormal =
|
|
253
|
+
result.status === null
|
|
254
|
+
? describeAbnormalExit(result, executionTimeoutMs)
|
|
255
|
+
: null;
|
|
256
|
+
// With both streams empty an abnormal termination names its signal; the
|
|
257
|
+
// `Unknown exit code` fallback is therefore reachable only with a real
|
|
258
|
+
// numeric status, never as `Unknown exit code null`.
|
|
259
|
+
const noOutputFallback = abnormal
|
|
260
|
+
? abnormal.preview
|
|
261
|
+
: `Unknown exit code ${result.status}`;
|
|
179
262
|
const errorOutput = (
|
|
180
263
|
result.stderr ||
|
|
181
264
|
result.stdout ||
|
|
182
|
-
|
|
265
|
+
noOutputFallback
|
|
183
266
|
).trim();
|
|
184
267
|
const errorPreview = errorOutput.substring(0, 500);
|
|
185
268
|
|
|
@@ -188,7 +271,12 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
188
271
|
'Command failed. Appending friction signal to NDJSON stream...',
|
|
189
272
|
);
|
|
190
273
|
|
|
191
|
-
|
|
274
|
+
// An abnormal termination classifies itself: the marker scan reads output
|
|
275
|
+
// the kill may have truncated (or never produced), so it cannot name the
|
|
276
|
+
// signal.
|
|
277
|
+
const classified = classifyFrictionCategory(errorOutput);
|
|
278
|
+
const category = abnormal?.category ?? classified.category;
|
|
279
|
+
const remediation = abnormal?.remediation ?? classified.remediation;
|
|
192
280
|
|
|
193
281
|
const { storyId: resolvedStoryId, epicId: resolvedEpicId } =
|
|
194
282
|
resolveContextIds({ storyId, epicId }, config);
|
|
@@ -199,6 +287,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
199
287
|
category,
|
|
200
288
|
commandStr,
|
|
201
289
|
errorPreview,
|
|
290
|
+
terminationDetails: abnormal?.details ?? null,
|
|
202
291
|
});
|
|
203
292
|
|
|
204
293
|
// Story #2874 — accept story-only context (no parent Epic). When
|
|
@@ -236,7 +325,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
236
325
|
Logger.error(remediation);
|
|
237
326
|
Logger.error('----------------------------------------\n');
|
|
238
327
|
|
|
239
|
-
process.exit(result.status)
|
|
328
|
+
// Never `process.exit(result.status)` on a null status — that exits 0 and
|
|
329
|
+
// reports success for a killed command.
|
|
330
|
+
process.exit(abnormal?.exitCode ?? result.status);
|
|
240
331
|
} else {
|
|
241
332
|
process.exit(0);
|
|
242
333
|
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { gitSpawn } from '../git-utils.js';
|
|
31
|
+
import { readNumstatRows } from '../orchestration/diff-magnitude.js';
|
|
31
32
|
import { selectSensitivePathClasses } from './selector.js';
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -58,6 +59,12 @@ export function resolveLensDiffFloor(config) {
|
|
|
58
59
|
* Count the changed lines (additions + deletions) in the
|
|
59
60
|
* `baseRef...headRef` diff via `git diff --numstat`.
|
|
60
61
|
*
|
|
62
|
+
* The read and the parse are shared with the light path's magnitude backstop
|
|
63
|
+
* ({@link module:lib/orchestration/diff-magnitude.readNumstatRows}) so the two
|
|
64
|
+
* cannot disagree about how a diff is measured. This one keeps a whole-diff
|
|
65
|
+
* total: the lens floor asks "is this diff small", not "is its implementation
|
|
66
|
+
* half small", so it deliberately does **not** apply the companion exemption.
|
|
67
|
+
*
|
|
61
68
|
* Total — never throws. Returns `null` (the neutral "count unknown" signal
|
|
62
69
|
* the floor fails open on) for any git failure or unparseable output, and
|
|
63
70
|
* `0` for a genuinely empty diff. Binary rows (`-\t-\tpath`) contribute 0
|
|
@@ -77,31 +84,9 @@ export function countChangedLines({
|
|
|
77
84
|
cwd = process.cwd(),
|
|
78
85
|
gitSpawnFn = gitSpawn,
|
|
79
86
|
} = {}) {
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
const result = gitSpawnFn(
|
|
84
|
-
cwd,
|
|
85
|
-
'diff',
|
|
86
|
-
'--numstat',
|
|
87
|
-
`${baseRef}...${headRef}`,
|
|
88
|
-
);
|
|
89
|
-
if (!result || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
let total = 0;
|
|
93
|
-
for (const line of result.stdout.split('\n')) {
|
|
94
|
-
const trimmedEnd = line.replace(/\s+$/, '');
|
|
95
|
-
if (trimmedEnd.length === 0) continue;
|
|
96
|
-
const match = /^(\d+|-)\t(\d+|-)\t/.exec(trimmedEnd);
|
|
97
|
-
if (!match) return null; // Unexpected format — the count is not trustworthy.
|
|
98
|
-
if (match[1] !== '-') total += Number(match[1]);
|
|
99
|
-
if (match[2] !== '-') total += Number(match[2]);
|
|
100
|
-
}
|
|
101
|
-
return total;
|
|
102
|
-
} catch {
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
87
|
+
const rows = readNumstatRows({ baseRef, headRef, cwd, gitSpawnFn });
|
|
88
|
+
if (rows === null) return null;
|
|
89
|
+
return rows.reduce((total, row) => total + row.additions + row.deletions, 0);
|
|
105
90
|
}
|
|
106
91
|
|
|
107
92
|
/**
|
|
@@ -30,10 +30,25 @@ export const name = 'maintainability';
|
|
|
30
30
|
export const keyField = 'path';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Files the maintainability scorer cannot measure because
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Files the maintainability scorer cannot measure because the upstream kernel
|
|
34
|
+
* throws on them. Each entry must carry a one-line reason so a future engine
|
|
35
|
+
* bump can audit the list and drop the exclusion.
|
|
36
|
+
*
|
|
37
|
+
* **This list is empty, and the goal is to keep it that way.** It previously
|
|
38
|
+
* held seven paths, all attributed to escomplex "choking on modern
|
|
39
|
+
* destructuring / regex-property patterns". That diagnosis was wrong. The real
|
|
40
|
+
* defect is that `typhonjs-escomplex`'s code generator is written against
|
|
41
|
+
* ESTree while its own parser emits Babel, so a regex literal in a loop head,
|
|
42
|
+
* an `await` in a loop head, a `?.`, a dynamic `import()`, or an object spread
|
|
43
|
+
* in a default parameter aborted the whole file's analysis. That is now
|
|
44
|
+
* repaired at the kernel boundary by `lib/escomplex-ast-compat.js`, which makes
|
|
45
|
+
* all four surviving entries scorable — and the audit incidentally showed the
|
|
46
|
+
* other three had been pointing at deleted files, because nothing audits an
|
|
47
|
+
* allowlist whose only effect is to suppress output.
|
|
48
|
+
*
|
|
49
|
+
* Before adding a path here, check whether `escomplex-ast-compat.js` can handle
|
|
50
|
+
* the construct instead. An entry here is a file nobody measures; a handler
|
|
51
|
+
* there gets it measured for every consumer.
|
|
37
52
|
*
|
|
38
53
|
* Story #2467 / Task #2494 — the prior behaviour stored these files with
|
|
39
54
|
* `mi: 0` in `baselines/maintainability.json`, which corrupted the global
|
|
@@ -44,34 +59,7 @@ export const keyField = 'path';
|
|
|
44
59
|
*
|
|
45
60
|
* Canonicalised POSIX repo-relative paths.
|
|
46
61
|
*/
|
|
47
|
-
export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(
|
|
48
|
-
new Set([
|
|
49
|
-
// escomplex: "Cannot read properties of undefined (reading 'pattern')" —
|
|
50
|
-
// chokes on modern destructuring / regex-property patterns used in the
|
|
51
|
-
// reconciler's spec walker.
|
|
52
|
-
'.agents/scripts/acceptance-spec-reconciler.js',
|
|
53
|
-
// escomplex: same "pattern" parse failure as acceptance-spec-reconciler;
|
|
54
|
-
// both files share the lifecycle-lint regex-driven scan helpers.
|
|
55
|
-
'.agents/scripts/check-lifecycle-lint.js',
|
|
56
|
-
// escomplex: "this[node.callee.type] is not a function" — the cyclomatic
|
|
57
|
-
// visitor lacks a handler for one of the quality-watch CLI's call shapes.
|
|
58
|
-
'.agents/scripts/quality-watch.js',
|
|
59
|
-
// escomplex: "Cannot read properties of undefined (reading 'pattern')" —
|
|
60
|
-
// the audit-to-stories parser reuses the same regex-property scan
|
|
61
|
-
// patterns as acceptance-spec-reconciler.
|
|
62
|
-
'.agents/scripts/lib/audit-to-stories/parse-audit-md.js',
|
|
63
|
-
// escomplex: same "pattern" parse failure family — the BDD scanner walks
|
|
64
|
-
// source trees with regex visitors that hit the upstream destructuring
|
|
65
|
-
// bug.
|
|
66
|
-
'.agents/scripts/lib/bdd-scenario-scanner.js',
|
|
67
|
-
// escomplex: same "pattern" parse failure — the wave-runner tick uses
|
|
68
|
-
// the regex-property destructuring escomplex chokes on.
|
|
69
|
-
'.agents/scripts/lib/wave-runner/tick.js',
|
|
70
|
-
// escomplex: same "pattern" parse failure — exercises the same
|
|
71
|
-
// destructuring shape in a test fixture.
|
|
72
|
-
'tests/scripts/story-close-merge-subject.test.js',
|
|
73
|
-
]),
|
|
74
|
-
);
|
|
62
|
+
export const MAINTAINABILITY_EXCLUSIONS = Object.freeze(new Set());
|
|
75
63
|
|
|
76
64
|
/**
|
|
77
65
|
* Filter parse-unscorable files out of a rows array. Used by the scorer
|
|
@@ -322,12 +322,20 @@ const REVIEW_SCHEMA = {
|
|
|
322
322
|
* issues via the graduator pre-parsed-findings seam, and the rendered retro
|
|
323
323
|
* sections list the filed issue numbers instead of paste-ready `gh` command
|
|
324
324
|
* stanzas; set it to `false` to fall back to the command stanzas.
|
|
325
|
+
*
|
|
326
|
+
* `frictionWindowDays` (Story #4850) bounds the run-scope friction recurrence
|
|
327
|
+
* window by row age. The window deliberately spans every surviving signal
|
|
328
|
+
* stream rather than the triggering run's own Stories — that is what lets a
|
|
329
|
+
* once-per-Story systemic defect reach the ≥ 2 actionable threshold — which
|
|
330
|
+
* left it unbounded in time, so a defect fixed weeks ago kept re-routing. An
|
|
331
|
+
* integer ≥ 1; unset means 30 days.
|
|
325
332
|
*/
|
|
326
333
|
const FEEDBACK_LOOP_SCHEMA = {
|
|
327
334
|
type: 'object',
|
|
328
335
|
properties: {
|
|
329
336
|
auditResultsAutoFile: { type: 'boolean' },
|
|
330
337
|
retroProposals: { type: 'boolean' },
|
|
338
|
+
frictionWindowDays: { type: 'integer', minimum: 1 },
|
|
331
339
|
},
|
|
332
340
|
additionalProperties: false,
|
|
333
341
|
};
|