@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
package/src/metrics.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_METRICS = Object.freeze({
|
|
8
|
+
enabled: true,
|
|
9
|
+
path: '',
|
|
10
|
+
maxEntries: 500,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let activeSettings = null;
|
|
14
|
+
const METRIC_RESULTS = new Set([
|
|
15
|
+
'committed',
|
|
16
|
+
'dry_run',
|
|
17
|
+
'cancelled',
|
|
18
|
+
'config',
|
|
19
|
+
'git_state',
|
|
20
|
+
'network',
|
|
21
|
+
'provider',
|
|
22
|
+
'response_format',
|
|
23
|
+
'sensitive_data',
|
|
24
|
+
'concurrent_modification',
|
|
25
|
+
'internal',
|
|
26
|
+
'unknown',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function metricsPath(settings = DEFAULT_METRICS, home = homedir()) {
|
|
30
|
+
return settings.path || join(home, '.aicommit', 'metrics.jsonl');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function configureMetrics(settings) {
|
|
34
|
+
activeSettings = { ...DEFAULT_METRICS, ...settings };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeUsage(usage) {
|
|
38
|
+
if (!usage) return null;
|
|
39
|
+
const safe = {};
|
|
40
|
+
for (const key of ['inputTokens', 'outputTokens', 'totalTokens']) {
|
|
41
|
+
if (typeof usage[key] === 'number' && Number.isFinite(usage[key]) && usage[key] >= 0) {
|
|
42
|
+
safe[key] = usage[key];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return Object.keys(safe).length ? safe : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function minimizeMetric(input = {}) {
|
|
49
|
+
const result = METRIC_RESULTS.has(input.result) ? input.result : 'unknown';
|
|
50
|
+
return {
|
|
51
|
+
durationMs:
|
|
52
|
+
typeof input.durationMs === 'number' && Number.isFinite(input.durationMs)
|
|
53
|
+
? Math.max(0, Math.round(input.durationMs))
|
|
54
|
+
: null,
|
|
55
|
+
usage: safeUsage(input.usage),
|
|
56
|
+
result,
|
|
57
|
+
edited: Boolean(input.edited),
|
|
58
|
+
rewrites:
|
|
59
|
+
Number.isInteger(input.rewrites) && input.rewrites >= 0 ? Math.min(input.rewrites, 1000) : 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function trimMetrics(path, maxEntries) {
|
|
64
|
+
const text = await readFile(path, 'utf8');
|
|
65
|
+
const lines = text.split('\n').filter(Boolean);
|
|
66
|
+
if (lines.length <= maxEntries) return;
|
|
67
|
+
|
|
68
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
69
|
+
try {
|
|
70
|
+
await writeFile(tempPath, lines.slice(-maxEntries).join('\n') + '\n', {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
mode: 0o600,
|
|
73
|
+
});
|
|
74
|
+
await rename(tempPath, path);
|
|
75
|
+
await chmod(path, 0o600).catch(() => {});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
await unlink(tempPath).catch(() => {});
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function recordMetric(input, settings = activeSettings) {
|
|
83
|
+
if (!settings?.enabled) return false;
|
|
84
|
+
const path = metricsPath(settings);
|
|
85
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
86
|
+
await appendFile(path, JSON.stringify(minimizeMetric(input)) + '\n', {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
mode: 0o600,
|
|
89
|
+
});
|
|
90
|
+
await chmod(path, 0o600).catch(() => {});
|
|
91
|
+
await trimMetrics(path, settings.maxEntries || DEFAULT_METRICS.maxEntries);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readUserMetricsConfig(path) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
98
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
99
|
+
throw new Error('expected a JSON object');
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code === 'ENOENT') return {};
|
|
104
|
+
throw new Error(`Failed to read user config ${path}: ${err.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function writeUserConfig(path, config) {
|
|
109
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
110
|
+
try {
|
|
111
|
+
await writeFile(tempPath, JSON.stringify(config, null, 2) + '\n', {
|
|
112
|
+
encoding: 'utf8',
|
|
113
|
+
mode: 0o600,
|
|
114
|
+
});
|
|
115
|
+
await rename(tempPath, path);
|
|
116
|
+
await chmod(path, 0o600).catch(() => {});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
await unlink(tempPath).catch(() => {});
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function metricCount(path) {
|
|
124
|
+
try {
|
|
125
|
+
return (await readFile(path, 'utf8')).split('\n').filter(Boolean).length;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err.code === 'ENOENT') return 0;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function readMetricRecords(path) {
|
|
133
|
+
let textValue;
|
|
134
|
+
try {
|
|
135
|
+
textValue = await readFile(path, 'utf8');
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err.code === 'ENOENT') return { records: [], invalid: 0 };
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
const records = [];
|
|
141
|
+
let invalid = 0;
|
|
142
|
+
for (const line of textValue.split('\n')) {
|
|
143
|
+
if (!line.trim()) continue;
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(line);
|
|
146
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
147
|
+
invalid++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
records.push(minimizeMetric(parsed));
|
|
151
|
+
} catch {
|
|
152
|
+
invalid++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { records, invalid };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function average(values) {
|
|
159
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function percentile(values, ratio) {
|
|
163
|
+
if (!values.length) return null;
|
|
164
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
165
|
+
return sorted[Math.ceil((sorted.length - 1) * ratio)];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function percent(part, whole) {
|
|
169
|
+
return whole ? (part / whole) * 100 : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function relativeChange(previous, recent) {
|
|
173
|
+
if (previous === null || recent === null || previous === 0) return null;
|
|
174
|
+
return ((recent - previous) / previous) * 100;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function tokenTotal(record) {
|
|
178
|
+
const usage = record.usage;
|
|
179
|
+
if (!usage) return null;
|
|
180
|
+
if (typeof usage.totalTokens === 'number') return usage.totalTokens;
|
|
181
|
+
if (typeof usage.inputTokens === 'number' && typeof usage.outputTokens === 'number') {
|
|
182
|
+
return usage.inputTokens + usage.outputTokens;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function halves(records) {
|
|
188
|
+
if (records.length < 2) return [[], records];
|
|
189
|
+
const split = Math.floor(records.length / 2);
|
|
190
|
+
return [records.slice(0, split), records.slice(split)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function windowQuality(records) {
|
|
194
|
+
const changed = records.filter((record) => record.edited || record.rewrites > 0).length;
|
|
195
|
+
return { runs: records.length, changed, rate: percent(changed, records.length) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function summarizeMetrics(inputRecords) {
|
|
199
|
+
const records = (inputRecords || []).map((record) => minimizeMetric(record));
|
|
200
|
+
const successful = records.filter((record) => ['committed', 'dry_run'].includes(record.result));
|
|
201
|
+
const failed = records.filter(
|
|
202
|
+
(record) => !['committed', 'dry_run', 'cancelled'].includes(record.result),
|
|
203
|
+
);
|
|
204
|
+
const edited = records.filter((record) => record.edited);
|
|
205
|
+
const rewritten = records.filter((record) => record.rewrites > 0);
|
|
206
|
+
const acceptedFirstPass = successful.filter((record) => !record.edited && record.rewrites === 0);
|
|
207
|
+
const durations = records
|
|
208
|
+
.map((record) => record.durationMs)
|
|
209
|
+
.filter((value) => typeof value === 'number');
|
|
210
|
+
const tokens = records.map(tokenTotal).filter((value) => typeof value === 'number');
|
|
211
|
+
const [previousRecords, recentRecords] = halves(records);
|
|
212
|
+
const previousDurations = previousRecords
|
|
213
|
+
.map((record) => record.durationMs)
|
|
214
|
+
.filter((value) => typeof value === 'number');
|
|
215
|
+
const recentDurations = recentRecords
|
|
216
|
+
.map((record) => record.durationMs)
|
|
217
|
+
.filter((value) => typeof value === 'number');
|
|
218
|
+
const previousTokens = previousRecords
|
|
219
|
+
.map(tokenTotal)
|
|
220
|
+
.filter((value) => typeof value === 'number');
|
|
221
|
+
const recentTokens = recentRecords.map(tokenTotal).filter((value) => typeof value === 'number');
|
|
222
|
+
const [previousSuccess, recentSuccess] = halves(successful);
|
|
223
|
+
const previousQuality = windowQuality(previousSuccess);
|
|
224
|
+
const recentQuality = windowQuality(recentSuccess);
|
|
225
|
+
const qualityImprovement =
|
|
226
|
+
previousQuality.rate === null || recentQuality.rate === null || previousQuality.rate === 0
|
|
227
|
+
? null
|
|
228
|
+
: ((previousQuality.rate - recentQuality.rate) / previousQuality.rate) * 100;
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
runs: records.length,
|
|
232
|
+
outcomes: {
|
|
233
|
+
committed: records.filter((record) => record.result === 'committed').length,
|
|
234
|
+
dryRuns: records.filter((record) => record.result === 'dry_run').length,
|
|
235
|
+
acceptedFirstPass: acceptedFirstPass.length,
|
|
236
|
+
edited: edited.length,
|
|
237
|
+
rewritten: rewritten.length,
|
|
238
|
+
rewriteCount: records.reduce((sum, record) => sum + record.rewrites, 0),
|
|
239
|
+
failed: failed.length,
|
|
240
|
+
cancelled: records.filter((record) => record.result === 'cancelled').length,
|
|
241
|
+
},
|
|
242
|
+
rates: {
|
|
243
|
+
firstPassAcceptance: percent(acceptedFirstPass.length, successful.length),
|
|
244
|
+
editOrRewrite: percent(
|
|
245
|
+
successful.filter((record) => record.edited || record.rewrites > 0).length,
|
|
246
|
+
successful.length,
|
|
247
|
+
),
|
|
248
|
+
failure: percent(failed.length, records.length),
|
|
249
|
+
},
|
|
250
|
+
latency: {
|
|
251
|
+
count: durations.length,
|
|
252
|
+
averageMs: average(durations),
|
|
253
|
+
p50Ms: percentile(durations, 0.5),
|
|
254
|
+
p95Ms: percentile(durations, 0.95),
|
|
255
|
+
previousAverageMs: average(previousDurations),
|
|
256
|
+
recentAverageMs: average(recentDurations),
|
|
257
|
+
relativeChangePercent: relativeChange(average(previousDurations), average(recentDurations)),
|
|
258
|
+
},
|
|
259
|
+
tokens: {
|
|
260
|
+
count: tokens.length,
|
|
261
|
+
total: tokens.reduce((sum, value) => sum + value, 0),
|
|
262
|
+
average: average(tokens),
|
|
263
|
+
previousAverage: average(previousTokens),
|
|
264
|
+
recentAverage: average(recentTokens),
|
|
265
|
+
relativeChangePercent: relativeChange(average(previousTokens), average(recentTokens)),
|
|
266
|
+
},
|
|
267
|
+
qualityTrend: {
|
|
268
|
+
baselineReady: previousSuccess.length >= 5 && recentSuccess.length >= 5,
|
|
269
|
+
previous: previousQuality,
|
|
270
|
+
recent: recentQuality,
|
|
271
|
+
relativeImprovementPercent: qualityImprovement,
|
|
272
|
+
targetMet: qualityImprovement !== null && qualityImprovement >= 20,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderPercent(value) {
|
|
278
|
+
return value === null ? 'n/a' : `${value.toFixed(1)}%`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderDuration(value) {
|
|
282
|
+
if (value === null) return 'n/a';
|
|
283
|
+
return value < 1000 ? `${Math.round(value)}ms` : `${(value / 1000).toFixed(1)}s`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderNumber(value) {
|
|
287
|
+
return value === null ? 'n/a' : Math.round(value).toLocaleString('en-US');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function runStatsCommand(action = 'show', options = {}) {
|
|
291
|
+
if (['clear', 'enable', 'disable'].includes(action)) {
|
|
292
|
+
return updateMetrics(action, options);
|
|
293
|
+
}
|
|
294
|
+
const home = options.home || homedir();
|
|
295
|
+
const userConfigPath = options.userConfigPath || join(home, '.aicommit.config.json');
|
|
296
|
+
const existing = await readUserMetricsConfig(userConfigPath);
|
|
297
|
+
const settings = { ...DEFAULT_METRICS, ...existing.metrics };
|
|
298
|
+
const path = metricsPath(settings, home);
|
|
299
|
+
const { records, invalid } = await readMetricRecords(path);
|
|
300
|
+
const stats = summarizeMetrics(records);
|
|
301
|
+
|
|
302
|
+
console.log('');
|
|
303
|
+
console.log(' ' + chalk.cyan.bold('Local quality stats'));
|
|
304
|
+
console.log(` Status: ${settings.enabled ? 'enabled' : 'disabled'}`);
|
|
305
|
+
console.log(
|
|
306
|
+
` Runs: ${stats.runs}${invalid ? ` (${invalid} invalid line(s) ignored)` : ''}`,
|
|
307
|
+
);
|
|
308
|
+
console.log(
|
|
309
|
+
` First pass: ${stats.outcomes.acceptedFirstPass} (${renderPercent(stats.rates.firstPassAcceptance)})`,
|
|
310
|
+
);
|
|
311
|
+
console.log(` Edited: ${stats.outcomes.edited}`);
|
|
312
|
+
console.log(
|
|
313
|
+
` Rewritten: ${stats.outcomes.rewritten} run(s), ${stats.outcomes.rewriteCount} rewrite(s)`,
|
|
314
|
+
);
|
|
315
|
+
console.log(` Failed: ${stats.outcomes.failed} (${renderPercent(stats.rates.failure)})`);
|
|
316
|
+
console.log(
|
|
317
|
+
` Latency: P50 ${renderDuration(stats.latency.p50Ms)}, P95 ${renderDuration(stats.latency.p95Ms)}, ` +
|
|
318
|
+
`recent ${renderPercent(stats.latency.relativeChangePercent)}`,
|
|
319
|
+
);
|
|
320
|
+
console.log(
|
|
321
|
+
` Tokens: total ${renderNumber(stats.tokens.total)}, average ${renderNumber(stats.tokens.average)}, ` +
|
|
322
|
+
`recent ${renderPercent(stats.tokens.relativeChangePercent)}`,
|
|
323
|
+
);
|
|
324
|
+
if (stats.qualityTrend.baselineReady) {
|
|
325
|
+
console.log(
|
|
326
|
+
` Edit/rewrite: previous ${renderPercent(stats.qualityTrend.previous.rate)} → ` +
|
|
327
|
+
`recent ${renderPercent(stats.qualityTrend.recent.rate)}; relative improvement ` +
|
|
328
|
+
`${renderPercent(stats.qualityTrend.relativeImprovementPercent)} ` +
|
|
329
|
+
`(20% target ${stats.qualityTrend.targetMet ? 'met' : 'not yet met'})`,
|
|
330
|
+
);
|
|
331
|
+
} else {
|
|
332
|
+
console.log(
|
|
333
|
+
' Edit/rewrite: collect at least 10 successful runs to establish two baseline windows',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
console.log(` Path: ${path}`);
|
|
337
|
+
console.log(
|
|
338
|
+
' Privacy: local only; no messages, diffs, reasoning, file names, providers, or models',
|
|
339
|
+
);
|
|
340
|
+
console.log('');
|
|
341
|
+
return { action, path, enabled: settings.enabled, invalid, stats };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function updateMetrics(action, options = {}) {
|
|
345
|
+
const home = options.home || homedir();
|
|
346
|
+
const userConfigPath = options.userConfigPath || join(home, '.aicommit.config.json');
|
|
347
|
+
const existing = await readUserMetricsConfig(userConfigPath);
|
|
348
|
+
const settings = { ...DEFAULT_METRICS, ...existing.metrics };
|
|
349
|
+
const path = metricsPath(settings, home);
|
|
350
|
+
|
|
351
|
+
if (action === 'clear') {
|
|
352
|
+
const count = await metricCount(path);
|
|
353
|
+
await unlink(path).catch((err) => {
|
|
354
|
+
if (err.code !== 'ENOENT') throw err;
|
|
355
|
+
});
|
|
356
|
+
console.log(
|
|
357
|
+
` ${chalk.green('✓')} Cleared ${count} local metric record(s): ${path} ` +
|
|
358
|
+
chalk.dim('(cannot be recovered)'),
|
|
359
|
+
);
|
|
360
|
+
return { action, count, path, enabled: settings.enabled };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (action === 'enable' || action === 'disable') {
|
|
364
|
+
const enabled = action === 'enable';
|
|
365
|
+
const updated = {
|
|
366
|
+
...existing,
|
|
367
|
+
metrics: { ...existing.metrics, enabled },
|
|
368
|
+
};
|
|
369
|
+
await writeUserConfig(userConfigPath, updated);
|
|
370
|
+
console.log(` ${chalk.green('✓')} Local metrics ${enabled ? 'enabled' : 'disabled'}.`);
|
|
371
|
+
return { action, count: await metricCount(path), path, enabled };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
throw new Error(`Unsupported stats action: ${action}`);
|
|
375
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { classifyError, EXIT_CODES } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const OUTPUT_SCHEMA_VERSION = '1.0';
|
|
4
|
+
|
|
5
|
+
function normalizedUsage(usage) {
|
|
6
|
+
if (!usage) return null;
|
|
7
|
+
const value = {};
|
|
8
|
+
for (const key of ['inputTokens', 'outputTokens', 'totalTokens']) {
|
|
9
|
+
if (typeof usage[key] === 'number' && Number.isFinite(usage[key])) value[key] = usage[key];
|
|
10
|
+
}
|
|
11
|
+
return Object.keys(value).length ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizedPlan(plan) {
|
|
15
|
+
if (!Array.isArray(plan)) return null;
|
|
16
|
+
return plan.map((item) => ({
|
|
17
|
+
message: String(item?.message || ''),
|
|
18
|
+
files: Array.isArray(item?.files) ? item.files.map(String) : [],
|
|
19
|
+
...(Array.isArray(item?.hunks)
|
|
20
|
+
? {
|
|
21
|
+
hunks: item.hunks.map((assignment) => ({
|
|
22
|
+
path: String(assignment?.path || ''),
|
|
23
|
+
ids: Array.isArray(assignment?.ids) ? assignment.ids.map(String) : [],
|
|
24
|
+
})),
|
|
25
|
+
}
|
|
26
|
+
: {}),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function successOutput(result = {}) {
|
|
31
|
+
const output = {
|
|
32
|
+
schemaVersion: OUTPUT_SCHEMA_VERSION,
|
|
33
|
+
ok: true,
|
|
34
|
+
message: typeof result.message === 'string' ? result.message : null,
|
|
35
|
+
plan: normalizedPlan(result.plan),
|
|
36
|
+
provider: result.provider || null,
|
|
37
|
+
model: result.model || null,
|
|
38
|
+
latencyMs:
|
|
39
|
+
typeof result.latencyMs === 'number' && Number.isFinite(result.latencyMs)
|
|
40
|
+
? result.latencyMs
|
|
41
|
+
: null,
|
|
42
|
+
usage: normalizedUsage(result.usage),
|
|
43
|
+
warnings: Array.isArray(result.warnings) ? result.warnings.map(String) : [],
|
|
44
|
+
exitReason: result.exitReason || 'success',
|
|
45
|
+
committed: Boolean(result.committed),
|
|
46
|
+
error: null,
|
|
47
|
+
};
|
|
48
|
+
if (result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
|
|
49
|
+
output.data = result.data;
|
|
50
|
+
}
|
|
51
|
+
return output;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function errorOutput(err) {
|
|
55
|
+
const classified = classifyError(err);
|
|
56
|
+
const output = {
|
|
57
|
+
schemaVersion: OUTPUT_SCHEMA_VERSION,
|
|
58
|
+
ok: false,
|
|
59
|
+
message: null,
|
|
60
|
+
plan: null,
|
|
61
|
+
provider: null,
|
|
62
|
+
model: null,
|
|
63
|
+
latencyMs: null,
|
|
64
|
+
usage: null,
|
|
65
|
+
warnings: [],
|
|
66
|
+
exitReason: classified.category,
|
|
67
|
+
committed: false,
|
|
68
|
+
error: {
|
|
69
|
+
category: classified.category,
|
|
70
|
+
message: classified.message,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
if (classified.data && typeof classified.data === 'object' && !Array.isArray(classified.data)) {
|
|
74
|
+
output.data = classified.data;
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function exitCodeFor(err) {
|
|
80
|
+
return classifyError(err).exitCode ?? EXIT_CODES.internal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isJsonOutputRequested(args = process.argv.slice(2)) {
|
|
84
|
+
return args.some(
|
|
85
|
+
(arg, index) => arg === '--output=json' || (arg === '--output' && args[index + 1] === 'json'),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import {
|
|
7
|
+
applyCommitlintPolicy,
|
|
8
|
+
DEFAULT_REPOSITORY_CONTEXT,
|
|
9
|
+
detectCommitlintConstraints,
|
|
10
|
+
} from './context.js';
|
|
11
|
+
import { ERROR_CATEGORIES, fail } from './errors.js';
|
|
12
|
+
import { readGit } from './git.js';
|
|
13
|
+
import { normalizeCommitPolicy, validateCommitCandidate } from './policy.js';
|
|
14
|
+
import { renderTeamPolicyTemplate } from './team-policy.js';
|
|
15
|
+
|
|
16
|
+
const MAX_MESSAGE_FILE_BYTES = 1024 * 1024;
|
|
17
|
+
const MAX_RANGE_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const MAX_RANGE_COMMITS = 1000;
|
|
19
|
+
|
|
20
|
+
function normalizedPolicyDocument(policy) {
|
|
21
|
+
return {
|
|
22
|
+
version: policy.version,
|
|
23
|
+
types: [...policy.types],
|
|
24
|
+
scope: { mode: policy.scope.mode, values: [...policy.scope.values] },
|
|
25
|
+
subject: { maxLength: policy.subject.maxLength },
|
|
26
|
+
body: { mode: policy.body.mode, maxLines: policy.body.maxLines },
|
|
27
|
+
breakingChange: policy.breakingChange,
|
|
28
|
+
language: policy.language,
|
|
29
|
+
effectiveLanguage: policy.effectiveLanguage,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function policyFingerprint(policy) {
|
|
34
|
+
return createHash('sha256')
|
|
35
|
+
.update(JSON.stringify(normalizedPolicyDocument(policy)))
|
|
36
|
+
.digest('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function messageFromFile(path) {
|
|
40
|
+
const absolutePath = resolve(path);
|
|
41
|
+
let info;
|
|
42
|
+
try {
|
|
43
|
+
info = await lstat(absolutePath);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw fail(ERROR_CATEGORIES.CONFIG, `Cannot read policy message file: ${path}`, { cause: err });
|
|
46
|
+
}
|
|
47
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
48
|
+
throw fail(ERROR_CATEGORIES.CONFIG, `Policy message file must be a regular file: ${path}`);
|
|
49
|
+
}
|
|
50
|
+
if (info.size > MAX_MESSAGE_FILE_BYTES) {
|
|
51
|
+
throw fail(ERROR_CATEGORIES.CONFIG, `Policy message file exceeds 1 MiB: ${path}`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return [{ id: 'message-file', message: await readFile(absolutePath, 'utf8') }];
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw fail(ERROR_CATEGORIES.CONFIG, `Cannot read policy message file: ${path}`, { cause: err });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function messagesFromRange(projectRoot, range) {
|
|
61
|
+
if (
|
|
62
|
+
typeof range !== 'string' ||
|
|
63
|
+
!range ||
|
|
64
|
+
range.length > 512 ||
|
|
65
|
+
range.startsWith('-') ||
|
|
66
|
+
/[\0\r\n]/.test(range)
|
|
67
|
+
) {
|
|
68
|
+
throw fail(ERROR_CATEGORIES.CONFIG, 'Policy Git range is invalid.');
|
|
69
|
+
}
|
|
70
|
+
const output = readGit(
|
|
71
|
+
['log', '-z', '--format=%H%x00%B', range, '--'],
|
|
72
|
+
projectRoot,
|
|
73
|
+
MAX_RANGE_OUTPUT_BYTES,
|
|
74
|
+
);
|
|
75
|
+
const fields = output.split('\0');
|
|
76
|
+
if (fields.at(-1) === '') fields.pop();
|
|
77
|
+
if (fields.length % 2 !== 0) {
|
|
78
|
+
throw fail(ERROR_CATEGORIES.GIT_STATE, 'Git returned a malformed commit-message stream.');
|
|
79
|
+
}
|
|
80
|
+
const messages = [];
|
|
81
|
+
for (let index = 0; index < fields.length; index += 2) {
|
|
82
|
+
messages.push({ id: fields[index], message: fields[index + 1] });
|
|
83
|
+
}
|
|
84
|
+
if (!messages.length) {
|
|
85
|
+
throw fail(ERROR_CATEGORIES.GIT_STATE, `Git range contains no commits: ${range}`);
|
|
86
|
+
}
|
|
87
|
+
if (messages.length > MAX_RANGE_COMMITS) {
|
|
88
|
+
throw fail(
|
|
89
|
+
ERROR_CATEGORIES.CONFIG,
|
|
90
|
+
`Policy check is limited to ${MAX_RANGE_COMMITS} commits per run.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return messages;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resultFor(entry, policy) {
|
|
97
|
+
// Team checks treat the resolved repository language as an explicit rule,
|
|
98
|
+
// even when the document says "inherit" inside commitPolicy.
|
|
99
|
+
const validation = validateCommitCandidate(entry.message, {
|
|
100
|
+
policy: { ...policy, language: policy.effectiveLanguage },
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
id: entry.id,
|
|
104
|
+
valid: validation.valid,
|
|
105
|
+
issues: validation.issues.map(({ code, message, severity }) => ({ code, message, severity })),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printResults(data) {
|
|
110
|
+
console.log(`Policy: commitPolicy v${data.policy.version} (${data.policyFingerprint})`);
|
|
111
|
+
for (const result of data.results) {
|
|
112
|
+
console.log(`${result.valid ? '✓' : '✗'} ${result.id}`);
|
|
113
|
+
for (const issue of result.issues) {
|
|
114
|
+
console.log(` ${issue.severity}: ${issue.code}: ${issue.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.log(
|
|
118
|
+
data.valid
|
|
119
|
+
? `Policy valid for ${data.results.length} commit message(s).`
|
|
120
|
+
: `Policy violations found in ${data.invalidCount} commit message(s).`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function runPolicyCommand(
|
|
125
|
+
action,
|
|
126
|
+
{ messageFile = null, range = null, machineOutput = false } = {},
|
|
127
|
+
) {
|
|
128
|
+
if (action === 'template') {
|
|
129
|
+
process.stdout.write(renderTeamPolicyTemplate());
|
|
130
|
+
return { exitReason: 'policy_template' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const loaded = await loadConfig(null, { resolveCredentials: false });
|
|
134
|
+
const constraints = detectCommitlintConstraints(
|
|
135
|
+
loaded.projectRoot,
|
|
136
|
+
DEFAULT_REPOSITORY_CONTEXT.commitlint,
|
|
137
|
+
);
|
|
138
|
+
const effective = applyCommitlintPolicy(
|
|
139
|
+
loaded.config.commitPolicy,
|
|
140
|
+
constraints,
|
|
141
|
+
loaded.config.language,
|
|
142
|
+
);
|
|
143
|
+
const policy = normalizeCommitPolicy(effective, loaded.config.language);
|
|
144
|
+
const entries = messageFile
|
|
145
|
+
? await messageFromFile(messageFile)
|
|
146
|
+
: messagesFromRange(loaded.projectRoot, range || 'HEAD');
|
|
147
|
+
const results = entries.map((entry) => resultFor(entry, policy));
|
|
148
|
+
const invalidCount = results.filter((result) => !result.valid).length;
|
|
149
|
+
const machineResults = results.map((result) => ({
|
|
150
|
+
id: result.id,
|
|
151
|
+
valid: result.valid,
|
|
152
|
+
issues: result.issues.map(({ code, severity }) => ({ code, severity })),
|
|
153
|
+
}));
|
|
154
|
+
const data = {
|
|
155
|
+
valid: invalidCount === 0,
|
|
156
|
+
invalidCount,
|
|
157
|
+
policy: normalizedPolicyDocument(policy),
|
|
158
|
+
policyFingerprint: policyFingerprint(policy),
|
|
159
|
+
source: messageFile ? 'file' : 'git-range',
|
|
160
|
+
range: messageFile ? null : range || 'HEAD',
|
|
161
|
+
commitlintSource: constraints?.path || null,
|
|
162
|
+
results: machineResults,
|
|
163
|
+
};
|
|
164
|
+
if (!machineOutput) printResults({ ...data, results });
|
|
165
|
+
if (invalidCount) {
|
|
166
|
+
throw fail(ERROR_CATEGORIES.CONFIG, `Team policy rejected ${invalidCount} commit message(s).`, {
|
|
167
|
+
reported: !machineOutput,
|
|
168
|
+
data,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return { exitReason: 'policy_valid', data };
|
|
172
|
+
}
|