@cspeach/cli 1.1.26 → 1.1.28
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/dist/agent/loop.js +41 -5
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/commands/whoami.js +1 -1
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +4 -6
- package/dist/lane/app-chain.js +116 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/extract-spec-gap.js +28 -5
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +15 -14
- package/dist/projects/saved-lines.js +36 -0
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/thinking-heartbeat.js +2 -1
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/sap/unreachable.js +4 -1
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +3 -4
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
package/dist/tools/sap-write.js
CHANGED
|
@@ -25,6 +25,7 @@ import crypto from 'node:crypto';
|
|
|
25
25
|
import { readFileSync } from 'node:fs';
|
|
26
26
|
import { registerTool } from './index.js';
|
|
27
27
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
28
|
+
import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
|
|
28
29
|
import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
|
|
29
30
|
import { includeSnapshotType } from './include-snapshot.js';
|
|
30
31
|
import { buildWriteChainLine } from '../renderer/verify-chain.js';
|
|
@@ -177,6 +178,12 @@ registerTool({
|
|
|
177
178
|
is_error: true,
|
|
178
179
|
};
|
|
179
180
|
}
|
|
181
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
182
|
+
{
|
|
183
|
+
const claim = grantClaimMismatch(verify.payload, { type: args.type, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
184
|
+
if (claim)
|
|
185
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
186
|
+
}
|
|
180
187
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
181
188
|
const startedAt = Date.now();
|
|
182
189
|
// ── Rule 9 conflict warning ──────────────────────────────────────────────
|
|
@@ -193,6 +200,13 @@ registerTool({
|
|
|
193
200
|
// If the object is already locked in an open request, write under THAT
|
|
194
201
|
// request — any other transport is a guaranteed CTS lock conflict.
|
|
195
202
|
const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
|
|
203
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
204
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
205
|
+
return {
|
|
206
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.name, resolved.owning) }),
|
|
207
|
+
is_error: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
196
210
|
// ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
|
|
197
211
|
// Only a genuine 404 (new object) may proceed snapshot-less. A read
|
|
198
212
|
// failure on a possibly-EXISTING object means no recovery copy → block.
|
|
@@ -352,6 +366,12 @@ registerTool({
|
|
|
352
366
|
is_error: true,
|
|
353
367
|
};
|
|
354
368
|
}
|
|
369
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
370
|
+
{
|
|
371
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
372
|
+
if (claim)
|
|
373
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
374
|
+
}
|
|
355
375
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
356
376
|
const startedAt = Date.now();
|
|
357
377
|
// ── Rule 9 conflict warning ──────────────────────────────────────────────
|
|
@@ -366,6 +386,13 @@ registerTool({
|
|
|
366
386
|
}
|
|
367
387
|
// ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
|
|
368
388
|
const resolved = await resolveWriteTransport(ctx, 'CLAS', args.class_name, args.transport);
|
|
389
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
390
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
391
|
+
return {
|
|
392
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.class_name, resolved.owning) }),
|
|
393
|
+
is_error: true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
369
396
|
// ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
|
|
370
397
|
// sap_update_method always targets an EXISTING class, so a read failure
|
|
371
398
|
// (non-404) means no recovery copy → block. (A genuine 404 falls through
|
|
@@ -674,6 +701,12 @@ registerTool({
|
|
|
674
701
|
is_error: true,
|
|
675
702
|
};
|
|
676
703
|
}
|
|
704
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
705
|
+
{
|
|
706
|
+
const claim = grantClaimMismatch(verify.payload, { type: args.type, package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
707
|
+
if (claim)
|
|
708
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
709
|
+
}
|
|
677
710
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
678
711
|
const startedAt = Date.now();
|
|
679
712
|
// No snapshot — new object has no prior source.
|
|
@@ -780,6 +813,12 @@ registerTool({
|
|
|
780
813
|
is_error: true,
|
|
781
814
|
};
|
|
782
815
|
}
|
|
816
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
817
|
+
{
|
|
818
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
819
|
+
if (claim)
|
|
820
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
821
|
+
}
|
|
783
822
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
784
823
|
const startedAt = Date.now();
|
|
785
824
|
// ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
|
|
@@ -794,6 +833,13 @@ registerTool({
|
|
|
794
833
|
// The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
|
|
795
834
|
// locked in a request from earlier work — battery S1's 409 root cause.
|
|
796
835
|
const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
|
|
836
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
837
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
838
|
+
return {
|
|
839
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.className, resolved.owning) }),
|
|
840
|
+
is_error: true,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
797
843
|
// ── Gate 2: Snapshot (Rule 7) — of the INCLUDE being overwritten ─────────
|
|
798
844
|
// Not the class main source: for a behaviour pool that is an empty shell and
|
|
799
845
|
// the change lives in CCIMP (2026-09-21 — the old main-source snapshot left
|
|
@@ -928,6 +974,12 @@ registerTool({
|
|
|
928
974
|
is_error: true,
|
|
929
975
|
};
|
|
930
976
|
}
|
|
977
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
978
|
+
{
|
|
979
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'DOMA', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
980
|
+
if (claim)
|
|
981
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
982
|
+
}
|
|
931
983
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
932
984
|
const startedAt = Date.now();
|
|
933
985
|
// No snapshot — new object has no prior source.
|
|
@@ -1061,6 +1113,12 @@ registerTool({
|
|
|
1061
1113
|
is_error: true,
|
|
1062
1114
|
};
|
|
1063
1115
|
}
|
|
1116
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
|
|
1117
|
+
{
|
|
1118
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'DTEL', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
1119
|
+
if (claim)
|
|
1120
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1121
|
+
}
|
|
1064
1122
|
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
1065
1123
|
const startedAt = Date.now();
|
|
1066
1124
|
// No snapshot — new object has no prior source.
|
|
@@ -1188,6 +1246,13 @@ registerTool({
|
|
|
1188
1246
|
is_error: true,
|
|
1189
1247
|
};
|
|
1190
1248
|
}
|
|
1249
|
+
// 2026-10-04 — a plan-grant id activates only the objects it names (every
|
|
1250
|
+
// member, not just the first), with the listed type for a single object.
|
|
1251
|
+
{
|
|
1252
|
+
const claim = grantActivateMismatch(verify.payload, args.objects);
|
|
1253
|
+
if (claim)
|
|
1254
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
|
|
1255
|
+
}
|
|
1191
1256
|
// Round 7 item 8e — an approval auto_approve minted (no human saw it)
|
|
1192
1257
|
// never activates an object whose last syntax check found errors. Any
|
|
1193
1258
|
// approval minted after the errors went through the card (risk floor).
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Program text pool — pure logic for selection texts, text symbols and list
|
|
3
|
+
* headings. No SAP, no fs.
|
|
4
|
+
*
|
|
5
|
+
* ADT serves each part as a plain-text source (sap-client
|
|
6
|
+
* getTextElementsSource) and a PUT replaces the WHOLE part. So a write is
|
|
7
|
+
* always read → merge → write: the texts already there are kept byte for
|
|
8
|
+
* byte, only the named entries are added or changed. Never a blind replace.
|
|
9
|
+
*
|
|
10
|
+
* Source formats (S4H read-only capture 2026-10-04 + SAP_BASIS 754 captures):
|
|
11
|
+
* selections `P_MAXROW=Maximum Rows`, blank line between entries; SAP pads
|
|
12
|
+
* the name to 8 chars on read (`S_BUDAT =Posting Date`). A
|
|
13
|
+
* dictionary-referenced text carries a bare `@DDICReference`
|
|
14
|
+
* line right above it. Some releases list screen fields with no
|
|
15
|
+
* text as `NAME=?...` (754); S4H leaves them out.
|
|
16
|
+
* symbols `@MaxLength:N` then `001=Text`, blank line between entries.
|
|
17
|
+
* headings `listHeader=…`, blank line, `columnHeader_1..4=…` — always
|
|
18
|
+
* all five lines.
|
|
19
|
+
*/
|
|
20
|
+
export const TEXT_ELEMENT_KINDS = ['selections', 'symbols', 'headings'];
|
|
21
|
+
export const SELECTION_TEXT_MAX = 30;
|
|
22
|
+
export const SYMBOL_TEXT_MAX = 132;
|
|
23
|
+
export const LIST_HEADER_MAX = 70;
|
|
24
|
+
export const COLUMN_HEADER_MAX = 132;
|
|
25
|
+
/** SAP's marker for a selection-screen field that has no text yet. */
|
|
26
|
+
export const UNTEXTED_MARKER = '?...';
|
|
27
|
+
const HEADING_KEYS = ['listHeader', 'columnHeader_1', 'columnHeader_2', 'columnHeader_3', 'columnHeader_4'];
|
|
28
|
+
export function isTextElementKind(k) {
|
|
29
|
+
return typeof k === 'string' && TEXT_ELEMENT_KINDS.includes(k);
|
|
30
|
+
}
|
|
31
|
+
/** Canonical key: selections/symbols upper-cased, headings in SAP's camelCase. */
|
|
32
|
+
export function canonicalKey(kind, key) {
|
|
33
|
+
const k = key.trim();
|
|
34
|
+
if (kind !== 'headings')
|
|
35
|
+
return k.toUpperCase();
|
|
36
|
+
const hit = HEADING_KEYS.find((h) => h.toLowerCase() === k.toLowerCase());
|
|
37
|
+
return hit ?? k;
|
|
38
|
+
}
|
|
39
|
+
export function parseTextPool(kind, body) {
|
|
40
|
+
const out = [];
|
|
41
|
+
let pendingMax;
|
|
42
|
+
let pendingAnn = [];
|
|
43
|
+
for (const raw of body.split(/\r\n|\r|\n/)) {
|
|
44
|
+
if (raw.trim() === '')
|
|
45
|
+
continue;
|
|
46
|
+
if (raw.startsWith('@')) {
|
|
47
|
+
const m = /^@MaxLength:\s*(\d+)\s*$/i.exec(raw);
|
|
48
|
+
if (m && kind === 'symbols')
|
|
49
|
+
pendingMax = parseInt(m[1], 10);
|
|
50
|
+
else
|
|
51
|
+
pendingAnn.push(raw);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const eq = raw.indexOf('=');
|
|
55
|
+
if (eq < 0)
|
|
56
|
+
continue;
|
|
57
|
+
const key = canonicalKey(kind, raw.slice(0, eq));
|
|
58
|
+
const text = raw.slice(eq + 1);
|
|
59
|
+
const entry = { key, text };
|
|
60
|
+
if (kind === 'symbols' && pendingMax !== undefined)
|
|
61
|
+
entry.maxLength = pendingMax;
|
|
62
|
+
if (kind === 'selections' && text === UNTEXTED_MARKER) {
|
|
63
|
+
entry.text = '';
|
|
64
|
+
entry.untexted = true;
|
|
65
|
+
}
|
|
66
|
+
if (pendingAnn.length > 0)
|
|
67
|
+
entry.annotations = pendingAnn;
|
|
68
|
+
out.push(entry);
|
|
69
|
+
pendingMax = undefined;
|
|
70
|
+
pendingAnn = [];
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export function formatTextPool(kind, entries) {
|
|
75
|
+
if (kind === 'headings') {
|
|
76
|
+
const byKey = new Map(entries.map((e) => [canonicalKey('headings', e.key), e.text]));
|
|
77
|
+
const v = (k) => byKey.get(k) ?? '';
|
|
78
|
+
return `listHeader=${v('listHeader')}\n\n`
|
|
79
|
+
+ HEADING_KEYS.slice(1).map((k) => `${k}=${v(k)}\n`).join('');
|
|
80
|
+
}
|
|
81
|
+
if (kind === 'symbols') {
|
|
82
|
+
if (entries.length === 0)
|
|
83
|
+
return '';
|
|
84
|
+
return entries
|
|
85
|
+
.map((e) => {
|
|
86
|
+
const max = Math.min(Math.max(e.maxLength ?? 0, e.text.length, 1), SYMBOL_TEXT_MAX);
|
|
87
|
+
return [`@MaxLength:${max}`, ...(e.annotations ?? []), `${e.key}=${e.text}`].join('\n');
|
|
88
|
+
})
|
|
89
|
+
.join('\n\n') + '\n';
|
|
90
|
+
}
|
|
91
|
+
// selections — fields without a text are SAP's placeholder, not a text: never written back.
|
|
92
|
+
// Blank line between entries, `@DDICReference` right above its entry: the shape S4H serves.
|
|
93
|
+
const texted = entries.filter((e) => !e.untexted && e.text !== '');
|
|
94
|
+
if (texted.length === 0)
|
|
95
|
+
return '';
|
|
96
|
+
return texted
|
|
97
|
+
.map((e) => [...(e.annotations ?? []), `${e.key}=${e.text}`].join('\n'))
|
|
98
|
+
.join('\n\n') + '\n';
|
|
99
|
+
}
|
|
100
|
+
export function validateUpdates(kind, updates) {
|
|
101
|
+
const errs = [];
|
|
102
|
+
if (!Array.isArray(updates) || updates.length === 0)
|
|
103
|
+
return ['texts must name at least one entry'];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const u of updates) {
|
|
106
|
+
if (typeof u?.key !== 'string' || typeof u?.text !== 'string') {
|
|
107
|
+
errs.push('every entry needs a key and a text (strings)');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const key = canonicalKey(kind, u.key);
|
|
111
|
+
const text = u.text;
|
|
112
|
+
if (seen.has(key))
|
|
113
|
+
errs.push(`${key}: named twice`);
|
|
114
|
+
seen.add(key);
|
|
115
|
+
if (/[\r\n]/.test(text))
|
|
116
|
+
errs.push(`${key}: a text must be one line`);
|
|
117
|
+
if (kind === 'selections') {
|
|
118
|
+
if (!/^[A-Z][A-Z0-9_]{0,7}$/.test(key))
|
|
119
|
+
errs.push(`${key}: not a selection-screen name (1-8 letters, digits, _)`);
|
|
120
|
+
if (text.trim() === '')
|
|
121
|
+
errs.push(`${key}: text is empty`);
|
|
122
|
+
if (text.length > SELECTION_TEXT_MAX)
|
|
123
|
+
errs.push(`${key}: selection text is ${text.length} chars — the limit is ${SELECTION_TEXT_MAX}`);
|
|
124
|
+
}
|
|
125
|
+
else if (kind === 'symbols') {
|
|
126
|
+
if (!/^[A-Z0-9]{3}$/.test(key))
|
|
127
|
+
errs.push(`${key}: a text symbol id is 3 letters/digits (TEXT-001 → 001)`);
|
|
128
|
+
if (text.trim() === '')
|
|
129
|
+
errs.push(`${key}: text is empty`);
|
|
130
|
+
if (text.length > SYMBOL_TEXT_MAX)
|
|
131
|
+
errs.push(`${key}: text symbol is ${text.length} chars — the limit is ${SYMBOL_TEXT_MAX}`);
|
|
132
|
+
if (u.max_length !== undefined) {
|
|
133
|
+
if (!Number.isInteger(u.max_length) || u.max_length < text.length || u.max_length > SYMBOL_TEXT_MAX) {
|
|
134
|
+
errs.push(`${key}: max_length must be a whole number from the text length (${text.length}) to ${SYMBOL_TEXT_MAX}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
if (!HEADING_KEYS.includes(key))
|
|
140
|
+
errs.push(`${key}: headings are ${HEADING_KEYS.join(', ')}`);
|
|
141
|
+
const limit = key === 'listHeader' ? LIST_HEADER_MAX : COLUMN_HEADER_MAX;
|
|
142
|
+
if (text.length > limit)
|
|
143
|
+
errs.push(`${key}: heading is ${text.length} chars — the limit is ${limit}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return errs;
|
|
147
|
+
}
|
|
148
|
+
/** Merge updates into the existing entries. Validate first (validateUpdates). */
|
|
149
|
+
export function mergeTextPool(kind, existing, updates) {
|
|
150
|
+
const entries = existing.map((e) => ({ ...e, ...(e.annotations ? { annotations: [...e.annotations] } : {}) }));
|
|
151
|
+
const index = new Map(entries.map((e, i) => [e.key, i]));
|
|
152
|
+
const r = { entries, added: [], changed: [], unchanged: [], kept: [], dictionaryReferenceDropped: [] };
|
|
153
|
+
const touched = new Set();
|
|
154
|
+
for (const u of updates) {
|
|
155
|
+
const key = canonicalKey(kind, u.key);
|
|
156
|
+
touched.add(key);
|
|
157
|
+
const at = index.get(key);
|
|
158
|
+
if (at === undefined) {
|
|
159
|
+
const e = { key, text: u.text };
|
|
160
|
+
if (kind === 'symbols')
|
|
161
|
+
e.maxLength = u.max_length ?? u.text.length;
|
|
162
|
+
entries.push(e);
|
|
163
|
+
index.set(key, entries.length - 1);
|
|
164
|
+
if (kind !== 'headings' || u.text !== '')
|
|
165
|
+
r.added.push(key);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const cur = entries[at];
|
|
169
|
+
const newMax = kind === 'symbols' ? (u.max_length ?? Math.max(cur.maxLength ?? 0, u.text.length)) : undefined;
|
|
170
|
+
const sameText = !cur.untexted && cur.text === u.text;
|
|
171
|
+
if (sameText && (kind !== 'symbols' || newMax === cur.maxLength)) {
|
|
172
|
+
r.unchanged.push(key);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (cur.untexted || (kind === 'headings' && cur.text === ''))
|
|
176
|
+
r.added.push(key);
|
|
177
|
+
else
|
|
178
|
+
r.changed.push(key);
|
|
179
|
+
const next = { ...cur, text: u.text };
|
|
180
|
+
if (kind === 'selections')
|
|
181
|
+
next.untexted = false;
|
|
182
|
+
if (kind === 'symbols')
|
|
183
|
+
next.maxLength = newMax;
|
|
184
|
+
if (kind === 'selections' && cur.annotations?.some((a) => /^@DDIC/i.test(a))) {
|
|
185
|
+
next.annotations = cur.annotations.filter((a) => !/^@DDIC/i.test(a));
|
|
186
|
+
r.dictionaryReferenceDropped.push(key);
|
|
187
|
+
}
|
|
188
|
+
entries[at] = next;
|
|
189
|
+
}
|
|
190
|
+
for (const e of existing) {
|
|
191
|
+
if (touched.has(e.key))
|
|
192
|
+
continue;
|
|
193
|
+
if (e.untexted || e.text === '')
|
|
194
|
+
continue;
|
|
195
|
+
r.kept.push(e.key);
|
|
196
|
+
}
|
|
197
|
+
return r;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Selection-screen field names declared in a program source (PARAMETERS /
|
|
201
|
+
* SELECT-OPTIONS, chained or not). Comments are ignored. `hasIncludes` says
|
|
202
|
+
* the screen may also be declared in an include this scan cannot see.
|
|
203
|
+
*/
|
|
204
|
+
export function selectionScreenNames(source) {
|
|
205
|
+
const lines = source.split(/\r\n|\r|\n/).filter((l) => !l.startsWith('*')).map(stripLineComment);
|
|
206
|
+
const code = lines.join('\n');
|
|
207
|
+
const names = new Set();
|
|
208
|
+
const re = /(?:^|[\s.])(PARAMETERS|PARAMETER|SELECT-OPTIONS|SELECT-OPTION)\b\s*(:?)([^.]*)\./gim;
|
|
209
|
+
let m;
|
|
210
|
+
while ((m = re.exec(code)) !== null) {
|
|
211
|
+
const parts = m[2] === ':' ? m[3].split(',') : [m[3]];
|
|
212
|
+
for (const p of parts) {
|
|
213
|
+
const id = /^\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(p);
|
|
214
|
+
if (id)
|
|
215
|
+
names.add(id[1].toUpperCase());
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const hasIncludes = /^\s*INCLUDE\s+(?!STRUCTURE\b|TYPE\b)[A-Za-z0-9_/]+/im.test(code);
|
|
219
|
+
return { names, hasIncludes };
|
|
220
|
+
}
|
|
221
|
+
/** Drop a `"` comment, leaving quotes inside '…' and `…` literals alone. */
|
|
222
|
+
function stripLineComment(line) {
|
|
223
|
+
let inQ = null;
|
|
224
|
+
for (let i = 0; i < line.length; i++) {
|
|
225
|
+
const c = line[i];
|
|
226
|
+
if (inQ) {
|
|
227
|
+
if (c === inQ)
|
|
228
|
+
inQ = null;
|
|
229
|
+
}
|
|
230
|
+
else if (c === "'" || c === '`' || c === '|') {
|
|
231
|
+
inQ = c;
|
|
232
|
+
}
|
|
233
|
+
else if (c === '"') {
|
|
234
|
+
return line.slice(0, i);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return line;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Snapshot-store key for one text-pool part: `PROG.TEXT_<KIND>`, name = the
|
|
241
|
+
* program. A dot keeps the store's type/name layout and can never collide
|
|
242
|
+
* with a real ABAP type code (same scheme as class includes, CLAS.<INCLUDE>).
|
|
243
|
+
*/
|
|
244
|
+
export function textElementsSnapshotType(kind) {
|
|
245
|
+
return `PROG.TEXT_${kind.toUpperCase()}`;
|
|
246
|
+
}
|
|
247
|
+
export function parseTextElementsSnapshotType(type) {
|
|
248
|
+
const m = /^PROG\.TEXT_(SELECTIONS|SYMBOLS|HEADINGS)$/i.exec(type);
|
|
249
|
+
return m ? m[1].toLowerCase() : null;
|
|
250
|
+
}
|