Skip to content

Commit 3af767f

Browse files
l0lawrenceCopilot
andauthored
Add language-agnostic emitter-diff tool (#11122)
## What `eng/emitter-diff` **diffs the generated code produced by two versions of a TypeSpec emitter**. It resolves a *baseline* and a *head* source tree (each a local folder or a GitHub url/sha), runs the emitter's own regenerate command in each, and renders the difference between the generated output as a clickable **HTML report** (default) plus a terminal summary. For go/rust/languages in azure/typespec-azure you would add the language to the registry and update your package.json to reference this in the core submodule (or you can directly call the cli script (no registry needed) with the correct flags set in your package.json) ## How it works - **Refs** (`--baseline` / `--head`): `local:/path`, `github:owner/repo@<sha|branch>`, or `gh:<sha|branch>` (this repo). Head defaults to the current working tree; baseline defaults to `upstream/main` (or `origin/main`). - **Setup**: a tree the tool fetches fresh from GitHub is prepared with the preset's `--setup` (install + build). The current working tree and `local:` paths are assumed already built and never installed by the tool. A commit-keyed cached worktree is prepared once, so reruns skip re-setup. - **Baseline-output cache**: baseline generated output is cached per profile + baseline commit, so iterative local runs only regenerate head. Disabled in CI with `--ci`. On a cache hit the baseline tree is not even checked out — its identity is resolved with a cheap shallow fetch. - **Filtering a subset of tests**: everything after `--` is appended to the regenerate command verbatim on both sides, so you can forward the regenerate script's own filter flags (e.g. python's `--name authentication/api-key --flavor azure`) to diff only part of the suite. ## Flags `--emitter <name>` (preset: `python`, `typescript`/`ts`), `--command`, `--emitter-path`, `--generated-code-path`, `--setup` (repeatable) / `--no-setup`, `--baseline` / `--head`, `--work-dir`, `--sequential` (regenerate sides one at a time instead of in parallel), `--ci`, `--html`, `--fail-on-diff`, and `-- <args>` passthrough. See `eng/emitter-diff/README.md`. ## Changes - **`eng/emitter-diff/`** — the tool: CLI/orchestrator (`cli.ts`), ref resolver for local/github/`gh:` refs with a commit-keyed worktree cache (`resolver.ts`), emitter presets (`registry.ts`), a zero-dependency HTML/terminal diff renderer (`diff.ts`), the local baseline-output cache (`baseline-cache.ts`), shared types/utilities, `tsconfig.json`, and `README.md`. - **`packages/http-client-python/package.json`** — adds the `diff-spector-tests` script (`tsx ../../eng/emitter-diff/src/cli.ts --emitter python`). - **`.github/workflows/ci-emitter-diff-python.yml`** — the PR workflow (below). - **`cspell.yaml`**, **`.chronus/changes/…`** — a spelling allow-list entry and an `internal` changeset. ## CI behavior The `python / emitter diff` workflow runs on PRs that touch `http-client-python` or the tool. It diffs this PR's emitter against the **PR merge-base** (overridable via `workflow_dispatch`), uploads the rendered **HTML report** as an artifact, and posts/updates a **sticky PR comment** with the diff summary. It is **informational**: the job fails only on a tool/build error — **never on a diff**. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 3212132 commit 3af767f

17 files changed

Lines changed: 1911 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: internal
3+
packages:
4+
- "@typespec/http-client-python"
5+
---
6+
7+
Add a `diff-spector-tests` script wired to the new language-agnostic emitter-diff tool, plus its PR CI workflow

.github/actions/setup/action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ runs:
1111

1212
steps:
1313
- name: Install pnpm
14-
uses: pnpm/action-setup@v3
14+
uses: pnpm/action-setup@v6
1515

1616
- name: Set node version to ${{ inputs.node-version }}
17-
uses: actions/setup-node@v4
17+
uses: actions/setup-node@v5
1818
with:
1919
node-version: ${{ inputs.node-version }}
2020
cache: pnpm
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
name: "python / emitter diff"
2+
3+
# Diffs generated code between this PR's emitter and the merge-base baseline.
4+
# Informational only: reports a sticky PR comment + HTML artifact, and fails the
5+
# job only on a tool/build error (never on a diff). See eng/emitter-diff.
6+
#
7+
# The tool runs `npm run regenerate` verbatim in each tree. It auto-prepares the
8+
# baseline tree it fetches from GitHub (via the preset's --setup: install +
9+
# build + venv), so this workflow only builds the head (PR) emitter itself.
10+
11+
on:
12+
pull_request:
13+
branches: [main, release/*]
14+
paths:
15+
- "packages/http-client-python/**"
16+
- "eng/emitter-diff/**"
17+
- ".github/workflows/ci-emitter-diff-python.yml"
18+
workflow_dispatch:
19+
inputs:
20+
baseline:
21+
description: "Baseline emitter ref (local path or github ref). Defaults to the PR merge-base. A gh:/github: ref is fetched and auto-prepared (install + build) by the tool; a local: path is used as-is."
22+
required: false
23+
default: ""
24+
25+
permissions:
26+
contents: read
27+
pull-requests: write
28+
29+
concurrency:
30+
group: ${{ github.workflow }}-${{ github.ref }}
31+
cancel-in-progress: true
32+
33+
jobs:
34+
emitter-diff:
35+
name: "Generate & Diff"
36+
runs-on: ubuntu-latest
37+
# Fork PRs are intentionally skipped
38+
if: github.event.pull_request.head.repo.fork != true
39+
steps:
40+
- uses: actions/checkout@v6
41+
with:
42+
fetch-depth: 0
43+
- uses: ./.github/actions/setup
44+
- uses: actions/setup-python@v6
45+
with:
46+
python-version: "3.12"
47+
48+
- name: Install repo dependencies
49+
run: pnpm install
50+
51+
- name: Determine baseline
52+
id: baseline
53+
# Dispatch input is untrusted: pass via env, reference only as "$VAR".
54+
env:
55+
BASELINE_INPUT: ${{ github.event.inputs.baseline || '' }}
56+
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}
57+
run: |
58+
input="$BASELINE_INPUT"
59+
if [ -n "$input" ]; then
60+
echo "ref=$input" >> "$GITHUB_OUTPUT"
61+
echo "Baseline (explicit): $input"
62+
else
63+
# merge-base = a real commit on the base branch; survives squash/rebase.
64+
git fetch --no-tags origin "$BASE_REF"
65+
base_sha="$(git merge-base FETCH_HEAD HEAD)"
66+
[ -n "$base_sha" ] || { echo "::error::No merge-base with $BASE_REF."; exit 1; }
67+
# gh:<sha> = this repo at the merge-base. The tool fetches that tree
68+
# and auto-preps it (install + build + venv) via the preset's --setup.
69+
echo "ref=gh:$base_sha" >> "$GITHUB_OUTPUT"
70+
echo "Baseline (merge-base): gh:$base_sha"
71+
fi
72+
73+
- name: Prepare head emitter (build + venv)
74+
working-directory: packages/http-client-python
75+
# http-client-python is excluded from the pnpm workspace (standalone,
76+
# registry-versioned deps), so the repo-level `pnpm install` above does
77+
# NOT provision its node_modules. Install them first (scripts skipped),
78+
# then `npm run setup` (build + venv). Mirrors the baseline tree prep the
79+
# tool does via the python preset's --setup.
80+
run: |
81+
npm install --ignore-scripts
82+
npm run setup
83+
84+
- name: Run emitter diff
85+
id: diff
86+
working-directory: packages/http-client-python
87+
env:
88+
BASELINE_REF: ${{ steps.baseline.outputs.ref }}
89+
RUNNER_TEMP: ${{ runner.temp }}
90+
run: |
91+
set +e
92+
# No --fail-on-diff (informational); tool/build errors still exit non-zero
93+
# (checked in "Fail on tool error").
94+
npm run diff-spector-tests -- \
95+
--ci \
96+
--baseline "$BASELINE_REF" \
97+
--html "$RUNNER_TEMP/emitter-diff.html" \
98+
--md "$RUNNER_TEMP/emitter-diff.md" \
99+
| tee "$RUNNER_TEMP/emitter-diff.log"
100+
echo "status=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
101+
# Reuse the tool's own summary line (strip ANSI) instead of re-parsing.
102+
summary="$(sed -r 's/\x1b\[[0-9;]*m//g' "$RUNNER_TEMP/emitter-diff.log" \
103+
| grep -oE 'Diff summary: [0-9]+ file\(s\), \+[0-9]+ / -[0-9]+' | head -1)"
104+
echo "summary=${summary:-No changes to generated output.}" >> "$GITHUB_OUTPUT"
105+
# Render the diff inline on the run page.
106+
if [ -f "$RUNNER_TEMP/emitter-diff.md" ]; then
107+
cat "$RUNNER_TEMP/emitter-diff.md" >> "$GITHUB_STEP_SUMMARY"
108+
fi
109+
110+
- name: Upload HTML diff
111+
if: always()
112+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
113+
with:
114+
name: emitter-diff-html
115+
path: ${{ runner.temp }}/emitter-diff.html
116+
if-no-files-found: ignore
117+
retention-days: 7
118+
119+
- name: Comment on PR
120+
if: always() && github.event_name == 'pull_request'
121+
continue-on-error: true
122+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
123+
env:
124+
BASELINE: ${{ steps.baseline.outputs.sha || steps.baseline.outputs.ref }}
125+
STATUS: ${{ steps.diff.outputs.status }}
126+
SUMMARY: ${{ steps.diff.outputs.summary }}
127+
with:
128+
script: |
129+
const marker = "<!-- emitter-diff-python -->";
130+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
131+
const { STATUS, SUMMARY, BASELINE } = process.env;
132+
let body = `${marker}\n### Python emitter diff\nBaseline \`${BASELINE}\` vs this PR.\n\n`;
133+
body += STATUS !== "0"
134+
? `⚠️ **emitter-diff failed** (exit \`${STATUS}\`) — tool/build error, not a diff. See the [run](${runUrl}).\n`
135+
: `${SUMMARY}\n\nRendered diff: inline on the [run summary](${runUrl}), or the **emitter-diff-html** artifact.\n`;
136+
body += `\n_Informational check (eng/emitter-diff); does not block the PR._`;
137+
const { owner, repo } = context.repo, issue_number = context.issue.number;
138+
const { data } = await github.rest.issues.listComments({ owner, repo, issue_number });
139+
const hit = data.find((c) => c.body && c.body.includes(marker));
140+
if (hit) await github.rest.issues.updateComment({ owner, repo, comment_id: hit.id, body });
141+
else await github.rest.issues.createComment({ owner, repo, issue_number, body });
142+
143+
- name: Fail on tool error
144+
if: always()
145+
env:
146+
STATUS: ${{ steps.diff.outputs.status }}
147+
run: '[ "$STATUS" = "0" ] || { echo "::error::emitter-diff failed (exit $STATUS)."; exit 1; }'

cspell.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ words:
156156
- MACVMIMAGEM
157157
- marshal
158158
- mday
159+
- Menlo
159160
- methodsubscriptionid
160161
- mgmt
161162
- mgmtplane

eng/emitter-diff/README.md

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# emitter-diff
2+
3+
A language-agnostic tool for **diffing the generated code produced by two versions of a
4+
TypeSpec emitter**.
5+
6+
It runs the emitter's own regenerate command against a **baseline** source tree and a **head**
7+
source tree, then shows the diff between the two generated outputs. Use it locally during
8+
development and in CI on PRs.
9+
10+
The tool contains **zero language logic**. An emitter integrates by naming three things — its
11+
regenerate command, the package directory to run it in, and the generated-code directory to diff —
12+
either as flags or via a built-in `--emitter` preset.
13+
14+
## How it works
15+
16+
```text
17+
baseline tree ─► run <command> in <emitter-path> ─► snapshot <generated-code-path> ─┐
18+
├─► git diff ─► terminal / HTML
19+
head tree ─────► run <command> in <emitter-path> ─► snapshot <generated-code-path> ─┘
20+
```
21+
22+
- A **source tree** is resolved for each side (`--baseline` / `--head`).
23+
- The **`--command`** is run verbatim (tokenized to argv — no shell) inside
24+
`<tree>/<emitter-path>`. This is the emitter's _unmodified_ regenerate command; the tool does
25+
not reach into it.
26+
- The `<emitter-path>/<generated-code-path>` subtree is snapshotted for each side and the
27+
two snapshots are diffed. `--generated-code-path` accepts **multiple roots** — an emitter that
28+
writes generated code to several directories (e.g. Go) passes a comma-separated list or repeats
29+
the flag; each root is snapshotted under its own relative path so outputs never collide.
30+
31+
Because the tool just runs a command, **any emitter with a regenerate script works** — no per-language
32+
plugin code.
33+
34+
## Usage
35+
36+
```bash
37+
# Using a built-in preset (fills in command + paths):
38+
node eng/emitter-diff/src/cli.ts --emitter python --baseline gh:<sha>
39+
40+
# Fully explicit (no preset needed):
41+
node eng/emitter-diff/src/cli.ts \
42+
--command "npm run regenerate" \
43+
--emitter-path packages/http-client-python \
44+
--generated-code-path tests/generated \
45+
--baseline gh:<sha>
46+
47+
# Multiple generated roots (e.g. Go writes several) — comma-separated (or repeat the flag):
48+
node eng/emitter-diff/src/cli.ts \
49+
--command "npm run tspcompile" \
50+
--emitter-path packages/typespec-go \
51+
--generated-code-path test/http-specs,test/azure-http-specs \
52+
--baseline github:Azure/autorest.go@<sha>
53+
```
54+
55+
> This tool is a set of plain `.ts` scripts — not an installed package. It runs through `node`
56+
> (which executes TypeScript directly on the versions this repo supports), so there is nothing to
57+
> build. Typecheck with `npx tsc -p eng/emitter-diff`.
58+
59+
> **Build your checkout first.** `--head` defaults to the current working tree, and the tool runs
60+
> the regenerate command against it **as-is** — it never installs or builds your checkout (see
61+
> [Command prep](#command-prep---setup)). Build the emitter for the head side before diffing; for
62+
> **python** that's `npm run setup` in `packages/http-client-python` (builds the emitter and creates
63+
> the venv `regenerate` requires). Only trees the tool fetches from GitHub are auto-prepared.
64+
65+
### Emitter config
66+
67+
| Flag | Meaning |
68+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
69+
| `--emitter <name>` | Built-in preset that fills in the three fields below. |
70+
| `--command <cmd>` | Regenerate command, run verbatim in `--emitter-path`. |
71+
| `--emitter-path <path>` | Package dir (relative to a tree root) to run the command in. |
72+
| `--generated-code-path <path>` | Generated-code dir (relative to `--emitter-path`) to diff. Accepts **multiple roots** — comma-separated (`a,b`) or by repeating the flag — for an emitter with several generated roots (e.g. Go); each is snapshotted under its own relative path. |
73+
74+
A preset supplies all three; each flag still overrides the preset value. To onboard a new language,
75+
add a row to `EMITTER_DEFAULTS` in `src/registry.ts` — or just pass the three flags directly and
76+
skip `--emitter`.
77+
78+
### Refs
79+
80+
| Syntax | Meaning |
81+
| --------------------------------- | ------------------------------------ |
82+
| `local:/path` or `./path` | a local source folder (run in place) |
83+
| `github:owner/repo@<sha\|branch>` | a GitHub source at a ref |
84+
| `gh:<sha\|branch>` | this repo (origin remote) at a ref |
85+
86+
`--head` defaults to the **current working tree**. `--baseline` defaults to the `upstream` remote's
87+
repo at its default branch, falling back to `origin` (e.g. `github:microsoft/typespec@main`).
88+
89+
### Command prep (`--setup`)
90+
91+
The `--command` is run **as-is** — it does not install deps or build. Instead, prep is handled by
92+
**`--setup`**, which runs **only in a tree the tool freshly fetched from GitHub** (a `gh:`/`github:`
93+
ref). The current working tree and user-provided `local:` paths are assumed already built and are
94+
**never touched** (so setup never mutates your checkout or a prepared CI worktree).
95+
96+
- A **preset** supplies sensible setup defaults, so `--emitter python --baseline gh:main` installs +
97+
builds the fetched baseline automatically (python: `npm install --ignore-scripts``npm run setup`;
98+
typescript: `pnpm install``npm run build`).
99+
- Override with one or more `--setup <cmd>` (each runs in order, in `<tree>/<emitter-path>`), or
100+
disable entirely with `--no-setup`.
101+
102+
Each `--setup` command — like `--command` — is tokenized to argv and run **without a shell**, so
103+
multi-step pipelines (`&&`, `|`) are not supported; pass repeated `--setup` flags instead.
104+
105+
### Common options
106+
107+
By default the tool writes a **clickable HTML report** (`emitter-diff.html`) into the work dir and
108+
prints a `file://` link to it.
109+
110+
- `--baseline <ref>` / `--head <ref>`: the two source trees to compare.
111+
- `--setup <cmd>` (repeatable) / `--no-setup`: prep commands for freshly fetched GitHub trees.
112+
- `--work-dir <dir>`: scratch dir for snapshots (default: a fresh temp dir).
113+
- `--sequential`: regenerate baseline then head one after another instead of in parallel. Useful on
114+
a single machine where running both at once oversubscribes the CPU (each regenerate already fans
115+
out across cores) or trips generator races.
116+
- `--ci`: disable the local baseline-output cache (intended for CI).
117+
- `--html <file>`: write the rendered HTML report to this path.
118+
- `--md <file>`: write a Markdown report (collapsible per-file `diff` blocks) to this path. Handy for
119+
a CI job summary (`$GITHUB_STEP_SUMMARY`) or a PR comment body.
120+
- `--no-group`: render every file separately in the HTML/Markdown reports. By default, files that
121+
share the same change — the same added/removed lines, even when the surrounding generated code
122+
differs — are merged into a single collapsible group (with the shared diff shown once and every
123+
once and every affected file listed), so a repeated change across many generated files is reviewed
124+
once instead of N times.
125+
- `--fail-on-diff`: exit non-zero when output differs (exit `2` = diff present, `1` = hard error).
126+
- `-- <args>`: everything after `--` is appended to `--command` verbatim on **both** sides, so the
127+
diff stays apples-to-apples. Use it to regenerate only a subset of tests by forwarding the
128+
regenerate script's own filter flags.
129+
130+
### Regenerating a subset of tests
131+
132+
The tool doesn't define its own test-filter flags — it forwards `-- <args>` to each emitter's
133+
regenerate command, which owns the filtering. For the **Python** emitter (`regenerate.ts`):
134+
135+
- `-n, --name <pattern>`: case-insensitive substring match on package name.
136+
- `-f, --flavor <azure|unbranded>`: limit to one flavor.
137+
- `-j, --jobs <n>`: parallel job count.
138+
139+
```sh
140+
# Only the authentication packages, azure flavor
141+
node ../../eng/emitter-diff/src/cli.ts --emitter python -- --name authentication --flavor azure
142+
143+
# Via the package script (first `--` is npm's, second is emitter-diff's passthrough separator)
144+
npm run diff-spector-tests -- -- --name type/array
145+
```
146+
147+
`--name` filters the spec set already bundled in the package's `node_modules`
148+
(`@azure-tools/azure-http-specs` + `@typespec/http-specs`); it does not point the test set at an
149+
arbitrary folder or ref. Other emitters expose their own filter flags — pass whatever their
150+
regenerate command accepts.
151+
152+
## CI integration
153+
154+
`.github/workflows/ci-emitter-diff-<lang>.yml` runs on PRs that touch the language emitter or this
155+
tool. The **baseline** is the base-branch commit the PR is based on (the `git merge-base` with the
156+
target branch). Because the tool runs the regenerate command as-is, the workflow **prepares both
157+
trees** (installs deps, builds the emitter, creates any venv) before invoking the tool, then:
158+
159+
- posts a **sticky PR comment** (updated in place on each push) linking the diff artifact, and
160+
- uploads the rendered **HTML report** as an artifact.
161+
162+
**Informational:** the check **always passes unless the tool hits a real tool/build error** — a
163+
generated-output diff does not fail the PR. CI runs the tool without `--fail-on-diff`, so a diff
164+
still exits `0`; only a non-zero exit (a build/venv/generate failure) fails the job.
165+
166+
**Fork PRs are not run.** The job checks out and executes the PR's code (builds the emitter, runs
167+
`regenerate`), so a job-level `if` guard restricts it to same-repo PRs — it skips any PR whose head
168+
is a fork.
169+
170+
## Adding a new language
171+
172+
Either add a preset row to `EMITTER_DEFAULTS` (`src/registry.ts`):
173+
174+
```ts
175+
rust: {
176+
command: "npm run tspcompile",
177+
emitterPath: "packages/typespec-rust",
178+
generatedCodePath: "test/generated",
179+
},
180+
```
181+
182+
…or skip the preset entirely and pass `--command` / `--emitter-path` /
183+
`--generated-code-path` directly. Either way, ensure each side's tree can actually run the
184+
command (see **Command prep** above). The orchestrator, ref resolver, and diff engine need no
185+
changes.
186+
187+
## Notes & limitations
188+
189+
- `--html` renders a self-contained, GitHub-style HTML report (inline CSS, no external requests).
190+
`--md` renders a Markdown report (collapsible per-file `diff` blocks) suitable for a CI job
191+
summary or PR comment. Both reports group files that share the same change into one block by
192+
default (`--no-group` to disable), so reviewing a repeated diff is a one-time effort. The diff
193+
itself is produced by `git diff --no-index`; the tool leans on only a couple of small repo dev
194+
dependencies (`execa`, `picocolors`) for process spawning and terminal coloring.
195+
- For github refs (including fork repos), the resolver uses a detached, commit-keyed cached git
196+
worktree under the temp directory, created from an isolated cache repo (not from your active
197+
checkout). Repeated runs on the same commit reuse it.
198+
- Spec inputs come from each side's own dependencies (e.g. `node_modules/@typespec/http-specs`).
199+
Spec versions rarely change, so any drift between baseline/head is treated as acceptable noise.

0 commit comments

Comments
 (0)