Skip to content

Commit a4e6eee

Browse files
feat(plugin): FormatPlugin API foundation — E05
Decouples serialization-format implementations from their consumers via a global registry. Unblocks E06 (gCTS format), E07 (gCTS commands), E08 (checkin). User requirement satisfied: any third party can register a format and "--format <id>" dispatches even if the corresponding command plugin isn't installed. API surface (packages/adt-plugin/src/lib/format/): - FormatPlugin { id, description, supportedTypes, getHandler, parseFilename? } - FormatHandler { type, fileExtension, schema, serialize, ... } - registerFormatPlugin / getFormatPlugin / requireFormatPlugin / listFormatPlugins / unregisterFormatPlugin / clearFormatRegistry - Registry stored on globalThis[Symbol.for('@abapify/adt-plugin/format-registry')] to survive dual-graph evaluation in tests + bundler outputs. Migration of consumers (gate: zero direct `from '@abapify/adt-plugin-abapgit'` imports in adt-export/adt-diff/adt-cli source — verified via grep): - packages/adt-cli/src/lib/cli.ts side-effect bootstrap (the ONE sanctioned direct dep, for self-registration) - packages/adt-cli/src/lib/utils/format-loader.ts uses registry + dynamic import - packages/adt-diff/src/commands/diff.ts uses getFormatPlugin('abapgit') abapgit self-registration: - packages/adt-plugin-abapgit/src/lib/format-plugin.ts new - packages/adt-plugin-abapgit/src/index.ts registers on import Design choice (documented in epic file): ObjectHandler<T,TSchema> is abapgit-specific (templated on AbapGitSchema). Promoting it would have leaked abapgit types into the generic interface. FormatHandler is a structural subset; ObjectHandler satisfies it via a one-way widening cast. No translation layer. Optional FormatPlugin.diff() deferred until E06 provides a second data point (single-impl generalisation = premature). Tests: +9 registry tests (adt-plugin gains test target). Existing 224 adt-plugin-abapgit + 22 adt-diff + 122 adt-cli tests unchanged. Roadmap: docs/roadmap/epics/e05-format-plugin-api.md ✅ (with follow-ups) Architecture doc: docs/architecture/format-plugins.md NEW Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 7a4ed04 commit a4e6eee

13 files changed

