@nforma.ai/nforma 0.2.1 → 0.28.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 (201) hide show
  1. package/README.md +2 -2
  2. package/agents/{qgsd-codebase-mapper.md → nf-codebase-mapper.md} +1 -1
  3. package/agents/{qgsd-debugger.md → nf-debugger.md} +3 -3
  4. package/agents/{qgsd-executor.md → nf-executor.md} +14 -14
  5. package/agents/{qgsd-integration-checker.md → nf-integration-checker.md} +1 -1
  6. package/agents/{qgsd-phase-researcher.md → nf-phase-researcher.md} +6 -6
  7. package/agents/{qgsd-plan-checker.md → nf-plan-checker.md} +9 -9
  8. package/agents/{qgsd-planner.md → nf-planner.md} +9 -9
  9. package/agents/{qgsd-project-researcher.md → nf-project-researcher.md} +2 -2
  10. package/agents/{qgsd-quorum-orchestrator.md → nf-quorum-orchestrator.md} +33 -33
  11. package/agents/{qgsd-quorum-slot-worker.md → nf-quorum-slot-worker.md} +3 -3
  12. package/agents/{qgsd-quorum-synthesizer.md → nf-quorum-synthesizer.md} +3 -3
  13. package/agents/{qgsd-quorum-test-worker.md → nf-quorum-test-worker.md} +1 -1
  14. package/agents/{qgsd-quorum-worker.md → nf-quorum-worker.md} +6 -6
  15. package/agents/{qgsd-research-synthesizer.md → nf-research-synthesizer.md} +5 -5
  16. package/agents/{qgsd-roadmapper.md → nf-roadmapper.md} +3 -3
  17. package/agents/{qgsd-verifier.md → nf-verifier.md} +8 -8
  18. package/bin/accept-debug-invariant.cjs +2 -2
  19. package/bin/account-manager.cjs +10 -10
  20. package/bin/aggregate-requirements.cjs +1 -1
  21. package/bin/analyze-assumptions.cjs +3 -3
  22. package/bin/analyze-state-space.cjs +14 -14
  23. package/bin/assumption-register.cjs +146 -0
  24. package/bin/attribute-trace-divergence.cjs +1 -1
  25. package/bin/auth-drivers/gh-cli.cjs +1 -1
  26. package/bin/auth-drivers/pool.cjs +1 -1
  27. package/bin/autoClosePtoF.cjs +3 -3
  28. package/bin/budget-tracker.cjs +77 -0
  29. package/bin/build-layer-manifest.cjs +153 -0
  30. package/bin/call-quorum-slot.cjs +3 -3
  31. package/bin/ccr-secure-config.cjs +5 -5
  32. package/bin/check-bundled-sdks.cjs +1 -1
  33. package/bin/check-mcp-health.cjs +1 -1
  34. package/bin/check-provider-health.cjs +6 -6
  35. package/bin/check-spec-sync.cjs +26 -26
  36. package/bin/check-trace-schema-drift.cjs +5 -5
  37. package/bin/conformance-schema.cjs +2 -2
  38. package/bin/cross-layer-dashboard.cjs +297 -0
  39. package/bin/design-impact.cjs +377 -0
  40. package/bin/detect-coverage-gaps.cjs +7 -7
  41. package/bin/failure-mode-catalog.cjs +227 -0
  42. package/bin/failure-taxonomy.cjs +177 -0
  43. package/bin/formal-scope-scan.cjs +179 -0
  44. package/bin/gate-a-grounding.cjs +334 -0
  45. package/bin/gate-b-abstraction.cjs +243 -0
  46. package/bin/gate-c-validation.cjs +166 -0
  47. package/bin/generate-formal-specs.cjs +17 -17
  48. package/bin/generate-petri-net.cjs +3 -3
  49. package/bin/generate-tla-cfg.cjs +5 -5
  50. package/bin/git-heatmap.cjs +571 -0
  51. package/bin/harness-diagnostic.cjs +326 -0
  52. package/bin/hazard-model.cjs +261 -0
  53. package/bin/install-formal-tools.cjs +1 -1
  54. package/bin/install.js +184 -139
  55. package/bin/instrumentation-map.cjs +178 -0
  56. package/bin/invariant-catalog.cjs +437 -0
  57. package/bin/issue-classifier.cjs +2 -2
  58. package/bin/load-baseline-requirements.cjs +4 -4
  59. package/bin/manage-agents-core.cjs +32 -32
  60. package/bin/migrate-to-slots.cjs +39 -39
  61. package/bin/mismatch-register.cjs +217 -0
  62. package/bin/nForma.cjs +176 -81
  63. package/bin/{qgsd-solve.cjs → nf-solve.cjs} +327 -14
  64. package/bin/observe-config.cjs +8 -0
  65. package/bin/observe-debt-writer.cjs +1 -1
  66. package/bin/observe-handler-deps.cjs +356 -0
  67. package/bin/observe-handler-grafana.cjs +2 -17
  68. package/bin/observe-handler-internal.cjs +5 -5
  69. package/bin/observe-handler-logstash.cjs +2 -17
  70. package/bin/observe-handler-prometheus.cjs +2 -17
  71. package/bin/observe-handler-upstream.cjs +251 -0
  72. package/bin/observe-handlers.cjs +12 -33
  73. package/bin/observe-render.cjs +68 -22
  74. package/bin/observe-utils.cjs +37 -0
  75. package/bin/observed-fsm.cjs +324 -0
  76. package/bin/planning-paths.cjs +6 -0
  77. package/bin/polyrepo.cjs +1 -1
  78. package/bin/probe-quorum-slots.cjs +1 -1
  79. package/bin/promote-gate-maturity.cjs +274 -0
  80. package/bin/promote-model.cjs +1 -1
  81. package/bin/propose-debug-invariants.cjs +1 -1
  82. package/bin/quorum-cache.cjs +144 -0
  83. package/bin/quorum-consensus-gate.cjs +1 -1
  84. package/bin/quorum-slot-dispatch.cjs +6 -6
  85. package/bin/requirements-core.cjs +1 -1
  86. package/bin/review-mcp-logs.cjs +1 -1
  87. package/bin/risk-heatmap.cjs +151 -0
  88. package/bin/run-account-manager-tlc.cjs +4 -4
  89. package/bin/run-account-pool-alloy.cjs +2 -2
  90. package/bin/run-alloy.cjs +2 -2
  91. package/bin/run-audit-alloy.cjs +2 -2
  92. package/bin/run-breaker-tlc.cjs +3 -3
  93. package/bin/run-formal-check.cjs +9 -9
  94. package/bin/run-formal-verify.cjs +30 -9
  95. package/bin/run-installer-alloy.cjs +2 -2
  96. package/bin/run-oscillation-tlc.cjs +4 -4
  97. package/bin/run-phase-tlc.cjs +1 -1
  98. package/bin/run-protocol-tlc.cjs +4 -4
  99. package/bin/run-quorum-composition-alloy.cjs +2 -2
  100. package/bin/run-sensitivity-sweep.cjs +2 -2
  101. package/bin/run-stop-hook-tlc.cjs +3 -3
  102. package/bin/run-tlc.cjs +21 -21
  103. package/bin/run-transcript-alloy.cjs +2 -2
  104. package/bin/secrets.cjs +5 -5
  105. package/bin/security-sweep.cjs +238 -0
  106. package/bin/sensitivity-report.cjs +3 -3
  107. package/bin/set-secret.cjs +5 -5
  108. package/bin/setup-telemetry-cron.sh +3 -3
  109. package/bin/stall-detector.cjs +126 -0
  110. package/bin/state-candidates.cjs +206 -0
  111. package/bin/sync-baseline-requirements.cjs +1 -1
  112. package/bin/telemetry-collector.cjs +1 -1
  113. package/bin/test-changed.cjs +111 -0
  114. package/bin/test-recipe-gen.cjs +250 -0
  115. package/bin/trace-corpus-stats.cjs +211 -0
  116. package/bin/unified-mcp-server.mjs +3 -3
  117. package/bin/update-scoreboard.cjs +1 -1
  118. package/bin/validate-memory.cjs +2 -2
  119. package/bin/validate-traces.cjs +10 -10
  120. package/bin/verify-quorum-health.cjs +66 -5
  121. package/bin/xstate-to-tla.cjs +4 -4
  122. package/bin/xstate-trace-walker.cjs +3 -3
  123. package/commands/{qgsd → nf}/add-phase.md +3 -3
  124. package/commands/{qgsd → nf}/add-requirement.md +3 -3
  125. package/commands/{qgsd → nf}/add-todo.md +3 -3
  126. package/commands/{qgsd → nf}/audit-milestone.md +4 -4
  127. package/commands/{qgsd → nf}/check-todos.md +3 -3
  128. package/commands/{qgsd → nf}/cleanup.md +3 -3
  129. package/commands/{qgsd → nf}/close-formal-gaps.md +2 -2
  130. package/commands/{qgsd → nf}/complete-milestone.md +9 -9
  131. package/commands/{qgsd → nf}/debug.md +9 -9
  132. package/commands/{qgsd → nf}/discuss-phase.md +3 -3
  133. package/commands/{qgsd → nf}/execute-phase.md +15 -15
  134. package/commands/{qgsd → nf}/fix-tests.md +3 -3
  135. package/commands/{qgsd → nf}/formal-test-sync.md +1 -1
  136. package/commands/{qgsd → nf}/health.md +3 -3
  137. package/commands/{qgsd → nf}/help.md +3 -3
  138. package/commands/{qgsd → nf}/insert-phase.md +3 -3
  139. package/commands/nf/join-discord.md +18 -0
  140. package/commands/{qgsd → nf}/list-phase-assumptions.md +2 -2
  141. package/commands/{qgsd → nf}/map-codebase.md +7 -7
  142. package/commands/{qgsd → nf}/map-requirements.md +3 -3
  143. package/commands/{qgsd → nf}/mcp-restart.md +3 -3
  144. package/commands/{qgsd → nf}/mcp-set-model.md +8 -8
  145. package/commands/{qgsd → nf}/mcp-setup.md +63 -63
  146. package/commands/{qgsd → nf}/mcp-status.md +3 -3
  147. package/commands/{qgsd → nf}/mcp-update.md +7 -7
  148. package/commands/{qgsd → nf}/new-milestone.md +8 -8
  149. package/commands/{qgsd → nf}/new-project.md +8 -8
  150. package/commands/{qgsd → nf}/observe.md +49 -16
  151. package/commands/{qgsd → nf}/pause-work.md +3 -3
  152. package/commands/{qgsd → nf}/plan-milestone-gaps.md +5 -5
  153. package/commands/{qgsd → nf}/plan-phase.md +6 -6
  154. package/commands/{qgsd → nf}/polyrepo.md +2 -2
  155. package/commands/{qgsd → nf}/progress.md +3 -3
  156. package/commands/{qgsd → nf}/queue.md +2 -2
  157. package/commands/{qgsd → nf}/quick.md +8 -8
  158. package/commands/{qgsd → nf}/quorum-test.md +10 -10
  159. package/commands/{qgsd → nf}/quorum.md +40 -40
  160. package/commands/{qgsd → nf}/reapply-patches.md +2 -2
  161. package/commands/{qgsd → nf}/remove-phase.md +3 -3
  162. package/commands/{qgsd → nf}/research-phase.md +12 -12
  163. package/commands/{qgsd → nf}/resume-work.md +3 -3
  164. package/commands/nf/review-requirements.md +31 -0
  165. package/commands/{qgsd → nf}/set-profile.md +3 -3
  166. package/commands/{qgsd → nf}/settings.md +6 -6
  167. package/commands/{qgsd → nf}/solve.md +35 -35
  168. package/commands/{qgsd → nf}/sync-baselines.md +4 -4
  169. package/commands/{qgsd → nf}/triage.md +10 -10
  170. package/commands/{qgsd → nf}/update.md +3 -3
  171. package/commands/{qgsd → nf}/verify-work.md +5 -5
  172. package/hooks/dist/config-loader.js +188 -32
  173. package/hooks/dist/conformance-schema.cjs +2 -2
  174. package/hooks/dist/gsd-context-monitor.js +118 -13
  175. package/hooks/dist/{qgsd-check-update.js → nf-check-update.js} +5 -5
  176. package/hooks/dist/{qgsd-circuit-breaker.js → nf-circuit-breaker.js} +35 -24
  177. package/hooks/dist/nf-circuit-breaker.test.js +1002 -0
  178. package/hooks/dist/{qgsd-precompact.js → nf-precompact.js} +13 -13
  179. package/hooks/dist/nf-precompact.test.js +227 -0
  180. package/hooks/dist/{qgsd-prompt.js → nf-prompt.js} +110 -33
  181. package/hooks/dist/nf-prompt.test.js +698 -0
  182. package/hooks/dist/nf-session-start.js +185 -0
  183. package/hooks/dist/nf-session-start.test.js +354 -0
  184. package/hooks/dist/{qgsd-slot-correlator.js → nf-slot-correlator.js} +13 -5
  185. package/hooks/dist/nf-slot-correlator.test.js +85 -0
  186. package/hooks/dist/{qgsd-spec-regen.js → nf-spec-regen.js} +17 -8
  187. package/hooks/dist/nf-spec-regen.test.js +73 -0
  188. package/hooks/dist/{qgsd-statusline.js → nf-statusline.js} +12 -3
  189. package/hooks/dist/nf-statusline.test.js +157 -0
  190. package/hooks/dist/{qgsd-stop.js → nf-stop.js} +152 -18
  191. package/hooks/dist/nf-stop.test.js +1388 -0
  192. package/hooks/dist/{qgsd-token-collector.js → nf-token-collector.js} +12 -4
  193. package/hooks/dist/nf-token-collector.test.js +262 -0
  194. package/hooks/dist/unified-mcp-server.mjs +2 -2
  195. package/package.json +4 -4
  196. package/scripts/build-hooks.js +13 -6
  197. package/scripts/secret-audit.sh +1 -1
  198. package/scripts/verify-hooks-sync.cjs +90 -0
  199. package/templates/{qgsd.json → nf.json} +4 -4
  200. package/commands/qgsd/join-discord.md +0 -18
  201. package/hooks/dist/qgsd-session-start.js +0 -122
