diff --git a/packages/adt-plugin-gcts/src/lib/aff/generic.ts b/packages/adt-plugin-gcts/src/lib/aff/generic.ts new file mode 100644 index 000000000..08422df5d --- /dev/null +++ b/packages/adt-plugin-gcts/src/lib/aff/generic.ts @@ -0,0 +1,129 @@ +/** + * Schema-aware generic AFF emit/parse — zero workspace deps. + * + * `genericToMetadata` builds the `{formatVersion, header}` envelope for a + * type (version pinned to its latest schema) plus only the schema- + * declared top-level body properties the payload carries — arbitrary + * payload fields are NOT copied. `genericFromMetadata` is its inverse: + * header → ADK projection, body fields spread into the payload so they + * land back on `obj.data` — enabling metadata round-trips for all + * wave2-registered types. + */ + +import { affBodyProps, latestAffVersion } from './schemas'; + +/** Minimal AFF metadata shape shared by all types. */ +export interface MinimalAff { + formatVersion: string; + header: { + description: string; + originalLanguage: string; + abapLanguageVersion?: string; + }; + [key: string]: unknown; +} + +export type GenericAffLike = { + name: string; + description?: string; + originalLanguage?: string; + abapLanguageVersion?: string; + /** Raw payload fields (deserialized AFF body, ADT response, …). */ + data?: Record; +}; + +/** + * Payload extraction that works for both plain objects (`data` is a + * record) and real ADK objects (`data` is an async method; the loaded + * payload lives behind the `dataSync` getter / `_data` field). Returns + * `undefined` when no usable payload is found — callers fall back to + * treating the object itself as the record. + */ +function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** Narrow to string/number; objects never reach `String()` coercion. */ +function str(v: unknown): string | undefined { + return typeof v === 'string' || typeof v === 'number' ? String(v) : undefined; +} + +/** ADK-object payload: `dataSync` getter (throws unloaded) or `_data`. */ +function adkPayloadOf( + obj: GenericAffLike, +): Record | undefined { + try { + const sync = (obj as { dataSync?: unknown }).dataSync; + if (isRecord(sync)) return sync; + } catch { + /* not loaded */ + } + const priv = (obj as { _data?: unknown })._data; + return isRecord(priv) ? priv : undefined; +} + +export function payloadOf( + obj: GenericAffLike, +): Record | undefined { + const d = obj.data as unknown; + if (isRecord(d)) return d; + if (typeof d === 'function') return adkPayloadOf(obj); + return undefined; +} + +export function genericToMetadata(type: string) { + return (obj: GenericAffLike): MinimalAff => { + const data = payloadOf(obj) ?? (obj as Record); + // Emit the payload's declared version when present (a v1 document + // stays a valid v1 document); fresh objects emit the latest schema + // version — resolved lazily so registration never touches the fs. + const declared = (data as Record).formatVersion; + const version = + typeof declared === 'string' || typeof declared === 'number' + ? String(declared) + : String(latestAffVersion({ type }) ?? '1'); + const bodyProps = affBodyProps({ + type, + version: Number.isNaN(Number(version)) ? undefined : Number(version), + }); + const meta: MinimalAff = { + formatVersion: version, + header: { + description: str(data.description) ?? obj.description ?? obj.name, + originalLanguage: ( + obj.originalLanguage ?? + str(data.originalLanguage) ?? + str(data.language) ?? + str(data.masterLanguage) ?? + 'en' + ).toLowerCase(), + }, + }; + const abapLang = str(data.abapLanguageVersion) ?? obj.abapLanguageVersion; + if (abapLang && abapLang !== 'standard') { + meta.header.abapLanguageVersion = abapLang; + } + for (const prop of bodyProps) { + const value = (data as Record)[prop]; + if (value !== undefined) meta[prop] = value; + } + return meta; + }; +} + +export function genericFromMetadata(meta: MinimalAff) { + const { formatVersion, header, ...body } = meta; + return { + name: '', + description: header.description, + language: header.originalLanguage?.toUpperCase(), + masterLanguage: header.originalLanguage?.toUpperCase(), + // formatVersion stays in the payload — a v1 document must re-emit as + // v1, not silently upgrade to a schema version it predates. + formatVersion, + ...(header.abapLanguageVersion + ? { abapLanguageVersion: header.abapLanguageVersion } + : {}), + ...body, + }; +} 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 a5c3c2277..95d727be0 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 @@ -1,63 +1,31 @@ /** * Generic AFF handler stubs for the remaining object types. * - * Wave 2 mass-generates handlers for all AFF types that have JSON schemas - * but no dedicated handler. Each stub emits the minimal AFF contract: - * { formatVersion: "1", header: { description, originalLanguage } } + * Wave 2 mass-registers handlers for all AFF types that have JSON schemas + * but no dedicated handler. Each stub runs the schema-aware generic + * emit/parse from `../../aff/generic`: the `{formatVersion, header}` + * envelope at the type's latest schema version plus every top-level body + * property present in the payload — schema-valid whenever the payload is. * - * Type-specific fields can be added later by promoting a stub to a - * dedicated handler file (like clas.ts, ddls.ts, etc.) when the ADK - * object model exposes richer data for that type. + * Types needing semantic mapping, source companions, or special file + * shapes graduate to a dedicated file (like clas.ts, ddls.ts, …). * * All types here are registered via the string-type form of * `createHandler` — no ADK class required. */ import { createHandler } from '../base'; +import { + genericFromMetadata, + genericToMetadata, + type GenericAffLike, + type MinimalAff, +} from '../../aff/generic'; -/** Minimal AFF metadata shape shared by all types. */ -interface MinimalAff { - formatVersion: string; - header: { - description: string; - originalLanguage: string; - abapLanguageVersion?: string; - }; - [key: string]: unknown; -} - -type GenericLike = { - name: string; - description?: string; - originalLanguage?: string; - abapLanguageVersion?: string; +type GenericLike = GenericAffLike & { getSource?: () => Promise | string; }; -function toMetadata(obj: GenericLike): MinimalAff { - const lang = (obj.originalLanguage ?? '').toLowerCase(); - const meta: MinimalAff = { - formatVersion: '1', - header: { - description: obj.description ?? obj.name ?? '', - originalLanguage: lang, - }, - }; - if (obj.abapLanguageVersion) { - meta.header.abapLanguageVersion = obj.abapLanguageVersion; - } - return meta; -} - -function fromMetadata(meta: MinimalAff) { - return { - name: '', - description: meta.header.description, - language: meta.header.originalLanguage?.toUpperCase(), - masterLanguage: meta.header.originalLanguage?.toUpperCase(), - }; -} - function getSource(obj: GenericLike): string | Promise { return typeof obj.getSource === 'function' ? obj.getSource() : ''; } @@ -146,8 +114,8 @@ const WAVE2_TYPES = [ for (const type of WAVE2_TYPES) { createHandler(type, { - toMetadata, + toMetadata: genericToMetadata(type.toLowerCase()), getSource, - fromMetadata, + fromMetadata: genericFromMetadata, }); } diff --git a/packages/adt-plugin-gcts/tests/aff/generic-roundtrip.test.ts b/packages/adt-plugin-gcts/tests/aff/generic-roundtrip.test.ts new file mode 100644 index 000000000..685645362 --- /dev/null +++ b/packages/adt-plugin-gcts/tests/aff/generic-roundtrip.test.ts @@ -0,0 +1,106 @@ +/** + * Generic emit round-trip: for every stub type (no dedicated + * `objects/{type}.ts`), take each upstream example as the payload, + * synthesize a GenericAffLike, run `genericToMetadata`, and require the + * emitted metadata to validate against the official schema. Also checks + * `genericFromMetadata` preserves every body field for the trip back. + */ + +import { before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + listAffTypes, + affExamples, + readAffExample, + schemaTargetForExample, + affValidate, + affBodyProps, +} from '../../src/lib/aff/schemas.ts'; +import { + genericToMetadata, + genericFromMetadata, + type MinimalAff, +} from '../../src/lib/aff/generic.ts'; + +const OBJECTS_DIR = join( + dirname(fileURLToPath(import.meta.url)), + '../../src/lib/handlers/objects', +); +const dedicated = (t: string) => existsSync(join(OBJECTS_DIR, `${t}.ts`)); + +interface Fail { + type: string; + file: string; + errors: string[]; +} +const emitFailures: Fail[] = []; +const bodyDrops: string[] = []; +let emitted = 0; + +function collect() { + for (const type of listAffTypes()) { + if (dedicated(type)) continue; + for (const rel of affExamples({ type })) { + const example = readAffExample({ type }, rel); + const target = schemaTargetForExample({ type }, rel, example); + if (target.companion || target.type !== type) continue; + + const { formatVersion, header, ...body } = example as MinimalAff & { + formatVersion: string; + header: { description: string; originalLanguage: string }; + }; + const obj = { + name: 'zz_aff_fixture', + description: header?.description ?? '', + originalLanguage: header?.originalLanguage ?? 'en', + data: { formatVersion, ...body }, + }; + + const meta = genericToMetadata(type)(obj); + const { valid, errors } = affValidate( + { + type, + version: Number.isNaN(Number(meta.formatVersion)) + ? undefined + : Number(meta.formatVersion), + }, + meta, + ); + if (!valid) { + emitFailures.push({ type, file: rel, errors: errors.slice(0, 3) }); + continue; + } + emitted++; + + // fromMetadata must return every body field so it survives to _data + const back = genericFromMetadata(example as MinimalAff); + for (const prop of affBodyProps({ type })) { + if ( + body[prop] !== undefined && + (back as Record)[prop] === undefined + ) { + bodyDrops.push(`${type}/${rel}: ${prop}`); + } + } + } + } +} + +describe('generic AFF emit round-trip (stubs)', () => { + before(collect); + + it('emits schema-valid metadata for upstream examples', () => { + assert.ok(emitted > 30, `expected >30 stub emissions, got ${emitted}`); + assert.deepEqual( + emitFailures.map((f) => `${f.type}/${f.file}: ${f.errors[0]}`), + [], + ); + }); + + it('fromMetadata preserves all body fields', () => { + assert.deepEqual(bodyDrops, []); + }); +});