diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f61278a39f..7d2e257ea9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -130,6 +130,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | | [`knip`](https://github.com/webpro-nl/knip) | ISC | diff --git a/knip.json b/knip.json index 22e2b09fdb..dfb8058d7c 100644 --- a/knip.json +++ b/knip.json @@ -20,11 +20,13 @@ "workspaces": { ".": { "entry": [ - "scripts/**/*.mjs" + "scripts/**/*.mjs", + "scripts/**/*.cjs" ], "project": [ "scripts/**/*.ts", - "scripts/**/*.mjs" + "scripts/**/*.mjs", + "scripts/**/*.cjs" ], "ignoreDependencies": [ "playwright" diff --git a/package.json b/package.json index 905c39f33b..7bd84db93a 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", "fast-check": "^4.8.0", + "istanbul-lib-report": "^3.0.1", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", "jsdom": "29.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b15d260058..01a5a2f64c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: fast-check: specifier: ^4.8.0 version: 4.8.0 + istanbul-lib-report: + specifier: ^3.0.1 + version: 3.0.1 js-yaml: specifier: ^4.2.0 version: 4.2.0 diff --git a/scripts/coverage-uncovered-locations.cjs b/scripts/coverage-uncovered-locations.cjs new file mode 100644 index 0000000000..ba32fb7692 --- /dev/null +++ b/scripts/coverage-uncovered-locations.cjs @@ -0,0 +1,108 @@ +'use strict'; + +/** + * Istanbul coverage reporter printing one clickable `path:line:col` record per + * uncovered statement, branch path, and function. Vitest's per-file threshold + * failures name only the file; this reporter supplies the exact locations, + * printed just above those ERROR lines (reports run before threshold checks). + * Files at 100% print nothing, so a green run stays silent. + * + * CommonJS by requirement: istanbul-reports loads custom reporters with a bare + * require() outside the tsx/ESM pipeline (istanbul-reports index.js create()), + * so this file can be neither TypeScript nor ESM. Wired into vitest.config.ts + * by absolute path — require() would resolve a relative specifier against + * istanbul-reports' own directory. + */ + +const path = require('node:path'); +const { ReportBase } = require('istanbul-lib-report'); + +/** + * Editor-convention `line:column` of an istanbul location start (istanbul + * columns are 0-based; editors and terminal link handlers expect 1-based). + */ +function pos(loc) { + return `${loc.start.line}:${loc.start.column + 1}`; +} + +/** Whether a location carries a usable 1-based start line. */ +function usable(loc) { + return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1); +} + +/** + * ` (to line:col)` suffix when the range end adds information beyond the + * start. v8-remapped whole-line statements carry end.column = Infinity; those + * degrade to a line-only suffix, or to nothing on a single line. + */ +function endSuffix(loc) { + const end = loc.end; + if (!end || !Number.isFinite(end.line) || end.line < 1) return ''; + if (!Number.isFinite(end.column)) { + return end.line === loc.start.line ? '' : ` (to ${end.line})`; + } + if (end.line === loc.start.line && end.column === loc.start.column) return ''; + return ` (to ${end.line}:${end.column + 1})`; +} + +class UncoveredLocationsReport extends ReportBase { + constructor(opts = {}) { + super(opts); + // Vitest passes the resolved config root alongside reporter options. + this.projectRoot = opts.projectRoot || process.cwd(); + this.records = []; + } + + onStart() { + this.records = []; + } + + onDetail(node) { + const fc = node.getFileCoverage(); + const rel = path.relative(this.projectRoot, fc.path).split(path.sep).join('/'); + const items = []; + const add = (loc, text) => items.push({ line: loc.start.line, column: loc.start.column, text }); + + for (const id of Object.keys(fc.statementMap)) { + if (fc.s[id] !== 0) continue; + const loc = fc.statementMap[id]; + if (!usable(loc)) continue; + add(loc, `${rel}:${pos(loc)} uncovered statement${endSuffix(loc)}`); + } + + for (const id of Object.keys(fc.fnMap)) { + if (fc.f[id] !== 0) continue; + const fn = fc.fnMap[id]; + const loc = usable(fn.decl) ? fn.decl : fn.loc; + if (!usable(loc)) continue; + const name = fn.name ? ` ${fn.name}` : ''; + add(loc, `${rel}:${pos(loc)} uncovered function${name}`); + } + + for (const id of Object.keys(fc.branchMap)) { + const counts = fc.b[id]; + const branch = fc.branchMap[id]; + for (let i = 0; i < counts.length; i += 1) { + if (counts[i] !== 0) continue; + // Implicit arms (e.g. a missing else) may carry an empty location; + // fall back to the branch's own span so the record stays clickable. + const loc = usable(branch.locations && branch.locations[i]) ? branch.locations[i] : branch.loc; + if (!usable(loc)) continue; + add(loc, `${rel}:${pos(loc)} uncovered branch (${branch.type}, path ${i + 1}/${counts.length})`); + } + } + + if (items.length === 0) return; + items.sort((a, b) => a.line - b.line || a.column - b.column); + for (const item of items) this.records.push(item.text); + } + + onEnd() { + if (this.records.length === 0) return; + console.log(`\nUncovered locations (per-file 100% gate): ${this.records.length}`); + for (const record of this.records) console.log(record); + console.log(''); + } +} + +module.exports = UncoveredLocationsReport; diff --git a/vitest.config.ts b/vitest.config.ts index 2909fb7459..cd37feb300 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,17 @@ import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import tsconfigPaths from 'vite-tsconfig-paths' import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts' import { defineConfig } from 'vitest/config' import { vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' +// Prints exact `path:line:col` records for every uncovered statement, branch +// path, and function when a file misses the per-file 100% gate — the built-in +// threshold ERRORs name only the file. Absolute path because istanbul-reports +// require()s custom reporters (which is also why the reporter is CJS). +const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-uncovered-locations.cjs', import.meta.url)) + // Resolution facade shared by every plugin instance below: tsconfig.base.json // has no include, which vite-tsconfig-paths treats as match-all, so its paths // map applies to every test file. paths must win over package exports so built @@ -227,7 +234,9 @@ export default defineConfig({ functions: 100, lines: 100, }, - reporter: process.env.CI ? ['text'] : ['text', 'html'], + reporter: process.env.CI + ? ['text', uncoveredLocationsReporter] + : ['text', 'html', uncoveredLocationsReporter], }, }, })