camunda / camunda/api-test-generator

Design: collapse the per-emitter build-script explosion behind the emitter registry

Open
#356 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
0
Forks
3
Avg merge
13h 41m
Merged PRs (30d)
23

Description

Design: collapse the per-emitter build-script explosion behind the emitter registry

Problem

The EmitterStrategy registry encapsulates the transform (scaffold/emit,
resolved via getEmitter/listEmitters). But the lifecycle around each
transform — fetch op-map → codegen → validate output → CI toolchain — is
hand-replicated in package.json scripts and test/CI config, once per target.
Adding an emitter therefore is not "register a strategy"; it's "register a
strategy and edit a half-dozen unrelated files":

Lifecycle phase Per-emitter surface today Where it leaks
fetch op-map fetch-js-sdk-map, fetch-python-sdk-map, … pipeline chain hardcodes each (package.json:45)
codegen codegen:js-sdk:all, codegen:python-sdk:all, codegen:csharp-sdk:all testsuite:generate chain hardcodes each (package.json:40)
validate output hardcoded SUITES[] array tests/regression/generated-suites-typecheck.test.ts (SDK suites not even listed)
CI toolchain per-language steps .github/workflows/ci.yml grows per target

The framework advertises emitters as pluggable; the build system contradicts it.
This is the same drift vector AGENTS.md already calls out (the #286/#288
"parallel implementations silently diverge" rule): every hand-copied
codegen:<lang> / fetch-<lang>-map line is a place where one target quietly
gains or loses a step relative to its siblings (exactly what already happened —
the SDK suites are wired into testsuite:generate but have no validation
gate, so a non-compiling suite passes CI silently; see #354/#355).

What is and isn't leaking

The leak is operational/lifecycle metadata, not the pure transform. The
transform is correctly encapsulated and correctly pure (EmitterStrategy.emit
is contractually "no filesystem, no network, no global state",
emitter-sdk/src/types.ts:146-148). The fix must preserve that purity:
emitters should declare their lifecycle as data; the (already-impure)
orchestrator/test layer runs it. We do not want to run tsc/dotnet
inside emit().

There is already a precedent for declarative emitter wiring in this repo:
per-config configs/<name>/codegen/emitters.json (loadEnabledEmitters,
materializer/src/index.ts) lets a config declare which emitters it authorises.
This proposal extends the same "declare, don't hand-wire" philosophy to the
build lifecycle.

Proposed design

1. Declarative lifecycle metadata on EmitterStrategy
interface EmitterStrategy {
  // ... existing id/name/supportedConfigs/scaffold/emit ...

  /** Upstream op-map source. Replaces the per-target fetch-<lang>-map scripts. */
  readonly sdkMap?: {
    repo: string;        // e.g. 'camunda/orchestration-cluster-api-js'
    path: string;        // e.g. 'examples/operation-map.json'
    refEnv?: string;     // e.g. 'JS_SDK_REF' (defaults documented)
    out: string;         // e.g. 'spec/js-sdk/operation-map.json'
  };

  /** How to typecheck/compile the emitted suite. Drives the validation gate. */
  readonly validate?:
    | { kind: 'tsc'; projectRelPath: string }     // tsconfig.json under outDir
    | { kind: 'dotnet'; projectRelPath: string }  // .csproj / .sln
    | { kind: 'pyright'; configRelPath?: string }
    | { kind: 'command'; argv: string[] }         // escape hatch
    | { kind: 'none' };                           // explicit opt-out
}

Emitters declare these; nothing in the strategy executes them.

2. One generic driver, registry-iterated
  • codegen: add --all-targets to materializer/src/index.ts, iterating
    listEmitters() (intersected with the config's emitters.json). The three
    codegen:*-sdk:all scripts collapse into one. testsuite:generate stops
    hardcoding a per-target chain.
  • fetch-map: a single fetch-maps step iterates registered sdkMap
    declarations (the per-language fetch-*-map scripts become one generic
    fetcher parameterised by {repo, path, refEnv, out}fetch-js-sdk-map.js
    is already exactly this shape, just hardcoded).
  • validate: generated-suites-typecheck.test.ts iterates the registry's
    validate descriptors instead of a hardcoded SUITES[]. A new emitter needs
    zero test edits to get a compile gate.
3. Cross-process seam: list-targets --json

npm/CI cannot read an in-process JS registry. Add a
tsx materializer/src/index.ts list-targets --json command that prints
[{ id, name, sdkMap, validate }]. CI matrices and any shell glue consume
that projection, keeping the registry the single source of truth and the build
system a thin reader. (This also lets CI gate toolchain availability —
e.g. only run the dotnet job when some target declares validate.kind === 'dotnet'.)

Non-goals

  • Do not move compilation into emit(); the purity contract stays.
  • Do not build a plugin-discovery/auto-import mechanism in this iteration;
    emitters are still registerEmitter'd explicitly in index.ts. The win is
    eliminating the script/test/CI duplication, not the one-line registration.

Tradeoffs / open questions

  • Escape hatch. Genuinely bespoke targets use validate: { kind: 'command', argv }
    or { kind: 'none' } with a justification. Avoid over-abstracting rare cases.
  • CI toolchain availability. .NET/pyright must be installed in CI when a
    target declares them; driven by the list-targets projection rather than
    hand-wired steps.
  • Contract change is ask-first. This touches the experimental
    EmitterStrategy contract (AGENTS.md "Ask first: adding/changing an emitter
    target"). Land the contract addition as its own reviewed change.
  • SDK-map determinism. fetch-js-sdk-map.js already notes a future pin file
    "analogous to spec-pin.json". A declared sdkMap is the natural home for a
    pinned ref/hash; out of scope here but worth keeping the shape pin-friendly.

Suggested sequencing (lowest-risk first)

  1. Collapse codegen into a registry-iterated --all-targets run. Pure
    refactor of existing scripts, no contract change — immediate win.
  2. Add list-targets --json + move op-map fetch onto a declared sdkMap
    consumed by one generic fetch step.
  3. Make the typecheck gate registry-driven via validate descriptors. Lands
    naturally with #354 (Python) and #355 (JS), where the SDK suites first become
    compilable, plus a dotnet gate for the already-complete C# suite.

Relationship to other issues

  • #354 / #355 — the SDK suites' missing compile gate is a symptom of phase 3
    above; the registry-driven validate descriptor is where their typecheck
    gates should land.
  • #286 / #288 — this is the same parallel-implementation-drift failure mode,
    applied to the build lifecycle rather than to a pipeline stage.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with package.json, materializer/src/index.ts, tests/regression/generated-suites-typecheck.test.ts, and .github/workflows/ci.yml; read AGENTS.md before changing the EmitterStrategy contract. Trace the existing per-target codegen, fetch, validation, and CI paths, then confirm the registry-driven lifecycle and list-targets projection cover the declared targets without moving work into emit().

Written by the indexing model from the issue text.

Assessment

Tech stack
github-actions, node.js, typescript
Domain
build-system, ci-cd, tooling
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.