diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index 579839bc27..ec7a5cd36b 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -1,136 +1,162 @@ --- name: records -description: Commit records and review pages for the ReScript compiler repository. Use when committing a change, when an agent reports its work, and when someone asks to review a commit, a pull request or a branch. Every commit message is a structured record that answers the questions ReScript reviewers ask; review pages are rendered from records with scripts/commitRecords.js, never written by hand. +description: Commit records and Semantic Diff pages for the ReScript compiler repository. Use when committing a change, when an agent reports its work, and when someone asks to review a commit, a pull request or a branch. A commit message describes what the commit changes in the form suited to it (trees, code, diagnostics, typing rules, clause matrices, representations) Before and After; scripts/commitRecords.js renders the messages as a Semantic Diff page, one card per commit drawing its message. --- -# Commit records and review pages +# Commit records -A **record** is a commit message whose body has named prose sections and -whose last block is git trailers. Its fields are the questions reviewers of -this repository ask on pull requests, answered before they are asked. A -review page is rendered from records: one card per commit, and for a range a -summary, a changelog draft and the files more than one commit changes. +A record is a commit message that describes what the commit changes, each +change in the form suited to what it changes: a parse tree or typed tree as +the tree before and after, Lambda and generated JavaScript as code before +and after, a diagnostic as its text, a typing rule as an inference rule, +the match compiler's split as a clause matrix, a runtime representation as +a mapping. `scripts/commitRecords.js` renders records as a Semantic Diff +page, one card per commit, drawing each form in its own way, Before and +After side by side. -## The record +## The message ``` - - - - -Example (rescript): - - -Before (js): - - -After (js): - - -Source: - + + +Why: + + +AST modified (raw extensions): +Example: + let f = %raw(`(a) => a`) +Before (parse): + structure_item + Pstr_value Nonrec + + pattern + Ppat_var "f" + expression + Pexp_extension "raw" +After (parse): + structure_item + Pstr_value Nonrec + + pattern + Ppat_var "f" + expression + Pexp_raw "raw" + Raw_template "(a) => a" + +Review: Breaking: -Review: - -Area: syntax printer frontend typechecker lambda codegen runtime rewatch - analysis reanalyze gentype tools playground scripts ci docs tests -Output: unchanged | changed , -Formatting: unchanged | changed -Formats: none | -PPX: unchanged | -Editor: unchanged | -Tests: +added/path ~updated/path -removed/path | none, because -Measured: after, and what produced it>; ... -PR: # -Signed-off-by: +Signed-off-by: Name ``` -- **Prose sections** are a line `Name:` or `Name (lang):` followed by text or - a block indented by 4 spaces. `lang` is `rescript`, `js`, `ts`, `sh`, - `rust`, `ocaml` or `text`; the renderer highlights ReScript and JavaScript. - `Before` and `After` sections of one language render side by side with - changed lines marked. Use `Before (rescript)` / `After (rescript)` for a - formatter change and `Diagnostic (text)` for compiler output. Any other - name is allowed and renders as prose or code. -- **The final block is git trailers**: `Key: value` lines, continuation lines - indented by one space, nothing after them. `git interpret-trailers --parse` - must read every key. `Signed-off-by` (DCO) is required on every commit. -- **No changelog in the record**: the `changes/..md` - fragment the commit adds is the single source of truth for its changelog - category and entry (see `changes/README.md`). The record repeats neither; - the review page reads the fragment from the diff. -- **Required keys**: `Area`, `Output`, `Tests`. `Formatting`, `Formats`, - `PPX` and `Editor` are required whenever the change touches the syntax - library, the parsetree, the typed tree or Lambda. Omit the others when they - do not apply. -- **Wording** follows the writing rules: present facts, positive statements, - claims as strong as their evidence, every number labelled by what produced - it. A JavaScript `Before`/`After` pair says where it came from in a - `Source:` paragraph (compiler run, or a snapshot file in the diff). -- **Lists in trailers**: `Measured` separates items with `; ` - (outside parentheses); each item renders on its own line. `Output` and the - other state trailers start with their state word (`unchanged`, `changed`, - `none`), followed by the detail. - -## What each field states - -| Field | What the record states | +- 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 + 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 ():`, + the kind one capitalised word naming the object, then an optional + `Example:` with a ReScript program indented by 4 spaces, then the + object's forms, each version a `Before ():` or `After ():` + heading with its content indented by 4 spaces. Text after the colon of a + `Before`/`After` heading captions the block under it, or is the version + itself when no block follows (`After (js): return x;`). A heading that + leaves out the object's parentheses is drawn as its action and kind. +- A block draws the versions it has: a form with both versions side by + side, or once, with the captions, when the two are equal; a form with + one version across the block under its version's label. Trees, code, + text, matrices and mappings are drawn on aligned rows, a line common to + both versions facing itself and the changed lines shaded; typing rules + and rewrites are drawn as two figures side by side, the changed parts + shaded: premises, conclusions, rule names and formulas, and each term of + a rewrite against the term at its position. +- The changelog entry is the `changes/..md` fragment the + commit adds (`changes/README.md`). + +## Kinds + +The table lists the common kinds. + +| Kind | Object | | --- | --- | -| `Area` | The layers the change touches. They decide who reviews it and which suites apply; a parsetree or typed-tree change also reaches editor analysis. | -| `Output` + `Before`/`After (js)` | The generated JavaScript for the example before and after, and which existing programs compile differently. `unchanged` holds when no committed generated JS changes, or when a rebuild of the test suites is byte-identical. | -| `Formatting` + `Before`/`After (rescript)` | Whether `rescript format` rewrites existing code, and whether formatted output parses back to the same tree with its comments. | -| `Formats` | The magic numbers bumped in `compiler/ext/config.ml`. Any change to the marshaled parsetree, typed tree (`.cmi`, `.cmt`) or `.cmj` layout, constructor order included, bumps the matching number. | -| `PPX` | The effect on the frozen v0 PPX wire: `parsetree0.ml` is unchanged, and a change to the v0 mapping comes with an ast-mapping fixture. | -| `Editor` | Completion, hover and inlay hints on new or changed nodes, and the analysis fixtures that cover them. | -| `Tests` | The tests added, changed or removed, each observing the effect the record claims, so that it fails without the change. `none, because …` gives the reason when no test changes. | -| `Measured` | Performance before and after, with the input and the command that produced each number. | -| `Breaking` | What existing code or tooling stops working, and the migration. | -| `Review` | Questions a reviewer raised or will raise, with their answers. | -| `PR` | The pull request; the renderer links it. | -| `Signed-off-by` | The DCO sign-off, with the commit author's name and email, as the last trailer. | +| `AST` | a parsetree node | +| `Typedtree` | a typed tree node | +| `Typing` | a typing rule | +| `Matching` | the pattern match compiler's split | +| `Lambda` | a Lambda term or pass | +| `JS` | generated JavaScript | +| `Diagnostic` | an error or warning | +| `Format` | the formatter's output | +| `Representation` | how a value is represented in JavaScript | + +## Forms + +| Form | Content | Drawn as | +| --- | --- | --- | +| `parse` | the parse tree | tree | +| `expanded` | the parse tree after the built-in PPX | tree | +| `typed` | the typed tree | tree | +| `lambda` | Lambda | highlighted code | +| `js` | the generated JavaScript | highlighted code | +| `rescript` | ReScript source, such as the formatter's output | highlighted code | +| `diagnostic` | what the compiler reports | text | +| `text` | any text | text | +| `rule` | inference rules and formulas | inference rule | +| `matrix` | a clause matrix | matrix | +| `representation` | how ReScript values map to JavaScript | mapping | + +- A tree is indented lines, two spaces a level, as the compiler prints it: + `bsc -bs-no-builtin-ppx -only-parse -dparsetree file.res` prints the + parse tree, `bsc -only-parse -dparsetree` the expanded one, and + `bsc -dtypedtree` the typed tree. +- Code is highlighted in its language; `bsc -drawlambda` prints the Lambda. + Code can be a rewrite: each `⇝` separates the code before it from the + code after it (`lhs ⇝ rhs` or a chain `a ⇝ b ⇝ c` on one line, or `⇝` + alone on a line), and `label: formula` lines after the last term are its + side conditions. +- A `rule` group is premises, a bar of `─` with the rule's name in + parentheses, and the conclusion; any other group is formulas, one a + line. Groups are separated by blank lines, side-by-side formulas by three + or more spaces. One-letter names, with their primes, are italic + variables, `_x`, `_{…}` and `^{…}` sub- and superscripts, and `code` in + backticks is ReScript. +- A `matrix` is one row a line, columns three or more spaces apart, each + cell a pattern; `label: text` lines are notes under it. +- A `representation` is `ReScript ↦ JavaScript` lines; `label: text` lines + are notes among them. +- A form of another name is drawn as text. + +## When to add a block + +A commit 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. ## Rendering ```sh -node scripts/commitRecords.js --out review.html ... -node scripts/commitRecords.js --out review.html ..... -node scripts/commitRecords.js --fragment --out part.html ... -node scripts/commitRecords.js --out review.html --pr 1234 --title "" .. +make semantic-diff # master..HEAD to _build/semantic-diff.html, opened +node scripts/commitRecords.js --out page.html [--pr ] [--title ] \ + ... ``` -- `--pr` and `--title` head the page with the pull request's number (linked) - and title; without them the page is headed "Semantic Diff". - -- A revision renders one card. A range renders a summary row, a changelog - draft grouped by category, its commits as cards, and the files more than - one commit (or more than one range) changes. The changelog category, the - entries and the draft come from the `changes/` fragments in the diff: a - card shows the fragments its commit adds or changes, and a range's draft - shows the fragments the range leaves, in their last version. -- Each card checks the record against its diff: a `changes/` fragment that - `yarn changelog:check` rejects (such as one still carrying the placeholder - PR link), an edit to `CHANGELOG.md`, modified generated `.mjs`, `.cjs` and `.res.js` files under - `Output: unchanged` (new ones from new tests are counted, not flagged), a - magic number bumped in `compiler/ext/config.ml` that - `Formats` does not name, a parsetree change with no ast-mapping fixture, a - missing `Tests` trailer, `Tests: none` on a diff that changes tests. A failed - check is shown on the card. The DCO app checks `Signed-off-by`. -- The page is one self-contained HTML file: no script, no external data - (fonts fall back to system faces), light and dark themes, phone width. - `--fragment` omits the document skeleton for embedding. -- A note on `refs/notes/records` replaces the commit message's record. Use it - for commits that exist already (history, a squash merge) and for review - outcomes added later; a new commit carries its record in its message. - -## When to use - -- Every commit an agent makes carries a record in its message. -- An agent's report lists its commits and the rendered page; the records are - the description of the work. A pull request description is a rendering of - its range. -- Before review or merge: render `master..` and hand over the page. -- On GitHub, `.github/workflows/semantic_diff.yml` renders each pull - request's range when it is opened or pushed, and comments the page's link - (`Semantic diff: https://cdn.rescript-lang.org/semantic-diff/pr-/…`). +- `--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. +- 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. diff --git a/AGENTS.md b/AGENTS.md index 09fd3e3d22..1378a13c8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ relevant guidance from each. - Build must pass before committing - Write each commit message as a record in the format of [`.claude/skills/records/SKILL.md`](.claude/skills/records/SKILL.md), and - render review pages from records with `node scripts/commitRecords.js` + render review pages from records with `make semantic-diff` ### Stacked pull requests diff --git a/changes/commit-record-forms.internal.md b/changes/commit-record-forms.internal.md new file mode 100644 index 0000000000..6b5f2c6a1a --- /dev/null +++ b/changes/commit-record-forms.internal.md @@ -0,0 +1 @@ +- Commit records describe each changed compiler object in its own form (parse and typed trees, Lambda and JavaScript, diagnostics, typing rules, clause matrices, representations) Before and After, and the semantic diff page draws each form in its own way, Before and After side by side. diff --git a/scripts/commitRecords.js b/scripts/commitRecords.js index 25b904ba8f..d3abaf9822 100644 --- a/scripts/commitRecords.js +++ b/scripts/commitRecords.js @@ -2,1020 +2,86 @@ // @ts-check -/* Renders commit records as a review page. A record is a commit message (or a - * note on refs/notes/records, which takes precedence) in the format of - * .claude/skills/records/SKILL.md: a subject, prose sections, code sections - * such as "Before (js):" and git trailers. The changelog comes from the - * changes/..md fragments the commits add, not from the - * record. +/* Draws commit records as a Semantic Diff page: one card per commit, each + * the commit's identity and its message drawn. A record is a commit message + * in the format of the `records` skill (.claude/skills/records/SKILL.md). + * This file reads git; scripts/recordRender.js draws. * * node scripts/commitRecords.js --out review.html ... - * node scripts/commitRecords.js --out review.html ..... + * node scripts/commitRecords.js --out review.html ..... * --pr and --title head the page with the pull request. - * --fragment omits the document skeleton. */ import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; -import { parseFragment } from "./changelog.js"; +import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js"; -const REPO_URL = "https://github.com/rescript-lang/rescript"; +/** @import { CommitRecord, Group } from "./recordRender.js" */ -/** - * @param {string[]} args - * @param {string} [input] - */ -const git = (args, input) => +/** @param {string[]} args */ +const git = args => execFileSync("git", args, { - input, encoding: "utf8", - maxBuffer: 64 << 20, - stdio: ["pipe", "pipe", "ignore"], + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "ignore"], }); -/** @param {string} s */ -const esc = s => - s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - -/** @param {string} s */ -/** Short shas of the cards on the page, for linking mentions. */ -const pageShas = new Set(); - -/** @param {string} s */ -const inline = s => - esc(s) - .replace(/`([^`]+)`/g, "$1") - .replace( - /https:\/\/github\.com\/rescript-lang\/rescript\/(pull|issues)\/(\d+)/g, - `#$2`, - ) - .replace(/\b([0-9a-f]{9})\b(\^?)/g, (m, sha, caret) => - pageShas.has(sha) && !caret - ? `${sha}` - : m, - ) - .replace( - /(^|[\s(])#(\d{3,5})\b/g, - `$1#$2`, - ) - .replace( - /(\d[\d.,]*\s*(?:ms|s|%|KB|MB)?)\s*->\s*(\d[\d.,]*\s*(?:ms|s|%|KB|MB)?)/g, - '$1 → $2', - ); - -// --------------------------------------------------------------------------- -// Syntax highlighting - -const RES_KEYWORDS = new Set( - "let rec and switch if else type module open include mutable async await for in to downto while external exception try catch when of as private constraint assert lazy dict list true false".split( - " ", - ), -); -const JS_KEYWORDS = new Set( - "let const var function return if else for while do new import export from as default this true false null undefined typeof instanceof class async await throw try catch finally of in switch case break continue void delete yield".split( - " ", - ), -); - -/** @type {Record} */ -const TOKENS = { - rescript: - /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`)|(@{1,2}[A-Za-z_][\w.]*|%{1,2}[A-Za-z_][\w.]*)|(#[A-Za-z_]\w*)|(\b0x[0-9a-fA-F_]+n?\b|\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_][\w']*)|(=>|->|\+\+|:=|\.\.\.|\|>|[=<>!|&+\-*/:?]+)/g, - js: /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|((?!x)x)|((?!x)x)|(\b0x[0-9a-fA-F]+n?\b|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_$][\w$]*)|(=>|===|!==|\*\*|[=<>!|&+\-*/:?%^]+)/g, -}; - -/** - * @param {string} code - * @param {string} lang - */ -function highlight(code, lang) { - const re = TOKENS[lang]; - if (!re) return esc(code); - const keywords = lang === "js" ? JS_KEYWORDS : RES_KEYWORDS; - let out = ""; - let last = 0; - re.lastIndex = 0; - for (let m = re.exec(code); m; m = re.exec(code)) { - out += esc(code.slice(last, m.index)); - last = m.index + m[0].length; - const [text, comment, str, attr, poly, num, ident, op] = m; - const cls = comment - ? "c" - : str - ? "s" - : attr - ? "a" - : poly - ? "p" - : num - ? "n" - : ident - ? keywords.has(ident) - ? "k" - : /^[A-Z]/.test(ident) - ? "t" - : "" - : op - ? "o" - : ""; - // A span never crosses a line break: lines are split after highlighting. - out += cls - ? text - .split("\n") - .map(part => `${esc(part)}`) - .join("\n") - : esc(text); - } - return out + esc(code.slice(last)); -} - -/** - * Lines of a code block, each highlighted, marked when the paired block of a - * Before/After pair lacks it. - * @param {string[]} lines - * @param {string} lang - * @param {Set | undefined} other - * @param {string} mark - */ -function codeLines(lines, lang, other, mark) { - const text = lines.join("\n").trimEnd(); - const shown = highlight(text, lang).split("\n"); - return text - .split("\n") - .map((line, i) => { - const changed = other && line.trim() && !other.has(line.trim()); - return `${shown[i] || " "}`; - }) - .join(""); -} - -// --------------------------------------------------------------------------- -// Records - -/** - * @typedef {{ name: string, lang: string, text: string[], block: string[] }} Section - * @typedef {{ status: string, path: string, added: number, removed: number }} FileChange - * @typedef {{ path: string, category: string, text: string, error: string }} Fragment - * @typedef {{ - * sha: string, author: string, date: string, subject: string, - * sections: Section[], trailers: Map, message: string, - * coauthors: string[], files: FileChange[], fragments: Fragment[], - * patch: string, noted: boolean - * }} CommitRecord - */ - -/** @param {string} text */ -function parseTrailers(text) { - /** @type {Map} */ - const trailers = new Map(); - for (const line of git(["interpret-trailers", "--parse"], text).split("\n")) { - const i = line.indexOf(":"); - if (i <= 0) continue; - const key = line.slice(0, i).trim(); - const value = line.slice(i + 1).trim(); - trailers.set( - key, - trailers.has(key) ? `${trailers.get(key)}\n${value}` : value, - ); - } - return trailers; -} - -/** @param {Section[]} sections */ -function pushProse(sections) { - /** @type {Section} */ - const s = { name: "", lang: "", text: [], block: [] }; - sections.push(s); - return s; -} - -/** @param {string} path */ -const isFragment = path => - /^changes\/[^/]+\.md$/.test(path) && path !== "changes/README.md"; - /** - * The changelog fragments a commit adds or changes, as they stand in its tree. - * A fragment that fails validation (such as one with the placeholder PR link) - * keeps the category from its name and records the error. + * The record of one commit: its short hash, author, date and message. * @param {string} rev - * @param {FileChange[]} files - * @returns {Fragment[]} + * @returns {CommitRecord} */ -function fragmentsOf(rev, files) { - return files - .filter(f => f.status !== "D" && isFragment(f.path)) - .map(f => { - const name = f.path.slice("changes/".length); - const content = git(["show", `${rev}:${f.path}`]); - try { - const { category, text } = parseFragment(name, content); - return { path: f.path, category, text, error: "" }; - } catch (e) { - return { - path: f.path, - category: /\.([a-z]+)\.md$/.exec(name)?.[1] ?? "?", - text: content.trim(), - error: e instanceof Error ? e.message : String(e), - }; - } - }); -} - -/** - * The bullets of a fragment, without their "- " and with continuation lines - * joined. - * @param {string} text - */ -const bullets = text => - text - .split(/\n(?=- )/) - .map(b => b.replace(/^-\s+/, "").replace(/\n\s+/g, " ")) - .filter(Boolean); - -/** @param {string} rev */ function readRecord(rev) { - let text = ""; - try { - text = git(["notes", "--ref=records", "show", rev]); - } catch { - // no note - } - const message = git(["show", "-s", "--format=%B", rev]); - const noted = text.trim() !== ""; - if (!noted) text = message; - const lines = text.trimEnd().split("\n"); - let end = lines.length; - while ( - end > 1 && - (/^[A-Za-z-]+: /.test(lines[end - 1]) || /^ \S/.test(lines[end - 1])) - ) - end--; - /** @type {Section[]} */ - const sections = []; - /** @type {Section | undefined} */ - let current; - for (const line of lines.slice(1, end)) { - const head = - /^([A-Z][A-Za-z]*(?: [A-Za-z]+){0,2})(?: \(([a-z]+)\))?:\s*(.*)$/.exec( - line, - ); - if (head) { - current = { - name: head[1], - lang: head[2] ?? "", - text: head[3] ? [head[3]] : [], - block: [], - }; - sections.push(current); - } else if (line.startsWith(" ")) { - if (!current) current = pushProse(sections); - current.block.push(line.slice(4)); - } else if (line.trim() === "" && current?.block.length) { - current.block.push(""); - } else if (line.trim()) { - if (!current || current.block.length) current = pushProse(sections); - current.text.push(line.trim()); - } else if (current?.text.length) current = undefined; - } - /** @type {Map} */ - const files = new Map(); - for (const line of git([ - "diff-tree", - "--no-commit-id", - "-r", - "-M", - "--name-status", - rev, - ]).split("\n")) { - const [status, ...paths] = line.split("\t"); - if (!status) continue; - const path = paths[paths.length - 1]; - files.set(path, { status: status[0], path, added: 0, removed: 0 }); - } - for (const line of git([ - "diff-tree", - "--no-commit-id", - "-r", - "-M", - "--numstat", - rev, - ]).split("\n")) { - const [a, r, ...rest] = line.split("\t"); - if (!rest.length) continue; - const path = rest - .join("\t") - .replace(/\{[^{}]* => ([^{}]*)\}/, "$1") - .replace(/^.* => /, "") - .replace(/\/\//g, "/"); - const f = files.get(path); - if (f) { - f.added = Number(a) || 0; - f.removed = Number(r) || 0; - } - } - /** @type {CommitRecord} */ - const record = { - sha: git(["rev-parse", "--short=9", rev]).trim(), - author: git(["show", "-s", "--format=%an", rev]).trim(), - date: git([ - "show", - "-s", - "--format=%ad", - "--date=format:%b %d, %Y", - rev, - ]).trim(), - subject: lines[0], - sections, - trailers: parseTrailers(text), - message, - noted, - coauthors: [ - ...new Set( - message - .split("\n") - .filter(l => /^Co-Authored-By:/i.test(l)) - .map(l => - l.replace(/^Co-Authored-By:\s*/i, "").replace(/\s*<[^>]*>/, ""), - ), - ), - ], - files: [...files.values()], - fragments: fragmentsOf(rev, [...files.values()]), - patch: git([ - "show", - "--format=", - "-U0", - rev, - "--", - "compiler/ext/config.ml", - ]), - }; - return record; -} - -/** - * @param {CommitRecord} r - * @param {string} key - */ -const words = (r, key) => - (r.trailers.get(key) ?? "").split(/\s+/).filter(Boolean); - -// --------------------------------------------------------------------------- -// What the diff says - -/** @type {[RegExp, string][]} */ -const AREAS = [ - [/^compiler\/syntax\//, "syntax"], - [/^compiler\/core\/lam_/, "lambda"], - [/^compiler\/core\//, "codegen"], - [/^compiler\/ml\//, "typechecker"], - [/^compiler\/frontend\//, "frontend"], - [/^compiler\/gentype\//, "gentype"], - [/^compiler\//, "compiler"], - [/^analysis\/reanalyze\//, "reanalyze"], - [/^analysis\//, "analysis"], - [/^rewatch\/tests\//, "tests"], - [/^rewatch\//, "rewatch"], - [/^packages\/@rescript\/runtime\//, "runtime"], - [/^packages\/dev-playground\//, "playground"], - [/^tools\//, "tools"], - [/^tests\//, "tests"], - [/^scripts\//, "scripts"], - [/^\.github\/|^Makefile$/, "ci"], - [/^changes\/|^CHANGELOG\.md$/, "changelog"], - [/\.md$|^docs\//, "docs"], -]; - -/** @param {string} path */ -const areaOf = path => AREAS.find(([re]) => re.test(path))?.[1] ?? "other"; - -/** @param {string} path */ -const isGeneratedJs = path => - /\.(mjs|cjs)$|\.res\.js$/.test(path) && - /^(tests\/|packages\/@rescript\/runtime\/lib\/|rewatch\/testrepo\/)/.test( - path, - ); - -/** - * Checks of the record against its own diff. Each is [ok, text]. - * @param {CommitRecord} r - * @returns {[boolean, string][]} - */ -function checks(r) { - /** @type {[boolean, string][]} */ - const out = []; - const has = (/** @type {RegExp} */ re) => r.files.some(f => re.test(f.path)); - for (const f of r.fragments) - out.push( - f.error - ? [false, `${f.path}: ${f.error.replace(/^[^:]*: /, "")}`] - : [true, `Changelog fragment ${f.path}`], - ); - if (has(/^CHANGELOG\.md$/)) - out.push([ - false, - "Edits CHANGELOG.md; a changes/ fragment carries the entry outside release PRs", - ]); - - const testsValue = r.trailers.get("Tests"); - const testFiles = r.files.filter(f => - /^tests\/|^rewatch\/tests\/|\.test\.js$/.test(f.path), - ); - if (testsValue === undefined) out.push([false, "No Tests trailer"]); - else if (/^none\b/.test(testsValue)) - out.push( - testFiles.length - ? [ - false, - `Tests: none, but the diff changes ${testFiles.length} test file${testFiles.length === 1 ? "" : "s"}`, - ] - : [true, "No test changes, as recorded"], - ); - - // Output of existing programs: modified generated JS. A new test's generated - // JS is output of a new program, counted separately. - const changedJs = r.files.filter( - f => isGeneratedJs(f.path) && f.status !== "A", - ); - const newJs = r.files.filter(f => isGeneratedJs(f.path) && f.status === "A"); - const jsFiles = (/** @type {number} */ n) => - `${n} committed generated JS file${n === 1 ? "" : "s"}`; - const newNote = newJs.length ? `; ${newJs.length} new` : ""; - const output = r.trailers.get("Output"); - if (output !== undefined) { - if (/^unchanged/.test(output) && changedJs.length) - out.push([ - false, - `Output: unchanged, but ${jsFiles(changedJs.length)} change${changedJs.length === 1 ? "s" : ""}${newNote}`, - ]); - else if (/^unchanged/.test(output)) - out.push([true, `No committed generated JS changes${newNote}`]); - else - out.push([ - true, - `${jsFiles(changedJs.length)} change${changedJs.length === 1 ? "s" : ""}${newNote}`, - ]); - } - - const bumped = [ - ...r.patch.matchAll(/^\+(?:let|and) (\w+)_magic_number = "([^"]+)"/gm), - ].map(m => `${m[1]} ${m[2]}`); - const formats = r.trailers.get("Formats"); - if (bumped.length) - out.push([ - formats !== undefined && formats !== "none", - `Magic numbers bumped: ${bumped.join(", ")}`, - ]); - else if (has(/^compiler\/ml\/(parsetree|typedtree|types|lambda)\.mli?$/)) - out.push([ - formats !== undefined && formats !== "none", - "Marshaled AST, typed tree or Lambda type changes with no magic number bump", - ]); - if (has(/^compiler\/ml\/parsetree0\.ml$/)) - out.push([false, "Changes the frozen compiler/ml/parsetree0.ml"]); - if (has(/^compiler\/ml\/ast_mapper_(from|to)0\.ml$/)) - out.push([ - has( - /^tests\/syntax_tests\/data\/ast-mapping\/|^tests\/ounit_tests\/ounit_ast_mapper0_tests\.ml$/, - ), - "v0 PPX bridge changes; ast-mapping fixture or mapper unit test in the diff", - ]); - return out; -} - -// --------------------------------------------------------------------------- -// Cards - -/** @type {Record} */ -const CATEGORIES = { - breaking: ["💥", "Breaking change"], - feature: ["🚀", "New feature"], - fix: ["🐛", "Bug fix"], - docs: ["📝", "Documentation"], - polish: ["💅", "Polish"], - internal: ["🏠", "Internal"], - none: ["", "No changelog"], -}; - -/** @param {string} category */ -const changelogPill = category => { - const [emoji, label] = CATEGORIES[category] ?? ["", category]; - return `${emoji ? `${emoji} ` : ""}${esc(label)}`; -}; - -/** @param {string} value */ -function tests(value) { - /** @type {Map} */ - const groups = new Map(); - for (const item of value.split(/\s+(?=[+~-]\S)/)) { - const kind = - item[0] === "+" - ? "add" - : item[0] === "~" - ? "upd" - : item[0] === "-" - ? "del" - : ""; - const body = kind ? item.slice(1) : item; - const suite = /^tests\/syntax_tests\//.test(body) - ? "syntax" - : /^tests\/tests\//.test(body) - ? "integration" - : /^tests\/ounit_tests\//.test(body) - ? "ounit" - : /^tests\/build_tests\/super_errors/.test(body) - ? "super_errors" - : /^tests\/build_tests\//.test(body) - ? "build" - : /^tests\/analysis_tests\//.test(body) - ? "analysis" - : /^tests\/gentype_tests\//.test(body) - ? "gentype" - : /^rewatch\/tests\//.test(body) - ? "rewatch integration" - : /^rewatch\/src\//.test(body) - ? "cargo test" - : "other"; - // The suite's root directory heads the group; items show the rest. - const root = - /^(tests\/(syntax_tests\/data|tests\/src|ounit_tests|build_tests)\/|rewatch\/(tests|src)\/)/.exec( - body, - )?.[0] ?? ""; - const label = `${suite}${root ? ` ${esc(root)}` : ""}`; - /** @type {Record} */ - const MARKS = { add: "+", upd: "~", del: "−" }; - const mark = MARKS[kind] ?? ""; - groups.set(label, [ - ...(groups.get(label) ?? []), - `${mark ? `${mark} ` : ""}${esc(body.slice(root.length))}`, - ]); - } - return `
${[...groups] - .map( - ([label, items]) => - `
${label}${items.join("")}
`, - ) - .join("")}
`; -} - -/** - * A state trailer: its leading word as a chip, the rest as prose. - * @param {string} v - */ -const stateChip = v => { - const [, word, rest] = /^([^\s,]+),?\s*(.*)$/s.exec(v) ?? ["", v, ""]; - const cls = /^(unchanged|none)$/.test(word) ? "same" : "diff"; - return `
${esc(word)}${rest ? `${inline(rest)}` : ""}
`; -}; - -/** - * Splits on "; " outside parentheses: the items of a Measured trailer. - * @param {string} v - */ -function items(v) { - const out = []; - let depth = 0; - let start = 0; - for (let i = 0; i < v.length; i++) { - if (v[i] === "(") depth++; - else if (v[i] === ")") depth = Math.max(0, depth - 1); - else if (v[i] === ";" && depth === 0) { - out.push(v.slice(start, i).trim()); - start = i + 1; - } - } - out.push(v.slice(start).trim()); - return out.filter(Boolean); -} - -const FACTS = [ - "Output", - "Formatting", - "Formats", - "PPX", - "Editor", - "Tests", - "Measured", -]; - -/** @param {Section[]} sections */ -function renderSections(sections) { - const out = []; - for (let i = 0; i < sections.length; i++) { - const s = sections[i]; - if (["Breaking", "Review"].includes(s.name)) continue; - const next = sections[i + 1]; - if ( - s.name === "Before" && - next?.name === "After" && - next.lang === s.lang && - s.block.length && - next.block.length - ) { - const before = new Set(s.block.map(l => l.trim())); - const after = new Set(next.block.map(l => l.trim())); - out.push( - `
${[ - [s, after, "del", "Before"], - [next, before, "add", "After"], - ] - .map( - ([sec, other, mark, label]) => - `
${label} ${esc(langName(/** @type {Section} */ (sec).lang))}${ - /** @type {Section} */ (sec).text.length - ? ` · ${inline(/** @type {Section} */ (sec).text.join(" "))}` - : "" - }
${codeLines(
-                /** @type {Section} */ (sec).block,
-                /** @type {Section} */ (sec).lang,
-                /** @type {Set} */ (other),
-                /** @type {string} */ (mark),
-              )}
`, - ) - .join("")}
`, - ); - i++; - continue; - } - if (s.name === "Source" && !s.block.length) { - out.push( - `

