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.
- package/Dockerfile +55 -20
- package/README.md +81 -0
- package/cloudbuild-agent-staging.yaml +40 -0
- package/docs/legacy-connectors.md +268 -0
- package/package.json +4 -1
- package/scripts/sync-fixtures.mjs +246 -0
- package/src/adapters/as400/as400-parser.js +12 -0
- package/src/adapters/as400/index.js +134 -0
- package/src/adapters/cobol/cobol-parser.js +37 -0
- package/src/adapters/cobol/index.js +151 -0
- package/src/adapters/ibmmq/ibmmq-parser.js +23 -0
- package/src/adapters/ibmmq/index.js +137 -0
- package/src/adapters/index.js +79 -0
- package/src/adapters/oracle-forms/index.js +145 -0
- package/src/adapters/oracle-forms/oracle-forms-parser.js +11 -0
- package/src/adapters/sap/index.js +146 -0
- package/src/adapters/sap/sap-parser.js +41 -0
- package/src/detectors/framework-detector.js +33 -0
- package/src/detectors/language-detector.js +26 -1
- package/src/exec-utils.js +182 -0
- package/src/server.js +329 -16
- package/test/adapters/as400/as400.test.js +121 -0
- package/test/adapters/cobol/cobol.test.js +217 -0
- package/test/adapters/ibmmq/ibmmq.test.js +122 -0
- package/test/adapters/oracle-forms/oracle-forms.test.js +180 -0
- package/test/adapters/registry.test.js +101 -0
- package/test/adapters/sap/sap.test.js +142 -0
- package/src/local-agent-source.tar.gz +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/adapters/ibmmq/index.js
|
|
2
|
+
// IbmMqLogAdapter — watches AMQERR0[1-3].LOG in the configured MQ errors dir;
|
|
3
|
+
// emits 6-key error events. AD-1 lifecycle, AD-3 polling, AD-4 byte-position
|
|
4
|
+
// dedup with TAIL-on-startup default, AD-5 per-file ordering.
|
|
5
|
+
// T-2-01 mitigation: default polling interval 5000 ms.
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { stat, open } from 'node:fs/promises';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
import chokidar from 'chokidar';
|
|
11
|
+
import { extractAmqCode } from './ibmmq-parser.js';
|
|
12
|
+
|
|
13
|
+
const ADAPTER = 'ibmmq';
|
|
14
|
+
|
|
15
|
+
// T-2-03 anchor: only AMQERR01.LOG / AMQERR02.LOG / AMQERR03.LOG are accepted.
|
|
16
|
+
// Equivalent to the glob AMQERR0[1-3].LOG, but enforced post-watch in the handler
|
|
17
|
+
// because chokidar usePolling:true does not engage with bracket-class globs on
|
|
18
|
+
// macOS/Node 24 (verified empirically: AMQERR*.LOG fires events; AMQERR0[1-3].LOG
|
|
19
|
+
// fires nothing). The watcher uses AMQERR*.LOG; this regex narrows back to 01..03.
|
|
20
|
+
const IBMMQ_FILENAME_ANCHOR = /^AMQERR0[1-3]\.LOG$/;
|
|
21
|
+
|
|
22
|
+
export class IbmMqLogAdapter extends EventEmitter {
|
|
23
|
+
constructor(config = {}) {
|
|
24
|
+
super();
|
|
25
|
+
this.errorsDir = config.errorsDir || '/var/mqm/qmgrs/QM_PRD01/errors';
|
|
26
|
+
this.interval = config.interval ?? 5000;
|
|
27
|
+
this.replayOnStart = config.replayOnStart === true;
|
|
28
|
+
this._watcher = null;
|
|
29
|
+
this._offsets = new Map(); // filePath → byte offset
|
|
30
|
+
this._inodes = new Map(); // filePath → inode
|
|
31
|
+
this._isRunning = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isRunning() { return this._isRunning; }
|
|
35
|
+
|
|
36
|
+
async start() {
|
|
37
|
+
if (this._isRunning) return;
|
|
38
|
+
this._isRunning = true;
|
|
39
|
+
|
|
40
|
+
// Watch glob = AMQERR*.LOG (chokidar polling-compatible); the strict anchor
|
|
41
|
+
// AMQERR0[1-3].LOG is enforced by IBMMQ_FILENAME_ANCHOR on every event.
|
|
42
|
+
const glob = join(this.errorsDir, 'AMQERR*.LOG');
|
|
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
|
+
});
|
|
50
|
+
|
|
51
|
+
const isAnchoredFile = (fp) => IBMMQ_FILENAME_ANCHOR.test(basename(fp));
|
|
52
|
+
|
|
53
|
+
const onAdd = async (filePath) => {
|
|
54
|
+
if (!isAnchoredFile(filePath)) return; // T-2-03: drop non-anchored
|
|
55
|
+
if (this.replayOnStart) {
|
|
56
|
+
this._offsets.set(filePath, 0);
|
|
57
|
+
// fall through to _readNew below
|
|
58
|
+
} else {
|
|
59
|
+
try {
|
|
60
|
+
const st = await stat(filePath);
|
|
61
|
+
this._offsets.set(filePath, st.size);
|
|
62
|
+
this._inodes.set(filePath, st.ino);
|
|
63
|
+
return; // TAIL mode — don't emit pre-existing content
|
|
64
|
+
} catch { return; }
|
|
65
|
+
}
|
|
66
|
+
this._readNew(filePath).catch(() => { /* ENOENT silence */ });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
this._watcher.on('add', onAdd);
|
|
70
|
+
this._watcher.on('change', (fp) => {
|
|
71
|
+
if (!isAnchoredFile(fp)) return; // T-2-03: drop non-anchored
|
|
72
|
+
this._readNew(fp).catch(() => { /* ENOENT silence */ });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Swallow chokidar's own bubbled errors (e.g., watch-path ENOENT).
|
|
76
|
+
this._watcher.on('error', () => {});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async stop() {
|
|
80
|
+
if (!this._isRunning) return;
|
|
81
|
+
this._isRunning = false;
|
|
82
|
+
if (this._watcher) {
|
|
83
|
+
await this._watcher.close().catch(() => {});
|
|
84
|
+
this._watcher = null;
|
|
85
|
+
}
|
|
86
|
+
this._offsets.clear();
|
|
87
|
+
this._inodes.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async _readNew(filePath) {
|
|
91
|
+
let st;
|
|
92
|
+
try { st = await stat(filePath); } catch { return; } // ENOENT silence
|
|
93
|
+
|
|
94
|
+
const lastOffset = this._offsets.get(filePath) ?? 0;
|
|
95
|
+
const lastInode = this._inodes.get(filePath);
|
|
96
|
+
|
|
97
|
+
// Rotation: inode changed OR size shrunk → reset to 0
|
|
98
|
+
if ((lastInode !== undefined && st.ino !== lastInode) || st.size < lastOffset) {
|
|
99
|
+
this._offsets.set(filePath, 0);
|
|
100
|
+
this._inodes.set(filePath, st.ino);
|
|
101
|
+
return this._readNew(filePath); // re-enter with offset=0
|
|
102
|
+
}
|
|
103
|
+
this._inodes.set(filePath, st.ino);
|
|
104
|
+
|
|
105
|
+
if (st.size <= lastOffset) return;
|
|
106
|
+
|
|
107
|
+
const fh = await open(filePath, 'r');
|
|
108
|
+
try {
|
|
109
|
+
const len = st.size - lastOffset;
|
|
110
|
+
const buf = Buffer.alloc(len);
|
|
111
|
+
await fh.read(buf, 0, len, lastOffset);
|
|
112
|
+
this._offsets.set(filePath, st.size);
|
|
113
|
+
const text = buf.toString('utf8');
|
|
114
|
+
const lines = text.split(/\r?\n/);
|
|
115
|
+
const sourceDir = filePath.split('/').slice(0, -1).join('/');
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (line.trim().length === 0) continue;
|
|
118
|
+
const code = extractAmqCode(line);
|
|
119
|
+
if (!code) continue;
|
|
120
|
+
// 6-key frozen event payload (AD-4 contract — extra keys forbidden)
|
|
121
|
+
const event = Object.freeze({
|
|
122
|
+
adapter: ADAPTER,
|
|
123
|
+
errorCode: code,
|
|
124
|
+
file: filePath,
|
|
125
|
+
message: line,
|
|
126
|
+
source: sourceDir,
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
});
|
|
129
|
+
this.emit('error', event);
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
await fh.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default IbmMqLogAdapter;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/adapters/index.js
|
|
2
|
+
// Phase 2 — Legacy connectors registry. AD-1: returns unstarted instances; caller starts.
|
|
3
|
+
//
|
|
4
|
+
// Public API:
|
|
5
|
+
// loadAdapters(config) → unstarted Adapter[]
|
|
6
|
+
// stopAll(adapters, timeoutMs?) → Promise<void>, never throws
|
|
7
|
+
// ALLOWED_STACK_LIST → frozen string[] of valid stack names
|
|
8
|
+
|
|
9
|
+
import { SapLogAdapter } from './sap/index.js';
|
|
10
|
+
import { IbmMqLogAdapter } from './ibmmq/index.js';
|
|
11
|
+
import { As400LogAdapter } from './as400/index.js';
|
|
12
|
+
import { OracleFormsLogAdapter } from './oracle-forms/index.js';
|
|
13
|
+
import { CobolLogAdapter } from './cobol/index.js';
|
|
14
|
+
|
|
15
|
+
const ALLOWED_STACKS = Object.freeze(['sap', 'ibmmq', 'as400', 'oracle-forms', 'cobol']);
|
|
16
|
+
|
|
17
|
+
const FACTORIES = Object.freeze({
|
|
18
|
+
'sap': (cfg) => new SapLogAdapter(cfg),
|
|
19
|
+
'ibmmq': (cfg) => new IbmMqLogAdapter(cfg),
|
|
20
|
+
'as400': (cfg) => new As400LogAdapter(cfg),
|
|
21
|
+
'oracle-forms': (cfg) => new OracleFormsLogAdapter(cfg),
|
|
22
|
+
'cobol': (cfg) => new CobolLogAdapter(cfg),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load configured adapter instances WITHOUT starting them (AD-1).
|
|
27
|
+
*
|
|
28
|
+
* @param {object} config
|
|
29
|
+
* @param {string[]} config.legacyStacks subset of ['sap','ibmmq','as400','oracle-forms','cobol']
|
|
30
|
+
* @param {object} [config.sap] per-adapter config object
|
|
31
|
+
* @param {object} [config.ibmmq]
|
|
32
|
+
* @param {object} [config.as400]
|
|
33
|
+
* @param {object} [config['oracle-forms']]
|
|
34
|
+
* @param {object} [config.cobol]
|
|
35
|
+
* @returns {Array} unstarted adapters; caller invokes .start() on each
|
|
36
|
+
* @throws {Error} on unknown stack name (message names the offender)
|
|
37
|
+
*/
|
|
38
|
+
export function loadAdapters(config = {}) {
|
|
39
|
+
const stacks = Array.isArray(config.legacyStacks) ? config.legacyStacks : [];
|
|
40
|
+
for (const s of stacks) {
|
|
41
|
+
if (!ALLOWED_STACKS.includes(s)) {
|
|
42
|
+
throw new Error(`loadAdapters: unknown stack '${s}'. Allowed: ${ALLOWED_STACKS.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return stacks.map((s) => FACTORIES[s](config[s] || {}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stop all adapters in parallel with overall timeout (AC-11).
|
|
50
|
+
* Used by SIGTERM handler. Never throws — failures are swallowed so the
|
|
51
|
+
* shutdown path always returns and the event loop drains.
|
|
52
|
+
*
|
|
53
|
+
* @param {Array} adapters
|
|
54
|
+
* @param {number} [timeoutMs=5000]
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
export async function stopAll(adapters, timeoutMs = 5000) {
|
|
58
|
+
if (!Array.isArray(adapters) || adapters.length === 0) return;
|
|
59
|
+
const stopPromises = adapters.map((a) =>
|
|
60
|
+
Promise.resolve()
|
|
61
|
+
.then(() => (typeof a?.stop === 'function' ? a.stop() : undefined))
|
|
62
|
+
.catch(() => { /* swallow per never-throw contract */ })
|
|
63
|
+
);
|
|
64
|
+
// Bounded timer so a non-resolving stop() cannot block process exit.
|
|
65
|
+
let timer;
|
|
66
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
67
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
68
|
+
// Per project convention, unref the timer so it never holds the loop open
|
|
69
|
+
// beyond the rest of the work (AD-3 timer hygiene).
|
|
70
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
await Promise.race([Promise.all(stopPromises), timeoutPromise]);
|
|
74
|
+
} finally {
|
|
75
|
+
if (timer) clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const ALLOWED_STACK_LIST = ALLOWED_STACKS;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// src/adapters/oracle-forms/index.js
|
|
2
|
+
// OracleFormsLogAdapter — watches *.trc files in the configured Forms trace
|
|
3
|
+
// dir; emits 6-key error events. AD-1 lifecycle, AD-3 polling, AD-4 byte-position
|
|
4
|
+
// dedup with TAIL-on-startup default, AD-5 per-file ordering, AD-6 Latin-1
|
|
5
|
+
// mojibake-trap tolerance via latin1 decode (never throws on any byte).
|
|
6
|
+
// T-2-01 mitigation: default polling interval 5000 ms.
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { stat, open } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import chokidar from 'chokidar';
|
|
12
|
+
import { extractFormsCode } from './oracle-forms-parser.js';
|
|
13
|
+
|
|
14
|
+
const ADAPTER = 'oracle-forms';
|
|
15
|
+
|
|
16
|
+
// Decode buffer using Latin-1 (single-byte) encoding — never throws on any
|
|
17
|
+
// byte sequence. ORA/FRM message codes are ASCII so the regex match is
|
|
18
|
+
// unaffected by the choice; accented free-text payload bytes survive into
|
|
19
|
+
// `message` as Latin-1 characters (mojibake-tolerant per AD-6 Forms trap).
|
|
20
|
+
function decodeTolerant(buf) {
|
|
21
|
+
return buf.toString('latin1');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class OracleFormsLogAdapter extends EventEmitter {
|
|
25
|
+
constructor(config = {}) {
|
|
26
|
+
super();
|
|
27
|
+
this.traceDir = config.traceDir || '/u01/oracle/forms/trace';
|
|
28
|
+
this.interval = config.interval ?? 5000;
|
|
29
|
+
this.replayOnStart = config.replayOnStart === true;
|
|
30
|
+
this._watcher = null;
|
|
31
|
+
this._offsets = new Map(); // filePath → byte offset
|
|
32
|
+
this._inodes = new Map(); // filePath → inode
|
|
33
|
+
this._isRunning = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get isRunning() {
|
|
37
|
+
return this._isRunning;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async start() {
|
|
41
|
+
if (this._isRunning) return;
|
|
42
|
+
this._isRunning = true;
|
|
43
|
+
|
|
44
|
+
// Glob bound to *.trc only (wildcard — chokidar polling-mode bracket-class
|
|
45
|
+
// workaround from Plan 02-08 does NOT apply here; *.trc is a plain wildcard).
|
|
46
|
+
const glob = join(this.traceDir, '*.trc');
|
|
47
|
+
|
|
48
|
+
this._watcher = chokidar.watch(glob, {
|
|
49
|
+
usePolling: true,
|
|
50
|
+
interval: this.interval,
|
|
51
|
+
ignoreInitial: false, // we handle initial ourselves via TAIL seek
|
|
52
|
+
awaitWriteFinish: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const onAdd = async (filePath) => {
|
|
56
|
+
if (this.replayOnStart) {
|
|
57
|
+
this._offsets.set(filePath, 0);
|
|
58
|
+
// fall through to _readNew below
|
|
59
|
+
} else {
|
|
60
|
+
try {
|
|
61
|
+
const st = await stat(filePath);
|
|
62
|
+
this._offsets.set(filePath, st.size);
|
|
63
|
+
this._inodes.set(filePath, st.ino);
|
|
64
|
+
return; // TAIL mode — don't emit pre-existing content
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
this._readNew(filePath).catch(() => { /* ENOENT silence */ });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
this._watcher.on('add', onAdd);
|
|
73
|
+
this._watcher.on('change', (fp) => {
|
|
74
|
+
this._readNew(fp).catch(() => { /* ENOENT silence */ });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Swallow chokidar's own bubbled errors (e.g., watch-path ENOENT) —
|
|
78
|
+
// dual-layer suppression with the per-read try/catch ensures full
|
|
79
|
+
// ENOENT silence (canonical pattern from Plan 02-07).
|
|
80
|
+
this._watcher.on('error', () => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async stop() {
|
|
84
|
+
if (!this._isRunning) return;
|
|
85
|
+
this._isRunning = false;
|
|
86
|
+
if (this._watcher) {
|
|
87
|
+
await this._watcher.close().catch(() => {});
|
|
88
|
+
this._watcher = null;
|
|
89
|
+
}
|
|
90
|
+
this._offsets.clear();
|
|
91
|
+
this._inodes.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async _readNew(filePath) {
|
|
95
|
+
let st;
|
|
96
|
+
try {
|
|
97
|
+
st = await stat(filePath);
|
|
98
|
+
} catch {
|
|
99
|
+
return; // ENOENT silence
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lastOffset = this._offsets.get(filePath) ?? 0;
|
|
103
|
+
const lastInode = this._inodes.get(filePath);
|
|
104
|
+
|
|
105
|
+
// Rotation: inode changed OR size shrunk → reset to 0
|
|
106
|
+
if ((lastInode !== undefined && st.ino !== lastInode) || st.size < lastOffset) {
|
|
107
|
+
this._offsets.set(filePath, 0);
|
|
108
|
+
this._inodes.set(filePath, st.ino);
|
|
109
|
+
return this._readNew(filePath); // re-enter with offset=0
|
|
110
|
+
}
|
|
111
|
+
this._inodes.set(filePath, st.ino);
|
|
112
|
+
|
|
113
|
+
if (st.size <= lastOffset) return;
|
|
114
|
+
|
|
115
|
+
const fh = await open(filePath, 'r');
|
|
116
|
+
try {
|
|
117
|
+
const len = st.size - lastOffset;
|
|
118
|
+
const buf = Buffer.alloc(len);
|
|
119
|
+
await fh.read(buf, 0, len, lastOffset);
|
|
120
|
+
this._offsets.set(filePath, st.size);
|
|
121
|
+
const text = decodeTolerant(buf);
|
|
122
|
+
const lines = text.split(/\r?\n/);
|
|
123
|
+
const sourceDir = filePath.split('/').slice(0, -1).join('/');
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (line.trim().length === 0) continue;
|
|
126
|
+
const code = extractFormsCode(line);
|
|
127
|
+
if (!code) continue;
|
|
128
|
+
// 6-key frozen event payload (AD-4 contract — extra keys forbidden)
|
|
129
|
+
const event = Object.freeze({
|
|
130
|
+
adapter: ADAPTER,
|
|
131
|
+
errorCode: code,
|
|
132
|
+
file: filePath,
|
|
133
|
+
message: line,
|
|
134
|
+
source: sourceDir,
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
this.emit('error', event);
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
await fh.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default OracleFormsLogAdapter;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/adapters/oracle-forms/oracle-forms-parser.js
|
|
2
|
+
// T-2-03 isolation surface: regex exported as a named const so it can be
|
|
3
|
+
// unit-tested against arbitrary text decoupled from the file-watching
|
|
4
|
+
// machinery in index.js. The watcher itself is bound to a literal *.trc
|
|
5
|
+
// glob so this regex never engages arbitrary text by construction.
|
|
6
|
+
export const ORACLE_FORMS_REGEX = /(?:Line \d+:|^)\s*(ORA-\d{5}|FRM-\d{5}):\s*(.+)/;
|
|
7
|
+
|
|
8
|
+
export function extractFormsCode(line) {
|
|
9
|
+
const m = line.match(ORACLE_FORMS_REGEX);
|
|
10
|
+
return m ? m[1] : null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/adapters/sap/index.js
|
|
2
|
+
// SapLogAdapter — watches dev_w* and SLOG* files; emits 6-key error events.
|
|
3
|
+
// AD-1 lifecycle, AD-3 polling, AD-4 byte-position dedup, AD-5 per-file ordering.
|
|
4
|
+
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { stat, open } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import chokidar from 'chokidar';
|
|
9
|
+
import { SAP_DEV_W_REGEX, SAP_SLOG_REGEX, normalizeDevWMatch, normalizeSlogMatch } from './sap-parser.js';
|
|
10
|
+
|
|
11
|
+
const ADAPTER = 'sap';
|
|
12
|
+
|
|
13
|
+
export class SapLogAdapter extends EventEmitter {
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
super();
|
|
16
|
+
this.sapDir = config.sapDir || '/usr/sap/PRD';
|
|
17
|
+
this.instanceNumber = config.instanceNumber || '00';
|
|
18
|
+
this.interval = config.interval ?? 5000;
|
|
19
|
+
this.replayOnStart = config.replayOnStart === true;
|
|
20
|
+
this._watchers = [];
|
|
21
|
+
this._offsets = new Map(); // filePath → byte offset
|
|
22
|
+
this._inodes = new Map(); // filePath → inode
|
|
23
|
+
this._isRunning = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get isRunning() { return this._isRunning; }
|
|
27
|
+
|
|
28
|
+
async start() {
|
|
29
|
+
if (this._isRunning) return;
|
|
30
|
+
this._isRunning = true;
|
|
31
|
+
|
|
32
|
+
const workGlob = join(this.sapDir, `D${this.instanceNumber}`, 'work', 'dev_w*');
|
|
33
|
+
const logGlob = join(this.sapDir, `D${this.instanceNumber}`, 'log', 'SLOG*');
|
|
34
|
+
|
|
35
|
+
const watcherOpts = {
|
|
36
|
+
usePolling: true,
|
|
37
|
+
interval: this.interval,
|
|
38
|
+
ignoreInitial: false, // we handle initial ourselves via TAIL seek
|
|
39
|
+
awaitWriteFinish: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const wWork = chokidar.watch(workGlob, watcherOpts);
|
|
43
|
+
const wSlog = chokidar.watch(logGlob, watcherOpts);
|
|
44
|
+
|
|
45
|
+
const handle = (filePath, kind) => {
|
|
46
|
+
// ENOENT-tolerant: stat() inside _readNew handles disappearance.
|
|
47
|
+
this._readNew(filePath, kind).catch(() => { /* swallow per ENOENT silence rule */ });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 'add' fires for files present at watch start; honor TAIL/replay flag.
|
|
51
|
+
const onAdd = (kind) => async (filePath) => {
|
|
52
|
+
if (this.replayOnStart) {
|
|
53
|
+
this._offsets.set(filePath, 0);
|
|
54
|
+
} else {
|
|
55
|
+
try {
|
|
56
|
+
const st = await stat(filePath);
|
|
57
|
+
this._offsets.set(filePath, st.size);
|
|
58
|
+
this._inodes.set(filePath, st.ino);
|
|
59
|
+
return; // TAIL mode — don't emit pre-existing content
|
|
60
|
+
} catch { return; }
|
|
61
|
+
}
|
|
62
|
+
handle(filePath, kind);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
wWork.on('add', onAdd('dev_w'));
|
|
66
|
+
wWork.on('change', (fp) => handle(fp, 'dev_w'));
|
|
67
|
+
wSlog.on('add', onAdd('slog'));
|
|
68
|
+
wSlog.on('change', (fp) => handle(fp, 'slog'));
|
|
69
|
+
|
|
70
|
+
// chokidar's 'error' events would otherwise bubble; swallow to keep ENOENT silent.
|
|
71
|
+
wWork.on('error', () => {});
|
|
72
|
+
wSlog.on('error', () => {});
|
|
73
|
+
|
|
74
|
+
this._watchers.push(wWork, wSlog);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async stop() {
|
|
78
|
+
if (!this._isRunning) return;
|
|
79
|
+
this._isRunning = false;
|
|
80
|
+
await Promise.all(this._watchers.map((w) => w.close().catch(() => {})));
|
|
81
|
+
this._watchers = [];
|
|
82
|
+
this._offsets.clear();
|
|
83
|
+
this._inodes.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _readNew(filePath, kind) {
|
|
87
|
+
let st;
|
|
88
|
+
try { st = await stat(filePath); } catch { return; } // ENOENT silence
|
|
89
|
+
|
|
90
|
+
const lastOffset = this._offsets.get(filePath) ?? 0;
|
|
91
|
+
const lastInode = this._inodes.get(filePath);
|
|
92
|
+
|
|
93
|
+
// Rotation: inode changed OR size shrunk → reset to 0
|
|
94
|
+
if ((lastInode !== undefined && st.ino !== lastInode) || st.size < lastOffset) {
|
|
95
|
+
this._offsets.set(filePath, 0);
|
|
96
|
+
this._inodes.set(filePath, st.ino);
|
|
97
|
+
return this._readNew(filePath, kind); // re-enter with offset=0
|
|
98
|
+
}
|
|
99
|
+
this._inodes.set(filePath, st.ino);
|
|
100
|
+
|
|
101
|
+
if (st.size <= lastOffset) return;
|
|
102
|
+
|
|
103
|
+
const fh = await open(filePath, 'r');
|
|
104
|
+
try {
|
|
105
|
+
const len = st.size - lastOffset;
|
|
106
|
+
const buf = Buffer.alloc(len);
|
|
107
|
+
await fh.read(buf, 0, len, lastOffset);
|
|
108
|
+
this._offsets.set(filePath, st.size);
|
|
109
|
+
// Tolerate CRLF and LF; split, drop trailing empty (no event for blank lines per AD-6 SAP empty-line trap).
|
|
110
|
+
const text = buf.toString('utf8');
|
|
111
|
+
const lines = text.split(/\r?\n/);
|
|
112
|
+
const sourceDir = filePath.split('/').slice(0, -1).join('/');
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (line.trim().length === 0) continue;
|
|
115
|
+
this._tryEmit(line, filePath, sourceDir, kind);
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
await fh.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_tryEmit(line, filePath, sourceDir, kind) {
|
|
123
|
+
let errorCode = null;
|
|
124
|
+
if (kind === 'dev_w') {
|
|
125
|
+
const m = line.match(SAP_DEV_W_REGEX);
|
|
126
|
+
errorCode = normalizeDevWMatch(m);
|
|
127
|
+
} else if (kind === 'slog') {
|
|
128
|
+
const m = line.match(SAP_SLOG_REGEX);
|
|
129
|
+
errorCode = normalizeSlogMatch(m);
|
|
130
|
+
}
|
|
131
|
+
if (errorCode === null) return; // no recognizable code → no event
|
|
132
|
+
|
|
133
|
+
// 6-key frozen event payload (AD-4 contract — extra keys forbidden)
|
|
134
|
+
const event = Object.freeze({
|
|
135
|
+
adapter: ADAPTER,
|
|
136
|
+
errorCode,
|
|
137
|
+
file: filePath,
|
|
138
|
+
message: line,
|
|
139
|
+
source: sourceDir,
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
});
|
|
142
|
+
this.emit('error', event);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default SapLogAdapter;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/adapters/sap/sap-parser.js
|
|
2
|
+
// T-2-03 mitigation: regexes exported as named constants for isolated testing.
|
|
3
|
+
// Adapter binds chokidar to dev_w* and SLOG* file globs only; these regexes
|
|
4
|
+
// never run against arbitrary text by the adapter itself.
|
|
5
|
+
|
|
6
|
+
// Matches ABAP work-process trace error lines:
|
|
7
|
+
// "M *** ERROR => Runtime error CONVT_NO_NUMBER"
|
|
8
|
+
// "M *** ERROR => Exception condition CX_SY_OPEN_SQL_DB raised"
|
|
9
|
+
// "B *** ERROR => SQL error in SELECT, sy-subrc = 4"
|
|
10
|
+
export const SAP_DEV_W_REGEX = /\*\*\* ERROR =>\s+(?:Runtime error|Exception condition|SQL error in [A-Z]+,)\s*([A-Z_]+(?:[A-Z_0-9]*)|sy-subrc\s*=\s*\d+)/i;
|
|
11
|
+
|
|
12
|
+
// Matches SLOG pipe-delimited error/fatal records:
|
|
13
|
+
// "| 2024-01-15 09:12:44 | E | 04 | Database error..., sy-subrc = 4 |"
|
|
14
|
+
// "| 2024-01-15 14:01:55 | F | 22 | Short dump 4815162343 created |"
|
|
15
|
+
export const SAP_SLOG_REGEX = /^\|\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s*\|\s*([EF])\s*\|\s*\d+\s*\|\s*(.+?)\s*\|/;
|
|
16
|
+
|
|
17
|
+
// Normalizes captured payload to errorCode string (or null when no recognizable code).
|
|
18
|
+
export function normalizeDevWMatch(match) {
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const raw = (match[1] || '').trim();
|
|
21
|
+
if (/^sy-subrc/i.test(raw)) {
|
|
22
|
+
const m = raw.match(/=\s*(\d+)/);
|
|
23
|
+
return m ? `SY-SUBRC=${m[1]}` : 'SY-SUBRC';
|
|
24
|
+
}
|
|
25
|
+
return raw.toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeSlogMatch(match) {
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const body = (match[2] || '').trim();
|
|
31
|
+
// Try to extract a known runtime-error keyword from the SLOG message body
|
|
32
|
+
const m = body.match(/(CONVT_NO_NUMBER|CX_SY_OPEN_SQL_DB|sy-subrc\s*=\s*\d+|Short dump \d+)/i);
|
|
33
|
+
if (!m) return null;
|
|
34
|
+
const cap = m[1].trim();
|
|
35
|
+
if (/^sy-subrc/i.test(cap)) {
|
|
36
|
+
const num = cap.match(/=\s*(\d+)/);
|
|
37
|
+
return num ? `SY-SUBRC=${num[1]}` : 'SY-SUBRC';
|
|
38
|
+
}
|
|
39
|
+
if (/^Short dump/i.test(cap)) return 'SHORT_DUMP';
|
|
40
|
+
return cap.toUpperCase();
|
|
41
|
+
}
|
|
@@ -17,11 +17,44 @@ export class FrameworkDetector {
|
|
|
17
17
|
return { framework: 'dotnet', version: null, buildTool: 'dotnet' };
|
|
18
18
|
case 'go':
|
|
19
19
|
return { framework: 'go', version: null, buildTool: 'go' };
|
|
20
|
+
case 'ios':
|
|
21
|
+
return this.detectSwiftFramework();
|
|
22
|
+
case 'kotlin':
|
|
23
|
+
return this.detectKotlinFramework();
|
|
20
24
|
default:
|
|
21
25
|
return { framework: 'unknown', version: null, buildTool: null };
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
async detectSwiftFramework() {
|
|
30
|
+
for (const f of this.files) {
|
|
31
|
+
if (!f.name.endsWith('.swift')) continue;
|
|
32
|
+
try {
|
|
33
|
+
const c = await this.fileReader.read(f.path);
|
|
34
|
+
if (c.content && c.content.includes('import SwiftUI')) {
|
|
35
|
+
return { framework: 'swiftui', version: null, buildTool: 'xcodebuild' };
|
|
36
|
+
}
|
|
37
|
+
if (c.content && c.content.includes('import UIKit')) {
|
|
38
|
+
return { framework: 'uikit', version: null, buildTool: 'xcodebuild' };
|
|
39
|
+
}
|
|
40
|
+
} catch (_) {}
|
|
41
|
+
}
|
|
42
|
+
return { framework: 'swift-plain', version: null, buildTool: 'xcodebuild' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async detectKotlinFramework() {
|
|
46
|
+
for (const f of this.files) {
|
|
47
|
+
if (!f.name.endsWith('.kt')) continue;
|
|
48
|
+
try {
|
|
49
|
+
const c = await this.fileReader.read(f.path);
|
|
50
|
+
if (c.content && c.content.includes('androidx.compose')) {
|
|
51
|
+
return { framework: 'jetpack-compose', version: null, buildTool: 'gradle' };
|
|
52
|
+
}
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
return { framework: 'android-xml', version: null, buildTool: 'gradle' };
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
async detectJavaFramework() {
|
|
26
59
|
const pomFile = this.files.find(f => f.name === 'pom.xml');
|
|
27
60
|
if (pomFile) {
|
|
@@ -16,6 +16,15 @@ export class LanguageDetector {
|
|
|
16
16
|
|
|
17
17
|
findMarkers() {
|
|
18
18
|
const markers = {
|
|
19
|
+
// iOS/Swift (highest priority when BOTH iOS and Java exist, per product rule:
|
|
20
|
+
// "Swift wins — it means the dev copied iOS files into a Java repo").
|
|
21
|
+
ios: this.files.some(f =>
|
|
22
|
+
f.name === 'Package.swift' ||
|
|
23
|
+
f.name === 'Podfile' ||
|
|
24
|
+
f.name === 'Podfile.lock' ||
|
|
25
|
+
f.name === 'Cartfile' ||
|
|
26
|
+
(f.name && (f.name.endsWith('.xcodeproj') || f.name.endsWith('.xcworkspace')))
|
|
27
|
+
),
|
|
19
28
|
java: this.files.some(f => f.name === 'pom.xml' || f.name === 'build.gradle'),
|
|
20
29
|
kotlin: this.files.some(f => f.name === 'build.gradle.kts'),
|
|
21
30
|
node: this.files.some(f => f.name === 'package.json'),
|
|
@@ -47,6 +56,20 @@ export class LanguageDetector {
|
|
|
47
56
|
.filter(([_, exists]) => exists)
|
|
48
57
|
.map(([lang]) => lang);
|
|
49
58
|
|
|
59
|
+
// Priority rule: iOS wins over anything else when its strong markers are present.
|
|
60
|
+
// Rationale: if a repo has BOTH Package.swift/Podfile/xcodeproj AND pom.xml, the
|
|
61
|
+
// developer copied iOS files into a Java repo (or vice versa) - the iOS app is
|
|
62
|
+
// the shipping artifact.
|
|
63
|
+
if (markers.ios) return 'ios';
|
|
64
|
+
// Even without strong iOS markers, a repo dominated by .swift source files is iOS.
|
|
65
|
+
const swiftCount = extensions['.swift'] || 0;
|
|
66
|
+
const totalCode = Object.entries(extensions)
|
|
67
|
+
.filter(([k]) => ['.swift','.m','.mm','.h','.java','.kt','.js','.ts','.py','.go','.rb','.cs','.php'].includes(k))
|
|
68
|
+
.reduce((s, [,v]) => s + v, 0);
|
|
69
|
+
if (swiftCount > 0 && (totalCode === swiftCount || swiftCount / Math.max(totalCode,1) >= 0.5)) {
|
|
70
|
+
return 'ios';
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
if (detected.length === 1) return detected[0];
|
|
51
74
|
if (detected.length > 1) {
|
|
52
75
|
// Filter out meta-markers (docker, static) if real languages exist
|
|
@@ -83,7 +106,9 @@ export class LanguageDetector {
|
|
|
83
106
|
'.dart': 'dart',
|
|
84
107
|
'.ex': 'elixir',
|
|
85
108
|
'.exs': 'elixir',
|
|
86
|
-
'.swift': '
|
|
109
|
+
'.swift': 'ios',
|
|
110
|
+
'.m': 'ios',
|
|
111
|
+
'.mm': 'ios',
|
|
87
112
|
'.scala': 'scala',
|
|
88
113
|
};
|
|
89
114
|
return map[ext] || null;
|