@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.
@@ -0,0 +1,325 @@
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.escapeHtml = escapeHtml;
37
+ exports.toolLabel = toolLabel;
38
+ exports.tierLabel = tierLabel;
39
+ exports.buildHtmlReport = buildHtmlReport;
40
+ exports.writeHtmlReport = writeHtmlReport;
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const config_1 = require("./config");
44
+ /** Escapes a user-controlled string for safe embedding in HTML text/attribute contexts. */
45
+ function escapeHtml(input) {
46
+ return String(input)
47
+ .replace(/&/g, '&amp;')
48
+ .replace(/</g, '&lt;')
49
+ .replace(/>/g, '&gt;')
50
+ .replace(/"/g, '&quot;')
51
+ .replace(/'/g, '&#39;');
52
+ }
53
+ /** Human-readable label for an AITool key (matches index.ts's reveal-box toolLabel). */
54
+ function toolLabel(tool) {
55
+ const labels = {
56
+ 'claude-code': 'Claude',
57
+ 'gemini-cli': 'Gemini',
58
+ 'cursor': 'Cursor',
59
+ 'copilot': 'Copilot',
60
+ 'codex': 'Codex',
61
+ 'aider': 'Aider',
62
+ 'ollama': 'Ollama',
63
+ 'other': 'Other AI',
64
+ 'unknown-ai': 'Unknown AI (heuristic)',
65
+ 'none': 'Human'
66
+ };
67
+ return labels[tool] || tool;
68
+ }
69
+ /** Honest, spelled-out confidence-tier description for the by-tool table. Never inflates trust. */
70
+ function tierLabel(tier) {
71
+ switch (tier) {
72
+ case 1: return 'confirmed — commit trailer';
73
+ case 2: return 'corroborated';
74
+ case 3: return 'heuristic — weak prior';
75
+ case 0:
76
+ default:
77
+ return 'unknown';
78
+ }
79
+ }
80
+ /** The weakest (least-confident) tier present in a group, so the table never overstates confidence. */
81
+ function dominantTier(sessions) {
82
+ const tiers = sessions.map(s => s.aiToolTier).filter((t) => t !== undefined);
83
+ if (tiers.length === 0)
84
+ return undefined;
85
+ return Math.min(...tiers);
86
+ }
87
+ function fmtPct(rate) {
88
+ if (rate === null || rate === undefined)
89
+ return 'unknown';
90
+ return `${(rate * 100).toFixed(0)}%`;
91
+ }
92
+ function survival30dLabel(w) {
93
+ if (!w)
94
+ return '—';
95
+ if (w.status === 'measured')
96
+ return `${(w.rate * 100).toFixed(0)}%`;
97
+ if (w.status === 'window-open')
98
+ return 'window open';
99
+ return 'unknown';
100
+ }
101
+ /** Insertion-weighted average survival rate across sessions with a numeric churn.survivalRate. */
102
+ function weightedSurvivalRate(sessions) {
103
+ let totalInsertions = 0;
104
+ let totalSurviving = 0;
105
+ let anyKnown = false;
106
+ for (const s of sessions) {
107
+ if (!s.churn || s.diff.totalInsertions <= 0)
108
+ continue;
109
+ anyKnown = true;
110
+ totalInsertions += s.diff.totalInsertions;
111
+ totalSurviving += s.diff.totalInsertions * s.churn.survivalRate;
112
+ }
113
+ if (!anyKnown || totalInsertions === 0)
114
+ return null;
115
+ return totalSurviving / totalInsertions;
116
+ }
117
+ function weightedSurvival30d(sessions) {
118
+ const measured = sessions
119
+ .map(s => s.churn?.survival30d)
120
+ .filter((v) => v !== undefined && v.status === 'measured');
121
+ if (measured.length === 0)
122
+ return null;
123
+ const rate = measured.reduce((sum, v) => sum + v.rate, 0) / measured.length;
124
+ return { rate, n: measured.length };
125
+ }
126
+ /** Builds a minimal, hand-rendered inline SVG bar chart of commits/day (AI vs human stacked). */
127
+ function buildTrendSvg(stats) {
128
+ const dates = Object.keys(stats.timeline).sort();
129
+ if (dates.length === 0) {
130
+ return '<p class="muted">No timeline data yet.</p>';
131
+ }
132
+ const width = 720;
133
+ const height = 160;
134
+ const paddingLeft = 30;
135
+ const paddingBottom = 20;
136
+ const plotWidth = width - paddingLeft - 10;
137
+ const plotHeight = height - paddingBottom - 10;
138
+ const barGap = 2;
139
+ const barWidth = Math.max(1, plotWidth / dates.length - barGap);
140
+ const maxTotal = Math.max(1, ...dates.map(d => stats.timeline[d].total));
141
+ let bars = '';
142
+ dates.forEach((d, i) => {
143
+ const day = stats.timeline[d];
144
+ const x = paddingLeft + i * (barWidth + barGap);
145
+ const aiH = (day.ai / maxTotal) * plotHeight;
146
+ const humanH = (day.human / maxTotal) * plotHeight;
147
+ const baseY = 10 + plotHeight;
148
+ const humanY = baseY - humanH;
149
+ const aiY = humanY - aiH;
150
+ if (day.human > 0) {
151
+ bars += `<rect x="${x.toFixed(1)}" y="${humanY.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${humanH.toFixed(1)}" fill="#64748b"><title>${escapeHtml(d)}: ${day.human} human commit(s)</title></rect>`;
152
+ }
153
+ if (day.ai > 0) {
154
+ bars += `<rect x="${x.toFixed(1)}" y="${aiY.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${aiH.toFixed(1)}" fill="#22d3ee"><title>${escapeHtml(d)}: ${day.ai} AI commit(s)</title></rect>`;
155
+ }
156
+ });
157
+ const axisY = 10 + plotHeight;
158
+ return `<svg viewBox="0 0 ${width} ${height}" width="100%" height="${height}" role="img" aria-label="Commits over time, AI vs human">
159
+ <line x1="${paddingLeft}" y1="${axisY}" x2="${width - 10}" y2="${axisY}" stroke="#475569" stroke-width="1"/>
160
+ ${bars}
161
+ <text x="${paddingLeft}" y="12" fill="#94a3b8" font-size="10">${escapeHtml(String(maxTotal))} max/day</text>
162
+ <text x="${paddingLeft}" y="${height - 4}" fill="#94a3b8" font-size="10">${escapeHtml(dates[0])}</text>
163
+ <text x="${width - 90}" y="${height - 4}" fill="#94a3b8" font-size="10">${escapeHtml(dates[dates.length - 1])}</text>
164
+ </svg>`;
165
+ }
166
+ /** Builds the ONE self-contained local HTML report for `devtrace report`. */
167
+ function buildHtmlReport(stats, sessions, projectDir) {
168
+ const repoName = escapeHtml(path.basename(projectDir));
169
+ const generatedAt = new Date().toISOString();
170
+ const aiSessions = sessions.filter(s => s.aiTool !== 'none');
171
+ const humanSessions = sessions.filter(s => s.aiTool === 'none');
172
+ const headSurvivalAi = weightedSurvivalRate(aiSessions);
173
+ const headSurvivalHuman = weightedSurvivalRate(humanSessions);
174
+ const w30 = weightedSurvival30d(aiSessions);
175
+ const aiSharePct = stats.totalCommits > 0 ? ((stats.aiCommits / stats.totalCommits) * 100).toFixed(0) : '0';
176
+ // By-tool table
177
+ const byToolRows = [];
178
+ const byTool = new Map();
179
+ for (const s of sessions) {
180
+ if (!byTool.has(s.aiTool))
181
+ byTool.set(s.aiTool, []);
182
+ byTool.get(s.aiTool).push(s);
183
+ }
184
+ for (const [tool, toolSessions] of byTool.entries()) {
185
+ if (toolSessions.length === 0)
186
+ continue;
187
+ const rate = weightedSurvivalRate(toolSessions);
188
+ const w = weightedSurvival30d(toolSessions);
189
+ const tier = dominantTier(toolSessions);
190
+ byToolRows.push(`<tr>
191
+ <td>${escapeHtml(toolLabel(tool))}</td>
192
+ <td>${toolSessions.length}</td>
193
+ <td>${escapeHtml(fmtPct(rate))}</td>
194
+ <td>${w ? escapeHtml(`${(w.rate * 100).toFixed(0)}% (n=${w.n})`) : '—'}</td>
195
+ <td class="tier">${escapeHtml(tierLabel(tier))}</td>
196
+ </tr>`);
197
+ }
198
+ // Per-commit table (most recent first)
199
+ const commitRows = [...sessions].reverse().map(s => {
200
+ const hash = escapeHtml(s.commitHash.substring(0, 8));
201
+ const date = escapeHtml(s.timestamp.substring(0, 10));
202
+ const tool = escapeHtml(toolLabel(s.aiTool));
203
+ const tier = escapeHtml(tierLabel(s.aiToolTier));
204
+ const lines = escapeHtml(`+${s.diff.totalInsertions}/-${s.diff.totalDeletions}`);
205
+ const survival = escapeHtml(s.churn ? fmtPct(s.churn.survivalRate) : '—');
206
+ const survival30 = escapeHtml(survival30dLabel(s.churn?.survival30d));
207
+ const branch = escapeHtml(s.branchName);
208
+ return `<tr>
209
+ <td><code>${hash}</code></td>
210
+ <td>${date}</td>
211
+ <td>${tool}</td>
212
+ <td class="tier">${tier}</td>
213
+ <td>${lines}</td>
214
+ <td>${survival}</td>
215
+ <td>${survival30}</td>
216
+ <td><code>${branch}</code></td>
217
+ </tr>`;
218
+ }).join('\n');
219
+ const trendSvg = buildTrendSvg(stats);
220
+ return `<!doctype html>
221
+ <html lang="en">
222
+ <head>
223
+ <meta charset="utf-8">
224
+ <meta name="viewport" content="width=device-width, initial-scale=1">
225
+ <title>DevTrace Report — ${repoName}</title>
226
+ <style>
227
+ :root { color-scheme: dark; }
228
+ * { box-sizing: border-box; }
229
+ body {
230
+ margin: 0; padding: 2rem 1.5rem 4rem;
231
+ background: #0f172a; color: #e2e8f0;
232
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
233
+ }
234
+ .wrap { max-width: 960px; margin: 0 auto; }
235
+ h1 { font-size: 1.6rem; margin-bottom: 0.1rem; }
236
+ .subtitle { color: #94a3b8; margin-bottom: 2rem; font-size: 0.9rem; }
237
+ h2 { font-size: 1.1rem; margin-top: 2.5rem; border-bottom: 1px solid #334155; padding-bottom: 0.4rem; }
238
+ .headline-grid { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0; }
239
+ .stat-card {
240
+ background: #1e293b; border: 1px solid #334155; border-radius: 10px;
241
+ padding: 1rem 1.25rem; min-width: 180px; flex: 1;
242
+ }
243
+ .stat-card .label { color: #94a3b8; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
244
+ .stat-card .value { font-size: 1.8rem; font-weight: 600; margin-top: 0.25rem; }
245
+ .stat-card .footnote { color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; }
246
+ table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; }
247
+ th, td { text-align: left; padding: 0.45rem 0.6rem; border-bottom: 1px solid #1e293b; }
248
+ th { color: #94a3b8; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.03em; }
249
+ tr:hover { background: #1e293b; }
250
+ code { background: #1e293b; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
251
+ td.tier { color: #94a3b8; font-size: 0.8rem; }
252
+ .muted { color: #64748b; }
253
+ .table-scroll { overflow-x: auto; }
254
+ .trend-box { background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 1rem; }
255
+ footer { margin-top: 3rem; color: #475569; font-size: 0.75rem; }
256
+ </style>
257
+ </head>
258
+ <body>
259
+ <div class="wrap">
260
+ <h1>DevTrace Report — ${repoName}</h1>
261
+ <div class="subtitle">Generated ${escapeHtml(generatedAt)} · ${stats.totalCommits} traced commit(s)</div>
262
+
263
+ <h2>Headline survival</h2>
264
+ <div class="headline-grid">
265
+ <div class="stat-card">
266
+ <div class="label">AI lines still at HEAD</div>
267
+ <div class="value">${escapeHtml(fmtPct(headSurvivalAi))}</div>
268
+ <div class="footnote">${aiSessions.length} AI commit(s)</div>
269
+ </div>
270
+ <div class="stat-card">
271
+ <div class="label">Human lines still at HEAD</div>
272
+ <div class="value">${escapeHtml(fmtPct(headSurvivalHuman))}</div>
273
+ <div class="footnote">${humanSessions.length} human commit(s)</div>
274
+ </div>
275
+ <div class="stat-card">
276
+ <div class="label">AI lines surviving 30 days</div>
277
+ <div class="value">${w30 ? escapeHtml(`${(w30.rate * 100).toFixed(0)}%`) : 'unknown'}</div>
278
+ <div class="footnote">${w30 ? `n=${w30.n} commit(s) measured` : 'no commits old enough to measure yet'}</div>
279
+ </div>
280
+ <div class="stat-card">
281
+ <div class="label">AI vs human commit share</div>
282
+ <div class="value">${escapeHtml(aiSharePct)}%</div>
283
+ <div class="footnote">${stats.aiCommits} AI / ${stats.humanCommits} human</div>
284
+ </div>
285
+ </div>
286
+
287
+ <h2>By tool</h2>
288
+ <div class="table-scroll">
289
+ <table>
290
+ <thead><tr><th>Tool</th><th>Commits</th><th>Survival (HEAD)</th><th>Survival (30d)</th><th>Confidence</th></tr></thead>
291
+ <tbody>${byToolRows.join('\n') || '<tr><td colspan="5" class="muted">No sessions recorded.</td></tr>'}</tbody>
292
+ </table>
293
+ </div>
294
+
295
+ <h2>Trend over time</h2>
296
+ <div class="trend-box">${trendSvg}</div>
297
+
298
+ <h2>Commits</h2>
299
+ <div class="table-scroll">
300
+ <table>
301
+ <thead><tr><th>Hash</th><th>Date</th><th>Tool</th><th>Confidence</th><th>Lines</th><th>Survival (HEAD)</th><th>Survival (30d)</th><th>Branch</th></tr></thead>
302
+ <tbody>${commitRows || '<tr><td colspan="8" class="muted">No sessions recorded.</td></tr>'}</tbody>
303
+ </table>
304
+ </div>
305
+
306
+ <footer>Generated by DevTrace — measured from local git history. Heuristic detections (tier 3) are weak priors, not proof.</footer>
307
+ </div>
308
+ </body>
309
+ </html>
310
+ `;
311
+ }
312
+ /** Writes the HTML report to disk and returns the absolute path. */
313
+ function writeHtmlReport(stats, sessions, projectDir, outPath) {
314
+ const config = config_1.ConfigManager.loadConfig(projectDir);
315
+ const target = outPath
316
+ ? path.resolve(projectDir, outPath)
317
+ : path.join(projectDir, config.storageDir, 'report.html');
318
+ const dir = path.dirname(target);
319
+ if (!fs.existsSync(dir)) {
320
+ fs.mkdirSync(dir, { recursive: true });
321
+ }
322
+ const html = buildHtmlReport(stats, sessions, projectDir);
323
+ fs.writeFileSync(target, html, 'utf8');
324
+ return target;
325
+ }
package/dist/ignore.js ADDED
@@ -0,0 +1,126 @@
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.IgnoreFilter = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ class IgnoreFilter {
40
+ rules = [];
41
+ projectDir;
42
+ // Default lockfiles and common ignore paths
43
+ static defaultPatterns = [
44
+ 'package-lock.json',
45
+ 'yarn.lock',
46
+ 'pnpm-lock.yaml',
47
+ 'bun.lockb',
48
+ 'node_modules/**',
49
+ '.devtrace/**',
50
+ '.git/**',
51
+ ];
52
+ constructor(projectDir = process.cwd(), customStorageDir) {
53
+ this.projectDir = path.resolve(projectDir);
54
+ // 1. Add defaults
55
+ for (const pattern of IgnoreFilter.defaultPatterns) {
56
+ this.rules.push(this.compileGlob(pattern));
57
+ }
58
+ if (customStorageDir) {
59
+ this.rules.push(this.compileGlob(`${customStorageDir}/**`));
60
+ }
61
+ // 2. Load .devtraceignore if exists
62
+ const ignorePath = path.join(this.projectDir, '.devtraceignore');
63
+ if (fs.existsSync(ignorePath)) {
64
+ try {
65
+ const content = fs.readFileSync(ignorePath, 'utf8');
66
+ const lines = content.split(/\r?\n/);
67
+ for (const line of lines) {
68
+ const trimmed = line.trim();
69
+ if (trimmed && !trimmed.startsWith('#')) {
70
+ this.rules.push(this.compileGlob(trimmed));
71
+ }
72
+ }
73
+ }
74
+ catch (error) {
75
+ console.warn(`[DevTrace] Failed to load .devtraceignore: ${error.message}`);
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Evaluates if a file path matches ignore rules.
81
+ */
82
+ shouldIgnore(filepath) {
83
+ let relativePath = filepath;
84
+ if (path.isAbsolute(filepath)) {
85
+ relativePath = path.relative(this.projectDir, filepath);
86
+ }
87
+ // Normalize path separators
88
+ const normalized = relativePath.replace(/\\/g, '/');
89
+ return this.rules.some(rule => rule.test(normalized));
90
+ }
91
+ /**
92
+ * Compiles simple globs into regexes without external dependencies.
93
+ */
94
+ compileGlob(glob) {
95
+ // Clean up pattern: remove leading/trailing slashes
96
+ let cleaned = glob.trim().replace(/\\/g, '/');
97
+ // If it starts with a slash, anchor it at root. Otherwise allow it anywhere in the path.
98
+ const rootAnchored = cleaned.startsWith('/');
99
+ if (rootAnchored) {
100
+ cleaned = cleaned.substring(1);
101
+ }
102
+ // Convert wildcards
103
+ // Escape regex special chars except * and ?
104
+ let regexStr = cleaned
105
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex characters
106
+ .replace(/\*\*/g, '@@') // temporarily label double star
107
+ .replace(/\*/g, '[^/]*') // single star matches anything except slash
108
+ .replace(/\?/g, '[^/]') // question mark matches single character except slash
109
+ .replace(/@@/g, '.*'); // restore double star to match anything including slash
110
+ // If it's a directory (ends with slash or matches a directory name), allow trailing matches
111
+ if (cleaned.endsWith('/')) {
112
+ regexStr = regexStr + '.*';
113
+ }
114
+ else {
115
+ // If it matches a directory name (e.g. "dist"), also allow "dist/*"
116
+ regexStr = `(?:${regexStr}|${regexStr}/.*)`;
117
+ }
118
+ if (rootAnchored) {
119
+ return new RegExp(`^${regexStr}$`);
120
+ }
121
+ else {
122
+ return new RegExp(`(?:^|/)${regexStr}$`);
123
+ }
124
+ }
125
+ }
126
+ exports.IgnoreFilter = IgnoreFilter;