diff --git a/.agents/pm/features/pm-linear-v618.toon b/.agents/pm/features/pm-linear-v618.toon new file mode 100644 index 00000000..ec715556 --- /dev/null +++ b/.agents/pm/features/pm-linear-v618.toon @@ -0,0 +1,23 @@ +id: pm-linear-v618 +title: Enforce a real coverage gate by running tests against TypeScript sources +description: "The package measured no coverage at all: tests imported the compiled output from dist, so the only obtainable numbers pointed at generated JavaScript offsets an author cannot act on, and no threshold was wired into any gate. The suite now runs against the TypeScript source directly. Node executes TypeScript natively from the version floor this package already declares, so tests import the source module and the coverage report lands on real source lines. The gate is scripts/coverage-gate.ts: it enforces per-dimension thresholds and reconciles the reported file list against the configured source list. That second check is the point. Node omits files that never load from its report entirely rather than reporting them at zero, so without it a package can satisfy a threshold while an entire module goes unexercised." +type: Feature +status: closed +priority: 1 +tags[3]: code-quality,coverage,testing +created_at: "2026-07-28T23:44:31.418Z" +updated_at: "2026-07-28T23:44:40.001Z" +deadline: "2026-08-04T23:44:31.418Z" +closed_at: "2026-07-28T23:44:40.000Z" +author: pi-agent +estimated_minutes: 240 +acceptance_criteria: "Tests import the TypeScript source rather than dist so coverage reports on editable lines; The gate fails the build when any threshold regresses; The gate fails when a configured source file never loads, proven by a negative control; Thresholds are recorded in package.json as a ratchet at the measured baseline; The gate runs in the release check and in CI on Node 22 and 26" +resolution: "Tests moved onto the TypeScript source 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 the TypeScript source, and fails when the source is untested or a threshold regresses." +actual_result: "npm run check passes. npm run coverage passes with 63 tests, index.ts present in the report, at 70.53 line, 80.42 branch and 80.43 function coverage. A negative control adding an untested __probe.ts source made the gate exit 1 and name that file; removing it returned exit 0. Thresholds pinned at 70/80/80. CI legs unobserved until the PR runs." +comments[1]{created_at,author,text}: + "2026-07-28T23:44:35.248Z",pi-agent,"Negative control run: with a throwaway __probe.ts source added to coverageGate.sources, the gate exited 1 and named __probe.ts as never loaded; after removing the probe it exited 0 again. Presence check is not vacuous." +learnings[1]{created_at,author,text}: + "2026-07-28T23:44:35.677Z",pi-agent,"Node omits never-loaded files from its coverage report entirely 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 configured source list." +close_reason: Coverage gate landed and locally verified; the Node 22 and 26 CI legs are unobserved until the pull request runs. +body: "Baseline measured 2026-07-29 across the single source file index.ts: 70.53 line, 80.42 branch, 80.43 function. Thresholds pinned at the measured floor (70/80/80) so the number can only ratchet up." diff --git a/.agents/pm/history/pm-linear-v618.jsonl b/.agents/pm/history/pm-linear-v618.jsonl new file mode 100644 index 00000000..cae9d8bd --- /dev/null +++ b/.agents/pm/history/pm-linear-v618.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-07-28T23:44:31.418Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_provenance":{"model":null},"op":"create","patch":[{"op":"replace","path":"/body","value":"Baseline measured 2026-07-29 across the single source file index.ts: 70.53 line, 80.42 branch, 80.43 function. Thresholds pinned at the measured floor (70/80/80) so the number can only ratchet up."},{"op":"add","path":"/metadata/id","value":"pm-linear-v618"},{"op":"add","path":"/metadata/title","value":"Enforce a real coverage gate by running tests against TypeScript sources"},{"op":"add","path":"/metadata/description","value":"The package measured no coverage at all: tests imported the compiled output from dist, so the only obtainable numbers pointed at generated JavaScript offsets an author cannot act on, and no threshold was wired into any gate. The suite now runs against the TypeScript source directly. Node executes TypeScript natively from the version floor this package already declares, so tests import the source module and the coverage report lands on real source lines. The gate is scripts/coverage-gate.ts: it enforces per-dimension thresholds and reconciles the reported file list against the configured source list. That second check is the point. Node omits files that never load from its report entirely rather than reporting them at zero, so without it a package can satisfy a threshold while an entire module goes unexercised."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["code-quality","coverage","testing"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-28T23:44:31.418Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-28T23:44:31.418Z"},{"op":"add","path":"/metadata/deadline","value":"2026-08-04T23:44:31.418Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/author","value":"pi-agent"},{"op":"add","path":"/metadata/estimated_minutes","value":240},{"op":"add","path":"/metadata/acceptance_criteria","value":"Tests import the TypeScript source rather than dist so coverage reports on editable lines; The gate fails the build when any threshold regresses; The gate fails when a configured source file never loads, proven by a negative control; Thresholds are recorded in package.json as a ratchet at the measured baseline; The gate runs in the release check and in CI on Node 22 and 26"},{"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 the TypeScript source, and fails when the source is untested or a threshold regresses."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"b568294529a3482ab96c1a05fc1ff6cdc67d169329dd60c5915ed2e18097c0dc","message":""} +{"ts":"2026-07-28T23:44:35.248Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_provenance":{"model":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-28T23:44:35.248Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-07-28T23:44:35.248Z","author":"pi-agent","text":"Negative control run: with a throwaway __probe.ts source added to coverageGate.sources, the gate exited 1 and named __probe.ts as never loaded; after removing the probe it exited 0 again. Presence check is not vacuous."}]}],"before_hash":"b568294529a3482ab96c1a05fc1ff6cdc67d169329dd60c5915ed2e18097c0dc","after_hash":"3d60f1f5a5512f3fd310ad27af56ea57968ca8f4f25cf43dab06fb7a6b9a7d97"} +{"ts":"2026-07-28T23:44:35.677Z","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:44:35.677Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-07-28T23:44:35.677Z","author":"pi-agent","text":"Node omits never-loaded files from its coverage report entirely 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 configured source list."}]}],"before_hash":"3d60f1f5a5512f3fd310ad27af56ea57968ca8f4f25cf43dab06fb7a6b9a7d97","after_hash":"f6cef1ff20960102859df670da0c05689557ed09354b1decd9c9ded8f5292832"} +{"ts":"2026-07-28T23:44:40.001Z","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:44:40.001Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-28T23:44:40.000Z"},{"op":"add","path":"/metadata/resolution","value":"Tests moved onto the TypeScript source and scripts/coverage-gate.ts wired into release:check and CI."},{"op":"add","path":"/metadata/actual_result","value":"npm run check passes. npm run coverage passes with 63 tests, index.ts present in the report, at 70.53 line, 80.42 branch and 80.43 function coverage. A negative control adding an untested __probe.ts source made the gate exit 1 and name that file; removing it returned exit 0. Thresholds pinned at 70/80/80. CI legs unobserved until the PR runs."},{"op":"add","path":"/metadata/close_reason","value":"Coverage gate landed and locally verified; the Node 22 and 26 CI legs are unobserved until the pull request runs."}],"before_hash":"f6cef1ff20960102859df670da0c05689557ed09354b1decd9c9ded8f5292832","after_hash":"f7dd19c6de6ed2b4bbc42bcf63068f130407dbc1f8a7416d012956ff46aeb5c8"} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f071233f..cbad742a 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 @@ -34,11 +40,8 @@ jobs: - name: Type check run: npm run typecheck - - 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 5445f4a8..fa06923f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ dist-test/ .env.* !.env.example .agents/pm/extensions/ + +coverage/ diff --git a/package.json b/package.json index 32ac646f..1b74dc5f 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", "dev": "tsc --watch", "prepack": "npm run build", "typecheck": "tsc --noEmit", @@ -32,11 +32,26 @@ "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-linear/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-linear/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-linear/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-linear/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" + }, + "coverageGate": { + "sources": [ + "." + ], + "tests": [ + "test/*.test.ts" + ], + "thresholds": { + "lines": 70, + "branches": 80, + "functions": 80 + }, + "ignore": [] }, "peerDependencies": { "@unbrained/pm-cli": ">=2026.7.28" 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 8390bc51..dc52d5f2 100644 --- a/test/atomic.test.ts +++ b/test/atomic.test.ts @@ -13,8 +13,8 @@ import extension, { deriveAtomicTransactionId, importLinearAtomic, syncLinearIssues, -} from "../dist/index.js"; -import type { PreparedLinearImport, LinearIssue } from "../dist/index.js"; +} from "../index.ts"; +import type { PreparedLinearImport, LinearIssue } from "../index.ts"; import { createExtensionTestHarness, type ExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; const PM_BIN = process.platform === "win32" ? "pm.cmd" : "pm"; diff --git a/test/smoke.test.ts b/test/smoke.test.ts index e8af6d2a..53031ac1 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -35,7 +35,7 @@ import extension, { resolveTeamSelection, AuthHttpError, normalizeCycleFilter, -} from "../dist/index.js"; +} from "../index.ts"; test("extension has required shape", () => { assert.ok(extension, "module should export a default value"); diff --git a/tsconfig.json b/tsconfig.json index 17a43385..e03b452b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,8 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmitOnError": false, + "rewriteRelativeImportExtensions": true, + "allowImportingTsExtensions": true, "types": [ "node" ] diff --git a/tsconfig.test.json b/tsconfig.test.json index 9e10dd4c..1481a087 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,10 +4,16 @@ "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", + "dist-test" + ] }