abapgit-agent 1.17.9 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -216,13 +216,20 @@ abapgit-agent debug step --type continue --json # continue to next brea
216
216
  abapgit-agent debug stack --json # call stack (shows which test method is active)
217
217
  ```
218
218
 
219
- > **`--expand` does not support table-row index notation.** `--expand "LT_DATA[1]"` returns "Variable not found". Use `--expand LT_DATA` to expand all rows, then `--name` on a specific scalar component.
219
+ > To inspect a specific table row, use `--expand TABLE[N]`:
220
+ > ```bash
221
+ > abapgit-agent debug vars --expand LT_DATA[1] --json # first row — shows its fields
222
+ > abapgit-agent debug vars --expand LT_DATA[1]->CARRID --json # drill into one field in row 1
223
+ > ```
224
+ > Use `--expand LT_DATA` (without index) to see all rows first.
220
225
 
221
- > **Field symbols assigned via `ASSIGNING FIELD-SYMBOL(<fs>)` do not appear in `debug vars` output.** Only named variables (`DATA`, `FINAL`) are listed. To inspect the current loop row, expand the parent table:
226
+ > **Field symbols are supported via source-parse enrichment.** `FIELD-SYMBOLS <FS>` declarations are not returned by ADT's `getChildVariables` hierarchy, so `debug vars` fetches the current method's source, extracts all `FIELD-SYMBOLS <name>` declarations, and requests them directly via `getVariables`. Assigned field symbols appear in the variable list and can be expanded like any other variable:
222
227
  > ```bash
