@cspeach/cli 0.8.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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +25 -19
- package/dist/repl.js +470 -22
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- 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
|
+
}
|