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..2d8f1aac 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 && npm run build:test && node scripts/coverage-gate.ts" }, "peerDependencies": { "@unbrained/pm-cli": ">=2026.7.28" @@ -92,5 +93,19 @@ }, "publishConfig": { "access": "public" + }, + "coverageGate": { + "sources": [ + "." + ], + "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..365dfd1c --- /dev/null +++ b/scripts/coverage-gate.ts @@ -0,0 +1,346 @@ +/** + * 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 { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { isAbsolute, 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, 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. */ + 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); +} + +/** Compiler paths used to locate a source file's emitted output. */ +interface TsConfig { + readonly compilerOptions?: { readonly outDir?: string; readonly rootDir?: string }; +} + +/** + * 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. + * + * 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"], { + cwd: repoRoot, + encoding: "utf8", + shell: process.platform === "win32", + }); + 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); + } + const parsed = JSON.parse(shown.stdout) as TsConfig; + return { + outDir: parsed.compilerOptions?.outDir ?? "dist", + rootDir: parsed.compilerOptions?.rootDir ?? ".", + }; +} + +/** + * 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 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. + */ +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()) { + 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[] = []; + for (const entry of readdirSync(target, { withFileTypes: true })) { + if (entry.isDirectory()) { + 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, join(target, entry.name)).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)); + +/** + * 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. + */ +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, + 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\`.`, + ); + 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(); + 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); +} + +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, + [ + "--test", + "--experimental-test-coverage", + // 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}`, + "--test-reporter=spec", + "--test-reporter-destination=stdout", + "--test-reporter=lcov", + `--test-reporter-destination=${lcovPath}`, + ...config.tests, + ], + { + 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) { + console.error(`coverage-gate: failed to start the test runner: ${result.error.message}`); + 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. + * + * `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:")) 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); +} + +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); +} + +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..d1867bef 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,10 +4,15 @@ "allowJs": false, "declaration": false, "declarationMap": false, - "noEmit": false, - "outDir": "./dist-test", - "rootDir": "./test" + "noEmit": true, + "rootDir": "." }, - "include": ["test/**/*.ts"], - "exclude": ["node_modules", "dist", "dist-test"] + "include": [ + "test/**/*.ts", + "scripts/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] }