@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.
- package/README.md +2 -2
- package/agents/{qgsd-codebase-mapper.md → nf-codebase-mapper.md} +1 -1
- package/agents/{qgsd-debugger.md → nf-debugger.md} +3 -3
- package/agents/{qgsd-executor.md → nf-executor.md} +14 -14
- package/agents/{qgsd-integration-checker.md → nf-integration-checker.md} +1 -1
- package/agents/{qgsd-phase-researcher.md → nf-phase-researcher.md} +6 -6
- package/agents/{qgsd-plan-checker.md → nf-plan-checker.md} +9 -9
- package/agents/{qgsd-planner.md → nf-planner.md} +9 -9
- package/agents/{qgsd-project-researcher.md → nf-project-researcher.md} +2 -2
- package/agents/{qgsd-quorum-orchestrator.md → nf-quorum-orchestrator.md} +33 -33
- package/agents/{qgsd-quorum-slot-worker.md → nf-quorum-slot-worker.md} +3 -3
- package/agents/{qgsd-quorum-synthesizer.md → nf-quorum-synthesizer.md} +3 -3
- package/agents/{qgsd-quorum-test-worker.md → nf-quorum-test-worker.md} +1 -1
- package/agents/{qgsd-quorum-worker.md → nf-quorum-worker.md} +6 -6
- package/agents/{qgsd-research-synthesizer.md → nf-research-synthesizer.md} +5 -5
- package/agents/{qgsd-roadmapper.md → nf-roadmapper.md} +3 -3
- package/agents/{qgsd-verifier.md → nf-verifier.md} +8 -8
- package/bin/accept-debug-invariant.cjs +2 -2
- package/bin/account-manager.cjs +10 -10
- package/bin/aggregate-requirements.cjs +1 -1
- package/bin/analyze-assumptions.cjs +3 -3
- package/bin/analyze-state-space.cjs +14 -14
- package/bin/assumption-register.cjs +146 -0
- package/bin/attribute-trace-divergence.cjs +1 -1
- package/bin/auth-drivers/gh-cli.cjs +1 -1
- package/bin/auth-drivers/pool.cjs +1 -1
- package/bin/autoClosePtoF.cjs +3 -3
- package/bin/budget-tracker.cjs +77 -0
- package/bin/build-layer-manifest.cjs +153 -0
- package/bin/call-quorum-slot.cjs +3 -3
- package/bin/ccr-secure-config.cjs +5 -5
- package/bin/check-bundled-sdks.cjs +1 -1
- package/bin/check-mcp-health.cjs +1 -1
- package/bin/check-provider-health.cjs +6 -6
- package/bin/check-spec-sync.cjs +26 -26
- package/bin/check-trace-schema-drift.cjs +5 -5
- package/bin/conformance-schema.cjs +2 -2
- package/bin/cross-layer-dashboard.cjs +297 -0
- package/bin/design-impact.cjs +377 -0
- package/bin/detect-coverage-gaps.cjs +7 -7
- package/bin/failure-mode-catalog.cjs +227 -0
- package/bin/failure-taxonomy.cjs +177 -0
- package/bin/formal-scope-scan.cjs +179 -0
- package/bin/gate-a-grounding.cjs +334 -0
- package/bin/gate-b-abstraction.cjs +243 -0
- package/bin/gate-c-validation.cjs +166 -0
- package/bin/generate-formal-specs.cjs +17 -17
- package/bin/generate-petri-net.cjs +3 -3
- package/bin/generate-tla-cfg.cjs +5 -5
- package/bin/git-heatmap.cjs +571 -0
- package/bin/harness-diagnostic.cjs +326 -0
- package/bin/hazard-model.cjs +261 -0
- package/bin/install-formal-tools.cjs +1 -1
- package/bin/install.js +184 -139
- package/bin/instrumentation-map.cjs +178 -0
- package/bin/invariant-catalog.cjs +437 -0
- package/bin/issue-classifier.cjs +2 -2
- package/bin/load-baseline-requirements.cjs +4 -4
- package/bin/manage-agents-core.cjs +32 -32
- package/bin/migrate-to-slots.cjs +39 -39
- package/bin/mismatch-register.cjs +217 -0
- package/bin/nForma.cjs +176 -81
- package/bin/{qgsd-solve.cjs → nf-solve.cjs} +327 -14
- package/bin/observe-config.cjs +8 -0
- package/bin/observe-debt-writer.cjs +1 -1
- package/bin/observe-handler-deps.cjs +356 -0
- package/bin/observe-handler-grafana.cjs +2 -17
- package/bin/observe-handler-internal.cjs +5 -5
- package/bin/observe-handler-logstash.cjs +2 -17
- package/bin/observe-handler-prometheus.cjs +2 -17
- package/bin/observe-handler-upstream.cjs +251 -0
- package/bin/observe-handlers.cjs +12 -33
- package/bin/observe-render.cjs +68 -22
- package/bin/observe-utils.cjs +37 -0
- package/bin/observed-fsm.cjs +324 -0
- package/bin/planning-paths.cjs +6 -0
- package/bin/polyrepo.cjs +1 -1
- package/bin/probe-quorum-slots.cjs +1 -1
- package/bin/promote-gate-maturity.cjs +274 -0
- package/bin/promote-model.cjs +1 -1
- package/bin/propose-debug-invariants.cjs +1 -1
- package/bin/quorum-cache.cjs +144 -0
- package/bin/quorum-consensus-gate.cjs +1 -1
- package/bin/quorum-slot-dispatch.cjs +6 -6
- package/bin/requirements-core.cjs +1 -1
- package/bin/review-mcp-logs.cjs +1 -1
- package/bin/risk-heatmap.cjs +151 -0
- package/bin/run-account-manager-tlc.cjs +4 -4
- package/bin/run-account-pool-alloy.cjs +2 -2
- package/bin/run-alloy.cjs +2 -2
- package/bin/run-audit-alloy.cjs +2 -2
- package/bin/run-breaker-tlc.cjs +3 -3
- package/bin/run-formal-check.cjs +9 -9
- package/bin/run-formal-verify.cjs +30 -9
- package/bin/run-installer-alloy.cjs +2 -2
- package/bin/run-oscillation-tlc.cjs +4 -4
- package/bin/run-phase-tlc.cjs +1 -1
- package/bin/run-protocol-tlc.cjs +4 -4
- package/bin/run-quorum-composition-alloy.cjs +2 -2
- package/bin/run-sensitivity-sweep.cjs +2 -2
- package/bin/run-stop-hook-tlc.cjs +3 -3
- package/bin/run-tlc.cjs +21 -21
- package/bin/run-transcript-alloy.cjs +2 -2
- package/bin/secrets.cjs +5 -5
- package/bin/security-sweep.cjs +238 -0
- package/bin/sensitivity-report.cjs +3 -3
- package/bin/set-secret.cjs +5 -5
- package/bin/setup-telemetry-cron.sh +3 -3
- package/bin/stall-detector.cjs +126 -0
- package/bin/state-candidates.cjs +206 -0
- package/bin/sync-baseline-requirements.cjs +1 -1
- package/bin/telemetry-collector.cjs +1 -1
- package/bin/test-changed.cjs +111 -0
- package/bin/test-recipe-gen.cjs +250 -0
- package/bin/trace-corpus-stats.cjs +211 -0
- package/bin/unified-mcp-server.mjs +3 -3
- package/bin/update-scoreboard.cjs +1 -1
- package/bin/validate-memory.cjs +2 -2
- package/bin/validate-traces.cjs +10 -10
- package/bin/verify-quorum-health.cjs +66 -5
- package/bin/xstate-to-tla.cjs +4 -4
- package/bin/xstate-trace-walker.cjs +3 -3
- package/commands/{qgsd → nf}/add-phase.md +3 -3
- package/commands/{qgsd → nf}/add-requirement.md +3 -3
- package/commands/{qgsd → nf}/add-todo.md +3 -3
- package/commands/{qgsd → nf}/audit-milestone.md +4 -4
- package/commands/{qgsd → nf}/check-todos.md +3 -3
- package/commands/{qgsd → nf}/cleanup.md +3 -3
- package/commands/{qgsd → nf}/close-formal-gaps.md +2 -2
- package/commands/{qgsd → nf}/complete-milestone.md +9 -9
- package/commands/{qgsd → nf}/debug.md +9 -9
- package/commands/{qgsd → nf}/discuss-phase.md +3 -3
- package/commands/{qgsd → nf}/execute-phase.md +15 -15
- package/commands/{qgsd → nf}/fix-tests.md +3 -3
- package/commands/{qgsd → nf}/formal-test-sync.md +1 -1
- package/commands/{qgsd → nf}/health.md +3 -3
- package/commands/{qgsd → nf}/help.md +3 -3
- package/commands/{qgsd → nf}/insert-phase.md +3 -3
- package/commands/nf/join-discord.md +18 -0
- package/commands/{qgsd → nf}/list-phase-assumptions.md +2 -2
- package/commands/{qgsd → nf}/map-codebase.md +7 -7
- package/commands/{qgsd → nf}/map-requirements.md +3 -3
- package/commands/{qgsd → nf}/mcp-restart.md +3 -3
- package/commands/{qgsd → nf}/mcp-set-model.md +8 -8
- package/commands/{qgsd → nf}/mcp-setup.md +63 -63
- package/commands/{qgsd → nf}/mcp-status.md +3 -3
- package/commands/{qgsd → nf}/mcp-update.md +7 -7
- package/commands/{qgsd → nf}/new-milestone.md +8 -8
- package/commands/{qgsd → nf}/new-project.md +8 -8
- package/commands/{qgsd → nf}/observe.md +49 -16
- package/commands/{qgsd → nf}/pause-work.md +3 -3
- package/commands/{qgsd → nf}/plan-milestone-gaps.md +5 -5
- package/commands/{qgsd → nf}/plan-phase.md +6 -6
- package/commands/{qgsd → nf}/polyrepo.md +2 -2
- package/commands/{qgsd → nf}/progress.md +3 -3
- package/commands/{qgsd → nf}/queue.md +2 -2
- package/commands/{qgsd → nf}/quick.md +8 -8
- package/commands/{qgsd → nf}/quorum-test.md +10 -10
- package/commands/{qgsd → nf}/quorum.md +40 -40
- package/commands/{qgsd → nf}/reapply-patches.md +2 -2
- package/commands/{qgsd → nf}/remove-phase.md +3 -3
- package/commands/{qgsd → nf}/research-phase.md +12 -12
- package/commands/{qgsd → nf}/resume-work.md +3 -3
- package/commands/nf/review-requirements.md +31 -0
- package/commands/{qgsd → nf}/set-profile.md +3 -3
- package/commands/{qgsd → nf}/settings.md +6 -6
- package/commands/{qgsd → nf}/solve.md +35 -35
- package/commands/{qgsd → nf}/sync-baselines.md +4 -4
- package/commands/{qgsd → nf}/triage.md +10 -10
- package/commands/{qgsd → nf}/update.md +3 -3
- package/commands/{qgsd → nf}/verify-work.md +5 -5
- package/hooks/dist/config-loader.js +188 -32
- package/hooks/dist/conformance-schema.cjs +2 -2
- package/hooks/dist/gsd-context-monitor.js +118 -13
- package/hooks/dist/{qgsd-check-update.js → nf-check-update.js} +5 -5
- package/hooks/dist/{qgsd-circuit-breaker.js → nf-circuit-breaker.js} +35 -24
- package/hooks/dist/nf-circuit-breaker.test.js +1002 -0
- package/hooks/dist/{qgsd-precompact.js → nf-precompact.js} +13 -13
- package/hooks/dist/nf-precompact.test.js +227 -0
- package/hooks/dist/{qgsd-prompt.js → nf-prompt.js} +110 -33
- package/hooks/dist/nf-prompt.test.js +698 -0
- package/hooks/dist/nf-session-start.js +185 -0
- package/hooks/dist/nf-session-start.test.js +354 -0
- package/hooks/dist/{qgsd-slot-correlator.js → nf-slot-correlator.js} +13 -5
- package/hooks/dist/nf-slot-correlator.test.js +85 -0
- package/hooks/dist/{qgsd-spec-regen.js → nf-spec-regen.js} +17 -8
- package/hooks/dist/nf-spec-regen.test.js +73 -0
- package/hooks/dist/{qgsd-statusline.js → nf-statusline.js} +12 -3
- package/hooks/dist/nf-statusline.test.js +157 -0
- package/hooks/dist/{qgsd-stop.js → nf-stop.js} +152 -18
- package/hooks/dist/nf-stop.test.js +1388 -0
- package/hooks/dist/{qgsd-token-collector.js → nf-token-collector.js} +12 -4
- package/hooks/dist/nf-token-collector.test.js +262 -0
- package/hooks/dist/unified-mcp-server.mjs +2 -2
- package/package.json +4 -4
- package/scripts/build-hooks.js +13 -6
- package/scripts/secret-audit.sh +1 -1
- package/scripts/verify-hooks-sync.cjs +90 -0
- package/templates/{qgsd.json → nf.json} +4 -4
- package/commands/qgsd/join-discord.md +0 -18
- 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
|
+
}
|