@@ -0,0 +1,571 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * git-heatmap.cjs — Mine git history for numerical adjustments, bugfix hotspots,
6
+ * and churn ranking. Produces .planning/formal/evidence/git-heatmap.json as
7
+ * structured evidence for nf:solve consumption.
8
+ *
9
+ * Requirements: QUICK-193
10
+ *
11
+ * Usage:
12
+ * node bin/git-heatmap.cjs # print summary to stdout
13
+ * node bin/git-heatmap.cjs --json # print full JSON to stdout
14
+ * node bin/git-heatmap.cjs --since=2024-01-01 # limit git history depth
15
+ * node bin/git-heatmap.cjs --project-root=/path # specify project root
16
+ *
17
+ * Security: Uses execFileSync with argument arrays (NOT exec with string
18
+ * concatenation) to prevent command injection. The --since value is validated
19
+ * against a strict date pattern before use.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { execFileSync } = require('child_process');
25
+
26
+ // ── CLI parsing ────────────────────────────────────────────────────────────
27
+
28
+ function parseArgs(argv) {
29
+ const args = { json: false, since: null, projectRoot: process.cwd() };
30
+ for (const arg of argv.slice(2)) {
31
+ if (arg === '--json') {
32
+ args.json = true;
33
+ } else if (arg.startsWith('--since=')) {
34
+ args.since = arg.slice('--since='.length);
35
+ } else if (arg.startsWith('--project-root=')) {
36
+ args.projectRoot = arg.slice('--project-root='.length);
37
+ }
38
+ }
39
+ return args;
40
+ }
41
+
42
+ // ── Input validation ───────────────────────────────────────────────────────
43
+
44
+ const SINCE_PATTERN = /^[\d\-\.TZ:]+$/;
45
+
46
+ function validateSince(since) {
47
+ if (since && !SINCE_PATTERN.test(since)) {
48
+ throw new Error(`Invalid --since value: "${since}". Must match date pattern (e.g., 2024-01-01 or 2024-01-01T00:00:00Z)`);
49
+ }
50
+ }
51
+
52
+ // ── Exec helper ────────────────────────────────────────────────────────────
53
+
54
+ const MAX_BUFFER = 50 * 1024 * 1024; // 50 MB
55
+
56
+ function gitExec(args, cwd) {
57
+ try {
58
+ return execFileSync('git', args, {
59
+ cwd,
60
+ maxBuffer: MAX_BUFFER,
61
+ encoding: 'utf8',
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ });
64
+ } catch (err) {
65
+ if (err.status !== null && err.status !== 0) {
66
+ throw new Error(`git ${args[0]} failed (exit ${err.status}): ${(err.stderr || '').slice(0, 500)}`);
67
+ }
68
+ // Some git commands return non-zero for empty results
69
+ return err.stdout || '';
70
+ }
71
+ }
72
+
73
+ // ── Model registry cross-reference ─────────────────────────────────────────
74
+
75
+ function buildCoverageMap(root) {
76
+ const registryPath = path.join(root, '.planning', 'formal', 'model-registry.json');
77
+ const coverageSet = new Set();
78
+
79
+ if (!fs.existsSync(registryPath)) {
80
+ return coverageSet;
81
+ }
82
+
83
+ try {
84
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
85
+ const models = registry.models || {};
86
+
87
+ for (const [modelPath, _entry] of Object.entries(models)) {
88
+ const fullModelPath = path.join(root, modelPath);
89
+ if (fs.existsSync(fullModelPath)) {
90
+ try {
91
+ const content = fs.readFileSync(fullModelPath, 'utf8');
92
+ // Extract file references from model content — look for source file paths
93
+ // Match patterns like hooks/xxx.js, bin/xxx.cjs, core/xxx, etc.
94
+ const fileRefPattern = /(?:hooks|bin|core|src|lib)\/[\w\-\.\/]+\.\w+/g;
95
+ let match;
96
+ while ((match = fileRefPattern.exec(content)) !== null) {
97
+ coverageSet.add(match[0]);
98
+ }
99
+ } catch (_e) {
100
+ // Skip unreadable model files
101
+ }
102
+ }
103
+ }
104
+ } catch (_e) {
105
+ // Registry parse failure — proceed with empty coverage
106
+ }
107
+
108
+ return coverageSet;
109
+ }
110
+
111
+ function hasFormalCoverage(file, coverageMap) {
112
+ // Check direct match
113
+ if (coverageMap.has(file)) return true;
114
+ // Check if file basename appears in any coverage entry
115
+ for (const covered of coverageMap) {
116
+ if (file.endsWith(covered) || covered.endsWith(file)) return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ // ── Signal 1: Numerical Adjustments ────────────────────────────────────────
122
+
123
+ /**
124
+ * Parse a numeric value from a diff line (removed or added).
125
+ * Returns { value, name, prefix } or null.
126
+ */
127
+ function parseDiffNumericLine(line) {
128
+ const prefix = line[0]; // '-' or '+'
129
+ const content = line.slice(1);
130
+
131
+ // Match lines with numeric constants/assignments
132
+ const patterns = [
133
+ // const TIMEOUT = 5000
134
+ /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(-?\d+(?:\.\d+)?)\s*[,;]?\s*$/,
135
+ // property: 5000 or property = 5000
136
+ /^\s*([\w.]+)\s*[:=]\s*(-?\d+(?:\.\d+)?)\s*[,;]?\s*$/,
137
+ // timeout: 5000 (YAML/JSON style with quotes)
138
+ /^\s*["']?([\w.]+)["']?\s*:\s*(-?\d+(?:\.\d+)?)\s*[,;]?\s*$/,
139
+ ];
140
+
141
+ for (const pat of patterns) {
142
+ const m = content.match(pat);
143
+ if (m) {
144
+ return { name: m[1], value: parseFloat(m[2]), prefix };
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Parse diff hunks respecting hunk boundaries.
152
+ * Returns array of { constant_name, old_value, new_value } for numeric changes
153
+ * within the same hunk and within 3 lines of each other.
154
+ */
155
+ function parseHunksForNumericChanges(diffText) {
156
+ const results = [];
157
+ const lines = diffText.split('\n');
158
+
159
+ let inHunk = false;
160
+ let removedLines = []; // { lineIdx, parsed }
161
+
162
+ for (let i = 0; i < lines.length; i++) {
163
+ const line = lines[i];
164
+
165
+ // Hunk header resets state
166
+ if (line.startsWith('@@')) {
167
+ inHunk = true;
168
+ removedLines = [];
169
+ continue;
170
+ }
171
+
172
+ // New file header resets hunk
173
+ if (line.startsWith('diff --git') || line.startsWith('---') || line.startsWith('+++')) {
174
+ inHunk = false;
175
+ removedLines = [];
176
+ continue;
177
+ }
178
+
179
+ if (!inHunk) continue;
180
+
181
+ if (line.startsWith('-') && !line.startsWith('---')) {
182
+ const parsed = parseDiffNumericLine(line);
183
+ if (parsed) {
184
+ removedLines.push({ lineIdx: i, parsed });
185
+ }
186
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
187
+ const parsed = parseDiffNumericLine(line);
188
+ if (parsed) {
189
+ // Find matching removed line within 3 lines, same constant name
190
+ for (let j = removedLines.length - 1; j >= 0; j--) {
191
+ const removed = removedLines[j];
192
+ if (removed.parsed.name === parsed.name &&
193
+ removed.parsed.value !== parsed.value &&
194
+ (i - removed.lineIdx) <= 3) {
195
+ results.push({
196
+ constant_name: parsed.name,
197
+ old_value: removed.parsed.value,
198
+ new_value: parsed.value,
199
+ });
200
+ removedLines.splice(j, 1);
201
+ break;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ return results;
209
+ }
210
+
211
+ function computeDriftDirection(values) {
212
+ if (values.length < 2) return 'stable';
213
+ let increasing = true;
214
+ let decreasing = true;
215
+ for (let i = 1; i < values.length; i++) {
216
+ if (values[i] <= values[i - 1]) increasing = false;
217
+ if (values[i] >= values[i - 1]) decreasing = false;
218
+ }
219
+ if (increasing) return 'increasing';
220
+ if (decreasing) return 'decreasing';
221
+ return 'oscillating';
222
+ }
223
+
224
+ function extractNumericalAdjustments(root, since, coverageMap) {
225
+ // Pass 1: identify candidate files via numstat
226
+ const numstatArgs = ['log', '--all', '--numstat', '--format=%H %aI'];
227
+ if (since) numstatArgs.push(`--since=${since}`);
228
+
229
+ const numstatOutput = gitExec(numstatArgs, root);
230
+
231
+ // Count changes per file
232
+ const fileChurn = {};
233
+ for (const line of numstatOutput.split('\n')) {
234
+ const numstatMatch = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
235
+ if (numstatMatch) {
236
+ const file = numstatMatch[3];
237
+ fileChurn[file] = (fileChurn[file] || 0) + parseInt(numstatMatch[1]) + parseInt(numstatMatch[2]);
238
+ }
239
+ }
240
+
241
+ // Filter to candidate files (numeric-heavy, config files, etc.)
242
+ const candidatePatterns = /\.(json|cjs|mjs|js|ts|config\.\w+|yml|yaml|toml)$/;
243
+ const candidates = Object.entries(fileChurn)
244
+ .filter(([f]) => candidatePatterns.test(f))
245
+ .sort((a, b) => b[1] - a[1])
246
+ .slice(0, 50)
247
+ .map(([f]) => f);
248
+
249
+ // Pass 2: targeted diff per candidate file
250
+ const adjustmentMap = {}; // key: file+constant_name
251
+
252
+ for (const file of candidates) {
253
+ const diffArgs = ['log', '-p', '--all', '--format=%H %aI', '--', file];
254
+ if (since) diffArgs.splice(2, 0, `--since=${since}`);
255
+
256
+ let diffOutput;
257
+ try {
258
+ diffOutput = gitExec(diffArgs, root);
259
+ } catch (_e) {
260
+ continue;
261
+ }
262
+
263
+ if (!diffOutput || diffOutput.length < 10) continue;
264
+
265
+ // Parse commits and their diffs
266
+ const commitSections = diffOutput.split(/^(?=[0-9a-f]{40} \d{4})/m);
267
+
268
+ for (const section of commitSections) {
269
+ const headerMatch = section.match(/^([0-9a-f]{40})\s+(\S+)/);
270
+ if (!headerMatch) continue;
271
+ const commit = headerMatch[1].slice(0, 8);
272
+ const date = headerMatch[2];
273
+
274
+ const changes = parseHunksForNumericChanges(section);
275
+ for (const change of changes) {
276
+ const key = `${file}::${change.constant_name}`;
277
+ if (!adjustmentMap[key]) {
278
+ adjustmentMap[key] = {
279
+ file,
280
+ constant_name: change.constant_name,
281
+ entries: [],
282
+ };
283
+ }
284
+ adjustmentMap[key].entries.push({
285
+ old_value: change.old_value,
286
+ new_value: change.new_value,
287
+ commit,
288
+ date,
289
+ });
290
+ }
291
+ }
292
+ }
293
+
294
+ // Build output array
295
+ return Object.values(adjustmentMap).map(adj => {
296
+ const values = [];
297
+ for (const e of adj.entries) {
298
+ if (values.length === 0 || values[values.length - 1] !== e.old_value) {
299
+ values.push(e.old_value);
300
+ }
301
+ values.push(e.new_value);
302
+ }
303
+ return {
304
+ file: adj.file,
305
+ constant_name: adj.constant_name,
306
+ touch_count: adj.entries.length,
307
+ values,
308
+ drift_direction: computeDriftDirection(values),
309
+ has_formal_coverage: hasFormalCoverage(adj.file, coverageMap),
310
+ changes: adj.entries,
311
+ };
312
+ });
313
+ }
314
+
315
+ // ── Signal 2: Bugfix Hotspots ──────────────────────────────────────────────
316
+
317
+ const BUGFIX_PATTERN = /\b(fix|bug|bugfix|patch|hotfix|resolve[ds]?)\b/i;
318
+
319
+ function isBugfixCommit(message) {
320
+ return BUGFIX_PATTERN.test(message);
321
+ }
322
+
323
+ function extractBugfixHotspots(root, since, coverageMap) {
324
+ const logArgs = ['log', '--all', '--oneline'];
325
+ if (since) logArgs.push(`--since=${since}`);
326
+
327
+ const logOutput = gitExec(logArgs, root);
328
+ const lines = logOutput.trim().split('\n').filter(Boolean);
329
+
330
+ const fixCommits = [];
331
+ for (const line of lines) {
332
+ const spaceIdx = line.indexOf(' ');
333
+ if (spaceIdx === -1) continue;
334
+ const sha = line.slice(0, spaceIdx);
335
+ const msg = line.slice(spaceIdx + 1);
336
+ if (isBugfixCommit(msg)) {
337
+ fixCommits.push(sha);
338
+ }
339
+ }
340
+
341
+ // Get files touched by each fix commit
342
+ const fileFixes = {};
343
+ for (const sha of fixCommits) {
344
+ let filesOutput;
345
+ try {
346
+ filesOutput = gitExec(['diff-tree', '--no-commit-id', '-r', '--name-only', sha], root);
347
+ } catch (_e) {
348
+ continue;
349
+ }
350
+ const files = filesOutput.trim().split('\n').filter(Boolean);
351
+ for (const file of files) {
352
+ fileFixes[file] = (fileFixes[file] || 0) + 1;
353
+ }
354
+ }
355
+
356
+ return Object.entries(fileFixes)
357
+ .sort((a, b) => b[1] - a[1])
358
+ .map(([file, fix_count]) => ({
359
+ file,
360
+ fix_count,
361
+ has_formal_coverage: hasFormalCoverage(file, coverageMap),
362
+ }));
363
+ }
364
+
365
+ // ── Signal 3: Churn Ranking ────────────────────────────────────────────────
366
+
367
+ function extractChurnRanking(root, since) {
368
+ const logArgs = ['log', '--numstat', '--all', '--no-merges', '--format=%H'];
369
+ if (since) logArgs.push(`--since=${since}`);
370
+
371
+ const logOutput = gitExec(logArgs, root);
372
+
373
+ const fileStats = {};
374
+ let currentCommit = null;
375
+
376
+ for (const line of logOutput.split('\n')) {
377
+ if (/^[0-9a-f]{40}$/.test(line)) {
378
+ currentCommit = line;
379
+ continue;
380
+ }
381
+ const numstatMatch = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
382
+ if (numstatMatch) {
383
+ const added = parseInt(numstatMatch[1]);
384
+ const removed = parseInt(numstatMatch[2]);
385
+ const file = numstatMatch[3];
386
+
387
+ if (!fileStats[file]) {
388
+ fileStats[file] = { commits: 0, lines_added: 0, lines_removed: 0, commitSet: new Set() };
389
+ }
390
+ fileStats[file].lines_added += added;
391
+ fileStats[file].lines_removed += removed;
392
+ if (currentCommit && !fileStats[file].commitSet.has(currentCommit)) {
393
+ fileStats[file].commitSet.add(currentCommit);
394
+ fileStats[file].commits++;
395
+ }
396
+ }
397
+ }
398
+
399
+ return Object.entries(fileStats)
400
+ .map(([file, s]) => ({
401
+ file,
402
+ commits: s.commits,
403
+ lines_added: s.lines_added,
404
+ lines_removed: s.lines_removed,
405
+ total_churn: s.lines_added + s.lines_removed,
406
+ }))
407
+ .sort((a, b) => b.total_churn - a.total_churn);
408
+ }
409
+
410
+ // ── Priority scoring ───────────────────────────────────────────────────────
411
+
412
+ function computePriority(churn, fixes, adjustments) {
413
+ return Math.max(churn, 1) * (1 + fixes) * (1 + adjustments);
414
+ }
415
+
416
+ // ── Cross-reference: uncovered hot zones ───────────────────────────────────
417
+
418
+ function buildUncoveredHotZones(numericalAdj, bugfixHotspots, churnRanking, coverageMap) {
419
+ const allFiles = new Set();
420
+
421
+ // Collect all files from all signals
422
+ for (const adj of numericalAdj) allFiles.add(adj.file);
423
+ for (const bf of bugfixHotspots) allFiles.add(bf.file);
424
+ for (const ch of churnRanking) allFiles.add(ch.file);
425
+
426
+ // Build lookup maps
427
+ const churnMap = {};
428
+ for (const ch of churnRanking) churnMap[ch.file] = ch.total_churn;
429
+
430
+ const fixMap = {};
431
+ for (const bf of bugfixHotspots) fixMap[bf.file] = bf.fix_count;
432
+
433
+ const adjMap = {};
434
+ for (const adj of numericalAdj) {
435
+ adjMap[adj.file] = (adjMap[adj.file] || 0) + adj.touch_count;
436
+ }
437
+
438
+ const uncovered = [];
439
+ for (const file of allFiles) {
440
+ if (hasFormalCoverage(file, coverageMap)) continue;
441
+
442
+ const churn = churnMap[file] || 0;
443
+ const fixes = fixMap[file] || 0;
444
+ const adjustments = adjMap[file] || 0;
445
+
446
+ const signals = [];
447
+ if (churnMap[file]) signals.push('churn');
448
+ if (fixMap[file]) signals.push('bugfix');
449
+ if (adjMap[file]) signals.push('numerical');
450
+
451
+ uncovered.push({
452
+ file,
453
+ priority: computePriority(churn, fixes, adjustments),
454
+ churn,
455
+ fixes,
456
+ adjustments,
457
+ signals,
458
+ });
459
+ }
460
+
461
+ return uncovered.sort((a, b) => b.priority - a.priority);
462
+ }
463
+
464
+ // ── Human-readable output ──────────────────────────────────────────────────
465
+
466
+ function printSummary(result) {
467
+ const { signals, uncovered_hot_zones } = result;
468
+
469
+ console.log('\n=== Git Heatmap Summary ===\n');
470
+
471
+ console.log('--- Top 10 Numerical Adjustments ---');
472
+ for (const adj of signals.numerical_adjustments.slice(0, 10)) {
473
+ console.log(` ${adj.file} :: ${adj.constant_name} (${adj.touch_count} changes, ${adj.drift_direction})${adj.has_formal_coverage ? ' [covered]' : ''}`);
474
+ }
475
+ if (signals.numerical_adjustments.length === 0) console.log(' (none found)');
476
+
477
+ console.log('\n--- Top 10 Bugfix Hotspots ---');
478
+ for (const bf of signals.bugfix_hotspots.slice(0, 10)) {
479
+ console.log(` ${bf.file} (${bf.fix_count} fixes)${bf.has_formal_coverage ? ' [covered]' : ''}`);
480
+ }
481
+ if (signals.bugfix_hotspots.length === 0) console.log(' (none found)');
482
+
483
+ console.log('\n--- Top 10 by Churn ---');
484
+ for (const ch of signals.churn_ranking.slice(0, 10)) {
485
+ console.log(` ${ch.file} (${ch.total_churn} lines, ${ch.commits} commits)`);
486
+ }
487
+ if (signals.churn_ranking.length === 0) console.log(' (none found)');
488
+
489
+ console.log('\n--- Top 10 Uncovered Hot Zones ---');
490
+ for (const hz of uncovered_hot_zones.slice(0, 10)) {
491
+ console.log(` ${hz.file} (priority: ${hz.priority}, signals: ${hz.signals.join(', ')})`);
492
+ }
493
+ if (uncovered_hot_zones.length === 0) console.log(' (none found)');
494
+
495
+ console.log(`\nTotals: ${signals.numerical_adjustments.length} adjustments, ${signals.bugfix_hotspots.length} bugfix files, ${signals.churn_ranking.length} files by churn, ${uncovered_hot_zones.length} uncovered hot zones`);
496
+ console.log('');
497
+ }
498
+
499
+ // ── Main ───────────────────────────────────────────────────────────────────
500
+
501
+ function main() {
502
+ const args = parseArgs(process.argv);
503
+ const root = path.resolve(args.projectRoot);
504
+
505
+ // Validate --since
506
+ validateSince(args.since);
507
+
508
+ // Verify git repo
509
+ try {
510
+ gitExec(['rev-parse', '--git-dir'], root);
511
+ } catch (err) {
512
+ process.stderr.write(`Error: ${root} is not a git repository\n`);
513
+ process.exit(1);
514
+ }
515
+
516
+ const coverageMap = buildCoverageMap(root);
517
+
518
+ const numericalAdj = extractNumericalAdjustments(root, args.since, coverageMap);
519
+ const bugfixHotspots = extractBugfixHotspots(root, args.since, coverageMap);
520
+ const churnRanking = extractChurnRanking(root, args.since);
521
+ const uncoveredHotZones = buildUncoveredHotZones(numericalAdj, bugfixHotspots, churnRanking, coverageMap);
522
+
523
+ const result = {
524
+ schema_version: '1',
525
+ generated: new Date().toISOString(),
526
+ signals: {
527
+ numerical_adjustments: numericalAdj,
528
+ bugfix_hotspots: bugfixHotspots,
529
+ churn_ranking: churnRanking,
530
+ },
531
+ uncovered_hot_zones: uncoveredHotZones,
532
+ };
533
+
534
+ // Write evidence file
535
+ const evidenceDir = path.join(root, '.planning', 'formal', 'evidence');
536
+ if (!fs.existsSync(evidenceDir)) {
537
+ fs.mkdirSync(evidenceDir, { recursive: true });
538
+ }
539
+ const outPath = path.join(evidenceDir, 'git-heatmap.json');
540
+ fs.writeFileSync(outPath, JSON.stringify(result, null, 2) + '\n', 'utf8');
541
+
542
+ if (args.json) {
543
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
544
+ } else {
545
+ printSummary(result);
546
+ console.log(`Evidence written to: ${outPath}`);
547
+ }
548
+ }
549
+
550
+ // ── Exports for testing ────────────────────────────────────────────────────
551
+
552
+ module.exports = {
553
+ parseArgs,
554
+ validateSince,
555
+ parseDiffNumericLine,
556
+ parseHunksForNumericChanges,
557
+ computeDriftDirection,
558
+ isBugfixCommit,
559
+ computePriority,
560
+ hasFormalCoverage,
561
+ buildCoverageMap,
562
+ extractNumericalAdjustments,
563
+ extractBugfixHotspots,
564
+ extractChurnRanking,
565
+ buildUncoveredHotZones,
566
+ SINCE_PATTERN,
567
+ };
568
+
569
+ if (require.main === module) {
570
+ main();
571
+ }