From ee903e4b63257573773e93d10612250e3634cae9 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 31 May 2026 13:57:40 -0500 Subject: feat(typescript): add coverage-summary to the TypeScript bundle Last language in the coverage-summary fan-out, after Elisp, Python, and Go. Same kernel: count every source file on disk that's absent from the coverage report as 0% and weight the project number by file, so an untested file stays visible instead of being averaged away. The script at languages/typescript/claude/scripts/coverage-summary.js parses an Istanbul json-summary report (the coverage-summary.json that c8, Vitest, and Jest all emit), takes per-file statements covered over total, and reports a file-weighted number plus the missing files. It walks the source dir for .ts/.js, skipping test files, declarations, and node_modules. Node built-ins only, so it runs via node with no install, and it doesn't reimplement the per-file table nyc already prints. Tests are black-box, run with node's own test runner: a temp tree plus a json-summary report, the script invoked via node, output asserted. They cover missing-file detection, all-tracked, test-file and node_modules exclusion, and the missing-report error. make test gained a node --test discovery path for languages/*/tests, guarded so environments without Node skip it cleanly. As with Python, the TypeScript bundle had no gitignore-add.txt, which would have left the script un-gitignored on install, so I added one. This finishes the fan-out: coverage-summary now ships in all four bundles, each parsing its own tool's report behind the same file-weighted, missing-as-0% kernel. I proved the Go and TypeScript scripts by running them (Go against a live profile, TS against a synthetic report and the CLI). Python and TypeScript weren't run against a live coverage tool, since neither coverage.py nor nyc is installed here, so the first adopter of each should check against a real report. --- .../typescript/tests/coverage-summary.test.js | 90 ++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 languages/typescript/tests/coverage-summary.test.js (limited to 'languages/typescript/tests/coverage-summary.test.js') diff --git a/languages/typescript/tests/coverage-summary.test.js b/languages/typescript/tests/coverage-summary.test.js new file mode 100644 index 0000000..b2b6ea3 --- /dev/null +++ b/languages/typescript/tests/coverage-summary.test.js @@ -0,0 +1,90 @@ +// Black-box tests for the TypeScript bundle coverage-summary script. +// +// The script ships into a project's .claude/scripts/ and runs via `node`, so +// these tests exercise the real CLI rather than importing it: build a temp tree +// (source files + an istanbul json-summary report), run the script against it, +// and assert on stdout. Run with `node --test`. +// +// Normal / Boundary / Error coverage at the behavior level: missing-file +// detection, the file-weighted number, all-tracked, ignoring test files and +// node_modules, and a missing-report error. +const { test } = require("node:test"); +const assert = require("node:assert"); +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const SCRIPT = path.resolve(__dirname, "..", "claude", "scripts", "coverage-summary.js"); + +// fixture builds a temp project. sources maps relpath -> contents. report is the +// istanbul json-summary object, with per-file keys given as relpaths that get +// rewritten to absolute paths (matching how istanbul records them). +function fixture(sources, reportRel) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "tscov-")); + for (const [rel, body] of Object.entries(sources)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); + } + const report = { total: {} }; + for (const [rel, stmts] of Object.entries(reportRel)) { + report[path.join(root, rel)] = { + statements: { total: stmts.total, covered: stmts.covered, pct: 0 }, + lines: { total: stmts.total, covered: stmts.covered, pct: 0 }, + }; + } + const reportPath = path.join(root, "coverage-summary.json"); + fs.writeFileSync(reportPath, JSON.stringify(report)); + return { root, reportPath }; +} + +function run(reportPath, sourceDir, root) { + return execFileSync("node", [SCRIPT, reportPath, sourceDir, root], { encoding: "utf8" }); +} + +test("missing file is surfaced and counted 0%", () => { + const { root, reportPath } = fixture( + { "src/a.ts": "export const a = 1;\n", "src/untested.ts": "export const u = 2;\n" }, + { "src/a.ts": { total: 2, covered: 2 } }, // a.ts 100%; untested.ts absent + ); + const out = run(reportPath, path.join(root, "src"), root); + assert.match(out, /src\/untested\.ts/); + assert.match(out, /0%/); + assert.match(out, /50\.0%/); // a=100, untested missing=0 -> 50 +}); + +test("all tracked, nothing missing", () => { + const { root, reportPath } = fixture( + { "src/a.ts": "export const a = 1;\n" }, + { "src/a.ts": { total: 2, covered: 2 } }, + ); + const out = run(reportPath, path.join(root, "src"), root); + assert.match(out, /Not in coverage report: 0 file/); + assert.match(out, /100\.0%/); +}); + +test("test files and node_modules are not counted as source", () => { + const { root, reportPath } = fixture( + { + "src/a.ts": "export const a = 1;\n", + "src/a.test.ts": "import {a} from './a';\n", + "node_modules/dep/index.js": "module.exports = 1;\n", + }, + { "src/a.ts": { total: 2, covered: 2 } }, + ); + const out = run(reportPath, path.join(root, "src"), root); + assert.doesNotMatch(out, /a\.test\.ts/); + assert.doesNotMatch(out, /node_modules/); + assert.match(out, /100\.0%/); +}); + +test("missing report exits non-zero", () => { + const { root } = fixture({ "src/a.ts": "export const a = 1;\n" }, {}); + assert.throws(() => + execFileSync("node", [SCRIPT, path.join(root, "nope.json"), path.join(root, "src"), root], { + encoding: "utf8", + stdio: "pipe", + }), + ); +}); -- cgit v1.2.3