@devtrace-ai/cli 1.0.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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/analyzer.js +163 -0
- package/dist/capture/claude.js +219 -0
- package/dist/capture/trailers.js +112 -0
- package/dist/card.js +142 -0
- package/dist/chat.js +266 -0
- package/dist/claude-correlate.js +150 -0
- package/dist/claude-hook-install.js +183 -0
- package/dist/claude-hooks.js +236 -0
- package/dist/cli.js +113 -0
- package/dist/config.js +174 -0
- package/dist/core/scan.js +265 -0
- package/dist/core/survival.js +400 -0
- package/dist/detector.js +92 -0
- package/dist/digest.js +183 -0
- package/dist/git.js +212 -0
- package/dist/hooks.js +166 -0
- package/dist/html_report.js +325 -0
- package/dist/ignore.js +126 -0
- package/dist/index.js +798 -0
- package/dist/metrics.js +133 -0
- package/dist/reporter.js +127 -0
- package/dist/storage.js +344 -0
- package/dist/test_runner.js +143 -0
- package/dist/types.js +2 -0
- package/package.json +53 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.computeSurvival = computeSurvival;
|
|
37
|
+
exports.survivalRateFor = survivalRateFor;
|
|
38
|
+
exports.computeWindowedSurvival = computeWindowedSurvival;
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const DEFAULT_MAX_FILE_BYTES = 1024 * 1024; // 1MB
|
|
44
|
+
const DEFAULT_MAX_FILE_LINES = 20000;
|
|
45
|
+
/**
|
|
46
|
+
* Pass 2 of the retroactive scan: blame HEAD once per file (not once per commit-per-file),
|
|
47
|
+
* and reverse-attribute every surviving line back to the commit that introduced it.
|
|
48
|
+
* O(files-alive-at-HEAD) git-blame invocations, amortized across every window commit that
|
|
49
|
+
* touched those files — see docs/plans/04-architecture-audit-build-plan.md §2.3.
|
|
50
|
+
*/
|
|
51
|
+
async function computeSurvival(touchedFiles, options = {}) {
|
|
52
|
+
const cwd = options.cwd || process.cwd();
|
|
53
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
54
|
+
const maxFileLines = options.maxFileLines ?? DEFAULT_MAX_FILE_LINES;
|
|
55
|
+
const concurrency = Math.max(1, Math.min(options.concurrency ?? os.cpus().length, 8));
|
|
56
|
+
const timeBudgetMs = options.timeBudgetMs ?? Infinity;
|
|
57
|
+
const ignoreFilter = options.ignoreFilter;
|
|
58
|
+
const startTime = Date.now();
|
|
59
|
+
const survivingLines = new Map();
|
|
60
|
+
const skippedFiles = [];
|
|
61
|
+
// De-dupe and filter to files that still exist at HEAD (working tree == HEAD is assumed
|
|
62
|
+
// for a clean checkout; we read from disk since that's what's actually blame-able).
|
|
63
|
+
const uniqueFiles = Array.from(new Set(touchedFiles));
|
|
64
|
+
const candidateFiles = [];
|
|
65
|
+
for (const file of uniqueFiles) {
|
|
66
|
+
if (ignoreFilter && ignoreFilter.shouldIgnore(file))
|
|
67
|
+
continue;
|
|
68
|
+
const absPath = path.join(cwd, file);
|
|
69
|
+
let stat;
|
|
70
|
+
try {
|
|
71
|
+
stat = fs.statSync(absPath);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue; // file no longer exists at HEAD — nothing to blame
|
|
75
|
+
}
|
|
76
|
+
if (!stat.isFile())
|
|
77
|
+
continue;
|
|
78
|
+
if (stat.size > maxFileBytes) {
|
|
79
|
+
skippedFiles.push(file);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
candidateFiles.push(file);
|
|
83
|
+
}
|
|
84
|
+
let filesBlamed = 0;
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
const totalFiles = candidateFiles.length;
|
|
87
|
+
async function worker() {
|
|
88
|
+
while (cursor < candidateFiles.length) {
|
|
89
|
+
if (Date.now() - startTime > timeBudgetMs) {
|
|
90
|
+
// Budget exhausted — mark all remaining candidates as skipped (unknown survival).
|
|
91
|
+
while (cursor < candidateFiles.length) {
|
|
92
|
+
skippedFiles.push(candidateFiles[cursor]);
|
|
93
|
+
cursor++;
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const idx = cursor++;
|
|
98
|
+
const file = candidateFiles[idx];
|
|
99
|
+
const lineCount = countLinesQuick(path.join(cwd, file), maxFileLines);
|
|
100
|
+
if (lineCount === null) {
|
|
101
|
+
skippedFiles.push(file);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const perCommit = await blameFile(file, 'HEAD', cwd);
|
|
105
|
+
if (perCommit === null) {
|
|
106
|
+
skippedFiles.push(file);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
for (const [hash, count] of perCommit.entries()) {
|
|
110
|
+
survivingLines.set(hash, (survivingLines.get(hash) || 0) + count);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
filesBlamed++;
|
|
115
|
+
if (options.onProgress) {
|
|
116
|
+
options.onProgress(filesBlamed, totalFiles);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const workers = Array.from({ length: Math.min(concurrency, Math.max(1, candidateFiles.length)) }, () => worker());
|
|
121
|
+
await Promise.all(workers);
|
|
122
|
+
return {
|
|
123
|
+
survivingLines,
|
|
124
|
+
skippedFiles,
|
|
125
|
+
filesBlamed,
|
|
126
|
+
filesSkipped: skippedFiles.length
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Returns the file's line count, or null if it exceeds maxFileLines (size guard).
|
|
131
|
+
* Cheap synchronous read — files are already size-bounded by maxFileBytes before this runs.
|
|
132
|
+
*/
|
|
133
|
+
function countLinesQuick(absPath, maxFileLines) {
|
|
134
|
+
try {
|
|
135
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
136
|
+
// Guard against pathological single-line minified files too: count newlines, cap early.
|
|
137
|
+
let lines = 1;
|
|
138
|
+
for (let i = 0; i < content.length; i++) {
|
|
139
|
+
if (content.charCodeAt(i) === 10) {
|
|
140
|
+
lines++;
|
|
141
|
+
if (lines > maxFileLines)
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null; // unreadable (e.g. binary with invalid utf8, permissions) — skip
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Runs `git blame --line-porcelain <ref> -- <file>` and returns a Map<commitHash, lineCount>
|
|
153
|
+
* for that single file, parsing porcelain groups properly: a new attribution group starts
|
|
154
|
+
* with a line matching `^<40-or-64-hex-hash> <origLine> <finalLine>(?: <numLines>)?$`, and
|
|
155
|
+
* every subsequent line up to and including the tab-prefixed content line belongs to that
|
|
156
|
+
* group's commit. This is NOT a naive "line startsWith(hash)" prefix match — commit hash
|
|
157
|
+
* only appears once per group, with content lines following it via metadata, not repetition.
|
|
158
|
+
*
|
|
159
|
+
* `ref` may be any git-ish (HEAD or a specific boundary commit hash) so this can be reused
|
|
160
|
+
* for both HEAD survival and windowed (commit+30d) survival against the same file.
|
|
161
|
+
*/
|
|
162
|
+
function blameFile(file, ref, cwd) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const child = (0, child_process_1.spawn)('git', ['blame', '--line-porcelain', ref, '--', file], {
|
|
165
|
+
cwd,
|
|
166
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
167
|
+
});
|
|
168
|
+
let buffer = '';
|
|
169
|
+
let stderr = '';
|
|
170
|
+
const counts = new Map();
|
|
171
|
+
let currentHash = null;
|
|
172
|
+
const commitLineRe = /^([0-9a-f]{40}|[0-9a-f]{64}) (\d+) (\d+)(?: (\d+))?$/;
|
|
173
|
+
child.stdout.setEncoding('utf8');
|
|
174
|
+
child.stdout.on('data', (data) => {
|
|
175
|
+
buffer += data;
|
|
176
|
+
let nlIdx;
|
|
177
|
+
while ((nlIdx = buffer.indexOf('\n')) !== -1) {
|
|
178
|
+
const line = buffer.substring(0, nlIdx);
|
|
179
|
+
buffer = buffer.substring(nlIdx + 1);
|
|
180
|
+
processLine(line);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
function processLine(line) {
|
|
184
|
+
if (line.length === 0)
|
|
185
|
+
return;
|
|
186
|
+
if (line[0] === '\t') {
|
|
187
|
+
// Content line — closes the current group. Attribute exactly one line to currentHash.
|
|
188
|
+
if (currentHash) {
|
|
189
|
+
counts.set(currentHash, (counts.get(currentHash) || 0) + 1);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const commitMatch = commitLineRe.exec(line);
|
|
194
|
+
if (commitMatch) {
|
|
195
|
+
currentHash = commitMatch[1];
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Otherwise it's a metadata line (author, author-mail, summary, previous, filename, etc.)
|
|
199
|
+
// belonging to the current group — no action needed, we only care about the content line.
|
|
200
|
+
}
|
|
201
|
+
child.stderr.setEncoding('utf8');
|
|
202
|
+
child.stderr.on('data', (data) => {
|
|
203
|
+
stderr += data;
|
|
204
|
+
});
|
|
205
|
+
child.on('error', () => resolve(null));
|
|
206
|
+
child.on('close', (code) => {
|
|
207
|
+
if (buffer.length > 0) {
|
|
208
|
+
processLine(buffer);
|
|
209
|
+
}
|
|
210
|
+
if (code !== 0) {
|
|
211
|
+
resolve(null); // e.g. file not tracked at HEAD, or blame failure — treat as unknown
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
resolve(counts);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* survivalRate(C) = survivingLines[C] / insertions(C), capped at 1.0.
|
|
220
|
+
* Returns null (unknown) when the commit's insertions were 0 (nothing to measure) — callers
|
|
221
|
+
* should distinguish this from a genuine 0 survival rate.
|
|
222
|
+
*/
|
|
223
|
+
function survivalRateFor(survivingLines, insertions) {
|
|
224
|
+
if (insertions <= 0)
|
|
225
|
+
return null;
|
|
226
|
+
return Math.min(1.0, survivingLines / insertions);
|
|
227
|
+
}
|
|
228
|
+
const WINDOW_DAYS = 30;
|
|
229
|
+
const WINDOW_MS = WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
230
|
+
/**
|
|
231
|
+
* Windowed 30-day survival (T1.1): for each commit old enough to have a full 30-day window,
|
|
232
|
+
* blame the (file, boundary-commit) pair at the last commit within [C.date, C.date+30d] —
|
|
233
|
+
* NOT always HEAD — and reverse-attribute surviving lines the same way computeSurvival does.
|
|
234
|
+
* Commits younger than 30 days are 'window-open' (never a fabricated false/0). Commits that hit
|
|
235
|
+
* a size guard or the time budget are 'unknown', never silently coerced to 0.
|
|
236
|
+
*
|
|
237
|
+
* Perf: (file, boundary) blame pairs are cached and computed at most once even when many
|
|
238
|
+
* commits resolve to the same boundary commit (common for repos with sparse commit cadence),
|
|
239
|
+
* reusing the same bounded concurrency pool and time budget as computeSurvival.
|
|
240
|
+
*/
|
|
241
|
+
async function computeWindowedSurvival(commits, options = {}) {
|
|
242
|
+
const cwd = options.cwd || process.cwd();
|
|
243
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
244
|
+
const maxFileLines = options.maxFileLines ?? DEFAULT_MAX_FILE_LINES;
|
|
245
|
+
const concurrency = Math.max(1, Math.min(options.concurrency ?? os.cpus().length, 8));
|
|
246
|
+
const timeBudgetMs = options.timeBudgetMs ?? Infinity;
|
|
247
|
+
const now = options.now ?? Date.now();
|
|
248
|
+
const startTime = Date.now();
|
|
249
|
+
const survival30d = new Map();
|
|
250
|
+
// Step 1: classify each commit as window-open (too young) or eligible, and resolve the
|
|
251
|
+
// boundary commit (last commit at or before C.date + 30d) for eligible ones. Boundary
|
|
252
|
+
// resolution is one cheap `git rev-list` per commit (not per file) — negligible next to blame.
|
|
253
|
+
const boundaryByCommit = new Map(); // commitHash -> boundary hash | null (resolution failed -> unknown)
|
|
254
|
+
const eligible = [];
|
|
255
|
+
for (const c of commits) {
|
|
256
|
+
const commitTime = new Date(c.authorDate).getTime();
|
|
257
|
+
if (isNaN(commitTime) || now - commitTime < WINDOW_MS) {
|
|
258
|
+
survival30d.set(c.commitHash, { status: 'window-open' });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
eligible.push(c);
|
|
262
|
+
const boundaryDate = new Date(commitTime + WINDOW_MS).toISOString();
|
|
263
|
+
const boundary = resolveBoundaryCommit(boundaryDate, cwd);
|
|
264
|
+
boundaryByCommit.set(c.commitHash, boundary);
|
|
265
|
+
if (boundary === null) {
|
|
266
|
+
survival30d.set(c.commitHash, { status: 'unknown' });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const measurable = eligible.filter(c => boundaryByCommit.get(c.commitHash) !== null);
|
|
270
|
+
if (measurable.length === 0) {
|
|
271
|
+
return { survival30d };
|
|
272
|
+
}
|
|
273
|
+
const pairSurviving = new Map();
|
|
274
|
+
const pairsToBlame = [];
|
|
275
|
+
const seenPairs = new Set();
|
|
276
|
+
for (const c of measurable) {
|
|
277
|
+
const boundary = boundaryByCommit.get(c.commitHash);
|
|
278
|
+
for (const file of c.files) {
|
|
279
|
+
const key = `${boundary}${file}`;
|
|
280
|
+
if (seenPairs.has(key))
|
|
281
|
+
continue;
|
|
282
|
+
seenPairs.add(key);
|
|
283
|
+
pairsToBlame.push({ key, boundary, file });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
let blamed = 0;
|
|
287
|
+
let cursor = 0;
|
|
288
|
+
const total = pairsToBlame.length;
|
|
289
|
+
async function worker() {
|
|
290
|
+
while (cursor < pairsToBlame.length) {
|
|
291
|
+
if (Date.now() - startTime > timeBudgetMs) {
|
|
292
|
+
while (cursor < pairsToBlame.length) {
|
|
293
|
+
const { key } = pairsToBlame[cursor];
|
|
294
|
+
pairSurviving.set(key, 'unknown');
|
|
295
|
+
cursor++;
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const idx = cursor++;
|
|
300
|
+
const { key, boundary, file } = pairsToBlame[idx];
|
|
301
|
+
const lineCount = countLinesAtRefQuick(file, boundary, cwd, maxFileBytes, maxFileLines);
|
|
302
|
+
if (lineCount === null) {
|
|
303
|
+
pairSurviving.set(key, 'unknown');
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const perCommit = await blameFile(file, boundary, cwd);
|
|
307
|
+
pairSurviving.set(key, perCommit === null ? 'unknown' : perCommit);
|
|
308
|
+
}
|
|
309
|
+
blamed++;
|
|
310
|
+
if (options.onProgress) {
|
|
311
|
+
options.onProgress(blamed, total);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const workers = Array.from({ length: Math.min(concurrency, Math.max(1, pairsToBlame.length)) }, () => worker());
|
|
316
|
+
await Promise.all(workers);
|
|
317
|
+
// Step 3: aggregate per commit from its (boundary, file) pairs' cached blame results.
|
|
318
|
+
for (const c of measurable) {
|
|
319
|
+
const boundary = boundaryByCommit.get(c.commitHash);
|
|
320
|
+
let surviving = 0;
|
|
321
|
+
let anyUnknown = false;
|
|
322
|
+
for (const file of c.files) {
|
|
323
|
+
const key = `${boundary}${file}`;
|
|
324
|
+
const result = pairSurviving.get(key);
|
|
325
|
+
if (result === undefined || result === 'unknown') {
|
|
326
|
+
anyUnknown = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
surviving += result.get(c.commitHash) || 0;
|
|
330
|
+
}
|
|
331
|
+
if (anyUnknown) {
|
|
332
|
+
// If the time budget was hit, degrade to unknown rather than silently understating
|
|
333
|
+
// survival from only the files that did get blamed.
|
|
334
|
+
survival30d.set(c.commitHash, { status: 'unknown' });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const rate = survivalRateFor(surviving, c.insertions);
|
|
338
|
+
survival30d.set(c.commitHash, rate === null ? { status: 'unknown' } : { status: 'measured', rate });
|
|
339
|
+
}
|
|
340
|
+
return { survival30d };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Resolves the boundary commit for a windowed measurement: the last commit at or before
|
|
344
|
+
* `beforeDateIso` (i.e. "closest to C+30d without going over"), reachable from HEAD.
|
|
345
|
+
* Returns null if resolution fails (e.g. shallow clone missing history) — callers must
|
|
346
|
+
* treat that as 'unknown', never fabricate a survival number.
|
|
347
|
+
*/
|
|
348
|
+
function resolveBoundaryCommit(beforeDateIso, cwd) {
|
|
349
|
+
try {
|
|
350
|
+
const result = (0, child_process_1.spawnSync)('git', ['rev-list', '-1', `--before=${beforeDateIso}`, 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
351
|
+
if (result.status !== 0)
|
|
352
|
+
return null;
|
|
353
|
+
const hash = result.stdout.trim();
|
|
354
|
+
return hash.length > 0 ? hash : null;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Size-guard check for a file *as it existed at `ref`* (not the working tree — the boundary
|
|
362
|
+
* commit is generally not HEAD, so we must read git object size/content, not disk state).
|
|
363
|
+
* Returns the line count, or null if the file doesn't exist at ref, exceeds the byte/line
|
|
364
|
+
* guard, or is otherwise unreadable (binary, etc.).
|
|
365
|
+
*/
|
|
366
|
+
function countLinesAtRefQuick(file, ref, cwd, maxFileBytes, maxFileLines) {
|
|
367
|
+
try {
|
|
368
|
+
const sizeResult = (0, child_process_1.spawnSync)('git', ['cat-file', '-s', `${ref}:${file}`], {
|
|
369
|
+
cwd,
|
|
370
|
+
encoding: 'utf8',
|
|
371
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
372
|
+
});
|
|
373
|
+
if (sizeResult.status !== 0)
|
|
374
|
+
return null; // file doesn't exist at this ref
|
|
375
|
+
const size = parseInt(sizeResult.stdout.trim(), 10);
|
|
376
|
+
if (isNaN(size) || size > maxFileBytes)
|
|
377
|
+
return null;
|
|
378
|
+
const contentResult = (0, child_process_1.spawnSync)('git', ['show', `${ref}:${file}`], {
|
|
379
|
+
cwd,
|
|
380
|
+
encoding: 'utf8',
|
|
381
|
+
maxBuffer: maxFileBytes + 1024,
|
|
382
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
383
|
+
});
|
|
384
|
+
if (contentResult.status !== 0)
|
|
385
|
+
return null;
|
|
386
|
+
const content = contentResult.stdout;
|
|
387
|
+
let lines = 1;
|
|
388
|
+
for (let i = 0; i < content.length; i++) {
|
|
389
|
+
if (content.charCodeAt(i) === 10) {
|
|
390
|
+
lines++;
|
|
391
|
+
if (lines > maxFileLines)
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return lines;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AIDetector = void 0;
|
|
4
|
+
const git_1 = require("./git");
|
|
5
|
+
const trailers_1 = require("./capture/trailers");
|
|
6
|
+
// Heuristic (tier 3) signals are weak priors, never proof. Each contributes at most
|
|
7
|
+
// this much confidence individually, and the tier as a whole is capped well below
|
|
8
|
+
// the trailer-confirmed floor so it can never be mistaken for a confirmed detection.
|
|
9
|
+
const HEURISTIC_SIGNAL_CAP = 0.25;
|
|
10
|
+
const HEURISTIC_TIER_CAP = 0.5;
|
|
11
|
+
const TRAILER_CONFIDENCE = 0.95;
|
|
12
|
+
const UNKNOWN_MAX_CONFIDENCE = 0.3;
|
|
13
|
+
class AIDetector {
|
|
14
|
+
/**
|
|
15
|
+
* Evaluates whether a commit shows AI provenance, tier-first:
|
|
16
|
+
* Tier 1 (confirmed): any commit-trailer match from TrailerParser — fact, not inference.
|
|
17
|
+
* Tier 3 (heuristic): velocity/greenfield diff-shape priors, demoted and capped.
|
|
18
|
+
* Tier 0 (unknown): no evidence — reported with LOW confidence (this used to be
|
|
19
|
+
* inverted into a fabricated 1.0 "confidence it's human"; see reasons below for the fix).
|
|
20
|
+
*/
|
|
21
|
+
static detect(commitHash, diff, cwd = process.cwd()) {
|
|
22
|
+
const reasons = [];
|
|
23
|
+
const metadata = git_1.GitTracker.getCommitMetadata(commitHash, cwd);
|
|
24
|
+
const commitMsg = metadata.message;
|
|
25
|
+
// --- Tier 1: confirmed trailer match(es) ---
|
|
26
|
+
const trailerMatches = trailers_1.TrailerParser.parse(commitMsg, metadata.author);
|
|
27
|
+
if (trailerMatches.length > 0) {
|
|
28
|
+
const primary = trailerMatches[0];
|
|
29
|
+
for (const match of trailerMatches) {
|
|
30
|
+
const suffix = match.model ? ` (model: ${match.model})` : '';
|
|
31
|
+
reasons.push(`${match.pattern}${match.unverified ? ' [unverified-pattern]' : ''}${suffix}`);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
tool: primary.tool,
|
|
35
|
+
confidence: TRAILER_CONFIDENCE,
|
|
36
|
+
tier: 1,
|
|
37
|
+
reasons
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// --- Tier 3: heuristic weak priors (git-only signals; never proof) ---
|
|
41
|
+
let heuristicScore = 0;
|
|
42
|
+
let heuristicFired = false;
|
|
43
|
+
// Velocity heuristic: commit landed <45s after its parent with >20 lines changed.
|
|
44
|
+
// Weak in both directions — false positives on --amend/squash/typo commits, false
|
|
45
|
+
// negatives on careful AI sessions, and rebases rewrite the timestamps it depends on.
|
|
46
|
+
try {
|
|
47
|
+
const parentHash = git_1.GitTracker.resolveRef(`${commitHash}~1`, cwd);
|
|
48
|
+
if (parentHash) {
|
|
49
|
+
const parentMetadata = git_1.GitTracker.getCommitMetadata(parentHash, cwd);
|
|
50
|
+
const commitTime = new Date(metadata.timestamp).getTime();
|
|
51
|
+
const parentTime = new Date(parentMetadata.timestamp).getTime();
|
|
52
|
+
const diffSeconds = (commitTime - parentTime) / 1000;
|
|
53
|
+
if (diffSeconds > 0 && diffSeconds < 45) {
|
|
54
|
+
const linesChanged = diff.totalInsertions + diff.totalDeletions;
|
|
55
|
+
if (linesChanged > 20) {
|
|
56
|
+
heuristicFired = true;
|
|
57
|
+
heuristicScore += Math.min(HEURISTIC_SIGNAL_CAP, 0.2);
|
|
58
|
+
reasons.push(`heuristic:velocity (${diffSeconds.toFixed(1)}s < 45s threshold, ${linesChanged} lines changed)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// No parent (initial commit) — velocity heuristic doesn't apply.
|
|
65
|
+
}
|
|
66
|
+
// Greenfield density heuristic: large insert-only diffs are typical of AI initial dumps,
|
|
67
|
+
// but equally typical of vendored code drops, generated files, or human scaffolding.
|
|
68
|
+
if (diff.totalInsertions > 150 && diff.totalDeletions === 0) {
|
|
69
|
+
heuristicFired = true;
|
|
70
|
+
heuristicScore += Math.min(HEURISTIC_SIGNAL_CAP, 0.2);
|
|
71
|
+
reasons.push(`heuristic:greenfield (${diff.totalInsertions} insertions, 0 deletions)`);
|
|
72
|
+
}
|
|
73
|
+
if (heuristicFired) {
|
|
74
|
+
const confidence = Math.min(HEURISTIC_TIER_CAP, heuristicScore);
|
|
75
|
+
return {
|
|
76
|
+
tool: 'unknown-ai',
|
|
77
|
+
confidence,
|
|
78
|
+
tier: 3,
|
|
79
|
+
reasons
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// --- Tier 0: no evidence. Honest low confidence — NOT a fabricated 1.0 "human" score. ---
|
|
83
|
+
reasons.push('tier0:no-signal');
|
|
84
|
+
return {
|
|
85
|
+
tool: 'none',
|
|
86
|
+
confidence: UNKNOWN_MAX_CONFIDENCE,
|
|
87
|
+
tier: 0,
|
|
88
|
+
reasons
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.AIDetector = AIDetector;
|
package/dist/digest.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseSinceDays = parseSinceDays;
|
|
37
|
+
exports.buildDigest = buildDigest;
|
|
38
|
+
exports.writeDigestMarkdown = writeDigestMarkdown;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const config_1 = require("./config");
|
|
42
|
+
const html_report_1 = require("./html_report");
|
|
43
|
+
function weightedSurvivalRate(sessions) {
|
|
44
|
+
let totalInsertions = 0;
|
|
45
|
+
let totalSurviving = 0;
|
|
46
|
+
let anyKnown = false;
|
|
47
|
+
for (const s of sessions) {
|
|
48
|
+
if (!s.churn || s.diff.totalInsertions <= 0)
|
|
49
|
+
continue;
|
|
50
|
+
anyKnown = true;
|
|
51
|
+
totalInsertions += s.diff.totalInsertions;
|
|
52
|
+
totalSurviving += s.diff.totalInsertions * s.churn.survivalRate;
|
|
53
|
+
}
|
|
54
|
+
if (!anyKnown || totalInsertions === 0)
|
|
55
|
+
return null;
|
|
56
|
+
return totalSurviving / totalInsertions;
|
|
57
|
+
}
|
|
58
|
+
/** Parses `--since <n>d` style args into a day count. Defaults to 7. */
|
|
59
|
+
function parseSinceDays(sinceArg) {
|
|
60
|
+
if (!sinceArg)
|
|
61
|
+
return 7;
|
|
62
|
+
const m = /^(\d+)d$/.exec(sinceArg);
|
|
63
|
+
if (m)
|
|
64
|
+
return parseInt(m[1], 10);
|
|
65
|
+
const n = parseInt(sinceArg, 10);
|
|
66
|
+
return Number.isFinite(n) && n > 0 ? n : 7;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Builds the terminal + markdown digest for the last N days: new commits captured, AI share,
|
|
70
|
+
* survival movements, and newly-measured 30-day windows. Honest "no data" when the window is empty.
|
|
71
|
+
*/
|
|
72
|
+
function buildDigest(sessions, days, now = new Date()) {
|
|
73
|
+
const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
74
|
+
const windowSessions = sessions.filter(s => {
|
|
75
|
+
const t = new Date(s.timestamp);
|
|
76
|
+
return !isNaN(t.getTime()) && t >= cutoff && t <= now;
|
|
77
|
+
});
|
|
78
|
+
const lines = [];
|
|
79
|
+
const md = [];
|
|
80
|
+
const header = `DevTrace digest — last ${days} day${days === 1 ? '' : 's'}`;
|
|
81
|
+
lines.push(header);
|
|
82
|
+
lines.push('='.repeat(header.length));
|
|
83
|
+
md.push(`# ${header}`, '');
|
|
84
|
+
md.push(`_Generated ${now.toISOString()}_`, '');
|
|
85
|
+
if (windowSessions.length === 0) {
|
|
86
|
+
const msg = 'No commits captured in this window.';
|
|
87
|
+
lines.push('', msg);
|
|
88
|
+
md.push(msg, '');
|
|
89
|
+
return { markdown: md.join('\n'), text: lines.join('\n'), windowSessions };
|
|
90
|
+
}
|
|
91
|
+
const aiSessions = windowSessions.filter(s => s.aiTool !== 'none');
|
|
92
|
+
const humanSessions = windowSessions.filter(s => s.aiTool === 'none');
|
|
93
|
+
const aiSharePct = ((aiSessions.length / windowSessions.length) * 100).toFixed(0);
|
|
94
|
+
lines.push('', `New commits captured: ${windowSessions.length}`);
|
|
95
|
+
lines.push(` AI-authored: ${aiSessions.length} (${aiSharePct}%)`);
|
|
96
|
+
lines.push(` Human-authored: ${humanSessions.length}`);
|
|
97
|
+
md.push(`## New commits captured`, '');
|
|
98
|
+
md.push(`- Total: ${windowSessions.length}`);
|
|
99
|
+
md.push(`- AI-authored: ${aiSessions.length} (${aiSharePct}%)`);
|
|
100
|
+
md.push(`- Human-authored: ${humanSessions.length}`, '');
|
|
101
|
+
// By-tool breakdown within the window
|
|
102
|
+
const byTool = new Map();
|
|
103
|
+
for (const s of aiSessions) {
|
|
104
|
+
if (!byTool.has(s.aiTool))
|
|
105
|
+
byTool.set(s.aiTool, []);
|
|
106
|
+
byTool.get(s.aiTool).push(s);
|
|
107
|
+
}
|
|
108
|
+
if (byTool.size > 0) {
|
|
109
|
+
lines.push('', 'By tool:');
|
|
110
|
+
md.push(`## By tool`, '');
|
|
111
|
+
for (const [tool, toolSessions] of byTool.entries()) {
|
|
112
|
+
const rate = weightedSurvivalRate(toolSessions);
|
|
113
|
+
const rateText = rate === null ? 'unknown' : `${(rate * 100).toFixed(0)}%`;
|
|
114
|
+
lines.push(` ${(0, html_report_1.toolLabel)(tool)}: ${toolSessions.length} commit(s), survival ${rateText}`);
|
|
115
|
+
md.push(`- **${(0, html_report_1.toolLabel)(tool)}**: ${toolSessions.length} commit(s), survival ${rateText}`);
|
|
116
|
+
}
|
|
117
|
+
md.push('');
|
|
118
|
+
}
|
|
119
|
+
// Survival movement: compare AI survival rate of window commits vs. the rest of history.
|
|
120
|
+
const restSessions = sessions.filter(s => !windowSessions.includes(s) && s.aiTool !== 'none');
|
|
121
|
+
const windowAiSurvival = weightedSurvivalRate(aiSessions);
|
|
122
|
+
const restAiSurvival = weightedSurvivalRate(restSessions);
|
|
123
|
+
lines.push('', 'Survival movement:');
|
|
124
|
+
md.push(`## Survival movement`, '');
|
|
125
|
+
if (windowAiSurvival === null) {
|
|
126
|
+
lines.push(' AI survival (this window): unknown (no churn data yet)');
|
|
127
|
+
md.push(`- AI survival (this window): unknown (no churn data yet)`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
lines.push(` AI survival (this window): ${(windowAiSurvival * 100).toFixed(0)}%`);
|
|
131
|
+
md.push(`- AI survival (this window): ${(windowAiSurvival * 100).toFixed(0)}%`);
|
|
132
|
+
}
|
|
133
|
+
if (restAiSurvival === null) {
|
|
134
|
+
lines.push(' AI survival (prior history): unknown (no baseline yet)');
|
|
135
|
+
md.push(`- AI survival (prior history): unknown (no baseline yet)`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
lines.push(` AI survival (prior history): ${(restAiSurvival * 100).toFixed(0)}%`);
|
|
139
|
+
md.push(`- AI survival (prior history): ${(restAiSurvival * 100).toFixed(0)}%`);
|
|
140
|
+
}
|
|
141
|
+
if (windowAiSurvival !== null && restAiSurvival !== null) {
|
|
142
|
+
const delta = (windowAiSurvival - restAiSurvival) * 100;
|
|
143
|
+
const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat';
|
|
144
|
+
lines.push(` Movement: ${dir} ${Math.abs(delta).toFixed(0)}pp`);
|
|
145
|
+
md.push(`- Movement: ${dir} ${Math.abs(delta).toFixed(0)}pp`);
|
|
146
|
+
}
|
|
147
|
+
md.push('');
|
|
148
|
+
// Newly-measured 30-day windows: sessions whose survival30d is 'measured' AND whose
|
|
149
|
+
// originating commit falls in-window OR whose measurement crossed into 'measured' recently.
|
|
150
|
+
// We only have current state (no history of prior digest runs), so we report commits in the
|
|
151
|
+
// window that now have a measured 30d verdict — an honest proxy for "newly measured."
|
|
152
|
+
const newlyMeasured = windowSessions.filter(s => s.churn?.survival30d?.status === 'measured');
|
|
153
|
+
lines.push('', `Newly-measured 30-day windows: ${newlyMeasured.length}`);
|
|
154
|
+
md.push(`## Newly-measured 30-day windows`, '');
|
|
155
|
+
if (newlyMeasured.length === 0) {
|
|
156
|
+
lines.push(' None this window.');
|
|
157
|
+
md.push('None this window.');
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
md.push(`| Commit | Date | Tool | Survival (30d) |`);
|
|
161
|
+
md.push(`| :--- | :--- | :--- | :---: |`);
|
|
162
|
+
for (const s of newlyMeasured) {
|
|
163
|
+
const w = s.churn.survival30d;
|
|
164
|
+
const hash = s.commitHash.substring(0, 8);
|
|
165
|
+
const date = s.timestamp.substring(0, 10);
|
|
166
|
+
lines.push(` ${hash} ${date} ${(0, html_report_1.toolLabel)(s.aiTool)} ${(w.rate * 100).toFixed(0)}%`);
|
|
167
|
+
md.push(`| \`${hash}\` | ${date} | ${(0, html_report_1.toolLabel)(s.aiTool)} | ${(w.rate * 100).toFixed(0)}% |`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
md.push('');
|
|
171
|
+
return { markdown: md.join('\n'), text: lines.join('\n'), windowSessions };
|
|
172
|
+
}
|
|
173
|
+
/** Writes the digest markdown to `.devtrace/digest.md` and returns the absolute path. */
|
|
174
|
+
function writeDigestMarkdown(markdown, projectDir) {
|
|
175
|
+
const config = config_1.ConfigManager.loadConfig(projectDir);
|
|
176
|
+
const storageDir = path.join(projectDir, config.storageDir);
|
|
177
|
+
if (!fs.existsSync(storageDir)) {
|
|
178
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
const target = path.join(storageDir, 'digest.md');
|
|
181
|
+
fs.writeFileSync(target, markdown, 'utf8');
|
|
182
|
+
return target;
|
|
183
|
+
}
|