diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index 699294964e..d500dbc35b 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -49,16 +49,31 @@ Breaking: Signed-off-by: Name ``` -- The subject and `Signed-off-by` are required. The message ends with its - trailers, git's `Key: value` lines (`Co-Authored-By:`, `Fixes:` or any - other one-word key), `Signed-off-by` last; the page lists the - `Co-Authored-By` names and draws no other trailer. Every section is - optional. -- A section starts at a line `Why:`, `Review:`, `Breaking:`, an object - block's heading, an `Example:` alone on its line, or a `Before (
):` - or `After ():` heading, and holds the text after its colon and the - block indented by 4 spaces or more under it; a blank line ends a section - that holds only text. `Review:` repeats, one question each. Every other +A commit without object blocks, such as a script change, is its subject +and a line or two of prose: + +``` +Draw handles starting with a digit as code in the comment + +GitHub usernames may start with a digit, so `@0x` in a commit message +would have mentioned a user; the comment now draws it as code as well. + +Signed-off-by: Name +``` + +- The subject, a description and `Signed-off-by` are required: the + description says why the commit is made, as a line or two of prose or + a `Why:` section, and object blocks then show what it changes. Every + other section is optional. The message ends with its trailers, git's + `Key: value` lines (`Co-Authored-By:`, `Fixes:` or any other one-word + key), `Signed-off-by` last; the page lists the `Co-Authored-By` names + and draws no other trailer. +- A section starts at a line `Why:`, `Review:` or `Breaking:` that no + paragraph of prose runs onto (a blank line or another section above it), + an object block's heading, an `Example:` alone on its line, or a + `Before ():` or `After ():` heading, and holds the text after + its colon and the block indented by 4 spaces or more under it; a blank + line ends a section that holds only text. `Review:` repeats, one question each. Every other line is prose, and a block indented by 4 spaces outside a section is ReScript code. Tabs count as 4 columns. - An **object block** is a heading ` added|modified|removed ():`, @@ -159,11 +174,13 @@ The table lists the common kinds. `-dce` and `-termination` select the other analyses. - A form of another name is drawn as text. -## When to add a block +## What a commit holds -A commit gets one object block for each object it changes, with the +Every commit says why it is made. A commit that changes what the compiler +or a tool does gets one object block for each object it changes, with the smallest example that shows the change. A refactor, a test, a script or a -review fix is a subject and a line or two of prose. +review fix needs no block, only its subject and a line or two of prose; a +review fix names the finding it answers. ## Rendering @@ -175,14 +192,16 @@ 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: after a push that keeps + the previous head, only the commits it added (`--since `). - 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. ## What the page shows The page is the heading, then a card per commit. A card shows the commit's -short sha, author and date, the pull request a `(#1234)` at the end of the -subject names, and the message drawn: the subject, the prose, each object -block with its example and forms, the `Breaking:` sections as banners, the -`Review:` sections as a list, and the co-authors. +short sha, author and co-authors, date, the pull request a `(#1234)` at +the end of the subject names, and the message drawn: the subject, the +prose, each object block with its example and forms, the `Breaking:` +sections as banners, and the `Review:` sections as a list. diff --git a/.github/workflows/semantic_diff.yml b/.github/workflows/semantic_diff.yml index 8bb1c65a48..152f241905 100644 --- a/.github/workflows/semantic_diff.yml +++ b/.github/workflows/semantic_diff.yml @@ -1,7 +1,10 @@ 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. The page holds +# every commit; the comment holds the commits a push added, or every commit +# when the pull request is opened or reopened or its branch is force-pushed. # # 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 @@ -34,6 +37,9 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + # The head before a push; empty when the pull request is opened or + # reopened. + BEFORE_SHA: ${{ github.event.action == 'synchronize' && github.event.before || '' }} steps: - name: Checkout renderer uses: actions/checkout@v7 @@ -48,9 +54,21 @@ jobs: 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}" + # A push that keeps the previous head adds commits on top of it: + # the comment holds those. After a force push the previous head is + # no ancestor (nor, from a fork, fetched), and the comment holds + # every commit. + since=() + if [ -n "$BEFORE_SHA" ] && git merge-base --is-ancestor "$BEFORE_SHA" "pr-${PR_NUMBER}" 2>/dev/null; then + since=(--since "$BEFORE_SHA") + fi 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 "${since[@]}" --url "$url" --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" + echo "head=${head}" >> "$GITHUB_OUTPUT" - name: Setup Rclone uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18 @@ -68,18 +86,17 @@ 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 + # A push that adds only merge commits writes no comment. + - name: Comment semantic diff + if: hashFiles('semantic-diff/comment.md') != '' 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. - 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 }} + # Each push gets its own comment, so the comments of earlier pushes + # stay; a re-run of the same head updates its comment. + comment-tag: semantic-diff-${{ steps.render.outputs.head }} + mode: upsert + 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..c719c8e717 100644 --- a/scripts/commitRecords.js +++ b/scripts/commitRecords.js @@ -10,10 +10,15 @@ * 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 . With + * --since , the comment draws only the commits lacks, and is + * not written when there are none. */ 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 +66,19 @@ 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", "--since"].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 ] [--since ]] ...", ); process.exit(1); } @@ -76,7 +86,39 @@ 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) { + const since = options.since; + // The commits of the targets that `since` does not reach. + const fresh = new Set( + since + ? targets.flatMap(t => + git(["rev-list", "--abbrev-commit", "--abbrev=9", t, `^${since}`]) + .split("\n") + .filter(Boolean), + ) + : [], + ); + const shown = since + ? groups.map(g => ({ + ...g, + records: g.records.filter(r => fresh.has(r.sha)), + })) + : groups; + if (shown.some(g => g.records.length)) { + writeFileSync( + options.markdown, + commentMarkdown(shown, { + url: options.url, + total: since + ? groups.reduce((n, g) => n + g.records.length, 0) + : undefined, + }), + ); + console.log(options.markdown); + } +} const head = `${esc(pageTitle(heading))} `; diff --git a/scripts/commitRecords.test.js b/scripts/commitRecords.test.js index 0d8e57075f..1f0cff5f57 100644 --- a/scripts/commitRecords.test.js +++ b/scripts/commitRecords.test.js @@ -132,9 +132,8 @@ test("a minimal record draws its identity and subject", async () => { has(html, "

Fix a typo

"); assert.match( html, - /
[0-9a-f]{9}<\/code> · A Person · [A-Z][a-z]{2} \d{2}, \d{4} · #7<\/a><\/div>/, + /
[0-9a-f]{9}<\/code> · A Person with B Person · [A-Z][a-z]{2} \d{2}, \d{4} · #7<\/a><\/div>/, ); - has(html, '
with B Person
'); lacks(html, '
'); lacks(html, "Signed-off-by"); lacks(html, "Co-Authored-By"); @@ -428,6 +427,18 @@ test("a line with a colon is prose unless it names a section", async () => { lacks(html, '

'); }); +test("a section's name wrapped onto a line of a paragraph is prose", async () => { + const html = await renderOne( + `Require a description\n\nIt requires prose or a\nWhy: section, and an example.\nWhy: alone after a blank line is a section.\n\nWhy: Here it is.\n\n${SIGNED}\n`, + ); + has( + html, + "

It requires prose or a Why: section, and an example. Why: alone after a blank line is a section.

", + ); + assert.equal(html.split('
').length - 1, 1); + has(html, "

Here it is.

"); +}); + test("a character literal with a Unicode escape is one string token", async () => { const html = await renderOne( `Match a range\n\nExample:\n switch c { | '\\u{100}' .. '\\u{1100}' => 1 | _ => 0 }\n\n${SIGNED}\n`, @@ -446,7 +457,7 @@ test("Breaking and Review sections right above the sign-off are drawn", async () ); has(html, '
  • Is -bs-x still read? No.
  • '); - has(html, '
    with B Person
    '); + has(html, "
    · A Person with B Person · "); lacks(html, "Signed-off-by"); }); @@ -493,7 +504,7 @@ test("trailers of any key end the message and are not drawn", async () => { const html = await renderOne( `Fix a thing\n\nCo-Authored-By: Claude Opus 5.5 \nClaude-Session: https://claude.ai/code/session_x\n${SIGNED}\n`, ); - has(html, '
    with Claude Opus 5.5
    '); + has(html, " · A Person with Claude Opus 5.5 · "); lacks(html, "Claude-Session"); lacks(html, "session_x"); lacks(html, '
    … '); has(html, ''); }); + +test("--since leaves the comment the commits a push added, the page every commit", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-")); + const git = (/** @type {string[]} */ ...a) => + execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim(); + git("init", "-q", "-b", "main"); + git("config", "user.name", "A Person"); + git("config", "user.email", "a@example.com"); + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit(git, dir, {}, `First change\n\n${SIGNED}`); + const before = git("rev-parse", "HEAD"); + await commit(git, dir, {}, `Second change\n\n${SIGNED}`); + const out = path.join(dir, "page.html"); + const md = path.join(dir, "comment.md"); + const run = (/** @type {string} */ since) => { + const r = spawnSync( + "node", + [ + script, + "--out", + out, + "--markdown", + md, + "--since", + since, + "HEAD~2..HEAD", + ], + { cwd: dir, encoding: "utf8" }, + ); + assert.equal(r.status, 0, r.stderr); + }; + run(before); + const comment = await fs.readFile(md, "utf8"); + has(comment, "1 new commit (2 total)"); + has(comment, "Second change"); + lacks(comment, "First change"); + const html = await fs.readFile(out, "utf8"); + has(html, "First change"); + has(html, "Second change"); + await fs.rm(md); + run("HEAD"); + await assert.rejects(fs.access(md)); +}); diff --git a/scripts/recordMarkdown.js b/scripts/recordMarkdown.js new file mode 100644 index 0000000000..163e85e96b --- /dev/null +++ b/scripts/recordMarkdown.js @@ -0,0 +1,454 @@ +// @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.coauthors.length ? ` with ${inline(r.coauthors.join(", "))}` : ""}${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")}`, + ); + 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. + * With `total`, the commits are the new ones a push added to a pull request + * of `total` commits. + * @param {Group[]} groups + * @param {{ url?: string, budget?: number, total?: number }} [options] + */ +export function commentMarkdown( + groups, + { url = "", budget = BUDGET, total } = {}, +) { + const records = groups.flatMap(g => g.records); + const n = records.length; + const head = `### Semantic Diff\n\n${n}${total === undefined ? "" : " new"} commit${n === 1 ? "" : "s"}${total === undefined || total === n ? "" : ` (${total} total)`}${url ? ` · [Full 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..309afb8137 --- /dev/null +++ b/scripts/recordMarkdown.test.js @@ -0,0 +1,233 @@ +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 · [Full page](https://cdn.example/pr-12/index.html?v=1)", + ); + has( + md, + "
    Represent raw extensions as their own node", + ); + has(md, "aaaaaaaa0 · A Person with B 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, "Yes.\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("thanks @0x, @9"), "thanks `@0x`, `@9`"); + 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..37a647ae9f 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,15 +113,16 @@ 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 * `Name:`, where the name is an object block's heading, a version heading, - * `Why`, `Review` or `Breaking`, or `Example` alone on its line; it holds - * the text after its colon and the lines indented by 4 spaces under it. A - * blank line ends a section that holds only text. Unnamed sections hold the - * paragraphs and indented blocks between. + * `Why`, `Review` or `Breaking` outside a paragraph of prose, or `Example` + * alone on its line; it holds the text after its colon and the lines + * indented by 4 spaces under it. A blank line ends a section that holds + * only text. Unnamed sections hold the paragraphs and indented blocks + * between. * @param {string} text */ function parseMessage(text) { @@ -140,11 +141,15 @@ function parseMessage(text) { for (const line of lines.slice(1, end)) { const head = /^(\S(?:[^:]|\([^()]*\))*):(?:\s+(.*))?$/.exec(line); const name = head?.[1] ?? ""; + // `Why:`, `Review:` or `Breaking:` that prose wraps onto a line of its + // paragraph continues the paragraph. + const inParagraph = + current && !current.name && current.text.length && !current.block.length; if ( head && (BLOCK.test(name) || (PART.test(name) && !(name === "Example" && head[2])) || - NAMED.has(name)) + (NAMED.has(name) && !inParagraph)) ) { current = { name, text: head[2] ? [head[2]] : [], block: [] }; sections.push(current); @@ -259,7 +264,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 +297,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 +345,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 +371,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 +387,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 +560,7 @@ const inline = s => * `_{…}`, `^{…}`), a name, or any other character. * @param {string} text */ -const mathTokens = text => +export const mathTokens = text => text.matchAll( /(?`[^`]+`)|(?