From 358e13e51ccb0e7b303ef286eaffcce01bf64856 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 23 Jun 2026 10:48:06 -0700 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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",