@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 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.0...HEAD
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
@@ -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.0
36
+ npm install --package-lock-only @hifullmoon/aicommit@2.4.1
37
37
  npm audit signatures
38
38
  ```
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hifullmoon/aicommit",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Safe, local-first AI commit message generator for Git workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -260,7 +260,64 @@ export function packItems(items, maxChars, maxItems = 24) {
260
260
  return batches;
261
261
  }
262
262
 
263
- async function jsonCall(config, instruction, items) {
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 all input IDs.',
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 JSON.parse(result.text.replace(/^```(?:json)?\s*|\s*```$/g, ''));
277
- } catch {
278
- throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Large-change analysis returned invalid JSON.');
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
- validatePartition(
521
- groups,
522
- batch.map((x) => x.id),
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}`,