Status: first attempt reverted. The implementation was preserved
on branch archive/walkfile-name-normalization (tip 1031be6e), but
that branch no longer exists on the remote; recover the code from the
revert commit's parent if needed. This document records what we were
trying to solve, the
design we tried, exactly how and why it failed, and the options for a
second design round.
Audience: whoever picks up the next attempt. Read this before re-deriving the problem: the failure mode is subtle and the obvious fix (the one we tried) is the one that doesn't work.
bca discovers source files by walking one or more seeds (--paths,
--paths-from, or a bca.toml manifest's paths). The walker
(ignore::WalkBuilder) emits each file's path prefixed by the seed as
the user spelled it. So the same on-disk file is emitted as a
different string depending on how the seed was written:
Invocation (file /repo/src/foo.rs) |
Emitted path |
|---|---|
--paths . (from /repo) |
./src/foo.rs |
--paths /repo |
/repo/src/foo.rs |
--paths "$PWD" |
/repo/src/foo.rs |
--paths src (from /repo) |
src/foo.rs |
manifest paths=["."] resolved to /repo, run from /repo/sub |
/repo/src/foo.rs (absolute) |
That emitted path is then consumed, unmodified, by several features that each need to compare or key on a file's identity:
[check.exclude]/--excludeglobs: patterns are authored in a./-anchored, walk-root-relative convention (vendor/**,./generated/*).- Baseline keys (
.bca-baseline.toml): must be stable across runs so a recorded offender is recognized again. --changed-only/--sincediff-scope filtering: compares a violation's path against the set of files git reports as changed.bca diff: pairs the "before" and "after" metric sets by file identity.
Because the emitted path form varied with seed spelling, each of these consumers grew its own re-anchoring step, and each one was the subject of a separate bug:
- #488: absolute seeds defeated the
./-anchored exclude globset. Fixed byreanchor_seed(collapse an at/under-CWD absolute seed to its CWD-relative form before walking). The strip is lexical first (the as-spelled seed, so path-form independence holds even for a symlinked seed), falling back to the canonicalized seed only when the lexical strip fails: the case where the seed reaches the CWD through a symlinked ancestor (current_diris canonical via getcwd; macOS's/var/foldersand/tmpare symlinks into/private). Without the fallback, such a seed stayed absolute and nested every emitted file under its full path. - #489: a manifest root above the CWD (run from a subdirectory)
could not be collapsed by
reanchor_seed, so the walker emitted absolute paths the./-anchored deny-set never matched. Fixed bymatch_path_for(anchor the glob match to the walk root, per-seed). - #493 / #497 (Bug B):
[check.exclude]re-anchored only--pathsseeds, not--paths-fromones, so a--paths-fromoffender escaped the exclude. Fixed byanchor_against_seeds(re-anchor a post-walk violation path against the full seed set). - #497 (Bug A):
bca diff --since <subdir>mis-paired because the before side (agit archiveextraction) and the after side were rooted differently. Fixed by treating the--sincepositional as a scope and anchoring both sides at the repo root.
The pattern was clear: the same "which file is this, in canonical form?" question was being answered independently, and slightly differently, at four or five places. Each new consumer (or each new seed source) reopened the bug.
-
Structural goal (ours): dissolve the bug class by computing a file's canonical identity once, where the seed association is still live, so no downstream consumer ever re-derives it. The four anchoring helpers (
reanchor_seed,match_path_for,anchor_against_seeds, plus thebca diffprocess-CWD swapwith_cwd) would collapse into one. -
User-facing goal (requested): normalize the emitted output
nameso the same file reports the same name regardless of seed spelling. Concretely,bca metrics --paths /abs/repo,--paths ., and--paths "$PWD"should all emitname: "./src/foo.rs"instead of three different strings, making twobca metricscaptures directly comparable, and making the JSON output stable.
The user explicitly authorized a 2.0 breaking change to achieve goal 2
("Let's do it all including normalizing the output name. This is against
our gate, but it is a one time exception").
A dual-path WalkFile computed at the walk seam:
pub struct WalkFile {
pub io_path: PathBuf, // the path the runner opens (always readable)
pub name: PathBuf, // the canonical identity reported downstream
}FilesData { paths: Vec<PathBuf> }becameFilesData { files: Vec<WalkFile> }, and theConcurrentRunnercallback tookWalkFileinstead ofPathBuf(the breaking API change).expand_seed_paths(the CLI's single walk seam) computed, for each walked file, a canonical name = the file's path relative to the analysis root,./-prefixed. The analysis root was the longest common ancestor (LCA) of the directory seeds, normalized lexically (lexical_abs→baseline::lexical_normalize, notfs::canonicalize, to stay in the walker's path space). Explicit single-file seeds kept their spelled name verbatim (WalkFile::verbatim).act_on_filereadio_pathbut handednameto everydispatch_*arm, so the emitted document name, baseline key, diff key, and exclude-match target all used the one canonicalname.- The four anchoring helpers and
with_cwdwere deleted. - A reusable safety tool,
utils/verify-name-only-churn.py, was written to prove the bulk snapshot regen was value-preserving (it is kept; it is generally useful and is the one artifact of this attempt worth retaining on the main branch).
It compiled, passed the full workspace test suite, both self-scan
tiers, and make pre-commit. The spelling-independence goal appeared
met: bca metrics --paths /abs/src, src, ., and $PWD (all
pointing at the same directory) emitted ./foo.rs.
A maximum-effort review (/code-review max) found three confirmed,
reproduced, silent correctness regressions. All three trace to one
root cause.
R1: bca diff --since <single-file> reports the file as added and
removed, with zero deltas. Reproduced:
$ bca diff --since HEAD src/work.rs --format json
buckets: []
added: ["/tmp/<workingtree>/src/work.rs"]
removed: ["/tmp/<extraction>/src/work.rs"]
A single-file scope has no directory seed, so the analysis root is
None and the file falls into the verbatim branch, emitting the
absolute path of each side's tree. The before side (a /tmp git archive extraction) and the after side (the working tree) therefore get
non-matching absolute names and never pair. (The directory-scope form,
--since HEAD src, worked, which is why every diff_since test, all
of which use directory scopes, stayed green.)
R2: subdirectory-scoped baseline keys lose the scope prefix. Reproduced:
$ bca check --paths . --write-baseline b_dot.toml # key: "src/work.rs"
$ bca check --paths src --write-baseline b_src.toml # key: "work.rs" ← wrong
$ bca check --paths src --baseline b_dot.toml # offender re-fires as [new]
With --paths src, the analysis root is src itself (LCA of a single
seed), so canonical_name strips the whole src/ and emits ./work.rs
→ baseline key work.rs. The same file under --paths . keys as
src/work.rs. A baseline written under one scope no longer suppresses
under the other; offenders silently re-fire.
R3: --changed-only from a subdirectory silently drops violations
(gate bypass). The canonical name is anchored to the analysis root,
but DiffScope::contains resolves a violation path with
canonicalize_for_match (path.canonicalize()), which is CWD-based.
When the analysis root ≠ CWD (a subdir scope, or a manifest root above
the CWD), the canonical ./-name resolves to the wrong absolute path,
fails to match the git-changed set, and the violation is dropped; the
gate can exit 0 on a real regression.
The premise ("compute one canonical identity") is unsound, because the consumers do not share an anchor:
| Consumer | Anchor it needs | Why |
|---|---|---|
[check.exclude] glob |
walk-root-relative (./…) |
patterns are authored that way |
| baseline key | baseline-file dir | keys must be stable across runs/locations |
--changed-only |
CWD (resolves to absolute) | compares against git's changed set |
bca diff |
tree-root-relative (per side) | the two trees live at different absolute roots |
A single string cannot be simultaneously walk-root-relative,
CWD-resolvable, baseline-anchor-relative, and tree-root-relative when
those anchors differ. The LCA-of-seeds ./-name we chose is
lossy: it drops the path segments between the analysis root and the
file, so a consumer that resolves it against a different anchor (CWD,
baseline dir) can no longer recover the file's true location.
Crucially, the pre-existing per-consumer design was not redundant boilerplate: each consumer re-anchored because it genuinely has a different anchor. Collapsing them was the wrong altitude: we mistook four correct, anchor-specific transforms for one duplicated transform.
The project's own dogfooding always runs --paths . (or manifest
paths=["."]) from the repo root, where analysis-root == CWD ==
repo-root == baseline-anchor. In that one configuration all anchors
coincide, so the lossy name round-trips and nothing breaks. The
regressions only surface for subdir scopes, single-file diffs, and
--changed-only/manifest runs from a subdirectory, none of which the
test suite or the self-scan exercised. This is itself a lesson: the
gate's uniformity hid an anchor-coupling bug.
Accept that identity is anchor-specific and keep each consumer's transform. This is correct and is the current state. The cost is the "five places answer the same-ish question" maintainability smell that motivated the attempt, but they are not actually the same question, so the smell is partly illusory. Lowest risk; abandons goal 2.
If we stay here, the worthwhile hardening is a shared, well-tested "resolve this emitted path to an absolute path" primitive that each consumer calls before applying its own anchor, i.e. unify the resolution step (lexical-abs, the part that is genuinely identical) without unifying the anchoring step (which is not).
Split the two roles the failed design conflated:
- A display name (presentation-only): the canonical
./-form, used for thebca metricsJSONnamefield and human output. This is what goal 2 actually wants. - A key/identity path (the raw, anchor-resolvable path): used by
baseline,
--changed-only, diff pairing, and exclude matching, each with its existing per-consumer anchor.
WalkFile would carry both io_path and a display_name, but the
keying consumers would continue to use io_path (or a
resolve-to-absolute of it) and their own anchors, never the display
name. This achieves the user-visible normalization without feeding the
lossy form into the keying paths.
Risk: medium. Requires auditing every consumer to confirm it uses
the key path, not the display name. The display name is only safe where
it is purely presentational. (Note: bca diff's pairing key is also
display-ish: two captures must pair on the normalized name, so diff
specifically wants the normalized form, but anchored per-side at the
tree root, not the LCA. Diff may need its own normalization parameter.)
Keep one name, but (1) make it non-lossy: anchor it to a single
fixed root passed explicitly by the caller (CWD for standalone
check/metrics; the manifest dir when a manifest set the paths; the tree
root for each diff side) rather than the LCA of the scope seeds; and
(2) ensure every consumer resolves it against that same fixed root.
The hard part is that the consumers' anchors still differ (baseline-dir
vs CWD vs tree-root). To make one name work for all, you would have to
re-express every consumer's anchor in terms of the chosen fixed root:
e.g. make baseline keys relative to the analysis root instead of the
baseline-file dir (a baseline-format change), and make --changed-only
compare in analysis-root space instead of canonicalizing against CWD.
That is a larger, format-touching change with its own migration.
Risk: high, and it changes the baseline format and the changed-only contract. Only worth it if a single canonical identity is judged valuable enough to re-anchor every consumer onto it deliberately.
Start from Option B. It is the smallest change that delivers the
actual user request (a normalized, comparable display/output name)
without touching the keying paths that the failed attempt broke. Reserve
Option C for a later, deliberate "single canonical identity"
project if Option B's display/key split proves awkward (e.g. if bca diff consumers want the normalized form as a key). Keep Option A as
the fallback if normalization is judged not worth the consumer audit.
Whatever the choice, the next attempt must add regression tests for the three configurations the gate's uniformity hid:
- a subdirectory scope (
--paths src): assert the baseline key and emitted name retain thesrc/segment; - a single-file
--sincescope: assert a real paired delta; --changed-only(or a manifest run) from a subdirectory: assert in-scope violations are kept.
- Reverted implementation: formerly on branch
archive/walkfile-name-normalization, tip1031be6e; no longer on the remote (refactor(api)!: normalize output names via dual-path WalkFile+refactor(cli): share lexical path normalizer; hoist walk CWD read). - The correct, retained consumer-side fixes for #497 Bug A/B are on the
main work branch as
f0b83a7d. utils/verify-name-only-churn.py(kept): proves a bulk snapshot regen is name-only/value-preserving; reusable for whichever round-two option touches emitted paths.- Related: STABILITY.md (the
2.0deferral list), issues #488 / #489 / #493 / #497, and lesson candidates below.
- Re-anchoring at multiple consumers is not necessarily duplication. When N consumers transform the same value, check whether they share a parameter (here: the anchor). If they don't, "compute it once" is the wrong altitude: you will produce a value that is correct for one consumer and lossy for the rest.
- A uniform gate can hide a coupling bug. Every self-scan/CI path
here ran
--paths .from the repo root, where four distinct anchors coincide. The anchor-coupling regression was invisible until a review varied the scope. Vary the invocation shape in tests, not just the inputs.