Repository navigation
feat(adt-plugin-gcts): schema-aware generic emit/parse for all wave2 stubs #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+251
−48
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>; | ||
| }; | ||
|
|
||
| /** | ||
| * 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<string, unknown> { | ||
| 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<string, unknown> | 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<string, unknown> | 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<string, unknown>); | ||
| // 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<string, unknown>).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; | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| if (abapLang && abapLang !== 'standard') { | ||
| meta.header.abapLanguageVersion = abapLang; | ||
| } | ||
| for (const prop of bodyProps) { | ||
| const value = (data as Record<string, unknown>)[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(), | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| // 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, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
106 changes: 106 additions & 0 deletions
106
packages/adt-plugin-gcts/tests/aff/generic-roundtrip.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| for (const prop of affBodyProps({ type })) { | ||
| if ( | ||
| body[prop] !== undefined && | ||
| (back as Record<string, unknown>)[prop] === undefined | ||
|
ThePlenkov marked this conversation as resolved.
ThePlenkov marked this conversation as resolved.
|
||
| ) { | ||
| 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, []); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.