diff --git a/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.i18n.yaml new file mode 100644 index 0000000000..3e0ab14ab8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md +2026-09-13-partitioned-coverage-location-canonicalization.md: 69afaa5244f3d810bad01079e59a9ce64dfe9f93 +2026-09-13-partitioned-coverage-location-canonicalization.zh.md: 4f32c229af1db01bdc29ea800494610138d1b5d6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md b/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md new file mode 100644 index 0000000000..69afaa5244 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md @@ -0,0 +1,43 @@ +# Agent Note: Canonicalize partition coverage locations before the blob merge + +Status: implemented + +English | [中文](2026-09-13-partitioned-coverage-location-canonicalization.zh.md) + +## Problem + +The coverage gate reported [packages/util/home-paths/src/index.ts](../../../../packages/util/home-paths/src/index.ts) at 96.96% statements with one uncovered statement at `48:22`, while branches, functions, and lines stayed at 100% and the same tests reported the file at 100% in an unpartitioned run. Line 48 holds one statement, so the merged report counted a second, unhit statement the source does not contain. The branch that exposed the failure only added a jsdom suite that loads a node-tested module; it did not touch that module or its package. + +A source file reaches the merged report through one statement map per Vite environment, and the serialized partition blob strips the location data istanbul-lib-coverage reconciles those maps with. A file executed under two environments can therefore fail the per-file 100% gate although every statement ran, and the failure follows the test environments and the partition count rather than the file. + +## Decision + +[scripts/coverage-partitions.ts](../../../../scripts/coverage-partitions.ts) passes every partition `--reporter=./scripts/coverage-canonical-locations.ts`, whose `onCoverage` hook rewrites each non-finite end column of the finished run's coverage map to `Number.MAX_SAFE_INTEGER`. That column keeps the meaning of a location that ends at its line's end, serializes as a number, and keys identically in every blob, so the merge command reconciles environment-specific spellings exactly as an in-process merge does. The per-file 100% gate keeps its full strength for every file `coverage.include` matches: the canonicalization adds hits through istanbul's containment rule and never removes a statement from the report. + +[scripts/coverage-uncovered-locations.cjs](../../../../scripts/coverage-uncovered-locations.cjs) reads the same column as a line end, so an uncovered record prints the same `path:line:col` with or without the canonicalization. The [in-job partitioned coverage](../process/2026-08-18-in-job-partitioned-coverage.md) coordinator owns the partition and merge commands this reporter joins, and it keeps its single merged threshold check. + +## Divergent statement maps across Vite environments + +A node suite maps a source file through the `ssr` environment and a `@vitest-environment jsdom` suite maps it through the `client` environment. The AST-based V8 remapper positions a statement at the node it finds in the transformed code, so one declaration enters the merged map twice: at its declared identifier from the ssr transform, at the nested call expression of the client transform. istanbul-lib-coverage attributes the hits of the narrowest containing range to an entry no other record names, which covers whichever spelling the client record introduces. + +That reconciliation runs on locations `getLoc()` accepts, which requires numeric line and column values. V8 ends a whole-line statement at column `Infinity`; a partition blob serializes `Infinity` as `null`, so the merge command holds a location it cannot compare and keeps the client-only spelling as an extra, unhit statement. The same records merged inside one process, where `Infinity` survives, report no such statement. + +## Testing + +`scripts/coverage-partitions.spec.ts` merges two records that spell one statement both ways through the JSON hop a blob performs, and asserts that the canonicalized merge reports no uncovered statement where the raw merge reports the phantom. A second case pins the canonicalization across statement, function, and branch locations, and a third pins the canonicalizing reporter on every partition command. + +## Alternatives considered + +**Drop the untested-file maps from partitions.** Rejected because those maps are what fails a file no test runs; removing them silences a real coverage gap in exchange for the phantom. + +**Reconcile statements by line in the merge command.** Rejected because several statements can share a line, so line-level merging hides genuinely uncovered code. + +**Keep jsdom suites from loading node-only modules.** Rejected because attribution must not depend on which environment a suite happens to load a module in, and any later cross-environment load would return the defect. + +**Repair the merged map in the report phase.** Rejected because the records are already fused by then, so re-adding hits cannot separate a phantom spelling from a genuinely unhit nested statement. + +**Exempt the file or relax the per-file gate.** Rejected because the file is covered and the gate is correct; the attribution was not. + +## Consequences + +Partitioned runs attribute for a cross-environment file what one process attributes, so the gate holds 100% per file without exempting anything. Canonicalization runs inside partitions only, which leaves unpartitioned runs and their reports as they are. Blobs carry a finite sentinel column for a line-end position, and both the partition reporter and the uncovered-locations reporter name that sentinel as the line-end convention. diff --git a/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.zh.md b/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.zh.md new file mode 100644 index 0000000000..4f32c229af --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 在 blob 合并前规范化分区覆盖率位置 + +Status: implemented + +[English](2026-09-13-partitioned-coverage-location-canonicalization.md) | 中文 + +## 问题 + +覆盖率门禁把 [packages/util/home-paths/src/index.ts](../../../../packages/util/home-paths/src/index.ts) 报为语句 96.96%,并在 `48:22` 标出 1 条未覆盖语句,而分支、函数与行都保持 100%,同一批测试在非分区运行中把该文件报为 100%。第 48 行只有 1 条语句,因此合并报告计入了源码中并不存在的第 2 条未命中语句。暴露该失败的分支只新增了一个加载受节点侧测试覆盖模块的 jsdom 套件,并未改动该模块或其所属包。 + +一个源文件在每个 Vite 环境中各有一份语句映射并据此进入合并报告,而序列化后的分区 blob 抹掉了 istanbul-lib-coverage 用来调和这些映射的位置数据。因此在两个环境中执行过的文件可能在每条语句都跑到的情况下仍未通过逐文件 100% 门禁,且该失败跟随测试环境与分区数量,而不跟随文件本身。 + +## 决策 + +[scripts/coverage-partitions.ts](../../../../scripts/coverage-partitions.ts) 为每个分区传入 `--reporter=./scripts/coverage-canonical-locations.ts`;该报告器的 `onCoverage` 钩子把当次运行覆盖率映射中所有非有限的结束列改写为 `Number.MAX_SAFE_INTEGER`。该列保留“位置结束于所在行行尾”的含义,序列化后仍是数字,并且在每个 blob 中生成相同的键,因此合并命令会像进程内合并那样调和各环境特有的写法。逐文件 100% 门禁对 `coverage.include` 命中的每个文件保持完整强度:规范化只会通过 istanbul 的包含规则增加命中,绝不会把语句移出报告。 + +[scripts/coverage-uncovered-locations.cjs](../../../../scripts/coverage-uncovered-locations.cjs) 把同一列读作行尾,因此无论是否经过规范化,未覆盖记录打印的 `path:line:col` 都相同。[单 job 分区覆盖率](../process/2026-08-18-in-job-partitioned-coverage.zh.md)协调器拥有该报告器所加入的分区与合并命令,并保留其唯一一次合并阈值判定。 + +## 跨 Vite 环境的语句映射分歧 + +节点侧套件把源文件映射到 `ssr` 环境,`@vitest-environment jsdom` 套件把它映射到 `client` 环境。基于 AST 的 V8 重映射器按它在转换后代码中找到的节点定位语句,因此同一条声明会以两种写法进入合并映射:ssr 转换给出其声明标识符的位置,client 转换给出其嵌套调用表达式的位置。istanbul-lib-coverage 会把最窄包含范围的命中计入其他记录都未命名的条目,从而覆盖 client 记录引入的那种写法。 + +该调和只作用于 `getLoc()` 接受的位置,这要求行列值都是数字。V8 把整行语句的结束列标为 `Infinity`;分区 blob 会把 `Infinity` 序列化为 `null`,于是合并命令拿到无法比较的位置,并把仅存在于 client 的写法保留为额外的未命中语句。同样的记录在单个进程内合并时 `Infinity` 得以保留,因此不会报出该语句。 + +## 验证 + +`scripts/coverage-partitions.spec.ts` 让两条以两种写法表示同一语句的记录经过 blob 实际执行的 JSON 跳转后再合并,断言规范化后的合并没有未覆盖语句,而未经规范化的合并会报出该幻影语句。第二个用例固定语句、函数与分支位置的规范化,第三个用例固定每个分区命令都带上该规范化报告器。 + +## 曾考虑的替代方案 + +**在分区中丢弃未测试文件映射。** 不予采用,因为这些映射正是让没有任何测试执行的文件失败的依据;去掉它们等于用真实的覆盖率缺口换取该幻影语句。 + +**在合并命令中按行调和语句。** 不予采用,因为同一行可以承载多条语句,按行合并会掩盖真正未覆盖的代码。 + +**禁止 jsdom 套件加载仅节点侧模块。** 不予采用,因为归因不能取决于套件恰好把模块加载到哪个环境,而且此后任何跨环境加载都会让该缺陷重现。 + +**在报告阶段修复合并后的映射。** 不予采用,因为此时各记录已经融合,补回命中无法区分幻影写法与真正未命中的嵌套语句。 + +**豁免该文件或放宽逐文件门禁。** 不予采用,因为该文件确有覆盖,门禁本身正确,错的是归因。 + +## 后果 + +分区运行对跨环境文件给出与单进程相同的归因,因此门禁在不豁免任何文件的前提下保持逐文件 100%。规范化只在分区内部运行,未分区运行及其报告保持原样。blob 在行尾位置携带一个有限哨兵列,分区报告器与未覆盖位置报告器都把该哨兵命名为行尾约定。 diff --git a/scripts/coverage-canonical-locations.ts b/scripts/coverage-canonical-locations.ts new file mode 100644 index 0000000000..f8c84acfd0 --- /dev/null +++ b/scripts/coverage-canonical-locations.ts @@ -0,0 +1,96 @@ +/** + * Canonicalize coverage locations inside a coverage partition, before its + * blob is written. + * + * Vitest remaps V8 coverage separately per Vite environment, so one source file + * can carry two statement maps in a single run: a node suite maps it through the + * ssr environment, a `@vitest-environment jsdom` suite maps it through the + * client environment, and ast-v8-to-istanbul spells the same statement + * differently in each map (a whole-line statement ends at column `Infinity` in + * the ssr map, at a nested expression column in the client map). + * + * istanbul-lib-coverage reconciles such overlapping ranges while merging, but + * only while a location carries numeric line and column values: `Infinity` + * survives an in-process merge, not the JSON serialization of a partition blob, + * which turns it into `null`. Without that reconciliation the merge keeps the + * client-only spelling as an extra, unhit statement, so a file whose every + * statement ran still fails the per-file 100% gate. + * + * Replacing the non-finite end column with {@link END_OF_LINE_COLUMN} keeps the + * line-end meaning, survives serialization, and keys identically in every blob, + * so the merged report attributes the coverage one process would have reported. + * + * @module + */ + +/** + * End column of a location that spans to the end of its start line, and the + * finite stand-in for ast-v8-to-istanbul's unrepresentable `Infinity`. + */ +export const END_OF_LINE_COLUMN = Number.MAX_SAFE_INTEGER + +/** Mutable structural view of the istanbul coverage map handed to reporters. */ +type CoverageRecord = Record + +/** Whether a value is a non-null object usable as a mutable record. */ +function isRecord(value: unknown): value is CoverageRecord { + return typeof value === 'object' && value !== null +} + +/** Rewrite one location's non-finite end column in place. */ +function canonicalizeLocation(location: unknown): void { + if (!isRecord(location)) return + const end = location['end'] + if (!isRecord(end)) return + const column = end['column'] + if (typeof column === 'number' && Number.isFinite(column)) return + end['column'] = END_OF_LINE_COLUMN +} + +/** Rewrite every location a statement, function, or branch entry carries. */ +function canonicalizeEntry(entry: unknown): void { + if (!isRecord(entry)) return + // A statement is a location itself; a function carries `decl` plus `loc`, and + // a branch carries `loc` plus one location per branch path. + canonicalizeLocation(entry) + canonicalizeLocation(entry['decl']) + canonicalizeLocation(entry['loc']) + const locations = entry['locations'] + if (Array.isArray(locations)) for (const location of locations) canonicalizeLocation(location) +} + +/** + * Replace every non-finite end column of an istanbul coverage map in place. + * @param coverageMap - istanbul `CoverageMap` whose `data` holds per-file coverage; + * any value without that structure is left untouched. + */ +export function canonicalizeEndOfLineColumns(coverageMap: unknown): void { + if (!isRecord(coverageMap)) return + const files = coverageMap['data'] + if (!isRecord(files)) return + for (const file of Object.values(files)) { + if (!isRecord(file)) continue + // FileCoverage keeps its maps on `data` and mirrors them as getters. + const coverage = isRecord(file['data']) ? file['data'] : file + for (const name of ['statementMap', 'fnMap', 'branchMap']) { + const entries = coverage[name] + if (!isRecord(entries)) continue + for (const entry of Object.values(entries)) canonicalizeEntry(entry) + } + } +} + +/** + * Vitest reporter that canonicalizes a partition's coverage map in + * {@link CanonicalCoverageLocationsReporter.onCoverage}, the hook Vitest runs + * before the blob reporter serializes that map. + */ +export default class CanonicalCoverageLocationsReporter { + /** + * Canonicalize the finished run's coverage locations. + * @param coverageMap - coverage map Vitest hands to every reporter. + */ + public onCoverage(coverageMap: unknown): void { + canonicalizeEndOfLineColumns(coverageMap) + } +} diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index b720e539ed..b221a4ef7c 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -1,6 +1,8 @@ import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { COVERAGE_PARTITION_MODE_ENV, @@ -19,6 +21,10 @@ import { type CoverageCommandResult, type CoveragePartitionCoordinatorOptions, } from './coverage-partitions.ts' +import { + END_OF_LINE_COLUMN, + canonicalizeEndOfLineColumns, +} from './coverage-canonical-locations.ts' const passed: CoverageCommandResult = { exitCode: 0, signalCode: null } @@ -273,6 +279,142 @@ describe('coverage file inventory', () => { }) }) +/** + * One source statement as two Vite environments spell it: the ssr map ends the + * declaration at its identifier, the jsdom/client map at the nested call, and + * both stop at their own line's end. + */ +const CRASH_LOOP = { start: { line: 36, column: 2 }, end: { line: 54, column: Infinity } } + +/** File path the canonicalization fixtures share. */ +const CANONICALIZED_FILE = 'packages/util/home-paths/src/index.ts' + +/** Istanbul statement location in the fixture's own terms. */ +interface StatementLocation { + start: { line: number; column: number } + end: { line: number; column: number } +} + +/** Statement hits and locations read back from a merged istanbul map. */ +interface MergedStatements { + s: Record + statementMap: Record +} + +/** The istanbul entry points the merge assertions use. */ +interface CoverageLibrary { + createCoverageMap: (data: unknown) => { + merge: (data: unknown) => void + fileCoverageFor: (file: string) => MergedStatements + } +} + +/** + * istanbul-lib-coverage is the merge implementation the partition blobs feed. + * It arrives as a dependency of the declared istanbul-lib-report devDependency, + * so this spec resolves it through that owner instead of declaring its own copy. + */ +const coverageLibrary = createRequire( + createRequire(import.meta.url).resolve('istanbul-lib-report'), +)('istanbul-lib-coverage') as CoverageLibrary + +/** One file's statement coverage, shaped as a partition blob carries it. */ +function statementRecord(statements: StatementLocation[], hits: number[]): Record { + return { + [CANONICALIZED_FILE]: { + path: CANONICALIZED_FILE, + statementMap: Object.fromEntries(statements.map((location, index) => [index, location])), + s: Object.fromEntries(hits.map((hit, index) => [index, hit])), + fnMap: {}, + f: {}, + branchMap: {}, + b: {}, + }, + } +} + +/** + * Merge partition records the way the coordinator's merge command does. The + * blob serializer's JSON hop turns a non-finite end column into `null`, which + * is what strips istanbul of the range containment it reconciles records with. + * Canonicalization runs on the `CoverageMap` the reporter hook receives, whose + * `data` holds the raw per-file records merged here. + */ +function mergePartitionRecords( + records: Array>, + canonicalize: boolean, +): MergedStatements { + const map = coverageLibrary.createCoverageMap({}) + for (const record of records) { + if (canonicalize) canonicalizeEndOfLineColumns({ data: record }) + map.merge(JSON.parse(JSON.stringify(record)) as unknown) + } + return map.fileCoverageFor(CANONICALIZED_FILE) +} + +/** Start positions of the statements the merged map still reports as unhit. */ +function uncoveredStarts(coverage: MergedStatements): Array<{ line: number; column: number }> { + return Object.entries(coverage.s) + .filter(([, hits]) => hits === 0) + .map(([index]) => coverage.statementMap[index]) + .filter((location): location is StatementLocation => location !== undefined) + .map(location => location.start) +} + +describe('coverage location canonicalization', () => { + // index.ts:48 as the ssr environment spells it (at `parent`) and as the + // client environment spells it (at `dirname(current)`); the loop holding + // both spellings ran in each, and only the client record never took the + // catch branch that owns the statement. + const ssrRecord = statementRecord( + [CRASH_LOOP, { start: { line: 48, column: 12 }, end: { line: 48, column: Infinity } }], + [3, 2], + ) + const clientRecord = statementRecord( + [CRASH_LOOP, { start: { line: 48, column: 21 }, end: { line: 48, column: Infinity } }], + [5, 0], + ) + + it('drops the phantom statement a client-only spelling leaves after the blob merge', () => { + // 33 statements, 32 covered: the ssr spelling is hit while the client + // spelling of the same source statement stays unhit, so a file whose every + // statement ran fails the per-file 100% gate. + expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], false))) + .toEqual([{ line: 48, column: 21 }]) + + expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], true))).toEqual([]) + }) + + it('canonicalizes line-end columns in statement, function, and branch locations', () => { + const lineEnd = (line: number): StatementLocation => ({ + start: { line, column: 4 }, + end: { line, column: Infinity }, + }) + const record = { + [CANONICALIZED_FILE]: { + path: CANONICALIZED_FILE, + statementMap: { 0: lineEnd(10), 1: { start: { line: 11, column: 4 }, end: { line: 11, column: 9 } } }, + s: { 0: 1, 1: 1 }, + fnMap: { 0: { name: 'probe', decl: lineEnd(10), loc: lineEnd(10) } }, + f: { 0: 1 }, + branchMap: { 0: { type: 'if', loc: lineEnd(12), locations: [lineEnd(12), lineEnd(13)] } }, + b: { 0: [1, 1] }, + }, + } + + canonicalizeEndOfLineColumns({ data: record }) + + const file = record[CANONICALIZED_FILE] + expect(file.statementMap[0]?.end.column).toBe(END_OF_LINE_COLUMN) + expect(file.statementMap[1]?.end.column).toBe(9) + expect(file.fnMap[0]?.decl.end.column).toBe(END_OF_LINE_COLUMN) + expect(file.fnMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN) + expect(file.branchMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN) + expect(file.branchMap[0]?.locations[0]?.end.column).toBe(END_OF_LINE_COLUMN) + expect(file.branchMap[0]?.locations[1]?.end.column).toBe(END_OF_LINE_COLUMN) + }) +}) + describe('coverage partition coordinator', () => { const weightedFiles = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts'] const weightedDurations = new Map([ @@ -285,6 +427,31 @@ describe('coverage partition coordinator', () => { ['b.spec.ts', 'process-bound'], ['c.spec.ts', 'process-bound'], ]) + it('canonicalizes each partition before its blob is written', async () => { + const root = await temporaryRoot() + const commands: CoverageCommand[] = [] + const runCommand = successfulCommandRecorder(commands) + const coordinator = new CoveragePartitionCoordinator({ + root, + partitions: 2, + pnpmEntrypoint: '/pnpm.cjs', + files: ['a.spec.ts', 'b.spec.ts'], + runCommand, + }) + + await expect(coordinator.run()).resolves.toBe(0) + + // Every partition serializes its coverage map into a blob, so every + // partition must name the reporter that canonicalizes it first. + const reporterPath = join(dirname(fileURLToPath(import.meta.url)), 'coverage-canonical-locations.ts') + for (const command of commands.slice(0, 2)) { + const argument = command.args.find(candidate => candidate.startsWith('--reporter=') && candidate.endsWith('coverage-canonical-locations.ts')) + if (argument === undefined) throw new Error(`${command.label} does not wire the coverage canonicalizer`) + // The child runs from the repository root, so the reporter resolves there. + expect(resolve(argument.slice('--reporter='.length))).toBe(reporterPath) + } + }) + it('runs every single-worker partition before one merged threshold check', async () => { const root = await temporaryRoot() const commands: CoverageCommand[] = [] diff --git a/scripts/coverage-partitions.ts b/scripts/coverage-partitions.ts index a2d0a2b503..1dbc89390a 100644 --- a/scripts/coverage-partitions.ts +++ b/scripts/coverage-partitions.ts @@ -15,6 +15,14 @@ export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE' /** Environment variable overriding instrumented test, polling, and hook timeouts. */ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS' +/** + * Reporter that canonicalizes a partition's coverage locations before its blob + * is serialized (see scripts/coverage-canonical-locations.ts). Root-relative in + * POSIX spelling because every child runs with the repository root as its + * working directory on every platform. + */ +const CANONICAL_LOCATIONS_REPORTER = './scripts/coverage-canonical-locations.ts' + /** One child command owned by the coverage coordinator. */ export interface CoverageCommand { /** Diagnostic identity. */ @@ -561,6 +569,7 @@ export class CoveragePartitionCoordinator { '--reporter=default', '--reporter=blob', '--reporter=json', + `--reporter=${CANONICAL_LOCATIONS_REPORTER}`, `--outputFile.blob=${this.relativePath(blobPath)}`, `--outputFile.json=${this.relativePath(jsonReportPath)}`, `--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`, diff --git a/scripts/coverage-uncovered-locations.cjs b/scripts/coverage-uncovered-locations.cjs index ba32fb7692..5614380817 100644 --- a/scripts/coverage-uncovered-locations.cjs +++ b/scripts/coverage-uncovered-locations.cjs @@ -17,6 +17,14 @@ const path = require('node:path'); const { ReportBase } = require('istanbul-lib-report'); +/** + * End column of a location that spans to the end of its start line. Whole-line + * statements carry `Infinity` until a partition blob serializes it, which the + * partition reporter replaces with this finite column + * (scripts/coverage-canonical-locations.ts); both spellings read as line ends. + */ +const END_OF_LINE_COLUMN = Number.MAX_SAFE_INTEGER; + /** * Editor-convention `line:column` of an istanbul location start (istanbul * columns are 0-based; editors and terminal link handlers expect 1-based). @@ -30,15 +38,20 @@ function usable(loc) { return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1); } +/** Whether an end column marks its line's end instead of a measured position. */ +function atLineEnd(column) { + return !Number.isFinite(column) || column >= END_OF_LINE_COLUMN; +} + /** * ` (to line:col)` suffix when the range end adds information beyond the - * start. v8-remapped whole-line statements carry end.column = Infinity; those + * start. v8-remapped whole-line statements end at their line end; 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)) { + if (atLineEnd(end.column)) { return end.line === loc.start.line ? '' : ` (to ${end.line})`; } if (end.line === loc.start.line && end.column === loc.start.column) return '';