Source ${inline(s.text.join(" "))}

`, - ); - continue; - } - if (s.name) - out.push( - `

${esc(s.name)}${s.lang ? ` ${esc(langName(s.lang))}` : ""}

`, - ); - if (s.text.length) out.push(`

${inline(s.text.join(" "))}

`); - if (s.block.length) { - const lang = s.lang || (s.name === "Example" ? "rescript" : ""); - const body = - lang === "text" - ? esc(s.block.join("\n").trimEnd()) - : codeLines(s.block, lang, undefined, ""); - out.push( - `
${body}
`, - ); - } - } - return out.join("\n"); -} - -/** @param {string} lang */ -const langName = lang => - ({ - rescript: "ReScript", - js: "JavaScript", - ts: "TypeScript", - sh: "shell", - rust: "Rust", - ocaml: "OCaml", - text: "output", - })[lang] ?? lang; - -/** @param {CommitRecord} r */ -function card(r) { - const categories = [...new Set(r.fragments.map(f => f.category))]; - const pr = ( - r.trailers.get("PR") ?? - /\(#(\d+)\)\s*$/.exec(r.subject)?.[0] ?? - "" - ).replace(/\D/g, ""); - const subject = r.subject.replace(/\s*\(#\d+\)\s*$/, ""); - const out = [ - `
`, - ]; - out.push( - `
${r.sha} · ${esc(r.author)} · ${esc(r.date)}${ - pr ? ` · #${pr} discussion ↗` : "" - }${r.noted ? ' · note' : ""}
` + - `

