mandrel 2.24.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -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
+ }
@@ -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
+ }