mandrel 2.27.0 → 2.29.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.
Files changed (26) hide show
  1. package/.agents/docs/configuration.md +3 -0
  2. package/.agents/docs/execution-reference.md +22 -12
  3. package/.agents/schemas/agentrc.schema.json +14 -0
  4. package/.agents/scripts/coverage-capture.js +31 -62
  5. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +41 -0
  6. package/.agents/scripts/lib/baselines/crap-preview-scan.js +135 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +13 -0
  8. package/.agents/scripts/lib/baselines/preview-gates.js +7 -87
  9. package/.agents/scripts/lib/bdd-scenario-budget.js +68 -0
  10. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +26 -0
  11. package/.agents/scripts/lib/config/gates/crap.schema.js +2 -0
  12. package/.agents/scripts/lib/config/quality.js +41 -0
  13. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  14. package/.agents/scripts/lib/coverage-capture-fullscope.js +105 -0
  15. package/.agents/scripts/lib/coverage-capture-incremental.js +116 -0
  16. package/.agents/scripts/lib/coverage-capture.js +101 -17
  17. package/.agents/scripts/lib/crap-baseline-index.js +46 -0
  18. package/.agents/scripts/lib/crap-baseline-join.js +153 -0
  19. package/.agents/scripts/lib/crap-coordinates.js +43 -0
  20. package/.agents/scripts/lib/crap-engine.js +26 -51
  21. package/.agents/scripts/lib/crap-utils-incremental.js +113 -0
  22. package/.agents/scripts/lib/crap-utils.js +40 -8
  23. package/.agents/scripts/lib/orchestration/plan-context.js +49 -13
  24. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -5
  25. package/docs/CHANGELOG.md +14 -0
  26. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * crap-baseline-join.js — the incremental-coverage CRAP join (Story #4981).
3
+ *
4
+ * Split out of `crap-engine.js` into its own file (rather than added inline)
5
+ * so the Story's join logic lands as new code, not a same-file expansion of
6
+ * the pre-existing scoring kernel. Imports its coordinate/formula
7
+ * primitives from `crap-coordinates.js` (not `crap-engine.js`) and its
8
+ * identity key from `crap-baseline-index.js` — both leaf modules — so this
9
+ * file stays a one-directional consumer with no edge back into
10
+ * `crap-engine.js`.
11
+ */
12
+ import { methodIdentityKey } from './crap-baseline-index.js';
13
+ import {
14
+ COORDINATE_ORIGINAL,
15
+ COORDINATE_TRANSPILED,
16
+ crapFormula,
17
+ } from './crap-coordinates.js';
18
+
19
+ /**
20
+ * Apply the standard `requireCoverage` policy to a single raw method row.
21
+ * The per-row half of `crap-engine.js#finalizeMethodRows`'s loop body,
22
+ * extracted (Story #4981) so `finalizeMethodRowsWithBaseline`, below, can
23
+ * apply the exact same per-row policy to a method whose file was NOT in the
24
+ * diff scope but whose baseline row could not be found — the fail-closed
25
+ * path AC-3 requires.
26
+ *
27
+ * @param {object} mr A raw row from `methodRowsFromReport`.
28
+ * @param {{requireCoverage: boolean, coverageAvailable: boolean}} opts
29
+ * @returns {{ resolved: boolean, row: object | null }} `row: null` means the
30
+ * method is skipped-and-counted; `resolved` tracks the join outcome
31
+ * (independent of whether the row survives the skip policy).
32
+ */
33
+ export function resolveRawRow(mr, { requireCoverage, coverageAvailable }) {
34
+ const unresolved = mr.crap === null || mr.coverage === null;
35
+ const resolved = !unresolved;
36
+ // Unjoinable is not untested (Story #4901).
37
+ if (
38
+ mr.coordinateSystem === COORDINATE_TRANSPILED ||
39
+ (unresolved && (requireCoverage || !coverageAvailable))
40
+ ) {
41
+ return { resolved, row: null };
42
+ }
43
+ const coverage = unresolved ? 0 : mr.coverage;
44
+ const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
45
+ // Everything the scan decided is carried forward; this step overrides only
46
+ // what its own policy resolves. Spreading rather than re-listing each field
47
+ // is why the row's identity marker (Story #4969) and its provenance
48
+ // (Story #4866) survive the step without a line each to remember them —
49
+ // a hand-rebuilt row is how a marker silently stops reaching the baseline.
50
+ return {
51
+ resolved,
52
+ row: {
53
+ ...mr,
54
+ coverage,
55
+ crap,
56
+ coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Incremental-mode join (Story #4981): resolve a file's raw method rows
63
+ * against its committed CRAP-baseline rows instead of requiring fresh
64
+ * coverage, for a file the diff did NOT touch.
65
+ *
66
+ * Rationale: `coverage-capture`'s incremental mode only runs the consumer's
67
+ * test suite scoped to the diff, so an untouched file's coverage entry may
68
+ * legitimately be absent even though nothing about that file's methods
69
+ * changed. Requiring a fresh join for it would either (a) skip-and-count
70
+ * every one of its methods under `requireCoverage: true`, weakening the
71
+ * gate's signal for the vast majority of the tree on every run, or (b) score
72
+ * them at an invented 0% under `requireCoverage: false`, manufacturing a
73
+ * maximal CRAP for code the diff never touched. Neither is a measurement.
74
+ *
75
+ * The join key is `${method}@${startLine}` — the per-file half of the
76
+ * composite identity `kinds/crap.js#crapRowKey` uses for the full baseline
77
+ * compare (`${path}::${method}@${startLine}`); callers pass in a
78
+ * per-file-scoped `baselineByKey` map so this function stays path-agnostic.
79
+ *
80
+ * **Fail-closed (AC-3).** `touched: true` (the file WAS in the diff) or a
81
+ * missing/empty `baselineByKey` reproduces `crap-engine.js#finalizeMethodRows`
82
+ * exactly — this is the pre-#4981 per-row policy, so a caller that never
83
+ * opts in sees byte-identical behaviour (AC-5). For an untouched file, a
84
+ * method whose baseline row cannot be found (new method, moved line, or a
85
+ * baseline that simply never carried it) is NOT invented — it falls back to
86
+ * the same per-row `requireCoverage` skip-and-count policy, via the shared
87
+ * `resolveRawRow`. A method whose coordinate system is transpiled is never
88
+ * resolved from the baseline either, for the same un-joinable reason
89
+ * `resolveRawRow` excludes it (Story #4901).
90
+ *
91
+ * @param {Array<object>} rawRows Rows from `methodRowsFromReport`, all for
92
+ * the SAME file.
93
+ * @param {{
94
+ * requireCoverage?: boolean,
95
+ * coverageAvailable?: boolean,
96
+ * touched?: boolean,
97
+ * baselineByKey?: Map<string, {crap: number}> | null,
98
+ * }} [opts]
99
+ * @returns {{
100
+ * rows: Array<object>,
101
+ * skippedMethodsNoCoverage: number,
102
+ * resolvedMethods: number,
103
+ * totalMethods: number,
104
+ * }}
105
+ */
106
+ export function finalizeMethodRowsWithBaseline(
107
+ rawRows,
108
+ {
109
+ requireCoverage = true,
110
+ coverageAvailable = true,
111
+ touched = true,
112
+ baselineByKey = null,
113
+ } = {},
114
+ ) {
115
+ const useBaseline = !touched && baselineByKey && baselineByKey.size > 0;
116
+ const rows = [];
117
+ let skippedMethodsNoCoverage = 0;
118
+ let resolvedMethods = 0;
119
+ let totalMethods = 0;
120
+ for (const mr of rawRows ?? []) {
121
+ totalMethods += 1;
122
+ if (useBaseline) {
123
+ const base =
124
+ mr.coordinateSystem === COORDINATE_TRANSPILED
125
+ ? undefined
126
+ : baselineByKey.get(methodIdentityKey(mr));
127
+ if (base && typeof base.crap === 'number') {
128
+ resolvedMethods += 1;
129
+ rows.push({
130
+ ...mr,
131
+ coverage: null,
132
+ crap: base.crap,
133
+ resolvedFromBaseline: true,
134
+ coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
135
+ });
136
+ continue;
137
+ }
138
+ }
139
+ // Touched file, no baseline scope, or no baseline row for this method —
140
+ // fail closed to the standard policy rather than inventing a verdict.
141
+ const resolution = resolveRawRow(mr, {
142
+ requireCoverage,
143
+ coverageAvailable,
144
+ });
145
+ if (resolution.resolved) resolvedMethods += 1;
146
+ if (resolution.row === null) {
147
+ skippedMethodsNoCoverage += 1;
148
+ continue;
149
+ }
150
+ rows.push(resolution.row);
151
+ }
152
+ return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
153
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * crap-coordinates.js — the CRAP kernel's two line-coordinate-system
3
+ * constants and the CRAP formula itself.
4
+ *
5
+ * Deliberately dependency-free (Story #4981 split, pulled out of
6
+ * `crap-engine.js`): both `crap-engine.js` and `crap-baseline-join.js`
7
+ * need these, and having either import them from the other would close a
8
+ * cycle. `crap-engine.js` re-exports the two constants so its existing
9
+ * importers (`baselines/kinds/crap.js`, `crap-utils.js`, tests) are
10
+ * unaffected.
11
+ */
12
+
13
+ /**
14
+ * The two line coordinate systems a CRAP row's `startLine` can be expressed
15
+ * in (Story #4866).
16
+ *
17
+ * `original` — the coordinates of the file a reader can open, and the ones
18
+ * istanbul's `fnMap` is keyed against. A JavaScript source is already in this
19
+ * system; a TS/TSX source reaches it only through a successful sourcemap
20
+ * lookup.
21
+ *
22
+ * `transpiled` — escomplex's own coordinates over the emitted JavaScript,
23
+ * kept only when the sourcemap has no entry originating on the method's
24
+ * generated line. Such a row is NOT an original-source coordinate and must
25
+ * never be presented as one: it cannot be joined to coverage, and it cannot
26
+ * be compared against a baseline row carrying the other provenance.
27
+ */
28
+ export const COORDINATE_ORIGINAL = 'original';
29
+ export const COORDINATE_TRANSPILED = 'transpiled';
30
+
31
+ /**
32
+ * CRAP formula, exported for callers that need to derive target scores or
33
+ * `fixGuidance` values without re-scoring source.
34
+ *
35
+ * @param {number} cyclomatic
36
+ * @param {number} coverage In [0, 1].
37
+ * @returns {number}
38
+ */
39
+ export function crapFormula(cyclomatic, coverage) {
40
+ const c = Number(cyclomatic) || 0;
41
+ const cov = Math.max(0, Math.min(1, Number(coverage) || 0));
42
+ return c * c * (1 - cov) ** 3 + c;
43
+ }
@@ -1,24 +1,21 @@
1
1
  import escomplex from 'typhonjs-escomplex';
2
2
  import { coverageForMethodInEntry } from './coverage-utils.js';
3
+ // `finalizeMethodRowsWithBaseline` (Story #4981) lives in
4
+ // crap-baseline-join.js — `resolveRawRow`, the per-row policy it shares with
5
+ // `finalizeMethodRows` below, is imported from there so the two stay a
6
+ // single implementation.
7
+ import { resolveRawRow } from './crap-baseline-join.js';
8
+ // `COORDINATE_ORIGINAL` / `COORDINATE_TRANSPILED` / `crapFormula` (Story
9
+ // #4866 / this module's original home) now live in crap-coordinates.js —
10
+ // re-exported here so every existing importer of this module is unaffected.
11
+ import {
12
+ COORDINATE_ORIGINAL,
13
+ COORDINATE_TRANSPILED,
14
+ crapFormula,
15
+ } from './crap-coordinates.js';
3
16
  import { deriveMethodIdentities } from './crap-method-identity.js';
4
17
 
5
- /**
6
- * The two line coordinate systems a CRAP row's `startLine` can be expressed
7
- * in (Story #4866).
8
- *
9
- * `original` — the coordinates of the file a reader can open, and the ones
10
- * istanbul's `fnMap` is keyed against. A JavaScript source is already in this
11
- * system; a TS/TSX source reaches it only through a successful sourcemap
12
- * lookup.
13
- *
14
- * `transpiled` — escomplex's own coordinates over the emitted JavaScript,
15
- * kept only when the sourcemap has no entry originating on the method's
16
- * generated line. Such a row is NOT an original-source coordinate and must
17
- * never be presented as one: it cannot be joined to coverage, and it cannot
18
- * be compared against a baseline row carrying the other provenance.
19
- */
20
- export const COORDINATE_ORIGINAL = 'original';
21
- export const COORDINATE_TRANSPILED = 'transpiled';
18
+ export { COORDINATE_ORIGINAL, COORDINATE_TRANSPILED, crapFormula };
22
19
 
23
20
  /**
24
21
  * Derive the raw per-method CRAP rows from an escomplex report.
@@ -170,33 +167,25 @@ export function finalizeMethodRows(
170
167
  let totalMethods = 0;
171
168
  for (const mr of rawRows ?? []) {
172
169
  totalMethods += 1;
173
- const unresolved = mr.crap === null || mr.coverage === null;
174
- if (!unresolved) resolvedMethods += 1;
175
- // Unjoinable is not untested (Story #4901) — see the block comment above.
176
- if (
177
- mr.coordinateSystem === COORDINATE_TRANSPILED ||
178
- (unresolved && (requireCoverage || !coverageAvailable))
179
- ) {
170
+ const resolution = resolveRawRow(mr, {
171
+ requireCoverage,
172
+ coverageAvailable,
173
+ });
174
+ if (resolution.resolved) resolvedMethods += 1;
175
+ if (resolution.row === null) {
180
176
  skippedMethodsNoCoverage += 1;
181
177
  continue;
182
178
  }
183
- const coverage = unresolved ? 0 : mr.coverage;
184
- const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
185
- // Everything the scan decided is carried forward; this step overrides only
186
- // what its own policy resolves. Spreading rather than re-listing each field
187
- // is why the row's identity marker (Story #4969) and its provenance
188
- // (Story #4866) survive the step without a line each to remember them —
189
- // a hand-rebuilt row is how a marker silently stops reaching the baseline.
190
- rows.push({
191
- ...mr,
192
- coverage,
193
- crap,
194
- coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
195
- });
179
+ rows.push(resolution.row);
196
180
  }
197
181
  return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
198
182
  }
199
183
 
184
+ // The incremental-mode join (Story #4981) lives in crap-baseline-join.js;
185
+ // re-exported here so it stays reachable from the scoring kernel's existing
186
+ // public surface.
187
+ export { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
188
+
200
189
  /**
201
190
  * Score each method in a JavaScript source for Change Risk Anti-Patterns
202
191
  * (CRAP): `c² · (1 − cov)³ + c`, where `c` is cyclomatic complexity and `cov`
@@ -243,20 +232,6 @@ export function calculateCrapForSource(
243
232
  return methodRowsFromReport(report, coverageForFile, mapLine);
244
233
  }
245
234
 
246
- /**
247
- * CRAP formula, exported for callers that need to derive target scores or
248
- * `fixGuidance` values without re-scoring source.
249
- *
250
- * @param {number} cyclomatic
251
- * @param {number} coverage In [0, 1].
252
- * @returns {number}
253
- */
254
- export function crapFormula(cyclomatic, coverage) {
255
- const c = Number(cyclomatic) || 0;
256
- const cov = Math.max(0, Math.min(1, Number(coverage) || 0));
257
- return c * c * (1 - cov) ** 3 + c;
258
- }
259
-
260
235
  /**
261
236
  * Derive the deterministic single-axis fixes that would bring a method at
262
237
  * cyclomatic complexity `c` at or under the `target` CRAP score.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * crap-utils-incremental.js — small pure helpers that wire the incremental
3
+ * CRAP join (Story #4981) into `crap-utils.js#scanAndScore`'s per-file work
4
+ * queue. Split into their own file so the wiring lands as new code rather
5
+ * than a same-file expansion of `scanAndScore` / `scoreFileSerial`.
6
+ */
7
+ import { indexBaselineRowsByFile } from './crap-baseline-index.js';
8
+
9
+ /**
10
+ * Resolve `scanAndScore`'s `incremental` option into the two lookup
11
+ * structures the per-file queue build needs. Both are `null` when
12
+ * `incremental` is absent (full-scope, the default) — every downstream
13
+ * consumer treats a `null` context as "not incremental".
14
+ *
15
+ * @param {{ touchedFiles?: Set<string>|string[], baselineRows?: Array<object> } | null} incremental
16
+ * @returns {{ touchedFiles: Set<string>|null, baselineByFile: Map<string, Map<string, object>>|null }}
17
+ */
18
+ export function resolveIncrementalContext(incremental) {
19
+ const touchedFiles = incremental?.touchedFiles
20
+ ? incremental.touchedFiles instanceof Set
21
+ ? incremental.touchedFiles
22
+ : new Set(incremental.touchedFiles)
23
+ : null;
24
+ const baselineByFile = incremental
25
+ ? indexBaselineRowsByFile(incremental.baselineRows)
26
+ : null;
27
+ return { touchedFiles, baselineByFile };
28
+ }
29
+
30
+ /**
31
+ * Merge one queued file's `touched` flag and per-file `baselineByKey` map
32
+ * (resolved from the `resolveIncrementalContext` output) onto its base queue
33
+ * item. `touched` defaults to `true` (every file is "touched" outside
34
+ * incremental mode, matching
35
+ * `crap-baseline-join.js#finalizeMethodRowsWithBaseline`'s own default).
36
+ *
37
+ * @param {object} item Base queue item (`{ abs, relPath, requireCoverage, coverageAvailable }`).
38
+ * @param {{ touchedFiles: Set<string>|null, baselineByFile: Map<string, Map<string, object>>|null }} ctx
39
+ * @returns {object} `item` plus `{ touched, baselineByKey }`.
40
+ */
41
+ export function resolveQueueIncrementalFields(
42
+ item,
43
+ { touchedFiles, baselineByFile },
44
+ ) {
45
+ const touched = touchedFiles ? touchedFiles.has(item.relPath) : true;
46
+ const baselineByKey = baselineByFile
47
+ ? (baselineByFile.get(item.relPath) ?? new Map())
48
+ : null;
49
+ return { ...item, touched, baselineByKey };
50
+ }
51
+
52
+ /**
53
+ * True when `scoreFileSerial` should resolve a file's methods from the
54
+ * baseline rather than requiring a fresh coverage entry — an untouched file
55
+ * with at least one indexed baseline row.
56
+ *
57
+ * @param {boolean} touched
58
+ * @param {Map<string, object>|null} baselineByKey
59
+ * @returns {boolean}
60
+ */
61
+ function isIncrementalJoinActive(touched, baselineByKey) {
62
+ return !touched && baselineByKey != null && baselineByKey.size > 0;
63
+ }
64
+
65
+ /**
66
+ * `scoreFileSerial`'s file-level skip decision, factored out whole so the
67
+ * incremental exception lives with the rest of this Story's branching
68
+ * rather than inflating the cyclomatic complexity of the pre-#4981 caller.
69
+ *
70
+ * @param {boolean} requireCoverage
71
+ * @param {object|null} entry Istanbul coverage entry for this file.
72
+ * @param {boolean} touched
73
+ * @param {Map<string, object>|null} baselineByKey
74
+ * @returns {boolean}
75
+ */
76
+ export function shouldSkipFileForNoCoverage(
77
+ requireCoverage,
78
+ entry,
79
+ touched,
80
+ baselineByKey,
81
+ ) {
82
+ return (
83
+ requireCoverage &&
84
+ entry === null &&
85
+ !isIncrementalJoinActive(touched, baselineByKey)
86
+ );
87
+ }
88
+
89
+ /**
90
+ * `scanAndScore`'s serial-vs-pool routing decision. Incremental mode always
91
+ * routes serial (see `isIncrementalJoinActive`'s doc for why the baseline
92
+ * lookup Maps don't cross the worker boundary); otherwise unchanged from the
93
+ * pre-#4981 queue-length cutover.
94
+ *
95
+ * @param {number} queueLength
96
+ * @param {unknown} incremental
97
+ * @param {number} serialThreshold
98
+ * @returns {boolean}
99
+ */
100
+ export function shouldRunSerial(queueLength, incremental, serialThreshold) {
101
+ return queueLength < serialThreshold || Boolean(incremental);
102
+ }
103
+
104
+ /**
105
+ * Project the `resolvedFromBaseline` marker onto a `scanAndScore` result row
106
+ * — present only when true, so a full-scope scan's rows are unaffected.
107
+ *
108
+ * @param {{ resolvedFromBaseline?: boolean }} mr
109
+ * @returns {{ resolvedFromBaseline: true } | {}}
110
+ */
111
+ export function resolvedFromBaselineFlag(mr) {
112
+ return mr.resolvedFromBaseline === true ? { resolvedFromBaseline: true } : {};
113
+ }
@@ -4,11 +4,19 @@ import escomplex from 'typhonjs-escomplex';
4
4
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
5
  import { findCoverageEntry } from './coverage-utils.js';
6
6
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
7
+ import { finalizeMethodRowsWithBaseline } from './crap-baseline-join.js';
7
8
  import {
8
9
  COORDINATE_ORIGINAL,
9
10
  finalizeMethodRows,
10
11
  methodRowsFromReport,
11
12
  } from './crap-engine.js';
13
+ import {
14
+ resolvedFromBaselineFlag,
15
+ resolveIncrementalContext,
16
+ resolveQueueIncrementalFields,
17
+ shouldRunSerial,
18
+ shouldSkipFileForNoCoverage,
19
+ } from './crap-utils-incremental.js';
12
20
  import { Logger } from './Logger.js';
13
21
  import { scanDirectory } from './maintainability-utils.js';
14
22
  import {
@@ -409,6 +417,10 @@ export function analyzeOnce(source, coverageForFile, mapLine = null) {
409
417
  * `regenerateMainFromTree`) SHOULD pass the MI scan's file list here so the
410
418
  * tree is walked only once per run.
411
419
  *
420
+ * `incremental` (Story #4981) resolves an untouched file's methods from
421
+ * `crap-baseline-join.js#finalizeMethodRowsWithBaseline` instead of
422
+ * requiring fresh coverage; omitted (the default), behaviour is unchanged.
423
+ *
412
424
  * @param {{
413
425
  * targetDirs: string[],
414
426
  * coverage: object|null,
@@ -416,6 +428,7 @@ export function analyzeOnce(source, coverageForFile, mapLine = null) {
416
428
  * cwd?: string,
417
429
  * scopeFiles?: Set<string>|string[]|null,
418
430
  * preScannedFiles?: string[]|null,
431
+ * incremental?: { touchedFiles: Set<string>|string[], baselineRows: Array<object> } | null,
419
432
  * }} params
420
433
  * @returns {{
421
434
  * rows: Array<{
@@ -439,6 +452,7 @@ export async function scanAndScore({
439
452
  scopeFiles = null,
440
453
  ignoreGlobs = [],
441
454
  preScannedFiles = null,
455
+ incremental = null,
442
456
  }) {
443
457
  if (!Array.isArray(targetDirs)) {
444
458
  throw new TypeError('scanAndScore: targetDirs must be an array');
@@ -460,6 +474,8 @@ export async function scanAndScore({
460
474
  }
461
475
  files.sort();
462
476
 
477
+ const incrementalCtx = resolveIncrementalContext(incremental);
478
+
463
479
  // Build the work-queue first so scopeFile filtering happens before
464
480
  // any I/O / IPC. `scannedFiles` is the in-scope count.
465
481
  // Story #2079: route every relPath through path-canon so a scan from
@@ -472,14 +488,18 @@ export async function scanAndScore({
472
488
  const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
473
489
  const relPath = canonicalisePath(rawRel);
474
490
  if (scopeSet && !scopeSet.has(relPath)) continue;
475
- queue.push({ abs, relPath, requireCoverage, coverageAvailable });
491
+ queue.push(
492
+ resolveQueueIncrementalFields(
493
+ { abs, relPath, requireCoverage, coverageAvailable },
494
+ incrementalCtx,
495
+ ),
496
+ );
476
497
  }
477
498
  const scannedFiles = queue.length;
478
499
 
479
- const perFile =
480
- queue.length < SERIAL_THRESHOLD
481
- ? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
482
- : await scoreFilesViaPool(queue, coverage);
500
+ const perFile = shouldRunSerial(queue.length, incremental, SERIAL_THRESHOLD)
501
+ ? queue.map((item) => ({ item, result: scoreFileSerial(item, coverage) }))
502
+ : await scoreFilesViaPool(queue, coverage);
483
503
 
484
504
  const rows = [];
485
505
  let skippedFilesNoCoverage = 0;
@@ -516,6 +536,7 @@ export async function scanAndScore({
516
536
  coverage: mr.coverage,
517
537
  crap: mr.crap,
518
538
  coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
539
+ ...resolvedFromBaselineFlag(mr),
519
540
  });
520
541
  }
521
542
  }
@@ -545,11 +566,20 @@ export async function scanAndScore({
545
566
  * CRAP rows and the MI score are derived from the same escomplex report.
546
567
  */
547
568
  function scoreFileSerial(
548
- { abs, relPath, requireCoverage, coverageAvailable = true },
569
+ {
570
+ abs,
571
+ relPath,
572
+ requireCoverage,
573
+ coverageAvailable = true,
574
+ touched = true,
575
+ baselineByKey = null,
576
+ },
549
577
  coverage,
550
578
  ) {
551
579
  const entry = findCoverageEntry(coverage, relPath);
552
- if (requireCoverage && entry === null) {
580
+ if (
581
+ shouldSkipFileForNoCoverage(requireCoverage, entry, touched, baselineByKey)
582
+ ) {
553
583
  return {
554
584
  skippedFileNoCoverage: true,
555
585
  rows: [],
@@ -575,9 +605,11 @@ function scoreFileSerial(
575
605
  prepared.mapLine,
576
606
  );
577
607
  if (parseError) return dropped;
578
- const finalized = finalizeMethodRows(crapRows, {
608
+ const finalized = finalizeMethodRowsWithBaseline(crapRows, {
579
609
  requireCoverage,
580
610
  coverageAvailable,
611
+ touched,
612
+ baselineByKey,
581
613
  });
582
614
  return {
583
615
  skippedFileNoCoverage: false,
@@ -46,15 +46,22 @@ import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
46
46
  * body and ship the raw seed on `seed.content` instead — the budget bounded
47
47
  * a field that never left the function.
48
48
  *
49
- * A measured seed-mode envelope on this repo is ~120 KB, dominated by the
50
- * digest-first `docsContext` (~63 KB inline digest) and the rendered
51
- * `systemPrompts` (~54 KB); every other field is under 1 KB. Story #4811
52
- * retired the tier-capped codebase snapshot that used to sit alongside them
53
- * (~35 KB skinny here). The seed itself is operator-supplied, carried
54
- * verbatim, and is the only unbounded contributor. 256 KB (~64K tokens at the
55
- * ≈4-chars/token estimate) leaves roughly headroom over that measurement
56
- * while staying well under the session budget. The test suite asserts
57
- * serialized envelopes stay under this value raise it only with a measured
49
+ * A measured seed-mode envelope on this repo (a thin `.feature` corpus) is
50
+ * ~120 KB, dominated by the digest-first `docsContext` (~63 KB inline
51
+ * digest) and the rendered `systemPrompts` (~54 KB); every other field is
52
+ * under 1 KB. Story #4811 retired the tier-capped codebase snapshot that
53
+ * used to sit alongside them (~35 KB skinny here). This measurement is
54
+ * **not** representative of every consumer, though: Story #4977 found
55
+ * `bddScenarios` at 118 KB on a consumer with a mature Gherkin corpus —
56
+ * larger than `docsContext` and `systemPrompts` combined, consuming nearly
57
+ * all of the ceiling's headroom on its own, because the scanner applied no
58
+ * cap. `bddScenarios` is now truncated to `BDD_SCENARIOS_BYTE_BUDGET`
59
+ * (`lib/bdd-scenario-budget.js`, ≤24 KB) before it reaches this envelope,
60
+ * so the seed remains the only field this ceiling leaves genuinely
61
+ * unbounded. 256 KB (~64K tokens at the ≈4-chars/token estimate) leaves
62
+ * roughly 2× headroom over the fixed-floor measurement above while staying
63
+ * well under the session budget. The test suite asserts serialized
64
+ * envelopes stay under this value — raise it only with a measured
58
65
  * justification.
59
66
  */
60
67
  export const PLAN_CONTEXT_ENVELOPE_BYTE_CEILING = 256_000;
@@ -62,6 +69,31 @@ export const PLAN_CONTEXT_ENVELOPE_BYTE_CEILING = 256_000;
62
69
  /** Fields named in the over-ceiling error, to point at what to trim. */
63
70
  const OVERSIZE_REPORT_FIELDS = 3;
64
71
 
72
+ /**
73
+ * Per-field remedy for the over-ceiling refusal, keyed by envelope field
74
+ * name. Story #4977 — the refusal used to hardcode "trim the seed, or plan
75
+ * fewer --tickets" regardless of which field actually blew the budget; on a
76
+ * consumer with a mature Gherkin corpus the dominant field was
77
+ * `bddScenarios` (repo-derived, not seed-derived), and "trim the seed" was a
78
+ * dead lever the operator had no way to act on. The remedy now follows the
79
+ * single largest field.
80
+ */
81
+ const OVERSIZE_FIELD_REMEDIES = Object.freeze({
82
+ seed: 'Trim the seed text — it is carried verbatim by design and is the one field with no elision path.',
83
+ sourceTickets:
84
+ 'Plan fewer --tickets source issues in one run — each source ticket body is carried verbatim.',
85
+ epic: 'Plan fewer --tickets source issues in one run, or re-plan with a shorter Epic body.',
86
+ bddScenarios:
87
+ "The project's .feature corpus is already capped near BDD_SCENARIOS_BYTE_BUDGET (lib/bdd-scenario-budget.js) — if this still dominates, another field is unusually small; check the full field breakdown.",
88
+ docsContext:
89
+ 'Trim project.docsContextFiles — docsContext is a digest built from those files.',
90
+ systemPrompts:
91
+ 'This field is a fixed framework prompt, not operator content — if it dominates, file a framework-gap issue rather than trying to trim it.',
92
+ });
93
+
94
+ const DEFAULT_OVERSIZE_REMEDY =
95
+ 'Trim the seed, or plan fewer --tickets source issues in one run.';
96
+
65
97
  /**
66
98
  * Fail closed when an assembled envelope exceeds
67
99
  * {@link PLAN_CONTEXT_ENVELOPE_BYTE_CEILING}.
@@ -97,22 +129,26 @@ function assertPlanContextWithinCeiling(envelope, opts = {}) {
97
129
  const bytes = Buffer.byteLength(JSON.stringify(envelope) ?? '', 'utf-8');
98
130
  if (bytes <= ceiling) return envelope;
99
131
 
100
- const largest = Object.entries(envelope)
132
+ const sortedFields = Object.entries(envelope)
101
133
  .map(([field, value]) => [
102
134
  field,
103
135
  Buffer.byteLength(JSON.stringify(value) ?? '', 'utf-8'),
104
136
  ])
105
- .sort((a, b) => b[1] - a[1])
137
+ .sort((a, b) => b[1] - a[1]);
138
+
139
+ const largest = sortedFields
106
140
  .slice(0, OVERSIZE_REPORT_FIELDS)
107
141
  .map(([field, size]) => `${field} (${Math.round(size / 1024)} KB)`)
108
142
  .join(', ');
109
143
 
144
+ const topField = sortedFields[0]?.[0];
145
+ const remedy = OVERSIZE_FIELD_REMEDIES[topField] ?? DEFAULT_OVERSIZE_REMEDY;
146
+
110
147
  throw new Error(
111
148
  `[plan-context] the assembled "${envelope?.mode}" envelope is ` +
112
149
  `${Math.round(bytes / 1024)} KB, over the ` +
113
150
  `${Math.round(ceiling / 1024)} KB planner-context ceiling. Largest ` +
114
- `fields: ${largest}. Trim the seed, or plan fewer --tickets source ` +
115
- 'issues in one run. Raising the ceiling needs a measured ' +
151
+ `fields: ${largest}. ${remedy} Raising the ceiling needs a measured ` +
116
152
  'justification — see PLAN_CONTEXT_ENVELOPE_BYTE_CEILING.',
117
153
  );
118
154
  }
@@ -12,6 +12,7 @@ import {
12
12
  resolveFeatureRoots,
13
13
  verifyBddRunnerPendingTag,
14
14
  } from '../../bdd-runner-detect.js';
15
+ import { capBddScenarios } from '../../bdd-scenario-budget.js';
15
16
  import { scanBddScenarios } from '../../bdd-scenario-scanner.js';
16
17
  import { getPaths, PROJECT_ROOT } from '../../config-resolver.js';
17
18
  import { fetchPriorFeedback } from '../../feedback-loop/prior-feedback-fetcher.js';
@@ -73,18 +74,24 @@ async function buildPlanningDocsContext({ seedIssueId, settings, cwd }) {
73
74
  /**
74
75
  * Story #2637 — index existing BDD scenarios so the Acceptance Engineer step
75
76
  * can annotate planned ACs with matches from the project's `.feature` files.
76
- * Empty array when the project has not adopted BDD; the scanner is
77
- * best-effort and never throws on filesystem errors.
77
+ * Empty (capped-shape) result when the project has not adopted BDD; the
78
+ * scanner is best-effort and never throws on filesystem errors.
78
79
  *
79
- * @returns {Array<object>}
80
+ * Story #4977 — the scan itself stays uncapped (a faithful `.feature`
81
+ * index), but the envelope-bound result is truncated to
82
+ * `BDD_SCENARIOS_BYTE_BUDGET` via `capBddScenarios` so a mature Gherkin
83
+ * corpus cannot alone consume the `/plan` context-envelope ceiling. Callers
84
+ * needing the raw count read `totalScenarios` vs `includedScenarios`.
85
+ *
86
+ * @returns {ReturnType<typeof capBddScenarios>}
80
87
  */
81
88
  function scanBddScenariosBestEffort() {
82
89
  try {
83
90
  const featureRoots = resolveFeatureRoots({ cwd: PROJECT_ROOT });
84
- return scanBddScenarios({ featureRoots });
91
+ return capBddScenarios(scanBddScenarios({ featureRoots }));
85
92
  } catch (err) {
86
93
  Logger.warn(`[plan-context] BDD scenario scan skipped: ${err.message}`);
87
- return [];
94
+ return capBddScenarios([]);
88
95
  }
89
96
  }
90
97