${inline(subject)}

` + - `
${categories.map(changelogPill).join("")}${words( - r, - "Area", - ) - .map(a => `${esc(a)}`) - .join("")}
`, - ); - const breaking = r.sections.find(s => s.name === "Breaking"); - if (breaking) - out.push( - ``, - ); - out.push(renderSections(r.sections)); - for (const f of r.fragments) - out.push( - `
${changelogPill(f.category)}${bullets(f.text).map(inline).join("
")}
`, - ); - const reviews = r.sections.filter(s => s.name === "Review"); - if (reviews.length) - out.push( - `

Review

    ${reviews - .map(s => `
  • ${inline([...s.text, ...s.block].join(" "))}
  • `) - .join("")}
`, - ); - const STATES = ["Output", "Formatting", "Formats", "PPX", "Editor"]; - const same = STATES.filter(k => - /^(unchanged|none)$/.test(r.trailers.get(k) ?? ""), - ); - const facts = FACTS.flatMap(k => { - const v = r.trailers.get(k); - if (v === undefined || same.includes(k)) return []; - const one = v.replace(/\n/g, " "); - const listed = items(one); - const shown = - k === "Tests" - ? /^none\b/.test(one) - ? stateChip(one) - : tests(one) - : STATES.includes(k) - ? stateChip(one) - : k === "Measured" && listed.length > 1 - ? `
    ${listed.map(l => `
  • ${inline(l)}
  • `).join("")}
` - : v - .split("\n") - .map(line => `${inline(line)}`) - .join(""); - return [`
${k}
${shown}
`]; + const show = (/** @type {string[]} */ ...format) => + git(["show", "-s", ...format, rev]); + return parseRecord({ + sha: show("--format=%h", "--abbrev=9").trim(), + author: show("--format=%an").trim(), + date: show("--format=%ad", "--date=format:%b %d, %Y").trim(), + message: show("--format=%B"), }); - if (same.length) - facts.unshift( - `
No effect
${same.map(k => `${k}: ${esc(r.trailers.get(k) ?? "")}`).join("")}
`, - ); - if (facts.length) out.push(`
${facts.join("")}
`); - out.push( - `
    ${checks(r) - .map( - ([ok, text]) => `
  • ${esc(text)}
  • `, - ) - .join("")}
`, - ); - /** @type {Map} */ - const byArea = new Map(); - for (const f of r.files) { - const a = areaOf(f.path); - const e = byArea.get(a) ?? { n: 0, a: 0, d: 0 }; - byArea.set(a, { n: e.n + 1, a: e.a + f.added, d: e.d + f.removed }); - } - const added = r.files.reduce((n, f) => n + f.added, 0); - const removed = r.files.reduce((n, f) => n + f.removed, 0); - out.push( - `
${r.files.length} files · +${added} −${removed} · ${[ - ...byArea, - ] - .map(([a, e]) => `${esc(a)} ${e.n}`) - .join(" · ")}${r.files - .map( - f => - ``, - ) - .join("")}
${f.status}${esc(f.path)}+${f.added}−${f.removed}
`, - ); - if (r.coauthors.length) - out.push(`
with ${esc(r.coauthors.join(" · "))}
`); - out.push("
"); - return out.join("\n"); } -// --------------------------------------------------------------------------- -// Ranges - /** - * @typedef {{ name: string, range?: string, records: CommitRecord[] }} Group + * A revision is one card; a range `base..tip` is its commits, oldest first. + * @param {string} arg + * @returns {Group} */ - -/** @param {string} arg */ function group(arg) { - if (!arg.includes("..")) return { name: arg, records: [readRecord(arg)] }; + if (!arg.includes("..")) return { records: [readRecord(arg)] }; const revs = git(["rev-list", "--reverse", "--no-merges", arg]) .split("\n") .filter(Boolean); - return { - name: arg.split("..")[1], - range: arg, - records: revs.map(readRecord), - }; -} - -/** - * The fragments a range leaves in changes/: the last version of each one its - * commits add or change, minus those a later commit deletes. - * @param {Group} g - */ -function rangeFragments(g) { - /** @type {Map} */ - const byPath = new Map(); - for (const r of g.records) { - for (const f of r.files) if (f.status === "D") byPath.delete(f.path); - for (const f of r.fragments) byPath.set(f.path, f); - } - return [...byPath.values()]; + return { range: arg, records: revs.map(readRecord) }; } -/** @param {Group[]} groups */ -function summary(groups) { - const ranges = groups.filter(g => g.range); - if (!ranges.length) return ""; - const rows = ranges.map(g => { - const cats = new Map(); - for (const f of rangeFragments(g)) - cats.set(f.category, (cats.get(f.category) ?? 0) + 1); - if (!cats.size) cats.set("none", 1); - const areas = [...new Set(g.records.flatMap(r => words(r, "Area")))]; - const js = g.records.filter(r => - /^changed/.test(r.trailers.get("Output") ?? ""), - ).length; - const formats = [ - ...new Set( - g.records - .map(r => r.trailers.get("Formats") ?? "none") - .filter(v => v !== "none"), - ), - ]; - const warnings = g.records.reduce( - (n, r) => n + checks(r).filter(([ok]) => !ok).length, - 0, - ); - return ( - `${esc(g.range ?? g.name)}${g.records.length}` + - `${[...cats].map(([c, n]) => `${changelogPill(c)}${n > 1 ? ` ×${n}` : ""}`).join(" ")}` + - `${areas.map(a => `${esc(a)}`).join("")}` + - `${js}${formats.map(inline).join("
") || 'none'}` + - `${warnings ? `${warnings}` : "0"}` - ); - }); - /** @type {Map} */ - const owners = new Map(); - for (const g of ranges) - for (const r of g.records) - for (const f of r.files) - owners.set(f.path, [ - ...(owners.get(f.path) ?? []), - ranges.length > 1 ? g.name : r.sha, - ]); - const shared = [...owners] - .map(([f, os]) => /** @type {[string, string[]]} */ ([f, [...new Set(os)]])) - .filter(([, os]) => os.length > 1) - .sort(); - /** @type {Map} */ - const draft = new Map(); - for (const g of ranges) - for (const f of rangeFragments(g)) - draft.set(f.category, [ - ...(draft.get(f.category) ?? []), - ...bullets(f.text), - ]); - const order = Object.keys(CATEGORIES); - return ( - `

Summary

${rows.join("")}
RangeCommitsChangelogAreasJS output changesFormatsFailed checks
` + - (draft.size - ? `

Changelog draft

${[...draft] - .sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0])) - .map( - ([c, es]) => - `
${changelogPill(c)}
    ${es.map(e => `
  • ${inline(e)}
  • `).join("")}
`, - ) - .join("")}
` - : "") + - (shared.length - ? `

Files changed by more than one ${ranges.length > 1 ? "range" : "commit"}

${shared - .map( - ([f, os]) => - ``, - ) - .join("")}
${esc(f)}${os.map(o => (ranges.length > 1 ? esc(o) : `${o}`)).join(", ")}
` - : "") + - `
` - ); -} - -// --------------------------------------------------------------------------- -// Page - -const LIGHT = `--bg:#f7f6f4; --surface:#ffffff; --ink:#1f1d1c; --muted:#6b6560; --rule:#e2ded9; --accent:#d43a3f; --accent-soft:#fbe9e9; - --code-bg:#f4f2ef; --code-ink:#2a2725; --k:#a3238e; --s:#1f7a3a; --n:#b35c00; --c:#8a8580; --t:#1d5fa8; --a:#8a5a00; --o:#6b6560; --p:#7a3db8; - --add:#e3f4e6; --del:#fbe5e3; --ok:#23784a; --ok-bg:#e2f3e8; --warn:#9a5b00; --warn-bg:#fdf0d9; - --same:#5a6470; --same-bg:#eceff2; --diff:#1d5fa8; --diff-bg:#e3edf9; --plus:#23784a; --minus:#b3261e;`; -const DARK = `--bg:#151413; --surface:#1e1c1b; --ink:#ece8e4; --muted:#a39d97; --rule:#36322f; --accent:#ff6b6f; --accent-soft:#3a1d1e; - --code-bg:#252220; --code-ink:#e6e1dc; --k:#e48ad6; --s:#8fd6a0; --n:#f2b36b; --c:#8a847e; --t:#8ab8f0; --a:#e0bc6a; --o:#b5aea7; --p:#c6a2f0; - --add:#1c3324; --del:#3a1f1d; --ok:#8fd6a0; --ok-bg:#1c3324; --warn:#f2c36b; --warn-bg:#3a2d16; - --same:#b0b8c2; --same-bg:#2a2e33; --diff:#8ab8f0; --diff-bg:#1d2b3d; --plus:#8fd6a0; --minus:#f19a93; color-scheme:dark;`; - -const CSS = ` -:root { ${LIGHT} - --display:"Fraunces",Georgia,serif; --body:"Inter","Helvetica Neue",Arial,sans-serif; --mono:"JetBrains Mono",Menlo,Consolas,monospace; } -@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ${DARK} } } -:root[data-theme="dark"] { ${DARK} } -* { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 var(--body) } -main { max-width:1120px; margin:0 auto; padding:32px 16px 64px; display:flex; flex-direction:column; gap:22px } -h1 { font:600 30px/1.2 var(--display); margin:0; text-wrap:balance } h1.brand { font-size:34px } h1 span, .kicker span { color:var(--accent) } h1 a { text-decoration:none } -.kicker { font:600 13px/1 var(--display); letter-spacing:.04em; margin:0 0 8px; color:var(--muted) } -h2 { font:600 22px/1.2 var(--display); margin:16px 0 8px; padding-top:14px; border-top:2px solid var(--ink) } -h3 { font:600 20px/1.3 var(--display); margin:4px 0 8px; text-wrap:balance } -h4 { font:600 11px/1 var(--body); letter-spacing:.08em; text-transform:uppercase; color:var(--muted); margin:14px 0 6px } -p { margin:2px 0; max-width:75ch } .lede, .muted { color:var(--muted) } -a { color:var(--accent) } :focus-visible { outline:2px solid var(--accent); outline-offset:2px } -code { font:12.5px/1.5 var(--mono); background:var(--code-bg); padding:1px 4px; border-radius:3px; overflow-wrap:anywhere } -.card { background:var(--surface); border:1px solid var(--rule); border-radius:10px; padding:20px 22px; display:flex; flex-direction:column; gap:6px; min-width:0 } -.card.breaking { border-top:4px solid var(--accent) } -.meta { font:12px/1.5 var(--mono); color:var(--muted) } .meta code { background:none; padding:0 } -.chips { display:flex; flex-wrap:wrap; gap:6px; align-items:center } -.chip { font:500 12px/1 var(--body); border:1px solid var(--rule); border-radius:99px; padding:5px 9px; color:var(--muted) } -.pill { font:600 12px/1 var(--body); padding:6px 10px; border-radius:99px; white-space:nowrap; background:var(--same-bg); color:var(--same) } -.pill.cat-breaking { background:var(--accent); color:#fff } .pill.cat-fix { background:var(--warn-bg); color:var(--warn) } -.pill.cat-feature { background:var(--ok-bg); color:var(--ok) } .pill.cat-polish { background:var(--diff-bg); color:var(--diff) } -.banner { background:var(--accent-soft); border-left:4px solid var(--accent); padding:10px 14px; border-radius:6px; margin:4px 0 } -.banner strong { color:var(--accent); margin-right:6px; text-transform:uppercase; font-size:12px; letter-spacing:.06em } -pre { margin:4px 0; padding:12px 0; border-radius:8px; overflow-x:auto; background:var(--code-bg); color:var(--code-ink); font:12.5px/1.6 var(--mono) } -pre .ln { display:block; padding:0 14px; white-space:pre; min-height:1.6em } pre .ln.add { background:var(--add) } pre .ln.del { background:var(--del) } -pre.term { padding:12px 14px; white-space:pre } -.k { color:var(--k) } .s { color:var(--s) } .n { color:var(--n) } .c { color:var(--c); font-style:italic } .t { color:var(--t) } .a { color:var(--a) } .o { color:var(--o) } .p { color:var(--p) } -.pair { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); grid-template-rows:auto auto; column-gap:10px; margin:8px 0 } -figure { margin:0; min-width:0; display:grid; grid-row:span 2; grid-template-rows:subgrid; row-gap:0 } figure pre { margin:0 } figcaption { font:12px/1.4 var(--body); color:var(--muted); margin-bottom:4px } figcaption b { font-weight:600; font-size:11px; letter-spacing:.06em; text-transform:uppercase } -.lang { font:500 10px/1 var(--mono); letter-spacing:0; text-transform:none; border:1px solid var(--rule); border-radius:4px; padding:2px 5px; color:var(--muted) } -.entry { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); padding:10px 12px; border-radius:8px; margin-top:8px; font-size:14px; flex-wrap:wrap } -.entry span:last-child { flex:1 1 300px; min-width:0 } -ul.review { margin:0; padding-left:18px } ul.review li { margin:2px 0 } -dl.facts { display:grid; grid-template-columns:max-content minmax(0,1fr); gap:8px 16px; margin:12px 0 0; padding-top:12px; border-top:1px solid var(--rule); font-size:13.5px } -dt { font:600 11px/2 var(--body); letter-spacing:.06em; text-transform:uppercase; color:var(--muted) } -dd { margin:0; min-width:0; display:flex; flex-direction:column; gap:3px } dd.row, .row { display:flex; flex-direction:row; flex-wrap:wrap; gap:6px; align-items:baseline } -.source { font-size:12.5px; color:var(--muted) } .source b { font-weight:600; font-size:11px; letter-spacing:.06em; text-transform:uppercase; margin-right:4px } -.state { align-self:flex-start; padding:3px 8px; border-radius:5px } .state.same { background:var(--same-bg); color:var(--same) } .state.diff { background:var(--diff-bg); color:var(--diff) } -.delta { font-variant-numeric:tabular-nums; white-space:nowrap } .delta .from { color:var(--muted); text-decoration:line-through } .delta .to { font-weight:600; color:var(--ok) } -.tests { display:flex; flex-direction:column; gap:4px } .tests > div { display:flex; flex-wrap:wrap; gap:4px; align-items:baseline } -.suite { font:600 11px/1 var(--body); color:var(--muted); min-width:9em } -.test { font:12px/1.3 var(--mono); padding:3px 7px; border-radius:4px; background:var(--code-bg); overflow-wrap:anywhere } -.test.add { background:var(--ok-bg); color:var(--ok) } .test.del { background:var(--del); color:var(--minus) } .test b { font-weight:600 } -.suite code { font-size:11px; font-weight:400; background:none; padding:0 } .run { margin:0; padding-left:18px } .run li { margin:1px 0 } -ul.checks { list-style:none; margin:10px 0 0; padding:0; display:flex; flex-wrap:wrap; gap:6px; font-size:12.5px } -ul.checks li { padding:3px 9px; border-radius:5px } ul.checks li.ok { background:var(--ok-bg); color:var(--ok) } ul.checks li.ok::before { content:"✓ " } -ul.checks li.warn { background:var(--warn-bg); color:var(--warn) } ul.checks li.warn::before { content:"! " } -details { margin-top:8px; font-size:13px; color:var(--muted) } summary { cursor:pointer } -table { border-collapse:collapse; width:100%; font-size:13px } th, td { text-align:left; vertical-align:top; padding:7px 10px; border-bottom:1px solid var(--rule) } -th { font:600 11px/1.2 var(--body); letter-spacing:.06em; text-transform:uppercase; color:var(--muted); white-space:nowrap } -td.num { font-variant-numeric:tabular-nums; text-align:right; white-space:nowrap } .plus { color:var(--plus) } .minus { color:var(--minus) } -table.files td { padding:3px 8px } td.st { font:600 11px var(--mono) } .st-A { color:var(--plus) } .st-D { color:var(--minus) } -.table-wrap { overflow-x:auto; border:1px solid var(--rule); border-radius:10px; background:var(--surface) } -.draft { display:flex; flex-direction:column; gap:10px; background:var(--surface); border:1px solid var(--rule); border-radius:10px; padding:14px 16px } -td.nowrap code { white-space:nowrap } .draft ul { margin:6px 0 0; padding-left:18px } .warn-n { color:var(--warn); font-weight:600 } -pre, code, .test { font-variant-ligatures:none; font-feature-settings:"calt" 0, "liga" 0 } -@media (max-width:720px) { .pair { grid-template-columns:minmax(0,1fr); grid-template-rows:none; row-gap:10px } figure { grid-row:auto; grid-template-rows:auto auto } dl.facts { grid-template-columns:minmax(0,1fr) } dt { line-height:1.2; margin-top:6px } .card { padding:16px } }`; - const args = process.argv.slice(2); /** @type {Record} */ const options = {}; +/** @type {string[]} */ const targets = []; -let fragment = false; for (let i = 0; i < args.length; i++) { if (["--out", "--pr", "--title"].includes(args[i]) && i + 1 < args.length) options[args[i].slice(2)] = args[++i]; - else if (args[i] === "--fragment") fragment = true; else targets.push(args[i]); } const outFile = options.out; if (!outFile) { console.error( - "usage: commitRecords.js --out [--pr ] [--title ] [--fragment] ...", + "usage: commitRecords.js --out [--pr ] [--title ] ...", ); process.exit(1); } -const pr = (options.pr ?? "").replace(/\D/g, ""); -const title = options.title ?? ""; -const groups = targets.map(group); -for (const g of groups) for (const r of g.records) pageShas.add(r.sha); -const commits = groups.reduce((n, g) => n + g.records.length, 0); -const commitCount = `${commits} commit${commits === 1 ? "" : "s"}, rendered from ${commits === 1 ? "its record" : "their records"}.`; -const heading = - pr || title - ? `

Semantic Diff

${pr ? `#${pr} ` : ""}${inline(title)}

` - : `

Semantic Diff

`; -const body = `
${heading}

${commitCount}

-${summary(groups)} -${groups - .map( - g => - (g.range - ? `

${esc(g.range)}

` - : "") + g.records.map(card).join("\n"), - ) - .join("\n")} -
`; -const pageTitle = - pr || title - ? `${pr ? `#${pr} ` : ""}${title} · Semantic Diff` - : "Semantic Diff"; -const head = `${esc(pageTitle)} - +const heading = { + pr: (options.pr ?? "").replace(/\D/g, ""), + title: options.title ?? "", +}; +const body = pageBody(targets.map(group), heading); +const head = `${esc(pageTitle(heading))} + `; writeFileSync( outFile, - fragment - ? `${head}\n${body}\n` - : `\n\n${head}${body}\n`, + `\n\n${head}${body}\n`, ); console.log(outFile); diff --git a/scripts/commitRecords.test.js b/scripts/commitRecords.test.js index 510a458d72..7dbbb322a7 100644 --- a/scripts/commitRecords.test.js +++ b/scripts/commitRecords.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -7,118 +7,303 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; const script = fileURLToPath(new URL("./commitRecords.js", import.meta.url)); - -const record = `Compile x + 0 to x - -Integer addition of the literal 0 compiles to the operand. - -Example (rescript): - let f = (x: int) => x + 0 - -Before (js): - return x + 0 | 0; - -After (js): - return x; - -Source: bsc before and after this commit. - -Area: codegen tests -Output: changed integer + 0; one snapshot -Formats: none -Tests: +tests/tests/src/a_test.mjs ~tests/tests/src/b_test.mjs -Measured: build 10 s -> 8 s (make, 1 run); bundle 5 KB -> 4 KB (esbuild) -Signed-off-by: A Person -`; +const SIGNED = "Signed-off-by: A Person "; /** - * Asserts that the page contains the text, without printing the page. + * Asserts that the page contains the text; a failure names the text. * @param {string} html * @param {string} text */ const has = (html, text) => assert.ok(html.includes(text), `page lacks ${JSON.stringify(text)}`); +/** + * Asserts that the page lacks the text. + * @param {string} html + * @param {string} text + */ +const lacks = (html, text) => + assert.ok(!html.includes(text), `page has ${JSON.stringify(text)}`); + +/** + * @param {string} dir + * @param {Record} files + */ +async function write(dir, files) { + for (const [name, content] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true }); + await fs.writeFile(path.join(dir, name), content); + } +} + /** * Renders the records of a temporary repository's commits. * @param {(git: (...args: string[]) => string, dir: string) => Promise} setup - * @param {string[]} revs + * @param {string[]} args */ -async function render(setup, revs) { +async function render(setup, args) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-")); - const git = (/** @type {string[]} */ ...args) => - execFileSync("git", args, { cwd: dir, encoding: "utf8" }); + const git = (/** @type {string[]} */ ...a) => + execFileSync("git", a, { cwd: dir, encoding: "utf8" }); git("init", "-q", "-b", "main"); git("config", "user.name", "A Person"); git("config", "user.email", "a@example.com"); await setup(git, dir); const out = path.join(dir, "page.html"); - execFileSync("node", [script, "--out", out, ...revs], { cwd: dir }); + const r = spawnSync("node", [script, "--out", out, ...args], { + cwd: dir, + encoding: "utf8", + }); + assert.equal(r.status, 0, r.stderr); return fs.readFile(out, "utf8"); } /** + * Commits the files with the message. + * @param {(...args: string[]) => string} git * @param {string} dir * @param {Record} files + * @param {string} message */ -async function write(dir, files) { - for (const [name, content] of Object.entries(files)) { - await fs.mkdir(path.dirname(path.join(dir, name)), { recursive: true }); - await fs.writeFile(path.join(dir, name), content); - } +async function commit(git, dir, files, message) { + await write(dir, files); + git("add", "-A", "."); + git("commit", "-q", "--allow-empty", "-m", message); } -test("renders a record and checks it against the diff", async () => { +/** @param {string} tree @param {string} indent */ +const indented = (tree, indent) => + tree + .trimEnd() + .split("\n") + .map(l => indent + l) + .join("\n"); + +const BEFORE_TREE = `[ + structure_item + Pstr_value Nonrec + [ + + pattern + Ppat_var "f" + expression + Pexp_extension "raw" + ] +]`; +const AFTER_TREE = BEFORE_TREE.replace( + 'Pexp_extension "raw"', + 'Pexp_raw "raw"\n Raw_template "(a) => a"', +); + +const AST_RECORD = `Represent raw extensions as their own node + +Why: + A raw extension carries JavaScript; its own node gives it a type. + +AST modified (raw extensions): +Example: + let f = %raw(\`(a) => a\`) +Before (parse): +${indented(BEFORE_TREE, " ")} +After (parse): +${indented(AFTER_TREE, " ")} + +Review: Does a PPX still see the extension? Yes: the v0 bridge maps it back, + as https://github.com/rescript-lang/rescript/pull/8624 describes. + +${SIGNED} +`; + +test("a minimal record draws its identity and subject", async () => { const html = await render( async (git, dir) => { - await write(dir, { "tests/tests/src/b_test.mjs": "x + 0 | 0\n" }); - git("add", "."); - git("commit", "-q", "-m", "base"); - await write(dir, { - "changes/identity.polish.md": - "- Compile `x + 0` to `x`. https://github.com/rescript-lang/rescript/pull/1234\n", - "tests/tests/src/a_test.mjs": "x\n", - "tests/tests/src/b_test.mjs": "x\n", - }); - git("add", "."); - git("commit", "-q", "-m", record); + await commit(git, dir, { "README.md": "a\n" }, "base"); + await commit( + git, + dir, + { "README.md": "b\n" }, + `Fix a typo (#7)\n\nCo-Authored-By: B Person \n${SIGNED}`, + ); }, - ["main~1..main"], + ["HEAD"], ); - has(html, "

Compile x + 0 to x

"); + has(html, "Semantic Diff"); has(html, '

Semantic Diff

'); - has(html, ''); - has(html, ''); - has(html, '

Source'); - has(html, "Changelog fragment changes/identity.polish.md"); + 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>/, + ); + has(html, '
with B Person
'); + lacks(html, '
'); + lacks(html, "Signed-off-by"); + lacks(html, "Co-Authored-By"); +}); + +test("an AST block's parse trees are drawn as aligned Before and After rows", async () => { + const html = await render( + async (git, dir) => { + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit(git, dir, { "compiler/ml/parsetree.ml": "x\n" }, AST_RECORD); + }, + ["HEAD"], + ); + has( + html, + '

modifiedAST raw extensions

', + ); + has(html, '%raw'); + has(html, '
parse
'); + has(html, '
'); + // the root stands bare; the common lines face each other; + // the changed node is a deleted row Before and two added rows After, the + // shorter side padded with a gap + const nb = "\u00a0"; has( html, - '
💅 PolishCompile x + 0', + 'structure_item', ); - has(html, "1 committed generated JS file changes; 1 new"); - has(html, "Formats: none"); - has(html, "tests/tests/src/"); - has(html, "
  • bundle "); - has(html, "changelog 1"); - has(html, '0'); - has(html, "
  • Compile x + 0"); + has( + html, + `${nb.repeat(9)}└─${nb}Pexp_extension "raw"`, + ); + has( + html, + `${nb.repeat(9)}├─${nb}Pexp_raw "raw"`, + ); + has(html, ''); + has(html, ''); + has(html, ''); + has(html, '
    • Does a PPX still see the extension?'); + has( + html, + 'as #8624 describes', + ); + has(html, '
      '); + // the example and its forms belong to the object's block + assert.equal(html.split('
      { + const html = await render( + async (git, dir) => { + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit( + git, + dir, + {}, + `Add a raw node\n\nAST added (Pexp_raw):\nAfter (parse):\n${indented(AFTER_TREE, " ")}\n\n${SIGNED}\n`, + ); + }, + ["HEAD"], + ); + has(html, 'added'); + has( + html, + '
      After
      ', + ); + has(html, 'Pexp_raw'); + lacks(html, '
      { +test("a range draws each commit's forms: JS as code, a diagnostic as text", async () => { const html = await render( async (git, dir) => { - await write(dir, { "tests/tests/src/b_test.mjs": "a\n" }); - git("add", "."); - git("commit", "-q", "-m", "base"); - await write(dir, { "tests/tests/src/b_test.mjs": "b\n" }); - git("add", "."); - git( - "commit", - "-q", - "-m", - "Change output\n\nArea: codegen\nOutput: unchanged", + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit(git, dir, {}, AST_RECORD); + await commit( + git, + dir, + {}, + `Compile raw extensions directly + +JS modified (raw extension): +Example: + let f = 1 +Before (js): + let f = 1; +After (js): + let f = 2; + +Diagnostic added (unused raw): +After (diagnostic): + Warning number 109 + This raw extension is unused. + +${SIGNED} +`, ); }, + ["HEAD~2..HEAD"], + ); + has(html, "

      HEAD~2..HEAD

      "); + has(html, 'js'); + has( + html, + 'let f = 1;', + ); + has( + html, + 'let f = 2;', + ); + has(html, 'diagnostic'); + has(html, '
      '); + has(html, "This raw extension is unused."); + assert.equal(html.split('
      ').length - 1, 1); +}); + +test("equal Before and After forms are drawn once", async () => { + const html = await render( + async (git, dir) => { + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit( + git, + dir, + {}, + `Keep the output\n\nJS modified (x):\nBefore (js):\n let x = 1;\nAfter (js):\n let x = 1;\n\n${SIGNED}\n`, + ); + }, + ["HEAD"], + ); + has(html, "Before and After are equal"); + lacks(html, '
      { + const message = `Change things\n\nWhy:\n A reason.\n\n${SIGNED}\n`; + /** @param {Record} files */ + const card = async files => { + const html = await render( + async (git, dir) => { + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit(git, dir, files, message); + }, + ["HEAD"], + ); + // the card with its short hash, which differs between the two commits, + // blanked + const sha = /id="c-([0-9a-f]+)"/.exec(html)?.[1] ?? ""; + return html + .slice(html.indexOf("")) + .replaceAll(sha, "SHA"); + }; + const touched = await card({ + "tests/tests/src/a_test.res": "let y = 1\n", + "tests/tests/src/a_test.mjs": "y\n", + "tests/build_tests/super_errors/expected/x.res.expected": "e\n", + "changes/x.fix.md": "- Fix a thing.\n", + "compiler/ext/config.ml": 'let cmi_magic_number = "BS00MI002"\n', + }); + assert.equal(touched, await card({})); + has(touched, "A reason."); + lacks(touched, "a_test"); +}); + +test("--pr and --title head the page, escaped", async () => { + const html = await render( + async (git, dir) => { + await commit(git, dir, { "a.txt": "a\n" }, `Base\n\n${SIGNED}`); + }, ["--pr", "42", "--title", "Fix & more", "HEAD"], ); has( @@ -126,80 +311,362 @@ test("flags a record that disagrees with its diff", async () => { '

      #42 Fix <output> & more

      ', ); has(html, "#42 Fix <output> & more · Semantic Diff"); - has(html, "Output: unchanged, but 1 committed generated JS file changes"); - has(html, "No Tests trailer"); }); -test("accepts Tests: none with a reason, and flags it against test files", async () => { +test("a typing rule draws subscripts, and a matrix its cells and notes", async () => { const html = await render( async (git, dir) => { - await write(dir, { "README.md": "a\n" }); - git("add", "."); - git("commit", "-q", "-m", "base"); - await write(dir, { "README.md": "b\n" }); - git("add", "."); - git( - "commit", - "-q", - "-m", - "Reword the readme\n\nArea: docs\nOutput: unchanged\nTests: none, because only prose changes", - ); - await write(dir, { "tests/tests/src/c_test.res": "let x = 1\n" }); - git("add", "."); - git( - "commit", - "-q", - "-m", - "Add a test\n\nArea: tests\nOutput: unchanged\nTests: none, because it is a fixture", + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit( + git, + dir, + {}, + `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} + +Matching modified (option split): +After (matrix): + Some(x) _ e1 + None Some e2 + order: Some before None + +${SIGNED} +`, ); }, - ["HEAD~2..HEAD"], + ["HEAD"], + ); + has(html, "e1"); + has(html, "τ2"); + has(html, '(App)'); + // only the premise that changes is shaded + has( + html, + '
      Γ ⊢ e1', ); - has(html, "No test changes, as recorded"); + has(html, 'Γ ⊢ e2'); + has(html, 'Γ ⊢ e2'); + assert.equal(html.split('').length - 1, 1); + assert.equal(html.split('').length - 1, 1); + has(html, '
      Γ ⊢'); + has(html, "grid-template-columns:repeat(3,max-content)"); + has(html, 'Some(x)'); has( html, - 'nonebecause only prose changes', + '
      order', ); - has(html, "Tests: none, but the diff changes 1 test file"); - has(html, 'No changelog'); }); -test("takes the changelog from the fragments a range leaves", async () => { - const link = "https://github.com/rescript-lang/rescript/pull"; +test("a line of another shape is prose, and a version without its pair is drawn across the block", async () => { const html = await render( async (git, dir) => { - await write(dir, { "CHANGELOG.md": "# Changelog\n" }); - git("add", "."); - git("commit", "-q", "-m", "base"); - await write(dir, { - "changes/crash.fix.md": `- Fix a crash. ${link}/XXXX\n`, - "changes/gone.feature.md": `- Add a thing. ${link}/7\n`, - }); - git("add", "."); - git("commit", "-q", "-m", "Draft\n\nArea: compiler"); - await write(dir, { - "changes/crash.fix.md": `- Fix a crash\n in the printer. ${link}/8\n- Fix another. ${link}/8\n`, - "CHANGELOG.md": "# Changelog\n\n- Edited.\n", - }); - git("rm", "-q", "changes/gone.feature.md"); - git("add", "."); - git("commit", "-q", "-m", "Link the PR\n\nArea: compiler"); + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit( + git, + dir, + {}, + `Change things + +Typing rule modified (x): +After (js): + let x = 1; + +JS modified: +After (js): + let y = 2; + +${SIGNED} +`, + ); + }, + ["HEAD"], + ); + has( + html, + '

      Typing rule modified (x):

      ', + ); + has( + html, + '

      modifiedJS

      ', + ); + assert.equal( + html.split( + '
      After
      ', + ).length - 1, + 2, + ); + lacks(html, '
      { + const html = await renderOne( + `Keep the node\n\nThe fix is simple: the parser keeps the node.\n\n${SIGNED}\n`, + ); + has(html, "

      The fix is simple: the parser keeps the node.

      "); + lacks(html, '

      '); +}); + +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`, + ); + has(html, `'\\u{100}'`); + has(html, `'\\u{1100}'`); +}); + +test("Breaking and Review sections right above the sign-off are drawn", async () => { + const html = await renderOne( + `Rename a flag\n\nBreaking: -bs-x is now -x.\nReview: Is -bs-x still read? No.\n${SIGNED}\nCo-Authored-By: B Person \n`, + ); + has( + html, + '', + ); + has(html, '
    • Is -bs-x still read? No.
    '); + has(html, '
    with B Person
    '); + lacks(html, "Signed-off-by"); +}); + +test("a block ending right above the sign-off keeps the sign-off out", async () => { + const html = await renderOne( + `Change x\n\nJS modified (x):\nAfter (js):\n let x = 1;\n${SIGNED}\n`, + ); + has(html, 'let x = 1;'); + lacks(html, "Signed-off-by"); + lacks(html, "A Person <"); +}); + +test("a pair of rewrites shades each side where it changes", async () => { + const html = await renderOne( + `Simplify additions\n\nLambda modified (identity):\nBefore (lambda):\n (+ x 0) ⇝ (+ x 0)\nAfter (lambda):\n (+ x 0) ⇝ x\n when: x is an int\n\n${SIGNED}\n`, + ); + has(html, '
    '); + // the left-hand sides are equal and unshaded; the right-hand sides differ + const lhs = + '(+ x 0)'; + assert.equal(html.split(lhs).length - 1, 2); + has( + html, + '(+ x 0)', + ); + has(html, 'x'); + has(html, '
    when'); +}); + +test("a chain of rewrites draws every term", async () => { + const html = await renderOne( + `Simplify twice\n\nLambda added (chain):\nAfter (lambda):\n (+ (+ x 0) 0) ⇝ (+ x 0) ⇝ x\n\n${SIGNED}\n`, + ); + has(html, '
    '); + assert.equal(html.split('⇝').length - 1, 2); + has(html, 'x'); + has( + html, + '(+ x 0)', + ); +}); + +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
    '); + lacks(html, "Claude-Session"); + lacks(html, "session_x"); + lacks(html, '
    { + const html = await renderOne( + `Drop an identity\n\nJS modified (x):\nBefore (js): return x + 0 | 0;\nAfter (js): return x;\n\nJS modified (y):\nBefore (js):\n\treturn y;\nAfter (js):\n\treturn y;\n\n${SIGNED}\n`, + ); + has( + html, + 'return x + 0 | 0;', + ); + has( + html, + 'return x;', + ); + lacks(html, ''); + assert.equal(html.split("Before and After are equal").length - 1, 1); + has(html, 'return y;'); +}); + +test("a blank line ends a section whose text is on its heading line", async () => { + const html = await renderOne( + `Rename a flag\n\nWhy: the problem.\n\nA separate paragraph.\n\nBreaking: -x is gone.\n\nAnother paragraph.\n\n${SIGNED}\n`, ); has( html, - "changes/crash.fix.md: replace the placeholder https://github.com/rescript-lang/rescript/pull/XXXX with the PR link", + '

    Why

    the problem.

    ', + ); + has(html, '

    A separate paragraph.

    '); + has(html, ''); + has(html, '

    Another paragraph.

    '); +}); + +test("a line indented by 2 spaces continues its paragraph", async () => { + const html = await renderOne( + `Change things\n\n- first bullet that wraps\n onto a second line\n\n${SIGNED}\n`, + ); + has(html, "

    - first bullet that wraps onto a second line

    "); + lacks(html, '
    ');
    +});
    +
    +test("a block indented under a paragraph is code", async () => {
    +  const html = await renderOne(
    +    `Change output\n\nThe new output for:\n\n    let f = x => x + 1\n    let g = y => y\n\n${SIGNED}\n`,
    +  );
    +  has(html, "

    The new output for:

    "); + has( + html, + '
    let f = x => x + 1let g = y => y
    ', + ); +}); + +test("Example: with text on its line is prose; alone, it heads a program", async () => { + const html = await renderOne( + `Fold constants\n\nExample: when the argument is constant, the call folds.\n\nJS modified (f):\nExample:\n let f = 1\nAfter (js):\n let f = 1;\n\n${SIGNED}\n`, + ); + has( + html, + '

    Example: when the argument is constant, the call folds.

    ', + ); + has( + html, + '
    let f = 1
    ', + ); +}); + +test("a representation, a matrix and captions are drawn on aligned rows", async () => { + const html = await renderOne(`Unbox options + +Representation modified (option): +Before (representation): boxed + Some(x) ↦ {_0: x} + None ↦ undefined +After (representation): unboxed + Some(x) ↦ x + None ↦ undefined + note: x is not undefined + +Matching modified (option split): +Before (matrix): + Some(x) e1 + None e2 +After (matrix): + Some(x) _ e1 + None e2 + +JS modified (f): +Before (js): same output + let f = 1; +After (js): same output + let f = 1; + +${SIGNED} +`); + has( + html, + '
    Before boxed
    After unboxed
    ', + ); + has( + html, + 'Some(x) ↦ x', + ); + has( + html, + 'None ↦ undefined', + ); + has(html, '
    note'); + has( + html, + 'Some(x) _ e1', + ); + has( + html, + 'None e2', + ); + has( + html, + '
    Before and After are equal same output
    ', + ); + assert.equal(html.split('
    ').length - 1, 2); +}); + +test("typed, expanded and lambda forms, and links in prose", async () => { + let first = ""; + const html = await render( + async (git, dir) => { + await commit(git, dir, { "a.txt": "a\n" }, "base"); + await commit(git, dir, {}, `Type raw nodes\n\n${SIGNED}\n`); + first = git("rev-parse", "--short=9", "HEAD").trim(); + await commit( + git, + dir, + {}, + `Lower raw nodes + +Why: + ${first} types raw nodes; #1234 lowers them. + +Typedtree added (raw): +After (typed): + Texp_raw "x" + +AST modified (raw): +Before (expanded): + Pexp_extension "raw" +After (expanded): + Pexp_raw "raw" + +Lambda added (raw): +After (lambda): + (let (f/1002 = (#raw_expr "x")) f/1002) + +${SIGNED} +`, + ); + }, + ["HEAD~2..HEAD"], ); - has(html, "Changelog fragment changes/gone.feature.md"); has( html, - "Edits CHANGELOG.md; a changes/ fragment carries the entry outside release PRs", + `${first} types raw nodes; #1234 lowers them.`, ); - has(html, '🐛 Bug fix'); + has(html, 'typed'); has( html, - '
    🐛 Bug fix