|
| 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. |
0 commit comments