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.
- package/README.md +17 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +14 -9
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +122 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +113 -100
- package/dist/controlplane/ui.js +1742 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +310 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +74 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +827 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +43 -22
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +515 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1700 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +50 -7
- package/docs/configuration.md +189 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +363 -188
- package/package.json +56 -53
package/dist/ibmi/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
`
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
}
|