mandrel 2.25.0 → 2.26.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/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 +64 -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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read.js — tolerant, strictly read-only filesystem access for the baseline
|
|
3
|
+
* hotspot engine (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* The engine's job is to *report on* the baseline surface, including the
|
|
6
|
+
* parts of it that are missing or malformed. So no read here throws: a
|
|
7
|
+
* missing file, an unreadable directory, or a JSON parse error is evidence
|
|
8
|
+
* the envelope carries, not a crash. Nothing in this module opens a file for
|
|
9
|
+
* writing — the read-only invariant over `baselines/` starts here.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/audit-baselines/read
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read and parse a JSON file without throwing.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} absolutePath
|
|
21
|
+
* @returns {{ exists: boolean, parsed: object | null, parseError: string | null }}
|
|
22
|
+
*/
|
|
23
|
+
export function readJsonFile(absolutePath) {
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(absolutePath, 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
return { exists: false, parsed: null, parseError: null };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return { exists: true, parsed: JSON.parse(raw), parseError: null };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
exists: true,
|
|
35
|
+
parsed: null,
|
|
36
|
+
parseError: err?.message ?? String(err),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List every file under `rootDir` as a posix repo-relative path, skipping
|
|
43
|
+
* `node_modules`, `.git`, and `.worktrees`. Returns `[]` for an absent or
|
|
44
|
+
* unreadable root rather than throwing.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} repoRoot
|
|
47
|
+
* @param {string} rootDir repo-relative directory to walk
|
|
48
|
+
* @returns {string[]} sorted repo-relative posix paths
|
|
49
|
+
*/
|
|
50
|
+
export function listFilesUnder(repoRoot, rootDir) {
|
|
51
|
+
const skip = new Set(['node_modules', '.git', '.worktrees']);
|
|
52
|
+
const out = [];
|
|
53
|
+
const walk = (abs) => {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (skip.has(entry.name)) continue;
|
|
62
|
+
const child = path.join(abs, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
walk(child);
|
|
65
|
+
} else if (entry.isFile()) {
|
|
66
|
+
out.push(path.relative(repoRoot, child).split(path.sep).join('/'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(path.resolve(repoRoot, rootDir));
|
|
71
|
+
return out.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Age in whole days between `generatedAt` and `now`. Null when the stamp is
|
|
76
|
+
* absent or unparseable — the engine reports "unknown", never a fabricated 0.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} generatedAt
|
|
79
|
+
* @param {Date} now
|
|
80
|
+
* @returns {number | null}
|
|
81
|
+
*/
|
|
82
|
+
export function ageInDays(generatedAt, now) {
|
|
83
|
+
if (typeof generatedAt !== 'string') return null;
|
|
84
|
+
const then = Date.parse(generatedAt);
|
|
85
|
+
if (Number.isNaN(then)) return null;
|
|
86
|
+
return Math.floor((now.getTime() - then) / 86_400_000);
|
|
87
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* staleness.js — the two clocks a baseline is stale against (Story #4962).
|
|
3
|
+
*
|
|
4
|
+
* Wall-clock age answers the wrong question on a busy repository.
|
|
5
|
+
* `coverage.json` and `maintainability.json` both read `staleDays: 0` while
|
|
6
|
+
* already predating merges that had rescored files they still carry rows for:
|
|
7
|
+
* refreshed an hour ago, and behind already. The second clock is the one that
|
|
8
|
+
* matters — how many commits have touched the **measured surface** since the
|
|
9
|
+
* baseline was last committed. A baseline older than the surface it scores is
|
|
10
|
+
* stale at zero wall-clock days.
|
|
11
|
+
*
|
|
12
|
+
* Read-only and degrading, like the rest of this engine: a shallow clone, an
|
|
13
|
+
* untracked baseline, or a surface that is not path-keyed all report `null`.
|
|
14
|
+
* Unknown is never rounded down to a reassuring 0.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/audit-baselines/staleness
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { KIND_SPECS } from './kinds.js';
|
|
21
|
+
import { ageInDays } from './read.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run a git command under `cwd`, returning trimmed stdout, or `null` on any
|
|
25
|
+
* failure or empty result.
|
|
26
|
+
*
|
|
27
|
+
* @param {string[]} args
|
|
28
|
+
* @param {{ cwd: string, run?: Function }} io
|
|
29
|
+
* @returns {string | null}
|
|
30
|
+
*/
|
|
31
|
+
function git(args, { cwd, run = execFileSync }) {
|
|
32
|
+
try {
|
|
33
|
+
const stdout = String(
|
|
34
|
+
run('git', args, {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
}),
|
|
40
|
+
).trim();
|
|
41
|
+
return stdout.length > 0 ? stdout : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The repo-relative paths whose commits would invalidate this baseline: the
|
|
49
|
+
* gate's declared `targetDirs` when it has them, else the baseline's own row
|
|
50
|
+
* ids — which are file paths for every kind but `lighthouse` (routes) and
|
|
51
|
+
* `bundle-size` (bundle names). Those two get an empty surface rather than a
|
|
52
|
+
* route name handed to git as a pathspec.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ kind: string, gateBlock: object | null, rows: Array<{id: string}> }} args
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
function measuredSurfaceOf({ kind, gateBlock, rows }) {
|
|
58
|
+
const dirs = (gateBlock?.targetDirs ?? []).filter(
|
|
59
|
+
(dir) => typeof dir === 'string' && dir.length > 0,
|
|
60
|
+
);
|
|
61
|
+
if (dirs.length > 0) return dirs;
|
|
62
|
+
if (KIND_SPECS[kind]?.idKind !== 'path') return [];
|
|
63
|
+
return [...new Set(rows.map((row) => row.id))];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Commits touching `surfacePaths` since `relPath` was last committed. An
|
|
68
|
+
* empty surface yields `null` too: nothing was checked, so nothing can be
|
|
69
|
+
* claimed.
|
|
70
|
+
*
|
|
71
|
+
* @param {{ relPath: string, surfacePaths: string[], io: object }} args
|
|
72
|
+
* @returns {number | null}
|
|
73
|
+
*/
|
|
74
|
+
function commitsSinceBaseline({ relPath, surfacePaths, io }) {
|
|
75
|
+
if (surfacePaths.length === 0) return null;
|
|
76
|
+
const writtenAt = git(['log', '-n1', '--format=%H', '--', relPath], io);
|
|
77
|
+
if (!writtenAt) return null;
|
|
78
|
+
const counted = git(
|
|
79
|
+
['rev-list', '--count', `${writtenAt}..HEAD`, '--', ...surfacePaths],
|
|
80
|
+
io,
|
|
81
|
+
);
|
|
82
|
+
const commits = Number.parseInt(counted ?? '', 10);
|
|
83
|
+
return Number.isInteger(commits) ? commits : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The four staleness fields of one `gateSurface[]` entry, on both clocks.
|
|
88
|
+
*
|
|
89
|
+
* @param {{
|
|
90
|
+
* kind: string, gateBlock: object | null, rows: Array<{id: string}>,
|
|
91
|
+
* relPath: string, baseline: object | null, now: Date,
|
|
92
|
+
* io: { cwd: string, run?: Function },
|
|
93
|
+
* }} args
|
|
94
|
+
* @returns {{
|
|
95
|
+
* generatedAt: string | null, staleDays: number | null,
|
|
96
|
+
* staleCommits: number | null, surfaceStale: boolean | null,
|
|
97
|
+
* }}
|
|
98
|
+
*/
|
|
99
|
+
export function stalenessOf({
|
|
100
|
+
kind,
|
|
101
|
+
gateBlock,
|
|
102
|
+
rows,
|
|
103
|
+
relPath,
|
|
104
|
+
baseline,
|
|
105
|
+
now,
|
|
106
|
+
io,
|
|
107
|
+
}) {
|
|
108
|
+
const generatedAt =
|
|
109
|
+
typeof baseline?.generatedAt === 'string' ? baseline.generatedAt : null;
|
|
110
|
+
const staleCommits = baseline
|
|
111
|
+
? commitsSinceBaseline({
|
|
112
|
+
relPath,
|
|
113
|
+
surfacePaths: measuredSurfaceOf({ kind, gateBlock, rows }),
|
|
114
|
+
io,
|
|
115
|
+
})
|
|
116
|
+
: null;
|
|
117
|
+
return {
|
|
118
|
+
generatedAt,
|
|
119
|
+
staleDays: ageInDays(generatedAt, now),
|
|
120
|
+
staleCommits,
|
|
121
|
+
surfaceStale: staleCommits === null ? null : staleCommits > 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-entry.js — the health report for one measuring instrument
|
|
3
|
+
* (Story #4902, split out in #4962).
|
|
4
|
+
*
|
|
5
|
+
* A baseline review that only reads the numbers cannot see the failure mode
|
|
6
|
+
* that matters most: an instrument that is not measuring anything. A gate can
|
|
7
|
+
* be unconfigured, its baseline file can be missing, it can be a **stub**
|
|
8
|
+
* (committed with no rows and an all-zero rollup, so it passes every run
|
|
9
|
+
* vacuously), it can be stale on either clock, and its `ignoreGlobs` can name
|
|
10
|
+
* paths that no longer exist — each of which reads as "green" from the gate's
|
|
11
|
+
* exit code. This module turns all of that into declarative fields for one
|
|
12
|
+
* kind; [`gate-surface.js`](gate-surface.js) walks the kinds and
|
|
13
|
+
* [`staleness.js`](staleness.js) owns the two clocks.
|
|
14
|
+
*
|
|
15
|
+
* @module lib/audit-baselines/surface-entry
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import picomatch from 'picomatch';
|
|
19
|
+
import { GATE_KINDS, KIND_SPECS, measuredTotalOf, rollupOf } from './kinds.js';
|
|
20
|
+
import { stalenessOf } from './staleness.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when every numeric leaf of the rollup is zero. A rollup with no
|
|
24
|
+
* numeric leaves at all is not all-zero — it carries no measurement to call
|
|
25
|
+
* zero, and treating it as such would flag shapes this engine cannot read.
|
|
26
|
+
*
|
|
27
|
+
* @param {object | null} rollup
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function isAllZeroRollup(rollup) {
|
|
31
|
+
if (!rollup || typeof rollup !== 'object') return false;
|
|
32
|
+
const numbers = Object.values(rollup).filter((v) => typeof v === 'number');
|
|
33
|
+
return numbers.length > 0 && numbers.every((v) => v === 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A **stub instrument**: zero rows AND an all-zero rollup. Both halves are
|
|
38
|
+
* required. Ratchet baselines carry no rollup, so a clean `arch-cycles`
|
|
39
|
+
* allowlist — genuinely zero cycles, the success state — is never mistaken
|
|
40
|
+
* for a dead instrument.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ rowCount: number, rollup: object | null }} args
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function isStubInstrument({ rowCount, rollup }) {
|
|
46
|
+
return rowCount === 0 && isAllZeroRollup(rollup);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which of a gate's configured `ignoreGlobs` match zero files on disk.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} ignoreGlobs
|
|
53
|
+
* @param {string[]} files repo-relative posix paths
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
function findDeadIgnoreGlobs(ignoreGlobs, files) {
|
|
57
|
+
const dead = [];
|
|
58
|
+
for (const glob of ignoreGlobs ?? []) {
|
|
59
|
+
if (typeof glob !== 'string' || glob.length === 0) continue;
|
|
60
|
+
const isMatch = picomatch(glob, { dot: true });
|
|
61
|
+
if (!files.some((f) => isMatch(f))) dead.push(glob);
|
|
62
|
+
}
|
|
63
|
+
return dead;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Assemble one `gateSurface[]` entry from an already-read baseline.
|
|
68
|
+
*
|
|
69
|
+
* `rowCount` and `measured` both appear because they legitimately disagree:
|
|
70
|
+
* rows are counted after the kind's per-file aggregation, while `measured` is
|
|
71
|
+
* the quantity the instrument reports in its own unit — 589 dead-export
|
|
72
|
+
* symbols sit in 187 files.
|
|
73
|
+
*
|
|
74
|
+
* @param {{
|
|
75
|
+
* kind: string, quality: object, read: object, relPath: string,
|
|
76
|
+
* files: string[], now: Date, io: { cwd: string, run?: Function },
|
|
77
|
+
* }} args
|
|
78
|
+
* @returns {object}
|
|
79
|
+
*/
|
|
80
|
+
export function surfaceEntryFor({
|
|
81
|
+
kind,
|
|
82
|
+
quality,
|
|
83
|
+
read,
|
|
84
|
+
relPath,
|
|
85
|
+
files,
|
|
86
|
+
now,
|
|
87
|
+
io,
|
|
88
|
+
}) {
|
|
89
|
+
const gateBlock = quality?.gates?.[kind] ?? null;
|
|
90
|
+
const { exists, parsed: baseline, parseError } = read;
|
|
91
|
+
const rows = baseline ? KIND_SPECS[kind].rows(baseline) : [];
|
|
92
|
+
const rollup = rollupOf(baseline);
|
|
93
|
+
return {
|
|
94
|
+
kind,
|
|
95
|
+
surface: GATE_KINDS.includes(kind) ? 'gate' : 'ratchet',
|
|
96
|
+
baselinePath: relPath,
|
|
97
|
+
configured: gateBlock !== null && typeof gateBlock === 'object',
|
|
98
|
+
baselineExists: exists,
|
|
99
|
+
stub: isStubInstrument({ rowCount: rows.length, rollup }),
|
|
100
|
+
rowCount: rows.length,
|
|
101
|
+
measured: measuredTotalOf(kind, baseline),
|
|
102
|
+
...stalenessOf({ kind, gateBlock, rows, relPath, baseline, now, io }),
|
|
103
|
+
deadIgnoreGlobs: findDeadIgnoreGlobs(gateBlock?.ignoreGlobs, files),
|
|
104
|
+
parseError,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trend.js — per-kind rollup deltas read out of each baseline file's own git
|
|
3
|
+
* history (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* A ratchet only tells you whether today is worse than yesterday. The
|
|
6
|
+
* question a baseline review asks is the other one: which direction has this
|
|
7
|
+
* number been moving, and is the ratchet actually ratcheting? That answer is
|
|
8
|
+
* already committed — every baseline refresh is a commit against the same
|
|
9
|
+
* path — so it is read with `git log` + `git show`, never recomputed by
|
|
10
|
+
* re-running an instrument.
|
|
11
|
+
*
|
|
12
|
+
* Degradation: any git failure (shallow clone, no history for the path, not
|
|
13
|
+
* a work tree) yields an empty `trend[]` and exit 0. A missing history is
|
|
14
|
+
* missing evidence, not an error.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/audit-baselines/trend
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { trendRollupOf } from './kinds.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* List the most recent commits touching `relPath`, newest first.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ cwd: string, relPath: string, limit: number, run?: Function }} args
|
|
26
|
+
* @returns {Array<{ sha: string, committedAt: string }>}
|
|
27
|
+
*/
|
|
28
|
+
function listCommits({ cwd, relPath, limit, run = execFileSync }) {
|
|
29
|
+
let stdout;
|
|
30
|
+
try {
|
|
31
|
+
stdout = run(
|
|
32
|
+
'git',
|
|
33
|
+
['log', `-n${limit}`, '--format=%H %cI', '--', relPath],
|
|
34
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return String(stdout)
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.includes(' '))
|
|
43
|
+
.map((line) => {
|
|
44
|
+
const [sha, committedAt] = line.split(' ');
|
|
45
|
+
return { sha, committedAt };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the comparable rollup of a baseline as it existed at `sha`.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ cwd: string, kind: string, sha: string, relPath: string, run?: Function }} args
|
|
53
|
+
* @returns {object | null}
|
|
54
|
+
*/
|
|
55
|
+
function rollupAt({ cwd, kind, sha, relPath, run = execFileSync }) {
|
|
56
|
+
let stdout;
|
|
57
|
+
try {
|
|
58
|
+
stdout = run('git', ['show', `${sha}:${relPath}`], {
|
|
59
|
+
cwd,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return trendRollupOf(kind, JSON.parse(stdout));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Numeric axis-by-axis difference `to - from`. Axes missing from either side
|
|
76
|
+
* are omitted rather than defaulted — a rollup that gained an axis has no
|
|
77
|
+
* delta on it, and inventing one would read as a regression.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} from
|
|
80
|
+
* @param {object} to
|
|
81
|
+
* @returns {Record<string, number>}
|
|
82
|
+
*/
|
|
83
|
+
function rollupDelta(from, to) {
|
|
84
|
+
const deltas = {};
|
|
85
|
+
for (const [axis, current] of Object.entries(to ?? {})) {
|
|
86
|
+
const previous = from?.[axis];
|
|
87
|
+
if (typeof current === 'number' && typeof previous === 'number') {
|
|
88
|
+
deltas[axis] = current - previous;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return deltas;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the `trend[]` section: newest-vs-previous rollup deltas per kind.
|
|
96
|
+
*
|
|
97
|
+
* @param {{
|
|
98
|
+
* cwd: string, kinds: string[], pathFor: (kind: string) => string,
|
|
99
|
+
* depth?: number, run?: Function,
|
|
100
|
+
* }} args
|
|
101
|
+
* @returns {Array<object>}
|
|
102
|
+
*/
|
|
103
|
+
export function buildTrend({ cwd, kinds, pathFor, depth = 5, run }) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const kind of kinds) {
|
|
106
|
+
const relPath = pathFor(kind);
|
|
107
|
+
const commits = listCommits({ cwd, relPath, limit: depth, run });
|
|
108
|
+
const samples = [];
|
|
109
|
+
for (const commit of commits) {
|
|
110
|
+
const rollup = rollupAt({ cwd, kind, sha: commit.sha, relPath, run });
|
|
111
|
+
if (rollup) samples.push({ ...commit, rollup });
|
|
112
|
+
}
|
|
113
|
+
if (samples.length < 2) continue;
|
|
114
|
+
const [current, previous] = samples;
|
|
115
|
+
out.push({
|
|
116
|
+
kind,
|
|
117
|
+
baselinePath: relPath,
|
|
118
|
+
sampleCount: samples.length,
|
|
119
|
+
from: { sha: previous.sha, committedAt: previous.committedAt },
|
|
120
|
+
to: { sha: current.sha, committedAt: current.committedAt },
|
|
121
|
+
deltas: rollupDelta(previous.rollup, current.rollup),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* weights.js — the three ranking multipliers, and their degradations
|
|
3
|
+
* (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* A hotspot's severity says how bad the code measures. These three say how
|
|
6
|
+
* much that badness costs: how often the file changes (churn), how much of
|
|
7
|
+
* the repository depends on it (import in-degree), and how often agents have
|
|
8
|
+
* actually tripped over it (friction signals).
|
|
9
|
+
*
|
|
10
|
+
* All three are **optional inputs**. A shallow clone has no git history, a
|
|
11
|
+
* fresh checkout has no friction ledger, and a consumer whose sources live
|
|
12
|
+
* outside the scanned roots has no resolvable import graph. Each degrades to
|
|
13
|
+
* a neutral multiplier of exactly 1.0 — never 0 (which would erase the
|
|
14
|
+
* hotspot) and never a guess. The engine exits 0 in every degraded case.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/audit-baselines/weights
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { computeInDegree, resolveRepoGraph } from '../import-graph.js';
|
|
23
|
+
|
|
24
|
+
/** Neutral multiplier every degraded weight collapses to. */
|
|
25
|
+
const NEUTRAL_WEIGHT = 1.0;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Saturating count → multiplier in `[1, 2)`. Zero observations gives exactly
|
|
29
|
+
* `NEUTRAL_WEIGHT`, so "no signal" and "signal says nothing notable" are the
|
|
30
|
+
* same number — the degradation is indistinguishable from an honest zero,
|
|
31
|
+
* which is the point: neither should move the ranking.
|
|
32
|
+
*
|
|
33
|
+
* @param {number} count
|
|
34
|
+
* @param {number} half count at which the multiplier reaches 1.5
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
function saturate(count, half) {
|
|
38
|
+
if (!Number.isFinite(count) || count <= 0) return NEUTRAL_WEIGHT;
|
|
39
|
+
return NEUTRAL_WEIGHT + count / (count + half);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Count commits touching each file in the recent history window.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ cwd: string, windowDays?: number, run?: Function }} args
|
|
46
|
+
* @returns {{ counts: Map<string, number>, degraded: boolean }}
|
|
47
|
+
* `degraded` is true when git could not answer at all — not a git work
|
|
48
|
+
* tree, no commits yet, or the binary is unavailable.
|
|
49
|
+
*/
|
|
50
|
+
export function readChurn({ cwd, windowDays = 180, run = execFileSync }) {
|
|
51
|
+
let stdout;
|
|
52
|
+
try {
|
|
53
|
+
stdout = run(
|
|
54
|
+
'git',
|
|
55
|
+
[
|
|
56
|
+
'log',
|
|
57
|
+
`--since=${windowDays}.days.ago`,
|
|
58
|
+
'--name-only',
|
|
59
|
+
'--pretty=format:',
|
|
60
|
+
'--no-renames',
|
|
61
|
+
],
|
|
62
|
+
{
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
66
|
+
// git narrates "not a git repository" on stderr; the degradation is
|
|
67
|
+
// reported in the envelope, not shouted at the operator.
|
|
68
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
} catch {
|
|
72
|
+
return { counts: new Map(), degraded: true };
|
|
73
|
+
}
|
|
74
|
+
const counts = new Map();
|
|
75
|
+
for (const line of String(stdout).split('\n')) {
|
|
76
|
+
const file = line.trim();
|
|
77
|
+
if (file.length === 0) continue;
|
|
78
|
+
counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
return { counts, degraded: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Import in-degree per module, keyed by repo-relative posix path.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ cwd: string, graph?: Map<string, string[]> | null }} args
|
|
87
|
+
* @returns {{ degrees: Map<string, number>, degraded: boolean }}
|
|
88
|
+
*/
|
|
89
|
+
export function readCentrality({ cwd, graph }) {
|
|
90
|
+
const resolved = graph === undefined ? resolveRepoGraph(cwd) : graph;
|
|
91
|
+
if (!resolved) return { degrees: new Map(), degraded: true };
|
|
92
|
+
return { degrees: computeInDegree(resolved), degraded: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Path tokens that look like repository files, harvested from signal text. */
|
|
96
|
+
const PATH_TOKEN_RE = /[\w@][\w./@-]*\.(?:js|mjs|cjs|ts|tsx|json|md)\b/g;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Collect every `signals.ndjson` under `tempRoot`. Absent tree → empty list.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} tempRootAbs
|
|
102
|
+
* @returns {string[]} absolute paths
|
|
103
|
+
*/
|
|
104
|
+
function findSignalStreams(tempRootAbs) {
|
|
105
|
+
const out = [];
|
|
106
|
+
const walk = (dir, depth) => {
|
|
107
|
+
if (depth > 6) return;
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const child = path.join(dir, entry.name);
|
|
116
|
+
if (entry.isDirectory()) walk(child, depth + 1);
|
|
117
|
+
else if (entry.name === 'signals.ndjson') out.push(child);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
walk(tempRootAbs, 0);
|
|
121
|
+
return out.sort();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Count friction signals blaming each file.
|
|
126
|
+
*
|
|
127
|
+
* Signal records carry no dedicated path field — the blamed file surfaces
|
|
128
|
+
* inside free-form `details` / `emitter.command` text — so paths are
|
|
129
|
+
* harvested by token scan over each record's serialized form. A malformed
|
|
130
|
+
* line is skipped, never fatal.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ tempRootAbs: string, kinds?: Set<string> }} args
|
|
133
|
+
* @returns {{ counts: Map<string, number>, degraded: boolean, streams: number }}
|
|
134
|
+
*/
|
|
135
|
+
export function readFriction({
|
|
136
|
+
tempRootAbs,
|
|
137
|
+
kinds = new Set(['friction', 'hotspot', 'rework', 'churn', 'retry']),
|
|
138
|
+
}) {
|
|
139
|
+
const streams = findSignalStreams(tempRootAbs);
|
|
140
|
+
if (streams.length === 0) {
|
|
141
|
+
return { counts: new Map(), degraded: true, streams: 0 };
|
|
142
|
+
}
|
|
143
|
+
const counts = new Map();
|
|
144
|
+
for (const stream of streams) {
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = fs.readFileSync(stream, 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
for (const line of raw.split('\n')) {
|
|
152
|
+
if (line.trim().length === 0) continue;
|
|
153
|
+
let record;
|
|
154
|
+
try {
|
|
155
|
+
record = JSON.parse(line);
|
|
156
|
+
} catch {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!kinds.has(record?.kind)) continue;
|
|
160
|
+
const text =
|
|
161
|
+
JSON.stringify(record.details ?? {}) + (record?.emitter?.command ?? '');
|
|
162
|
+
for (const token of text.match(PATH_TOKEN_RE) ?? []) {
|
|
163
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { counts, degraded: false, streams: streams.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bundle the three weight lookups into one resolver the hotspot builder can
|
|
172
|
+
* call per cluster id, plus the degradation flags the envelope reports.
|
|
173
|
+
*
|
|
174
|
+
* @param {{
|
|
175
|
+
* churn: { counts: Map<string, number>, degraded: boolean },
|
|
176
|
+
* centrality: { degrees: Map<string, number>, degraded: boolean },
|
|
177
|
+
* friction: { counts: Map<string, number>, degraded: boolean },
|
|
178
|
+
* }} sources
|
|
179
|
+
* @returns {(id: string) => { churnWeight: number, centralityWeight: number, frictionWeight: number }}
|
|
180
|
+
*/
|
|
181
|
+
export function makeWeightResolver({ churn, centrality, friction }) {
|
|
182
|
+
return (id) => ({
|
|
183
|
+
churnWeight: churn.degraded
|
|
184
|
+
? NEUTRAL_WEIGHT
|
|
185
|
+
: saturate(churn.counts.get(id) ?? 0, 12),
|
|
186
|
+
centralityWeight: centrality.degraded
|
|
187
|
+
? NEUTRAL_WEIGHT
|
|
188
|
+
: saturate(centrality.degrees.get(id) ?? 0, 8),
|
|
189
|
+
frictionWeight: friction.degraded
|
|
190
|
+
? NEUTRAL_WEIGHT
|
|
191
|
+
: saturate(friction.counts.get(id) ?? 0, 4),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -27,15 +27,10 @@ export {
|
|
|
27
27
|
} from './lens-diff-floor.js';
|
|
28
28
|
export { runAuditSuite } from './runner.js';
|
|
29
29
|
export {
|
|
30
|
-
GLOBAL_LENS_ALLOWLIST,
|
|
31
|
-
isGlobalLens,
|
|
32
30
|
LENS_TIERS,
|
|
33
31
|
matchesAnyFilePattern,
|
|
34
32
|
matchesFilePattern,
|
|
35
|
-
NAVIGABILITY_LENS,
|
|
36
33
|
resolveLensTier,
|
|
37
|
-
resolveNavigabilityRouteGlobs,
|
|
38
|
-
routesNavigabilityLens,
|
|
39
34
|
selectAudits,
|
|
40
35
|
selectLocalLenses,
|
|
41
36
|
} from './selector.js';
|