From 358e13e51ccb0e7b303ef286eaffcce01bf64856 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 10:48:06 -0700 Subject: [PATCH 01/13] test: trigger regen diff workflow From cf710e24cefe853d94b018f36ec4691e80a16ac1 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 11:01:29 -0700 Subject: [PATCH 02/13] fix: pass diff title via env var to avoid shell quoting error --- .github/workflows/python-regen-diff.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-regen-diff.yml b/.github/workflows/python-regen-diff.yml index 95d23bdc6e6..1546feb6999 100644 --- a/.github/workflows/python-regen-diff.yml +++ b/.github/workflows/python-regen-diff.yml @@ -62,7 +62,9 @@ jobs: run: npm run regenerate - name: Render HTML diff - run: npm run regenerate:render-diff -- --output "${{ runner.temp }}/diff-site" --title "Python emitter — generated test diff (PR #${{ github.event.pull_request.number || 'manual' }})" + env: + DIFF_TITLE: "Python emitter generated test diff (PR #${{ github.event.pull_request.number || 'manual' }})" + run: npm run regenerate:render-diff -- --output "${{ runner.temp }}/diff-site" --title "$DIFF_TITLE" - name: Record PR metadata run: echo "${{ github.event.pull_request.number }}" > "${{ runner.temp }}/diff-site/pr.txt" From e4e83179c6083aa5973a60942d8ebf4c8cd30703 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 11:17:28 -0700 Subject: [PATCH 03/13] fix: guard against oversized diffs in render-diff (RangeError) --- .../eng/scripts/ci/render-diff.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index c207d5f83bd..7f22d9a3974 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -69,6 +69,12 @@ const OUTPUT_DIR = argv.values.output : resolve(PACKAGE_ROOT, "temp/diff-site"); const TITLE = argv.values.title ?? "Python emitter — generated test diff"; +// diff2html builds the entire page as a single in-memory string. Side-by-side +// HTML is ~10-20x the size of the raw unified diff, and V8 caps a string at +// ~512MB, so a very large diff throws `RangeError: Invalid string length`. +// Above this raw-diff size we skip inline rendering and link to diff.txt instead. +const MAX_INLINE_DIFF_BYTES = 12 * 1024 * 1024; + interface DiffSummary { changed: boolean; filesChanged: number; @@ -191,6 +197,10 @@ async function main(): Promise { rmSync(OUTPUT_DIR, { recursive: true, force: true }); mkdirSync(OUTPUT_DIR, { recursive: true }); writeFileSync(join(OUTPUT_DIR, "summary.json"), JSON.stringify(summary, null, 2) + "\n"); + // Always persist the raw unified diff so a too-large diff is still viewable. + if (summary.changed) { + writeFileSync(join(OUTPUT_DIR, "diff.txt"), diffText); + } writeFileSync(join(OUTPUT_DIR, "index.html"), renderHtml(diffText, summary)); console.log( @@ -209,13 +219,27 @@ function renderHtml(diffText: string, summary: DiffSummary): string { const cssPath = require.resolve("diff2html/bundles/css/diff2html.min.css"); const css = readFileSync(cssPath, "utf8"); - const body = summary.changed - ? diff2html(diffText, { + const diffBytes = Buffer.byteLength(diffText, "utf8"); + const tooLarge = diffBytes > MAX_INLINE_DIFF_BYTES; + + let body: string; + if (!summary.changed) { + body = `
✅ No differences from the baseline.
`; + } else if (tooLarge) { + body = oversizedNotice(diffBytes); + } else { + try { + body = diff2html(diffText, { drawFileList: true, matching: "lines", outputFormat: "side-by-side", - }) - : `
✅ No differences from the baseline.
`; + }); + } catch (err) { + // Most commonly `RangeError: Invalid string length` for very large diffs. + console.warn(pc.yellow(`Inline diff rendering failed (${err}); falling back to raw diff.`)); + body = oversizedNotice(diffBytes); + } + } const noteHtml = summary.note ? `

⚠️ ${escapeHtml(summary.note)}

