scripts — Codegen & Tooling

TypeScript-driven code generation from a shared JSON Schema produces the typed RPC surface in every SDK.

The scripts/ directory is where one JSON Schema becomes four language-native SDKs. A chain of TypeScript files reads api.schema.json (sourced from nodejs/node_modules/@github/copilot/schemas/), normalises it, then emits per-language method stubs, type declarations, and polymorphic converters. This is the mechanism that keeps the four SDKs in sync — adding an RPC method means updating the schema and re-running codegen, not editing four files by hand.

Schema-first TypeScript codegen Committed output

4
Emitters
1
Schema
TS
Language
committed
Outputs

📚 What is this & why does it exist?

A set of TypeScript scripts that read api.schema.json (the authoritative description of every RPC method, parameter, and event) and generate code for each SDK: GeneratedRpc in Node, _generated_rpc.py in Python, generated_rpc.go in Go, and Generated/Rpc.cs + Generated/SessionEvents.cs in .NET. The generators share a common AST representation (utils.ts, postProcess.ts) and a corrections layer for schema quirks.

💡 Real-world analogy

Think of api.schema.json as the architectural blueprint for a skyscraper, and the four emitters as specialist contractors: one frames in steel (Go), one pours concrete (C#), one fits interiors (TypeScript), one runs plumbing (Python). They all read the same blueprint, but each knows the idioms of their trade: where to add a nullable flag, how to spell a method name (camelCase vs snake_case vs PascalCase), whether to emit a tagged union or a polymorphic converter. The corrections dir is the clipboard where the architect clarified "on this corner, ignore the blueprint — use this instead."

🎯 Why This Module Exists

Without codegen, every protocol addition means touching four language repos, writing four type declarations, and hoping nobody typoed a field name. With codegen, a schema edit + one command updates all four SDKs atomically. The decision to commit the generated output rather than generate at build time is deliberate: PR reviewers can see the exact diff (does this method signature break users?); CI doesn't need Node.js to build the Go SDK; debuggers step into real code, not trampolines. The cost — occasional merge conflicts in generated files — is small compared to the reviewability win.

🏗️ Design Decisions

DecisionChoice MadeAlternativesWhy
Generator languageTypeScriptPython, Go, JavaNode is required for the SDK anyway; TS gives strong typing over the schema structure
Schema sourceJSON Schema co-located with Node types under @github/copilot/schemas/OpenAPI; Protocol BuffersJSON Schema draft-07 is compact, already in use by TS type gen upstream
Output locationCommitted into each SDK's source treeGenerate-at-build; external packageReviewable diffs, no build-time Node dep for non-TS SDKs
Corrections mechanismPatch-style objects in corrections/Edit generated output; fork the schemaKeeps schema canonical; corrections are auditable & narrowly scoped
Dispatch style per languagePer-language idiomatic: TS unions, Py dataclasses, Go type tags, C# polymorphic convertersSame shape in all fourEach SDK should feel native; force-fitting one pattern breaks ergonomics
Number-vs-integer handlingPre-process to preserve integer constraintsTreat all numbers the sameGo, C# distinguish int64/double; dropping it causes wrong wire encoding
Nullable required handlingfixNullableRequiredRefsInApiSchema pre-passManual fixes in each emitterRaw schema's "required but nullable" is ambiguous across languages; normalize once up front

Codegen Pipeline

flowchart LR
  SCHEMA[api.schema.json
authoritative] PRE[Pre-processing
postProcessSchema
fixNullableRequiredRefs] CORR[corrections/
patch overrides] AST[Shared AST
utils.ts] subgraph emitters["Per-language emitters"] TS[typescript.ts
→ GeneratedRpc] PY[python.ts
→ _generated_rpc.py] GO[go.ts
→ generated_rpc.go] CS[csharp.ts
→ Generated/Rpc.cs] end SCHEMA --> PRE CORR --> PRE PRE --> AST AST --> TS AST --> PY AST --> GO AST --> CS TS -->|committed| NODE[nodejs/ sources] PY -->|committed| PYTHON[python/ sources] GO -->|committed| GOLANG[go/ sources] CS -->|committed| DOTNET[dotnet/ sources]

🚶 Codegen Walkthrough

Follow npm run codegen end-to-end:

  1. Entry — scripts/codegen/index.ts reads api.schema.json via a path relative to nodejs/node_modules/@github/copilot/schemas/.
  2. Parse schema — JSON.parse into a typed tree (the TS script has its own types describing the schema shape).
  3. Apply corrections — merge every object under corrections/*.json / .ts into the schema tree (override definitions, add missing descriptions, delete deprecated methods).
  4. postProcessSchema — normalise: resolve $refs, mark integers vs numbers, flatten single-variant unions, set explicit nullable flags.
  5. fixNullableRequiredRefsInApiSchema — for each required property that is also nullable, ensure the language emitter knows to emit Field? / Optional[T] / *T appropriately.
  6. Build shared AST — utils.ts helpers turn the schema tree into a method/type/event tree with language-agnostic names.
  7. Emit — each of typescript.ts, python.ts, go.ts, csharp.ts walks the AST and prints source using a StringBuilder-like pattern. No templating engine — raw string concatenation with per-language quote/escape helpers.
  8. Format — each emitter pipes output through its language's formatter where available (prettier, gofmt, dotnet format, ruff).
  9. Write — overwrite the target files in the four SDKs.
  10. Verify — CI runs npm run codegen -- --check, which regenerates into .tmp and diffs against committed files. Any drift fails the build.

Key Components

FileRoleWhy it matters
scripts/codegen/index.tsTop-level orchestratorReads the schema, dispatches to each emitter.
scripts/codegen/utils.tsSchema walkers, name conversion (camel/snake/pascal), quote helpersShared by all emitters; the "AST" layer.
scripts/codegen/postProcess.tspostProcessSchema, fixNullableRequiredRefsInApiSchemaNormalisation pass; ensures per-language emitters see a consistent tree.
scripts/codegen/typescript.tsTS emitter — interfaces + GeneratedRpc client classTrivial for the host language; acts as the reference output.
scripts/codegen/python.tsPython emitter — dataclasses + type unionsTargets Python 3.11+; emits from __future__ import annotations style.
scripts/codegen/go.tsGo emitter — structs, methods, type tags; skips event union (hand-written)Handles camelCase→PascalCase, json:"..." tags, zero-value semantics.
scripts/codegen/csharp.tsC# emitter — records, polymorphic converter class, nullable refsEmits [JsonConverter(...)], [JsonPropertyName(...)], STJ polymorphic discriminators.
scripts/codegen/corrections/Per-method/type overridesWhere "the schema is not quite right for language X" lives.
scripts/check-api-surface.tsAPI-diff guardVerifies Node public API matches a manifest; catches accidental breaking changes.
scripts/validate-sdk-cli-versions.tsVersion coherence checkEnsures each SDK advertises the same protocol version as the schema.

⚡ Algorithm Spotlight

postProcessSchema

A recursive walker that (a) inlines $refs under a size threshold, (b) tags numeric types with {kind: "integer" | "number"} from JSON-Schema's type + format + multipleOf, (c) flattens single-variant oneOf into the parent, (d) canonicalises nullable (explicit type: [T, null] becomes {nullable: true}). Visitor pattern; ~200 lines.

fixNullableRequiredRefsInApiSchema

Specific fix for a recurring schema pattern where a property is listed in required but its type is [T, null]. In TypeScript this is T | null (still required), in Go it is *T, in Python it is Optional[T], in C# it is T?. Without the correction, Go would emit T (no pointer, no nil representation) and silently lose null-carrying information.

Per-language emitter strategy

Each emitter: (1) pre-computes name mappings (camelCase→target convention), (2) emits top-of-file headers, (3) walks types in topological order (so referenced types appear before referencing ones), (4) emits methods, (5) emits events (or skips them if the SDK hand-writes them, as Go does for result_union.go).

⚠ Edge Cases & Pitfalls

ScenarioBehaviourNotes
Schema adds a new event typeAll four emitters pick it up; CI diff fails until commitEnsures no SDK silently drops events
Schema changes a field's typeCodegen updates; compilation may fail downstreamIntentional — wire-breaking change should break compile
Corrections file is staleOverride applied but schema no longer has that pathA warning is logged; CI shouldn't fail (stale corrections are a common intermediate state)
Reserved-word collisionEach emitter keeps a reserved-word list and appends _e.g. Python class → class_
Regeneration driftnpm run codegen -- --check in CI catches itDeveloper must rerun codegen before committing

📊 Performance

Codegen is a one-shot tool — runs in ~1.5s locally for all four SDKs. Not a hot path. Optimisation went into reviewability (deterministic output, stable ordering) not throughput.

Deep-Dive Highlights

Codegen Pipeline Focus

End-to-end: schema → AST → per-language emitter → formatter → committed file.

Protocol Negotiation Focus

How the SDKs advertise their generated protocol version and reject older CLIs.

Configuration Reference

KnobWherePurpose
--checkCLI flagRegenerate into a temp dir and fail on drift
--languageCLI flagRegenerate a single language only
SCHEMA_PATHenvOverride schema source path
OUT_DIRenvWrite to alternate location (used by CI diff)
Reserved-words listper-emitter constantName-collision safe words
Name-case mappingutils.tsControls camel/snake/pascal conversion
Formatter toggleper-emitterSkip formatter if tool missing (local dev)
Emit events?Go emitterSkip (Go uses hand-written result_union.go)
Emit polymorphic converter?C# emitterYes — essential for STJ
Emit dataclass slots?Python emitterYes (3.10+)

Code Examples

utils.ts — name conversion

export function toPascalCase(s: string): string {
    return s.replace(/(^|[_-])([a-z])/g, (_, _sep, c) => c.toUpperCase());
}
export function toSnakeCase(s: string): string {
    return s.replace(/[A-Z]/g, c => "_" + c.toLowerCase()).replace(/^_/, "");
}

postProcessSchema structure

export function postProcessSchema(schema: JsonSchema): JsonSchema {
    schema = inlineSmallRefs(schema);
    schema = tagIntegers(schema);
    schema = canonicaliseNullable(schema);
    schema = flattenSingleUnions(schema);
    return schema;
}

csharp.ts — record emission (illustrative)

function emitRecord(t: TypeNode, out: Emitter): void {
    out.line(`public sealed record ${pascal(t.name)}(`);
    out.indent(() => {
        t.fields.forEach((f, i) => {
            const comma = i < t.fields.length - 1 ? "," : "";
            out.line(`${csType(f.type)} ${pascal(f.name)}${comma}`);
        });
    });
    out.line(`);`);
}

Directory Map

scripts/ source tree
scripts/
├── codegen/
│   ├── index.ts                     ← Orchestrator entry
│   ├── utils.ts                     ← Shared helpers + AST walkers
│   ├── postProcess.ts               ← Schema normalisation pass
│   ├── typescript.ts                ← TS emitter
│   ├── python.ts                    ← Python emitter
│   ├── go.ts                        ← Go emitter
│   ├── csharp.ts                    ← C# emitter
│   └── corrections/                 ← Per-method/type patches
├── check-api-surface.ts             ← Node API-diff guard
├── validate-sdk-cli-versions.ts     ← Version coherence check
├── validate-samples.sh              ← Build-check every sample
└── package.json                     ← devDeps: tsx, prettier, etc.