@hifullmoon/aicommit 2.4.0 → 2.5.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.
@@ -245,27 +245,57 @@ export function analyzeLocally(config, capture, protect, previews) {
245
245
  metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
246
246
  failedFiles: 0,
247
247
  completedChunks: 0,
248
+ cachedChunks: 0,
249
+ requestedChunks: 0,
248
250
  },
249
251
  };
250
252
  }
251
253
 
252
- export function localPlanItems(config, facts, cap) {
254
+ export function localPlanBatches(config, facts, cap) {
253
255
  const error = () =>
254
- fail(
256
+ fail(ERROR_CATEGORIES.CONFIG, 'A split candidate is too large for one planning request.', {
257
+ data: { fallbackPlan: true },
258
+ });
259
+ const maxItems = config.splitMaxPlanFiles || 100;
260
+ const batches = [];
261
+ let batch = [];
262
+ for (const fact of facts) {
263
+ const item = {
264
+ ...summaryOf(fact, false),
265
+ summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
266
+ };
267
+ if (Buffer.byteLength(JSON.stringify([item])) > cap) throw error();
268
+ const candidate = [...batch, item];
269
+ if (
270
+ batch.length &&
271
+ (candidate.length > maxItems || Buffer.byteLength(JSON.stringify(candidate)) > cap)
272
+ ) {
273
+ batches.push(batch);
274
+ batch = [];
275
+ }
276
+ batch.push(item);
277
+ }
278
+ if (batch.length) batches.push(batch);
279
+
280
+ const factsById = new Map(facts.map((fact) => [fact.id, fact]));
281
+ for (const items of batches) {
282
+ for (const item of items) {
283
+ const evidence = factsById.get(item.id)?.evidence;
284
+ if (!evidence) continue;
285
+ item.representativeExcerpt = evidence;
286
+ if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
287
+ }
288
+ }
289
+ return batches;
290
+ }
291
+
292
+ export function localPlanItems(config, facts, cap) {
293
+ const batches = localPlanBatches(config, facts, cap);
294
+ if (batches.length !== 1)
295
+ throw fail(
255
296
  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.',
297
+ 'The complete split candidate inventory requires multiple planning requests.',
298
+ { data: { fallbackPlan: true } },
257
299
  );
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;
300
+ return batches[0];
271
301
  }
package/src/main.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  summarizeChanges,
30
30
  } from './change-analysis.js';
31
31
  import { cleanupGitSpools } from './git-spool.js';
32
+ import { createAnalysisCache } from './analysis-cache.js';
32
33
  import { generateCommitMessage } from './api.js';
33
34
  import {
34
35
  statusColor,
@@ -110,6 +111,7 @@ async function runMain() {
110
111
  splitPlanFile,
111
112
  dryRun,
112
113
  yes,
114
+ allowSingleFallback,
113
115
  setup,
114
116
  update,
115
117
  doctor,
@@ -291,6 +293,7 @@ async function runMain() {
291
293
  console.log(chalk.dim(` split: ${split}`));
292
294
  console.log(chalk.dim(` dryRun: ${dryRun}`));
293
295
  console.log(chalk.dim(` yes: ${yes}`));
296
+ console.log(chalk.dim(` singleFallback: ${allowSingleFallback}`));
294
297
  console.log(chalk.dim(' final config:'));
295
298
  for (const [key, value] of Object.entries(config)) {
296
299
  const display =
@@ -321,6 +324,7 @@ async function runMain() {
321
324
  scope: split,
322
325
  dryRun,
323
326
  yes,
327
+ allowSingleFallback,
324
328
  machineOutput,
325
329
  provider: selectedProvider,
326
330
  exportPlanPath: splitCommand === 'plan' ? splitPlanFile : null,
@@ -572,9 +576,25 @@ async function runMain() {
572
576
  );
573
577
  let analysis;
574
578
  let analyzedFacts;
579
+ let persistentAnalysisCache = null;
575
580
  let generationConfig = config;
576
581
  if (large) {
577
582
  generationConfig = analysisConfig(config);
583
+ persistentAnalysisCache = createAnalysisCache({
584
+ projectRoot,
585
+ snapshotFingerprint: plannedIndexFingerprint,
586
+ config: generationConfig,
587
+ protect: protectAnalysis,
588
+ });
589
+ if (
590
+ generationConfig.largeChange?.strategy === 'deep' &&
591
+ generationConfig.largeChange.cache?.enabled &&
592
+ !protectAnalysis &&
593
+ !generationConfig.largeChange.cache.allowUnprotected
594
+ ) {
595
+ warnings.push('Deep-analysis recovery cache was disabled for unprotected input.');
596
+ console.log(chalk.dim(' Recovery cache: disabled for unprotected input.'));
597
+ }
578
598
  console.log(
579
599
  chalk.dim(
580
600
  generationConfig.largeChange?.strategy === 'deep'
@@ -622,8 +642,11 @@ async function runMain() {
622
642
  captured,
623
643
  protectAnalysis,
624
644
  null,
625
- ({ completedChunks }) =>
626
- console.error(` Analysis: ${completedChunks} chunks completed`),
645
+ ({ completedChunks, cachedChunks }) =>
646
+ console.error(
647
+ ` Analysis: ${completedChunks} chunks completed${cachedChunks ? ` (${cachedChunks} cached)` : ''}`,
648
+ ),
649
+ persistentAnalysisCache,
627
650
  );
628
651
  modelDiff =
629
652
  analyzedFacts.summary ||
@@ -691,6 +714,8 @@ async function runMain() {
691
714
  reported: true,
692
715
  });
693
716
  }
717
+ persistentAnalysisCache?.clear();
718
+ persistentAnalysisCache = null;
694
719
 
695
720
  // ── 4. User action ─────────────────────────────────────────────────
696
721
 
package/src/split.js CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  summarizeChanges,
75
75
  } from './change-analysis.js';
76
76
  import { updateGitHash, spoolGit } from './git-spool.js';
77
+ import { createAnalysisCache } from './analysis-cache.js';
77
78
 
78
79
  // ═══════════════════════════════════════════════════════════════════════════
79
80
  // Split mode: group changes into multiple logical commits
@@ -468,6 +469,51 @@ function groupMessage(g, policy) {
468
469
  return validateCommitCandidate(message, { policy }).valid ? message : '';
469
470
  }
470
471
 
472
+ function fallbackCommitMessage(policy) {
473
+ const type = policy.types.includes('chore') ? 'chore' : policy.types[0];
474
+ let scope = '';
475
+ if (policy.scope.mode === 'required') {
476
+ if (policy.scope.values.length) scope = policy.scope.values[0];
477
+ else {
478
+ const disallowed = new Set(policy.scope.disallowedValues || []);
479
+ const preferred = ['changes', 'repository', 'all'];
480
+ scope = preferred.find((candidate) => !disallowed.has(candidate)) || '';
481
+ for (let index = 1; !scope; index++) {
482
+ const candidate = `fallback-${index}`;
483
+ if (!disallowed.has(candidate)) scope = candidate;
484
+ }
485
+ }
486
+ }
487
+ const breaking = policy.breakingChange === 'require' ? '!' : '';
488
+ const prefix = `${type}${scope ? `(${scope})` : ''}${breaking}: `;
489
+ const preferred = policy.effectiveLanguage === 'zh' ? '更新其余文件' : 'update remaining files';
490
+ const available = Math.max(
491
+ 1,
492
+ Math.min(
493
+ policy.subject.maxLength,
494
+ policy.subject.headerMaxLength
495
+ ? policy.subject.headerMaxLength - [...prefix].length
496
+ : policy.subject.maxLength,
497
+ ),
498
+ );
499
+ const subject = [...preferred].slice(0, available).join('');
500
+ const body =
501
+ policy.body.mode === 'required'
502
+ ? policy.effectiveLanguage === 'zh'
503
+ ? '- 包含本次已审核的全部文件变更'
504
+ : '- Include all reviewed file changes'
505
+ : '';
506
+ const message = cleanCommitMessage(`${prefix}${subject}${body ? `\n\n${body}` : ''}`);
507
+ const validation = validateCommitCandidate(message, { policy });
508
+ if (!validation.valid) {
509
+ throw fail(
510
+ ERROR_CATEGORIES.CONFIG,
511
+ `Cannot construct a conservative fallback message under commitPolicy: ${validation.errors.map((item) => item.message).join(' ')}`,
512
+ );
513
+ }
514
+ return message;
515
+ }
516
+
471
517
  // Clean up the model's plan: drop unknown/duplicate files, drop empty
472
518
  // groups, and sweep any file the model forgot into a final catch-all group.
473
519
  export function normalizePlan(groups, allFiles, language, commitPolicy = null) {
@@ -531,12 +577,8 @@ export function normalizePlan(groups, allFiles, language, commitPolicy = null) {
531
577
  if (ids.length) leftoverHunks.push({ path: change.path, ids });
532
578
  }
533
579
  if (leftoverFiles.length || leftoverHunks.length) {
534
- const type = policy.types.includes('chore') ? 'chore' : policy.types[0];
535
580
  result.push({
536
- message:
537
- policy.effectiveLanguage === 'zh'
538
- ? `${type}: 更新其余文件`
539
- : `${type}: update remaining files`,
581
+ message: fallbackCommitMessage(policy),
540
582
  files: leftoverFiles,
541
583
  ...(leftoverHunks.length ? { hunks: leftoverHunks } : {}),
542
584
  });
@@ -1421,6 +1463,7 @@ export async function splitFlow(
1421
1463
  scope = 'prompt',
1422
1464
  dryRun = false,
1423
1465
  yes = false,
1466
+ allowSingleFallback = false,
1424
1467
  machineOutput = false,
1425
1468
  provider = null,
1426
1469
  exportPlanPath = null,
@@ -1621,6 +1664,7 @@ export async function splitFlow(
1621
1664
 
1622
1665
  let raw, reasoningText, elapsed, usage;
1623
1666
  let analysis;
1667
+ let persistentAnalysisCache = null;
1624
1668
  const large =
1625
1669
  needsAnalysis(captured, config, true) ||
1626
1670
  untrackedSnapshot.previews.sources?.size > 0 ||
@@ -1634,7 +1678,22 @@ export async function splitFlow(
1634
1678
  ERROR_CATEGORIES.CONFIG,
1635
1679
  'Large-change analysis does not support experimental hunk plans; use file-level split.',
1636
1680
  );
1637
- planningConfig = analysisConfig(planningConfig);
1681
+ planningConfig = analysisConfig({ ...planningConfig, analysisTask: 'split' });
1682
+ persistentAnalysisCache = createAnalysisCache({
1683
+ projectRoot,
1684
+ snapshotFingerprint: plannedStateFingerprint,
1685
+ config: planningConfig,
1686
+ protect: protectModelInput,
1687
+ });
1688
+ if (
1689
+ planningConfig.largeChange?.strategy === 'deep' &&
1690
+ planningConfig.largeChange.cache?.enabled &&
1691
+ !protectModelInput &&
1692
+ !planningConfig.largeChange.cache.allowUnprotected
1693
+ ) {
1694
+ warnings.push('Deep-analysis recovery cache was disabled for unprotected input.');
1695
+ console.log(chalk.dim(' Recovery cache: disabled for unprotected input.'));
1696
+ }
1638
1697
  console.log(
1639
1698
  chalk.dim(
1640
1699
  planningConfig.largeChange?.strategy === 'deep'
@@ -1657,15 +1716,53 @@ export async function splitFlow(
1657
1716
  failureMessage: 'API call failed',
1658
1717
  task: async (stream) => {
1659
1718
  if (large) {
1660
- analysis = await analyzeChanges(
1661
- planningConfig,
1662
- captured,
1663
- protectModelInput,
1664
- untrackedSnapshot.previews,
1665
- ({ completedChunks }) =>
1666
- console.error(` Analysis: ${completedChunks} chunks completed`),
1667
- );
1668
- const plan = await planAnalyzedChanges(planningConfig, analysis.facts, analysis.coverage);
1719
+ let plan;
1720
+ try {
1721
+ analysis = await analyzeChanges(
1722
+ planningConfig,
1723
+ captured,
1724
+ protectModelInput,
1725
+ untrackedSnapshot.previews,
1726
+ ({ completedChunks, cachedChunks }) =>
1727
+ console.error(
1728
+ ` Analysis: ${completedChunks} chunks completed${cachedChunks ? ` (${cachedChunks} cached)` : ''}`,
1729
+ ),
1730
+ persistentAnalysisCache,
1731
+ );
1732
+ plan = await planAnalyzedChanges(planningConfig, analysis.facts, analysis.coverage);
1733
+ } catch (err) {
1734
+ const exhausted =
1735
+ err.data?.analysis?.exhausted ||
1736
+ (!planningConfig.analysisBudget.remainingMs() ? 'time' : null);
1737
+ if (!exhausted && !err.data?.fallbackPlan) throw err;
1738
+ if (yes && !dryRun && !allowSingleFallback) {
1739
+ throw fail(
1740
+ err.category || ERROR_CATEGORIES.PROVIDER,
1741
+ 'Large-change planning requires a conservative single-commit fallback. No commit was created; review interactively or rerun with --allow-single-fallback.',
1742
+ { cause: err, data: err.data },
1743
+ );
1744
+ }
1745
+ const fallbackConfig = {
1746
+ ...planningConfig,
1747
+ largeChange: { ...planningConfig.largeChange, strategy: 'auto' },
1748
+ };
1749
+ analysis = await analyzeChanges(
1750
+ fallbackConfig,
1751
+ captured,
1752
+ protectModelInput,
1753
+ untrackedSnapshot.previews,
1754
+ );
1755
+ analysis.coverage = {
1756
+ ...analysis.coverage,
1757
+ strategy: 'fallback',
1758
+ degradedFrom: planningConfig.largeChange.strategy,
1759
+ fallbackReason: exhausted || 'planning_capacity',
1760
+ };
1761
+ plan = normalizePlan([], allFiles, config.language, config.commitPolicy);
1762
+ const warning = `Large-change planning used one conservative all-files commit after ${exhausted || 'planning capacity'} exhaustion; review the fallback message and grouping.`;
1763
+ warnings.push(warning);
1764
+ console.error(` Fallback: ${warning}`);
1765
+ }
1669
1766
  if (analysis.coverage.strategy === 'auto')
1670
1767
  warnings.push(
1671
1768
  'Split candidates used local metadata and selected excerpts; review the grouping because content was not fully analyzed.',
@@ -1736,6 +1833,8 @@ export async function splitFlow(
1736
1833
  reported: true,
1737
1834
  });
1738
1835
  }
1836
+ persistentAnalysisCache?.clear();
1837
+ persistentAnalysisCache = null;
1739
1838
 
1740
1839
  // Review / edit / regenerate loop
1741
1840
  let regenCounts = groups.map(() => 0);