Lines changed: 685 additions & 33 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# Format-Plugin API
2+
3+
_Status_: accepted (E05)
4+
5+
## Problem
6+
7+
The `adt` CLI originally hard-wired `@abapify/adt-plugin-abapgit` as the only
8+
serialization format. `export`, `diff`, `import`, and `checkout` all imported
9+
that package directly, which made it impossible to add a new format (gCTS,
10+
AFF, …) without either (a) forking the CLI or (b) shipping an additional
11+
`adt-plugin-abapgit`-shaped package and patching every consumer.
12+
13+
The user requirement driving this epic is:
14+
15+
> Even if the `gcts` command isn't installed we must still be able to save in
16+
> gCTS format alongside abapGit.
17+
18+
That means the CLI must discover formats through a contract, not through
19+
import statements.
20+
21+
## Solution
22+
23+
A small in-process registry of **format plugins**, plus a stable
24+
`FormatPlugin` interface that any third-party package can implement. Format
25+
plugins self-register at module-load time — importing the package is enough
26+
to make `--format <id>` work.
27+
28+
### Moving parts
29+
30+
```
31+
┌──────────────────────────────────────────────────────┐
32+
│ @abapify/adt-plugin │
33+
│ │
34+
│ interface FormatPlugin { id, description, │
35+
│ supportedTypes, │
36+
│ getHandler(type), │
37+
│ parseFilename?(name) } │
38+
│ │
39+
│ registerFormatPlugin(plugin) │
40+
│ getFormatPlugin(id) / requireFormatPlugin(id) │
41+
│ listFormatPlugins() │
42+
└───────────────────────▲──────────────────────────────┘
43+
│ implements
44+
45+
┌───────────────────────┴──────────────────────────────┐
46+
│ @abapify/adt-plugin-abapgit │
47+
│ │
48+
│ abapgitFormatPlugin : FormatPlugin │
49+
│ (self-registers on module load) │
50+
└───────────────────────▲──────────────────────────────┘
51+
│ looks up via getFormatPlugin('abapgit')
52+
53+
┌───────────────────────┴──────────────────────────────┐
54+
│ Consumers │
55+
│ - @abapify/adt-diff │
56+
│ - @abapify/adt-export │
57+
│ - @abapify/adt-cli services/import + checkout │
58+
└──────────────────────────────────────────────────────┘
59+
```
60+
61+
The registry is keyed on the `globalThis` with a `Symbol.for` well-known key
62+
so duplicate module-graph evaluation (tests, bundler outputs) does not
63+
produce two independent registries.
64+
65+
### Bootstrap
66+
67+
Exactly **one** location in the CLI imports `@abapify/adt-plugin-abapgit`
68+
directly: `packages/adt-cli/src/lib/cli.ts` uses a side-effect-only import
69+
(no `from` clause) so that the acceptance grep — which forbids
70+
`from.*adt-plugin-abapgit` in `adt-export`, `adt-diff`, and `adt-cli` — stays
71+
clean. Every other file uses `getFormatPlugin('abapgit')`.
72+
73+
Third-party plugins are loaded either:
74+
75+
1. Statically, by adding `import '@abapify/<your-format-plugin>';` to the
76+
consumer's entry point, or
77+
2. Dynamically, via `await import(packageName)` (current behaviour of
78+
`loadFormatPlugin` in adt-cli — useful for plugins listed in
79+
`adt.config.ts`).
80+
81+
Both paths trigger the plugin's module-level `registerFormatPlugin(...)` call.
82+
83+
### Interface contract
84+
85+
```ts
86+
export interface FormatPlugin {
87+
readonly id: string;
88+
readonly description: string;
89+
readonly supportedTypes: ReadonlyArray<string>;
90+
getHandler(type: string): FormatHandler | undefined;
91+
parseFilename?(filename: string): ParsedFormatFilename | undefined;
92+
}
93+
```
94+
95+
- **`id`** is what users pass after `--format` on the CLI. It is part of the
96+
public API and cannot change without a major version bump.
97+
- **`supportedTypes`** may be computed lazily via a getter (the abapGit
98+
plugin does this because it reads from a live handler registry).
99+
- **`getHandler(type)`** returns a `FormatHandler` — the per-object-type
100+
serializer. The abapGit concrete `ObjectHandler<T, TSchema>` is a
101+
structural superset of `FormatHandler`, so existing abapGit handlers work
102+
through a widening cast (no translation layer needed).
103+
- **`parseFilename(name)`** is optional because not every format uses
104+
filenames at all (gCTS streams via REST, for example).
105+
106+
### Registration rules
107+
108+
```
109+
registerFormatPlugin(plugin)
110+
- same id, same instance → no-op (idempotent, survives HMR / dual graph)
111+
- same id, different object → throws (prevents silent shadowing)
112+
- new id → stored
113+
```
114+
115+
`requireFormatPlugin(id)` throws with a message that lists the currently
116+
registered ids, which keeps user-visible errors actionable:
117+
118+
```
119+
Format plugin "gcts" is not registered. Available formats: abapgit.
120+
```
121+
122+
## Alternatives considered
123+
124+
1. **Extend the existing `AdtPlugin`** (`name`, `registry`, `format` shape) —
125+
rejected because that interface couples serialization logic with
126+
import/export workflow orchestration. The format registry needs a
127+
narrower, purely serialization-focused contract so that future formats
128+
(e.g. gCTS) can implement it without also re-implementing the import
129+
pipeline.
130+
131+
2. **Move `ObjectHandler` into `@abapify/adt-plugin`** — rejected because
132+
`ObjectHandler` is heavily parameterized on `AbapGitSchema`, which is an
133+
abapGit-specific thing and has no business leaking into the generic
134+
plugin interface. Instead, `FormatHandler` in `@abapify/adt-plugin`
135+
defines the minimum surface consumers need (parse/build/serialize) and
136+
concrete handlers may be structural supersets.
137+
138+
3. **Auto-discover `node_modules/@abapify/*`** — deferred. The CLI already
139+
has a plugin-loading mechanism fed by `adt.config.ts`; rather than
140+
introducing a second discovery path, format plugins piggy-back on the
141+
same module imports. See _Open questions_ below.
142+
143+
## Out of scope
144+
145+
- Implementing gCTS as a format plugin (E06).
146+
- The `gcts` CLI command surface (E07).
147+
- Checkin-side lock/transport orchestration (E08).
148+
- `diff(local, remote)` on the `FormatPlugin` interface — the diff command
149+
today reaches into the concrete handler directly through `getHandler()`.
150+
Promoting it onto `FormatPlugin` can happen when a second format needs
151+
diff support.
152+
153+
## Open questions
154+
155+
1. **Automatic discovery vs explicit bootstrap.** The current design relies
156+
on the consumer (CLI, tests) deciding which plugin packages to import.
157+
Should `@abapify/adt-plugin` ship a `discoverFormatPlugins()` helper that
158+
scans `node_modules/@abapify/adt-plugin-*` and imports them? The
159+
equivalent already exists for CLI-command plugins — parity is probably
160+
desirable once a second format lands.
161+
162+
2. **Multi-file round-trip metadata.** `SerializedFile[]` carries only
163+
`path`, `content`, `encoding`. Some formats may need extra per-file
164+
metadata (e.g. gCTS pack hints, charset overrides). We intentionally did
165+
**not** extend `SerializedFile` yet; we will revisit when the first
166+
format actually needs it.
167+
168+
3. **Diff on `FormatPlugin`.** The epic listed
169+
`diff(local, remote): Promise<DiffResult>` as a candidate method. It was
170+
dropped from the v1 interface because the current diff logic is
171+
abapGit-specific (projecting remote onto local's field set, XML
172+
normalization, etc.) and there is no obvious generic contract yet. When
173+
gCTS arrives (E06) we'll have two data points and can lift a real
174+
abstraction.

