Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 37 additions & 18 deletions .claude/skills/records/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,31 @@ Breaking: <what existing code or tooling stops working, and the migration>
Signed-off-by: Name <email>
```

- The subject and `Signed-off-by` are required. The message ends with its
trailers, git's `Key: value` lines (`Co-Authored-By:`, `Fixes:` or any
other one-word key), `Signed-off-by` last; the page lists the
`Co-Authored-By` names and draws no other trailer. Every section is
optional.
- A section starts at a line `Why:`, `Review:`, `Breaking:`, an object
block's heading, an `Example:` alone on its line, or a `Before (<form>):`
or `After (<form>):` heading, and holds the text after its colon and the
block indented by 4 spaces or more under it; a blank line ends a section
that holds only text. `Review:` repeats, one question each. Every other
A commit without object blocks, such as a script change, is its subject
and a line or two of prose:

```
Draw handles starting with a digit as code in the comment

GitHub usernames may start with a digit, so `@0x` in a commit message
would have mentioned a user; the comment now draws it as code as well.

Signed-off-by: Name <email>
```

- The subject, a description and `Signed-off-by` are required: the
description says why the commit is made, as a line or two of prose or
a `Why:` section, and object blocks then show what it changes. Every
other section is optional. The message ends with its trailers, git's
`Key: value` lines (`Co-Authored-By:`, `Fixes:` or any other one-word
key), `Signed-off-by` last; the page lists the `Co-Authored-By` names
and draws no other trailer.
- A section starts at a line `Why:`, `Review:` or `Breaking:` that no
paragraph of prose runs onto (a blank line or another section above it),
an object block's heading, an `Example:` alone on its line, or a
`Before (<form>):` or `After (<form>):` heading, and holds the text after
its colon and the block indented by 4 spaces or more under it; a blank
line ends a section that holds only text. `Review:` repeats, one question each. Every other
line is prose, and a block indented by 4 spaces outside a section is
ReScript code. Tabs count as 4 columns.
- An **object block** is a heading `<Kind> added|modified|removed (<object>):`,
Expand Down Expand Up @@ -159,11 +174,13 @@ The table lists the common kinds.
`-dce` and `-termination` select the other analyses.
- A form of another name is drawn as text.

## When to add a block
## What a commit holds

A commit gets one object block for each object it changes, with the
Every commit says why it is made. A commit that changes what the compiler
or a tool does gets one object block for each object it changes, with the
smallest example that shows the change. A refactor, a test, a script or a
review fix is a subject and a line or two of prose.
review fix needs no block, only its subject and a line or two of prose; a
review fix names the finding it answers.

## Rendering

Expand All @@ -175,14 +192,16 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \

- `--pr` and `--title` head the page with the pull request.
`.github/workflows/semantic_diff.yml` renders each pull request's range
and comments the page's link.
and comments the records as Markdown (`--markdown <file> --url <page>`,
scripts/recordMarkdown.js) with the page's link: after a push that keeps
the previous head, only the commits it added (`--since <previous head>`).
- In prose, `#1234` and full pull request and issue URLs link as `#1234`,
and the 9-character short sha of a commit on the page links to its card.

## What the page shows

The page is the heading, then a card per commit. A card shows the commit's
short sha, author and date, the pull request a `(#1234)` at the end of the
subject names, and the message drawn: the subject, the prose, each object
block with its example and forms, the `Breaking:` sections as banners, the
`Review:` sections as a list, and the co-authors.
short sha, author and co-authors, date, the pull request a `(#1234)` at
the end of the subject names, and the message drawn: the subject, the
prose, each object block with its example and forms, the `Breaking:`
sections as banners, and the `Review:` sections as a list.
41 changes: 29 additions & 12 deletions .github/workflows/semantic_diff.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
name: Semantic Diff

# Renders the commit records of each pull request (scripts/commitRecords.js)
# as a review page on the CDN and links it from the pull request.
# as a review page on the CDN, and comments them on the pull request as
# Markdown (scripts/recordMarkdown.js) with the page's link. The page holds
# every commit; the comment holds the commits a push added, or every commit
# when the pull request is opened or reopened or its branch is force-pushed.
#
# A pull request from a branch of this repository runs on pull_request, with
# its own workflow and renderer. A pull request from a fork runs on
Expand Down Expand Up @@ -34,6 +37,9 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
# The head before a push; empty when the pull request is opened or
# reopened.
BEFORE_SHA: ${{ github.event.action == 'synchronize' && github.event.before || '' }}
steps:
- name: Checkout renderer
uses: actions/checkout@v7
Expand All @@ -48,9 +54,21 @@ jobs:
run: |
git fetch --no-tags --filter=blob:none origin "refs/pull/${PR_NUMBER}/head:refs/heads/pr-${PR_NUMBER}"
base=$(git merge-base "$BASE_SHA" "pr-${PR_NUMBER}")
head=$(git rev-parse --short=9 "pr-${PR_NUMBER}")
# The query string names the rendered commit, so each push gets a
# URL that no cache has seen.
url="https://cdn.rescript-lang.org/semantic-diff/pr-${PR_NUMBER}/index.html?v=${head}"
# A push that keeps the previous head adds commits on top of it:
# the comment holds those. After a force push the previous head is
# no ancestor (nor, from a fork, fetched), and the comment holds
# every commit.
since=()
if [ -n "$BEFORE_SHA" ] && git merge-base --is-ancestor "$BEFORE_SHA" "pr-${PR_NUMBER}" 2>/dev/null; then
since=(--since "$BEFORE_SHA")
fi
mkdir -p semantic-diff
node scripts/commitRecords.js --out semantic-diff/index.html --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}"
echo "head=$(git rev-parse --short=9 "pr-${PR_NUMBER}")" >> "$GITHUB_OUTPUT"
node scripts/commitRecords.js --out semantic-diff/index.html --markdown semantic-diff/comment.md "${since[@]}" --url "$url" --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}"
echo "head=${head}" >> "$GITHUB_OUTPUT"

- name: Setup Rclone
uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18
Expand All @@ -68,18 +86,17 @@ jobs:
- name: Upload semantic diff
run: rclone copyto --s3-no-check-bucket semantic-diff/index.html "rescript:cdn-assets/semantic-diff/pr-${PR_NUMBER}/index.html"

# The query string names the rendered commit, so each push gets a URL
# that no cache has seen.
- name: Comment semantic diff URL
# A push that adds only merge commits writes no comment.
- name: Comment semantic diff
if: hashFiles('semantic-diff/comment.md') != ''
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ github.event.pull_request.number }}
comment-tag: semantic-diff
# Each push replaces the comment, so the newest link is the last
# comment on the pull request.
mode: recreate
message: |
Semantic diff: https://cdn.rescript-lang.org/semantic-diff/pr-${{ github.event.pull_request.number }}/index.html?v=${{ steps.render.outputs.head }}
# Each push gets its own comment, so the comments of earlier pushes
# stay; a re-run of the same head updates its comment.
comment-tag: semantic-diff-${{ steps.render.outputs.head }}
mode: upsert
file-path: semantic-diff/comment.md

