abapgit-agent 1.17.8 → 1.18.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.
@@ -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
@@ -178,6 +178,13 @@ module.exports = {
178
178
  console.log('');
179
179
  },
180
180
 
181
+ _extractAiContent(content) {
182
+ const marker = '<!-- AI-CONDENSED-START -->';
183
+ const markerIndex = content.indexOf(marker);
184
+ if (markerIndex === -1) return content;
185
+ return content.slice(markerIndex + marker.length).trimStart();
186
+ },
187
+
181
188
  async execute(args) {
182
189
  if (args.includes('--migrate')) {
183
190
  return this._runMigrate(args);
@@ -197,6 +204,16 @@ module.exports = {
197
204
 
198
205
  const content = fs.readFileSync(filePath, 'utf8');
199
206
 
207
+ if (args.includes('--ai')) {
208
+ const aiContent = this._extractAiContent(content);
209
+ if (args.includes('--json')) {
210
+ console.log(JSON.stringify({ path: filePath, content: aiContent }));
211
+ return;
212
+ }
213
+ console.log(aiContent);
214
+ return;
215
+ }
216
+
200
217
  if (args.includes('--json')) {
201
218
  console.log(JSON.stringify({ path: filePath, content }));
202
219
  return;
@@ -248,7 +248,7 @@ module.exports = {
248
248
  requiresVersionCheck: true,
249
249
 
250
250
  async execute(args, context) {
251
- const { loadConfig, AbapHttp } = context;
251
+ const { loadConfig, AbapHttp, getInspectConfig } = context;
252
252
 
253
253
  if (args.includes('--help') || args.includes('-h')) {
254
254
  console.log(`
@@ -287,9 +287,11 @@ Examples:
287
287
 
288
288
  const filesSyntaxCheck = args[filesArgIndex + 1].split(',').map(f => f.trim());
289
289
 
290
- // Parse optional --variant parameter
290
+ // Parse optional --variant parameter; fall back to project config
291
291
  const variantArgIndex = args.indexOf('--variant');
292
- const variant = variantArgIndex !== -1 ? args[variantArgIndex + 1] : null;
292
+ const variantArg = variantArgIndex !== -1 ? args[variantArgIndex + 1] : null;
293
+ const inspectConfig = getInspectConfig();
294
+ const variant = variantArg || inspectConfig.variant || null;
293
295
 
294
296
  // Parse optional --junit-output parameter
295
297
  const junitArgIndex = args.indexOf('--junit-output');
@@ -298,7 +300,8 @@ Examples:
298
300
  if (!jsonOutput) {
299
301
  console.log(`\n Inspect for ${filesSyntaxCheck.length} file(s)`);
300
302
  if (variant) {
301
- console.log(` Using variant: ${variant}`);
303
+ const source = variantArg ? '' : ' (from project config)';
304
+ console.log(` Using variant: ${variant}${source}`);
302
305
  }
303
306
  if (junitOutput) {
304
307
  console.log(` JUnit output: ${junitOutput}`);
@@ -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,6 +363,9 @@ Examples:
362
363
  if (success === 'X' || success === true) {
363
364
  console.log(`✅ Pull completed successfully!`);
364
365
  console.log(` Message: ${message || 'N/A'}`);
366
+ if (activatedCountRaw !== null && activatedCount === 0 && activatedObjects.length === 0 && files && !isRepull) {
367
+ console.warn(`⚠️ ACTIVATED_COUNT: 0 — no objects were activated. Check for unpushed commits: git log origin/<branch>..HEAD`);
368
+ }
365
369
  } else if (failedCount === 0 && failedObjects.length === 0 &&
366
370
  activatedCount === 0 && logMessages.length === 0 &&
367
371
  (!message || /activation cancelled|nothing to activate|already active/i.test(message))) {
@@ -522,20 +526,40 @@ Examples:
522
526
  const quotedPaths = diffFiles.map(f => `"${f.relPath}"`).join(' ');
523
527
  execSync(`git add ${quotedPaths}`, { cwd: process.cwd() });
524
528
 
525
- // 3. Amend last commit
529
+ // 3. Capture pre-amend SHA before amending (used for --force-with-lease below)
530
+ const preAmendSha = execSync('git rev-parse HEAD', { cwd: process.cwd(), stdio: 'pipe' }).toString().trim();
531
+
532
+ // 3b. Check whether origin/<branch> already exists on the remote.
533
+ // Use git ls-remote rather than rev-parse origin/<branch> because the local
534
+ // remote-tracking ref may not exist even when the branch is on the remote
535
+ // (e.g. after a push that only wrote branch config but not refs/remotes/).
536
+ let remoteRefExists = false;
537
+ try {
538
+ const lsOut = execSync(`git ls-remote origin "refs/heads/${branch}"`, { cwd: process.cwd(), stdio: 'pipe' }).toString().trim();
539
+ remoteRefExists = lsOut.length > 0;
540
+ } catch (_) { /* cannot reach remote — treat as new branch */ }
541
+
542
+ // 4. Amend last commit
526
543
  execSync('git commit --amend --no-edit', { cwd: process.cwd() });
527
544
 
528
- // 4. Push with force-with-lease; fetch first so tracking ref is current
529
- // (the remote may have been force-pushed by another process between our last
530
- // fetch and this push a fetch makes --force-with-lease reliable)
545
+ // 5. Push with force-with-lease using the pre-amend SHA as the expected remote value.
546
+ // Using --force-with-lease=<refname>:<sha> tells git: "the remote must have exactly
547
+ // <preAmendSha> if it does, replace it with our amended commit".
548
+ // Plain --force-with-lease (no sha) re-checks the tracking ref which was just updated
549
+ // by git fetch, making it always fail after an amend.
531
550
  let pushed = false;
532
551
  try {
533
- try { execSync('git fetch origin', { cwd: process.cwd(), stdio: 'pipe' }); } catch (_) { /* no remote is fine */ }
534
552
  // Retry the push up to 3 times on transient server errors (e.g. GitHub Enterprise 500)
535
553
  let pushErr;
536
554
  for (let attempt = 1; attempt <= 3; attempt++) {
537
555
  try {
538
- execSync('git push --force-with-lease', { cwd: process.cwd(), stdio: 'pipe' });
556
+ // If origin/<branch> exists: use --force-with-lease=branch:preAmendSha so we only
557
+ // overwrite if the remote still has the pre-amend commit (safe concurrent guard).
558
+ // If origin/<branch> doesn't exist yet: plain push with --set-upstream (no lease needed).
559
+ const pushCmd = remoteRefExists
560
+ ? `git push --set-upstream origin ${branch} --force-with-lease=${branch}:${preAmendSha}`
561
+ : `git push --set-upstream origin ${branch}`;
562
+ execSync(pushCmd, { cwd: process.cwd(), stdio: 'pipe' });
539
563
  pushed = true;
540
564
  break;
541
565
  } catch (err) {
@@ -550,13 +574,7 @@ Examples:
550
574
  }
551
575
  }
552
576
  } catch (pushErr) {
553
- const msg = (pushErr.stderr || pushErr.stdout || pushErr.message || '').toString();
554
- if (msg.includes('no upstream branch') || msg.includes('has no upstream')) {
555
- // Branch not yet pushed — set upstream and force push (amend requires force)
556
- execSync(`git push --force-with-lease --set-upstream origin ${branch}`, { cwd: process.cwd(), stdio: 'pipe' });
557
- pushed = true;
558
- }
559
- // Any other push error (no remote at all, auth failure, etc.) → skip silently
577
+ // Any push error (no remote, auth failure, etc.) skip silently
560
578
  }
561
579
 
562
580
  if (pushed) {
@@ -166,8 +166,9 @@ async function runUnitTestForFile(sourceFile, csrfToken, config, coverage, http,
166
166
  // Handle uppercase keys from ABAP
167
167
  const success = result.SUCCESS || result.success;
168
168
  const testCount = result.TEST_COUNT || result.test_count || 0;
169
- const passedCount = result.PASSED_COUNT || result.passed_count || 0;
170
169
  const failedCount = result.FAILED_COUNT || result.failed_count || 0;
170
+ // ABAP AUnit API does not return individual passing method names — derive passed count
171
+ const passedCount = testCount - failedCount;
171
172
  const message = result.MESSAGE || result.message || '';
172
173
  const errors = result.ERRORS || result.errors || [];
173
174
 
@@ -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
@@ -247,6 +247,17 @@ function getCoverageConfig() {
247
247
  };
248
248
  }
249
249
 
250
+ /**
251
+ * Get inspect settings from project-level config (.abapgit-agent.json)
252
+ * @returns {{ variant: string|null }}
253
+ */
254
+ function getInspectConfig() {
255
+ const projectConfig = loadProjectConfig();
256
+ return {
257
+ variant: projectConfig?.inspect?.variant || null,
258
+ };
259
+ }
260
+
250
261
  module.exports = {
251
262
  loadConfig,
252
263
  getAbapConfig,
@@ -261,5 +272,6 @@ module.exports = {
261
272
  getTransportHookConfig,
262
273
  getTransportSettings,
263
274
  getScratchWorkspace,
264
- getCoverageConfig
275
+ getCoverageConfig,
276
+ getInspectConfig
265
277
  };