arc-1 0.9.5 → 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 (107) hide show
  1. package/README.md +21 -3
  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 +150 -8
  14. package/dist/adt/client.d.ts.map +1 -1
  15. package/dist/adt/client.js +345 -12
  16. package/dist/adt/client.js.map +1 -1
  17. package/dist/adt/config.d.ts +7 -1
  18. package/dist/adt/config.d.ts.map +1 -1
  19. package/dist/adt/config.js.map +1 -1
  20. package/dist/adt/crud.d.ts +38 -0
  21. package/dist/adt/crud.d.ts.map +1 -1
  22. package/dist/adt/crud.js +73 -1
  23. package/dist/adt/crud.js.map +1 -1
  24. package/dist/adt/errors.d.ts +2 -2
  25. package/dist/adt/errors.d.ts.map +1 -1
  26. package/dist/adt/errors.js +50 -6
  27. package/dist/adt/errors.js.map +1 -1
  28. package/dist/adt/features.d.ts.map +1 -1
  29. package/dist/adt/features.js +27 -3
  30. package/dist/adt/features.js.map +1 -1
  31. package/dist/adt/gcts.d.ts +3 -2
  32. package/dist/adt/gcts.d.ts.map +1 -1
  33. package/dist/adt/gcts.js +4 -4
  34. package/dist/adt/gcts.js.map +1 -1
  35. package/dist/adt/http.d.ts +41 -0
  36. package/dist/adt/http.d.ts.map +1 -1
  37. package/dist/adt/http.js +132 -45
  38. package/dist/adt/http.js.map +1 -1
  39. package/dist/adt/package-hierarchy.d.ts +67 -0
  40. package/dist/adt/package-hierarchy.d.ts.map +1 -0
  41. package/dist/adt/package-hierarchy.js +100 -0
  42. package/dist/adt/package-hierarchy.js.map +1 -0
  43. package/dist/adt/release.d.ts +35 -0
  44. package/dist/adt/release.d.ts.map +1 -0
  45. package/dist/adt/release.js +48 -0
  46. package/dist/adt/release.js.map +1 -0
  47. package/dist/adt/safety.d.ts +39 -3
  48. package/dist/adt/safety.d.ts.map +1 -1
  49. package/dist/adt/safety.js +136 -15
  50. package/dist/adt/safety.js.map +1 -1
  51. package/dist/adt/types.d.ts +74 -0
  52. package/dist/adt/types.d.ts.map +1 -1
  53. package/dist/adt/xml-parser.d.ts +68 -1
  54. package/dist/adt/xml-parser.d.ts.map +1 -1
  55. package/dist/adt/xml-parser.js +263 -0
  56. package/dist/adt/xml-parser.js.map +1 -1
  57. package/dist/authz/policy.d.ts.map +1 -1
  58. package/dist/authz/policy.js +12 -0
  59. package/dist/authz/policy.js.map +1 -1
  60. package/dist/context/grep.d.ts +48 -0
  61. package/dist/context/grep.d.ts.map +1 -0
  62. package/dist/context/grep.js +146 -0
  63. package/dist/context/grep.js.map +1 -0
  64. package/dist/handlers/intent.d.ts +2 -1
  65. package/dist/handlers/intent.d.ts.map +1 -1
  66. package/dist/handlers/intent.js +614 -50
  67. package/dist/handlers/intent.js.map +1 -1
  68. package/dist/handlers/schemas.d.ts +52 -6
  69. package/dist/handlers/schemas.d.ts.map +1 -1
  70. package/dist/handlers/schemas.js +90 -9
  71. package/dist/handlers/schemas.js.map +1 -1
  72. package/dist/handlers/tools.d.ts.map +1 -1
  73. package/dist/handlers/tools.js +73 -12
  74. package/dist/handlers/tools.js.map +1 -1
  75. package/dist/lint/lint.d.ts.map +1 -1
  76. package/dist/lint/lint.js +6 -0
  77. package/dist/lint/lint.js.map +1 -1
  78. package/dist/lint/pre-write-hints.d.ts +45 -0
  79. package/dist/lint/pre-write-hints.d.ts.map +1 -0
  80. package/dist/lint/pre-write-hints.js +145 -0
  81. package/dist/lint/pre-write-hints.js.map +1 -0
  82. package/dist/server/audit.d.ts +27 -1
  83. package/dist/server/audit.d.ts.map +1 -1
  84. package/dist/server/audit.js.map +1 -1
  85. package/dist/server/auth-rate-limit.d.ts +78 -0
  86. package/dist/server/auth-rate-limit.d.ts.map +1 -0
  87. package/dist/server/auth-rate-limit.js +95 -0
  88. package/dist/server/auth-rate-limit.js.map +1 -0
  89. package/dist/server/config.d.ts.map +1 -1
  90. package/dist/server/config.js +32 -0
  91. package/dist/server/config.js.map +1 -1
  92. package/dist/server/http.d.ts.map +1 -1
  93. package/dist/server/http.js +73 -2
  94. package/dist/server/http.js.map +1 -1
  95. package/dist/server/mcp-rate-limit.d.ts +69 -0
  96. package/dist/server/mcp-rate-limit.d.ts.map +1 -0
  97. package/dist/server/mcp-rate-limit.js +92 -0
  98. package/dist/server/mcp-rate-limit.js.map +1 -0
  99. package/dist/server/server.d.ts +26 -6
  100. package/dist/server/server.d.ts.map +1 -1
  101. package/dist/server/server.js +87 -28
  102. package/dist/server/server.js.map +1 -1
  103. package/dist/server/types.d.ts +20 -1
  104. package/dist/server/types.d.ts.map +1 -1
  105. package/dist/server/types.js +2 -0
  106. package/dist/server/types.js.map +1 -1
  107. package/package.json +14 -12
