@forge-ts/doctest 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +34 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ interface ExtractedExample {
|
|
|
23
23
|
*
|
|
24
24
|
* @param symbols - The symbols produced by the core AST walker.
|
|
25
25
|
* @returns A flat array of {@link ExtractedExample} objects, one per code block.
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { createWalker, loadConfig } from "@forge-ts/core";
|
|
29
|
+
* import { extractExamples } from "@forge-ts/doctest";
|
|
30
|
+
* const config = await loadConfig();
|
|
31
|
+
* const symbols = createWalker(config).walk();
|
|
32
|
+
* const examples = extractExamples(symbols);
|
|
33
|
+
* console.log(`Found ${examples.length} examples`);
|
|
34
|
+
* ```
|
|
26
35
|
* @public
|
|
27
36
|
*/
|
|
28
37
|
declare function extractExamples(symbols: ForgeSymbol[]): ExtractedExample[];
|
|
@@ -56,6 +65,12 @@ interface VirtualTestFile {
|
|
|
56
65
|
* @param examples - Examples to include in the generated file.
|
|
57
66
|
* @param options - Output configuration.
|
|
58
67
|
* @returns An array of {@link VirtualTestFile} objects (one per source file).
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* import { generateTestFiles } from "@forge-ts/doctest";
|
|
71
|
+
* const files = generateTestFiles(examples, { cacheDir: "/tmp/doctest-cache" });
|
|
72
|
+
* console.log(`Generated ${files.length} test file(s)`);
|
|
73
|
+
* ```
|
|
59
74
|
* @public
|
|
60
75
|
*/
|
|
61
76
|
declare function generateTestFiles(examples: ExtractedExample[], options: GeneratorOptions): VirtualTestFile[];
|
|
@@ -94,6 +109,14 @@ interface TestCaseResult {
|
|
|
94
109
|
*
|
|
95
110
|
* @param files - The virtual test files to write and run.
|
|
96
111
|
* @returns A {@link RunResult} summarising the test outcome.
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* import { runTests } from "@forge-ts/doctest";
|
|
115
|
+
* const result = await runTests(virtualFiles);
|
|
116
|
+
* if (!result.success) {
|
|
117
|
+
* console.error(`${result.failed} doctest(s) failed`);
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
97
120
|
* @public
|
|
98
121
|
*/
|
|
99
122
|
declare function runTests(files: VirtualTestFile[]): Promise<RunResult>;
|
|
@@ -105,7 +128,6 @@ declare function runTests(files: VirtualTestFile[]): Promise<RunResult>;
|
|
|
105
128
|
* generates virtual `node:test` test files, and executes them.
|
|
106
129
|
*
|
|
107
130
|
* @packageDocumentation
|
|
108
|
-
* @public
|
|
109
131
|
*/
|
|
110
132
|
|
|
111
133
|
/**
|
|
@@ -113,6 +135,17 @@ declare function runTests(files: VirtualTestFile[]): Promise<RunResult>;
|
|
|
113
135
|
*
|
|
114
136
|
* @param config - The resolved {@link ForgeConfig} for the project.
|
|
115
137
|
* @returns A {@link ForgeResult} with success/failure and any diagnostics.
|
|
138
|
+
* @example
|
|
139
|
+
* ```typescript
|
|
140
|
+
* import { loadConfig } from "@forge-ts/core";
|
|
141
|
+
* import { doctest } from "@forge-ts/doctest";
|
|
142
|
+
* const config = await loadConfig();
|
|
143
|
+
* const result = await doctest(config);
|
|
144
|
+
* if (!result.success) {
|
|
145
|
+
* console.error(`${result.errors.length} doctest failure(s)`);
|
|
146
|
+
* }
|
|
147
|
+
* ```
|
|
148
|
+
* @packageDocumentation
|
|
116
149
|
* @public
|
|
117
150
|
*/
|
|
118
151
|
declare function doctest(config: ForgeConfig): Promise<ForgeResult>;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/extractor.ts","../src/generator.ts","../src/runner.ts","../src/index.ts"],"sourcesContent":["import type { ForgeSymbol } from \"@forge-ts/core\";\n\n/**\n * A single extracted `@example` block ready for test generation.\n * @public\n */\nexport interface ExtractedExample {\n\t/** The symbol this example belongs to. */\n\tsymbolName: string;\n\t/** Absolute path to the source file. */\n\tfilePath: string;\n\t/** 1-based line number of the `@example` tag. */\n\tline: number;\n\t/** The raw code inside the fenced block. */\n\tcode: string;\n\t/** The language identifier (e.g. `\"typescript\"`). */\n\tlanguage: string;\n\t/** Sequential index among examples for this symbol. */\n\tindex: number;\n}\n\n/**\n * Extracts all `@example` blocks from a list of {@link ForgeSymbol} objects.\n *\n * @param symbols - The symbols produced by the core AST walker.\n * @returns A flat array of {@link ExtractedExample} objects, one per code block.\n * @public\n */\nexport function extractExamples(symbols: ForgeSymbol[]): ExtractedExample[] {\n\tconst results: ExtractedExample[] = [];\n\n\tfor (const symbol of symbols) {\n\t\tconst examples = symbol.documentation?.examples ?? [];\n\t\tfor (let i = 0; i < examples.length; i++) {\n\t\t\tconst ex = examples[i];\n\t\t\tresults.push({\n\t\t\t\tsymbolName: symbol.name,\n\t\t\t\tfilePath: symbol.filePath,\n\t\t\t\tline: ex.line,\n\t\t\t\tcode: ex.code,\n\t\t\t\tlanguage: ex.language,\n\t\t\t\tindex: i,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn results;\n}\n","import { basename, relative } from \"node:path\";\nimport type { ExtractedExample } from \"./extractor.js\";\n\n/**\n * Options for virtual test file generation.\n * @public\n */\nexport interface GeneratorOptions {\n\t/** Directory where virtual test files will be written. */\n\tcacheDir: string;\n}\n\n/**\n * A generated virtual test file.\n * @public\n */\nexport interface VirtualTestFile {\n\t/** Absolute path where the file will be written. */\n\tpath: string;\n\t/** File contents (valid TypeScript). */\n\tcontent: string;\n}\n\n/**\n * Converts `// => value` comment patterns in example code to `assert.strictEqual` calls.\n *\n * @param code - Raw example code from the TSDoc block.\n * @returns Code with assertion comments replaced by actual assertion calls.\n * @internal\n */\nfunction processAssertions(code: string): string {\n\treturn code\n\t\t.split(\"\\n\")\n\t\t.map((line) => {\n\t\t\t// Match: expression // => expected\n\t\t\tconst arrowMatch = line.match(/^(\\s*)(.+?)\\s*\\/\\/\\s*=>\\s*(.+)$/);\n\t\t\tif (arrowMatch) {\n\t\t\t\tconst [, indent, expr, expected] = arrowMatch;\n\t\t\t\treturn `${indent}assert.strictEqual(${expr.trim()}, ${expected.trim()});`;\n\t\t\t}\n\t\t\treturn line;\n\t\t})\n\t\t.join(\"\\n\");\n}\n\n/**\n * Builds a base64-encoded inline source map that maps generated test file lines\n * back to the original TSDoc `@example` block in the source file.\n *\n * @param generatedFile - Absolute path of the generated test file.\n * @param examples - Examples contained in this file, each carrying its source location.\n * @param lineMap - Array mapping each generated line index (0-based) to its original line.\n * @returns A `//# sourceMappingURL=data:...` comment string.\n * @internal\n */\nfunction buildInlineSourceMap(\n\tgeneratedFile: string,\n\texamples: ExtractedExample[],\n\tlineMap: Array<{ generatedLine: number; originalLine: number; sourceFile: string }>,\n): string {\n\t// Collect unique source files\n\tconst sources = [...new Set(examples.map((e) => e.filePath))];\n\n\t// Build mappings: each entry is [generatedLine, sourceIndex, originalLine, 0] (0-based)\n\t// VLQ encoding is complex; we generate a minimal valid source map with explicit mappings\n\tconst mappings: string[] = [];\n\tlet lastGenLine = 0;\n\n\tfor (const entry of lineMap) {\n\t\t// Fill gaps with empty mappings\n\t\twhile (lastGenLine < entry.generatedLine) {\n\t\t\tmappings.push(\";\");\n\t\t\tlastGenLine++;\n\t\t}\n\t\tconst sourceIdx = sources.indexOf(entry.sourceFile);\n\t\t// Each mapping segment: [genCol=0, srcIdx, srcLine(0-based), srcCol=0]\n\t\t// Encode as VLQ - use a simple approach with base64 VLQ\n\t\tconst seg = encodeVlqSegment(0, sourceIdx, entry.originalLine - 1, 0);\n\t\tmappings.push(seg);\n\t\tlastGenLine++;\n\t}\n\n\tconst sourceMap = {\n\t\tversion: 3,\n\t\tfile: basename(generatedFile),\n\t\tsources,\n\t\tsourcesContent: null,\n\t\tnames: [],\n\t\tmappings: mappings.join(\";\"),\n\t};\n\n\tconst encoded = Buffer.from(JSON.stringify(sourceMap)).toString(\"base64\");\n\treturn `//# sourceMappingURL=data:application/json;base64,${encoded}`;\n}\n\n/**\n * Encodes a single source map segment using Base64 VLQ encoding.\n * Each field is relative to the previous segment's value.\n * @internal\n */\nfunction encodeVlqSegment(...fields: number[]): string {\n\treturn fields.map(encodeVlq).join(\"\");\n}\n\n/** VLQ Base64 alphabet. @internal */\nconst VLQ_BASE64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n\n/**\n * Encodes a single signed integer as Base64 VLQ.\n * @internal\n */\nfunction encodeVlq(value: number): string {\n\t// Convert to unsigned VLQ (sign bit in LSB)\n\tlet vlq = value < 0 ? (-value << 1) | 1 : value << 1;\n\tlet result = \"\";\n\tdo {\n\t\tlet digit = vlq & 0x1f;\n\t\tvlq >>>= 5;\n\t\tif (vlq > 0) {\n\t\t\tdigit |= 0x20; // continuation bit\n\t\t}\n\t\tresult += VLQ_BASE64[digit];\n\t} while (vlq > 0);\n\treturn result;\n}\n\n/**\n * Generates a virtual test file for a set of extracted examples.\n *\n * Each example is wrapped in an `it()` block using the Node built-in\n * `node:test` runner so that no additional test framework is required.\n * Auto-imports the tested symbol from the source file, processes `// =>`\n * assertion patterns, and appends an inline source map.\n *\n * @param examples - Examples to include in the generated file.\n * @param options - Output configuration.\n * @returns An array of {@link VirtualTestFile} objects (one per source file).\n * @public\n */\nexport function generateTestFiles(\n\texamples: ExtractedExample[],\n\toptions: GeneratorOptions,\n): VirtualTestFile[] {\n\t// Group by source file\n\tconst byFile = new Map<string, ExtractedExample[]>();\n\tfor (const ex of examples) {\n\t\tconst group = byFile.get(ex.filePath) ?? [];\n\t\tgroup.push(ex);\n\t\tbyFile.set(ex.filePath, group);\n\t}\n\n\tconst files: VirtualTestFile[] = [];\n\n\tfor (const [sourcePath, exs] of byFile) {\n\t\tconst slug = sourcePath\n\t\t\t.replace(/[^a-zA-Z0-9]/g, \"_\")\n\t\t\t.replace(/_+/g, \"_\")\n\t\t\t.toLowerCase();\n\t\tconst testFilePath = `${options.cacheDir}/${slug}.test.ts`;\n\n\t\t// Compute relative import path from cacheDir to sourcePath,\n\t\t// replacing the extension with .js for ESM compatibility\n\t\tconst relPath = relative(options.cacheDir, sourcePath).replace(/\\.tsx?$/, \".js\");\n\t\tconst importPath = relPath.startsWith(\".\") ? relPath : `./${relPath}`;\n\n\t\t// Collect unique symbol names for the import\n\t\tconst symbolNames = [...new Set(exs.map((ex) => ex.symbolName))];\n\n\t\t// Track line numbers for source map\n\t\tconst lineMap: Array<{ generatedLine: number; originalLine: number; sourceFile: string }> = [];\n\n\t\t// Build it-blocks and track line positions\n\t\tconst lines: string[] = [];\n\n\t\t// Header lines (0-based index):\n\t\t// 0: // Auto-generated...\n\t\t// 1: // Source: ...\n\t\t// 2: import { describe, it } from \"node:test\";\n\t\t// 3: import assert from \"node:assert/strict\";\n\t\t// 4: import { symbolNames } from \"...\";\n\t\t// 5: (empty)\n\t\t// 6: describe(...) {\n\t\tlines.push(`// Auto-generated by @forge-ts/doctest — do not edit`);\n\t\tlines.push(`// Source: ${sourcePath}`);\n\t\tlines.push(`import { describe, it } from \"node:test\";`);\n\t\tlines.push(`import assert from \"node:assert/strict\";`);\n\t\tlines.push(`import { ${symbolNames.join(\", \")} } from \"${importPath}\";`);\n\t\tlines.push(``);\n\t\tlines.push(`describe(\"doctest: ${basename(sourcePath)}\", () => {`);\n\n\t\tfor (const ex of exs) {\n\t\t\tconst itLine = lines.length; // 0-based line index of the it() call\n\t\t\tlineMap.push({ generatedLine: itLine, originalLine: ex.line, sourceFile: ex.filePath });\n\n\t\t\tlines.push(\n\t\t\t\t`\\tit(\"${ex.symbolName} example ${ex.index + 1} (line ${ex.line})\", async () => {`,\n\t\t\t);\n\n\t\t\tconst processedCode = processAssertions(ex.code);\n\t\t\tfor (const codeLine of processedCode.split(\"\\n\")) {\n\t\t\t\tlines.push(`\\t\\t${codeLine}`);\n\t\t\t}\n\t\t\tlines.push(`\\t});`);\n\t\t}\n\n\t\tlines.push(`});`);\n\t\tlines.push(``);\n\n\t\t// Append inline source map\n\t\tconst sourceMapComment = buildInlineSourceMap(testFilePath, exs, lineMap);\n\t\tlines.push(sourceMapComment);\n\t\tlines.push(``);\n\n\t\tconst content = lines.join(\"\\n\");\n\t\tfiles.push({ path: testFilePath, content });\n\t}\n\n\treturn files;\n}\n","import { spawn } from \"node:child_process\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { basename, dirname } from \"node:path\";\nimport type { VirtualTestFile } from \"./generator.js\";\n\n/**\n * Result of running the generated test files.\n * @public\n */\nexport interface RunResult {\n\t/** Whether all tests passed. */\n\tsuccess: boolean;\n\t/** Number of tests that passed. */\n\tpassed: number;\n\t/** Number of tests that failed. */\n\tfailed: number;\n\t/** Combined stdout + stderr output from the test runner. */\n\toutput: string;\n\t/** Individual test results with name and status. */\n\ttests: TestCaseResult[];\n}\n\n/**\n * The result of a single test case.\n * @public\n */\nexport interface TestCaseResult {\n\t/** The full test name as reported by the runner. */\n\tname: string;\n\t/** Whether this test passed. */\n\tpassed: boolean;\n\t/** The source file this test was generated from, if determinable. */\n\tsourceFile?: string;\n}\n\n/**\n * Parses TAP output from `node --test` into structured results.\n *\n * @param output - The raw TAP text from the runner.\n * @returns An object with pass/fail counts and per-test results.\n * @internal\n */\nfunction parseTapOutput(output: string): {\n\tpassed: number;\n\tfailed: number;\n\ttests: TestCaseResult[];\n} {\n\tconst tests: TestCaseResult[] = [];\n\tlet passed = 0;\n\tlet failed = 0;\n\n\tfor (const line of output.split(\"\\n\")) {\n\t\t// TAP ok / not ok lines: \"ok 1 - test name\" or \"not ok 1 - test name\"\n\t\tconst okMatch = line.match(/^(?: {4})?ok \\d+ - (.+)$/);\n\t\tconst notOkMatch = line.match(/^(?: {4})?not ok \\d+ - (.+)$/);\n\n\t\tif (okMatch) {\n\t\t\tconst name = okMatch[1].trim();\n\t\t\t// Skip subtests that are suite-level (no leading spaces means top-level pass)\n\t\t\tif (!line.startsWith(\" \")) {\n\t\t\t\tpassed++;\n\t\t\t\ttests.push({ name, passed: true });\n\t\t\t}\n\t\t} else if (notOkMatch) {\n\t\t\tconst name = notOkMatch[1].trim();\n\t\t\tif (!line.startsWith(\" \")) {\n\t\t\t\tfailed++;\n\t\t\t\ttests.push({ name, passed: false });\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback: use summary lines if no individual results parsed\n\tif (tests.length === 0) {\n\t\tconst passMatch = output.match(/# pass\\s+(\\d+)/);\n\t\tconst failMatch = output.match(/# fail\\s+(\\d+)/);\n\t\tpassed = passMatch ? parseInt(passMatch[1], 10) : 0;\n\t\tfailed = failMatch ? parseInt(failMatch[1], 10) : 0;\n\t}\n\n\treturn { passed, failed, tests };\n}\n\n/**\n * Writes virtual test files to disk and executes them with Node 24 native\n * TypeScript support (`--experimental-strip-types --test`).\n *\n * @param files - The virtual test files to write and run.\n * @returns A {@link RunResult} summarising the test outcome.\n * @public\n */\nexport async function runTests(files: VirtualTestFile[]): Promise<RunResult> {\n\tif (files.length === 0) {\n\t\treturn { success: true, passed: 0, failed: 0, output: \"\", tests: [] };\n\t}\n\n\t// Write all files to disk\n\tfor (const file of files) {\n\t\tawait mkdir(dirname(file.path), { recursive: true });\n\t\tawait writeFile(file.path, file.content, \"utf8\");\n\t}\n\n\tconst paths = files.map((f) => f.path);\n\n\treturn new Promise((resolve) => {\n\t\tconst proc = spawn(process.execPath, [\"--experimental-strip-types\", \"--test\", ...paths], {\n\t\t\tstdio: \"pipe\",\n\t\t});\n\n\t\tlet output = \"\";\n\t\tproc.stdout?.on(\"data\", (chunk: Buffer) => {\n\t\t\toutput += chunk.toString();\n\t\t});\n\t\tproc.stderr?.on(\"data\", (chunk: Buffer) => {\n\t\t\toutput += chunk.toString();\n\t\t});\n\n\t\tproc.on(\"close\", (code) => {\n\t\t\tconst { passed, failed, tests } = parseTapOutput(output);\n\n\t\t\t// Annotate tests with source file from filename slug\n\t\t\tconst annotated = tests.map((t) => {\n\t\t\t\t// Test names like \"doctest: filename.ts > symbolName example 1 (line N)\"\n\t\t\t\tconst srcMatch = t.name.match(/doctest:\\s*(.+?)(?:\\s*>|$)/);\n\t\t\t\treturn srcMatch ? { ...t, sourceFile: srcMatch[1].trim() } : t;\n\t\t\t});\n\n\t\t\t// Enrich failure output with file locations\n\t\t\tconst enrichedOutput = output;\n\t\t\tfor (const file of files) {\n\t\t\t\tconst fileBase = basename(file.path);\n\t\t\t\tif (enrichedOutput.includes(fileBase)) {\n\t\t\t\t\t// Already references the file; no additional enrichment needed\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresolve({\n\t\t\t\tsuccess: code === 0,\n\t\t\t\tpassed,\n\t\t\t\tfailed,\n\t\t\t\toutput: enrichedOutput,\n\t\t\t\ttests: annotated,\n\t\t\t});\n\t\t});\n\t});\n}\n","/**\n * @forge-ts/doctest — TSDoc `@example` block extractor and test runner.\n *\n * Extracts fenced code blocks from `@example` tags in TSDoc comments,\n * generates virtual `node:test` test files, and executes them.\n *\n * @packageDocumentation\n * @public\n */\n\nexport { type ExtractedExample, extractExamples } from \"./extractor.js\";\nexport {\n\ttype GeneratorOptions,\n\tgenerateTestFiles,\n\ttype VirtualTestFile,\n} from \"./generator.js\";\nexport { type RunResult, runTests, type TestCaseResult } from \"./runner.js\";\n\nimport { createWalker, type ForgeConfig, type ForgeResult } from \"@forge-ts/core\";\nimport { extractExamples } from \"./extractor.js\";\nimport { generateTestFiles } from \"./generator.js\";\nimport { runTests } from \"./runner.js\";\n\n/**\n * Runs the full doctest pipeline: extract → generate → run.\n *\n * @param config - The resolved {@link ForgeConfig} for the project.\n * @returns A {@link ForgeResult} with success/failure and any diagnostics.\n * @public\n */\nexport async function doctest(config: ForgeConfig): Promise<ForgeResult> {\n\tconst start = Date.now();\n\n\tconst walker = createWalker(config);\n\tconst symbols = walker.walk();\n\n\tconst examples = extractExamples(symbols);\n\tconst files = generateTestFiles(examples, { cacheDir: config.doctest.cacheDir });\n\tconst runResult = await runTests(files);\n\n\treturn {\n\t\tsuccess: runResult.success,\n\t\tsymbols,\n\t\terrors:\n\t\t\trunResult.failed > 0\n\t\t\t\t? [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcode: \"D001\",\n\t\t\t\t\t\t\tmessage: `${runResult.failed} doctest(s) failed. See output for details.`,\n\t\t\t\t\t\t\tfilePath: \"\",\n\t\t\t\t\t\t\tline: 0,\n\t\t\t\t\t\t\tcolumn: 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t: [],\n\t\twarnings: [],\n\t\tduration: Date.now() - start,\n\t};\n}\n"],"mappings":"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","names":["basename"]}
|
|
1
|
+
{"version":3,"sources":["../src/extractor.ts","../src/generator.ts","../src/runner.ts","../src/index.ts"],"sourcesContent":["import type { ForgeSymbol } from \"@forge-ts/core\";\n\n/**\n * A single extracted `@example` block ready for test generation.\n * @public\n */\nexport interface ExtractedExample {\n\t/** The symbol this example belongs to. */\n\tsymbolName: string;\n\t/** Absolute path to the source file. */\n\tfilePath: string;\n\t/** 1-based line number of the `@example` tag. */\n\tline: number;\n\t/** The raw code inside the fenced block. */\n\tcode: string;\n\t/** The language identifier (e.g. `\"typescript\"`). */\n\tlanguage: string;\n\t/** Sequential index among examples for this symbol. */\n\tindex: number;\n}\n\n/**\n * Extracts all `@example` blocks from a list of {@link ForgeSymbol} objects.\n *\n * @param symbols - The symbols produced by the core AST walker.\n * @returns A flat array of {@link ExtractedExample} objects, one per code block.\n * @example\n * ```typescript\n * import { createWalker, loadConfig } from \"@forge-ts/core\";\n * import { extractExamples } from \"@forge-ts/doctest\";\n * const config = await loadConfig();\n * const symbols = createWalker(config).walk();\n * const examples = extractExamples(symbols);\n * console.log(`Found ${examples.length} examples`);\n * ```\n * @public\n */\nexport function extractExamples(symbols: ForgeSymbol[]): ExtractedExample[] {\n\tconst results: ExtractedExample[] = [];\n\n\tfor (const symbol of symbols) {\n\t\tconst examples = symbol.documentation?.examples ?? [];\n\t\tfor (let i = 0; i < examples.length; i++) {\n\t\t\tconst ex = examples[i];\n\t\t\tresults.push({\n\t\t\t\tsymbolName: symbol.name,\n\t\t\t\tfilePath: symbol.filePath,\n\t\t\t\tline: ex.line,\n\t\t\t\tcode: ex.code,\n\t\t\t\tlanguage: ex.language,\n\t\t\t\tindex: i,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn results;\n}\n","import { basename, relative } from \"node:path\";\nimport type { ExtractedExample } from \"./extractor.js\";\n\n/**\n * Options for virtual test file generation.\n * @public\n */\nexport interface GeneratorOptions {\n\t/** Directory where virtual test files will be written. */\n\tcacheDir: string;\n}\n\n/**\n * A generated virtual test file.\n * @public\n */\nexport interface VirtualTestFile {\n\t/** Absolute path where the file will be written. */\n\tpath: string;\n\t/** File contents (valid TypeScript). */\n\tcontent: string;\n}\n\n/**\n * Converts `// => value` comment patterns in example code to `assert.strictEqual` calls.\n *\n * @param code - Raw example code from the TSDoc block.\n * @returns Code with assertion comments replaced by actual assertion calls.\n * @internal\n */\nfunction processAssertions(code: string): string {\n\treturn code\n\t\t.split(\"\\n\")\n\t\t.map((line) => {\n\t\t\t// Match: expression // => expected\n\t\t\tconst arrowMatch = line.match(/^(\\s*)(.+?)\\s*\\/\\/\\s*=>\\s*(.+)$/);\n\t\t\tif (arrowMatch) {\n\t\t\t\tconst [, indent, expr, expected] = arrowMatch;\n\t\t\t\treturn `${indent}assert.strictEqual(${expr.trim()}, ${expected.trim()});`;\n\t\t\t}\n\t\t\treturn line;\n\t\t})\n\t\t.join(\"\\n\");\n}\n\n/**\n * Builds a base64-encoded inline source map that maps generated test file lines\n * back to the original TSDoc `@example` block in the source file.\n *\n * @param generatedFile - Absolute path of the generated test file.\n * @param examples - Examples contained in this file, each carrying its source location.\n * @param lineMap - Array mapping each generated line index (0-based) to its original line.\n * @returns A `//# sourceMappingURL=data:...` comment string.\n * @internal\n */\nfunction buildInlineSourceMap(\n\tgeneratedFile: string,\n\texamples: ExtractedExample[],\n\tlineMap: Array<{ generatedLine: number; originalLine: number; sourceFile: string }>,\n): string {\n\t// Collect unique source files\n\tconst sources = [...new Set(examples.map((e) => e.filePath))];\n\n\t// Build mappings: each entry is [generatedLine, sourceIndex, originalLine, 0] (0-based)\n\t// VLQ encoding is complex; we generate a minimal valid source map with explicit mappings\n\tconst mappings: string[] = [];\n\tlet lastGenLine = 0;\n\n\tfor (const entry of lineMap) {\n\t\t// Fill gaps with empty mappings\n\t\twhile (lastGenLine < entry.generatedLine) {\n\t\t\tmappings.push(\";\");\n\t\t\tlastGenLine++;\n\t\t}\n\t\tconst sourceIdx = sources.indexOf(entry.sourceFile);\n\t\t// Each mapping segment: [genCol=0, srcIdx, srcLine(0-based), srcCol=0]\n\t\t// Encode as VLQ - use a simple approach with base64 VLQ\n\t\tconst seg = encodeVlqSegment(0, sourceIdx, entry.originalLine - 1, 0);\n\t\tmappings.push(seg);\n\t\tlastGenLine++;\n\t}\n\n\tconst sourceMap = {\n\t\tversion: 3,\n\t\tfile: basename(generatedFile),\n\t\tsources,\n\t\tsourcesContent: null,\n\t\tnames: [],\n\t\tmappings: mappings.join(\";\"),\n\t};\n\n\tconst encoded = Buffer.from(JSON.stringify(sourceMap)).toString(\"base64\");\n\treturn `//# sourceMappingURL=data:application/json;base64,${encoded}`;\n}\n\n/**\n * Encodes a single source map segment using Base64 VLQ encoding.\n * Each field is relative to the previous segment's value.\n * @internal\n */\nfunction encodeVlqSegment(...fields: number[]): string {\n\treturn fields.map(encodeVlq).join(\"\");\n}\n\n/** VLQ Base64 alphabet. @internal */\nconst VLQ_BASE64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n\n/**\n * Encodes a single signed integer as Base64 VLQ.\n * @internal\n */\nfunction encodeVlq(value: number): string {\n\t// Convert to unsigned VLQ (sign bit in LSB)\n\tlet vlq = value < 0 ? (-value << 1) | 1 : value << 1;\n\tlet result = \"\";\n\tdo {\n\t\tlet digit = vlq & 0x1f;\n\t\tvlq >>>= 5;\n\t\tif (vlq > 0) {\n\t\t\tdigit |= 0x20; // continuation bit\n\t\t}\n\t\tresult += VLQ_BASE64[digit];\n\t} while (vlq > 0);\n\treturn result;\n}\n\n/**\n * Generates a virtual test file for a set of extracted examples.\n *\n * Each example is wrapped in an `it()` block using the Node built-in\n * `node:test` runner so that no additional test framework is required.\n * Auto-imports the tested symbol from the source file, processes `// =>`\n * assertion patterns, and appends an inline source map.\n *\n * @param examples - Examples to include in the generated file.\n * @param options - Output configuration.\n * @returns An array of {@link VirtualTestFile} objects (one per source file).\n * @example\n * ```typescript\n * import { generateTestFiles } from \"@forge-ts/doctest\";\n * const files = generateTestFiles(examples, { cacheDir: \"/tmp/doctest-cache\" });\n * console.log(`Generated ${files.length} test file(s)`);\n * ```\n * @public\n */\nexport function generateTestFiles(\n\texamples: ExtractedExample[],\n\toptions: GeneratorOptions,\n): VirtualTestFile[] {\n\t// Group by source file\n\tconst byFile = new Map<string, ExtractedExample[]>();\n\tfor (const ex of examples) {\n\t\tconst group = byFile.get(ex.filePath) ?? [];\n\t\tgroup.push(ex);\n\t\tbyFile.set(ex.filePath, group);\n\t}\n\n\tconst files: VirtualTestFile[] = [];\n\n\tfor (const [sourcePath, exs] of byFile) {\n\t\tconst slug = sourcePath\n\t\t\t.replace(/[^a-zA-Z0-9]/g, \"_\")\n\t\t\t.replace(/_+/g, \"_\")\n\t\t\t.toLowerCase();\n\t\tconst testFilePath = `${options.cacheDir}/${slug}.test.ts`;\n\n\t\t// Compute relative import path from cacheDir to sourcePath,\n\t\t// replacing the extension with .js for ESM compatibility\n\t\tconst relPath = relative(options.cacheDir, sourcePath).replace(/\\.tsx?$/, \".js\");\n\t\tconst importPath = relPath.startsWith(\".\") ? relPath : `./${relPath}`;\n\n\t\t// Collect unique symbol names for the import\n\t\tconst symbolNames = [...new Set(exs.map((ex) => ex.symbolName))];\n\n\t\t// Track line numbers for source map\n\t\tconst lineMap: Array<{ generatedLine: number; originalLine: number; sourceFile: string }> = [];\n\n\t\t// Build it-blocks and track line positions\n\t\tconst lines: string[] = [];\n\n\t\t// Header lines (0-based index):\n\t\t// 0: // Auto-generated...\n\t\t// 1: // Source: ...\n\t\t// 2: import { describe, it } from \"node:test\";\n\t\t// 3: import assert from \"node:assert/strict\";\n\t\t// 4: import { symbolNames } from \"...\";\n\t\t// 5: (empty)\n\t\t// 6: describe(...) {\n\t\tlines.push(`// Auto-generated by @forge-ts/doctest — do not edit`);\n\t\tlines.push(`// Source: ${sourcePath}`);\n\t\tlines.push(`import { describe, it } from \"node:test\";`);\n\t\tlines.push(`import assert from \"node:assert/strict\";`);\n\t\tlines.push(`import { ${symbolNames.join(\", \")} } from \"${importPath}\";`);\n\t\tlines.push(``);\n\t\tlines.push(`describe(\"doctest: ${basename(sourcePath)}\", () => {`);\n\n\t\tfor (const ex of exs) {\n\t\t\tconst itLine = lines.length; // 0-based line index of the it() call\n\t\t\tlineMap.push({ generatedLine: itLine, originalLine: ex.line, sourceFile: ex.filePath });\n\n\t\t\tlines.push(\n\t\t\t\t`\\tit(\"${ex.symbolName} example ${ex.index + 1} (line ${ex.line})\", async () => {`,\n\t\t\t);\n\n\t\t\tconst processedCode = processAssertions(ex.code);\n\t\t\tfor (const codeLine of processedCode.split(\"\\n\")) {\n\t\t\t\tlines.push(`\\t\\t${codeLine}`);\n\t\t\t}\n\t\t\tlines.push(`\\t});`);\n\t\t}\n\n\t\tlines.push(`});`);\n\t\tlines.push(``);\n\n\t\t// Append inline source map\n\t\tconst sourceMapComment = buildInlineSourceMap(testFilePath, exs, lineMap);\n\t\tlines.push(sourceMapComment);\n\t\tlines.push(``);\n\n\t\tconst content = lines.join(\"\\n\");\n\t\tfiles.push({ path: testFilePath, content });\n\t}\n\n\treturn files;\n}\n","import { spawn } from \"node:child_process\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { basename, dirname } from \"node:path\";\nimport type { VirtualTestFile } from \"./generator.js\";\n\n/**\n * Result of running the generated test files.\n * @public\n */\nexport interface RunResult {\n\t/** Whether all tests passed. */\n\tsuccess: boolean;\n\t/** Number of tests that passed. */\n\tpassed: number;\n\t/** Number of tests that failed. */\n\tfailed: number;\n\t/** Combined stdout + stderr output from the test runner. */\n\toutput: string;\n\t/** Individual test results with name and status. */\n\ttests: TestCaseResult[];\n}\n\n/**\n * The result of a single test case.\n * @public\n */\nexport interface TestCaseResult {\n\t/** The full test name as reported by the runner. */\n\tname: string;\n\t/** Whether this test passed. */\n\tpassed: boolean;\n\t/** The source file this test was generated from, if determinable. */\n\tsourceFile?: string;\n}\n\n/**\n * Parses TAP output from `node --test` into structured results.\n *\n * @param output - The raw TAP text from the runner.\n * @returns An object with pass/fail counts and per-test results.\n * @internal\n */\nfunction parseTapOutput(output: string): {\n\tpassed: number;\n\tfailed: number;\n\ttests: TestCaseResult[];\n} {\n\tconst tests: TestCaseResult[] = [];\n\tlet passed = 0;\n\tlet failed = 0;\n\n\tfor (const line of output.split(\"\\n\")) {\n\t\t// TAP ok / not ok lines: \"ok 1 - test name\" or \"not ok 1 - test name\"\n\t\tconst okMatch = line.match(/^(?: {4})?ok \\d+ - (.+)$/);\n\t\tconst notOkMatch = line.match(/^(?: {4})?not ok \\d+ - (.+)$/);\n\n\t\tif (okMatch) {\n\t\t\tconst name = okMatch[1].trim();\n\t\t\t// Skip subtests that are suite-level (no leading spaces means top-level pass)\n\t\t\tif (!line.startsWith(\" \")) {\n\t\t\t\tpassed++;\n\t\t\t\ttests.push({ name, passed: true });\n\t\t\t}\n\t\t} else if (notOkMatch) {\n\t\t\tconst name = notOkMatch[1].trim();\n\t\t\tif (!line.startsWith(\" \")) {\n\t\t\t\tfailed++;\n\t\t\t\ttests.push({ name, passed: false });\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fallback: use summary lines if no individual results parsed\n\tif (tests.length === 0) {\n\t\tconst passMatch = output.match(/# pass\\s+(\\d+)/);\n\t\tconst failMatch = output.match(/# fail\\s+(\\d+)/);\n\t\tpassed = passMatch ? parseInt(passMatch[1], 10) : 0;\n\t\tfailed = failMatch ? parseInt(failMatch[1], 10) : 0;\n\t}\n\n\treturn { passed, failed, tests };\n}\n\n/**\n * Writes virtual test files to disk and executes them with Node 24 native\n * TypeScript support (`--experimental-strip-types --test`).\n *\n * @param files - The virtual test files to write and run.\n * @returns A {@link RunResult} summarising the test outcome.\n * @example\n * ```typescript\n * import { runTests } from \"@forge-ts/doctest\";\n * const result = await runTests(virtualFiles);\n * if (!result.success) {\n * console.error(`${result.failed} doctest(s) failed`);\n * }\n * ```\n * @public\n */\nexport async function runTests(files: VirtualTestFile[]): Promise<RunResult> {\n\tif (files.length === 0) {\n\t\treturn { success: true, passed: 0, failed: 0, output: \"\", tests: [] };\n\t}\n\n\t// Write all files to disk\n\tfor (const file of files) {\n\t\tawait mkdir(dirname(file.path), { recursive: true });\n\t\tawait writeFile(file.path, file.content, \"utf8\");\n\t}\n\n\tconst paths = files.map((f) => f.path);\n\n\treturn new Promise((resolve) => {\n\t\tconst proc = spawn(process.execPath, [\"--experimental-strip-types\", \"--test\", ...paths], {\n\t\t\tstdio: \"pipe\",\n\t\t});\n\n\t\tlet output = \"\";\n\t\tproc.stdout?.on(\"data\", (chunk: Buffer) => {\n\t\t\toutput += chunk.toString();\n\t\t});\n\t\tproc.stderr?.on(\"data\", (chunk: Buffer) => {\n\t\t\toutput += chunk.toString();\n\t\t});\n\n\t\tproc.on(\"close\", (code) => {\n\t\t\tconst { passed, failed, tests } = parseTapOutput(output);\n\n\t\t\t// Annotate tests with source file from filename slug\n\t\t\tconst annotated = tests.map((t) => {\n\t\t\t\t// Test names like \"doctest: filename.ts > symbolName example 1 (line N)\"\n\t\t\t\tconst srcMatch = t.name.match(/doctest:\\s*(.+?)(?:\\s*>|$)/);\n\t\t\t\treturn srcMatch ? { ...t, sourceFile: srcMatch[1].trim() } : t;\n\t\t\t});\n\n\t\t\t// Enrich failure output with file locations\n\t\t\tconst enrichedOutput = output;\n\t\t\tfor (const file of files) {\n\t\t\t\tconst fileBase = basename(file.path);\n\t\t\t\tif (enrichedOutput.includes(fileBase)) {\n\t\t\t\t\t// Already references the file; no additional enrichment needed\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tresolve({\n\t\t\t\tsuccess: code === 0,\n\t\t\t\tpassed,\n\t\t\t\tfailed,\n\t\t\t\toutput: enrichedOutput,\n\t\t\t\ttests: annotated,\n\t\t\t});\n\t\t});\n\t});\n}\n","/**\n * @forge-ts/doctest — TSDoc `@example` block extractor and test runner.\n *\n * Extracts fenced code blocks from `@example` tags in TSDoc comments,\n * generates virtual `node:test` test files, and executes them.\n *\n * @packageDocumentation\n */\n\nexport { type ExtractedExample, extractExamples } from \"./extractor.js\";\nexport {\n\ttype GeneratorOptions,\n\tgenerateTestFiles,\n\ttype VirtualTestFile,\n} from \"./generator.js\";\nexport { type RunResult, runTests, type TestCaseResult } from \"./runner.js\";\n\nimport { createWalker, type ForgeConfig, type ForgeResult } from \"@forge-ts/core\";\nimport { extractExamples } from \"./extractor.js\";\nimport { generateTestFiles } from \"./generator.js\";\nimport { runTests } from \"./runner.js\";\n\n/**\n * Runs the full doctest pipeline: extract → generate → run.\n *\n * @param config - The resolved {@link ForgeConfig} for the project.\n * @returns A {@link ForgeResult} with success/failure and any diagnostics.\n * @example\n * ```typescript\n * import { loadConfig } from \"@forge-ts/core\";\n * import { doctest } from \"@forge-ts/doctest\";\n * const config = await loadConfig();\n * const result = await doctest(config);\n * if (!result.success) {\n * console.error(`${result.errors.length} doctest failure(s)`);\n * }\n * ```\n * @packageDocumentation\n * @public\n */\nexport async function doctest(config: ForgeConfig): Promise<ForgeResult> {\n\tconst start = Date.now();\n\n\tconst walker = createWalker(config);\n\tconst symbols = walker.walk();\n\n\tconst examples = extractExamples(symbols);\n\tconst files = generateTestFiles(examples, { cacheDir: config.doctest.cacheDir });\n\tconst runResult = await runTests(files);\n\n\treturn {\n\t\tsuccess: runResult.success,\n\t\tsymbols,\n\t\terrors:\n\t\t\trunResult.failed > 0\n\t\t\t\t? [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcode: \"D001\",\n\t\t\t\t\t\t\tmessage: `${runResult.failed} doctest(s) failed. See output for details.`,\n\t\t\t\t\t\t\tfilePath: \"\",\n\t\t\t\t\t\t\tline: 0,\n\t\t\t\t\t\t\tcolumn: 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t: [],\n\t\twarnings: [],\n\t\tduration: Date.now() - start,\n\t};\n}\n"],"mappings":"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","names":["basename"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge-ts/doctest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TSDoc @example block extractor and test runner for forge-ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@forge-ts/core": "0.
|
|
27
|
+
"@forge-ts/core": "0.4.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsup": "^8.3.5",
|
|
31
31
|
"typescript": "^5.8.2",
|
|
32
32
|
"vitest": "^4.1.0",
|
|
33
|
-
"@forge-ts/core": "0.
|
|
33
|
+
"@forge-ts/core": "0.4.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsup",
|