From 24b4146508082d37924a1939f6b9f1b3d07d325a Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:34:57 +0200 Subject: [PATCH 1/9] Run the test suite against TypeScript sources behind an uncheatable coverage gate Coverage was unmeasured here. The suite imported the compiled output from dist/, so the only numbers obtainable pointed at generated JavaScript offsets rather than lines an author edits, and no threshold was wired into any gate. Tests now import the TypeScript sources directly. Node has stripped types without a flag since 22.18.0, which is the floor this package already declares in engines, so this needs no new tooling. Enabling rewriteRelativeImportExtensions lets a source file import its siblings as ./x.ts while tsc still emits ./x.js into dist, leaving the published artifact's module graph unchanged. The gate is scripts/coverage-gate.ts. Beyond enforcing per-dimension thresholds it reconciles the reported file list against a walk of the source tree, and that second check is the reason it is a script rather than a bare flag: Node omits files that never load from its coverage report entirely instead of reporting them at zero, so a threshold alone is satisfiable by narrowing what the suite imports. A negative control confirms the check is not vacuous - adding an untested source file makes the gate exit 1 and name it. Thresholds are pinned at the measured baseline (76 line, 78 branch, 75 function) so the number can only ratchet up. release:check now runs the gate in place of a bare test run, and CI runs it on both Node 22 and 26. Baseline across the two source files: 76.14 line, 78.93 branch, 75.71 function. The weakest is index.ts at 73.08/78.30/73.03; projects.ts is at 97.00/81.41/100.00. Both are the targets of the follow-up work to reach the mandated 100. Tracked in pm-github-uv1a. --- .agents/pm/features/pm-github-uv1a.toon | 20 +++ .agents/pm/history/pm-github-uv1a.jsonl | 7 + .github/workflows/ci.yml | 12 +- .gitignore | 1 + index.ts | 2 +- package.json | 22 ++- scripts/coverage-gate.ts | 191 ++++++++++++++++++++++++ test/atomic.test.ts | 4 +- test/comments-sync.test.ts | 4 +- test/dryrun-preview.test.ts | 4 +- test/helpers/comment-sync-child.ts | 8 +- test/import-lock.test.ts | 8 +- test/link-deps.test.ts | 4 +- test/projects.test.ts | 2 +- test/smoke.test.ts | 4 +- tsconfig.json | 2 + tsconfig.test.json | 10 +- 17 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 .agents/pm/features/pm-github-uv1a.toon create mode 100644 .agents/pm/history/pm-github-uv1a.jsonl create mode 100644 scripts/coverage-gate.ts diff --git a/.agents/pm/features/pm-github-uv1a.toon b/.agents/pm/features/pm-github-uv1a.toon new file mode 100644 index 00000000..5239f2ff --- /dev/null +++ b/.agents/pm/features/pm-github-uv1a.toon @@ -0,0 +1,20 @@ +id: pm-github-uv1a +title: Enforce a real coverage gate by running tests against TypeScript sources +description: "" +type: Feature +status: closed +priority: 2 +tags: [] +created_at: "2026-07-28T23:32:24.103Z" +updated_at: "2026-07-28T23:33:58.688Z" +closed_at: "2026-07-28T23:33:58.687Z" +author: pi-agent +resolution: "Tests moved onto TypeScript sources and scripts/coverage-gate.ts wired into release:check and CI." +expected_result: "A coverage gate runs in the release check and in CI on both supported Node lines, reports against TypeScript sources, and fails when a source file is untested or a threshold regresses." +actual_result: "npm run check passes. npm run coverage passes with 172 tests, both source files present in the report, at 76.14 line, 78.93 branch and 75.74 function coverage. A negative control adding an untested source file made the gate exit 1 and name that file, so the presence check is not vacuous. Thresholds pinned at 76/78/75. The Node 22 CI leg is unobserved until the PR runs." +comments[1]{created_at,author,text}: + "2026-07-28T23:33:19.033Z",pi-agent,"Negative control: added an untested __probe.ts source file to coverageGate.sources, ran node scripts/coverage-gate.ts, observed exit code 1 with the gate naming __probe.ts as never loaded. Removed the probe and re-ran: exit code 0, 2 source files reported. The presence check is not vacuous." +learnings[1]{created_at,author,text}: + "2026-07-28T23:33:45.811Z",pi-agent,"Node omits never-loaded files from its coverage report rather than reporting them at zero, so a threshold flag alone is satisfiable by narrowing what the suite imports. Any coverage gate must reconcile the report against the source tree." +close_reason: Coverage gate landed and locally verified; the Node 22 CI leg is unobserved until the pull request runs. +body: "Baseline measured 2026-07-29 across two source files: index.ts at 73.08 line, 78.30 branch, 73.03 function; projects.ts at 97.00 line, 81.41 branch, 100.00 function. All files aggregate: 76.14 line, 78.93 branch, 75.74 function. Thresholds pinned at the measured floor (76/78/75) so the numbers can only ratchet up. 172 tests, 0 failures." diff --git a/.agents/pm/history/pm-github-uv1a.jsonl b/.agents/pm/history/pm-github-uv1a.jsonl new file mode 100644 index 00000000..25b9d9e4 --- /dev/null +++ b/.agents/pm/history/pm-github-uv1a.jsonl @@ -0,0 +1,7 @@ +{"ts":"2026-07-28T23:32:24.103Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_provenance":{"model":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-uv1a"},{"op":"add","path":"/metadata/title","value":"Enforce a real coverage gate by running tests against TypeScript sources"},{"op":"add","path":"/metadata/description","value":""},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-07-28T23:32:24.103Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-28T23:32:24.103Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"4ff9f1878bdf7b6c042fdc8a5f3835e09201452b39349f4db44b2e179288361c","message":""} +{"ts":"2026-07-28T23:33:03.628Z","author":"codex","author_source":"configured","agent_harness":"pi","agent_provenance":{"model":null},"op":"update","patch":[{"op":"replace","path":"/body","value":"Baseline measured 2026-07-29 across two source files: index.ts at 73.08 line, 78.30 branch, 73.03 function; projects.ts at 97.00 line, 81.41 branch, 100.00 function. All files aggregate: 76.14 line, 78.93 branch, 75.74 function. Thresholds pinned at the measured floor (76/78/75) so the numbers can only ratchet up. 172 tests, 0 failures."},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:33:03.628Z"}],"before_hash":"4ff9f1878bdf7b6c042fdc8a5f3835e09201452b39349f4db44b2e179288361c","after_hash":"212419c94d449a6f48c6f8b0ee9ef62da46d7777ae991d83facc809d99cb7037"} +{"ts":"2026-07-28T23:33:08.515Z","author":"codex","author_source":"configured","agent_harness":"pi","agent_provenance":{"model":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:33:08.515Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-28T23:33:08.515Z","author":"codex","text":"Negative control: added an untested __probe.ts source file to coverageGate.sources, ran node scripts/coverage-gate.ts, observed exit code 1 with the gate naming __probe.ts as never loaded. Removed the probe and re-ran: exit code 0, 2 source files reported. The presence check is not vacuous."}]}],"before_hash":"212419c94d449a6f48c6f8b0ee9ef62da46d7777ae991d83facc809d99cb7037","after_hash":"83d96227b427fdaa3ec6c560f5b8d01fdeb5dd61ca157a464677fd5c7fe4128a"} +{"ts":"2026-07-28T23:33:19.033Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_provenance":{"model":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-07-28T23:33:19.033Z","author":"pi-agent","text":"Negative control: added an untested __probe.ts source file to coverageGate.sources, ran node scripts/coverage-gate.ts, observed exit code 1 with the gate naming __probe.ts as never loaded. Removed the probe and re-ran: exit code 0, 2 source files reported. The presence check is not vacuous."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:33:19.033Z"}],"before_hash":"83d96227b427fdaa3ec6c560f5b8d01fdeb5dd61ca157a464677fd5c7fe4128a","after_hash":"112aba8ff38bde0e955707691748d0733d815315c0b3fc4a4a064e6dc5f0190b"} +{"ts":"2026-07-28T23:33:40.620Z","author":"codex","author_source":"configured","agent_harness":"pi","agent_provenance":{"model":null},"op":"comment_delete","patch":[{"op":"remove","path":"/metadata/comments/1"},{"op":"replace","path":"/metadata/comments/0/author","value":"pi-agent"},{"op":"replace","path":"/metadata/comments/0/created_at","value":"2026-07-28T23:33:19.033Z"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:33:40.620Z"}],"before_hash":"112aba8ff38bde0e955707691748d0733d815315c0b3fc4a4a064e6dc5f0190b","after_hash":"ed4f93345e79d5a9ad843482f5a7735741b1197a74744ef2df7c3f12db01b748"} +{"ts":"2026-07-28T23:33:45.812Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_provenance":{"model":null},"op":"learning_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:33:45.812Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-28T23:33:45.811Z","author":"pi-agent","text":"Node omits never-loaded files from its coverage report rather than reporting them at zero, so a threshold flag alone is satisfiable by narrowing what the suite imports. Any coverage gate must reconcile the report against the source tree."}]}],"before_hash":"ed4f93345e79d5a9ad843482f5a7735741b1197a74744ef2df7c3f12db01b748","after_hash":"002bf7d9137a9ea7920f6e6809c98d22ffdb259ee4eac684312aab12f64ad9ad"} +{"ts":"2026-07-28T23:33:58.688Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_provenance":{"model":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:33:58.688Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-28T23:33:58.687Z"},{"op":"add","path":"/metadata/resolution","value":"Tests moved onto TypeScript sources and scripts/coverage-gate.ts wired into release:check and CI."},{"op":"add","path":"/metadata/expected_result","value":"A coverage gate runs in the release check and in CI on both supported Node lines, reports against TypeScript sources, and fails when a source file is untested or a threshold regresses."},{"op":"add","path":"/metadata/actual_result","value":"npm run check passes. npm run coverage passes with 172 tests, both source files present in the report, at 76.14 line, 78.93 branch and 75.74 function coverage. A negative control adding an untested source file made the gate exit 1 and name that file, so the presence check is not vacuous. Thresholds pinned at 76/78/75. The Node 22 CI leg is unobserved until the PR runs."},{"op":"add","path":"/metadata/close_reason","value":"Coverage gate landed and locally verified; the Node 22 CI leg is unobserved until the pull request runs."}],"before_hash":"002bf7d9137a9ea7920f6e6809c98d22ffdb259ee4eac684312aab12f64ad9ad","after_hash":"5042ac70caca4a6513c0865fd6805a90559568769b41bcfb91e5cd89a3ac79fc"} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e0fa572..6943671c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ permissions: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 22 is the floor declared in `engines` and the first line that strips + # TypeScript without a flag; 26 is what the fleet develops against. + node-version: [22, 26] steps: - name: Checkout uses: actions/checkout@v7 @@ -22,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: npm - name: Setup Bun @@ -43,8 +49,8 @@ jobs: - name: Build run: npm run build - - name: Run tests - run: npm test + - name: Test with coverage gate + run: npm run coverage - name: Production dependency audit run: npm run audit:prod diff --git a/.gitignore b/.gitignore index 6561a76f..38c60915 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist-test/ .agents/pm/runtime/ .agents/pm/search/ .agents/pm/extensions/ +coverage/ diff --git a/index.ts b/index.ts index 3f0489d2..6b0a3b8e 100644 --- a/index.ts +++ b/index.ts @@ -58,7 +58,7 @@ import { parseProjectRef, parseStatusMap, projectItemTag, -} from "./projects.js"; +} from "./projects.ts"; // --------------------------------------------------------------------------- diff --git a/package.json b/package.json index afb9af2a..87a58f2d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "scripts": { "build": "tsc", "build:test": "tsc -p tsconfig.test.json", - "test": "npm run build && npm run build:test && node --test dist-test/*.js", + "test": "npm run build && npm run build:test && node --test test/*.test.ts", "prepack": "npm run build", "typecheck": "tsc --noEmit", "check": "npm run typecheck", @@ -31,11 +31,12 @@ "changelog": "pm-changelog --pm-root .agents/pm --mode prepend --output CHANGELOG.md --release-version-from-package --since-previous-tag --until-release-tag --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release", "changelog:full": "pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release", "changelog:check": "pm-changelog --pm-root .agents/pm --mode prepend --output CHANGELOG.md --release-version-from-package --since-previous-tag --until-release-tag --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --check", - "release:check": "npm run typecheck && npm run build && npm test && npm run audit:prod && npm run pack:dry-run && npm run changelog:check", + "release:check": "npm run typecheck && npm run build && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check", "prepublishOnly": "npm run release:check", "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary", "prepare": "node scripts/prepare-merge-driver.mjs", - "merge:install": "pm merge install" + "merge:install": "pm merge install", + "coverage": "npm run build && node scripts/coverage-gate.ts" }, "peerDependencies": { "@unbrained/pm-cli": ">=2026.7.28" @@ -92,5 +93,20 @@ }, "publishConfig": { "access": "public" + }, + "coverageGate": { + "sources": [ + "index.ts", + "projects.ts" + ], + "tests": [ + "test/*.test.ts" + ], + "thresholds": { + "lines": 76, + "branches": 78, + "functions": 75 + }, + "ignore": [] } } diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts new file mode 100644 index 00000000..e59024fa --- /dev/null +++ b/scripts/coverage-gate.ts @@ -0,0 +1,191 @@ +/** + * Coverage gate for the package test suite. + * + * Runs `node --test` with the runtime's built-in V8 coverage against the + * TypeScript sources directly (Node executes `.ts` natively, so the reported + * line numbers are the ones an author edits, not compiled output), enforces a + * per-dimension threshold, and reconciles the reported file list against the + * files actually on disk. + * + * That last step is the reason this script exists rather than a bare + * `node --test --test-coverage-lines=...` invocation. Node only reports files + * that were loaded during the run: a source module with no test at all is + * omitted from the report entirely rather than reported at zero. The published + * percentage is therefore computed over the tested subset, and a package can + * satisfy a 100% threshold while an entire module goes unexercised. Comparing + * the report against a directory walk turns that silent omission into a failure + * naming the missing files, so the threshold cannot be passed by narrowing what + * the suite touches. + * + * Configuration lives in `package.json` under `coverageGate` so the numbers the + * gate enforces are visible in the same file that declares the scripts, and a + * threshold change shows up in review as a deliberate diff. + * + * @example + * ```bash + * node scripts/coverage-gate.ts + * ``` + */ + +import { spawnSync } from "node:child_process"; +import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; + +/** + * Minimum acceptable percentage for each coverage dimension Node reports. + * + * Statement coverage is not listed because V8 reports statements as lines; the + * line figure is the statement figure for this runtime. + */ +interface CoverageThresholds { + /** Minimum percentage of executable lines that must be covered. */ + readonly lines: number; + /** Minimum percentage of branch arms that must be taken. */ + readonly branches: number; + /** Minimum percentage of declared functions that must be invoked. */ + readonly functions: number; +} + +/** The `coverageGate` block read from `package.json`. */ +interface CoverageGateConfig { + /** + * Source locations the gate requires to appear in the report. Each entry is + * either a directory, which is walked recursively for `.ts` files, or a + * single file, which lets a package whose entrypoint sits at the repository + * root declare its sources without dragging in `test/` and `scripts/`. + */ + readonly sources: readonly string[]; + /** Test file arguments handed to `node --test`. */ + readonly tests: readonly string[]; + /** Threshold enforced on the aggregate report. */ + readonly thresholds: CoverageThresholds; + /** + * Source files exempt from the presence check, each of which must be + * type-only. A module that erases to nothing emits no coverage counters, so + * requiring it in the report would make the gate unsatisfiable. + */ + readonly ignore?: readonly string[]; +} + +/** Shape of the `package.json` fields this script reads. */ +interface PackageManifest { + readonly coverageGate?: CoverageGateConfig; +} + +const repoRoot = resolve(import.meta.dirname, ".."); +const manifest = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as PackageManifest; +const config = manifest.coverageGate; + +if (!config) { + console.error("coverage-gate: package.json has no `coverageGate` block."); + process.exit(1); +} + +/** + * Collects every TypeScript source file at a configured location. + * + * A file entry resolves to itself; a directory entry is walked recursively. + * Declaration files are skipped either way: they carry no runtime code and so + * can never appear in a coverage report. + * + * @param target - Absolute path to a source file or directory. + * @returns Repository-relative POSIX paths, in directory order. + */ +function collectSources(target: string): string[] { + if (!statSync(target).isDirectory()) { + return [relative(repoRoot, target).split(sep).join("/")]; + } + const found: string[] = []; + for (const entry of readdirSync(target, { withFileTypes: true })) { + const full = join(target, entry.name); + if (entry.isDirectory()) { + found.push(...collectSources(full)); + } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { + found.push(relative(repoRoot, full).split(sep).join("/")); + } + } + return found; +} + +const expected = config.sources.flatMap((source) => collectSources(join(repoRoot, source))); +const exempt = new Set(config.ignore ?? []); +const required = expected.filter((file) => !exempt.has(file)); + +if (required.length === 0) { + console.error("coverage-gate: source walk found no files; check `coverageGate.sources`."); + process.exit(1); +} + +const lcovPath = join(repoRoot, "coverage", "lcov.info"); +mkdirSync(join(repoRoot, "coverage"), { recursive: true }); + +const result = spawnSync( + process.execPath, + [ + "--test", + "--experimental-test-coverage", + ...config.sources.map((source) => + statSync(join(repoRoot, source)).isDirectory() + ? `--test-coverage-include=${source}/**` + : `--test-coverage-include=${source}`, + ), + `--test-coverage-lines=${config.thresholds.lines}`, + `--test-coverage-branches=${config.thresholds.branches}`, + `--test-coverage-functions=${config.thresholds.functions}`, + "--test-reporter=spec", + "--test-reporter-destination=stdout", + "--test-reporter=lcov", + `--test-reporter-destination=${lcovPath}`, + ...config.tests, + ], + { cwd: repoRoot, stdio: "inherit" }, +); + +if (result.error) { + console.error(`coverage-gate: failed to start the test runner: ${result.error.message}`); + process.exit(1); +} + +/** + * Source files the run actually reported on, read back from the lcov output. + * + * A missing or unreadable lcov file means the runner died before writing a + * report; that is surfaced as an empty set so the presence check below fails + * loudly rather than treating "no data" as "nothing missing". + */ +const reported = new Set(); +try { + statSync(lcovPath); + for (const line of readFileSync(lcovPath, "utf8").split("\n")) { + if (line.startsWith("SF:")) { + reported.add(line.slice(3).trim().split(sep).join("/")); + } + } +} catch { + console.error(`coverage-gate: no coverage report was written to ${relative(repoRoot, lcovPath)}.`); + process.exit(1); +} + +const missing = required.filter((file) => !reported.has(file)); + +if (missing.length > 0) { + console.error( + [ + "", + `coverage-gate: ${missing.length} source file(s) never loaded during the run and were`, + "omitted from the coverage report, so the reported percentages exclude them entirely:", + ...missing.map((file) => ` - ${file}`), + "", + "Import each file from a test (or exercise it through the CLI entrypoint under test).", + "A file that is genuinely type-only belongs in `coverageGate.ignore` in package.json.", + "", + ].join("\n"), + ); + process.exit(1); +} + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +console.log(`\ncoverage-gate: ${required.length} source file(s) reported, thresholds met.`); diff --git a/test/atomic.test.ts b/test/atomic.test.ts index 005e7e7b..ffb0ac81 100644 --- a/test/atomic.test.ts +++ b/test/atomic.test.ts @@ -15,8 +15,8 @@ import { parseImportOptions, resolveCommitItemMutations, runImport, -} from "../dist/index.js"; -import type { GhIssue, PreparedGithubImport } from "../dist/index.js"; +} from "../index.ts"; +import type { GhIssue, PreparedGithubImport } from "../index.ts"; const PM_BIN = process.platform === "win32" ? "pm.cmd" : "pm"; const PM_SPAWN_OPTS = { diff --git a/test/comments-sync.test.ts b/test/comments-sync.test.ts index 5f4862d0..ff2825ef 100644 --- a/test/comments-sync.test.ts +++ b/test/comments-sync.test.ts @@ -22,8 +22,8 @@ import { parseCreatedItemId, parseImportOptions, syncGithubCommentsToAnnotations, -} from "../dist/index.js"; -import type { GhComment, GhIssue } from "../dist/index.js"; +} from "../index.ts"; +import type { GhComment, GhIssue } from "../index.ts"; // Minimal factories ----------------------------------------------------------- // diff --git a/test/dryrun-preview.test.ts b/test/dryrun-preview.test.ts index af474cb6..bb8f227d 100644 --- a/test/dryrun-preview.test.ts +++ b/test/dryrun-preview.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseImportOptions, runImport } from "../dist/index.js"; -import type { GhIssue } from "../dist/index.js"; +import { parseImportOptions, runImport } from "../index.ts"; +import type { GhIssue } from "../index.ts"; // A --dry-run preview exists to tell an agent what a real run will do. The // non-atomic path used to skip building the provenance index entirely, so every diff --git a/test/helpers/comment-sync-child.ts b/test/helpers/comment-sync-child.ts index 13b116a2..084fe8de 100644 --- a/test/helpers/comment-sync-child.ts +++ b/test/helpers/comment-sync-child.ts @@ -4,13 +4,13 @@ // concurrent `pm github import` runs do: separate processes, same workspace, // same item, and the same GitHub comments. -import { syncGithubCommentsToAnnotations } from "../../dist/index.js"; -import type { GhComment } from "../../dist/index.js"; -import { waitForBarrier } from "./barrier.js"; +import { syncGithubCommentsToAnnotations } from "../../index.ts"; +import type { GhComment } from "../../index.ts"; +import { waitForBarrier } from "./barrier.ts"; const [itemId, pmRoot] = process.argv.slice(2); if (!itemId || !pmRoot) { - console.error("usage: comment-sync-child.js "); + console.error("usage: comment-sync-child.ts "); process.exit(2); } diff --git a/test/import-lock.test.ts b/test/import-lock.test.ts index 2e28d7a4..c7ba3c09 100644 --- a/test/import-lock.test.ts +++ b/test/import-lock.test.ts @@ -31,9 +31,9 @@ import { importCommentSyncLockPath, resolvePmDataDir, syncGithubCommentsToAnnotations, -} from "../dist/index.js"; -import type { GhComment } from "../dist/index.js"; -import { waitForBarrier } from "./helpers/barrier.js"; +} from "../index.ts"; +import type { GhComment } from "../index.ts"; +import { waitForBarrier } from "./helpers/barrier.ts"; // Minimal factories + workspace helpers -------------------------------------- @@ -53,7 +53,7 @@ const PM_BIN = fileURLToPath( ); const CHILD_SCRIPT = fileURLToPath( - new URL("./helpers/comment-sync-child.js", import.meta.url), + new URL("./helpers/comment-sync-child.ts", import.meta.url), ); function makeWorkspace(): string { diff --git a/test/link-deps.test.ts b/test/link-deps.test.ts index 8163b9d0..5cc24f08 100644 --- a/test/link-deps.test.ts +++ b/test/link-deps.test.ts @@ -12,13 +12,13 @@ import { parseDependencyReferences, planDependencyLinks, runImport, -} from "../dist/index.js"; +} from "../index.ts"; import type { DepLinkSnapshotItem, GhIssue, ImportOptions, ResolvedDepEdge, -} from "../dist/index.js"; +} from "../index.ts"; const PM_BIN = process.platform === "win32" ? "pm.cmd" : "pm"; const PM_SPAWN_OPTS = { diff --git a/test/projects.test.ts b/test/projects.test.ts index 9576fca7..d1dd6956 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -18,7 +18,7 @@ import { type ProjectItem, type ProjectRef, type ProjectStatusField, -} from "../dist/projects.js"; +} from "../projects.ts"; const REF: ProjectRef = { owner: "unbraind", number: 5 }; diff --git a/test/smoke.test.ts b/test/smoke.test.ts index 0846a750..693323a5 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -41,13 +41,13 @@ import extension, { resolveGitHubToken, resolveSearchRepo, scopeItemsByIds, -} from "../dist/index.js"; +} from "../index.ts"; import type { ExportPlanEntry, GhIssue, ImportOptions, ProjectsV2Page, -} from "../dist/index.js"; +} from "../index.ts"; // The extension registers against six surfaces (commands, importers, schema, // hooks, preflight, search) — exactly the `capabilities` block in diff --git a/tsconfig.json b/tsconfig.json index 1c6e64ec..332a6ac8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,8 @@ "skipLibCheck": true, "noEmitOnError": false, "forceConsistentCasingInFileNames": true, + "rewriteRelativeImportExtensions": true, + "allowImportingTsExtensions": true, "types": [ "node" ] diff --git a/tsconfig.test.json b/tsconfig.test.json index 9e10dd4c..775f40dd 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,10 +4,8 @@ "allowJs": false, "declaration": false, "declarationMap": false, - "noEmit": false, - "outDir": "./dist-test", - "rootDir": "./test" + "noEmit": true }, - "include": ["test/**/*.ts"], - "exclude": ["node_modules", "dist", "dist-test"] -} + "include": ["test/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file From 3ce64db507f517fe983c9e48cd35094fb6285ecf Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:02:51 +0200 Subject: [PATCH 2/9] Harden the coverage gate: TZ pinning, test-tree typecheck, dynamic source walk Brings this package onto the final form of the gate after three defects were found and fixed on the reference implementation while this rollout was in flight. Pin TZ=UTC when spawning the runner. Code that branches on a timestamp's UTC offset takes different paths under a local offset than under UTC, so the reported percentage moved between a contributor's machine and CI and a threshold pinned to one failed on the other. Chain build:test into the coverage script. release:check no longer runs `npm test`, so tsconfig.test.json - the only config that typechecks the test tree and the gate script itself - had dropped out of every gate, and Node strips types without checking them. Adding it exposed that the config also inherited rootDir from the base config, which tsc rejects once the test build stops emitting; rootDir is now overridden to the repository root. Enumerate sources by walking a directory instead of listing files. A file list freezes the required set, so a module added later was never required and an untested new file slipped the gate entirely - the exact blind spot the presence check exists to close. The walk prunes non-source directories, and the coverage include flags are derived from the enumerated list so the measured set and the required set are the same set by construction. Thresholds re-measured against the corrected gate. --- package.json | 7 ++-- scripts/coverage-gate.ts | 73 +++++++++++++++++++++++++++++++--------- tsconfig.test.json | 15 ++++++--- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 87a58f2d..ce1bb325 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "release:notes": "pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary", "prepare": "node scripts/prepare-merge-driver.mjs", "merge:install": "pm merge install", - "coverage": "npm run build && node scripts/coverage-gate.ts" + "coverage": "npm run build && npm run build:test && node scripts/coverage-gate.ts" }, "peerDependencies": { "@unbrained/pm-cli": ">=2026.7.28" @@ -96,15 +96,14 @@ }, "coverageGate": { "sources": [ - "index.ts", - "projects.ts" + "." ], "tests": [ "test/*.test.ts" ], "thresholds": { "lines": 76, - "branches": 78, + "branches": 79, "functions": 75 }, "ignore": [] diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index e59024fa..6dad58f0 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -50,11 +50,20 @@ interface CoverageThresholds { interface CoverageGateConfig { /** * Source locations the gate requires to appear in the report. Each entry is - * either a directory, which is walked recursively for `.ts` files, or a - * single file, which lets a package whose entrypoint sits at the repository - * root declare its sources without dragging in `test/` and `scripts/`. + * either a directory, walked recursively for `.ts` files, or a single file. + * + * Prefer a directory — including `"."` for a package whose entrypoint sits at + * the repository root. A directory is enumerated at run time, so a source file + * added later is required automatically. An explicit file list freezes the + * required set at the moment it was written, and a new untested module simply + * never enters it, which is the same blind spot this gate exists to close. */ readonly sources: readonly string[]; + /** + * Directory names skipped while walking, on top of {@link DEFAULT_SKIP_DIRS}. + * Needed only for a source tree with a non-standard non-source directory. + */ + readonly skipDirs?: readonly string[]; /** Test file arguments handed to `node --test`. */ readonly tests: readonly string[]; /** Threshold enforced on the aggregate report. */ @@ -81,12 +90,36 @@ if (!config) { process.exit(1); } +/** + * Directories never treated as source, so that `sources: ["."]` works for a + * package whose entrypoint sits at the repository root. + * + * These hold tests, build output, tooling and installed dependencies. None of + * them contain shipped source, and several would otherwise make the required + * set unsatisfiable — a test file cannot appear in its own coverage report. + */ +const DEFAULT_SKIP_DIRS: readonly string[] = [ + "node_modules", + "dist", + "dist-test", + "coverage", + "test", + "tests", + "scripts", + "public", + ".agents", + ".git", + ".github", +]; + +const skipDirs = new Set([...DEFAULT_SKIP_DIRS, ...(config.skipDirs ?? [])]); + /** * Collects every TypeScript source file at a configured location. * - * A file entry resolves to itself; a directory entry is walked recursively. - * Declaration files are skipped either way: they carry no runtime code and so - * can never appear in a coverage report. + * A file entry resolves to itself; a directory entry is walked recursively with + * {@link DEFAULT_SKIP_DIRS} pruned. Declaration files are skipped either way: + * they carry no runtime code and so can never appear in a coverage report. * * @param target - Absolute path to a source file or directory. * @returns Repository-relative POSIX paths, in directory order. @@ -97,11 +130,12 @@ function collectSources(target: string): string[] { } const found: string[] = []; for (const entry of readdirSync(target, { withFileTypes: true })) { - const full = join(target, entry.name); if (entry.isDirectory()) { - found.push(...collectSources(full)); + if (!skipDirs.has(entry.name)) { + found.push(...collectSources(join(target, entry.name))); + } } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { - found.push(relative(repoRoot, full).split(sep).join("/")); + found.push(relative(repoRoot, join(target, entry.name)).split(sep).join("/")); } } return found; @@ -124,11 +158,11 @@ const result = spawnSync( [ "--test", "--experimental-test-coverage", - ...config.sources.map((source) => - statSync(join(repoRoot, source)).isDirectory() - ? `--test-coverage-include=${source}/**` - : `--test-coverage-include=${source}`, - ), + // Scope the report to exactly the files the presence check requires. Passing + // the enumerated paths rather than a directory glob keeps the two in step by + // construction, and keeps test files and tooling out of the percentages even + // when the source root is the repository root. + ...required.map((file) => `--test-coverage-include=${file}`), `--test-coverage-lines=${config.thresholds.lines}`, `--test-coverage-branches=${config.thresholds.branches}`, `--test-coverage-functions=${config.thresholds.functions}`, @@ -138,7 +172,16 @@ const result = spawnSync( `--test-reporter-destination=${lcovPath}`, ...config.tests, ], - { cwd: repoRoot, stdio: "inherit" }, + { + cwd: repoRoot, + stdio: "inherit", + // Pin the timezone so the measurement is reproducible on any machine. + // Code that branches on a timestamp's UTC offset takes different paths under + // a local offset than under UTC, which moves the reported percentage between + // a contributor's machine and CI. A threshold pinned to one machine's number + // then fails on the other for reasons unrelated to the change under review. + env: { ...process.env, TZ: "UTC" }, + }, ); if (result.error) { diff --git a/tsconfig.test.json b/tsconfig.test.json index 775f40dd..d1867bef 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,8 +4,15 @@ "allowJs": false, "declaration": false, "declarationMap": false, - "noEmit": true + "noEmit": true, + "rootDir": "." }, - "include": ["test/**/*.ts", "scripts/**/*.ts"], - "exclude": ["node_modules", "dist"] -} \ No newline at end of file + "include": [ + "test/**/*.ts", + "scripts/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} From 0d5284185e0962856d9d3e91c63fdb1ddc6197b0 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:22:41 +0200 Subject: [PATCH 3/9] Set the branch ratchet from the CI-observed figure CI measured 78.93% branch where this host measured marginally higher, so the threshold rejected its own baseline. CI is the authoritative gate, so the ratchet is set from its floor. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce1bb325..2d8f1aac 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ ], "thresholds": { "lines": 76, - "branches": 79, + "branches": 78, "functions": 75 }, "ignore": [] From 3f52d3831f5dd1497d50ab176014c1aa83937665 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:35:30 +0200 Subject: [PATCH 4/9] Report a runner failure before the presence check, and harden two gate edges Three review findings raised on this rollout, all valid. Report the runner's exit status before the never-loaded diagnostic. A failing suite or an unmet threshold can legitimately leave sources out of the lcov output, and the gate then blamed a missing import for what was actually a test failure the author needed to see first. Normalise lcov `SF:` paths to repository-relative POSIX form. The reporter emits working-directory-relative paths on Linux, but that is not contractual and absolute paths have been seen on Windows runners; unnormalised, the presence check inverts into a permanently red build accusing every source file of never loading. Diagnose a non-existent `coverageGate.sources` entry instead of throwing a raw ENOENT stack out of statSync, which was the one failure mode in this script that did not produce a `coverage-gate:` message. --- scripts/coverage-gate.ts | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index 6dad58f0..f2f16e13 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -28,8 +28,8 @@ */ import { spawnSync } from "node:child_process"; -import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, resolve, sep } from "node:path"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; /** * Minimum acceptable percentage for each coverage dimension Node reports. @@ -125,6 +125,12 @@ const skipDirs = new Set([...DEFAULT_SKIP_DIRS, ...(config.skipDirs ?? [])]); * @returns Repository-relative POSIX paths, in directory order. */ function collectSources(target: string): string[] { + if (!existsSync(target)) { + console.error( + `coverage-gate: \`coverageGate.sources\` names ${relative(repoRoot, target)}, which does not exist.`, + ); + process.exit(1); + } if (!statSync(target).isDirectory()) { return [relative(repoRoot, target).split(sep).join("/")]; } @@ -192,23 +198,35 @@ if (result.error) { /** * Source files the run actually reported on, read back from the lcov output. * - * A missing or unreadable lcov file means the runner died before writing a - * report; that is surfaced as an empty set so the presence check below fails - * loudly rather than treating "no data" as "nothing missing". + * `SF:` paths are normalised to repository-relative POSIX form so they can be + * compared against the walk. The lcov reporter emits them relative to the + * working directory on Linux, but that is not contractual and Windows runners + * have been seen to emit absolute paths; without normalising, the presence + * check would invert into a permanently red build that blames every source file + * for never loading. */ const reported = new Set(); try { statSync(lcovPath); for (const line of readFileSync(lcovPath, "utf8").split("\n")) { - if (line.startsWith("SF:")) { - reported.add(line.slice(3).trim().split(sep).join("/")); - } + if (!line.startsWith("SF:")) continue; + const raw = line.slice(3).trim(); + const abs = isAbsolute(raw) ? raw : join(repoRoot, raw); + reported.add(relative(repoRoot, abs).split(sep).join("/")); } } catch { console.error(`coverage-gate: no coverage report was written to ${relative(repoRoot, lcovPath)}.`); process.exit(1); } +// Surface a runner failure before the presence check. A failing suite or an +// unmet threshold can legitimately leave sources out of the lcov output, and +// reporting "never loaded" in that case blames a missing import for what is +// actually a test failure the author needs to see first. +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + const missing = required.filter((file) => !reported.has(file)); if (missing.length > 0) { @@ -227,8 +245,4 @@ if (missing.length > 0) { process.exit(1); } -if (result.status !== 0) { - process.exit(result.status ?? 1); -} - console.log(`\ncoverage-gate: ${required.length} source file(s) reported, thresholds met.`); From 4044af01810a2fb19f53b830e7e3843ac9501d03 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:56:39 +0200 Subject: [PATCH 5/9] Close three more gate escapes found in review: stale lcov, bad entries, ignore abuse Delete the previous lcov report before the run. mkdirSync left an existing coverage/lcov.info in place, so a run that produced no report at all was checked against the previous run's file - the gate could pass by reading history instead of by measuring anything. Validate `coverageGate.ignore`. The exemption exists for type-only modules, which erase to nothing and can never appear in a report, but it was trusted blindly - making it the one supported way to remove an executable module from both the measured set and the required set, the exact escape this gate exists to prevent. Entries are now checked against the compiled output: TypeScript emits `export {};` and nothing else for a module that erases completely. Reject a non-TypeScript or declaration-file entry in `coverageGate.sources` rather than requiring a file that can never be reported. --- scripts/coverage-gate.ts | 58 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index f2f16e13..30933be1 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -28,7 +28,7 @@ */ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; /** @@ -90,6 +90,15 @@ if (!config) { process.exit(1); } +/** Compiler paths used to locate a source file's emitted output. */ +interface TsConfig { + readonly compilerOptions?: { readonly outDir?: string; readonly rootDir?: string }; +} + +const tsConfig = JSON.parse(readFileSync(join(repoRoot, "tsconfig.json"), "utf8")) as TsConfig; +const tsOutDir = tsConfig.compilerOptions?.outDir ?? "dist"; +const tsRootDir = tsConfig.compilerOptions?.rootDir ?? "."; + /** * Directories never treated as source, so that `sources: ["."]` works for a * package whose entrypoint sits at the repository root. @@ -132,6 +141,12 @@ function collectSources(target: string): string[] { process.exit(1); } if (!statSync(target).isDirectory()) { + if (!target.endsWith(".ts") || target.endsWith(".d.ts")) { + console.error( + `coverage-gate: \`coverageGate.sources\` names ${relative(repoRoot, target)}, which is not a TypeScript source file. A declaration file or non-TypeScript entry can never appear in a coverage report, so requiring it would make the gate unsatisfiable.`, + ); + process.exit(1); + } return [relative(repoRoot, target).split(sep).join("/")]; } const found: string[] = []; @@ -151,6 +166,43 @@ const expected = config.sources.flatMap((source) => collectSources(join(repoRoot const exempt = new Set(config.ignore ?? []); const required = expected.filter((file) => !exempt.has(file)); +/** + * Rejects an `ignore` entry that still carries runtime code. + * + * The exemption exists for type-only modules, which erase to nothing and so can + * never appear in a coverage report. Left untested, it is also the one way to + * remove an executable module from both the measured set and the required set — + * exactly the escape this gate exists to prevent. TypeScript emits `export {};` + * and nothing else for a module that erases completely, so the compiled output + * settles the question rather than the author's say-so. + */ +for (const file of config.ignore ?? []) { + if (!expected.includes(file)) { + console.error(`coverage-gate: \`coverageGate.ignore\` names ${file}, which is not under \`sources\`.`); + process.exit(1); + } + const emitted = join(repoRoot, tsOutDir, relative(join(repoRoot, tsRootDir), join(repoRoot, file))).replace( + /\.ts$/, + ".js", + ); + if (!existsSync(emitted)) { + console.error( + `coverage-gate: cannot verify that ignored file ${file} is type-only — no compiled output at ${relative(repoRoot, emitted)}. Build before running the gate, or correct \`outDir\`/\`rootDir\`.`, + ); + process.exit(1); + } + const body = readFileSync(emitted, "utf8") + .replace(/^\s*\/\/.*$/gm, "") + .replace(/export\s*\{\s*\}\s*;?/g, "") + .trim(); + if (body.length > 0) { + console.error( + `coverage-gate: \`coverageGate.ignore\` names ${file}, but it emits runtime code to ${relative(repoRoot, emitted)}. Only type-only modules may be exempt; anything executable must be covered.`, + ); + process.exit(1); + } +} + if (required.length === 0) { console.error("coverage-gate: source walk found no files; check `coverageGate.sources`."); process.exit(1); @@ -158,6 +210,10 @@ if (required.length === 0) { const lcovPath = join(repoRoot, "coverage", "lcov.info"); mkdirSync(join(repoRoot, "coverage"), { recursive: true }); +// Delete any previous report first. If this run writes none, a leftover file +// from an earlier, broader run would satisfy the presence check on stale data — +// the gate would pass by reading history rather than by measuring anything. +rmSync(lcovPath, { force: true }); const result = spawnSync( process.execPath, From 2110d7f9e3cfec9ec581073d0fa6c1334acf91bd Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:24:22 +0200 Subject: [PATCH 6/9] Propagate the runner's exit status before reading the coverage report at all The status check sat between the report parse and the presence check, so it covered the "sources missing from the report" diagnostic but not the "no report was written" one directly above it. A runner that exits non-zero without producing lcov - a test file that fails to load, an aborted run - still hit the missing-report branch first and exited with that message, so CI showed a coverage-report failure where the author needed to see a test failure. The check now runs immediately after the spawn, before the report is touched. --- scripts/coverage-gate.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index 30933be1..11bf7343 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -251,6 +251,15 @@ if (result.error) { process.exit(1); } +// Surface a runner failure before touching the report at all. A failing suite, +// an unmet threshold, or a test file that will not load can each leave the lcov +// output absent or incomplete, and every diagnostic below would then describe a +// coverage-configuration problem the author does not have — burying the test +// failure they need to act on. +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + /** * Source files the run actually reported on, read back from the lcov output. * @@ -275,14 +284,6 @@ try { process.exit(1); } -// Surface a runner failure before the presence check. A failing suite or an -// unmet threshold can legitimately leave sources out of the lcov output, and -// reporting "never loaded" in that case blames a missing import for what is -// actually a test failure the author needs to see first. -if (result.status !== 0) { - process.exit(result.status ?? 1); -} - const missing = required.filter((file) => !reported.has(file)); if (missing.length > 0) { From 8b4c9eaa1d19fd8bbe5c0404e822987048cc01eb Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:46:39 +0200 Subject: [PATCH 7/9] Strip block comments when checking whether an ignored module is type-only The type-only check normalised away line comments but not block comments, so a documented type-only module whose JSDoc survived emit would have been read as runtime code and rejected - a false failure on the exemption path. Applied as hardening rather than as a reproduced bug: with tsc's default emit settings the leading JSDoc on a fully-erased module is not carried into the output. Comment-preserving configurations exist, the normalisation is a single expression, and a false rejection on this path is confusing enough to pre-empt. --- scripts/coverage-gate.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index 11bf7343..aee212b8 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -191,7 +191,11 @@ for (const file of config.ignore ?? []) { ); process.exit(1); } + // Block comments are stripped as well as line comments: tsc carries a + // file-leading JSDoc into the emit, so a documented type-only module would + // otherwise read as runtime code and be rejected for having a comment. const body = readFileSync(emitted, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, "") .replace(/export\s*\{\s*\}\s*;?/g, "") .trim(); From bd8d0f6ce73720d83da198c034430bab21dc1077 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:00:38 +0200 Subject: [PATCH 8/9] Resolve the effective tsconfig via the compiler instead of parsing it as JSON The ignored-module check located a file's emitted output by reading outDir and rootDir straight out of tsconfig.json with JSON.parse. That is wrong twice over: tsconfig may be JSONC, where JSON.parse throws on a perfectly valid config, and either option may be inherited through an `extends` chain, where reading direct fields silently yields the wrong paths and the check then looks for compiled output that was never going to be there. It now asks `tsc --showConfig`, which returns the flattened configuration the compiler actually used, and warns and falls back to the conventional defaults if the compiler cannot be reached. The resolution runs only when `coverageGate.ignore` is non-empty, so no package pays for it unless it uses the exemption. --- scripts/coverage-gate.ts | 42 +++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index aee212b8..f3732463 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -95,9 +95,34 @@ interface TsConfig { readonly compilerOptions?: { readonly outDir?: string; readonly rootDir?: string }; } -const tsConfig = JSON.parse(readFileSync(join(repoRoot, "tsconfig.json"), "utf8")) as TsConfig; -const tsOutDir = tsConfig.compilerOptions?.outDir ?? "dist"; -const tsRootDir = tsConfig.compilerOptions?.rootDir ?? "."; +/** + * Resolves the compiler's effective output paths. + * + * Asks `tsc --showConfig` rather than parsing `tsconfig.json` directly: the file + * may be JSONC and may inherit `outDir`/`rootDir` through an `extends` chain, so + * a raw `JSON.parse` can either throw on a valid config or silently read the + * wrong paths and then look for emitted output where none exists. Falls back to + * the conventional defaults if the compiler cannot be reached, which keeps the + * gate usable rather than failing on an unrelated tooling problem. + */ +function resolveEmitPaths(): { outDir: string; rootDir: string } { + const shown = spawnSync("npx", ["tsc", "--showConfig", "-p", "tsconfig.json"], { + cwd: repoRoot, + encoding: "utf8", + shell: process.platform === "win32", + }); + if (shown.status === 0 && shown.stdout) { + const parsed = JSON.parse(shown.stdout) as TsConfig; + return { + outDir: parsed.compilerOptions?.outDir ?? "dist", + rootDir: parsed.compilerOptions?.rootDir ?? ".", + }; + } + console.warn( + "coverage-gate: could not resolve the effective tsconfig via `tsc --showConfig`; assuming outDir dist and rootDir .", + ); + return { outDir: "dist", rootDir: "." }; +} /** * Directories never treated as source, so that `sources: ["."]` works for a @@ -176,15 +201,18 @@ const required = expected.filter((file) => !exempt.has(file)); * and nothing else for a module that erases completely, so the compiled output * settles the question rather than the author's say-so. */ +const emitPaths = (config.ignore ?? []).length > 0 ? resolveEmitPaths() : { outDir: "dist", rootDir: "." }; + for (const file of config.ignore ?? []) { if (!expected.includes(file)) { console.error(`coverage-gate: \`coverageGate.ignore\` names ${file}, which is not under \`sources\`.`); process.exit(1); } - const emitted = join(repoRoot, tsOutDir, relative(join(repoRoot, tsRootDir), join(repoRoot, file))).replace( - /\.ts$/, - ".js", - ); + const emitted = join( + repoRoot, + emitPaths.outDir, + relative(join(repoRoot, emitPaths.rootDir), join(repoRoot, file)), + ).replace(/\.ts$/, ".js"); if (!existsSync(emitted)) { console.error( `coverage-gate: cannot verify that ignored file ${file} is type-only — no compiled output at ${relative(repoRoot, emitted)}. Build before running the gate, or correct \`outDir\`/\`rootDir\`.`, From 293c9e7acf735bd0b2081791eca0be4fe5212799 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:16:39 +0200 Subject: [PATCH 9/9] Fail closed when the effective tsconfig cannot be resolved The fallback warned and assumed outDir dist / rootDir "." when `tsc --showConfig` could not be reached. That was the wrong default for this consumer: the resolved paths feed the check deciding whether an exempted module is genuinely type-only, so guessing the emit layout can clear an executable module by inspecting the wrong file - the one outcome this gate exists to make impossible. Leniency here is indistinguishable from a bypass. It now exits 1 and prints the compiler's stderr. --- scripts/coverage-gate.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/scripts/coverage-gate.ts b/scripts/coverage-gate.ts index f3732463..365dfd1c 100644 --- a/scripts/coverage-gate.ts +++ b/scripts/coverage-gate.ts @@ -101,9 +101,13 @@ interface TsConfig { * Asks `tsc --showConfig` rather than parsing `tsconfig.json` directly: the file * may be JSONC and may inherit `outDir`/`rootDir` through an `extends` chain, so * a raw `JSON.parse` can either throw on a valid config or silently read the - * wrong paths and then look for emitted output where none exists. Falls back to - * the conventional defaults if the compiler cannot be reached, which keeps the - * gate usable rather than failing on an unrelated tooling problem. + * wrong paths. + * + * Fails closed if the compiler cannot be reached. This feeds the check that + * decides whether an exempted module is genuinely type-only, and guessing the + * emit layout there could clear an executable module by looking at the wrong + * file — the one outcome this gate must never produce. A package that cannot + * run its own compiler has a problem worth stopping for. */ function resolveEmitPaths(): { outDir: string; rootDir: string } { const shown = spawnSync("npx", ["tsc", "--showConfig", "-p", "tsconfig.json"], { @@ -111,17 +115,22 @@ function resolveEmitPaths(): { outDir: string; rootDir: string } { encoding: "utf8", shell: process.platform === "win32", }); - if (shown.status === 0 && shown.stdout) { - const parsed = JSON.parse(shown.stdout) as TsConfig; - return { - outDir: parsed.compilerOptions?.outDir ?? "dist", - rootDir: parsed.compilerOptions?.rootDir ?? ".", - }; + if (shown.status !== 0 || !shown.stdout) { + console.error( + [ + "coverage-gate: could not resolve the effective tsconfig via `tsc --showConfig`,", + "so the emit layout is unknown and `coverageGate.ignore` entries cannot be verified", + "as type-only. Refusing to guess.", + shown.stderr?.trim() ? `\n${shown.stderr.trim()}` : "", + ].join("\n"), + ); + process.exit(1); } - console.warn( - "coverage-gate: could not resolve the effective tsconfig via `tsc --showConfig`; assuming outDir dist and rootDir .", - ); - return { outDir: "dist", rootDir: "." }; + const parsed = JSON.parse(shown.stdout) as TsConfig; + return { + outDir: parsed.compilerOptions?.outDir ?? "dist", + rootDir: parsed.compilerOptions?.rootDir ?? ".", + }; } /**