@@ -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,12 +32,14 @@ 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';
37
39
  import { sanitizeArgs } from '../server/audit.js';
38
40
  import { generateRequestId, requestContext } from '../server/context.js';
39
41
  import { logger } from '../server/logger.js';
42
+ import { resolveRateLimitUserKey } from '../server/mcp-rate-limit.js';
40
43
  import { expandHyperfocusedArgs } from './hyperfocused.js';
41
44
  import { getToolSchema } from './schemas.js';
42
45
  import { formatZodError } from './zod-errors.js';
@@ -225,8 +228,12 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
225
228
  if (err instanceof AdtApiError) {
226
229
  // Append additional SAP messages (line numbers, secondary errors) if available
227
230
  const enriched = enrichWithSapDetails(err, message);
228
- const argType = String(args.type ?? '').toUpperCase();
229
- const classification = classifySapDomainError(err.statusCode, err.responseBody, err.path);
231
+ const argType = canonicalTablType(String(args.type ?? '').toUpperCase());
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);
230
237
  if (classification) {
231
238
  const transactionLine = classification.transaction ? `\nSAP Transaction: ${classification.transaction}` : '';
232
239
  return `${enriched}\n\nHint: ${classification.hint}${transactionLine}`;
@@ -262,6 +269,14 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
262
269
  `sqlFilter="MANDT = '100'" or sqlFilter="MATNR LIKE 'Z%'".`);
263
270
  }
264
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
+ }
265
280
  const behaviorPoolHint = getBehaviorPoolSaveFailureHint(err, args);
