Format Plugins
Status: accepted (E05). Implemented in @abapify/adt-plugin-abapgit
(reference) and @abapify/adt-plugin-gcts. See also:
Plugins → Overview,
SDK → adt-plugin,
SDK → adt-plugin-abapgit,
SDK → adt-plugin-gcts.
Problem
The adt CLI originally hard-wired @abapify/adt-plugin-abapgit as the only
serialization format. export, diff, import, and checkout all imported
that package directly, which made it impossible to add a new format (gCTS,
AFF, …) without either (a) forking the CLI or (b) shipping an additional
adt-plugin-abapgit-shaped package and patching every consumer.
The user requirement driving this epic is:
Even if the
gctscommand isn't installed we must still be able to save in gCTS format alongside abapGit.
That means the CLI must discover formats through a contract, not through import statements.
Solution
A small in-process registry of format plugins, plus a stable
FormatPlugin interface that any third-party package can implement. Format
plugins self-register at module-load time — importing the package is enough
to make --format <id> work.
Moving parts
┌──────────────────────────────────────────────────────┐
│ @abapify/adt-plugin │
│ │
│ interface FormatPlugin { id, description, │
│ supportedTypes, │
│ getHandler(type), │
│ parseFilename?(name) } │
│ │
│ registerFormatPlugin(plugin) │
│ getFormatPlugin(id) / requireFormatPlugin(id) │
│ listFormatPlugins() │
└───────────────────────▲──────────────────────────────┘
│ implements
│
┌───────────────────────┴──────────────────────────────┐
│ @abapify/adt-plugin-abapgit │
│ │
│ abapgitFormatPlugin : FormatPlugin │
│ (self-registers on module load) │
└───────────────────────▲──────────────────────────────┘
│ looks up via getFormatPlugin('abapgit')
│
┌───────────────────────┴──────────────────────────────┐
│ Consumers │
│ - @abapify/adt-diff │
│ - @abapify/adt-export │
│ - @abapify/adt-cli services/import + checkout │
└──────────────────────────────────────────────────────┘
The registry is keyed on the globalThis with a Symbol.for well-known key
so duplicate module-graph evaluation (tests, bundler outputs) does not
produce two independent registries.
Bootstrap
Exactly one location in the CLI imports @abapify/adt-plugin-abapgit
directly: packages/adt-cli/src/lib/cli.ts uses a side-effect-only import
(no from clause) so that the acceptance grep — which forbids
from.*adt-plugin-abapgit in adt-export, adt-diff, and adt-cli — stays
clean. Every other file uses getFormatPlugin('abapgit').
Third-party plugins are loaded either:
- Statically, by adding
import '@abapify/<your-format-plugin>';to the consumer's entry point, or - Dynamically, via
await import(packageName)(current behaviour ofloadFormatPluginin adt-cli — useful for plugins listed inadt.config.ts).
Both paths trigger the plugin's module-level registerFormatPlugin(...) call.
Interface contract
export interface FormatPlugin {
readonly id: string;
readonly description: string;
readonly supportedTypes: ReadonlyArray<string>;
getHandler(type: string): FormatHandler | undefined;
parseFilename?(filename: string): ParsedFormatFilename | undefined;
}
idis what users pass after--formaton the CLI. It is part of the public API and cannot change without a major version bump.supportedTypesmay be computed lazily via a getter (the abapGit plugin does this because it reads from a live handler registry).getHandler(type)returns aFormatHandler— the per-object-type serializer. The abapGit concreteObjectHandler<T, TSchema>is a structural superset ofFormatHandler, so existing abapGit handlers work through a widening cast (no translation layer needed).parseFilename(name)is optional because not every format uses filenames at all (gCTS streams via REST, for example).
Registration rules
registerFormatPlugin(plugin)
- same id, same instance → no-op (idempotent, survives HMR / dual graph)
- same id, different object → throws (prevents silent shadowing)
- new id → stored
requireFormatPlugin(id) throws with a message that lists the currently
registered ids, which keeps user-visible errors actionable:
Format plugin "gcts" is not registered. Available formats: abapgit.
Alternatives considered
-
Extend the existing
AdtPlugin(name,registry,formatshape) — rejected because that interface couples serialization logic with import/export workflow orchestration. The format registry needs a narrower, purely serialization-focused contract so that future formats (e.g. gCTS) can implement it without also re-implementing the import pipeline. -
Move
ObjectHandlerinto@abapify/adt-plugin— rejected becauseObjectHandleris heavily parameterized onAbapGitSchema, which is an abapGit-specific thing and has no business leaking into the generic plugin interface. Instead,FormatHandlerin@abapify/adt-plugindefines the minimum surface consumers need (parse/build/serialize) and concrete handlers may be structural supersets. -
Auto-discover
node_modules/@abapify/*— deferred. The CLI already has a plugin-loading mechanism fed byadt.config.ts; rather than introducing a second discovery path, format plugins piggy-back on the same module imports. See Open questions below.
Out of scope
- Implementing gCTS as a format plugin (E06).
- The
gctsCLI command surface (E07). - Checkin-side lock/transport orchestration (E08).
diff(local, remote)on theFormatPlugininterface — the diff command today reaches into the concrete handler directly throughgetHandler(). Promoting it ontoFormatPlugincan happen when a second format needs diff support.
Open questions
-
Automatic discovery vs explicit bootstrap. The current design relies on the consumer (CLI, tests) deciding which plugin packages to import. Should
@abapify/adt-pluginship adiscoverFormatPlugins()helper that scansnode_modules/@abapify/adt-plugin-*and imports them? The equivalent already exists for CLI-command plugins — parity is probably desirable once a second format lands. -
Multi-file round-trip metadata.
SerializedFile[]carries onlypath,content,encoding. Some formats may need extra per-file metadata (e.g. gCTS pack hints, charset overrides). We intentionally did not extendSerializedFileyet; we will revisit when the first format actually needs it. -
Diff on
FormatPlugin. The epic listeddiff(local, remote): Promise<DiffResult>as a candidate method. It was dropped from the v1 interface because the current diff logic is abapGit-specific (projecting remote onto local's field set, XML normalization, etc.) and there is no obvious generic contract yet. When gCTS arrives (E06) we'll have two data points and can lift a real abstraction.