diff --git a/.github/scripts/arch-lint.ts b/.github/scripts/arch-lint.ts index 117c2d65b..2925a5cbe 100644 --- a/.github/scripts/arch-lint.ts +++ b/.github/scripts/arch-lint.ts @@ -15,7 +15,7 @@ import { err, ok, Result } from "neverthrow"; import { type Node, parseSync } from "oxc-parser"; import { parseDocument } from "yaml"; import { z } from "zod"; -import { countNoun } from "../../src/text.js"; +import { countNoun, quote } from "../../src/text.js"; export const ARCHITECTURE_PATH = "architecture.yml"; @@ -83,15 +83,33 @@ export function parseArchitecture(root: string): Result return err(doc.error); } const parsed = ARCHITECTURE.safeParse(doc.value); - return parsed.success - ? ok(parsed.data) - : err(located(parsed.error.issues.flatMap((issue) => describeIssue(doc.value, issue)))); + if (!parsed.success) { + return err(located(parsed.error.issues.flatMap((issue) => describeIssue(doc.value, issue)))); + } + const merged = mergedMermaidNodes(parsed.data); + return merged.length > 0 ? err(located(merged)) : ok(parsed.data); } function located(problems: readonly string[]): string[] { return problems.map((problem) => `${ARCHITECTURE_PATH}: ${problem}`); } +/** A hyphen in a layer name is edge syntax to mermaid, so ids swap it for an underscore. */ +function mermaidId(layer: string): string { + return layer.replace(/-/g, "_"); +} + +/** The id mapping is not injective, so two layer names can share a node; the declaration refuses that pair. */ +function mergedMermaidNodes(arch: Architecture): string[] { + return [...Map.groupBy(Object.keys(arch.layers), mermaidId)].flatMap(([id, layers]) => + layers.length > 1 + ? [ + `layers ${layers.map(quote).join(" and ")} share the mermaid id ${id}, which would draw them as one node of the module map; rename all but one`, + ] + : [], + ); +} + /** For callers that render rather than lint (docs, tests): a malformed declaration is fatal to them. */ export function readArchitecture(root: string): Architecture { return parseArchitecture(root).match( @@ -279,14 +297,14 @@ export function lintArchitecture(root: string, arch = readArchitecture(root)): s return problems; } -/** A hyphen in a layer name is edge syntax to mermaid, so ids swap it for an underscore. */ export function renderArchitectureMermaid(arch: Architecture): string { - const id = (layer: string): string => layer.replace(/-/g, "_"); return [ "graph TD", - ...Object.entries(arch.layers).map(([name, paths]) => ` ${id(name)}["${paths.join("
")}"]`), + ...Object.entries(arch.layers).map( + ([name, paths]) => ` ${mermaidId(name)}["${paths.join("
")}"]`, + ), ...Object.entries(arch.edges).flatMap(([from, targets]) => - targets.map((to) => ` ${id(from)} --> ${id(to)}`), + targets.map((to) => ` ${mermaidId(from)} --> ${mermaidId(to)}`), ), ].join("\n"); } diff --git a/.github/scripts/lib/count-word.ts b/.github/scripts/lib/count-word.ts index 2238de350..e2abc94fb 100644 --- a/.github/scripts/lib/count-word.ts +++ b/.github/scripts/lib/count-word.ts @@ -1,7 +1,8 @@ /** * The written-out counts docs prose uses ("Fifteen sections list ..."), shared by the generators - * and the docs pins. The loud throw is the tripwire: a derived list outgrowing the words fails - * the build instead of the prose silently falling back to a numeral. + * and the docs pins. Every caller counts a list the code derives (a registry, a constant), never a + * number read from a page, so a count past the words is a BUG: invariant: the derived list outgrew + * the table and the build fails instead of the prose silently falling back to a numeral. */ const COUNT_WORDS = [ @@ -32,7 +33,7 @@ export function countWord(n: number): string { const word = COUNT_WORDS[n]; if (word === undefined) { throw new Error( - `extend COUNT_WORDS (.github/scripts/lib/count-word.ts): no word for count ${n}`, + `BUG: extend COUNT_WORDS (.github/scripts/lib/count-word.ts): no word for count ${n}`, ); } return word; diff --git a/test/architecture/architecture.test.ts b/test/architecture/architecture.test.ts index a1a2e9f97..a135079ed 100644 --- a/test/architecture/architecture.test.ts +++ b/test/architecture/architecture.test.ts @@ -152,6 +152,15 @@ describe("parseArchitecture", () => { "Flow sequence in block collection must be sufficiently indented and end with a ] at line 4, column 1", ], ], + [ + // Mermaid reads a hyphen as edge syntax, so the renderer swaps it for an underscore; two names one id would + // merge into a single node of the module map, silently, unless the declaration refuses them first. + "two layers the mermaid map would draw as one node", + "layers: {plain-data: [src/plain-data.ts], plain_data: [src/plain_data.ts]}\nexclude: []\nedges: {}", + [ + 'layers "plain-data" and "plain_data" share the mermaid id plain_data, which would draw them as one node of the module map; rename all but one', + ], + ], ])("%s fails naming the key", (_case, document, problems) => withTempDir("arch-lint-parse-", (dir) => { expect(parse(dir, document)).toEqual(err(problems.map((p) => `${ARCHITECTURE_PATH}: ${p}`))); diff --git a/test/scripts/count-word.test.ts b/test/scripts/count-word.test.ts index 51ea9a5e5..cf8a88860 100644 --- a/test/scripts/count-word.test.ts +++ b/test/scripts/count-word.test.ts @@ -1,12 +1,15 @@ -/** The written-out counts (.github/scripts/lib/count-word.ts): the tripwire past the range, never a numeral fallback. */ +/** + * The written-out counts (.github/scripts/lib/count-word.ts): the tripwire past the range is a BUG: invariant, + * never a numeral fallback, so a derived list outgrowing the words fails the build as a programming error. + */ import { expect, test } from "bun:test"; import { countWord } from "../../.github/scripts/lib/count-word.js"; -test("a count with no word throws naming the list to extend, whatever shape the count has", () => { +test("a count with no word throws a BUG: invariant naming the list to extend, whatever shape the count has", () => { for (const count of [21, -1, 1.5, Number.NaN]) { expect(() => countWord(count)).toThrow( - `extend COUNT_WORDS (.github/scripts/lib/count-word.ts): no word for count ${count}`, + `BUG: extend COUNT_WORDS (.github/scripts/lib/count-word.ts): no word for count ${count}`, ); } });