diff --git a/.github/workflows/python-regen-diff-publish.yml b/.github/workflows/python-regen-diff-publish.yml deleted file mode 100644 index 3f654649176..00000000000 --- a/.github/workflows/python-regen-diff-publish.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Python Regen Diff Publish - -# Companion to python-regen-diff.yml. Runs after that workflow finishes, in the -# base-repo context (so GITHUB_TOKEN has write access even for fork PRs), then: -# 1. downloads the rendered diff-site artifact, -# 2. publishes it to GitHub Pages under pr//, -# 3. upserts a sticky PR comment linking to the preview. -# Uses only the built-in GITHUB_TOKEN — no PAT / extra secret. - -on: - workflow_run: - workflows: ["Python Regen Diff"] - types: [completed] - -permissions: - contents: write - pull-requests: write - issues: write - actions: read - -concurrency: - group: python-regen-diff-publish-${{ github.event.workflow_run.id }} - cancel-in-progress: false - -jobs: - publish: - name: "Publish diff preview" - runs-on: ubuntu-latest - if: > - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' - steps: - - name: Download diff site artifact - uses: actions/download-artifact@v8 - with: - name: python-regen-diff - path: diff-site - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Read PR metadata - id: meta - run: | - set -euo pipefail - if [ ! -f diff-site/pr.txt ]; then - echo "No pr.txt in artifact; nothing to publish." - echo "pr=" >> "$GITHUB_OUTPUT" - exit 0 - fi - PR="$(tr -d '[:space:]' < diff-site/pr.txt)" - echo "pr=$PR" >> "$GITHUB_OUTPUT" - - - name: Publish to GitHub Pages - if: steps.meta.outputs.pr != '' - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ steps.meta.outputs.pr }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - url="https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" - tmp="$(mktemp -d)" - if ! git clone --depth 1 --branch gh-pages "$url" "$tmp" 2>/dev/null; then - git clone --depth 1 "$url" "$tmp" - git -C "$tmp" checkout --orphan gh-pages - git -C "$tmp" rm -rf . >/dev/null 2>&1 || true - printf 'TypeSpec diff previews

TypeSpec generated-diff previews

