From 753006ae4837641cb9d6ab219a4ef110022b8a04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:23:51 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=202=EB=8B=A8=EA=B3=84=20PR=208=20(=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=EC=9D=B4=20=EC=93=B4=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EB=8A=94=20=EA=B2=80=EC=82=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YDk7JRDmTHg8hV22rdfCih From 73584103dfe488229857a10b78a400b009b8e067 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:32:57 +0000 Subject: [PATCH 2/4] web: check:human-lock compares locked text with its base, word for word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's writing (rfc-cleanup.md §2.9) was locked by a rule only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YDk7JRDmTHg8hV22rdfCih --- docs/.human-written | 1 + web/scripts/human-lock.ts | 329 +++++++++++++++++++++++++++++++++++++ web/scripts/lib/pr-base.ts | 62 +++++++ 3 files changed, 392 insertions(+) create mode 100644 docs/.human-written create mode 100644 web/scripts/human-lock.ts create mode 100644 web/scripts/lib/pr-base.ts diff --git a/docs/.human-written b/docs/.human-written new file mode 100644 index 00000000..1e088aa5 --- /dev/null +++ b/docs/.human-written @@ -0,0 +1 @@ +# Paths the maintainer locks beyond docs/rfc-cleanup.md §2.9, one per line from the repository root; a directory locks what is under it. web/scripts/human-lock.ts reads this file. diff --git a/web/scripts/human-lock.ts b/web/scripts/human-lock.ts new file mode 100644 index 00000000..841ddd6f --- /dev/null +++ b/web/scripts/human-lock.ts @@ -0,0 +1,329 @@ +/** + * The lock on the maintainer's own writing: `npm run check:human-lock` in web/ + * (docs/rfc-cleanup.md §2.9). A pull request may change the markup, layout and + * link and image paths in locked text. It may not change a word. A typo is + * reported in the PR description, not fixed. + * + * Locked: + * + * web/content/journal/ every file, every language + * docs/manifesto.md + * docs/releases/*.md + * README.md "Where it began", "The community responded" and + * "Story so far", each from its ## heading to the next + * docs/rfc-*.md Part 1, from its heading to the next of its level + * roadmap-data.ts each node's detail and detailKo, read with the + * TypeScript parser and keyed by the node's id + * docs/.human-written each path in it, a directory locking what is under it + * + * Each locked file or region is compared as plain text: the base's against the + * working tree's. Plain text is what is left with the Markdown and MDX syntax + * and all whitespace gone. The syntax is heading, quote and list marks, + * emphasis and code marks, table pipes and rules, tags (their alt, title and + * aria-label values stay, being words), link targets, bare URLs and front + * matter keys. Front matter values are text, unless one is a path or a URL. + * + * The base is scripts/lib/pr-base.ts's. A locked file the base lacks is new, + * and passes. Locked text that leaves its place passes only if it turns up + * word for word in a file this diff adds or changes; that is a move, and it + * prints that the move needs the maintainer's approval on the PR. Taking a + * path off docs/.human-written fails: only the maintainer does that. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { ROOT, changedSince, findBase, git, readAt, readNow, type Base } from "./lib/pr-base"; + +const README_SECTIONS = ["Where it began", "The community responded", "Story so far"]; +const ROADMAP = "web/src/app/roadmap/roadmap-data.ts"; +const LIST = "docs/.human-written"; + +// ── Plain text ── + +// The characters kept, and the offset of each in the text they came from. +type Plain = { text: string; at: number[] }; + +// Syntax is blanked to spaces rather than cut, so every offset stays where it +// was and a changed word can be shown in its line. +const blank = (s: string) => s.replace(/[^\n]/g, " "); +// [text](target): the brackets and the target go, the text stays. +const keep = (_m: string, open: string, text: string, close: string) => blank(open) + text + blank(close); +const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; +function entity(m: string): string { + const body = m.slice(1, -1); + const code = body[0] === "#" ? Number(/^#x/i.test(body) ? `0${body.slice(1)}` : body.slice(1)) : NaN; + const c = body[0] === "#" ? (code <= 0x10ffff ? String.fromCodePoint(code) : undefined) : ENTITIES[body.toLowerCase()]; + return c === undefined ? m : c + " ".repeat(m.length - c.length); +} +// A tag goes, but the words in its alt, title and aria-label stay. +function tag(m: string): string { + let out = blank(m); + for (const a of m.matchAll(/\b(?:alt|title|aria-label)=(["'])([^"']*)\1/g)) { + const at = (a.index ?? 0) + a[0].indexOf(a[1]) + 1; + out = out.slice(0, at) + a[2] + out.slice(at + a[2].length); + } + return out; +} +function frontMatter(m: string, body: string): string { + const values = body.split("\n").map((line) => { + const kv = /^(\s*[\w-]+:\s*)(["']?)(.*?)(\2\s*)$/.exec(line); + if (!kv) return line; + return blank(kv[1] + kv[2]) + (/^(?:\.{0,2}\/|https?:)/.test(kv[3]) ? blank(kv[3]) : kv[3]) + blank(kv[4]); + }); + return blank(m.slice(0, m.indexOf("\n") + 1)) + values.join("\n") + blank(m.slice(m.lastIndexOf("\n"))); +} + +const SYNTAX: [RegExp, (m: string, ...g: string[]) => string][] = [ + [/^\uFEFF?---[ \t]*\n([\s\S]*?)\n---(?=[ \t]*(?:\n|$))/, frontMatter], + [//g, blank], + [/^[ \t]*(?:```|~~~).*$/gm, blank], // the fences; the code between them is text + [/^[ \t]*\[[^\]\n]+\]:[ \t]*\S.*$/gm, blank], // reference definitions + [/<\/?[A-Za-z][^>]*>/g, tag], // tags and autolinks + [/(!\[)([^\]]*)(\]\([^)]*\))/g, keep], // images, then the links that may hold them + [/(\[)([^\]]*)(\](?:\([^)]*\)|\[[^\]]*\]))/g, keep], + [/https?:\/\/[^\s)\]>]+/g, blank], + [/^ {0,3}#{1,6}(?=\s)/gm, blank], + [/^[ \t]*(?:>[ \t]?)+/gm, blank], + [/^[ \t]*(?:[-*+]|\d+[.)])(?=[ \t])/gm, blank], + [/^[ \t|:=*_-]*$/gm, blank], // rules, setext underlines, table delimiter rows + [/&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, entity], + [/\\(?=[!-/:-@[-`{-~])/g, blank], + [/[*~`|]/g, blank], + [/(? string] = [/^(?:import|export)\s.*$/gm, blank]; + +function plain(src: string, mdx: boolean): Plain { + let s = src.replace(/\r/g, " "); + for (const [re, f] of mdx ? [MDX, ...SYNTAX] : SYNTAX) s = s.replace(re, f as (m: string, ...g: string[]) => string); + const out: Plain = { text: "", at: [] }; + const chars: string[] = []; + for (let i = 0; i < s.length; i++) + if (!/\s/.test(s[i])) { + chars.push(s[i]); + out.at.push(i); + } + out.text = chars.join(""); + return out; +} + +function slice(p: Plain, from: number, to: number): Plain { + const [a, b] = [p.at.findIndex((x) => x >= from), p.at.findIndex((x) => x >= to)]; + const [i, j] = [a < 0 ? p.at.length : a, b < 0 ? p.at.length : b]; + return { text: p.text.slice(i, j), at: p.at.slice(i, j) }; +} + +// ── What is locked ── + +type Unit = { key: string; file: string; src: string; line0: number; plain: Plain }; + +const listOf = (text: string | null) => + (text ?? "").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => l.replace(/^\.?\//, "")); + +function kindOf(f: string, listed: string[]): string | null { + if (listed.some((p) => f === p || f.startsWith(p.replace(/\/*$/, "/")))) return "listed"; + if (f.startsWith("web/content/journal/")) return "journal"; + if (/^docs\/releases\/[^/]+\.md$/.test(f)) return "release"; + if (f === "docs/manifesto.md") return "manifesto"; + if (f === "README.md") return "readme"; + if (/^docs\/(?:[^/]+\/)*rfc-[^/]+\.md$/.test(f)) return "rfc"; + if (f === ROADMAP) return "roadmap"; + return null; +} + +// Headings outside code fences that `wanted` picks, each running to the next +// heading of its level or higher. +function sections(src: string, wanted: (level: number, title: string) => boolean) { + const out: { title: string; from: number; to: number }[] = []; + let open: { level: number; title: string; from: number } | null = null; + let fence = false; + let at = 0; + for (const line of src.split("\n")) { + if (/^ {0,3}(?:```|~~~)/.test(line)) fence = !fence; + const h = fence ? null : /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); + if (h && open && h[1].length <= open.level) { + out.push({ title: open.title, from: open.from, to: at }); + open = null; + } + if (h && !open && wanted(h[1].length, h[2])) open = { level: h[1].length, title: h[2], from: at }; + at += line.length + 1; + } + if (open) out.push({ title: open.title, from: open.from, to: src.length }); + return out; +} + +// A string the parser can read without running anything: a literal, a +// template's fixed parts, or literals joined with +. +function literal(e: ts.Expression): string | null { + if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; + if (ts.isTemplateExpression(e)) return [e.head.text, ...e.templateSpans.map((s) => s.literal.text)].join(""); + if (ts.isParenthesizedExpression(e)) return literal(e.expression); + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const [l, r] = [literal(e.left), literal(e.right)]; + return l === null || r === null ? null : l + r; + } + return null; +} + +function roadmapStrings(src: string): Unit[] { + const sf = ts.createSourceFile(ROADMAP, src, ts.ScriptTarget.Latest, true); + const out: Unit[] = []; + const name = (p: ts.ObjectLiteralElementLike) => + ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : null; + const visit = (n: ts.Node): void => { + if (ts.isObjectLiteralExpression(n)) { + const idProp = n.properties.find((p) => name(p) === "id"); + const id = idProp && ts.isPropertyAssignment(idProp) ? literal(idProp.initializer) : null; + for (const p of n.properties) { + const field = name(p); + if (!ts.isPropertyAssignment(p) || (field !== "detail" && field !== "detailKo")) continue; + const text = literal(p.initializer); + const line = sf.getLineAndCharacterOfPosition(p.initializer.getStart()).line + 1; + if (text !== null) out.push({ key: `${ROADMAP} › ${id ?? `line ${line}`}.${field}`, file: ROADMAP, src: text, line0: line, plain: plain(text, false) }); + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + return out; +} + +function unitsOf(f: string, src: string, kind: string): Unit[] { + if (kind === "roadmap") return roadmapStrings(src); + const whole = plain(src, f.endsWith(".mdx")); + const cut = (key: string, from: number, to: number): Unit => ({ key, file: f, src, line0: 1, plain: slice(whole, from, to) }); + if (kind === "readme") + return sections(src, (level, title) => level === 2 && README_SECTIONS.includes(title)).map((s) => cut(`${f} › ${s.title}`, s.from, s.to)); + if (kind === "rfc") return sections(src, (_, title) => /^Part 1\b/.test(title)).map((s) => cut(`${f} › Part 1`, s.from, s.to)); + return [{ key: f, file: f, src, line0: 1, plain: whole }]; +} + +// ── Showing a change ── + +const CONTEXT = 30; +const LONGEST = 200; + +// The source around plain characters [p, q), with that stretch marked «…». +function excerpt(u: Unit, p: number, q: number): string { + const { src, plain: { at } } = u; + const markFrom = p < q ? at[p] : p > 0 ? at[p - 1] + 1 : (at[0] ?? 0); + const markTo = p < q ? at[q - 1] + 1 : markFrom; + const from = Math.min(markFrom, at[Math.max(0, p - CONTEXT)] ?? markFrom); + const to = Math.max(markTo, (at[Math.min(at.length, q + CONTEXT) - 1] ?? markTo - 1) + 1); + const flat = (s: string) => s.replace(/\s+/g, " "); + let marked = flat(src.slice(markFrom, markTo)); + if (marked.length > LONGEST) marked = `${marked.slice(0, LONGEST)}…`; + const line = u.line0 + (src.slice(0, markFrom).match(/\n/g)?.length ?? 0); + return `${u.file}:${line} …${flat(src.slice(from, markFrom))}«${marked}»${flat(src.slice(markTo, to))}…`; +} + +function difference(a: Unit, b: Unit): [string, string] { + const [x, y] = [a.plain.text, b.plain.text]; + let p = 0; + while (p < x.length && p < y.length && x[p] === y[p]) p++; + let s = 0; + while (s < x.length - p && s < y.length - p && x[x.length - 1 - s] === y[y.length - 1 - s]) s++; + return [excerpt(a, p, x.length - s), excerpt(b, p, y.length - s)]; +} + +// ── The check ── + +const strict = process.env.PF_DRIFT_STRICT === "1"; +let base: Base; +try { + base = findBase(); +} catch (e) { + console.log(`check:human-lock: ${(e as Error).message}`); + process.exit(1); +} +if (base.sha === null) { + console.log(`check:human-lock: skipped, ${base.why}`); + if (strict) console.log("PF_DRIFT_STRICT=1, so a skip fails"); + process.exit(strict ? 1 : 0); +} +const BASE = base.sha; + +const listedBefore = listOf(readAt(BASE, LIST)); +const listedNow = listOf(readNow(LIST)); +const listed = [...new Set([...listedBefore, ...listedNow])]; + +// What is locked at HEAD, counted, so a run says what it looked at. +const count = new Map(); +for (const f of git(["ls-files", "-z"]).split("\0").filter(Boolean)) { + const kind = kindOf(f, listedNow); + if (!kind || kind === "listed") continue; + const n = kind === "readme" || kind === "rfc" || kind === "roadmap" ? unitsOf(f, readNow(f) ?? "", kind).length : 1; + count.set(kind, (count.get(kind) ?? 0) + n); +} +const n = (kind: string) => count.get(kind) ?? 0; +console.log(`base: ${BASE.slice(0, 7)} (${base.from})`); +console.log( + `locked: ${n("journal")} journal files, ${n("release")} release files, docs/manifesto.md, ${n("readme")} README sections, ` + + `Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${listedNow.length} paths in ${LIST}`, +); + +const changes = changedSince(BASE); +const touched = changes.filter((c) => kindOf(c.path, listed)); +console.log(`this diff touches ${touched.length} locked file${touched.length === 1 ? "" : "s"}`); + +let failed = 0; +let moved = 0; +if (touched.length || listedBefore.some((p) => !listedNow.includes(p))) console.log(""); + +for (const p of listedBefore.filter((p) => !listedNow.includes(p))) { + console.log(`unlisted ${LIST} no longer names ${p}; only the maintainer takes a path off it`); + failed++; +} + +// Where vanished text may have gone: every file this diff adds or changes. +let corpus: { file: string; text: string }[] | undefined; +function whereIs(text: string): string | null { + corpus ??= changes.flatMap((c) => { + if (c.status === "deleted") return []; + const raw = fs.existsSync(path.join(ROOT, c.path)) ? fs.readFileSync(path.join(ROOT, c.path)) : null; + if (!raw || raw.includes(0)) return []; + const src = raw.toString("utf8"); + const kind = kindOf(c.path, listed); + const parts = kind === "roadmap" ? unitsOf(c.path, src, kind).map((u) => u.plain.text) : []; + return [plain(src, c.path.endsWith(".mdx")).text, ...parts].map((text) => ({ file: c.path, text })); + }); + return corpus.find((c) => c.text.includes(text))?.file ?? null; +} + +for (const c of touched) { + const kind = kindOf(c.path, listed)!; + const before = c.status === "added" ? null : readAt(BASE, c.path); + const now = c.status === "deleted" ? null : readNow(c.path); + const old = new Map((before === null ? [] : unitsOf(c.path, before, kind)).map((u) => [u.key, u])); + const cur = new Map((now === null ? [] : unitsOf(c.path, now, kind)).map((u) => [u.key, u])); + let same = 0; + for (const [key, a] of old) { + const b = cur.get(key); + if (b && a.plain.text === b.plain.text) same++; + else if (b) { + const [was, is] = difference(a, b); + console.log(`changed ${key}`); + console.log(` base ${was}`); + console.log(` head ${is}`); + failed++; + } else if (!a.plain.text) same++; + else { + const to = whereIs(a.plain.text); + console.log(to ? `moved ${key} → ${to}, word for word` : `removed ${key}: its words are in no file this diff adds or changes`); + if (to) moved++; + else failed++; + } + } + const fresh = [...cur.keys()].filter((k) => !old.has(k)).length; + if (same) console.log(`same ${c.path}${old.size > 1 ? ` (${same} of ${old.size} locked ${kind === "roadmap" ? "strings" : "sections"})` : ""}`); + if (fresh) console.log(`new ${c.path}${cur.size > 1 ? ` (${fresh} locked ${kind === "roadmap" ? "strings" : "sections"})` : ""}`); +} + +console.log(""); +if (moved) console.log("Moving locked text needs the maintainer's approval on the PR (docs/rfc-cleanup.md §2.9)."); +if (failed) { + console.log(`check:human-lock: ${failed} locked text${failed === 1 ? "" : "s"} changed. Markup, layout and link and image paths may change in it; words may not.`); + console.log("A typo is reported in the PR description, not fixed."); + process.exitCode = 1; +} else console.log("check:human-lock: no locked words changed"); diff --git a/web/scripts/lib/pr-base.ts b/web/scripts/lib/pr-base.ts new file mode 100644 index 00000000..310c49de --- /dev/null +++ b/web/scripts/lib/pr-base.ts @@ -0,0 +1,62 @@ +/** + * The commit a pull request starts from, and the files it changes since. + * scripts/human-lock.ts compares locked text at that commit with the working + * tree; scripts/voice-lint.ts reads the lines a branch adds since it. + * + * PF_LOCK_BASE names the commit, and CI passes the pull request's base SHA. + * At a desk it is the merge-base with origin/dev, then with origin/main. With + * none of the three there is no base, and the check says why and skips + * (docs/investigations/2026-10-cleanup-step2-plan.md, D5). + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); + +export const git = (args: string[]) => + execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "ignore"] }); + +const attempt = (f: () => T, fallback: T): T => { + try { + return f(); + } catch { + return fallback; + } +}; +const commit = (ref: string) => attempt(() => git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).trim(), ""); + +export type Base = { sha: string; from: string } | { sha: null; why: string }; + +/** The base and where it came from, or why there is none. Throws when PF_LOCK_BASE names a commit this clone lacks. */ +export function findBase(): Base { + const named = process.env.PF_LOCK_BASE?.trim(); + if (named) { + const sha = commit(named); + if (!sha) throw new Error(`PF_LOCK_BASE is ${named}, and this clone has no such commit (check out with fetch-depth: 0)`); + return { sha, from: "PF_LOCK_BASE" }; + } + for (const ref of ["origin/dev", "origin/main"]) { + const sha = commit(ref) && attempt(() => git(["merge-base", "HEAD", ref]).trim(), ""); + if (sha) return { sha, from: `the merge-base with ${ref}` }; + } + return { sha: null, why: "no PF_LOCK_BASE, and no merge-base with origin/dev or origin/main" }; +} + +export type Change = { path: string; status: "added" | "modified" | "deleted" }; + +/** What the working tree changes since base, untracked files included. A move is a deletion and an addition. */ +export function changedSince(base: string): Change[] { + const out = new Map(); + const fields = git(["diff", "--name-status", "--no-renames", "-z", base]).split("\0").filter(Boolean); + for (let i = 0; i + 1 < fields.length; i += 2) + out.set(fields[i + 1], fields[i] === "A" ? "added" : fields[i] === "D" ? "deleted" : "modified"); + for (const f of git(["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean)) out.set(f, "added"); + return [...out].map(([p, status]) => ({ path: p, status })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +/** A file as it was at a commit, or null if it was not there. */ +export const readAt = (ref: string, file: string) => attempt(() => git(["cat-file", "-p", `${ref}:${file}`]), null); + +/** A file as it is in the working tree, or null if it is not there. */ +export const readNow = (file: string) => attempt(() => fs.readFileSync(path.join(ROOT, file), "utf8"), null); From be2dd0ac10e908983c8f0d6f8862e0b6a50372c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:35:12 +0000 Subject: [PATCH 3/4] web: check:human-lock counts a path taken off the list apart from changed text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YDk7JRDmTHg8hV22rdfCih --- web/scripts/human-lock.ts | 45 +++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/web/scripts/human-lock.ts b/web/scripts/human-lock.ts index 841ddd6f..f80d7bd9 100644 --- a/web/scripts/human-lock.ts +++ b/web/scripts/human-lock.ts @@ -16,18 +16,19 @@ * TypeScript parser and keyed by the node's id * docs/.human-written each path in it, a directory locking what is under it * - * Each locked file or region is compared as plain text: the base's against the - * working tree's. Plain text is what is left with the Markdown and MDX syntax - * and all whitespace gone. The syntax is heading, quote and list marks, - * emphasis and code marks, table pipes and rules, tags (their alt, title and - * aria-label values stay, being words), link targets, bare URLs and front - * matter keys. Front matter values are text, unless one is a path or a URL. + * Each locked file or region is compared as plain text, the base's against the + * working tree's. Plain text is what is left once the Markdown and MDX syntax + * and all whitespace are gone. The syntax is heading, quote and list marks, + * emphasis and code marks, code fences (not the code in them), table pipes and + * rules, comments, tags, link targets, bare URLs and front matter keys. A + * tag's alt, title and aria-label stay, being words, and so do front matter + * values, unless one is a path or a URL. * - * The base is scripts/lib/pr-base.ts's. A locked file the base lacks is new, - * and passes. Locked text that leaves its place passes only if it turns up - * word for word in a file this diff adds or changes; that is a move, and it - * prints that the move needs the maintainer's approval on the PR. Taking a - * path off docs/.human-written fails: only the maintainer does that. + * The base comes from scripts/lib/pr-base.ts. A locked file the base lacks is + * new, and passes. Locked text that leaves its place passes only if it turns + * up word for word in a file this diff adds or changes. That is a move, and the + * run says it needs the maintainer's approval on the PR. Taking a path off + * docs/.human-written fails: only the maintainer does that. */ import fs from "node:fs"; import path from "node:path"; @@ -260,7 +261,7 @@ const n = (kind: string) => count.get(kind) ?? 0; console.log(`base: ${BASE.slice(0, 7)} (${base.from})`); console.log( `locked: ${n("journal")} journal files, ${n("release")} release files, docs/manifesto.md, ${n("readme")} README sections, ` + - `Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${listedNow.length} paths in ${LIST}`, + `Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${listedNow.length} path${listedNow.length === 1 ? "" : "s"} in ${LIST}`, ); const changes = changedSince(BASE); @@ -269,12 +270,9 @@ console.log(`this diff touches ${touched.length} locked file${touched.length === let failed = 0; let moved = 0; -if (touched.length || listedBefore.some((p) => !listedNow.includes(p))) console.log(""); - -for (const p of listedBefore.filter((p) => !listedNow.includes(p))) { - console.log(`unlisted ${LIST} no longer names ${p}; only the maintainer takes a path off it`); - failed++; -} +const unlisted = listedBefore.filter((p) => !listedNow.includes(p)); +if (touched.length || unlisted.length) console.log(""); +for (const p of unlisted) console.log(`unlisted ${LIST} no longer names ${p}; only the maintainer takes a path off it`); // Where vanished text may have gone: every file this diff adds or changes. let corpus: { file: string; text: string }[] | undefined; @@ -322,8 +320,13 @@ for (const c of touched) { console.log(""); if (moved) console.log("Moving locked text needs the maintainer's approval on the PR (docs/rfc-cleanup.md §2.9)."); -if (failed) { - console.log(`check:human-lock: ${failed} locked text${failed === 1 ? "" : "s"} changed. Markup, layout and link and image paths may change in it; words may not.`); - console.log("A typo is reported in the PR description, not fixed."); +const plural = (k: number, one: string, many: string) => `${k} ${k === 1 ? one : many}`; +const wrong = [ + failed && `${plural(failed, "locked text", "locked texts")} changed`, + unlisted.length && `${plural(unlisted.length, "path", "paths")} taken off ${LIST}`, +].filter(Boolean); +if (wrong.length) { + console.log(`check:human-lock: ${wrong.join(", ")}`); + if (failed) console.log("Markup, layout and link and image paths may change in locked text; words may not. A typo is reported in the PR description, not fixed."); process.exitCode = 1; } else console.log("check:human-lock: no locked words changed"); From 9571671561bfec3006b7328cce759397d2875f81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:44:16 +0000 Subject: [PATCH 4/4] web: check:human-lock fits the cleanup's 300-line cap Same checks; entities are blanked rather than decoded, and the roadmap reads plain literals only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YDk7JRDmTHg8hV22rdfCih --- web/scripts/human-lock.ts | 237 ++++++++++++------------------------- web/scripts/lib/pr-base.ts | 41 ++----- 2 files changed, 88 insertions(+), 190 deletions(-) diff --git a/web/scripts/human-lock.ts b/web/scripts/human-lock.ts index f80d7bd9..bfd412a2 100644 --- a/web/scripts/human-lock.ts +++ b/web/scripts/human-lock.ts @@ -1,76 +1,49 @@ /** * The lock on the maintainer's own writing: `npm run check:human-lock` in web/ * (docs/rfc-cleanup.md §2.9). A pull request may change the markup, layout and - * link and image paths in locked text. It may not change a word. A typo is - * reported in the PR description, not fixed. + * link and image paths in locked text, not a word of it. A typo is reported in + * the PR description, not fixed. * - * Locked: + * Locked: web/content/journal/, docs/manifesto.md, docs/releases/*.md, three + * README sections (README_SECTIONS, each to the next ## heading), Part 1 of + * each docs/rfc-*.md, each roadmap node's detail and detailKo (keyed by its id), + * and each path in docs/.human-written, a directory locking what is under it. * - * web/content/journal/ every file, every language - * docs/manifesto.md - * docs/releases/*.md - * README.md "Where it began", "The community responded" and - * "Story so far", each from its ## heading to the next - * docs/rfc-*.md Part 1, from its heading to the next of its level - * roadmap-data.ts each node's detail and detailKo, read with the - * TypeScript parser and keyed by the node's id - * docs/.human-written each path in it, a directory locking what is under it + * Each is compared as plain text, the base's (scripts/lib/pr-base.ts) against + * the working tree's, with the Markdown and MDX syntax and all whitespace gone: + * marks, fences (not the code in them), pipes, rules, comments, entities, tags, + * link targets, URLs and front matter keys. A tag's alt, title and aria-label + * stay, and so do front matter values that are not a path or a URL. * - * Each locked file or region is compared as plain text, the base's against the - * working tree's. Plain text is what is left once the Markdown and MDX syntax - * and all whitespace are gone. The syntax is heading, quote and list marks, - * emphasis and code marks, code fences (not the code in them), table pipes and - * rules, comments, tags, link targets, bare URLs and front matter keys. A - * tag's alt, title and aria-label stay, being words, and so do front matter - * values, unless one is a path or a URL. - * - * The base comes from scripts/lib/pr-base.ts. A locked file the base lacks is - * new, and passes. Locked text that leaves its place passes only if it turns - * up word for word in a file this diff adds or changes. That is a move, and the - * run says it needs the maintainer's approval on the PR. Taking a path off - * docs/.human-written fails: only the maintainer does that. + * A locked file the base lacks is new, and passes. Text that leaves its place + * passes only if it turns up word for word in a file this diff adds or changes: + * a move, which needs the maintainer's approval on the PR. Taking a path off + * docs/.human-written fails, since only the maintainer does that. */ import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; -import { ROOT, changedSince, findBase, git, readAt, readNow, type Base } from "./lib/pr-base"; +import { ROOT, changedSince, findBase, git, readAt, readNow } from "./lib/pr-base"; const README_SECTIONS = ["Where it began", "The community responded", "Story so far"]; const ROADMAP = "web/src/app/roadmap/roadmap-data.ts"; const LIST = "docs/.human-written"; // ── Plain text ── - // The characters kept, and the offset of each in the text they came from. -type Plain = { text: string; at: number[] }; - // Syntax is blanked to spaces rather than cut, so every offset stays where it // was and a changed word can be shown in its line. +type Plain = { text: string; at: number[] }; const blank = (s: string) => s.replace(/[^\n]/g, " "); -// [text](target): the brackets and the target go, the text stays. const keep = (_m: string, open: string, text: string, close: string) => blank(open) + text + blank(close); -const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; -function entity(m: string): string { - const body = m.slice(1, -1); - const code = body[0] === "#" ? Number(/^#x/i.test(body) ? `0${body.slice(1)}` : body.slice(1)) : NaN; - const c = body[0] === "#" ? (code <= 0x10ffff ? String.fromCodePoint(code) : undefined) : ENTITIES[body.toLowerCase()]; - return c === undefined ? m : c + " ".repeat(m.length - c.length); -} // A tag goes, but the words in its alt, title and aria-label stay. -function tag(m: string): string { - let out = blank(m); - for (const a of m.matchAll(/\b(?:alt|title|aria-label)=(["'])([^"']*)\1/g)) { - const at = (a.index ?? 0) + a[0].indexOf(a[1]) + 1; - out = out.slice(0, at) + a[2] + out.slice(at + a[2].length); - } - return out; -} +const tag = (m: string) => + m.replace(/\b(alt|title|aria-label)=(["'])([^"']*)\2|[^\n]/g, (_x, a?: string, q?: string, w?: string) => (a ? blank(`${a}=${q}`) + w + blank(q!) : " ")); function frontMatter(m: string, body: string): string { const values = body.split("\n").map((line) => { const kv = /^(\s*[\w-]+:\s*)(["']?)(.*?)(\2\s*)$/.exec(line); - if (!kv) return line; - return blank(kv[1] + kv[2]) + (/^(?:\.{0,2}\/|https?:)/.test(kv[3]) ? blank(kv[3]) : kv[3]) + blank(kv[4]); + return !kv ? line : blank(kv[1] + kv[2]) + (/^(?:\.{0,2}\/|https?:)/.test(kv[3]) ? blank(kv[3]) : kv[3]) + blank(kv[4]); }); return blank(m.slice(0, m.indexOf("\n") + 1)) + values.join("\n") + blank(m.slice(m.lastIndexOf("\n"))); } @@ -88,7 +61,7 @@ const SYNTAX: [RegExp, (m: string, ...g: string[]) => string][] = [ [/^[ \t]*(?:>[ \t]?)+/gm, blank], [/^[ \t]*(?:[-*+]|\d+[.)])(?=[ \t])/gm, blank], [/^[ \t|:=*_-]*$/gm, blank], // rules, setext underlines, table delimiter rows - [/&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, entity], + [/&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank], [/\\(?=[!-/:-@[-`{-~])/g, blank], [/[*~`|]/g, blank], [/(? string] = [/^(?:import|export)\s.*$/gm, blank function plain(src: string, mdx: boolean): Plain { let s = src.replace(/\r/g, " "); for (const [re, f] of mdx ? [MDX, ...SYNTAX] : SYNTAX) s = s.replace(re, f as (m: string, ...g: string[]) => string); - const out: Plain = { text: "", at: [] }; - const chars: string[] = []; - for (let i = 0; i < s.length; i++) - if (!/\s/.test(s[i])) { - chars.push(s[i]); - out.at.push(i); - } - out.text = chars.join(""); - return out; + const at: number[] = []; + for (let i = 0; i < s.length; i++) if (!/\s/.test(s[i])) at.push(i); + return { text: at.map((i) => s[i]).join(""), at }; } - function slice(p: Plain, from: number, to: number): Plain { - const [a, b] = [p.at.findIndex((x) => x >= from), p.at.findIndex((x) => x >= to)]; - const [i, j] = [a < 0 ? p.at.length : a, b < 0 ? p.at.length : b]; + const [i, j] = [from, to].map((x) => p.at.findIndex((a) => a >= x)).map((k) => (k < 0 ? p.at.length : k)); return { text: p.text.slice(i, j), at: p.at.slice(i, j) }; } // ── What is locked ── - type Unit = { key: string; file: string; src: string; line0: number; plain: Plain }; const listOf = (text: string | null) => @@ -129,59 +93,43 @@ function kindOf(f: string, listed: string[]): string | null { if (f === "docs/manifesto.md") return "manifesto"; if (f === "README.md") return "readme"; if (/^docs\/(?:[^/]+\/)*rfc-[^/]+\.md$/.test(f)) return "rfc"; - if (f === ROADMAP) return "roadmap"; - return null; + return f === ROADMAP ? "roadmap" : null; } -// Headings outside code fences that `wanted` picks, each running to the next -// heading of its level or higher. +// The headings outside code fences that `wanted` picks, each to the next heading of its level or higher. function sections(src: string, wanted: (level: number, title: string) => boolean) { const out: { title: string; from: number; to: number }[] = []; let open: { level: number; title: string; from: number } | null = null; - let fence = false; - let at = 0; + let [fence, at] = [false, 0]; for (const line of src.split("\n")) { if (/^ {0,3}(?:```|~~~)/.test(line)) fence = !fence; const h = fence ? null : /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); if (h && open && h[1].length <= open.level) { - out.push({ title: open.title, from: open.from, to: at }); + out.push({ ...open, to: at }); open = null; } if (h && !open && wanted(h[1].length, h[2])) open = { level: h[1].length, title: h[2], from: at }; at += line.length + 1; } - if (open) out.push({ title: open.title, from: open.from, to: src.length }); + if (open) out.push({ ...open, to: src.length }); return out; } -// A string the parser can read without running anything: a literal, a -// template's fixed parts, or literals joined with +. -function literal(e: ts.Expression): string | null { - if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; - if (ts.isTemplateExpression(e)) return [e.head.text, ...e.templateSpans.map((s) => s.literal.text)].join(""); - if (ts.isParenthesizedExpression(e)) return literal(e.expression); - if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const [l, r] = [literal(e.left), literal(e.right)]; - return l === null || r === null ? null : l + r; - } - return null; -} - +// Each node's detail and detailKo, read with the TypeScript parser. A string +// that is not a plain literal is not read, and so is reported removed. function roadmapStrings(src: string): Unit[] { const sf = ts.createSourceFile(ROADMAP, src, ts.ScriptTarget.Latest, true); + const literal = (e?: ts.Expression) => (e && (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) ? e.text : null); const out: Unit[] = []; - const name = (p: ts.ObjectLiteralElementLike) => - ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : null; const visit = (n: ts.Node): void => { if (ts.isObjectLiteralExpression(n)) { - const idProp = n.properties.find((p) => name(p) === "id"); - const id = idProp && ts.isPropertyAssignment(idProp) ? literal(idProp.initializer) : null; - for (const p of n.properties) { - const field = name(p); - if (!ts.isPropertyAssignment(p) || (field !== "detail" && field !== "detailKo")) continue; - const text = literal(p.initializer); - const line = sf.getLineAndCharacterOfPosition(p.initializer.getStart()).line + 1; - if (text !== null) out.push({ key: `${ROADMAP} › ${id ?? `line ${line}`}.${field}`, file: ROADMAP, src: text, line0: line, plain: plain(text, false) }); + const props = n.properties.filter(ts.isPropertyAssignment); + const named = (k: string) => props.find((p) => (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === k)?.initializer; + for (const field of ["detail", "detailKo"]) { + const [text, e] = [literal(named(field)), named(field)]; + const line = e ? sf.getLineAndCharacterOfPosition(e.getStart()).line + 1 : 0; + const key = `${ROADMAP} › ${literal(named("id")) ?? `line ${line}`}.${field}`; + if (text !== null) out.push({ key, file: ROADMAP, src: text, line0: line, plain: plain(text, false) }); } } ts.forEachChild(n, visit); @@ -193,120 +141,88 @@ function roadmapStrings(src: string): Unit[] { function unitsOf(f: string, src: string, kind: string): Unit[] { if (kind === "roadmap") return roadmapStrings(src); const whole = plain(src, f.endsWith(".mdx")); - const cut = (key: string, from: number, to: number): Unit => ({ key, file: f, src, line0: 1, plain: slice(whole, from, to) }); - if (kind === "readme") - return sections(src, (level, title) => level === 2 && README_SECTIONS.includes(title)).map((s) => cut(`${f} › ${s.title}`, s.from, s.to)); - if (kind === "rfc") return sections(src, (_, title) => /^Part 1\b/.test(title)).map((s) => cut(`${f} › Part 1`, s.from, s.to)); + const cut = (key: string, s: { from: number; to: number }): Unit => ({ key, file: f, src, line0: 1, plain: slice(whole, s.from, s.to) }); + if (kind === "readme") return sections(src, (l, t) => l === 2 && README_SECTIONS.includes(t)).map((s) => cut(`${f} › ${s.title}`, s)); + if (kind === "rfc") return sections(src, (_, t) => /^Part 1\b/.test(t)).map((s) => cut(`${f} › Part 1`, s)); return [{ key: f, file: f, src, line0: 1, plain: whole }]; } // ── Showing a change ── - -const CONTEXT = 30; -const LONGEST = 200; - -// The source around plain characters [p, q), with that stretch marked «…». +// The source around plain characters [p, q), 30 of them each side, with the stretch marked «…». function excerpt(u: Unit, p: number, q: number): string { const { src, plain: { at } } = u; const markFrom = p < q ? at[p] : p > 0 ? at[p - 1] + 1 : (at[0] ?? 0); const markTo = p < q ? at[q - 1] + 1 : markFrom; - const from = Math.min(markFrom, at[Math.max(0, p - CONTEXT)] ?? markFrom); - const to = Math.max(markTo, (at[Math.min(at.length, q + CONTEXT) - 1] ?? markTo - 1) + 1); + const from = Math.min(markFrom, at[Math.max(0, p - 30)] ?? markFrom); + const to = Math.max(markTo, (at[Math.min(at.length, q + 30) - 1] ?? markTo - 1) + 1); const flat = (s: string) => s.replace(/\s+/g, " "); - let marked = flat(src.slice(markFrom, markTo)); - if (marked.length > LONGEST) marked = `${marked.slice(0, LONGEST)}…`; + const marked = flat(src.slice(markFrom, markTo)); const line = u.line0 + (src.slice(0, markFrom).match(/\n/g)?.length ?? 0); - return `${u.file}:${line} …${flat(src.slice(from, markFrom))}«${marked}»${flat(src.slice(markTo, to))}…`; + return `${u.file}:${line} …${flat(src.slice(from, markFrom))}«${marked.length > 200 ? `${marked.slice(0, 200)}…` : marked}»${flat(src.slice(markTo, to))}…`; } - function difference(a: Unit, b: Unit): [string, string] { const [x, y] = [a.plain.text, b.plain.text]; - let p = 0; + let [p, s] = [0, 0]; while (p < x.length && p < y.length && x[p] === y[p]) p++; - let s = 0; while (s < x.length - p && s < y.length - p && x[x.length - 1 - s] === y[y.length - 1 - s]) s++; return [excerpt(a, p, x.length - s), excerpt(b, p, y.length - s)]; } // ── The check ── - const strict = process.env.PF_DRIFT_STRICT === "1"; -let base: Base; -try { - base = findBase(); -} catch (e) { - console.log(`check:human-lock: ${(e as Error).message}`); - process.exit(1); -} +const base = findBase(); if (base.sha === null) { - console.log(`check:human-lock: skipped, ${base.why}`); - if (strict) console.log("PF_DRIFT_STRICT=1, so a skip fails"); - process.exit(strict ? 1 : 0); + console.log(`check:human-lock: ${base.fatal ? base.why : `skipped, ${base.why}${strict ? "\nPF_DRIFT_STRICT=1, so a skip fails" : ""}`}`); + process.exit(base.fatal || strict ? 1 : 0); } const BASE = base.sha; +const plural = (k: number, one: string) => `${k} ${one}${k === 1 ? "" : "s"}`; -const listedBefore = listOf(readAt(BASE, LIST)); -const listedNow = listOf(readNow(LIST)); +const [listedBefore, listedNow] = [listOf(readAt(BASE, LIST)), listOf(readNow(LIST))]; const listed = [...new Set([...listedBefore, ...listedNow])]; +const unlisted = listedBefore.filter((p) => !listedNow.includes(p)); // What is locked at HEAD, counted, so a run says what it looked at. const count = new Map(); -for (const f of git(["ls-files", "-z"]).split("\0").filter(Boolean)) { +for (const f of git(["ls-files", "-z"]).split("\0")) { const kind = kindOf(f, listedNow); - if (!kind || kind === "listed") continue; - const n = kind === "readme" || kind === "rfc" || kind === "roadmap" ? unitsOf(f, readNow(f) ?? "", kind).length : 1; - count.set(kind, (count.get(kind) ?? 0) + n); + if (kind && kind !== "listed") count.set(kind, (count.get(kind) ?? 0) + (["readme", "rfc", "roadmap"].includes(kind) ? unitsOf(f, readNow(f) ?? "", kind).length : 1)); } const n = (kind: string) => count.get(kind) ?? 0; console.log(`base: ${BASE.slice(0, 7)} (${base.from})`); -console.log( - `locked: ${n("journal")} journal files, ${n("release")} release files, docs/manifesto.md, ${n("readme")} README sections, ` + - `Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${listedNow.length} path${listedNow.length === 1 ? "" : "s"} in ${LIST}`, -); - +console.log(`locked: ${n("journal")} journal files, ${n("release")} release files, docs/manifesto.md, ${n("readme")} README sections, Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${plural(listedNow.length, "path")} in ${LIST}`); const changes = changedSince(BASE); const touched = changes.filter((c) => kindOf(c.path, listed)); -console.log(`this diff touches ${touched.length} locked file${touched.length === 1 ? "" : "s"}`); - -let failed = 0; -let moved = 0; -const unlisted = listedBefore.filter((p) => !listedNow.includes(p)); +console.log(`this diff touches ${plural(touched.length, "locked file")}`); if (touched.length || unlisted.length) console.log(""); for (const p of unlisted) console.log(`unlisted ${LIST} no longer names ${p}; only the maintainer takes a path off it`); // Where vanished text may have gone: every file this diff adds or changes. let corpus: { file: string; text: string }[] | undefined; function whereIs(text: string): string | null { - corpus ??= changes.flatMap((c) => { - if (c.status === "deleted") return []; - const raw = fs.existsSync(path.join(ROOT, c.path)) ? fs.readFileSync(path.join(ROOT, c.path)) : null; + corpus ??= changes.flatMap(({ path: f, status }) => { + const raw = status !== "deleted" && fs.existsSync(path.join(ROOT, f)) ? fs.readFileSync(path.join(ROOT, f)) : null; if (!raw || raw.includes(0)) return []; - const src = raw.toString("utf8"); - const kind = kindOf(c.path, listed); - const parts = kind === "roadmap" ? unitsOf(c.path, src, kind).map((u) => u.plain.text) : []; - return [plain(src, c.path.endsWith(".mdx")).text, ...parts].map((text) => ({ file: c.path, text })); + const parts = kindOf(f, listed) === "roadmap" ? roadmapStrings(raw.toString("utf8")).map((u) => u.plain.text) : []; + return [plain(raw.toString("utf8"), f.endsWith(".mdx")).text, ...parts].map((t) => ({ file: f, text: t })); }); return corpus.find((c) => c.text.includes(text))?.file ?? null; } +let [failed, moved] = [0, 0]; for (const c of touched) { const kind = kindOf(c.path, listed)!; - const before = c.status === "added" ? null : readAt(BASE, c.path); - const now = c.status === "deleted" ? null : readNow(c.path); - const old = new Map((before === null ? [] : unitsOf(c.path, before, kind)).map((u) => [u.key, u])); - const cur = new Map((now === null ? [] : unitsOf(c.path, now, kind)).map((u) => [u.key, u])); + const units = (src: string | null) => new Map((src === null ? [] : unitsOf(c.path, src, kind)).map((u) => [u.key, u])); + const [old, cur] = [units(c.status === "added" ? null : readAt(BASE, c.path)), units(c.status === "deleted" ? null : readNow(c.path))]; let same = 0; for (const [key, a] of old) { const b = cur.get(key); - if (b && a.plain.text === b.plain.text) same++; + if (b ? a.plain.text === b.plain.text : !a.plain.text) same++; else if (b) { const [was, is] = difference(a, b); - console.log(`changed ${key}`); - console.log(` base ${was}`); - console.log(` head ${is}`); + console.log(`changed ${key}\n base ${was}\n head ${is}`); failed++; - } else if (!a.plain.text) same++; - else { + } else { const to = whereIs(a.plain.text); console.log(to ? `moved ${key} → ${to}, word for word` : `removed ${key}: its words are in no file this diff adds or changes`); if (to) moved++; @@ -314,19 +230,16 @@ for (const c of touched) { } } const fresh = [...cur.keys()].filter((k) => !old.has(k)).length; - if (same) console.log(`same ${c.path}${old.size > 1 ? ` (${same} of ${old.size} locked ${kind === "roadmap" ? "strings" : "sections"})` : ""}`); - if (fresh) console.log(`new ${c.path}${cur.size > 1 ? ` (${fresh} locked ${kind === "roadmap" ? "strings" : "sections"})` : ""}`); + const what = kind === "roadmap" ? "strings" : "sections"; + if (same) console.log(`same ${c.path}${old.size > 1 ? ` (${same} of ${old.size} locked ${what})` : ""}`); + if (fresh) console.log(`new ${c.path}${cur.size > 1 ? ` (${fresh} locked ${what})` : ""}`); } console.log(""); if (moved) console.log("Moving locked text needs the maintainer's approval on the PR (docs/rfc-cleanup.md §2.9)."); -const plural = (k: number, one: string, many: string) => `${k} ${k === 1 ? one : many}`; -const wrong = [ - failed && `${plural(failed, "locked text", "locked texts")} changed`, - unlisted.length && `${plural(unlisted.length, "path", "paths")} taken off ${LIST}`, -].filter(Boolean); -if (wrong.length) { - console.log(`check:human-lock: ${wrong.join(", ")}`); +const wrong = [failed && `${plural(failed, "locked text")} changed`, unlisted.length && `${plural(unlisted.length, "path")} taken off ${LIST}`]; +if (failed || unlisted.length) { + console.log(`check:human-lock: ${wrong.filter(Boolean).join(", ")}`); if (failed) console.log("Markup, layout and link and image paths may change in locked text; words may not. A typo is reported in the PR description, not fixed."); process.exitCode = 1; } else console.log("check:human-lock: no locked words changed"); diff --git a/web/scripts/lib/pr-base.ts b/web/scripts/lib/pr-base.ts index 310c49de..26b14b56 100644 --- a/web/scripts/lib/pr-base.ts +++ b/web/scripts/lib/pr-base.ts @@ -1,11 +1,8 @@ /** - * The commit a pull request starts from, and the files it changes since. - * scripts/human-lock.ts compares locked text at that commit with the working - * tree; scripts/voice-lint.ts reads the lines a branch adds since it. - * - * PF_LOCK_BASE names the commit, and CI passes the pull request's base SHA. - * At a desk it is the merge-base with origin/dev, then with origin/main. With - * none of the three there is no base, and the check says why and skips + * The commit a pull request starts from, and the files it changes since, for + * scripts/human-lock.ts and scripts/voice-lint.ts. PF_LOCK_BASE names it (CI + * passes the pull request's base SHA); at a desk it is the merge-base with + * origin/dev, then origin/main. With none of the three, the check skips * (docs/investigations/2026-10-cleanup-step2-plan.md, D5). */ import { execFileSync } from "node:child_process"; @@ -13,34 +10,25 @@ import fs from "node:fs"; import path from "node:path"; export const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); - export const git = (args: string[]) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "ignore"] }); - const attempt = (f: () => T, fallback: T): T => { - try { - return f(); - } catch { - return fallback; - } + try { return f(); } catch { return fallback; } }; const commit = (ref: string) => attempt(() => git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).trim(), ""); -export type Base = { sha: string; from: string } | { sha: null; why: string }; +export type Base = { sha: string; from: string } | { sha: null; why: string; fatal: boolean }; -/** The base and where it came from, or why there is none. Throws when PF_LOCK_BASE names a commit this clone lacks. */ +/** The base and where it came from, or why there is none: fatal when PF_LOCK_BASE names a commit this clone lacks. */ export function findBase(): Base { const named = process.env.PF_LOCK_BASE?.trim(); - if (named) { - const sha = commit(named); - if (!sha) throw new Error(`PF_LOCK_BASE is ${named}, and this clone has no such commit (check out with fetch-depth: 0)`); - return { sha, from: "PF_LOCK_BASE" }; - } + if (named && !commit(named)) return { sha: null, why: `PF_LOCK_BASE is ${named}, and this clone has no such commit (check out with fetch-depth: 0)`, fatal: true }; + if (named) return { sha: commit(named), from: "PF_LOCK_BASE" }; for (const ref of ["origin/dev", "origin/main"]) { const sha = commit(ref) && attempt(() => git(["merge-base", "HEAD", ref]).trim(), ""); if (sha) return { sha, from: `the merge-base with ${ref}` }; } - return { sha: null, why: "no PF_LOCK_BASE, and no merge-base with origin/dev or origin/main" }; + return { sha: null, why: "no PF_LOCK_BASE, and no merge-base with origin/dev or origin/main", fatal: false }; } export type Change = { path: string; status: "added" | "modified" | "deleted" }; @@ -49,14 +37,11 @@ export type Change = { path: string; status: "added" | "modified" | "deleted" }; export function changedSince(base: string): Change[] { const out = new Map(); const fields = git(["diff", "--name-status", "--no-renames", "-z", base]).split("\0").filter(Boolean); - for (let i = 0; i + 1 < fields.length; i += 2) - out.set(fields[i + 1], fields[i] === "A" ? "added" : fields[i] === "D" ? "deleted" : "modified"); + for (let i = 0; i + 1 < fields.length; i += 2) out.set(fields[i + 1], fields[i] === "A" ? "added" : fields[i] === "D" ? "deleted" : "modified"); for (const f of git(["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean)) out.set(f, "added"); - return [...out].map(([p, status]) => ({ path: p, status })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + return [...out].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([p, status]) => ({ path: p, status })); } -/** A file as it was at a commit, or null if it was not there. */ +/** A file as it was at a commit, or as it is in the working tree; null if it is not there. */ export const readAt = (ref: string, file: string) => attempt(() => git(["cat-file", "-p", `${ref}:${file}`]), null); - -/** A file as it is in the working tree, or null if it is not there. */ export const readNow = (file: string) => attempt(() => fs.readFileSync(path.join(ROOT, file), "utf8"), null);