deepflow 0.1.110 → 0.1.112
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/bin/install.js +68 -84
- package/bin/install.test.js +1 -0
- package/bin/ratchet.js +185 -8
- package/bin/ratchet.test.js +4 -4
- package/bin/wave-runner.js +11 -3
- package/bin/worktree-deps.js +28 -22
- package/hooks/ac-coverage.js +66 -60
- package/hooks/df-check-update.js +1 -0
- package/hooks/df-command-usage.js +1 -0
- package/hooks/df-dashboard-push.js +1 -0
- package/hooks/df-execution-history.js +1 -0
- package/hooks/df-explore-protocol.js +1 -21
- package/hooks/df-harness-score.js +389 -0
- package/hooks/df-invariant-check.js +154 -1
- package/hooks/df-quota-logger.js +1 -0
- package/hooks/df-snapshot-guard.js +1 -0
- package/hooks/df-spec-lint.js +6 -0
- package/hooks/df-spec-lint.test.js +57 -1
- package/hooks/df-statusline.js +1 -0
- package/hooks/df-subagent-registry.js +1 -0
- package/hooks/df-tool-usage-spike.js +2 -0
- package/hooks/df-tool-usage.js +1 -0
- package/hooks/df-worktree-guard.js +157 -0
- package/hooks/lib/installer-utils.js +114 -0
- package/package.json +1 -1
- package/src/commands/df/debate.md +20 -4
- package/src/commands/df/discover.md +1 -1
- package/src/commands/df/execute.md +125 -33
- package/src/commands/df/plan.md +12 -7
- package/src/commands/df/spec.md +1 -0
- package/src/commands/df/verify.md +2 -0
- package/src/skills/repo-inspect/SKILL.md +205 -0
- package/templates/config-template.yaml +3 -0
- package/templates/spec-template.md +17 -0
package/hooks/ac-coverage.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @hook-event: PostToolUse
|
|
3
|
+
// @hook-owner: deepflow
|
|
2
4
|
/**
|
|
3
5
|
* deepflow AC coverage checker
|
|
4
6
|
* Standalone script called by the orchestrator after ratchet checks.
|
|
5
7
|
*
|
|
6
8
|
* Usage:
|
|
7
|
-
* node ac-coverage.js --spec <path> --
|
|
8
|
-
* node ac-coverage.js --spec <path> --
|
|
9
|
+
* node ac-coverage.js --spec <path> --test-files <file1,file2,...> --status <pass|fail|revert>
|
|
10
|
+
* node ac-coverage.js --spec <path> --snapshot <path> --status <pass|fail|revert>
|
|
9
11
|
*
|
|
10
12
|
* Exit codes:
|
|
11
13
|
* 0 — all ACs covered, no ACs in spec, or input status was fail/revert
|
|
@@ -16,6 +18,7 @@
|
|
|
16
18
|
'use strict';
|
|
17
19
|
|
|
18
20
|
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
19
22
|
|
|
20
23
|
// ── Argument parsing ────────────────────────────────────────────────────────
|
|
21
24
|
|
|
@@ -24,8 +27,8 @@ function parseArgs(argv) {
|
|
|
24
27
|
for (let i = 0; i < argv.length; i++) {
|
|
25
28
|
const a = argv[i];
|
|
26
29
|
if (a === '--spec' && argv[i + 1]) { args.spec = argv[++i]; }
|
|
27
|
-
else if (a === '--
|
|
28
|
-
else if (a === '--
|
|
30
|
+
else if (a === '--test-files' && argv[i + 1]) { args.testFiles = argv[++i]; }
|
|
31
|
+
else if (a === '--snapshot' && argv[i + 1]) { args.snapshot = argv[++i]; }
|
|
29
32
|
else if (a === '--status' && argv[i + 1]) { args.status = argv[++i]; }
|
|
30
33
|
}
|
|
31
34
|
return args;
|
|
@@ -82,28 +85,55 @@ function extractSpecACs(specContent) {
|
|
|
82
85
|
});
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
// ── AC
|
|
88
|
+
// ── AC scanning from test files ─────────────────────────────────────────────
|
|
86
89
|
|
|
87
90
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
91
|
+
* Scan test files for AC references in test names.
|
|
92
|
+
* Matches patterns like: it('AC-1: ...'), test('AC-2 ...'), describe('AC-3 ...')
|
|
93
|
+
* Returns a Set of AC-N identifiers found across all test files.
|
|
90
94
|
*/
|
|
91
|
-
function
|
|
92
|
-
const
|
|
95
|
+
function scanTestFilesForACs(testFilePaths) {
|
|
96
|
+
const found = new Set();
|
|
97
|
+
const pattern = /\b(it|test|describe)\s*\(\s*['"`][^'"`]*\bAC-(\d+)\b/g;
|
|
93
98
|
|
|
94
|
-
const
|
|
95
|
-
|
|
99
|
+
for (const filePath of testFilePaths) {
|
|
100
|
+
let content;
|
|
101
|
+
try {
|
|
102
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Skip unreadable files — not a fatal error
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
96
107
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
status: m[2],
|
|
103
|
-
reason: m[3] ? m[3].trim() : null,
|
|
104
|
-
});
|
|
108
|
+
pattern.lastIndex = 0;
|
|
109
|
+
let m;
|
|
110
|
+
while ((m = pattern.exec(content)) !== null) {
|
|
111
|
+
found.add(`AC-${m[2]}`);
|
|
112
|
+
}
|
|
105
113
|
}
|
|
106
|
-
|
|
114
|
+
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve test file paths from --test-files (comma-separated list) or
|
|
120
|
+
* --snapshot (newline-separated list of paths from snapshot file).
|
|
121
|
+
*/
|
|
122
|
+
function resolveTestFiles(args) {
|
|
123
|
+
if (args.testFiles) {
|
|
124
|
+
return args.testFiles.split(',').map(f => f.trim()).filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
if (args.snapshot) {
|
|
127
|
+
let content;
|
|
128
|
+
try {
|
|
129
|
+
content = fs.readFileSync(args.snapshot, 'utf8');
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(`[ac-coverage] Error reading snapshot file: ${e.message}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
return content.split('\n').map(f => f.trim()).filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
// ── Main logic ──────────────────────────────────────────────────────────────
|
|
@@ -127,77 +157,53 @@ function run(args) {
|
|
|
127
157
|
process.exit(1);
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
// Extract canonical ACs
|
|
160
|
+
// Extract canonical ACs from spec
|
|
131
161
|
const specACs = extractSpecACs(specContent);
|
|
132
162
|
|
|
133
|
-
//
|
|
163
|
+
// No Acceptance Criteria section → silent exit
|
|
134
164
|
if (specACs === null) {
|
|
135
165
|
process.exit(0);
|
|
136
166
|
}
|
|
137
167
|
|
|
138
|
-
//
|
|
168
|
+
// Section exists but no AC-\d+ patterns → silent exit
|
|
139
169
|
if (specACs.length === 0) {
|
|
140
170
|
process.exit(0);
|
|
141
171
|
}
|
|
142
172
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
if (args.outputFile) {
|
|
146
|
-
try {
|
|
147
|
-
outputText = fs.readFileSync(args.outputFile, 'utf8');
|
|
148
|
-
} catch (e) {
|
|
149
|
-
console.error(`[ac-coverage] Error reading output file: ${e.message}`);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
152
|
-
} else if (args.output !== undefined) {
|
|
153
|
-
outputText = args.output;
|
|
154
|
-
}
|
|
173
|
+
// Resolve test files to scan
|
|
174
|
+
const testFilePaths = resolveTestFiles(args);
|
|
155
175
|
|
|
156
|
-
//
|
|
157
|
-
const
|
|
176
|
+
// Scan test files for AC references
|
|
177
|
+
const coveredACs = scanTestFilesForACs(testFilePaths);
|
|
158
178
|
|
|
159
|
-
//
|
|
179
|
+
// Diff — identify missed ACs (not referenced in any test name)
|
|
160
180
|
const missed = [];
|
|
161
|
-
const skipped = [];
|
|
162
181
|
let coveredCount = 0;
|
|
163
182
|
|
|
164
183
|
for (const id of specACs) {
|
|
165
|
-
|
|
166
|
-
if (entry && entry.status === 'done') {
|
|
184
|
+
if (coveredACs.has(id)) {
|
|
167
185
|
coveredCount++;
|
|
168
|
-
} else if (entry && entry.status === 'skip') {
|
|
169
|
-
skipped.push({ id, reason: entry.reason });
|
|
170
186
|
} else {
|
|
171
187
|
missed.push(id);
|
|
172
188
|
}
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
const totalACs = specACs.length;
|
|
176
|
-
const reportedDoneCount = coveredCount;
|
|
177
|
-
|
|
178
|
-
// AC-6: summary line
|
|
179
|
-
const summaryParts = [];
|
|
180
|
-
if (missed.length > 0) {
|
|
181
|
-
summaryParts.push(`missed: ${missed.join(', ')}`);
|
|
182
|
-
}
|
|
183
|
-
if (skipped.length > 0) {
|
|
184
|
-
const skipDesc = skipped.map(s => s.reason ? `${s.id} (${s.reason})` : s.id).join(', ');
|
|
185
|
-
summaryParts.push(`skipped: ${skipDesc}`);
|
|
186
|
-
}
|
|
187
192
|
|
|
188
|
-
|
|
189
|
-
|
|
193
|
+
// Summary line
|
|
194
|
+
const summaryDetail = missed.length > 0 ? ` — missed: ${missed.join(', ')}` : '';
|
|
195
|
+
console.log(`[ac-coverage] ${coveredCount}/${totalACs} ACs covered${summaryDetail}`);
|
|
190
196
|
|
|
191
|
-
//
|
|
197
|
+
// Status override
|
|
192
198
|
const inputStatus = args.status;
|
|
193
199
|
const hasMissed = missed.length > 0;
|
|
194
200
|
|
|
195
201
|
if (hasMissed && inputStatus === 'pass') {
|
|
196
|
-
//
|
|
202
|
+
// Override to SALVAGEABLE
|
|
197
203
|
console.log('OVERRIDE:SALVAGEABLE');
|
|
198
204
|
process.exit(2);
|
|
199
205
|
} else {
|
|
200
|
-
//
|
|
206
|
+
// All done, or status was already fail/revert — no override
|
|
201
207
|
console.log('OVERRIDE:none');
|
|
202
208
|
process.exit(0);
|
|
203
209
|
}
|
|
@@ -210,4 +216,4 @@ if (require.main === module) {
|
|
|
210
216
|
run(args);
|
|
211
217
|
}
|
|
212
218
|
|
|
213
|
-
module.exports = { extractSpecACs,
|
|
219
|
+
module.exports = { extractSpecACs, extractACSection, scanTestFilesForACs, resolveTestFiles };
|
package/hooks/df-check-update.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @hook-event: PreToolUse
|
|
3
|
+
// @hook-owner: deepflow
|
|
3
4
|
/**
|
|
4
5
|
* deepflow explore protocol injector
|
|
5
6
|
* PreToolUse hook: fires before the Agent tool executes.
|
|
@@ -244,27 +245,6 @@ readStdinIfMain(module, (payload) => {
|
|
|
244
245
|
},
|
|
245
246
|
};
|
|
246
247
|
|
|
247
|
-
// --- Metrics logging (fail-open) ---
|
|
248
|
-
// Log phase 1 hit rate to explore-metrics.jsonl.
|
|
249
|
-
// Wraps in try/catch so metrics failures never block hook execution.
|
|
250
|
-
try {
|
|
251
|
-
const metricsDir = path.join(effectiveCwd, '.deepflow');
|
|
252
|
-
const metricsPath = path.join(metricsDir, 'explore-metrics.jsonl');
|
|
253
|
-
if (!fs.existsSync(metricsDir)) {
|
|
254
|
-
fs.mkdirSync(metricsDir, { recursive: true });
|
|
255
|
-
}
|
|
256
|
-
const metricsEntry = {
|
|
257
|
-
timestamp: new Date().toISOString(),
|
|
258
|
-
query: originalPrompt,
|
|
259
|
-
phase1_hit: phase1Hit,
|
|
260
|
-
// tool_calls intentionally omitted: PreToolUse hooks fire before tool execution,
|
|
261
|
-
// so actual tool call counts are not observable here without a PostToolUse hook.
|
|
262
|
-
};
|
|
263
|
-
fs.appendFileSync(metricsPath, JSON.stringify(metricsEntry) + '\n', 'utf8');
|
|
264
|
-
} catch (_) {
|
|
265
|
-
// Metrics logging failure is silent — never blocks execution (REQ-8).
|
|
266
|
-
}
|
|
267
|
-
|
|
268
248
|
process.stdout.write(JSON.stringify(result));
|
|
269
249
|
} catch (_) {
|
|
270
250
|
// AC-10: catch ALL errors — malformed JSON, missing tool_input, filesystem errors, etc.
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @hook-event: PostToolUse
|
|
3
|
+
// @hook-owner: deepflow
|
|
4
|
+
/**
|
|
5
|
+
* deepflow harness quality scorer (REQ-6).
|
|
6
|
+
*
|
|
7
|
+
* Aggregates 4 deterministic signals about test harness health and emits a
|
|
8
|
+
* SALVAGEABLE signal when the combined score falls below the configured
|
|
9
|
+
* threshold (`harness_min_score`, default 0.6).
|
|
10
|
+
*
|
|
11
|
+
* Score = (D1 + D2 + D3 + D4) / 4, each dimension ∈ [0, 1]:
|
|
12
|
+
* D1 AC test ratio (AC-referenced tests) / (total ACs in spec)
|
|
13
|
+
* D2 Diff sibling ratio (test files in diff) / (impl files in diff)
|
|
14
|
+
* D3 Complexity proxy 1 - min(1, branch_kw_in_±20 / 10)
|
|
15
|
+
* D4 JSDoc ratio (functions with JSDoc) / (total changed fns)
|
|
16
|
+
*
|
|
17
|
+
* Hook trigger: PostToolUse, tool_name "Bash", command contains "git commit".
|
|
18
|
+
*
|
|
19
|
+
* Exit codes:
|
|
20
|
+
* 0 — score >= threshold, or insufficient signal, or non-commit event
|
|
21
|
+
* 2 — SALVAGEABLE: score < threshold (written to stderr)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { execFileSync } = require('child_process');
|
|
29
|
+
|
|
30
|
+
const { readStdinIfMain } = require('./lib/hook-stdin');
|
|
31
|
+
const { scanTestFilesForACs, extractSpecACs } = require('./ac-coverage');
|
|
32
|
+
const {
|
|
33
|
+
queryLsp,
|
|
34
|
+
detectLanguageServer,
|
|
35
|
+
isBinaryAvailable,
|
|
36
|
+
} = require('./df-invariant-check');
|
|
37
|
+
|
|
38
|
+
const DEFAULT_MIN_SCORE = 0.6;
|
|
39
|
+
const COMPLEXITY_THRESHOLD = 10;
|
|
40
|
+
const CONTEXT_LINES = 20;
|
|
41
|
+
const JSDOC_LOOKBACK_LINES = 3;
|
|
42
|
+
|
|
43
|
+
const BRANCH_KEYWORDS = [
|
|
44
|
+
'if', 'else', 'switch', 'case', 'for', 'while', 'do',
|
|
45
|
+
'try', 'catch', 'finally', 'throw', '&&', '||', '??', '?',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const TEST_FILE_RE = /(^|\/)(__tests__\/|test\/|tests\/|spec\/)|\.(test|spec)\.(js|jsx|ts|tsx|mjs|cjs|py|go|rs)$/i;
|
|
49
|
+
const IMPL_EXT_RE = /\.(js|jsx|ts|tsx|mjs|cjs|py|go|rs|java|rb)$/i;
|
|
50
|
+
|
|
51
|
+
// ── Config loading ───────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read harness_min_score from .deepflow/config.yaml. Returns DEFAULT_MIN_SCORE
|
|
55
|
+
* on any failure (missing file, malformed YAML, missing key).
|
|
56
|
+
*/
|
|
57
|
+
function readMinScore(cwd) {
|
|
58
|
+
const configPath = path.join(cwd, '.deepflow', 'config.yaml');
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = fs.readFileSync(configPath, 'utf8');
|
|
62
|
+
} catch (_) {
|
|
63
|
+
return DEFAULT_MIN_SCORE;
|
|
64
|
+
}
|
|
65
|
+
// Minimal YAML scan — avoid a YAML dep. Look for "harness_min_score: <num>"
|
|
66
|
+
const m = content.match(/^\s*harness_min_score\s*:\s*([0-9]*\.?[0-9]+)\s*(#.*)?$/m);
|
|
67
|
+
if (!m) return DEFAULT_MIN_SCORE;
|
|
68
|
+
const v = parseFloat(m[1]);
|
|
69
|
+
if (Number.isNaN(v) || v < 0 || v > 1) return DEFAULT_MIN_SCORE;
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Active spec loader (mirrors df-invariant-check.js) ───────────────────────
|
|
74
|
+
|
|
75
|
+
function loadActiveSpec(cwd) {
|
|
76
|
+
const candidates = [
|
|
77
|
+
path.join(cwd, '.deepflow'),
|
|
78
|
+
path.join(cwd, 'specs'),
|
|
79
|
+
];
|
|
80
|
+
for (const dir of candidates) {
|
|
81
|
+
try {
|
|
82
|
+
const entries = fs.readdirSync(dir);
|
|
83
|
+
const doing = entries.find((e) => e.startsWith('doing-') && e.endsWith('.md'));
|
|
84
|
+
if (doing) {
|
|
85
|
+
const p = path.join(dir, doing);
|
|
86
|
+
try { return { path: p, content: fs.readFileSync(p, 'utf8') }; } catch (_) { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
} catch (_) {
|
|
89
|
+
// dir missing — continue
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Diff helpers ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function getChangedFiles(cwd) {
|
|
98
|
+
try {
|
|
99
|
+
const out = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], {
|
|
100
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'ignore'],
|
|
101
|
+
});
|
|
102
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
103
|
+
} catch (_) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getFullDiff(cwd) {
|
|
109
|
+
try {
|
|
110
|
+
return execFileSync('git', ['diff', 'HEAD~1', 'HEAD'], {
|
|
111
|
+
encoding: 'utf8', cwd, stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
|
+
});
|
|
113
|
+
} catch (_) {
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isTestFile(p) {
|
|
119
|
+
return TEST_FILE_RE.test(p);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isImplFile(p) {
|
|
123
|
+
return IMPL_EXT_RE.test(p) && !isTestFile(p);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── D1: AC test ratio ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function dimensionAcTestRatio(specContent, testFilePaths) {
|
|
129
|
+
const specACs = extractSpecACs(specContent);
|
|
130
|
+
if (!specACs || specACs.length === 0) return { score: null, reason: 'no-acs' };
|
|
131
|
+
|
|
132
|
+
const covered = scanTestFilesForACs(testFilePaths);
|
|
133
|
+
let hits = 0;
|
|
134
|
+
for (const id of specACs) if (covered.has(id)) hits++;
|
|
135
|
+
return { score: hits / specACs.length, total: specACs.length, covered: hits };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── D2: diff sibling test ratio ─────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function dimensionDiffSibling(changedFiles) {
|
|
141
|
+
const impl = changedFiles.filter(isImplFile);
|
|
142
|
+
const tests = changedFiles.filter(isTestFile);
|
|
143
|
+
if (impl.length === 0) return { score: null, reason: 'no-impl-files' };
|
|
144
|
+
// Ratio is clamped to [0, 1] — adding more test files than impl files still
|
|
145
|
+
// caps at a perfect score rather than over-inflating.
|
|
146
|
+
const raw = tests.length / impl.length;
|
|
147
|
+
return { score: Math.max(0, Math.min(1, raw)), impl: impl.length, tests: tests.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── D3: complexity proxy ────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Count occurrences of branch keywords within ±CONTEXT_LINES around every
|
|
154
|
+
* changed hunk across the unified diff. Higher count = more complex change.
|
|
155
|
+
* Returns { score, branches } where score = 1 - min(1, branches / threshold).
|
|
156
|
+
*/
|
|
157
|
+
function dimensionComplexity(diffText) {
|
|
158
|
+
if (!diffText) return { score: null, reason: 'no-diff' };
|
|
159
|
+
|
|
160
|
+
// Build a regex that matches any branch keyword. Word-boundary for alphas,
|
|
161
|
+
// literal for symbolic operators.
|
|
162
|
+
const wordKw = BRANCH_KEYWORDS.filter((k) => /^[a-z]+$/i.test(k));
|
|
163
|
+
const symKw = BRANCH_KEYWORDS.filter((k) => !/^[a-z]+$/i.test(k));
|
|
164
|
+
const wordRe = new RegExp(`\\b(?:${wordKw.join('|')})\\b`, 'g');
|
|
165
|
+
const symParts = symKw.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
166
|
+
const symRe = symParts.length ? new RegExp(symParts.join('|'), 'g') : null;
|
|
167
|
+
|
|
168
|
+
// Walk file-by-file, track hunk line windows in the post-image (b/ side).
|
|
169
|
+
// For each hunk header "@@ -x,y +a,b @@" we mark [a - CTX, a + b + CTX] as
|
|
170
|
+
// the window of interest. We then count keyword occurrences on content
|
|
171
|
+
// lines (starting with " ", "+", or "-") whose b-side line number falls in
|
|
172
|
+
// any window. For simplicity we count keywords across the WHOLE hunk body
|
|
173
|
+
// (hunks are already the "±20 lines" surrounding a change), effectively
|
|
174
|
+
// implementing the ±20 context heuristic without tracking per-line numbers.
|
|
175
|
+
let branches = 0;
|
|
176
|
+
const lines = diffText.split('\n');
|
|
177
|
+
let inHunk = false;
|
|
178
|
+
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
if (line.startsWith('@@')) { inHunk = true; continue; }
|
|
181
|
+
if (line.startsWith('diff --git') || line.startsWith('+++ ') || line.startsWith('--- ')
|
|
182
|
+
|| line.startsWith('index ') || line.startsWith('new file')
|
|
183
|
+
|| line.startsWith('deleted file') || line.startsWith('similarity index')
|
|
184
|
+
|| line.startsWith('rename ')) {
|
|
185
|
+
inHunk = false;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!inHunk) continue;
|
|
189
|
+
// Count within context + added + removed lines
|
|
190
|
+
if (!(line.startsWith(' ') || line.startsWith('+') || line.startsWith('-'))) continue;
|
|
191
|
+
const body = line.slice(1);
|
|
192
|
+
const wm = body.match(wordRe);
|
|
193
|
+
if (wm) branches += wm.length;
|
|
194
|
+
if (symRe) {
|
|
195
|
+
const sm = body.match(symRe);
|
|
196
|
+
if (sm) branches += sm.length;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const score = 1 - Math.min(1, branches / COMPLEXITY_THRESHOLD);
|
|
201
|
+
return { score, branches };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── D4: JSDoc ratio via queryLsp + lookback ─────────────────────────────────
|
|
205
|
+
|
|
206
|
+
const FUNCTION_KINDS = new Set([
|
|
207
|
+
12, // Function
|
|
208
|
+
6, // Method
|
|
209
|
+
9, // Constructor
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
function flattenSymbols(symbols, out = []) {
|
|
213
|
+
for (const s of symbols || []) {
|
|
214
|
+
out.push(s);
|
|
215
|
+
if (Array.isArray(s.children) && s.children.length) flattenSymbols(s.children, out);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function symbolStartLine(sym) {
|
|
221
|
+
// DocumentSymbol has .range; SymbolInformation has .location.range.
|
|
222
|
+
const range = sym.range || (sym.location && sym.location.range);
|
|
223
|
+
if (!range || !range.start) return null;
|
|
224
|
+
return range.start.line; // 0-based
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function hasJsDocAbove(fileLines, startLine) {
|
|
228
|
+
const start = Math.max(0, startLine - JSDOC_LOOKBACK_LINES);
|
|
229
|
+
const slice = fileLines.slice(start, startLine).map((l) => l.trim());
|
|
230
|
+
// Accept any JSDoc-style comment: */ on a preceding line, or /** on one of
|
|
231
|
+
// the lookback lines, or leading // comments for non-JS languages.
|
|
232
|
+
for (const line of slice) {
|
|
233
|
+
if (line.startsWith('/**') || line.startsWith('*/') || line.startsWith('*')
|
|
234
|
+
|| line.startsWith('///') || line.startsWith('# ') || line.startsWith('"""')) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function dimensionJsDocRatio(cwd, changedFiles) {
|
|
242
|
+
const implFiles = changedFiles.filter(isImplFile);
|
|
243
|
+
if (implFiles.length === 0) return { score: null, reason: 'no-impl-files' };
|
|
244
|
+
|
|
245
|
+
const detected = detectLanguageServer(cwd, implFiles);
|
|
246
|
+
if (!detected || !isBinaryAvailable(detected.binary)) {
|
|
247
|
+
return { score: null, reason: 'lsp_unavailable' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let totalFns = 0;
|
|
251
|
+
let documented = 0;
|
|
252
|
+
|
|
253
|
+
for (const rel of implFiles) {
|
|
254
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(cwd, rel);
|
|
255
|
+
let source;
|
|
256
|
+
try { source = fs.readFileSync(abs, 'utf8'); } catch (_) { continue; }
|
|
257
|
+
const fileLines = source.split('\n');
|
|
258
|
+
|
|
259
|
+
const uri = 'file://' + abs;
|
|
260
|
+
let resp;
|
|
261
|
+
try {
|
|
262
|
+
resp = await queryLsp(detected.binary, cwd, uri, 'textDocument/documentSymbol', {
|
|
263
|
+
textDocument: { uri },
|
|
264
|
+
});
|
|
265
|
+
} catch (_) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!resp || !resp.ok) continue;
|
|
269
|
+
|
|
270
|
+
const symbols = flattenSymbols(Array.isArray(resp.result) ? resp.result : []);
|
|
271
|
+
for (const sym of symbols) {
|
|
272
|
+
if (!FUNCTION_KINDS.has(sym.kind)) continue;
|
|
273
|
+
const startLine = symbolStartLine(sym);
|
|
274
|
+
if (startLine == null) continue;
|
|
275
|
+
totalFns++;
|
|
276
|
+
if (hasJsDocAbove(fileLines, startLine)) documented++;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (totalFns === 0) return { score: null, reason: 'no-functions' };
|
|
281
|
+
return { score: documented / totalFns, total: totalFns, documented };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Aggregation ──────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Main scoring function.
|
|
288
|
+
* @param {object} opts
|
|
289
|
+
* @param {string} opts.cwd project root
|
|
290
|
+
* @param {string} opts.specContent active spec markdown
|
|
291
|
+
* @param {string} opts.diff unified diff against HEAD~1
|
|
292
|
+
* @param {string[]} opts.changedFiles changed file paths (relative)
|
|
293
|
+
* @param {string[]} [opts.testFilePaths] test files to scan for AC refs
|
|
294
|
+
* (defaults to all test files under cwd discovered from changedFiles +
|
|
295
|
+
* existing on disk — callers may override for determinism).
|
|
296
|
+
* @returns {Promise<{ score: number|null, dimensions: object }>}
|
|
297
|
+
*/
|
|
298
|
+
async function scoreHarness(opts) {
|
|
299
|
+
const { cwd, specContent, diff, changedFiles } = opts;
|
|
300
|
+
|
|
301
|
+
// D1 — scan ALL test files in the repo for broad AC coverage, not just diff.
|
|
302
|
+
const testFilePaths = opts.testFilePaths
|
|
303
|
+
|| changedFiles.filter(isTestFile).map((f) => path.isAbsolute(f) ? f : path.join(cwd, f));
|
|
304
|
+
|
|
305
|
+
const d1 = dimensionAcTestRatio(specContent, testFilePaths);
|
|
306
|
+
const d2 = dimensionDiffSibling(changedFiles);
|
|
307
|
+
const d3 = dimensionComplexity(diff);
|
|
308
|
+
const d4 = await dimensionJsDocRatio(cwd, changedFiles);
|
|
309
|
+
|
|
310
|
+
const dims = [d1, d2, d3, d4];
|
|
311
|
+
const scored = dims.filter((d) => d && typeof d.score === 'number');
|
|
312
|
+
if (scored.length === 0) {
|
|
313
|
+
return { score: null, dimensions: { d1, d2, d3, d4 }, reason: 'no-signal' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Equal-weight average over the 4 dimensions. Dimensions with null score
|
|
317
|
+
// (insufficient signal) are treated as 1.0 (neutral) to avoid penalising
|
|
318
|
+
// a change for something that cannot be measured deterministically.
|
|
319
|
+
const vals = dims.map((d) => (d && typeof d.score === 'number' ? d.score : 1));
|
|
320
|
+
const score = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
321
|
+
|
|
322
|
+
return { score, dimensions: { d1, d2, d3, d4 } };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Hook entry ───────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
function isGitCommitBash(toolName, toolInput) {
|
|
328
|
+
if (toolName !== 'Bash') return false;
|
|
329
|
+
const cmd = (toolInput && (toolInput.command || toolInput.cmd || '')) || '';
|
|
330
|
+
return /git\s+commit\b/.test(cmd);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function runHook(data) {
|
|
334
|
+
const toolName = data.tool_name || '';
|
|
335
|
+
const toolInput = data.tool_input || {};
|
|
336
|
+
if (!isGitCommitBash(toolName, toolInput)) return;
|
|
337
|
+
|
|
338
|
+
const cwd = data.cwd || process.cwd();
|
|
339
|
+
|
|
340
|
+
const diff = getFullDiff(cwd);
|
|
341
|
+
const changedFiles = getChangedFiles(cwd);
|
|
342
|
+
if (!diff || changedFiles.length === 0) return;
|
|
343
|
+
|
|
344
|
+
const spec = loadActiveSpec(cwd);
|
|
345
|
+
if (!spec) return; // no active spec — nothing to score against
|
|
346
|
+
|
|
347
|
+
const minScore = readMinScore(cwd);
|
|
348
|
+
const { score, dimensions, reason } = await scoreHarness({
|
|
349
|
+
cwd, specContent: spec.content, diff, changedFiles,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
if (score == null) {
|
|
353
|
+
// No signal — silent pass
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const pct = (n) => (n == null ? 'n/a' : n.toFixed(2));
|
|
358
|
+
const summary = `[df-harness-score] score=${score.toFixed(2)} (min=${minScore.toFixed(2)}) `
|
|
359
|
+
+ `D1=${pct(dimensions.d1.score)} D2=${pct(dimensions.d2.score)} `
|
|
360
|
+
+ `D3=${pct(dimensions.d3.score)} D4=${pct(dimensions.d4.score)}`;
|
|
361
|
+
|
|
362
|
+
if (score < minScore) {
|
|
363
|
+
console.error(summary);
|
|
364
|
+
console.error('[df-harness-score] OVERRIDE:SALVAGEABLE — harness quality below threshold');
|
|
365
|
+
process.exit(2);
|
|
366
|
+
} else {
|
|
367
|
+
console.log(summary);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Wire up stdin entry ──────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
readStdinIfMain(module, (data) => {
|
|
374
|
+
// runHook is async — fire and let the process exit naturally on resolution.
|
|
375
|
+
// We avoid awaiting inside readStdinIfMain's sync callback.
|
|
376
|
+
runHook(data).catch(() => { /* never break Claude Code on hook errors */ });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
module.exports = {
|
|
380
|
+
scoreHarness,
|
|
381
|
+
dimensionAcTestRatio,
|
|
382
|
+
dimensionDiffSibling,
|
|
383
|
+
dimensionComplexity,
|
|
384
|
+
dimensionJsDocRatio,
|
|
385
|
+
readMinScore,
|
|
386
|
+
isGitCommitBash,
|
|
387
|
+
isTestFile,
|
|
388
|
+
isImplFile,
|
|
389
|
+
};
|