' > "$tmp/index.html" - fi - rm -rf "$tmp/pr/${PR}" - mkdir -p "$tmp/pr/${PR}" - cp -r diff-site/. "$tmp/pr/${PR}/" - rm -f "$tmp/pr/${PR}/pr.txt" - git -C "$tmp" config user.name "github-actions[bot]" - git -C "$tmp" config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git -C "$tmp" add -A - if git -C "$tmp" commit -m "Update Python regen diff preview for PR #${PR}"; then - git -C "$tmp" push origin gh-pages - else - echo "No changes to publish." - fi - - - name: Upsert PR comment - if: steps.meta.outputs.pr != '' - uses: actions/github-script@v7 - env: - PR: ${{ steps.meta.outputs.pr }} - with: - script: | - const fs = require('fs'); - const prNumber = Number(process.env.PR); - const owner = context.repo.owner; - const repo = context.repo.repo; - const pagesUrl = `https://${owner}.github.io/${repo}/pr/${prNumber}/`; - - let summary = { changed: true, filesChanged: 0, additions: 0, deletions: 0, baselineTag: '', note: undefined }; - try { - summary = JSON.parse(fs.readFileSync('diff-site/summary.json', 'utf8')); - } catch (e) { - core.warning(`Could not read summary.json: ${e}`); - } - - const marker = ''; - const statsLine = summary.changed - ? `**${summary.filesChanged}** files changed · \`+${summary.additions}\` / \`-${summary.deletions}\`` - : '✅ No differences from the baseline.'; - const tagLine = summary.baselineTag - ? `Baseline tag: \`${summary.baselineTag}\`` - : '_Baseline not bootstrapped yet — entire output shown as added._'; - const noteLine = summary.note ? `\n> ⚠️ ${summary.note}\n` : ''; - - // When the PR changes generated output, the contributor must refresh - // the external baseline and pin the new tag in assets.json (test-proxy - // "push" model), so the baseline travels with the PR. - const actionBlock = summary.changed - ? [ - '', - '
👇 This PR changes generated output — update the baseline', - '', - 'Review the rendered diff above. If the changes are expected, refresh the', - 'baseline and pin the new tag so it ships with this PR:', - '', - '```bash', - 'cd packages/http-client-python', - 'npm run regenerate # produce fresh generated output', - 'npm run regenerate:push-assets # publish baseline + bump assets.json', - '```', - '', - 'Then commit the updated `packages/http-client-python/assets.json` (its', - '`Tag` now points at the new baseline). Pushing it makes this check pass', - 'with **0 differences**.', - '
', - ].join('\n') - : ''; - - const body = [ - marker, - '### 🐍 Python emitter — generated test diff', - '', - statsLine, - '', - `👉 **[View the rendered HTML diff](${pagesUrl})**`, - noteLine, - actionBlock, - `${tagLine} · updated for the latest commit. Re-runs of CI refresh this preview.`, - ].join('\n'); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: prNumber, per_page: 100, - }); - const existing = comments.find((c) => c.body && c.body.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); - core.info(`Updated comment ${existing.id}`); - } else { - await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); - core.info('Created new diff comment'); - } diff --git a/.github/workflows/python-regen-diff.yml b/.github/workflows/python-regen-diff.yml index e7b6fcd61b6..09a447080df 100644 --- a/.github/workflows/python-regen-diff.yml +++ b/.github/workflows/python-regen-diff.yml @@ -61,6 +61,10 @@ jobs: - name: Regenerate tests run: npm run regenerate + - name: Install regen-diff tool + working-directory: eng/common/scripts/regen-diff + run: npm install --no-package-lock + - name: Render HTML diff env: DIFF_TITLE: "Python emitter generated test diff (PR #${{ github.event.pull_request.number || 'manual' }})" @@ -72,6 +76,6 @@ jobs: - name: Upload diff site uses: actions/upload-artifact@v7 with: - name: python-regen-diff + name: regen-diff-site path: ${{ runner.temp }}/diff-site retention-days: 7 diff --git a/.github/workflows/python-regen-diff-cleanup.yml b/.github/workflows/regen-diff-cleanup.yml similarity index 58% rename from .github/workflows/python-regen-diff-cleanup.yml rename to .github/workflows/regen-diff-cleanup.yml index 62f57204e34..1cc3005e544 100644 --- a/.github/workflows/python-regen-diff-cleanup.yml +++ b/.github/workflows/regen-diff-cleanup.yml @@ -1,15 +1,19 @@ -name: Python Regen Diff Cleanup +name: Regen Diff Cleanup -# Removes a PR's published diff preview from GitHub Pages when the PR is closed. -# Uses pull_request_target (closed) so it runs in the base-repo context with a -# write token even for fork PRs. It does NOT check out or run any PR code — it -# only deletes the pr// folder from gh-pages and updates the sticky comment. +# Shared, language-agnostic cleanup. Removes a PR's published diff previews from +# GitHub Pages when the PR is closed. Uses pull_request_target (closed) so it +# runs in the base-repo context with a write token even for fork PRs. It does +# NOT check out or run any PR code — it only deletes the pr// folder from +# gh-pages and updates the sticky comment(s). on: pull_request_target: types: [closed] paths: - "packages/http-client-python/**" + - "packages/http-client-java/**" + - "packages/http-client-csharp/**" + - "packages/http-client-js/**" permissions: contents: write @@ -41,10 +45,10 @@ jobs: git -C "$tmp" rm -r "pr/${PR}" >/dev/null git -C "$tmp" config user.name "github-actions[bot]" git -C "$tmp" config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git -C "$tmp" commit -m "Remove diff preview for closed PR #${PR}" + git -C "$tmp" commit -m "Remove diff previews for closed PR #${PR}" git -C "$tmp" push origin gh-pages - - name: Update PR comment + - name: Update PR comments uses: actions/github-script@v7 env: PR: ${{ github.event.pull_request.number }} @@ -53,16 +57,17 @@ jobs: const prNumber = Number(process.env.PR); const owner = context.repo.owner; const repo = context.repo.repo; - const marker = ''; + const markerPrefix = '`; + const statsLine = summary.changed + ? `**${files}** files changed · \`+${adds}\` / \`-${dels}\`` + : '✅ No differences from the baseline.'; + const tagLine = tag + ? `Baseline tag: \`${tag}\`` + : '_Baseline not bootstrapped yet — entire output shown as added._'; + const noteLine = note ? `\n> ⚠️ ${note}\n` : ''; + + const body = [ + marker, + `### ${title}`, + '', + statsLine, + '', + `👉 **[View the rendered HTML diff](${pagesUrl})**`, + noteLine, + `${tagLine} · updated for the latest commit. Re-runs of CI refresh this preview.`, + ].join('\n'); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + core.info(`Updated comment ${existing.id}`); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + core.info('Created new diff comment'); + } diff --git a/eng/common/scripts/regen-diff/README.md b/eng/common/scripts/regen-diff/README.md new file mode 100644 index 00000000000..881c802d5c1 --- /dev/null +++ b/eng/common/scripts/regen-diff/README.md @@ -0,0 +1,155 @@ +# regen-diff — shared generated-test diff previews + +A small, self-contained tool that lets any TypeSpec **emitter language** preview +the impact of a change on its generated test output, the same way the Azure SDK +[test-proxy](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/README.md) +externalizes recordings. + +Instead of committing a large generated-test tree (or force-pushing it to a side +branch), the **baseline** ("last accepted output") lives in an external, public +**assets repo**, pinned by a tag in an `assets.json` file (test-proxy +convention). On every PR the emitter is regenerated, diffed against the restored +baseline, rendered to **HTML**, published to **GitHub Pages**, and linked from a +**sticky PR comment** plus a `regen-diff/` commit status. + +``` +PR touches packages//** + │ + ▼ +-regen-diff.yml (pull_request, read-only token) + build → regenerate → render HTML diff → upload artifact + │ + ▼ workflow_run (base-repo context, GITHUB_TOKEN write) +regen-diff-publish.yml (shared, language-agnostic) + publish to gh-pages: pr/// · sticky comment · commit status +``` + +Everything uses only the built-in `GITHUB_TOKEN` (no PAT / extra secret) and is +safe for fork PRs. + +## Layout + +| Path | What it is | +| ----------------------------------------------- | ------------------------------------------------------------ | +| `eng/common/scripts/regen-diff/` | This shared tool (one copy, used by every language). | +| `packages//assets.json` | Baseline pointer (assets repo + pinned tag). | +| `packages//regen-diff.config.json` | Per-language config (slug, flavors, generated dir, title). | +| `.github/workflows/-regen-diff.yml` | Thin per-language workflow: build → regenerate → render. | +| `.github/workflows/regen-diff-publish.yml` | Shared: publish to Pages + comment + status. | +| `.github/workflows/regen-diff-cleanup.yml` | Shared: remove a PR's preview on close. | + +## CLI + +``` +tsx eng/common/scripts/regen-diff/src/cli.ts --package [options] +``` + +| Command | Purpose | +| ------------- | ----------------------------------------------------------------------- | +| `render` | Render the HTML diff (current generated output vs the assets baseline). | +| `push-assets` | Publish the current output as a new baseline and bump `assets.json`. | + +`render` options: `--output `, `--generated `, `--title `, +`--open` (browser), `--vscode` (native side-by-side editor diffs), `--max `. +`push-assets` options: `--message `, `--branch `, `--dry-run`. + +The tool has its own `package.json`/`package-lock.json`, so it is **independent +of the consuming package's package manager** (npm, pnpm, …). Install its deps once with `npm install` in this directory before running it. + +## Onboarding a new emitter language + +1. **Pick an assets repo + prefix.** It must be a public repo you can push tags + to. Use a unique `AssetsRepoPrefixPath` so languages don't collide. Add + `packages//assets.json`: + + ```json + { + "AssetsRepo": "/", + "AssetsRepoPrefixPath": "", + "TagPrefix": "/tests", + "Tag": "" + } + ``` + + Leave `Tag` empty until you bootstrap (step 4); the diff then shows the whole + output as "added", which is harmless. + +2. **Describe the output.** Add `packages//regen-diff.config.json`: + + ```json + { + "slug": "", + "title": " emitter — generated test diff", + "generatedDir": "tests/generated", + "flavors": ["azure", "unbranded"], + "pruneFilePrefixes": [] + } + ``` + + - `slug` — URL/identifier-safe, namespaces the Pages folder + status context. + - `flavors` — the top-level folders under `generatedDir` that make up the + baseline (use `["."]` if there's a single flat tree). + - `pruneFilePrefixes` — filename prefixes to drop as transient codegen noise + (e.g. `[".tsp-codegen-"]`); leave `[]` if none. + +3. **Wire up scripts** in `packages//package.json` (paths are relative + to the package; adjust depth as needed): + + ```jsonc + "regenerate:render-diff": "tsx ../../eng/common/scripts/regen-diff/src/cli.ts render --package .", + "regenerate:diff": "tsx ../../eng/common/scripts/regen-diff/src/cli.ts render --package . --open", + "regenerate:vscode-diff": "tsx ../../eng/common/scripts/regen-diff/src/cli.ts render --package . --vscode", + "regenerate:review": " && ", + "regenerate:push-assets": "tsx ../../eng/common/scripts/regen-diff/src/cli.ts push-assets --package ." + ``` + +4. **Bootstrap the baseline** (maintainer, local, one time). Generate the output + and publish the first tag: + + ```bash + + npm install --no-package-lock --prefix eng/common/scripts/regen-diff + # pushes a tag to the assets repo + bumps assets.json + ``` + + Commit the updated `assets.json` (its `Tag` now points at the baseline). + +5. **Add the diff workflow** `.github/workflows/-regen-diff.yml` — copy + `python-regen-diff.yml` and adapt the build/regenerate steps + the + `paths:` filter to your package. It must: + - run `npm install` in `eng/common/scripts/regen-diff` before rendering, + - render with `--output "${{ runner.temp }}/diff-site"`, + - write the PR number to `diff-site/pr.txt`, + - upload the artifact as **`regen-diff-site`**. + +6. **Register with the shared publish workflow.** Add your diff workflow's + `name:` to the `workflow_run.workflows` list in `regen-diff-publish.yml`. + The shared cleanup workflow already matches `packages/http-client-*/**`; add + your package glob there too if it lives elsewhere. + +7. **Enable GitHub Pages** (gh-pages branch) on the repo if it isn't already, + and optionally make `regen-diff/` a required status via branch + protection so a drift without an `assets.json` bump blocks merge. + +## Local developer experience + +```bash +npm install --no-package-lock --prefix eng/common/scripts/regen-diff # one time + # produce fresh output + # render + open the HTML diff +# or: # open native VS Code diffs +``` + +No token is needed — the baseline is restored with an anonymous clone of the +public assets repo. When the changes are intentional, run `regenerate:push-assets` +to refresh the baseline and bump `assets.json`, then commit it so the check +passes with **0 differences**. + +## Notes + +- The renderer is fully generic: it groups changed files by folder, paginates a + side-by-side page per file, and bounds memory by capping per-file render size. +- `assets.ts` here is a self-contained copy of the test-proxy restore helper. + Emitters whose `regenerate` step also seeds a baseline (e.g. legacy smoke + tests) keep their own small `assets.ts`; this tool does not depend on it. + diff --git a/eng/common/scripts/regen-diff/package.json b/eng/common/scripts/regen-diff/package.json new file mode 100644 index 00000000000..6c4f25edccd --- /dev/null +++ b/eng/common/scripts/regen-diff/package.json @@ -0,0 +1,20 @@ +{ + "name": "@typespec/internal-regen-diff", + "version": "0.1.0", + "private": true, + "description": "Shared tool to render generated-test diffs against an external assets baseline and publish new baselines. Used by the TypeSpec emitter languages.", + "type": "module", + "scripts": { + "regen-diff": "tsx ./src/cli.ts", + "build": "tsc -p .", + "test:tsc": "tsc -p ." + }, + "dependencies": { + "picocolors": "~1.1.1" + }, + "devDependencies": { + "@types/node": "~25.0.2", + "tsx": "^4.21.0", + "typescript": "~5.9.2" + } +} diff --git a/eng/common/scripts/regen-diff/src/assets.ts b/eng/common/scripts/regen-diff/src/assets.ts new file mode 100644 index 00000000000..02ee1d6d620 --- /dev/null +++ b/eng/common/scripts/regen-diff/src/assets.ts @@ -0,0 +1,145 @@ +/* eslint-disable no-console */ +/** + * Test-proxy-style baseline assets helpers, shared across emitter languages. + * + * The generated-test baseline ("the last accepted regeneration output") is + * stored in an external, public assets repo and pinned by a tag, following the + * Azure SDK test-proxy `assets.json` convention. Restoring the baseline is an + * anonymous clone of that public repo, so it needs **no token**. + * + * `assets.json` (next to a package's `package.json`) looks like: + * { + * "AssetsRepo": "Azure/azure-sdk-assets", + * "AssetsRepoPrefixPath": "typespec/python", + * "TagPrefix": "typespec/python/tests", + * "Tag": "typespec/python/tests_" + * } + * + * In the assets repo the baseline lives under + * / + * at the commit pointed to by , one folder per configured flavor. + */ + +import { execSync } from "child_process"; +import { existsSync, readFileSync, rmSync } from "fs"; +import { cp, mkdir, mkdtemp } from "fs/promises"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import pc from "picocolors"; + +/** Parsed `assets.json` describing where the baseline lives. */ +export interface AssetsConfig { + /** "owner/repo" of the public assets repo. */ + assetsRepo: string; + /** Subdirectory in the assets repo that contains the language baseline. */ + prefixPath: string; + /** Tag namespace, e.g. "typespec/python/tests". */ + tagPrefix: string; + /** Concrete tag the baseline is pinned to (empty until first bootstrap). */ + tag: string; + /** Absolute path to the `assets.json` file this was read from. */ + configPath: string; +} + +/** + * Reads `assets.json` from `packageRoot`. Returns `undefined` if the file does + * not exist or has no `AssetsRepo`, so callers can treat that as "no baseline". + */ +export function readAssetsConfig(packageRoot: string): AssetsConfig | undefined { + const configPath = resolve(packageRoot, "assets.json"); + if (!existsSync(configPath)) { + return undefined; + } + + const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record; + const assetsRepo = raw.AssetsRepo?.trim() ?? ""; + if (!assetsRepo) { + return undefined; + } + + return { + assetsRepo, + prefixPath: (raw.AssetsRepoPrefixPath ?? "").trim(), + tagPrefix: (raw.TagPrefix ?? "").trim(), + tag: (raw.Tag ?? "").trim(), + configPath, + }; +} + +/** HTTPS URL for the public assets repo (anonymous, no credentials). */ +export function assetsRepoUrl(config: AssetsConfig): string { + return `https://github.com/${config.assetsRepo}.git`; +} + +/** + * Anonymously checks out the assets repo at `config.tag` into a fresh temp dir + * and invokes `handler` with the absolute path to `/` (the + * directory that contains the per-flavor baseline folders). The temp dir is + * always cleaned up afterwards. + * + * Throws if `config.tag` is empty (baseline not bootstrapped yet). + */ +export async function withBaselineCheckout( + config: AssetsConfig, + handler: (baselineRoot: string) => Promise, +): Promise { + if (!config.tag) { + throw new Error( + `assets.json has an empty "Tag"; bootstrap the baseline first ` + + `(run " regenerate:push-assets").`, + ); + } + + const repoUrl = assetsRepoUrl(config); + const tempDir = await mkdtemp(join(tmpdir(), "typespec-assets-")); + + try { + console.log(pc.dim(`Cloning ${config.assetsRepo}@${config.tag} into ${tempDir}`)); + const run = (cmd: string) => + execSync(cmd, { cwd: tempDir, stdio: ["ignore", "ignore", "inherit"] }); + + run(`git init`); + run(`git config core.longpaths true`); + run(`git remote add origin ${repoUrl}`); + if (config.prefixPath) { + run(`git config core.sparseCheckout true`); + run(`git sparse-checkout init --cone`); + run(`git sparse-checkout set ${config.prefixPath}`); + } + // Fetch the specific tag shallowly. Tags are fetched under refs/tags/. + run(`git fetch --depth 1 origin "refs/tags/${config.tag}:refs/tags/${config.tag}"`); + run(`git checkout "tags/${config.tag}"`); + + const baselineRoot = config.prefixPath + ? join(tempDir, ...config.prefixPath.split("/")) + : tempDir; + return await handler(baselineRoot); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +/** + * Restores the **full** baseline (every configured flavor folder) from the + * assets repo into `destDir` as `destDir/`. `destDir/` is wiped + * before copying. Used by the diff renderer to obtain the "before" snapshot. + */ +export async function restoreFullBaseline( + config: AssetsConfig, + destDir: string, + flavors: string[], +): Promise { + await withBaselineCheckout(config, async (baselineRoot) => { + for (const flavor of flavors) { + const src = join(baselineRoot, flavor); + const dest = join(destDir, flavor); + if (!existsSync(src)) { + console.warn(pc.yellow(`Baseline flavor folder not found in assets repo: ${flavor}`)); + continue; + } + rmSync(dest, { recursive: true, force: true }); + await mkdir(dest, { recursive: true }); + await cp(src, dest, { recursive: true }); + } + }); +} diff --git a/eng/common/scripts/regen-diff/src/cli.ts b/eng/common/scripts/regen-diff/src/cli.ts new file mode 100644 index 00000000000..5a9cd536f9e --- /dev/null +++ b/eng/common/scripts/regen-diff/src/cli.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-console */ +/** + * Shared regen-diff CLI. One self-contained tool used by every emitter language + * to render generated-test diffs against an external assets baseline and to + * publish a new baseline. + * + * Commands: + * render Render the HTML diff (current generated output vs baseline). + * push-assets Publish the current output as a new baseline + bump assets.json. + * + * Every command takes `--package `, the emitter package root that holds the + * `regen-diff.config.json` and `assets.json`. Run it with `tsx`: + * + * tsx eng/common/scripts/regen-diff/src/cli.ts render --package packages/http-client-python + * + * Most consumers wire this up behind package.json scripts (see the README). + */ + +import pc from "picocolors"; +import { parseArgs } from "util"; + +import { pushAssets } from "./push-assets.js"; +import { render } from "./render-diff.js"; + +const HELP = `${pc.bold("regen-diff")} — shared generated-test diff tool + +${pc.bold("Usage:")} + regen-diff --package [options] + +${pc.bold("Commands:")} + render Render an HTML diff of the current generated output vs the + assets baseline. + --package Emitter package root (required). + --output Output directory (default: /temp/diff-site). + --generated Current generated dir (default: from config). + --title Title shown on the diff page (default: from config). + --open Open the rendered diff in your default browser. + --vscode Open each changed file as a native VS Code editor diff. + --max Max files to open in VS Code (default 40). + + push-assets Publish the current generated output as a new baseline and bump + assets.json to the new tag (maintainer-run, local). + --package Emitter package root (required). + --message Commit message (default auto-generated). + --branch Assets-repo branch to push to (default: main). + --dry-run Build the commit/tag locally but do not push. + + -h, --help Show this help. +`; + +async function main(): Promise { + const [command, ...rest] = process.argv.slice(2); + + if (!command || command === "-h" || command === "--help") { + console.log(HELP); + process.exit(command ? 0 : 1); + } + + const { values } = parseArgs({ + args: rest, + options: { + package: { type: "string", short: "p" }, + output: { type: "string", short: "o" }, + generated: { type: "string", short: "g" }, + title: { type: "string", short: "t" }, + open: { type: "boolean" }, + vscode: { type: "boolean" }, + max: { type: "string" }, + message: { type: "string", short: "m" }, + branch: { type: "string", short: "b" }, + "dry-run": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + }); + + if (values.help) { + console.log(HELP); + process.exit(0); + } + + const packageRoot = values.package; + if (!packageRoot) { + console.error(pc.red(`Missing required --package .`)); + console.log(HELP); + process.exit(1); + } + + switch (command) { + case "render": + await render({ + packageRoot, + output: values.output, + generated: values.generated, + title: values.title, + open: values.open, + vscode: values.vscode, + max: values.max ? Number(values.max) : undefined, + }); + break; + case "push-assets": + await pushAssets({ + packageRoot, + message: values.message, + branch: values.branch, + dryRun: values["dry-run"], + }); + break; + default: + console.error(pc.red(`Unknown command: ${command}`)); + console.log(HELP); + process.exit(1); + } +} + +main().catch((err) => { + console.error(pc.red(`Fatal error: ${err?.stack ?? err}`)); + process.exit(1); +}); diff --git a/eng/common/scripts/regen-diff/src/config.ts b/eng/common/scripts/regen-diff/src/config.ts new file mode 100644 index 00000000000..a14f65465ce --- /dev/null +++ b/eng/common/scripts/regen-diff/src/config.ts @@ -0,0 +1,97 @@ +/* eslint-disable no-console */ +/** + * Loads a package's `regen-diff.config.json`, the per-language configuration for + * the shared regen-diff tool. It sits next to the package's test-proxy-style + * `assets.json` (which points at the external baseline) and describes the bits + * that are specific to one emitter: where the generated output lives, which + * top-level "flavor" folders make it up, and how to label the rendered diff. + * + * Example `regen-diff.config.json`: + * { + * "slug": "python", + * "title": "Python emitter — generated test diff", + * "generatedDir": "tests/generated", + * "flavors": ["azure", "unbranded"], + * "pruneFilePrefixes": [".tsp-codegen-"] + * } + */ + +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; + +/** Resolved per-language regen-diff configuration. */ +export interface RegenDiffConfig { + /** + * Short, URL/identifier-safe language slug (e.g. "python", "java", "csharp"). + * Used to namespace the Pages preview (`pr///`), the commit-status + * context (`regen-diff/`) and the sticky PR comment marker. + */ + slug: string; + /** Human-readable title shown on the diff page and the PR comment. */ + title: string; + /** Generated-output directory, relative to the package root. */ + generatedDir: string; + /** Top-level folders under `generatedDir` that make up the baseline. */ + flavors: string[]; + /** + * Filename prefixes to drop from both sides before diffing (transient codegen + * artifacts that embed machine-local paths and would otherwise be diff noise). + */ + pruneFilePrefixes: string[]; + /** Absolute path to the package root the config was loaded from. */ + packageRoot: string; +} + +const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; + +/** + * Reads and validates `/regen-diff.config.json`. Throws a friendly + * error if the file is missing or malformed, since every command needs it. + */ +export function readRegenDiffConfig(packageRoot: string): RegenDiffConfig { + const root = resolve(packageRoot); + const configPath = resolve(root, "regen-diff.config.json"); + if (!existsSync(configPath)) { + throw new Error( + `No regen-diff.config.json found at ${configPath}. ` + + `See eng/common/scripts/regen-diff/README.md for the onboarding steps.`, + ); + } + + const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record; + + const slug = typeof raw.slug === "string" ? raw.slug.trim() : ""; + if (!SLUG_RE.test(slug)) { + throw new Error( + `regen-diff.config.json: "slug" must match ${SLUG_RE} (got ${JSON.stringify(raw.slug)}).`, + ); + } + + const flavors = Array.isArray(raw.flavors) ? raw.flavors.map((f) => String(f)) : []; + if (flavors.length === 0) { + throw new Error(`regen-diff.config.json: "flavors" must be a non-empty array of folder names.`); + } + + const pruneFilePrefixes = Array.isArray(raw.pruneFilePrefixes) + ? raw.pruneFilePrefixes.map((p) => String(p)) + : []; + + const title = + typeof raw.title === "string" && raw.title.trim() + ? raw.title.trim() + : `${slug} emitter — generated test diff`; + + const generatedDir = + typeof raw.generatedDir === "string" && raw.generatedDir.trim() + ? raw.generatedDir.trim() + : "tests/generated"; + + return { + slug, + title, + generatedDir, + flavors, + pruneFilePrefixes, + packageRoot: root, + }; +} diff --git a/packages/http-client-python/eng/scripts/ci/push-assets.ts b/eng/common/scripts/regen-diff/src/push-assets.ts similarity index 51% rename from packages/http-client-python/eng/scripts/ci/push-assets.ts rename to eng/common/scripts/regen-diff/src/push-assets.ts index 9615ac5978b..f120e4e4eaf 100644 --- a/packages/http-client-python/eng/scripts/ci/push-assets.ts +++ b/eng/common/scripts/regen-diff/src/push-assets.ts @@ -1,63 +1,39 @@ /* eslint-disable no-console */ /** - * Publishes the current `tests/generated/{azure,unbranded}` output as a new - * baseline in the external assets repo and bumps `assets.json` to the new tag. + * Publishes the current generated output as a new baseline in the external + * assets repo and bumps `assets.json` to the new tag. Shared across emitter + * languages; the generated dir, flavor folders and commit-label come from the + * package's `regen-diff.config.json`. * * This is a **maintainer-run, local** tool. It pushes to the assets repo using * whatever git credentials are already configured on the machine (or a * `GH_TOKEN`/`GITHUB_TOKEN` env var if present), so it needs **no CI secret**. * * Typical flow: - * 1. `npm run regenerate` # produce fresh tests/generated output - * 2. `npm run regenerate:push-assets` # publish it + bump assets.json + * 1. # produce fresh generated output + * 2. regenerate:push-assets # publish it + bump assets.json * 3. commit the assets.json change and open a PR * * Usage: - * tsx ./eng/scripts/ci/push-assets.ts [--message ""] [--branch ] [--dry-run] + * push-assets --package [--message ""] [--branch ] [--dry-run] */ import { execFileSync } from "child_process"; import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { cp, mkdir, mkdtemp } from "fs/promises"; import { tmpdir } from "os"; -import { dirname, join, resolve } from "path"; +import { dirname, join } from "path"; import pc from "picocolors"; -import { fileURLToPath } from "url"; -import { parseArgs } from "util"; - -import { assetsRepoUrl, FLAVORS, readAssetsConfig } from "./assets.js"; - -const argv = parseArgs({ - args: process.argv.slice(2), - options: { - message: { type: "string", short: "m" }, - branch: { type: "string", short: "b" }, - "dry-run": { type: "boolean" }, - help: { type: "boolean", short: "h" }, - }, -}); - -if (argv.values.help) { - console.log(` -${pc.bold("Usage:")} tsx push-assets.ts [options] - -Publishes tests/generated/{azure,unbranded} to the assets repo named in -assets.json, creates a new tag, pushes it, and bumps assets.json's "Tag". - -${pc.bold("Options:")} - -m, --message Commit message (default auto-generated). - -b, --branch Assets-repo branch to push to (default: main). - --dry-run Build the commit/tag locally but do not push. - -h, --help Show this help. -`); - process.exit(0); -} -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); -const PACKAGE_ROOT = resolve(SCRIPT_DIR, "../../../"); -const GENERATED_DIR = resolve(PACKAGE_ROOT, "tests/generated"); -const BRANCH = argv.values.branch ?? "main"; -const DRY_RUN = argv.values["dry-run"] ?? false; +import { assetsRepoUrl, readAssetsConfig } from "./assets.js"; +import { readRegenDiffConfig } from "./config.js"; + +export interface PushOptions { + packageRoot: string; + message?: string; + branch?: string; + dryRun?: boolean; +} /** Returns an authenticated push URL when a token is in the environment. */ function pushUrl(repoSlug: string): string { @@ -69,12 +45,9 @@ function pushUrl(repoSlug: string): string { } /** - * Recursively rewrites every text file under `dir` with all `\r` bytes removed. - * On Windows, Python writing `\r\n` to a text-mode file yields `\r\r\n` (double - * CR); relying on git's `eol=lf` normalization can leave a stray inner CR, which - * later shows up as spurious diff noise. Stripping all CR here guarantees the - * stored baseline is pure LF regardless of the maintainer's OS. Files containing - * a NUL byte are treated as binary and left untouched. + * Recursively rewrites every text file under `dir` with all `\r` bytes removed, + * so the stored baseline is pure LF regardless of the maintainer's OS. Files + * containing a NUL byte are treated as binary and left untouched. */ function normalizeEol(dir: string): void { for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -92,40 +65,40 @@ function normalizeEol(dir: string): void { } /** - * Removes transient codegen handoff files (`.tsp-codegen-*.json`) that the - * TypeSpec emit step writes for the Python batch step. They embed absolute, - * machine-local paths (e.g. a Windows temp dir) and are not real generated - * output, so they must never be published into the portable baseline — for - * legacy specs whose baseline is restored verbatim, a stale path here breaks - * regeneration on other machines. + * Removes transient codegen handoff files (configured via `pruneFilePrefixes`) + * that embed absolute, machine-local paths and must never be published into the + * portable baseline. */ -function pruneIntermediates(dir: string): void { +function pruneIntermediates(dir: string, prefixes: string[]): void { + if (prefixes.length === 0) return; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { - pruneIntermediates(full); - } else if (entry.isFile() && entry.name.startsWith(".tsp-codegen-")) { + pruneIntermediates(full, prefixes); + } else if (entry.isFile() && prefixes.some((p) => entry.name.startsWith(p))) { rmSync(full, { force: true }); } } } -async function main(): Promise { - const config = readAssetsConfig(PACKAGE_ROOT); +/** Publishes the current generated output as a new baseline + tag. */ +export async function pushAssets(options: PushOptions): Promise { + const cfg = readRegenDiffConfig(options.packageRoot); + const generatedDir = join(cfg.packageRoot, ...cfg.generatedDir.split("/")); + const branch = options.branch ?? "main"; + const dryRun = options.dryRun ?? false; + + const config = readAssetsConfig(cfg.packageRoot); if (!config) { - console.error(pc.red(`No usable assets.json found at ${PACKAGE_ROOT}/assets.json`)); - process.exit(1); + throw new Error(`No usable assets.json found at ${cfg.packageRoot}/assets.json`); } // Validate the generated output is present. - for (const flavor of FLAVORS) { - if (!existsSync(join(GENERATED_DIR, flavor))) { - console.error( - pc.red( - `Missing ${join(GENERATED_DIR, flavor)}. Run "npm run regenerate" before pushing assets.`, - ), + for (const flavor of cfg.flavors) { + if (!existsSync(join(generatedDir, flavor))) { + throw new Error( + `Missing ${join(generatedDir, flavor)}. Run the package's regenerate step before pushing assets.`, ); - process.exit(1); } } @@ -146,46 +119,37 @@ async function main(): Promise { } }; - console.log(pc.cyan(`Preparing assets push to ${config.assetsRepo} (branch ${BRANCH})`)); + console.log(pc.cyan(`Preparing assets push to ${config.assetsRepo} (branch ${branch})`)); git(["init"]); git(["config", "core.longpaths", "true"]); - // Store the baseline with LF regardless of the maintainer's OS, so the diff - // isn't swamped by CRLF-vs-LF noise when CI (Linux) regenerates with LF. + // Store the baseline with LF regardless of the maintainer's OS. git(["config", "core.autocrlf", "false"]); git(["remote", "add", "origin", repoUrl]); // Try to base the new commit on the existing branch; if the repo/branch is // empty this fails harmlessly and we start an orphan history. - const fetched = git(["fetch", "--depth", "1", "origin", BRANCH], { allowFail: true }); + const fetched = git(["fetch", "--depth", "1", "origin", branch], { allowFail: true }); if (fetched !== "" || git(["rev-parse", "--verify", "FETCH_HEAD"], { allowFail: true })) { - git(["checkout", "-B", BRANCH, "FETCH_HEAD"], { allowFail: true }); + git(["checkout", "-B", branch, "FETCH_HEAD"], { allowFail: true }); } if (!git(["rev-parse", "--verify", "HEAD"], { allowFail: true })) { - git(["checkout", "--orphan", BRANCH], { allowFail: true }); + git(["checkout", "--orphan", branch], { allowFail: true }); } // Replace the per-flavor baseline under / with the fresh output. const prefixRoot = config.prefixPath ? join(tempDir, ...config.prefixPath.split("/")) : tempDir; - for (const flavor of FLAVORS) { + for (const flavor of cfg.flavors) { const dest = join(prefixRoot, flavor); rmSync(dest, { recursive: true, force: true }); await mkdir(dirname(dest), { recursive: true }); - await cp(join(GENERATED_DIR, flavor), dest, { recursive: true }); + await cp(join(generatedDir, flavor), dest, { recursive: true }); } - // Normalize line endings to LF in the committed blobs (text=auto skips - // detected binaries), so a Windows maintainer's CRLF files don't poison the - // baseline. Written before `git add` so it applies to the staged files. + // Normalize line endings to LF in the committed blobs. writeFileSync(join(tempDir, ".gitattributes"), "* text=auto eol=lf\n"); - - // Belt-and-suspenders: strip stray CR bytes (incl. Windows double-CR - // `\r\r\n`) that git's eol=lf normalization can leave behind. normalizeEol(prefixRoot); - - // Drop transient codegen handoff files; they embed machine-local paths and - // must not pollute the portable baseline. - pruneIntermediates(prefixRoot); + pruneIntermediates(prefixRoot, cfg.pruneFilePrefixes); git(["add", "-A"]); const status = git(["status", "--porcelain"], { allowFail: true }); @@ -195,9 +159,8 @@ async function main(): Promise { } const message = - argv.values.message ?? - `[python] Update generated test baseline (${new Date().toISOString()})`; - // Use a stable bot identity when none is configured locally. + options.message ?? + `[${cfg.slug}] Update generated test baseline (${new Date().toISOString()})`; git(["config", "user.name", process.env.GIT_AUTHOR_NAME || "typespec-assets-bot"]); git([ "config", @@ -210,12 +173,12 @@ async function main(): Promise { const tag = `${config.tagPrefix}_${shortSha}`; git(["tag", tag]); - if (DRY_RUN) { - console.log(pc.yellow(`[dry-run] Would push branch ${BRANCH} and tag ${tag}.`)); + if (dryRun) { + console.log(pc.yellow(`[dry-run] Would push branch ${branch} and tag ${tag}.`)); } else { const authUrl = pushUrl(config.assetsRepo); git(["remote", "set-url", "origin", authUrl]); - git(["push", "origin", `HEAD:${BRANCH}`]); + git(["push", "origin", `HEAD:${branch}`]); git(["push", "origin", tag]); console.log(pc.green(`Pushed baseline and tag ${tag} to ${config.assetsRepo}.`)); } @@ -229,15 +192,10 @@ async function main(): Promise { } } -/** Rewrites only the `Tag` field of assets.json, preserving formatting. */ +/** Rewrites only the `Tag` field of assets.json, preserving the other fields. */ function bumpAssetsTag(configPath: string, tag: string): void { const raw = readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw) as Record; parsed.Tag = tag; writeFileSync(configPath, JSON.stringify(parsed, null, 2) + "\n"); } - -main().catch((err) => { - console.error(pc.red(`Fatal error: ${err?.stack ?? err}`)); - process.exit(1); -}); diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/eng/common/scripts/regen-diff/src/render-diff.ts similarity index 60% rename from packages/http-client-python/eng/scripts/ci/render-diff.ts rename to eng/common/scripts/regen-diff/src/render-diff.ts index 3c1f9496cd1..85848c9ef8a 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/eng/common/scripts/regen-diff/src/render-diff.ts @@ -2,22 +2,29 @@ /** * Renders an HTML diff between the **assets baseline** (the last accepted * regeneration output, restored from the assets repo) and the **current** - * `tests/generated` output (produced by `npm run regenerate` beforehand). + * generated output (produced by the package's regenerate step beforehand). * - * Output (default `temp/diff-site/`): - * - index.html A self-contained, side-by-side HTML diff (diff2html). - * - summary.json { changed, filesChanged, additions, deletions } for the - * PR-comment step to consume. + * This is the shared, language-agnostic renderer. The per-language specifics + * (generated dir, flavor folders, title, transient files to prune) come from + * the package's `regen-diff.config.json`; the baseline pointer comes from its + * `assets.json`. + * + * Output (default `/temp/diff-site/`): + * - index.html A folder-grouped, searchable tree of changed files. + * - files/NN.html One side-by-side page per changed file (diff2html). + * - summary.json { slug, title, changed, filesChanged, additions, + * deletions, baselineTag, note } for the publish step. * * Restoring the baseline is an anonymous clone of the public assets repo, so * this needs no token. If assets.json has no Tag yet (not bootstrapped), the * whole current output is treated as "added". * * Usage: - * tsx ./eng/scripts/ci/render-diff.ts [--output ] [--generated ] [--title ] + * render-diff --package [--output ] [--title ] [--open] + * [--vscode] [--max ] */ -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { existsSync, mkdirSync, @@ -27,62 +34,33 @@ import { writeFileSync, } from "fs"; import { cp, mkdtemp } from "fs/promises"; -import { createRequire } from "module"; import { tmpdir } from "os"; -import { dirname, join, resolve } from "path"; +import { join, resolve } from "path"; import pc from "picocolors"; -import { fileURLToPath } from "url"; -import { parseArgs } from "util"; - -import { FLAVORS, readAssetsConfig, restoreFullBaseline } from "./assets.js"; - -// diff2html is CommonJS; load via createRequire for ESM. -const require = createRequire(import.meta.url); -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { html: diff2html } = require("diff2html") as typeof import("diff2html"); - -const argv = parseArgs({ - args: process.argv.slice(2), - options: { - output: { type: "string", short: "o" }, - generated: { type: "string", short: "g" }, - title: { type: "string", short: "t" }, - help: { type: "boolean", short: "h" }, - }, -}); - -if (argv.values.help) { - console.log(` -${pc.bold("Usage:")} tsx render-diff.ts [options] - -Renders an HTML diff of the current tests/generated output vs the assets baseline. - -${pc.bold("Options:")} - -o, --output Output directory (default: temp/diff-site). - -g, --generated Current generated dir (default: tests/generated). - -t, --title Title shown on the diff page. - -h, --help Show this help. -`); - process.exit(0); -} +import { pathToFileURL } from "url"; + +import { readAssetsConfig, restoreFullBaseline } from "./assets.js"; +import { readRegenDiffConfig, type RegenDiffConfig } from "./config.js"; -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); -const PACKAGE_ROOT = resolve(SCRIPT_DIR, "../../../"); -const GENERATED_DIR = argv.values.generated - ? resolve(argv.values.generated) - : resolve(PACKAGE_ROOT, "tests/generated"); -const OUTPUT_DIR = argv.values.output - ? resolve(argv.values.output) - : resolve(PACKAGE_ROOT, "temp/diff-site"); -const TITLE = argv.values.title ?? "Python emitter — generated test diff"; + +export interface RenderOptions { + packageRoot: string; + output?: string; + generated?: string; + title?: string; + open?: boolean; + vscode?: boolean; + max?: number; +} // Each changed file is rendered as its own page, so we never build one giant // HTML string (which throws `RangeError: Invalid string length` past ~512MB). -// A single file whose diff exceeds this is shown as a raw
 instead of a
