@cspeach/cli 0.9.0 → 1.0.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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Deterministic merge of N parallel /abap-cca assessment slices into ONE
3
+ * consolidated cca-assessment (the /abap-cca-merge skill's engine).
4
+ *
5
+ * WHY CODE, NOT PROSE: battery scenario S4 (docs/research/battery/
6
+ * s4-cca-scorecard.md, defects D6/D7) proved the model cannot keep
7
+ * manifest counts consistent with manifest rows when reconciling by
8
+ * hand — `retire: 0` next to 7 retire rows, 27/104 rows silently
9
+ * dropped. This module does the arithmetic; the model presents the
10
+ * result and adds judgment commentary, nothing else.
11
+ *
12
+ * Rules implemented (mirrors .claude/skills/abap-cca-merge/SKILL.md):
13
+ * - identity: same projectId across inputs, or opts.allowCrossProject
14
+ * - atcVariant must match across inputs (hard error — classifications
15
+ * produced under different ATC variants are not comparable)
16
+ * - scope-overlap detection: intersecting scope patterns AND duplicate
17
+ * object names across inputs both produce warnings
18
+ * - per-object union keyed by objectName|objectType (item ids are
19
+ * PER-INPUT local — item-001 exists in every slice — so ids cannot
20
+ * be the union key; merged rows are re-numbered)
21
+ * - conflict rule: most-restrictive wins (retire > redesign > fix >
22
+ * keep), recorded BOTH as a warning AND on the merged row
23
+ * (`conflict: { values: {label: cls}, rule: 'most-restrictive' }`)
24
+ * - counts RECOMPUTED from merged rows — never trusted from inputs
25
+ * - truncation-aware: an input marked classificationsTruncated yields
26
+ * a warning that the merge covers manifest rows only and the detail
27
+ * file must be consulted for full coverage
28
+ */
29
+ import { OUTPUT_ROOT } from './output-paths.js';
30
+ /** Higher = more restrictive. Conflict resolution picks the max. */
31
+ const RESTRICTIVENESS = {
32
+ retire: 3,
33
+ redesign: 2,
34
+ fix: 1,
35
+ keep: 0,
36
+ };
37
+ /**
38
+ * Manifest row-priority sort (matches abap-cca SKILL.md: "redesign first,
39
+ * then fix, then retire, then keep, ordered by confidence DESC within
40
+ * each group"). This is WORK priority, not restrictiveness.
41
+ */
42
+ const ROW_PRIORITY = {
43
+ redesign: 0,
44
+ fix: 1,
45
+ retire: 2,
46
+ keep: 3,
47
+ };
48
+ const CONFIDENCE_RANK = {
49
+ high: 0,
50
+ medium: 1,
51
+ low: 2,
52
+ };
53
+ /* ── scope expression helpers ───────────────────────────────────────────── */
54
+ /**
55
+ * Split a scope expression like "ZFI* + ZSD*" or "ZFI*, ZSD*" into
56
+ * individual patterns. Exported for tests.
57
+ */
58
+ export function parseScopePatterns(scope) {
59
+ return scope
60
+ .split(/[+,]/)
61
+ .map((s) => s.trim())
62
+ .filter((s) => s.length > 0);
63
+ }
64
+ /**
65
+ * Conservative intersection check for the prefix-glob patterns the
66
+ * --scope flag accepts (`ZFI*`, exact names). Two prefix globs intersect
67
+ * when one stem is a prefix of the other (`ZF*` ⊇ `ZFI*`). Patterns we
68
+ * cannot reason about (internal `*`) only match when identical —
69
+ * deliberately under-reports rather than spamming false overlaps.
70
+ * Exported for tests.
71
+ */
72
+ export function scopePatternsIntersect(a, b) {
73
+ const A = a.trim().toUpperCase();
74
+ const B = b.trim().toUpperCase();
75
+ if (A === B)
76
+ return true;
77
+ const aStar = A.endsWith('*') && !A.slice(0, -1).includes('*');
78
+ const bStar = B.endsWith('*') && !B.slice(0, -1).includes('*');
79
+ const aStem = aStar ? A.slice(0, -1) : A;
80
+ const bStem = bStar ? B.slice(0, -1) : B;
81
+ if (A.includes('*') && !aStar)
82
+ return false; // unparseable — identical-only
83
+ if (B.includes('*') && !bStar)
84
+ return false;
85
+ if (aStar && bStar)
86
+ return aStem.startsWith(bStem) || bStem.startsWith(aStem);
87
+ if (aStar)
88
+ return B.startsWith(aStem);
89
+ if (bStar)
90
+ return A.startsWith(bStem);
91
+ return false; // two distinct exact names
92
+ }
93
+ /* ── merge ──────────────────────────────────────────────────────────────── */
94
+ function todayUtc() {
95
+ return new Date().toISOString().slice(0, 10);
96
+ }
97
+ export function mergeCcaAssessments(inputs, opts = {}) {
98
+ if (inputs.length < 2) {
99
+ throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
100
+ }
101
+ const warnings = [];
102
+ const first = inputs[0];
103
+ // ── identity validation ──
104
+ for (const inp of inputs.slice(1)) {
105
+ if (inp.content.projectId !== first.content.projectId && !opts.allowCrossProject) {
106
+ throw new Error(`projectId mismatch: '${inp.label}' is project '${inp.content.projectId}' but '${first.label}' is '${first.content.projectId}'. `
107
+ + `Merging different projects silently corrupts the estate view — pass allowCrossProject only for disjoint-scope slices of the same system.`);
108
+ }
109
+ }
110
+ for (const inp of inputs.slice(1)) {
111
+ if (inp.content.atcVariant !== first.content.atcVariant) {
112
+ throw new Error(`atcVariant mismatch: '${inp.label}' was classified under '${inp.content.atcVariant}' but '${first.label}' under '${first.content.atcVariant}'. `
113
+ + `Classifications from different ATC variants are not comparable — re-run one slice with the matching variant.`);
114
+ }
115
+ }
116
+ // ── scope-overlap detection (warning, not blocker) ──
117
+ for (let i = 0; i < inputs.length; i++) {
118
+ for (let j = i + 1; j < inputs.length; j++) {
119
+ const a = inputs[i];
120
+ const b = inputs[j];
121
+ for (const pa of parseScopePatterns(a.content.scope)) {
122
+ for (const pb of parseScopePatterns(b.content.scope)) {
123
+ if (scopePatternsIntersect(pa, pb)) {
124
+ warnings.push(`SCOPE OVERLAP: '${a.label}' scope '${pa}' intersects '${b.label}' scope '${pb}' — slices were not disjoint; duplicate objects are reconciled most-restrictive.`);
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ // ── truncation awareness ──
131
+ for (const inp of inputs) {
132
+ if (inp.content.classificationsTruncated) {
133
+ const shown = inp.content.classificationsShown ?? inp.content.classifications.length;
134
+ const total = inp.content.classificationsTotal ?? '?';
135
+ warnings.push(`TRUNCATED INPUT: '${inp.label}' manifest carries only ${shown} of ${total} classifications — `
136
+ + `this merge covers the manifest rows only. For full coverage merge from the detail file at ${inp.content.detailPath}.`);
137
+ }
138
+ }
139
+ // ── per-object union (key: objectName|objectType) ──
140
+ const byKey = new Map();
141
+ const keyOrder = [];
142
+ for (const inp of inputs) {
143
+ for (const row of inp.content.classifications) {
144
+ const key = `${row.objectName.toUpperCase()}|${row.objectType.toUpperCase()}`;
145
+ if (!byKey.has(key)) {
146
+ byKey.set(key, []);
147
+ keyOrder.push(key);
148
+ }
149
+ byKey.get(key).push({
150
+ label: inp.label,
151
+ classification: row.classification,
152
+ confidence: row.confidence,
153
+ objectName: row.objectName,
154
+ objectType: row.objectType,
155
+ });
156
+ }
157
+ }
158
+ const mergedRows = [];
159
+ for (const key of keyOrder) {
160
+ const sources = byKey.get(key);
161
+ const base = sources[0];
162
+ const distinct = new Set(sources.map((s) => s.classification));
163
+ if (sources.length > 1 && distinct.size === 1) {
164
+ warnings.push(`DUPLICATE OBJECT: ${base.objectName} (${base.objectType}) appears in ${sources.length} inputs `
165
+ + `(${sources.map((s) => s.label).join(', ')}) with the same classification '${base.classification}' — overlapping slices, merged into one row.`);
166
+ }
167
+ if (distinct.size > 1) {
168
+ // conflict — most-restrictive wins
169
+ const winner = sources.reduce((best, s) => RESTRICTIVENESS[s.classification] > RESTRICTIVENESS[best.classification] ? s : best);
170
+ const values = {};
171
+ for (const s of sources)
172
+ values[s.label] = s.classification;
173
+ warnings.push(`CLASSIFICATION CONFLICT: ${base.objectName} (${base.objectType}) — `
174
+ + sources.map((s) => `${s.label}: ${s.classification} [${s.confidence}]`).join('; ')
175
+ + ` → resolved: ${winner.classification} (most-restrictive rule). Review with the consultants who classified before remediation planning.`);
176
+ mergedRows.push({
177
+ id: '', // re-numbered after sort
178
+ objectName: base.objectName,
179
+ objectType: base.objectType,
180
+ classification: winner.classification,
181
+ confidence: winner.confidence,
182
+ conflict: { values, rule: 'most-restrictive' },
183
+ });
184
+ }
185
+ else {
186
+ mergedRows.push({
187
+ id: '',
188
+ objectName: base.objectName,
189
+ objectType: base.objectType,
190
+ classification: base.classification,
191
+ confidence: base.confidence,
192
+ });
193
+ }
194
+ }
195
+ // ── manifest row-priority sort + re-number ──
196
+ mergedRows.sort((a, b) => {
197
+ const p = ROW_PRIORITY[a.classification] - ROW_PRIORITY[b.classification];
198
+ if (p !== 0)
199
+ return p;
200
+ const c = CONFIDENCE_RANK[a.confidence] - CONFIDENCE_RANK[b.confidence];
201
+ if (c !== 0)
202
+ return c;
203
+ return a.objectName.localeCompare(b.objectName);
204
+ });
205
+ mergedRows.forEach((row, i) => {
206
+ row.id = `item-${String(i + 1).padStart(3, '0')}`;
207
+ });
208
+ // ── counts RECOMPUTED from rows — never trusted from inputs ──
209
+ const tally = { keep: 0, fix: 0, retire: 0, redesign: 0 };
210
+ for (const row of mergedRows)
211
+ tally[row.classification]++;
212
+ // uncategorized objects have no classification rows, so they cannot be
213
+ // recomputed — summed across inputs (slices are disjoint by contract;
214
+ // overlap is already warned about above).
215
+ const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
216
+ const date = opts.mergedDate ?? todayUtc();
217
+ const mergedProjectId = `${first.content.projectId}_merged_${date}`;
218
+ const scopes = [];
219
+ for (const inp of inputs) {
220
+ const s = inp.content.scope.trim();
221
+ if (s.length > 0 && !scopes.includes(s))
222
+ scopes.push(s);
223
+ }
224
+ const merged = {
225
+ detailPath: `${OUTPUT_ROOT}/cca/projects/${mergedProjectId}/project.json`,
226
+ projectId: mergedProjectId,
227
+ scope: scopes.join(', '),
228
+ atcVariant: first.content.atcVariant,
229
+ summary: {
230
+ totalObjects: mergedRows.length + uncategorized,
231
+ keep: tally.keep,
232
+ fix: tally.fix,
233
+ retire: tally.retire,
234
+ redesign: tally.redesign,
235
+ uncategorized,
236
+ },
237
+ classifications: mergedRows,
238
+ };
239
+ return { merged, warnings };
240
+ }
241
+ /* ── manifest rendering ─────────────────────────────────────────────────── */
242
+ /** Manifest table cap — full rows live in the merged detail data. */
243
+ export const CCA_MANIFEST_ROW_CAP = 50;
244
+ function renderConflictCell(conflict) {
245
+ const pairs = Object.entries(conflict.values)
246
+ .map(([label, cls]) => `${label.replace(/[|;:]/g, '_')}:${cls}`)
247
+ .join(';');
248
+ return `conflict=${pairs}`;
249
+ }
250
+ /**
251
+ * Render the EXACT `<!-- csforge:cca-manifest ... -->` block for a merged
252
+ * assessment. The model must paste this VERBATIM — every number is
253
+ * computed here, and extract-cca.ts re-validates counts-vs-rows on save,
254
+ * so a hand-edited block fails loudly instead of saving silently wrong.
255
+ *
256
+ * `omitDetailPath` is for the detail-write-failed path in cca_merge:
257
+ * a manifest must never point at a file that does not exist, so the
258
+ * line is dropped (the extractor then refuses the save loudly).
259
+ */
260
+ export function renderCcaMergeManifest(merged, title, opts = {}) {
261
+ const rows = merged.classifications.slice(0, CCA_MANIFEST_ROW_CAP);
262
+ const truncated = merged.classifications.length > CCA_MANIFEST_ROW_CAP;
263
+ const lines = [
264
+ '<!-- csforge:cca-manifest',
265
+ 'artefact: cca-assessment',
266
+ `title: ${title}`,
267
+ ...(opts.omitDetailPath ? [] : [`detail_path: ${merged.detailPath}`]),
268
+ `project_id: ${merged.projectId}`,
269
+ `scope: ${merged.scope}`,
270
+ `atc_variant: ${merged.atcVariant}`,
271
+ `total_objects: ${merged.summary.totalObjects}`,
272
+ `keep: ${merged.summary.keep}`,
273
+ `fix: ${merged.summary.fix}`,
274
+ `retire: ${merged.summary.retire}`,
275
+ `redesign: ${merged.summary.redesign}`,
276
+ `uncategorized: ${merged.summary.uncategorized}`,
277
+ ];
278
+ if (truncated) {
279
+ lines.push('classifications_truncated: true');
280
+ lines.push(`classifications_shown: ${rows.length}`);
281
+ lines.push(`classifications_total: ${merged.classifications.length}`);
282
+ }
283
+ lines.push('classifications:');
284
+ for (const row of rows) {
285
+ const cells = [row.id, row.objectName, row.objectType, row.classification, row.confidence];
286
+ if (row.conflict)
287
+ cells.push(renderConflictCell(row.conflict));
288
+ lines.push(` ${cells.join(' | ')}`);
289
+ }
290
+ lines.push('-->');
291
+ return lines.join('\n');
292
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Deterministic merge of N parallel /abap-upgrade-fix progress slices
3
+ * into ONE consolidated upgrade-progress (the /abap-upgrade-merge
4
+ * skill's engine).
5
+ *
6
+ * WHY CODE, NOT PROSE: see merge-cca.ts header — battery S4 (defects
7
+ * D6/D7) showed the model cannot keep counts and rows consistent when
8
+ * reconciling envelopes by hand. This module owns the arithmetic.
9
+ *
10
+ * Rules implemented (mirrors .claude/skills/abap-upgrade-merge/SKILL.md):
11
+ * - baseline_sha256 must match across all inputs — HARD ERROR
12
+ * otherwise (progress against different baselines is meaningless)
13
+ * - per-finding status reconciliation, precedence:
14
+ * failed > fixed > skipped > pending
15
+ * - double-fix detection: same finding 'fixed' in 2+ inputs → warning
16
+ * - findings in the baseline untouched by every slice → pending rows
17
+ * - counts RECOMPUTED from merged rows — never trusted from inputs
18
+ */
19
+ import { OUTPUT_ROOT } from './output-paths.js';
20
+ /** Higher = wins the reconciliation. */
21
+ const STATUS_PRECEDENCE = {
22
+ failed: 3,
23
+ fixed: 2,
24
+ skipped: 1,
25
+ pending: 0,
26
+ };
27
+ const VALID_STATUSES = ['fixed', 'skipped', 'failed', 'pending'];
28
+ function todayUtc() {
29
+ return new Date().toISOString().slice(0, 10);
30
+ }
31
+ function asStatus(raw, label, findingId, warnings) {
32
+ if (VALID_STATUSES.includes(raw))
33
+ return raw;
34
+ warnings.push(`UNKNOWN STATUS: '${label}' reports status '${raw}' for ${findingId} — treated as 'pending'.`);
35
+ return 'pending';
36
+ }
37
+ export function mergeUpgradeProgress(inputs, baseline) {
38
+ if (inputs.length < 2) {
39
+ throw new Error(`upgrade merge requires at least 2 inputs (got ${inputs.length}) — a single progress file needs no merge`);
40
+ }
41
+ const warnings = [];
42
+ // ── baseline integrity — HARD ERROR on mismatch ──
43
+ for (const inp of inputs) {
44
+ if (inp.content.baselineRef.sha256 !== baseline.sha256) {
45
+ throw new Error(`baseline mismatch: '${inp.label}' was fixed against baseline sha256 ${inp.content.baselineRef.sha256 || '(empty)'} `
46
+ + `but the merge baseline is ${baseline.sha256}. Progress files from different baselines cannot be merged — `
47
+ + `re-run /abap-upgrade-fix against the current baseline or merge only matching slices.`);
48
+ }
49
+ }
50
+ // All-empty hashes pass the equality check above vacuously — that is
51
+ // NOT verified integrity, so say so out loud.
52
+ if (baseline.sha256.trim().length === 0) {
53
+ warnings.push('NO BASELINE HASH: no input carries a baseline_sha256 — baseline integrity is unverified. '
54
+ + 'The slices may have been fixed against different scans; the sha256 discipline could not be applied.');
55
+ }
56
+ const byId = new Map();
57
+ const idOrder = [];
58
+ for (const inp of inputs) {
59
+ for (const fix of inp.content.fixes) {
60
+ if (!byId.has(fix.id)) {
61
+ byId.set(fix.id, []);
62
+ idOrder.push(fix.id);
63
+ }
64
+ byId.get(fix.id).push({
65
+ label: inp.label,
66
+ status: asStatus(fix.status, inp.label, fix.id, warnings),
67
+ objectName: fix.objectName,
68
+ finding: fix.finding,
69
+ });
70
+ }
71
+ }
72
+ const mergedFixes = [];
73
+ for (const id of idOrder) {
74
+ const sources = byId.get(id);
75
+ const base = sources[0];
76
+ // data-integrity check: same finding id should reference the same object
77
+ const names = new Set(sources.map((s) => s.objectName.toUpperCase()));
78
+ if (names.size > 1) {
79
+ warnings.push(`ID COLLISION: ${id} maps to different objects across inputs (${sources.map((s) => `${s.label}: ${s.objectName}`).join('; ')}) `
80
+ + `— check that all slices came from the same scan. Keeping '${base.objectName}'.`);
81
+ }
82
+ // double-fix detection (warning, not blocker)
83
+ const fixers = sources.filter((s) => s.status === 'fixed');
84
+ if (fixers.length > 1) {
85
+ warnings.push(`DOUBLE-FIX: ${id} — ${base.objectName} reported 'fixed' by ${fixers.length} inputs `
86
+ + `(${fixers.map((s) => s.label).join(', ')}). Two devs likely claimed the same object — `
87
+ + `/abap-upgrade-verify will confirm which fix is active.`);
88
+ }
89
+ const winner = sources.reduce((best, s) => STATUS_PRECEDENCE[s.status] > STATUS_PRECEDENCE[best.status] ? s : best);
90
+ mergedFixes.push({
91
+ id,
92
+ objectName: base.objectName,
93
+ finding: base.finding,
94
+ status: winner.status,
95
+ });
96
+ }
97
+ // ── pending-fill: baseline findings no slice touched ──
98
+ if (baseline.findings) {
99
+ for (const f of baseline.findings) {
100
+ if (!byId.has(f.id)) {
101
+ mergedFixes.push({
102
+ id: f.id,
103
+ objectName: f.objectName,
104
+ finding: f.finding,
105
+ status: 'pending',
106
+ });
107
+ }
108
+ }
109
+ }
110
+ // stable order by finding id (scan order: finding-001, finding-002, ...)
111
+ mergedFixes.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
112
+ // ── counts RECOMPUTED from rows ──
113
+ const tally = { fixed: 0, skipped: 0, failed: 0, pending: 0 };
114
+ for (const fix of mergedFixes)
115
+ tally[fix.status]++;
116
+ // ── transport: keep only when unanimous ──
117
+ const transports = [...new Set(inputs.map((i) => i.content.transport).filter((t) => t !== null))];
118
+ let transport = null;
119
+ if (transports.length === 1) {
120
+ transport = transports[0];
121
+ }
122
+ else if (transports.length > 1) {
123
+ warnings.push(`MULTIPLE TRANSPORTS: slices used different transports (${transports.join(', ')}) — merged progress carries no single transport; release each separately.`);
124
+ }
125
+ const baselinePath = baseline.path ?? inputs[0].content.baselineRef.path;
126
+ const date = baseline.mergedDate ?? todayUtc();
127
+ const stem = (baselinePath.split(/[\\\/]/).pop() ?? 'baseline').replace(/\.json$/i, '');
128
+ const merged = {
129
+ detailPath: `${OUTPUT_ROOT}/upgrades/${stem}_progress_merged_${date}.json`,
130
+ baselineRef: { path: baselinePath, sha256: baseline.sha256 },
131
+ transport,
132
+ summary: {
133
+ fixed: tally.fixed,
134
+ skipped: tally.skipped,
135
+ failed: tally.failed,
136
+ pending: tally.pending,
137
+ },
138
+ fixes: mergedFixes,
139
+ };
140
+ return { merged, warnings };
141
+ }
142
+ /* ── manifest rendering ─────────────────────────────────────────────────── */
143
+ /**
144
+ * Render the EXACT `<!-- csforge:upgrade-manifest ... -->` block for a
145
+ * merged progress. The model must paste this VERBATIM — every number is
146
+ * computed here from the merged rows.
147
+ *
148
+ * `omitDetailPath` is for the detail-write-failed path in
149
+ * upgrade_merge_progress: a manifest must never point at a file that
150
+ * does not exist, so the line is dropped (the extractor then refuses
151
+ * the save loudly).
152
+ */
153
+ export function renderUpgradeMergeManifest(merged, title, opts = {}) {
154
+ const lines = [
155
+ '<!-- csforge:upgrade-manifest',
156
+ 'artefact: upgrade-progress',
157
+ `title: ${title}`,
158
+ ...(opts.omitDetailPath ? [] : [`detail_path: ${merged.detailPath}`]),
159
+ `baseline_path: ${merged.baselineRef.path}`,
160
+ `baseline_sha256: ${merged.baselineRef.sha256}`,
161
+ `transport: ${merged.transport ?? '-'}`,
162
+ `fixed: ${merged.summary.fixed}`,
163
+ `skipped: ${merged.summary.skipped}`,
164
+ `failed: ${merged.summary.failed}`,
165
+ `pending: ${merged.summary.pending}`,
166
+ 'fixes:',
167
+ ];
168
+ for (const fix of merged.fixes) {
169
+ lines.push(` ${[fix.id, fix.objectName, fix.finding, fix.status].join(' | ')}`);
170
+ }
171
+ lines.push('-->');
172
+ return lines.join('\n');
173
+ }
@@ -24,11 +24,12 @@
24
24
  * The prompt is interactive (via the supplied async prompt callback);
25
25
  * one-shot mode skips it entirely (caller passes a no-op).
26
26
  */
27
- import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
27
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
  import os from 'node:os';
30
30
  import { loadConfig, saveConfig } from '../config/loader.js';
31
31
  import { defaultWorkspaceFor } from './workspace.js';
32
+ import { OUTPUT_ROOT, LEGACY_ROOT } from './output-paths.js';
32
33
  /**
33
34
  * Run the migration prompt if and only if it hasn't run before AND
34
35
  * there's actually something to migrate. No-op otherwise. Always sets
@@ -109,3 +110,104 @@ export async function maybePromptWorkspaceMigration(deps) {
109
110
  }
110
111
  await saveConfig({ ...cfg, migration_prompted: true });
111
112
  }
113
+ /**
114
+ * Recursively move every entry under `srcDir` into `dstDir`, MERGING into
115
+ * existing directories rather than clobbering them. Files that already exist
116
+ * at the destination are left untouched (the legacy copy is dropped — the
117
+ * dual-read already prefers the new root, so an existing new file wins).
118
+ * After the walk, the source directory is removed if empty.
119
+ */
120
+ function mergeMoveTree(srcDir, dstDir, log) {
121
+ mkdirSync(dstDir, { recursive: true });
122
+ for (const name of readdirSync(srcDir)) {
123
+ const src = join(srcDir, name);
124
+ const dst = join(dstDir, name);
125
+ const isDir = statSync(src).isDirectory();
126
+ if (isDir) {
127
+ mergeMoveTree(src, dst, log);
128
+ }
129
+ else if (existsSync(dst)) {
130
+ log(`(keeping existing ${name} — already migrated)`);
131
+ }
132
+ else {
133
+ renameSync(src, dst);
134
+ }
135
+ }
136
+ // Remove the source dir if the walk emptied it. A merge that kept some
137
+ // existing-destination files may leave it non-empty — rmdir throws then,
138
+ // which we swallow (the leftover is harmless and visible to the user).
139
+ try {
140
+ rmdirSync(srcDir);
141
+ }
142
+ catch {
143
+ /* non-empty after a partial merge — leave it for the user to inspect */
144
+ }
145
+ }
146
+ /**
147
+ * One-time offer to migrate a workspace's legacy `.abapforge/` output root
148
+ * into the branded `.cspeach/` root.
149
+ *
150
+ * Fires only when a top-level `.abapforge/` directory exists in the given
151
+ * workspace root AND the user has not previously chosen "never". Safe to call
152
+ * every startup: a successful move removes the folder (so it stops naturally),
153
+ * and the dual-read resolver keeps old projects working in the interim if the
154
+ * user declines.
155
+ *
156
+ * Idempotent and non-destructive: the move MERGES into any existing
157
+ * `.cspeach/<domain>` rather than overwriting it.
158
+ */
159
+ export async function maybeOfferAbapforgeMigration(workspaceRoot, deps) {
160
+ const legacyDir = join(workspaceRoot, LEGACY_ROOT);
161
+ if (!existsSync(legacyDir))
162
+ return;
163
+ const cfg = await loadConfig();
164
+ if (cfg.abapforge_migration_declined === true)
165
+ return;
166
+ const newDir = join(workspaceRoot, OUTPUT_ROOT);
167
+ let choice;
168
+ try {
169
+ choice = await deps.prompt({ workspaceRoot, legacyDir, newDir });
170
+ }
171
+ catch {
172
+ // User aborted the prompt (Ctrl+C) — leave everything as-is so the
173
+ // offer can re-fire next session. Never silently commit to a default.
174
+ return;
175
+ }
176
+ if (choice === 'never') {
177
+ await saveConfig({ ...cfg, abapforge_migration_declined: true });
178
+ deps.log(`Leaving ${legacyDir} alone. (Will not ask again.)`);
179
+ return;
180
+ }
181
+ if (choice === 'skip') {
182
+ deps.log(`Leaving ${legacyDir} alone for now.`);
183
+ return;
184
+ }
185
+ // choice === 'move'
186
+ try {
187
+ for (const child of readdirSync(legacyDir)) {
188
+ const src = join(legacyDir, child);
189
+ const dst = join(newDir, child);
190
+ if (statSync(src).isDirectory()) {
191
+ mergeMoveTree(src, dst, deps.log);
192
+ }
193
+ else if (existsSync(dst)) {
194
+ deps.log(`(keeping existing ${child} — already migrated)`);
195
+ }
196
+ else {
197
+ mkdirSync(newDir, { recursive: true });
198
+ renameSync(src, dst);
199
+ }
200
+ }
201
+ // Drop the now-(hopefully)-empty legacy root.
202
+ try {
203
+ rmdirSync(legacyDir);
204
+ }
205
+ catch {
206
+ /* a partial merge left files behind — keep the folder for inspection */
207
+ }
208
+ deps.log(`Moved legacy outputs from ${legacyDir} → ${newDir}`);
209
+ }
210
+ catch (err) {
211
+ deps.log(`Migration failed: ${err.message}. Old folder left untouched.`);
212
+ }
213
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** Branded output root for customer working files (was .abapforge). */
4
+ export const OUTPUT_ROOT = '.cspeach';
5
+ /** Pre-2026-06 root — still READ for back-compat, never written anew. */
6
+ export const LEGACY_ROOT = '.abapforge';
7
+ /** Build `<projectRoot>/.cspeach/<domain>` (domain = cca | upgrades | modernize | tests). */
8
+ export function domainDir(projectRoot, domain) {
9
+ return join(projectRoot, OUTPUT_ROOT, domain);
10
+ }
11
+ /**
12
+ * Resolve a project-relative detail path to an absolute path, preferring the
13
+ * new root and falling back to the legacy `.abapforge/` location when the new
14
+ * one is absent. Returns the NEW absolute path when neither exists (the caller
15
+ * is about to create it). `relPath` may be expressed against either root — we
16
+ * normalise the leading segment before probing.
17
+ */
18
+ export function resolveExistingDomainPath(projectRoot, relPath) {
19
+ const tail = relPath.replace(/^\.(cspeach|abapforge)[/\\]/, '');
20
+ const asNew = join(projectRoot, OUTPUT_ROOT, tail);
21
+ if (existsSync(asNew))
22
+ return asNew;
23
+ const asLegacy = join(projectRoot, LEGACY_ROOT, tail);
24
+ if (existsSync(asLegacy))
25
+ return asLegacy;
26
+ return asNew;
27
+ }