arc-1 0.9.6 → 0.9.7

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.
Files changed (72) hide show
  1. package/README.md +2 -2
  2. package/dist/adt/abapgit.d.ts +2 -1
  3. package/dist/adt/abapgit.d.ts.map +1 -1
  4. package/dist/adt/abapgit.js +2 -2
  5. package/dist/adt/abapgit.js.map +1 -1
  6. package/dist/adt/btp.d.ts.map +1 -1
  7. package/dist/adt/btp.js +7 -3
  8. package/dist/adt/btp.js.map +1 -1
  9. package/dist/adt/class-structure.d.ts +176 -0
  10. package/dist/adt/class-structure.d.ts.map +1 -0
  11. package/dist/adt/class-structure.js +317 -0
  12. package/dist/adt/class-structure.js.map +1 -0
  13. package/dist/adt/client.d.ts +112 -1
  14. package/dist/adt/client.d.ts.map +1 -1
  15. package/dist/adt/client.js +245 -3
  16. package/dist/adt/client.js.map +1 -1
  17. package/dist/adt/crud.d.ts +38 -0
  18. package/dist/adt/crud.d.ts.map +1 -1
  19. package/dist/adt/crud.js +73 -1
  20. package/dist/adt/crud.js.map +1 -1
  21. package/dist/adt/errors.d.ts +2 -2
  22. package/dist/adt/errors.d.ts.map +1 -1
  23. package/dist/adt/errors.js +50 -6
  24. package/dist/adt/errors.js.map +1 -1
  25. package/dist/adt/gcts.d.ts +3 -2
  26. package/dist/adt/gcts.d.ts.map +1 -1
  27. package/dist/adt/gcts.js +4 -4
  28. package/dist/adt/gcts.js.map +1 -1
  29. package/dist/adt/http.d.ts +18 -0
  30. package/dist/adt/http.d.ts.map +1 -1
  31. package/dist/adt/http.js +50 -43
  32. package/dist/adt/http.js.map +1 -1
  33. package/dist/adt/package-hierarchy.d.ts +67 -0
  34. package/dist/adt/package-hierarchy.d.ts.map +1 -0
  35. package/dist/adt/package-hierarchy.js +100 -0
  36. package/dist/adt/package-hierarchy.js.map +1 -0
  37. package/dist/adt/release.d.ts +35 -0
  38. package/dist/adt/release.d.ts.map +1 -0
  39. package/dist/adt/release.js +48 -0
  40. package/dist/adt/release.js.map +1 -0
  41. package/dist/adt/safety.d.ts +39 -3
  42. package/dist/adt/safety.d.ts.map +1 -1
  43. package/dist/adt/safety.js +136 -15
  44. package/dist/adt/safety.js.map +1 -1
  45. package/dist/adt/types.d.ts +74 -0
  46. package/dist/adt/types.d.ts.map +1 -1
  47. package/dist/adt/xml-parser.d.ts +46 -1
  48. package/dist/adt/xml-parser.d.ts.map +1 -1
  49. package/dist/adt/xml-parser.js +231 -0
  50. package/dist/adt/xml-parser.js.map +1 -1
  51. package/dist/authz/policy.d.ts.map +1 -1
  52. package/dist/authz/policy.js +12 -0
  53. package/dist/authz/policy.js.map +1 -1
  54. package/dist/context/grep.d.ts +48 -0
  55. package/dist/context/grep.d.ts.map +1 -0
  56. package/dist/context/grep.js +146 -0
  57. package/dist/context/grep.js.map +1 -0
  58. package/dist/handlers/intent.d.ts.map +1 -1
  59. package/dist/handlers/intent.js +430 -24
  60. package/dist/handlers/intent.js.map +1 -1
  61. package/dist/handlers/schemas.d.ts +42 -4
  62. package/dist/handlers/schemas.d.ts.map +1 -1
  63. package/dist/handlers/schemas.js +85 -9
  64. package/dist/handlers/schemas.js.map +1 -1
  65. package/dist/handlers/tools.d.ts.map +1 -1
  66. package/dist/handlers/tools.js +68 -12
  67. package/dist/handlers/tools.js.map +1 -1
  68. package/dist/server/server.d.ts +20 -2
  69. package/dist/server/server.d.ts.map +1 -1
  70. package/dist/server/server.js +45 -11
  71. package/dist/server/server.js.map +1 -1
  72. package/package.json +1 -1
@@ -11,8 +11,9 @@
11
11
  */
12
12
  import { checkRepo as abapGitCheckRepo, createBranch as abapGitCreateBranch, createRepo as abapGitCreateRepo, getExternalInfo as abapGitGetExternalInfo, listRepos as abapGitListRepos, pullRepo as abapGitPullRepo, pushRepo as abapGitPushRepo, stageRepo as abapGitStageRepo, switchBranch as abapGitSwitchBranch, unlinkRepo as abapGitUnlinkRepo, } from '../adt/abapgit.js';
13
13
  import { buildSiblingExtensionFinding, classifyCdsImpact, deriveSiblingStem, isSiblingNameMatch, } from '../adt/cds-impact.js';
14
+ import { diffMethodSets, extractMethodNameFromClause, findSectionAnchor, insertMethodPair, moveMethodDefinition, removeMethodPair, spliceClassDefinition, spliceMethodSignature, } from '../adt/class-structure.js';
14
15
  import { findDefinition, findInterfaceImplementersViaSeoMetaRel, findReferences, findWhereUsed, getCompletion, getWhereUsedScope, } from '../adt/codeintel.js';
15
- import { createObject, deleteObject, lockObject, safeUpdateObject, safeUpdateSource, unlockObject, updateObject, updateSource, } from '../adt/crud.js';
16
+ import { createObject, deleteObject, lockObject, safeUpdateClassInclude, safeUpdateObject, safeUpdateSource, unlockObject, updateObject, updateSource, } from '../adt/crud.js';
16
17
  import { buildDataElementXml, buildDomainXml, buildMessageClassXml, buildPackageXml, buildServiceBindingXml, decodeKtdText, rewriteKtdText, } from '../adt/ddic-xml.js';
17
18
  import { activate, activateBatch, applyFixProposal, getFixProposals, getPrettyPrinterSettings, prettyPrint, publishServiceBinding, runAtcCheck, runUnitTests, setPrettyPrinterSettings, syntaxCheck, unpublishServiceBinding, } from '../adt/devtools.js';
18
19
  import { getDump, getGatewayErrorDetail, getObjectState, getTraceDbAccesses, getTraceHitlist, getTraceStatements, listDumps, listGatewayErrors, listSystemMessages, listTraces, } from '../adt/diagnostics.js';
@@ -31,6 +32,7 @@ import { getAppInfo } from '../adt/ui5-repository.js';
31
32
  import { validateAffHeader } from '../aff/validator.js';
