Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions packages/adt-plugin-gcts/src/lib/aff/generic.ts
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
* 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;
Comment thread
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(),
Comment thread
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,
};
}
64 changes: 16 additions & 48 deletions packages/adt-plugin-gcts/src/lib/handlers/objects/wave2-generic.ts
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ThePlenkov marked this conversation as resolved.
* 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> | 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<string> {
return typeof obj.getSource === 'function' ? obj.getSource() : '';
}
Expand Down Expand Up @@ -146,8 +114,8 @@ const WAVE2_TYPES = [

for (const type of WAVE2_TYPES) {
createHandler<GenericLike, MinimalAff>(type, {
toMetadata,
toMetadata: genericToMetadata(type.toLowerCase()),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
getSource,
fromMetadata,
fromMetadata: genericFromMetadata,
});
}
106 changes: 106 additions & 0 deletions packages/adt-plugin-gcts/tests/aff/generic-roundtrip.test.ts
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);
Comment thread
ThePlenkov marked this conversation as resolved.
for (const prop of affBodyProps({ type })) {
if (
body[prop] !== undefined &&
(back as Record<string, unknown>)[prop] === undefined
Comment thread
ThePlenkov marked this conversation as resolved.
Comment thread
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, []);
});
});
Loading