abapgit-agent 1.18.2 → 1.19.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.
@@ -6,9 +6,9 @@ parent: ABAP Coding Guidelines
6
6
  grand_parent: ABAP Development
7
7
  ---
8
8
 
9
- # abapGit XML Metadata — XML-Only Objects (TABL, STRU, DTEL, TTYP, DOMA, MSAG)
9
+ # abapGit XML Metadata — XML-Only Objects (TABL, STRU, DTEL, TTYP, DOMA, MSAG, SUSO)
10
10
 
11
- XML templates for ABAP Dictionary objects that have no `.abap` source file — TABL, STRU, DTEL, TTYP, DOMA, and message classes (MSAG).
11
+ XML templates for ABAP Dictionary objects that have no `.abap` source file — TABL, STRU, DTEL, TTYP, DOMA, message classes (MSAG), and authorization objects (SUSO).
12
12
 
13
13
  > **CRITICAL: Always write XML files with a UTF-8 BOM (`\ufeff`) as the very first character**, before `<?xml ...`.
14
14
  > Without the BOM, abapGit shows the object as **"M" (modified)** after every pull.
@@ -19,7 +19,7 @@ XML templates for ABAP Dictionary objects that have no `.abap` source file — T
19
19
  > **For CLAS, INTF, PROG, DDLS, DCLS, FUGR** (have source files): see `abapgit-agent ref --topic abapgit`
20
20
  > **For DDLS, DCLS** (CDS — have source files): also see `abapgit-agent ref --topic abapgit`
21
21
 
22
- **Searchable keywords**: table xml, structure xml, data element xml, table type xml, domain xml, message class xml, tabl, stru, dtel, ttyp, doma, msag, dictionary
22
+ **Searchable keywords**: table xml, structure xml, data element xml, table type xml, domain xml, message class xml, tabl, stru, dtel, ttyp, doma, msag, suso, authorization object, dictionary
23
23
 
24
24
  ---
25
25
 
@@ -451,3 +451,63 @@ abapGit uses the view `DD01V` as the root element (not `DD01L`). The serializer
451
451
  - `T100/TEXT`: Message text — use `&1`–`&4` for placeholders (serialized as `&amp;1`–`&amp;4` in XML)
452
452
 
453
453
  **Note**: The `<T100>` wrapper contains repeated `<T100>` child elements — one per message.
454
+
455
+ ---
456
+
457
+ ### Authorization Object (SUSO)
458
+
459
+ **Filename**: `src/zmy_suso.suso.xml`
460
+
461
+ > Pull with: `pull --files src/zmy_suso.suso.xml`
462
+ >
463
+ > View with: `view --objects ZMY_SUSO --type SUSO`
464
+
465
+ ```xml
466
+ <?xml version="1.0" encoding="utf-8"?>
467
+ <abapGit version="v1.0.0" serializer="LCL_OBJECT_SUSO" serializer_version="v1.0.0">
468
+ <asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">
469
+ <asx:values>
470
+ <TOBJ>
471
+ <OBJCT>ZMY_SUSO</OBJCT>
472
+ <FIEL1>ACTVT</FIEL1>
473
+ <FIEL2>ZMY_FIELD</FIEL2>
474
+ <OCLSS>ZMY</OCLSS>
475
+ </TOBJ>
476
+ <TOBJT>
477
+ <LANGU>E</LANGU>
478
+ <OBJECT>ZMY_SUSO</OBJECT>
479
+ <TTEXT>My authorization object description</TTEXT>
480
+ </TOBJT>
481
+ <TOBJVORFLG>
482
+ <OBJCT>ZMY_SUSO</OBJCT>
483
+ </TOBJVORFLG>
484
+ <TACTZ>
485
+ <TACTZ>
486
+ <BROBJ>ZMY_SUSO</BROBJ>
487
+ <ACTVT>01</ACTVT>
488
+ </TACTZ>
489
+ <TACTZ>
490
+ <BROBJ>ZMY_SUSO</BROBJ>
491
+ <ACTVT>02</ACTVT>
492
+ </TACTZ>
493
+ <TACTZ>
494
+ <BROBJ>ZMY_SUSO</BROBJ>
495
+ <ACTVT>03</ACTVT>
496
+ </TACTZ>
497
+ </TACTZ>
498
+ </asx:values>
499
+ </asx:abap>
500
+ </abapGit>
501
+ ```
502
+
503
+ **Key Fields**:
504
+ - `TOBJ/OBJCT`: Authorization object name — same as the filename stem
505
+ - `TOBJ/FIEL1`–`FIEL0`: Up to 10 authorization fields (from SU20) that make up this object
506
+ - `TOBJ/OCLSS`: Object class (4 chars) — groups related authorization objects (e.g. `AAAB`, `BC_A`)
507
+ - `TOBJT/TTEXT`: Description text
508
+ - `TOBJVORFLG`: Flags record — include with just `OBJCT` when no flags are set
509
+ - `TACTZ`: Allowed activity values — one `<TACTZ>` entry per permitted `ACTVT` value
510
+
511
+ **Note**: Authorization objects (SUSO) are the SU21 objects that group authorization fields (AUTH/SU20)
512
+ together. An object like `AUD_SCOPEM` uses fields `ACTVT`, `BO_SERVICE`, and `AUD_GROUP`.
513
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abapgit-agent",
3
- "version": "1.18.2",
3
+ "version": "1.19.0",
4
4
  "description": "ABAP Git Agent - Pull and activate ABAP code via abapGit from any git repository",