223
- > abapgit-agent debug vars --expand RT_DATA --json # table rows visible by index
228
+ > abapgit-agent debug vars --json # <LS> appears in the list
229
+ > abapgit-agent debug vars --expand '<LS>' --json # expand field symbol children
230
+ > abapgit-agent debug vars --expand '<LS>'->NAME --json # single field
224
231
  > ```
225
- > Alternatively, step over the assignment and use `--name` on a scalar component directly.
232
+ > Unassigned field symbols will be returned with an empty or unset value.
226
233
 
227
234
  > **`step --type continue` return values:**
228
235
  > - `{"continued":true,"finished":true}` — program ran to **completion** (ADT returned HTTP 500, session is over). Do not re-attach.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abapgit-agent",
3
- "version": "1.17.9",
3
+ "version": "1.18.1",
4
4
  "description": "ABAP Git Agent - Pull and activate ABAP code via abapGit from any git repository",
5
5
  "files": [
6
6
  "bin/",
@@ -42,10 +42,13 @@
42
42
  "test:customize": "node tests/run-all.js --customize",
43
43
  "test:cmd:debug": "node tests/run-all.js --cmd --command=debug",
44
44
  "test:debug": "node tests/run-all.js --debug",
45
- "test:debug:scenarios": "bash tests/integration/debug-scenarios.sh",
46
- "test:debug:scenarios:1": "bash tests/integration/debug-scenarios.sh 1",
47
- "test:debug:scenarios:2": "bash tests/integration/debug-scenarios.sh 2",
48
- "test:debug:scenarios:3": "bash tests/integration/debug-scenarios.sh 3",
45
+ "test:debug:scripted": "node tests/run-all.js --debug-scripted",
46
+ "test:debug:scripted:3": "bash tests/integration/debug-scripted-scenarios.sh 3",
47
+ "test:debug:scripted:4": "bash tests/integration/debug-scripted-scenarios.sh 4",
48
+ "test:debug:scripted:5": "bash tests/integration/debug-scripted-scenarios.sh 5",
49
+ "test:debug:repl": "node tests/run-all.js --debug-repl",
50
+ "test:debug:repl:1": "bash tests/integration/debug-repl-scenarios.sh 1",
51
+ "test:debug:repl:2": "bash tests/integration/debug-repl-scenarios.sh 2",
49
52
  "test:cmd:upgrade": "node tests/run-all.js --cmd --command=upgrade",
50
53
  "test:lifecycle": "node tests/run-all.js --lifecycle",
51
54
  "test:pull": "node tests/run-all.js --pull",
@@ -56,13 +59,14 @@
56
59
  "unrelease": "node scripts/unrelease.js"
57
60
  },
58
61
  "dependencies": {
62
+ "axios": "^1.15.2",
59
63
  "dotenv": "^16.3.1",
60
64
  "node-fetch": "^2.7.0",
61
65
  "uuid": "^9.0.0",
62
66
  "winston": "^3.11.0"
63
67
  },
64
68
  "devDependencies": {
65
- "jest": "^29.7.0",
69
+ "jest": "^30.3.0",
66
70
  "supertest": "^7.2.2"
67
71
  },
68
72
  "engines": {
@@ -19,8 +19,10 @@
19
19
  * - Local state persisted in tmp so stateless CLI calls share the breakpoint list.
20
20
  *
21
21
  * Session management for AI/scripting mode (--json):
22
- * attach --json spawns a background daemon (debug-daemon.js) that holds the
23
- * stateful ADT HTTP connection open. step/vars/stack/terminate are thin IPC
22
+ * attach --json runs the IPC socket server in-process after emitting the
23
+ * session JSON line. This keeps the ADT TCP connection alive in the same
24
+ * process that called attach() — SAP pins the debug session to the
25
+ * originating TCP connection. step/vars/stack/terminate are thin IPC
24
26
  * clients that connect to the daemon's Unix socket and exchange one JSON command
25
27
  * per invocation. The daemon exits when terminate is called or after 30 min idle.
26
28
  *
@@ -30,7 +32,6 @@
30
32
 
31
33
  const net = require('net');
32
34
  const path = require('path');
33
- const { spawn } = require('child_process');
34
35
  const { AdtHttp } = require('../utils/adt-http');
35
36
  const { DebugSession } = require('../utils/debug-session');
36
37
  const debugStateModule = require('../utils/debug-state');
@@ -47,7 +48,13 @@ const _loadBpState = debugStateModule.loadBreakpointState;
47
48
  // Daemon socket path — may be absent in unit-test mocks
48
49
  const _getDaemonSocketPath = debugStateModule.getDaemonSocketPath;
49
50
 
50
- const ADT_CLIENT_ID = 'ABAPGIT-AGENT-CLI';
51
+ // Allow callers to override the terminalId/ideId used for listener and
52
+ // breakpoint registration via env var. This lets CI jobs use a unique ID
53
+ // per run to avoid stale listeners from crashed previous runs blocking
54
+ // the listener POST (SAP ICM returns 400 if another listener with the
55
+ // same terminalId is still registered and DELETE is not available on the
56
+ // system).
57
+ const ADT_CLIENT_ID = process.env.ABAPGIT_DEBUG_TERMINAL_ID || 'ABAPGIT-AGENT-CLI';
51
58
 
52
59
  // ─── Helpers ─────────────────────────────────────────────────────────────────
53
60
 
@@ -632,8 +639,8 @@ async function cmdDelete(args, config, adt) {
632
639
  async function cmdAttach(args, config, adt) {
633
640
  const sessionIdOverride = val(args, '--session');
634
641
  const jsonOutput = hasFlag(args, '--json');
635
- // Per-poll timeout in seconds sent to ADT (ADT blocks the POST for this long)
636
642
  const pollTimeout = parseInt(val(args, '--timeout') || '30', 10);
643
+ const maxListenSeconds = parseInt(val(args, '--max-listen') || '240', 10);
637
644
  // Shorter timeout used in takeover mode — keeps the connection alive but
638
645
  // lets ADT process stepContinue quickly and lets session 2 exit within
639
646
  // ~5 seconds of clearActiveSession being written.
@@ -646,8 +653,42 @@ async function cmdAttach(args, config, adt) {
646
653
  process.stderr.write('\n Waiting for breakpoint hit... (run your ABAP program in a separate window)\n');
647
654
  }
648
655
 
656
+ // Fresh session for attach flow. Don't set stateful here — CSRF fetch
657
+ // without stateful avoids tying up a dialog WP. Stateful is added per-call
658
+ // by debug-session.js STATEFUL_HEADER on attach/getStack/step/vars.
659
+ adt.clearSession();
660
+ if (adt._axios) {
661
+ const agent = adt._axios.defaults.httpsAgent || adt._axios.defaults.httpAgent;
662
+ if (agent) agent.destroy();
663
+ if (adt._axios.defaults.httpsAgent) {
664
+ adt._axios.defaults.httpsAgent = new (require('https').Agent)({ rejectUnauthorized: false, keepAlive: true });
665
+ } else if (adt._axios.defaults.httpAgent) {
666
+ adt._axios.defaults.httpAgent = new (require('http').Agent)({ keepAlive: true });
667
+ }
668
+ }
649
669
  await adt.fetchCsrfToken();
650
670
 
671
+ // Delete any stale listener registered under our terminalId from a previous
672
+ // (possibly crashed) run. A frozen ABAP work process holding a listener for
673
+ // the same terminalId causes SAP ICM to return HTTP 400 "Service cannot be
674
+ // reached" for every new listener POST. Deleting first clears that state.
675
+ // Ignore errors — there may be no stale listener to delete.
676
+ if (!sessionIdOverride) {
677
+ try {
678
+ const delUrl = `/sap/bc/adt/debugger/listeners` +
679
+ `?debuggingMode=user` +
680
+ `&requestUser=${encodeURIComponent((config.user || '').toUpperCase())}` +
681
+ `&terminalId=${encodeURIComponent(ADT_CLIENT_ID)}` +
682
+ `&ideId=${encodeURIComponent(ADT_CLIENT_ID)}` +
683
+ `&checkConflict=false` +
684
+ `&notifyConflict=true`;
685
+ await adt.delete(delUrl);
686
+ if (jsonOutput) process.stderr.write(`[debug-attach] deleted stale listener\n`);
687
+ } catch (e) {
688
+ if (jsonOutput) process.stderr.write(`[debug-attach] delete listener (cleanup): ${e.statusCode || e.message}\n`);
689
+ }
690
+ }
691
+
651
692
  // Re-POST local breakpoints to refresh them on the server before listening.
652
693
  // Breakpoints expire when the SAP session or work process is restarted.
653
694
  // This ensures they are active regardless of when they were originally set.
@@ -672,6 +713,7 @@ async function cmdAttach(args, config, adt) {
672
713
  }
673
714
  let sessionId = sessionIdOverride;
674
715
  let positionResult = null;
716
+ let session = null;
675
717
 
676
718
  if (!sessionId) {
677
719
  // ADT listeners: POST long-polls until a breakpoint is hit (or timeout).
@@ -683,9 +725,9 @@ async function cmdAttach(args, config, adt) {
683
725
  `&terminalId=${encodeURIComponent(listenTerminalId)}` +
684
726
  `&ideId=${encodeURIComponent(listenTerminalId)}`;
685
727
 
686
- const MAX_POLLS = Math.ceil(240 / pollTimeout); // up to 4 minutes total
728
+ const MAX_POLLS = Math.ceil(maxListenSeconds / pollTimeout);
687
729
  // In takeover mode we switch to takeoverPollTimeout — recalculate the limit then.
688
- const MAX_TAKEOVER_POLLS = Math.ceil(240 / takeoverPollTimeout);
730
+ const MAX_TAKEOVER_POLLS = Math.ceil(maxListenSeconds / takeoverPollTimeout);
689
731
  let dots = 0;
690
732
  const attachStartedAt = Date.now(); // used to detect if another session takes over
691
733
  let takenOver = false;
@@ -723,6 +765,12 @@ async function cmdAttach(args, config, adt) {
723
765
  await new Promise(r => setTimeout(r, 2000));
724
766
  continue;
725
767
  }
768
+ if (err && err.statusCode === 400 &&
769
+ err.body && err.body.includes('Service cannot be reached')) {
770
+ if (jsonOutput) process.stderr.write(`[debug-attach] listener POST returned 400 (Service cannot be reached), retrying in 5s...\n`);
771
+ await new Promise(r => setTimeout(r, 5000));
772
+ continue;
773
+ }
726
774
  if (err && err.statusCode === 404) {
727
775
  console.error(
728
776
  '\n Debug session commands require ADT Debugger service (listeners).' +
@@ -742,6 +790,7 @@ async function cmdAttach(args, config, adt) {
742
790
  const debuggeeIdMatch = resp.body.match(/<DEBUGGEE_ID>([^<]+)<\/DEBUGGEE_ID>/i) ||
743
791
  resp.body.match(/DEBUGGEE_ID="([^"]+)"/i);
744
792
  sessionId = debuggeeIdMatch ? debuggeeIdMatch[1].trim() : null;
793
+ if (jsonOutput && sessionId) process.stderr.write(`[debug-attach] listener returned debuggeeId=${sessionId}\n`);
745
794
 
746
795
  if (!sessionId) {
747
796
  // Fallback: some ADT versions put session info in different attributes
@@ -773,6 +822,34 @@ async function cmdAttach(args, config, adt) {
773
822
  }
774
823
  continue;
775
824
  }
825
+
826
+ // Attach to the WP and verify it's alive before committing.
827
+ // In --json mode: if the WP is a stale session from a previous run,
828
+ // attach() may succeed but getPosition() returns 400. When that happens,
829
+ // reset the SAP session fully and continue listening for a fresh BP hit.
830
+ {
831
+ if (!jsonOutput) process.stderr.write('\n Attaching to debug session...\n');
832
+ const candidateSession = new DebugSession(adt, sessionId);
833
+ try {
834
+ await candidateSession.attach(sessionId, (config.user || '').toUpperCase());
835
+ } catch (e) {
836
+ if (jsonOutput) {
837
+ process.stderr.write(`[debug-attach] attach() failed: ${e.message || e} — resetting session\n`);
838
+ adt.clearSession();
839
+ await adt.fetchCsrfToken();
840
+ sessionId = null;
841
+ continue;
842
+ }
843
+ printHttpError(e, { prefix: ' Error during attach' });
844
+ if (e.body) console.error(' Response body:', e.body.substring(0, 400));
845
+ process.exit(1);
846
+ }
847
+ // attach() succeeded — skip getPosition() validation.
848
+ // With keepAlive, the connection is shared and getStack via the
849
+ // in-process daemon will use the same AdtHttp + same TCP socket.
850
+ positionResult = { position: {}, source: [] };
851
+ session = candidateSession;
852
+ }
776
853
  break;
777
854
  }
778
855
 
@@ -799,93 +876,53 @@ async function cmdAttach(args, config, adt) {
799
876
  if (!jsonOutput) process.stderr.write(' Listener timed out waiting for other session to finish.\n\n');
800
877
  process.exit(0);
801
878
  }
802
- console.error('\n Timeout: No breakpoint was hit within 4 minutes.\n');
879
+ console.error(`\n Timeout: No breakpoint was hit within ${maxListenSeconds}s.\n`);
803
880
  process.exit(1);
804
881
  }
805
882
  }
806
883
 
807
- const session = new DebugSession(adt, sessionId);
808
-
809
- // If we got here via the listener (not --session override), we have a
810
- // DEBUGGEE_ID and need to call ?method=attach to register as the active
811
- // debugger for that work process. Without this step, all subsequent calls
812
- // (getStack, getVariables, step) return "noSessionAttached" (T100-530).
813
- if (!sessionIdOverride) {
814
- if (!jsonOutput) {
815
- process.stderr.write('\n Attaching to debug session...\n');
816
- }
884
+ // When using --session override (no listener), do attach+getPosition here.
885
+ if (sessionIdOverride) {
886
+ session = new DebugSession(adt, sessionId);
887
+ // getPosition is best-effort for --session override (e.g. human REPL recovery)
817
888
  try {
818
- await session.attach(sessionId, (config.user || '').toUpperCase());
889
+ positionResult = await session.getPosition();
819
890
  } catch (e) {
820
- printHttpError(e, { prefix: ' Error during attach' });
821
- if (e.body) console.error(' Response body:', e.body.substring(0, 400));
822
- process.exit(1);
891
+ positionResult = { position: {}, source: [] };
823
892
  }
824
893
  }
825
894
 
826
- // Fetch position + variables now that we have a live session
827
- try {
828
- positionResult = await session.getPosition();
829
- } catch (e) {
830
- // Position fetch is best-effort; proceed with empty state
831
- positionResult = { position: {}, source: [] };
832
- }
833
-
834
- // Dummy loop body retained for structural compatibility (never executes)
835
- if (false) {
836
- const MAX_POLLS = 0;
837
- const POLL_INTERVAL = 0;
838
- for (let i = 0; i < MAX_POLLS; i++) {
839
- try {
840
- const frames = await session.getStack();
841
- if (frames && frames.length > 0 && frames[0].line > 0) {
842
- positionResult = await session.getPosition();
843
- break;
844
- }
845
- } catch (e) {
846
- // Stack not ready yet — keep polling
847
- }
848
- await new Promise(r => setTimeout(r, POLL_INTERVAL));
849
- }
895
+ // session and positionResult are now set (either via listener loop or --session override)
896
+ if (!session) {
897
+ // Should not reach here — listener loop either sets session or exits
898
+ console.error('\n Internal error: session not initialized after attach.\n');
899
+ process.exit(1);
850
900
  }
901
+ if (!positionResult) positionResult = { position: {}, source: [] };
851
902
 
852
903
  const { position, source } = positionResult;
853
904
  saveActiveSession(config, { sessionId, position });
854
905
 
855
906
  if (jsonOutput) {
856
- // Spawn the background daemon to hold the stateful ADT HTTP connection.
857
- // The daemon process inherits the exact SAP_SESSIONID cookie + CSRF token
858
- // via the snapshot env var, so it reuses the same ABAP work process.
907
+ // Run the socket server in-process so all ADT requests reuse the same TCP
908
+ // connection that called attach(). SAP pins the debug session to the
909
+ // originating TCP connection a new connection returns HTTP 400.
859
910
  const socketPath = _getDaemonSocketPath ? _getDaemonSocketPath(config) : null;
860
911
  if (socketPath) {
861
- const snapshot = { csrfToken: adt.csrfToken, cookies: adt.cookies };
862
- const daemonEnv = {
863
- ...process.env,
864
- DEBUG_DAEMON_MODE: '1',
865
- DEBUG_DAEMON_CONFIG: JSON.stringify(config),
866
- DEBUG_DAEMON_SESSION_ID: sessionId,
867
- DEBUG_DAEMON_SOCK_PATH: socketPath,
868
- DEBUG_DAEMON_SESSION_SNAPSHOT: JSON.stringify(snapshot)
869
- };
870
- const daemonScript = path.resolve(__dirname, '../utils/debug-daemon.js');
871
- const child = spawn(process.execPath, [daemonScript], {
872
- detached: true,
873
- stdio: ['ignore', 'ignore', 'ignore'],
874
- env: daemonEnv
875
- });
876
- child.unref();
877
-
878
- // Wait for daemon socket to appear (up to 5 s) before returning JSON
879
- try {
880
- await waitForSocket(socketPath, 5000);
881
- // Persist socket path in session state so step/vars/stack/terminate find it
882
- saveActiveSession(config, { sessionId, position, socketPath });
883
- } catch (e) {
884
- // Non-fatal: fall back to stateless direct-ADT mode
885
- }
912
+ // Save cookies so terminate can release the frozen WP even if the daemon is gone
913
+ saveActiveSession(config, { sessionId, position, socketPath, cookies: adt.cookies });
886
914
  }
887
915
 
916
+ // Emit session JSON now so the caller (script / AI) knows the BP was hit.
888
917
  console.log(JSON.stringify({ session: sessionId, position, source }));
918
+
919
+ if (socketPath) {
920
+ const { startDaemon } = require('../utils/debug-daemon');
921
+ // Blocks until terminate is called or idle timeout — intentional.
922
+ // The process stays alive as the IPC socket server, holding the ADT
923
+ // TCP connection open. The caller runs this command in the background (&).
924
+ await startDaemon(session, socketPath);
925
+ }
889
926
  return;
890
927
  }
891
928
 
@@ -1050,8 +1087,8 @@ async function cmdVars(args, config, adt) {
1050
1087
  * LO_FACTORY->MT_COMMAND_MAP (object attr → table)
1051
1088
  * LS_DATA->COMPONENT (structure field)
1052
1089
  *
1053
- * When a daemon socket is active, uses daemon IPC for single-segment paths.
1054
- * Multi-segment paths always run directly against ADT (DebugSession.expandPath).
1090
+ * When a daemon socket is active, uses daemon IPC for both single-segment and
1091
+ * multi-segment paths so the stateful ADT session is always reused.
1055
1092
  */
1056
1093
  async function cmdExpand(expandName, sessionId, socketPath, config, adt, jsonOutput) {
1057
1094
  // Split on -> to detect path notation. Also handle --> typos by stripping stray dashes.
@@ -1059,20 +1096,37 @@ async function cmdExpand(expandName, sessionId, socketPath, config, adt, jsonOut
1059
1096
  // [N]-FIELD → [N]->FIELD (array row then struct field)
1060
1097
  // *-FIELD → *->FIELD (dereference then struct field: lr_request->*-files)
1061
1098
  const normalizedName = expandName
1099
+ .replace(/^([A-Za-z0-9_@]+)\[(\d+)\]$/, '$1->[$2]') // VAR[N] → VAR->[N] (exact match)
1100
+ .replace(/([A-Za-z0-9_@]+)\[(\d+)\]->/g, '$1->[$2]->') // VAR[N]->X → VAR->[N]->X (mid-path)
1062
1101
  .replace(/\](-(?!>))/g, ']->') // [N]-FIELD → [N]->FIELD
1063
1102
  .replace(/\*(-(?!>))/g, '*->'); // *-FIELD → *->FIELD
1064
1103
  const pathParts = normalizedName.split('->').map(s => s.replace(/^-+|-+$/g, '').trim()).filter(Boolean);
1065
1104
 
1066
- // Multi-segment path: must go direct (daemon IPC only handles one level at a time)
1105
+ // Multi-segment path: route through daemon when available (daemon holds stateful ADT session)
1067
1106
  if (pathParts.length > 1) {
1068
- if (!adt.csrfToken) await adt.fetchCsrfToken();
1069
- const session = new DebugSession(adt, sessionId);
1070
1107
  let result;
1071
- try {
1072
- result = await session.expandPath(pathParts);
1073
- } catch (err) {
1074
- printHttpError(err, { prefix: ' Error' });
1075
- process.exit(1);
1108
+ if (socketPath) {
1109
+ let resp;
1110
+ try {
1111
+ resp = await sendDaemonCommand(socketPath, { cmd: 'expandPath', pathParts }, 60000);
1112
+ } catch (err) {
1113
+ printHttpError(err, { prefix: ' Error' });
1114
+ process.exit(1);
1115
+ }
1116
+ if (!resp.ok) {
1117
+ console.error(`\n Error: ${resp.error}\n`);
1118
+ process.exit(1);
1119
+ }
1120
+ result = { variable: resp.variable, children: resp.children };
1121
+ } else {
1122
+ if (!adt.csrfToken) await adt.fetchCsrfToken();
1123
+ const session = new DebugSession(adt, sessionId);
1124
+ try {
1125
+ result = await session.expandPath(pathParts);
1126
+ } catch (err) {
1127
+ printHttpError(err, { prefix: ' Error' });
1128
+ process.exit(1);
1129
+ }
1076
1130
  }
1077
1131
  const { variable: target, children } = result;
1078
1132
  return _printExpandResult(expandName, target, children, jsonOutput);
@@ -1243,20 +1297,29 @@ async function cmdTerminate(args, config, adt) {
1243
1297
 
1244
1298
  // Prefer daemon IPC — daemon calls session.terminate() then exits itself
1245
1299
  if (socketPath) {
1246
- let resp;
1300
+ let ipcSucceeded = false;
1247
1301
  try {
1248
- resp = await sendDaemonCommand(socketPath, { cmd: 'terminate' }, 30000);
1302
+ const resp = await sendDaemonCommand(socketPath, { cmd: 'terminate' }, 30000);
1303
+ if (resp && resp.ok) ipcSucceeded = true;
1249
1304
  } catch (err) {
1250
- // Socket may be gone already (daemon crashed or timed out) — treat as terminated
1251
- resp = { ok: true, terminated: true };
1305
+ // Socket is gone (daemon crashed or was killed) — fall through to direct ADT call
1252
1306
  }
1253
- clearActiveSession(config);
1254
- if (jsonOutput) {
1255
- console.log(JSON.stringify({ terminated: resp.ok ? true : resp.error }));
1307
+ if (ipcSucceeded) {
1308
+ clearActiveSession(config);
1309
+ if (jsonOutput) {
1310
+ console.log(JSON.stringify({ terminated: true }));
1311
+ return;
1312
+ }
1313
+ console.log('\n Debug session terminated.\n');
1256
1314
  return;
1257
1315
  }
1258
- console.log('\n Debug session terminated.\n');
1259
- return;
1316
+ // IPC failed — daemon is gone but ABAP WP may still be frozen.
1317
+ // Use stored session cookies to release the WP via direct ADT call.
1318
+ const savedState = loadActiveSession(config);
1319
+ if (savedState && savedState.cookies && sessionId) {
1320
+ adt.cookies = savedState.cookies;
1321
+ }
1322
+ // Fall through to direct ADT terminate below
1260
1323
  }
1261
1324
 
1262
1325
  // Fallback: direct ADT call
@@ -1289,32 +1352,6 @@ async function cmdTerminate(args, config, adt) {
1289
1352
 
1290
1353
  // ─── Daemon IPC helpers ───────────────────────────────────────────────────────
1291
1354
 
1292
- /**
1293
- * Wait for the daemon's Unix socket to appear (after spawn).
1294
- * @param {string} socketPath
1295
- * @param {number} timeoutMs
1296
- */
1297
- function waitForSocket(socketPath, timeoutMs) {
1298
- return new Promise((resolve, reject) => {
1299
- const deadline = Date.now() + timeoutMs;
1300
- function check() {
1301
- const client = net.createConnection(socketPath);
1302
- client.on('connect', () => {
1303
- client.destroy();
1304
- resolve();
1305
- });
1306
- client.on('error', () => {
1307
- if (Date.now() >= deadline) {
1308
- reject(new Error('Timeout waiting for debug daemon to start'));
1309
- } else {
1310
- setTimeout(check, 100);
1311
- }
1312
- });
1313
- }
1314
- check();
1315
- });
1316
- }
1317
-
1318
1355
  /**
1319
1356
  * Send one JSON command to the daemon and return the parsed JSON response.
1320
1357
  * @param {string} socketPath
@@ -285,7 +285,7 @@ Examples:
285
285
  process.exit(1);
286
286
  }
287
287
 
288
- const filesSyntaxCheck = args[filesArgIndex + 1].split(',').map(f => f.trim());
288
+ let filesSyntaxCheck = args[filesArgIndex + 1].split(',').map(f => f.trim());
289
289
 
290
290
  // Parse optional --variant parameter; fall back to project config
291
291
  const variantArgIndex = args.indexOf('--variant');
@@ -293,6 +293,31 @@ Examples:
293
293
  const inspectConfig = getInspectConfig();
294
294
  const variant = variantArg || inspectConfig.variant || null;
295
295
 
296
+ // Filter out files matching inspect.exclude patterns from project config
297
+ const excludePatterns = inspectConfig.exclude || [];
298
+ if (excludePatterns.length > 0) {
299
+ const before = filesSyntaxCheck.length;
300
+ filesSyntaxCheck = filesSyntaxCheck.filter(f => {
301
+ const objName = pathModule.basename(f).split('.')[0].toUpperCase();
302
+ return !excludePatterns.some(pattern => {
303
+ const re = new RegExp('^' + pattern.toUpperCase().replace(/\*/g, '.*') + '$');
304
+ return re.test(objName);
305
+ });
306
+ });
307
+ const skipped = before - filesSyntaxCheck.length;
308
+ if (skipped > 0 && !args.includes('--json')) {
309
+ console.log(` Skipped ${skipped} file(s) excluded by inspect config`);
310
+ }
311
+ if (filesSyntaxCheck.length === 0) {
312
+ if (args.includes('--json')) {
313
+ console.log(JSON.stringify([]));
314
+ } else {
315
+ console.log('\n All files excluded by inspect config — nothing to check.\n');
316
+ }
317
+ return;
318
+ }
319
+ }
320
+
296
321
  // Parse optional --junit-output parameter
297
322
  const junitArgIndex = args.indexOf('--junit-output');
298
323
  const junitOutput = junitArgIndex !== -1 ? args[junitArgIndex + 1] : null;
@@ -316,6 +341,51 @@ Examples:
316
341
  // Send all files in one request
317
342
  const results = await inspectAllFiles(filesSyntaxCheck, csrfToken, config, variant, http, verbose);
318
343
 
344
+ // Apply inspect.suppress rules — downgrade matching errors/warnings to infos
345
+ const suppressRules = inspectConfig.suppress || [];
346
+ if (suppressRules.length > 0) {
347
+ for (const result of results) {
348
+ const objName = (result.OBJECT_NAME || result.object_name || '').toUpperCase();
349
+ for (const rule of suppressRules) {
350
+ const objPattern = new RegExp('^' + (rule.object || '*').toUpperCase().replace(/\*/g, '.*') + '$');
351
+ if (!objPattern.test(objName)) continue;
352
+ const msgPattern = new RegExp((rule.message || '*').replace(/\*/g, '.*'), 'i');
353
+
354
+ // Downgrade matching errors → infos
355
+ const errors = result.ERRORS || result.errors || [];
356
+ const kept = [];
357
+ for (const err of errors) {
358
+ const text = err.TEXT || err.text || '';
359
+ if (msgPattern.test(text)) {
360
+ const infos = result.INFOS || result.infos || [];
361
+ infos.push({ ...err, MESSAGE: `[suppressed] ${text}`, message: `[suppressed] ${text}` });
362
+ if (result.INFOS !== undefined) result.INFOS = infos; else result.infos = infos;
363
+ const ec = result.ERROR_COUNT !== undefined ? 'ERROR_COUNT' : 'error_count';
364
+ result[ec] = Math.max(0, (result[ec] || 0) - 1);
365
+ } else {
366
+ kept.push(err);
367
+ }
368
+ }
369
+ if (result.ERRORS !== undefined) result.ERRORS = kept; else result.errors = kept;
370
+
371
+ // Downgrade matching warnings → infos
372
+ const warnings = result.WARNINGS || result.warnings || [];
373
+ const keptW = [];
374
+ for (const warn of warnings) {
375
+ const text = warn.MESSAGE || warn.message || '';
376
+ if (msgPattern.test(text)) {
377
+ const infos = result.INFOS || result.infos || [];
378
+ infos.push({ ...warn, MESSAGE: `[suppressed] ${text}`, message: `[suppressed] ${text}` });
379
+ if (result.INFOS !== undefined) result.INFOS = infos; else result.infos = infos;
380
+ } else {
381
+ keptW.push(warn);
382
+ }
383
+ }
384
+ if (result.WARNINGS !== undefined) result.WARNINGS = keptW; else result.warnings = keptW;
385
+ }
386
+ }
387
+ }
388
+
319
389
  // JUnit output mode — write XML file, then continue to normal output
320
390
  if (junitOutput) {
321
391
  const xml = buildInspectJUnit(results);
@@ -329,7 +329,8 @@ Examples:
329
329
  const jobId = result.JOB_ID || result.job_id;
330
330
  const message = result.MESSAGE || result.message;
331
331
  const errorDetail = result.ERROR_DETAIL || result.error_detail;
332
- const activatedCount = result.ACTIVATED_COUNT || result.activated_count || 0;
332
+ const activatedCountRaw = result.ACTIVATED_COUNT ?? result.activated_count ?? null;
333
+ const activatedCount = activatedCountRaw || 0;
333
334
  const failedCount = result.FAILED_COUNT || result.failed_count || 0;
334
335
  const logMessages = result.LOG_MESSAGES || result.log_messages || [];
335
336
  const activatedObjects = result.ACTIVATED_OBJECTS || result.activated_objects || [];
@@ -362,7 +363,7 @@ Examples:
362
363
  if (success === 'X' || success === true) {
363
364
  console.log(`✅ Pull completed successfully!`);
364
365
  console.log(` Message: ${message || 'N/A'}`);
365
- if (activatedCount === 0 && files && !isRepull) {
366
+ if (activatedCountRaw !== null && activatedCount === 0 && activatedObjects.length === 0 && files && !isRepull) {
366
367
  console.warn(`⚠️ ACTIVATED_COUNT: 0 — no objects were activated. Check for unpushed commits: git log origin/<branch>..HEAD`);
367
368
  }
368
369
  } else if (failedCount === 0 && failedObjects.length === 0 &&
@@ -151,7 +151,7 @@ async function computeGlobalStarts(objName, sections, config) {
151
151
  * Returns 0 if no better line is found (falls back to METHOD statement).
152
152
  */
153
153
  function findFirstExecutableLine(lines) {
154
- const declPattern = /^\s*(data|final|types|constants|class-data)[\s:(]/i;
154
+ const declPattern = /^\s*(data|final|types|constants|class-data|field-symbols)[\s:(</\[]/i;
155
155
  const methodPattern = /^\s*method\s+/i;
156
156
  const commentPattern = /^\s*[*"]/;
157
157
  // Program-level header/declaration keywords that are not executable statements
package/src/config.js CHANGED
@@ -255,6 +255,8 @@ function getInspectConfig() {
255
255
  const projectConfig = loadProjectConfig();
256
256
  return {
257
257
  variant: projectConfig?.inspect?.variant || null,
258
+ exclude: projectConfig?.inspect?.exclude || [],
259
+ suppress: projectConfig?.inspect?.suppress || [],
258
260
  };
259
261
  }
260
262