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.
Files changed (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. 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';