5
5
  "files": [
6
6
  "bin/",
@@ -54,6 +54,7 @@
54
54
  "test:pull": "node tests/run-all.js --pull",
55
55
  "test:full-pull": "node tests/run-all.js --full-pull",
56
56
  "test:conflict": "node tests/run-all.js --conflict",
57
+ "test:async-pull": "node tests/run-all.js --async-pull",
57
58
  "pull": "node bin/abapgit-agent",
58
59
  "release": "node scripts/release.js",
59
60
  "unrelease": "node scripts/unrelease.js"
@@ -24,15 +24,17 @@ function escapeXml(str) {
24
24
  * Maps to JUnit schema:
25
25
  * <testsuites>
26
26
  * <testsuite name="CLAS ZCL_MY_CLASS" tests="N" failures="F" errors="0">
27
- * <testcase name="Syntax check" classname="ZCL_MY_CLASS">
28
- * <failure type="SyntaxError" message="...">line/col/method detail</failure>
27
+ * <testcase name="Inspect / Syntax check" classname="ZCL_MY_CLASS"/> ← clean
28
+ * <testcase name="Inspect / CL_CI_TEST_EXCEPTION/UNHANDLED_01" ← finding
29
+ * classname="ZCL_MY_CLASS">
30
+ * <failure type="SyntaxError" message="...">method/line/detail</failure>
29
31
  * </testcase>
30
32
  * </testsuite>
31
33
  * </testsuites>
32
34
  *
33
- * One testsuite per object. Each error becomes a <failure>. Warnings become
34
- * a single <failure type="Warning"> so they are visible but don't fail the build
35
- * unless there are also hard errors (Jenkins distinguishes failure vs unstable).
35
+ * One testsuite per object. Each error/warning becomes its own testcase named
36
+ * after the SCI check rule (check_class/check_code) so Jenkins shows exactly
37
+ * which rule fired. Clean objects get a single passing testcase.
36
38
  */
37
39
  function buildInspectJUnit(results) {
38
40
  const suites = results.map(res => {
@@ -42,28 +44,30 @@ function buildInspectJUnit(results) {
42
44
  const warnings = res.WARNINGS !== undefined ? res.WARNINGS : (res.warnings || []);
43
45
  const errorCount = errors.length;
44
46
  const warnCount = warnings.length;
45
- // One testcase per error/warning; at least one testcase for a clean object
46
47
  const testCount = Math.max(1, errorCount + warnCount);
47
48
 
48
49
  const testcases = [];
49
50
 
50
51
  if (errorCount === 0 && warnCount === 0) {
51
- testcases.push(` <testcase name="Syntax check" classname="${escapeXml(objectName)}"/>`);
52
+ testcases.push(` <testcase name="Inspect / Syntax check" classname="${escapeXml(objectName)}"/>`);
52
53
  }
53
54
 
54
55
  for (const err of errors) {
55
- const line = err.LINE || err.line || '?';
56
- const column = err.COLUMN || err.column || '?';
57
- const text = err.TEXT || err.text || 'Unknown error';
56
+ const line = err.LINE || err.line || '?';
57
+ const column = err.COLUMN || err.column || '?';
58
+ const text = err.TEXT || err.text || 'Unknown error';
58
59
  const methodName = err.METHOD_NAME || err.method_name;
59
- const sobjname = err.SOBJNAME || err.sobjname || '';
60
+ const sobjname = err.SOBJNAME || err.sobjname || '';
61
+ const checkClass = err.CHECK_CLASS || err.check_class || '';
62
+ const checkCode = err.CHECK_CODE || err.check_code || '';
60
63
  const detail = [
61
64
  methodName ? `Method: ${methodName}` : null,
62
65
  `Line ${line}, Column ${column}`,
63
66
  sobjname ? `Include: ${sobjname}` : null,
64
67
  text
65
68
  ].filter(Boolean).join('\n');
66
- const caseName = methodName ? `${methodName} line ${line}` : `Line ${line}`;
69
+ const checkId = checkClass && checkCode ? `${checkClass}/${checkCode}` : null;
70
+ const caseName = checkId ? `Inspect / ${checkId}` : (methodName ? `${methodName} line ${line}` : `Line ${line}`);
67
71
  testcases.push(
68
72
  ` <testcase name="${escapeXml(caseName)}" classname="${escapeXml(objectName)}">\n` +
69
73
  ` <failure type="SyntaxError" message="${escapeXml(text)}">${escapeXml(detail)}</failure>\n` +
@@ -72,17 +76,20 @@ function buildInspectJUnit(results) {
72
76
  }
73
77
 
74
78
  for (const warn of warnings) {
75
- const line = warn.LINE || warn.line || '?';
76
- const text = warn.MESSAGE || warn.message || warn.TEXT || warn.text || 'Warning';
79
+ const line = warn.LINE || warn.line || '?';
80
+ const text = warn.MESSAGE || warn.message || warn.TEXT || warn.text || 'Warning';
77
81
  const methodName = warn.METHOD_NAME || warn.method_name;
78
- const sobjname = warn.SOBJNAME || warn.sobjname || '';
82
+ const sobjname = warn.SOBJNAME || warn.sobjname || '';
83
+ const checkClass = warn.CHECK_CLASS || warn.check_class || '';
84
+ const checkCode = warn.CHECK_CODE || warn.check_code || '';
79
85
  const detail = [
80
86
  methodName ? `Method: ${methodName}` : null,
81
87
  `Line ${line}`,
82
88
  sobjname ? `Include: ${sobjname}` : null,
83
89
  text
84
90
  ].filter(Boolean).join('\n');
85
- const caseName = methodName ? `${methodName} line ${line} (warning)` : `Line ${line} (warning)`;
91
+ const checkId = checkClass && checkCode ? `${checkClass}/${checkCode}` : null;
92
+ const caseName = checkId ? `Inspect / ${checkId}` : (methodName ? `${methodName} line ${line}` : `Line ${line}`);
86
93
  testcases.push(
87
94
  ` <testcase name="${escapeXml(caseName)}" classname="${escapeXml(objectName)}">\n` +
88
95
  ` <failure type="Warning" message="${escapeXml(text)}">${escapeXml(detail)}</failure>\n` +
@@ -107,8 +114,10 @@ function buildInspectJUnit(results) {
107
114
  }
108
115
 
109
116
  /**
110
- * Inspect all files in one request
117
+ * Inspect a single batch of files in one request (max CHUNK_SIZE files)
111
118
  */
119
+ const CHUNK_SIZE = 10;
120
+
112
121
  async function inspectAllFiles(files, csrfToken, config, variant, http, verbose = false) {
113
122
  // Convert files to uppercase names
114
123
  const fileNames = files.map(f => {
@@ -152,6 +161,66 @@ async function inspectAllFiles(files, csrfToken, config, variant, http, verbose
152
161
  }
153
162
  }
154
163
 
164
+ /**
165
+ * Apply inspect.suppress rules to a single result — downgrade matching errors/warnings to infos.
166
+ */
167
+ function applySuppressRules(result, suppressRules) {
168
+ if (!suppressRules || suppressRules.length === 0) return;
169
+ const objName = (result.OBJECT_NAME || result.object_name || '').toUpperCase();
170
+ for (const rule of suppressRules) {
171
+ const objPattern = new RegExp('^' + (rule.object || '*').toUpperCase().replace(/\*/g, '.*') + '$');
172
+ if (!objPattern.test(objName)) continue;
173
+ const msgPattern = new RegExp((rule.message || '*').replace(/\*/g, '.*'), 'i');
174
+
175
+ const errors = result.ERRORS || result.errors || [];
176
+ const kept = [];
177
+ for (const err of errors) {
178
+ const text = err.TEXT || err.text || '';
179
+ if (msgPattern.test(text)) {
180
+ const infos = result.INFOS || result.infos || [];
181
+ infos.push({ ...err, MESSAGE: `[suppressed] ${text}`, message: `[suppressed] ${text}` });
182
+ if (result.INFOS !== undefined) result.INFOS = infos; else result.infos = infos;
183
+ const ec = result.ERROR_COUNT !== undefined ? 'ERROR_COUNT' : 'error_count';
184
+ result[ec] = Math.max(0, (result[ec] || 0) - 1);
185
+ } else {
186
+ kept.push(err);
187
+ }
188
+ }
189
+ if (result.ERRORS !== undefined) result.ERRORS = kept; else result.errors = kept;
190
+
191
+ const warnings = result.WARNINGS || result.warnings || [];
192
+ const keptW = [];
193
+ for (const warn of warnings) {
194
+ const text = warn.MESSAGE || warn.message || '';
195
+ if (msgPattern.test(text)) {
196
+ const infos = result.INFOS || result.infos || [];
197
+ infos.push({ ...warn, MESSAGE: `[suppressed] ${text}`, message: `[suppressed] ${text}` });
198
+ if (result.INFOS !== undefined) result.INFOS = infos; else result.infos = infos;
199
+ } else {
200
+ keptW.push(warn);
201
+ }
202
+ }
203
+ if (result.WARNINGS !== undefined) result.WARNINGS = keptW; else result.warnings = keptW;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Split files into chunks and inspect each sequentially, combining results.
209
+ * Calls onChunkResult(result) for each object result as chunks complete.
210
+ */
211
+ async function inspectInChunks(files, csrfToken, config, variant, http, verbose = false, onChunkResult = null) {
212
+ const results = [];
213
+ for (let i = 0; i < files.length; i += CHUNK_SIZE) {
214
+ const chunk = files.slice(i, i + CHUNK_SIZE);
215
+ const chunkResults = await inspectAllFiles(chunk, csrfToken, config, variant, http, verbose);
216
+ for (const result of chunkResults) {
217
+ results.push(result);
218
+ if (onChunkResult) onChunkResult(result);
219
+ }
220
+ }
221
+ return results;
222
+ }
223
+
155
224
  /**
156
225
  * Process a single inspect result
157
226
  */
@@ -181,6 +250,8 @@ async function processInspectResult(res) {
181
250
  const text = err.TEXT || err.text || 'Unknown error';
182
251
  const methodName = err.METHOD_NAME || err.method_name;
183
252
  const sobjname = err.SOBJNAME || err.sobjname;
253
+ const checkClass = err.CHECK_CLASS || err.check_class || '';
254
+ const checkCode = err.CHECK_CODE || err.check_code || '';
184
255
 
185
256
  if (methodName) {
186
257
  console.log(` Method: ${methodName}`);
@@ -190,6 +261,10 @@ async function processInspectResult(res) {
190
261
  console.log(` Include: ${sobjname}`);
191
262
  }
192
263
  console.log(` ${text}`);
264
+ if (checkCode) {
265
+ console.log(` [${checkClass}/${checkCode}]`);
266
+ console.log(` → abapgit-agent inspect --doc ${checkClass}/${checkCode}`);
267
+ }
193
268
  console.log('');
194
269
  }
195
270
 
@@ -202,6 +277,8 @@ async function processInspectResult(res) {
202
277
  const text = warn.MESSAGE || warn.message || 'Unknown warning';
203
278
  const methodName = warn.METHOD_NAME || warn.method_name;
204
279
  const sobjname = warn.SOBJNAME || warn.sobjname;
280
+ const checkClass = warn.CHECK_CLASS || warn.check_class || '';
281
+ const checkCode = warn.CHECK_CODE || warn.check_code || '';
205
282
 
206
283
  if (methodName) {
207
284
  console.log(` Method: ${methodName}`);
@@ -211,6 +288,10 @@ async function processInspectResult(res) {
211
288
  console.log(` Include: ${sobjname}`);
212
289
  }
213
290
  console.log(` ${text}`);
291
+ if (checkCode) {
292
+ console.log(` [${checkClass}/${checkCode}]`);
293
+ console.log(` → abapgit-agent inspect --doc ${checkClass}/${checkCode}`);
294
+ }
214
295
  }
215
296
  }
216
297
 
@@ -223,6 +304,8 @@ async function processInspectResult(res) {
223
304
  const text = info.MESSAGE || info.message || 'Unknown info';
224
305
  const methodName = info.METHOD_NAME || info.method_name;
225
306
  const sobjname = info.SOBJNAME || info.sobjname;
307
+ const checkClass = info.CHECK_CLASS || info.check_class || '';
308
+ const checkCode = info.CHECK_CODE || info.check_code || '';
226
309
 
227
310
  if (methodName) {
228
311
  console.log(` Method: ${methodName}`);
@@ -232,6 +315,10 @@ async function processInspectResult(res) {
232
315
  console.log(` Include: ${sobjname}`);
233
316
  }
234
317
  console.log(` ${text}`);
318
+ if (checkCode) {
319
+ console.log(` [${checkClass}/${checkCode}]`);
320
+ console.log(` → abapgit-agent inspect --doc ${checkClass}/${checkCode}`);
321
+ }
235
322
  }
236
323
  }
237
324
  } else if (success === true || success === 'X') {
@@ -254,25 +341,78 @@ module.exports = {
254
341
  console.log(`
255
342
  Usage:
256
343
  abapgit-agent inspect --files <file1>,<file2>,... [--variant <check-variant>] [--junit-output <file>] [--json]
344
+ abapgit-agent inspect --doc <check_class>/<check_code>
257
345
 
258
346
  Description:
259
347
  Run SAP Code Inspector checks on activated ABAP objects. Requires the objects
260
348
  to be already active in the ABAP system (run pull first).
261
349
 
350
+ Use --doc to fetch the SAP documentation for a specific check finding.
351
+ The check class and code are shown in brackets after each finding, e.g.:
352
+ [CL_CI_TEST_OMIT_BRACKETS/OMIT_01]
353
+
262
354
  Parameters:
263
- --files <file1,...> Comma-separated ABAP source files (required).
355
+ --files <file1,...> Comma-separated ABAP source files (required for inspection).
264
356
  --variant <variant> Code Inspector variant (default: system default).
265
357
  --junit-output <file> Write results as JUnit XML to this file.
266
358
  --json Output as JSON.
359
+ --doc <class>/<code> Fetch documentation for a check finding.
267
360
 
268
361
  Examples:
269
362
  abapgit-agent inspect --files src/zcl_my_class.clas.abap
270
363
  abapgit-agent inspect --files src/zcl_my_class.clas.abap --variant ALL_CHECKS
271
364
  abapgit-agent inspect --files src/zcl_my_class.clas.abap --junit-output reports/inspect.xml
365
+ abapgit-agent inspect --doc CL_CI_TEST_OMIT_BRACKETS/OMIT_01
272
366
  `);
273
367
  return;
274
368
  }
275
369
 
370
+ // --doc mode: fetch documentation for a check finding
371
+ const docArgIndex = args.indexOf('--doc');
372
+ if (docArgIndex !== -1) {
373
+ const docArg = args[docArgIndex + 1];
374
+ if (!docArg || !docArg.includes('/')) {
375
+ console.error('Error: --doc requires <check_class>/<check_code>');
376
+ console.error('Example: abapgit-agent inspect --doc CL_CI_TEST_OMIT_BRACKETS/OMIT_01');
377
+ process.exit(1);
378
+ }
379
+ const slashIdx = docArg.indexOf('/');
380
+ const checkClass = docArg.slice(0, slashIdx);
381
+ const checkCode = docArg.slice(slashIdx + 1);
382
+
383
+ const config = loadConfig();
384
+ const http = new AbapHttp(config);
385
+ const csrfToken = await http.fetchCsrfToken();
386
+
387
+ try {
388
+ const result = await http.post('/sap/bc/z_abapgit_agent/insp_doc', {
389
+ check_class: checkClass,
390
+ check_code: checkCode
391
+ }, { csrfToken });
392
+
393
+ const title = result.TITLE || result.title || '';
394
+ let text = result.TEXT || result.text || '';
395
+
396
+ // Strip ITF inline tags (<AB>, <EX>, </>, <DS:...>, etc.), HTML tags, and ITF placeholders (&...&)
397
+ text = text
398
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
399
+ .replace(/<[^>]+>/g, '')
400
+ .replace(/&[A-Z_]+&/g, '')
401
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
402
+ .replace(/&nbsp;/g, ' ').replace(/&#160;/g, ' ')
403
+ .replace(/\n{3,}/g, '\n\n')
404
+ .trim();
405
+
406
+ if (title) console.log(`\n${title}\n${'─'.repeat(Math.min(title.length, 60))}`);
407
+ console.log(`\n${text || 'No documentation available.'}\n`);
408
+ } catch (error) {
409
+ const { printHttpError } = require('../utils/format-error');
410
+ printHttpError(error, { verbose: args.includes('--verbose') });
411
+ process.exit(1);
412
+ }
413
+ return;
414
+ }
415
+
276
416
  const jsonOutput = args.includes('--json');
277
417
  const verbose = args.includes('--verbose');
278
418
  const filesArgIndex = args.indexOf('--files');
@@ -285,7 +425,7 @@ Examples:
285
425
  process.exit(1);
286
426
  }
287
427
 
288
- let filesSyntaxCheck = args[filesArgIndex + 1].split(',').map(f => f.trim());
428
+ let filesSyntaxCheck = [...new Set(args[filesArgIndex + 1].split(',').map(f => f.trim()))];
289
429
 
290
430
  // Parse optional --variant parameter; fall back to project config
291
431
  const variantArgIndex = args.indexOf('--variant');
@@ -338,51 +478,23 @@ Examples:
338
478
  const http = new AbapHttp(config);
339
479
  const csrfToken = await http.fetchCsrfToken();
340
480
 
341
- // Send all files in one request
342
- const results = await inspectAllFiles(filesSyntaxCheck, csrfToken, config, variant, http, verbose);
343
-
344
- // Apply inspect.suppress rules — downgrade matching errors/warnings to infos
345
481
  const suppressRules = inspectConfig.suppress || [];
346
- if (suppressRules.length > 0) {
482
+ let hasErrors = false;
483
+
484
+ // Stream results: apply suppress and print each object as its chunk completes
485
+ const onChunkResult = jsonOutput ? null : async (result) => {
486
+ applySuppressRules(result, suppressRules);
487
+ await processInspectResult(result);
488
+ const errorCount = result.ERROR_COUNT !== undefined ? result.ERROR_COUNT : (result.error_count || 0);
489
+ if (errorCount > 0) hasErrors = true;
490
+ };
491
+
492
+ const results = await inspectInChunks(filesSyntaxCheck, csrfToken, config, variant, http, verbose, onChunkResult);
493
+
494
+ // For JSON mode, apply suppress rules now (they were skipped during streaming)
495
+ if (jsonOutput) {
347
496
  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
- }
497
+ applySuppressRules(result, suppressRules);
386
498
  }
387
499
  }
388
500
 
@@ -408,14 +520,6 @@ Examples:
408
520
  return;
409
521
  }
410
522
 
411
- // Process results
412
- let hasErrors = false;
413
- for (const result of results) {
414
- await processInspectResult(result);
415
- const errorCount = result.ERROR_COUNT !== undefined ? result.ERROR_COUNT : (result.error_count || 0);
416
- if (errorCount > 0) hasErrors = true;
417
- }
418
-
419
523
  if (hasErrors) {
420
524
  process.exit(1);
421
525
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const { printHttpError } = require('../utils/format-error');
6
+ const { pollForCompletion, displayProgress } = require('../utils/backgroundJobPoller');
6
7
  const fs = require('fs');
7
8
  const pathModule = require('path');
8
9
  const { execSync } = require('child_process');
@@ -295,7 +296,49 @@ Examples:
295
296
  data.transport_request = transportRequest;
296
297
  }
297
298
 
298
- const result = await http.post('/sap/bc/z_abapgit_agent/pull', data, { csrfToken });
299
+ const rawResult = await http.post('/sap/bc/z_abapgit_agent/pull', data, { csrfToken });
300
+
301
+ // --- Async pull: ABAP returned 202 (job scheduled for >10 files) ---
302
+ const jobStatus = rawResult.STATUS || rawResult.status;
303
+ const jobNumber = rawResult.JOB_NUMBER || rawResult.jobNumber;
304
+ let result = rawResult;
305
+
306
+ if ((jobStatus === 'scheduled' || jobStatus === 'accepted') && jobNumber) {
307
+ const fileCount = (data.files || []).length;
308
+ if (!jsonOutput) {
309
+ console.log(` Activating ${fileCount} file(s) as background job...`);
310
+ console.log('');
311
+ }
312
+
313
+ const endpoint = '/sap/bc/z_abapgit_agent/pull';
314
+ let finalJobResult;
315
+ try {
316
+ finalJobResult = await pollForCompletion(http, endpoint, jobNumber, {
317
+ pollInterval: 2000,
318
+ maxAttempts: 300,
319
+ onProgress: jsonOutput ? () => {} : (progress, message) => {
320
+ displayProgress(progress, message);
321
+ }
322
+ });
323
+ } finally {
324
+ if (!jsonOutput) process.stdout.write('\n');
325
+ }
326
+
327
+ if (!finalJobResult || finalJobResult.status === 'error') {
328
+ const errMsg = (finalJobResult && (finalJobResult.result || finalJobResult.message)) || 'Pull background job failed';
329
+ const err = new Error(errMsg);
330
+ err._isPullError = true;
331
+ throw err;
332
+ }
333
+
334
+ // Parse the result JSON string from the completed job
335
+ const resultStr = finalJobResult.result || finalJobResult.RESULT;
336
+ try {
337
+ result = typeof resultStr === 'string' ? JSON.parse(resultStr) : resultStr;
338
+ } catch (e) {
339
+ result = { success: '', message: 'Failed to parse pull job result' };
340
+ }
341
+ }
299
342
 
300
343
  // Detect missing .abapgit.xml — without it abapGit's stored starting_folder
301
344
  // may not match the actual source folder, causing pull to silently return ACTIVATED_COUNT=0
@@ -48,48 +48,84 @@ function buildRunConfigXml(adtUri, coverage) {
48
48
  * Returns:
49
49
  * {
50
50
  * className,
51
- * methods: [{ name, passed, kind?, title?, detail? }],
51
+ * methods: [{
52
+ * name, testClassName, passed, executionTime,
53
+ * kind?, title?, details?: string[], stack?: [{name, uri}]
54
+ * }],
52
55
  * coverageStats: { totalLines, coveredLines, coverageRate } | null
53
56
  * }
54
57
  */
55
58
  function parseRunResult(xml, className) {
56
59
  const methods = [];
57
60
 
58
- // Split on <testMethod each block is one method
59
- const methodBlocks = xml.split(/<testMethod\s/);
60
- for (let i = 1; i < methodBlocks.length; i++) {
61
- const block = methodBlocks[i];
61
+ // Split on <testClass blocks to capture test class name per method
62
+ const testClassBlocks = xml.split(/<testClass\s/);
63
+ for (let ci = 1; ci < testClassBlocks.length; ci++) {
64
+ const classBlock = testClassBlocks[ci];
62
65
 
63
- const nameMatch = block.match(/adtcore:name="([^"]*)"/);
64
- if (!nameMatch) continue;
65
- const name = nameMatch[1];
66
+ const classNameMatch = classBlock.match(/adtcore:name="([^"]*)"/);
67
+ const testClassName = classNameMatch ? classNameMatch[1] : className;
66
68
 
67
- // Find the <alerts> section content between <alerts> and </alerts>
68
- const alertsMatch = block.match(/<alerts>([\s\S]*?)<\/alerts>/);
69
- const alertsContent = alertsMatch ? alertsMatch[1].trim() : '';
69
+ // Split methods within this test class block
70
+ const methodBlocks = classBlock.split(/<testMethod\s/);
71
+ for (let i = 1; i < methodBlocks.length; i++) {
72
+ const block = methodBlocks[i];
70
73
 
71
- if (!alertsContent || alertsContent === '<alerts/>') {
72
- methods.push({ name, passed: true });
73
- continue;
74
- }
74
+ const nameMatch = block.match(/adtcore:name="([^"]*)"/);
75
+ if (!nameMatch) continue;
76
+ const name = nameMatch[1];
77
+
78
+ const execTimeMatch = block.match(/executionTime="([^"]*)"/);
79
+ const executionTime = execTimeMatch ? parseFloat(execTimeMatch[1]) : 0;
75
80
 
76
- // Extract alert kind
77
- const kindMatch = block.match(/<alert\s[^>]*kind="([^"]*)"/);
78
- const kind = kindMatch ? kindMatch[1] : 'failedAssertion';
81
+ // Find the <alerts> section
82
+ const alertsMatch = block.match(/<alerts>([\s\S]*?)<\/alerts>/);
83
+ const alertsContent = alertsMatch ? alertsMatch[1].trim() : '';
79
84
 
80
- // Extract title
81
- const titleMatch = block.match(/<title[^>]*>([^<]*)<\/title>/);
82
- const title = titleMatch ? titleMatch[1].trim() : 'Test failed';
85
+ if (!alertsContent || alertsContent === '<alerts/>') {
86
+ methods.push({ name, testClassName, passed: true, executionTime });
87
+ continue;
88
+ }
83
89
 
84
- // Extract first detail text
85
- const detailMatch = block.match(/<detail\s[^>]*text="([^"]*)"/);
86
- const detail = detailMatch ? detailMatch[1].trim() : '';
90
+ // Extract alert kind
91
+ const kindMatch = block.match(/<alert\s[^>]*kind="([^"]*)"/);
92
+ const kind = kindMatch ? kindMatch[1] : 'failedAssertion';
93
+
94
+ // Extract title
95
+ const titleMatch = block.match(/<title[^>]*>([^<]*)<\/title>/);
96
+ const title = titleMatch ? titleMatch[1].trim() : 'Test failed';
97
+
98
+ // Extract all detail texts (may be multiple)
99
+ const details = [];
100
+ const detailRe = /<detail\s[^>]*text="([^"]*)"/g;
101
+ let dm;
102
+ while ((dm = detailRe.exec(block)) !== null) {
103
+ const t = dm[1].trim();
104
+ if (t) details.push(t);
105
+ }
87
106
 
88
- methods.push({ name, passed: false, kind, title, detail });
107
+ // Extract stack entries: adtcore:name + adtcore:uri (contains #start=N for line)
108
+ const stack = [];
109
+ const stackRe = /<stackEntry\s([^>]*\/?>)/g;
110
+ let sm;
111
+ while ((sm = stackRe.exec(block)) !== null) {
112
+ const entry = sm[1];
113
+ const snMatch = entry.match(/adtcore:name="([^"]*)"/);
114
+ const suMatch = entry.match(/adtcore:uri="([^"]*)"/);
115
+ if (snMatch) {
116
+ const lineMatch = suMatch && suMatch[1].match(/#start=(\d+)/);
117
+ stack.push({
118
+ name: snMatch[1],
119
+ line: lineMatch ? parseInt(lineMatch[1], 10) : null,
120
+ });
121
+ }
122
+ }
123
+
124
+ methods.push({ name, testClassName, passed: false, executionTime, kind, title, details, stack });
125
+ }
89
126
  }
90
127
 
91
128
  // Parse coverage stats if present
92
- // <coverage ... adtcore:lines_total="N" adtcore:lines_covered="M" adtcore:coverage_rate="R"/>
93
129
  let coverageStats = null;
94
130
  const covMatch = xml.match(/<coverage\b[^>]*>/);
95
131
  if (covMatch) {
@@ -137,13 +173,19 @@ function buildUnitJUnit(results) {
137
173
  lines.push(` <testcase name="(no tests)" classname="${escapeXml(className)}"/>`);
138
174
  } else {
139
175
  for (const m of methods) {
176
+ const timeAttr = m.executionTime != null ? ` time="${m.executionTime}"` : '';
177
+ // Use "ZCL_MY_TEST.LTCL_UNIT_TEST" so Jenkins groups by ABAP class first,
178
+ // then local test class — giving a clear package hierarchy in the test report.
179
+ const testClass = m.testClassName ? `${className}.${m.testClassName}` : className;
140
180
  if (m.passed) {
141
- lines.push(` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(className)}"/>`);
181
+ lines.push(` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(testClass)}"${timeAttr}/>`);
142
182
  } else {
143
183
  const msg = escapeXml(m.title || 'Test failed');
144
- const body = m.detail ? escapeXml(`${m.title}\n${m.detail}`) : msg;
184
+ const bodyParts = [m.title || 'Test failed'];
185
+ if (m.details && m.details.length) bodyParts.push(...m.details);
186
+ const body = escapeXml(bodyParts.join('\n'));
145
187
  lines.push(
146
- ` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(className)}">\n` +
188
+ ` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(testClass)}"${timeAttr}>\n` +
147
189
  ` <failure type="${escapeXml(m.kind || 'failedAssertion')}" message="${msg}">${body}</failure>\n` +
148
190
  ` </testcase>`
149
191
  );
@@ -231,10 +273,42 @@ async function runUnitTestForFile(sourceFile, adt, coverage, jsonOutput, verbose
231
273
  console.log(` Covered Lines: ${coveredLines}`);
232
274
  }
233
275
 
276
+ // Slow test summary: show methods with executionTime > 0.1s
277
+ const slowThreshold = 0.1;
278
+ const slowMethods = result.methods
279
+ .filter(m => m.executionTime > slowThreshold)
280
+ .sort((a, b) => b.executionTime - a.executionTime);
281
+ if (slowMethods.length > 0) {
282
+ const slowList = slowMethods.map(m => `${m.name} (${m.executionTime.toFixed(3)}s)`).join(', ');
283
+ console.log(` ⏱ Slowest: ${slowList}`);
284
+ }
285
+
234
286
  if (failedCount > 0) {
235
287
  for (const m of result.methods.filter(m => !m.passed)) {
236
- console.log(` ✗ ${className}=>${m.name}: ${m.title || 'Test failed'}`);
288
+ const prefix = m.testClassName || className;
289
+ console.log(`\n ✗ ${prefix}=>${m.name}`);
290
+ console.log(` ${m.title || 'Test failed'}`);
291
+ if (m.details && m.details.length) {
292
+ for (const d of m.details) console.log(` ${d}`);
293
+ }
294
+ if (m.stack && m.stack.length) {
295
+ for (const s of m.stack) {
296
+ const loc = s.line != null ? ` (line ${s.line})` : '';
297
+ console.log(` at ${s.name}${loc}`);
298
+ }
299
+ }
237
300
  }
301
+ console.log('');
302
+ }
303
+
304
+ // --verbose: per-method time table
305
+ if (verbose && testCount > 0) {
306
+ console.log(` Method times:`);
307
+ for (const m of result.methods) {
308
+ const mark = m.passed ? '✓' : '✗';
309
+ console.log(` ${mark} ${m.name.padEnd(40)} ${(m.executionTime || 0).toFixed(3)}s`);
310
+ }
311
+ console.log('');
238
312
  }
239
313
  }
240
314
 
@@ -285,6 +359,7 @@ Parameters:
285
359
  --coverage-mode <warn|fail> Action when below threshold: warn = UNSTABLE, fail = error. Default: fail.
286
360
  --junit-output <file> Write results as JUnit XML to this file.
287
361
  --json Output as JSON.
362
+ --verbose Show per-method execution times; also print raw HTTP error responses.
288
363
 
289
364
  Examples:
290
365
  abapgit-agent unit --files src/zcl_my_test.clas.testclasses.abap
@@ -368,6 +443,24 @@ Examples:
368
443
  }
369
444
  }
370
445
 
446
+ // Failed tests summary
447
+ if (!jsonOutput) {
448
+ const allFailed = results.flatMap(r => (r.methods || []).filter(m => !m.passed).map(m => ({ ...m, className: r.className })));
449
+ if (allFailed.length > 0) {
450
+ console.log('\nFailed Tests:');
451
+ console.log('─'.repeat(80));
452
+ for (const m of allFailed) {
453
+ const prefix = m.testClassName || m.className;
454
+ console.log(` ✗ ${prefix}=>${m.name}`);
455
+ console.log(` ${m.title || 'Test failed'}`);
456
+ if (m.details && m.details.length) {
457
+ for (const d of m.details) console.log(` ${d}`);
458
+ }
459
+ console.log('');
460
+ }
461
+ }
462
+ }
463
+
371
464
  // JUnit output
372
465
  if (junitOutput) {
373
466
  const xml = buildUnitJUnit(results);
@@ -246,7 +246,7 @@ Description:
246
246
  Parameters:
247
247
  --objects <obj1,...> Comma-separated object names (required).
248
248
  --type <type> Object type: CLAS, INTF, PROG, TABL, STRU, DTEL, TTYP, DOMA,
249
- DDLS, DCLS, MSAG, FUGR (auto-detected from TADIR if omitted).
249
+ DDLS, DCLS, MSAG, FUGR, SUSO (auto-detected from TADIR if omitted).
250
250
  --full Show full source including all method implementations.
251
251
  --lines Show dual line numbers (G = global for debug set, [N] = include-local).
252
252
  --fm <name> With --full: show only the specified function module (FUGR only).
@@ -593,6 +593,51 @@ Examples:
593
593
  console.log(` ${desc}`);
594
594
  }
595
595
  }
596
+ } else if (objType === 'SUSO' || objType === 'Authorization Object') {
597
+ // SUSO: property box for object class + fields, then activities list
598
+ const classComp = components.filter(c => String(c.FIELD || c.field || '').startsWith('OCLSS'));
599
+ const fieldComps = components.filter(c => String(c.FIELD || c.field || '').startsWith('FIELD_'));
600
+ const actvtComps = components.filter(c => String(c.FIELD || c.field || '').startsWith('ACTVT_'));
601
+
602
+ const propWidth = 16;
603
+ const valueWidth = 42;
604
+ const sep = '┌' + '─'.repeat(propWidth + 2) + '┬' + '─'.repeat(valueWidth + 2) + '┐';
605
+ const mid = '├' + '─'.repeat(propWidth + 2) + '┼' + '─'.repeat(valueWidth + 2) + '┤';
606
+ const end = '└' + '─'.repeat(propWidth + 2) + '┴' + '─'.repeat(valueWidth + 2) + '┘';
607
+ const buildPropRow = (property, value) =>
608
+ '│ ' + String(property || '').padEnd(propWidth) + ' │ ' +
609
+ String(value || '').substring(0, valueWidth).padEnd(valueWidth) + ' │';
610
+
611
+ console.log(` AUTH OBJECT ${objName}:`);
612
+ console.log(sep);
613
+ console.log(buildPropRow('Property', 'Value'));
614
+ console.log(mid);
615
+
616
+ for (const comp of classComp) {
617
+ const desc = comp.DESCRIPTION || comp.description || '';
618
+ const colonIdx = desc.indexOf(': ');
619
+ const value = colonIdx !== -1 ? desc.substring(colonIdx + 2) : desc;
620
+ console.log(buildPropRow('Object class', value));
621
+ }
622
+
623
+ for (const comp of fieldComps) {
624
+ const desc = comp.DESCRIPTION || comp.description || '';
625
+ const colonIdx = desc.indexOf(': ');
626
+ const value = colonIdx !== -1 ? desc.substring(colonIdx + 2) : desc;
627
+ const idx = String(comp.FIELD || comp.field || '').replace('FIELD_', '');
628
+ console.log(buildPropRow(`Field ${idx}`, value));
629
+ }
630
+
631
+ console.log(end);
632
+
633
+ if (actvtComps.length > 0) {
634
+ console.log('');
635
+ console.log(` Allowed activities (${actvtComps.length}):`);
636
+ for (const comp of actvtComps) {
637
+ const desc = comp.DESCRIPTION || comp.description || '';
638
+ console.log(` ${desc}`);
639
+ }
640
+ }
596
641
  } else {
597
642
  // Build table display for TABL/STRU with Data Element and Description
598
643
  const colWidths = {