mandrel 2.25.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/reader.js +52 -38
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +71 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-surface.js — walk both halves of the baseline surface (Story #4902).
|
|
3
|
+
*
|
|
4
|
+
* The closed `delivery.quality.gates` kinds and the out-of-band ratchet
|
|
5
|
+
* baselines are one surface, and an engine that walks only the first silently
|
|
6
|
+
* drops the second. This module owns that walk and the file universe an
|
|
7
|
+
* `ignoreGlobs` entry is checked against; [`surface-entry.js`](surface-entry.js)
|
|
8
|
+
* turns each kind into its health report.
|
|
9
|
+
*
|
|
10
|
+
* @module lib/audit-baselines/gate-surface
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { ALL_KINDS, baselinePathFor, GATE_KINDS } from './kinds.js';
|
|
15
|
+
import { listFilesUnder, readJsonFile } from './read.js';
|
|
16
|
+
import { surfaceEntryFor } from './surface-entry.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every `targetDirs` entry declared by any gate, deduplicated. This is the
|
|
20
|
+
* file universe an `ignoreGlobs` entry is checked against — a glob that
|
|
21
|
+
* matches nothing inside the dirs its own gate scans is dead weight.
|
|
22
|
+
*
|
|
23
|
+
* @param {object | null | undefined} quality
|
|
24
|
+
* @returns {string[]}
|
|
25
|
+
*/
|
|
26
|
+
function declaredTargetDirs(quality) {
|
|
27
|
+
const dirs = new Set();
|
|
28
|
+
for (const kind of GATE_KINDS) {
|
|
29
|
+
for (const dir of quality?.gates?.[kind]?.targetDirs ?? []) {
|
|
30
|
+
if (typeof dir === 'string' && dir.length > 0) dirs.add(dir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return [...dirs].sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Walk both halves of the gate surface — the closed `delivery.quality.gates`
|
|
38
|
+
* kinds and the out-of-band ratchet baselines — and report each instrument's
|
|
39
|
+
* health.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ cwd: string, quality: object, now?: Date, run?: Function }} args
|
|
42
|
+
* `run` overrides the git spawn; `staleness.js` owns the real default.
|
|
43
|
+
* @returns {{ entries: object[], baselines: Map<string, object|null> }}
|
|
44
|
+
* `baselines` carries each parsed envelope forward so the hotspot, trend,
|
|
45
|
+
* and headroom sections never re-read a 650KB file off disk.
|
|
46
|
+
*/
|
|
47
|
+
export function buildGateSurface({ cwd, quality, now = new Date(), run }) {
|
|
48
|
+
const files = declaredTargetDirs(quality).flatMap((dir) =>
|
|
49
|
+
listFilesUnder(cwd, dir),
|
|
50
|
+
);
|
|
51
|
+
const io = { cwd, run };
|
|
52
|
+
const entries = [];
|
|
53
|
+
const baselines = new Map();
|
|
54
|
+
for (const kind of ALL_KINDS) {
|
|
55
|
+
const relPath = baselinePathFor(kind, quality);
|
|
56
|
+
const read = readJsonFile(path.resolve(cwd, relPath));
|
|
57
|
+
entries.push(
|
|
58
|
+
surfaceEntryFor({ kind, quality, read, relPath, files, now, io }),
|
|
59
|
+
);
|
|
60
|
+
baselines.set(kind, read.parsed);
|
|
61
|
+
}
|
|
62
|
+
return { entries, baselines };
|
|
63
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headroom.js — how much slack sits between each configured floor and the
|
|
3
|
+
* number actually measured (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* A floor set far from reality is a gate that cannot fail. Headroom is the
|
|
6
|
+
* distance the measurement could still drift before the gate notices, and it
|
|
7
|
+
* is the direct input to the only remediation this lens ever recommends:
|
|
8
|
+
* tighten the floor to what the repo already achieves.
|
|
9
|
+
*
|
|
10
|
+
* Both sides come from the repository, never from this module: the floor
|
|
11
|
+
* from `resolveQuality()` over `.agentrc.json` (so a consumer's own floors
|
|
12
|
+
* are the ones reported), and the measurement from the committed baseline's
|
|
13
|
+
* whole-repo rollup. Polarity comes from `axisDirection` in the gate's own
|
|
14
|
+
* floors phase, so "better" always means the same thing here as it does when
|
|
15
|
+
* `check-baselines.js` decides pass or fail.
|
|
16
|
+
*
|
|
17
|
+
* @module lib/audit-baselines/headroom
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { axisDirection } from '../orchestration/check-baselines/phases/floors.js';
|
|
21
|
+
import { rollupOf } from './kinds.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Signed distance from the floor, positive when the measurement sits on the
|
|
25
|
+
* good side of it. Null when the baseline carries no value for the axis.
|
|
26
|
+
*
|
|
27
|
+
* @param {number | undefined} measured
|
|
28
|
+
* @param {number} floor
|
|
29
|
+
* @param {'gte' | 'lte'} direction
|
|
30
|
+
* @returns {number | null}
|
|
31
|
+
*/
|
|
32
|
+
function headroomFor(measured, floor, direction) {
|
|
33
|
+
if (typeof measured !== 'number') return null;
|
|
34
|
+
return direction === 'gte' ? measured - floor : floor - measured;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the `headroom[]` section across every configured gate kind.
|
|
39
|
+
*
|
|
40
|
+
* @param {{
|
|
41
|
+
* kinds: string[],
|
|
42
|
+
* quality: object,
|
|
43
|
+
* baselines: Map<string, object | null>,
|
|
44
|
+
* }} args
|
|
45
|
+
* @returns {Array<object>}
|
|
46
|
+
*/
|
|
47
|
+
export function buildHeadroom({ kinds, quality, baselines }) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const kind of kinds) {
|
|
50
|
+
const floors = quality?.gates?.[kind]?.floors?.['*'];
|
|
51
|
+
if (!floors || typeof floors !== 'object') continue;
|
|
52
|
+
const rollup = rollupOf(baselines.get(kind) ?? null);
|
|
53
|
+
for (const [axis, floor] of Object.entries(floors)) {
|
|
54
|
+
if (typeof floor !== 'number' || !Number.isFinite(floor)) continue;
|
|
55
|
+
const measured = rollup?.[axis];
|
|
56
|
+
const direction = axisDirection(kind, axis);
|
|
57
|
+
out.push({
|
|
58
|
+
kind,
|
|
59
|
+
axis,
|
|
60
|
+
floor,
|
|
61
|
+
measured: typeof measured === 'number' ? measured : null,
|
|
62
|
+
direction,
|
|
63
|
+
// Positive headroom = the measurement is on the good side of the
|
|
64
|
+
// floor by this much; negative = the floor is already breached.
|
|
65
|
+
headroom: headroomFor(measured, floor, direction),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out.sort(
|
|
70
|
+
(a, b) => a.kind.localeCompare(b.kind) || a.axis.localeCompare(b.axis),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hotspots.js — join per-gate outliers into ranked, per-file clusters
|
|
3
|
+
* (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* The signal a baseline review is looking for is *convergence*: the file that
|
|
6
|
+
* is simultaneously a CRAP outlier, a maintainability outlier, and a
|
|
7
|
+
* duplication outlier is a different kind of problem from three unrelated
|
|
8
|
+
* files each bad at one thing. Reading the baselines one at a time cannot see
|
|
9
|
+
* it, because each gate's own top-20 is a different list.
|
|
10
|
+
*
|
|
11
|
+
* So severity **adds across gate memberships** and the three cost
|
|
12
|
+
* multipliers apply to the sum. Two gate memberships of severity s therefore
|
|
13
|
+
* outrank one membership of severity s at equal churn, centrality, and
|
|
14
|
+
* friction — which is the ranking property this section exists to provide.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/audit-baselines/hotspots
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Group per-gate outlier rows by cluster id and rank them.
|
|
21
|
+
*
|
|
22
|
+
* @param {{
|
|
23
|
+
* outliers: Array<object>,
|
|
24
|
+
* weightsFor: (id: string) => {
|
|
25
|
+
* churnWeight: number, centralityWeight: number, frictionWeight: number,
|
|
26
|
+
* },
|
|
27
|
+
* limit?: number,
|
|
28
|
+
* }} args
|
|
29
|
+
* @returns {Array<object>} highest rank first
|
|
30
|
+
*/
|
|
31
|
+
export function buildHotspots({ outliers, weightsFor, limit = 50 }) {
|
|
32
|
+
const clusters = new Map();
|
|
33
|
+
for (const row of outliers) {
|
|
34
|
+
let cluster = clusters.get(row.id);
|
|
35
|
+
if (!cluster) {
|
|
36
|
+
cluster = { path: row.id, gates: [], severityWeight: 0 };
|
|
37
|
+
clusters.set(row.id, cluster);
|
|
38
|
+
}
|
|
39
|
+
cluster.gates.push({
|
|
40
|
+
kind: row.kind,
|
|
41
|
+
metric: row.metric,
|
|
42
|
+
value: row.value,
|
|
43
|
+
rowCount: row.rowCount,
|
|
44
|
+
severityWeight: row.severityWeight,
|
|
45
|
+
});
|
|
46
|
+
cluster.severityWeight += row.severityWeight;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ranked = [];
|
|
50
|
+
for (const cluster of clusters.values()) {
|
|
51
|
+
const weights = weightsFor(cluster.path);
|
|
52
|
+
cluster.gates.sort((a, b) => a.kind.localeCompare(b.kind));
|
|
53
|
+
ranked.push({
|
|
54
|
+
path: cluster.path,
|
|
55
|
+
gates: cluster.gates,
|
|
56
|
+
gateKinds: cluster.gates.map((g) => g.kind),
|
|
57
|
+
gateCount: cluster.gates.length,
|
|
58
|
+
severityWeight: cluster.severityWeight,
|
|
59
|
+
...weights,
|
|
60
|
+
rank:
|
|
61
|
+
cluster.severityWeight *
|
|
62
|
+
weights.churnWeight *
|
|
63
|
+
weights.centralityWeight *
|
|
64
|
+
weights.frictionWeight,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
ranked.sort((a, b) => b.rank - a.rank || a.path.localeCompare(b.path));
|
|
68
|
+
return ranked.slice(0, Math.max(0, limit));
|
|
69
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kinds.js — the two halves of the gate surface, and how to read a row out
|
|
3
|
+
* of each (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* Mandrel's baseline surface is not one list. `delivery.quality.gates` is a
|
|
6
|
+
* **closed** AJV schema of eight kinds, enforced by `check-baselines.js`.
|
|
7
|
+
* Alongside it sit out-of-band **ratchet** baselines — dead exports (two
|
|
8
|
+
* passes), import cycles, context budget — which no gate block declares and
|
|
9
|
+
* only the CI baselines job runs. An engine that walks one half and calls it
|
|
10
|
+
* "the baselines" silently drops the other; both halves are enumerated here.
|
|
11
|
+
*
|
|
12
|
+
* `GATE_KINDS` is derived from `GATES_SCHEMA` rather than re-typed, so a
|
|
13
|
+
* ninth gate kind landing in the schema reaches this engine automatically.
|
|
14
|
+
*
|
|
15
|
+
* @module lib/audit-baselines/kinds
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { GATES_SCHEMA } from '../config/gates/index.js';
|
|
19
|
+
|
|
20
|
+
/** The closed `delivery.quality.gates` kind set, in stable order. */
|
|
21
|
+
export const GATE_KINDS = Object.freeze(
|
|
22
|
+
Object.keys(GATES_SCHEMA.properties).sort(),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Out-of-band ratchet baselines: committed under `baselines/` and enforced
|
|
27
|
+
* only by the CI baselines job, never by `check-baselines.js`.
|
|
28
|
+
*/
|
|
29
|
+
const RATCHET_KINDS = Object.freeze([
|
|
30
|
+
'arch-cycles',
|
|
31
|
+
'context-budget',
|
|
32
|
+
'cyclomatic',
|
|
33
|
+
'dead-exports',
|
|
34
|
+
'dead-exports-production',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Every kind the engine walks, gates first then ratchets. */
|
|
38
|
+
export const ALL_KINDS = Object.freeze([...GATE_KINDS, ...RATCHET_KINDS]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a kind's baseline path from config, falling back to the framework
|
|
42
|
+
* default layout. Never hardcodes a consumer's location: a repo that moved
|
|
43
|
+
* `baselines/crap.json` via `gates.crap.baselinePath` is followed.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} kind
|
|
46
|
+
* @param {object | null | undefined} quality resolved `delivery.quality`
|
|
47
|
+
* @returns {string} repo-relative path
|
|
48
|
+
*/
|
|
49
|
+
export function baselinePathFor(kind, quality) {
|
|
50
|
+
const configured = quality?.gates?.[kind]?.baselinePath;
|
|
51
|
+
if (typeof configured === 'string' && configured.length > 0) {
|
|
52
|
+
return configured;
|
|
53
|
+
}
|
|
54
|
+
return `baselines/${kind}.json`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Flatten `context-budget.json` into `{ id, value }` rows. Its three
|
|
59
|
+
* sections all measure the same axis (bytes of context a file costs) under
|
|
60
|
+
* different keys, so they fold into one row set rather than three.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} baseline
|
|
63
|
+
* @returns {Array<{ id: string, value: number }>}
|
|
64
|
+
*/
|
|
65
|
+
function contextBudgetRows(baseline) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const tier of Object.values(baseline?.tiers ?? {})) {
|
|
68
|
+
for (const f of tier?.files ?? []) out.push({ id: f.path, value: f.bytes });
|
|
69
|
+
}
|
|
70
|
+
for (const f of baseline?.agentBoot?.files ?? []) {
|
|
71
|
+
out.push({ id: f.path, value: f.bytes });
|
|
72
|
+
}
|
|
73
|
+
for (const e of baseline?.workflowClosure?.entryPoints ?? []) {
|
|
74
|
+
out.push({ id: e.path, value: e.reachableBytes });
|
|
75
|
+
}
|
|
76
|
+
return out.filter(
|
|
77
|
+
(r) => typeof r.id === 'string' && Number.isFinite(r.value),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Count how many allowlisted cycles each module participates in. A module in
|
|
83
|
+
* three cycles is a worse architectural hotspot than one in a single cycle.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} baseline
|
|
86
|
+
* @returns {Array<{ id: string, value: number }>}
|
|
87
|
+
*/
|
|
88
|
+
function archCycleRows(baseline) {
|
|
89
|
+
const counts = new Map();
|
|
90
|
+
for (const cycle of baseline?.cycles ?? []) {
|
|
91
|
+
for (const member of new Set(cycle ?? [])) {
|
|
92
|
+
counts.set(member, (counts.get(member) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...counts].map(([id, value]) => ({ id, value }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Count dead exports per file. The row grain is `{ file, symbol }`; the
|
|
100
|
+
* hotspot grain is the file.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} baseline
|
|
103
|
+
* @returns {Array<{ id: string, value: number }>}
|
|
104
|
+
*/
|
|
105
|
+
function deadExportRows(baseline) {
|
|
106
|
+
const counts = new Map();
|
|
107
|
+
for (const row of baseline?.rows ?? []) {
|
|
108
|
+
if (typeof row?.file !== 'string') continue;
|
|
109
|
+
counts.set(row.file, (counts.get(row.file) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
return [...counts].map(([id, value]) => ({ id, value }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the `{ id, value }` extractor for an envelope-shaped gate kind.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} idKey row property carrying the cluster identity
|
|
118
|
+
* @param {string} metric row property carrying the measured value
|
|
119
|
+
* @returns {(baseline: object) => Array<{ id: string, value: number }>}
|
|
120
|
+
*/
|
|
121
|
+
function envelopeRows(idKey, metric) {
|
|
122
|
+
return (baseline) =>
|
|
123
|
+
(baseline?.rows ?? [])
|
|
124
|
+
.map((row) => ({ id: row?.[idKey], value: row?.[metric] }))
|
|
125
|
+
.filter((r) => typeof r.id === 'string' && Number.isFinite(r.value));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fold `{ id, value }` rows into one whole-repo number.
|
|
130
|
+
*
|
|
131
|
+
* `TOTAL` is for **additive** metrics — dead-export symbols, context bytes,
|
|
132
|
+
* lint errors — where the sum is the quantity the instrument measures.
|
|
133
|
+
* `TALLY` is for **non-additive** ones — percentages, indices, scores — where
|
|
134
|
+
* summing per-file values would fabricate a statistic; the honest whole-repo
|
|
135
|
+
* number is how many rows are tracked, and the unit name says so.
|
|
136
|
+
*/
|
|
137
|
+
const TOTAL = (rows) => rows.reduce((sum, row) => sum + row.value, 0);
|
|
138
|
+
const TALLY = (rows) => rows.length;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `[unit, fold]` per kind: the unit each kind's whole-repo total is
|
|
142
|
+
* denominated in, and how its rows fold into it.
|
|
143
|
+
*
|
|
144
|
+
* This is the fix for the roll-up that counted **files** for every kind
|
|
145
|
+
* (Story #4962). `dead-exports-production` moving 590 → 589 *symbols* across
|
|
146
|
+
* 187 files on both sides read as a delta of 0, and a 421-byte context-budget
|
|
147
|
+
* growth read as 0 too, because the fallback counted rows — a grain finer than
|
|
148
|
+
* the file for dead exports and coarser than the byte for context budget.
|
|
149
|
+
* Naming the unit is half the fix: an axis called `symbols` or `bytes` cannot
|
|
150
|
+
* be re-read as a file count the way a bare `rowCount` was.
|
|
151
|
+
*/
|
|
152
|
+
const TREND_UNITS = Object.freeze({
|
|
153
|
+
'bundle-size': ['rawKb', TOTAL],
|
|
154
|
+
coverage: ['filesTracked', TALLY],
|
|
155
|
+
crap: ['filesTracked', TALLY],
|
|
156
|
+
duplication: ['filesTracked', TALLY],
|
|
157
|
+
lighthouse: ['routesTracked', TALLY],
|
|
158
|
+
lint: ['errorCount', TOTAL],
|
|
159
|
+
maintainability: ['filesTracked', TALLY],
|
|
160
|
+
mutation: ['filesTracked', TALLY],
|
|
161
|
+
'arch-cycles': ['cycleMemberships', TOTAL],
|
|
162
|
+
'context-budget': ['bytes', TOTAL],
|
|
163
|
+
cyclomatic: ['filesTracked', TALLY],
|
|
164
|
+
'dead-exports': ['symbols', TOTAL],
|
|
165
|
+
'dead-exports-production': ['symbols', TOTAL],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Per-kind row spec.
|
|
170
|
+
*
|
|
171
|
+
* - `metric` — the axis a hotspot is measured on.
|
|
172
|
+
* - `worse` — which end of that axis is the bad end.
|
|
173
|
+
* - `rows` — baseline → `{ id, value }` pairs, already aggregated where
|
|
174
|
+
* the on-disk grain is finer than the file (dead exports,
|
|
175
|
+
* cycles).
|
|
176
|
+
* - `idKind` — what the cluster key names. Every kind but `lighthouse`
|
|
177
|
+
* (routes) and `bundle-size` (bundle names) keys on a
|
|
178
|
+
* repository file path.
|
|
179
|
+
*/
|
|
180
|
+
export const KIND_SPECS = Object.freeze({
|
|
181
|
+
'bundle-size': {
|
|
182
|
+
metric: 'rawKb',
|
|
183
|
+
worse: 'higher',
|
|
184
|
+
idKind: 'bundle',
|
|
185
|
+
rows: envelopeRows('bundle', 'rawKb'),
|
|
186
|
+
},
|
|
187
|
+
coverage: {
|
|
188
|
+
metric: 'lines',
|
|
189
|
+
worse: 'lower',
|
|
190
|
+
idKind: 'path',
|
|
191
|
+
rows: envelopeRows('path', 'lines'),
|
|
192
|
+
},
|
|
193
|
+
crap: {
|
|
194
|
+
metric: 'crap',
|
|
195
|
+
worse: 'higher',
|
|
196
|
+
idKind: 'path',
|
|
197
|
+
rows: envelopeRows('path', 'crap'),
|
|
198
|
+
},
|
|
199
|
+
duplication: {
|
|
200
|
+
metric: 'percentage',
|
|
201
|
+
worse: 'higher',
|
|
202
|
+
idKind: 'path',
|
|
203
|
+
rows: envelopeRows('path', 'percentage'),
|
|
204
|
+
},
|
|
205
|
+
lighthouse: {
|
|
206
|
+
metric: 'performance',
|
|
207
|
+
worse: 'lower',
|
|
208
|
+
idKind: 'route',
|
|
209
|
+
rows: envelopeRows('route', 'performance'),
|
|
210
|
+
},
|
|
211
|
+
lint: {
|
|
212
|
+
metric: 'errorCount',
|
|
213
|
+
worse: 'higher',
|
|
214
|
+
idKind: 'path',
|
|
215
|
+
rows: envelopeRows('path', 'errorCount'),
|
|
216
|
+
},
|
|
217
|
+
maintainability: {
|
|
218
|
+
metric: 'mi',
|
|
219
|
+
worse: 'lower',
|
|
220
|
+
idKind: 'path',
|
|
221
|
+
rows: envelopeRows('path', 'mi'),
|
|
222
|
+
},
|
|
223
|
+
mutation: {
|
|
224
|
+
metric: 'score',
|
|
225
|
+
worse: 'lower',
|
|
226
|
+
idKind: 'path',
|
|
227
|
+
rows: envelopeRows('path', 'score'),
|
|
228
|
+
},
|
|
229
|
+
'arch-cycles': {
|
|
230
|
+
metric: 'cycleMemberships',
|
|
231
|
+
worse: 'higher',
|
|
232
|
+
idKind: 'path',
|
|
233
|
+
rows: archCycleRows,
|
|
234
|
+
},
|
|
235
|
+
'context-budget': {
|
|
236
|
+
metric: 'bytes',
|
|
237
|
+
worse: 'higher',
|
|
238
|
+
idKind: 'path',
|
|
239
|
+
rows: contextBudgetRows,
|
|
240
|
+
},
|
|
241
|
+
cyclomatic: {
|
|
242
|
+
metric: 'maxCyclomatic',
|
|
243
|
+
worse: 'higher',
|
|
244
|
+
idKind: 'path',
|
|
245
|
+
rows: envelopeRows('file', 'maxCyclomatic'),
|
|
246
|
+
},
|
|
247
|
+
'dead-exports': {
|
|
248
|
+
metric: 'deadExports',
|
|
249
|
+
worse: 'higher',
|
|
250
|
+
idKind: 'path',
|
|
251
|
+
rows: deadExportRows,
|
|
252
|
+
},
|
|
253
|
+
'dead-exports-production': {
|
|
254
|
+
metric: 'deadExports',
|
|
255
|
+
worse: 'higher',
|
|
256
|
+
idKind: 'path',
|
|
257
|
+
rows: deadExportRows,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The whole-repo rollup for a kind, or `null` when the kind does not carry
|
|
263
|
+
* one. Ratchet baselines have no rollup — which is why stub detection asks
|
|
264
|
+
* for an all-zero rollup rather than merely empty rows: an `arch-cycles`
|
|
265
|
+
* baseline with zero cycles is a passing gate, not a dead instrument.
|
|
266
|
+
*
|
|
267
|
+
* @param {object | null} baseline
|
|
268
|
+
* @returns {object | null}
|
|
269
|
+
*/
|
|
270
|
+
export function rollupOf(baseline) {
|
|
271
|
+
const rollup = baseline?.rollup?.['*'];
|
|
272
|
+
return rollup && typeof rollup === 'object' ? rollup : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The whole-repo quantity a kind measures, in the unit it measures it in —
|
|
277
|
+
* `{ unit, value }`, or `null` when the kind or the baseline is unreadable.
|
|
278
|
+
*
|
|
279
|
+
* This sits beside `rowCount` in `gateSurface[]` precisely because the two
|
|
280
|
+
* disagree: `dead-exports-production` carries 589 rows across 187 files, so a
|
|
281
|
+
* lone `rowCount: 187` reads as a symbol count and is not one.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} kind
|
|
284
|
+
* @param {object | null} baseline
|
|
285
|
+
* @returns {{ unit: string, value: number } | null}
|
|
286
|
+
*/
|
|
287
|
+
export function measuredTotalOf(kind, baseline) {
|
|
288
|
+
const spec = KIND_SPECS[kind];
|
|
289
|
+
const denomination = TREND_UNITS[kind];
|
|
290
|
+
if (!baseline || !spec || !denomination) return null;
|
|
291
|
+
const [unit, fold] = denomination;
|
|
292
|
+
return { unit, value: fold(spec.rows(baseline)) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The rollup a trend sample compares on. Gate kinds carry their own, already
|
|
297
|
+
* axis-named; ratchet baselines carry none, so the whole-repo total stands in
|
|
298
|
+
* under its own unit — "dead exports went 590 → 589 **symbols**" is exactly
|
|
299
|
+
* the direction-of-travel question trend answers, and skipping the ratchet
|
|
300
|
+
* half here would leave it visible in `gateSurface[]` but invisible in
|
|
301
|
+
* `trend[]`.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} kind
|
|
304
|
+
* @param {object | null} baseline
|
|
305
|
+
* @returns {object | null}
|
|
306
|
+
*/
|
|
307
|
+
export function trendRollupOf(kind, baseline) {
|
|
308
|
+
if (!baseline) return null;
|
|
309
|
+
const declared = rollupOf(baseline);
|
|
310
|
+
if (declared) return declared;
|
|
311
|
+
const measured = measuredTotalOf(kind, baseline);
|
|
312
|
+
return measured ? { [measured.unit]: measured.value } : null;
|
|
313
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outliers.js — bounded top-N outlier extraction per gate (Story #4902).
|
|
3
|
+
*
|
|
4
|
+
* `baselines/crap.json` alone is ~650KB of per-method rows. The engine must
|
|
5
|
+
* never embed a whole baseline in its envelope, so every kind is narrowed to
|
|
6
|
+
* at most `topN` rows here, before anything downstream sees them.
|
|
7
|
+
*
|
|
8
|
+
* Narrowing happens in two steps:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Aggregate to the cluster grain.** Baseline rows are per-method
|
|
11
|
+
* (crap) or per-symbol (dead exports); hotspots are per-file. Each id
|
|
12
|
+
* keeps its single worst value, plus how many rows it contributed.
|
|
13
|
+
* 2. **Score, then cut.** `severityWeight` is the row's position in its
|
|
14
|
+
* own kind's distribution, from 0 (the best value present) to 1 (the
|
|
15
|
+
* worst). Scoring within the kind is what makes CRAP 29 and MI 74
|
|
16
|
+
* comparable at all — the two axes share no unit, and the whole point
|
|
17
|
+
* of a cluster is to add them up.
|
|
18
|
+
*
|
|
19
|
+
* @module lib/audit-baselines/outliers
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { KIND_SPECS } from './kinds.js';
|
|
23
|
+
|
|
24
|
+
/** Default bound on rows extracted per gate. */
|
|
25
|
+
export const DEFAULT_TOP_N = 20;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fold `{ id, value }` rows to one entry per id, keeping the worst value.
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<{ id: string, value: number }>} rows
|
|
31
|
+
* @param {'higher' | 'lower'} worse
|
|
32
|
+
* @returns {Array<{ id: string, value: number, rowCount: number }>}
|
|
33
|
+
*/
|
|
34
|
+
function aggregateById(rows, worse) {
|
|
35
|
+
const byId = new Map();
|
|
36
|
+
for (const { id, value } of rows) {
|
|
37
|
+
const prev = byId.get(id);
|
|
38
|
+
if (prev === undefined) {
|
|
39
|
+
byId.set(id, { id, value, rowCount: 1 });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
prev.rowCount += 1;
|
|
43
|
+
const isWorse =
|
|
44
|
+
worse === 'higher' ? value > prev.value : value < prev.value;
|
|
45
|
+
if (isWorse) prev.value = value;
|
|
46
|
+
}
|
|
47
|
+
return [...byId.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Position of `value` in `[min, max]` normalized so 1 is always the worst
|
|
52
|
+
* end. A degenerate distribution (every value identical) scores 1 for every
|
|
53
|
+
* row: they are all equally the worst, and equally the best.
|
|
54
|
+
*
|
|
55
|
+
* @param {number} value
|
|
56
|
+
* @param {number} min
|
|
57
|
+
* @param {number} max
|
|
58
|
+
* @param {'higher' | 'lower'} worse
|
|
59
|
+
* @returns {number} 0..1
|
|
60
|
+
*/
|
|
61
|
+
function normalizeSeverity(value, min, max, worse) {
|
|
62
|
+
if (!(max > min)) return 1;
|
|
63
|
+
const ratio = (value - min) / (max - min);
|
|
64
|
+
return worse === 'higher' ? ratio : 1 - ratio;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract the bounded worst-N rows for one kind.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ kind: string, baseline: object | null, topN?: number }} args
|
|
71
|
+
* @returns {Array<{
|
|
72
|
+
* kind: string, id: string, metric: string, value: number,
|
|
73
|
+
* rowCount: number, severityWeight: number,
|
|
74
|
+
* }>} worst first
|
|
75
|
+
*/
|
|
76
|
+
export function extractOutliers({ kind, baseline, topN = DEFAULT_TOP_N }) {
|
|
77
|
+
const spec = KIND_SPECS[kind];
|
|
78
|
+
if (!spec || !baseline) return [];
|
|
79
|
+
const aggregated = aggregateById(spec.rows(baseline), spec.worse);
|
|
80
|
+
if (aggregated.length === 0) return [];
|
|
81
|
+
let min = Number.POSITIVE_INFINITY;
|
|
82
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
83
|
+
for (const row of aggregated) {
|
|
84
|
+
if (row.value < min) min = row.value;
|
|
85
|
+
if (row.value > max) max = row.value;
|
|
86
|
+
}
|
|
87
|
+
return aggregated
|
|
88
|
+
.map((row) => ({
|
|
89
|
+
kind,
|
|
90
|
+
id: row.id,
|
|
91
|
+
metric: spec.metric,
|
|
92
|
+
value: row.value,
|
|
93
|
+
rowCount: row.rowCount,
|
|
94
|
+
severityWeight: normalizeSeverity(row.value, min, max, spec.worse),
|
|
95
|
+
}))
|
|
96
|
+
.sort(
|
|
97
|
+
(a, b) => b.severityWeight - a.severityWeight || a.id.localeCompare(b.id),
|
|
98
|
+
)
|
|
99
|
+
.slice(0, Math.max(0, topN));
|
|
100
|
+
}
|