diff --git a/bun.lock b/bun.lock index 9d6bda146..4518d1f0f 100644 --- a/bun.lock +++ b/bun.lock @@ -119,11 +119,11 @@ "adt": "./dist/adt.mjs", }, "devDependencies": { - "@abapify/adt-atc": "0.4.22", - "@abapify/adt-cli": "0.4.22", - "@abapify/adt-codegen": "0.4.22", - "@abapify/adt-diff": "0.4.22", - "@abapify/adt-export": "0.4.22", + "@abapify/adt-atc": "0.4.20", + "@abapify/adt-cli": "0.4.20", + "@abapify/adt-codegen": "0.4.20", + "@abapify/adt-diff": "0.4.20", + "@abapify/adt-export": "0.4.20", }, }, "packages/adt-atc": { @@ -159,28 +159,28 @@ "adt": "./dist/bin/adt.mjs", }, "dependencies": { - "@abapify/adk": "0.4.22", - "@abapify/adt-atc": "0.4.22", - "@abapify/adt-aunit": "0.4.22", - "@abapify/adt-auth": "0.4.22", - "@abapify/adt-client": "0.4.22", - "@abapify/adt-codegen": "0.4.22", - "@abapify/adt-config": "0.4.22", - "@abapify/adt-contracts": "0.4.22", - "@abapify/adt-diff": "0.4.22", - "@abapify/adt-export": "0.4.22", - "@abapify/adt-lint": "0.4.22", - "@abapify/adt-locks": "0.4.22", - "@abapify/adt-plugin": "0.4.22", - "@abapify/adt-plugin-abapgit": "0.4.22", - "@abapify/adt-plugin-gcts": "0.4.22", - "@abapify/adt-plugin-gcts-cli": "0.4.22", - "@abapify/adt-proxy": "0.4.22", - "@abapify/adt-rfc": "0.4.22", - "@abapify/adt-schemas": "0.4.22", - "@abapify/adt-services": "0.4.22", - "@abapify/adt-tui": "0.4.22", - "@abapify/logger": "0.4.22", + "@abapify/adk": "0.4.20", + "@abapify/adt-atc": "0.4.20", + "@abapify/adt-aunit": "0.4.20", + "@abapify/adt-auth": "0.4.20", + "@abapify/adt-client": "0.4.20", + "@abapify/adt-codegen": "0.4.20", + "@abapify/adt-config": "0.4.20", + "@abapify/adt-contracts": "0.4.20", + "@abapify/adt-diff": "0.4.20", + "@abapify/adt-export": "0.4.20", + "@abapify/adt-lint": "0.4.20", + "@abapify/adt-locks": "0.4.20", + "@abapify/adt-plugin": "0.4.20", + "@abapify/adt-plugin-abapgit": "0.4.20", + "@abapify/adt-plugin-gcts": "0.4.20", + "@abapify/adt-plugin-gcts-cli": "0.4.20", + "@abapify/adt-proxy": "0.4.20", + "@abapify/adt-rfc": "0.4.20", + "@abapify/adt-schemas": "0.4.20", + "@abapify/adt-services": "0.4.20", + "@abapify/adt-tui": "0.4.20", + "@abapify/logger": "0.4.20", "@inquirer/prompts": "^7.9.0", "@xmldom/xmldom": "^0.9.9", "chalk": "^5.6.2", @@ -194,8 +194,8 @@ "react": "18.3.1", }, "devDependencies": { - "@abapify/adt-fixtures": "0.4.22", - "@abapify/adt-mcp": "0.4.22", + "@abapify/adt-fixtures": "0.4.20", + "@abapify/adt-mcp": "0.4.20", "@types/react": "18.3.0", }, }, @@ -216,8 +216,8 @@ "adt-codegen": "./dist/cli.mjs", }, "dependencies": { - "@abapify/adt-config": "0.4.22", - "@abapify/adt-plugin": "0.4.22", + "@abapify/adt-config": "0.4.20", + "@abapify/adt-plugin": "0.4.20", "chalk": "^5.3.0", "commander": "^11.1.0", "fast-xml-parser": "^5.5.0", @@ -242,26 +242,26 @@ "name": "@abapify/adt-diff", "version": "0.4.22", "dependencies": { - "@abapify/adk": "0.4.22", - "@abapify/adt-contracts": "0.4.22", - "@abapify/adt-plugin": "0.4.22", - "@abapify/adt-plugin-abapgit": "0.4.22", + "@abapify/adk": "0.4.20", + "@abapify/adt-contracts": "0.4.20", + "@abapify/adt-plugin": "0.4.20", + "@abapify/adt-plugin-abapgit": "0.4.20", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-xml-parser": "^5.5.5", }, "devDependencies": { - "@abapify/adt-fixtures": "0.4.22", + "@abapify/adt-fixtures": "0.4.20", }, }, "packages/adt-export": { "name": "@abapify/adt-export", "version": "0.4.22", "dependencies": { - "@abapify/adk": "0.4.22", - "@abapify/adt-locks": "0.4.22", - "@abapify/adt-plugin": "0.4.22", - "@abapify/adt-plugin-abapgit": "0.4.22", + "@abapify/adk": "0.4.20", + "@abapify/adt-locks": "0.4.20", + "@abapify/adt-plugin": "0.4.20", + "@abapify/adt-plugin-abapgit": "0.4.20", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-xml-parser": "^5.5.5", @@ -337,9 +337,9 @@ "zod": "^3.24.0", }, "devDependencies": { - "@abapify/adt-client": "0.4.22", - "@abapify/adt-fixtures": "0.4.22", - "@abapify/adt-mcp": "0.4.22", + "@abapify/adt-client": "0.4.20", + "@abapify/adt-fixtures": "0.4.20", + "@abapify/adt-mcp": "0.4.20", "mastra": "^1.30.0", }, }, @@ -347,9 +347,9 @@ "name": "@abapify/adt-playwright", "version": "0.4.22", "dependencies": { - "@abapify/adt-auth": "0.4.22", - "@abapify/adt-config": "0.4.22", - "@abapify/browser-auth": "0.4.22", + "@abapify/adt-auth": "0.4.20", + "@abapify/adt-config": "0.4.20", + "@abapify/browser-auth": "0.4.20", "playwright": "^1.57.0", }, "devDependencies": { @@ -379,8 +379,8 @@ "name": "@abapify/adt-plugin-gcts", "version": "0.4.22", "dependencies": { - "@abapify/adk": "0.4.22", - "@abapify/adt-plugin": "0.4.22", + "@abapify/adk": "0.4.20", + "@abapify/adt-plugin": "0.4.20", "ajv": "^8.20.0", }, "devDependencies": { @@ -391,8 +391,8 @@ "name": "@abapify/adt-plugin-gcts-cli", "version": "0.4.22", "dependencies": { - "@abapify/adt-contracts": "0.4.22", - "@abapify/adt-plugin": "0.4.22", + "@abapify/adt-contracts": "0.4.20", + "@abapify/adt-plugin": "0.4.20", "zod": "^3.23.8", }, }, @@ -400,17 +400,17 @@ "name": "@abapify/adt-proxy", "version": "0.4.22", "dependencies": { - "@abapify/adt-contracts": "0.4.22", - "@abapify/speci": "0.4.22", + "@abapify/adt-contracts": "0.4.20", + "@abapify/speci": "0.4.20", }, }, "packages/adt-puppeteer": { "name": "@abapify/adt-puppeteer", "version": "0.4.22", "dependencies": { - "@abapify/adt-auth": "0.4.22", - "@abapify/adt-config": "0.4.22", - "@abapify/browser-auth": "0.4.22", + "@abapify/adt-auth": "0.4.20", + "@abapify/adt-config": "0.4.20", + "@abapify/browser-auth": "0.4.20", "puppeteer": "^25.15.0", }, "peerDependencies": { @@ -476,7 +476,7 @@ "name": "@abapify/adt-tui", "version": "0.4.22", "dependencies": { - "@abapify/adt-contracts": "0.4.22", + "@abapify/adt-contracts": "0.4.20", "fast-xml-parser": "^5.3.1", "ink": "5.1.0", "ink-select-input": "^6.0.0", @@ -488,7 +488,7 @@ "@types/react": "18.3.0", }, "peerDependencies": { - "@abapify/adt-client": "0.4.22", + "@abapify/adt-client": "0.4.20", }, }, "packages/asjson-parser": { @@ -502,7 +502,7 @@ "name": "@abapify/browser-auth", "version": "0.4.22", "dependencies": { - "@abapify/adt-config": "0.4.22", + "@abapify/adt-config": "0.4.20", }, "devDependencies": { "@types/node": "^22.10.2", @@ -519,14 +519,14 @@ "openai-codegen": "./dist/cli.mjs", }, "dependencies": { - "@abapify/abap-ast": "0.4.22", + "@abapify/abap-ast": "0.4.20", "@apidevtools/swagger-parser": "^10.1.0", "commander": "^11.1.0", "fast-xml-parser": "^5.5.0", "yaml": "^2.5.0", }, "devDependencies": { - "@abapify/aclass": "0.4.22", + "@abapify/aclass": "0.4.20", "@abaplint/core": "^2.118.12", }, }, diff --git a/packages/adt-plugin-gcts/src/index.ts b/packages/adt-plugin-gcts/src/index.ts index 98d12cf1f..c43e920d9 100644 --- a/packages/adt-plugin-gcts/src/index.ts +++ b/packages/adt-plugin-gcts/src/index.ts @@ -40,8 +40,12 @@ export type { // Filename utilities export { gctsFilename, + gctsSourceExt, parseGctsFilename, adtUriToGctsPath, + sourceKey, + splitSourceKey, + LIMU_SUBTYPES, PACKAGE_FILENAME, METADATA_EXTENSION, } from './lib/format/filename'; diff --git a/packages/adt-plugin-gcts/src/lib/aff/generic.ts b/packages/adt-plugin-gcts/src/lib/aff/generic.ts index a2769fe07..39be98973 100644 --- a/packages/adt-plugin-gcts/src/lib/aff/generic.ts +++ b/packages/adt-plugin-gcts/src/lib/aff/generic.ts @@ -11,6 +11,7 @@ */ import { affBodyProps, latestAffVersion } from './schemas'; +import { splitSourceKey } from '../format/filename'; /** Minimal AFF metadata shape shared by all types. */ export interface MinimalAff { @@ -30,9 +31,11 @@ export type GenericAffLike = { abapLanguageVersion?: string; /** Raw payload fields (deserialized AFF body, ADT response, …). */ data?: Record; - /** Companion sources keyed by file suffix — `main` → no suffix - * (`{name}.{type}.{ext}`), any other key becomes the middle segment - * (`{name}.{type}.{key}.{ext}`, e.g. csnm's `model_inferred`). */ + /** Companion sources keyed by filename — `main` → the suffix-less file + * (`{name}.{type}.{ext}`); other keys are suffixes + * (`{name}.{type}.{key}.{typeExt}`, e.g. csnm's `model_inferred`); a key + * ending in a file extension selects that extension instead + * (`z_func.func.json`, `texts.en.properties`). */ sources?: Record; }; @@ -118,8 +121,10 @@ export function genericToMetadata(type: string) { /** * Companion-source emit for source-bearing types (`.abap`, `.acds`, * `.csn`, …): reads `obj.sources` — `main` emits the suffix-less file, - * other keys emit `{name}.{type}.{suffix}.{ext}`. Falls back to - * `obj.getSource()` as the single `main` source. + * other keys emit `{name}.{type}.{suffix}.{ext}`. A key ending in a file + * extension selects that extension instead of the type default + * (`z_func.func.json` → LIMU metadata, `texts.en.properties` → texts). + * Falls back to `obj.getSource()` as the single `main` source. */ export function genericGetSources(obj: GenericAffLike) { // Read from the same place genericSetSources writes: the payload's @@ -136,7 +141,7 @@ export function genericGetSources(obj: GenericAffLike) { return Object.entries(merged) .filter(([, content]) => !!content) .map(([key, content]) => ({ - suffix: key === 'main' ? undefined : key, + ...splitSourceKey(key), content, })); } diff --git a/packages/adt-plugin-gcts/src/lib/deserializer.ts b/packages/adt-plugin-gcts/src/lib/deserializer.ts index 083b0649e..7d9b3fe92 100644 --- a/packages/adt-plugin-gcts/src/lib/deserializer.ts +++ b/packages/adt-plugin-gcts/src/lib/deserializer.ts @@ -26,8 +26,16 @@ import type { FileTree, ExportOptions } from '@abapify/adt-plugin'; import { createAdk, type AdkObject, type AdtClient } from '@abapify/adk'; -import { getHandler, getSupportedTypes } from './handlers/base'; -import { parseGctsFilename, PACKAGE_FILENAME } from './format/filename'; +import { + getHandler, + getSupportedTypes, + type GctsHandler, +} from './handlers/base'; +import { + parseGctsFilename, + PACKAGE_FILENAME, + sourceKey, +} from './format/filename'; /** Source-file extensions emitted by gCTS handlers. `.properties` * translation files are deliberately excluded — they are companions @@ -63,7 +71,7 @@ interface ObjectFiles { name: string; type: string; metadataFile?: string; - sourceFiles: Array<{ path: string; suffix?: string }>; + sourceFiles: Array<{ path: string; suffix?: string; ext?: string }>; /** `{name}.{type}.{docu}.json` companion — not the metadata anchor. */ docuFile?: string; /** `.properties` translations keyed by suffix (`en`, `texts.en`, `''`). */ @@ -118,9 +126,18 @@ export async function* deserialize( } // `{name}.{type}.docu.json` is a DOCU companion, not a second // metadata anchor — without this it can overwrite metadataFile - // depending on glob order. + // depending on glob order. Other suffixed `.json` companions (LIMU + // sub-object metadata, `{sub}.{stype}`) flow through the source + // pipeline with an explicit `json` ext marker. if (parsed.suffix === 'docu') { objectMap.get(key)!.docuFile = jsonPath; + } else if (parsed.suffix) { + objectMap.get(key)!.sourceFiles.push({ + path: jsonPath, + suffix: parsed.suffix, + ext: 'json', + }); + continue; } else { objectMap.get(key)!.metadataFile = jsonPath; } @@ -148,17 +165,21 @@ export async function* deserialize( const obj = objectMap.get(key); if (!obj) continue; - obj.sourceFiles.push({ path, suffix: parsed.suffix }); + obj.sourceFiles.push({ + path, + suffix: parsed.suffix, + ext: parsed.extension, + }); } } - // Pre-read all companion contents in bounded batches — sequential - // `await`s inside the object loop both serialize I/O and trip static - // analysis, while an unbounded Promise.all over thousands of files can - // exhaust file descriptors (EMFILE). Per-path failures are recorded - // (not thrown) so one unreadable file fails only its owning object - // inside the per-object try/catch below. - const companionContents = new Map(); + // Pre-read all companion + source contents in bounded batches — + // sequential `await`s inside the object loop both serialize I/O and + // trip static analysis, while an unbounded Promise.all over thousands + // of files can exhaust file descriptors (EMFILE). Per-path failures + // are recorded (not thrown) so one unreadable file fails only its + // owning object inside the per-object try/catch below. + const fileContents = new Map(); const readErrors = new Map(); const companionPaths = [...objectMap.values()].flatMap((o) => [ ...(o.docuFile ? [o.docuFile] : []), @@ -171,7 +192,7 @@ export async function* deserialize( const path = companionPaths[cursor++]; if (path === undefined) return; try { - companionContents.set(path, await fileTree.read(path)); + fileContents.set(path, await fileTree.read(path)); } catch (error) { readErrors.set(path, error); } @@ -195,116 +216,15 @@ export async function* deserialize( // Handler schema.parse is plain JSON.parse — the parsed object IS the // metadata passed to fromMetadata. const metadata = handler.schema.parse(raw); - - // Surface any companion read failure here, inside the per-object - // try — one bad file fails only this object, not the generator. - for (const path of [ - ...(objFiles.docuFile ? [objFiles.docuFile] : []), - ...objFiles.propertyFiles.map((f) => f.path), - ...objFiles.sourceFiles.map((f) => f.path), - ]) { - if (readErrors.has(path)) throw readErrors.get(path); - } - - // Read sources, mapping suffix → source key via handler map. - const sources: Record = {}; - for (const { path, suffix } of objFiles.sourceFiles) { - const content = companionContents.get(path); - if (content === undefined) continue; - const sourceKey = suffix - ? (handler.suffixToSourceKey?.[suffix] ?? suffix) - : 'main'; - sources[sourceKey] = content; - } - - const payload: { - name: string; - description?: string; - [key: string]: unknown; - } = handler.fromAbapGit - ? handler.fromAbapGit(metadata) - : { name: objFiles.name }; - - // DOCU companion → `data.docu` (genericGetExtraFiles re-emits it). - const docuRaw = objFiles.docuFile - ? companionContents.get(objFiles.docuFile) - : undefined; - if (docuRaw !== undefined) { - try { - payload.docu = JSON.parse(docuRaw) as unknown; - } catch { - payload.docu = docuRaw; - } - } - - // `.properties` translations → `data.properties` keyed by suffix - // ('' = master file, 'en', 'texts.en', …). - if (objFiles.propertyFiles.length > 0) { - const props: Record = {}; - for (const { path, suffix } of objFiles.propertyFiles) { - const content = companionContents.get(path); - if (content !== undefined) props[suffix ?? ''] = content; - } - payload.properties = props; - } - - // AFF metadata frequently omits the object name (it's encoded in the - // filename). Fall back to the filename-derived name. - const objectName = payload.name || objFiles.name; - const fullData = { ...payload, name: objectName }; - - const adkType = - typeof payload.type === 'string' ? payload.type : objFiles.type; - const adkObject = adk.getWithData(fullData, adkType) as AdkObject; - - if (Object.keys(sources).length > 0) { - if (handler.setSources) { - handler.setSources(adkObject, sources); - } else if (sources.main) { - (adkObject as unknown as { _pendingSource: string })._pendingSource = - sources.main; - } - } - - if (payload.description) { - ( - adkObject as unknown as { _pendingDescription: string } - )._pendingDescription = payload.description; - } - - // Record relative directory (useful for future directory-based package - // resolution). Plain metadata dir, no starting-folder normalisation — - // gCTS does not define one. - const sourceDir = objFiles - .metadataFile!.split('/') - .slice(0, -1) - .join('/'); - (adkObject as unknown as { _relDir: string })._relDir = sourceDir; - - // Assign rootPackage to every object without a packageRef — no folder - // logic available, so we cannot derive per-package assignments (see - // E06 follow-ups). Matches abapGit's "no .abapgit.xml" fallback. - if (options?.rootPackage) { - const data = ( - adkObject as unknown as { _data?: Record } - )._data; - if (data && !data.packageRef) { - data.packageRef = { name: options.rootPackage }; - } - } - - if (options?.abapLanguageVersion) { - const data = ( - adkObject as unknown as { _data?: Record } - )._data; - if (data && !data.abapLanguageVersion) { - data.abapLanguageVersion = - abapLangVerToAdt(options.abapLanguageVersion) ?? - options.abapLanguageVersion; - } - } - - yield adkObject; + yield objectFromFiles( + objFiles, + handler, + metadata, + fileContents, + readErrors, + adk, + options, + ); } catch (error) { // Mirrors abapGit: log and continue so one bad object does not abort // the whole checkin. @@ -315,3 +235,171 @@ export async function* deserialize( } } } + +/** + * Read the collected source/companion files into a source-key map. + * Companions whose extension differs from the type's default encode it + * into the key (`{suffix}.{ext}`) — LIMU `.json` metadata and + * `.properties` texts stay distinguishable from `.abap` sources. + */ +function collectSources( + objFiles: ObjectFiles, + handler: GctsHandler, + contents: ReadonlyMap, +): Record { + const sources: Record = {}; + for (const { path, suffix, ext } of objFiles.sourceFiles) { + const content = contents.get(path); + if (content === undefined) continue; + let key: string; + if (suffix) { + key = sourceKey( + objFiles.type, + handler.suffixToSourceKey?.[suffix] ?? suffix, + ext ?? '', + ); + } else if (ext && ext !== 'json') { + key = sourceKey(objFiles.type, 'main', ext); + } else { + key = 'main'; + } + sources[key] = content; + } + return sources; +} + +/** + * Stage companion keys on an ADK object without a dedicated + * `setSources` hook — everything lands on `_pendingSources` / + * `_pendingSource` so the shared emit path in `base.ts` re-emits + * LIMU/sub-object files instead of silently dropping them. + */ +function stagePendingSources( + adkObject: AdkObject, + sources: Record, +): void { + const staged = adkObject as unknown as { + _pendingSource?: string; + _pendingSources?: Record; + }; + const { main, ...rest } = sources; + if (main !== undefined) staged._pendingSource = main; + if (Object.keys(rest).length > 0) { + staged._pendingSources = { ...staged._pendingSources, ...rest }; + } +} + +/** Build the payload record: handler projection + docu + properties. */ +function buildPayload( + objFiles: ObjectFiles, + handler: GctsHandler, + metadata: unknown, + contents: ReadonlyMap, +): { name: string; description?: string; [key: string]: unknown } { + const payload = ( + handler.fromAbapGit + ? handler.fromAbapGit(metadata) + : { name: objFiles.name } + ) as { name: string; description?: string; [key: string]: unknown }; + + // DOCU companion → `data.docu` (the shared emit path re-emits it). + const docuRaw = objFiles.docuFile + ? contents.get(objFiles.docuFile) + : undefined; + if (docuRaw !== undefined) { + try { + payload.docu = JSON.parse(docuRaw) as unknown; + } catch { + payload.docu = docuRaw; + } + } + + // `.properties` translations → `data.properties` keyed by suffix + // ('' = master file, 'en', 'texts.en', …). + if (objFiles.propertyFiles.length > 0) { + const props: Record = {}; + for (const { path, suffix } of objFiles.propertyFiles) { + const content = contents.get(path); + if (content !== undefined) props[suffix ?? ''] = content; + } + payload.properties = props; + } + return payload; +} + +/** Merge `data.packageRef`/`abapLanguageVersion` fallbacks onto the object. */ +function applyOptionsFallbacks( + adkObject: AdkObject, + options?: ExportOptions, +): void { + const data = (adkObject as unknown as { _data?: Record }) + ._data; + if (!data) return; + // Assign rootPackage to every object without a packageRef — no folder + // logic available, so we cannot derive per-package assignments (see + // E06 follow-ups). Matches abapGit's "no .abapgit.xml" fallback. + if (options?.rootPackage && !data.packageRef) { + data.packageRef = { name: options.rootPackage }; + } + if (options?.abapLanguageVersion && !data.abapLanguageVersion) { + data.abapLanguageVersion = + abapLangVerToAdt(options.abapLanguageVersion) ?? + options.abapLanguageVersion; + } +} + +/** Metadata + collected files → a staged ADK object. */ +function objectFromFiles( + objFiles: ObjectFiles, + handler: GctsHandler, + metadata: unknown, + contents: ReadonlyMap, + readErrors: ReadonlyMap, + adk: ReturnType, + options?: ExportOptions, +): AdkObject { + // Surface any companion read failure here — the caller invokes us + // inside the per-object try, so one bad file fails only this object. + for (const path of [ + ...(objFiles.docuFile ? [objFiles.docuFile] : []), + ...objFiles.propertyFiles.map((f) => f.path), + ...objFiles.sourceFiles.map((f) => f.path), + ]) { + if (readErrors.has(path)) throw readErrors.get(path); + } + + const sources = collectSources(objFiles, handler, contents); + const payload = buildPayload(objFiles, handler, metadata, contents); + + // AFF metadata frequently omits the object name (it's encoded in the + // filename). Fall back to the filename-derived name. + const objectName = payload.name || objFiles.name; + const fullData = { ...payload, name: objectName }; + + const adkType = + typeof payload.type === 'string' ? payload.type : objFiles.type; + const adkObject = adk.getWithData(fullData, adkType) as AdkObject; + + if (Object.keys(sources).length > 0) { + if (handler.setSources) { + handler.setSources(adkObject, sources); + } else { + stagePendingSources(adkObject, sources); + } + } + + if (payload.description) { + ( + adkObject as unknown as { _pendingDescription: string } + )._pendingDescription = payload.description; + } + + // Record relative directory (useful for future directory-based package + // resolution). Plain metadata dir, no starting-folder normalisation — + // gCTS does not define one. + const sourceDir = objFiles.metadataFile!.split('/').slice(0, -1).join('/'); + (adkObject as unknown as { _relDir: string })._relDir = sourceDir; + + applyOptionsFallbacks(adkObject, options); + return adkObject; +} diff --git a/packages/adt-plugin-gcts/src/lib/format/filename.ts b/packages/adt-plugin-gcts/src/lib/format/filename.ts index 70566cf9f..ea95aa783 100644 --- a/packages/adt-plugin-gcts/src/lib/format/filename.ts +++ b/packages/adt-plugin-gcts/src/lib/format/filename.ts @@ -52,29 +52,69 @@ export const METADATA_EXTENSION = 'json'; /** Fixed filename for package metadata (AFF / gCTS convention). */ export const PACKAGE_FILENAME = 'package.devc.json'; +/** + * AFF filename-safe form of an object name: namespaces encode as brackets + * (`/NMSPC/CL_OBJECT` → `(nmspc)cl_object`, AFF spec §file-names) so a + * `/` in the name can never split the file into nested directories. + */ +export function affName(name: string): string { + return name.toLowerCase().replace(/^\/([^/]+)\//, '($1)'); +} + /** * Build the AFF/gCTS on-disk filename for an object. * * @param name Object name (case preserved — AFF normalises to lowercase) * @param type ABAP object type code (CLAS, INTF, ...) * @param kind `"metadata"` (default) or `"source"`. - * @param suffix Optional sub-include suffix (e.g. `locals_def` for CLAS). + * @param suffix Optional sub-include suffix (e.g. `locals_def` for CLAS, + * or `z_func.func` for a LIMU sub-object). + * @param ext Source-file extension override — defaults to the type's + * extension (`abap`, `acds`, …). Needed for companions whose + * extension differs from the type's main source (`.json` + * LIMU metadata, `.properties` texts). */ export function gctsFilename( name: string, type: string, kind: 'metadata' | 'source' = 'metadata', suffix?: string, + ext?: string, ): string { - const base = name.toLowerCase(); + const base = affName(name); const typeLower = mainType(type).toLowerCase(); if (kind === 'metadata') { return `${base}.${typeLower}.${METADATA_EXTENSION}`; } - const ext = TYPE_TO_SOURCE_EXT[mainType(type).toUpperCase()] ?? 'abap'; + const sourceExt = + ext ?? TYPE_TO_SOURCE_EXT[mainType(type).toUpperCase()] ?? 'abap'; return suffix - ? `${base}.${typeLower}.${suffix}.${ext}` - : `${base}.${typeLower}.${ext}`; + ? `${base}.${typeLower}.${suffix}.${sourceExt}` + : `${base}.${typeLower}.${sourceExt}`; +} + +/** + * LIMU sub-object type codes (AFF spec §file-names): they never appear as + * root objects — a `{parent}.{ptype}.{sub}.{stype}.{ext}` filename routes to + * the parent object with `{sub}.{stype}` as its suffix. + */ +export const LIMU_SUBTYPES = new Set([ + 'func', + 'reps', + 'cpub', + 'cpro', + 'cprt', + 'cpri', + 'sotr', + 'sotu', +]); + +/** + * The default source-file extension for an object type (`abap` fallback) — + * used to decide whether a source-key suffix carries an explicit extension. + */ +export function gctsSourceExt(type: string): string { + return TYPE_TO_SOURCE_EXT[mainType(type).toUpperCase()] ?? 'abap'; } /** Source/companion extensions recognised by the spec file-name grammar. */ @@ -90,6 +130,41 @@ const KNOWN_SOURCE_EXTS = new Set([ 'properties', ]); +/** Every file extension the spec grammar can carry after the suffix. */ +const KNOWN_FILE_EXTS = new Set([...KNOWN_SOURCE_EXTS, METADATA_EXTENSION]); + +/** + * Encode a companion file as a source-map key: bare `suffix` when the file + * uses the type's default source extension, `{suffix}.{ext}` when it does + * not (e.g. `z_func.func.json` → LIMU metadata, `texts.en.properties` → + * translated texts). Symmetric with `splitSourceKey`. + */ +export function sourceKey(type: string, suffix: string, ext: string): string { + return !ext || ext === gctsSourceExt(type) ? suffix : `${suffix}.${ext}`; +} + +/** + * Inverse of `sourceKey`: splits a source-map key into the filename suffix + * and an optional extension override (`undefined` → the type's default). + */ +export function splitSourceKey(key: string): { + suffix?: string; + ext?: string; +} { + if (key === 'main') return { suffix: undefined, ext: undefined }; + // Suffix-less companions with a non-default extension key as + // `main.{ext}` (e.g. master texts `name.prog.properties`). + if (key.startsWith('main.')) { + const tail = key.slice(5); + if (KNOWN_FILE_EXTS.has(tail)) return { suffix: undefined, ext: tail }; + } + const dot = key.lastIndexOf('.'); + if (dot > 0 && KNOWN_FILE_EXTS.has(key.slice(dot + 1))) { + return { suffix: key.slice(0, dot), ext: key.slice(dot + 1) }; + } + return { suffix: key, ext: undefined }; +} + /** * Parse an AFF/gCTS filename back into its components. * @@ -119,13 +194,19 @@ export function parseGctsFilename( */ function parseMetadataName(parts: string[]): ParsedFormatFilename { const docu = parts.at(-2) === 'docu' && parts.length >= 4; - const offset = docu ? -3 : -2; + // `{parent}.{ptype}.{sub}.{stype}.json` — LIMU sub-object metadata is a + // companion of the parent (`z_fugr.fugr.z_func.func.json`). + const limu = parts.length >= 5 && LIMU_SUBTYPES.has(parts.at(-2)!); + let offset = -2; + if (docu) offset = -3; + else if (limu) offset = -4; const parsed: ParsedFormatFilename = { name: parts.slice(0, offset).join('.'), type: parts.at(offset)!.toUpperCase(), extension: METADATA_EXTENSION, }; if (docu) parsed.suffix = 'docu'; + else if (limu) parsed.suffix = `${parts.at(-3)}.${parts.at(-2)}`; return parsed; } @@ -171,6 +252,16 @@ function parseSourceName( extension: string, ): ParsedFormatFilename | undefined { if (!KNOWN_SOURCE_EXTS.has(extension)) return undefined; + // LIMU sub-object source: `{parent}.{ptype}.{sub}.{stype}.{ext}` — + // the suffix carries `{sub}.{stype}` (e.g. `z_func.func`). + if (parts.length >= 5 && LIMU_SUBTYPES.has(parts.at(-2)!)) { + return { + name: parts.slice(0, -4).join('.'), + type: parts.at(-4)!.toUpperCase(), + suffix: `${parts.at(-3)}.${parts.at(-2)}`, + extension, + }; + } if (parts.length >= 4) { return { name: parts.slice(0, -3).join('.'), diff --git a/packages/adt-plugin-gcts/src/lib/handlers/base.ts b/packages/adt-plugin-gcts/src/lib/handlers/base.ts index 464b96e27..b5671bf46 100644 --- a/packages/adt-plugin-gcts/src/lib/handlers/base.ts +++ b/packages/adt-plugin-gcts/src/lib/handlers/base.ts @@ -15,7 +15,8 @@ import type { AdkObject, AdkKind } from '@abapify/adk'; import { getTypeForKind, getMainType } from '@abapify/adk'; import type { FormatHandler, SerializedFile } from '@abapify/adt-plugin'; -import { gctsFilename } from '../format/filename'; +import { gctsFilename, splitSourceKey } from '../format/filename'; +import { payloadOf } from '../aff/generic'; import type { GctsMetadata } from '../format/types'; export type AbapObjectType = string; @@ -33,6 +34,12 @@ export type AdkObjectClass = { */ export interface GctsSourceEntry { suffix?: string; + /** + * File extension override — defaults to the object type's source + * extension. Required for companions whose extension differs + * (`.properties` texts, `.json` LIMU sub-object metadata, `.xdp`). + */ + ext?: string; content: string | Promise | (() => string | Promise); } @@ -130,6 +137,108 @@ function buildMetadataFile(meta: GctsMetadata): string { return JSON.stringify(meta, null, 2) + '\n'; } +/** Staged source companions → files (extension-aware keys → LIMU names). */ +function sourceCompanionFiles( + object: unknown, + type: string, + objectName: string, + suffixToSourceKey?: Record, +): SerializedFile[] { + const payload = payloadOf(object as Parameters[0]); + const pending = object as { + _pendingSource?: string; + _pendingSources?: Record; + }; + const map: Record = { + ...(payload?.sources as Record | undefined), + ...pending._pendingSources, + }; + if (pending._pendingSource && !map.main) map.main = pending._pendingSource; + // Staged keys are ADK include/source names — the file suffix is the + // inverse of `suffixToSourceKey` (CLAS `locals_def` → `definitions`). + const keyToSuffix = Object.fromEntries( + Object.entries(suffixToSourceKey ?? {}).map(([suffix, key]) => [ + key, + suffix, + ]), + ); + const files: SerializedFile[] = []; + for (const [key, content] of Object.entries(map)) { + // Skip absent entries only — an empty companion is still a companion + // and must round-trip (e.g. an intentionally empty include). + if (content == null) continue; + const { suffix, ext } = splitSourceKey(key); + const fileSuffix = suffix ? (keyToSuffix[suffix] ?? suffix) : suffix; + files.push({ + path: gctsFilename(objectName, type, 'source', fileSuffix, ext), + content, + }); + } + return files; +} + +/** `data.docu` → `{name}.{type}.docu.json` file (or nothing). */ +function docuCompanionFile( + payload: Record | undefined, + objectName: string, + typeLower: string, +): SerializedFile[] { + const docu = payload?.docu; + if (docu === undefined || docu === null) return []; + return [ + { + path: `${objectName}.${typeLower}.docu.json`, + content: + typeof docu === 'string' ? docu : JSON.stringify(docu, null, 2) + '\n', + }, + ]; +} + +/** `data.properties` map → `.properties` files ('' key → master file). */ +function propertyCompanionFiles( + payload: Record | undefined, + objectName: string, + typeLower: string, +): SerializedFile[] { + const props = payload?.properties as Record | undefined; + const files: SerializedFile[] = []; + for (const [key, content] of Object.entries(props ?? {})) { + if (content == null) continue; + files.push({ + path: key + ? `${objectName}.${typeLower}.${key}.properties` + : `${objectName}.${typeLower}.properties`, + content, + }); + } + return files; +} + +/** + * Universal companion preservation. Deserialization stages every + * companion a dedicated hook does not claim into `_pendingSources` / + * `data.sources` (extension-aware keys), `data.docu`, and + * `data.properties` — emitted here so handlers without a matching emit + * hook still round-trip LIMU sub-objects, DOCU texts, and translations + * instead of silently dropping them. Paths already in `emitted` win. + */ +function companionFiles( + object: unknown, + type: string, + objectName: string, + emitted: ReadonlySet, + suffixToSourceKey?: Record, +): SerializedFile[] { + const typeLower = type.split('/')[0].toLowerCase(); + const payload = payloadOf(object as Parameters[0]); + const candidates = [ + ...sourceCompanionFiles(object, type, objectName, suffixToSourceKey), + ...docuCompanionFile(payload, objectName, typeLower), + ...propertyCompanionFiles(payload, objectName, typeLower), + ]; + return candidates.filter((f) => !emitted.has(f.path)); +} + /** * Register a handler for an ADK class (preferred — `type` is derived from * the static `kind`) or an explicit type string (for types without an ADK @@ -197,7 +306,13 @@ export function createHandler< const content = await resolveSource(entry); if (!content) continue; files.push({ - path: gctsFilename(objectName, type, 'source', entry.suffix), + path: gctsFilename( + objectName, + type, + 'source', + entry.suffix, + entry.ext, + ), content, }); } @@ -233,6 +348,16 @@ export function createHandler< } } + files.push( + ...companionFiles( + object, + type, + objectName, + new Set(files.map((f) => f.path)), + definition.suffixToSourceKey, + ), + ); + return files; }, fromAbapGit: definition.fromMetadata diff --git a/packages/adt-plugin-gcts/src/lib/handlers/objects/fugr.ts b/packages/adt-plugin-gcts/src/lib/handlers/objects/fugr.ts index a92165a96..849b67b0f 100644 --- a/packages/adt-plugin-gcts/src/lib/handlers/objects/fugr.ts +++ b/packages/adt-plugin-gcts/src/lib/handlers/objects/fugr.ts @@ -12,14 +12,16 @@ * * AFF's FUGR representation nests function modules under the group * directory. This handler emits the group metadata plus per-function-module - * files when function module data is available: - * .func.json — FM metadata (func-v1.json schema) - * .func.abap — FM source + * files when function module data is available (LIMU naming keeps the + * submodule files inside the parent's namespace): + * .fugr..func.json — FM metadata (func-v1.json schema) + * .fugr..func.abap — FM source */ import { AdkFunctionGroup } from '@abapify/adk'; import { createHandler, type GctsExtraFile } from '../base'; -import type { FugrAff, FuncAff } from '../../../schemas/generated'; +import { affName } from '../../format/filename'; +import type { FugrAff } from '../../../schemas/generated'; type FmDescriptor = { name: string; @@ -73,15 +75,19 @@ export const functionGroupHandler = createHandler(AdkFunctionGroup, { getExtraFiles(fugr: FugrLike): GctsExtraFile[] { const fms = fugr.functionModules ?? []; const files: GctsExtraFile[] = []; + // LIMU naming: `{parent}.{ptype}.{sub}.{stype}.{ext}` — the parent + // prefix keeps the file inside the FUGR object's namespace so the + // deserializer's suffix routing can re-attach it. + const parent = affName(fugr.name ?? ''); for (const fm of fms) { - const fmName = fm.name.toLowerCase(); + const fmName = affName(fm.name); files.push({ - path: `${fmName}.func.json`, + path: `${parent}.fugr.${fmName}.func.json`, content: buildFuncMetadata(fm), }); if (fm.source) { files.push({ - path: `${fmName}.func.abap`, + path: `${parent}.fugr.${fmName}.func.abap`, content: fm.source, }); } diff --git a/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts b/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts index feccaa403..03eb7fefd 100644 --- a/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts +++ b/packages/adt-plugin-gcts/tests/aff/filename-grammar.test.ts @@ -14,6 +14,8 @@ import { fileURLToPath } from 'node:url'; import { parseGctsFilename, gctsFilename, + sourceKey, + splitSourceKey, } from '../../src/lib/format/filename.ts'; const AFF_EXAMPLES = join( @@ -79,6 +81,94 @@ describe('parseGctsFilename — upstream example shapes', () => { }); }); +describe('LIMU sub-objects — `{parent}.{ptype}.{sub}.{stype}.{ext}`', () => { + it('routes .func.json to the parent FUGR object', () => { + const p = parseGctsFilename( + 'z_aff_example_fugr.fugr.z_aff_example_func.func.json', + ); + assert.equal(p?.name, 'z_aff_example_fugr'); + assert.equal(p?.type, 'FUGR'); + assert.equal(p?.suffix, 'z_aff_example_func.func'); + assert.equal(p?.extension, 'json'); + }); + + it('routes .reps.abap sources the same way', () => { + const p = parseGctsFilename( + 'z_aff_example_fugr.fugr.lz_aff_example_fugrtop.reps.abap', + ); + assert.equal(p?.name, 'z_aff_example_fugr'); + assert.equal(p?.type, 'FUGR'); + assert.equal(p?.suffix, 'lz_aff_example_fugrtop.reps'); + assert.equal(p?.extension, 'abap'); + }); + + it('every upstream LIMU filename resolves to its parent type', () => { + const fugrDir = join(AFF_EXAMPLES, 'fugr', 'examples'); + for (const f of readdirSync(fugrDir)) { + if (!f.includes('.')) continue; + const p = parseGctsFilename(f); + assert.equal(p?.type, 'FUGR', `${f} must resolve to FUGR`); + } + }); +}); + +describe('source keys — extension-aware encoding', () => { + it('sourceKey encodes non-default extensions into the key', () => { + assert.equal(sourceKey('FUGR', 'z_func.func', 'json'), 'z_func.func.json'); + assert.equal(sourceKey('FUGR', 'z_func.func', 'abap'), 'z_func.func'); + assert.equal( + sourceKey('PROG', 'texts.en', 'properties'), + 'texts.en.properties', + ); + assert.equal(sourceKey('CSNM', 'model_inferred', 'csn'), 'model_inferred'); + // suffix-less companion with non-default ext: `name.prog.properties` + assert.equal(sourceKey('PROG', 'main', 'properties'), 'main.properties'); + assert.equal(sourceKey('PROG', 'main', 'abap'), 'main'); + }); + + it('splitSourceKey inverts it', () => { + assert.deepEqual(splitSourceKey('z_func.func.json'), { + suffix: 'z_func.func', + ext: 'json', + }); + assert.deepEqual(splitSourceKey('texts.en.properties'), { + suffix: 'texts.en', + ext: 'properties', + }); + assert.deepEqual(splitSourceKey('z_func.func'), { + suffix: 'z_func.func', + ext: undefined, + }); + assert.deepEqual(splitSourceKey('model_inferred'), { + suffix: 'model_inferred', + ext: undefined, + }); + assert.deepEqual(splitSourceKey('main'), { + suffix: undefined, + ext: undefined, + }); + assert.deepEqual(splitSourceKey('main.properties'), { + suffix: undefined, + ext: 'properties', + }); + }); + + it('gctsFilename emits LIMU and .properties filenames', () => { + assert.equal( + gctsFilename('z_fugr', 'FUGR', 'source', 'z_func.func', 'json'), + 'z_fugr.fugr.z_func.func.json', + ); + assert.equal( + gctsFilename('z_prog', 'PROG', 'source', 'texts.en', 'properties'), + 'z_prog.prog.texts.en.properties', + ); + assert.equal( + gctsFilename('z_fugr', 'FUGR', 'source', 'z_func.func'), + 'z_fugr.fugr.z_func.func.abap', + ); + }); +}); + describe('gctsFilename — spec extension coverage', () => { it('maps every example-evidenced source extension', () => { const cases: Array<[string, string]> = [ diff --git a/packages/adt-plugin-gcts/tests/handlers/wave1.test.ts b/packages/adt-plugin-gcts/tests/handlers/wave1.test.ts index c0674f5e9..423367070 100644 --- a/packages/adt-plugin-gcts/tests/handlers/wave1.test.ts +++ b/packages/adt-plugin-gcts/tests/handlers/wave1.test.ts @@ -382,12 +382,15 @@ describe('FUGR handler — per-function-module files', () => { ], }), ); - const fm1Json = files.find((f) => f.path === 'z_fm1.func.json'); - const fm1Abap = files.find((f) => f.path === 'z_fm1.func.abap'); - const fm2Json = files.find((f) => f.path === 'z_fm2.func.json'); - assert.ok(fm1Json, 'z_fm1.func.json emitted'); - assert.ok(fm1Abap, 'z_fm1.func.abap emitted'); - assert.ok(fm2Json, 'z_fm2.func.json emitted'); + // LIMU naming: `{parent}.fugr.{fm}.func.{json,abap}` — the parent + // prefix keeps the file inside the FUGR namespace so the + // deserializer's LIMU suffix routing re-attaches it. + const fm1Json = files.find((f) => f.path === 'zgrp.fugr.z_fm1.func.json'); + const fm1Abap = files.find((f) => f.path === 'zgrp.fugr.z_fm1.func.abap'); + const fm2Json = files.find((f) => f.path === 'zgrp.fugr.z_fm2.func.json'); + assert.ok(fm1Json, 'zgrp.fugr.z_fm1.func.json emitted'); + assert.ok(fm1Abap, 'zgrp.fugr.z_fm1.func.abap emitted'); + assert.ok(fm2Json, 'zgrp.fugr.z_fm2.func.json emitted'); const m1 = JSON.parse(fm1Json!.content); assert.strictEqual(m1.formatVersion, '1');