@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.
Files changed (74) hide show
  1. package/dist/agent/loop.js +41 -5
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/approvals/grant-claims.js +70 -0
  9. package/dist/approvals/jwt.js +21 -1
  10. package/dist/approvals/plan-grant.js +297 -0
  11. package/dist/commands/config-set.js +12 -0
  12. package/dist/commands/deploy-card.js +10 -5
  13. package/dist/commands/export-audit.js +1 -1
  14. package/dist/commands/plan-audit-evidence.js +47 -1
  15. package/dist/commands/plan-audit.js +23 -8
  16. package/dist/commands/plan-batch-audit.js +349 -0
  17. package/dist/commands/plan-boundary.js +88 -0
  18. package/dist/commands/plan-chain.js +409 -566
  19. package/dist/commands/plan-continue.js +21 -7
  20. package/dist/commands/plan-gate.js +28 -49
  21. package/dist/commands/plan-reground.js +174 -0
  22. package/dist/commands/plan-resume.js +227 -103
  23. package/dist/commands/plan-sap-pause.js +105 -0
  24. package/dist/commands/plan-startup.js +218 -0
  25. package/dist/commands/whoami.js +1 -1
  26. package/dist/config/loader.js +9 -0
  27. package/dist/doctor/checks/forge-rules.js +67 -85
  28. package/dist/doctor/checks/skill.js +60 -11
  29. package/dist/doctor/run.js +4 -6
  30. package/dist/lane/app-chain.js +116 -9
  31. package/dist/one-shot.js +1 -0
  32. package/dist/projects/extract-design.js +4 -3
  33. package/dist/projects/extract-estimate.js +4 -3
  34. package/dist/projects/extract-plan.js +34 -1
  35. package/dist/projects/extract-spec-gap.js +28 -5
  36. package/dist/projects/handover-md.js +1 -1
  37. package/dist/projects/plan-objects.js +75 -0
  38. package/dist/projects/plan-run.js +74 -18
  39. package/dist/projects/plan-schema.js +6 -0
  40. package/dist/projects/plan-sources.js +199 -0
  41. package/dist/projects/promote-command.js +1 -1
  42. package/dist/projects/save-command.js +15 -14
  43. package/dist/projects/saved-lines.js +36 -0
  44. package/dist/projects/title-heading.js +36 -0
  45. package/dist/renderer/checklist-format.js +5 -5
  46. package/dist/renderer/machine-blocks.js +141 -0
  47. package/dist/renderer/thinking-heartbeat.js +2 -1
  48. package/dist/renderer/tool-labels.js +3 -0
  49. package/dist/repl/plan-hop.js +22 -0
  50. package/dist/repl/rule8-detector.js +16 -1
  51. package/dist/repl.js +320 -55
  52. package/dist/router/router-core.js +2 -2
  53. package/dist/sap/reachability-state.js +25 -0
  54. package/dist/sap/unreachable.js +4 -1
  55. package/dist/session/audit-export.js +10 -16
  56. package/dist/session/recap.js +16 -26
  57. package/dist/skills/bundled-skills.js +1 -1
  58. package/dist/tools/_filesystem-shared.js +37 -0
  59. package/dist/tools/approval.js +48 -2
  60. package/dist/tools/dispatch-skill.js +3 -2
  61. package/dist/tools/filesystem/file-read.js +5 -3
  62. package/dist/tools/filesystem/glob.js +27 -4
  63. package/dist/tools/fiori/tools.js +6 -0
  64. package/dist/tools/lane/app-lane-resolve.js +1 -1
  65. package/dist/tools/sap-read.js +37 -3
  66. package/dist/tools/sap-write.js +65 -0
  67. package/dist/tools/text-elements/pool.js +250 -0
  68. package/dist/tools/text-elements/tools.js +433 -0
  69. package/dist/tools/tool-loading.js +3 -0
  70. package/dist/tools/transport.js +26 -5
  71. package/dist/tools/write-mode.js +4 -3
  72. package/package.json +3 -4
  73. package/dist/doctor/checks/zcspeach.js +0 -96
  74. package/dist/projects/email-template.js +0 -56
@@ -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
+ }