From de7980029cc871bb873c3fb090a0d582981e48c9 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Thu, 22 Oct 2026 05:29:49 +0000 Subject: [PATCH 1/9] Comment the semantic diff on the pull request as Markdown The Semantic Diff workflow now comments each pull request's commit records drawn as GitHub Markdown, with the page's link: code, trees, matrices, mappings and reanalyze reports as unified diffs, typing rules as math. Past a size budget the largest commits leave their forms to the page. Area: ci docs Changelog: none Output: unchanged Tests: +scripts/recordMarkdown.test.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzPS5F8RDSGZ6dhfK6NdZp Signed-off-by: Christoph Knittel --- .claude/skills/records/SKILL.md | 3 +- .github/workflows/semantic_diff.yml | 22 +- package.json | 2 +- scripts/commitRecords.js | 20 +- scripts/recordMarkdown.js | 451 ++++++++++++++++++++++++++++ scripts/recordMarkdown.test.js | 232 ++++++++++++++ scripts/recordRender.js | 165 ++++++---- 7 files changed, 826 insertions(+), 69 deletions(-) create mode 100644 scripts/recordMarkdown.js create mode 100644 scripts/recordMarkdown.test.js diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index 699294964e..812c769df7 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -175,7 +175,8 @@ node scripts/commitRecords.js --out page.html [--pr ] [--title ] \ - `--pr` and `--title` head the page with the pull request. `.github/workflows/semantic_diff.yml` renders each pull request's range - and comments the page's link. + and comments the records as Markdown (`--markdown --url `, + scripts/recordMarkdown.js) with the page's link. - In prose, `#1234` and full pull request and issue URLs link as `#1234`, and the 9-character short sha of a commit on the page links to its card. diff --git a/.github/workflows/semantic_diff.yml b/.github/workflows/semantic_diff.yml index 8bb1c65a48..cfe0bb7b9b 100644 --- a/.github/workflows/semantic_diff.yml +++ b/.github/workflows/semantic_diff.yml @@ -1,7 +1,8 @@ name: Semantic Diff # Renders the commit records of each pull request (scripts/commitRecords.js) -# as a review page on the CDN and links it from the pull request. +# as a review page on the CDN, and comments them on the pull request as +# Markdown (scripts/recordMarkdown.js) with the page's link. # # A pull request from a branch of this repository runs on pull_request, with # its own workflow and renderer. A pull request from a fork runs on @@ -44,13 +45,15 @@ jobs: persist-credentials: false - name: Render semantic diff - id: render run: | git fetch --no-tags --filter=blob:none origin "refs/pull/${PR_NUMBER}/head:refs/heads/pr-${PR_NUMBER}" base=$(git merge-base "$BASE_SHA" "pr-${PR_NUMBER}") + head=$(git rev-parse --short=9 "pr-${PR_NUMBER}") + # The query string names the rendered commit, so each push gets a + # URL that no cache has seen. + url="https://cdn.rescript-lang.org/semantic-diff/pr-${PR_NUMBER}/index.html?v=${head}" mkdir -p semantic-diff - node scripts/commitRecords.js --out semantic-diff/index.html --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" - echo "head=$(git rev-parse --short=9 "pr-${PR_NUMBER}")" >> "$GITHUB_OUTPUT" + node scripts/commitRecords.js --out semantic-diff/index.html --markdown semantic-diff/comment.md --url "$url" --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" - name: Setup Rclone uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18 @@ -68,18 +71,15 @@ jobs: - name: Upload semantic diff run: rclone copyto --s3-no-check-bucket semantic-diff/index.html "rescript:cdn-assets/semantic-diff/pr-${PR_NUMBER}/index.html" - # The query string names the rendered commit, so each push gets a URL - # that no cache has seen. - - name: Comment semantic diff URL + - name: Comment semantic diff uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ github.event.pull_request.number }} comment-tag: semantic-diff - # Each push replaces the comment, so the newest link is the last - # comment on the pull request. + # Each push replaces the comment, so the newest semantic diff is + # the last comment on the pull request. mode: recreate - message: | - Semantic diff: https://cdn.rescript-lang.org/semantic-diff/pr-${{ github.event.pull_request.number }}/index.html?v=${{ steps.render.outputs.head }} + file-path: semantic-diff/comment.md cleanup: if: >- diff --git a/package.json b/package.json index f42dfdf0ab..09612a20c3 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "changelog:release": "node scripts/changelog.js release", "changelog:open": "node scripts/changelog.js open", "test-changelog": "node --test scripts/changelog.test.js", - "test-commit-records": "node --test scripts/commitRecords.test.js" + "test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js" }, "files": [ "CHANGELOG.md", diff --git a/scripts/commitRecords.js b/scripts/commitRecords.js index d3abaf9822..f139858744 100644 --- a/scripts/commitRecords.js +++ b/scripts/commitRecords.js @@ -10,10 +10,13 @@ * node scripts/commitRecords.js --out review.html ... * node scripts/commitRecords.js --out review.html ..... * --pr and --title head the page with the pull request. + * --markdown also draws the records as the pull request's comment + * (scripts/recordMarkdown.js), linking the page at --url . */ import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; +import { commentMarkdown } from "./recordMarkdown.js"; import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js"; /** @import { CommitRecord, Group } from "./recordRender.js" */ @@ -61,14 +64,17 @@ const options = {}; /** @type {string[]} */ const targets = []; for (let i = 0; i < args.length; i++) { - if (["--out", "--pr", "--title"].includes(args[i]) && i + 1 < args.length) + if ( + ["--out", "--pr", "--title", "--markdown", "--url"].includes(args[i]) && + i + 1 < args.length + ) options[args[i].slice(2)] = args[++i]; else targets.push(args[i]); } const outFile = options.out; if (!outFile) { console.error( - "usage: commitRecords.js --out [--pr ] [--title ] ...", + "usage: commitRecords.js --out [--pr ] [--title ] [--markdown [--url ]] ...", ); process.exit(1); } @@ -76,7 +82,15 @@ const heading = { pr: (options.pr ?? "").replace(/\D/g, ""), title: options.title ?? "", }; -const body = pageBody(targets.map(group), heading); +const groups = targets.map(group); +const body = pageBody(groups, heading); +if (options.markdown) { + writeFileSync( + options.markdown, + commentMarkdown(groups, { url: options.url }), + ); + console.log(options.markdown); +} const head = `${esc(pageTitle(heading))} `; diff --git a/scripts/recordMarkdown.js b/scripts/recordMarkdown.js new file mode 100644 index 0000000000..9fc8920543 --- /dev/null +++ b/scripts/recordMarkdown.js @@ -0,0 +1,451 @@ +// @ts-check + +/* Commit records drawn as GitHub Markdown, for the Semantic Diff comment on + * a pull request. GitHub strips styles from a comment, so each form is drawn + * in what its Markdown renders natively: lines of code, trees, text, + * matrices, mappings and reanalyze reports as a unified diff, typing rules + * as mathematics. A comment holds at most 65536 characters; past a budget + * below that, the largest commits are drawn without their forms, and the + * page has them all. recordRender.js parses the records and draws the page. + */ + +import { + alignLines, + atomDiff, + BELOW, + BLOCK, + cellsOf, + columnWidths, + dedent, + esc, + FORMS, + formEntries, + mappingWidth, + mapsTo, + matchLines, + mathAtoms, + mathGroups, + mathTokens, + NOTE, + proseChunks, + reportEntries, + sectionGroups, + treeLine, +} from "./recordRender.js"; + +/** @import { CommitRecord, Group, Section, FormEntry } from "./recordRender.js" */ + +/** The characters a comment holds at most, with room for the action's tag. */ +export const BUDGET = 60000; + +// --------------------------------------------------------------------------- +// Inline text + +/** + * Prose split into its code spans and attributes (odd indices) and the + * text between (even ones). An attribute such as `@react.component` written + * outside backticks is drawn as code, so that GitHub does not take it for a + * mention of a user; so is any other `@name`. + * @param {string} s + */ +const spans = s => + s.split(/(`[^`]+`|(? + spans(s) + .map((part, i) => + i % 2 + ? part.startsWith("`") + ? part + : `\`${part}\`` + : part.replace(/&/g, "&").replace(/[\\`*_[\]<>|~$]/g, "\\$&"), + ) + .join("") + .replace(/^[#+-]/, "\\$&") + .replace(/^(\d+)([.)])/, "$1\\$2"); + +/** + * Prose as HTML, for a ``, where GitHub reads no Markdown: code + * spans and attributes as ``. + * @param {string} s + */ +const inlineHtml = s => + spans(s) + .map((part, i) => + i % 2 ? `${esc(part.replace(/^`|`$/g, ""))}` : esc(part), + ) + .join(""); + +/** + * A fenced code block: the fence longer than any run of backticks in the + * lines, so that no line closes it. + * @param {string} lang + * @param {string[]} lines + */ +export function fence(lang, lines) { + const longest = Math.max( + 0, + ...lines.flatMap(l => (l.match(/`+/g) ?? []).map(r => r.length)), + ); + const f = "`".repeat(Math.max(3, longest + 1)); + return `${f}${lang}\n${lines.join("\n")}\n${f}`; +} + +/** + * Prose: paragraphs, and ReScript code blocks (`proseChunks`). + * @param {string[]} lines + */ +const prose = lines => + proseChunks(lines).map(c => + c.code ? fence("rescript", c.lines) : inline(c.lines[0]), + ); + +// --------------------------------------------------------------------------- +// Mathematics + +/** Characters TeX reads as markup, escaped. @param {string} s */ +const tex = s => + s.replace(/[#$%&_{}\\^~]/g, c => + c === "\\" + ? "\\backslash " + : c === "^" + ? "\\hat{}" + : c === "~" + ? "\\sim " + : `\\${c}`, + ); + +/** + * A formula in TeX, as the page sets it: one-letter names, with their + * primes, italic variables, longer names upright; `_x`, `_{…}` and `^{…}` + * sub- and superscripts; `code` in backticks as typewriter text; primes as + * `'`. A space between two words (a name, code or a subscripted name) is + * kept. + * @param {string} text + * @returns {string} + */ +export function texMath(text) { + const toks = [...mathTokens(text)]; + const word = (/** @type {RegExpMatchArray | undefined} */ m) => + !!(m?.groups?.name || m?.groups?.code || m?.groups?.script); + return toks + .map((m, k) => { + const g = m.groups ?? {}; + if (g.code) return `\\texttt{${tex(g.code.slice(1, -1))}}`; + if (g.script) + return `${g.script[0]}{${texMath(g.script.replace(/^[_^]\{?|\}$/g, ""))}}`; + if (g.name) + return /^.[0-9'′]*$/.test(g.name) + ? g.name.replace(/′/g, "'") + : `\\mathrm{${g.name.replace(/′/g, "'")}}`; + if (/\s/.test(m[0])) + return word(toks[k - 1]) && word(toks[k + 1]) ? "\\ " : ""; + return m[0] === "′" ? "'" : tex(m[0]); + }) + .join(""); +} + +/** + * Mathematics in TeX (`mathGroups`): inference rules as fractions, their + * names after them, and rows of formulas; the marked parts (numbered as + * `mathAtoms` lists them) coloured. + * @param {string[]} block + * @param {{ marked?: Set, color?: string }} [marks] + */ +export function texRules(block, marks = {}) { + let n = 0; + const part = (/** @type {string} */ t) => + marks.marked?.has(n++) ? `\\color{${marks.color}}{${t}}` : t; + const out = mathGroups(block).map(g => { + if ("rows" in g) + return `\\begin{array}{l}${g.rows + .map(r => r.map(p => part(texMath(p))).join(" \\qquad ")) + .join(" \\\\ ")}\\end{array}`; + const premises = g.premises.map(p => part(texMath(p))).join(" \\qquad "); + const conclusion = g.conclusion.map(l => part(texMath(l))).join(" \\\\ "); + const name = g.name ? ` \\quad ${part(`(\\textsf{${tex(g.name)}})`)}` : ""; + return `\\dfrac{${premises}}{\\begin{array}{c}${conclusion}\\end{array}}${name}`; + }); + return out.length > 1 + ? `\\begin{array}{l}${out.join(" \\\\[1em] ")}\\end{array}` + : (out[0] ?? ""); +} + +// --------------------------------------------------------------------------- +// Forms + +/** + * Each drawing's fenced block language for one version: GitHub highlights + * ReScript and JavaScript; trees, Lambda, text and reports are plain. + * @type {Record} + */ +const LANG = { rescript: "rescript", js: "js", math: "math" }; + +/** + * The lines of one version of a form as drawn in a fenced block: a tree's + * list brackets dropped, a matrix's cells and a mapping's arrows aligned + * across both versions (`both`). + * @param {string} drawing + * @param {string[]} ls + * @param {string[]} both + */ +function formLines(drawing, ls, both) { + if (drawing === "tree") return ls.filter(treeLine); + if (drawing === "matrix") { + const widths = columnWidths(both); + return ls.map(l => + NOTE.test(l) + ? l.trim() + : cellsOf(l) + .map((p, c) => p.padEnd(widths[c] ?? 0)) + .join(" ") + .trimEnd(), + ); + } + if (drawing === "mapping") { + const width = mappingWidth(both); + return ls.map(l => { + const m = mapsTo(l); + return m ? `${m[0].padEnd(width)} ↦ ${m[1]}` : l.trim(); + }); + } + return ls; +} + +/** + * Two versions of a form's lines as a unified diff: a line common to both + * is context; between two common lines, the lines only Before has, then the + * lines only After has. + * @param {string[]} b + * @param {string[]} a + */ +function diffLines(b, a) { + /** @type {string[]} */ + const out = []; + /** @type {string[]} */ + let adds = []; + for (const row of alignLines(b, a)) { + if (row.same) { + out.push(...adds); + adds = []; + out.push(` ${b[row.a ?? 0]}`); + continue; + } + if (row.a !== null) out.push(`-${b[row.a]}`); + if (row.b !== null) adds.push(`+${a[row.b]}`); + } + return [...out, ...adds]; +} + +/** + * Two reanalyze reports as a unified diff of their entries, aligned by + * identity: a finding one report lacks is removed or added, a run of + * findings in both is one context line counting them, and a note in both, + * such as the closing count, is context. + * @param {string[]} b + * @param {string[]} a + */ +function reportDiff(b, a) { + const before = reportEntries(b); + const after = reportEntries(a); + const pairs = matchLines( + before.map(e => e.key), + after.map(e => e.key), + k => k, + ); + /** @type {string[][]} */ + const out = []; + let same = 0; + const collapse = () => { + if (same) + out.push([` … ${same} unchanged finding${same === 1 ? "" : "s"}`]); + same = 0; + }; + const lines = (/** @type {string} */ key, /** @type {string} */ sign) => + key.split("\n").map(l => sign + l); + let i = 0; + let j = 0; + for (const [x, y] of [...pairs, [before.length, after.length]]) { + const dels = before.slice(i, x); + const adds = after.slice(j, y); + if (dels.length || adds.length) collapse(); + for (const e of dels) out.push(lines(e.key, e.elision ? " " : "-")); + for (const e of adds) out.push(lines(e.key, e.elision ? " " : "+")); + if (x < before.length) { + if (before[x].analysis) same++; + else { + collapse(); + out.push(lines(before[x].key, " ")); + } + } + i = x + 1; + j = y + 1; + } + collapse(); + return out.flatMap((e, k) => (k ? [" ", ...e] : e)); +} + +/** + * One form of an object block: its name and captions, then the versions + * drawn. Two versions are a diff, or, for typing rules, two figures side by + * side with the changed parts coloured; equal versions and a lone version + * are drawn once. + * @param {Extract} e + */ +function form(e) { + const drawing = FORMS[e.form] ?? "text"; + const { before, after } = e; + const hasBlock = (/** @type {Section} */ s) => s.block.some(l => l.trim()); + const caption = (/** @type {Section | undefined} */ s) => + s && hasBlock(s) && s.text.length ? inline(s.text.join(" ")) : ""; + const lines = (/** @type {Section} */ s) => + hasBlock(s) ? dedent(s.block) : s.text; + const label = (/** @type {string[]} */ notes) => + [`\`${e.form}\``, ...notes.filter(Boolean)].join(" · "); + const one = (/** @type {string[]} */ ls) => + drawing === "math" + ? fence("math", [texRules(ls)]) + : fence(LANG[drawing] ?? "", formLines(drawing, ls, ls)); + if (before && after) { + const [b, a] = [lines(before), lines(after)]; + const [cb, ca] = [caption(before), caption(after)]; + if (b.length && b.join("\n") === a.join("\n")) + return `${label(["Before and After are equal", cb, ca === cb ? "" : ca])}\n\n${one(a)}`; + const head = label( + cb === ca ? [cb] : [cb && `Before: ${cb}`, ca && `After: ${ca}`], + ); + if (drawing === "math") { + const [del, add] = atomDiff(mathAtoms(b), mathAtoms(a)); + const cell = (/** @type {string} */ t) => `\n\n${t}\n\n`; + return `${head}\n\n${cell( + fence("math", [texRules(b, { marked: del, color: "red" })]), + )}${cell(fence("math", [texRules(a, { marked: add, color: "green" })]))}
BeforeAfter
`; + } + const diff = + drawing === "report" + ? reportDiff(b, a) + : diffLines( + formLines(drawing, b, [...b, ...a]), + formLines(drawing, a, [...b, ...a]), + ); + return `${head}\n\n${fence("diff", diff)}`; + } + const only = /** @type {Section} */ (before ?? after); + return `${label([before ? "Before" : "After", caption(only)])}\n\n${one(lines(only))}`; +} + +// --------------------------------------------------------------------------- +// Commits + +/** + * One section with the example and forms it holds; without `full`, the + * forms are left to the page. + * @param {Section} s + * @param {Section[]} parts + * @param {boolean} full + */ +function section(s, parts, full) { + /** @type {string[]} */ + const out = []; + const block = BLOCK.exec(s.name); + const text = s.text.length ? [inline(s.text.join(" "))] : []; + const lines = dedent(s.block); + const body = !lines.length + ? [] + : s.name + ? prose(lines) + : [fence("rescript", lines)]; + if (s.name === "Why") + out.push( + [`**Why**`, ...text, ...body] + .join("\n\n") + .split("\n") + .map(l => (l ? `> ${l}` : ">")) + .join("\n"), + ); + else { + if (s.name) + out.push( + block + ? `**${block[1]} ${block[2]}**${block[3] ? ` (${inline(block[3])})` : ""}` + : `**${inline(s.name)}**`, + ); + out.push(...text, ...body); + } + const entries = formEntries(parts); + if (full) + for (const e of entries) + out.push("example" in e ? fence("rescript", e.example) : form(e)); + else if (entries.length) out.push("Forms on the page."); + return out.join("\n\n"); +} + +/** + * A commit as a collapsible block: its subject, identity and message drawn; + * without `full`, its forms are left to the page. + * @param {CommitRecord} r + * @param {boolean} full + */ +export function commitMarkdown(r, full) { + const pr = /\(#(\d+)\)\s*$/.exec(r.subject)?.[1] ?? ""; + const subject = r.subject.replace(/\s*\(#\d+\)\s*$/, ""); + const joined = (/** @type {Section} */ s) => + [...s.text, ...s.block.map(l => l.trim())].join(" ").trim(); + const out = [ + `
${inlineHtml(subject)}`, + `${esc(r.sha)} · ${inline(r.author)}${r.date ? ` · ${esc(r.date)}` : ""}${pr ? ` · #${pr}` : ""}`, + ...sectionGroups(r.sections.filter(s => !BELOW.has(s.name))).map(g => + section(g.section, g.parts, full), + ), + ...r.sections + .filter(s => s.name === "Breaking") + .map(s => `> [!CAUTION]\n> **Breaking:** ${inline(joined(s))}`), + ]; + const reviews = r.sections.filter(s => s.name === "Review"); + if (reviews.length) + out.push( + `**Review**\n\n${reviews.map(s => `- ${inline(joined(s))}`).join("\n")}`, + ); + if (r.coauthors.length) + out.push(`with ${inline(r.coauthors.join(" · "))}`); + out.push("
"); + return out.filter(Boolean).join("\n\n"); +} + +/** + * The comment: a heading linking the page, then each commit. Past the + * budget, the commits with the most characters are drawn without their + * forms, and if that is not enough, the last commits are left to the page. + * @param {Group[]} groups + * @param {{ url?: string, budget?: number }} [options] + */ +export function commentMarkdown(groups, { url = "", budget = BUDGET } = {}) { + const records = groups.flatMap(g => g.records); + const n = records.length; + const head = `### Semantic Diff\n\n${n} commit${n === 1 ? "" : "s"}, drawn from ${n === 1 ? "its message" : "their messages"}${url ? `; [side by side on the page](${url})` : ""}.`; + const drawn = records.map(r => commitMarkdown(r, true)); + const size = () => drawn.reduce((t, d) => t + d.length + 2, head.length); + const order = drawn + .map((d, k) => ({ k, length: d.length })) + .sort((x, y) => y.length - x.length); + for (const { k } of order) { + if (size() <= budget) break; + drawn[k] = commitMarkdown(records[k], false); + } + let left = 0; + while (drawn.length && size() > budget) { + drawn.pop(); + left++; + } + if (left) + drawn.push( + `${left} more commit${left === 1 ? "" : "s"} on the page.`, + ); + return `${[head, ...drawn].join("\n\n")}\n`; +} diff --git a/scripts/recordMarkdown.test.js b/scripts/recordMarkdown.test.js new file mode 100644 index 0000000000..c5d762284a --- /dev/null +++ b/scripts/recordMarkdown.test.js @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { commentMarkdown, fence, inline, texMath } from "./recordMarkdown.js"; +import { parseRecord } from "./recordRender.js"; + +const SIGNED = "Signed-off-by: A Person "; + +/** + * The comment drawn from commit messages, one commit each. + * @param {string[]} messages + * @param {{ url?: string, budget?: number }} [options] + */ +const comment = (messages, options) => + commentMarkdown( + [ + { + records: messages.map((message, k) => + parseRecord({ + sha: `${k}`.padStart(9, "a"), + author: "A Person", + date: "Oct 22, 2026", + message, + }), + ), + }, + ], + options, + ); + +/** + * Asserts that the comment contains the text; a failure names the text. + * @param {string} md + * @param {string} text + */ +const has = (md, text) => + assert.ok(md.includes(text), `comment lacks ${JSON.stringify(text)}`); + +test("a commit is a block with its identity, sections and co-authors", () => { + const md = comment( + [ + `Represent raw extensions as their own node (#12) + +Why: + A raw extension carries JavaScript. + +AST modified (raw extensions): +Example: + let f = %raw(\`(a) => a\`) +Before (parse): + [ + expression + Pexp_extension "raw" + ] +After (parse): + [ + expression + Pexp_raw "raw" + ] + +Review: Does a PPX still see it? Yes. +Breaking: PPXs must match Pexp_raw. + +Co-Authored-By: B Person +${SIGNED} +`, + ], + { url: "https://cdn.example/pr-12/index.html?v=1" }, + ); + has( + md, + "### Semantic Diff\n\n1 commit, drawn from its message; [side by side on the page](https://cdn.example/pr-12/index.html?v=1).", + ); + has( + md, + "
Represent raw extensions as their own node", + ); + has(md, "aaaaaaaa0 · A Person · Oct 22, 2026 · #12"); + has(md, "> **Why**\n>\n> A raw extension carries JavaScript."); + has(md, "**AST modified** (raw extensions)"); + has(md, "```rescript\nlet f = %raw(`(a) => a`)\n```"); + // the tree's list brackets are dropped and the changed line is a diff + has( + md, + '`parse`\n\n```diff\n expression\n- Pexp_extension "raw"\n+ Pexp_raw "raw"\n```', + ); + has(md, "> [!CAUTION]\n> **Breaking:** PPXs must match Pexp\\_raw."); + has(md, "**Review**\n\n- Does a PPX still see it? Yes."); + has(md, "with B Person\n\n
"); + assert.ok(!md.includes("Signed-off-by")); +}); + +test("a typing rule is mathematics, the changed parts coloured", () => { + const md = comment([ + `Check applications + +Typing modified (application): +Before (rule): + Γ ⊢ e_1 : τ_{1} → τ_{2} Γ ⊢ e_2 : τ_{1} + ─────────────── (App) + Γ ⊢ e_1 e_2 : τ_{2} +After (rule): + Γ ⊢ e_1 : τ_{1} → τ_{2} Γ ⊢ e_2 : τ_{1}′ + ─────────────── (App) + Γ ⊢ e_1 e_2 : τ_{2} + +${SIGNED} +`, + ]); + has(md, "
BeforeAfter
\n\n```math\n"); + has( + md, + "\\dfrac{Γ⊢e_{1}:τ_{1}→τ_{2} \\qquad \\color{red}{Γ⊢e_{2}:τ_{1}}}{\\begin{array}{c}Γ⊢e_{1}\\ e_{2}:τ_{2}\\end{array}} \\quad (\\textsf{App})", + ); + has(md, "\\color{green}{Γ⊢e_{2}:τ_{1}'}"); + assert.equal(md.split("\\color").length - 1, 2); +}); + +test("a formula's words, code and TeX characters", () => { + assert.equal( + texMath("fresh x `f(a, ...)` & {p}"), + "\\mathrm{fresh}\\ x\\texttt{f(a, ...)}\\&\\{p\\}", + ); +}); + +test("matrices and mappings align their columns in the diff", () => { + const md = comment([ + `Unbox options + +Representation modified (option): +Before (representation): boxed + Some(x) ↦ {_0: x} + None ↦ undefined +After (representation): unboxed + Some(x) ↦ x + None ↦ undefined + +Matching modified (option split): +Before (matrix): + Some(x) e1 + None e2 +After (matrix): + Some(x) _ e1 + None e2 + order: Some first + +JS modified (f): +Before (js): same output + let f = 1; +After (js): same output + let f = 1; + +${SIGNED} +`, + ]); + has( + md, + "`representation` · Before: boxed · After: unboxed\n\n```diff\n-Some(x) ↦ {_0: x}\n+Some(x) ↦ x\n None ↦ undefined\n```", + ); + has( + md, + "```diff\n-Some(x) e1\n+Some(x) _ e1\n None e2\n+order: Some first\n```", + ); + has( + md, + "`js` · Before and After are equal · same output\n\n```js\nlet f = 1;\n```", + ); +}); + +const DIVIDE = `Exception Analysis +Division.res:1:5-10 +divide might throw Division_by_zero`; +const DIVIDE_BY = DIVIDE.replace(/divide/g, "divideBy").replace(":1:", ":2:"); + +test("a reanalyze report is a diff of findings, unchanged ones counted", () => { + const indented = (/** @type {string} */ s) => + s + .split("\n") + .map(l => (l ? ` ${l}` : "")) + .join("\n"); + const md = comment([ + `Skip literal divisors + +Reanalyze modified (division): +Before (reanalyze): +${indented(`${DIVIDE}\n\n${DIVIDE_BY}\n\n${DIVIDE_BY.replaceAll("divideBy", "divideTo")}\n\nAnalysis reported 3 issues`)} +After (reanalyze): +${indented(`${DIVIDE_BY}\n\n${DIVIDE_BY.replaceAll("divideBy", "divideTo")}\n\nAnalysis reported 2 issues`)} + +${SIGNED} +`, + ]); + has( + md, + "```diff\n-Exception Analysis\n-Division.res:1:5-10\n-divide might throw Division_by_zero\n \n … 2 unchanged findings\n \n-Analysis reported 3 issues\n \n+Analysis reported 2 issues\n```", + ); +}); + +test("prose escapes markup and draws attributes and mentions as code", () => { + assert.equal( + inline("Use @react.component and `a*b` for in $x_1 * 2, cc @someone"), + "Use `@react.component` and `a*b` for \\ in \\$x\\_1 \\* 2, cc `@someone`", + ); + assert.equal(inline("# not a heading"), "\\# not a heading"); + assert.equal(inline("1. not a list"), "1\\. not a list"); + assert.equal(inline("mail a@example.com"), "mail a@example.com"); +}); + +test("a fence is longer than any run of backticks in its lines", () => { + assert.equal(fence("js", ["let s = ```;"]), "````js\nlet s = ```;\n````"); +}); + +test("past the budget, the largest commits leave their forms to the page", () => { + const big = `Change output + +JS modified (f): +Before (js): +${Array.from({ length: 200 }, (_, k) => ` let a${k} = ${k};`).join("\n")} +After (js): +${Array.from({ length: 200 }, (_, k) => ` let b${k} = ${k};`).join("\n")} + +${SIGNED} +`; + const small = `Fix a typo\n\nWhy:\n It was wrong.\n\n${SIGNED}\n`; + const md = comment([small, big, small], { budget: 4000 }); + assert.ok(md.length <= 4000, `${md.length}`); + has(md, "**JS modified** (f)\n\nForms on the page."); + assert.equal(md.split("> It was wrong.").length - 1, 2); + const tiny = comment([small, big, small], { budget: 400 }); + assert.ok(tiny.length <= 500, `${tiny.length}`); + has(tiny, "more commit"); +}); diff --git a/scripts/recordRender.js b/scripts/recordRender.js index d298ddffa9..02dc0a4758 100644 --- a/scripts/recordRender.js +++ b/scripts/recordRender.js @@ -36,7 +36,7 @@ export const esc = s => * of another name is drawn as text. * @type {Record} */ -const FORMS = { +export const FORMS = { parse: "tree", expanded: "tree", typed: "tree", @@ -55,11 +55,11 @@ const FORMS = { * An object block's heading: ` ()`, * the kind one word, the object's parentheses optional. */ -const BLOCK = +export const BLOCK = /^([A-Z][A-Za-z]*) (added|modified|removed)(?: \(((?:[^()]|\([^()]*\))*)\))?$/; /** A section an object block holds: its example or one version of a form. */ -const PART = /^(?:Example|(Before|After) \(([a-z]+)\))$/; +export const PART = /^(?:Example|(Before|After) \(([a-z]+)\))$/; // --------------------------------------------------------------------------- // Records @@ -113,7 +113,7 @@ const untab = l => }); /** Whether a line is indented by 4 spaces or more. @param {string} l */ -const isIndented = l => /^ {4,}\S/.test(l); +export const isIndented = l => /^ {4,}\S/.test(l); /** * A record's subject, sections and trailers. A section starts at a line @@ -259,7 +259,7 @@ function highlight(code, lang) { * @param {(line: string) => string} key * @returns {[number, number][]} */ -function matchLines(a, b, key) { +export function matchLines(a, b, key) { const x = a.map(key); const y = b.map(key); /** @type {number[][]} */ @@ -292,7 +292,7 @@ function matchLines(a, b, key) { * @param {string[]} b * @returns {{ a: number | null, b: number | null, same: boolean }[]} */ -function alignLines(a, b) { +export function alignLines(a, b) { /** @type {{ a: number | null, b: number | null, same: boolean }[]} */ const rows = []; let i = 0; @@ -340,7 +340,7 @@ const indentOf = l => /^ */.exec(l)?.[0].length ?? 0; const codeHtml = (lines, draw) => draw(lines.join("\n")).split("\n"); /** An elision line, `…` and any text, marking lines left out. @param {string} l */ -const elided = l => l.trimStart().startsWith("…"); +export const elided = l => l.trimStart().startsWith("…"); /** * Lines of code, each drawn by `draw`, the marked ones shaded and the @@ -366,7 +366,7 @@ function codeLines(lines, draw, marks = {}) { } /** A note line of a drawing: `label: text`. */ -const NOTE = /^\s*[a-z][\w ]{0,20}:\s/; +export const NOTE = /^\s*[a-z][\w ]{0,20}:\s/; /** * The parts of a figure (a typing rule or a rewrite) that a Before/After @@ -382,7 +382,7 @@ const NOTE = /^\s*[a-z][\w ]{0,20}:\s/; * @param {Atom[]} b * @returns {[Set, Set]} */ -function atomDiff(a, b) { +export function atomDiff(a, b) { const del = new Set(); const add = new Set(); for (const slot of new Set([...a, ...b].map(x => x.slot))) { @@ -555,7 +555,7 @@ const inline = s => * `_{…}`, `^{…}`), a name, or any other character. * @param {string} text */ -const mathTokens = text => +export const mathTokens = text => text.matchAll( /(?`[^`]+`)|(?