diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md new file mode 100644 index 0000000000..afcb5d58ec --- /dev/null +++ b/.claude/skills/records/SKILL.md @@ -0,0 +1,135 @@ +--- +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. +--- + +# Commit records and review pages + +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. + +## The record + +``` + + + + +Example (rescript): + + +Before (js): + + +After (js): + + +Source: + +Breaking: + +Changelog entry: ..md fragment> + +Review: + +Area: syntax printer frontend typechecker lambda codegen runtime rewatch + analysis reanalyze gentype tools playground scripts ci docs tests +Changelog: breaking | feature | fix | docs | polish | internal | none +Output: unchanged | changed , +Formatting: unchanged | changed +Formats: none | +PPX: unchanged | +Editor: unchanged | +Errors: +Tests: +added/path ~updated/path -removed/path +Ran: ; ; ... +Measured: after, and what produced it>; ... +Depends: +PR: # +Signed-off-by: +``` + +- **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. +- **Required keys**: `Area`, `Changelog`, `Output`. `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**: `Ran` and `Measured` separate 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. + +## Why these fields + +Each field answers a question that recurs in the review threads of the +upstream repository (62 merged pull requests between #8606 and #8857, human +and automated review comments). + +| Field | The question it answers | Where reviewers asked it | +| --- | --- | --- | +| `Area` | Which layer changed, so who reviews it and which suites apply | AGENTS.md routes by layer; AST changes broke editor analysis in #8606, #8619, #8633, #8746 | +| `Changelog` + `Changelog entry` | Is it user-facing, which section, is the link right, is it short | missing in #8611, #8667, #8691, #8692, #8693, #8694; missing or wrong link in #8627, #8631, #8633, #8634, #8662; too long in #8608; breaking or internal in #8606, #8682 | +| `Output` + `Before`/`After (js)` | What the generated JavaScript is now, and whether existing programs change | differential JS rebuild in #8606; byte-identical claim in #8769, #8773; JS shown in #8801; invalid to valid JS in #8692 | +| `Formatting` + `Before`/`After (rescript)` | Does `rescript format` rewrite existing code, and does it round-trip | churn in production code asked in #8611; round-trip and comment loss in #8614, #8627, #8674, #8677 | +| `Formats` | Does a marshaled artifact change without a magic-number bump | CMT bump asked in #8682; CMI/CMT/CMJ in #8606; Lambda tag order for `.cmj` in #8746 | +| `PPX` | Does the frozen v0 wire still carry the same tree to external PPXs | `parsetree0.ml` touched in #8619; identity PPX no longer a no-op and missing ast-mapping fixture in #8606; PPX output rejected in #8682 | +| `Editor` | Do completion, hover and inlay hints still work on the new nodes | #8606, #8619, #8746 | +| `Errors` | Is `tests/ERROR_VARIANTS.md` in step with the fixtures | #8746 | +| `Tests` | Which tests pin the change, and do they observe the claimed effect | missing coverage in #8667, #8771, #8773; assertion too weak in #8667; widths in #8677 | +| `Ran` | Which suites ran | PR test plans (#8692) and replies ("syntax and roundtrip suites pass", #8674) | +| `Measured` | Performance before and after, on what input | #8769, #8771, #8773, #8801 | +| `Depends` | Stacking and conflicts with open PRs | #8606 (conflict with #8608), #8771, #8773 (stacked) | +| `PR` | The discussion behind the change | the renderer links it | +| `Signed-off-by` | DCO | the most frequent automated finding: #8691, #8729, #8773, #8837, #8842, #8846, #8857 | +| `Breaking` | What stops working and how to migrate | #8606, #8634, #8682 | +| `Review` | Answers given in the thread, kept with the change | #8617, #8667, #8682 | + +## Rendering + +```sh +node scripts/commitRecords.js --out review.html ... +node scripts/commitRecords.js --out review.html ..... +node scripts/commitRecords.js --fragment --out part.html ... +``` + +- 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. +- Each card checks the record against its diff: a changelog category with no + new `changes/` fragment (or a `Changelog entry` that differs from the + fragment's bullet), modified generated `.mjs`/`.cjs` 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 + new `super_errors` fixture with no `ERROR_VARIANTS.md` change. 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. diff --git a/AGENTS.md b/AGENTS.md index 9f39c3394e..4e807dddf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,9 @@ relevant guidance from each. need to. - Include appropriate tests with all changes - 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` ### Stacked pull requests diff --git a/changes/commit-records.internal.md b/changes/commit-records.internal.md new file mode 100644 index 0000000000..fa7df6d29e --- /dev/null +++ b/changes/commit-records.internal.md @@ -0,0 +1 @@ +- Add commit records: a commit message format that answers recurring review questions, and `scripts/commitRecords.js`, which renders review pages from them. https://github.com/rescript-lang/rescript/pull/8861 diff --git a/package.json b/package.json index 60aafcdb87..4689ec272e 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "changelog:preview": "node scripts/changelog.js preview", "changelog:release": "node scripts/changelog.js release", "changelog:open": "node scripts/changelog.js open", - "test-changelog": "node --test scripts/changelog.test.js" + "test-changelog": "node --test scripts/changelog.test.js", + "test-commit-records": "node --test scripts/commitRecords.test.js" }, "files": [ "CHANGELOG.md", diff --git a/scripts/commitRecords.js b/scripts/commitRecords.js new file mode 100644 index 0000000000..a4ccd84666 --- /dev/null +++ b/scripts/commitRecords.js @@ -0,0 +1,967 @@ +#!/usr/bin/env node + +// @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. + * + * node scripts/commitRecords.js --out review.html ... + * node scripts/commitRecords.js --out review.html ..... + * --fragment omits the document skeleton. + */ + +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +const REPO_URL = "https://github.com/rescript-lang/rescript"; + +/** + * @param {string[]} args + * @param {string} [input] + */ +const git = (args, input) => + execFileSync("git", args, { + input, + encoding: "utf8", + maxBuffer: 64 << 20, + stdio: ["pipe", "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 {{ + * sha: string, author: string, date: string, subject: string, + * sections: Section[], trailers: Map, message: string, + * coauthors: string[], files: FileChange[], 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} 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()], + patch: git([ + "show", + "--format=", + "-U0", + rev, + "--", + "CHANGELOG.md", + "changes", + "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)$/.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)); + const category = r.trailers.get("Changelog"); + const changelogTouched = has(/^CHANGELOG\.md$|^changes\/.*\.md$/); + if (category !== undefined) { + if (category === "none") + out.push([ + !changelogTouched, + changelogTouched + ? "Changelog: none, but the diff changes the changelog" + : "No changelog entry, as recorded", + ]); + else if (!changelogTouched) + out.push([ + false, + `Changelog: ${category}, but the diff adds no changelog entry`, + ]); + else { + const entry = r.sections.find(s => s.name === "Changelog entry"); + const added = r.patch + .split("\n") + .filter(l => l.startsWith("+") && !l.startsWith("+++")) + .join("\n"); + const text = entry + ? [...entry.text, ...entry.block].join(" ").trim() + : ""; + if (text) + out.push( + added.replace(/\s+/g, " ").includes(text.replace(/\s+/g, " ")) + ? [true, "Changelog entry matches the line the diff adds"] + : [false, "Changelog entry differs from the line the diff adds"], + ); + else out.push([true, `Changelog: ${category}; the diff adds an entry`]); + } + } else out.push([false, "No Changelog trailer"]); + + // 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", + ]); + const newFixtures = r.files.filter( + f => + f.status === "A" && + /^tests\/build_tests\/super_errors[^/]*\/fixtures\//.test(f.path), + ); + if (newFixtures.length) + out.push([ + has(/^tests\/ERROR_VARIANTS\.md$/) || r.trailers.has("Errors"), + `${newFixtures.length} new super_errors fixture${newFixtures.length === 1 ? "" : "s"}; ERROR_VARIANTS.md ${has(/^tests\/ERROR_VARIANTS\.md$/) ? "updated" : r.trailers.has("Errors") ? "unchanged, as the Errors trailer states" : "unchanged"}`, + ]); + return out; +} + +// --------------------------------------------------------------------------- +// Cards + +/** @type {Record} */ +const CATEGORIES = { + breaking: ["๐Ÿ’ฅ", "Breaking change"], + spec: ["๐Ÿ‘“", "Spec compliance"], + feature: ["๐Ÿš€", "New feature"], + bugfix: ["๐Ÿ›", "Bug fix"], + 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 Ran or 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", + "Errors", + "Tests", + "Ran", + "Measured", + "Depends", +]; + +/** @param {Section[]} sections */ +function renderSections(sections) { + const out = []; + for (let i = 0; i < sections.length; i++) { + const s = sections[i]; + if (["Breaking", "Changelog entry", "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 category = r.trailers.get("Changelog") ?? ""; + 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)}

