mcp-for-i 0.1.13 → 0.2.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.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -3,8 +3,10 @@ import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import { Tools } from "./Tools.js";
5
5
  import { IBMiContent } from "./IBMiContent.js";
6
+ import { checkHostKey } from "./knownHosts.js";
7
+ import { DEFAULT_MAPEPIRE_PORT, PersistentSqlJob, isQualifiedJobName, normalizeMapepireMode } from "./sqlJob.js";
8
+ import { desiredLibraryList, existingLibrariesSql, invalidNamesWarning, libraryListCommand, libraryNamesFromRows, qshLibraryListPrefix, referencedLibraries, resolveAgainstExisting } from "./libraryList.js";
6
9
  const LOCALE = "LC_ALL=EN_US.UTF-8";
7
- const SQL_HEREDOC_TAG = "__MCP_FOR_I_SQL__";
8
10
  export class IBMiClient {
9
11
  client;
10
12
  content;
@@ -14,10 +16,25 @@ export class IBMiClient {
14
16
  currentPort = 22;
15
17
  currentUser = "";
16
18
  currentConnectionName = "";
19
+ /** Long-lived SQL job (Mapepire) shared by SQL and CL calls; see sqlJob.ts. */
20
+ sqlJob;
17
21
  config;
18
- constructor(config) {
22
+ connection;
23
+ /**
24
+ * The configured library list checked for QZDFMDB2 and qsh jobs, which start
25
+ * fresh on every call; re-checked when the setting changes.
26
+ */
27
+ fallbackLibraryList;
28
+ constructor(config, options = {}) {
19
29
  this.config = config;
20
30
  this.content = new IBMiContent(this);
31
+ this.sqlJob = new PersistentSqlJob({
32
+ getConfig: () => this.config,
33
+ getOpenRequest: () => this.sqlJobOpenRequest(),
34
+ factory: options.sqlJobFactory,
35
+ cancelStatement: jobName => this.cancelSqlInJob(jobName),
36
+ fallbackLibraryListWarning: () => this.fallbackLibraryList?.resolved.warning
37
+ });
21
38
  }
22
39
  getConfig() {
23
40
  return this.config;
@@ -47,20 +64,46 @@ export class IBMiClient {
47
64
  this.currentUser = connection.username;
48
65
  this.currentConnectionName = connection.name;
49
66
  this.client = new NodeSSH();
50
- await this.client.connect({
51
- host: connection.host,
52
- port: connection.port,
53
- username: connection.username,
54
- password: connection.password,
55
- privateKey: connection.privateKeyPath ? await fs.readFile(connection.privateKeyPath, "utf8") : undefined,
56
- keepaliveInterval: connection.keepaliveInterval,
57
- readyTimeout: connection.readyTimeout,
58
- debug: connection.sshDebug ? (msg) => process.stderr.write(`[ssh] ${msg}\n`) : undefined
59
- });
60
- await this.detectRemoteFeatures();
61
- await this.loadDefaultLibraryList();
67
+ // Trust on first use: the host key is recorded on the first connection and
68
+ // must match on every later one (see docs/security.md).
69
+ let hostKeyError;
70
+ const hostVerifier = (key, verify) => {
71
+ checkHostKey({ host: connection.host, port: connection.port, key })
72
+ .then(() => verify(true))
73
+ .catch((err) => {
74
+ hostKeyError = err;
75
+ verify(false);
76
+ });
77
+ };
78
+ try {
79
+ await this.client.connect({
80
+ host: connection.host,
81
+ port: connection.port,
82
+ username: connection.username,
83
+ password: connection.password,
84
+ privateKey: connection.privateKeyPath ? await fs.readFile(connection.privateKeyPath, "utf8") : undefined,
85
+ keepaliveInterval: connection.keepaliveInterval,
86
+ readyTimeout: connection.readyTimeout,
87
+ hostVerifier,
88
+ debug: connection.sshDebug ? (msg) => process.stderr.write(`[ssh] ${msg}\n`) : undefined
89
+ });
90
+ }
91
+ catch (err) {
92
+ this.client.dispose();
93
+ this.client = undefined;
94
+ throw hostKeyError ?? err;
95
+ }
96
+ this.connection = connection;
97
+ await Promise.all([
98
+ this.detectRemoteFeatures(),
99
+ this.loadDefaultLibraryList()
100
+ ]);
62
101
  }
63
102
  async disconnect() {
103
+ // Close the SQL job first: in single mode it runs on this SSH session.
104
+ await this.sqlJob.close().catch(() => undefined);
105
+ this.connection = undefined;
106
+ this.fallbackLibraryList = undefined;
64
107
  if (this.client) {
65
108
  this.client.dispose();
66
109
  this.client = undefined;
@@ -110,19 +153,161 @@ export class IBMiClient {
110
153
  command
111
154
  };
112
155
  }
113
- async runSQL(statements) {
156
+ /**
157
+ * Runs SQL. Uses the connection's persistent Mapepire job when it is
158
+ * available (so library list changes and QTEMP carry over), otherwise a new
159
+ * QZDFMDB2 job. Either way the connection's configured library list and
160
+ * current library are in effect. When `signal` aborts, a statement running
161
+ * in the persistent job is cancelled on the IBM i.
162
+ */
163
+ async runSQL(statements, options = {}) {
164
+ const rows = await this.sqlJob.runSql(statements, options);
165
+ if (rows)
166
+ return rows;
167
+ return this.runSqlQzdfmdb2(statements);
168
+ }
169
+ /**
170
+ * Runs a CL command where it affects later SQL and CL: in the persistent
171
+ * job when there is one, otherwise with `system` in a qsh job.
172
+ */
173
+ async runCL(command, options = {}) {
174
+ try {
175
+ const messages = await this.sqlJob.runCl(command, options);
176
+ if (messages) {
177
+ const stdout = messages.map(m => (m.id ? `${m.id}: ${m.text}` : m.text)).join("\n");
178
+ return { code: 0, signal: null, stdout, stderr: "", command };
179
+ }
180
+ }
181
+ catch (err) {
182
+ return { code: 1, signal: null, stdout: "", stderr: err?.message || String(err), command };
183
+ }
184
+ // A new job: the qsh liblist builtin sets the configured library list,
185
+ // which the job `system` starts inherits. Single-quoted for qsh, so
186
+ // nothing in the command is expanded.
187
+ const resolved = await this.resolveFallbackLibraryList();
188
+ const prefix = resolved ? qshLibraryListPrefix(resolved, this.defaultUserLibraries) : "";
189
+ const run = `system ${Tools.shellQuote(command)}`;
190
+ return this.sendQsh({ command: prefix ? `${prefix}; ${run}` : run });
191
+ }
192
+ /**
193
+ * Puts the configured library list in effect now (ibmi.libl.* applyToJob).
194
+ * In the persistent job it is applied immediately; QZDFMDB2 and qsh jobs
195
+ * start fresh, so there it is checked now and applied on every call.
196
+ */
197
+ async applyConfiguredLibraryList() {
198
+ const result = await this.sqlJob.reapplyLibraryList();
199
+ if (result)
200
+ return { applied: "job", ...(result.warning ? { warning: result.warning } : {}) };
201
+ this.fallbackLibraryList = undefined;
202
+ const resolved = await this.resolveFallbackLibraryList();
203
+ return { applied: "per-call", ...(resolved?.warning ? { warning: resolved.warning } : {}) };
204
+ }
205
+ /**
206
+ * The configured library list as it can be applied in a new job. Checked
207
+ * once per setting value by running the CHGLIBL in a QZDFMDB2 job; when it
208
+ * fails (CHGLIBL fails entirely when one library is missing) the libraries
209
+ * that do not exist are left out, so a bad entry cannot fail every batch.
210
+ */
211
+ async resolveFallbackLibraryList() {
212
+ const desired = desiredLibraryList(this.config);
213
+ const cached = this.fallbackLibraryList;
214
+ if (cached && cached.signature === desired.signature)
215
+ return cached.resolved;
216
+ const full = libraryListCommand(desired.libraries, desired.currentLibrary);
217
+ const invalid = invalidNamesWarning(desired);
218
+ let resolved = {
219
+ command: full,
220
+ libraries: desired.libraries,
221
+ currentLibrary: desired.currentLibrary,
222
+ ...(invalid ? { warning: invalid } : {})
223
+ };
224
+ if (full) {
225
+ try {
226
+ await this.runQzdfmdb2Batch(`@${full}`);
227
+ }
228
+ catch (firstErr) {
229
+ if (!this.client)
230
+ return undefined;
231
+ const firstError = firstErr?.message || String(firstErr);
232
+ try {
233
+ const names = referencedLibraries(desired);
234
+ let rows;
235
+ try {
236
+ rows = await this.runQzdfmdb2Batch(existingLibrariesSql(names));
237
+ }
238
+ catch {
239
+ // Older releases do not know *ALLSIMPLE.
240
+ rows = await this.runQzdfmdb2Batch(existingLibrariesSql(names, false));
241
+ }
242
+ resolved = resolveAgainstExisting(desired, libraryNamesFromRows(rows));
243
+ if (resolved.command)
244
+ await this.runQzdfmdb2Batch(`@${resolved.command}`);
245
+ if (!resolved.warning)
246
+ resolved.warning = `${full} failed: ${firstError}`;
247
+ }
248
+ catch (err) {
249
+ if (!this.client)
250
+ return undefined;
251
+ resolved = { libraries: [], warning: `Configured library list not applied: ${full} failed: ${firstError}` };
252
+ }
253
+ }
254
+ }
255
+ this.fallbackLibraryList = { signature: desired.signature, resolved };
256
+ return resolved;
257
+ }
258
+ sqlJobOpenRequest() {
259
+ if (!this.client || !this.connection)
260
+ return undefined;
261
+ const port = Number(this.config.mapepirePort);
262
+ return {
263
+ host: this.connection.host,
264
+ username: this.connection.username,
265
+ password: this.connection.password,
266
+ ssh: this.client,
267
+ mode: normalizeMapepireMode(this.config.mapepireMode),
268
+ port: Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_MAPEPIRE_PORT,
269
+ serverPath: this.config.mapepireServerPath || undefined,
270
+ javaPath: this.config.mapepireJavaPath || undefined,
271
+ probeTimeoutMs: 4000,
272
+ sendCommand: options => this.sendCommand(options)
273
+ };
274
+ }
275
+ /** QSYS2.CANCEL_SQL runs in a separate QZDFMDB2 job, since the target job is busy. */
276
+ async cancelSqlInJob(jobName) {
277
+ if (!isQualifiedJobName(jobName))
278
+ throw new Error(`Invalid job name: ${jobName}`);
279
+ await this.runQzdfmdb2Batch(`CALL QSYS2.CANCEL_SQL('${jobName.toUpperCase()}')`);
280
+ }
281
+ /**
282
+ * Runs SQL in a new QZDFMDB2 job. The configured library list is set first,
283
+ * in the same job, by a leading CHGLIBL (libraries that do not exist are
284
+ * left out so it cannot fail the batch).
285
+ */
286
+ async runSqlQzdfmdb2(statements) {
287
+ if (!Tools.fixSQL(statements, true).trim())
288
+ return [];
289
+ const resolved = await this.resolveFallbackLibraryList();
290
+ const command = resolved?.command;
291
+ return this.runQzdfmdb2Batch(command ? `@${command}\n${statements}` : statements);
292
+ }
293
+ /** Runs SQL in a new QZDFMDB2 job as it is (no library list). */
294
+ async runQzdfmdb2Batch(statements) {
114
295
  const input = Tools.fixSQL(statements, true).trim();
115
296
  if (!input)
116
297
  return [];
117
- const ccsid = this.config.sqlJobCcsid ?? 1208;
118
- const command = [
119
- `system "CHGJOB CCSID(${ccsid})"`,
120
- `cat <<'${SQL_HEREDOC_TAG}' | system "call QSYS/QZDFMDB2 PARM('-d' '-i' '-t')"`,
121
- input,
122
- SQL_HEREDOC_TAG
123
- ].join("\n");
124
- const output = await this.sendQsh({
125
- command,
298
+ const ccsid = Number(this.config.sqlJobCcsid ?? 1208);
299
+ if (!Number.isInteger(ccsid) || ccsid < 1 || ccsid > 65535) {
300
+ throw new Error(`Invalid sqlJobCcsid: ${this.config.sqlJobCcsid}`);
301
+ }
302
+ // The SQL text is never part of the shell command: it goes to QZDFMDB2 on
303
+ // the SSH channel's stdin. CHGJOB reads /dev/null so it cannot consume it.
304
+ const script = [
305
+ `system "CHGJOB CCSID(${ccsid})" </dev/null`,
306
+ `system "call QSYS/QZDFMDB2 PARM('-d' '-i' '-t')"`
307
+ ].join("; ");
308
+ const output = await this.sendCommand({
309
+ command: `${LOCALE} /QOpenSys/usr/bin/qsh -c ${Tools.shellQuote(script)}`,
310
+ stdin: `${input}\n`,
126
311
  env: { QIBM_PASE_CCSID: String(ccsid) }
127
312
  });
128
313
  if (output.stdout && output.stdout.trim()) {
@@ -209,13 +394,17 @@ export class IBMiClient {
209
394
  stat: ["/QOpenSys/pkgs/bin/stat", "/usr/bin/stat"],
210
395
  ls: ["/QOpenSys/pkgs/bin/ls", "/usr/bin/ls"]
211
396
  };
212
- for (const [name, paths] of Object.entries(featurePaths)) {
213
- for (const p of paths) {
214
- const res = await this.sendCommand({ command: `test -x ${p} && echo ${p}` });
215
- if (res.stdout.trim()) {
216
- this.remoteFeatures[name] = p;
217
- break;
218
- }
397
+ const discoveryCommand = Object.entries(featurePaths).map(([name, paths]) => `for p in ${paths.map(Tools.shellQuote).join(" ")}; do ` +
398
+ `if [ -x "$p" ]; then printf '%s=%s\\n' ${Tools.shellQuote(name)} "$p"; break; fi; done`).join("; ");
399
+ const result = await this.sendCommand({ command: discoveryCommand });
400
+ for (const line of result.stdout.split(/\r?\n/)) {
401
+ const separator = line.indexOf("=");
402
+ if (separator <= 0)
403
+ continue;
404
+ const name = line.slice(0, separator);
405
+ const featurePath = line.slice(separator + 1);
406
+ if (Object.hasOwn(featurePaths, name) && featurePath) {
407
+ this.remoteFeatures[name] = featurePath;
219
408
  }
220
409
  }
221
410
  // QZDFMDB2.PGM is in QSYS/QZDFMDB2 by default; treat as present
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Parser for IBM i compiler events files (EVFEVENT), written by compilers
3
+ * when OPTION(*EVENTF) is used.
4
+ *
5
+ * Each record starts with a record type padded to 10 characters, followed by
6
+ * blank separated fixed-width fields:
7
+ *
8
+ * TIMESTAMP rev yyyymmddhhmmss
9
+ * PROCESSOR rev fileid(000) outputFileId
10
+ * FILEID rev fileid(3) includedAtLine(6) nameLength(3) name timestamp(14) tempFlag(1)
11
+ * FILEEND rev fileid(3) lineCount(6)
12
+ * EXPANSION rev fileid(3) inStart(6) inEnd(6) outStart(6) outEnd(6)
13
+ * ERROR rev fileid(3) annotClass(1) stmtLine(6) startLine(6) startCol(3+)
14
+ * endLine(6) endCol(3+) msgid(7) sevChar(1) sev(2) msgLength(3) text
15
+ *
16
+ * A new PROCESSOR record starts a new pass (for example the RPG compiler run
17
+ * after the SQL precompiler) and file ids restart in each pass. Errors that a
18
+ * later pass reports against a temporary file (temp flag 1, such as
19
+ * QTEMP/QSQLTEMP1) are mapped back to the original source through the
20
+ * EXPANSION records of the previous pass.
21
+ */
22
+ const ERROR_RE = /^ERROR\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+([A-Za-z])\s+(\d+)\s+(\d+)(?:\s(.*))?$/;
23
+ const FILEID_RE = /^FILEID\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/;
24
+ const FILEEND_RE = /^FILEEND\s+(\d+)\s+(\d+)(?:\s+(\d+))?/;
25
+ const EXPANSION_RE = /^EXPANSION\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/;
26
+ const TIMESTAMP_RE = /^TIMESTAMP\s+(\d+)\s+(\d+)/;
27
+ const PROCESSOR_RE = /^PROCESSOR\b/;
28
+ const KNOWN_RECORD_RE = /^(MAPDEFINE|MAPSTART|MAPEND|FEEDBACK|PROGRAM)\b/;
29
+ export function severityFrom(char, level) {
30
+ switch (char.toUpperCase()) {
31
+ case "I": return "INFO";
32
+ case "W": return "WARN";
33
+ case "E":
34
+ case "S":
35
+ case "T":
36
+ case "U":
37
+ case "C": return "ERROR";
38
+ }
39
+ if (level < 10)
40
+ return "INFO";
41
+ if (level < 20)
42
+ return "WARN";
43
+ return "ERROR";
44
+ }
45
+ /** Split a FILEID name into library/file/member or an IFS path. */
46
+ export function describeEvfFile(name) {
47
+ const qsys = name.match(/^([^/()\s]+)\/([^/()\s]+)\(([^()\s]+)\)$/);
48
+ if (qsys)
49
+ return { library: qsys[1].toUpperCase(), sourceFile: qsys[2].toUpperCase(), member: qsys[3].toUpperCase() };
50
+ const qsysPath = name.match(/^\/QSYS\.LIB\/([^/]+)\.LIB\/([^/]+)\.FILE\/([^/]+)\.MBR$/i);
51
+ if (qsysPath)
52
+ return { library: qsysPath[1].toUpperCase(), sourceFile: qsysPath[2].toUpperCase(), member: qsysPath[3].toUpperCase() };
53
+ if (name.startsWith("/"))
54
+ return { path: name };
55
+ return {};
56
+ }
57
+ export function parseEvfevent(content) {
58
+ const lines = Array.isArray(content) ? content : String(content ?? "").split(/\r?\n/);
59
+ const processors = [];
60
+ let current;
61
+ let timestamp;
62
+ let unparsedLines = 0;
63
+ const raw = [];
64
+ const ensureProcessor = () => {
65
+ if (!current) {
66
+ current = { index: processors.length + 1, files: new Map(), expansions: [] };
67
+ processors.push(current);
68
+ }
69
+ return current;
70
+ };
71
+ for (const rawLine of lines) {
72
+ const line = String(rawLine ?? "").replace(/\s+$/, "");
73
+ if (!line.trim())
74
+ continue;
75
+ const record = line.trimStart();
76
+ if (PROCESSOR_RE.test(record)) {
77
+ current = { index: processors.length + 1, files: new Map(), expansions: [] };
78
+ processors.push(current);
79
+ continue;
80
+ }
81
+ let match = record.match(TIMESTAMP_RE);
82
+ if (match) {
83
+ timestamp = timestamp || match[2];
84
+ continue;
85
+ }
86
+ match = record.match(FILEID_RE);
87
+ if (match) {
88
+ const proc = ensureProcessor();
89
+ const id = match[2];
90
+ const nameLength = Number(match[4]);
91
+ const rest = match[5];
92
+ let name;
93
+ let temp = false;
94
+ const tail = rest.match(/^(.*?)\s+(\d{14})\s+([01])\s*$/);
95
+ if (tail) {
96
+ name = tail[1].trim();
97
+ temp = tail[3] === "1";
98
+ }
99
+ else {
100
+ name = (nameLength > 0 ? rest.slice(0, nameLength) : rest).trim();
101
+ const flag = rest.slice(nameLength).trim().split(/\s+/).pop();
102
+ temp = flag === "1";
103
+ }
104
+ if (!proc.files.has(id)) {
105
+ proc.files.set(id, { id, name, includedAtLine: Number(match[3]), temp });
106
+ if (!proc.firstFileId)
107
+ proc.firstFileId = id;
108
+ }
109
+ continue;
110
+ }
111
+ match = record.match(FILEEND_RE);
112
+ if (match) {
113
+ const file = current?.files.get(match[2]);
114
+ if (file && match[3] !== undefined)
115
+ file.lineCount = Number(match[3]);
116
+ continue;
117
+ }
118
+ match = record.match(EXPANSION_RE);
119
+ if (match) {
120
+ ensureProcessor().expansions.push({
121
+ fileId: match[2],
122
+ inStart: Number(match[3]),
123
+ inEnd: Number(match[4]),
124
+ outStart: Number(match[5]),
125
+ outEnd: Number(match[6])
126
+ });
127
+ continue;
128
+ }
129
+ match = record.match(ERROR_RE);
130
+ if (match) {
131
+ const proc = ensureProcessor();
132
+ const statementLine = Number(match[4]);
133
+ const startLine = Number(match[5]) || statementLine;
134
+ const endLine = Number(match[7]) || startLine;
135
+ const severityChar = match[10].toUpperCase();
136
+ const severityLevel = Number(match[11]);
137
+ const msgLength = Number(match[12]);
138
+ const restText = match[13] ?? "";
139
+ const text = (msgLength > 0 && restText.length > msgLength ? restText.slice(0, msgLength) : restText).trim();
140
+ raw.push({
141
+ processor: proc,
142
+ fileId: match[2],
143
+ diag: {
144
+ line: startLine,
145
+ column: Number(match[6]),
146
+ endLine,
147
+ endColumn: Number(match[8]),
148
+ statementLine,
149
+ msgid: match[9].toUpperCase(),
150
+ severity: severityFrom(severityChar, severityLevel),
151
+ severityChar,
152
+ severityLevel,
153
+ text,
154
+ processor: proc.index
155
+ }
156
+ });
157
+ continue;
158
+ }
159
+ if (!KNOWN_RECORD_RE.test(record))
160
+ unparsedLines++;
161
+ }
162
+ const diagnostics = [];
163
+ const seen = new Set();
164
+ for (const entry of raw) {
165
+ const diag = resolveDiagnostic(entry.processor, processors, entry.fileId, entry.diag);
166
+ const key = [diag.file, diag.line, diag.column, diag.msgid, diag.text].join("\u0000");
167
+ if (seen.has(key))
168
+ continue;
169
+ seen.add(key);
170
+ diagnostics.push(diag);
171
+ }
172
+ const byFile = new Map();
173
+ for (const diag of diagnostics) {
174
+ let group = byFile.get(diag.file);
175
+ if (!group) {
176
+ group = { file: diag.file, ...describeEvfFile(diag.file), errors: [], counts: { errors: 0, warnings: 0, infos: 0 } };
177
+ byFile.set(diag.file, group);
178
+ }
179
+ group.errors.push(diag);
180
+ if (diag.severity === "ERROR")
181
+ group.counts.errors++;
182
+ else if (diag.severity === "WARN")
183
+ group.counts.warnings++;
184
+ else
185
+ group.counts.infos++;
186
+ }
187
+ const files = Array.from(byFile.values());
188
+ for (const group of files) {
189
+ group.errors.sort((a, b) => a.line - b.line || a.column - b.column);
190
+ }
191
+ return {
192
+ timestamp,
193
+ processors: processors.length,
194
+ files,
195
+ diagnostics,
196
+ summary: {
197
+ total: diagnostics.length,
198
+ errors: diagnostics.filter(d => d.severity === "ERROR").length,
199
+ warnings: diagnostics.filter(d => d.severity === "WARN").length,
200
+ infos: diagnostics.filter(d => d.severity === "INFO").length,
201
+ highestSeverity: diagnostics.reduce((max, d) => Math.max(max, d.severityLevel), 0)
202
+ },
203
+ unparsedLines
204
+ };
205
+ }
206
+ function resolveDiagnostic(proc, processors, fileId, diag) {
207
+ const file = proc.files.get(fileId);
208
+ const name = file?.name || `FILEID ${fileId}`;
209
+ if (!file?.temp)
210
+ return { ...diag, file: name };
211
+ // Errors against a temporary (expanded) file: map through the expansions
212
+ // recorded by the pass that produced it.
213
+ const previous = processors
214
+ .slice(0, processors.indexOf(proc))
215
+ .reverse()
216
+ .find(p => p.expansions.length > 0 || p.files.size > 0);
217
+ if (!previous?.firstFileId)
218
+ return { ...diag, file: name };
219
+ const mapLine = (line) => mapExpandedLine(previous, line);
220
+ const start = mapLine(diag.line);
221
+ const end = mapLine(diag.endLine);
222
+ const statement = mapLine(diag.statementLine || diag.line);
223
+ return {
224
+ ...diag,
225
+ file: start.file,
226
+ line: start.line,
227
+ endLine: end.file === start.file ? Math.max(end.line, start.line) : start.line,
228
+ statementLine: statement.file === start.file ? statement.line : start.line,
229
+ mappedFrom: { file: name, line: diag.line }
230
+ };
231
+ }
232
+ /** Map a line of a pass's output back to the original file and line. */
233
+ function mapExpandedLine(proc, outputLine) {
234
+ const mainId = proc.firstFileId;
235
+ const nameOf = (id) => proc.files.get(id)?.name || `FILEID ${id}`;
236
+ let offset = 0;
237
+ const expansions = [...proc.expansions].sort((a, b) => a.outStart - b.outStart);
238
+ for (const exp of expansions) {
239
+ if (exp.outEnd < exp.outStart)
240
+ continue;
241
+ if (outputLine >= exp.outStart && outputLine <= exp.outEnd) {
242
+ if (exp.fileId !== mainId && exp.inStart > 0) {
243
+ // Included source copied line for line.
244
+ return { file: nameOf(exp.fileId), line: exp.inStart + (outputLine - exp.outStart) };
245
+ }
246
+ // Generated code: point at the statement it came from.
247
+ return { file: nameOf(exp.fileId), line: exp.inStart > 0 ? exp.inStart : Math.max(1, outputLine - offset) };
248
+ }
249
+ if (exp.outEnd < outputLine) {
250
+ const outputLength = exp.outEnd - exp.outStart + 1;
251
+ const replaced = exp.fileId === mainId && exp.inStart > 0 && exp.inEnd >= exp.inStart ? exp.inEnd - exp.inStart + 1 : 0;
252
+ offset += outputLength - replaced;
253
+ }
254
+ }
255
+ return { file: nameOf(mainId), line: Math.max(1, outputLine - offset) };
256
+ }