@hifullmoon/aicommit 2.4.0 → 2.4.1
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/CHANGELOG.md +8 -1
- package/docs/distribution.md +1 -1
- package/package.json +1 -1
- package/src/change-analysis.js +104 -35
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.4.1] - 2026-09-10
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Recovered malformed large-change JSON responses once and accepted complete JSON arrays wrapped in provider reasoning, code fences, or explanatory prose while preserving strict ID coverage validation.
|
|
12
|
+
|
|
7
13
|
## [2.4.0] - 2026-09-09
|
|
8
14
|
|
|
9
15
|
### Changed
|
|
@@ -200,7 +206,8 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
|
|
|
200
206
|
- Added file-level split planning and execution with Git-state concurrency checks.
|
|
201
207
|
- Added provider presets and user/project configuration boundaries.
|
|
202
208
|
|
|
203
|
-
[Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.4.
|
|
209
|
+
[Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.4.1...HEAD
|
|
210
|
+
[2.4.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.4.1
|
|
204
211
|
[2.4.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.4.0
|
|
205
212
|
[2.3.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.3.0
|
|
206
213
|
[2.2.3]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.3
|
package/docs/distribution.md
CHANGED
|
@@ -33,7 +33,7 @@ The release workflow uses npm Trusted Publishing without a long-lived `NPM_TOKEN
|
|
|
33
33
|
```bash
|
|
34
34
|
workdir=$(mktemp -d)
|
|
35
35
|
cd "$workdir"
|
|
36
|
-
npm install --package-lock-only @hifullmoon/aicommit@2.4.
|
|
36
|
+
npm install --package-lock-only @hifullmoon/aicommit@2.4.1
|
|
37
37
|
npm audit signatures
|
|
38
38
|
```
|
|
39
39
|
|
package/package.json
CHANGED
package/src/change-analysis.js
CHANGED
|
@@ -260,7 +260,64 @@ export function packItems(items, maxChars, maxItems = 24) {
|
|
|
260
260
|
return batches;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
function parseAnalysisJson(raw) {
|
|
264
|
+
const text = String(raw || '')
|
|
265
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
266
|
+
.trim();
|
|
267
|
+
const fence = text.match(/^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/);
|
|
268
|
+
const candidates = fence ? [fence[1].trim(), text] : [text];
|
|
269
|
+
|
|
270
|
+
for (const candidate of candidates) {
|
|
271
|
+
try {
|
|
272
|
+
const parsed = JSON.parse(candidate);
|
|
273
|
+
if (Array.isArray(parsed)) return parsed;
|
|
274
|
+
} catch {
|
|
275
|
+
// Fall through to the balanced-array extractor below. Some providers
|
|
276
|
+
// wrap otherwise valid JSON in a short explanation or an outer object.
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (
|
|
280
|
+
let start = candidate.indexOf('[');
|
|
281
|
+
start !== -1;
|
|
282
|
+
start = candidate.indexOf('[', start + 1)
|
|
283
|
+
) {
|
|
284
|
+
let depth = 0;
|
|
285
|
+
let quoted = false;
|
|
286
|
+
let escaped = false;
|
|
287
|
+
for (let index = start; index < candidate.length; index++) {
|
|
288
|
+
const char = candidate[index];
|
|
289
|
+
if (quoted) {
|
|
290
|
+
if (escaped) escaped = false;
|
|
291
|
+
else if (char === '\\') escaped = true;
|
|
292
|
+
else if (char === '"') quoted = false;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (char === '"') {
|
|
296
|
+
quoted = true;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (char === '[') depth++;
|
|
300
|
+
if (char !== ']') continue;
|
|
301
|
+
depth--;
|
|
302
|
+
if (depth !== 0) continue;
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(candidate.slice(start, index + 1));
|
|
305
|
+
if (Array.isArray(parsed)) return parsed;
|
|
306
|
+
} catch {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
throw new SyntaxError('Response contains no complete JSON array.');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function jsonCall(config, instruction, items, validate = null) {
|
|
316
|
+
const parseAndValidate = (raw) => {
|
|
317
|
+
const parsed = parseAnalysisJson(raw);
|
|
318
|
+
validate?.(parsed);
|
|
319
|
+
return parsed;
|
|
320
|
+
};
|
|
264
321
|
const result = await getResponseText(
|
|
265
322
|
config,
|
|
266
323
|
[
|
|
@@ -269,13 +326,25 @@ async function jsonCall(config, instruction, items) {
|
|
|
269
326
|
],
|
|
270
327
|
0,
|
|
271
328
|
Math.min(2048, config.maxTokens || 1024),
|
|
272
|
-
'Return the complete requested JSON, preserving
|
|
329
|
+
'Return the complete requested JSON array, preserving every required input ID exactly once. ' +
|
|
330
|
+
`Required IDs: ${JSON.stringify(items.map((item) => item.id))}`,
|
|
273
331
|
null,
|
|
332
|
+
(response) => {
|
|
333
|
+
try {
|
|
334
|
+
parseAndValidate(response);
|
|
335
|
+
return true;
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
},
|
|
274
340
|
);
|
|
275
341
|
try {
|
|
276
|
-
return
|
|
277
|
-
} catch {
|
|
278
|
-
|
|
342
|
+
return parseAndValidate(result.text);
|
|
343
|
+
} catch (cause) {
|
|
344
|
+
if (cause?.category === ERROR_CATEGORIES.RESPONSE_FORMAT) throw cause;
|
|
345
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Large-change analysis returned invalid JSON.', {
|
|
346
|
+
cause,
|
|
347
|
+
});
|
|
279
348
|
}
|
|
280
349
|
}
|
|
281
350
|
|
|
@@ -344,23 +413,23 @@ export async function analyzeChanges(
|
|
|
344
413
|
}),
|
|
345
414
|
)
|
|
346
415
|
.digest('hex');
|
|
416
|
+
const inputIds = input.map((x) => x.id);
|
|
347
417
|
const groups =
|
|
348
418
|
cache.get(cacheKey) ||
|
|
349
419
|
(await jsonCall(
|
|
350
420
|
config,
|
|
351
421
|
'Return one entry per input ID: [{"ids":["input ID"],"summary":"concise factual changes and uncertainties"}]. Each input ID must appear exactly once. Never combine different IDs. Summaries must be at most 160 characters.',
|
|
352
422
|
input,
|
|
423
|
+
(candidate) => {
|
|
424
|
+
validatePartition(candidate, inputIds);
|
|
425
|
+
if (candidate.some((group) => group.ids.length !== 1))
|
|
426
|
+
throw fail(
|
|
427
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
428
|
+
'Fragment analysis combined unrelated source IDs.',
|
|
429
|
+
);
|
|
430
|
+
},
|
|
353
431
|
));
|
|
354
|
-
validatePartition(
|
|
355
|
-
groups,
|
|
356
|
-
input.map((x) => x.id),
|
|
357
|
-
);
|
|
358
432
|
const byId = new Map(input.map((x) => [x.id, x.fileId]));
|
|
359
|
-
if (groups.some((group) => group.ids.length !== 1))
|
|
360
|
-
throw fail(
|
|
361
|
-
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
362
|
-
'Fragment analysis combined unrelated source IDs.',
|
|
363
|
-
);
|
|
364
433
|
cache.set(cacheKey, groups);
|
|
365
434
|
for (const group of groups) {
|
|
366
435
|
for (const id of new Set(group.ids.map((x) => byId.get(x))))
|
|
@@ -411,17 +480,17 @@ export async function analyzeChanges(
|
|
|
411
480
|
summaries.map((summary, i) => ({ id: `S${i}`, summary })),
|
|
412
481
|
maxChars,
|
|
413
482
|
)) {
|
|
483
|
+
const ids = group.map((x) => x.id);
|
|
414
484
|
const reduced = await jsonCall(
|
|
415
485
|
config,
|
|
416
486
|
'Return [{"ids":[all input IDs],"summary":"one factual summary, at most 400 characters"}]. Preserve important behavior and uncertainty.',
|
|
417
487
|
group,
|
|
488
|
+
(candidate) => {
|
|
489
|
+
validatePartition(candidate, ids);
|
|
490
|
+
if (candidate.length !== 1)
|
|
491
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
492
|
+
},
|
|
418
493
|
);
|
|
419
|
-
validatePartition(
|
|
420
|
-
reduced,
|
|
421
|
-
group.map((x) => x.id),
|
|
422
|
-
);
|
|
423
|
-
if (reduced.length !== 1)
|
|
424
|
-
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
425
494
|
next.push(reduced[0].summary);
|
|
426
495
|
}
|
|
427
496
|
summaries = next;
|
|
@@ -472,17 +541,17 @@ export async function summarizeChanges(config, facts) {
|
|
|
472
541
|
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Change summary did not converge.');
|
|
473
542
|
const next = [];
|
|
474
543
|
for (const batch of packItems(items, cap)) {
|
|
544
|
+
const ids = batch.map((x) => x.id);
|
|
475
545
|
const groups = await jsonCall(
|
|
476
546
|
config,
|
|
477
547
|
'Return [{"ids":[all input IDs],"summary":"one summary of supported behavior changes and uncertainty, at most 400 characters"}].',
|
|
478
548
|
batch,
|
|
549
|
+
(candidate) => {
|
|
550
|
+
validatePartition(candidate, ids);
|
|
551
|
+
if (candidate.length !== 1)
|
|
552
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
553
|
+
},
|
|
479
554
|
);
|
|
480
|
-
validatePartition(
|
|
481
|
-
groups,
|
|
482
|
-
batch.map((x) => x.id),
|
|
483
|
-
);
|
|
484
|
-
if (groups.length !== 1)
|
|
485
|
-
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
486
555
|
next.push({ id: `L${level}N${next.length}`, summary: groups[0].summary });
|
|
487
556
|
}
|
|
488
557
|
items = next;
|
|
@@ -512,21 +581,21 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
|
512
581
|
}
|
|
513
582
|
const next = [];
|
|
514
583
|
for (const batch of batches) {
|
|
584
|
+
const ids = batch.map((x) => x.id);
|
|
515
585
|
const groups = await jsonCall(
|
|
516
586
|
config,
|
|
517
587
|
`Each commit message must follow this policy: ${JSON.stringify(policy)}.\nGroup related changes into logical commits, including related implementation and tests across directories. Return [{"ids":[input IDs],"summary":"factual combined change summary","subject":"commit subject","body":"optional commit body"}]. Assign every input ID exactly once. Do not merge unrelated changes just to reduce group count.`,
|
|
518
588
|
batch,
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
589
|
+
(candidate) => {
|
|
590
|
+
validatePartition(candidate, ids);
|
|
591
|
+
if (candidate.some((group) => typeof group.subject !== 'string' || !group.subject.trim()))
|
|
592
|
+
throw fail(
|
|
593
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
594
|
+
'Analysis plan is missing a commit subject.',
|
|
595
|
+
);
|
|
596
|
+
},
|
|
523
597
|
);
|
|
524
598
|
for (const group of groups) {
|
|
525
|
-
if (typeof group.subject !== 'string' || !group.subject.trim())
|
|
526
|
-
throw fail(
|
|
527
|
-
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
528
|
-
'Analysis plan is missing a commit subject.',
|
|
529
|
-
);
|
|
530
599
|
next.push({
|
|
531
600
|
...group,
|
|
532
601
|
id: `L${level}G${next.length}`,
|