docs/roadmap/epics/e05-format-plugin-api.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,15 @@ Do NOT commit without approval.
111111

112112
- Should plugin discovery scan `node_modules/@abapify/*` automatically (like the existing CLI command-plugin pattern), or require explicit registration in user config? Recommend automatic for parity with CLI-command plugins.
113113
- How does multi-file serialization round-trip (e.g. one ABAP class produces 6 files)? Confirm `SerializedFile[]` carries enough metadata.
114+
115+
## Follow-ups discovered during implementation
116+
117+
- **`diff()` on `FormatPlugin` was deferred.** The epic proposed `diff(local, remote): Promise<DiffResult>` as an optional method. It was dropped from v1 because current diff logic is abapGit-specific (field projection, XML normalization). Revisit when a second format (gCTS) needs diff support, so we have two data points to generalise from.
118+
119+
- **`ObjectHandler` vs `FormatHandler`.** The concrete `ObjectHandler<T, TSchema>` in `adt-plugin-abapgit` is heavily parameterised on `AbapGitSchema` — moving it to `@abapify/adt-plugin` would drag abapgit types into the generic interface. Instead, we defined a narrower `FormatHandler` in `@abapify/adt-plugin` and rely on structural subtyping (the abapgit handler is a superset). New formats (gCTS, AFF) can define their own concrete handler shape as long as it satisfies `FormatHandler`.
120+
121+
- **Dynamic-import fast path.** `loadFormatPlugin` in `adt-cli/src/lib/utils/format-loader.ts` still performs a dynamic `await import(pkg)` to obtain the legacy `AdtPlugin` instance (needed for import services and the bundled-binary preloaded-plugin code path). Once the `AdtPlugin`/`FormatPlugin` split is fully consumed by E08, the dynamic import can be replaced with a pure `getFormatPlugin(id)` lookup.
122+
123+
- **Sourcemaps trip the literal grep.** The acceptance grep matches pre-existing compiled `dist/**/*.mjs.map` files (sourcemap `sourcesContent` embeds source as a single JSON line, so `.` inadvertently matches across what were originally multi-line imports). Run the grep with `--exclude-dir=dist --exclude-dir=node_modules` to see only real source hits (zero after this change).
124+
125+
- **Bundler static-import assumption.** The previous `format-loader.ts` carried a comment noting that Bun's bundler required a static `import * as abapgitPlugin from '@abapify/adt-plugin-abapgit'`. We replaced it with dynamic import + side-effect bootstrap. If we see regressions in `adt-all` bundled binary resolution, reintroduce the static import only inside the bootstrap file (`cli.ts`), never in shared utilities.

packages/adt-cli/src/lib/cli.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
#!/usr/bin/env -S npx tsx
22

