@hifullmoon/aicommit 2.3.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/.aicommit.config.example.json +31 -8
- package/CHANGELOG.md +21 -1
- package/README.md +31 -5
- package/README.zh-CN.md +31 -5
- package/bin/aicommit.js +6 -1
- package/docs/distribution.md +1 -1
- package/docs/large-change-implementation-plan.md +193 -0
- package/docs/privacy.md +6 -0
- package/docs/provider-compatibility.md +44 -16
- package/docs/troubleshooting.md +10 -0
- package/package.json +4 -2
- package/schemas/aicommit-output.schema.json +120 -18
- package/src/analysis-budget.js +76 -0
- package/src/api.js +6 -316
- package/src/change-analysis.js +627 -0
- package/src/config.js +16 -0
- package/src/doctor.js +2 -4
- package/src/git-spool.js +108 -0
- package/src/git.js +25 -4
- package/src/local-analysis.js +271 -0
- package/src/main.js +114 -25
- package/src/model-client.js +403 -0
- package/src/provider-response.js +148 -0
- package/src/providers.js +134 -206
- package/src/runtime.js +6 -0
- package/src/split.js +193 -42
package/src/split.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
protectSensitiveDiff,
|
|
32
32
|
protectSensitiveText,
|
|
33
33
|
isSensitiveFile,
|
|
34
|
+
pathBatches,
|
|
34
35
|
} from './git.js';
|
|
35
36
|
import { statusColor, statusIcon, highlightMessage, vimSelect, vimCheckbox } from './ui.js';
|
|
36
37
|
import {
|
|
@@ -64,6 +65,15 @@ import {
|
|
|
64
65
|
} from './split-checkpoint.js';
|
|
65
66
|
import { fallbackHunkGroups, stripHunkCatalog, validateHunkTransaction } from './split-hunks.js';
|
|
66
67
|
import { runModelTask } from './generation-ui.js';
|
|
68
|
+
import {
|
|
69
|
+
captureChanges,
|
|
70
|
+
needsAnalysis,
|
|
71
|
+
analysisConfig,
|
|
72
|
+
analyzeChanges,
|
|
73
|
+
planAnalyzedChanges,
|
|
74
|
+
summarizeChanges,
|
|
75
|
+
} from './change-analysis.js';
|
|
76
|
+
import { updateGitHash, spoolGit } from './git-spool.js';
|
|
67
77
|
|
|
68
78
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
69
79
|
// Split mode: group changes into multiple logical commits
|
|
@@ -256,10 +266,10 @@ export function getSplitStateFingerprint(projectRoot, head, files, scope = 'all'
|
|
|
256
266
|
files ||= getAllChangedFiles(projectRoot);
|
|
257
267
|
hash.update(readGit(['status', '--porcelain', '-z', '-uall'], projectRoot));
|
|
258
268
|
if (head) {
|
|
259
|
-
hash
|
|
269
|
+
updateGitHash(hash, ['diff', '--binary', '--full-index', 'HEAD'], projectRoot);
|
|
260
270
|
} else {
|
|
261
|
-
hash
|
|
262
|
-
hash
|
|
271
|
+
updateGitHash(hash, ['diff', '--binary', '--full-index', '--cached'], projectRoot);
|
|
272
|
+
updateGitHash(hash, ['diff', '--binary', '--full-index'], projectRoot);
|
|
263
273
|
}
|
|
264
274
|
|
|
265
275
|
const buffer = Buffer.alloc(64 * 1024);
|
|
@@ -665,6 +675,7 @@ export function captureUntrackedSnapshots(
|
|
|
665
675
|
maxPreviewBytes = 2000,
|
|
666
676
|
) {
|
|
667
677
|
const previews = new Map();
|
|
678
|
+
previews.sources = new Map();
|
|
668
679
|
const findings = [];
|
|
669
680
|
const scanBuffer = Buffer.alloc(64 * 1024);
|
|
670
681
|
const overlapBytes = 256;
|
|
@@ -698,6 +709,11 @@ export function captureUntrackedSnapshots(
|
|
|
698
709
|
fd = openSync(fullPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
699
710
|
const opened = fstatSync(fd);
|
|
700
711
|
if (!opened.isFile()) continue;
|
|
712
|
+
const full =
|
|
713
|
+
includePreview && opened.size > maxPreviewBytes
|
|
714
|
+
? { source: spoolGit([], projectRoot), privateKey: false, binary: false }
|
|
715
|
+
: null;
|
|
716
|
+
if (full) previews.sources.set(path, full);
|
|
701
717
|
|
|
702
718
|
const previewParts = [];
|
|
703
719
|
let previewLength = 0;
|
|
@@ -707,6 +723,10 @@ export function captureUntrackedSnapshots(
|
|
|
707
723
|
const count = readSync(fd, scanBuffer, 0, scanBuffer.length, offset);
|
|
708
724
|
if (!count) break;
|
|
709
725
|
const chunk = Buffer.from(scanBuffer.subarray(0, count));
|
|
726
|
+
if (full) {
|
|
727
|
+
full.source.append(chunk);
|
|
728
|
+
if (chunk.includes(0)) full.binary = true;
|
|
729
|
+
}
|
|
710
730
|
|
|
711
731
|
if (includePreview && previewLength < maxPreviewBytes) {
|
|
712
732
|
const take = Math.min(count, maxPreviewBytes - previewLength);
|
|
@@ -715,7 +735,15 @@ export function captureUntrackedSnapshots(
|
|
|
715
735
|
}
|
|
716
736
|
|
|
717
737
|
const scanWindow = overlap.length ? Buffer.concat([overlap, chunk]) : chunk;
|
|
718
|
-
|
|
738
|
+
const detected = protectSensitiveText(scanWindow.toString('utf-8'), path).findings;
|
|
739
|
+
findings.push(...detected);
|
|
740
|
+
if (
|
|
741
|
+
full &&
|
|
742
|
+
detected.some(
|
|
743
|
+
(item) => item.startsWith('private-key') || item.startsWith('sensitive file:'),
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
full.privateKey = true;
|
|
719
747
|
overlap = Buffer.from(scanWindow.subarray(Math.max(0, scanWindow.length - overlapBytes)));
|
|
720
748
|
offset += count;
|
|
721
749
|
}
|
|
@@ -730,6 +758,12 @@ export function captureUntrackedSnapshots(
|
|
|
730
758
|
} catch {
|
|
731
759
|
// The path/name remains in the plan, but unreadable content is neither
|
|
732
760
|
// previewed nor treated as if it had been successfully scanned.
|
|
761
|
+
const full = previews.sources.get(path);
|
|
762
|
+
if (full) {
|
|
763
|
+
full.source.dispose();
|
|
764
|
+
previews.sources.delete(path);
|
|
765
|
+
}
|
|
766
|
+
findings.push(`unreadable file: ${path}`);
|
|
733
767
|
} finally {
|
|
734
768
|
if (fd !== undefined) closeSync(fd);
|
|
735
769
|
}
|
|
@@ -848,11 +882,12 @@ function readStageZeroEntries(projectRoot) {
|
|
|
848
882
|
return parseStageZeroEntries(readGit(['ls-files', '--stage', '-z'], projectRoot));
|
|
849
883
|
}
|
|
850
884
|
|
|
851
|
-
function runGitWithIndex(args, projectRoot, indexPath, inherit = false) {
|
|
885
|
+
function runGitWithIndex(args, projectRoot, indexPath, inherit = false, input = undefined) {
|
|
852
886
|
try {
|
|
853
887
|
return execFileSync('git', args, {
|
|
854
888
|
cwd: projectRoot,
|
|
855
889
|
env: { ...process.env, GIT_INDEX_FILE: indexPath },
|
|
890
|
+
input,
|
|
856
891
|
encoding: 'utf8',
|
|
857
892
|
stdio:
|
|
858
893
|
inherit === 'stderr'
|
|
@@ -900,13 +935,21 @@ function activeCommitHooks(projectRoot) {
|
|
|
900
935
|
|
|
901
936
|
function submodulePaths(projectRoot, paths) {
|
|
902
937
|
const found = new Set();
|
|
903
|
-
const index =
|
|
938
|
+
const index = pathBatches(paths)
|
|
939
|
+
.map((batch) =>
|
|
940
|
+
readGit(['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', ...batch], projectRoot),
|
|
941
|
+
)
|
|
942
|
+
.join('');
|
|
904
943
|
for (const entry of index.split('\0')) {
|
|
905
944
|
const match = entry.match(/^160000 [0-9a-f]+ \d\t([\s\S]+)$/);
|
|
906
945
|
if (match) found.add(match[1]);
|
|
907
946
|
}
|
|
908
947
|
if (hasHead(projectRoot)) {
|
|
909
|
-
const tree =
|
|
948
|
+
const tree = pathBatches(paths)
|
|
949
|
+
.map((batch) =>
|
|
950
|
+
readGit(['--literal-pathspecs', 'ls-tree', '-rz', 'HEAD', '--', ...batch], projectRoot),
|
|
951
|
+
)
|
|
952
|
+
.join('');
|
|
910
953
|
for (const entry of tree.split('\0')) {
|
|
911
954
|
const match = entry.match(/^160000 commit [0-9a-f]+\t([\s\S]+)$/);
|
|
912
955
|
if (match) found.add(match[1]);
|
|
@@ -1011,7 +1054,19 @@ export function preflightSplit(groups, projectRoot, allFiles, scope = 'all') {
|
|
|
1011
1054
|
function resetCommittedPaths(projectRoot, groups, allFiles) {
|
|
1012
1055
|
const paths = [...new Set(groups.flatMap((group) => expandGroupPaths(group, allFiles)))];
|
|
1013
1056
|
if (paths.length && hasHead(projectRoot))
|
|
1014
|
-
runGit(
|
|
1057
|
+
runGit(
|
|
1058
|
+
[
|
|
1059
|
+
'--literal-pathspecs',
|
|
1060
|
+
'reset',
|
|
1061
|
+
'-q',
|
|
1062
|
+
'HEAD',
|
|
1063
|
+
'--pathspec-from-file=-',
|
|
1064
|
+
'--pathspec-file-nul',
|
|
1065
|
+
],
|
|
1066
|
+
projectRoot,
|
|
1067
|
+
false,
|
|
1068
|
+
paths.join('\0') + '\0',
|
|
1069
|
+
);
|
|
1015
1070
|
}
|
|
1016
1071
|
|
|
1017
1072
|
function captureTargetEntries(projectRoot, scope, paths) {
|
|
@@ -1024,7 +1079,13 @@ function captureTargetEntries(projectRoot, scope, paths) {
|
|
|
1024
1079
|
projectRoot,
|
|
1025
1080
|
indexPath,
|
|
1026
1081
|
);
|
|
1027
|
-
runGitWithIndex(
|
|
1082
|
+
runGitWithIndex(
|
|
1083
|
+
['--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'],
|
|
1084
|
+
projectRoot,
|
|
1085
|
+
indexPath,
|
|
1086
|
+
false,
|
|
1087
|
+
paths.join('\0') + '\0',
|
|
1088
|
+
);
|
|
1028
1089
|
return parseStageZeroEntries(
|
|
1029
1090
|
runGitWithIndex(['ls-files', '--stage', '-z'], projectRoot, indexPath),
|
|
1030
1091
|
);
|
|
@@ -1063,7 +1124,11 @@ function currentHeadParent(projectRoot) {
|
|
|
1063
1124
|
function readHeadEntries(projectRoot, paths) {
|
|
1064
1125
|
const entries = new Map();
|
|
1065
1126
|
if (!hasHead(projectRoot) || !paths.length) return entries;
|
|
1066
|
-
const text =
|
|
1127
|
+
const text = pathBatches(paths)
|
|
1128
|
+
.map((batch) =>
|
|
1129
|
+
readGit(['--literal-pathspecs', 'ls-tree', '-rz', 'HEAD', '--', ...batch], projectRoot),
|
|
1130
|
+
)
|
|
1131
|
+
.join('');
|
|
1067
1132
|
for (const field of text.split('\0')) {
|
|
1068
1133
|
if (!field) continue;
|
|
1069
1134
|
const match = field.match(/^(\d+) \S+ ([0-9a-f]+)\t([\s\S]+)$/);
|
|
@@ -1157,22 +1222,20 @@ function executeTransactionalSplit(
|
|
|
1157
1222
|
if (hunkExecution) {
|
|
1158
1223
|
runGitWithIndex(['read-tree', hunkExecution.groupTrees[i]], projectRoot, indexPath);
|
|
1159
1224
|
} else {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1225
|
+
const oidLength = baseTree.length;
|
|
1226
|
+
const entries = paths
|
|
1227
|
+
.map((path) => {
|
|
1228
|
+
const entry = snapshot.get(path);
|
|
1229
|
+
return `${entry ? `${entry.mode} ${entry.oid}` : `0 ${'0'.repeat(oidLength)}`}\t${path}\0`;
|
|
1230
|
+
})
|
|
1231
|
+
.join('');
|
|
1232
|
+
runGitWithIndex(
|
|
1233
|
+
['update-index', '-z', '--index-info'],
|
|
1234
|
+
projectRoot,
|
|
1235
|
+
indexPath,
|
|
1236
|
+
false,
|
|
1237
|
+
entries,
|
|
1238
|
+
);
|
|
1176
1239
|
}
|
|
1177
1240
|
const groupTree = runGitWithIndex(['write-tree'], projectRoot, indexPath).trim();
|
|
1178
1241
|
if (groupTree === baseTree) {
|
|
@@ -1459,7 +1522,18 @@ export async function splitFlow(
|
|
|
1459
1522
|
);
|
|
1460
1523
|
|
|
1461
1524
|
const plannedStateFingerprint = getSplitStateFingerprint(projectRoot, head, allFiles, scope);
|
|
1462
|
-
const
|
|
1525
|
+
const u = unifiedArg(config.diffContextLines);
|
|
1526
|
+
const commands =
|
|
1527
|
+
scope === 'staged'
|
|
1528
|
+
? [['diff', u, '--cached']]
|
|
1529
|
+
: head
|
|
1530
|
+
? [['diff', u, 'HEAD']]
|
|
1531
|
+
: [
|
|
1532
|
+
['diff', u, '--cached'],
|
|
1533
|
+
['diff', u],
|
|
1534
|
+
];
|
|
1535
|
+
const captured = captureChanges(commands, projectRoot, allFiles, config);
|
|
1536
|
+
const diff = captured.diff || '';
|
|
1463
1537
|
const untrackedSnapshot =
|
|
1464
1538
|
scope === 'all'
|
|
1465
1539
|
? captureUntrackedSnapshots(projectRoot, allFiles, config.stripFiles)
|
|
@@ -1482,6 +1556,7 @@ export async function splitFlow(
|
|
|
1482
1556
|
.filter(isSensitiveFile);
|
|
1483
1557
|
const sensitiveFindings = [
|
|
1484
1558
|
...protectedInput.findings,
|
|
1559
|
+
...captured.findings,
|
|
1485
1560
|
...sensitivePaths.map((path) => `sensitive file: ${path}`),
|
|
1486
1561
|
...untrackedSnapshot.findings,
|
|
1487
1562
|
].filter((item, index, all) => all.indexOf(item) === index);
|
|
@@ -1545,6 +1620,29 @@ export async function splitFlow(
|
|
|
1545
1620
|
}
|
|
1546
1621
|
|
|
1547
1622
|
let raw, reasoningText, elapsed, usage;
|
|
1623
|
+
let analysis;
|
|
1624
|
+
const large =
|
|
1625
|
+
needsAnalysis(captured, config, true) ||
|
|
1626
|
+
untrackedSnapshot.previews.sources?.size > 0 ||
|
|
1627
|
+
[...untrackedSnapshot.previews.values()].reduce(
|
|
1628
|
+
(n, text) => n + (text?.length || 0),
|
|
1629
|
+
diff.length,
|
|
1630
|
+
) > (config.splitMaxDiffChars || 16000);
|
|
1631
|
+
if (large) {
|
|
1632
|
+
if (allFiles.some((file) => file.hunks?.length))
|
|
1633
|
+
throw fail(
|
|
1634
|
+
ERROR_CATEGORIES.CONFIG,
|
|
1635
|
+
'Large-change analysis does not support experimental hunk plans; use file-level split.',
|
|
1636
|
+
);
|
|
1637
|
+
planningConfig = analysisConfig(planningConfig);
|
|
1638
|
+
console.log(
|
|
1639
|
+
chalk.dim(
|
|
1640
|
+
planningConfig.largeChange?.strategy === 'deep'
|
|
1641
|
+
? ` Large change: analyzing all ${allFiles.length} files in bounded chunks.`
|
|
1642
|
+
: ` Large change: building local split candidates for ${allFiles.length} files.`,
|
|
1643
|
+
),
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1548
1646
|
try {
|
|
1549
1647
|
({
|
|
1550
1648
|
raw,
|
|
@@ -1557,15 +1655,47 @@ export async function splitFlow(
|
|
|
1557
1655
|
machineOutput,
|
|
1558
1656
|
cancelMessage: 'Split cancelled.',
|
|
1559
1657
|
failureMessage: 'API call failed',
|
|
1560
|
-
task: (stream) =>
|
|
1561
|
-
|
|
1658
|
+
task: async (stream) => {
|
|
1659
|
+
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);
|
|
1669
|
+
if (analysis.coverage.strategy === 'auto')
|
|
1670
|
+
warnings.push(
|
|
1671
|
+
'Split candidates used local metadata and selected excerpts; review the grouping because content was not fully analyzed.',
|
|
1672
|
+
);
|
|
1673
|
+
const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
|
|
1674
|
+
if (plan.some((group) => !groupMessage(group, policy))) {
|
|
1675
|
+
throw fail(
|
|
1676
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
1677
|
+
'Large-change plan contains a commit message that violates commitPolicy. No fallback groups were created.',
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
console.error(
|
|
1681
|
+
` Coverage: ${analysis.coverage.analyzedFiles} files analyzed; ${analysis.coverage.sampledFiles || 0} representative excerpts; ${analysis.coverage.metadataOnlyFiles} metadata only.`,
|
|
1682
|
+
);
|
|
1683
|
+
return {
|
|
1684
|
+
raw: JSON.stringify(plan),
|
|
1685
|
+
elapsed: planningConfig.analysisBudget.snapshot().elapsedMs,
|
|
1686
|
+
usage: planningConfig.analysisBudget.snapshot().usage,
|
|
1687
|
+
reasoning: null,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
return generateSplitPlan(
|
|
1562
1691
|
planningConfig,
|
|
1563
1692
|
allFiles,
|
|
1564
1693
|
planningDiff,
|
|
1565
1694
|
projectRoot,
|
|
1566
1695
|
stream,
|
|
1567
1696
|
untrackedSnapshot.previews,
|
|
1568
|
-
)
|
|
1697
|
+
);
|
|
1698
|
+
},
|
|
1569
1699
|
successMessage(result) {
|
|
1570
1700
|
let done = `Plan generated in ${chalk.bold(formatMs(result.elapsed))}`;
|
|
1571
1701
|
if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
|
|
@@ -1686,17 +1816,22 @@ export async function splitFlow(
|
|
|
1686
1816
|
});
|
|
1687
1817
|
|
|
1688
1818
|
for (const idx of picked) {
|
|
1689
|
-
const rawGroupDiff =
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1819
|
+
const rawGroupDiff = analysis
|
|
1820
|
+
? await summarizeChanges(
|
|
1821
|
+
planningConfig,
|
|
1822
|
+
analysis.facts.filter((fact) => groups[idx].files.includes(fact.path)),
|
|
1823
|
+
)
|
|
1824
|
+
: getGroupDiff(
|
|
1825
|
+
projectRoot,
|
|
1826
|
+
head,
|
|
1827
|
+
scope,
|
|
1828
|
+
groups[idx],
|
|
1829
|
+
allFiles,
|
|
1830
|
+
config.diffContextLines,
|
|
1831
|
+
planningConfig.stripFiles,
|
|
1832
|
+
protectModelInput,
|
|
1833
|
+
untrackedSnapshot.previews,
|
|
1834
|
+
);
|
|
1700
1835
|
const groupDiff = protectModelInput
|
|
1701
1836
|
? protectSensitiveDiff(rawGroupDiff).diff
|
|
1702
1837
|
: rawGroupDiff;
|
|
@@ -1728,7 +1863,7 @@ export async function splitFlow(
|
|
|
1728
1863
|
failureMessage: `Group ${idx + 1} regenerate failed`,
|
|
1729
1864
|
task: (stream) =>
|
|
1730
1865
|
generateCommitMessage(
|
|
1731
|
-
config,
|
|
1866
|
+
analysis ? planningConfig : config,
|
|
1732
1867
|
groupDiff,
|
|
1733
1868
|
regenCounts[idx],
|
|
1734
1869
|
groups[idx].message,
|
|
@@ -1754,6 +1889,10 @@ export async function splitFlow(
|
|
|
1754
1889
|
break; // commit, or finish the dry run
|
|
1755
1890
|
}
|
|
1756
1891
|
|
|
1892
|
+
if (analysis) {
|
|
1893
|
+
usage = planningConfig.analysisBudget.snapshot().usage;
|
|
1894
|
+
elapsed = planningConfig.analysisBudget.snapshot().elapsedMs;
|
|
1895
|
+
}
|
|
1757
1896
|
const reviewedPlan = createSplitPlanArtifact({
|
|
1758
1897
|
scope,
|
|
1759
1898
|
baseHead: head ? readGit(['rev-parse', 'HEAD'], projectRoot).trim() : null,
|
|
@@ -1781,6 +1920,13 @@ export async function splitFlow(
|
|
|
1781
1920
|
usage,
|
|
1782
1921
|
warnings,
|
|
1783
1922
|
exitReason: 'dry_run',
|
|
1923
|
+
...(analysis
|
|
1924
|
+
? {
|
|
1925
|
+
data: {
|
|
1926
|
+
analysis: { ...analysis.coverage, ...planningConfig.analysisBudget.snapshot() },
|
|
1927
|
+
},
|
|
1928
|
+
}
|
|
1929
|
+
: {}),
|
|
1784
1930
|
committed: false,
|
|
1785
1931
|
edited: planEdited,
|
|
1786
1932
|
rewrites: rewriteCount,
|
|
@@ -1821,6 +1967,11 @@ export async function splitFlow(
|
|
|
1821
1967
|
usage,
|
|
1822
1968
|
warnings,
|
|
1823
1969
|
exitReason: 'success',
|
|
1970
|
+
...(analysis
|
|
1971
|
+
? {
|
|
1972
|
+
data: { analysis: { ...analysis.coverage, ...planningConfig.analysisBudget.snapshot() } },
|
|
1973
|
+
}
|
|
1974
|
+
: {}),
|
|
1824
1975
|
committed: true,
|
|
1825
1976
|
edited: planEdited,
|
|
1826
1977
|
rewrites: rewriteCount,
|