@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/.aicommit.config.example.json +31 -8
- package/CHANGELOG.md +25 -1
- package/README.md +41 -6
- package/README.zh-CN.md +41 -6
- package/bin/aicommit.js +6 -1
- package/docs/distribution.md +8 -1
- package/docs/large-change-implementation-plan.md +193 -0
- package/docs/privacy.md +6 -0
- package/docs/provider-compatibility.md +44 -16
- package/docs/troubleshooting.md +12 -0
- package/package.json +4 -2
- package/schemas/aicommit-output.schema.json +120 -18
- package/src/analysis-budget.js +76 -0
- package/src/api.js +6 -316
- package/src/change-analysis.js +558 -0
- package/src/cli.js +31 -5
- package/src/completion.js +3 -0
- package/src/config.js +16 -0
- package/src/doctor.js +2 -4
- package/src/git-spool.js +108 -0
- package/src/git.js +25 -4
- package/src/local-analysis.js +271 -0
- package/src/main.js +120 -27
- package/src/model-client.js +403 -0
- package/src/provider-response.js +148 -0
- package/src/providers.js +134 -206
- package/src/runtime.js +6 -0
- package/src/split.js +194 -43
- package/src/update.js +326 -0
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
|
-
|
|
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)' :
|
|
44
|
+
`v${nodeVersion}${nodeSupported(nodeVersion) ? ' (supported)' : ` (requires >=${MIN_NODE_VERSION})`}`,
|
|
47
45
|
);
|
|
48
46
|
|
|
49
47
|
try {
|
package/src/git-spool.js
ADDED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|
+
}
|