From d755d14e08bb67d2fd15417d0ad994f0265a325e Mon Sep 17 00:00:00 2001 From: ThePlenkov Date: Thu, 22 Oct 2026 14:57:52 +0000 Subject: [PATCH] feat(adt-plugin-gcts): source companions + DOCU for wave2 stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - genericGetSources: obj.sources map → per-suffix files — `main` → `{name}.{type}.{ext}`, other keys → `{name}.{type}.{suffix}.{ext}` (csnm's `` grammar, 0..n files); falls back to single obj.getSource(). - genericSetSources: stubs now stage sources on deserialize (was dropped silently); `docu` key parses to data.docu, rest to data.sources. - genericGetExtraFiles: DOCU companion emit — `{name}.{type}.docu.json` (companion metadata, not an object). - parseGctsFilename: `{name}.{type}.docu.json` resolves to the real type + suffix 'docu' (was misparsed as type DOCU). - deserializer: .docu.json routes to sourceFiles — no longer overwrites the metadata anchor. tests/aff/source-companions.test.ts + full aff suite: 19/19 green — ddla .acds, csnm model_inferred, docu emit+validate+round-trip. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../adt-plugin-gcts/src/lib/aff/generic.ts | 117 ++++++++++++++++++ .../src/lib/format/filename.ts | 20 ++- .../src/lib/handlers/objects/wave2-generic.ts | 15 ++- .../tests/aff/source-companions.test.ts | 97 +++++++++++++++ 4 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 packages/adt-plugin-gcts/tests/aff/source-companions.test.ts diff --git a/packages/adt-plugin-gcts/src/lib/aff/generic.ts b/packages/adt-plugin-gcts/src/lib/aff/generic.ts index 08422df5..a2769fe0 100644 --- a/packages/adt-plugin-gcts/src/lib/aff/generic.ts +++ b/packages/adt-plugin-gcts/src/lib/aff/generic.ts @@ -30,6 +30,10 @@ 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`). */ + sources?: Record; }; /** @@ -111,6 +115,119 @@ 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. + */ +export function genericGetSources(obj: GenericAffLike) { + // Read from the same place genericSetSources writes: the payload's + // `sources` map (plain objects) or `_pendingSources` (ADK objects + // staged for check-in). + const data = payloadOf(obj); + const sources = + (data?.sources as Record | undefined) ?? + obj.sources ?? + (obj as { _pendingSources?: Record })._pendingSources; + const single = (obj as { _pendingSource?: string })._pendingSource; + const merged = single ? { main: single, ...sources } : sources; + if (merged && Object.keys(merged).length > 0) { + return Object.entries(merged) + .filter(([, content]) => !!content) + .map(([key, content]) => ({ + suffix: key === 'main' ? undefined : key, + content, + })); + } + const getSource = (obj as { getSource?: () => string | Promise }) + .getSource; + const src = typeof getSource === 'function' ? getSource.call(obj) : undefined; + return src ? [{ suffix: undefined, content: src }] : []; +} + +/** + * DOCU companion emit: `data.docu` carries the raw DOCU JSON document — + * emitted as `{name}.{type}.docu.json` beside the main metadata file + * (AFF spec: DOCU is companion metadata, not a standalone object). + */ +export function genericGetExtraFiles(type: string) { + return (obj: GenericAffLike) => { + const docu = payloadOf(obj)?.docu; + if (docu === undefined || docu === null) return []; + const name = (obj.name ?? '').toLowerCase(); + // Parsed documents re-serialize; a raw string (unparseable JSON kept + // by genericSetSources) re-emits verbatim instead of double-encoding. + const content = + typeof docu === 'string' ? docu : JSON.stringify(docu, null, 2) + '\n'; + return [ + { + path: `${name}.${type.toLowerCase()}.docu.json`, + content, + }, + ]; + }; +} + +/** + * Is `obj.data` assignable? Plain test doubles have a writable (or no) + * `data` property — assignment creates/updates it. ADK objects expose + * `data` as a method or a getter-only accessor (`AdkGenericObject`), where + * assignment throws in strict mode; their mutable slot is `_data`. + */ +function dataIsWritable(obj: GenericAffLike): boolean { + let cur: object | null = obj; + while (cur) { + const d = Object.getOwnPropertyDescriptor(cur, 'data'); + if (d) return 'value' in d ? d.writable !== false : d.set !== undefined; + cur = Object.getPrototypeOf(cur) as object | null; + } + return true; +} + +/** Deserialize side: stage companion sources back onto the object. */ +export function genericSetSources( + obj: GenericAffLike, + sources: Record, +): void { + // On ADK objects `data` is a method or a getter-only accessor — the + // mutable payload lives in `_data` (also written to `_pendingSources`, + // which ADK check-in reads for source uploads). + const raw = obj.data as unknown; + const isAdkObject = typeof raw === 'function' || !dataIsWritable(obj); + let data = payloadOf(obj); + if (!data) { + if (isAdkObject) { + const priv = obj as { _data?: Record }; + priv._data = priv._data ?? {}; + data = priv._data; + } else { + const fresh: Record = {}; + (obj as { data?: Record }).data = fresh; + data = fresh; + } + } + const { docu, ...rest } = sources; + // DOCU companions are JSON documents, not source text — parse once, + // re-emit via genericGetExtraFiles. A source key literally named + // `docu.` still lands in the sources map (extension-aware). + if (docu !== undefined) { + try { + data.docu = JSON.parse(docu) as unknown; + } catch { + data.docu = docu; + } + } + data.sources = { + ...(data.sources as Record | undefined), + ...rest, + }; + if (isAdkObject) { + const pending = obj as { _pendingSources?: Record }; + pending._pendingSources = { ...pending._pendingSources, ...rest }; + } +} + export function genericFromMetadata(meta: MinimalAff) { const { formatVersion, header, ...body } = meta; return { diff --git a/packages/adt-plugin-gcts/src/lib/format/filename.ts b/packages/adt-plugin-gcts/src/lib/format/filename.ts index c4e11f92..70566cf9 100644 --- a/packages/adt-plugin-gcts/src/lib/format/filename.ts +++ b/packages/adt-plugin-gcts/src/lib/format/filename.ts @@ -114,23 +114,19 @@ export function parseGctsFilename( } /** - * `{name}.{type}.json` metadata files, plus the DOCU companion form + * `{name}.{type}.json`, plus the DOCU companion form * `{name}.{type}.docu.json` which belongs to the parent object. */ function parseMetadataName(parts: string[]): ParsedFormatFilename { - if (parts.at(-2) === 'docu' && parts.length >= 4) { - return { - name: parts.slice(0, -3).join('.'), - type: parts.at(-3)!.toUpperCase(), - suffix: 'docu', - extension: METADATA_EXTENSION, - }; - } - return { - name: parts.slice(0, -2).join('.'), - type: parts.at(-2)!.toUpperCase(), + const docu = parts.at(-2) === 'docu' && parts.length >= 4; + const offset = docu ? -3 : -2; + const parsed: ParsedFormatFilename = { + name: parts.slice(0, offset).join('.'), + type: parts.at(offset)!.toUpperCase(), extension: METADATA_EXTENSION, }; + if (docu) parsed.suffix = 'docu'; + return parsed; } /** diff --git a/packages/adt-plugin-gcts/src/lib/handlers/objects/wave2-generic.ts b/packages/adt-plugin-gcts/src/lib/handlers/objects/wave2-generic.ts index 95d727be..b9a5c9c1 100644 --- a/packages/adt-plugin-gcts/src/lib/handlers/objects/wave2-generic.ts +++ b/packages/adt-plugin-gcts/src/lib/handlers/objects/wave2-generic.ts @@ -17,18 +17,15 @@ import { createHandler } from '../base'; import { genericFromMetadata, + genericGetExtraFiles, + genericGetSources, + genericSetSources, genericToMetadata, type GenericAffLike, type MinimalAff, } from '../../aff/generic'; -type GenericLike = GenericAffLike & { - getSource?: () => Promise | string; -}; - -function getSource(obj: GenericLike): string | Promise { - return typeof obj.getSource === 'function' ? obj.getSource() : ''; -} +type GenericLike = GenericAffLike; /** * Remaining AFF types with schemas but no dedicated handler. @@ -115,7 +112,9 @@ const WAVE2_TYPES = [ for (const type of WAVE2_TYPES) { createHandler(type, { toMetadata: genericToMetadata(type.toLowerCase()), - getSource, + getSources: genericGetSources, + getExtraFiles: genericGetExtraFiles(type.toLowerCase()), fromMetadata: genericFromMetadata, + setSources: genericSetSources, }); } diff --git a/packages/adt-plugin-gcts/tests/aff/source-companions.test.ts b/packages/adt-plugin-gcts/tests/aff/source-companions.test.ts new file mode 100644 index 00000000..d2a6a01e --- /dev/null +++ b/packages/adt-plugin-gcts/tests/aff/source-companions.test.ts @@ -0,0 +1,97 @@ +/** + * W3: source-bearing + special types — companion emit/parse round-trips + * driven by upstream example file shapes. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + genericGetSources, + genericGetExtraFiles, + genericSetSources, + type GenericAffLike, +} from '../../src/lib/aff/generic.ts'; +import { gctsFilename } from '../../src/lib/format/filename.ts'; +import { affValidate, readAffExample } from '../../src/lib/aff/schemas.ts'; + +describe('companion sources — suffix grammar', () => { + it('ddla: sources.main → {name}.ddla.acds', () => { + const obj: GenericAffLike = { + name: 'abapcatalog', + sources: { main: 'define data element zz_dtel { }' }, + }; + const [entry] = genericGetSources(obj); + assert.equal( + gctsFilename('abapcatalog', 'ddla', 'source', entry.suffix), + 'abapcatalog.ddla.acds', + ); + }); + + it('csnm: sources.model_inferred → {name}.csnm.model_inferred.csn', () => { + const obj: GenericAffLike = { + name: 'z_aff_example_csnm', + sources: { model_inferred: '{ "definitions": {} }' }, + }; + const [entry] = genericGetSources(obj); + assert.equal(entry.suffix, 'model_inferred'); + assert.equal( + gctsFilename('z_aff_example_csnm', 'csnm', 'source', entry.suffix), + 'z_aff_example_csnm.csnm.model_inferred.csn', + ); + }); + + it('getSource fallback → suffix-less main file', () => { + const obj = { + name: 'z', + getSource: () => 'source text', + } as GenericAffLike; + const entries = genericGetSources(obj); + assert.equal(entries.length, 1); + assert.equal(entries[0].suffix, undefined); + }); +}); + +describe('DOCU companions', () => { + const docuJson = readAffExample( + { type: 'doma' }, + 'z_aff_example.doma.docu.json', + ); + + it('emits {name}.{type}.docu.json when data.docu present', () => { + const obj: GenericAffLike = { + name: 'z_aff_example', + data: { docu: docuJson }, + }; + const files = genericGetExtraFiles('doma')(obj); + assert.equal(files.length, 1); + assert.equal(files[0].path, 'z_aff_example.doma.docu.json'); + assert.deepEqual(JSON.parse(files[0].content as string), docuJson); + assert.ok((files[0].content as string).endsWith('\n')); + }); + + it('emits nothing when data.docu absent', () => { + assert.deepEqual(genericGetExtraFiles('doma')({ name: 'x' }), []); + }); + + it('upstream docu example validates against docu schema', () => { + const { valid, errors } = affValidate({ type: 'docu' }, docuJson); + assert.deepEqual(errors, []); + assert.ok(valid); + }); + + it('setSources parses docu JSON into data.docu (round-trip)', () => { + const obj: GenericAffLike = { name: 'z' }; + genericSetSources(obj, { + docu: JSON.stringify(docuJson), + 'texts.en': 'k = v\n', + }); + assert.deepEqual(obj.data?.docu, docuJson); + assert.deepEqual( + (obj.data?.sources as Record)['texts.en'], + 'k = v\n', + ); + // and it re-emits via extras, not as a source file + const extras = genericGetExtraFiles('doma')(obj); + assert.equal(extras[0]?.path, 'z.doma.docu.json'); + }); +});