@forge-ts/doctest 0.6.0 → 0.6.2

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.js CHANGED
@@ -184,10 +184,12 @@ async function runTests(files) {
184
184
  break;
185
185
  }
186
186
  }
187
+ const actualFailed = code !== 0 && failed === 0 ? 1 : failed;
188
+ const actualPassed = actualFailed > failed ? Math.max(0, passed - 1) : passed;
187
189
  resolve({
188
190
  success: code === 0,
189
- passed,
190
- failed,
191
+ passed: actualPassed,
192
+ failed: actualFailed,
191
193
  output: enrichedOutput,
192
194
  tests: annotated
193
195
  });
@@ -204,18 +206,29 @@ async function doctest(config) {
204
206
  const examples = extractExamples(symbols);
205
207
  const files = generateTestFiles(examples, { cacheDir: config.doctest.cacheDir });
206
208
  const runResult = await runTests(files);
209
+ const errors = [];
210
+ if (runResult.failed > 0) {
211
+ errors.push({
212
+ code: "D001",
213
+ message: `${runResult.failed} doctest(s) failed. See output for details.`,
214
+ filePath: "",
215
+ line: 0,
216
+ column: 0
217
+ });
218
+ } else if (!runResult.success) {
219
+ errors.push({
220
+ code: "D002",
221
+ message: `Doctest runner exited with an error. ${runResult.output ? `Output:
222
+ ${runResult.output.slice(0, 2e3)}` : "No output captured."}`,
223
+ filePath: "",
224
+ line: 0,
225
+ column: 0
226
+ });
227
+ }
207
228
  return {
208
229
  success: runResult.success,
209
230
  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
- ] : [],
231
+ errors,
219
232
  warnings: [],
220
233
  duration: Date.now() - start
221
234
  };
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 * @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"]}
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\t// Reconcile exit code with parsed failures.\n\t\t\t// If node:test exits non-zero but TAP parsing found 0 failures,\n\t\t\t// the runner itself had an error (compilation, import, etc.).\n\t\t\t// Ensure failed >= 1 so consumers never see \"0 failures\" with exit 1.\n\t\t\tconst actualFailed = code !== 0 && failed === 0 ? 1 : failed;\n\t\t\tconst actualPassed = actualFailed > failed ? Math.max(0, passed - 1) : passed;\n\n\t\t\tresolve({\n\t\t\t\tsuccess: code === 0,\n\t\t\t\tpassed: actualPassed,\n\t\t\t\tfailed: actualFailed,\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\tconst errors = [];\n\tif (runResult.failed > 0) {\n\t\terrors.push({\n\t\t\tcode: \"D001\",\n\t\t\tmessage: `${runResult.failed} doctest(s) failed. See output for details.`,\n\t\t\tfilePath: \"\",\n\t\t\tline: 0,\n\t\t\tcolumn: 0,\n\t\t});\n\t} else if (!runResult.success) {\n\t\t// Runner exited non-zero without parsed test failures — likely a\n\t\t// compilation or import error in the generated test files.\n\t\terrors.push({\n\t\t\tcode: \"D002\",\n\t\t\tmessage: `Doctest runner exited with an error. ${runResult.output ? `Output:\\n${runResult.output.slice(0, 2000)}` : \"No output captured.\"}`,\n\t\t\tfilePath: \"\",\n\t\t\tline: 0,\n\t\t\tcolumn: 0,\n\t\t});\n\t}\n\n\treturn {\n\t\tsuccess: runResult.success,\n\t\tsymbols,\n\t\terrors,\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.6.0",
3
+ "version": "0.6.2",
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.6.0"
27
+ "@forge-ts/core": "0.6.2"
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.6.0"
33
+ "@forge-ts/core": "0.6.2"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup",