diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md new file mode 100644 index 0000000000..d9682b3fb0 --- /dev/null +++ b/.claude/skills/records/SKILL.md @@ -0,0 +1,136 @@ +--- +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 | none, because +Measured: after, and what produced it>; ... +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`, `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 | +| --- | --- | +| `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. | +| `Changelog` + `Changelog entry` | Whether the change is user-facing, and its category: `breaking` when existing code or tooling must adapt, `internal` when nothing user-visible changes. The entry is one short line ending in the PR link, identical to the fragment's bullet. | +| `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. | +| `Errors` | The `tests/ERROR_VARIANTS.md` rows added, removed or relinked for changed error variants or fixtures. | +| `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. | + +## 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 "" .. +``` + +- `--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. +- 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` 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 + new `super_errors` fixture with no `ERROR_VARIANTS.md` change, 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-/โ€ฆ`). diff --git a/.github/workflows/semantic_diff.yml b/.github/workflows/semantic_diff.yml new file mode 100644 index 0000000000..e128487ac5 --- /dev/null +++ b/.github/workflows/semantic_diff.yml @@ -0,0 +1,108 @@ +name: Semantic Diff + +# Renders the commit records of each pull request (scripts/commitRecords.js) +# as a review page on the CDN and links it from the pull request. +# +# A pull request from a branch of this repository runs on pull_request, with +# its own workflow and renderer. A pull request from a fork runs on +# pull_request_target, which GitHub takes from the default branch and runs +# with the repository's secrets: it executes only the default branch's code, +# and the fork's commits are fetched as git data and never checked out. + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + pull_request_target: + types: [opened, synchronize, reopened, closed] + +concurrency: + group: semantic-diff-${{ github.event_name }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + render: + if: >- + github.event.action != 'closed' && + github.actor != 'dependabot[bot]' && + (github.event_name == 'pull_request') == + (github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + steps: + - name: Checkout renderer + uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.event.pull_request.base.sha }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Render semantic diff + id: render + run: | + git fetch --no-tags --filter=blob:none origin "refs/pull/${PR_NUMBER}/head:refs/heads/pr-${PR_NUMBER}" + base=$(git merge-base "$BASE_SHA" "pr-${PR_NUMBER}") + mkdir -p semantic-diff + node scripts/commitRecords.js --out semantic-diff/index.html --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" + echo "head=$(git rev-parse --short=9 "pr-${PR_NUMBER}")" >> "$GITHUB_OUTPUT" + + - name: Setup Rclone + uses: cometkim/rclone-actions/setup-rclone@main + + - name: Configure Rclone remote + uses: cometkim/rclone-actions/configure-remote/s3-provider@main + with: + name: rescript + provider: Cloudflare + endpoint: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + access-key-id: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + secret-access-key: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + acl: private + + - name: Upload semantic diff + run: rclone copyto --s3-no-check-bucket semantic-diff/index.html "rescript:cdn-assets/semantic-diff/pr-${PR_NUMBER}/index.html" + + # The query string names the rendered commit, so each push gets a URL + # that no cache has seen. + - name: Comment semantic diff URL + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ github.event.pull_request.number }} + comment-tag: semantic-diff + # Each push replaces the comment, so the newest link is the last + # comment on the pull request. + mode: recreate + message: | + Semantic diff: https://cdn.rescript-lang.org/semantic-diff/pr-${{ github.event.pull_request.number }}/index.html?v=${{ steps.render.outputs.head }} + + cleanup: + if: >- + github.event.action == 'closed' && + github.actor != 'dependabot[bot]' && + (github.event_name == 'pull_request') == + (github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-24.04 + permissions: {} + steps: + - name: Setup Rclone + uses: cometkim/rclone-actions/setup-rclone@main + + - name: Configure Rclone remote + uses: cometkim/rclone-actions/configure-remote/s3-provider@main + with: + name: rescript + provider: Cloudflare + endpoint: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + access-key-id: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + secret-access-key: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + acl: private + + - name: Delete semantic diff + continue-on-error: true + run: rclone purge --s3-no-check-bucket "rescript:cdn-assets/semantic-diff/pr-${{ github.event.pull_request.number }}" diff --git a/AGENTS.md b/AGENTS.md index a5328a9766..1844abe36a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,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/Makefile b/Makefile index 110dff344c..d1e7abecd3 100644 --- a/Makefile +++ b/Makefile @@ -238,6 +238,17 @@ format: | $(YARN_INSTALL_STAMP) checkformat: | $(YARN_INSTALL_STAMP) ./scripts/format_check.sh +# Semantic diff: renders the commit records of SEMANTIC_DIFF_BASE..HEAD +# (scripts/commitRecords.js) to _build/semantic-diff.html and opens it + +SEMANTIC_DIFF_BASE ?= master + +semantic-diff: + mkdir -p _build + node scripts/commitRecords.js --out _build/semantic-diff.html --title "$$(git branch --show-current)" $(SEMANTIC_DIFF_BASE)..HEAD + @if command -v open >/dev/null; then open _build/semantic-diff.html; \ + elif command -v xdg-open >/dev/null; then xdg-open _build/semantic-diff.html; fi + # Coverage (bisect_ppx) # # Requires the pending `bisect_ppx` OCaml 5.5 compatibility patch in your switch: @@ -371,4 +382,4 @@ clean: clean-lib clean-compiler clean-rewatch clean-coverage .DEFAULT_GOAL := build -.PHONY: yarn-install build rewatch compiler lib artifacts bench test test-analysis test-reanalyze benchmark-reanalyze test-tools test-syntax test-syntax-roundtrip test-gentype test-rewatch test-all playground playground-compiler playground-test playground-cmijs playground-release format checkformat clean-rewatch clean-compiler clean-lib clean-gentype clean-tests clean +.PHONY: yarn-install build rewatch compiler lib artifacts bench test test-analysis test-reanalyze benchmark-reanalyze test-tools test-syntax test-syntax-roundtrip test-gentype test-rewatch test-all playground playground-compiler playground-test playground-cmijs playground-release format checkformat semantic-diff clean-rewatch clean-compiler clean-lib clean-gentype clean-tests clean diff --git a/changes/commit-records.internal.md b/changes/commit-records.internal.md new file mode 100644 index 0000000000..09e191f0f9 --- /dev/null +++ b/changes/commit-records.internal.md @@ -0,0 +1 @@ +- Add commit records, a commit message format that answers recurring review questions, and a semantic diff page rendered from them and linked on each pull request. https://github.com/rescript-lang/rescript/pull/8870 diff --git a/package.json b/package.json index 5ce0580037..ec0c7caeda 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,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..8b7aea0def --- /dev/null +++ b/scripts/commitRecords.js @@ -0,0 +1,1000 @@ +#!/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 ..... + * --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"; + +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)$|\.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)); + 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"]); + + 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", + ]); + 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 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", + "Measured", +]; + +/** @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" + ? /^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}
`]; + }); + 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 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-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); +/** @type {Record} */ +const options = {}; +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] ...", + ); + 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)} + +`; +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..4517eea3ff --- /dev/null +++ b/scripts/commitRecords.test.js @@ -0,0 +1,164 @@ +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 +Measured: build 10 s -> 8 s (make, 1 run); bundle 5 KB -> 4 KB (esbuild) +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, '

Semantic Diff

'); + 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, "

  • bundle "); + 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", + ); + }, + ["--pr", "42", "--title", "Fix & more", "HEAD"], + ); + has( + html, + '

    #42 Fix <output> & more

    ', + ); + has(html, "#42 Fix <output> & more ยท Semantic Diff"); + has(html, "Changelog: fix, but the diff adds no changelog entry"); + 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 () => { + 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\nChangelog: none\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\nChangelog: none\nOutput: unchanged\nTests: none, because it is a fixture", + ); + }, + ["HEAD~2..HEAD"], + ); + has(html, "No test changes, as recorded"); + has( + html, + 'nonebecause only prose changes', + ); + has(html, "Tests: none, but the diff changes 1 test file"); +});