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.
- package/.agents/docs/configuration.md +3 -0
- package/.agents/docs/execution-reference.md +22 -12
- package/.agents/schemas/agentrc.schema.json +14 -0
- package/.agents/scripts/coverage-capture.js +31 -62
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +41 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +135 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +13 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +7 -87
- package/.agents/scripts/lib/bdd-scenario-budget.js +68 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +26 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +2 -0
- package/.agents/scripts/lib/config/quality.js +41 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +105 -0
- package/.agents/scripts/lib/coverage-capture-incremental.js +116 -0
- package/.agents/scripts/lib/coverage-capture.js +101 -17
- package/.agents/scripts/lib/crap-baseline-index.js +46 -0
- package/.agents/scripts/lib/crap-baseline-join.js +153 -0
- package/.agents/scripts/lib/crap-coordinates.js +43 -0
- package/.agents/scripts/lib/crap-engine.js +26 -51
- package/.agents/scripts/lib/crap-utils-incremental.js +113 -0
- package/.agents/scripts/lib/crap-utils.js +40 -8
- package/.agents/scripts/lib/orchestration/plan-context.js +49 -13
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -5
- package/docs/CHANGELOG.md +14 -0
- 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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
481
|
-
|
|
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
|
-
{
|
|
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 (
|
|
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 =
|
|
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
|
|
50
|
-
* digest-first `docsContext` (~63 KB inline
|
|
51
|
-
* `systemPrompts` (~54 KB); every other field is
|
|
52
|
-
* retired the tier-capped codebase snapshot that
|
|
53
|
-
* (~35 KB skinny here).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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}.
|
|
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
|
|
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
|
-
*
|
|
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
|
|