@hifullmoon/aicommit 2.2.3 → 2.4.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/src/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile, chmod } from 'node:fs/promises';
2
+ import { DEFAULT_LARGE_CHANGE } from './analysis-budget.js';
2
3
  import { execSync } from 'node:child_process';
3
4
 
4
5
  import chalk from 'chalk';
@@ -148,6 +149,7 @@ export const DEFAULT_CONFIG = {
148
149
  // condensed to a `git diff --stat` summary plus truncated hunks, so a huge
149
150
  // change set doesn't burn tokens on lines the model doesn't need.
150
151
  maxDiffChars: 30000,
152
+ largeChange: DEFAULT_LARGE_CHANGE,
151
153
  // Cap on a single file's diff section. One huge file (e.g. a new generated
152
154
  // asset) is truncated to its header and leading hunks instead of eating
153
155
  // the whole maxDiffChars budget and pushing every other file out.
@@ -434,6 +436,20 @@ function assertNumber(config, key, { integer = false, min = -Infinity, max = Inf
434
436
  }
435
437
 
436
438
  export function validateConfig(config) {
439
+ if (config.largeChange !== undefined) {
440
+ const value = config.largeChange;
441
+ if (!object(value)) throw new Error('Invalid config "largeChange": expected an object.');
442
+ assertKnownKeys(value, new Set(Object.keys(DEFAULT_LARGE_CHANGE)), 'largeChange');
443
+ const settings = { ...DEFAULT_LARGE_CHANGE, ...value };
444
+ if (!['auto', 'deep'].includes(settings.strategy))
445
+ throw new Error('Invalid config "largeChange.strategy": expected auto or deep.');
446
+ for (const key of ['chunkInputTokens', 'maxTotalTokens', 'timeoutMs']) {
447
+ assertNumber(settings, key, { integer: true, min: 1024 });
448
+ }
449
+ assertNumber(settings, 'concurrency', { integer: true, min: 1, max: 4 });
450
+ if (settings.maxTotalTokens < settings.chunkInputTokens)
451
+ throw new Error('largeChange.maxTotalTokens must cover one input request.');
452
+ }
437
453
  assertUrl(config, 'apiUrl');
438
454
  assertString(config, 'modelId');
439
455
  if (typeof config.prompt !== 'string' || config.prompt.length > 100_000) {
package/src/doctor.js CHANGED
@@ -8,9 +8,7 @@ import { classifyError, ERROR_CATEGORIES, fail } from './errors.js';
8
8
  import { getProviderAdapter } from './providers.js';
9
9
  import { formatMs, formatUsage, redactSensitiveUrl, sanitizeTerminalText } from './utils.js';
10
10
 
11
- function nodeSupported(version = process.versions.node) {
12
- return Number(version.split('.')[0]) >= 18;
13
- }
11
+ import { nodeSupported, MIN_NODE_VERSION } from './runtime.js';
14
12
 
15
13
  function renderCheck(check) {
16
14
  const icon = check.status === 'pass' ? chalk.green('✓') : check.status === 'warn' ? '⚠' : '✗';
@@ -43,7 +41,7 @@ export async function runDoctor(cliProvider = null, cliModel = null) {
43
41
  checks,
44
42
  'Node.js',
45
43
  nodeSupported(nodeVersion) ? 'pass' : 'fail',
46
- `v${nodeVersion}${nodeSupported(nodeVersion) ? ' (supported)' : ' (requires >=18)'}`,
44
+ `v${nodeVersion}${nodeSupported(nodeVersion) ? ' (supported)' : ` (requires >=${MIN_NODE_VERSION})`}`,
47
45
  );
48
46
 
49
47
  try {
@@ -0,0 +1,108 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { closeSync, fstatSync, mkdtempSync, openSync, readSync, rmSync, writeSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const active = new Set();
7
+ export function cleanupGitSpools() {
8
+ for (const source of active) source.dispose();
9
+ }
10
+ process.once('exit', cleanupGitSpools);
11
+
12
+ // Git writes directly to a private temporary file, never to a Node stdout
13
+ // buffer. Consumers scan the captured bytes without reopening the worktree.
14
+ export function spoolGit(commands, cwd) {
15
+ const dir = mkdtempSync(join(tmpdir(), 'aicommit-diff-'));
16
+ const path = join(dir, 'patch');
17
+ let fd;
18
+ try {
19
+ fd = openSync(path, 'wx+', 0o600);
20
+ for (const args of commands) {
21
+ execFileSync('git', ['--no-pager', ...args], {
22
+ cwd,
23
+ stdio: ['ignore', fd, 'pipe'],
24
+ timeout: 120000,
25
+ maxBuffer: 1024 * 1024,
26
+ });
27
+ }
28
+ let size = fstatSync(fd).size;
29
+ closeSync(fd);
30
+ fd = undefined;
31
+ const source = {
32
+ get size() {
33
+ return size;
34
+ },
35
+ append(buffer) {
36
+ const output = openSync(path, 'r+');
37
+ try {
38
+ let written = 0;
39
+ while (written < buffer.length)
40
+ written += writeSync(output, buffer, written, buffer.length - written, size + written);
41
+ size += written;
42
+ } finally {
43
+ closeSync(output);
44
+ }
45
+ },
46
+ *buffers() {
47
+ const input = openSync(path, 'r');
48
+ const buffer = Buffer.alloc(64 * 1024);
49
+ let offset = 0;
50
+ try {
51
+ while (offset < size) {
52
+ const count = readSync(input, buffer, 0, buffer.length, offset);
53
+ if (!count) throw new Error('Captured Git patch was truncated.');
54
+ offset += count;
55
+ yield buffer.subarray(0, count);
56
+ }
57
+ } finally {
58
+ closeSync(input);
59
+ }
60
+ },
61
+ *lines(maxLineBytes = 1024 * 1024) {
62
+ let pending = Buffer.alloc(0);
63
+ for (const buffer of this.buffers()) {
64
+ const data = Buffer.concat([pending, buffer]);
65
+ let start = 0;
66
+ for (let end = data.indexOf(10); end !== -1; end = data.indexOf(10, start)) {
67
+ if (end - start > maxLineBytes)
68
+ throw new Error(
69
+ 'Diff line exceeds the safe analysis limit (1 MiB); split the change before retrying.',
70
+ );
71
+ yield data.subarray(start, end + 1).toString('utf8');
72
+ start = end + 1;
73
+ }
74
+ pending = Buffer.from(data.subarray(start));
75
+ if (pending.length > maxLineBytes)
76
+ throw new Error(
77
+ 'Diff line exceeds the safe analysis limit (1 MiB); split the change before retrying.',
78
+ );
79
+ }
80
+ if (pending.length) yield pending.toString('utf8');
81
+ },
82
+ text(limit) {
83
+ if (size > limit) return null;
84
+ return [...this.lines()].join('').trim();
85
+ },
86
+ dispose() {
87
+ if (!active.delete(source)) return;
88
+ rmSync(dir, { recursive: true, force: true });
89
+ },
90
+ };
91
+ active.add(source);
92
+ return source;
93
+ } catch (err) {
94
+ if (fd !== undefined) closeSync(fd);
95
+ rmSync(dir, { recursive: true, force: true });
96
+ throw new Error(`Failed to capture Git patch: ${err.message}`, { cause: err });
97
+ }
98
+ }
99
+
100
+ export function updateGitHash(hash, args, cwd) {
101
+ const source = spoolGit([args], cwd);
102
+ try {
103
+ for (const buffer of source.buffers()) hash.update(buffer);
104
+ } finally {
105
+ source.dispose();
106
+ }
107
+ return hash;
108
+ }
package/src/git.js CHANGED
@@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync
3
3
  import { createHash } from 'node:crypto';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { updateGitHash } from './git-spool.js';
6
7
 
7
8
  // Big repos can produce multi-MB diffs; raise the default 1MB child-process
8
9
  // buffer and surface a contextual error if the explicit limit is exceeded.
@@ -73,8 +74,11 @@ export function getStagedDiff(cwd, contextLines) {
73
74
  // ids) so a long-running AI/review step cannot silently commit a different
74
75
  // index from the one used to generate the message.
75
76
  export function getIndexFingerprint(cwd) {
76
- const patch = readGit(['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'], cwd);
77
- return createHash('sha256').update(patch).digest('hex');
77
+ return updateGitHash(
78
+ createHash('sha256'),
79
+ ['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'],
80
+ cwd,
81
+ ).digest('hex');
78
82
  }
79
83
 
80
84
  function hashFileOrMissing(path) {
@@ -428,7 +432,7 @@ export function protectSensitiveDiff(diff) {
428
432
  return { diff: out.join(''), findings: [...new Set(findings)] };
429
433
  }
430
434
 
431
- export function runGit(args, projectRoot, inherit = false) {
435
+ export function runGit(args, projectRoot, inherit = false, input = undefined) {
432
436
  try {
433
437
  const stdio =
434
438
  inherit === 'stderr'
@@ -436,7 +440,7 @@ export function runGit(args, projectRoot, inherit = false) {
436
440
  : inherit
437
441
  ? 'inherit'
438
442
  : 'pipe';
439
- execFileSync('git', args, { cwd: projectRoot, stdio });
443
+ execFileSync('git', args, { cwd: projectRoot, stdio, input });
440
444
  } catch (err) {
441
445
  const detail =
442
446
  typeof err.stderr === 'string' ? err.stderr.trim() : err.stderr?.toString('utf-8').trim();
@@ -445,6 +449,23 @@ export function runGit(args, projectRoot, inherit = false) {
445
449
  }
446
450
  }
447
451
 
452
+ export function pathBatches(paths, limit = 6000) {
453
+ const batches = [];
454
+ let batch = [];
455
+ let size = 0;
456
+ for (const path of paths) {
457
+ if (batch.length && size + path.length * 2 + 4 > limit) {
458
+ batches.push(batch);
459
+ batch = [];
460
+ size = 0;
461
+ }
462
+ batch.push(path);
463
+ size += path.length * 2 + 4;
464
+ }
465
+ if (batch.length) batches.push(batch);
466
+ return batches;
467
+ }
468
+
448
469
  export function hasHead(projectRoot) {
449
470
  try {
450
471
  execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
@@ -0,0 +1,271 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isLockFile, matchStripPattern } from './git.js';
3
+ import { ERROR_CATEGORIES, fail } from './errors.js';
4
+
5
+ // These are evidence-selection rules, not exclusions from Git or secret scans.
6
+ export function isGeneratedFile(path) {
7
+ return (
8
+ /(?:^|\/)(?:dist|build|coverage|vendor|node_modules)\//.test(path) ||
9
+ /(?:\.min\.(?:js|css)|\.map|\.snap|\.generated\.[^/]+)$/.test(path)
10
+ );
11
+ }
12
+
13
+ export function isDeepAnalysis(config) {
14
+ return config.largeChange?.strategy === 'deep';
15
+ }
16
+
17
+ function clip(text, bytes) {
18
+ if (Buffer.byteLength(text) <= bytes) return text;
19
+ let result = '';
20
+ let size = 0;
21
+ for (const char of text) {
22
+ size += Buffer.byteLength(char);
23
+ if (size > Math.max(0, bytes - 3)) break;
24
+ result += char;
25
+ }
26
+ return result + '…';
27
+ }
28
+
29
+ function category(file, config) {
30
+ if (isLockFile(file.path)) return 'lockfile';
31
+ if (matchStripPattern(file.path, config.stripFiles)) return 'excluded';
32
+ if (isGeneratedFile(file.path)) return 'generated';
33
+ if (/(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\./.test(file.path)) return 'test';
34
+ if (/\.(?:md|rst|txt)$/.test(file.path)) return 'documentation';
35
+ if (/(?:^|\/)(?:[^/]*config[^/]*|package\.json)$|\.(?:ya?ml|toml)$/.test(file.path))
36
+ return 'configuration';
37
+ return 'source';
38
+ }
39
+
40
+ function moduleOf(path) {
41
+ const parts = path.split('/');
42
+ return parts.length === 1 ? '.' : parts.slice(0, Math.min(parts.length - 1, 2)).join('/');
43
+ }
44
+
45
+ export function localInputBytes(config) {
46
+ // UTF-8 bytes also bound non-ASCII input. Leave space for JSON escaping,
47
+ // repository context, policy, and the provider's message envelope.
48
+ return Math.max(
49
+ 256,
50
+ Math.min(
51
+ config.maxDiffChars || 30000,
52
+ Math.floor((config.analysisBudget?.limits.chunkInputTokens || 12000) * 0.6),
53
+ ),
54
+ );
55
+ }
56
+
57
+ function summaryOf(fact, evidence = true) {
58
+ return {
59
+ id: fact.id,
60
+ kind: fact.kind,
61
+ status: fact.status,
62
+ module: clip(fact.module, 160),
63
+ fileCount: fact.files.length,
64
+ additions: fact.additions,
65
+ deletions: fact.deletions,
66
+ ...(fact.modeChange ? { modeChange: fact.modeChange } : {}),
67
+ ...(fact.binary ? { binary: true } : {}),
68
+ examples: fact.files.slice(0, 2).map((path) => clip(path, 180)),
69
+ ...(evidence && fact.evidence ? { representativeExcerpt: fact.evidence } : {}),
70
+ };
71
+ }
72
+
73
+ const SAMPLE_NOTICE =
74
+ 'Local change inventory with selected excerpts, not complete semantic analysis. ' +
75
+ 'Counts cover every file. Excerpts are partial; omitted content and paths are not evidence of unchanged behavior. ' +
76
+ 'Identical textual edits are grouped within a module. Describe supported changes conservatively; do not infer intent.';
77
+
78
+ function ranked(facts) {
79
+ const priority = { source: 0, configuration: 1, test: 2, documentation: 3 };
80
+ return [...facts].sort(
81
+ (a, b) =>
82
+ (priority[a.kind] ?? 4) - (priority[b.kind] ?? 4) ||
83
+ b.files.length - a.files.length ||
84
+ a.id.localeCompare(b.id),
85
+ );
86
+ }
87
+
88
+ export function localOverview(config, facts) {
89
+ const kinds = {};
90
+ const statuses = {};
91
+ let totalFiles = 0;
92
+ let additions = 0;
93
+ let deletions = 0;
94
+ let modeChangedFiles = 0;
95
+ let binaryFiles = 0;
96
+ for (const fact of facts) {
97
+ totalFiles += fact.files.length;
98
+ additions += fact.additions;
99
+ deletions += fact.deletions;
100
+ if (fact.modeChange) modeChangedFiles += fact.files.length;
101
+ if (fact.binary) binaryFiles += fact.files.length;
102
+ kinds[fact.kind] = (kinds[fact.kind] || 0) + fact.files.length;
103
+ statuses[fact.status] = (statuses[fact.status] || 0) + fact.files.length;
104
+ }
105
+ const data = {
106
+ notice: SAMPLE_NOTICE,
107
+ totalFiles,
108
+ additions,
109
+ deletions,
110
+ modeChangedFiles,
111
+ binaryFiles,
112
+ lineCounts: 'tracked diff only',
113
+ kinds,
114
+ statuses,
115
+ totalGroups: facts.length,
116
+ omittedGroups: facts.length,
117
+ samples: [],
118
+ };
119
+ const cap = localInputBytes(config);
120
+ let sampledFiles = 0;
121
+ const seen = new Set();
122
+ const ordered = ranked(facts);
123
+ // First cover distinct modules/categories, then use remaining space for detail.
124
+ const diverse = [];
125
+ const rest = [];
126
+ for (const fact of ordered) {
127
+ const key = JSON.stringify([fact.module, fact.kind]);
128
+ if (seen.has(key)) rest.push(fact);
129
+ else {
130
+ seen.add(key);
131
+ diverse.push(fact);
132
+ }
133
+ }
134
+ const firstKinds = new Set();
135
+ const first = [];
136
+ const remaining = [];
137
+ for (const fact of diverse) {
138
+ if (firstKinds.has(fact.kind)) remaining.push(fact);
139
+ else {
140
+ firstKinds.add(fact.kind);
141
+ first.push(fact);
142
+ }
143
+ }
144
+ for (const fact of [...first, ...remaining, ...rest]) {
145
+ if (data.samples.length >= 16) break;
146
+ const sample = summaryOf(fact);
147
+ data.samples.push(sample);
148
+ if (Buffer.byteLength(JSON.stringify(data)) > cap) {
149
+ data.samples.pop();
150
+ continue;
151
+ }
152
+ if (fact.evidence) sampledFiles++;
153
+ data.omittedGroups--;
154
+ }
155
+ // A tiny configured budget may not even fit aggregate metadata. Fail before
156
+ // sending rather than silently slicing JSON or dropping the uncertainty notice.
157
+ const text = JSON.stringify(data);
158
+ if (Buffer.byteLength(text) > cap)
159
+ throw fail(
160
+ ERROR_CATEGORIES.CONFIG,
161
+ 'Local change inventory does not fit the input budget. Increase largeChange.chunkInputTokens or maxDiffChars.',
162
+ );
163
+ return { text, sampledFiles };
164
+ }
165
+
166
+ export function analyzeLocally(config, capture, protect, previews) {
167
+ const records = new Map(
168
+ capture.manifest.map((file) => [
169
+ file.id,
170
+ {
171
+ file,
172
+ hash: createHash('sha256'),
173
+ evidence: '',
174
+ textual: false,
175
+ },
176
+ ]),
177
+ );
178
+ for (const unit of capture.units(protect, previews)) {
179
+ if (unit.metadataOnly) continue;
180
+ const record = records.get(unit.fileId);
181
+ // Tracked fragments carry Git headers; untracked fragments are plain text.
182
+ const section = unit.id.match(/S\d+(?=P\d+$)/)?.[0];
183
+ if (section && section !== record.section) {
184
+ record.section = section;
185
+ record.inHunk = false;
186
+ }
187
+ for (const line of unit.text.split(/(?<=\n)/)) {
188
+ if (section) {
189
+ if (line.startsWith('@@')) record.inHunk = true;
190
+ if (!record.inHunk || !/^[+-]/.test(line)) continue;
191
+ }
192
+ record.hash.update(line);
193
+ record.textual = true;
194
+ const remaining = 900 - Buffer.byteLength(record.evidence);
195
+ if (remaining > 3) record.evidence += clip(line, remaining);
196
+ }
197
+ }
198
+ const grouped = new Map();
199
+ for (const { file, hash, evidence, textual } of records.values()) {
200
+ const kind = category(file, config);
201
+ const module = moduleOf(file.addPaths?.at(-1) || file.path);
202
+ const status = file.status.replace(/\d+$/, '');
203
+ const metadata = ['lockfile', 'excluded', 'generated'].includes(kind);
204
+ const modeChange =
205
+ file.oldMode && file.newMode && file.oldMode !== file.newMode
206
+ ? `${file.oldMode} -> ${file.newMode}`
207
+ : null;
208
+ // Unknown/binary changes and renames stay independent for split safety.
209
+ const identity = metadata
210
+ ? 'metadata'
211
+ : textual && !status.startsWith('R')
212
+ ? hash.digest('hex')
213
+ : file.id;
214
+ const key = JSON.stringify([kind, module, status, identity, modeChange, !!file.binary]);
215
+ let fact = grouped.get(key);
216
+ if (!fact) {
217
+ fact = {
218
+ id: file.id,
219
+ kind,
220
+ module,
221
+ status,
222
+ files: [],
223
+ additions: 0,
224
+ deletions: 0,
225
+ modeChange,
226
+ binary: !!file.binary,
227
+ evidence: metadata ? '' : evidence,
228
+ };
229
+ grouped.set(key, fact);
230
+ }
231
+ fact.files.push(file.path);
232
+ fact.additions += file.additions;
233
+ fact.deletions += file.deletions;
234
+ }
235
+ const facts = [...grouped.values()];
236
+ const overview = localOverview(config, facts);
237
+ return {
238
+ facts,
239
+ summary: overview.text,
240
+ coverage: {
241
+ strategy: 'auto',
242
+ totalFiles: capture.manifest.length,
243
+ analyzedFiles: 0,
244
+ sampledFiles: overview.sampledFiles,
245
+ metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
246
+ failedFiles: 0,
247
+ completedChunks: 0,
248
+ },
249
+ };
250
+ }
251
+
252
+ export function localPlanItems(config, facts, cap) {
253
+ const error = () =>
254
+ fail(
255
+ ERROR_CATEGORIES.CONFIG,
256
+ 'The complete split candidate inventory does not fit one request. Stage a smaller logical change or explicitly set personal largeChange.strategy to deep. No planning request was sent.',
257
+ );
258
+ if (facts.length > (config.splitMaxPlanFiles || 100)) throw error();
259
+ const items = facts.map((fact) => ({
260
+ ...summaryOf(fact, false),
261
+ summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
262
+ }));
263
+ if (Buffer.byteLength(JSON.stringify(items)) > cap) throw error();
264
+ for (const fact of ranked(facts)) {
265
+ if (!fact.evidence) continue;
266
+ const item = items.find((item) => item.id === fact.id);
267
+ item.representativeExcerpt = fact.evidence;
268
+ if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
269
+ }
270
+ return items;
271
+ }