@cspeach/cli 0.9.0 → 1.1.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 +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- 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/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- 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 +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- 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/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- 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-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- 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/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -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 +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -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-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -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/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -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 +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- 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 +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- 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,195 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/handover-md.ts
|
|
2
|
+
//
|
|
3
|
+
// Task 8 (agentic-flow, 2026-07-03) — markdown handover projection.
|
|
4
|
+
//
|
|
5
|
+
// Design rule (docs/strategy/2026-07-02-agentic-flow-design.md, Decision 5):
|
|
6
|
+
// the .cspeach.json envelope is the SINGLE machine source of truth; this
|
|
7
|
+
// markdown file is a WRITE-ONLY, deterministically regenerable projection.
|
|
8
|
+
// Nothing ever reads it back as state — enforced by design: no function in
|
|
9
|
+
// this module accepts markdown. One `<planBase>-handover.md` per plan family
|
|
10
|
+
// (the version suffix is stripped), overwritten on every envelope save.
|
|
11
|
+
//
|
|
12
|
+
// renderPlanHandover is PURE and DETERMINISTIC: no Date.now(), no randomness —
|
|
13
|
+
// every value derives from envelope fields, so the same envelope always
|
|
14
|
+
// renders byte-identical output (regenerability is the whole contract).
|
|
15
|
+
import { writeFileSync } from 'node:fs';
|
|
16
|
+
import { basename, dirname, join } from 'node:path';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { statusesFromItems, computeNextPhase, isPhaseSatisfied, planCompletionLines, } from './plan-run.js';
|
|
19
|
+
import { projectFilename } from './filename.js';
|
|
20
|
+
import { readProjectFile } from './status.js';
|
|
21
|
+
/**
|
|
22
|
+
* Plain-text mirror of the tracker's auditGlyph wording (plan-run.ts) — same
|
|
23
|
+
* words, no chalk. Empty-audit phases render NO segment, matching the
|
|
24
|
+
* tracker's legacy-envelope behaviour. The `default` arm is deliberate
|
|
25
|
+
* forward-compat: an unknown future PlanAuditState reads "audit not run"
|
|
26
|
+
* instead of crashing the projection.
|
|
27
|
+
*/
|
|
28
|
+
function auditWords(audit) {
|
|
29
|
+
if (!audit)
|
|
30
|
+
return null;
|
|
31
|
+
switch (audit.state) {
|
|
32
|
+
case 'passed': return '✔ audited';
|
|
33
|
+
case 'warn': {
|
|
34
|
+
// Non-blocking tier — passed WITH notes. Plain-text mirror of the
|
|
35
|
+
// tracker's warn glyph; count comes from findings.
|
|
36
|
+
const n = audit.findings?.length ?? 0;
|
|
37
|
+
return `⚠ passed with ${n} note${n === 1 ? '' : 's'}`;
|
|
38
|
+
}
|
|
39
|
+
case 'waived': return `✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
|
|
40
|
+
case 'failed': return '✖ audit failed';
|
|
41
|
+
case 'not_audited_hand_edited': return '✋ hand-edited';
|
|
42
|
+
case 'pending':
|
|
43
|
+
case 'infra_failed':
|
|
44
|
+
default:
|
|
45
|
+
return '⚠ audit not run';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Minimal YAML scalar emitter (no library — the frontmatter fields are all
|
|
50
|
+
* scalars / string arrays). Simple token values stay bare; anything else is
|
|
51
|
+
* emitted as a JSON string, which is valid YAML 1.2 double-quoted syntax.
|
|
52
|
+
*/
|
|
53
|
+
function yamlScalar(v) {
|
|
54
|
+
// Bare (plain) scalar only for the safe token alphabet: starts with a
|
|
55
|
+
// non-indicator char (`@` is a YAML reserved indicator and `-` opens a
|
|
56
|
+
// block-sequence entry — both are quoted when leading, but stay fine
|
|
57
|
+
// mid-string), no `: ` (mapping ambiguity), no ` #` (comment), no
|
|
58
|
+
// trailing space. Everything else goes JSON-quoted.
|
|
59
|
+
const plainSafe = /^[A-Za-z0-9/_.][A-Za-z0-9@/_.\- ]*$/.test(v)
|
|
60
|
+
&& !v.includes(': ') && !v.includes(' #') && !v.endsWith(' ');
|
|
61
|
+
return plainSafe ? v : JSON.stringify(v);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Audit off-switch (2026-07-06, owner) — derive `audits: on | off | mixed` for
|
|
65
|
+
* the frontmatter PURELY from phase audit-field presence, no new state:
|
|
66
|
+
* among the SATISFIED phases (the ones that actually ran to completion), an ON
|
|
67
|
+
* run leaves every one carrying an audit field (passed/waived), an OFF run
|
|
68
|
+
* leaves none. Mixed = both present (a plan run partly on, partly off).
|
|
69
|
+
* Nothing satisfied yet ⇒ 'on' (the config default; nothing has run off).
|
|
70
|
+
* Caveat: a pre-auditor legacy plan (satisfied phases, no audit fields) derives
|
|
71
|
+
* 'off' — technically "audits never existed" rather than "turned off", but the
|
|
72
|
+
* field-presence signal is honest and needs no schema addition.
|
|
73
|
+
*/
|
|
74
|
+
function deriveAudits(phases, statuses) {
|
|
75
|
+
const satisfied = phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit));
|
|
76
|
+
if (satisfied.length === 0)
|
|
77
|
+
return 'on';
|
|
78
|
+
const withAudit = satisfied.filter((p) => p.audit !== undefined).length;
|
|
79
|
+
if (withAudit === 0)
|
|
80
|
+
return 'off';
|
|
81
|
+
if (withAudit === satisfied.length)
|
|
82
|
+
return 'on';
|
|
83
|
+
return 'mixed';
|
|
84
|
+
}
|
|
85
|
+
function progressLine(p, status) {
|
|
86
|
+
const segments = [
|
|
87
|
+
p.id,
|
|
88
|
+
p.title?.trim() || null,
|
|
89
|
+
status,
|
|
90
|
+
auditWords(p.audit),
|
|
91
|
+
p.work?.transport ? `transport ${p.work.transport}` : null,
|
|
92
|
+
].filter((s) => s !== null && s.length > 0);
|
|
93
|
+
return `- ${segments.join(' — ')}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Render the handover markdown for a plan envelope. Pure + deterministic —
|
|
97
|
+
* see the module header. Throws on non-plan envelopes (structural misuse);
|
|
98
|
+
* the write helper below catches everything.
|
|
99
|
+
*/
|
|
100
|
+
export function renderPlanHandover(envelope) {
|
|
101
|
+
if (envelope.artefactType !== 'plan') {
|
|
102
|
+
throw new Error(`renderPlanHandover expects a plan envelope, got ${envelope.artefactType}`);
|
|
103
|
+
}
|
|
104
|
+
const content = envelope.content;
|
|
105
|
+
const statuses = statusesFromItems(envelope.interaction.items, content.phases);
|
|
106
|
+
// Derived status — same predicates as the DAG (never reimplemented here):
|
|
107
|
+
// all satisfied → complete; any blocked → blocked; else in-progress.
|
|
108
|
+
const allSatisfied = content.phases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit));
|
|
109
|
+
const anyBlocked = content.phases.some((p) => statuses[p.id] === 'blocked');
|
|
110
|
+
const status = allSatisfied ? 'complete' : anyBlocked ? 'blocked' : 'in-progress';
|
|
111
|
+
const next = computeNextPhase(content.phases, statuses);
|
|
112
|
+
// 'done' only when genuinely complete; a deadlocked plan (no eligible phase,
|
|
113
|
+
// not all satisfied) says 'none' — 'done' would misreport a blocked plan.
|
|
114
|
+
const phaseField = next ? next.id : allSatisfied ? 'done' : 'none';
|
|
115
|
+
// The resume command uses the canonical filename reconstructed from envelope
|
|
116
|
+
// fields (deterministic). A rare on-disk `-N` collision sibling still
|
|
117
|
+
// resolves — version-less/newest-version resolution in plan-resume.ts
|
|
118
|
+
// matches the whole family.
|
|
119
|
+
const nextAction = allSatisfied
|
|
120
|
+
? planCompletionLines(content.phases).join(' ')
|
|
121
|
+
: `/abap-plan --resume @${projectFilename({
|
|
122
|
+
title: envelope.title, artefactType: 'plan', id: envelope.id, version: envelope.version,
|
|
123
|
+
})}`;
|
|
124
|
+
const artifacts = content.phases.flatMap((p) => p.work?.generated ?? []);
|
|
125
|
+
const audits = deriveAudits(content.phases, statuses);
|
|
126
|
+
const lines = [
|
|
127
|
+
'---',
|
|
128
|
+
`status: ${status}`,
|
|
129
|
+
`audits: ${audits}`,
|
|
130
|
+
`phase: ${yamlScalar(phaseField)}`,
|
|
131
|
+
`next-action: ${yamlScalar(nextAction)}`,
|
|
132
|
+
`artifacts: [${artifacts.map(yamlScalar).join(', ')}]`,
|
|
133
|
+
`envelope-version: ${envelope.version}`,
|
|
134
|
+
'---',
|
|
135
|
+
`# ${envelope.title} — plan handover`,
|
|
136
|
+
'',
|
|
137
|
+
'## Progress',
|
|
138
|
+
'',
|
|
139
|
+
...content.phases.map((p) => progressLine(p, statuses[p.id] ?? 'todo')),
|
|
140
|
+
'',
|
|
141
|
+
'## Last stop',
|
|
142
|
+
'',
|
|
143
|
+
];
|
|
144
|
+
const stop = content.lastStop;
|
|
145
|
+
lines.push(stop
|
|
146
|
+
? `${stop.phaseId} — ${stop.reason}${stop.detail ? ` — ${stop.detail}` : ''} (${stop.at})`
|
|
147
|
+
: '(none)');
|
|
148
|
+
lines.push('', '## Decisions & notes', '');
|
|
149
|
+
const noted = content.phases.filter((p) => (p.work?.notes ?? '').trim().length > 0);
|
|
150
|
+
if (noted.length === 0) {
|
|
151
|
+
lines.push('(none)');
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
for (const p of noted) {
|
|
155
|
+
lines.push(`### ${p.id}`, '', p.work.notes.trim(), '');
|
|
156
|
+
}
|
|
157
|
+
lines.pop(); // drop the trailing blank inside the section
|
|
158
|
+
}
|
|
159
|
+
return lines.join('\n') + '\n';
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The projection's path for an envelope path: version suffix (`-vN` and any
|
|
163
|
+
* `-vN-M` collision sub-suffix from saveProject) stripped, so ONE handover
|
|
164
|
+
* file per plan family is overwritten across versions —
|
|
165
|
+
* `hr-extract-plan-ab12-v3.cspeach.json` → `hr-extract-plan-ab12-handover.md`.
|
|
166
|
+
*/
|
|
167
|
+
export function handoverPathFor(envelopePath) {
|
|
168
|
+
const name = basename(envelopePath);
|
|
169
|
+
const base = name.replace(/(?:-v\d+(?:-\d+)?)?\.cspeach\.json$/, '');
|
|
170
|
+
return join(dirname(envelopePath), `${base}-handover.md`);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Regenerate `<planBase>-handover.md` beside a just-saved plan envelope.
|
|
174
|
+
* Reads the ENVELOPE back from `savedPath` (reading the envelope is fine —
|
|
175
|
+
* it is the source of truth; the .md is never read). Best-effort by design:
|
|
176
|
+
* any failure logs one dim line and returns null — projection loss must
|
|
177
|
+
* never break the run. Returns the written path on success; null for
|
|
178
|
+
* non-plan envelopes and on any failure.
|
|
179
|
+
*/
|
|
180
|
+
export function writePlanHandover(savedPath, log) {
|
|
181
|
+
const logFn = log ?? ((...lines) => { for (const l of lines)
|
|
182
|
+
console.log(l); });
|
|
183
|
+
try {
|
|
184
|
+
const envelope = readProjectFile(savedPath);
|
|
185
|
+
if (envelope.artefactType !== 'plan')
|
|
186
|
+
return null;
|
|
187
|
+
const target = handoverPathFor(savedPath);
|
|
188
|
+
writeFileSync(target, renderPlanHandover(envelope), 'utf8');
|
|
189
|
+
return target;
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
logFn(chalk.dim(`[plan] handover markdown not regenerated (${e instanceof Error ? e.message : String(e)}) — the envelope remains the source of truth.`));
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/projects/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export { planContentSchema, parsePlanContent, PLAN_LAYERS, PLAN_DELEGATE_SKILLS, PLAN_PHASE_STATUSES } from './plan-schema.js';
|
|
2
2
|
export { extractPlan } from './extract-plan.js';
|
|
3
|
-
export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision } from './plan-run.js';
|
|
3
|
+
export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, isPhaseSatisfied, isAuditResolved } from './plan-run.js';
|
|
4
4
|
export { validateEnvelope } from './validate.js';
|
|
5
5
|
export { canonicalSha256 } from './canonicalize.js';
|
|
6
6
|
export { titleSlug, shortId, projectFilename } from './filename.js';
|
|
7
|
-
export { runSaveCommand } from './save-command.js';
|
|
7
|
+
export { runSaveCommand, detectArtifactSkill, peekPlanManifestTitle, resolvePriorPlanEnvelope } from './save-command.js';
|
|
8
|
+
export { offerAnswerBlockers, openBlockers, applyBlockerAnswers } from './answer-blockers.js';
|
|
9
|
+
export { mergeCcaAssessments, renderCcaMergeManifest, parseScopePatterns, scopePatternsIntersect, CCA_MANIFEST_ROW_CAP } from './merge-cca.js';
|
|
10
|
+
export { mergeUpgradeProgress, renderUpgradeMergeManifest } from './merge-upgrade.js';
|
|
8
11
|
export { extractDesign } from './extract-design.js';
|
|
9
12
|
export { extractEstimate } from './extract-estimate.js';
|
|
10
13
|
export { buildPromotedFromSnapshot, validatePromotionSource } from './promote.js';
|
|
@@ -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
|
+
}
|