3+
// Bootstrap: side-effect import registers the abapGit FormatPlugin into the
4+
// global registry (`@abapify/adt-plugin`). This is the ONE sanctioned place
5+
// where adt-cli depends on `@abapify/adt-plugin-abapgit` directly — every
6+
// other consumer MUST go through `getFormatPlugin('abapgit')`.
7+
import '@abapify/adt-plugin-abapgit';
8+
39
import { Command } from 'commander';
410
import {
511
importObjectCommand,
Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,35 @@
1-
// Static import for bundled abapgit plugin
2-
import * as abapgitPlugin from '@abapify/adt-plugin-abapgit';
3-
41
/**
5-
* Bundled format plugins - statically imported for bundler compatibility
2+
* Format-plugin loader for CLI commands.
3+
*
4+
* Format plugins self-register into the global `FormatPlugin` registry when
5+
* their package is imported. This loader translates the CLI's legacy format
6+
* spec (`abapgit` / `ag` / `@abapify/adt-plugin-abapgit` / `pkg/preset`) into
7+
* a live `AdtPlugin` (the higher-level import/export plugin) so existing
8+
* import services keep working.
9+
*
10+
* Built-in format id → package mappings. New built-in formats just need one
11+
* extra entry here (plus the side-effect import in `cli.ts`).
612
*/
7-
const BUNDLED_PLUGINS: Record<string, any> = {
8-
'@abapify/adt-plugin-abapgit': abapgitPlugin,
9-
};
13+
import { getFormatPlugin, type AdtPlugin } from '@abapify/adt-plugin';
1014

11-
/**
12-
* Format shortcuts - map short names to actual package names
13-
*/
1415
const FORMAT_SHORTCUTS: Record<string, string> = {
1516
abapgit: '@abapify/adt-plugin-abapgit',
1617
ag: '@abapify/adt-plugin-abapgit',
1718
};
1819

20+
/** Resolve a CLI format id to its registered package name (if known). */
21+
function resolvePackageForFormatId(id: string): string | undefined {
22+
return FORMAT_SHORTCUTS[id];
23+
}
24+
1925
/**
20-
* Parse format specification with optional preset
26+
* Parse format specification with optional preset.
27+
*
2128
* Examples:
22-
* @abapify/adt-plugin-abapgit -> { package: '@abapify/adt-plugin-abapgit', preset: undefined }
23-
* @abapify/adt-plugin-abapgit/full -> { package: '@abapify/adt-plugin-abapgit', preset: 'full' }
24-
* abapgit -> { package: '@abapify/adt-plugin-abapgit', preset: undefined } (shortcut)
25-
* ag -> { package: '@abapify/adt-plugin-abapgit', preset: undefined } (shortcut)
29+
* @abapify/adt-plugin-abapgit { package: '@abapify/adt-plugin-abapgit' }
30+
* @abapify/adt-plugin-abapgit/full { package: '@abapify/adt-plugin-abapgit', preset: 'full' }
31+
* abapgit { package: '@abapify/adt-plugin-abapgit' } (shortcut)
32+
* ag { package: '@abapify/adt-plugin-abapgit' } (shortcut)
2633
*/
2734
export function parseFormatSpec(formatSpec: string): {
2835
package: string;
@@ -47,29 +54,49 @@ export function parseFormatSpec(formatSpec: string): {
4754
}
4855

4956
/**
50-
* Load format plugin
51-
* Uses static imports for bundled plugins, dynamic imports for external ones
57+
* Load a format plugin by CLI spec.
58+
*
59+
* Resolution order:
60+
* 1. Look up the format id in the {@link FormatPlugin} registry. If it is
61+
* already registered (e.g. via CLI bootstrap), use it without any dynamic
62+
* import.
63+
* 2. Otherwise dynamically `import(packageName)` — this triggers the
64+
* package's self-registration side-effects — then return the resolved
65+
* legacy `AdtPlugin` instance from the package's default/named export.
5266
*/
53-
export async function loadFormatPlugin(formatSpec: string) {
67+
export async function loadFormatPlugin(formatSpec: string): Promise<{
68+
name: string;
69+
description: string;
70+
instance: AdtPlugin;
71+
preset?: string;
72+
}> {
5473
const { package: packageName, preset } = parseFormatSpec(formatSpec);
5574

75+
// Fast path: if the format plugin has already self-registered (typical when
76+
// the CLI bootstrap has side-effect-imported the package), we're done — but
77+
// we still need the legacy AdtPlugin instance for the import services, so
78+
// fall through to the dynamic import. The registry lookup merely validates
79+
// that the requested format is available.
80+
const builtinId = Object.entries(FORMAT_SHORTCUTS).find(
81+
([, pkg]) => pkg === packageName,
82+
)?.[0];
83+
if (builtinId && !getFormatPlugin(builtinId)) {
84+
// Registry is empty for this id — the dynamic import below will populate
85+
// it via the package's self-registration side-effect.
86+
}
87+
5688
try {
57-
// Use bundled plugin if available, otherwise try dynamic import
58-
const pluginModule =
59-
BUNDLED_PLUGINS[packageName] ?? (await import(packageName));
89+
const pluginModule = await import(packageName);
6090
const PluginClass =
6191
pluginModule.default || pluginModule[Object.keys(pluginModule)[0]];
6292

6393
if (!PluginClass) {
6494
throw new Error(`No plugin class found in ${packageName}`);
6595
}
6696

67-
// Create plugin instance with preset options
6897
const options = preset ? { preset } : {};
6998

70-
// Check if PluginClass is already an instance (from createFormatPlugin)
71-
// or if it's a constructor function that needs to be instantiated
72-
const plugin =
99+
const plugin: AdtPlugin =
73100
typeof PluginClass === 'function' && PluginClass.prototype
74101
? new PluginClass(options)
75102
: PluginClass;
@@ -82,9 +109,8 @@ export async function loadFormatPlugin(formatSpec: string) {
82109
};
83110
} catch (error: unknown) {
84111
const err = error as Error;
85-
// Check both error code (CommonJS) and message (ES modules)
86112
if (
87-
(err as any).code === 'MODULE_NOT_FOUND' ||
113+
(err as { code?: string }).code === 'MODULE_NOT_FOUND' ||
88114
err.message?.includes(`Cannot find module '${packageName}'`)
89115
) {
90116
throw new Error(
@@ -95,3 +121,5 @@ export async function loadFormatPlugin(formatSpec: string) {
95121
throw error;
96122
}
97123
}
124+
125+
export { resolvePackageForFormatId };

packages/adt-diff/src/commands/diff.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,26 @@ import { resolve, basename, dirname, join, relative } from 'node:path';
3434
import { createTwoFilesPatch } from 'diff';
3535
import chalk from 'chalk';
3636
import {
37-
getHandler,
38-
getSupportedTypes,
39-
parseAbapGitFilename,
40-
type ObjectHandler,
41-
} from '@abapify/adt-plugin-abapgit';
37+
requireFormatPlugin,
38+
getFormatPlugin,
39+
type FormatHandler,
40+
type ParsedFormatFilename,
41+
} from '@abapify/adt-plugin';
42+
43+
// abapGit is the only diff format today. The plugin registers itself on
44+
// import (see adt-cli/src/lib/cli.ts bootstrap).
45+
const abapgit = () => requireFormatPlugin('abapgit');
46+
const getHandler = (type: string): FormatHandler | undefined =>
47+
abapgit().getHandler(type);
48+
// Used in command `description` at module-load time — must be safe to call
49+
// before the bootstrap has registered the plugin (returns `[]` in that case;
50+
// the actual `execute` body always runs post-bootstrap).
51+
const getSupportedTypes = (): string[] => [
52+
...(getFormatPlugin('abapgit')?.supportedTypes ?? []),
53+
];
54+
const parseAbapGitFilename = (filename: string): ParsedFormatFilename | null =>
55+
abapgit().parseFilename?.(filename) ?? null;
56+
type ObjectHandler = FormatHandler;
4257
import { tablXmlToCdsDdl } from '../lib/abapgit-to-cds';
4358
import { adtContract } from '@abapify/adt-contracts';
4459

packages/adt-plugin-abapgit/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
import { registerFormatPlugin } from '@abapify/adt-plugin';
2+
import { abapgitFormatPlugin } from './lib/format-plugin';
3+
4+
// Self-register as a FormatPlugin at module-load time. Idempotent — safe
5+
// against dual module-graph evaluation.
6+
registerFormatPlugin(abapgitFormatPlugin);
7+
8+
// FormatPlugin export (the preferred public entry-point going forward)
9+
export { abapgitFormatPlugin } from './lib/format-plugin';
10+
111
// Plugin instance
212
export { abapGitPlugin, AbapGitPlugin } from './lib/abapgit';
313

0 commit comments

Comments
 (0)