` + + `
${category ? changelogPill(category) : ""}${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)); + const entry = r.sections.find(s => s.name === "Changelog entry"); + if (entry) + out.push( + `
${category ? changelogPill(category) : ""}${inline([...entry.text, ...entry.block].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" + ? tests(one) + : STATES.includes(k) + ? stateChip(one) + : ["Ran", "Measured"].includes(k) && listed.length > 1 + ? `
    ${listed.map(l => `
  • ${inline(l)}
  • `).join("")}
` + : v + .split("\n") + .map(line => `${inline(line)}`) + .join(""); + return [`
${k}
${shown}
`]; + }); + 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 + */ + +/** @param {string} arg */ +function group(arg) { + if (!arg.includes("..")) return { name: arg, 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), + }; +} + +/** @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 r of g.records) { + const c = r.trailers.get("Changelog") ?? "?"; + cats.set(c, (cats.get(c) ?? 0) + 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 r of g.records) { + const c = r.trailers.get("Changelog"); + const entry = r.sections.find(s => s.name === "Changelog entry"); + if (c && c !== "none" && entry) + draft.set(c, [ + ...(draft.get(c) ?? []), + [...entry.text, ...entry.block].join(" ").replace(/^-\s+/, ""), + ]); + } + 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 34px/1.1 var(--display); margin:0 } h1 span { color:var(--accent) } +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-bugfix, .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); +const outAt = args.indexOf("--out"); +if (outAt < 0 || outAt + 1 >= args.length) { + console.error( + "usage: commitRecords.js --out [--fragment] ...", + ); + process.exit(1); +} +const outFile = args[outAt + 1]; +const fragment = args.includes("--fragment"); +const targets = args.filter( + (a, i) => i !== outAt && i !== outAt + 1 && a !== "--fragment", +); +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 body = `

Commit Records

${commits} commit${commits === 1 ? "" : "s"} of the ReScript compiler, rendered from their records.

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

${esc(g.range)}

` + : "") + g.records.map(card).join("\n"), + ) + .join("\n")} +
`; +const head = `Commit Records + +`; +writeFileSync( + outFile, + fragment + ? `${head}\n${body}\n` + : `\n\n${head}${body}\n`, +); +console.log(outFile); diff --git a/scripts/commitRecords.test.js b/scripts/commitRecords.test.js new file mode 100644 index 0000000000..426fc01845 --- /dev/null +++ b/scripts/commitRecords.test.js @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +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. + +Changelog entry: - Compile \`x + 0\` to \`x\`. https://github.com/rescript-lang/rescript/pull/1234 + +Area: codegen tests +Changelog: polish +Output: changed integer + 0; one snapshot +Formats: none +Tests: +tests/tests/src/a_test.mjs ~tests/tests/src/b_test.mjs +Ran: make (exit 0); make test (exit 0: 3 passing; 0 failing) +Signed-off-by: A Person +`; + +/** + * Asserts that the page contains the text, without printing the page. + * @param {string} html + * @param {string} text + */ +const has = (html, text) => + assert.ok(html.includes(text), `page lacks ${JSON.stringify(text)}`); + +/** + * Renders the records of a temporary repository's commits. + * @param {(git: (...args: string[]) => string, dir: string) => Promise} setup + * @param {string[]} revs + */ +async function render(setup, revs) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-")); + const git = (/** @type {string[]} */ ...args) => + execFileSync("git", args, { 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 }); + return fs.readFile(out, "utf8"); +} + +/** + * @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); + } +} + +test("renders a record and checks it against the diff", 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); + }, + ["main~1..main"], + ); + has(html, "

Compile x + 0 to x

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

Source'); + has(html, "Changelog entry matches the line the diff adds"); + has(html, "1 committed generated JS file changes; 1 new"); + has(html, "Formats: none"); + has(html, "tests/tests/src/"); + has(html, "

  • make test (exit 0: 3 passing; 0 failing)
  • "); + has(html, "changelog 1"); + has(html, '0'); + has(html, "
  • Compile x + 0"); +}); + +test("flags a record that disagrees with its diff", 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\nChangelog: fix\nOutput: unchanged", + ); + }, + ["HEAD"], + ); + has(html, "Changelog: fix, but the diff adds no changelog entry"); + has(html, "Output: unchanged, but 1 committed generated JS file changes"); +});