32
33
  import { extractCdsDependencies, extractCdsElements } from '../context/cds-deps.js';
33
34
  import { compressCdsContext, compressContext } from '../context/compressor.js';
35
+ import { grepSource } from '../context/grep.js';
34
36
  import { extractMethod, formatMethodListing, listMethods, spliceMethod } from '../context/method-surgery.js';
35
37
  import { buildLintConfig, listRulesFromConfig, } from '../lint/config-builder.js';
36
38
  import { detectFilename, lintAbapSource, lintAndFix, validateBeforeWrite } from '../lint/lint.js';
@@ -227,7 +229,11 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
227
229
  // Append additional SAP messages (line numbers, secondary errors) if available
228
230
  const enriched = enrichWithSapDetails(err, message);
229
231
  const argType = canonicalTablType(String(args.type ?? '').toUpperCase());
230
- const classification = classifySapDomainError(err.statusCode, err.responseBody, err.path);
232
+ // Pass the detected SAP_BASIS release so the 423 lock-handle hint can specialize
233
+ // (< 7.51 → point at abapfs_extensions; see issue #293). cachedFeatures is set by the
234
+ // startup probe; config.abapRelease is the manual SAP_ABAP_RELEASE override fallback.
235
+ const abapRelease = cachedFeatures?.abapRelease ?? config.abapRelease;
236
+ const classification = classifySapDomainError(err.statusCode, err.responseBody, err.path, abapRelease);
231
237
  if (classification) {
232
238
  const transactionLine = classification.transaction ? `\nSAP Transaction: ${classification.transaction}` : '';
233
239
  return `${enriched}\n\nHint: ${classification.hint}${transactionLine}`;
@@ -263,6 +269,14 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
263
269
  `sqlFilter="MANDT = '100'" or sqlFilter="MATNR LIKE 'Z%'".`);
264
270
  }
265
271
  }
272
+ if (tool === 'SAPRead' && argType === 'TABLE_QUERY' && err.statusCode === 400) {
273
+ const combined = `${err.message}\n${err.responseBody ?? ''}`;
274
+ if (/is invalid here|due to grammar/i.test(combined)) {
275
+ return (`${enriched}\n\nHint: TABLE_QUERY parser error — check field names match the actual column names ` +
276
+ 'exposed by the table/CDS view (use SAPRead(type="DDLS", include="elements") to inspect CDS view fields). ' +
277
+ 'Also verify value formats (e.g. FiscalPeriod is C(2,0) so use "01" not "001").');
278
+ }
279
+ }
266
280
  const behaviorPoolHint = getBehaviorPoolSaveFailureHint(err, args);
