From bb59651ce2d0f6c5ec1643900a86a4aa9aa89a21 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 13 Sep 2026 00:08:22 -0400 Subject: [PATCH] feat(tools): add the font-coverage gate (RIG-3603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T8 retires the Unifont fallback pin from the e2e font set (`tools/toolchain/chromium-e2e-env.nix`). That is only safe once nothing in `apps/ui/src` renders a character the two branded faces lack, and until now the check for that was an eyeball pass over a hand-written census. The census has been wrong twice: it classified characters as safe prose by appearance, and `⟨`/`⟩`/`⌗` turned out to be absent from Space Mono while `−` and `→`, both assumed to need conversion, turned out to be present. The gate parses the `cmap` of `SpaceMono-Regular.ttf` and `DepartureMono-Regular.otf` (format 4 and format 12, TrueType and CFF), scans `apps/ui/src` for non-ASCII characters in rendered positions, and reports the ones neither face covers. Comments are stripped line-preservingly and string- aware, so a `//` inside a literal is not mistaken for a comment and a finding's reported line number survives the strip. The covered set is Space Mono's cmap alone, not the union of both faces. `--rigel-display` (Departure Mono) is used by exactly one rule (`apps/ui/src/app.css:444`); everything else resolves `--rigel-mono`, so a character present only in the display face still falls through to the pinned fallback at a body-text site. Departure is loaded, counted and plausibility-checked — it proves the format-12 parse path — but findings clear against Space Mono. That is deliberately conservative: at the single display site the gate can over-report, never under-report, so it cannot green a site that still needs the fallback. It runs in WARN mode (reports, exits 0) because 8 characters are uncovered today; `FONT_COVERAGE_GATE=error` exits non-zero, and T8 flips the default. A gate that cannot tell a clean tree from one it never read is worse than none, so a missing or unparseable font face exits 2 in both modes rather than reporting zero findings. Registered in `.moon/workspace.yml`: moon discovers projects only from that map, and an unregistered tool never runs. Verified: 15 unit tests pass; injecting a naive line-deleting comment strip reddens 2 of them, so the line-number assertion discriminates. Against the live tree the gate reports 24 findings over 8 distinct codepoints (`▸ ■ ⟩ ⟨ ⎇ ⌗ ➜ ▪`), matching an independent cmap measurement; warn exits 0, error exits 1, a missing font exits 2, a truncated font exits 2. --- .moon/workspace.yml | 7 + bun.lock | 12 + tools/font-coverage-gate/biome.json | 4 + tools/font-coverage-gate/index.test.ts | 150 ++++++++++ tools/font-coverage-gate/index.ts | 372 +++++++++++++++++++++++++ tools/font-coverage-gate/moon.yml | 53 ++++ tools/font-coverage-gate/package.json | 14 + tools/font-coverage-gate/tsconfig.json | 11 + 8 files changed, 623 insertions(+) create mode 100644 tools/font-coverage-gate/biome.json create mode 100644 tools/font-coverage-gate/index.test.ts create mode 100644 tools/font-coverage-gate/index.ts create mode 100644 tools/font-coverage-gate/moon.yml create mode 100644 tools/font-coverage-gate/package.json create mode 100644 tools/font-coverage-gate/tsconfig.json diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 773d9d18a..41e7a5dbf 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -107,6 +107,13 @@ projects: # file's --rigel-purple is the one narrow allowlist. WARN until the adoption # step-5 flip, then ERROR. cx-token-gate: 'tools/cx-token-gate' + # The font-coverage gate (RIG-3603 T8 precondition): parses the branded font + # cmaps (Space Mono / Departure Mono) and scans apps/ui/src for a rendered + # character they lack — the check that lets RIG-3742 retire the Unifont + # fallback pin. Registered so its typecheck + unit tests ride the moon-driven + # CI sweep; unregistered, moon never discovers it and the gate is silently + # inert. WARN until T8 (8 known-uncovered chars today), then ERROR. + font-coverage-gate: 'tools/font-coverage-gate' # The agent-image env gate (RIG-1444): builds the compass-agent image and # fails on wrong-image signals a green `container build` misses — a DEVENV_ # env var whose value names a /nix/store path (a closure root nix2container diff --git a/bun.lock b/bun.lock index 786a61944..9cec54547 100644 --- a/bun.lock +++ b/bun.lock @@ -159,6 +159,16 @@ "typescript": "catalog:", }, }, + "tools/font-coverage-gate": { + "name": "@compass/font-coverage-gate", + "bin": { + "font-coverage-gate": "./index.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/forge-linear-token": { "name": "@compass/forge-linear-token", "bin": { @@ -485,6 +495,8 @@ "@compass/eng-docs": ["@compass/eng-docs@workspace:apps/eng-docs"], + "@compass/font-coverage-gate": ["@compass/font-coverage-gate@workspace:tools/font-coverage-gate"], + "@compass/forge-linear-token": ["@compass/forge-linear-token@workspace:tools/forge-linear-token"], "@compass/inline-sql-gate": ["@compass/inline-sql-gate@workspace:tools/inline-sql-gate"], diff --git a/tools/font-coverage-gate/biome.json b/tools/font-coverage-gate/biome.json new file mode 100644 index 000000000..ece11dd9b --- /dev/null +++ b/tools/font-coverage-gate/biome.json @@ -0,0 +1,4 @@ +{ + "extends": "//", + "linter": { "rules": { "suspicious": { "noConsole": "off" } } } +} diff --git a/tools/font-coverage-gate/index.test.ts b/tools/font-coverage-gate/index.test.ts new file mode 100644 index 000000000..bf0bd8e50 --- /dev/null +++ b/tools/font-coverage-gate/index.test.ts @@ -0,0 +1,150 @@ +// Unit tests for the font-coverage-gate's pure core (index.ts). +// +// This gate is the oracle that lets RIG-3742 retire the Unifont fallback pin, +// so this suite defends the machine-readable contract: the cmap parser reads +// the REAL font faces (format 4 for Space Mono, format 12 for Departure Mono's +// astral coverage), the source scanner ignores comments/tests but flags +// rendered characters, and the evaluate/mode cores behave. The font assertions +// (U+2212 present in Space Mono, U+27E9 absent, U+27E9 present in Departure) +// are measured facts, not guesses. + +import { describe, expect, test } from "bun:test"; +import { + cmapCodepoints, + evaluate, + type Finding, + resolveMode, + scanSource, + stripComments, +} from "./index.ts"; + +const FONTS = `${import.meta.dir}/../../apps/eng-docs/public/fonts`; +const bytes = async (name: string): Promise => + new Uint8Array(await Bun.file(`${FONTS}/${name}`).arrayBuffer()); + +// --------------------------------------------------------------------------- +// cmapCodepoints — against the REAL font files. +// --------------------------------------------------------------------------- + +describe("cmapCodepoints", () => { + test("Space Mono (format 4): covers U+2212 / U+2192, lacks U+27E9 / U+2387", async () => { + const cp = cmapCodepoints(await bytes("SpaceMono-Regular.ttf")); + expect(cp.has(0x2212)).toBe(true); // MINUS SIGN + expect(cp.has(0x2192)).toBe(true); // RIGHTWARDS ARROW + expect(cp.has(0x27e9)).toBe(false); // MATHEMATICAL RIGHT ANGLE BRACKET + expect(cp.has(0x2387)).toBe(false); // ALTERNATIVE KEY SYMBOL + }); + + test("Departure Mono (format 12 / CFF OTTO): covers U+27E9", async () => { + const cp = cmapCodepoints(await bytes("DepartureMono-Regular.otf")); + // U+27E9 is absent from Space Mono but present here — proving both the + // OTTO/CFF sfnt path and the format-12 branch actually run. + expect(cp.has(0x27e9)).toBe(true); + // Departure has astral coverage; a codepoint > U+FFFF can only come from + // a format-12 subtable, so any such hit confirms the branch. + expect([...cp].some((c) => c > 0xffff)).toBe(true); + }); + + test("throws on a truncated font rather than returning a partial set", () => { + expect(() => cmapCodepoints(new Uint8Array([0, 1, 0, 0, 0, 1]))).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// stripComments / scanSource — comment awareness + line preservation. +// --------------------------------------------------------------------------- + +describe("scanSource", () => { + test("does not flag a non-ASCII char in a line comment", () => { + expect(scanSource("apps/ui/src/a.ts", "const x = 1; // arrow ▸\n")).toEqual( + [], + ); + }); + + test("does not flag a non-ASCII char in a block comment", () => { + expect( + scanSource("apps/ui/src/a.ts", "/* bracket ⟩ */\nconst x = 1;\n"), + ).toEqual([]); + }); + + test("flags a non-ASCII char in a string that contains //", () => { + // The `//` here is inside a string literal, so it is NOT a comment; the + // bracket after it is a rendered character and must be flagged. + const found = scanSource("apps/ui/src/a.ts", 'const s = "http:// ⟩";\n'); + expect(found.map((f) => f.codepoint)).toEqual([0x27e9]); + }); + + test("reports the correct line AFTER stripping a multi-line block comment", () => { + // A naive strip that DELETES comment lines would report the ■ on line 2, + // not 4. The space-preserving strip keeps it on line 4. + const text = ["/* a", " b", "*/", 'const t = "■";', ""].join("\n"); + const found = scanSource("apps/ui/src/a.ts", text); + expect(found).toHaveLength(1); + expect(found[0]?.line).toBe(4); + expect(found[0]?.codepoint).toBe(0x25a0); + }); + + test("column is 1-based within the (stripped) line", () => { + const found = scanSource("apps/ui/src/a.ts", 'x="▸"\n'); + expect(found[0]?.column).toBe(4); + }); + + test("skips *.test.ts and *.test.tsx entirely", () => { + expect(scanSource("apps/ui/src/a.test.ts", 'const s = "▸";\n')).toEqual([]); + expect(scanSource("apps/ui/src/a.test.tsx", 'const s = "▸";\n')).toEqual( + [], + ); + }); + + test("an escaped quote does not end a string, so // inside stays a string", () => { + // The \" does not close the string; the // and the ▸ are still inside it. + const found = scanSource("apps/ui/src/a.ts", 'const s = "a\\" // ▸";\n'); + expect(found.map((f) => f.codepoint)).toEqual([0x25b8]); + }); +}); + +// --------------------------------------------------------------------------- +// stripComments — direct: line count is invariant. +// --------------------------------------------------------------------------- + +describe("stripComments", () => { + test("preserves the line count of a block comment", () => { + const text = "/* a\nb\nc */\nx"; + expect(stripComments(text).split("\n")).toHaveLength(4); + }); +}); + +// --------------------------------------------------------------------------- +// evaluate — filters covered, keeps uncovered. +// --------------------------------------------------------------------------- + +describe("evaluate", () => { + test("keeps only findings whose codepoint is absent from covered", () => { + const findings: Finding[] = [ + { path: "a.ts", line: 1, column: 1, char: "−", codepoint: 0x2212 }, + { path: "a.ts", line: 2, column: 1, char: "⟩", codepoint: 0x27e9 }, + ]; + const covered = new Set([0x2212]); + expect(evaluate(findings, covered).map((f) => f.codepoint)).toEqual([ + 0x27e9, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// resolveMode — default WARN; env override. +// --------------------------------------------------------------------------- + +describe("resolveMode", () => { + test("defaults to warn", () => { + expect(resolveMode({})).toBe("warn"); + }); + + test("FONT_COVERAGE_GATE=error selects error", () => { + expect(resolveMode({ FONT_COVERAGE_GATE: "error" })).toBe("error"); + }); + + test("any other value stays warn", () => { + expect(resolveMode({ FONT_COVERAGE_GATE: "loud" })).toBe("warn"); + }); +}); diff --git a/tools/font-coverage-gate/index.ts b/tools/font-coverage-gate/index.ts new file mode 100644 index 000000000..42f46983d --- /dev/null +++ b/tools/font-coverage-gate/index.ts @@ -0,0 +1,372 @@ +// font-coverage-gate (RIG-3603 T8 precondition) — prove no rendered UI site +// uses a character absent from the branded font faces. +// +// WHY this exists: the e2e chromium env (tools/toolchain/chromium-e2e-env.nix, +// fontDirs) pins Space Mono + Departure Mono (the two branded faces) plus +// Unifont/unifont_upper purely as a COVERAGE FALLBACK, so an uncovered glyph +// renders a real (dual-width, off-grid) shape instead of baking tofu into a +// visual baseline. RIG-3742 / T8 removes the Unifont pin. That is only safe +// once nothing renders a character the branded faces lack — a claim that was an +// eyeball pass over a hand-written census, proven wrong twice. This gate makes +// it checkable: it parses the real font cmaps and scans the rendered UI source. +// +// Body text resolves --rigel-mono (Space Mono); --rigel-display (Departure +// Mono) is used by exactly one CSS rule. So the effective coverage a rendered +// character must satisfy is Space Mono's cmap; Departure is loaded, counted, +// and plausibility-checked too (it proves the format-12 path and guards the +// future display-face work), but Space Mono is the covered set findings clear. +// +// Adoption posture (RIG-3742 / T8): WARN until T8, then ERROR. There are 8 +// known-uncovered rendered characters TODAY, so a fail-closed gate would be red +// on arrival. In WARN it prints findings and exits 0; in ERROR it exits 1 on +// any finding. Mode is FONT_COVERAGE_GATE=warn|error (default warn). THE FLIP: +// at T8 (RIG-3742), change the default below from "warn" to "error" so an +// uncovered rendered glyph fails the build. +// +// KNOWN BLIND SPOTS — the T8 flip assumes these stay absent, so widening the +// gate is cheaper than discovering one after it is fail-closed: +// * Only LITERAL non-ASCII bytes are seen. A "\u25b8" escape, a ▸ +// entity, or String.fromCodePoint renders the glyph invisibly to the scan. +// The tree writes literal glyphs throughout, which is what makes this safe. +// * stripComments has no regex-literal state, so a `//` inside a regex blanks +// the rest of that line; and an apostrophe in JSX text ("don't") opens a +// string state that can suppress a later comment strip. Neither fires on +// the current tree (verified against a whole-tree oracle sweep). +// * Only UI_SRC_DIR is scanned; apps/ui/e2e authors baselines too. +// +// Inputs (env): +// GATE_ROOT - directory to scan (default: git toplevel). +// FONT_COVERAGE_GATE - "warn" (default) or "error". +// Exit codes: +// 0 - no findings, OR findings in WARN mode (printed, non-blocking) +// 1 - one or more findings in ERROR mode +// 2 - usage / internal error (font missing, truncated, or implausibly small) + +import { $ } from "bun"; + +/** The UI source root whose rendered characters the gate governs. */ +export const UI_SRC_DIR = "apps/ui/src"; +/** Where the branded font faces live, repo-relative. */ +export const FONTS_DIR = "apps/eng-docs/public/fonts"; +/** The primary branded face: body text resolves --rigel-mono (Space Mono). */ +export const SPACE_MONO_REL = `${FONTS_DIR}/SpaceMono-Regular.ttf`; +/** The display face (--rigel-display); its cmap exercises the format-12 path. */ +export const DEPARTURE_REL = `${FONTS_DIR}/DepartureMono-Regular.otf`; + +/** A face parsing to fewer codepoints than this is treated as a false read. */ +export const MIN_PLAUSIBLE_CODEPOINTS = 100; + +/** The gate's blocking posture. WARN prints but exits 0; ERROR exits 1. */ +export type Mode = "warn" | "error"; + +/** A non-ASCII rendered character, located by file + 1-based line/column. */ +export interface Finding { + path: string; + line: number; + column: number; + char: string; + codepoint: number; +} + +// --------------------------------------------------------------------------- +// cmap parser (pure, exported). +// --------------------------------------------------------------------------- + +/** + * Union the codepoint coverage of a font's cmap subtables. Parses the sfnt + * table directory (TrueType 0x00010000 / 'true', or CFF 'OTTO') and reads the + * `cmap` table, supporting subtable format 4 (BMP) and format 12 (astral); + * other formats are ignored, not fatal. A malformed/truncated file throws — a + * partial set would silently read as "uncovered" and green a broken gate. + */ +export function cmapCodepoints(bytes: Uint8Array): Set { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const need = (end: number, what: string): void => { + if (end > dv.byteLength) { + throw new Error(`cmap: truncated font — ${what} runs past end of file`); + } + }; + const u16 = (o: number): number => { + need(o + 2, "uint16"); + return dv.getUint16(o); + }; + const i16 = (o: number): number => { + need(o + 2, "int16"); + return dv.getInt16(o); + }; + const u32 = (o: number): number => { + need(o + 4, "uint32"); + return dv.getUint32(o); + }; + + need(12, "sfnt header"); + const numTables = u16(4); + let cmapOffset = -1; + for (let t = 0; t < numTables; t++) { + const rec = 12 + t * 16; + need(rec + 16, "table record"); + const tag = String.fromCharCode( + dv.getUint8(rec), + dv.getUint8(rec + 1), + dv.getUint8(rec + 2), + dv.getUint8(rec + 3), + ); + if (tag === "cmap") { + cmapOffset = u32(rec + 8); + break; + } + } + if (cmapOffset < 0) throw new Error("cmap: no cmap table in font"); + + const covered = new Set(); + const numSub = u16(cmapOffset + 2); + for (let s = 0; s < numSub; s++) { + const rec = cmapOffset + 4 + s * 8; + const subOffset = cmapOffset + u32(rec + 4); + const format = u16(subOffset); + if (format === 4) readFormat4(subOffset, covered, u16, i16); + else if (format === 12) readFormat12(subOffset, covered, u32); + // Any other format: ignore, not fatal. + } + return covered; +} + +/** Format 4: segment-mapped BMP coverage. A codepoint is covered iff its glyph id is non-zero. */ +function readFormat4( + base: number, + out: Set, + u16: (o: number) => number, + i16: (o: number) => number, +): void { + const segCount = u16(base + 6) / 2; + const endBase = base + 14; + const startBase = endBase + segCount * 2 + 2; // +2 reservedPad + const deltaBase = startBase + segCount * 2; + const rangeBase = deltaBase + segCount * 2; + for (let i = 0; i < segCount; i++) { + const end = u16(endBase + i * 2); + const start = u16(startBase + i * 2); + const delta = i16(deltaBase + i * 2); + const rangeOffset = u16(rangeBase + i * 2); + if (start > end) continue; // the 0xffff..0xffff terminator degenerates here + for (let c = start; c <= end && c !== 0xffff; c++) { + let glyph: number; + if (rangeOffset === 0) { + glyph = (c + delta) & 0xffff; + } else { + // idRangeOffset indexes into glyphIdArray, measured from the + // rangeOffset slot itself (the classic TrueType pointer trick). + const gaddr = rangeBase + i * 2 + rangeOffset + (c - start) * 2; + glyph = u16(gaddr); + if (glyph !== 0) glyph = (glyph + delta) & 0xffff; + } + if (glyph !== 0) out.add(c); + } + } +} + +/** Format 12: segmented coverage, full Unicode range (Departure Mono needs this). */ +function readFormat12( + base: number, + out: Set, + u32: (o: number) => number, +): void { + const numGroups = u32(base + 12); + const groupBase = base + 16; + for (let g = 0; g < numGroups; g++) { + const rec = groupBase + g * 12; + const start = u32(rec); + const end = u32(rec + 4); + if (start > end) continue; + for (let c = start; c <= end; c++) out.add(c); + } +} + +// --------------------------------------------------------------------------- +// Source scanner (pure, exported). +// --------------------------------------------------------------------------- + +/** + * Find every non-ASCII character in a rendered position in one UI source file. + * Comment bodies (line + block) are stripped preserving line numbers; test + * files (*.test.ts / *.test.tsx) are skipped entirely. The strip is + * string-aware: a `//` inside a string literal is not a comment, and an escaped + * quote does not end the string. + */ +export function scanSource(relPath: string, text: string): Finding[] { + if (relPath.endsWith(".test.ts") || relPath.endsWith(".test.tsx")) return []; + const stripped = stripComments(text); + const findings: Finding[] = []; + let line = 0; + for (const raw of stripped.split("\n")) { + line++; + let column = 0; + for (const ch of raw) { + column++; + const cp = ch.codePointAt(0); + if (cp !== undefined && cp > 127) { + findings.push({ path: relPath, line, column, char: ch, codepoint: cp }); + } + } + } + return findings; +} + +/** + * Replace comment bodies with spaces, PRESERVING newlines (so line numbers of + * later code are unchanged). String literals ('...', "...", `...`) are skipped + * with escape handling, so a `//` or `/*` inside a string is not a comment. + */ +export function stripComments(text: string): string { + const out: string[] = []; + let i = 0; + const n = text.length; + let state: "normal" | "line" | "block" = "normal"; + let quote: string | null = null; + while (i < n) { + const c = text[i] ?? ""; + const next = i + 1 < n ? (text[i + 1] ?? "") : ""; + if (quote !== null) { + if (c === "\\") { + out.push(c, next); + i += 2; + continue; + } + out.push(c); + if (c === quote) quote = null; + i++; + continue; + } + if (state === "line") { + out.push(c === "\n" ? "\n" : " "); + if (c === "\n") state = "normal"; + i++; + continue; + } + if (state === "block") { + if (c === "*" && next === "/") { + out.push(" ", " "); + i += 2; + state = "normal"; + continue; + } + out.push(c === "\n" ? "\n" : " "); + i++; + continue; + } + if (c === "/" && next === "/") { + out.push(" ", " "); + i += 2; + state = "line"; + continue; + } + if (c === "/" && next === "*") { + out.push(" ", " "); + i += 2; + state = "block"; + continue; + } + if (c === '"' || c === "'" || c === "`") { + quote = c; + } + out.push(c); + i++; + } + return out.join(""); +} + +// --------------------------------------------------------------------------- +// Evaluate core (pure, exported). +// --------------------------------------------------------------------------- + +/** Keep only the findings whose codepoint is absent from the covered set. */ +export function evaluate(findings: Finding[], covered: Set): Finding[] { + return findings.filter((f) => !covered.has(f.codepoint)); +} + +/** Resolve the blocking posture from env (default WARN). */ +export function resolveMode(env: Record): Mode { + return (env.FONT_COVERAGE_GATE ?? "").toLowerCase() === "error" + ? "error" + : "warn"; +} + +/** Render a codepoint as U+XXXX (at least 4 hex digits, uppercase). */ +export function formatCodepoint(cp: number): string { + return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`; +} + +// --------------------------------------------------------------------------- +// I/O wiring. +// --------------------------------------------------------------------------- + +if (import.meta.main) { + const root = + process.env.GATE_ROOT ?? + (await $`git rev-parse --show-toplevel`.nothrow().quiet().text()).trim(); + const mode = resolveMode(process.env); + + const load = async (rel: string): Promise> => { + const file = Bun.file(`${root}/${rel}`); + if (!(await file.exists())) { + console.error(`font-coverage-gate: font not found: ${rel}`); + process.exit(2); + } + return cmapCodepoints(new Uint8Array(await file.arrayBuffer())); + }; + + let spaceMono: Set; + let departure: Set; + try { + spaceMono = await load(SPACE_MONO_REL); + departure = await load(DEPARTURE_REL); + } catch (error) { + console.error("font-coverage-gate: cannot parse a font face:"); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } + + // A zero-finding run that loaded zero codepoints is a false green: a face + // parsing to an implausibly small set means the parser silently failed, so + // fail loudly in BOTH modes rather than clear every character. + if ( + spaceMono.size < MIN_PLAUSIBLE_CODEPOINTS || + departure.size < MIN_PLAUSIBLE_CODEPOINTS + ) { + console.error( + `font-coverage-gate: implausible cmap — Space Mono ${spaceMono.size}, Departure ${departure.size} (< ${MIN_PLAUSIBLE_CODEPOINTS}); the parser likely failed.`, + ); + process.exit(2); + } + + const findings: Finding[] = []; + const glob = new Bun.Glob(`${UI_SRC_DIR}/**/*.{ts,tsx}`); + for await (const rel of glob.scan({ cwd: root })) { + const posix = rel.replaceAll("\\", "/"); + const text = await Bun.file(`${root}/${posix}`).text(); + findings.push(...scanSource(posix, text)); + } + + // Body text resolves Space Mono, so a rendered character must be in its cmap. + const uncovered = evaluate(findings, spaceMono).sort( + (a, b) => + a.path.localeCompare(b.path) || a.line - b.line || a.column - b.column, + ); + + for (const f of uncovered) { + console.log( + `${f.path}:${f.line}:${f.column} ${formatCodepoint(f.codepoint)} ${f.char}`, + ); + } + console.log( + `font-coverage-gate: ${uncovered.length} uncovered rendered char(s) [${mode.toUpperCase()} mode]; Space Mono ${spaceMono.size} cp, Departure ${departure.size} cp.`, + ); + + if (mode === "error" && uncovered.length > 0) process.exit(1); + if (uncovered.length > 0) { + console.log( + "font-coverage-gate: WARN mode — reported, not blocking. Flip default to ERROR at T8 (RIG-3742).", + ); + } + process.exit(0); +} diff --git a/tools/font-coverage-gate/moon.yml b/tools/font-coverage-gate/moon.yml new file mode 100644 index 000000000..f587c3fa4 --- /dev/null +++ b/tools/font-coverage-gate/moon.yml @@ -0,0 +1,53 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# font-coverage-gate (RIG-3603 T8 precondition) — fail when rendered UI source +# uses a character absent from the branded font faces (Space Mono / Departure +# Mono). This is the check that lets RIG-3742 retire the Unifont fallback pin in +# the e2e chromium env: today the claim "no site renders an uncovered glyph" is +# an eyeball pass over a hand-written census that has been wrong twice; this gate +# parses the real font cmaps and scans apps/ui/src instead. WARN until T8, then +# ERROR (FONT_COVERAGE_GATE=error). +# +# A bun/TypeScript CLI; a hoisted root-workspace member (`bun` tag): install is +# inherited via .moon/tasks/tag-bun.yml (the shared root install) and +# lint/format are whole-repo tasks on the root project (/moon.yml), so this leaf +# has no own bun.lock and never runs its own install. +# +# NOT wired into the `:ci` aggregate's default red path: there are 8 +# known-uncovered rendered characters TODAY, so `check` runs in WARN mode and +# exits 0. T8 flips the default to ERROR. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock'] + test: + inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock'] + check: + # The coverage gate: parse the branded font cmaps and scan the real + # apps/ui/src tree for rendered characters they lack. Run from the workspace + # root so the globs resolve repo-relative (GATE_ROOT defaults to the git + # toplevel = workspace root). Distinct from `test`, which drives the pure + # core over fixtures; this observes the real tree and the real font files. + command: 'bun run tools/font-coverage-gate/index.ts' + deps: ['install'] + options: + runFromWorkspaceRoot: true + # Never cache: the gate's subject is the live apps/ui/src tree and the + # real font files, and a cached green from another checkout is the + # false-green a coverage gate exists to stop. + cache: false + inputs: + - '/apps/ui/src/**/*.ts' + - '/apps/ui/src/**/*.tsx' + - '/apps/eng-docs/public/fonts/SpaceMono-Regular.ttf' + - '/apps/eng-docs/public/fonts/DepartureMono-Regular.otf' + - 'index.ts' + ci: + deps: ['typecheck', 'test', 'check'] + options: + cache: false diff --git a/tools/font-coverage-gate/package.json b/tools/font-coverage-gate/package.json new file mode 100644 index 000000000..996486afe --- /dev/null +++ b/tools/font-coverage-gate/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/font-coverage-gate", + "private": true, + "type": "module", + "description": "CI gate (RIG-3603 T8 precondition): fail when rendered UI source uses a character absent from the branded font faces (Space Mono / Departure Mono), the check that lets RIG-3742 retire the Unifont fallback pin.", + "module": "index.ts", + "bin": { + "font-coverage-gate": "./index.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/font-coverage-gate/tsconfig.json b/tools/font-coverage-gate/tsconfig.json new file mode 100644 index 000000000..d40cc9e50 --- /dev/null +++ b/tools/font-coverage-gate/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "moduleDetection": "force", + "allowJs": true, + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "types": ["bun"] + } +}