@hifullmoon/aicommit 1.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 +118 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/README.zh-CN.md +408 -0
- package/SECURITY.md +33 -0
- package/bin/aicommit.js +47 -0
- package/docs/distribution.md +104 -0
- package/docs/examples/aicommit-policy.yml +24 -0
- package/docs/examples/commit-msg +4 -0
- package/docs/examples/extension/aicommit-extension.json +9 -0
- package/docs/examples/extension/index.mjs +32 -0
- package/docs/extensions.md +93 -0
- package/docs/privacy.md +58 -0
- package/docs/provider-compatibility.md +37 -0
- package/docs/provider-presets.md +100 -0
- package/docs/team-policy.md +86 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +100 -0
- package/presets/provider-presets.json +60 -0
- package/schemas/aicommit-extension.schema.json +27 -0
- package/schemas/aicommit-output.schema.json +86 -0
- package/schemas/aicommit-provider-presets.schema.json +56 -0
- package/schemas/aicommit-split-checkpoint.schema.json +92 -0
- package/schemas/aicommit-split-plan.schema.json +108 -0
- package/schemas/aicommit-team-policy.schema.json +59 -0
- package/src/api.js +740 -0
- package/src/cli.js +558 -0
- package/src/completion.js +136 -0
- package/src/config-command.js +59 -0
- package/src/config.js +524 -0
- package/src/context.js +537 -0
- package/src/credentials.js +123 -0
- package/src/doctor.js +131 -0
- package/src/errors.js +96 -0
- package/src/extension-runner.mjs +36 -0
- package/src/extensions.js +426 -0
- package/src/generation-ui.js +53 -0
- package/src/git.js +458 -0
- package/src/main.js +768 -0
- package/src/metrics.js +375 -0
- package/src/output.js +87 -0
- package/src/policy-command.js +172 -0
- package/src/policy.js +421 -0
- package/src/preset-command.js +91 -0
- package/src/provider-presets.js +361 -0
- package/src/providers.js +306 -0
- package/src/setup.js +268 -0
- package/src/split-checkpoint.js +252 -0
- package/src/split-hunks.js +263 -0
- package/src/split-plan.js +339 -0
- package/src/split.js +2285 -0
- package/src/team-policy.js +95 -0
- package/src/trust.js +31 -0
- package/src/ui.js +488 -0
- package/src/utils.js +165 -0
- package/templates/.aicommit.policy.json +22 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
8
|
+
|
|
9
|
+
function git(projectRoot, args, { indexPath = null, input = undefined } = {}) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('git', args, {
|
|
12
|
+
cwd: projectRoot,
|
|
13
|
+
env: indexPath ? { ...process.env, GIT_INDEX_FILE: indexPath } : process.env,
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
input,
|
|
16
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
17
|
+
});
|
|
18
|
+
} catch (err) {
|
|
19
|
+
const detail = err.stderr?.toString('utf8').trim();
|
|
20
|
+
throw new Error(`git ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`, { cause: err });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function updateIndexEntry(projectRoot, indexPath, path, entry) {
|
|
25
|
+
if (entry) {
|
|
26
|
+
git(
|
|
27
|
+
projectRoot,
|
|
28
|
+
['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`],
|
|
29
|
+
{ indexPath },
|
|
30
|
+
);
|
|
31
|
+
} else {
|
|
32
|
+
git(projectRoot, ['update-index', '--force-remove', '--', path], { indexPath });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseIndexEntries(text) {
|
|
37
|
+
const entries = new Map();
|
|
38
|
+
for (const field of text.split('\0')) {
|
|
39
|
+
if (!field) continue;
|
|
40
|
+
const match = field.match(/^(\d+) ([0-9a-f]+) 0\t([\s\S]+)$/);
|
|
41
|
+
if (match) entries.set(match[3], { mode: match[1], oid: match[2] });
|
|
42
|
+
}
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parsePatch(path, patch) {
|
|
47
|
+
if (
|
|
48
|
+
!patch.startsWith('diff --git ') ||
|
|
49
|
+
/^(?:GIT binary patch|Binary files |new file mode |deleted file mode |old mode |new mode |rename (?:from|to) )/m.test(
|
|
50
|
+
patch,
|
|
51
|
+
)
|
|
52
|
+
) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const matches = [...patch.matchAll(/^@@ /gm)];
|
|
56
|
+
if (matches.length < 2) return null;
|
|
57
|
+
const header = patch.slice(0, matches[0].index);
|
|
58
|
+
const hunks = matches.map((match, index) => {
|
|
59
|
+
const end = matches[index + 1]?.index ?? patch.length;
|
|
60
|
+
const text = patch.slice(match.index, end);
|
|
61
|
+
const coordinates = text.match(HUNK_HEADER_RE);
|
|
62
|
+
if (!coordinates) throw new Error(`Cannot parse unified hunk header for ${path}.`);
|
|
63
|
+
return {
|
|
64
|
+
id: `H${index + 1}`,
|
|
65
|
+
hash: createHash('sha256').update(`${path}\0${text}`).digest('hex'),
|
|
66
|
+
oldStart: Number(coordinates[1]),
|
|
67
|
+
oldLines: Number(coordinates[2] ?? 1),
|
|
68
|
+
newStart: Number(coordinates[3]),
|
|
69
|
+
newLines: Number(coordinates[4] ?? 1),
|
|
70
|
+
text,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return { path, header, hunks };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function targetTreeAndPatches(projectRoot, baseHead, changes, snapshots) {
|
|
77
|
+
if (!baseHead) return { targetTree: null, patches: new Map() };
|
|
78
|
+
const snapshot = new Map(snapshots.map((entry) => [entry.path, entry.target]));
|
|
79
|
+
const candidates = changes.filter(
|
|
80
|
+
(change) =>
|
|
81
|
+
change.addPaths.length === 1 &&
|
|
82
|
+
change.status.includes('M') &&
|
|
83
|
+
snapshot.get(change.addPaths[0]),
|
|
84
|
+
);
|
|
85
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'aicommit-split-hunks-'));
|
|
86
|
+
const indexPath = join(tempDir, 'index');
|
|
87
|
+
try {
|
|
88
|
+
git(projectRoot, ['read-tree', baseHead], { indexPath });
|
|
89
|
+
for (const change of candidates) {
|
|
90
|
+
const path = change.addPaths[0];
|
|
91
|
+
updateIndexEntry(projectRoot, indexPath, path, snapshot.get(path));
|
|
92
|
+
}
|
|
93
|
+
const targetTree = git(projectRoot, ['write-tree'], { indexPath }).trim();
|
|
94
|
+
const patches = new Map();
|
|
95
|
+
for (const change of candidates) {
|
|
96
|
+
const path = change.addPaths[0];
|
|
97
|
+
const patch = git(projectRoot, [
|
|
98
|
+
'diff',
|
|
99
|
+
'--no-ext-diff',
|
|
100
|
+
'--binary',
|
|
101
|
+
'--no-textconv',
|
|
102
|
+
'--full-index',
|
|
103
|
+
'--unified=3',
|
|
104
|
+
baseHead,
|
|
105
|
+
targetTree,
|
|
106
|
+
'--',
|
|
107
|
+
path,
|
|
108
|
+
]);
|
|
109
|
+
const parsed = parsePatch(path, patch);
|
|
110
|
+
if (parsed) patches.set(change.path, parsed);
|
|
111
|
+
}
|
|
112
|
+
return { targetTree, patches };
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function publicHunk(hunk) {
|
|
119
|
+
return {
|
|
120
|
+
id: hunk.id,
|
|
121
|
+
hash: hunk.hash,
|
|
122
|
+
oldStart: hunk.oldStart,
|
|
123
|
+
oldLines: hunk.oldLines,
|
|
124
|
+
newStart: hunk.newStart,
|
|
125
|
+
newLines: hunk.newLines,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function discoverSplitHunks(projectRoot, baseHead, changes, snapshots) {
|
|
130
|
+
const { patches } = targetTreeAndPatches(projectRoot, baseHead, changes, snapshots);
|
|
131
|
+
return changes.map((change) => {
|
|
132
|
+
const parsed = patches.get(change.path);
|
|
133
|
+
return parsed
|
|
134
|
+
? { ...change, hunks: parsed.hunks.map(publicHunk) }
|
|
135
|
+
: { status: change.status, path: change.path, addPaths: [...change.addPaths] };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sameHunkMetadata(expected, actual) {
|
|
140
|
+
return (
|
|
141
|
+
expected.id === actual.id &&
|
|
142
|
+
expected.hash === actual.hash &&
|
|
143
|
+
expected.oldStart === actual.oldStart &&
|
|
144
|
+
expected.oldLines === actual.oldLines &&
|
|
145
|
+
expected.newStart === actual.newStart &&
|
|
146
|
+
expected.newLines === actual.newLines
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateCatalog(plan, patches) {
|
|
151
|
+
for (const change of plan.changes) {
|
|
152
|
+
if (!change.hunks?.length) continue;
|
|
153
|
+
const parsed = patches.get(change.path);
|
|
154
|
+
if (
|
|
155
|
+
!parsed ||
|
|
156
|
+
parsed.hunks.length !== change.hunks.length ||
|
|
157
|
+
change.hunks.some((hunk, index) => !sameHunkMetadata(hunk, parsed.hunks[index]))
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(`Hunk catalog no longer matches the target snapshot: ${change.path}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function groupRealPaths(group, changes) {
|
|
165
|
+
const byPath = new Map(changes.map((change) => [change.path, change]));
|
|
166
|
+
const displays = [...group.files, ...(group.hunks || []).map((entry) => entry.path)];
|
|
167
|
+
return [...new Set(displays.flatMap((path) => byPath.get(path)?.addPaths || [path]))];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function entriesEqual(left, right) {
|
|
171
|
+
if (!left) return !right;
|
|
172
|
+
return Boolean(right) && left.mode === right.mode && left.oid === right.oid;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateHunkTransaction(projectRoot, plan, snapshots) {
|
|
176
|
+
if (!plan.hunkMode) return null;
|
|
177
|
+
const { patches } = targetTreeAndPatches(projectRoot, plan.baseHead, plan.changes, snapshots);
|
|
178
|
+
validateCatalog(plan, patches);
|
|
179
|
+
|
|
180
|
+
const snapshot = new Map(snapshots.map((entry) => [entry.path, entry.target]));
|
|
181
|
+
const byDisplay = new Map(plan.changes.map((change) => [change.path, change]));
|
|
182
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'aicommit-split-hunk-validate-'));
|
|
183
|
+
const indexPath = join(tempDir, 'index');
|
|
184
|
+
const groupTrees = [];
|
|
185
|
+
try {
|
|
186
|
+
git(projectRoot, ['read-tree', plan.baseHead], { indexPath });
|
|
187
|
+
let previousTree = git(projectRoot, ['write-tree'], { indexPath }).trim();
|
|
188
|
+
for (const [groupIndex, group] of plan.groups.entries()) {
|
|
189
|
+
for (const path of group.files) {
|
|
190
|
+
const change = byDisplay.get(path);
|
|
191
|
+
for (const realPath of change.addPaths) {
|
|
192
|
+
updateIndexEntry(projectRoot, indexPath, realPath, snapshot.get(realPath) || null);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const assignment of group.hunks || []) {
|
|
196
|
+
const parsed = patches.get(assignment.path);
|
|
197
|
+
const selected = new Set(assignment.ids);
|
|
198
|
+
const patch =
|
|
199
|
+
parsed.header +
|
|
200
|
+
parsed.hunks
|
|
201
|
+
.filter((hunk) => selected.has(hunk.id))
|
|
202
|
+
.map((hunk) => hunk.text)
|
|
203
|
+
.join('');
|
|
204
|
+
git(projectRoot, ['apply', '--cached', '--3way', '--whitespace=nowarn', '--recount', '-'], {
|
|
205
|
+
indexPath,
|
|
206
|
+
input: patch,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const tree = git(projectRoot, ['write-tree'], { indexPath }).trim();
|
|
210
|
+
if (tree === previousTree) {
|
|
211
|
+
throw new Error(`Hunk group ${groupIndex + 1} would create an empty commit.`);
|
|
212
|
+
}
|
|
213
|
+
groupTrees.push(tree);
|
|
214
|
+
previousTree = tree;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const finalEntries = parseIndexEntries(
|
|
218
|
+
git(projectRoot, ['ls-files', '--stage', '-z', '--', ...snapshots.map((item) => item.path)], {
|
|
219
|
+
indexPath,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
for (const entry of snapshots) {
|
|
223
|
+
if (!entriesEqual(finalEntries.get(entry.path) || null, entry.target)) {
|
|
224
|
+
throw new Error(`Hunk plan is not lossless for ${entry.path}.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
groupTrees,
|
|
229
|
+
realPaths: plan.groups.map((group) => groupRealPaths(group, plan.changes)),
|
|
230
|
+
};
|
|
231
|
+
} finally {
|
|
232
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function stripHunkCatalog(changes) {
|
|
237
|
+
return changes.map((change) => ({
|
|
238
|
+
status: change.status,
|
|
239
|
+
path: change.path,
|
|
240
|
+
addPaths: [...change.addPaths],
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function fallbackHunkGroups(groups, changes) {
|
|
245
|
+
const hunkPaths = new Set(
|
|
246
|
+
changes.filter((change) => change.hunks?.length).map((change) => change.path),
|
|
247
|
+
);
|
|
248
|
+
const owner = new Map();
|
|
249
|
+
groups.forEach((group, index) => {
|
|
250
|
+
for (const assignment of group.hunks || []) {
|
|
251
|
+
if (!owner.has(assignment.path)) owner.set(assignment.path, index);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
return groups
|
|
255
|
+
.map((group, index) => {
|
|
256
|
+
const files = [...group.files];
|
|
257
|
+
for (const path of hunkPaths) {
|
|
258
|
+
if (owner.get(path) === index && !files.includes(path)) files.push(path);
|
|
259
|
+
}
|
|
260
|
+
return { message: group.message, files };
|
|
261
|
+
})
|
|
262
|
+
.filter((group) => group.files.length);
|
|
263
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { normalizeCommitPolicy, validateCommitCandidate } from './policy.js';
|
|
5
|
+
|
|
6
|
+
export const SPLIT_PLAN_KIND = 'aicommit-split-plan';
|
|
7
|
+
export const SPLIT_PLAN_VERSION = 1;
|
|
8
|
+
const MAX_PLAN_BYTES = 1024 * 1024;
|
|
9
|
+
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
10
|
+
const FINGERPRINT_RE = /^[0-9a-f]{64}$/;
|
|
11
|
+
const HUNK_HASH_RE = /^[0-9a-f]{64}$/;
|
|
12
|
+
|
|
13
|
+
function object(value) {
|
|
14
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safePath(value, label) {
|
|
18
|
+
if (
|
|
19
|
+
typeof value !== 'string' ||
|
|
20
|
+
!value ||
|
|
21
|
+
value.length > 1000 ||
|
|
22
|
+
value.includes('\0') ||
|
|
23
|
+
isAbsolute(value) ||
|
|
24
|
+
value === '..' ||
|
|
25
|
+
value.startsWith('../') ||
|
|
26
|
+
value.split(/[\\/]/).includes('..')
|
|
27
|
+
) {
|
|
28
|
+
throw new Error(`${label} must be a safe repository-relative path.`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clonePolicy(policy, language) {
|
|
34
|
+
const normalized = normalizeCommitPolicy(policy, language);
|
|
35
|
+
return {
|
|
36
|
+
version: normalized.version,
|
|
37
|
+
types: [...normalized.types],
|
|
38
|
+
scope: { ...normalized.scope, values: [...normalized.scope.values] },
|
|
39
|
+
subject: { ...normalized.subject },
|
|
40
|
+
body: { ...normalized.body },
|
|
41
|
+
breakingChange: normalized.breakingChange,
|
|
42
|
+
language: normalized.language,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeChange(change, index) {
|
|
47
|
+
if (!object(change)) throw new Error(`changes[${index}] must be an object.`);
|
|
48
|
+
const keys = Object.keys(change);
|
|
49
|
+
if (keys.some((key) => !['status', 'path', 'addPaths', 'hunks'].includes(key))) {
|
|
50
|
+
throw new Error(`changes[${index}] contains an unknown property.`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof change.status !== 'string' || !change.status || change.status.length > 8) {
|
|
53
|
+
throw new Error(`changes[${index}].status must be a short non-empty string.`);
|
|
54
|
+
}
|
|
55
|
+
const path = safePath(change.path, `changes[${index}].path`);
|
|
56
|
+
if (!Array.isArray(change.addPaths) || !change.addPaths.length || change.addPaths.length > 2) {
|
|
57
|
+
throw new Error(`changes[${index}].addPaths must contain one or two paths.`);
|
|
58
|
+
}
|
|
59
|
+
const addPaths = change.addPaths.map((item, pathIndex) =>
|
|
60
|
+
safePath(item, `changes[${index}].addPaths[${pathIndex}]`),
|
|
61
|
+
);
|
|
62
|
+
if (new Set(addPaths).size !== addPaths.length) {
|
|
63
|
+
throw new Error(`changes[${index}].addPaths contains a duplicate path.`);
|
|
64
|
+
}
|
|
65
|
+
let hunks;
|
|
66
|
+
if (change.hunks !== undefined) {
|
|
67
|
+
if (!Array.isArray(change.hunks) || change.hunks.length < 2 || change.hunks.length > 1000) {
|
|
68
|
+
throw new Error(`changes[${index}].hunks must contain 2-1000 entries.`);
|
|
69
|
+
}
|
|
70
|
+
hunks = change.hunks.map((hunk, hunkIndex) => {
|
|
71
|
+
if (
|
|
72
|
+
!object(hunk) ||
|
|
73
|
+
Object.keys(hunk).some(
|
|
74
|
+
(key) => !['id', 'hash', 'oldStart', 'oldLines', 'newStart', 'newLines'].includes(key),
|
|
75
|
+
) ||
|
|
76
|
+
typeof hunk.id !== 'string' ||
|
|
77
|
+
!/^H[1-9]\d*$/.test(hunk.id) ||
|
|
78
|
+
typeof hunk.hash !== 'string' ||
|
|
79
|
+
!HUNK_HASH_RE.test(hunk.hash)
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(`changes[${index}].hunks[${hunkIndex}] is invalid.`);
|
|
82
|
+
}
|
|
83
|
+
for (const key of ['oldStart', 'oldLines', 'newStart', 'newLines']) {
|
|
84
|
+
if (!Number.isInteger(hunk[key]) || hunk[key] < 0) {
|
|
85
|
+
throw new Error(`changes[${index}].hunks[${hunkIndex}].${key} is invalid.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
id: hunk.id,
|
|
90
|
+
hash: hunk.hash,
|
|
91
|
+
oldStart: hunk.oldStart,
|
|
92
|
+
oldLines: hunk.oldLines,
|
|
93
|
+
newStart: hunk.newStart,
|
|
94
|
+
newLines: hunk.newLines,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
if (new Set(hunks.map((hunk) => hunk.id)).size !== hunks.length) {
|
|
98
|
+
throw new Error(`changes[${index}].hunks contains a duplicate id.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { status: change.status, path, addPaths, ...(hunks ? { hunks } : {}) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeGroup(group, index, allowedPaths, hunkCatalog, policy, language) {
|
|
105
|
+
if (!object(group)) throw new Error(`groups[${index}] must be an object.`);
|
|
106
|
+
const keys = Object.keys(group);
|
|
107
|
+
if (keys.some((key) => !['message', 'files', 'hunks'].includes(key))) {
|
|
108
|
+
throw new Error(`groups[${index}] contains an unknown property.`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof group.message !== 'string' || !group.message.trim() || group.message.length > 10_000) {
|
|
111
|
+
throw new Error(`groups[${index}].message must be a non-empty bounded string.`);
|
|
112
|
+
}
|
|
113
|
+
const validation = validateCommitCandidate(group.message, {
|
|
114
|
+
policy: normalizeCommitPolicy(policy, language),
|
|
115
|
+
});
|
|
116
|
+
if (!validation.valid) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`groups[${index}].message violates commitPolicy: ${validation.errors
|
|
119
|
+
.map((issue) => issue.message)
|
|
120
|
+
.join(' ')}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(group.files)) {
|
|
124
|
+
throw new Error(`groups[${index}].files must be an array.`);
|
|
125
|
+
}
|
|
126
|
+
const files = group.files.map((item, fileIndex) =>
|
|
127
|
+
safePath(item, `groups[${index}].files[${fileIndex}]`),
|
|
128
|
+
);
|
|
129
|
+
if (new Set(files).size !== files.length) {
|
|
130
|
+
throw new Error(`groups[${index}].files contains a duplicate path.`);
|
|
131
|
+
}
|
|
132
|
+
for (const path of files) {
|
|
133
|
+
if (!allowedPaths.has(path))
|
|
134
|
+
throw new Error(`groups[${index}] references unknown path: ${path}`);
|
|
135
|
+
}
|
|
136
|
+
let hunks = [];
|
|
137
|
+
if (group.hunks !== undefined) {
|
|
138
|
+
if (!Array.isArray(group.hunks)) throw new Error(`groups[${index}].hunks must be an array.`);
|
|
139
|
+
hunks = group.hunks.map((assignment, assignmentIndex) => {
|
|
140
|
+
if (
|
|
141
|
+
!object(assignment) ||
|
|
142
|
+
Object.keys(assignment).some((key) => !['path', 'ids'].includes(key)) ||
|
|
143
|
+
typeof assignment.path !== 'string' ||
|
|
144
|
+
!hunkCatalog.has(assignment.path) ||
|
|
145
|
+
!Array.isArray(assignment.ids) ||
|
|
146
|
+
!assignment.ids.length
|
|
147
|
+
) {
|
|
148
|
+
throw new Error(`groups[${index}].hunks[${assignmentIndex}] is invalid.`);
|
|
149
|
+
}
|
|
150
|
+
const path = safePath(assignment.path, `groups[${index}].hunks[${assignmentIndex}].path`);
|
|
151
|
+
const ids = assignment.ids.map((id) => {
|
|
152
|
+
if (typeof id !== 'string' || !hunkCatalog.get(path).has(id)) {
|
|
153
|
+
throw new Error(`groups[${index}] references unknown hunk: ${path}#${id}`);
|
|
154
|
+
}
|
|
155
|
+
return id;
|
|
156
|
+
});
|
|
157
|
+
if (new Set(ids).size !== ids.length) {
|
|
158
|
+
throw new Error(`groups[${index}].hunks[${assignmentIndex}] contains a duplicate id.`);
|
|
159
|
+
}
|
|
160
|
+
return { path, ids };
|
|
161
|
+
});
|
|
162
|
+
if (new Set(hunks.map((assignment) => assignment.path)).size !== hunks.length) {
|
|
163
|
+
throw new Error(`groups[${index}].hunks contains a duplicate path.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!files.length && !hunks.length) throw new Error(`groups[${index}] must not be empty.`);
|
|
167
|
+
return { message: validation.parsed.cleaned, files, ...(hunks.length ? { hunks } : {}) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function validateSplitPlanArtifact(input) {
|
|
171
|
+
if (!object(input)) throw new Error('Split plan must be a JSON object.');
|
|
172
|
+
const allowedKeys = new Set([
|
|
173
|
+
'kind',
|
|
174
|
+
'version',
|
|
175
|
+
'createdAt',
|
|
176
|
+
'hunkMode',
|
|
177
|
+
'scope',
|
|
178
|
+
'baseHead',
|
|
179
|
+
'fingerprint',
|
|
180
|
+
'language',
|
|
181
|
+
'commitPolicy',
|
|
182
|
+
'changes',
|
|
183
|
+
'groups',
|
|
184
|
+
]);
|
|
185
|
+
if (Object.keys(input).some((key) => !allowedKeys.has(key))) {
|
|
186
|
+
throw new Error('Split plan contains an unknown top-level property.');
|
|
187
|
+
}
|
|
188
|
+
if (input.kind !== SPLIT_PLAN_KIND || input.version !== SPLIT_PLAN_VERSION) {
|
|
189
|
+
throw new Error(`Unsupported split plan; expected ${SPLIT_PLAN_KIND} version 1.`);
|
|
190
|
+
}
|
|
191
|
+
if (typeof input.createdAt !== 'string' || !Number.isFinite(Date.parse(input.createdAt))) {
|
|
192
|
+
throw new Error('Split plan createdAt must be an ISO timestamp.');
|
|
193
|
+
}
|
|
194
|
+
if (!['staged', 'all'].includes(input.scope)) {
|
|
195
|
+
throw new Error('Split plan scope must be staged or all.');
|
|
196
|
+
}
|
|
197
|
+
const hunkMode = input.hunkMode === true;
|
|
198
|
+
if (input.hunkMode !== undefined && typeof input.hunkMode !== 'boolean') {
|
|
199
|
+
throw new Error('Split plan hunkMode must be a boolean.');
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
input.baseHead !== null &&
|
|
203
|
+
(typeof input.baseHead !== 'string' || !HASH_RE.test(input.baseHead))
|
|
204
|
+
) {
|
|
205
|
+
throw new Error('Split plan baseHead must be a Git object id or null.');
|
|
206
|
+
}
|
|
207
|
+
if (typeof input.fingerprint !== 'string' || !FINGERPRINT_RE.test(input.fingerprint)) {
|
|
208
|
+
throw new Error('Split plan fingerprint must be a SHA-256 digest.');
|
|
209
|
+
}
|
|
210
|
+
if (!['zh', 'en'].includes(input.language)) {
|
|
211
|
+
throw new Error('Split plan language must be zh or en.');
|
|
212
|
+
}
|
|
213
|
+
const commitPolicy = clonePolicy(input.commitPolicy, input.language);
|
|
214
|
+
if (!Array.isArray(input.changes) || !input.changes.length || input.changes.length > 10_000) {
|
|
215
|
+
throw new Error('Split plan changes must contain 1-10000 entries.');
|
|
216
|
+
}
|
|
217
|
+
const changes = input.changes.map(normalizeChange);
|
|
218
|
+
const displayPaths = changes.map((change) => change.path);
|
|
219
|
+
if (new Set(displayPaths).size !== displayPaths.length) {
|
|
220
|
+
throw new Error('Split plan changes contains duplicate display paths.');
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(input.groups) || !input.groups.length || input.groups.length > 1000) {
|
|
223
|
+
throw new Error('Split plan groups must contain 1-1000 entries.');
|
|
224
|
+
}
|
|
225
|
+
const allowedPaths = new Set(displayPaths);
|
|
226
|
+
const hunkCatalog = new Map(
|
|
227
|
+
changes
|
|
228
|
+
.filter((change) => change.hunks?.length)
|
|
229
|
+
.map((change) => [change.path, new Set(change.hunks.map((hunk) => hunk.id))]),
|
|
230
|
+
);
|
|
231
|
+
if (!hunkMode && hunkCatalog.size) {
|
|
232
|
+
throw new Error('Split plan cannot contain a hunk catalog when hunkMode is disabled.');
|
|
233
|
+
}
|
|
234
|
+
const groups = input.groups.map((group, index) =>
|
|
235
|
+
normalizeGroup(group, index, allowedPaths, hunkCatalog, commitPolicy, input.language),
|
|
236
|
+
);
|
|
237
|
+
const assignedFiles = groups.flatMap((group) => group.files);
|
|
238
|
+
if (new Set(assignedFiles).size !== assignedFiles.length) {
|
|
239
|
+
throw new Error('Split plan assigns a path to more than one group.');
|
|
240
|
+
}
|
|
241
|
+
const assignedHunks = new Set();
|
|
242
|
+
for (const group of groups) {
|
|
243
|
+
for (const assignment of group.hunks || []) {
|
|
244
|
+
if (assignedFiles.includes(assignment.path)) {
|
|
245
|
+
throw new Error(`Split plan assigns both a whole file and hunks: ${assignment.path}`);
|
|
246
|
+
}
|
|
247
|
+
for (const id of assignment.ids) {
|
|
248
|
+
const key = `${assignment.path}\0${id}`;
|
|
249
|
+
if (assignedHunks.has(key)) {
|
|
250
|
+
throw new Error(`Split plan assigns a hunk more than once: ${assignment.path}#${id}`);
|
|
251
|
+
}
|
|
252
|
+
assignedHunks.add(key);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const missing = displayPaths.filter((path) => {
|
|
257
|
+
if (assignedFiles.includes(path)) return false;
|
|
258
|
+
const ids = hunkCatalog.get(path);
|
|
259
|
+
return !ids || [...ids].some((id) => !assignedHunks.has(`${path}\0${id}`));
|
|
260
|
+
});
|
|
261
|
+
if (missing.length) throw new Error(`Split plan leaves paths unassigned: ${missing.join(', ')}`);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
kind: SPLIT_PLAN_KIND,
|
|
265
|
+
version: SPLIT_PLAN_VERSION,
|
|
266
|
+
createdAt: input.createdAt,
|
|
267
|
+
hunkMode,
|
|
268
|
+
scope: input.scope,
|
|
269
|
+
baseHead: input.baseHead,
|
|
270
|
+
fingerprint: input.fingerprint,
|
|
271
|
+
language: input.language,
|
|
272
|
+
commitPolicy,
|
|
273
|
+
changes,
|
|
274
|
+
groups,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createSplitPlanArtifact({
|
|
279
|
+
scope,
|
|
280
|
+
baseHead,
|
|
281
|
+
fingerprint,
|
|
282
|
+
language,
|
|
283
|
+
commitPolicy,
|
|
284
|
+
changes,
|
|
285
|
+
groups,
|
|
286
|
+
hunkMode = false,
|
|
287
|
+
createdAt = new Date().toISOString(),
|
|
288
|
+
}) {
|
|
289
|
+
return validateSplitPlanArtifact({
|
|
290
|
+
kind: SPLIT_PLAN_KIND,
|
|
291
|
+
version: SPLIT_PLAN_VERSION,
|
|
292
|
+
createdAt,
|
|
293
|
+
hunkMode,
|
|
294
|
+
scope,
|
|
295
|
+
baseHead,
|
|
296
|
+
fingerprint,
|
|
297
|
+
language,
|
|
298
|
+
commitPolicy,
|
|
299
|
+
changes,
|
|
300
|
+
groups,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function writeSplitPlanArtifact(path, artifact) {
|
|
305
|
+
const absolute = resolve(path);
|
|
306
|
+
const validated = validateSplitPlanArtifact(artifact);
|
|
307
|
+
await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
308
|
+
const temporary = `${absolute}.${process.pid}.${Date.now()}.tmp`;
|
|
309
|
+
try {
|
|
310
|
+
await writeFile(temporary, JSON.stringify(validated, null, 2) + '\n', {
|
|
311
|
+
encoding: 'utf8',
|
|
312
|
+
mode: 0o600,
|
|
313
|
+
});
|
|
314
|
+
await rename(temporary, absolute);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
await unlink(temporary).catch(() => {});
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
return absolute;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function readSplitPlanArtifact(path) {
|
|
323
|
+
const absolute = resolve(path);
|
|
324
|
+
const stat = await lstat(absolute).catch((err) => {
|
|
325
|
+
if (err.code === 'ENOENT') throw new Error(`Split plan not found: ${absolute}`);
|
|
326
|
+
throw err;
|
|
327
|
+
});
|
|
328
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
329
|
+
throw new Error('Split plan must be a regular, non-symbolic-link file.');
|
|
330
|
+
}
|
|
331
|
+
if (stat.size > MAX_PLAN_BYTES) throw new Error('Split plan exceeds the 1 MiB limit.');
|
|
332
|
+
let parsed;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(await readFile(absolute, 'utf8'));
|
|
335
|
+
} catch (err) {
|
|
336
|
+
throw new Error(`Failed to parse split plan ${absolute}: ${err.message}`);
|
|
337
|
+
}
|
|
338
|
+
return { path: absolute, artifact: validateSplitPlanArtifact(parsed) };
|
|
339
|
+
}
|