@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
package/dist/config.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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.GlobalConfigManager = exports.ConfigManager = exports.DEFAULT_CONFIG = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
exports.DEFAULT_CONFIG = {
|
|
40
|
+
storageDir: '.devtrace',
|
|
41
|
+
testCommand: null,
|
|
42
|
+
chatLogPath: null,
|
|
43
|
+
autoGitIgnore: true,
|
|
44
|
+
runTestsAsync: true,
|
|
45
|
+
};
|
|
46
|
+
class ConfigManager {
|
|
47
|
+
static configFileName = 'devtrace.config.json';
|
|
48
|
+
/**
|
|
49
|
+
* Load DevTrace config from the repository root.
|
|
50
|
+
* If config doesn't exist, returns the default configuration.
|
|
51
|
+
*/
|
|
52
|
+
static loadConfig(projectDir = process.cwd()) {
|
|
53
|
+
const configPath = path.join(projectDir, this.configFileName);
|
|
54
|
+
if (!fs.existsSync(configPath)) {
|
|
55
|
+
return exports.DEFAULT_CONFIG;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
59
|
+
const parsed = JSON.parse(content);
|
|
60
|
+
return this.validateAndNormalize(parsed);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.warn(`[DevTrace] Failed to parse config file: ${error.message}. Using defaults.`);
|
|
64
|
+
return exports.DEFAULT_CONFIG;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Write config to project root.
|
|
69
|
+
*/
|
|
70
|
+
static saveConfig(config, projectDir = process.cwd()) {
|
|
71
|
+
const configPath = path.join(projectDir, this.configFileName);
|
|
72
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
73
|
+
if (config.autoGitIgnore) {
|
|
74
|
+
this.ensureGitIgnore(config.storageDir, projectDir);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Safely adds the storage directory to the local .gitignore if autoGitIgnore is enabled.
|
|
79
|
+
*/
|
|
80
|
+
static ensureGitIgnore(storageDir, projectDir) {
|
|
81
|
+
const gitignorePath = path.join(projectDir, '.gitignore');
|
|
82
|
+
try {
|
|
83
|
+
let content = '';
|
|
84
|
+
if (fs.existsSync(gitignorePath)) {
|
|
85
|
+
content = fs.readFileSync(gitignorePath, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
// Check if already in gitignore
|
|
88
|
+
const lines = content.split(/\r?\n/);
|
|
89
|
+
const isIgnored = lines.some(line => line.trim() === storageDir || line.trim() === `${storageDir}/`);
|
|
90
|
+
if (!isIgnored) {
|
|
91
|
+
const separator = content.endsWith('\n') || content === '' ? '' : '\n';
|
|
92
|
+
fs.appendFileSync(gitignorePath, `${separator}# DevTrace dataset directory\n${storageDir}/\n`, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error(`[DevTrace] Failed to update .gitignore: ${error.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Ensures the loaded config satisfies key types and sets fallbacks for missing fields.
|
|
101
|
+
*/
|
|
102
|
+
static validateAndNormalize(parsed) {
|
|
103
|
+
return {
|
|
104
|
+
storageDir: typeof parsed.storageDir === 'string' ? parsed.storageDir : exports.DEFAULT_CONFIG.storageDir,
|
|
105
|
+
testCommand: typeof parsed.testCommand === 'string' || parsed.testCommand === null ? parsed.testCommand : exports.DEFAULT_CONFIG.testCommand,
|
|
106
|
+
chatLogPath: typeof parsed.chatLogPath === 'string' || parsed.chatLogPath === null ? parsed.chatLogPath : exports.DEFAULT_CONFIG.chatLogPath,
|
|
107
|
+
autoGitIgnore: typeof parsed.autoGitIgnore === 'boolean' ? parsed.autoGitIgnore : exports.DEFAULT_CONFIG.autoGitIgnore,
|
|
108
|
+
runTestsAsync: typeof parsed.runTestsAsync === 'boolean' ? parsed.runTestsAsync : exports.DEFAULT_CONFIG.runTestsAsync,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
exports.ConfigManager = ConfigManager;
|
|
113
|
+
class GlobalConfigManager {
|
|
114
|
+
/**
|
|
115
|
+
* Resolves the directory used for global (cross-project) DevTrace state.
|
|
116
|
+
* Honors DEVTRACE_HOME as an override so tests (and users who want an
|
|
117
|
+
* isolated config) never touch the real ~/.devtrace.
|
|
118
|
+
*/
|
|
119
|
+
static getGlobalConfigDir() {
|
|
120
|
+
if (process.env.DEVTRACE_HOME) {
|
|
121
|
+
return process.env.DEVTRACE_HOME;
|
|
122
|
+
}
|
|
123
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
124
|
+
return path.join(homeDir, '.devtrace');
|
|
125
|
+
}
|
|
126
|
+
static getGlobalConfigPath() {
|
|
127
|
+
return path.join(this.getGlobalConfigDir(), 'global_config.json');
|
|
128
|
+
}
|
|
129
|
+
static load() {
|
|
130
|
+
const filePath = this.getGlobalConfigPath();
|
|
131
|
+
if (!fs.existsSync(filePath)) {
|
|
132
|
+
return { trustedWorkspaces: [] };
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
136
|
+
const parsed = JSON.parse(content);
|
|
137
|
+
return {
|
|
138
|
+
trustedWorkspaces: Array.isArray(parsed.trustedWorkspaces) ? parsed.trustedWorkspaces : []
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { trustedWorkspaces: [] };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
static save(config) {
|
|
146
|
+
const filePath = this.getGlobalConfigPath();
|
|
147
|
+
const dir = path.dirname(filePath);
|
|
148
|
+
if (!fs.existsSync(dir)) {
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
}
|
|
151
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
|
|
152
|
+
}
|
|
153
|
+
static isWorkspaceTrusted(projectDir = process.cwd()) {
|
|
154
|
+
const config = this.load();
|
|
155
|
+
const resolvedPath = path.resolve(projectDir).toLowerCase();
|
|
156
|
+
return config.trustedWorkspaces.some(w => path.resolve(w).toLowerCase() === resolvedPath);
|
|
157
|
+
}
|
|
158
|
+
static trustWorkspace(projectDir = process.cwd()) {
|
|
159
|
+
const config = this.load();
|
|
160
|
+
const resolvedPath = path.resolve(projectDir);
|
|
161
|
+
const resolvedPathLower = resolvedPath.toLowerCase();
|
|
162
|
+
if (!config.trustedWorkspaces.some(w => path.resolve(w).toLowerCase() === resolvedPathLower)) {
|
|
163
|
+
config.trustedWorkspaces.push(resolvedPath);
|
|
164
|
+
this.save(config);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
static untrustWorkspace(projectDir = process.cwd()) {
|
|
168
|
+
const config = this.load();
|
|
169
|
+
const resolvedPath = path.resolve(projectDir).toLowerCase();
|
|
170
|
+
config.trustedWorkspaces = config.trustedWorkspaces.filter(w => path.resolve(w).toLowerCase() !== resolvedPath);
|
|
171
|
+
this.save(config);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
exports.GlobalConfigManager = GlobalConfigManager;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanCommitHistory = scanCommitHistory;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const trailers_1 = require("../capture/trailers");
|
|
6
|
+
const metrics_1 = require("../metrics");
|
|
7
|
+
// Field/record delimiters chosen to never collide with real commit content.
|
|
8
|
+
// Commit messages can contain anything except NUL, but they realistically never
|
|
9
|
+
// contain the ASCII "record separator" (0x1e) or "unit separator" (0x1f) control
|
|
10
|
+
// characters, so we use those instead of trying to escape arbitrary message bytes.
|
|
11
|
+
const RECORD_SEP = '\x1e';
|
|
12
|
+
const FIELD_SEP = '\x1f';
|
|
13
|
+
const GIT_LOG_FORMAT = `${RECORD_SEP}%H${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%P${FIELD_SEP}%B${RECORD_SEP}`;
|
|
14
|
+
// Same heuristic weights/caps as AIDetector's tier-3 logic (src/detector.ts). Duplicated here
|
|
15
|
+
// (rather than calling AIDetector.detect) because that method spawns `git show`/`git rev-parse`
|
|
16
|
+
// per commit for metadata + parent lookup — exactly the per-commit-spawn cost Pass 1 must avoid.
|
|
17
|
+
// We already have author/timestamp/parent from the single streamed log, so we replicate the
|
|
18
|
+
// pure scoring logic in-memory instead.
|
|
19
|
+
const HEURISTIC_SIGNAL_CAP = 0.25;
|
|
20
|
+
const HEURISTIC_TIER_CAP = 0.5;
|
|
21
|
+
const TRAILER_CONFIDENCE = 0.95;
|
|
22
|
+
const UNKNOWN_MAX_CONFIDENCE = 0.3;
|
|
23
|
+
const VELOCITY_THRESHOLD_SECONDS = 45;
|
|
24
|
+
const VELOCITY_MIN_LINES = 20;
|
|
25
|
+
const GREENFIELD_MIN_INSERTIONS = 150;
|
|
26
|
+
/**
|
|
27
|
+
* In-memory equivalent of AIDetector.detect's tier-3 heuristics, taking the parent commit's
|
|
28
|
+
* timestamp directly instead of spawning git to look it up. Tier-1 trailer matching is
|
|
29
|
+
* identical to AIDetector (delegates to the same TrailerParser).
|
|
30
|
+
*/
|
|
31
|
+
function classifyCommit(commitHash, authorName, message, diff, authorDate, parentDate) {
|
|
32
|
+
const reasons = [];
|
|
33
|
+
// --- Tier 1: confirmed trailer match(es) ---
|
|
34
|
+
const trailerMatches = trailers_1.TrailerParser.parse(message, authorName);
|
|
35
|
+
if (trailerMatches.length > 0) {
|
|
36
|
+
const primary = trailerMatches[0];
|
|
37
|
+
for (const match of trailerMatches) {
|
|
38
|
+
const suffix = match.model ? ` (model: ${match.model})` : '';
|
|
39
|
+
reasons.push(`${match.pattern}${match.unverified ? ' [unverified-pattern]' : ''}${suffix}`);
|
|
40
|
+
}
|
|
41
|
+
return { tool: primary.tool, confidence: TRAILER_CONFIDENCE, tier: 1, reasons };
|
|
42
|
+
}
|
|
43
|
+
// --- Tier 3: heuristic weak priors ---
|
|
44
|
+
let heuristicScore = 0;
|
|
45
|
+
let heuristicFired = false;
|
|
46
|
+
if (parentDate) {
|
|
47
|
+
const commitTime = new Date(authorDate).getTime();
|
|
48
|
+
const parentTime = new Date(parentDate).getTime();
|
|
49
|
+
const diffSeconds = (commitTime - parentTime) / 1000;
|
|
50
|
+
if (diffSeconds > 0 && diffSeconds < VELOCITY_THRESHOLD_SECONDS) {
|
|
51
|
+
const linesChanged = diff.totalInsertions + diff.totalDeletions;
|
|
52
|
+
if (linesChanged > VELOCITY_MIN_LINES) {
|
|
53
|
+
heuristicFired = true;
|
|
54
|
+
heuristicScore += Math.min(HEURISTIC_SIGNAL_CAP, 0.2);
|
|
55
|
+
reasons.push(`heuristic:velocity (${diffSeconds.toFixed(1)}s < ${VELOCITY_THRESHOLD_SECONDS}s threshold, ${linesChanged} lines changed)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (diff.totalInsertions > GREENFIELD_MIN_INSERTIONS && diff.totalDeletions === 0) {
|
|
60
|
+
heuristicFired = true;
|
|
61
|
+
heuristicScore += Math.min(HEURISTIC_SIGNAL_CAP, 0.2);
|
|
62
|
+
reasons.push(`heuristic:greenfield (${diff.totalInsertions} insertions, 0 deletions)`);
|
|
63
|
+
}
|
|
64
|
+
if (heuristicFired) {
|
|
65
|
+
return { tool: 'unknown-ai', confidence: Math.min(HEURISTIC_TIER_CAP, heuristicScore), tier: 3, reasons };
|
|
66
|
+
}
|
|
67
|
+
reasons.push('tier0:no-signal');
|
|
68
|
+
return { tool: 'none', confidence: UNKNOWN_MAX_CONFIDENCE, tier: 0, reasons };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parses a single numstat block (lines following a commit header, up to the next RECORD_SEP)
|
|
72
|
+
* into a DiffMetrics. Handles binary files ("-\t-\tpath") and rename lines
|
|
73
|
+
* ("old.ts => new.ts" or the "{old => new}/rest.ts" braced form).
|
|
74
|
+
*/
|
|
75
|
+
function parseNumstatBlock(lines, ignoreFilter) {
|
|
76
|
+
const files = [];
|
|
77
|
+
let totalInsertions = 0;
|
|
78
|
+
let totalDeletions = 0;
|
|
79
|
+
for (const rawLine of lines) {
|
|
80
|
+
const line = rawLine.trim();
|
|
81
|
+
if (!line)
|
|
82
|
+
continue;
|
|
83
|
+
const tabParts = line.split('\t');
|
|
84
|
+
if (tabParts.length < 3)
|
|
85
|
+
continue;
|
|
86
|
+
const insRaw = tabParts[0];
|
|
87
|
+
const delRaw = tabParts[1];
|
|
88
|
+
const pathField = tabParts.slice(2).join('\t');
|
|
89
|
+
const filepath = resolveRenamedPath(pathField);
|
|
90
|
+
if (ignoreFilter && ignoreFilter.shouldIgnore(filepath)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const isBinary = insRaw === '-' || delRaw === '-';
|
|
94
|
+
const insertions = isBinary ? 0 : parseInt(insRaw, 10) || 0;
|
|
95
|
+
const deletions = isBinary ? 0 : parseInt(delRaw, 10) || 0;
|
|
96
|
+
const dotIdx = filepath.lastIndexOf('.');
|
|
97
|
+
const slashIdx = filepath.lastIndexOf('/');
|
|
98
|
+
const ext = dotIdx > slashIdx ? filepath.substring(dotIdx + 1).toLowerCase() : '';
|
|
99
|
+
files.push({
|
|
100
|
+
path: filepath,
|
|
101
|
+
insertions,
|
|
102
|
+
deletions,
|
|
103
|
+
isBinary,
|
|
104
|
+
language: ext || null
|
|
105
|
+
});
|
|
106
|
+
totalInsertions += insertions;
|
|
107
|
+
totalDeletions += deletions;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
filesChanged: files.length,
|
|
111
|
+
totalInsertions,
|
|
112
|
+
totalDeletions,
|
|
113
|
+
netLines: totalInsertions - totalDeletions,
|
|
114
|
+
files
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolves numstat's rename path notations to the post-rename (current) path:
|
|
119
|
+
* "old.ts => new.ts" -> "new.ts"
|
|
120
|
+
* "src/{old => new}/file.ts" -> "src/new/file.ts"
|
|
121
|
+
* "plain/unchanged/path.ts" -> unchanged
|
|
122
|
+
*/
|
|
123
|
+
function resolveRenamedPath(pathField) {
|
|
124
|
+
const braceMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
|
125
|
+
if (braceMatch) {
|
|
126
|
+
const [, prefix, , after, suffix] = braceMatch;
|
|
127
|
+
return `${prefix}${after}${suffix}`;
|
|
128
|
+
}
|
|
129
|
+
const simpleArrow = pathField.split(' => ');
|
|
130
|
+
if (simpleArrow.length === 2) {
|
|
131
|
+
return simpleArrow[1].trim();
|
|
132
|
+
}
|
|
133
|
+
return pathField;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Pass 1 of the retroactive scan: a single streamed `git log` subprocess covering the
|
|
137
|
+
* requested window, parsed incrementally into classified ScannedCommit records.
|
|
138
|
+
* No per-commit git spawns — velocity heuristic uses consecutive commit timestamps
|
|
139
|
+
* already present in the stream (parent lookup by hash, not by re-invoking git).
|
|
140
|
+
*/
|
|
141
|
+
function scanCommitHistory(options = {}) {
|
|
142
|
+
const cwd = options.cwd || process.cwd();
|
|
143
|
+
const since = options.since || '90 days ago';
|
|
144
|
+
const ignoreFilter = options.ignoreFilter;
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const args = [
|
|
147
|
+
'log',
|
|
148
|
+
`--since=${since}`,
|
|
149
|
+
'--no-merges',
|
|
150
|
+
'--numstat',
|
|
151
|
+
'--date=iso-strict',
|
|
152
|
+
`--format=${GIT_LOG_FORMAT}`,
|
|
153
|
+
'HEAD'
|
|
154
|
+
];
|
|
155
|
+
const child = (0, child_process_1.spawn)('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
156
|
+
let buffer = '';
|
|
157
|
+
let stderr = '';
|
|
158
|
+
const rawCommits = [];
|
|
159
|
+
// Each commit record is bracketed by RECORD_SEP on both sides:
|
|
160
|
+
// RS H \x1f an \x1f aI \x1f P \x1f B RS \n\n <numstat lines...>
|
|
161
|
+
// and the numstat lines run until the *next* record's opening RECORD_SEP. So splitting
|
|
162
|
+
// the whole stream on RECORD_SEP yields, in order: [leading-junk, header1, numstat1,
|
|
163
|
+
// header2, numstat2, ...] — odd-indexed segments (1-based pairs starting at index 1)
|
|
164
|
+
// are headers, the segment immediately after each header is that same commit's numstat.
|
|
165
|
+
let pendingHeader = null;
|
|
166
|
+
function consumeBuffer(finalFlush) {
|
|
167
|
+
let idx;
|
|
168
|
+
// Keep the last (possibly incomplete) segment in `buffer` unless this is the final flush.
|
|
169
|
+
while ((idx = buffer.indexOf(RECORD_SEP)) !== -1) {
|
|
170
|
+
const segment = buffer.substring(0, idx);
|
|
171
|
+
buffer = buffer.substring(idx + 1);
|
|
172
|
+
consumeSegment(segment);
|
|
173
|
+
}
|
|
174
|
+
if (finalFlush && buffer.length > 0) {
|
|
175
|
+
consumeSegment(buffer);
|
|
176
|
+
buffer = '';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function consumeSegment(segment) {
|
|
180
|
+
if (pendingHeader === null) {
|
|
181
|
+
// This segment is a header (or leading junk before the first header).
|
|
182
|
+
const fields = segment.split(FIELD_SEP);
|
|
183
|
+
if (fields.length < 5) {
|
|
184
|
+
return; // leading junk / malformed — not a header, stay in header-seeking state
|
|
185
|
+
}
|
|
186
|
+
const [hash, authorName, authorDate, parents, ...messageParts] = fields;
|
|
187
|
+
pendingHeader = {
|
|
188
|
+
hash: hash.trim(),
|
|
189
|
+
authorName: authorName.trim(),
|
|
190
|
+
authorDate: authorDate.trim(),
|
|
191
|
+
parentHashes: parents.trim().length > 0 ? parents.trim().split(/\s+/) : [],
|
|
192
|
+
message: messageParts.join(FIELD_SEP)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// This segment is the numstat block belonging to pendingHeader.
|
|
197
|
+
const numstatLines = segment
|
|
198
|
+
.split('\n')
|
|
199
|
+
.map(l => l.trim())
|
|
200
|
+
.filter(l => l.length > 0);
|
|
201
|
+
rawCommits.push({ ...pendingHeader, numstatLines });
|
|
202
|
+
pendingHeader = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
child.stdout.setEncoding('utf8');
|
|
206
|
+
child.stdout.on('data', (data) => {
|
|
207
|
+
buffer += data;
|
|
208
|
+
consumeBuffer(false);
|
|
209
|
+
if (options.onProgress) {
|
|
210
|
+
options.onProgress(rawCommits.length);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
child.stderr.setEncoding('utf8');
|
|
214
|
+
child.stderr.on('data', (data) => {
|
|
215
|
+
stderr += data;
|
|
216
|
+
});
|
|
217
|
+
child.on('error', (err) => reject(err));
|
|
218
|
+
child.on('close', (code) => {
|
|
219
|
+
consumeBuffer(true);
|
|
220
|
+
if (code !== 0 && rawCommits.length === 0) {
|
|
221
|
+
// Empty repo / no commits in window is not an error; only reject on genuine failures
|
|
222
|
+
// where git produced neither output nor commits.
|
|
223
|
+
if (stderr.trim().length > 0) {
|
|
224
|
+
reject(new Error(`git log failed (exit ${code}): ${stderr.trim()}`));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// git log emits newest-first; reverse to oldest-first so parent-date lookups
|
|
229
|
+
// (needed for the velocity heuristic) can use a simple hash map built as we go,
|
|
230
|
+
// and so downstream consumers see a natural chronological order.
|
|
231
|
+
const chronological = rawCommits.slice().reverse();
|
|
232
|
+
const dateByHash = new Map();
|
|
233
|
+
for (const rc of chronological) {
|
|
234
|
+
dateByHash.set(rc.hash, rc.authorDate);
|
|
235
|
+
}
|
|
236
|
+
const commits = [];
|
|
237
|
+
for (const rc of chronological) {
|
|
238
|
+
const diff = parseNumstatBlock(rc.numstatLines, ignoreFilter);
|
|
239
|
+
// Skip commits that touched nothing but ignored paths (mirrors post-commit hook behavior).
|
|
240
|
+
if (rc.numstatLines.length > 0 && diff.filesChanged === 0) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const parentHash = rc.parentHashes[0] || '';
|
|
244
|
+
const parentDate = parentHash ? dateByHash.get(parentHash) || null : null;
|
|
245
|
+
const classification = classifyCommit(rc.hash, rc.authorName, rc.message, diff, rc.authorDate, parentDate);
|
|
246
|
+
const changeType = metrics_1.MetricsClassifier.classify(diff, rc.message);
|
|
247
|
+
commits.push({
|
|
248
|
+
commitHash: rc.hash,
|
|
249
|
+
parentHash,
|
|
250
|
+
authorName: rc.authorName,
|
|
251
|
+
authorDate: rc.authorDate,
|
|
252
|
+
message: rc.message,
|
|
253
|
+
diff,
|
|
254
|
+
aiTool: classification.tool,
|
|
255
|
+
aiToolConfidence: classification.confidence,
|
|
256
|
+
aiToolTier: classification.tier,
|
|
257
|
+
reasons: classification.reasons,
|
|
258
|
+
changeType: changeType.changeType,
|
|
259
|
+
changeTypeConfidence: changeType.confidence
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
resolve({ commits, totalCommitsSeen: rawCommits.length });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|