266
281
  if (behaviorPoolHint) {
267
282
  return `${enriched}\n\nHint: ${behaviorPoolHint}`;
@@ -295,7 +310,7 @@ function buildBaseErrorMessage(err, message, tool, args, config) {
295
310
  return enriched;
296
311
  }
297
312
  if (err instanceof AdtSafetyError) {
298
- const argType = String(args.type ?? '').toUpperCase();
313
+ const argType = canonicalTablType(String(args.type ?? '').toUpperCase());
299
314
  if (tool === 'SAPRead' && argType === 'TABLE_CONTENTS') {
300
315
  return (`${message}\n\nHint: TABLE_CONTENTS is blocked by safety configuration or missing data scope. ` +
301
316
  'Set SAP_ALLOW_DATA_PREVIEW=true at the server level and, in authenticated HTTP mode, ' +
@@ -658,7 +673,7 @@ async function inactiveSyntaxDiagnostic(client, type, name) {
658
673
  }
659
674
  }
660
675
  async function tryPostSaveSyntaxCheck(client, type, name) {
661
- if (!DDIC_POST_SAVE_CHECK_TYPES.has(type.toUpperCase()))
676
+ if (!DDIC_POST_SAVE_CHECK_TYPES.has(canonicalTablType(type.toUpperCase())))
662
677
  return '';
663
678
  return inactiveSyntaxDiagnostic(client, type, name);
664
679
  }
@@ -746,7 +761,7 @@ function classifyError(err) {
746
761
  * all tools are allowed (backward compatibility).
747
762
  * @param server - MCP Server instance for elicitation support.
748
763
  */
749
- export async function handleToolCall(client, config, toolName, args, authInfo, _server, cachingLayer, isPerUserClient) {
764
+ export async function handleToolCall(client, config, toolName, args, authInfo, _server, cachingLayer, isPerUserClient, mcpRateLimiter) {
750
765
  const reqId = generateRequestId();
751
766
  const start = Date.now();
752
767
  // Build user context for audit logging
@@ -763,6 +778,47 @@ export async function handleToolCall(client, config, toolName, args, authInfo, _
763
778
  tool: toolName,
764
779
  args: sanitizeArgs(args),
765
780
  });
781
+ // ─── Layer 2: per-user MCP tool-call rate limit ─────────────────────
782
+ // Applied immediately so we don't waste any work on denied calls. Stdio mode
783
+ // (no authInfo) is exempt — there's no user identity to key on. On denial we
784
+ // return an MCP tool error (not HTTP 429) so the LLM client surfaces it as a
785
+ // tool failure and the agent loop backs off via its own retry policy.
786
+ // See docs_page/rate-limiting.md (Layer 2). Cost weighting per tool is deferred
787
+ // to v2 — every consume call counts as one point.
788
+ if (mcpRateLimiter && authInfo) {
789
+ // Walks the most-specific identity claim first (userName → email → sub →
790
+ // preferred_username → clientId) so OIDC users sharing one `azp` clientId
791
+ // don't collapse into a single bucket. See resolveRateLimitUserKey.
792
+ const userKey = resolveRateLimitUserKey(authInfo);
793
+ const decision = await mcpRateLimiter.consume(userKey, toolName);
794
+ if (!decision.allowed) {
795
+ const retryAfter = Math.ceil(decision.retryAfterMs / 1000);
796
+ logger.emitAudit({
797
+ timestamp: new Date().toISOString(),
798
+ level: 'warn',
799
+ event: 'mcp_rate_limited',
800
+ requestId: reqId,
801
+ clientId,
802
+ user: userKey,
803
+ tool: toolName,
804
+ limitPerMinute: decision.limitPerMinute,
805
+ retryAfterMs: decision.retryAfterMs,
806
+ });
807
+ return {
808
+ content: [
809
+ {
810
+ type: 'text',
811
+ text: JSON.stringify({
812
+ error: 'rate_limited',
813
+ retryAfter,
814
+ message: `Rate limit exceeded (${decision.limitPerMinute}/min per user). Retry after ${retryAfter} seconds.`,
815
+ }),
816
+ },
817
+ ],
818
+ isError: true,
819
+ };
820
+ }
821
+ }
766
822
  // Unified scope enforcement via ACTION_POLICY — routes through action/type-aware lookup.
767
823
  // For SAPRead, the policy key is Tool.{type}; for other action-bearing tools, Tool.{action};
768
824
  // for tools without an action/type enum (SAPSearch, SAPQuery), the tool-level default applies.
@@ -945,6 +1001,28 @@ export async function handleToolCall(client, config, toolName, args, authInfo, _
945
1001
  function isBtpSystem() {
946
1002
  return cachedFeatures?.systemType === 'btp';
947
1003
  }
1004
+ /** Return whether the SAP ADT discovery feed advertises the /sap/bc/adt/ddic/tables
1005
+ * collection (the transparent-table editor endpoint). Absent on NW 7.50/7.51 —
1006
+ * SAP added it in NW 7.52 along with the new database-table editor. When the
1007
+ * discovery cache is empty (e.g. probe never ran, tests that bypass SAPManage),
1008
+ * returns `undefined` so callers can decide whether to default-allow.
1009
+ * See issue #285. */
1010
+ function isTablesEndpointAvailable() {
1011
+ const map = cachedFeatures?.discoveryMap ?? cachedDiscovery;
1012
+ if (!map || map.size === 0)
1013
+ return undefined;
1014
+ return map.has('/sap/bc/adt/ddic/tables');
1015
+ }
1016
+ /** Stable hint surfaced when ARC-1 refuses a TABL/DT write because the connected
1017
+ * system does not expose /sap/bc/adt/ddic/tables/. Shared between the
1018
+ * resolver-driven update/delete/activate paths and the discovery-gated create
1019
+ * paths so the LLM always sees the same recovery instructions. */
1020
+ const TABL_DT_WRITE_UNAVAILABLE_HINT = 'Transparent table writes via ADT REST are not available on this system ' +
1021
+ '(/sap/bc/adt/ddic/tables/ is not exposed — NW 7.50/7.51 ship the DDIC ' +
1022
+ 'structures endpoint only; the table editor was added in NW 7.52). ' +
1023
+ 'Use SE11 in SAPGUI, or connect ARC-1 to an SAP_BASIS ≥ 7.52 system. ' +
1024
+ 'Writing the source via /sap/bc/adt/ddic/structures/ would silently flip ' +
1025
+ 'DD02L-TABCLASS to INTTAB and corrupt the table.';
948
1026
  /** BTP-specific error messages for unavailable operations */
949
1027
  const BTP_HINTS = {
950
1028
  PROG: 'Executable programs (reports) are not available on BTP ABAP Environment. Use CLAS with IF_OO_ADT_CLASSRUN for console applications.',
@@ -1041,6 +1119,11 @@ async function handleSAPRead(client, args, cachingLayer) {
1041
1119
  const indicator = cacheHit && revalidated ? '[cached:revalidated]\n' : '';
1042
1120
  return textResult(`${note}${indicator}${source}`);
1043
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
+ };
1044
1127
  // Structured format is only supported for CLAS type
1045
1128
  if (args.format === 'structured' && type !== 'CLAS') {
1046
1129
  return errorResult('The "structured" format is only supported for CLAS type. Other types return text format.');
@@ -1048,9 +1131,45 @@ async function handleSAPRead(client, args, cachingLayer) {
1048
1131
  switch (type) {
1049
1132
  case 'PROG': {
1050
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);
1051
1136
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1052
1137
  }
1053
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
+ }
1054
1173
  // Structured format: return JSON with metadata + decomposed source
1055
1174
  if (args.format === 'structured') {
1056
1175
  const structured = await client.getClassStructured(name);
@@ -1085,6 +1204,8 @@ async function handleSAPRead(client, args, cachingLayer) {
1085
1204
  }
1086
1205
  case 'INTF': {
1087
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);
1088
1209
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1089
1210
  }
1090
1211
  case 'FUNC': {
@@ -1120,6 +1241,8 @@ async function handleSAPRead(client, args, cachingLayer) {
1120
1241
  };
1121
1242
  return textResult(JSON.stringify(payload, null, 2));
1122
1243
  }
1244
+ if (args.grep)
1245
+ return grepText(source);
1123
1246
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1124
1247
  }
1125
1248
  case 'FUGR': {
@@ -1147,6 +1270,8 @@ async function handleSAPRead(client, args, cachingLayer) {
1147
1270
  }
1148
1271
  case 'INCL': {
1149
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);
1150
1275
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1151
1276
  }
1152
1277
  case 'DDLS': {
@@ -1159,23 +1284,33 @@ async function handleSAPRead(client, args, cachingLayer) {
1159
1284
  // Elements extraction is derived from source — no cache indicator
1160
1285
  return cachedTextResult(extractCdsElements(ddlSource, name), false, false, versionWarning);
1161
1286
  }
1287
+ if (args.grep)
1288
+ return grepText(ddlSource);
1162
1289
  return cachedTextResult(ddlSource, cacheHit, revalidated, versionWarning);
1163
1290
  }
1164
1291
  case 'DCLS': {
1165
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);
1166
1295
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1167
1296
  }
1168
1297
  case 'BDEF': {
1169
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);
1170
1301
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1171
1302
  }
1172
1303
  case 'SRVD': {
1173
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);
1174
1307
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1175
1308
  }
1176
1309
  case 'DDLX': {
1177
1310
  try {
1178
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);
1179
1314
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1180
1315
  }
1181
1316
  catch (err) {
@@ -1209,10 +1344,14 @@ async function handleSAPRead(client, args, cachingLayer) {
1209
1344
  // client.getTabl() handles the /tables/ → /structures/ fallback internally
1210
1345
  // and caches the resolved URL for subsequent write/activate paths.
1211
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);
1212
1349
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1213
1350
  }
1214
1351
  case 'VIEW': {
1215
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);
1216
1355
  return cachedTextResult(source, cacheHit, revalidated, versionWarning);
1217
1356
  }
1218
1357
  case 'DOMA': {
@@ -1319,6 +1458,15 @@ async function handleSAPRead(client, args, cachingLayer) {
1319
1458
  const data = await client.getTableContents(name, maxRows, args.sqlFilter);
1320
1459
  return textResult(JSON.stringify(data, null, 2));
1321
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
+ }
1322
1470
  case 'SOBJ': {
1323
1471
  const method = String(args.method ?? '');
1324
1472
  // Sanitize inputs to prevent SQL injection — BOR names are alphanumeric + underscore only
@@ -1478,7 +1626,7 @@ async function handleSAPSearch(client, args) {
1478
1626
  // keeps the same logical object from appearing twice in the merged matches.
1479
1627
  // Preserve the more-specific slash form when both originate from ADT+DB.
1480
1628
  const seen = new Map();
1481
- 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()}`;
1482
1630
  for (const m of adtMatches)
1483
1631
  seen.set(baseKey(m), m);
1484
1632
  for (const m of dbMatches) {
@@ -2253,18 +2401,24 @@ export function buildCreateXml(type, name, pkg, description, properties) {
2253
2401
  <adtcore:packageRef adtcore:name="${escapeXml(pkg)}"/>
2254
2402
  </dcl:dclSource>`;
2255
2403
  case 'TABL':
2256
- // TABL creation also uses SAP's "blue" framework envelope, then source is written via /source/main.
2404
+ case 'TABL/DT':
2405
+ case 'TABL/DS': {
2406
+ // Bare TABL is the legacy alias for TABL/DT (transparent table). The same
2407
+ // <blue:blueSource> envelope works for both subtypes — only adtcore:type
2408
+ // and the POST URL differ. See docs/plans/completed/fix-tabl-ds-create-routing.md.
2409
+ const adtType = type === 'TABL/DS' ? 'TABL/DS' : 'TABL/DT';
2257
2410
  return `<?xml version="1.0" encoding="UTF-8"?>
2258
2411
  <blue:blueSource xmlns:blue="http://www.sap.com/wbobj/blue"
2259
2412
  xmlns:adtcore="http://www.sap.com/adt/core"
2260
2413
  adtcore:description="${escapeXml(description)}"
2261
2414
  adtcore:name="${escapeXml(name)}"
2262
- adtcore:type="TABL/DT"
2415
+ adtcore:type="${adtType}"
2263
2416
  adtcore:masterLanguage="EN"
2264
2417
  adtcore:masterSystem="H00"
2265
2418
  adtcore:responsible="DEVELOPER">
2266
2419
  <adtcore:packageRef adtcore:name="${escapeXml(pkg)}"/>
2267
2420
  </blue:blueSource>`;
2421
+ }
2268
2422
  case 'BDEF':
2269
2423
  // BDEF uses SAP's "blue" framework — blue:blueSource with http://www.sap.com/wbobj/blue namespace.
2270
2424
  // Confirmed by vibing-steampunk (Go) and fr0ster (TypeScript) reference implementations.
@@ -2569,6 +2723,37 @@ export function normalizeObjectType(type) {
2569
2723
  return '';
2570
2724
  return SLASH_TYPE_MAP[normalized] ?? normalized;
2571
2725
  }
2726
+ /** TABL subtypes that SAPWrite preserves (instead of collapsing to bare 'TABL' via
2727
+ * SLASH_TYPE_MAP) so the create path can route TABL/DT → /ddic/tables and
2728
+ * TABL/DS → /ddic/structures. See docs/plans/completed/fix-tabl-ds-create-routing.md. */
2729
+ const TABL_WRITE_SUBTYPES = new Set(['TABL/DT', 'TABL/DS']);
2730
+ /** Legacy slash-form aliases SAPWrite remaps to a canonical subtype before
2731
+ * SLASH_TYPE_MAP runs — otherwise STRU/DS would collapse to bare 'TABL' and
2732
+ * route the structure create to /ddic/tables. */
2733
+ const SAPWRITE_TABL_ALIAS = {
2734
+ 'STRU/DS': 'TABL/DS',
2735
+ };
2736
+ /** SAPWrite-only normalizer: preserves TABL/DT and TABL/DS and remaps STRU/DS
2737
+ * to TABL/DS. Every other tool keeps the global collapsing behaviour of
2738
+ * `normalizeObjectType`. */
2739
+ function normalizeWriteObjectType(type) {
2740
+ const normalized = String(type).trim().toUpperCase();
2741
+ if (!normalized)
2742
+ return '';
2743
+ const aliased = SAPWRITE_TABL_ALIAS[normalized];
2744
+ if (aliased)
2745
+ return aliased;
2746
+ if (TABL_WRITE_SUBTYPES.has(normalized))
2747
+ return normalized;
2748
+ return SLASH_TYPE_MAP[normalized] ?? normalized;
2749
+ }
2750
+ /** Collapse TABL/DT and TABL/DS back to bare 'TABL' for downstream Set-membership
2751
+ * checks (DDIC hints, RAP preflight, CDS dependency hints, cache invalidation)
2752
+ * that only know about canonical types. The slash form survives at URL routing
2753
+ * + XML envelope sites. */
2754
+ function canonicalTablType(type) {
2755
+ return type === 'TABL/DT' || type === 'TABL/DS' ? 'TABL' : type;
2756
+ }
2572
2757
  /** Normalize type fields before schema validation so slash/case aliases are accepted. */
2573
2758
  function normalizeTypeArgsForValidation(toolName, args) {
2574
2759
  switch (toolName) {
@@ -2579,14 +2764,15 @@ function normalizeTypeArgsForValidation(toolName, args) {
2579
2764
  objectType: args.objectType === undefined ? undefined : normalizeObjectType(String(args.objectType ?? '')),
2580
2765
  };
2581
2766
  case 'SAPWrite':
2767
+ // SAPWrite preserves TABL/DT and TABL/DS so the create path can route by subtype.
2582
2768
  return {
2583
2769
  ...args,
2584
- type: args.type === undefined ? undefined : normalizeObjectType(String(args.type ?? '')),
2770
+ type: args.type === undefined ? undefined : normalizeWriteObjectType(String(args.type ?? '')),
2585
2771
  objects: Array.isArray(args.objects)
2586
2772
  ? args.objects.map((obj) => typeof obj === 'object' && obj !== null
2587
2773
  ? {
2588
2774
  ...obj,
2589
- type: normalizeObjectType(String(obj.type ?? '')),
2775
+ type: normalizeWriteObjectType(String(obj.type ?? '')),
2590
2776
  }
2591
2777
  : obj)
2592
2778
  : args.objects,
@@ -2688,10 +2874,13 @@ export function objectBasePath(type) {
2688
2874
  case 'SRVB':
2689
2875
  return '/sap/bc/adt/businessservices/bindings/';
2690
2876
  case 'TABL':
2691
- // Default URL prefix for TABL: /tables/ (transparent tables). DDIC structures
2692
- // live at /sap/bc/adt/ddic/structures/<name>; for those, callers must use
2693
- // AdtClient.resolveTablObjectUrl(name) which falls back on 404.
2877
+ case 'TABL/DT':
2878
+ // Bare TABL defaults to transparent table. For reads, callers should use
2879
+ // AdtClient.resolveTablObjectUrl(name) which falls back to /structures/ on 404.
2694
2880
  return '/sap/bc/adt/ddic/tables/';
2881
+ case 'TABL/DS':
2882
+ // DDIC structures only route through this collection; see follow-up to #285.
2883
+ return '/sap/bc/adt/ddic/structures/';
2695
2884
  case 'DOMA':
2696
2885
  return '/sap/bc/adt/ddic/domains/';
2697
2886
  case 'DTEL':
@@ -2819,13 +3008,23 @@ function stripIncludeHeader(source) {
2819
3008
  return source.replace(/^=== \w+ ===\n/, '');
2820
3009
  }
2821
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']);
2822
3017
  async function handleSAPWrite(client, args, config, cachingLayer) {
2823
3018
  const action = String(args.action ?? '');
2824
- const type = normalizeObjectType(String(args.type ?? ''));
3019
+ const type = normalizeWriteObjectType(String(args.type ?? ''));
2825
3020
  const name = String(args.name ?? '');
2826
3021
  const source = String(args.source ?? '');
2827
3022
  const hasSource = typeof args.source === 'string';
2828
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() !== '';
2829
3028
  const transport = args.transport;
2830
3029
  const lintOverride = args.lintBeforeWrite;
2831
3030
  const preflightOverride = args.preflightBeforeWrite;
@@ -2835,12 +3034,16 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2835
3034
  return errorResult('"type" and "name" are required for this action.');
2836
3035
  }
2837
3036
  // SAP TADIR stores object names uppercase. Mixed-case names cause silent corruption
2838
- // (e.g. DDLS created as "Zc_MyView" registers as "ZC_MYVIEW" in TADIR but the source body
2839
- // still contains "Zc_MyView", confusing every downstream tool). Reject pre-flight on create
2840
- // 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.)
2841
3044
  // Note: source code INSIDE the object can use mixed case (e.g. for DDLS: name="ZC_MYVIEW"
2842
3045
  // but `define view entity Zc_MyView` is fine inside the source body).
2843
- if (action === 'create' && name && name !== name.toUpperCase()) {
3046
+ if (NAME_CASE_GUARD_ACTIONS.has(action) && name && name !== name.toUpperCase()) {
2844
3047
  return errorResult(`Object name "${name}" contains lowercase characters. SAP object names must be uppercase (e.g. "${name.toUpperCase()}").\n\n` +
2845
3048
  `Note: the object NAME in TADIR must be uppercase, but the source code inside the object can use mixed case ` +
2846
3049
  `(e.g. for DDLS: name="${name.toUpperCase()}" but source can contain "define view entity ${name}").`);
@@ -2860,8 +3063,22 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2860
3063
  // `buildCreateXml('FUNC', …, properties)` finds it.
2861
3064
  let objectUrl;
2862
3065
  let srcUrl;
2863
- if (type === 'TABL' && action !== 'create' && action !== 'batch_create') {
2864
- objectUrl = await client.resolveTablObjectUrl(name);
3066
+ if ((type === 'TABL' || type === 'TABL/DT' || type === 'TABL/DS') &&
3067
+ action !== 'create' &&
3068
+ action !== 'batch_create') {
3069
+ // All TABL forms route through the search-first resolver on update/delete/activate
3070
+ // so the PR #286 SE11-hint refusal applies even when callers pass an explicit slash form.
3071
+ try {
3072
+ objectUrl = await client.resolveTablObjectUrlForWrite(name, {
3073
+ tablesEndpointAvailable: isTablesEndpointAvailable(),
3074
+ });
3075
+ }
3076
+ catch (resolveErr) {
3077
+ if (resolveErr instanceof AdtSafetyError) {
3078
+ return errorResult(resolveErr.message);
3079
+ }
3080
+ throw resolveErr;
3081
+ }
2865
3082
  srcUrl = `${objectUrl}/source/main`;
2866
3083
  }
2867
3084
  else if (type === 'FUNC') {
@@ -2886,23 +3103,53 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2886
3103
  args.group = group;
2887
3104
  }
2888
3105
  else {
3106
+ // Discovery gate: refuse transparent-table creates upfront on systems that
3107
+ // don't expose /ddic/tables/ (NW 7.50/7.51). TABL/DS skips this — /structures/
3108
+ // is always available. See issue #285.
3109
+ if ((type === 'TABL' || type === 'TABL/DT') && (action === 'create' || action === 'batch_create')) {
3110
+ if (isTablesEndpointAvailable() === false) {
3111
+ return errorResult(TABL_DT_WRITE_UNAVAILABLE_HINT);
3112
+ }
3113
+ }
2889
3114
  objectUrl = objectUrlForType(type, name);
2890
3115
  srcUrl = sourceUrlForType(type, name);
2891
3116
  }
2892
3117
  const invalidateWrittenObject = (objType = type, objName = name) => {
2893
- cachingLayer?.invalidate(objType, objName, 'all');
3118
+ // Source cache is keyed by canonical type (SAPRead collapses TABL/DT, TABL/DS).
3119
+ cachingLayer?.invalidate(canonicalTablType(objType), objName, 'all');
2894
3120
  cachingLayer?.inactiveLists.invalidate(client.username);
2895
3121
  };
2896
3122
  // Helper: enforce allowedPackages for existing objects (update/delete/edit_method/scaffold_rap_handlers).
2897
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.
2898
3126
  async function enforcePackageForExistingObject() {
2899
3127
  if (client.safety.allowedPackages.length === 0)
2900
3128
  return undefined;
2901
3129
  const pkg = await client.resolveObjectPackage(objectUrl);
2902
- if (pkg)
2903
- 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());
2904
3135
  return pkg;
2905
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
+ }
2906
3153
  switch (action) {
2907
3154
  case 'update': {
2908
3155
  const existingPackage = await enforcePackageForExistingObject();
@@ -2919,9 +3166,14 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
2919
3166
  if (!hasSource) {
2920
3167
  return errorResult('"source" is required when updating a CLAS include.');
2921
3168
  }
2922
- 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);
2923
3174
  invalidateWrittenObject(type, name);
2924
- 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.`);
2925
3177
  }
2926
3178
  if (type === 'SKTD') {
2927
3179
  // KTD update requires the full <sktd:docu> XML envelope with the Markdown
@@ -3022,7 +3274,7 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3022
3274
  }
3023
3275
  case 'create': {
3024
3276
  const pkg = String(args.package ?? '$TMP');
3025
- checkPackage(client.safety, pkg);
3277
+ await checkPackage(client.safety, pkg, client.getPackageHierarchyResolver());
3026
3278
  const description = String(args.description ?? name);
3027
3279
  // Pre-flight: check transport requirements for non-$TMP packages when no transport provided.
3028
3280
  // SAP requires a transport number for objects in transportable packages.
@@ -3135,7 +3387,7 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3135
3387
  // 'application/*' — the wildcard lets the SAP server resolve the correct
3136
3388
  // handler (matching how ADT Eclipse and abap-adt-api send requests).
3137
3389
  const contentType = createContentTypeForType(type);
3138
- const needsPackageParam = type === 'BDEF' || type === 'TABL';
3390
+ const needsPackageParam = type === 'BDEF' || type === 'TABL' || type === 'TABL/DT' || type === 'TABL/DS';
3139
3391
  let result;
3140
3392
  try {
3141
3393
  result = await createObject(client.http, client.safety, createUrl, body, contentType, effectiveTransport, needsPackageParam ? pkg : undefined, cachedFeatures?.abapRelease);
@@ -3338,6 +3590,261 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3338
3590
  const extras = [lintWarnings.warnings, checkNotes].filter(Boolean).join('\n\n');
3339
3591
  return extras ? textResult(`${msg}\n\n${extras}`) : textResult(msg);
3340
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
+ }
3341
3848
  case 'scaffold_rap_handlers': {
3342
3849
  // What this action does:
3343
3850
  // Given a behavior-pool class (ZBP_*) and its interface BDEF, inspect
@@ -3581,7 +4088,9 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3581
4088
  });
3582
4089
  }
3583
4090
  catch (err) {
3584
- if (err instanceof AdtApiError && CDS_DEPENDENCY_SENSITIVE_TYPES.has(type) && isDeleteDependencyError(err)) {
4091
+ if (err instanceof AdtApiError &&
4092
+ CDS_DEPENDENCY_SENSITIVE_TYPES.has(canonicalTablType(type)) &&
4093
+ isDeleteDependencyError(err)) {
3585
4094
  const hint = await buildCdsDeleteDependencyHint(client, type, name, objectUrl);
3586
4095
  if (hint) {
3587
4096
  // Attach via extraHint so the LLM-facing formatter renders it after
@@ -3608,15 +4117,20 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3608
4117
  const activateAtEnd = args.activateAtEnd === true || String(args.activateAtEnd) === 'true';
3609
4118
  const defaultPackage = normalizePackageOverride(args.package, '$TMP');
3610
4119
  const batchPlan = objects.map((obj) => {
3611
- const objType = normalizeObjectType(String(obj.type ?? ''));
4120
+ const objType = normalizeWriteObjectType(String(obj.type ?? ''));
3612
4121
  const objName = String(obj.name ?? '');
3613
4122
  const objPackage = normalizePackageOverride(obj.package, defaultPackage);
3614
4123
  const explicitTransport = normalizeTransportOverride(obj.transport) ?? transport;
3615
4124
  return { obj, type: objType, name: objName, packageName: objPackage, explicitTransport };
3616
4125
  });
3617
4126
  // Check every target package before starting any creates.
3618
- for (const pkg of new Set(batchPlan.map((item) => item.packageName))) {
3619
- 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
+ }
3620
4134
  }
3621
4135
  // Pre-flight transport check for batch_create (same logic as single create),
3622
4136
  // but keyed by each effective package because objects can override package.
@@ -3749,13 +4263,24 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3749
4263
  break;
3750
4264
  }
3751
4265
  }
3752
- // Step 1: Create the object
4266
+ // Step 1: Create the object (per-entry transparent-table discovery gate;
4267
+ // mirrors the single-create site above. TABL/DS skips it — /structures/ always exists.)
4268
+ if ((objType === 'TABL' || objType === 'TABL/DT') && isTablesEndpointAvailable() === false) {
4269
+ results.push({
4270
+ type: objType,
4271
+ name: objName,
4272
+ packageName: objPackage,
4273
+ status: 'failed',
4274
+ error: TABL_DT_WRITE_UNAVAILABLE_HINT,
4275
+ });
4276
+ break;
4277
+ }
3753
4278
  const objUrl = objectUrlForType(objType, objName);
3754
4279
  const createUrl = objUrl.replace(/\/[^/]+$/, '');
3755
4280
  const objMetadataProps = getMetadataWriteProperties(obj);
3756
4281
  const body = buildCreateXml(objType, objName, objPackage, objDescription, objMetadataProps);
3757
4282
  const contentType = createContentTypeForType(objType);
3758
- const needsPackageParam = objType === 'BDEF' || objType === 'TABL';
4283
+ const needsPackageParam = objType === 'BDEF' || objType === 'TABL' || objType === 'TABL/DT' || objType === 'TABL/DS';
3759
4284
  try {
3760
4285
  await createObject(client.http, client.safety, createUrl, body, contentType, objTransport, needsPackageParam ? objPackage : undefined, cachedFeatures?.abapRelease);
3761
4286
  }
@@ -3879,11 +4404,11 @@ async function handleSAPWrite(client, args, config, cachingLayer) {
3879
4404
  const batchStatuses = buildBatchActivationStatuses(writtenObjects, activationOutcome);
3880
4405
  const statusDetails = formatBatchActivationStatuses(batchStatuses);
3881
4406
  terminalActivationFailure = statusDetails;
3882
- 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]));
3883
4408
  for (const result of results) {
3884
4409
  if (result.status !== 'success')
3885
4410
  continue;
3886
- const key = `${result.type}${result.name}`;
4411
+ const key = `${result.type}\x00${result.name}`;
3887
4412
  const matched = statusByName.get(key);
3888
4413
  if (!matched)
3889
4414
  continue;
@@ -3942,7 +4467,8 @@ function runRapPreflightValidation(source, type, name, features, configSystemTyp
3942
4467
  return { blocked: false };
3943
4468
  }
3944
4469
  const systemType = features?.systemType ?? (configSystemType !== 'auto' ? configSystemType : undefined);
3945
- const result = validateRapSource(type, source, {
4470
+ // Canonicalize so validateRapSource's 'TABL' case matches TABL/DT and TABL/DS.
4471
+ const result = validateRapSource(canonicalTablType(type), source, {
3946
4472
  systemType,
3947
4473
  abapRelease: features?.abapRelease,
3948
4474
  });
@@ -4196,7 +4722,12 @@ async function handleSAPActivate(client, args, cachingLayer) {
4196
4722
  const objName = String(o.name ?? '');
4197
4723
  let url;
4198
4724
  if (objType === 'TABL') {
4199
- url = await client.resolveTablObjectUrl(objName);
4725
+ // Use the write-path resolver: refuses TABL/DT activation on systems
4726
+ // that don't expose /sap/bc/adt/ddic/tables/ (NW 7.50/7.51), where
4727
+ // activate would hit the wrong endpoint. See issue #285.
4728
+ url = await client.resolveTablObjectUrlForWrite(objName, {
4729
+ tablesEndpointAvailable: isTablesEndpointAvailable(),
4730
+ });
4200
4731
  }
4201
4732
  else if (objType === 'FUNC') {
4202
4733
  let group = String(o.group ?? args.group ?? '').trim();
@@ -4238,15 +4769,26 @@ async function handleSAPActivate(client, args, cachingLayer) {
4238
4769
  .join('');
4239
4770
  return errorResult(`Batch activation failed for: ${names}.${statusDetails}\n${formatActivationMessages(result)}${combinedDiag}`);
4240
4771
  }
4241
- // Single activation (existing behavior). For TABL we resolve the URL because
4242
- // the existing object may live at /tables/ (transparent) or /structures/
4243
- // (DDIC structure); using the wrong one would produce a confusing 404.
4772
+ // Single activation (existing behavior). For TABL we use the write-path
4773
+ // resolver so transparent-table activations on NW 7.50/7.51 are refused
4774
+ // with the SE11 hint instead of silently activating against /structures/
4775
+ // (which would not even be the right object). See issue #285.
4244
4776
  // For FUNC the URL needs the parent function group baked into the path
4245
4777
  // (issue #250) — `objectBasePath('FUNC')` deliberately throws so generic
4246
4778
  // builders fail loudly. Auto-resolve the group when omitted.
4247
4779
  let objectUrl;
4248
4780
  if (type === 'TABL') {
4249
- objectUrl = await client.resolveTablObjectUrl(name);
4781
+ try {
4782
+ objectUrl = await client.resolveTablObjectUrlForWrite(name, {
4783
+ tablesEndpointAvailable: isTablesEndpointAvailable(),
4784
+ });
4785
+ }
4786
+ catch (resolveErr) {
4787
+ if (resolveErr instanceof AdtSafetyError) {
4788
+ return errorResult(resolveErr.message);
4789
+ }
4790
+ throw resolveErr;
4791
+ }
4250
4792
  }
4251
4793
  else if (type === 'FUNC') {
4252
4794
  let group = String(args.group ?? '').trim();
@@ -4906,7 +5448,7 @@ async function handleSAPGit(client, args, _authInfo) {
4906
5448
  password,
4907
5449
  token,
4908
5450
  };
4909
- result = await gctsCloneRepo(client.http, client.safety, params);
5451
+ result = await gctsCloneRepo(client.http, client.safety, params, client.getPackageHierarchyResolver());
4910
5452
  }
4911
5453
  else {
4912
5454
  if (!packageName)
@@ -4918,7 +5460,7 @@ async function handleSAPGit(client, args, _authInfo) {
4918
5460
  transportRequest: String(args.transport ?? '').trim() || undefined,
4919
5461
  user,
4920
5462
  password,
4921
- });
5463
+ }, client.getPackageHierarchyResolver());
4922
5464
  }
4923
5465
  break;
4924
5466
  case 'pull':
@@ -4976,7 +5518,7 @@ async function handleSAPGit(client, args, _authInfo) {
4976
5518
  result = await gctsCreateBranch(client.http, client.safety, repoId, {
4977
5519
  branch,
4978
5520
  ...(packageName ? { package: packageName } : {}),
4979
- });
5521
+ }, client.getPackageHierarchyResolver());
4980
5522
  }
4981
5523
  else {
4982
5524
  await abapGitCreateBranch(client.http, client.safety, repoId, branch);
@@ -5477,10 +6019,19 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5477
6019
  if (!description)
5478
6020
  return errorResult('"description" is required for create_package action.');
5479
6021
  checkOperation(client.safety, OperationType.Create, 'CreatePackage');
5480
- // Package allowlist is enforced on the parent package, not the new package name.
5481
- // 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.
5482
6030
  if (superPackage) {
5483
- checkPackage(client.safety, superPackage);
6031
+ await checkPackage(client.safety, superPackage, client.getPackageHierarchyResolver());
6032
+ }
6033
+ else {
6034
+ await checkPackage(client.safety, name, client.getPackageHierarchyResolver());
5484
6035
  }
5485
6036
  let effectiveTransport = transport || undefined;
5486
6037
  const packageUrl = `/sap/bc/adt/packages/${encodeURIComponent(name)}`;
@@ -5523,6 +6074,9 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5523
6074
  packageType,
5524
6075
  });
5525
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();
5526
6080
  return textResult(`Created package ${name}.`);
5527
6081
  }
5528
6082
  case 'delete_package': {
@@ -5531,6 +6085,9 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5531
6085
  if (!name)
5532
6086
  return errorResult('"name" is required for delete_package action.');
5533
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());
5534
6091
  const packageUrl = `/sap/bc/adt/packages/${encodeURIComponent(name)}`;
5535
6092
  await client.http.withStatefulSession(async (session) => {
5536
6093
  const lock = await lockObject(session, client.safety, packageUrl, 'MODIFY', cachedFeatures?.abapRelease);
@@ -5547,6 +6104,8 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5547
6104
  }
5548
6105
  }
5549
6106
  });
6107
+ // Hierarchy changed: invalidate cached subtrees.
6108
+ client.invalidatePackageHierarchy();
5550
6109
  return textResult(`Deleted package ${name}.`);
5551
6110
  }
5552
6111
  case 'change_package': {
@@ -5565,8 +6124,11 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5565
6124
  if (!newPackage)
5566
6125
  return errorResult('"newPackage" is required for change_package action.');
5567
6126
  checkOperation(client.safety, OperationType.Update, 'ChangePackage');
5568
- checkPackage(client.safety, oldPackage);
5569
- 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
+ }
5570
6132
  // Resolve object URI via search if not provided
5571
6133
  if (!objectUri) {
5572
6134
  const searchResp = await client.http.get(`/sap/bc/adt/repository/informationsystem/search?operation=quickSearch&query=${encodeURIComponent(objectName)}&maxResults=10`);
@@ -5612,6 +6174,8 @@ async function handleSAPManage(client, config, args, cachingLayer, isPerUserClie
5612
6174
  newPackage,
5613
6175
  transport: effectiveTransport,
5614
6176
  });
6177
+ // Hierarchy may have shifted (object moved between packages); invalidate cache.
6178
+ client.invalidatePackageHierarchy();
5615
6179
  const transportNote = result.transport ? ` (transport: ${result.transport})` : '';
5616
6180
  return textResult(`Moved ${objectName} from package ${oldPackage} to ${newPackage}${transportNote}.`);
5617
6181
  }