From 2527cc13c5f49acdfd41239fb3afeddffc0a57fc Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Wed, 23 Sep 2026 16:33:56 +0000 Subject: [PATCH 1/4] S0329-parser-first-use-path: execute both first examples and name any unexecuted runnable tag The quickstart's first CCD is committed byte for byte as test/__fixtures__/first-use/quickstart-ccd.xml, which pnpm phi-scan reads as a document, and the suite asserts the copy. Both first-use blocks (the quickstart's first and the first under the README Usage heading) are checked to be ones the two sweeps execute, run explicitly, and shown to go red on a changed claim; the quickstart's also on a changed input. Every fence tagged runnable in README.md or docs-content/ must be one the harness extracts, or the suite names it by file and line. Install commands are compared with package.json name. No document text changed, so no changeset. --- .../__fixtures__/first-use/quickstart-ccd.xml | 143 +++++++++++++++++ test/_helpers/first-use.ts | 121 ++++++++++++++ test/docs-content.test.ts | 151 +++++++++++++++++- test/install-specifier.test.ts | 32 ++++ 4 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 test/__fixtures__/first-use/quickstart-ccd.xml create mode 100644 test/_helpers/first-use.ts create mode 100644 test/install-specifier.test.ts diff --git a/test/__fixtures__/first-use/quickstart-ccd.xml b/test/__fixtures__/first-use/quickstart-ccd.xml new file mode 100644 index 0000000..14a4649 --- /dev/null +++ b/test/__fixtures__/first-use/quickstart-ccd.xml @@ -0,0 +1,143 @@ + + + + + + + + Synthetic Continuity of Care Document + + + + + + JaneQDoe + + + + + +
+ + + Problems + Essential hypertension + + + + + + + + + + +
+
+ + + Medications + Lisinopril 10 MG Oral Tablet + + + + + + + + + + + + + +
+
+ + + Allergies + No known allergies. + + + + + + + + + +
+
+ + + Results + Hemoglobin 13.5 g/dL + + + + + + + + + + + + +
+
+ + + + Vital Signs + Heart rate: 72 /min + + + + + + + + + + + + + + +
+
+ + + Social History + Never smoker + + + + + + + + +
+
+ + + Immunizations + Influenza, split virus, trivalent, injectable, preservative free + + + + + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/test/_helpers/first-use.ts b/test/_helpers/first-use.ts new file mode 100644 index 0000000..05a5edc --- /dev/null +++ b/test/_helpers/first-use.ts @@ -0,0 +1,121 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; + +/** + * Readers for the package's first-use path: the first fenced block of + * `docs-content/quickstart.md`, the first fenced block under `## Usage` in `README.md`, and the + * install command both documents print. Every reader works on the text of the document as it is + * on disk at test time, never on a copy held here. + */ + +/** One fenced code block: its info string split into language and tags, and its body. */ +export interface Fence { + readonly lang: string; + readonly tags: readonly string[]; + readonly body: string; +} + +/** Every fenced block in a chunk of markdown, in document order. */ +export function fences(markdown: string): Fence[] { + const out: Fence[] = []; + let open: { marker: string; lang: string; tags: string[] } | undefined; + let buf: string[] = []; + for (const line of markdown.split("\n")) { + if (open === undefined) { + const opened = /^(`{3,}|~{3,})\s*(.*)$/.exec(line); + if (opened !== null) { + const tokens = (opened[2] ?? "").trim().split(/\s+/).filter(Boolean); + open = { marker: opened[1] ?? "```", lang: tokens[0] ?? "", tags: tokens.slice(1) }; + buf = []; + } + continue; + } + if (line.trimEnd() === open.marker) { + out.push({ lang: open.lang, tags: open.tags, body: buf.join("\n") }); + open = undefined; + continue; + } + buf.push(line); + } + if (open !== undefined) throw new Error("unterminated code fence"); + return out; +} + +/** The lines of one `##` section, from its heading to the next `##` heading. */ +export function section(markdown: string, heading: string): string { + const lines = markdown.split("\n"); + const start = lines.indexOf(heading); + if (start === -1) throw new Error(`no ${heading} section`); + let end = lines.length; + for (let i = start + 1; i < lines.length; i += 1) { + if (lines[i]?.startsWith("## ") === true) { + end = i; + break; + } + } + return lines.slice(start + 1, end).join("\n"); +} + +/** Every backtick-quoted literal in a snippet that holds a C-CDA document. */ +export function documentLiterals(code: string): string[] { + const literals: string[] = []; + for (const match of code.matchAll(/`([^`]* { + if (open !== undefined) { + if (text.trimEnd() === open) open = undefined; + return; + } + const opened = /^(\s*)(`{3,}|~{3,})\s*(.*)$/.exec(text); + if (opened === null) return; + open = `${opened[1] ?? ""}${opened[2] ?? "```"}`; + const tokens = (opened[3] ?? "").trim().split(/\s+/).filter(Boolean); + if (tokens.slice(1).includes("runnable")) out.push({ line: index + 1, lang: tokens[0] ?? "" }); + }); + return out; +} + +/** Every file under `dir`, keyed by its exact contents, mapped to its path from `root`. */ +export function fixturesByContent(root: string, dir: string): Map { + const byText = new Map(); + const walk = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const full = join(current, entry.name); + if (entry.isDirectory()) walk(full); + else byText.set(readFileSync(full, "utf8"), relative(root, full)); + } + }; + walk(dir); + return byText; +} + +/** + * The package names a document tells a reader to install: the first non-flag argument of every + * `npm install`, `npm i`, `pnpm add`, `yarn add` or `bun add`, with any `@version` suffix removed. + * A specifier ends at whitespace, a quote or a backtick, so an inline-code command reads cleanly. + */ +export function installSpecifiers(markdown: string): string[] { + const out: string[] = []; + const command = + /\b(?:npm (?:install|i)|pnpm add|yarn add|bun add)((?:[ \t]+-{1,2}[\w-]+)*)[ \t]+([^\s`'"]+)/g; + for (const match of markdown.matchAll(command)) { + const spec = match[2]; + if (spec === undefined) continue; + const versionAt = spec.indexOf("@", 1); + out.push(versionAt === -1 ? spec : spec.slice(0, versionAt)); + } + return out; +} diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index 1862c57..a753eca 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -1,10 +1,22 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { join, relative } from "node:path"; -import { beforeAll, describe, expect, test } from "vitest"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { docSnippetSuite } from "@cosyte/vitest-config/snippets"; +import { + docSnippetSuite, + extractRunnableSnippets, + runSnippet, +} from "@cosyte/vitest-config/snippets"; + +import { + documentLiterals, + fences, + fixturesByContent, + runnableTaggedFences, + section, +} from "./_helpers/first-use.js"; /** * Doc/code-agreement gate. Every ```` ```ts runnable ```` block in `docs-content/` **and in @@ -74,3 +86,134 @@ describe("README status section", () => { expect(status).toContain(`\`${version ?? ""}\``); }); }); + +/** + * The two FIRST-USE examples, the quickstart's first block and the first block under the README's + * `## Usage`, are the ones a reader runs first. Each must be the block a sweep above executes, each + * is run again here with a changed value to prove the run can go red, and every C-CDA document + * literal either one prints must be a byte-for-byte copy of a committed fixture under + * `test/__fixtures__`, which `pnpm phi-scan` reads as a document. Temp modules for these runs live + * in their own directory inside the root, as the harness requires, and are removed when the file is + * done. + */ +const resolveEntry = (specifier: string): string | undefined => + specifier === "@cosyte/ccda" ? ENTRY : undefined; +const FIRST_USE_TMP = join(root, ".cosyte-first-use-snippets"); +const FIXTURE_DIR = join(root, "test", "__fixtures__"); +const QUICKSTART_TEXT = readFileSync(join(root, "docs-content", "quickstart.md"), "utf8"); +const README_TEXT = readFileSync(README, "utf8"); +const FIRST_USE = [ + { + doc: "docs-content/quickstart.md", + fence: fences(QUICKSTART_TEXT)[0], + snippets: extractRunnableSnippets(QUICKSTART_TEXT), + claim: 'doc.getMrn(); // => "MRN-00042"', + claimMutated: 'doc.getMrn(); // => "MRN-00043"', + }, + { + doc: "README.md", + fence: fences(section(README_TEXT, "## Usage"))[0], + snippets: extractRunnableSnippets(README_TEXT), + claim: 'doc.getMrn(); // => "MRN001"', + claimMutated: 'doc.getMrn(); // => "MRN002"', + }, +] as const; + +afterAll(() => { + rmSync(FIRST_USE_TMP, { recursive: true, force: true }); +}); + +describe("the first-use examples", () => { + for (const example of FIRST_USE) { + const acRun = example.doc === "README.md" ? "AC-CC2" : "AC-CC1"; + + test(`${acRun}: the first block of ${example.doc} is one the sweep executes, and it runs`, async () => { + expect(example.fence?.lang).toBe("ts"); + expect(example.fence?.tags).toContain("runnable"); + expect(example.fence?.tags).not.toContain("throws"); + const executed = example.snippets.find((s) => s.code === example.fence?.body); + expect( + executed, + `${example.doc}: the first block is not among the executed ones`, + ).toBeDefined(); + if (executed === undefined) return; + await runSnippet(executed, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }); + }); + + test(`AC-CC4: a changed claimed value in the first block of ${example.doc} turns it red`, async () => { + const code = example.fence?.body ?? ""; + expect(code.split(example.claim).length - 1).toBe(1); + const mutated = code.replace(example.claim, example.claimMutated); + await expect( + runSnippet(mutated, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }), + ).rejects.toThrow(); + }); + + test(`AC-CC5: every document literal in the first block of ${example.doc} is a committed fixture`, () => { + const fixtures = fixturesByContent(root, FIXTURE_DIR); + for (const literal of documentLiterals(example.fence?.body ?? "")) { + expect( + fixtures.get(literal), + `${example.doc}: a document literal is no fixture`, + ).toBeDefined(); + } + }); + } + + test("AC-CC5: the quickstart's first block reads exactly one document, and a changed value leaves the corpus", async () => { + const code = FIRST_USE[0].fence?.body ?? ""; + expect(documentLiterals(code)).toHaveLength(1); + expect(code.split('extension="MRN-00042"').length - 1).toBe(1); + const mutated = code.replace('extension="MRN-00042"', 'extension="MRN-00043"'); + const fixtures = fixturesByContent(root, FIXTURE_DIR); + expect(fixtures.get(documentLiterals(mutated)[0] ?? "")).toBeUndefined(); + await expect( + runSnippet(mutated, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }), + ).rejects.toThrow(); + }); +}); + +/** + * A fence tagged `runnable` is a claim that something executes it. The two sweeps above execute + * every TypeScript block so tagged in `docs-content/` and `README.md`; a block tagged `runnable` in + * any other language is extracted by nothing, so it is named here instead of passing silently. + */ +function markdownUnder(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...markdownUnder(full)); + else if (/\.mdx?$/i.test(entry.name)) out.push(full); + } + return out; +} + +/** `file:line` for every runnable-tagged fence in `markdown` that the snippet harness does not extract. */ +function unexecutedRunnableFences(file: string, markdown: string): string[] { + const tagged = runnableTaggedFences(markdown); + const unexecuted = tagged + .filter((f) => !["ts", "typescript", "tsx"].includes(f.lang.toLowerCase())) + .map((f) => `${file}:${String(f.line)}`); + if (unexecuted.length === 0 && tagged.length !== extractRunnableSnippets(markdown).length) { + unexecuted.push( + `${file}: tagged ${String(tagged.length)}, extracted ${String(extractRunnableSnippets(markdown).length)}`, + ); + } + return unexecuted; +} + +describe("a runnable tag is an execution that happens", () => { + test("AC-CC3: every runnable-tagged block in README.md and docs-content/ is executed", () => { + const files = [README, ...markdownUnder(join(root, "docs-content"))]; + const unexecuted = files.flatMap((file) => + unexecutedRunnableFences(relative(root, file), readFileSync(file, "utf8")), + ); + expect(unexecuted).toEqual([]); + }); + + test("AC-CC3: a runnable-tagged block no sweep executes is named by file and line", () => { + const fence = "```"; + const planted = `# Page\n\n${fence}js runnable\nconst x = 1;\n${fence}\n\n${fence}ts runnable\nconst y = 2;\n${fence}\n`; + expect(unexecutedRunnableFences("planted.md", planted)).toEqual(["planted.md:3"]); + }); +}); diff --git a/test/install-specifier.test.ts b/test/install-specifier.test.ts new file mode 100644 index 0000000..21a0a5e --- /dev/null +++ b/test/install-specifier.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { installSpecifiers } from "./_helpers/first-use.js"; + +/** + * The install command a reader copies has to fetch THIS package. The subject is package identity: + * each specifier the two first-use documents print is compared with `package.json` `name`, and a + * mismatch names both strings. + */ +const root = join(import.meta.dirname, ".."); +const { name } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name: string }; + +describe("the documented install specifier", () => { + for (const doc of ["docs-content/installation.md", "README.md"]) { + it(`AC-CC7: every install command in ${doc} names package.json name`, () => { + const specs = installSpecifiers(readFileSync(join(root, doc), "utf8")); + expect(specs.length, `${doc} prints no install command`).toBeGreaterThan(0); + for (const spec of specs) { + expect(spec, `${doc} installs "${spec}", package.json name is "${name}"`).toBe(name); + } + }); + } + + it("AC-CC7: a specifier that is not the package name is read as the name it prints", () => { + expect(installSpecifiers("npm install @cosyte/ccdb\n`pnpm add -D @cosyte/ccda@0.0.1`")).toEqual( + ["@cosyte/ccdb", "@cosyte/ccda"], + ); + }); +}); From 9312da92aaa1de0b4cb4b5beb8713b9f1eb2e011 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Wed, 23 Sep 2026 17:12:28 +0000 Subject: [PATCH 2/4] S0329-parser-first-use-path: embed the quickstart CCD in a .ts fixture, not a tracked .xml CI refused the committed test/__fixtures__/first-use/quickstart-ccd.xml: artifact-provenance asserts no XML document is ever tracked, because this repository keeps its C-CDA fixtures as XML embedded in test/__fixtures__/*.ts (which pnpm phi-scan reads). The quickstart CCD now lives there, as QUICKSTART_CCD in test/__fixtures__/first-use.ts, and the suite checks each first-use document literal against the embedded fixtures by value. --- .../quickstart-ccd.xml => first-use.ts} | 10 ++++++-- test/_helpers/first-use.ts | 17 ------------- test/docs-content.test.ts | 24 +++++++------------ 3 files changed, 17 insertions(+), 34 deletions(-) rename test/__fixtures__/{first-use/quickstart-ccd.xml => first-use.ts} (94%) diff --git a/test/__fixtures__/first-use/quickstart-ccd.xml b/test/__fixtures__/first-use.ts similarity index 94% rename from test/__fixtures__/first-use/quickstart-ccd.xml rename to test/__fixtures__/first-use.ts index 14a4649..88fda4f 100644 --- a/test/__fixtures__/first-use/quickstart-ccd.xml +++ b/test/__fixtures__/first-use.ts @@ -1,4 +1,10 @@ - +/** + * The synthetic CCD the quickstart's first example parses, byte for byte. Every value is invented + * (the canonical synthetic patient "Jane Doe", fake OIDs and identifiers). It lives here, embedded + * in a `.ts` module like every other fixture in this repository, because no XML document is ever + * tracked as a file, and `pnpm phi-scan` reads the XML embedded under `test/__fixtures__/`. + */ +export const QUICKSTART_CCD = ` @@ -140,4 +146,4 @@ - \ No newline at end of file +`; diff --git a/test/_helpers/first-use.ts b/test/_helpers/first-use.ts index 05a5edc..9b69d50 100644 --- a/test/_helpers/first-use.ts +++ b/test/_helpers/first-use.ts @@ -1,6 +1,3 @@ -import { readFileSync, readdirSync } from "node:fs"; -import { join, relative } from "node:path"; - /** * Readers for the package's first-use path: the first fenced block of * `docs-content/quickstart.md`, the first fenced block under `## Usage` in `README.md`, and the @@ -88,20 +85,6 @@ export function runnableTaggedFences(markdown: string): { line: number; lang: st return out; } -/** Every file under `dir`, keyed by its exact contents, mapped to its path from `root`. */ -export function fixturesByContent(root: string, dir: string): Map { - const byText = new Map(); - const walk = (current: string): void => { - for (const entry of readdirSync(current, { withFileTypes: true })) { - const full = join(current, entry.name); - if (entry.isDirectory()) walk(full); - else byText.set(readFileSync(full, "utf8"), relative(root, full)); - } - }; - walk(dir); - return byText; -} - /** * The package names a document tells a reader to install: the first non-flag argument of every * `npm install`, `npm i`, `pnpm add`, `yarn add` or `bun add`, with any `@version` suffix removed. diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index a753eca..5a4d10d 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -10,13 +10,8 @@ import { runSnippet, } from "@cosyte/vitest-config/snippets"; -import { - documentLiterals, - fences, - fixturesByContent, - runnableTaggedFences, - section, -} from "./_helpers/first-use.js"; +import { documentLiterals, fences, runnableTaggedFences, section } from "./_helpers/first-use.js"; +import * as firstUseFixtures from "./__fixtures__/first-use.js"; /** * Doc/code-agreement gate. Every ```` ```ts runnable ```` block in `docs-content/` **and in @@ -91,15 +86,16 @@ describe("README status section", () => { * The two FIRST-USE examples, the quickstart's first block and the first block under the README's * `## Usage`, are the ones a reader runs first. Each must be the block a sweep above executes, each * is run again here with a changed value to prove the run can go red, and every C-CDA document - * literal either one prints must be a byte-for-byte copy of a committed fixture under - * `test/__fixtures__`, which `pnpm phi-scan` reads as a document. Temp modules for these runs live + * literal either one prints must be a byte-for-byte copy of a fixture embedded in + * `test/__fixtures__/first-use.ts`, whose XML `pnpm phi-scan` reads. Temp modules for these runs live * in their own directory inside the root, as the harness requires, and are removed when the file is * done. */ const resolveEntry = (specifier: string): string | undefined => specifier === "@cosyte/ccda" ? ENTRY : undefined; const FIRST_USE_TMP = join(root, ".cosyte-first-use-snippets"); -const FIXTURE_DIR = join(root, "test", "__fixtures__"); +/** The embedded first-use fixtures, by value: a document literal must be exactly one of them. */ +const FIRST_USE_FIXTURES: ReadonlySet = new Set(Object.values(firstUseFixtures)); const QUICKSTART_TEXT = readFileSync(join(root, "docs-content", "quickstart.md"), "utf8"); const README_TEXT = readFileSync(README, "utf8"); const FIRST_USE = [ @@ -150,12 +146,11 @@ describe("the first-use examples", () => { }); test(`AC-CC5: every document literal in the first block of ${example.doc} is a committed fixture`, () => { - const fixtures = fixturesByContent(root, FIXTURE_DIR); for (const literal of documentLiterals(example.fence?.body ?? "")) { expect( - fixtures.get(literal), + FIRST_USE_FIXTURES.has(literal), `${example.doc}: a document literal is no fixture`, - ).toBeDefined(); + ).toBe(true); } }); } @@ -165,8 +160,7 @@ describe("the first-use examples", () => { expect(documentLiterals(code)).toHaveLength(1); expect(code.split('extension="MRN-00042"').length - 1).toBe(1); const mutated = code.replace('extension="MRN-00042"', 'extension="MRN-00043"'); - const fixtures = fixturesByContent(root, FIXTURE_DIR); - expect(fixtures.get(documentLiterals(mutated)[0] ?? "")).toBeUndefined(); + expect(FIRST_USE_FIXTURES.has(documentLiterals(mutated)[0] ?? "")).toBe(false); await expect( runSnippet(mutated, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }), ).rejects.toThrow(); From 57ad91fe43293d6bb36584eba6cd1e08b1661dd4 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Thu, 24 Sep 2026 04:37:25 +0000 Subject: [PATCH 3/4] S0329-parser-first-use-path: compile both first examples as a reader's new TypeScript project does The snippet harness strips types without checking them, so pnpm test would stay green over a first-use example that does not compile. Both first examples now compile with the options tsc --init writes for a new project (strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax, NodeNext), against src/index.ts, and the suite fails on any error the example itself carries. Both compile today; each carries a control that breaks its types and must be reported. The install-specifier reader also reads pnpm i, pnpm install, npm add and deno add npm:, stops a specifier at a full stop and skips a protocol specifier, so no printed form passes vacuously. --- test/_helpers/first-use.ts | 80 +++++++++++++++++++++++++++++++--- test/docs-content.test.ts | 37 +++++++++++++++- test/install-specifier.test.ts | 16 +++++++ 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/test/_helpers/first-use.ts b/test/_helpers/first-use.ts index 9b69d50..6692efb 100644 --- a/test/_helpers/first-use.ts +++ b/test/_helpers/first-use.ts @@ -1,3 +1,7 @@ +import { join } from "node:path"; + +import ts from "typescript"; + /** * Readers for the package's first-use path: the first fenced block of * `docs-content/quickstart.md`, the first fenced block under `## Usage` in `README.md`, and the @@ -5,6 +9,69 @@ * on disk at test time, never on a copy held here. */ +/** + * The compiler options of a new project, as `tsc --init` of the TypeScript this repository pins + * writes them: `strict` plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and + * `verbatimModuleSyntax`, NodeNext modules. The emit-only settings it also writes (`sourceMap`, + * `declaration`, `declarationMap`, `jsx`) are left out because nothing is emitted, and Node's + * types are loaded where `tsc --init` writes `types: []`, because every first-use example is a + * Node program. + */ +const READER_OPTIONS: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ESNext, + strict: true, + noUncheckedIndexedAccess: true, + exactOptionalPropertyTypes: true, + verbatimModuleSyntax: true, + isolatedModules: true, + noUncheckedSideEffectImports: true, + moduleDetection: ts.ModuleDetectionKind.Force, + skipLibCheck: true, + noEmit: true, + types: ["node"], +}; + +/** + * Every TypeScript error a reader's new project reports for one example, as `line N: TSxxxx + * message`; empty when the example compiles. The example is compiled as an ES module sitting in + * the repository root, with each specifier in `paths` resolved to the single source file the + * bundler compiles into that published entry point and its types. Only the example's own errors + * are returned. + */ +export function compileErrors( + root: string, + paths: Readonly>, + code: string, +): string[] { + const file = join(root, "first-use-example.mts"); + const options: ts.CompilerOptions = { + ...READER_OPTIONS, + typeRoots: [join(root, "node_modules", "@types")], + paths: Object.fromEntries(Object.entries(paths).map(([pkg, entry]) => [pkg, [entry]])), + }; + const host = ts.createCompilerHost(options, true); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (name, language, onError, create) => + name === file + ? ts.createSourceFile(name, code, language, true) + : getSourceFile(name, language, onError, create); + const fileExists = host.fileExists.bind(host); + host.fileExists = (name) => name === file || fileExists(name); + const readFile = host.readFile.bind(host); + host.readFile = (name) => (name === file ? code : readFile(name)); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program, program.getSourceFile(file)) + .filter((d) => d.file?.fileName === file) + .map((d) => { + const line = d.file?.getLineAndCharacterOfPosition(d.start ?? 0).line ?? 0; + const text = ts.flattenDiagnosticMessageText(d.messageText, " "); + return `line ${String(line + 1)}: TS${String(d.code)} ${text}`; + }); +} + /** One fenced code block: its info string split into language and tags, and its body. */ export interface Fence { readonly lang: string; @@ -87,18 +154,19 @@ export function runnableTaggedFences(markdown: string): { line: number; lang: st /** * The package names a document tells a reader to install: the first non-flag argument of every - * `npm install`, `npm i`, `pnpm add`, `yarn add` or `bun add`, with any `@version` suffix removed. - * A specifier ends at whitespace, a quote or a backtick, so an inline-code command reads cleanly. + * `npm install`, `npm i`, `npm add`, `pnpm add`, `pnpm install`, `pnpm i`, `yarn add`, `bun add` + * or `deno add npm:`, read as a package name, so any `@version` suffix, closing quote or backtick, + * or sentence-ending full stop is left off. A local path or a protocol specifier (`file:../pkg`, + * `link:`, `git+https:`) installs whatever that path or URL holds rather than resolving a package + * name, so it is not a registry specifier and is not read. */ export function installSpecifiers(markdown: string): string[] { const out: string[] = []; const command = - /\b(?:npm (?:install|i)|pnpm add|yarn add|bun add)((?:[ \t]+-{1,2}[\w-]+)*)[ \t]+([^\s`'"]+)/g; + /\b(?:npm (?:install|add|i)|pnpm (?:add|install|i)|yarn add|bun add|deno add)((?:[ \t]+-{1,2}[\w-]+)*)[ \t]+(?:npm:)?(@?\w[\w.-]*(?:\/[\w.-]+)?)(?![\w.:/+-])/g; for (const match of markdown.matchAll(command)) { const spec = match[2]; - if (spec === undefined) continue; - const versionAt = spec.indexOf("@", 1); - out.push(versionAt === -1 ? spec : spec.slice(0, versionAt)); + if (spec !== undefined) out.push(spec.replace(/\.+$/, "")); } return out; } diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index 5a4d10d..d9015ef 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -10,7 +10,13 @@ import { runSnippet, } from "@cosyte/vitest-config/snippets"; -import { documentLiterals, fences, runnableTaggedFences, section } from "./_helpers/first-use.js"; +import { + compileErrors, + documentLiterals, + fences, + runnableTaggedFences, + section, +} from "./_helpers/first-use.js"; import * as firstUseFixtures from "./__fixtures__/first-use.js"; /** @@ -94,6 +100,14 @@ describe("README status section", () => { const resolveEntry = (specifier: string): string | undefined => specifier === "@cosyte/ccda" ? ENTRY : undefined; const FIRST_USE_TMP = join(root, ".cosyte-first-use-snippets"); +/** + * The snippet harness strips types without checking them, so compiling is checked separately, the + * way a reader's new TypeScript project compiles the block, against the source entry point the + * bundler compiles into the published types. A program over the source takes seconds to check, so + * these cases state their own budget. + */ +const SOURCE_PATHS = { "@cosyte/ccda": join(root, "src", "index.ts") }; +const COMPILE_TIMEOUT = 60_000; /** The embedded first-use fixtures, by value: a document literal must be exactly one of them. */ const FIRST_USE_FIXTURES: ReadonlySet = new Set(Object.values(firstUseFixtures)); const QUICKSTART_TEXT = readFileSync(join(root, "docs-content", "quickstart.md"), "utf8"); @@ -136,6 +150,27 @@ describe("the first-use examples", () => { await runSnippet(executed, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }); }); + test( + `${acRun}: the first block of ${example.doc} compiles in a new TypeScript project`, + () => { + expect(compileErrors(root, SOURCE_PATHS, example.fence?.body ?? "")).toEqual([]); + }, + COMPILE_TIMEOUT, + ); + + test( + `${acRun}: a first block of ${example.doc} that does not compile is reported, so it turns this suite red`, + () => { + const code = example.fence?.body ?? ""; + expect(code.split(example.claim).length - 1).toBe(1); + const broken = example.claim.replace("doc.getMrn()", "doc.getMrnn()"); + expect(compileErrors(root, SOURCE_PATHS, code.replace(example.claim, broken))).toEqual([ + expect.stringMatching(/TS2551|TS2339/), + ]); + }, + COMPILE_TIMEOUT, + ); + test(`AC-CC4: a changed claimed value in the first block of ${example.doc} turns it red`, async () => { const code = example.fence?.body ?? ""; expect(code.split(example.claim).length - 1).toBe(1); diff --git a/test/install-specifier.test.ts b/test/install-specifier.test.ts index 21a0a5e..9c6e7e5 100644 --- a/test/install-specifier.test.ts +++ b/test/install-specifier.test.ts @@ -29,4 +29,20 @@ describe("the documented install specifier", () => { ["@cosyte/ccdb", "@cosyte/ccda"], ); }); + + it("AC-CC7: every install command form a reader may copy is read, inline code included", () => { + const forms = [ + "pnpm i @cosyte/ccdb", + "pnpm install @cosyte/ccdb", + "npm add @cosyte/ccdb", + "deno add npm:@cosyte/ccdb", + "run `npm install @cosyte/ccdb` first", + "then run npm install @cosyte/ccdb.", + ]; + for (const form of forms) expect(installSpecifiers(form), form).toEqual(["@cosyte/ccdb"]); + expect(installSpecifiers("pnpm install\npnpm install --frozen-lockfile")).toEqual([]); + expect( + installSpecifiers("pnpm add file:../ccda\nnpm install git+https://x.test/ccda.git"), + ).toEqual([]); + }); }); From 5cf6572f77a90fbdc395be7c292881fa1b1db215 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Thu, 24 Sep 2026 04:47:51 +0000 Subject: [PATCH 4/4] S0329-parser-first-use-path: an edited builder input in the README's first example turns the run red --- test/docs-content.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index d9015ef..8194623 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -190,6 +190,15 @@ describe("the first-use examples", () => { }); } + test("AC-CC4: a changed input value in the first block of README.md turns it red", async () => { + const code = FIRST_USE[1].fence?.body ?? ""; + expect(code.split('mrn: "MRN001"').length - 1).toBe(1); + const mutated = code.replace('mrn: "MRN001"', 'mrn: "MRN002"'); + await expect( + runSnippet(mutated, { resolve: resolveEntry, tmpDir: FIRST_USE_TMP }), + ).rejects.toThrow(); + }); + test("AC-CC5: the quickstart's first block reads exactly one document, and a changed value leaves the corpus", async () => { const code = FIRST_USE[0].fence?.body ?? ""; expect(documentLiterals(code)).toHaveLength(1);