@forge-ts/doctest 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 forge-ts contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,120 @@
1
+ import { ForgeSymbol, ForgeConfig, ForgeResult } from '@codluv/forge-core';
2
+
3
+ /**
4
+ * A single extracted `@example` block ready for test generation.
5
+ * @public
6
+ */
7
+ interface ExtractedExample {
8
+ /** The symbol this example belongs to. */
9
+ symbolName: string;
10
+ /** Absolute path to the source file. */
11
+ filePath: string;
12
+ /** 1-based line number of the `@example` tag. */
13
+ line: number;
14
+ /** The raw code inside the fenced block. */
15
+ code: string;
16
+ /** The language identifier (e.g. `"typescript"`). */
17
+ language: string;
18
+ /** Sequential index among examples for this symbol. */
19
+ index: number;
20
+ }
21
+ /**
22
+ * Extracts all `@example` blocks from a list of {@link ForgeSymbol} objects.
23
+ *
24
+ * @param symbols - The symbols produced by the core AST walker.
25
+ * @returns A flat array of {@link ExtractedExample} objects, one per code block.
26
+ * @public
27
+ */
28
+ declare function extractExamples(symbols: ForgeSymbol[]): ExtractedExample[];
29
+
30
+ /**
31
+ * Options for virtual test file generation.
32
+ * @public
33
+ */
34
+ interface GeneratorOptions {
35
+ /** Directory where virtual test files will be written. */
36
+ cacheDir: string;
37
+ }
38
+ /**
39
+ * A generated virtual test file.
40
+ * @public
41
+ */
42
+ interface VirtualTestFile {
43
+ /** Absolute path where the file will be written. */
44
+ path: string;
45
+ /** File contents (valid TypeScript). */
46
+ content: string;
47
+ }
48
+ /**
49
+ * Generates a virtual test file for a set of extracted examples.
50
+ *
51
+ * Each example is wrapped in an `it()` block using the Node built-in
52
+ * `node:test` runner so that no additional test framework is required.
53
+ * Auto-imports the tested symbol from the source file, processes `// =>`
54
+ * assertion patterns, and appends an inline source map.
55
+ *
56
+ * @param examples - Examples to include in the generated file.
57
+ * @param options - Output configuration.
58
+ * @returns An array of {@link VirtualTestFile} objects (one per source file).
59
+ * @public
60
+ */
61
+ declare function generateTestFiles(examples: ExtractedExample[], options: GeneratorOptions): VirtualTestFile[];
62
+
63
+ /**
64
+ * Result of running the generated test files.
65
+ * @public
66
+ */
67
+ interface RunResult {
68
+ /** Whether all tests passed. */
69
+ success: boolean;
70
+ /** Number of tests that passed. */
71
+ passed: number;
72
+ /** Number of tests that failed. */
73
+ failed: number;
74
+ /** Combined stdout + stderr output from the test runner. */
75
+ output: string;
76
+ /** Individual test results with name and status. */
77
+ tests: TestCaseResult[];
78
+ }
79
+ /**
80
+ * The result of a single test case.
81
+ * @public
82
+ */
83
+ interface TestCaseResult {
84
+ /** The full test name as reported by the runner. */
85
+ name: string;
86
+ /** Whether this test passed. */
87
+ passed: boolean;
88
+ /** The source file this test was generated from, if determinable. */
89
+ sourceFile?: string;
90
+ }
91
+ /**
92
+ * Writes virtual test files to disk and executes them with Node 24 native
93
+ * TypeScript support (`--experimental-strip-types --test`).
94
+ *
95
+ * @param files - The virtual test files to write and run.
96
+ * @returns A {@link RunResult} summarising the test outcome.
97
+ * @public
98
+ */
99
+ declare function runTests(files: VirtualTestFile[]): Promise<RunResult>;
100
+
101
+ /**
102
+ * @codluv/forge-doctest — TSDoc `@example` block extractor and test runner.
103
+ *
104
+ * Extracts fenced code blocks from `@example` tags in TSDoc comments,
105
+ * generates virtual `node:test` test files, and executes them.
106
+ *
107
+ * @packageDocumentation
108
+ * @public
109
+ */
110
+
111
+ /**
112
+ * Runs the full doctest pipeline: extract → generate → run.
113
+ *
114
+ * @param config - The resolved {@link ForgeConfig} for the project.
115
+ * @returns A {@link ForgeResult} with success/failure and any diagnostics.
116
+ * @public
117
+ */
118
+ declare function doctest(config: ForgeConfig): Promise<ForgeResult>;
119
+
120
+ export { type ExtractedExample, type GeneratorOptions, type RunResult, type TestCaseResult, type VirtualTestFile, doctest, extractExamples, generateTestFiles, runTests };
package/dist/index.js ADDED
@@ -0,0 +1,229 @@
1
+ // src/extractor.ts
2
+ function extractExamples(symbols) {
3
+ const results = [];
4
+ for (const symbol of symbols) {
5
+ const examples = symbol.documentation?.examples ?? [];
6
+ for (let i = 0; i < examples.length; i++) {
7
+ const ex = examples[i];
8
+ results.push({
9
+ symbolName: symbol.name,
10
+ filePath: symbol.filePath,
11
+ line: ex.line,
12
+ code: ex.code,
13
+ language: ex.language,
14
+ index: i
15
+ });
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ // src/generator.ts
22
+ import { basename, relative } from "path";
23
+ function processAssertions(code) {
24
+ return code.split("\n").map((line) => {
25
+ const arrowMatch = line.match(/^(\s*)(.+?)\s*\/\/\s*=>\s*(.+)$/);
26
+ if (arrowMatch) {
27
+ const [, indent, expr, expected] = arrowMatch;
28
+ return `${indent}assert.strictEqual(${expr.trim()}, ${expected.trim()});`;
29
+ }
30
+ return line;
31
+ }).join("\n");
32
+ }
33
+ function buildInlineSourceMap(generatedFile, examples, lineMap) {
34
+ const sources = [...new Set(examples.map((e) => e.filePath))];
35
+ const mappings = [];
36
+ let lastGenLine = 0;
37
+ for (const entry of lineMap) {
38
+ while (lastGenLine < entry.generatedLine) {
39
+ mappings.push(";");
40
+ lastGenLine++;
41
+ }
42
+ const sourceIdx = sources.indexOf(entry.sourceFile);
43
+ const seg = encodeVlqSegment(0, sourceIdx, entry.originalLine - 1, 0);
44
+ mappings.push(seg);
45
+ lastGenLine++;
46
+ }
47
+ const sourceMap = {
48
+ version: 3,
49
+ file: basename(generatedFile),
50
+ sources,
51
+ sourcesContent: null,
52
+ names: [],
53
+ mappings: mappings.join(";")
54
+ };
55
+ const encoded = Buffer.from(JSON.stringify(sourceMap)).toString("base64");
56
+ return `//# sourceMappingURL=data:application/json;base64,${encoded}`;
57
+ }
58
+ function encodeVlqSegment(...fields) {
59
+ return fields.map(encodeVlq).join("");
60
+ }
61
+ var VLQ_BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
62
+ function encodeVlq(value) {
63
+ let vlq = value < 0 ? -value << 1 | 1 : value << 1;
64
+ let result = "";
65
+ do {
66
+ let digit = vlq & 31;
67
+ vlq >>>= 5;
68
+ if (vlq > 0) {
69
+ digit |= 32;
70
+ }
71
+ result += VLQ_BASE64[digit];
72
+ } while (vlq > 0);
73
+ return result;
74
+ }
75
+ function generateTestFiles(examples, options) {
76
+ const byFile = /* @__PURE__ */ new Map();
77
+ for (const ex of examples) {
78
+ const group = byFile.get(ex.filePath) ?? [];
79
+ group.push(ex);
80
+ byFile.set(ex.filePath, group);
81
+ }
82
+ const files = [];
83
+ for (const [sourcePath, exs] of byFile) {
84
+ const slug = sourcePath.replace(/[^a-zA-Z0-9]/g, "_").replace(/_+/g, "_").toLowerCase();
85
+ const testFilePath = `${options.cacheDir}/${slug}.test.ts`;
86
+ const relPath = relative(options.cacheDir, sourcePath).replace(/\.tsx?$/, ".js");
87
+ const importPath = relPath.startsWith(".") ? relPath : `./${relPath}`;
88
+ const symbolNames = [...new Set(exs.map((ex) => ex.symbolName))];
89
+ const lineMap = [];
90
+ const lines = [];
91
+ lines.push(`// Auto-generated by @codluv/forge-doctest \u2014 do not edit`);
92
+ lines.push(`// Source: ${sourcePath}`);
93
+ lines.push(`import { describe, it } from "node:test";`);
94
+ lines.push(`import assert from "node:assert/strict";`);
95
+ lines.push(`import { ${symbolNames.join(", ")} } from "${importPath}";`);
96
+ lines.push(``);
97
+ lines.push(`describe("doctest: ${basename(sourcePath)}", () => {`);
98
+ for (const ex of exs) {
99
+ const itLine = lines.length;
100
+ lineMap.push({ generatedLine: itLine, originalLine: ex.line, sourceFile: ex.filePath });
101
+ lines.push(
102
+ ` it("${ex.symbolName} example ${ex.index + 1} (line ${ex.line})", async () => {`
103
+ );
104
+ const processedCode = processAssertions(ex.code);
105
+ for (const codeLine of processedCode.split("\n")) {
106
+ lines.push(` ${codeLine}`);
107
+ }
108
+ lines.push(` });`);
109
+ }
110
+ lines.push(`});`);
111
+ lines.push(``);
112
+ const sourceMapComment = buildInlineSourceMap(testFilePath, exs, lineMap);
113
+ lines.push(sourceMapComment);
114
+ lines.push(``);
115
+ const content = lines.join("\n");
116
+ files.push({ path: testFilePath, content });
117
+ }
118
+ return files;
119
+ }
120
+
121
+ // src/runner.ts
122
+ import { spawn } from "child_process";
123
+ import { mkdir, writeFile } from "fs/promises";
124
+ import { basename as basename2, dirname } from "path";
125
+ function parseTapOutput(output) {
126
+ const tests = [];
127
+ let passed = 0;
128
+ let failed = 0;
129
+ for (const line of output.split("\n")) {
130
+ const okMatch = line.match(/^(?: {4})?ok \d+ - (.+)$/);
131
+ const notOkMatch = line.match(/^(?: {4})?not ok \d+ - (.+)$/);
132
+ if (okMatch) {
133
+ const name = okMatch[1].trim();
134
+ if (!line.startsWith(" ")) {
135
+ passed++;
136
+ tests.push({ name, passed: true });
137
+ }
138
+ } else if (notOkMatch) {
139
+ const name = notOkMatch[1].trim();
140
+ if (!line.startsWith(" ")) {
141
+ failed++;
142
+ tests.push({ name, passed: false });
143
+ }
144
+ }
145
+ }
146
+ if (tests.length === 0) {
147
+ const passMatch = output.match(/# pass\s+(\d+)/);
148
+ const failMatch = output.match(/# fail\s+(\d+)/);
149
+ passed = passMatch ? parseInt(passMatch[1], 10) : 0;
150
+ failed = failMatch ? parseInt(failMatch[1], 10) : 0;
151
+ }
152
+ return { passed, failed, tests };
153
+ }
154
+ async function runTests(files) {
155
+ if (files.length === 0) {
156
+ return { success: true, passed: 0, failed: 0, output: "", tests: [] };
157
+ }
158
+ for (const file of files) {
159
+ await mkdir(dirname(file.path), { recursive: true });
160
+ await writeFile(file.path, file.content, "utf8");
161
+ }
162
+ const paths = files.map((f) => f.path);
163
+ return new Promise((resolve) => {
164
+ const proc = spawn(process.execPath, ["--experimental-strip-types", "--test", ...paths], {
165
+ stdio: "pipe"
166
+ });
167
+ let output = "";
168
+ proc.stdout?.on("data", (chunk) => {
169
+ output += chunk.toString();
170
+ });
171
+ proc.stderr?.on("data", (chunk) => {
172
+ output += chunk.toString();
173
+ });
174
+ proc.on("close", (code) => {
175
+ const { passed, failed, tests } = parseTapOutput(output);
176
+ const annotated = tests.map((t) => {
177
+ const srcMatch = t.name.match(/doctest:\s*(.+?)(?:\s*>|$)/);
178
+ return srcMatch ? { ...t, sourceFile: srcMatch[1].trim() } : t;
179
+ });
180
+ const enrichedOutput = output;
181
+ for (const file of files) {
182
+ const fileBase = basename2(file.path);
183
+ if (enrichedOutput.includes(fileBase)) {
184
+ break;
185
+ }
186
+ }
187
+ resolve({
188
+ success: code === 0,
189
+ passed,
190
+ failed,
191
+ output: enrichedOutput,
192
+ tests: annotated
193
+ });
194
+ });
195
+ });
196
+ }
197
+
198
+ // src/index.ts
199
+ import { createWalker } from "@codluv/forge-core";
200
+ async function doctest(config) {
201
+ const start = Date.now();
202
+ const walker = createWalker(config);
203
+ const symbols = walker.walk();
204
+ const examples = extractExamples(symbols);
205
+ const files = generateTestFiles(examples, { cacheDir: config.doctest.cacheDir });
206
+ const runResult = await runTests(files);
207
+ return {
208
+ success: runResult.success,
209
+ symbols,
210
+ errors: runResult.failed > 0 ? [
211
+ {
212
+ code: "D001",
213
+ message: `${runResult.failed} doctest(s) failed. See output for details.`,
214
+ filePath: "",
215
+ line: 0,
216
+ column: 0
217
+ }
218
+ ] : [],
219
+ warnings: [],
220
+ duration: Date.now() - start
221
+ };
222
+ }
223
+ export {
224
+ doctest,
225
+ extractExamples,
226
+ generateTestFiles,
227
+ runTests
228
+ };
229
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/extractor.ts","../src/generator.ts","../src/runner.ts","../src/index.ts"],"sourcesContent":["import type { ForgeSymbol } from \"@codluv/forge-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 @codluv/forge-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 * @codluv/forge-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 \"@codluv/forge-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"]}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@forge-ts/doctest",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "TSDoc @example block extractor and test runner for forge-ts",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/codluv/forge-ts"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "import": "./dist/index.js",
23
+ "types": "./dist/index.d.ts"
24
+ }
25
+ },
26
+ "dependencies": {
27
+ "@codluv/forge-core": "npm:@forge-ts/core@0.2.0"
28
+ },
29
+ "devDependencies": {
30
+ "tsup": "^8.3.5",
31
+ "typescript": "^5.8.2",
32
+ "vitest": "^4.1.0",
33
+ "@codluv/forge-core": "npm:@forge-ts/core@0.2.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run"
40
+ }
41
+ }