@hifullmoon/aicommit 2.4.1 → 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.
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);