267
281
  if (behaviorPoolHint) {
268
282
  return `${enriched}\n\nHint: ${behaviorPoolHint}`;
@@ -1105,6 +1119,11 @@ async function handleSAPRead(client, args, cachingLayer) {
1105
1119
  const indicator = cacheHit && revalidated ? '[cached:revalidated]\n' : '';
1106
1120
  return textResult(`${note}${indicator}${source}`);
1107
1121
  };
1122
+ /** When args.grep is set, return only matching source lines (+context) instead of full source. */
1123
+ const grepText = (source) => {
1124
+ const g = grepSource(source, String(args.grep));
1125
+ return g.invalidPattern ? errorResult(g.output) : textResult(g.output);
1126
+ };
1108
1127
  // Structured format is only supported for CLAS type
1109
1128
  if (args.format === 'structured' && type !== 'CLAS') {
1110
1129
  return errorResult('The "structured" format is only supported for CLAS type. Other types return text format.');
@@ -1112,9 +1131,45 @@ async function handleSAPRead(client, args, cachingLayer) {
1112
1131
  switch (type) {
1113
1132
  case 'PROG': {
1114
1133
  const { source, cacheHit, revalidated } = await cachedGet('PROG', name, effectiveVersion, (ifNoneMatch) => client.getProgram(name, { ifNoneMatch, version: effectiveVersion }));
1134
+ if (args.grep)
1135
+ return grepText(source);
1115
1136
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1116
1137
  }
1117
1138
  case 'CLAS': {
1139
+ // grep: return only matching source lines (+context), annotated with the owning class/method.
1140
+ if (args.grep) {
1141
+ if (args.method) {
1142
+ return errorResult('Do not combine grep with method. Use grep to find code, then method="<name>" to read the full method.');
1143
+ }
1144
+ const rawSection = args.include;
1145
+ // 'main' (and the default) live at /source/main, not /includes/main — read via the
1146
+ // cached main path; only real sub-includes go through the raw getClassInclude endpoint.
1147
+ const section = rawSection && rawSection.toLowerCase() !== 'main' ? rawSection : undefined;
1148
+ let clasSource;
1149
+ if (section) {
1150
+ try {
1151
+ clasSource = (await client.getClassInclude(name, section, { version: effectiveVersion })).source;
1152
+ }
1153
+ catch (err) {
1154
+ if (isNotFoundError(err)) {
1155
+ return textResult(`Include "${section}" is not available for class ${name}. Run grep without include= to search the full class source.`);
1156
+ }
1157
+ throw err;
1158
+ }
1159
+ }
1160
+ else {
1161
+ clasSource = (await cachedGet('CLAS', name, effectiveVersion, (ifNoneMatch) => client.getClass(name, undefined, { ifNoneMatch, version: effectiveVersion }))).source;
1162
+ }
1163
+ const abaplintVer = cachedFeatures?.abapRelease
1164
+ ? mapSapReleaseToAbaplintVersion(cachedFeatures.abapRelease)
1165
+ : undefined;
1166
+ // MethodInfo is a structural superset of grepSource's MethodRange — pass through directly.
1167
+ const listing = listMethods(clasSource, name, abaplintVer);
1168
+ const g = grepSource(clasSource, String(args.grep), listing.success ? { methods: listing.methods } : undefined);
1169
+ return g.invalidPattern
1170
+ ? errorResult(g.output)
1171
+ : textResult(`[${name} section=${rawSection ?? 'main'}]\n${g.output}`);
1172
+ }
1118
1173
  // Structured format: return JSON with metadata + decomposed source
1119
1174
  if (args.format === 'structured') {
1120
1175
  const structured = await client.getClassStructured(name);
@@ -1149,6 +1204,8 @@ async function handleSAPRead(client, args, cachingLayer) {
1149
1204
  }
1150
1205
  case 'INTF': {
1151
1206
  const { source, cacheHit, revalidated } = await cachedGet('INTF', name, effectiveVersion, (ifNoneMatch) => client.getInterface(name, { ifNoneMatch, version: effectiveVersion }));
1207
+ if (args.grep)
1208
+ return grepText(source);
1152
1209
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1153
1210
  }
1154
1211
  case 'FUNC': {
@@ -1184,6 +1241,8 @@ async function handleSAPRead(client, args, cachingLayer) {
1184
1241
  };
1185
1242
  return textResult(JSON.stringify(payload, null, 2));
1186
1243
  }
1244
+ if (args.grep)
1245
+ return grepText(source);
1187
1246
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1188
1247
  }
1189
1248
  case 'FUGR': {
@@ -1211,6 +1270,8 @@ async function handleSAPRead(client, args, cachingLayer) {
1211
1270
  }
1212
1271
  case 'INCL': {
1213
1272
  const { source, cacheHit, revalidated } = await cachedGet('INCL', name, effectiveVersion, (ifNoneMatch) => client.getInclude(name, { ifNoneMatch, version: effectiveVersion }));
1273
+ if (args.grep)
1274
+ return grepText(source);
1214
1275
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1215
1276
  }
1216
1277
  case 'DDLS': {
@@ -1223,23 +1284,33 @@ async function handleSAPRead(client, args, cachingLayer) {
1223
1284
  // Elements extraction is derived from source — no cache indicator
1224
1285
  return cachedTextResult(extractCdsElements(ddlSource, name), false, false, versionWarning);
1225
1286
  }
1287
+ if (args.grep)
1288
+ return grepText(ddlSource);
1226
1289
  return cachedTextResult(ddlSource, cacheHit, revalidated, versionWarning);
1227
1290
  }
1228
1291
  case 'DCLS': {
1229
1292
  const { source, cacheHit, revalidated } = await cachedGet('DCLS', name, effectiveVersion, (ifNoneMatch) => client.getDcl(name, { ifNoneMatch, version: effectiveVersion }));
1293
+ if (args.grep)
1294
+ return grepText(source);
1230
1295
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1231
1296
  }
1232
1297
  case 'BDEF': {
1233
1298
  const { source, cacheHit, revalidated } = await cachedGet('BDEF', name, effectiveVersion, (ifNoneMatch) => client.getBdef(name, { ifNoneMatch, version: effectiveVersion }));
1299
+ if (args.grep)
1300
+ return grepText(source);
1234
1301
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1235
1302
  }
1236
1303
  case 'SRVD': {
1237
1304
  const { source, cacheHit, revalidated } = await cachedGet('SRVD', name, effectiveVersion, (ifNoneMatch) => client.getSrvd(name, { ifNoneMatch, version: effectiveVersion }));
1305
+ if (args.grep)
1306
+ return grepText(source);
1238
1307
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1239
1308
  }
1240
1309
  case 'DDLX': {
1241
1310
  try {
1242
1311
  const { source, cacheHit, revalidated } = await cachedGet('DDLX', name, effectiveVersion, (ifNoneMatch) => client.getDdlx(name, { ifNoneMatch, version: effectiveVersion }));
1312
+ if (args.grep)
1313
+ return grepText(source);
1243
1314
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1244
1315
  }
1245
1316
  catch (err) {
@@ -1273,10 +1344,14 @@ async function handleSAPRead(client, args, cachingLayer) {
1273
1344
  // client.getTabl() handles the /tables/ → /structures/ fallback internally
1274
1345
  // and caches the resolved URL for subsequent write/activate paths.
1275
1346
  const { source, cacheHit, revalidated } = await cachedGet('TABL', name, effectiveVersion, (ifNoneMatch) => client.getTabl(name, { ifNoneMatch, version: effectiveVersion }));
1347
+ if (args.grep)
1348
+ return grepText(source);
1276
1349
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1277
1350
  }
1278
1351
  case 'VIEW': {
1279
1352
  const { source, cacheHit, revalidated } = await cachedGet('VIEW', name, effectiveVersion, (ifNoneMatch) => client.getView(name, { ifNoneMatch, version: effectiveVersion }));
1353
+ if (args.grep)
1354
+ return grepText(source);
1280
1355
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1281
1356
  }
1282
1357
  case 'DOMA': {
@@ -1383,6 +1458,15 @@ async function handleSAPRead(client, args, cachingLayer) {
1383
1458
  const data = await client.getTableContents(name, maxRows, args.sqlFilter);
1384
1459
  return textResult(JSON.stringify(data, null, 2));
1385
1460
  }
1461
+ case 'TABLE_QUERY': {
1462
+ const maxRows = Number(args.maxRows ?? 100);
1463
+ const columns = Array.isArray(args.columns) ? args.columns : undefined;
1464
+ const where = Array.isArray(args.where)
1465
+ ? args.where
1466
+ : undefined;
1467
+ const data = await client.runTableQuery(name, { columns, where, maxRows });
1468
+ return textResult(JSON.stringify(data, null, 2));
1469
+ }
1386
1470
  case 'SOBJ': {
1387
1471
  const method = String(args.method ?? '');
1388
1472
  // Sanitize inputs to prevent SQL injection — BOR names are alphanumeric + underscore only
@@ -1542,7 +1626,7 @@ async function handleSAPSearch(client, args) {
1542
1626
  // keeps the same logical object from appearing twice in the merged matches.
1543
1627
  // Preserve the more-specific slash form when both originate from ADT+DB.
1544
1628
  const seen = new Map();
1545
- const baseKey = (m) => `${(m.objectType.split('/')[0] || m.objectType).toUpperCase()}${m.objectName.toUpperCase()}`;
1629
+ const baseKey = (m) => `${(m.objectType.split('/')[0] || m.objectType).toUpperCase()}\x00${m.objectName.toUpperCase()}`;
1546
1630
  for (const m of adtMatches)
1547
1631
  seen.set(baseKey(m), m);
1548
1632
  for (const m of dbMatches) {
@@ -2924,6 +3008,12 @@ function stripIncludeHeader(source) {
2924
3008
  return source.replace(/^=== \w+ ===\n/, '');
2925
3009
  }
2926
3010
  // ─── SAPWrite Handler ────────────────────────────────────────────────
3011
+ /**
3012
+ * Single-object actions whose top-level `name` is an SAP object name and must be
3013
+ * uppercase (TADIR convention). `batch_create` is excluded — its names live in
3014
+ * the `objects[]` items and are validated per item in the batch_create branch.
3015
+ */
3016
+ const NAME_CASE_GUARD_ACTIONS = new Set(['create', 'update', 'edit_method', 'delete']);
2927
3017
  async function handleSAPWrite(client, args, config, cachingLayer) {
2928
3018
  const action = String(args.action ?? '');
2929
3019
  const type = normalizeWriteObjectType(String(args.type ?? ''));
@@ -2931,6 +3021,10 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2931
3021
  const source = String(args.source ?? '');
2932
3022
  const hasSource = typeof args.source === 'string';
2933
3023
  const include = normalizeClassWriteInclude(args.include);
3024
+ // Whether a non-empty include was actually requested. Some MCP clients serialize
3025
+ // an omitted optional string as "" — treat empty/whitespace as "not provided" so
3026
+ // those clients aren't rejected with a bogus "Invalid CLAS include" on the MAIN path.
3027
+ const includeProvided = typeof args.include === 'string' && args.include.trim() !== '';
2934
3028
  const transport = args.transport;
2935
3029
  const lintOverride = args.lintBeforeWrite;
2936
3030
  const preflightOverride = args.preflightBeforeWrite;
@@ -2940,12 +3034,16 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2940
3034
  return errorResult('"type" and "name" are required for this action.');
2941
3035
  }
2942
3036
  // SAP TADIR stores object names uppercase. Mixed-case names cause silent corruption
2943
- // (e.g. DDLS created as "Zc_MyView" registers as "ZC_MYVIEW" in TADIR but the source body
2944
- // still contains "Zc_MyView", confusing every downstream tool). Reject pre-flight on create
2945
- // applies on every SAP release; this is universal SAP convention, not a 7.50 quirk.
3037
+ // on create (e.g. DDLS "Zc_MyView" registers as "ZC_MYVIEW" in TADIR but the source body
3038
+ // still contains "Zc_MyView", confusing every downstream tool) and broken URL lookups on
3039
+ // mutate/delete the lock is held against the canonical uppercase name while the request
3040
+ // URL carries the mixed-case one, which surfaces on ECC as 423 "... is not locked" (issue
3041
+ // #293, original report used name "Z_HELLO_world"). Reject pre-flight for every name-bearing
3042
+ // single-object action — universal SAP convention, not a 7.50 quirk. (batch_create validates
3043
+ // each item separately below.)
2946
3044
  // Note: source code INSIDE the object can use mixed case (e.g. for DDLS: name="ZC_MYVIEW"
2947
3045
  // but `define view entity Zc_MyView` is fine inside the source body).
2948
- if (action === 'create' && name && name !== name.toUpperCase()) {
3046
+ if (NAME_CASE_GUARD_ACTIONS.has(action) && name && name !== name.toUpperCase()) {
2949
3047
  return errorResult(`Object name "${name}" contains lowercase characters. SAP object names must be uppercase (e.g. "${name.toUpperCase()}").\n\n` +
2950
3048
  `Note: the object NAME in TADIR must be uppercase, but the source code inside the object can use mixed case ` +
2951
3049
  `(e.g. for DDLS: name="${name.toUpperCase()}" but source can contain "define view entity ${name}").`);
@@ -3023,14 +3121,35 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3023
3121
  };
3024
3122
  // Helper: enforce allowedPackages for existing objects (update/delete/edit_method/scaffold_rap_handlers).
3025
3123
  // Only fetches metadata when package restrictions are configured — no extra HTTP call otherwise.
3124
+ // Fail-closed: if the package cannot be determined from ADT metadata, refuse the write
3125
+ // rather than silently passing through the allowlist gate.
3026
3126
  async function enforcePackageForExistingObject() {
3027
3127
  if (client.safety.allowedPackages.length === 0)
3028
3128
  return undefined;
3029
3129
  const pkg = await client.resolveObjectPackage(objectUrl);
3030
- if (pkg)
3031
- checkPackage(client.safety, pkg);
3130
+ if (!pkg) {
3131
+ throw new AdtSafetyError(`Operations on ${type} '${name}' blocked: ARC-1 could not determine the object's package ` +
3132
+ `from ADT metadata (no adtcore:packageRef in response). Fail-closed because allowedPackages is restricted.`);
3133
+ }
3134
+ await checkPackage(client.safety, pkg, client.getPackageHierarchyResolver());
3032
3135
  return pkg;
3033
3136
  }
3137
+ // Helper for class-section surgery (issue #303): fetch the class structure AND
3138
+ // /source/main at the SAME effective version, so the spliced line ranges line
3139
+ // up with the bytes being edited. resolveVersionAndDraftInfo picks 'inactive'
3140
+ // when an unactivated draft exists. We pass that version to BOTH getClassStructure
3141
+ // (the /objectstructure?version= read) and the source read, AND to the cache opts
3142
+ // (so inactive bytes aren't cached under the 'active' key). Without this, a chained
3143
+ // surgery call on a draft would splice active-version line ranges into inactive
3144
+ // source and silently corrupt the draft.
3145
+ async function fetchClassStructureAndMain(clsName) {
3146
+ const { effectiveVersion } = await resolveVersionAndDraftInfo(client, cachingLayer, 'CLAS', clsName, 'auto');
3147
+ const structure = await client.getClassStructure(clsName, effectiveVersion);
3148
+ const main = cachingLayer
3149
+ ? (await cachingLayer.getSource('CLAS', clsName, (ifNoneMatch) => client.getClass(clsName, undefined, { ifNoneMatch, version: effectiveVersion }), { version: effectiveVersion })).source
3150
+ : (await client.getClass(clsName, undefined, { version: effectiveVersion })).source;
3151
+ return { structure, main, effectiveVersion };
3152
+ }
3034
3153
  switch (action) {
3035
3154
  case 'update': {
3036
3155
  const existingPackage = await enforcePackageForExistingObject();
@@ -3047,9 +3166,14 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3047
3166
  if (!hasSource) {
3048
3167
  return errorResult('"source" is required when updating a CLAS include.');
3049
3168
  }
3050
- await safeUpdateSource(client.http, client.safety, objectUrl, classIncludeUrl(name, include), source, transport, cachedFeatures?.abapRelease);
3169
+ // Auto-initialise the include if it doesn't exist yet. On a fresh class
3170
+ // the testclasses (CCAU) include is absent — a content PUT alone fails
3171
+ // with HTTP 500 "…CCAU does not have any inactive version". safeUpdateClassInclude
3172
+ // probes the include and POST-creates it (under the same lock) before the PUT.
3173
+ const { initialized } = await safeUpdateClassInclude(client.http, client.safety, objectUrl, classIncludeUrl(name, include), source, transport, cachedFeatures?.abapRelease);
3051
3174
  invalidateWrittenObject(type, name);
3052
- return textResult(`Successfully updated ${type} ${name} include ${include}. Active version remains unchanged until activation; read with SAPRead(version="inactive") to verify the draft.`);
3175
+ const initNote = initialized ? ` (initialised the ${include} include first)` : '';
3176
+ return textResult(`Successfully updated ${type} ${name} include ${include}${initNote}. Active version remains unchanged until activation; read with SAPRead(version="inactive") to verify the draft.`);
3053
3177
  }
3054
3178
  if (type === 'SKTD') {
3055
3179
  // KTD update requires the full <sktd:docu> XML envelope with the Markdown
@@ -3150,7 +3274,7 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3150
3274
  }
3151
3275
  case 'create': {
3152
3276
  const pkg = String(args.package ?? '$TMP');
3153
- checkPackage(client.safety, pkg);
3277
+ await checkPackage(client.safety, pkg, client.getPackageHierarchyResolver());
3154
3278
  const description = String(args.description ?? name);
3155
3279
  // Pre-flight: check transport requirements for non-$TMP packages when no transport provided.
3156
3280
  // SAP requires a transport number for objects in transportable packages.
@@ -3466,6 +3590,261 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3466
3590
  const extras = [lintWarnings.warnings, checkNotes].filter(Boolean).join('\n\n');
3467
3591
  return extras ? textResult(`${msg}\n\n${extras}`) : textResult(msg);
3468
3592
  }
3593
+ // ─── Class-section surgery actions (issue #303) ─────────────────────
3594
+ //
3595
+ // Four actions share a common shape: fetch objectstructure → optional
3596
+ // diff/refuse → splice into /source/main (or /includes/<inc> when
3597
+ // include= is set) → PUT under lock → no auto-activate.
3598
+ //
3599
+ // Pre-write lint runs on the SPLICED FULL source (not the partial input
3600
+ // fragment) because a raw DEFINITION block alone fails abaplint with
3601
+ // "Expected CLASSIMPLEMENTATION" — verified live on a4h. Lint is skipped
3602
+ // for include= writes (same precedent as `update include=` path).
3603
+ case 'edit_class_definition': {
3604
+ if (type !== 'CLAS')
3605
+ return errorResult('edit_class_definition is only supported for type=CLAS.');
3606
+ if (!hasSource)
3607
+ return errorResult('"source" (new CLASS DEFINITION block) is required for edit_class_definition.');
3608
+ if (includeProvided && !include) {
3609
+ return errorResult(`Invalid CLAS include "${String(args.include)}". Valid values: ${CLASS_WRITE_INCLUDES.join(', ')}.`);
3610
+ }
3611
+ await enforcePackageForExistingObject();
3612
+ const writeUrl = include ? classIncludeUrl(name, include) : srcUrl;
3613
+ let spliced;
3614
+ if (include) {
3615
+ // include= path: whole-replace the local include (CCDEF/CCIMP/macros/
3616
+ // testclasses). The structure-based diff/refuse doesn't apply — the
3617
+ // /objectstructure endpoint reports the GLOBAL class, not the local
3618
+ // include's split DEFINITION/IMPLEMENTATION halves. SAP activation is the
3619
+ // validator here (same precedent as `update include=`). No structure or
3620
+ // source fetch is needed: the caller's `source` IS the new include body.
3621
+ spliced = source.endsWith('\n') ? source : `${source}\n`;
3622
+ }
3623
+ else {
3624
+ // MAIN path: fetch structure + source at the same effective version so
3625
+ // the spliced line ranges align with the bytes being edited.
3626
+ const { structure, main } = await fetchClassStructureAndMain(name);
3627
+ // Refuse-policy: compute the method-set diff against the NEW DEFINITION.
3628
+ const diff = diffMethodSets(structure, source);
3629
+ const missingImpls = [];
3630
+ const orphanImpls = [];
3631
+ for (const add of diff.added) {
3632
+ // Exempt declarations that never have a METHOD…ENDMETHOD body.
3633
+ if (add.isAbstract || add.isEvent || add.isInterface || add.isAlias)
3634
+ continue;
3635
+ // Does IMPLEMENTATION already have a METHOD <name> header? Match the
3636
+ // method name followed by a word-boundary so AMDP / event-handler /
3637
+ // multi-line headers (`METHOD x BY DATABASE PROCEDURE…`, `METHOD x FOR
3638
+ // EVENT…`, `METHOD x\n IMPORTING…`) are recognized — NOT only the bare
3639
+ // `METHOD x.` form. \b after the name prevents matching a longer name
3640
+ // with the same prefix (METHOD x_helper for added X).
3641
+ const re = new RegExp(`^\\s*METHOD\\s+${add.name}\\b`, 'im');
3642
+ if (!re.test(main))
3643
+ missingImpls.push(add.name);
3644
+ }
3645
+ for (const rem of diff.removed) {
3646
+ if (rem.implementation) {
3647
+ // Was concrete, still has impl range — caller didn't remove the body.
3648
+ orphanImpls.push(rem.name);
3649
+ }
3650
+ }
3651
+ if (missingImpls.length > 0 || orphanImpls.length > 0) {
3652
+ const parts = [];
3653
+ if (missingImpls.length > 0) {
3654
+ parts.push(`Cannot apply edit_class_definition: the new DEFINITION declares method(s) ${missingImpls.join(', ')} but the existing IMPLEMENTATION block has no matching METHOD…ENDMETHOD body. Either include a METHOD <name>. ENDMETHOD. block per added method in your new source, or use SAPWrite(action="add_method", name="${name}", method="<METHODS clause>") to insert each one atomically.`);
3655
+ }
3656
+ if (orphanImpls.length > 0) {
3657
+ parts.push(`Cannot apply edit_class_definition: the new DEFINITION removes method(s) ${orphanImpls.join(', ')} but the existing IMPLEMENTATION block still has METHOD…ENDMETHOD bodies for them (orphan implementation). Either remove those METHOD blocks in your edit, or use SAPWrite(action="delete_method", name="${name}", method="<name>") to drop each one atomically.`);
3658
+ }
3659
+ return errorResult(parts.join('\n\n'));
3660
+ }
3661
+ spliced = spliceClassDefinition(main, structure, source);
3662
+ }
3663
+ // Pre-write lint on the spliced full source (MAIN path only — include=
3664
+ // fragments can't be lint-parsed standalone).
3665
+ if (!include) {
3666
+ const lintWarnings = runPreWriteLint(spliced, type, name, config, lintOverride);
3667
+ if (lintWarnings.blocked)
3668
+ return lintWarnings.result;
3669
+ }
3670
+ await safeUpdateSource(client.http, client.safety, objectUrl, writeUrl, spliced, transport, cachedFeatures?.abapRelease);
3671
+ invalidateWrittenObject(type, name);
3672
+ const whereLabel = include ? ` (include: ${include})` : '';
3673
+ return textResult(`Successfully updated DEFINITION of ${type} ${name}${whereLabel}. Active version unchanged until activation; read with SAPRead(version="inactive") to verify, then SAPActivate.`);
3674
+ }
3675
+ case 'edit_method_signature': {
3676
+ if (type !== 'CLAS')
3677
+ return errorResult('edit_method_signature is only supported for type=CLAS.');
3678
+ const methodSpecifier = String(args.method ?? '').trim();
3679
+ if (!methodSpecifier) {
3680
+ return errorResult('"method" (the method NAME to re-sign) is required for edit_method_signature.');
3681
+ }
3682
+ if (!hasSource) {
3683
+ return errorResult('"source" (the new METHODS clause) is required for edit_method_signature.');
3684
+ }
3685
+ // MAIN-only action: include= is rejected at the schema layer (this action is
3686
+ // not in SAPWRITE_INCLUDE_AWARE_ACTIONS). Defensive guard for direct CLI calls
3687
+ // that bypass Zod.
3688
+ if (includeProvided) {
3689
+ return errorResult('edit_method_signature targets the global class DEFINITION (/source/main). For local-class (CCDEF) signatures, use edit_class_definition with include=definitions.');
3690
+ }
3691
+ await enforcePackageForExistingObject();
3692
+ const { structure, main } = await fetchClassStructureAndMain(name);
3693
+ const upperName = methodSpecifier.toUpperCase();
3694
+ const method = structure.methods.find((m) => m.name === upperName);
3695
+ if (!method) {
3696
+ const available = structure.methods.map((m) => m.name).join(', ');
3697
+ const hint = methodSpecifier.includes('~')
3698
+ ? ' Interface-qualified names (e.g. "zif_x~m") are not addressable here — objectstructure lists the implementing method under its bare name; for interface/local-handler bodies use edit_method.'
3699
+ : '';
3700
+ return errorResult(`Method "${methodSpecifier}" not found in CLAS ${name}. Available methods: ${available || '(none)'}.${hint}`);
3701
+ }
3702
+ const spliced = spliceMethodSignature(main, method, source);
3703
+ // No pre-write lint: edit_method_signature changes ONLY the declaration; the
3704
+ // method body still references the old signature until the caller follows up
3705
+ // with edit_method. Linting the spliced full source here would flag legitimate
3706
+ // in-progress renames (e.g. "param `name` not declared"). SAP activation is the
3707
+ // authoritative check — same rationale as the include= lint skip on edit_method.
3708
+ await safeUpdateSource(client.http, client.safety, objectUrl, srcUrl, spliced, transport, cachedFeatures?.abapRelease);
3709
+ invalidateWrittenObject(type, name);
3710
+ return textResult(`Successfully updated signature of method "${method.name}" in ${type} ${name}. Active version unchanged until activation; if the body still references the old signature, follow up with edit_method, then SAPActivate.`);
3711
+ }
3712
+ case 'add_method': {
3713
+ if (type !== 'CLAS')
3714
+ return errorResult('add_method is only supported for type=CLAS.');
3715
+ const clause = String(args.method ?? '');
3716
+ if (!clause.trim()) {
3717
+ return errorResult('"method" (the full METHODS clause, e.g. "METHODS greet IMPORTING who TYPE string.") is required for add_method.');
3718
+ }
3719
+ const methodName = extractMethodNameFromClause(clause);
3720
+ if (!methodName) {
3721
+ return errorResult('Could not extract method name from the METHODS clause. Provide a clause starting with "METHODS <name>" or "CLASS-METHODS <name>".');
3722
+ }
3723
+ // Interface-qualified names (lhc_x~y, zif_x~m) can't be added to a global
3724
+ // class's DEFINITION/IMPLEMENTATION — `~` is interface-method scope and would
3725
+ // produce invalid ABAP in the METHOD stub. Reject with a clear pointer.
3726
+ if (methodName.includes('~')) {
3727
+ return errorResult(`add_method cannot add the interface-qualified method "${methodName}" to a global class. Implement the interface via "INTERFACES <name>." in the DEFINITION (use edit_class_definition), then provide the body with edit_method.`);
3728
+ }
3729
+ const visibility = args.visibility ?? 'public';
3730
+ const isAbstract = args.abstract === true;
3731
+ // MAIN-only action: include= is rejected at the schema layer (not in
3732
+ // SAPWRITE_INCLUDE_AWARE_ACTIONS). Defensive guard for direct CLI calls.
3733
+ if (includeProvided) {
3734
+ return errorResult('add_method targets the global class DEFINITION (/source/main). For local-class (CCDEF) method additions, use edit_class_definition with include=definitions.');
3735
+ }
3736
+ await enforcePackageForExistingObject();
3737
+ const { structure, main } = await fetchClassStructureAndMain(name);
3738
+ // Refuse if method already exists (would silently duplicate).
3739
+ if (structure.methods.some((m) => m.name === methodName)) {
3740
+ return errorResult(`Method "${methodName}" already exists in CLAS ${name}. Use SAPWrite(action="edit_method_signature", method="${methodName}", source="<new METHODS clause>") to change its signature.`);
3741
+ }
3742
+ // A concrete (non-abstract) method needs an IMPLEMENTATION block to receive
3743
+ // its METHOD…ENDMETHOD stub. A purely-abstract class has no IMPLEMENTATION
3744
+ // half, so inserting a concrete declaration there would leave it unimplemented.
3745
+ if (!isAbstract && !structure.classImplementationBlock) {
3746
+ return errorResult(`CLAS ${name} has no IMPLEMENTATION block (purely abstract class). Pass abstract=true to add an abstract method, or add the IMPLEMENTATION half first via edit_class_definition.`);
3747
+ }
3748
+ // Refuse with hint if the target visibility section header is missing.
3749
+ const anchor = findSectionAnchor(main, structure, visibility);
3750
+ if (!anchor) {
3751
+ return errorResult(`No ${visibility.toUpperCase()} SECTION exists in CLAS ${name}. Use SAPWrite(action="edit_class_definition") to add the section header first, then re-run add_method.`);
3752
+ }
3753
+ const spliced = insertMethodPair(main, structure, {
3754
+ decl: clause,
3755
+ visibility,
3756
+ methodName,
3757
+ isAbstract,
3758
+ });
3759
+ const lintWarnings = runPreWriteLint(spliced, type, name, config, lintOverride);
3760
+ if (lintWarnings.blocked)
3761
+ return lintWarnings.result;
3762
+ await safeUpdateSource(client.http, client.safety, objectUrl, srcUrl, spliced, transport, cachedFeatures?.abapRelease);
3763
+ invalidateWrittenObject(type, name);
3764
+ const stubNote = isAbstract ? ' (abstract — no IMPL stub inserted)' : '';
3765
+ return textResult(`Successfully added method "${methodName}" (${visibility}) to ${type} ${name}${stubNote}. Active version unchanged until activation; SAPActivate next.`);
3766
+ }
3767
+ case 'delete_method': {
3768
+ if (type !== 'CLAS')
3769
+ return errorResult('delete_method is only supported for type=CLAS.');
3770
+ const methodSpecifier = String(args.method ?? '').trim();
3771
+ if (!methodSpecifier) {
3772
+ return errorResult('"method" (the method NAME to delete) is required for delete_method.');
3773
+ }
3774
+ // MAIN-only action: include= is rejected at the schema layer (not in
3775
+ // SAPWRITE_INCLUDE_AWARE_ACTIONS). Defensive guard for direct CLI calls.
3776
+ if (includeProvided) {
3777
+ return errorResult('delete_method targets the global class DEFINITION (/source/main). For local-class (CCDEF/CCIMP) method removal, use edit_class_definition with include=...');
3778
+ }
3779
+ await enforcePackageForExistingObject();
3780
+ const { structure, main } = await fetchClassStructureAndMain(name);
3781
+ const upperName = methodSpecifier.toUpperCase();
3782
+ const method = structure.methods.find((m) => m.name === upperName);
3783
+ if (!method) {
3784
+ const available = structure.methods.map((m) => m.name).join(', ');
3785
+ const hint = methodSpecifier.includes('~')
3786
+ ? ' Interface-qualified names (e.g. "zif_x~m") are not addressable here; objectstructure lists methods under their bare names.'
3787
+ : '';
3788
+ return errorResult(`Method "${methodSpecifier}" not found in CLAS ${name}. Available methods: ${available || '(none)'}.${hint}`);
3789
+ }
3790
+ const spliced = removeMethodPair(main, method);
3791
+ const lintWarnings = runPreWriteLint(spliced, type, name, config, lintOverride);
3792
+ if (lintWarnings.blocked)
3793
+ return lintWarnings.result;
3794
+ await safeUpdateSource(client.http, client.safety, objectUrl, srcUrl, spliced, transport, cachedFeatures?.abapRelease);
3795
+ invalidateWrittenObject(type, name);
3796
+ const where = method.implementation ? ' (DEFINITION + IMPLEMENTATION)' : ' (DEFINITION only — was ABSTRACT)';
3797
+ return textResult(`Successfully deleted method "${method.name}" from ${type} ${name}${where}. Active version unchanged until activation; SAPActivate next.`);
3798
+ }
3799
+ case 'change_method_visibility': {
3800
+ // Body-preserving visibility move (issue #303 follow-up). Moves the METHODS
3801
+ // clause from its current section to the target section; the IMPLEMENTATION
3802
+ // block is never touched, so the method body survives. This is the safe
3803
+ // alternative to delete_method + add_method (which discards the body).
3804
+ if (type !== 'CLAS')
3805
+ return errorResult('change_method_visibility is only supported for type=CLAS.');
3806
+ const methodSpecifier = String(args.method ?? '').trim();
3807
+ if (!methodSpecifier) {
3808
+ return errorResult('"method" (the method NAME to move) is required for change_method_visibility.');
3809
+ }
3810
+ const target = args.visibility;
3811
+ if (!target) {
3812
+ return errorResult('"visibility" (target section: public, protected, or private) is required for change_method_visibility.');
3813
+ }
3814
+ // MAIN-only action: include= is rejected at the schema layer (not in
3815
+ // SAPWRITE_INCLUDE_AWARE_ACTIONS). Defensive guard for direct CLI calls.
3816
+ if (includeProvided) {
3817
+ return errorResult('change_method_visibility targets the global class DEFINITION (/source/main). For local-class (CCDEF) methods, use edit_class_definition with include=definitions.');
3818
+ }
3819
+ await enforcePackageForExistingObject();
3820
+ const { structure, main } = await fetchClassStructureAndMain(name);
3821
+ const upperName = methodSpecifier.toUpperCase();
3822
+ const method = structure.methods.find((m) => m.name === upperName);
3823
+ if (!method) {
3824
+ const available = structure.methods.map((m) => m.name).join(', ');
3825
+ const hint = methodSpecifier.includes('~')
3826
+ ? ' Interface-qualified names (e.g. "zif_x~m") are not addressable here; objectstructure lists methods under their bare names.'
3827
+ : '';
3828
+ return errorResult(`Method "${methodSpecifier}" not found in CLAS ${name}. Available methods: ${available || '(none)'}.${hint}`);
3829
+ }
3830
+ // Idempotent: already in the requested section → no write.
3831
+ if (method.visibility === target) {
3832
+ return textResult(`Method "${method.name}" is already in the ${target.toUpperCase()} SECTION of ${type} ${name}. No change made.`);
3833
+ }
3834
+ // The target section header must already exist (same constraint as add_method).
3835
+ const anchor = findSectionAnchor(main, structure, target);
3836
+ if (!anchor) {
3837
+ return errorResult(`No ${target.toUpperCase()} SECTION exists in CLAS ${name}. Use SAPWrite(action="edit_class_definition") to add the section header first, then re-run change_method_visibility.`);
3838
+ }
3839
+ // DEFINITION-only move — IMPLEMENTATION (the method body) is preserved verbatim.
3840
+ const spliced = moveMethodDefinition(main, method, anchor.afterLine);
3841
+ const lintWarnings = runPreWriteLint(spliced, type, name, config, lintOverride);
3842
+ if (lintWarnings.blocked)
3843
+ return lintWarnings.result;
3844
+ await safeUpdateSource(client.http, client.safety, objectUrl, srcUrl, spliced, transport, cachedFeatures?.abapRelease);
3845
+ invalidateWrittenObject(type, name);
3846
+ return textResult(`Successfully moved method "${method.name}" from ${method.visibility.toUpperCase()} to ${target.toUpperCase()} SECTION of ${type} ${name} (IMPLEMENTATION preserved). Active version unchanged until activation; SAPActivate next.`);
3847
+ }
3469
3848
  case 'scaffold_rap_handlers': {
3470
3849
  // What this action does:
3471
3850
  // Given a behavior-pool class (ZBP_*) and its interface BDEF, inspect
@@ -3745,8 +4124,13 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3745
4124
  return { obj, type: objType, name: objName, packageName: objPackage, explicitTransport };
3746
4125
  });
3747
4126
  // Check every target package before starting any creates.
3748
- for (const pkg of new Set(batchPlan.map((item) => item.packageName))) {
3749
- checkPackage(client.safety, pkg);
4127
+ // Resolver is shared across the loop so subtree BFS happens once even when
4128
+ // many objects target descendants of the same `ZFOO/**` root.
4129
+ {
4130
+ const resolver = client.getPackageHierarchyResolver();
4131
+ for (const pkg of new Set(batchPlan.map((item) => item.packageName))) {
4132
+ await checkPackage(client.safety, pkg, resolver);
4133
+ }
3750
4134
  }
3751
4135
  // Pre-flight transport check for batch_create (same logic as single create),
3752
4136
  // but keyed by each effective package because objects can override package.
@@ -4020,11 +4404,11 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
4020
4404
  const batchStatuses = buildBatchActivationStatuses(writtenObjects, activationOutcome);
4021
4405
  const statusDetails = formatBatchActivationStatuses(batchStatuses);
4022
4406
  terminalActivationFailure = statusDetails;
4023
- const statusByName = new Map(batchStatuses.map((s) => [`${s.type}${s.name}`, s]));
4407
+ const statusByName = new Map(batchStatuses.map((s) => [`${s.type}\x00${s.name}`, s]));
4024
4408
  for (const result of results) {
4025
4409
  if (result.status !== 'success')
4026
4410
  continue;
4027
- const key = `${result.type}${result.name}`;
4411
+ const key = `${result.type}\x00${result.name}`;
4028
4412
  const matched = statusByName.get(key);
4029
4413
  if (!matched)
4030
4414
  continue;
@@ -5064,7 +5448,7 @@ async function handleSAPGit(client, args, _authInfo) {
5064
5448
  password,
5065
5449
  token,
5066
5450
  };
5067
- result = await gctsCloneRepo(client.http, client.safety, params);
5451
+ result = await gctsCloneRepo(client.http, client.safety, params, client.getPackageHierarchyResolver());
5068
5452
  }
5069
5453
  else {
5070
5454
  if (!packageName)
@@ -5076,7 +5460,7 @@ async function handleSAPGit(client, args, _authInfo) {
5076
5460
  transportRequest: String(args.transport ?? '').trim() || undefined,
5077
5461
  user,
5078
5462
  password,
5079
- });
5463
+ }, client.getPackageHierarchyResolver());
5080
5464
  }
5081
5465
  break;
5082
5466
  case 'pull':
@@ -5134,7 +5518,7 @@ async function handleSAPGit(client, args, _authInfo) {
5134
5518
  result = await gctsCreateBranch(client.http, client.safety, repoId, {
5135
5519
  branch,
5136
5520
  ...(packageName ? { package: packageName } : {}),
5137
- });
5521
+ }, client.getPackageHierarchyResolver());
5138
5522
  }
5139
5523
  else {
5140
5524
  await abapGitCreateBranch(client.http, client.safety, repoId, branch);
@@ -5635,10 +6019,19 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5635
6019
  if (!description)
5636
6020
  return errorResult('"description" is required for create_package action.');
5637
6021
  checkOperation(client.safety, OperationType.Create, 'CreatePackage');
5638
- // Package allowlist is enforced on the parent package, not the new package name.
5639
- // This enables creating children in allowed parents like $TMP.
6022
+ // Package allowlist gate:
6023
+ // - When `superPackage` is set, gate the parent. This enables creating
6024
+ // children in allowed parents like $TMP. With subtree (`X/**`) rules,
6025
+ // the new child will automatically be inside its parent's subtree.
6026
+ // - When `superPackage` is omitted, the new package is created at the
6027
+ // root and IS the gateable name itself — otherwise an admin's
6028
+ // allowedPackages restriction would be bypassed by simply omitting
6029
+ // the parent. Gate the new name in that case.
5640
6030
  if (superPackage) {
5641
- checkPackage(client.safety, superPackage);
6031
+ await checkPackage(client.safety, superPackage, client.getPackageHierarchyResolver());
6032
+ }
6033
+ else {
6034
+ await checkPackage(client.safety, name, client.getPackageHierarchyResolver());
5642
6035
  }
5643
6036
  let effectiveTransport = transport || undefined;
5644
6037
  const packageUrl = `/sap/bc/adt/packages/${encodeURIComponent(name)}`;
@@ -5681,6 +6074,9 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5681
6074
  packageType,
5682
6075
  });
5683
6076
  await createObject(client.http, client.safety, '/sap/bc/adt/packages', xml, 'application/*', effectiveTransport, undefined, cachedFeatures?.abapRelease);
6077
+ // Hierarchy changed: invalidate any cached subtree that could contain
6078
+ // the new package. Conservative: clear all (cheap; per-call cost is one BFS).
6079
+ client.invalidatePackageHierarchy();
5684
6080
  return textResult(`Created package ${name}.`);
5685
6081
  }
5686
6082
  case 'delete_package': {
@@ -5689,6 +6085,9 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5689
6085
  if (!name)
5690
6086
  return errorResult('"name" is required for delete_package action.');
5691
6087
  checkOperation(client.safety, OperationType.Delete, 'DeletePackage');
6088
+ // Gate by allowedPackages: deletion targets the package itself, so the
6089
+ // package name must be in the allowed set (or in an allowed subtree).
6090
+ await checkPackage(client.safety, name, client.getPackageHierarchyResolver());
5692
6091
  const packageUrl = `/sap/bc/adt/packages/${encodeURIComponent(name)}`;
5693
6092
  await client.http.withStatefulSession(async (session) => {
5694
6093
  const lock = await lockObject(session, client.safety, packageUrl, 'MODIFY', cachedFeatures?.abapRelease);
@@ -5705,6 +6104,8 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5705
6104
  }
5706
6105
  }
5707
6106
  });
6107
+ // Hierarchy changed: invalidate cached subtrees.
6108
+ client.invalidatePackageHierarchy();
5708
6109
  return textResult(`Deleted package ${name}.`);
5709
6110
  }
5710
6111
  case 'change_package': {
@@ -5723,8 +6124,11 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5723
6124
  if (!newPackage)
5724
6125
  return errorResult('"newPackage" is required for change_package action.');
5725
6126
  checkOperation(client.safety, OperationType.Update, 'ChangePackage');
5726
- checkPackage(client.safety, oldPackage);
5727
- checkPackage(client.safety, newPackage);
6127
+ {
6128
+ const resolver = client.getPackageHierarchyResolver();
6129
+ await checkPackage(client.safety, oldPackage, resolver);
6130
+ await checkPackage(client.safety, newPackage, resolver);
6131
+ }
5728
6132
  // Resolve object URI via search if not provided
5729
6133
  if (!objectUri) {
5730
6134
  const searchResp = await client.http.get(`/sap/bc/adt/repository/informationsystem/search?operation=quickSearch&query=${encodeURIComponent(objectName)}&maxResults=10`);
@@ -5770,6 +6174,8 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5770
6174
  newPackage,
5771
6175
  transport: effectiveTransport,
5772
6176
  });
6177
+ // Hierarchy may have shifted (object moved between packages); invalidate cache.
6178
+ client.invalidatePackageHierarchy();
5773
6179
  const transportNote = result.transport ? ` (transport: ${result.transport})` : '';
5774
6180
  return textResult(`Moved ${objectName} from package ${oldPackage} to ${newPackage}${transportNote}.`);
5775
6181
  }