-// rich side-by-side render, to bound per-page memory/size.
+// A single file whose diff exceeds this is shown as a raw 
 instead.
 const MAX_FILE_DIFF_BYTES = 2 * 1024 * 1024;
 
 interface DiffSummary {
+  slug: string;
+  title: string;
   changed: boolean;
   filesChanged: number;
   additions: number;
@@ -91,6 +69,12 @@ interface DiffSummary {
   note?: string;
 }
 
+// Resolved once per run from options + config; read by the render helpers.
+let CONFIG: RegenDiffConfig;
+let GENERATED_DIR: string;
+let OUTPUT_DIR: string;
+let TITLE: string;
+
 function git(args: string[], cwd: string, allowFail = false): string {
   try {
     return execFileSync("git", args, {
@@ -116,7 +100,7 @@ function stripCr(text: string): string {
 /**
  * Recursively rewrites every text file under `dir` with all `\r` bytes removed,
  * so line endings are pure LF. The baseline may have been generated on Windows,
- * where Python writing `\r\n` to a text-mode file yields `\r\r\n` (double CR);
+ * where writing `\r\n` to a text-mode file yields `\r\r\n` (double CR);
  * `git diff --ignore-cr-at-eol` only ignores a *single* trailing CR, so without
  * this those lines show as spurious changes. Normalizing both trees to LF makes
  * the comparison truly line-ending agnostic. Files containing a NUL byte are
@@ -138,17 +122,17 @@ function normalizeEol(dir: string): void {
 }
 
 /**
- * Removes transient codegen handoff files (`.tsp-codegen-*.json`) from a tree.
- * These are written by the TypeSpec emit step for the Python batch step; they
- * embed absolute machine-local paths and are not real generated output, so they
- * would otherwise show up as noise in the diff.
+ * Removes transient codegen handoff files (configured via `pruneFilePrefixes`)
+ * from a tree. These embed absolute machine-local paths and are not real
+ * generated output, so they would otherwise show up as noise in the diff.
  */
 function pruneIntermediates(dir: string): void {
+  if (CONFIG.pruneFilePrefixes.length === 0) return;
   for (const entry of readdirSync(dir, { withFileTypes: true })) {
     const full = join(dir, entry.name);
     if (entry.isDirectory()) {
       pruneIntermediates(full);
-    } else if (entry.isFile() && entry.name.startsWith(".tsp-codegen-")) {
+    } else if (entry.isFile() && CONFIG.pruneFilePrefixes.some((p) => entry.name.startsWith(p))) {
       rmSync(full, { force: true });
     }
   }
@@ -171,20 +155,27 @@ function parseNumstat(numstat: string): { files: number; additions: number; dele
   return { files, additions, deletions };
 }
 
-async function main(): Promise {
+/** Renders the HTML diff for one package. Returns the produced summary. */
+export async function render(options: RenderOptions): Promise {
+  CONFIG = readRegenDiffConfig(options.packageRoot);
+  GENERATED_DIR = options.generated
+    ? resolve(options.generated)
+    : resolve(CONFIG.packageRoot, CONFIG.generatedDir);
+  OUTPUT_DIR = options.output
+    ? resolve(options.output)
+    : resolve(CONFIG.packageRoot, "temp/diff-site");
+  TITLE = options.title ?? CONFIG.title;
+
   // Validate current output exists.
-  for (const flavor of FLAVORS) {
+  for (const flavor of CONFIG.flavors) {
     if (!existsSync(join(GENERATED_DIR, flavor))) {
-      console.error(
-        pc.red(
-          `Missing ${join(GENERATED_DIR, flavor)}. Run "npm run regenerate" before render-diff.`,
-        ),
+      throw new Error(
+        `Missing ${join(GENERATED_DIR, flavor)}. Run the package's regenerate step before render-diff.`,
       );
-      process.exit(1);
     }
   }
 
-  const config = readAssetsConfig(PACKAGE_ROOT);
+  const config = readAssetsConfig(CONFIG.packageRoot);
   const baselineTag = config?.tag ?? "";
 
   const workDir = await mkdtemp(join(tmpdir(), "typespec-diff-"));
@@ -200,15 +191,15 @@ async function main(): Promise {
     let note: string | undefined;
     if (config && config.tag) {
       console.log(pc.cyan(`Restoring baseline ${config.assetsRepo}@${config.tag}...`));
-      await restoreFullBaseline(config, baselineDir);
+      await restoreFullBaseline(config, baselineDir, CONFIG.flavors);
     } else {
       note =
         "No baseline tag is configured in assets.json yet; the entire current output is shown as added.";
       console.warn(pc.yellow(note));
     }
 
-    // Normalize line endings to LF on both sides so EOL artifacts (e.g. a
-    // Windows-generated baseline with `\r\r\n`) don't masquerade as real diffs.
+    // Normalize line endings to LF on both sides so EOL artifacts don't
+    // masquerade as real diffs.
     normalizeEol(currentDir);
     normalizeEol(baselineDir);
 
@@ -217,44 +208,25 @@ async function main(): Promise {
     pruneIntermediates(baselineDir);
 
     // git diff --no-index returns exit code 1 when there are differences.
-    // --ignore-cr-at-eol makes the diff line-ending agnostic: the baseline may
-    // have been pushed from Windows (CRLF) while CI regenerates on Linux (LF),
-    // and without this every line shows as changed (pure line-ending noise).
-    const diffText = stripCr(
-      git(
-        [
-          "-c",
-          "core.quotepath=false",
-          "diff",
-          "--no-index",
-          "--no-color",
-          "--ignore-cr-at-eol",
-          "--",
-          "baseline",
-          "current",
-        ],
-        workDir,
-        true,
-      ),
-    );
-    const numstat = git(
-      [
-        "-c",
-        "core.quotepath=false",
-        "diff",
-        "--no-index",
-        "--numstat",
-        "--ignore-cr-at-eol",
-        "--",
-        "baseline",
-        "current",
-      ],
-      workDir,
-      true,
-    );
+    // --ignore-cr-at-eol makes the diff line-ending agnostic.
+    const diffArgs = (extra: string[]) => [
+      "-c",
+      "core.quotepath=false",
+      "diff",
+      "--no-index",
+      ...extra,
+      "--ignore-cr-at-eol",
+      "--",
+      "baseline",
+      "current",
+    ];
+    const diffText = stripCr(git(diffArgs(["--no-color"]), workDir, true));
+    const numstat = git(diffArgs(["--numstat"]), workDir, true);
     const counts = parseNumstat(numstat);
 
     const summary: DiffSummary = {
+      slug: CONFIG.slug,
+      title: TITLE,
       changed: diffText.trim().length > 0,
       filesChanged: counts.files,
       additions: counts.additions,
@@ -268,17 +240,133 @@ async function main(): Promise {
     writeFileSync(join(OUTPUT_DIR, "summary.json"), JSON.stringify(summary, null, 2) + "\n");
     writeSite(diffText, summary);
 
+    const indexPath = join(OUTPUT_DIR, "index.html");
     console.log(
       pc.green(
         `Diff rendered to ${OUTPUT_DIR} ` +
           `(${summary.filesChanged} files, +${summary.additions}/-${summary.deletions}).`,
       ),
     );
+    console.log(pc.cyan(`View it at ${pathToFileURL(indexPath).href}`));
+    if (options.open) {
+      openInBrowser(indexPath);
+    }
+
+    // Open the diff natively in VS Code: persist both normalized trees to a
+    // stable path and pop a side-by-side editor for each changed file. The
+    // generated output is git-ignored, so it never shows in Source Control.
+    if (options.vscode) {
+      const treesDir = resolve(CONFIG.packageRoot, "temp/diff-trees");
+      const baselineOut = join(treesDir, "baseline");
+      const currentOut = join(treesDir, "current");
+      rmSync(treesDir, { recursive: true, force: true });
+      mkdirSync(treesDir, { recursive: true });
+      await cp(baselineDir, baselineOut, { recursive: true });
+      await cp(currentDir, currentOut, { recursive: true });
+      const max = options.max && options.max > 0 ? options.max : 40;
+      openInVscode(diffText, baselineOut, currentOut, summary.filesChanged, max);
+    }
+
+    return summary;
   } finally {
     rmSync(workDir, { recursive: true, force: true });
   }
 }
 
+/** Opens a local file in the OS default browser; never fails the run. */
+function openInBrowser(target: string): void {
+  try {
+    if (process.platform === "win32") {
+      execFileSync("cmd", ["/c", "start", "", target], { stdio: "ignore" });
+    } else if (process.platform === "darwin") {
+      execFileSync("open", [target], { stdio: "ignore" });
+    } else {
+      execFileSync("xdg-open", [target], { stdio: "ignore" });
+    }
+  } catch (err) {
+    console.warn(pc.yellow(`Could not open a browser automatically: ${err}`));
+  }
+}
+
+/**
+ * Parses `diff --git a/baseline/ b/current/` header lines to recover
+ * the per-file relative paths that changed.
+ */
+function changedRelPaths(diffText: string): string[] {
+  const paths: string[] = [];
+  for (const line of diffText.split("\n")) {
+    const m = /^diff --git a\/baseline\/(.+?) b\/current\/(.+)$/.exec(line);
+    if (m) paths.push(m[2]);
+  }
+  return paths;
+}
+
+/**
+ * Opens each changed file as a native VS Code editor diff (`code --diff
+ *  `). Added/removed files (one side missing) are opened on
+ * their own. Capped at `max` tabs.
+ */
+function openInVscode(
+  diffText: string,
+  baselineDir: string,
+  currentDir: string,
+  filesChanged: number,
+  max: number,
+): void {
+  const rels = changedRelPaths(diffText);
+
+  console.log(pc.cyan(`Baseline tree:  ${baselineDir}`));
+  console.log(pc.cyan(`Current tree:   ${currentDir}`));
+
+  if (rels.length === 0) {
+    console.log(pc.green("No changed files — nothing to open in VS Code."));
+    return;
+  }
+
+  // `code` is a shell script / .cmd on most platforms, so it must be launched
+  // through a shell (Node refuses to execFile a .cmd directly). Compose a single
+  // quoted command string so the shell parses paths with spaces correctly.
+  const q = (p: string) => `"${p.replace(/"/g, '\\"')}"`;
+  const code = (parts: string[]) => execSync(["code", ...parts].join(" "), { stdio: "ignore" });
+
+  const toOpen = rels.slice(0, max);
+  if (rels.length > max) {
+    console.warn(
+      pc.yellow(
+        `${filesChanged} files changed; opening the first ${max} in VS Code. ` +
+          `Use --max  to open more, or the HTML page for all of them.`,
+      ),
+    );
+  }
+
+  let opened = 0;
+  for (const rel of toOpen) {
+    const baseFile = join(baselineDir, rel);
+    const curFile = join(currentDir, rel);
+    const hasBase = existsSync(baseFile);
+    const hasCur = existsSync(curFile);
+    try {
+      if (hasBase && hasCur) {
+        code(["--diff", q(baseFile), q(curFile)]);
+      } else if (hasCur) {
+        code([q(curFile)]); // added
+      } else if (hasBase) {
+        code([q(baseFile)]); // removed
+      }
+      opened += 1;
+    } catch (err) {
+      console.warn(
+        pc.yellow(
+          `Could not launch VS Code (is the "code" command on PATH?). ` +
+            `Compare the two trees above manually. Details: ${err}`,
+        ),
+      );
+      return;
+    }
+  }
+  console.log(pc.green(`Opened ${opened} file diff(s) in VS Code.`));
+}
+
 interface FileDiff {
   /** Display path (baseline/current prefixes stripped). */
   path: string;
@@ -289,10 +377,38 @@ interface FileDiff {
   status: "added" | "removed" | "modified";
 }
 
+/**
+ * Rewrites a file chunk's diff header so both sides share the same path.
+ *
+ * The diff comes from `git diff --no-index baseline current`, so every header
+ * reads `a/baseline/` vs `b/current/`. Because those two paths
+ * differ only by the temp-dir prefix, diff2html mistakes every file for a
+ * RENAME. Stripping the `baseline/`/`current/` prefixes makes old === new path,
+ * so it renders as a normal modification.
+ */
+function normalizeChunkHeader(chunk: string): string {
+  const lines = chunk.split("\n");
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+    if (line.startsWith("@@")) break; // header is done once hunks begin
+    if (line.startsWith("diff --git ")) {
+      lines[i] = line.replace(/ a\/baseline\//g, " a/").replace(/ b\/current\//g, " b/");
+    } else if (line.startsWith("--- ")) {
+      lines[i] = line.replace(/^--- a\/baseline\//, "--- a/");
+    } else if (line.startsWith("+++ ")) {
+      lines[i] = line.replace(/^\+\+\+ b\/current\//, "+++ b/");
+    } else if (line.startsWith("rename from ")) {
+      lines[i] = line.replace(/^rename from baseline\//, "rename from ");
+    } else if (line.startsWith("rename to ")) {
+      lines[i] = line.replace(/^rename to current\//, "rename to ");
+    }
+  }
+  return lines.join("\n");
+}
+
 /** Splits a `git diff --no-index` blob into one chunk per file. */
 function splitDiffByFile(diffText: string): FileDiff[] {
   const files: FileDiff[] = [];
-  // Each file section begins with a line `diff --git a/... b/...`.
   const sections = diffText.split(/(?=^diff --git )/m).filter((s) => s.startsWith("diff --git "));
   for (const chunk of sections) {
     const lines = chunk.split("\n");
@@ -323,7 +439,7 @@ function splitDiffByFile(diffText: string): FileDiff[] {
     const display = strip(isAdded ? newPath : oldPath) || strip(newPath) || "(unknown)";
     files.push({
       path: display,
-      chunk,
+      chunk: normalizeChunkHeader(chunk),
       additions,
       deletions,
       status: isAdded ? "added" : isRemoved ? "removed" : "modified",
@@ -335,9 +451,7 @@ function splitDiffByFile(diffText: string): FileDiff[] {
 
 /** Writes the full multi-page diff site to OUTPUT_DIR. */
 function writeSite(diffText: string, summary: DiffSummary): void {
-  const cssPath = require.resolve("diff2html/bundles/css/diff2html.min.css");
-  const sharedCss = readFileSync(cssPath, "utf8") + "\n" + SITE_CSS;
-  writeFileSync(join(OUTPUT_DIR, "diff2html.css"), sharedCss);
+  writeFileSync(join(OUTPUT_DIR, "styles.css"), SITE_CSS);
 
   if (!summary.changed) {
     writeFileSync(
@@ -465,11 +579,7 @@ ${renderTreeChildren(node, depth + 1)}
 `;
 }
 
-function renderFileRow(
-  name: string,
-  href: string,
-  file: FileDiff,
-): string {
+function renderFileRow(name: string, href: string, file: FileDiff): string {
   const badge =
     file.status === "added"
       ? `A`
@@ -481,11 +591,63 @@ function renderFileRow(
 `;
 }
 
-/** One page per changed file: rich side-by-side diff with prev/next nav. */
+/**
+ * Renders a unified-diff chunk as line-numbered, colorized HTML rows.
+ * Replaces the former diff2html dependency with a small self-contained renderer
+ * so the tool has zero runtime rendering dependencies.
+ */
+function renderUnifiedDiff(chunk: string): string {
+  const rows: string[] = [];
+  let oldLn = 0;
+  let newLn = 0;
+  const row = (cls: string, oldNo: string, newNo: string, text: string): string =>
+    `
${oldNo}${newNo}` + + `${escapeHtml(text.length ? text : " ")}
`; + + for (const raw of chunk.split("\n")) { + const line = raw.replace(/\r$/, ""); + if (line.startsWith("@@")) { + const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (m) { + oldLn = Number(m[1]); + newLn = Number(m[2]); + } + rows.push(row("hunk", "", "", line)); + continue; + } + if ( + line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("new file") || + line.startsWith("deleted file") || + line.startsWith("rename ") || + line.startsWith("similarity ") || + line.startsWith("old mode") || + line.startsWith("new mode") || + line.startsWith("\\") + ) { + rows.push(row("meta", "", "", line)); + continue; + } + if (line.startsWith("+")) { + rows.push(row("add", "", String(newLn++), line)); + } else if (line.startsWith("-")) { + rows.push(row("del", String(oldLn++), "", line)); + } else { + rows.push(row("ctx", String(oldLn++), String(newLn++), line)); + } + } + return `
${rows.join("")}
`; +} + +/** One page per changed file: line-numbered colorized diff with prev/next nav. */ function renderFilePage(file: FileDiff, files: FileDiff[], index: number): string { const pad = String(files.length).length; const fileName = (i: number): string => `${String(i + 1).padStart(pad, "0")}.html`; - const prev = index > 0 ? `← Prev` : `← Prev`; + const prev = + index > 0 ? `← Prev` : `← Prev`; const next = index < files.length - 1 ? `Next →` @@ -499,16 +661,7 @@ function renderFilePage(file: FileDiff, files: FileDiff[], index: number): strin (1024 * 1024) ).toFixed(1)} MB). View it in the raw diff.`; } else { - try { - diffBody = diff2html(file.chunk, { - drawFileList: false, - matching: "lines", - outputFormat: "side-by-side", - }); - } catch (err) { - console.warn(pc.yellow(`Rendering ${file.path} failed (${err}); showing raw chunk.`)); - diffBody = `
${escapeHtml(file.chunk)}
`; - } + diffBody = renderUnifiedDiff(file.chunk); } const nav = `