sap-adt-mcp 0.7.1

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/src/diff.js ADDED
@@ -0,0 +1,123 @@
1
+ // Minimal line-based unified diff using LCS DP.
2
+ // Adequate for ABAP source comparison (typically <5000 lines).
3
+ // Output mirrors the conventional `diff -u` format — well understood by LLMs.
4
+
5
+ export function unifiedLineDiff(a, b, { context = 3, fromFile = "a", toFile = "b" } = {}) {
6
+ const aLines = splitLines(a);
7
+ const bLines = splitLines(b);
8
+ const ops = lcsDiff(aLines, bLines);
9
+ const hunks = collectHunks(ops, context);
10
+
11
+ if (hunks.length === 0) {
12
+ return { identical: true, diff: "", stats: { added: 0, removed: 0 } };
13
+ }
14
+
15
+ let added = 0;
16
+ let removed = 0;
17
+ for (const op of ops) {
18
+ if (op.kind === "+") added++;
19
+ else if (op.kind === "-") removed++;
20
+ }
21
+
22
+ let out = `--- ${fromFile}\n+++ ${toFile}\n`;
23
+ for (const h of hunks) {
24
+ out += `@@ -${h.aStart},${h.aLen} +${h.bStart},${h.bLen} @@\n`;
25
+ for (const line of h.lines) out += line + "\n";
26
+ }
27
+ return { identical: false, diff: out, stats: { added, removed } };
28
+ }
29
+
30
+ function splitLines(s) {
31
+ if (typeof s !== "string") return [];
32
+ if (s.length === 0) return [];
33
+ const lines = s.split(/\r\n|\n|\r/);
34
+ // split keeps a trailing empty entry if the string ends with a newline; drop it.
35
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
36
+ return lines;
37
+ }
38
+
39
+ // Returns a list of ops: { kind: " " | "-" | "+", text, ai, bi } where ai/bi are
40
+ // 1-based line numbers in original/new files (undefined for the missing side).
41
+ function lcsDiff(a, b) {
42
+ const n = a.length;
43
+ const m = b.length;
44
+ // Standard LCS DP table.
45
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
46
+ for (let i = n - 1; i >= 0; i--) {
47
+ for (let j = m - 1; j >= 0; j--) {
48
+ if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
49
+ else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
50
+ }
51
+ }
52
+ const ops = [];
53
+ let i = 0;
54
+ let j = 0;
55
+ while (i < n && j < m) {
56
+ if (a[i] === b[j]) {
57
+ ops.push({ kind: " ", text: a[i], ai: i + 1, bi: j + 1 });
58
+ i++;
59
+ j++;
60
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
61
+ ops.push({ kind: "-", text: a[i], ai: i + 1 });
62
+ i++;
63
+ } else {
64
+ ops.push({ kind: "+", text: b[j], bi: j + 1 });
65
+ j++;
66
+ }
67
+ }
68
+ while (i < n) {
69
+ ops.push({ kind: "-", text: a[i], ai: i + 1 });
70
+ i++;
71
+ }
72
+ while (j < m) {
73
+ ops.push({ kind: "+", text: b[j], bi: j + 1 });
74
+ j++;
75
+ }
76
+ return ops;
77
+ }
78
+
79
+ function collectHunks(ops, context) {
80
+ const hunks = [];
81
+ let i = 0;
82
+ while (i < ops.length) {
83
+ while (i < ops.length && ops[i].kind === " ") i++;
84
+ if (i >= ops.length) break;
85
+
86
+ const start = Math.max(0, i - context);
87
+ let end = i;
88
+ while (end < ops.length) {
89
+ if (ops[end].kind !== " ") {
90
+ end++;
91
+ continue;
92
+ }
93
+ // Look ahead: is the next change within 2*context lines?
94
+ let gap = 0;
95
+ let k = end;
96
+ while (k < ops.length && ops[k].kind === " " && gap < 2 * context) {
97
+ gap++;
98
+ k++;
99
+ }
100
+ if (k < ops.length && ops[k].kind !== " " && gap < 2 * context) {
101
+ end = k;
102
+ continue;
103
+ }
104
+ break;
105
+ }
106
+ const tail = Math.min(ops.length, end + context);
107
+
108
+ const slice = ops.slice(start, tail);
109
+ const aStart = slice.find((o) => o.ai != null)?.ai ?? 0;
110
+ const bStart = slice.find((o) => o.bi != null)?.bi ?? 0;
111
+ let aLen = 0;
112
+ let bLen = 0;
113
+ const lines = [];
114
+ for (const o of slice) {
115
+ lines.push(o.kind + o.text);
116
+ if (o.kind !== "+") aLen++;
117
+ if (o.kind !== "-") bLen++;
118
+ }
119
+ hunks.push({ aStart, aLen, bStart, bLen, lines });
120
+ i = tail;
121
+ }
122
+ return hunks;
123
+ }
@@ -0,0 +1,251 @@
1
+ // Parse ADT runtime-dumps Atom feed.
2
+ // The /sap/bc/adt/runtime/dumps endpoint returns an Atom feed where each
3
+ // <entry> represents one ST22-style short dump. SAP enriches entries with
4
+ // dump-specific elements in the rba (runtime / basis abap) namespace, but
5
+ // concrete element names vary across NetWeaver releases. We parse the Atom
6
+ // basics defensively and surface any rba:* fields as a map so the agent can
7
+ // see release-specific metadata without us hard-coding every name.
8
+
9
+ const ENTRY_RE = /<(?:[a-z]+:)?entry\b[^>]*>([\s\S]*?)<\/(?:[a-z]+:)?entry>/g;
10
+ const TAG_RE = (tag) =>
11
+ new RegExp(`<(?:[a-z]+:)?${tag}\\b[^>]*>([\\s\\S]*?)<\\/(?:[a-z]+:)?${tag}>`, "i");
12
+ // Leaf-only: content must not contain '<', so a wrapper like
13
+ // <rba:abapRuntimeError>...children...</rba:abapRuntimeError> is skipped and
14
+ // we descend into its children. Mixed-content elements (rare in ADT XML) are
15
+ // not captured — acceptable trade-off for not needing a full XML parser.
16
+ const NS_FIELD_RE = /<([a-z]+):([a-zA-Z0-9_]+)\b[^>]*>([^<]*)<\/\1:\2>/g;
17
+
18
+ const NS_BLOCKLIST = new Set(["atom", "adtcore", "app"]);
19
+
20
+ function pickFirst(xml, tag) {
21
+ const m = xml.match(TAG_RE(tag));
22
+ return m ? decodeEntities(m[1].trim()) : undefined;
23
+ }
24
+
25
+ function pickAttr(xml, tag, attr) {
26
+ const re = new RegExp(`<${tag}\\b[^>]*\\b${attr}="([^"]*)"`, "i");
27
+ const m = xml.match(re);
28
+ return m ? decodeEntities(m[1]) : undefined;
29
+ }
30
+
31
+ function decodeEntities(s) {
32
+ return s
33
+ .replace(/&lt;/g, "<")
34
+ .replace(/&gt;/g, ">")
35
+ .replace(/&quot;/g, '"')
36
+ .replace(/&apos;/g, "'")
37
+ .replace(/&amp;/g, "&");
38
+ }
39
+
40
+ function parseExtensionFields(xml) {
41
+ const out = {};
42
+ for (const m of xml.matchAll(NS_FIELD_RE)) {
43
+ const ns = m[1];
44
+ const name = m[2];
45
+ if (NS_BLOCKLIST.has(ns)) continue;
46
+ const value = decodeEntities(m[3].trim());
47
+ if (!value) continue;
48
+ out[`${ns}:${name}`] = value;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function parseDumpFeed(xml) {
54
+ const entries = [];
55
+ for (const m of xml.matchAll(ENTRY_RE)) {
56
+ const inner = m[1];
57
+ const id = pickFirst(inner, "id");
58
+ const dumpId = id ? id.split("/").pop() : undefined;
59
+ let title = pickFirst(inner, "title");
60
+ const updated = pickFirst(inner, "updated");
61
+ const published = pickFirst(inner, "published");
62
+ const summary = pickFirst(inner, "summary");
63
+ const categories = [];
64
+ const catRe = /<(?:[a-z]+:)?category\b([^>]*)\/?>/gi;
65
+ for (const cm of inner.matchAll(catRe)) {
66
+ const attrs = cm[1];
67
+ const term = attrs.match(/\bterm="([^"]*)"/i)?.[1];
68
+ const label = attrs.match(/\blabel="([^"]*)"/i)?.[1];
69
+ if (term) categories.push({ term: decodeEntities(term), label: label ? decodeEntities(label) : undefined });
70
+ }
71
+ const runtimeError = categories.find((c) => /runtime error/i.test(c.label ?? ""))?.term;
72
+ const program = categories.find((c) => /terminated/i.test(c.label ?? ""))?.term;
73
+ if (!title) title = runtimeError;
74
+ const authorName = (() => {
75
+ const author = inner.match(/<(?:[a-z]+:)?author\b[^>]*>([\s\S]*?)<\/(?:[a-z]+:)?author>/i);
76
+ if (!author) return undefined;
77
+ const n = author[1].match(/<(?:[a-z]+:)?name\b[^>]*>([\s\S]*?)<\/(?:[a-z]+:)?name>/i);
78
+ return n ? decodeEntities(n[1].trim()) : undefined;
79
+ })();
80
+ const fields = parseExtensionFields(inner);
81
+ entries.push({
82
+ id: dumpId ?? id,
83
+ title,
84
+ runtimeError,
85
+ program,
86
+ updated: updated ?? published,
87
+ user: authorName,
88
+ summary,
89
+ fields,
90
+ });
91
+ }
92
+ return entries;
93
+ }
94
+
95
+ export function parseDumpDetail(xml) {
96
+ // The detail response can be either an Atom entry or a richer rba-namespaced
97
+ // document; surface what we can extract plus the raw body.
98
+ const id = pickFirst(xml, "id") ?? pickAttr(xml, "rba:abapRuntimeError", "id");
99
+ const title = pickFirst(xml, "title");
100
+ const updated = pickFirst(xml, "updated") ?? pickFirst(xml, "published");
101
+ const fields = parseExtensionFields(xml);
102
+ return {
103
+ id: id ? id.split("/").pop() : undefined,
104
+ title,
105
+ updated,
106
+ fields,
107
+ };
108
+ }
109
+
110
+ // Parse the application/vnd.sap.adt.runtime.dump.v1+xml metadata document.
111
+ // SAP returns a namespaced XML containing dump metadata (id, runtime error,
112
+ // program, include, line, timestamps) plus one or more <dump:link> entries
113
+ // that point at the actual dump text (formatted / unformatted). We extract
114
+ // the leaf metadata fields generically and surface every link so the agent —
115
+ // and our get_dump handler — can follow the right sub-resource.
116
+ const LINK_RE = /<(?:[a-z]+:)?link\b([^>]*)\/?>(?:\s*<\/(?:[a-z]+:)?link>)?/gi;
117
+
118
+ // Extract attributes from the document's root element. Some on-prem releases
119
+ // carry the dump payload as root attributes (title, error, terminatedProgram,
120
+ // author, datetime, serverInstance, …) rather than child elements. xmlns
121
+ // declarations are filtered out.
122
+ const ROOT_OPEN_RE = /<(?:[a-z]+:)?[a-zA-Z_][\w-]*\b([^>]*)>/;
123
+ const ATTR_PAIR_RE = /\b([a-zA-Z_][\w:-]*)\s*=\s*"([^"]*)"/g;
124
+
125
+ function parseRootAttributes(xml) {
126
+ const m = xml.match(ROOT_OPEN_RE);
127
+ if (!m) return {};
128
+ const out = {};
129
+ for (const am of m[1].matchAll(ATTR_PAIR_RE)) {
130
+ const name = am[1];
131
+ if (name === "xmlns" || name.startsWith("xmlns:")) continue;
132
+ out[name] = decodeEntities(am[2]);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ export function parseDumpMetadata(xml) {
138
+ const rootAttrs = parseRootAttributes(xml);
139
+ const leafFields = parseExtensionFields(xml);
140
+ // Root attributes win over leaf-element duplicates (closer to the document
141
+ // identity); both are stored side-by-side under fields.
142
+ const fields = { ...leafFields, ...rootAttrs };
143
+ const id =
144
+ pickFirst(xml, "id") ??
145
+ rootAttrs.id ??
146
+ fields["dump:id"] ??
147
+ fields["rba:id"];
148
+ const links = [];
149
+ for (const m of xml.matchAll(LINK_RE)) {
150
+ const attrs = m[1];
151
+ const relation =
152
+ attrs.match(/\b(?:relation|rel)="([^"]*)"/i)?.[1];
153
+ const uri =
154
+ attrs.match(/\b(?:uri|href)="([^"]*)"/i)?.[1];
155
+ const contentType = attrs.match(/\bcontentType="([^"]*)"/i)?.[1];
156
+ if (uri) links.push({ relation, uri: decodeEntities(uri), contentType });
157
+ }
158
+ // Lift commonly-needed fields to the top level so the agent doesn't have to
159
+ // probe the fields map for them. We accept both bare names (from root
160
+ // attributes) and namespaced variants (from leaf elements).
161
+ const pickField = (...keys) => {
162
+ for (const k of keys) if (fields[k]) return fields[k];
163
+ return undefined;
164
+ };
165
+ return {
166
+ id: id ? id.split("/").pop() : undefined,
167
+ title: pickField("title", "dump:title", "rba:title"),
168
+ runtimeError: pickField("error", "dump:error", "runtimeError", "dump:runtimeError"),
169
+ program: pickField("terminatedProgram", "dump:terminatedProgram", "program", "dump:program"),
170
+ user: pickField("author", "dump:author", "user", "dump:user"),
171
+ time: pickField("datetime", "dump:datetime", "occurredAt"),
172
+ server: pickField("serverInstance", "dump:serverInstance", "host", "dump:host"),
173
+ fields,
174
+ links,
175
+ };
176
+ }
177
+
178
+ // Parse a formatted ST22 dump text into a chapter map. ST22 formatted output
179
+ // uses chapter titles at column 0 followed by indented body text. We match a
180
+ // known set of English (and a few German) titles; everything between two
181
+ // title lines becomes the chapter body.
182
+ const CHAPTER_PATTERNS = [
183
+ { key: "shortText", re: /^(?:Short\s*text|Kurztext)\b/i },
184
+ { key: "whatHappened", re: /^(?:What\s*happened\??|Was\s*ist\s*passiert\??)\b/i },
185
+ // Real dumps title this chapter as "What can I do?" (first-person) — older
186
+ // docs say "you". Accept any single subject word so translations / variants
187
+ // ("we", etc.) don't fall back into the previous chapter's body.
188
+ { key: "whatCanYouDo", re: /^What\s*can\s*\S+\s*do\??/i },
189
+ { key: "errorAnalysis", re: /^(?:Error\s*analysis|Fehleranalyse)\b/i },
190
+ { key: "howToCorrect", re: /^How\s*to\s*correct\b/i },
191
+ { key: "whereTerminated", re: /^(?:Information\s*on\s*where\s*terminated|Where\s*terminated)\b/i },
192
+ { key: "sourceCodeExtract", re: /^Source\s*Code\s*Extract\b/i },
193
+ { key: "userAndTransaction", re: /^User\s*and\s*Transaction\b/i },
194
+ { key: "activeCalls", re: /^Active\s*Calls.*Events\b/i },
195
+ { key: "systemEnvironment", re: /^System\s*environment\b/i },
196
+ { key: "systemFields", re: /^Contents\s*of\s*system\s*fields\b/i },
197
+ { key: "internalNotes", re: /^Internal\s*notes\b/i },
198
+ { key: "chosenVariables", re: /^Chosen\s*variables\b/i },
199
+ { key: "directoryAppTables", re: /^Directory\s*of\s*Application\s*Tables\b/i },
200
+ { key: "programsAffected", re: /^List\s*of\s*ABAP\s*programs\s*affected\b/i },
201
+ ];
202
+
203
+ export const CRITICAL_CHAPTER_KEYS = [
204
+ "shortText",
205
+ "whatHappened",
206
+ "errorAnalysis",
207
+ "howToCorrect",
208
+ "whereTerminated",
209
+ "sourceCodeExtract",
210
+ ];
211
+
212
+ // Some on-prem ADT releases ship the formatted dump as a box-drawn table:
213
+ // each line is wrapped in pipe bars (|content|) and chapters are separated
214
+ // by horizontal rules of "-" or "=". Unbox before title matching so the
215
+ // patterns work uniformly across releases.
216
+ function unbox(line) {
217
+ const m = line.match(/^\|(.*?)\s*\|?\s*$/);
218
+ return m ? m[1] : line;
219
+ }
220
+
221
+ const SEPARATOR_RE = /^[-=_]{4,}$/;
222
+
223
+ export function parseDumpChapters(text) {
224
+ if (typeof text !== "string" || text.length === 0) return {};
225
+ const lines = text.split(/\r?\n/);
226
+ const result = {};
227
+ let current = null;
228
+ for (const raw of lines) {
229
+ const stripped = unbox(raw);
230
+ if (SEPARATOR_RE.test(stripped.trim())) continue;
231
+ // Titles sit at column 0 of the (unboxed) content and are not blank.
232
+ const isTitleCandidate = stripped.length > 0 && !/^\s/.test(stripped);
233
+ let matched = null;
234
+ if (isTitleCandidate) {
235
+ for (const c of CHAPTER_PATTERNS) {
236
+ if (c.re.test(stripped)) {
237
+ matched = c.key;
238
+ break;
239
+ }
240
+ }
241
+ }
242
+ if (matched) {
243
+ if (current) result[current.key] = current.body.replace(/\n+$/, "");
244
+ current = { key: matched, body: "" };
245
+ } else if (current) {
246
+ current.body += stripped.replace(/\s+$/, "") + "\n";
247
+ }
248
+ }
249
+ if (current) result[current.key] = current.body.replace(/\n+$/, "");
250
+ return result;
251
+ }
package/src/lock.js ADDED
@@ -0,0 +1,52 @@
1
+ export async function acquireLock(client, objectPath, accessModeOrOptions = "MODIFY") {
2
+ // Backwards-compatible: callers may pass either a string accessMode or an
3
+ // options object { accessMode, corrNr }.
4
+ const opts =
5
+ typeof accessModeOrOptions === "string"
6
+ ? { accessMode: accessModeOrOptions }
7
+ : accessModeOrOptions ?? {};
8
+ const accessMode = opts.accessMode ?? "MODIFY";
9
+ const query = { _action: "LOCK", accessMode };
10
+ if (opts.corrNr) query.corrNr = opts.corrNr;
11
+ const res = await client.request({
12
+ method: "POST",
13
+ path: objectPath,
14
+ query,
15
+ headers: { "X-sap-adt-sessiontype": "stateful" },
16
+ accept: "application/vnd.sap.as+xml;dataname=com.sap.adt.lock.Result",
17
+ });
18
+ const body = await res.text();
19
+ if (!res.ok) {
20
+ return {
21
+ ok: false,
22
+ status: res.status,
23
+ body,
24
+ contentType: res.headers.get("content-type"),
25
+ };
26
+ }
27
+ const handle = extractLockHandle(body);
28
+ if (!handle) {
29
+ return {
30
+ ok: false,
31
+ status: res.status,
32
+ body,
33
+ contentType: res.headers.get("content-type"),
34
+ error: "no-lock-handle-in-response",
35
+ };
36
+ }
37
+ return { ok: true, handle };
38
+ }
39
+
40
+ export async function releaseLock(client, objectPath, lockHandle) {
41
+ return client.request({
42
+ method: "POST",
43
+ path: objectPath,
44
+ query: { _action: "UNLOCK", lockHandle },
45
+ headers: { "X-sap-adt-sessiontype": "stateful" },
46
+ });
47
+ }
48
+
49
+ export function extractLockHandle(xml) {
50
+ const m = xml.match(/<LOCK_HANDLE>([\s\S]*?)<\/LOCK_HANDLE>/i);
51
+ return m ? m[1].trim() : null;
52
+ }
@@ -0,0 +1,56 @@
1
+ // Helpers for /sap/bc/adt/repository/nodestructure — the ADT package tree endpoint.
2
+ //
3
+ // Returns XML like:
4
+ // <SEU_ADT_REPOSITORY_OBJ_NODE>
5
+ // <OBJECT_TYPE>CLAS/OC</OBJECT_TYPE>
6
+ // <OBJECT_NAME>ZCL_FOO</OBJECT_NAME>
7
+ // <DESCRIPTION>...</DESCRIPTION>
8
+ // </SEU_ADT_REPOSITORY_OBJ_NODE>
9
+
10
+ const NODE_RE =
11
+ /<SEU_ADT_REPOSITORY_OBJ_NODE>([\s\S]*?)<\/SEU_ADT_REPOSITORY_OBJ_NODE>/g;
12
+
13
+ export function buildNodeStructureQuery(packageName) {
14
+ return new URLSearchParams({
15
+ parent_name: packageName,
16
+ parent_tech_name: packageName,
17
+ parent_type: "DEVC/K",
18
+ withShortDescriptions: "true",
19
+ });
20
+ }
21
+
22
+ export async function fetchPackageNodes(client, packageName) {
23
+ const q = buildNodeStructureQuery(packageName).toString();
24
+ const res = await client.request({
25
+ method: "POST",
26
+ path: "/sap/bc/adt/repository/nodestructure?" + q,
27
+ });
28
+ const body = await res.text();
29
+ return { ok: res.ok, status: res.status, body, nodes: res.ok ? parseNodes(body) : [] };
30
+ }
31
+
32
+ export function parseNodes(xml) {
33
+ const out = [];
34
+ for (const m of xml.matchAll(NODE_RE)) {
35
+ const block = m[1];
36
+ const type = field(block, "OBJECT_TYPE");
37
+ const name = field(block, "OBJECT_NAME");
38
+ const description = field(block, "DESCRIPTION") ?? "";
39
+ if (type && name) out.push({ type, name, description });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function field(block, tag) {
45
+ const m = block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
46
+ return m ? decodeEntities(m[1]) : undefined;
47
+ }
48
+
49
+ function decodeEntities(s) {
50
+ return s
51
+ .replace(/&lt;/g, "<")
52
+ .replace(/&gt;/g, ">")
53
+ .replace(/&quot;/g, '"')
54
+ .replace(/&apos;/g, "'")
55
+ .replace(/&amp;/g, "&");
56
+ }