pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
115 lines (95 loc) • 7.37 kB
Markdown
# ADR 0009: reported-path attribution is one seam, based on the runner cwd
## Status
Accepted — 2026-09-23
## Context
Every runner that parses a tool's textual or JSON output has to answer one
question per reported line: *is this diagnostic about the file the dispatcher
ran me for?* Twelve sites answered it with their own predicate — a bare `===` or
`!==` over two `path.resolve` results, a `path.resolve` with NO base argument, a
`path.posix.basename` compare, and two `endsWith` compares. #209 and #3277 are
the shipped defects of the first two shapes: a spelling that differs only in
case is the SAME file on Windows and on a case-folding POSIX mount, so the
compare dropped every finding for the edited file and the run reported clean.
#3278 measured the population at ~11 and #1193 recorded the fold verdict.
The state space, written before the first edit. Left = the path the tool
printed. Right = `path.resolve(ctx.filePath)`, which is
`normalizeMapKey`-canonical and absolute by the #2016 invariant.
| axis | value | consequence |
|---|---|---|
| host folds case | win32 always; POSIX only on a case-folding mount (APFS, `nocase` vfat/ntfs3/cifs) | a `process.platform === "win32"` test is wrong in both directions on APFS. Only the filesystem knows, so the predicate must ask it. |
| reported spelling | absolute (the tool echoes our argv), relative to the tool's own cwd, relative to a package/base directory it chose, or basename-only | only the tool's OWN cwd is a base the caller can supply; `process.cwd()` is the extension's and is a different directory whenever `resolveRunnerCwd` returns a nested language root. |
| separators after `path.resolve` | host-native on both sides; a foreign separator survives only embedded in the reported string | separator folding can only MERGE, never split. |
| symlinked project dir | `pathsEqual` does NOT resolve symlinks on POSIX (`clients/path-utils.ts:181`, deliberate) | a project entered through a directory symlink can still mis-match; that is a property of the shared seam, unchanged by this decision (#3276's recorded output). |
| purpose | identity test of ONE file, per reported line — not a stored key, not set membership | asks for an equality predicate, not a key derivation. |
Candidate seams: `normalizeEphemeralMapKey` is rejected by its own contract
(`clients/path-utils.ts:488-490` — "never … compared against externally-supplied
paths where symlink / real-casing resolution actually matters"; the left side is
literally an external process's output, and it folds case on
`process.platform === "win32"` only). `normalizeFilePath`/`normalizeMapKey` are
the right rule in the wrong shape: the caller would still hold the comparison
and re-derive both keys. A NEW `reportedPathMatchesTarget(reported, { cwd,
target })` helper fails the deletion test — removing it would put back the same
single expression at each call site rather than concentrating anything — and it
would hide the comparison from the #1193 sweep's call-site walk while raising
the helper count.
## Decision
The predicate is `pathsEqual(path.resolve(<the cwd the tool ran in>, reported),
absTarget)`, written INLINE at each call site. It is not a new helper: it is the
call shape `clients/dispatch/runners/terragrunt.ts:90`,
`elixir-check.ts:63` (#3276) and `go-vet.ts:107` (#3277) already use, so this
decision migrates the remaining members onto an existing seam and adds nothing.
A parse function that does not receive the runner cwd takes it as a parameter;
no parse function resolves a reported path against `process.cwd()`.
Absence is enforced by `tests/config/reported-path-attribution-sweep.test.ts`, a
shrink-only census of runner sites that still hold their own predicate.
`tests/config/path-key-fold-sweep.test.ts` counts folds that EXIST and therefore
structurally cannot see a member with no fold at all — measured on #3277, where
mutating `go-vet.ts` to a hand-rolled case fold left that sweep green.
A member's fold is admitted only with its premise established: what spelling
does this tool really emit under OUR invocation? Where the tool echoes the
absolute path we hand it as argv, the fold is behaviour-preserving and its tests
say so, rather than feeding a spelling the tool never emits.
## Consequences
The path-case rule exists once, in `clients/path-utils.ts`; no runner holds a
`process.platform` test or a `toLowerCase` for this question. `pathsEqual` costs
a `realpathSync.native`/`existsSync` per reported line, which is bounded by the
tool's own output size on a parse path that already allocates per line.
Two directions are now pinned per member: the tool's real spelling attaches, and
a different file's finding does not. On a case-folding host the case-variant
spelling attaches too, and on a case-sensitive host it must not — asserted from
the filesystem's own answer, never from `process.platform`.
The predicate still cannot see through a directory symlink on POSIX, and a
runner that needs that must say so at its own call site rather than re-deriving
a local rule. It also cannot SPLIT a separator: `pathsEqual` folds `\` to `/` on
every platform (`clients/path-utils.ts:231`), so a POSIX file literally named
`a\b.gleam` stays merged with `a/b.gleam` after a member's own hand-rolled fold
is deleted. Deleting the local fold removes a second, divergent copy of the rule;
it does not change that answer.
Two amendments from #3285 / #3286, the two remainders:
1. The CAPTURE is part of this decision, not a detail before it. A tool renders
its location line however it likes, and `gleam` renders through
`codespan_reporting`, whose locus line wraps the path in a `┌─` gutter. A
suffix compare tolerates that decoration and an equality predicate cannot, so
a member whose reported path arrives decorated moves the decoration out of the
captured group in the same change — and its premise is a vector generated by
the upstream RENDERER (`tests/fixtures/gleam-codespan/`), not a transcription
of it. The same rule covers colour: a tool that may emit SGR codes goes
through `stripAnsi` before an anchored capture.
2. The population is runners AND tool clients (`clients/*-client.ts`). The autofix
half of a tool lives in its client, `ruff-client.ts` held the same bare `!==`,
and a detector scoped to `clients/dispatch/runners/**` structurally could not
see it. Widening cost exactly one non-member registration
(`clients/test-runner-client.ts`, two self-derived directories).
## Links
- Umbrella: #1193 (P3 fold), #3278 (the population).
- Precedents: #3276 (`elixir-check`), #3277 / PR #3281 (`go-vet`).
- Shipped defects: #209, #3277.
- Detector: `tests/config/reported-path-attribution-sweep.test.ts`.
- Cells: `tests/clients/dispatch/runners/reported-path-attribution.test.ts`.
- Witness (ADR 0007): `tests/fixtures/witness/runner-outcome-eslint-golangci/golangci-lint-relative-path.txt`.
- Remainders, both now closed: `gleam-check.ts` (#3285 — its `endsWith` was
load-bearing for `codespan_reporting`'s gutter) and `clients/ruff-client.ts`
(#3286 — outside the runner population).
- Upstream vectors: `tests/fixtures/gleam-codespan/` (gleam v1.18.1 +
codespan-reporting 0.13.1), `tests/fixtures/ruff-json/` (ruff 0.16.8).
- Cells for the client half: `tests/clients/ruff-client-reported-path.test.ts`.