` : ""; const tagLine = summary.baselineTag @@ -254,6 +278,14 @@ ${body} `; } +function oversizedNotice(diffBytes: number): string { + const mb = (diffBytes / (1024 * 1024)).toFixed(1); + return `
+ ⚠️ The diff is too large to render inline (${mb} MB). +
Download the raw unified diff instead: diff.txt. +
`; +} + function escapeHtml(value: string): string { return value .replace(/&/g, "&") From 3110edd18ee52e9ef1fffc1e5f0f5ed50c7c08f3 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 11:43:18 -0700 Subject: [PATCH 04/13] feat: render diff as navigable per-file pages instead of one giant HTML --- .../eng/scripts/ci/render-diff.ts | 271 ++++++++++++++---- 1 file changed, 221 insertions(+), 50 deletions(-) diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index 7f22d9a3974..c6648215103 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -69,11 +69,11 @@ const OUTPUT_DIR = argv.values.output : resolve(PACKAGE_ROOT, "temp/diff-site"); const TITLE = argv.values.title ?? "Python emitter — generated test diff"; -// diff2html builds the entire page as a single in-memory string. Side-by-side -// HTML is ~10-20x the size of the raw unified diff, and V8 caps a string at -// ~512MB, so a very large diff throws `RangeError: Invalid string length`. -// Above this raw-diff size we skip inline rendering and link to diff.txt instead. -const MAX_INLINE_DIFF_BYTES = 12 * 1024 * 1024; +// 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.
+const MAX_FILE_DIFF_BYTES = 2 * 1024 * 1024;
 
 interface DiffSummary {
   changed: boolean;
@@ -197,11 +197,7 @@ async function main(): Promise {
     rmSync(OUTPUT_DIR, { recursive: true, force: true });
     mkdirSync(OUTPUT_DIR, { recursive: true });
     writeFileSync(join(OUTPUT_DIR, "summary.json"), JSON.stringify(summary, null, 2) + "\n");
-    // Always persist the raw unified diff so a too-large diff is still viewable.
-    if (summary.changed) {
-      writeFileSync(join(OUTPUT_DIR, "diff.txt"), diffText);
-    }
-    writeFileSync(join(OUTPUT_DIR, "index.html"), renderHtml(diffText, summary));
+    writeSite(diffText, summary);
 
     console.log(
       pc.green(
@@ -214,77 +210,252 @@ async function main(): Promise {
   }
 }
 
-/** Builds a self-contained HTML page embedding the diff2html CSS + fragment. */
-function renderHtml(diffText: string, summary: DiffSummary): string {
-  const cssPath = require.resolve("diff2html/bundles/css/diff2html.min.css");
-  const css = readFileSync(cssPath, "utf8");
+interface FileDiff {
+  /** Display path (baseline/current prefixes stripped). */
+  path: string;
+  /** Raw unified-diff chunk for just this file. */
+  chunk: string;
+  additions: number;
+  deletions: number;
+  status: "added" | "removed" | "modified";
+}
+
+/** 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");
+    let oldPath = "";
+    let newPath = "";
+    let additions = 0;
+    let deletions = 0;
+    for (const line of lines) {
+      if (line.startsWith("--- ")) {
+        oldPath = line.slice(4).trim();
+      } else if (line.startsWith("+++ ")) {
+        newPath = line.slice(4).trim();
+      } else if (line.startsWith("+") && !line.startsWith("+++")) {
+        additions += 1;
+      } else if (line.startsWith("-") && !line.startsWith("---")) {
+        deletions += 1;
+      }
+    }
+    const strip = (p: string): string =>
+      p
+        .replace(/^["ab]\//, "")
+        .replace(/^a\//, "")
+        .replace(/^b\//, "")
+        .replace(/^baseline\//, "")
+        .replace(/^current\//, "");
+    const isAdded = oldPath === "/dev/null";
+    const isRemoved = newPath === "/dev/null";
+    const display = strip(isAdded ? newPath : oldPath) || strip(newPath) || "(unknown)";
+    files.push({
+      path: display,
+      chunk,
+      additions,
+      deletions,
+      status: isAdded ? "added" : isRemoved ? "removed" : "modified",
+    });
+  }
+  files.sort((a, b) => a.path.localeCompare(b.path));
+  return files;
+}
 
-  const diffBytes = Buffer.byteLength(diffText, "utf8");
-  const tooLarge = diffBytes > MAX_INLINE_DIFF_BYTES;
+/** 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);
 
-  let body: string;
   if (!summary.changed) {
-    body = `
✅ No differences from the baseline.
`; - } else if (tooLarge) { - body = oversizedNotice(diffBytes); + writeFileSync( + join(OUTPUT_DIR, "index.html"), + pageShell( + TITLE, + headerHtml(summary), + `
✅ No differences from the baseline.
`, + ".", + ), + ); + return; + } + + // Keep a full raw diff available for download. + writeFileSync(join(OUTPUT_DIR, "diff.txt"), diffText); + + const files = splitDiffByFile(diffText); + const filesDir = join(OUTPUT_DIR, "files"); + mkdirSync(filesDir, { recursive: true }); + + const pad = String(files.length).length; + files.forEach((file, i) => { + const name = `${String(i + 1).padStart(pad, "0")}.html`; + writeFileSync(join(filesDir, name), renderFilePage(file, files, i)); + }); + + writeFileSync(join(OUTPUT_DIR, "index.html"), renderIndexPage(files, summary, pad)); +} + +/** Index page: a searchable, navigable list of all changed files. */ +function renderIndexPage(files: FileDiff[], summary: DiffSummary, pad: number): string { + const rows = files + .map((f, i) => { + const href = `files/${String(i + 1).padStart(pad, "0")}.html`; + const badge = + f.status === "added" + ? `added` + : f.status === "removed" + ? `removed` + : `modified`; + return ` + ${badge} + ${escapeHtml(f.path)} + +${f.additions} + -${f.deletions} +`; + }) + .join("\n"); + + const body = ` + +

Click a file to view its side-by-side diff. Download the full raw diff.

+ + + +${rows} + +
File+
+`; + + return pageShell(TITLE, headerHtml(summary), body, "."); +} + +/** One page per changed file: rich side-by-side 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 next = + index < files.length - 1 + ? `Next →` + : `Next →`; + + const chunkBytes = Buffer.byteLength(file.chunk, "utf8"); + let diffBody: string; + if (chunkBytes > MAX_FILE_DIFF_BYTES) { + diffBody = `
⚠️ This file's diff is too large to render (${( + chunkBytes / + (1024 * 1024) + ).toFixed(1)} MB). View it in the raw diff.
`; } else { try { - body = diff2html(diffText, { - drawFileList: true, + diffBody = diff2html(file.chunk, { + drawFileList: false, matching: "lines", outputFormat: "side-by-side", }); } catch (err) { - // Most commonly `RangeError: Invalid string length` for very large diffs. - console.warn(pc.yellow(`Inline diff rendering failed (${err}); falling back to raw diff.`)); - body = oversizedNotice(diffBytes); + console.warn(pc.yellow(`Rendering ${file.path} failed (${err}); showing raw chunk.`)); + diffBody = `
${escapeHtml(file.chunk)}
`; } } - const noteHtml = summary.note ? `

⚠️ ${escapeHtml(summary.note)}

` : ""; + const nav = ``; + + const header = `
+

${escapeHtml(file.path)}

+
+${file.additions} / -${file.deletions} · ${file.status}
+
`; + + return pageShell(`${file.path} · ${TITLE}`, header + nav, diffBody, "..", nav); +} + +function headerHtml(summary: DiffSummary): string { const tagLine = summary.baselineTag ? `Baseline tag: ${escapeHtml(summary.baselineTag)}` : "Baseline: none (not bootstrapped)"; + const noteHtml = summary.note ? `

⚠️ ${escapeHtml(summary.note)}

` : ""; + return `
+

${escapeHtml(TITLE)}

+
${tagLine}  ·  ${summary.filesChanged} files changed  ·  +${summary.additions} / -${summary.deletions}
+
${noteHtml}`; +} +/** Wraps body content in a full HTML document linking the shared stylesheet. */ +function pageShell( + title: string, + headerAndNav: string, + body: string, + cssBase: string, + footerNav = "", +): string { return ` -${escapeHtml(TITLE)} - +${escapeHtml(title)} + -
-

${escapeHtml(TITLE)}

-
${tagLine}  ·  ${summary.filesChanged} files changed  ·  +${summary.additions} / -${summary.deletions}
-
-${noteHtml} +${headerAndNav}
${body}
+${footerNav} `; } -function oversizedNotice(diffBytes: number): string { - const mb = (diffBytes / (1024 * 1024)).toFixed(1); - return `
- ⚠️ The diff is too large to render inline (${mb} MB). -
Download the raw unified diff instead: diff.txt. -
`; -} +/** Site chrome shared across all pages (appended to the diff2html stylesheet). */ +const SITE_CSS = ` +body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1f2328; } +header { padding: 16px 20px; background: #24292f; color: #fff; } +header h1 { margin: 0 0 6px; font-size: 18px; word-break: break-all; } +header .meta { font-size: 13px; opacity: 0.9; } +header code { background: rgba(255,255,255,0.15); padding: 1px 5px; border-radius: 4px; } +.add { color: #3fb950; } +.del { color: #f85149; } +.note { color: #9a6700; background: #fff8c5; margin: 12px 20px; padding: 10px 14px; border-radius: 6px; } +.no-changes { margin: 40px 20px; font-size: 16px; color: #1a7f37; } +.content { padding: 12px 16px; } +.hint { color: #57606a; font-size: 13px; margin: 8px 0 16px; } +#filter { width: 100%; box-sizing: border-box; padding: 8px 12px; font-size: 14px; border: 1px solid #d0d7de; border-radius: 6px; margin-top: 12px; } +table.file-list { width: 100%; border-collapse: collapse; font-size: 13px; } +table.file-list th { text-align: left; color: #57606a; font-weight: 600; border-bottom: 1px solid #d0d7de; padding: 6px 8px; } +table.file-list td { padding: 5px 8px; border-bottom: 1px solid #eaeef2; } +table.file-list td.path-cell { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +table.file-list td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +table.file-list a { color: #0969da; text-decoration: none; } +table.file-list a:hover { text-decoration: underline; } +.st { font-size: 11px; padding: 1px 6px; border-radius: 999px; text-transform: uppercase; letter-spacing: .03em; } +.st.added { background: #dafbe1; color: #1a7f37; } +.st.removed { background: #ffebe9; color: #cf222e; } +.st.modified { background: #ddf4ff; color: #0969da; } +nav.filenav { display: flex; align-items: center; gap: 14px; padding: 8px 16px; background: #f6f8fa; border-bottom: 1px solid #d0d7de; font-size: 13px; } +nav.filenav .spacer { flex: 1; } +nav.filenav a { color: #0969da; text-decoration: none; } +nav.filenav .muted { color: #8c959f; } +nav.filenav .counter { color: #57606a; } +pre.raw { white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: #f6f8fa; padding: 12px; border-radius: 6px; } +`; function escapeHtml(value: string): string { return value From 5c3b6006469059be192b9b798c0ca6ce0bc610a5 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 12:16:45 -0700 Subject: [PATCH 05/13] fix: ignore CRLF/LF noise in regen diff; store baseline as LF --- .../eng/scripts/ci/push-assets.ts | 8 ++++ .../eng/scripts/ci/render-diff.ts | 38 ++++++++++++------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/http-client-python/eng/scripts/ci/push-assets.ts b/packages/http-client-python/eng/scripts/ci/push-assets.ts index b1f42e71184..069bd234c1c 100644 --- a/packages/http-client-python/eng/scripts/ci/push-assets.ts +++ b/packages/http-client-python/eng/scripts/ci/push-assets.ts @@ -108,6 +108,9 @@ async function main(): Promise { 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. + 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 @@ -129,6 +132,11 @@ async function main(): Promise { await cp(join(GENERATED_DIR, 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. + writeFileSync(join(tempDir, ".gitattributes"), "* text=auto eol=lf\n"); + git(["add", "-A"]); const status = git(["status", "--porcelain"], { allowFail: true }); if (!status) { diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index c6648215103..f46288847ce 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -101,6 +101,11 @@ function git(args: string[], cwd: string, allowFail = false): string { } } +/** Removes carriage returns so diff2html doesn't render literal `^M` markers. */ +function stripCr(text: string): string { + return text.replace(/\r/g, ""); +} + /** Parses `git diff --numstat` output into aggregate counts. */ function parseNumstat(numstat: string): { files: number; additions: number; deletions: number } { let files = 0; @@ -155,19 +160,25 @@ async function main(): Promise { } // git diff --no-index returns exit code 1 when there are differences. - const diffText = git( - [ - "-c", - "core.quotepath=false", - "diff", - "--no-index", - "--no-color", - "--", - "baseline", - "current", - ], - workDir, - true, + // --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( [ @@ -176,6 +187,7 @@ async function main(): Promise { "diff", "--no-index", "--numstat", + "--ignore-cr-at-eol", "--", "baseline", "current", From 4a7536cb742f4045a0153ffcabe4d649031502f5 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 12:35:34 -0700 Subject: [PATCH 06/13] feat: group regen diff index by folder tree with aggregated counts --- .../eng/scripts/ci/render-diff.ts | 153 +++++++++++++----- 1 file changed, 116 insertions(+), 37 deletions(-) diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index f46288847ce..182ad70d75c 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -311,49 +311,119 @@ function writeSite(diffText: string, summary: DiffSummary): void { writeFileSync(join(OUTPUT_DIR, "index.html"), renderIndexPage(files, summary, pad)); } -/** Index page: a searchable, navigable list of all changed files. */ +/** Index page: a folder-grouped, searchable tree of all changed files. */ function renderIndexPage(files: FileDiff[], summary: DiffSummary, pad: number): string { - const rows = files - .map((f, i) => { - const href = `files/${String(i + 1).padStart(pad, "0")}.html`; - const badge = - f.status === "added" - ? `added` - : f.status === "removed" - ? `removed` - : `modified`; - return ` - ${badge} - ${escapeHtml(f.path)} - +${f.additions} - -${f.deletions} -`; - }) - .join("\n"); + const root = buildTree(files, pad); + const tree = renderTreeChildren(root, 0); const body = ` -

Click a file to view its side-by-side diff. Download the full raw diff.

- - - -${rows} - -
File+
+
+ + + Grouped by folder · download the full raw diff. +
+
+${tree} +
`; return pageShell(TITLE, headerHtml(summary), body, "."); } +interface TreeNode { + dirs: Map; + files: { name: string; href: string; file: FileDiff }[]; + additions: number; + deletions: number; + count: number; +} + +function newTreeNode(): TreeNode { + return { dirs: new Map(), files: [], additions: 0, deletions: 0, count: 0 }; +} + +/** Builds a directory tree from the (sorted) flat file list. */ +function buildTree(files: FileDiff[], pad: number): TreeNode { + const root = newTreeNode(); + files.forEach((file, i) => { + const href = `files/${String(i + 1).padStart(pad, "0")}.html`; + const segments = file.path.split("/"); + const fileName = segments.pop() ?? file.path; + let node = root; + node.count += 1; + node.additions += file.additions; + node.deletions += file.deletions; + for (const seg of segments) { + let child = node.dirs.get(seg); + if (!child) { + child = newTreeNode(); + node.dirs.set(seg, child); + } + child.count += 1; + child.additions += file.additions; + child.deletions += file.deletions; + node = child; + } + node.files.push({ name: fileName, href, file }); + }); + return root; +} + +function renderTreeChildren(node: TreeNode, depth: number): string { + const dirNames = [...node.dirs.keys()].sort((a, b) => a.localeCompare(b)); + const dirHtml = dirNames + .map((name) => renderDir(name, node.dirs.get(name)!, depth)) + .join("\n"); + const fileHtml = node.files.map((f) => renderFileRow(f.name, f.href, f.file)).join("\n"); + return dirHtml + (dirHtml && fileHtml ? "\n" : "") + fileHtml; +} + +function renderDir(name: string, node: TreeNode, depth: number): string { + // Open the top two levels (flavor + spec) by default; collapse deeper ones. + const open = depth < 2 ? " open" : ""; + return `
+ ${escapeHtml(name)}/ ${node.count} files +${node.additions} -${node.deletions} +
+${renderTreeChildren(node, depth + 1)} +
+
`; +} + +function renderFileRow( + name: string, + href: string, + file: FileDiff, +): string { + const badge = + file.status === "added" + ? `A` + : file.status === "removed" + ? `D` + : `M`; + return `
+ ${badge}${escapeHtml(name)}+${file.additions} -${file.deletions} +
`; +} + /** One page per changed file: rich side-by-side diff with prev/next nav. */ function renderFilePage(file: FileDiff, files: FileDiff[], index: number): string { const pad = String(files.length).length; @@ -450,14 +520,23 @@ header code { background: rgba(255,255,255,0.15); padding: 1px 5px; border-radiu .content { padding: 12px 16px; } .hint { color: #57606a; font-size: 13px; margin: 8px 0 16px; } #filter { width: 100%; box-sizing: border-box; padding: 8px 12px; font-size: 14px; border: 1px solid #d0d7de; border-radius: 6px; margin-top: 12px; } -table.file-list { width: 100%; border-collapse: collapse; font-size: 13px; } -table.file-list th { text-align: left; color: #57606a; font-weight: 600; border-bottom: 1px solid #d0d7de; padding: 6px 8px; } -table.file-list td { padding: 5px 8px; border-bottom: 1px solid #eaeef2; } -table.file-list td.path-cell { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -table.file-list td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } -table.file-list a { color: #0969da; text-decoration: none; } -table.file-list a:hover { text-decoration: underline; } -.st { font-size: 11px; padding: 1px 6px; border-radius: 999px; text-transform: uppercase; letter-spacing: .03em; } +.treebar { display: flex; align-items: center; gap: 10px; margin: 10px 0 14px; flex-wrap: wrap; } +.treebar button { font-size: 12px; padding: 4px 10px; border: 1px solid #d0d7de; background: #f6f8fa; border-radius: 6px; cursor: pointer; } +.treebar button:hover { background: #eaeef2; } +.treebar .hint { margin: 0; } +.tree { font-size: 13px; } +details.dir { margin: 0; } +details.dir > summary { cursor: pointer; padding: 3px 6px; border-radius: 6px; list-style-position: inside; display: flex; align-items: center; gap: 8px; } +details.dir > summary:hover { background: #f0f3f6; } +details.dir > summary .dirname { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; } +details.dir > summary .counts { font-size: 11px; } +.counts { margin-left: auto; white-space: nowrap; font-variant-numeric: tabular-nums; display: inline-flex; gap: 8px; } +.children { margin-left: 16px; border-left: 1px solid #eaeef2; padding-left: 8px; } +.file-row { display: flex; align-items: center; gap: 8px; padding: 2px 6px; } +.file-row:hover { background: #f6f8fa; } +.file-row a { color: #0969da; text-decoration: none; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.file-row a:hover { text-decoration: underline; } +.st { font-size: 10px; font-weight: 700; width: 16px; height: 16px; line-height: 16px; text-align: center; border-radius: 4px; flex: none; } .st.added { background: #dafbe1; color: #1a7f37; } .st.removed { background: #ffebe9; color: #cf222e; } .st.modified { background: #ddf4ff; color: #0969da; } From 548fc4e17d6eff7f473e186772cebd32ed1708a3 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 13:32:10 -0700 Subject: [PATCH 07/13] test(python): add comment to version template to demo regen diff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/codegen/templates/version.py.jinja2 | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/version.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/version.py.jinja2 index 2086d0fc1d7..08faa5a6bb1 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/version.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/version.py.jinja2 @@ -3,4 +3,5 @@ {{ code_model.license_header }} {% endif %} +# Generated by the TypeSpec Python emitter. VERSION = "{{ code_model.options.get("package-version") }}" From 53c91eeb8964f1c815f801539f64e136dfafdb77 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 14:43:32 -0700 Subject: [PATCH 08/13] test: bump python regen baseline to spec-aligned tag 481763e148 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/http-client-python/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-python/assets.json b/packages/http-client-python/assets.json index 13cce63bc99..acc273622d4 100644 --- a/packages/http-client-python/assets.json +++ b/packages/http-client-python/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "l0lawrence/typespec-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/tests", - "Tag": "python/tests_7870c7a975" + "Tag": "python/tests_481763e148" } From 35f35d08654fddd14219c8d08e8ae337e374e2e5 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 15:32:09 -0700 Subject: [PATCH 09/13] render-diff: show modified files as modified, not renamed git diff --no-index prefixes each path with baseline/ vs current/, so diff2html mistook every file for a rename ({baseline -> current}). Strip those temp-dir prefixes from the per-file chunk header so old == new path and it renders as a normal modification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/ci/render-diff.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index 3c1f9496cd1..b2daede3290 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -289,6 +289,35 @@ 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 (showing `{baseline → current}`). 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[] = []; @@ -323,7 +352,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", From cc9ba36b742f92c9c5f79b2d3770d17c956e8005 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 15:47:32 -0700 Subject: [PATCH 10/13] render-diff: add local --open flow and convenience scripts Add a --open flag that prints a clickable file:// URL and opens the rendered diff in the default browser, plus two npm scripts: - regenerate:diff render the diff and open it - regenerate:review regenerate then render+open so contributors get the same grouped HTML diff locally, no PR/CI needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/ci/render-diff.ts | 28 ++++++++++++++++++- packages/http-client-python/package.json | 2 ++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index b2daede3290..e1b8700782e 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -31,7 +31,7 @@ import { createRequire } from "module"; import { tmpdir } from "os"; import { dirname, join, resolve } from "path"; import pc from "picocolors"; -import { fileURLToPath } from "url"; +import { fileURLToPath, pathToFileURL } from "url"; import { parseArgs } from "util"; import { FLAVORS, readAssetsConfig, restoreFullBaseline } from "./assets.js"; @@ -47,6 +47,7 @@ const argv = parseArgs({ output: { type: "string", short: "o" }, generated: { type: "string", short: "g" }, title: { type: "string", short: "t" }, + open: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, }); @@ -61,6 +62,7 @@ ${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. + --open Open the rendered diff in your default browser. -h, --help Show this help. `); process.exit(0); @@ -268,17 +270,41 @@ 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}).`, ), ); + // Print a clickable file:// URL so the page is one click away locally, and + // optionally pop it open in the default browser. + console.log(pc.cyan(`View it at ${pathToFileURL(indexPath).href}`)); + if (argv.values.open) { + openInBrowser(indexPath); + } } 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") { + // `start` is a cmd builtin; the empty first arg is the window title so a + // path with spaces isn't mistaken for one. + 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}`)); + } +} + interface FileDiff { /** Display path (baseline/current prefixes stripped). */ path: string; diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index 0a2dcfb9c65..76d25e81c02 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -52,6 +52,8 @@ "regenerate": "tsx ./eng/scripts/ci/regenerate.ts", "regenerate:push-assets": "tsx ./eng/scripts/ci/push-assets.ts", "regenerate:render-diff": "tsx ./eng/scripts/ci/render-diff.ts", + "regenerate:diff": "tsx ./eng/scripts/ci/render-diff.ts --open", + "regenerate:review": "npm run regenerate && npm run regenerate:diff", "ci": "npm run test:emitter && npm run ci:generated", "ci:generated": "tsx ./eng/scripts/ci/run-tests.ts --generator --env=ci", "change:version": "pnpm chronus version --ignore-policies --only @typespec/http-client-python", From 1f139d4acb5dcb346d7c0b6510a537e9da76a260 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 29 Jun 2026 09:10:00 -0700 Subject: [PATCH 11/13] add --vscode native diff mode to render-diff tests/**/generated is git-ignored, so the regenerated output never shows in VS Code Source Control. Add a --vscode mode that restores the assets-tag baseline, persists both normalized trees to temp/diff-trees, and opens a native 'code --diff' editor per changed file (capped via --max). New regenerate:vscode-diff script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/ci/render-diff.ts | 106 +++++++++++++++++- packages/http-client-python/package.json | 1 + 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/http-client-python/eng/scripts/ci/render-diff.ts b/packages/http-client-python/eng/scripts/ci/render-diff.ts index e1b8700782e..8699622e353 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/packages/http-client-python/eng/scripts/ci/render-diff.ts @@ -17,7 +17,7 @@ * tsx ./eng/scripts/ci/render-diff.ts [--output ] [--generated ] [--title ] */ -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { existsSync, mkdirSync, @@ -48,6 +48,8 @@ const argv = parseArgs({ generated: { type: "string", short: "g" }, title: { type: "string", short: "t" }, open: { type: "boolean" }, + vscode: { type: "boolean" }, + max: { type: "string" }, help: { type: "boolean", short: "h" }, }, }); @@ -63,6 +65,10 @@ ${pc.bold("Options:")} -g, --generated Current generated dir (default: tests/generated). -t, --title Title shown on the diff page. --open Open the rendered diff in your default browser. + --vscode Open each changed file as a native VS Code editor diff + (baseline vs current) and keep both trees on disk at + temp/diff-trees/ for use with a folder-compare extension. + --max Max number of files to open in VS Code (default 40). -h, --help Show this help. `); process.exit(0); @@ -283,6 +289,22 @@ async function main(): Promise { if (argv.values.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. This is + // the "VS Code changes" experience — the generated output is git-ignored, so + // it never shows up in Source Control on its own. + if (argv.values.vscode) { + const treesDir = resolve(PACKAGE_ROOT, "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 = Number(argv.values.max) > 0 ? Number(argv.values.max) : 40; + openInVscode(diffText, baselineOut, currentOut, summary.filesChanged, max); + } } finally { rmSync(workDir, { recursive: true, force: true }); } @@ -305,6 +327,88 @@ function openInBrowser(target: string): void { } } +/** + * 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; for larger sets the persisted folder pair is + * printed so a folder-compare extension or the HTML page can show the rest. + */ +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 + // (passing an args array with shell:true is deprecated, DEP0190). + 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, the HTML page for all of them, or a ` + + `folder-compare extension (e.g. "Compare Folders") on the two trees above.`, + ), + ); + } + + 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; diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index 76d25e81c02..f215e8749ce 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -53,6 +53,7 @@ "regenerate:push-assets": "tsx ./eng/scripts/ci/push-assets.ts", "regenerate:render-diff": "tsx ./eng/scripts/ci/render-diff.ts", "regenerate:diff": "tsx ./eng/scripts/ci/render-diff.ts --open", + "regenerate:vscode-diff": "tsx ./eng/scripts/ci/render-diff.ts --vscode", "regenerate:review": "npm run regenerate && npm run regenerate:diff", "ci": "npm run test:emitter && npm run ci:generated", "ci:generated": "tsx ./eng/scripts/ci/run-tests.ts --generator --env=ci", From 23d4a2201fa447efa48c5b241c6595544e2e885e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 29 Jun 2026 14:15:32 -0700 Subject: [PATCH 12/13] feat: generalize regen-diff into a shared config-driven tool Extract the Python-specific regen-diff baseline + HTML-diff PR-preview system into a self-contained, language-agnostic tool under eng/common/scripts/regen-diff (render + push-assets CLI driven by a per-package regen-diff.config.json). Convert the Python emitter to use it as the reference adopter and generalize the publish/cleanup workflows so a single pair serves every emitter language (slug-namespaced gh-pages previews, sticky comment, and regen-diff/ commit status). Adds an onboarding README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-regen-diff-publish.yml | 157 ------------ .github/workflows/python-regen-diff.yml | 6 +- ...iff-cleanup.yml => regen-diff-cleanup.yml} | 39 +-- .github/workflows/regen-diff-publish.yml | 206 ++++++++++++++++ eng/common/scripts/regen-diff/README.md | 155 ++++++++++++ eng/common/scripts/regen-diff/package.json | 21 ++ eng/common/scripts/regen-diff/src/assets.ts | 145 +++++++++++ eng/common/scripts/regen-diff/src/cli.ts | 118 +++++++++ eng/common/scripts/regen-diff/src/config.ts | 97 ++++++++ .../scripts/regen-diff/src}/push-assets.ts | 152 +++++------- .../scripts/regen-diff/src}/render-diff.ts | 226 +++++++----------- eng/common/scripts/regen-diff/tsconfig.json | 15 ++ .../packaging_templates/pyproject.toml.jinja2 | 2 +- packages/http-client-python/package.json | 8 +- .../http-client-python/regen-diff.config.json | 7 + 15 files changed, 941 insertions(+), 413 deletions(-) delete mode 100644 .github/workflows/python-regen-diff-publish.yml rename .github/workflows/{python-regen-diff-cleanup.yml => regen-diff-cleanup.yml} (58%) create mode 100644 .github/workflows/regen-diff-publish.yml create mode 100644 eng/common/scripts/regen-diff/README.md create mode 100644 eng/common/scripts/regen-diff/package.json create mode 100644 eng/common/scripts/regen-diff/src/assets.ts create mode 100644 eng/common/scripts/regen-diff/src/cli.ts create mode 100644 eng/common/scripts/regen-diff/src/config.ts rename {packages/http-client-python/eng/scripts/ci => eng/common/scripts/regen-diff/src}/push-assets.ts (51%) rename {packages/http-client-python/eng/scripts/ci => eng/common/scripts/regen-diff/src}/render-diff.ts (80%) create mode 100644 eng/common/scripts/regen-diff/tsconfig.json create mode 100644 packages/http-client-python/regen-diff.config.json 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..d5cc2c91238 --- /dev/null +++ b/eng/common/scripts/regen-diff/package.json @@ -0,0 +1,21 @@ +{ + "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": { + "diff2html": "^3.4.56", + "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 80% rename from packages/http-client-python/eng/scripts/ci/render-diff.ts rename to eng/common/scripts/regen-diff/src/render-diff.ts index 8699622e353..0983c4c983e 100644 --- a/packages/http-client-python/eng/scripts/ci/render-diff.ts +++ b/eng/common/scripts/regen-diff/src/render-diff.ts @@ -2,19 +2,26 @@ /** * 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, execSync } from "child_process"; @@ -29,68 +36,36 @@ import { 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, pathToFileURL } from "url"; -import { parseArgs } from "util"; +import { pathToFileURL } from "url"; -import { FLAVORS, readAssetsConfig, restoreFullBaseline } from "./assets.js"; +import { readAssetsConfig, restoreFullBaseline } from "./assets.js"; +import { readRegenDiffConfig, type RegenDiffConfig } from "./config.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" }, - open: { type: "boolean" }, - vscode: { type: "boolean" }, - max: { type: "string" }, - 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. - --open Open the rendered diff in your default browser. - --vscode Open each changed file as a native VS Code editor diff - (baseline vs current) and keep both trees on disk at - temp/diff-trees/ for use with a folder-compare extension. - --max Max number of files to open in VS Code (default 40). - -h, --help Show this help. -`); - process.exit(0); +export interface RenderOptions { + packageRoot: string; + output?: string; + generated?: string; + title?: string; + open?: boolean; + vscode?: boolean; + max?: number; } -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"; - // 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;
@@ -99,6 +74,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, {
@@ -124,7 +105,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
@@ -146,17 +127,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 });
     }
   }
