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
|
@@ -183,6 +183,9 @@ top-level keys are validation errors.
|
|
|
183
183
|
| `quality.gates.crap.refreshTag` | No | `string` | — | — |
|
|
184
184
|
| `quality.gates.crap.refreshTimeoutMs` | No | `integer` | — | Bounded timeout (ms) for `npm run crap:update` spawned by the baseline-attribution refresh path. Mirrors `coverage.timeoutMs`: a SIGKILL fired at the budget boundary maps to exit 124 so the close orchestrator can flip the Story to `agent::blocked`. Default 60000 (Story #2165). |
|
|
185
185
|
| `quality.gates.crap.ignoreGlobs` | No | `array<string>` | — | Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from CRAP discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217). |
|
|
186
|
+
| `quality.gates.crap.incrementalCoverage` | No | `object` | — | Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today's full-repo behaviour byte-for-byte. When `enabled: true`, `coverage-capture.js` scopes `npm run test:coverage` to the files changed against `baseRef` (default: the gate's own `--ref` / `main`), and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it. |
|
|
187
|
+
| `quality.gates.crap.incrementalCoverage.enabled` | No | `boolean` | — | — |
|
|
188
|
+
| `quality.gates.crap.incrementalCoverage.baseRef` | No | `string` | — | — |
|
|
186
189
|
| `quality.gates.maintainability` | No | `object` | — | Nested configuration block. |
|
|
187
190
|
| `quality.gates.maintainability.enabled` | No | `boolean` | — | — |
|
|
188
191
|
| `quality.gates.maintainability.baselinePath` | No | `string` | — | — |
|
|
@@ -96,18 +96,28 @@ over-ceiling envelope or an over-budget Story count.
|
|
|
96
96
|
- **`PLAN_CONTEXT_ENVELOPE_BYTE_CEILING`** (`lib/orchestration/plan-context.js`):
|
|
97
97
|
256 KB (≈64K tokens at the ≈4-chars/token estimate) on the serialized
|
|
98
98
|
envelope `buildPlanContext` assembles, checked at the single choke point
|
|
99
|
-
every mode returns through. A measured seed-mode envelope on this repo
|
|
100
|
-
~120 KB — `docsContext` (~63 KB) and
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
every mode returns through. A measured seed-mode envelope on this repo's
|
|
100
|
+
thin `.feature` corpus is ~120 KB — `docsContext` (~63 KB) and
|
|
101
|
+
`systemPrompts` (~54 KB) are the bulk of the fixed floor, every other field
|
|
102
|
+
under 1 KB. That is **not** representative of every consumer: Story #4977
|
|
103
|
+
measured `bddScenarios` at 118 KB on a consumer with a mature Gherkin
|
|
104
|
+
corpus — larger than `docsContext` and `systemPrompts` combined, leaving
|
|
105
|
+
~5.5% headroom instead of ~2×. `bddScenarios` is now truncated to
|
|
106
|
+
`BDD_SCENARIOS_BYTE_BUDGET` (`lib/bdd-scenario-budget.js`, ≤24 KB,
|
|
107
|
+
reported via `truncated` / `totalScenarios` / `includedScenarios` rather
|
|
108
|
+
than silently dropped) before it reaches the envelope, deliberately a fixed
|
|
109
|
+
framework constant rather than an `.agentrc.json` knob — the same reasoning
|
|
110
|
+
Story #4811 applied when it retired the codebase snapshot. The
|
|
111
|
+
operator-supplied seed remains the one field with no cap and no elision
|
|
112
|
+
path.
|
|
113
|
+
- **On refusal**, the error names the envelope's largest fields and the
|
|
114
|
+
remedy that follows the single largest one (`OVERSIZE_FIELD_REMEDIES` in
|
|
115
|
+
`plan-context.js`) — trim the seed, plan fewer `--tickets` source issues in
|
|
116
|
+
one run, or trim `docsContextFiles`, depending on which field actually blew
|
|
117
|
+
the budget. The seed itself is carried **verbatim** by design — it is the
|
|
118
|
+
operator's request, and summarizing it silently would degrade planning
|
|
119
|
+
quality precisely when the input is richest — so it alone has no elision
|
|
120
|
+
path to fall back on. Raising the ceiling needs a measured justification.
|
|
111
121
|
|
|
112
122
|
### Session-mass capacity (plan-time sizing)
|
|
113
123
|
|
|
@@ -820,6 +820,20 @@
|
|
|
820
820
|
"minLength": 1
|
|
821
821
|
},
|
|
822
822
|
"description": "Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from CRAP discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217)."
|
|
823
|
+
},
|
|
824
|
+
"incrementalCoverage": {
|
|
825
|
+
"type": "object",
|
|
826
|
+
"properties": {
|
|
827
|
+
"enabled": {
|
|
828
|
+
"type": "boolean"
|
|
829
|
+
},
|
|
830
|
+
"baseRef": {
|
|
831
|
+
"type": "string",
|
|
832
|
+
"minLength": 1
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
"additionalProperties": false,
|
|
836
|
+
"description": "Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today's full-repo behaviour byte-for-byte. When `enabled: true`, `coverage-capture.js` scopes `npm run test:coverage` to the files changed against `baseRef` (default: the gate's own `--ref` / `main`), and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it."
|
|
823
837
|
}
|
|
824
838
|
},
|
|
825
839
|
"additionalProperties": false
|
|
@@ -21,17 +21,18 @@
|
|
|
21
21
|
* caller MUST surface this — silently passing here would defeat the
|
|
22
22
|
* CRAP gate's `requireCoverage: true` policy.
|
|
23
23
|
*/
|
|
24
|
-
import path from 'node:path';
|
|
25
24
|
import { getChangedFiles } from './lib/changed-files.js';
|
|
26
25
|
import { isDirectInvocation } from './lib/cli-utils.js';
|
|
27
26
|
import { getQuality, resolveConfig } from './lib/config-resolver.js';
|
|
28
27
|
import {
|
|
29
|
-
anyChangedUnderTargets,
|
|
30
28
|
computeContentDigest,
|
|
29
|
+
filterFilesUnderTargets,
|
|
31
30
|
isCoverageFresh,
|
|
32
31
|
runCapture,
|
|
33
32
|
writeCaptureStamp,
|
|
34
33
|
} from './lib/coverage-capture.js';
|
|
34
|
+
import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
|
|
35
|
+
import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
|
|
35
36
|
|
|
36
37
|
import { Logger } from './lib/Logger.js';
|
|
37
38
|
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
@@ -78,6 +79,7 @@ export function parseArgs(argv) {
|
|
|
78
79
|
* runCaptureImpl?: typeof runCapture,
|
|
79
80
|
* computeContentDigestImpl?: typeof computeContentDigest,
|
|
80
81
|
* writeCaptureStampImpl?: typeof writeCaptureStamp,
|
|
82
|
+
* filterFilesUnderTargetsImpl?: typeof filterFilesUnderTargets,
|
|
81
83
|
* logger?: { info: Function, warn: Function, error: Function },
|
|
82
84
|
* }} [deps]
|
|
83
85
|
* @returns {number} process exit code
|
|
@@ -93,6 +95,7 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
93
95
|
runCaptureImpl = runCapture,
|
|
94
96
|
computeContentDigestImpl = computeContentDigest,
|
|
95
97
|
writeCaptureStampImpl = writeCaptureStamp,
|
|
98
|
+
filterFilesUnderTargetsImpl = filterFilesUnderTargets,
|
|
96
99
|
logger = Logger,
|
|
97
100
|
} = deps;
|
|
98
101
|
const args = parseArgs(argv);
|
|
@@ -120,69 +123,35 @@ export function runCoverageCapture(argv = process.argv, deps = {}) {
|
|
|
120
123
|
return 1;
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
return 0;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const freshness = isCoverageFreshImpl({
|
|
144
|
-
coveragePath: crap.coveragePath,
|
|
145
|
-
targetDirs: crap.targetDirs,
|
|
146
|
-
cwd: args.cwd,
|
|
126
|
+
// Story #4981 — incremental mode, opt-in via
|
|
127
|
+
// `delivery.quality.gates.crap.incrementalCoverage.enabled`. `null` means
|
|
128
|
+
// "not applicable" (disabled, or a ref-resolution error) — fall through to
|
|
129
|
+
// the full-scope path below rather than silently skipping capture.
|
|
130
|
+
const incrementalResult = tryIncrementalCapture({
|
|
131
|
+
crap,
|
|
132
|
+
coverage,
|
|
133
|
+
args,
|
|
134
|
+
getChangedFilesImpl,
|
|
135
|
+
filterFilesUnderTargetsImpl,
|
|
136
|
+
isCoverageFreshImpl,
|
|
137
|
+
runCaptureImpl,
|
|
138
|
+
computeContentDigestImpl,
|
|
139
|
+
writeCaptureStampImpl,
|
|
140
|
+
logger,
|
|
147
141
|
});
|
|
148
|
-
if (
|
|
149
|
-
logger.info(
|
|
150
|
-
`[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
|
|
151
|
-
);
|
|
152
|
-
return 0;
|
|
153
|
-
}
|
|
142
|
+
if (incrementalResult !== null) return incrementalResult;
|
|
154
143
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
144
|
+
return runFullScopeCapture({
|
|
145
|
+
crap,
|
|
146
|
+
coverage,
|
|
147
|
+
args,
|
|
148
|
+
getChangedFilesImpl,
|
|
149
|
+
isCoverageFreshImpl,
|
|
150
|
+
runCaptureImpl,
|
|
151
|
+
computeContentDigestImpl,
|
|
152
|
+
writeCaptureStampImpl,
|
|
153
|
+
logger,
|
|
162
154
|
});
|
|
163
|
-
if (code !== 0) {
|
|
164
|
-
logger.error(
|
|
165
|
-
`[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
|
|
166
|
-
);
|
|
167
|
-
return code;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Persist the content digest next to the fresh artifact so subsequent
|
|
171
|
-
// freshness checks are content-aware (mtime churn from branch switches no
|
|
172
|
-
// longer invalidates). Best-effort — a missing stamp just means the next
|
|
173
|
-
// check falls back to the mtime heuristic.
|
|
174
|
-
const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
|
|
175
|
-
if (
|
|
176
|
-
digest &&
|
|
177
|
-
writeCaptureStampImpl({
|
|
178
|
-
cwd: args.cwd,
|
|
179
|
-
coveragePath: crap.coveragePath,
|
|
180
|
-
digest,
|
|
181
|
-
})
|
|
182
|
-
) {
|
|
183
|
-
logger.info('[coverage-capture] Wrote content-digest capture stamp.');
|
|
184
|
-
}
|
|
185
|
-
return code;
|
|
186
155
|
}
|
|
187
156
|
|
|
188
157
|
// cli-opt-out: synchronous main returns an exit code that is forwarded via process.exit(code); runAsCli's async-main signature does not preserve the result code.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-preview-incremental.js — resolve `runCrapPreview`'s incremental-join
|
|
3
|
+
* `scanAndScore` input (Story #4981).
|
|
4
|
+
*
|
|
5
|
+
* Split into its own file (rather than added inline to `preview-gates.js`)
|
|
6
|
+
* so the Story's opt-in wiring lands as new code, not a same-file expansion
|
|
7
|
+
* of the pre-existing preview runner.
|
|
8
|
+
*/
|
|
9
|
+
import { getChangedFiles } from '../changed-files.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the `incremental` option `scanAndScore` (`crap-utils.js`) expects,
|
|
13
|
+
* or `null` when incremental mode is disabled or the changed-files ref could
|
|
14
|
+
* not be resolved — a resolution failure falls back to full-scope rather
|
|
15
|
+
* than silently relaxing the gate.
|
|
16
|
+
*
|
|
17
|
+
* @param {{
|
|
18
|
+
* crap: { incrementalCoverage?: { enabled?: boolean, baseRef?: string } },
|
|
19
|
+
* diffRef: string | null,
|
|
20
|
+
* cwd: string,
|
|
21
|
+
* baselineRows: Array<object>,
|
|
22
|
+
* getChangedFilesImpl?: typeof getChangedFiles,
|
|
23
|
+
* }} opts
|
|
24
|
+
* @returns {{ touchedFiles: Set<string>, baselineRows: Array<object> } | null}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveCrapPreviewIncremental({
|
|
27
|
+
crap,
|
|
28
|
+
diffRef,
|
|
29
|
+
cwd,
|
|
30
|
+
baselineRows,
|
|
31
|
+
getChangedFilesImpl = getChangedFiles,
|
|
32
|
+
}) {
|
|
33
|
+
if (crap.incrementalCoverage?.enabled !== true) return null;
|
|
34
|
+
const baseRef = crap.incrementalCoverage.baseRef || diffRef || 'main';
|
|
35
|
+
try {
|
|
36
|
+
const touchedFiles = new Set(getChangedFilesImpl({ ref: baseRef, cwd }));
|
|
37
|
+
return { touchedFiles, baselineRows };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-preview-scan.js — the scan → compare → report tail of
|
|
3
|
+
* `preview-gates.js#runCrapPreview`, once its baseline is loaded and judged
|
|
4
|
+
* compatible.
|
|
5
|
+
*
|
|
6
|
+
* Hoisted out of `runCrapPreview` verbatim (Story #4981) so that function's
|
|
7
|
+
* cyclomatic complexity does not grow with the incremental-mode wiring
|
|
8
|
+
* alongside it — this is a relocation of pre-existing logic, not new
|
|
9
|
+
* behaviour; the incremental resolution itself is the only Story #4981
|
|
10
|
+
* addition (see `resolveCrapPreviewIncremental`).
|
|
11
|
+
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { loadCoverage } from '../coverage-utils.js';
|
|
14
|
+
import {
|
|
15
|
+
KERNEL_VERSION,
|
|
16
|
+
resolveEscomplexVersion,
|
|
17
|
+
scanAndScore,
|
|
18
|
+
} from '../crap-utils.js';
|
|
19
|
+
import { resolveCrapPreviewIncremental } from './crap-preview-incremental.js';
|
|
20
|
+
import { resolveCrapEnvOverrides } from './env-overrides.js';
|
|
21
|
+
import {
|
|
22
|
+
assessComparisonBasis,
|
|
23
|
+
buildCrapReport,
|
|
24
|
+
compareCrap,
|
|
25
|
+
filterRowsByFileScope,
|
|
26
|
+
suppressVerdicts,
|
|
27
|
+
} from './kinds/crap.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Narrow a CRAP baseline to the rows whose file path is in `scopeSet`,
|
|
31
|
+
* or return all rows when no diff-scope filter is active.
|
|
32
|
+
*
|
|
33
|
+
* @param {{ rows: object[] }} baseline
|
|
34
|
+
* @param {Set<string>|null|undefined} scopeSet
|
|
35
|
+
* @returns {object[]}
|
|
36
|
+
*/
|
|
37
|
+
function resolveBaselineRows(baseline, scopeSet) {
|
|
38
|
+
return scopeSet
|
|
39
|
+
? filterRowsByFileScope(baseline.rows, scopeSet)
|
|
40
|
+
: baseline.rows;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return true when the CRAP compare result contains regressions or new
|
|
45
|
+
* violations — i.e. when the preview gate should exit non-zero.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ regressions: number, newViolations: number }} result
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function hasCrapRegressions(result) {
|
|
51
|
+
return result.regressions > 0 || result.newViolations > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan `crap.targetDirs`, compare against the (already compatibility-judged)
|
|
56
|
+
* baseline, and build the `--json` envelope — the exact pre-#4981 tail of
|
|
57
|
+
* `runCrapPreview`, now including the Story #4981 incremental-join opt-in.
|
|
58
|
+
*
|
|
59
|
+
* @param {{
|
|
60
|
+
* crap: object,
|
|
61
|
+
* cwd: string,
|
|
62
|
+
* scopeSet: Set<string>|null,
|
|
63
|
+
* scope: string,
|
|
64
|
+
* diffRef: string|null,
|
|
65
|
+
* baseline: { rows: object[] },
|
|
66
|
+
* }} opts
|
|
67
|
+
* @returns {Promise<{ exitCode: number, envelope: object }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function computeCrapPreviewScan({
|
|
70
|
+
crap,
|
|
71
|
+
cwd,
|
|
72
|
+
scopeSet,
|
|
73
|
+
scope,
|
|
74
|
+
diffRef,
|
|
75
|
+
baseline,
|
|
76
|
+
}) {
|
|
77
|
+
const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
|
|
78
|
+
const crapIgnoreGlobs = Array.isArray(crap.ignoreGlobs)
|
|
79
|
+
? crap.ignoreGlobs
|
|
80
|
+
: [];
|
|
81
|
+
const requireCoverage = crap.requireCoverage !== false;
|
|
82
|
+
const coveragePath = crap.coveragePath ?? 'coverage/coverage-final.json';
|
|
83
|
+
const coverage = loadCoverage(path.resolve(cwd, coveragePath));
|
|
84
|
+
// Story #4731 (AC-3) — feed the CRAP regression compare the *configured*
|
|
85
|
+
// crap tolerance (env override → `gates.crap.tolerance` → framework default)
|
|
86
|
+
// so `compareCrap` demotes positive deltas at or under tolerance rather than
|
|
87
|
+
// failing on any positive delta; over-tolerance deltas still fail. This keeps
|
|
88
|
+
// the pre-commit/pre-push preview aligned with the authoritative gate.
|
|
89
|
+
const { newMethodCeiling, tolerance } = resolveCrapEnvOverrides(
|
|
90
|
+
crap,
|
|
91
|
+
process.env,
|
|
92
|
+
);
|
|
93
|
+
const incremental = resolveCrapPreviewIncremental({
|
|
94
|
+
crap,
|
|
95
|
+
diffRef,
|
|
96
|
+
cwd,
|
|
97
|
+
baselineRows: baseline.rows,
|
|
98
|
+
});
|
|
99
|
+
const scan = await scanAndScore({
|
|
100
|
+
targetDirs,
|
|
101
|
+
coverage,
|
|
102
|
+
requireCoverage,
|
|
103
|
+
cwd,
|
|
104
|
+
scopeFiles: scopeSet,
|
|
105
|
+
ignoreGlobs: crapIgnoreGlobs,
|
|
106
|
+
incremental,
|
|
107
|
+
});
|
|
108
|
+
const baselineRows = resolveBaselineRows(baseline, scopeSet);
|
|
109
|
+
const result = compareCrap({
|
|
110
|
+
currentRows: scan.rows,
|
|
111
|
+
baselineRows,
|
|
112
|
+
newMethodCeiling,
|
|
113
|
+
tolerance,
|
|
114
|
+
});
|
|
115
|
+
const envelope = buildCrapReport({
|
|
116
|
+
compareResult: result,
|
|
117
|
+
scanSummary: scan,
|
|
118
|
+
kernelVersion: KERNEL_VERSION,
|
|
119
|
+
escomplexVersion: resolveEscomplexVersion(),
|
|
120
|
+
newMethodCeiling,
|
|
121
|
+
scopeInfo: { scope, diffRef },
|
|
122
|
+
});
|
|
123
|
+
// Story #4866 (AC-5): above the drifted-row ratio the basis is self-
|
|
124
|
+
// evidently unsound and every per-method verdict below it is an artefact of
|
|
125
|
+
// a mis-keyed join. Say so once, by name, and fail open.
|
|
126
|
+
const basis = assessComparisonBasis(result);
|
|
127
|
+
if (!basis.sound) {
|
|
128
|
+
return {
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
envelope: suppressVerdicts(envelope, basis.diagnostic),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const exitCode = hasCrapRegressions(result) ? 1 : 0;
|
|
134
|
+
return { exitCode, envelope };
|
|
135
|
+
}
|
|
@@ -245,6 +245,19 @@ function crapRowKey(row) {
|
|
|
245
245
|
return `${row.path}::${row.method}@${row.startLine}`;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// `methodIdentityKey` / `indexBaselineRowsByFile` (Story #4981) live in
|
|
249
|
+
// crap-baseline-index.js, not here — `crap-utils.js#scanAndScore` needs them
|
|
250
|
+
// to build the incremental join's per-file baseline lookup, and crap-utils.js
|
|
251
|
+
// already imports `getCrapBaseline` FROM this module. Defining them here and
|
|
252
|
+
// importing them into crap-utils.js would close that edge into a cycle
|
|
253
|
+
// (kinds/crap.js → crap-utils.js → kinds/crap.js). Re-exported here so
|
|
254
|
+
// existing importers of this module keep a single door to the identity key
|
|
255
|
+
// `crapRowKey` composes with the file path.
|
|
256
|
+
export {
|
|
257
|
+
indexBaselineRowsByFile,
|
|
258
|
+
methodIdentityKey,
|
|
259
|
+
} from '../../crap-baseline-index.js';
|
|
260
|
+
|
|
248
261
|
/**
|
|
249
262
|
* Pure stabilizer for s-stability-epsilon (Story #1964). CRAP rows match
|
|
250
263
|
* by the composite `path::method@startLine` identity. Sub-epsilon CRAP
|
|
@@ -21,20 +21,11 @@ import path from 'node:path';
|
|
|
21
21
|
|
|
22
22
|
import { resolvePreviewScope } from '../changed-files.js';
|
|
23
23
|
import { getBaselines, getQuality, resolveConfig } from '../config-resolver.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
KERNEL_VERSION,
|
|
27
|
-
resolveEscomplexVersion,
|
|
28
|
-
scanAndScore,
|
|
29
|
-
} from '../crap-utils.js';
|
|
24
|
+
import { KERNEL_VERSION, resolveEscomplexVersion } from '../crap-utils.js';
|
|
30
25
|
import { calculateAll, scanDirectory } from '../maintainability-utils.js';
|
|
31
|
-
import {
|
|
26
|
+
import { computeCrapPreviewScan } from './crap-preview-scan.js';
|
|
32
27
|
import {
|
|
33
28
|
assertBaselineCompatible,
|
|
34
|
-
assessComparisonBasis,
|
|
35
|
-
buildCrapReport,
|
|
36
|
-
compareCrap,
|
|
37
|
-
filterRowsByFileScope,
|
|
38
29
|
INCOMPATIBLE_BASELINE_DIAGNOSTIC,
|
|
39
30
|
loadCrapBaseline,
|
|
40
31
|
suppressVerdicts,
|
|
@@ -110,31 +101,6 @@ function compareScores(scores, baseline, tolerance) {
|
|
|
110
101
|
return { regressions, newFiles, improvements, regressedFiles };
|
|
111
102
|
}
|
|
112
103
|
|
|
113
|
-
/**
|
|
114
|
-
* Narrow a CRAP baseline to the rows whose file path is in `scopeSet`,
|
|
115
|
-
* or return all rows when no diff-scope filter is active.
|
|
116
|
-
*
|
|
117
|
-
* @param {{ rows: object[] }} baseline
|
|
118
|
-
* @param {Set<string>|null|undefined} scopeSet
|
|
119
|
-
* @returns {object[]}
|
|
120
|
-
*/
|
|
121
|
-
function resolveBaselineRows(baseline, scopeSet) {
|
|
122
|
-
return scopeSet
|
|
123
|
-
? filterRowsByFileScope(baseline.rows, scopeSet)
|
|
124
|
-
: baseline.rows;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Return true when the CRAP compare result contains regressions or new
|
|
129
|
-
* violations — i.e. when the preview gate should exit non-zero.
|
|
130
|
-
*
|
|
131
|
-
* @param {{ regressions: number, newViolations: number }} result
|
|
132
|
-
* @returns {boolean}
|
|
133
|
-
*/
|
|
134
|
-
function hasCrapRegressions(result) {
|
|
135
|
-
return result.regressions > 0 || result.newViolations > 0;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
104
|
/**
|
|
139
105
|
* Build the zero-row CRAP envelope the preview returns when it has nothing to
|
|
140
106
|
* compare against — no baseline, or the gate disabled.
|
|
@@ -289,58 +255,12 @@ export async function runCrapPreview({
|
|
|
289
255
|
};
|
|
290
256
|
}
|
|
291
257
|
|
|
292
|
-
|
|
293
|
-
const crapIgnoreGlobs = Array.isArray(crap.ignoreGlobs)
|
|
294
|
-
? crap.ignoreGlobs
|
|
295
|
-
: [];
|
|
296
|
-
const requireCoverage = crap.requireCoverage !== false;
|
|
297
|
-
const coveragePath = crap.coveragePath ?? 'coverage/coverage-final.json';
|
|
298
|
-
const coverage = loadCoverage(path.resolve(cwd, coveragePath));
|
|
299
|
-
// Story #4731 (AC-3) — feed the CRAP regression compare the *configured*
|
|
300
|
-
// crap tolerance (env override → `gates.crap.tolerance` → framework default)
|
|
301
|
-
// so `compareCrap` demotes positive deltas at or under tolerance rather than
|
|
302
|
-
// failing on any positive delta; over-tolerance deltas still fail. This keeps
|
|
303
|
-
// the pre-commit/pre-push preview aligned with the authoritative gate.
|
|
304
|
-
const { newMethodCeiling, tolerance } = resolveCrapEnvOverrides(
|
|
258
|
+
return computeCrapPreviewScan({
|
|
305
259
|
crap,
|
|
306
|
-
process.env,
|
|
307
|
-
);
|
|
308
|
-
const scan = await scanAndScore({
|
|
309
|
-
targetDirs,
|
|
310
|
-
coverage,
|
|
311
|
-
requireCoverage,
|
|
312
260
|
cwd,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const result = compareCrap({
|
|
318
|
-
currentRows: scan.rows,
|
|
319
|
-
baselineRows,
|
|
320
|
-
newMethodCeiling,
|
|
321
|
-
tolerance,
|
|
322
|
-
});
|
|
323
|
-
const envelope = buildCrapReport({
|
|
324
|
-
compareResult: result,
|
|
325
|
-
scanSummary: scan,
|
|
326
|
-
kernelVersion: KERNEL_VERSION,
|
|
327
|
-
escomplexVersion: resolveEscomplexVersion(),
|
|
328
|
-
newMethodCeiling,
|
|
329
|
-
scopeInfo: {
|
|
330
|
-
scope,
|
|
331
|
-
diffRef,
|
|
332
|
-
},
|
|
261
|
+
scopeSet,
|
|
262
|
+
scope,
|
|
263
|
+
diffRef,
|
|
264
|
+
baseline,
|
|
333
265
|
});
|
|
334
|
-
// Story #4866 (AC-5): above the drifted-row ratio the basis is self-
|
|
335
|
-
// evidently unsound and every per-method verdict below it is an artefact of
|
|
336
|
-
// a mis-keyed join. Say so once, by name, and fail open.
|
|
337
|
-
const basis = assessComparisonBasis(result);
|
|
338
|
-
if (!basis.sound) {
|
|
339
|
-
return {
|
|
340
|
-
exitCode: 0,
|
|
341
|
-
envelope: suppressVerdicts(envelope, basis.diagnostic),
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
const exitCode = hasCrapRegressions(result) ? 1 : 0;
|
|
345
|
-
return { exitCode, envelope };
|
|
346
266
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bdd-scenario-budget.js — envelope byte budget for the `bddScenarios`
|
|
3
|
+
* `/plan` context-envelope field (Story #4977).
|
|
4
|
+
*
|
|
5
|
+
* `bdd-scenario-scanner.js`'s `scanBddScenarios` stays a faithful, uncapped
|
|
6
|
+
* index of the project's `.feature` corpus — that scan is also used
|
|
7
|
+
* directly by `lib/qa/resolve-selection.js`, which needs the full set. The
|
|
8
|
+
* cap belongs at the envelope boundary instead, in its own module: on a
|
|
9
|
+
* consumer with a mature Gherkin corpus, `bddScenarios` grew to 118 KB —
|
|
10
|
+
* larger than `docsContext` and `systemPrompts` combined — consuming
|
|
11
|
+
* nearly the entire `PLAN_CONTEXT_ENVELOPE_BYTE_CEILING` headroom on its
|
|
12
|
+
* own and blocking `/audit-to-stories`' single-plan path entirely.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately a fixed framework constant rather than an `.agentrc.json`
|
|
15
|
+
* knob: Story #4541 retired the one operator-tunable planner-context budget
|
|
16
|
+
* (`planning.context.maxBytes`) because a cap the operator can raise past
|
|
17
|
+
* what the model can read fails silently again, and the same reasoning
|
|
18
|
+
* applies here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Byte budget for the capped `bddScenarios` envelope field. Sized well
|
|
23
|
+
* under `PLAN_CONTEXT_ENVELOPE_BYTE_CEILING` (256 KB) — this is one of
|
|
24
|
+
* several envelope fields, not the whole budget. At the measured ~337
|
|
25
|
+
* bytes/scenario average (Story #4977 evidence, a mature Gherkin corpus),
|
|
26
|
+
* 24 KB holds roughly 70 scenarios before truncating.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately module-private: the only production consumer is
|
|
29
|
+
* {@link capBddScenarios} below via the `opts.byteBudget` default. Tests
|
|
30
|
+
* assert the resulting behavior (truncation, order, fit) rather than
|
|
31
|
+
* importing this value directly, so it carries no public export the
|
|
32
|
+
* `--production` dead-exports ratchet would otherwise flag as test-only.
|
|
33
|
+
*/
|
|
34
|
+
const BDD_SCENARIOS_BYTE_BUDGET = 24_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Truncate a scenario index to a byte budget, deterministically (scan
|
|
38
|
+
* order — file walk order, then in-file order — never re-sorted), and
|
|
39
|
+
* report what was dropped rather than truncating silently.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array<object>} scenarios Full scan output (order preserved).
|
|
42
|
+
* @param {{ byteBudget?: number }} [opts]
|
|
43
|
+
* @returns {{
|
|
44
|
+
* scenarios: Array<object>,
|
|
45
|
+
* totalScenarios: number,
|
|
46
|
+
* includedScenarios: number,
|
|
47
|
+
* truncated: boolean,
|
|
48
|
+
* }}
|
|
49
|
+
*/
|
|
50
|
+
export function capBddScenarios(scenarios, opts = {}) {
|
|
51
|
+
const byteBudget = opts.byteBudget ?? BDD_SCENARIOS_BYTE_BUDGET;
|
|
52
|
+
const list = Array.isArray(scenarios) ? scenarios : [];
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
let cut = list.length;
|
|
55
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
56
|
+
bytes += Buffer.byteLength(JSON.stringify(list[i]), 'utf-8');
|
|
57
|
+
if (bytes > byteBudget) {
|
|
58
|
+
cut = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
scenarios: list.slice(0, cut),
|
|
64
|
+
totalScenarios: list.length,
|
|
65
|
+
includedScenarios: cut,
|
|
66
|
+
truncated: cut < list.length,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* node:coverage ignore file -- AJV schema declaration (data-as-code) */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `delivery.quality.gates.crap.incrementalCoverage` — opt-in incremental
|
|
5
|
+
* coverage-capture + CRAP-join scoping (Story #4981).
|
|
6
|
+
*
|
|
7
|
+
* Split into its own module (rather than an inline property literal on
|
|
8
|
+
* `CRAP_GATE`) so the schema addition lands as a new file, not a same-file
|
|
9
|
+
* expansion of `crap.schema.js` — the file this module's sole export is
|
|
10
|
+
* spread into.
|
|
11
|
+
*
|
|
12
|
+
* Default (key absent) preserves today's full-repo behaviour byte-for-byte.
|
|
13
|
+
* When `enabled: true`, `coverage-capture.js` scopes `npm run test:coverage`
|
|
14
|
+
* to the files changed against `baseRef` (default: the gate's own `--ref` /
|
|
15
|
+
* `main`), and the CRAP join treats a method in a file the diff did not
|
|
16
|
+
* touch as resolved by its committed baseline row instead of requiring
|
|
17
|
+
* fresh coverage for it.
|
|
18
|
+
*/
|
|
19
|
+
export const INCREMENTAL_COVERAGE_SCHEMA = {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
enabled: { type: 'boolean' },
|
|
23
|
+
baseRef: { type: 'string', minLength: 1 },
|
|
24
|
+
},
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* node:coverage ignore file -- AJV schema declaration (data-as-code) */
|
|
2
2
|
|
|
3
|
+
import { INCREMENTAL_COVERAGE_SCHEMA } from './crap-incremental-coverage.schema.js';
|
|
3
4
|
import {
|
|
4
5
|
GATE_BASE,
|
|
5
6
|
LIST_OR_EXTENDER_OF_STRINGS,
|
|
@@ -35,6 +36,7 @@ export const CRAP_GATE = {
|
|
|
35
36
|
// paths to exclude files from CRAP discovery before scoring. Orthogonal
|
|
36
37
|
// to `components` (grouping). Absent/empty preserves existing behaviour.
|
|
37
38
|
ignoreGlobs: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
39
|
+
incrementalCoverage: INCREMENTAL_COVERAGE_SCHEMA,
|
|
38
40
|
},
|
|
39
41
|
additionalProperties: false,
|
|
40
42
|
};
|