deepflow 0.1.111 → 0.1.113

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.
@@ -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> --output <text> --status <pass|fail|revert>
8
- * node ac-coverage.js --spec <path> --output-file <path> --status <pass|fail|revert>
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 === '--output' && argv[i + 1]) { args.output = argv[++i]; }
28
- else if (a === '--output-file' && argv[i + 1]) { args.outputFile = argv[++i]; }
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,40 +85,67 @@ function extractSpecACs(specContent) {
82
85
  });
83
86
  }
84
87
 
85
- // ── AC parsing from agent output ────────────────────────────────────────────
88
+ // ── AC scanning from test files ─────────────────────────────────────────────
86
89
 
87
90
  /**
88
- * Parse AC_COVERAGE block from agent output.
89
- * Returns a Map of AC-N → { status: 'done'|'skip', reason: string|null }
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 parseACCoverage(outputText) {
92
- const map = new Map();
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 blockMatch = outputText.match(/AC_COVERAGE:([\s\S]*?)AC_COVERAGE_END/);
95
- if (!blockMatch) return map;
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
- const block = blockMatch[1];
98
- const linePattern = /^(AC-\d+):(done|skip)(?::(.+))?$/gm;
99
- let m;
100
- while ((m = linePattern.exec(block)) !== null) {
101
- map.set(m[1], {
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
- return map;
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 ──────────────────────────────────────────────────────────────
110
140
 
111
141
  function run(args) {
112
142
  if (!args.spec) {
113
- console.error('[ac-coverage] Error: --spec is required');
114
- process.exit(1);
143
+ // Called as a PostToolUse hook without explicit args — no-op
144
+ process.exit(0);
115
145
  }
116
146
  if (!args.status) {
117
- console.error('[ac-coverage] Error: --status is required');
118
- process.exit(1);
147
+ // Called as a PostToolUse hook without explicit args — no-op
148
+ process.exit(0);
119
149
  }
120
150
 
121
151
  // Read spec
@@ -127,77 +157,53 @@ function run(args) {
127
157
  process.exit(1);
128
158
  }
129
159
 
130
- // Extract canonical ACs (AC-2, AC-7, AC-8)
160
+ // Extract canonical ACs from spec
131
161
  const specACs = extractSpecACs(specContent);
132
162
 
133
- // AC-7: no Acceptance Criteria section → silent exit
163
+ // No Acceptance Criteria section → silent exit
134
164
  if (specACs === null) {
135
165
  process.exit(0);
136
166
  }
137
167
 
138
- // AC-8: section exists but no AC-\d+ patterns → silent exit
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
- // Read agent output
144
- let outputText = '';
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
- // AC-3: parse AC_COVERAGE block from agent output
157
- const reported = parseACCoverage(outputText);
176
+ // Scan test files for AC references
177
+ const coveredACs = scanTestFilesForACs(testFilePaths);
158
178
 
159
- // AC-3: diff — identify missed ACs (not reported as done)
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
- const entry = reported.get(id);
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
- const summaryDetail = summaryParts.length > 0 ? ` — ${summaryParts.join('; ')}` : '';
189
- console.log(`[ac-coverage] ${reportedDoneCount}/${totalACs} ACs covered${summaryDetail}`);
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
- // AC-4 / AC-5: status override
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
- // AC-4: override to SALVAGEABLE
202
+ // Override to SALVAGEABLE
197
203
  console.log('OVERRIDE:SALVAGEABLE');
198
204
  process.exit(2);
199
205
  } else {
200
- // AC-5: all done, or status was already fail/revert — no override
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, parseACCoverage, extractACSection };
219
+ module.exports = { extractSpecACs, extractACSection, scanTestFilesForACs, resolveTestFiles };
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // @hook-event: SessionStart
3
+ // @hook-owner: deepflow
3
4
  /**
4
5
  * deepflow update checker
5
6
  * Runs in background, checks npm for newer versions
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // @hook-event: PreToolUse, PostToolUse, SessionEnd
3
+ // @hook-owner: dashboard
3
4
  /**
4
5
  * deepflow command usage tracker
5
6
  * Tracks df:* command invocations with token deltas and tool call counts.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // @hook-event: SessionEnd
3
+ // @hook-owner: dashboard
3
4
  /**
4
5
  * deepflow dashboard push — SessionEnd hook
5
6
  * Collects session summary (tokens, duration, tool calls, model), gets
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // @hook-event: PostToolUse
3
+ // @hook-owner: dashboard
3
4
  /**
4
5
  * deepflow execution history recorder
5
6
  * PostToolUse hook: fires when the Agent tool completes.
@@ -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
+ };