@@ -179,20 +160,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-"));
@@ -208,15 +196,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);
 
@@ -225,44 +213,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,
@@ -283,28 +252,27 @@ async function main(): Promise {
           `(${summary.filesChanged} files, +${summary.additions}/-${summary.deletions}).`,
       ),
     );
-    // Print a clickable file:// URL so the page is one click away locally, and
-    // optionally pop it open in the default browser.
     console.log(pc.cyan(`View it at ${pathToFileURL(indexPath).href}`));
-    if (argv.values.open) {
+    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. This is
-    // the "VS Code changes" experience — the generated output is git-ignored, so
-    // it never shows up in Source Control on its own.
-    if (argv.values.vscode) {
-      const treesDir = resolve(PACKAGE_ROOT, "temp/diff-trees");
+    // 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 = Number(argv.values.max) > 0 ? Number(argv.values.max) : 40;
+      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 });
   }
@@ -314,8 +282,6 @@ async function main(): Promise {
 function openInBrowser(target: string): void {
   try {
     if (process.platform === "win32") {
-      // `start` is a cmd builtin; the empty first arg is the window title so a
-      // path with spaces isn't mistaken for one.
       execFileSync("cmd", ["/c", "start", "", target], { stdio: "ignore" });
     } else if (process.platform === "darwin") {
       execFileSync("open", [target], { stdio: "ignore" });
@@ -343,8 +309,7 @@ function changedRelPaths(diffText: string): string[] {
 /**
  * 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; for larger sets the persisted folder pair is
- * printed so a folder-compare extension or the HTML page can show the rest.
+ * their own. Capped at `max` tabs.
  */
 function openInVscode(
   diffText: string,
@@ -365,8 +330,7 @@ function openInVscode(
 
   // `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
-  // (passing an args array with shell:true is deprecated, DEP0190).
+  // 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" });
 
@@ -375,8 +339,7 @@ function openInVscode(
     console.warn(
       pc.yellow(
         `${filesChanged} files changed; opening the first ${max} in VS Code. ` +
-          `Use --max  to open more, the HTML page for all of them, or a ` +
-          `folder-compare extension (e.g. "Compare Folders") on the two trees above.`,
+          `Use --max  to open more, or the HTML page for all of them.`,
       ),
     );
   }
@@ -425,8 +388,8 @@ interface FileDiff {
  * 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 (showing `{baseline → current}`). Stripping the `baseline/`/`current/`
- * prefixes makes old === new path, so it renders as a normal modification.
+ * 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");
@@ -451,7 +414,6 @@ function normalizeChunkHeader(chunk: string): string {
 /** 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");
@@ -624,11 +586,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`
@@ -644,7 +602,8 @@ function renderFileRow(
 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 →`
@@ -771,8 +730,3 @@ function escapeHtml(value: string): string {
     .replace(/>/g, ">")
     .replace(/"/g, """);
 }
-
-main().catch((err) => {
-  console.error(pc.red(`Fatal error: ${err?.stack ?? err}`));
-  process.exit(1);
-});
diff --git a/eng/common/scripts/regen-diff/tsconfig.json b/eng/common/scripts/regen-diff/tsconfig.json
new file mode 100644
index 00000000000..4c330940440
--- /dev/null
+++ b/eng/common/scripts/regen-diff/tsconfig.json
@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "target": "es2022",
+    "module": "nodenext",
+    "moduleResolution": "nodenext",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noImplicitOverride": true,
+    "skipLibCheck": true,
+    "noEmit": true,
+    "types": ["node"]
+  },
+  "include": ["src/**/*.ts"]
+}
diff --git a/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2
index 68915429341..63618f9dcfd 100644
--- a/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2
+++ b/packages/http-client-python/generator/pygen/codegen/templates/packaging_templates/pyproject.toml.jinja2
@@ -5,7 +5,7 @@
 {% endif %}
 
 [build-system]
-requires = ["setuptools>=77.0.3", "wheel"]
+requires = ["setuptools>=78.0.3", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [project]
diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json
index f215e8749ce..964ab1d41a7 100644
--- a/packages/http-client-python/package.json
+++ b/packages/http-client-python/package.json
@@ -50,10 +50,10 @@
     "typecheck": "tsx ./eng/scripts/ci/typecheck.ts",
     "typecheck:generated": "tsx ./eng/scripts/ci/typecheck.ts --generated",
     "regenerate": "tsx ./eng/scripts/ci/regenerate.ts",
-    "regenerate:push-assets": "tsx ./eng/scripts/ci/push-assets.ts",
-    "regenerate:render-diff": "tsx ./eng/scripts/ci/render-diff.ts",
-    "regenerate:diff": "tsx ./eng/scripts/ci/render-diff.ts --open",
-    "regenerate:vscode-diff": "tsx ./eng/scripts/ci/render-diff.ts --vscode",
+    "regenerate:push-assets": "tsx ../../eng/common/scripts/regen-diff/src/cli.ts push-assets --package .",
+    "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": "npm run regenerate && npm run regenerate:diff",
     "ci": "npm run test:emitter && npm run ci:generated",
     "ci:generated": "tsx ./eng/scripts/ci/run-tests.ts --generator --env=ci",
diff --git a/packages/http-client-python/regen-diff.config.json b/packages/http-client-python/regen-diff.config.json
new file mode 100644
index 00000000000..964452d20d8
--- /dev/null
+++ b/packages/http-client-python/regen-diff.config.json
@@ -0,0 +1,7 @@
+{
+  "slug": "python",
+  "title": "Python emitter — generated test diff",
+  "generatedDir": "tests/generated",
+  "flavors": ["azure", "unbranded"],
+  "pruneFilePrefixes": [".tsp-codegen-"]
+}

From 0d050946eb4ad34b140a228b59f944dcc6130efd Mon Sep 17 00:00:00 2001
From: Libba Lawrence 
Date: Mon, 29 Jun 2026 14:26:17 -0700
Subject: [PATCH 13/13] refactor(regen-diff): drop diff2html, self-render
 colorized unified diff

Replace the diff2html dependency with a small built-in renderer that emits
a line-numbered, colorized unified diff per file, so the shared tool has no
runtime rendering dependency (only picocolors remains). Rename the emitted
stylesheet diff2html.css -> styles.css and update the publish allowlist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .github/workflows/regen-diff-publish.yml      |  2 +-
 eng/common/scripts/regen-diff/package.json    |  1 -
 .../scripts/regen-diff/src/render-diff.ts     | 87 ++++++++++++++-----
 3 files changed, 68 insertions(+), 22 deletions(-)

diff --git a/.github/workflows/regen-diff-publish.yml b/.github/workflows/regen-diff-publish.yml
index 1b0bd072af0..2ebed55c4c5 100644
--- a/.github/workflows/regen-diff-publish.yml
+++ b/.github/workflows/regen-diff-publish.yml
@@ -94,7 +94,7 @@ jobs:
           # Allowlist the rendered output (no arbitrary files from the artifact)
           # so a fork PR cannot host unexpected content on the *.github.io origin.
           cp diff-site/index.html "$dest/" 2>/dev/null || true
-          cp diff-site/diff2html.css "$dest/" 2>/dev/null || true
+          cp diff-site/styles.css "$dest/" 2>/dev/null || true
           cp diff-site/summary.json "$dest/" 2>/dev/null || true
           cp diff-site/diff.txt "$dest/" 2>/dev/null || true
           if [ -d diff-site/files ]; then
diff --git a/eng/common/scripts/regen-diff/package.json b/eng/common/scripts/regen-diff/package.json
index d5cc2c91238..6c4f25edccd 100644
--- a/eng/common/scripts/regen-diff/package.json
+++ b/eng/common/scripts/regen-diff/package.json
@@ -10,7 +10,6 @@
     "test:tsc": "tsc -p ."
   },
   "dependencies": {
-    "diff2html": "^3.4.56",
     "picocolors": "~1.1.1"
   },
   "devDependencies": {
diff --git a/eng/common/scripts/regen-diff/src/render-diff.ts b/eng/common/scripts/regen-diff/src/render-diff.ts
index 0983c4c983e..85848c9ef8a 100644
--- a/eng/common/scripts/regen-diff/src/render-diff.ts
+++ b/eng/common/scripts/regen-diff/src/render-diff.ts
@@ -34,7 +34,6 @@ import {
   writeFileSync,
 } from "fs";
 import { cp, mkdtemp } from "fs/promises";
-import { createRequire } from "module";
 import { tmpdir } from "os";
 import { join, resolve } from "path";
 import pc from "picocolors";
@@ -43,10 +42,6 @@ import { pathToFileURL } from "url";
 import { readAssetsConfig, restoreFullBaseline } from "./assets.js";
 import { readRegenDiffConfig, type RegenDiffConfig } from "./config.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");
 
 export interface RenderOptions {
   packageRoot: string;
@@ -456,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(
@@ -598,7 +591,58 @@ function renderFileRow(name: string, href: string, file: FileDiff): string {
 `;
 }
 
-/** 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`; @@ -617,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 = `