cleanup:
if: >-
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"changelog:release": "node scripts/changelog.js release",
"changelog:open": "node scripts/changelog.js open",
"test-changelog": "node --test scripts/changelog.test.js",
"test-commit-records": "node --test scripts/commitRecords.test.js"
"test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js"
},
"files": [
"CHANGELOG.md",
Expand Down
48 changes: 45 additions & 3 deletions scripts/commitRecords.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@
* node scripts/commitRecords.js --out review.html <rev>...
* node scripts/commitRecords.js --out review.html <base>..<tip>...
* --pr <number> and --title <text> head the page with the pull request.
* --markdown <file> also draws the records as the pull request's comment
* (scripts/recordMarkdown.js), linking the page at --url <url>. With
* --since <rev>, the comment draws only the commits <rev> lacks, and is
* not written when there are none.
*/

import { execFileSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { commentMarkdown } from "./recordMarkdown.js";
import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js";

/** @import { CommitRecord, Group } from "./recordRender.js" */
Expand Down Expand Up @@ -61,22 +66,59 @@ const options = {};
/** @type {string[]} */
const targets = [];
for (let i = 0; i < args.length; i++) {
if (["--out", "--pr", "--title"].includes(args[i]) && i + 1 < args.length)
if (
["--out", "--pr", "--title", "--markdown", "--url", "--since"].includes(
args[i],
) &&
i + 1 < args.length
)
options[args[i].slice(2)] = args[++i];
else targets.push(args[i]);
}
const outFile = options.out;
if (!outFile) {
console.error(
"usage: commitRecords.js --out <page.html> [--pr <number>] [--title <text>] <rev | base..tip>...",
"usage: commitRecords.js --out <page.html> [--pr <number>] [--title <text>] [--markdown <comment.md> [--url <page url>] [--since <rev>]] <rev | base..tip>...",
);
process.exit(1);
}
const heading = {
pr: (options.pr ?? "").replace(/\D/g, ""),
title: options.title ?? "",
};
const body = pageBody(targets.map(group), heading);
const groups = targets.map(group);
const body = pageBody(groups, heading);
if (options.markdown) {
const since = options.since;
// The commits of the targets that `since` does not reach.
const fresh = new Set(
since
? targets.flatMap(t =>
git(["rev-list", "--abbrev-commit", "--abbrev=9", t, `^${since}`])
.split("\n")
.filter(Boolean),
)
: [],
);
const shown = since
? groups.map(g => ({
...g,
records: g.records.filter(r => fresh.has(r.sha)),
}))
: groups;
if (shown.some(g => g.records.length)) {
writeFileSync(
options.markdown,
commentMarkdown(shown, {
url: options.url,
total: since
? groups.reduce((n, g) => n + g.records.length, 0)
: undefined,
}),
);
console.log(options.markdown);
}
}
const head = `<title>${esc(pageTitle(heading))}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=STIX+Two+Text:ital,wght@0,400;0,500;1,400&display=swap">
<style>${CSS}</style>`;
Expand Down
62 changes: 58 additions & 4 deletions scripts/commitRecords.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,8 @@ test("a minimal record draws its identity and subject", async () => {
has(html, "<h3>Fix a typo</h3>");
assert.match(
html,
/<div class="meta"><code>[0-9a-f]{9}<\/code> · A Person · [A-Z][a-z]{2} \d{2}, \d{4} · <a href="https:\/\/github.com\/rescript-lang\/rescript\/pull\/7">#7<\/a><\/div>/,
/<div class="meta"><code>[0-9a-f]{9}<\/code> · A Person with B Person · [A-Z][a-z]{2} \d{2}, \d{4} · <a href="https:\/\/github.com\/rescript-lang\/rescript\/pull\/7">#7<\/a><\/div>/,
);
has(html, '<div class="meta">with B Person</div>');
lacks(html, '<div class="below">');
lacks(html, "Signed-off-by");
lacks(html, "Co-Authored-By");
Expand Down Expand Up @@ -428,6 +427,18 @@ test("a line with a colon is prose unless it names a section", async () => {
lacks(html, '<h4 class="change">');
});

test("a section's name wrapped onto a line of a paragraph is prose", async () => {
const html = await renderOne(
`Require a description\n\nIt requires prose or a\nWhy: section, and an example.\nWhy: alone after a blank line is a section.\n\nWhy: Here it is.\n\n${SIGNED}\n`,
);
has(
html,
"<p>It requires prose or a Why: section, and an example. Why: alone after a blank line is a section.</p>",
);
assert.equal(html.split('<section class="block k-why">').length - 1, 1);
has(html, "<p>Here it is.</p>");
});

test("a character literal with a Unicode escape is one string token", async () => {
const html = await renderOne(
`Match a range\n\nExample:\n switch c { | '\\u{100}' .. '\\u{1100}' => 1 | _ => 0 }\n\n${SIGNED}\n`,
Expand All @@ -446,7 +457,7 @@ test("Breaking and Review sections right above the sign-off are drawn", async ()
);
has(html, '<article class="card breaking"');
has(html, '<ul class="review"><li>Is -bs-x still read? No.</li></ul>');
has(html, '<div class="meta">with B Person</div>');
has(html, "</code> · A Person with B Person · ");
lacks(html, "Signed-off-by");
});

Expand Down Expand Up @@ -493,7 +504,7 @@ test("trailers of any key end the message and are not drawn", async () => {
const html = await renderOne(
`Fix a thing\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_x\n${SIGNED}\n`,
);
has(html, '<div class="meta">with Claude Opus 5.5</div>');
has(html, " · A Person with Claude Opus 5.5 · ");
lacks(html, "Claude-Session");
lacks(html, "session_x");
lacks(html, '<section class="block');
Expand Down Expand Up @@ -820,3 +831,46 @@ ${SIGNED}
has(html, '<span class="ln elide" style="--r:3;--c:2;--i:0">… ');
has(html, '<span class="ln del" style="--r:2;--c:1;--i:0">');
});

test("--since leaves the comment the commits a push added, the page every commit", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-"));
const git = (/** @type {string[]} */ ...a) =>
execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim();
git("init", "-q", "-b", "main");
git("config", "user.name", "A Person");
git("config", "user.email", "a@example.com");
await commit(git, dir, { "a.txt": "a\n" }, "base");
await commit(git, dir, {}, `First change\n\n${SIGNED}`);
const before = git("rev-parse", "HEAD");
await commit(git, dir, {}, `Second change\n\n${SIGNED}`);
const out = path.join(dir, "page.html");
const md = path.join(dir, "comment.md");
const run = (/** @type {string} */ since) => {
const r = spawnSync(
"node",
[
script,
"--out",
out,
"--markdown",
md,
"--since",
since,
"HEAD~2..HEAD",
],
{ cwd: dir, encoding: "utf8" },
);
assert.equal(r.status, 0, r.stderr);
};
run(before);
const comment = await fs.readFile(md, "utf8");
has(comment, "1 new commit (2 total)");
has(comment, "Second change");
lacks(comment, "First change");
const html = await fs.readFile(out, "utf8");
has(html, "First change");
has(html, "Second change");
await fs.rm(md);
run("HEAD");
await assert.rejects(fs.access(md));
});
Loading
Loading