deepdebug-local-agent 1.0.21 → 1.0.23

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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // scripts/sync-fixtures.mjs — Phase 2 fixture sync (Plan 02-12 full implementation).
3
+ //
4
+ // For each requested stack, clones williambella/visionhammer-example-<stack>
5
+ // shallow (--depth 1, --branch main) into /tmp/sync-<stack>, copies samples/
6
+ // log files into test/fixtures/<adapter-dir>/ (with IBM MQ filename mapping),
7
+ // then deletes the temp clone. Idempotent: re-runs cleanly overwrite prior
8
+ // fixtures and rewipe /tmp scratch dirs.
9
+ //
10
+ // SECURITY (T-2-04 — PAT discipline):
11
+ // - PAT read ONCE from ~/.config/visionhammer/github-pat.
12
+ // - Embedded ONLY in `https://x-access-token:$PAT@github.com/...` URL form,
13
+ // passed as a `child_process.spawn` ARGV element (not a shell string), so
14
+ // no shell ever sees it. The argv carrying the URL IS visible to ps(1)
15
+ // during clone — that is the standard git-over-https risk window per AD-3.
16
+ // - Stdout/stderr are CAPTURED but only stack names / generic progress are
17
+ // written to console. Git's own stderr (which can contain the embedded
18
+ // URL on auth failure) is sanitised: every literal occurrence of the PAT
19
+ // is replaced with `***REDACTED***` before the buffer touches stderr.
20
+ // - The PAT NEVER goes into argv via a shell string, environment variable,
21
+ // log file, or thrown error message.
22
+ //
23
+ // SECURITY (T-2-05 — PAT scope assertion):
24
+ // - Before any clone, calls GitHub /user (with the PAT) to confirm the token
25
+ // authenticates. We do NOT enforce a specific scope here because the
26
+ // fine-grained-PAT scope vocabulary differs from classic-PAT scopes; a
27
+ // successful GET /user is sufficient to prove the token is valid and the
28
+ // token cannot exceed `repo`-equivalent scope per the project setup
29
+ // contract documented in docs/legacy-connectors.md.
30
+ //
31
+ // SECURITY (T-2-06 — fixture quarantine):
32
+ // - test/fixtures/*/ is .gitignore'd at the repo root (verified by Plan 02-01).
33
+ // The synced files are therefore quarantined: they NEVER enter version
34
+ // control of deepdebug-local-agent. AD-2 explicit submodule rejection.
35
+
36
+ import {
37
+ readFileSync,
38
+ mkdirSync,
39
+ rmSync,
40
+ readdirSync,
41
+ copyFileSync,
42
+ existsSync,
43
+ } from 'node:fs';
44
+ import { spawn } from 'node:child_process';
45
+ import { homedir } from 'node:os';
46
+ import { join, dirname, basename } from 'node:path';
47
+ import { fileURLToPath } from 'node:url';
48
+
49
+ const PAT_PATH = join(homedir(), '.config', 'visionhammer', 'github-pat');
50
+ const ALLOWED_STACKS = ['oracle-forms', 'plsql', 'sap-abap', 'cobol', 'ibm-mq'];
51
+
52
+ // Mapping: example-repo-stack name → local fixture directory name.
53
+ // Example repos use product names (sap-abap, ibm-mq); local fixtures use
54
+ // adapter names (sap, ibmmq). plsql has no Phase 2 adapter — fixtures are
55
+ // synced for Phase 3 catalog work.
56
+ const FIXTURE_DIR_MAP = Object.freeze({
57
+ 'oracle-forms': 'oracle-forms',
58
+ 'plsql': 'plsql',
59
+ 'sap-abap': 'sap',
60
+ 'cobol': 'cobol',
61
+ 'ibm-mq': 'ibmmq',
62
+ });
63
+
64
+ // IBM MQ filename mapping (per Plan 02-08 SUMMARY's filename-mismatch decision).
65
+ // The IbmMqLogAdapter only reads files matching `^AMQERR0[1-3]\.LOG$`. The
66
+ // remote example repo intentionally uses descriptive sample names; sync-time
67
+ // renaming makes the synced fixtures consumable by the adapter's contract.
68
+ // Order is stable so re-runs always produce the same target filenames.
69
+ const IBM_MQ_FILENAME_MAP = Object.freeze({
70
+ 'ibm-mq-sample-mqrc-2033.log': 'AMQERR01.LOG',
71
+ 'ibm-mq-sample-mqrc-2058.log': 'AMQERR02.LOG',
72
+ 'ibm-mq-sample-resource-leak.log': 'AMQERR03.LOG',
73
+ });
74
+
75
+ const __dirname = dirname(fileURLToPath(import.meta.url));
76
+ const REPO_ROOT = join(__dirname, '..');
77
+
78
+ function readPat() {
79
+ // SECURITY: do NOT log PAT_PATH contents on error or success. ENOENT = caller missing setup.
80
+ try {
81
+ return readFileSync(PAT_PATH, 'utf8').trim();
82
+ } catch (err) {
83
+ if (err.code === 'ENOENT') {
84
+ process.stderr.write(
85
+ 'sync-fixtures: GitHub credentials not configured. See docs/legacy-connectors.md.\n',
86
+ );
87
+ process.exit(2);
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ function parseArgs(argv) {
94
+ // --stacks=oracle-forms,plsql OR --stacks oracle-forms plsql OR default = all
95
+ const args = argv.slice(2);
96
+ const flagIdx = args.findIndex((a) => a === '--stacks' || a.startsWith('--stacks='));
97
+ if (flagIdx === -1) return ALLOWED_STACKS;
98
+ const flag = args[flagIdx];
99
+ const raw = flag.startsWith('--stacks=')
100
+ ? flag.slice('--stacks='.length)
101
+ : (args[flagIdx + 1] || '');
102
+ const requested = raw.split(',').map((s) => s.trim()).filter(Boolean);
103
+ const unknown = requested.filter((s) => !ALLOWED_STACKS.includes(s));
104
+ if (unknown.length > 0) {
105
+ process.stderr.write(
106
+ `sync-fixtures: unknown stack(s): ${unknown.join(', ')}. Allowed: ${ALLOWED_STACKS.join(', ')}\n`,
107
+ );
108
+ process.exit(2);
109
+ }
110
+ return requested.length === 0 ? ALLOWED_STACKS : requested;
111
+ }
112
+
113
+ /**
114
+ * Run a child process; never log argv. Captures stdout/stderr buffers.
115
+ * @returns {Promise<{code:number, out:string, err:string}>}
116
+ */
117
+ function run(cmd, args, cwd) {
118
+ return new Promise((resolve, reject) => {
119
+ const child = spawn(cmd, args, {
120
+ cwd,
121
+ stdio: ['ignore', 'pipe', 'pipe'],
122
+ });
123
+ let out = '';
124
+ let err = '';
125
+ child.stdout.on('data', (d) => { out += d.toString(); });
126
+ child.stderr.on('data', (d) => { err += d.toString(); });
127
+ child.on('close', (code) => resolve({ code, out, err }));
128
+ child.on('error', reject);
129
+ });
130
+ }
131
+
132
+ /**
133
+ * T-2-05: confirm the PAT authenticates against GitHub before any clone.
134
+ * On failure, exit 2 with a generic message — never echo the PAT or response.
135
+ */
136
+ async function assertPatValid(pat) {
137
+ // node:fetch is built-in on Node 18+. Authorization header is the standard
138
+ // PAT delivery channel — never logged, never persisted.
139
+ const res = await fetch('https://api.github.com/user', {
140
+ headers: {
141
+ Authorization: `Bearer ${pat}`,
142
+ Accept: 'application/vnd.github+json',
143
+ 'User-Agent': 'visionhammer-sync-fixtures',
144
+ },
145
+ });
146
+ if (!res.ok) {
147
+ // Do NOT print response body — GitHub may echo parts of the request URL.
148
+ process.stderr.write(
149
+ `sync-fixtures: PAT authentication failed (HTTP ${res.status}). Verify ~/.config/visionhammer/github-pat.\n`,
150
+ );
151
+ process.exit(2);
152
+ }
153
+ // We intentionally do NOT verify scope strings — fine-grained-PAT and
154
+ // classic-PAT report scopes differently. Validity is sufficient per AD-3.
155
+ }
156
+
157
+ async function syncStack(stack, pat) {
158
+ const tmpClone = `/tmp/sync-${stack}`;
159
+ const repoUrl = `https://x-access-token:${pat}@github.com/williambella/visionhammer-example-${stack}.git`;
160
+ const fixtureDir = FIXTURE_DIR_MAP[stack];
161
+ const dest = join(REPO_ROOT, 'test', 'fixtures', fixtureDir);
162
+
163
+ // Clean any prior temp clone (idempotent re-runs).
164
+ try { rmSync(tmpClone, { recursive: true, force: true }); } catch (_) { /* ok */ }
165
+
166
+ process.stdout.write(`sync-fixtures: cloning ${stack} ... `);
167
+ const clone = await run(
168
+ 'git',
169
+ ['clone', '--depth', '1', '--branch', 'main', repoUrl, tmpClone],
170
+ REPO_ROOT,
171
+ );
172
+ if (clone.code !== 0) {
173
+ process.stderr.write(
174
+ `\nsync-fixtures: git clone failed for ${stack} (exit ${clone.code}).\n`,
175
+ );
176
+ // Sanitize: replace any occurrence of the PAT with a redaction before
177
+ // letting git's stderr touch our stderr. Defence in depth — git rarely
178
+ // includes the credential portion of the URL but the redaction is cheap.
179
+ const sanitized = clone.err.split(pat).join('***REDACTED***');
180
+ process.stderr.write(sanitized + '\n');
181
+ return false;
182
+ }
183
+ process.stdout.write('cloned. ');
184
+
185
+ const srcSamples = join(tmpClone, 'samples');
186
+ if (!existsSync(srcSamples)) {
187
+ process.stderr.write(
188
+ `\nsync-fixtures: ${stack}: samples/ dir missing in repo, skipping copy.\n`,
189
+ );
190
+ try { rmSync(tmpClone, { recursive: true, force: true }); } catch (_) { /* ok */ }
191
+ return false;
192
+ }
193
+
194
+ mkdirSync(dest, { recursive: true });
195
+ const files = readdirSync(srcSamples).filter(
196
+ (f) => f.toLowerCase().endsWith('.log'),
197
+ );
198
+ let copied = 0;
199
+ for (const f of files) {
200
+ let targetName = f;
201
+ if (stack === 'ibm-mq') {
202
+ // Apply filename mapping (Plan 02-08 SUMMARY decision).
203
+ targetName = IBM_MQ_FILENAME_MAP[f] ?? null;
204
+ if (!targetName) {
205
+ // Sample without an explicit mapping is silently skipped — adapter
206
+ // would not pick it up anyway (filename anchor /^AMQERR0[1-3]\.LOG$/).
207
+ continue;
208
+ }
209
+ }
210
+ copyFileSync(join(srcSamples, f), join(dest, targetName));
211
+ copied++;
212
+ }
213
+ process.stdout.write(`copied ${copied} log(s) → test/fixtures/${fixtureDir}/. `);
214
+
215
+ // Cleanup temp clone.
216
+ try { rmSync(tmpClone, { recursive: true, force: true }); } catch (_) { /* ok */ }
217
+ process.stdout.write('done.\n');
218
+ return true;
219
+ }
220
+
221
+ async function main() {
222
+ const stacks = parseArgs(process.argv);
223
+ const pat = readPat();
224
+ await assertPatValid(pat); // T-2-05 gate
225
+ let failures = 0;
226
+ for (const s of stacks) {
227
+ const ok = await syncStack(s, pat);
228
+ if (!ok) failures++;
229
+ }
230
+ // PAT goes out of scope here — never persisted, never logged.
231
+ if (failures > 0) {
232
+ process.stderr.write(
233
+ `sync-fixtures: ${failures}/${stacks.length} stack(s) failed.\n`,
234
+ );
235
+ process.exit(1);
236
+ }
237
+ process.stdout.write(`sync-fixtures: all ${stacks.length} stack(s) synced.\n`);
238
+ }
239
+
240
+ main().catch((err) => {
241
+ // err.message MUST not contain PAT. We never interpolate the PAT into thrown
242
+ // errors anywhere above; the only thrown paths are fs/network errors whose
243
+ // messages do not reference the credential.
244
+ process.stderr.write(`sync-fixtures: fatal: ${err.message}\n`);
245
+ process.exit(1);
246
+ });
@@ -0,0 +1,12 @@
1
+ // src/adapters/as400/as400-parser.js
2
+ // OS/400 message ID prefixes: CPF (escape), MCH (machine check), SQL (DB2 for i)
3
+ // Anchored at start-of-line so arbitrary text containing the substring
4
+ // (e.g. "see CPF1234 docs") does not match — T-2-03-equivalent isolation surface.
5
+ export const AS400_MSGID_REGEX = /^(CPF|MCH|SQL)(\d{4})\b/;
6
+
7
+ export function extractMessageId(line) {
8
+ const m = line.match(AS400_MSGID_REGEX);
9
+ return m ? `${m[1]}${m[2]}` : null;
10
+ }
11
+
12
+ export const DSPLOG_COMMAND = 'system "DSPLOG OUTPUT(*PRINT) LOGTYPE(*MSG) MSGTYPE(*ESCAPE *NOTIFY)"';
@@ -0,0 +1,134 @@
1
+ // src/adapters/as400/index.js
2
+ // AS/400 (IBM i) log adapter — ssh2 Client polls DSPLOG output.
3
+ // T-2-01 mitigation: pollInterval default 30000 ms (30 s — distinct from chokidar 5 s default per AD-3 + Constraints).
4
+ // T-2-02 mitigation: ssh2 keepaliveInterval=30 s + keepaliveCountMax=3; 'close' triggers reconnect with
5
+ // exponential backoff [1, 2, 4, 8, 16, 32, 60] s (capped at 60 s); backoff resets to base after a successful auth.
6
+ import { EventEmitter } from 'node:events';
7
+ import { Client as Ssh2Client } from 'ssh2';
8
+ import { extractMessageId, DSPLOG_COMMAND } from './as400-parser.js';
9
+
10
+ const ADAPTER = 'as400';
11
+ const DEFAULT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 32000, 60000];
12
+
13
+ export class As400LogAdapter extends EventEmitter {
14
+ constructor(config = {}) {
15
+ super();
16
+ this.host = config.host || 'as400-prd-01';
17
+ this.port = config.port ?? 22;
18
+ this.username = config.username || 'TESTUSER01';
19
+ this.password = config.password;
20
+ this.privateKey = config.privateKey;
21
+ this.pollInterval = config.pollInterval ?? 30000;
22
+ this.backoffSchedule = config.backoffSchedule ?? DEFAULT_BACKOFF;
23
+ this._sshOpts = {
24
+ host: this.host,
25
+ port: this.port,
26
+ username: this.username,
27
+ ...(this.password ? { password: this.password } : {}),
28
+ ...(this.privateKey ? { privateKey: this.privateKey } : {}),
29
+ keepaliveInterval: 30000,
30
+ keepaliveCountMax: 3,
31
+ readyTimeout: 10000,
32
+ };
33
+ this._sshClientFactory = config.sshClientFactory || (() => new Ssh2Client());
34
+ this._client = null;
35
+ this._isRunning = false;
36
+ this._pollTimer = null;
37
+ this._backoffIdx = 0;
38
+ this._reconnectTimer = null;
39
+ }
40
+
41
+ get isRunning() { return this._isRunning; }
42
+
43
+ async start() {
44
+ if (this._isRunning) return;
45
+ this._isRunning = true;
46
+ this._connect();
47
+ }
48
+
49
+ async stop() {
50
+ if (!this._isRunning) return;
51
+ this._isRunning = false;
52
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
53
+ if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
54
+ if (this._client) {
55
+ try { this._client.end(); } catch { /* swallow */ }
56
+ this._client = null;
57
+ }
58
+ }
59
+
60
+ _connect() {
61
+ if (!this._isRunning) return;
62
+ const client = this._sshClientFactory();
63
+ this._client = client;
64
+
65
+ client.on('ready', () => {
66
+ this._backoffIdx = 0; // reset on successful auth
67
+ this._startPolling();
68
+ });
69
+
70
+ client.on('close', () => {
71
+ if (!this._isRunning) return;
72
+ if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
73
+ this._scheduleReconnect();
74
+ });
75
+
76
+ client.on('error', () => {
77
+ // ssh2 surfaces auth/network errors as 'error' then 'close'.
78
+ // 'close' handler schedules reconnect; nothing to do here.
79
+ if (!this._isRunning) return;
80
+ });
81
+
82
+ try {
83
+ client.connect(this._sshOpts);
84
+ } catch {
85
+ this._scheduleReconnect();
86
+ }
87
+ }
88
+
89
+ _scheduleReconnect() {
90
+ if (!this._isRunning) return;
91
+ const delay = this.backoffSchedule[Math.min(this._backoffIdx, this.backoffSchedule.length - 1)];
92
+ this._backoffIdx = Math.min(this._backoffIdx + 1, this.backoffSchedule.length - 1);
93
+ this._reconnectTimer = setTimeout(() => this._connect(), delay);
94
+ // Do not block process exit on a pending reconnect — `stop()` clears it explicitly.
95
+ this._reconnectTimer.unref?.();
96
+ }
97
+
98
+ _startPolling() {
99
+ const tick = () => {
100
+ if (!this._isRunning || !this._client) return;
101
+ this._client.exec(DSPLOG_COMMAND, (err, stream) => {
102
+ if (err) return; // close handler covers reconnect
103
+ let buf = '';
104
+ stream.on('data', (chunk) => { buf += chunk.toString('utf8'); });
105
+ stream.on('close', () => this._processOutput(buf));
106
+ stream.stderr?.on('data', () => { /* discard */ });
107
+ });
108
+ };
109
+ tick();
110
+ this._pollTimer = setInterval(tick, this.pollInterval);
111
+ // Do not block process exit on the poll cadence — `stop()` clears it explicitly.
112
+ this._pollTimer.unref?.();
113
+ }
114
+
115
+ _processOutput(text) {
116
+ const lines = text.split(/\r?\n/);
117
+ for (const line of lines) {
118
+ if (line.trim().length === 0) continue;
119
+ const code = extractMessageId(line);
120
+ if (!code) continue;
121
+ const event = Object.freeze({
122
+ adapter: ADAPTER,
123
+ errorCode: code,
124
+ file: `ssh://${this.host}/DSPLOG`,
125
+ message: line,
126
+ source: `ssh://${this.host}`,
127
+ timestamp: new Date().toISOString(),
128
+ });
129
+ this.emit('error', event);
130
+ }
131
+ }
132
+ }
133
+
134
+ export default As400LogAdapter;
@@ -0,0 +1,37 @@
1
+ // src/adapters/cobol/cobol-parser.js
2
+ // Multi-pattern parser surface for COBOL/JES2 spool exports. Each regex is
3
+ // exported as a named const for isolated unit-testing (T-2-03-equivalent
4
+ // mitigation surface). The `extractCobolCode` helper picks the first matching
5
+ // pattern in priority order.
6
+ //
7
+ // Priority (highest first): SYSTEM COMPLETION CODE → SQLCODE → FILE STATUS →
8
+ // ABEND CODE → IEF info code. The first three are hard error conditions; IEF
9
+ // info codes are the lowest priority because the same line often carries
10
+ // the actual completion code (e.g. `IEF450I ... ABEND=S0C7`) and the
11
+ // system-completion match is more specific.
12
+
13
+ export const COBOL_COMPLETION_REGEX = /SYSTEM COMPLETION CODE=([SU]?\w{3,4})/;
14
+ export const COBOL_SQLCODE_REGEX = /SQLCODE\s*=?\s*(-?\d+)/;
15
+ export const COBOL_IEF_REGEX = /\b(IEF\d{3}I)\b/;
16
+ export const COBOL_FILE_STATUS_REGEX = /FILE STATUS\s+(\d+)/;
17
+ export const COBOL_ABEND_CODE_REGEX = /ABEND CODE=(\w+)/;
18
+
19
+ export function extractCobolCode(line) {
20
+ let m = line.match(COBOL_COMPLETION_REGEX);
21
+ if (m) {
22
+ const raw = m[1];
23
+ // System codes start with 'S' (system) or 'U' (user). 0C7 → S0C7
24
+ // normalization per IBM convention.
25
+ if (/^[SU]/.test(raw)) return raw;
26
+ return `S${raw}`;
27
+ }
28
+ m = line.match(COBOL_SQLCODE_REGEX);
29
+ if (m) return m[1]; // includes leading '-' for negative codes
30
+ m = line.match(COBOL_FILE_STATUS_REGEX);
31
+ if (m) return `FILE_STATUS_${m[1]}`;
32
+ m = line.match(COBOL_ABEND_CODE_REGEX);
33
+ if (m) return m[1];
34
+ m = line.match(COBOL_IEF_REGEX);
35
+ if (m) return m[1];
36
+ return null;
37
+ }
@@ -0,0 +1,151 @@
1
+ // src/adapters/cobol/index.js
2
+ // CobolLogAdapter — watches arbitrary files in the configured JES2 spool
3
+ // directory; emits 6-key error events. AD-1 lifecycle, AD-3 polling, AD-4
4
+ // byte-position dedup with TAIL-on-startup default, AD-5 per-file ordering,
5
+ // AD-6 CRLF tolerance + 80-col fixed-width column-1 preservation (z/OS spool
6
+ // trap). T-2-01 mitigation: default polling interval 5000 ms.
7
+ //
8
+ // The glob is `*` (any file) — JES2 spool exports have arbitrary names.
9
+ // This is broader than sibling adapters but the parser regexes are narrow
10
+ // enough (SYSTEM COMPLETION CODE, SQLCODE, IEF, FILE STATUS, ABEND CODE)
11
+ // that lines without any match are silently dropped.
12
+
13
+ import { EventEmitter } from 'node:events';
14
+ import { stat, open } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import chokidar from 'chokidar';
17
+ import { extractCobolCode } from './cobol-parser.js';
18
+
19
+ const ADAPTER = 'cobol';
20
+
21
+ export class CobolLogAdapter extends EventEmitter {
22
+ constructor(config = {}) {
23
+ super();
24
+ this.spoolDir = config.spoolDir || '/var/spool/jes2';
25
+ this.interval = config.interval ?? 5000;
26
+ this.replayOnStart = config.replayOnStart === true;
27
+ this._watcher = null;
28
+ this._offsets = new Map(); // filePath → byte offset
29
+ this._inodes = new Map(); // filePath → inode
30
+ this._isRunning = false;
31
+ }
32
+
33
+ get isRunning() {
34
+ return this._isRunning;
35
+ }
36
+
37
+ async start() {
38
+ if (this._isRunning) return;
39
+ this._isRunning = true;
40
+
41
+ // Glob bound to any file in spool dir. depth:0 prevents recursion.
42
+ const glob = join(this.spoolDir, '*');
43
+
44
+ this._watcher = chokidar.watch(glob, {
45
+ usePolling: true,
46
+ interval: this.interval,
47
+ ignoreInitial: false, // we handle initial ourselves via TAIL seek
48
+ awaitWriteFinish: false,
49
+ depth: 0, // do not recurse into subdirs
50
+ });
51
+
52
+ const onAdd = async (filePath) => {
53
+ if (this.replayOnStart) {
54
+ this._offsets.set(filePath, 0);
55
+ // fall through to _readNew below
56
+ } else {
57
+ try {
58
+ const st = await stat(filePath);
59
+ this._offsets.set(filePath, st.size);
60
+ this._inodes.set(filePath, st.ino);
61
+ return; // TAIL mode — don't emit pre-existing content
62
+ } catch {
63
+ return;
64
+ }
65
+ }
66
+ this._readNew(filePath).catch(() => { /* ENOENT silence */ });
67
+ };
68
+
69
+ this._watcher.on('add', onAdd);
70
+ this._watcher.on('change', (fp) => {
71
+ this._readNew(fp).catch(() => { /* ENOENT silence */ });
72
+ });
73
+
74
+ // Swallow chokidar's own bubbled errors (e.g., watch-path ENOENT) —
75
+ // dual-layer suppression with the per-read try/catch ensures full
76
+ // ENOENT silence (canonical pattern from Plan 02-07).
77
+ this._watcher.on('error', () => {});
78
+ }
79
+
80
+ async stop() {
81
+ if (!this._isRunning) return;
82
+ this._isRunning = false;
83
+ if (this._watcher) {
84
+ await this._watcher.close().catch(() => {});
85
+ this._watcher = null;
86
+ }
87
+ this._offsets.clear();
88
+ this._inodes.clear();
89
+ }
90
+
91
+ async _readNew(filePath) {
92
+ let st;
93
+ try {
94
+ st = await stat(filePath);
95
+ } catch {
96
+ return; // ENOENT silence
97
+ }
98
+ if (!st.isFile()) return; // skip dirs that may surface from broad `*` glob
99
+
100
+ const lastOffset = this._offsets.get(filePath) ?? 0;
101
+ const lastInode = this._inodes.get(filePath);
102
+
103
+ // Rotation: inode changed OR size shrunk → reset to 0
104
+ if ((lastInode !== undefined && st.ino !== lastInode) || st.size < lastOffset) {
105
+ this._offsets.set(filePath, 0);
106
+ this._inodes.set(filePath, st.ino);
107
+ return this._readNew(filePath); // re-enter with offset=0
108
+ }
109
+ this._inodes.set(filePath, st.ino);
110
+
111
+ if (st.size <= lastOffset) return;
112
+
113
+ const fh = await open(filePath, 'r');
114
+ try {
115
+ const len = st.size - lastOffset;
116
+ const buf = Buffer.alloc(len);
117
+ await fh.read(buf, 0, len, lastOffset);
118
+ this._offsets.set(filePath, st.size);
119
+ const text = buf.toString('utf8');
120
+ // CRLF-tolerant split: \r?\n splits on either ending. The trailing
121
+ // \r is consumed by the regex so event.message is clean of artifacts
122
+ // (AD-6 z/OS spool trap).
123
+ const lines = text.split(/\r?\n/);
124
+ const sourceDir = filePath.split('/').slice(0, -1).join('/');
125
+ for (const line of lines) {
126
+ // CRITICAL: do NOT trim. Column-1 in z/OS spool = print control char
127
+ // (0/1/space) and is semantically meaningful (page eject markers,
128
+ // ABEND headers, etc.) — AD-6 fixed-width trap. Skip only truly
129
+ // empty lines; lines that are pure whitespace (column-1 padding)
130
+ // are also skipped because no parser regex would match them.
131
+ if (line.length === 0) continue;
132
+ const code = extractCobolCode(line);
133
+ if (!code) continue;
134
+ // 6-key frozen event payload (AD-4 contract — extra keys forbidden)
135
+ const event = Object.freeze({
136
+ adapter: ADAPTER,
137
+ errorCode: code,
138
+ file: filePath,
139
+ message: line,
140
+ source: sourceDir,
141
+ timestamp: new Date().toISOString(),
142
+ });
143
+ this.emit('error', event);
144
+ }
145
+ } finally {
146
+ await fh.close();
147
+ }
148
+ }
149
+ }
150
+
151
+ export default CobolLogAdapter;
@@ -0,0 +1,23 @@
1
+ // src/adapters/ibmmq/ibmmq-parser.js
2
+ // T-2-03 isolation surface: regexes exported as named constants for unit testing.
3
+ // Adapter binds chokidar to AMQERR0[1-3].LOG glob only; these regexes never
4
+ // run against arbitrary text by the adapter itself.
5
+
6
+ // Pattern: AMQ followed by 4 digits, optional suffix letter (E error / W warn / I info).
7
+ // IBM MQ V9 message-code shape per Knowledge Center.
8
+ export const IBMMQ_AMQ_REGEX = /\b(AMQ\d{4}[EWI]?)\b/;
9
+
10
+ // Secondary capture: MQRC reason code from "ArithInsert1(NNNN)" (formal SLOG)
11
+ // or "returned NNNN" (free-form prose). Group 1 = ArithInsert1 form, group 2 = "returned" form.
12
+ export const IBMMQ_MQRC_REGEX = /(?:ArithInsert1\((\d+)\)|returned\s+(\d+))/;
13
+
14
+ export function extractAmqCode(line) {
15
+ const m = line.match(IBMMQ_AMQ_REGEX);
16
+ return m ? m[1] : null;
17
+ }
18
+
19
+ export function extractMqrc(line) {
20
+ const m = line.match(IBMMQ_MQRC_REGEX);
21
+ if (!m) return null;
22
+ return m[1] || m[2] || null;
23
+ }