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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions test/__fixtures__/first-use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* 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 = `<?xml version="1.0" encoding="UTF-8"?>
<ClinicalDocument xmlns="urn:hl7-org:v3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<realmCode code="US"/>
<templateId root="2.16.840.1.113883.10.20.22.1.1" extension="2015-08-01"/>
<templateId root="2.16.840.1.113883.10.20.22.1.2" extension="2015-08-01"/>
<id root="2.16.840.1.113883.19.5.99999.1" extension="DOC-0001"/>
<code code="34133-9" codeSystem="2.16.840.1.113883.6.1" displayName="Summarization of Episode Note"/>
<title>Synthetic Continuity of Care Document</title>
<effectiveTime value="20240101120000-0500"/>
<languageCode code="en-US"/>
<recordTarget><patientRole>
<id root="2.16.840.1.113883.19.5" extension="MRN-00042" assigningAuthorityName="Sample Hospital"/>
<patient>
<name><given>Jane</given><given>Q</given><family>Doe</family></name>
<administrativeGenderCode code="F" codeSystem="2.16.840.1.113883.5.1" displayName="Female"/>
<birthTime value="19800101"/>
</patient>
</patientRole></recordTarget>
<component><structuredBody>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.5.1" extension="2015-08-01"/>
<code code="11450-4" codeSystem="2.16.840.1.113883.6.1"/>
<title>Problems</title>
<text><content ID="prob1">Essential hypertension</content></text>
<entry><act classCode="ACT" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.3" extension="2015-08-01"/>
<statusCode code="active"/>
<entryRelationship typeCode="SUBJ"><observation classCode="OBS" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.4" extension="2015-08-01"/>
<code code="55607006" codeSystem="2.16.840.1.113883.6.96"/>
<value xsi:type="CD" code="59621000" codeSystem="2.16.840.1.113883.6.96" displayName="Essential hypertension"/>
<text><reference value="#prob1"/></text>
</observation></entryRelationship>
</act></entry>
</section></component>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.1.1" extension="2014-06-09"/>
<code code="10160-0" codeSystem="2.16.840.1.113883.6.1"/>
<title>Medications</title>
<text><content ID="med1">Lisinopril 10 MG Oral Tablet</content></text>
<entry><substanceAdministration classCode="SBADM" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.16" extension="2014-06-09"/>
<statusCode code="active"/>
<routeCode code="C38288" codeSystem="2.16.840.1.113883.3.26.1.1" displayName="Oral"/>
<doseQuantity value="10" unit="mg"/>
<consumable><manufacturedProduct classCode="MANU">
<templateId root="2.16.840.1.113883.10.20.22.4.23" extension="2014-06-09"/>
<manufacturedMaterial>
<code code="314076" codeSystem="2.16.840.1.113883.6.88" displayName="Lisinopril 10 MG Oral Tablet"/>
</manufacturedMaterial>
</manufacturedProduct></consumable>
<text><reference value="#med1"/></text>
</substanceAdministration></entry>
</section></component>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.6.1" extension="2015-08-01"/>
<code code="48765-2" codeSystem="2.16.840.1.113883.6.1"/>
<title>Allergies</title>
<text>No known allergies.</text>
<entry><act classCode="ACT" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.30" extension="2015-08-01"/>
<statusCode code="active"/>
<entryRelationship typeCode="SUBJ"><observation classCode="OBS" moodCode="EVN" negationInd="true">
<templateId root="2.16.840.1.113883.10.20.22.4.7" extension="2014-06-09"/>
<code code="ASSERTION" codeSystem="2.16.840.1.113883.5.4"/>
<value xsi:type="CD" code="419199007" codeSystem="2.16.840.1.113883.6.96" displayName="Allergy to substance"/>
</observation></entryRelationship>
</act></entry>
</section></component>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.3.1" extension="2015-08-01"/>
<code code="30954-2" codeSystem="2.16.840.1.113883.6.1"/>
<title>Results</title>
<text><content ID="res1">Hemoglobin 13.5 g/dL</content></text>
<entry><organizer classCode="BATTERY" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.1" extension="2015-08-01"/>
<code code="58410-2" codeSystem="2.16.840.1.113883.6.1" displayName="CBC panel"/>
<statusCode code="completed"/>
<component><observation classCode="OBS" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.2" extension="2015-08-01"/>
<code code="718-7" codeSystem="2.16.840.1.113883.6.1" displayName="Hemoglobin"/>
<statusCode code="completed"/>
<value xsi:type="PQ" value="13.5" unit="g/dL"/>
<interpretationCode code="N" codeSystem="2.16.840.1.113883.5.83"/>
</observation></component>
</organizer></entry>
</section></component>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.4" extension="2015-08-01"/>
<templateId root="2.16.840.1.113883.10.20.22.2.4.1" extension="2015-08-01"/>
<code code="8716-3" codeSystem="2.16.840.1.113883.6.1"/>
<title>Vital Signs</title>
<text><content ID="vit1">Heart rate: 72 /min</content></text>
<entry><organizer classCode="CLUSTER" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.26" extension="2015-08-01"/>
<code code="46680005" codeSystem="2.16.840.1.113883.6.96" displayName="Vital signs"/>
<statusCode code="completed"/>
<effectiveTime value="20240101120000-0500"/>
<component><observation classCode="OBS" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.27" extension="2014-06-09"/>
<code code="8867-4" codeSystem="2.16.840.1.113883.6.1" displayName="Heart rate"/>
<text><reference value="#vit1"/></text>
<statusCode code="completed"/>
<effectiveTime value="20240101120000-0500"/>
<value xsi:type="PQ" value="72" unit="/min"/>
</observation></component>
</organizer></entry>
</section></component>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.17" extension="2015-08-01"/>
<code code="29762-2" codeSystem="2.16.840.1.113883.6.1"/>
<title>Social History</title>
<text><content ID="soc1">Never smoker</content></text>
<entry><observation classCode="OBS" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.78" extension="2014-06-09"/>
<code code="72166-2" codeSystem="2.16.840.1.113883.6.1" displayName="Tobacco smoking status"/>
<text><reference value="#soc1"/></text>
<statusCode code="completed"/>
<effectiveTime value="20240101"/>
<value xsi:type="CD" code="266919005" codeSystem="2.16.840.1.113883.6.96" displayName="Never smoker"/>
</observation></entry>
</section></component>
<component><section>
<templateId root="2.16.840.1.113883.10.20.22.2.2.1" extension="2015-08-01"/>
<code code="11369-6" codeSystem="2.16.840.1.113883.6.1"/>
<title>Immunizations</title>
<text><content ID="imm1">Influenza, split virus, trivalent, injectable, preservative free</content></text>
<entry><substanceAdministration classCode="SBADM" moodCode="EVN">
<templateId root="2.16.840.1.113883.10.20.22.4.52" extension="2015-08-01"/>
<statusCode code="completed"/>
<effectiveTime value="20240101"/>
<doseQuantity value="0.5" unit="mL"/>
<consumable><manufacturedProduct classCode="MANU">
<templateId root="2.16.840.1.113883.10.20.22.4.54" extension="2014-06-09"/>
<manufacturedMaterial>
<code code="140" codeSystem="2.16.840.1.113883.12.292" displayName="Influenza, split virus, trivalent, injectable, preservative free"/>
</manufacturedMaterial>
</manufacturedProduct></consumable>
<text><reference value="#imm1"/></text>
</substanceAdministration></entry>
</section></component>
</structuredBody></component>
</ClinicalDocument>`;
172 changes: 172 additions & 0 deletions test/_helpers/first-use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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
* 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.
*/

/**
* 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<Record<string, string>>,
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;
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(/`([^`]*<ClinicalDocument\b[^`]*)`/g)) {
const literal = match[1];
if (literal !== undefined) literals.push(literal);
}
return literals;
}

/**
* Every fenced block whose info string carries the `runnable` tag, with the 1-based line of its
* opening fence and its language token. A fence may be indented (inside a list item), and closes on
* the same marker at the same indentation, which is how the snippet harness reads it too.
*/
export function runnableTaggedFences(markdown: string): { line: number; lang: string }[] {
const out: { line: number; lang: string }[] = [];
let open: string | undefined;
markdown.split("\n").forEach((text, index) => {
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;
}

/**
* The package names a document tells a reader to install: the first non-flag argument of every
* `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|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) out.push(spec.replace(/\.+$/, ""));
}
return out;
}
Loading
Loading