Skip to content

fix(review): join the changeset to native-separator graph paths - #646

Merged
zzet merged 5 commits into
zzet:mainfrom
tiendungdev:fix/review-rulepack-native-path-join
Aug 23, 2026
Merged

fix(review): join the changeset to native-separator graph paths#646
zzet merged 5 commits into
zzet:mainfrom
tiendungdev:fix/review-rulepack-native-path-join

Conversation

@tiendungdev

Copy link
Copy Markdown
Contributor

Problem

reviewRulepackMatches builds the changed-file key correctly, then looks it up with filepath.Clean — which rewrites the one separator a graph path deliberately keeps as /: the repo prefix.

Probed on windows/amd64 with the existing fixture:

changed map key             "repo-a/pkg\widget.go"
target.GraphPath            "repo-a/pkg\widget.go"    <- identical
filepath.Clean(GraphPath)   "repo-a\pkg\widget.go"    <- prefix slash lost
hit                         false

The narrowing therefore produced an empty target set and review reported zero findings on code its own detector bundle flags under analyze --kind review. That is the same false clean reviewChangedGraphPaths was written to fix — its doc-comment says so — reintroduced one line later by the Clean.

Change

Two halves of one contract, both in tools_review.go:

  1. The join. Compare in graphpath.Norm form on both sides. Norm is the canonical comparison spelling for a path shaped "<prefix>/" + native remainder, and it is filepath.ToSlash, so this is the identity on POSIX.

  2. The output. reviewRepoRelPath feeds the .gortex.yaml rule globs, rankFileRisk's row keys, and the forge comment API — the function's own doc-comment lists them, and all three speak /. On Windows it returned pkg\widget.go, which matches no glob and anchors no comment. Normalize there too.

Effect

Whole internal/mcp package on windows/amd64, go1.26.6, -count=1:

failures
main (c982cf52) 27
with this change 24

Diffing failing test names: exactly three flip, newly-broken set empty.

FIXED:  TestReviewRulepackMatches_JoinsRepoRelativeChangedFiles
        TestReviewRulepackMatches_AcceptsAlreadyPrefixedChangedFiles
        TestReview_PrefixedGraphReportsRulepackFinding
NEWLY BROKEN: (none)

Verification

Each half sabotage-verified separately, because a fix that only looks necessary is not:

  • revert the join → all three fail;
  • revert the output normalization → the two that assert m.File fail on Not equal.

golangci-lint run ./internal/mcp/... reports 6 staticcheck SA5011 findings; pre-existing, identical with this change stashed. git diff --check clean.

Why there is no cross-platform test

graphpath.Norm is filepath.ToSlash, deliberately a no-op on POSIX — the package doc is explicit that a backslash is an ordinary filename byte there and must survive. So a test feeding a backslash path and asserting a slash result would fail on linux/macos, and one built with filepath.FromSlash asserts nothing on either. The three names therefore join the existing windows native-separator step, whose package list already carries internal/mcp — no new step, no new compile.

Declared, not swept under

TestReviewPackNeverClaimsNoTestSymbolsWithTestTargets fails on windows too and I deliberately left it out: it fails on test_targets being empty, not on the path join, and testpath.IsTestFile already normalizes separators itself — so it is a different cause and belongs in its own change rather than riding along here.

Branched from main at c982cf52. Windows 11, go1.26.6.

reviewRulepackMatches builds the changed-file key correctly and then
looks it up with filepath.Clean, which rewrites the one separator a graph
path keeps as '/': the repo prefix. Probed on windows/amd64:

    changed map key            "repo-a/pkg\widget.go"
    target.GraphPath           "repo-a/pkg\widget.go"   <- identical
    filepath.Clean(GraphPath)  "repo-a\pkg\widget.go"   <- prefix slash lost
    hit                        false

So `review` narrowed the rulepack to an empty target set and reported
zero findings on code its own detector bundle flags — the same false
clean the join was written to fix, reintroduced by the Clean.

Compare in graphpath.Norm form on both sides instead. Norm is the
canonical comparison spelling for a path that is "<prefix>/" plus a
native remainder, and it is filepath.ToSlash, so this is the identity on
POSIX.

Second half, same contract: reviewRepoRelPath feeds the `.gortex.yaml`
rule globs, rankFileRisk's row keys and the forge comment API, all of
which speak '/'. It returned `pkg\widget.go` on Windows, which matches no
glob and anchors no comment. Normalize there too.

Whole package on windows before/after: 27 -> 24 failures, exactly the
three rulepack tests flip, nothing else changes state. Each half is
separately load-bearing: reverting the join fails all three, reverting
the output normalization fails the two that assert m.File.

graphpath.Norm is deliberately a no-op on POSIX — a backslash is an
ordinary filename byte there — so no test can bind this on the
linux/macos matrix. The three names join the existing windows
native-separator step, whose package list already carries internal/mcp.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes after an additional path-provenance validation.

Blocking correctness issue at internal/mcp/tools_review.go:700:

With repoPrefix repo-a and a legitimate Git-relative changed path repo-a/pkg/widget.go, normalization produces the same string. The HasPrefix check then treats it as already graph-prefixed, although the correct graph key is repo-a/repo-a/pkg/widget.go. This misses the changed target and, when pkg/widget.go also exists, can scan that unchanged shadow target instead. The ambiguity already exists on POSIX and this change newly exposes the wrong-target behavior on Windows.

Please address the following:

  1. Carry explicit path provenance for Git-relative versus graph-keyed paths instead of inferring it with HasPrefix.
  2. Prefix production MapGitDiff paths unconditionally, or otherwise make the input domain explicit.
  3. Add a regression fixture containing both pkg/widget.go and repo-a/pkg/widget.go, mark only the nested path changed, and assert that only the changed target is scanned.
  4. Rerun the focused Windows native-separator validation and the existing CI suite.

Adding both possible graph keys is not a safe workaround because it can still select unchanged code.

Addresses the review on zzet#646.

reviewChangedGraphPaths inferred "this path is already graph-keyed" from
strings.HasPrefix(f, repoPrefix+"/"). The two domains overlap, so that is
not recoverable by inspection: in a repo whose tree carries a top-level
directory named like the repo prefix, `repo-a/pkg/widget.go` is a valid
git-relative path AND a valid graph key for a different file. The
inference skips the real key `repo-a/repo-a/pkg/widget.go`, so the
changed file is never scanned - and when a same-named `pkg/widget.go`
exists, that unchanged shadow is scanned in its place.

Make the domain explicit and travel with the data:

  changedPathsRepoRelative  git's spelling, relative to the working tree
  changedPathsGraphKeyed    the graph's "<prefix>/<rel>" node key

All four production callers pass DiffResult.ChangedFiles, which MapGitDiff
documents as keeping "the diff-relative paths (callers re-join them with
git pathspecs)", so they pass changedPathsRepoRelative and the prefix is
now applied unconditionally. Only the test covering a caller that already
holds node keys passes changedPathsGraphKeyed.

The Norm-based join and output normalization from the first revision are
unchanged; this only replaces the inference.

Regression test: a fixture carrying both pkg/widget.go and
repo-a/pkg/widget.go, only the nested path marked changed, asserting every
match reports the changed target. Both files carry the detector fixture,
so a wrong-target scan still returns matches and only the reported path
separates the outcomes - restoring the HasPrefix inference fails it with
"pkg/widget.go" is the unchanged shadow.

The ci.yml selector hunk is dropped: zzet#652 removes that job outright.
@tiendungdev

Copy link
Copy Markdown
Contributor Author

You were right, and the failure mode reproduces exactly as you described. Pushed fdcf19d2.

I verified the claim before changing anything: restoring the HasPrefix inference against the new fixture fails with

--- FAIL: TestReviewRulepackMatches_PrefixShadowedPathScansOnlyTheChangedTarget
    Messages: only the changed target may be scanned;
              "pkg/widget.go" is the unchanged shadow

— the changed repo-a/pkg/widget.go never scanned, the unchanged shadow scanned in its place.

1 + 2 — explicit provenance, unconditional prefixing

The domain now travels with the data instead of being recovered from it:

changedPathsRepoRelative  // git's spelling, relative to the working tree
changedPathsGraphKeyed    // the graph's "<prefix>/<rel>" node key

reviewRulepackMatches takes it and passes it through; reviewChangedGraphPaths prefixes unconditionally when the domain is repo-relative and never inspects the string.

On "prefix production MapGitDiff paths unconditionally" — that is now what happens, and the contract already supported it. All four production callers (tools_review.go ×2, tools_critique_review.go, tools_review_post.go) pass DiffResult.ChangedFiles, and joinHunksToSymbols documents that field as keeping "the diff-relative paths (callers re-join them with git pathspecs); only the node lookup is prefix-aware". So the repo-relative domain is the production domain by contract, not by assumption. changedPathsGraphKeyed is now reached only by the test that covers a caller holding node keys — I kept the capability rather than deleting it, but it is opt-in and named.

3 — regression fixture

TestReviewRulepackMatches_PrefixShadowedPathScansOnlyTheChangedTarget: the repo tree carries both pkg/widget.go and repo-a/pkg/widget.go, only the nested path is marked changed, and every match must report repo-a/pkg/widget.go.

Both files carry the detector fixture deliberately — otherwise a wrong-target scan would return empty and the test would pass for the wrong reason. With both seeded, the wrong target still produces matches and only the reported path separates the outcomes.

4 — validation

  • Focused Windows native-separator selector: ok for analysis, store_sqlite, mcp, resolver, persistence.
  • Whole internal/mcp on windows/amd64: 27 → 24 against main, failing test names diffed — same three fixed, newly-broken set empty, and the new test passes.
  • golangci-lint ./internal/mcp/...: 6 staticcheck findings, all pre-existing (identical with the change stashed).

One thing I removed

The ci.yml selector hunk is gone from this branch. #652 deletes that job outright, so leaving it would only hand you a conflict — and your point on #647 about selector lists applies here too.

Also worth flagging since it is upstream of this: the ambiguity is in the data model, not only in this function. DiffResult.ChangedFiles is documented as diff-relative but is plain []string, so the next caller can make the same inference. If you want that closed properly the type belongs on the field rather than at this call site — happy to do it as its own change if you think it is worth the churn.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second review round — requesting changes.

The rulepack narrowing fix itself is correct, but the prefix-shadow case is still incorrect end to end.

  1. ChangedSymbols still bind to the unchanged shadow.

MapGitDiff passes Git-relative hunk paths to analysis.JoinFileNodes (internal/analysis/diffmap.go:109-117). JoinFileNodes tries the raw path first and returns it before trying the prefixed key. With repoPrefix repo-a and changed Git path repo-a/pkg/widget.go, the raw key repo-a/pkg/widget.go belongs to the unchanged pkg/widget.go shadow; the real changed graph key is repo-a/repo-a/pkg/widget.go. If the shadow exists it is returned, and if it does not exist the HasPrefix branch returns nil. The correct key is never tried.

That contaminates diff.ChangedSymbols. review and review_pack then derive impact, contracts, guards, test targets, classification, previews, receipts, and potentially the verdict from the unchanged file or from no symbols at all.

  1. File-risk normalization repeats the domain collision.

internal/review/report.go:123-129 uses one normalizer that strips repoPrefix from graph-keyed ChangedSymbol.FilePath values and from already repo-relative findings and ChangedFiles. In the same case, the legitimate repo-relative repo-a/pkg/widget.go becomes pkg/widget.go. This attributes risk to the shadow path; after fixing JoinFileNodes it can also produce two inconsistent risk rows and incorrect finding counts/verdict input.

The new TestReviewRulepackMatches_PrefixShadowedPathScansOnlyTheChangedTarget directly calls reviewRulepackMatches, so it bypasses MapGitDiff, JoinFileNodes, rankFileRisk, and review_pack gates. The existing end-to-end test has no prefix-shadow fixture. Green CI therefore does not exercise either failure.

Please address before merge:

  • Carry explicit repo-relative provenance through MapGitDiff/joinHunksToSymbols and prefix that domain unconditionally when resolving graph nodes. Audit JoinFilePath and the other JoinFileNodes callers rather than changing its mixed-domain contract implicitly.
  • Normalize file-risk inputs by domain: strip the graph prefix only from graph-keyed symbol paths, not from repo-relative findings or ChangedFiles.
  • Add end-to-end review and review_pack tests containing both pkg/widget.go and repo-a/pkg/widget.go, changing only the nested file. Assert the finding, ChangedSymbols, FileRisk, contracts/guards/test targets, previews, receipt, and verdict all refer only to repo-a/pkg/widget.go. Include deleted/renamed old-side paths if the join remains shared with those flows.

Non-blocking cleanup: changedPathsGraphKeyed currently has no production caller; only its test reaches that branch.

… file risk

Second review round on zzet#646.

1. JoinFileNodes resolved the wrong file.

It tried the raw key first and the prefixed key second, so a git-relative
"<prefix>/<rel>" resolved against the same-named top-level file — and when
that shadow did not exist the HasPrefix branch returned nil, never trying
the real key "<prefix>/<prefix>/<rel>". That contaminated
DiffResult.ChangedSymbols, which review and review_pack derive impact,
classification, risk, receipts and the verdict from.

The domain now travels with the path, as analysis.PathDomain, and a
repo-relative path is prefixed unconditionally. All five production call
sites were audited and every one is repo-relative: joinHunksToSymbols
(hunks and vanished paths), changedSymbolsForFiles (forge file list) and
suggest_reviewers (its own comment already said so). Nothing depended on
the mixed contract.

GraphKey also converts to native separators. The key's shape is
"<prefix>/" + the remainder as the indexing machine spells it (see
internal/graphpath), so a '/'-spelled git path missed every file below the
repo root on Windows — the shadow fixture proved it, resolving 0 nodes
before and 4 after.

JoinFilePath collapsed into GraphKey: with the domain explicit it was the
same function, and probing the store to pick between two candidate keys is
what made it ambiguous.

2. File risk repeated the collision.

rankFileRisk ran one normalizer over every input, stripping the repo
prefix from graph-keyed symbol paths and from repo-relative findings and
ChangedFiles alike, so a legitimate "repo-a/pkg/widget.go" became
"pkg/widget.go" and the risk landed on the shadow. Split by domain:
fromGraphKey strips, fromRepoRel does not.

3. Tests.

End-to-end review and review_pack tests drive MapGitDiff, JoinFileNodes,
rankFileRisk and the pack gates over a repo whose tree carries a top-level
directory named like the repo prefix. Both files carry the flagged source
at the base commit and only the nested one is edited — inside the function,
so the hunk overlaps a symbol — so a wrong-target join still produces
findings and only the attributed path separates the outcomes.

Fixture paths are deliberately flat: a nested spelling differs by
separator on Windows and would mask the defect there.

Existing tests that encoded the old tolerance were rewritten rather than
deleted: TestJoinFileNodes and TestChangedSymbolsForFiles_RepoPrefixJoin
now assert the shadow resolves to the nested file, and their fixtures
build graph keys the way the indexer does instead of hard-coding the
'/'-joined form.

changedPathDomain folded into analysis.PathDomain so one vocabulary spans
both packages.

Windows: internal/mcp 27 -> 24 against main, failing test names diffed,
newly-broken set empty. The seven internal/review failures are unchanged
and pre-existing (identical with this change stashed). Lint: 6 staticcheck
findings, also pre-existing.
@tiendungdev

Copy link
Copy Markdown
Contributor Author

Both are real and both are fixed. Pushed a7ca67b0.

1 — JoinFileNodes resolved the wrong file

Confirmed exactly as you described, and the audit you asked for came back clean: all five production call sites are repo-relative, so nothing depended on the mixed contract.

call site source
joinHunksToSymbols — hunk paths diff parsing
joinHunksToSymbolsvanished FileChange.Path / PreviousPath
changedSymbolsForFiles (tools_prs.go) forge file list
suggest_reviewers ×2 its own comment already said "the changed-file paths are repo-relative (git / forge)"

The domain now travels as analysis.PathDomain and a repo-relative path is prefixed unconditionally. JoinFilePath collapsed into GraphKey — with the domain explicit it was the same function, and probing the store to choose between two candidate keys is what made it ambiguous in the first place.

One thing I found while building the fixture that is worth flagging: GraphKey also has to convert separators. The key is "<prefix>/" + the remainder as the indexing machine spells it, so a /-spelled git path misses everything below the repo root on Windows. Probed on the real fixture:

GetFileNodes("repo-a/repo-a/pkg/widget.go")  -> 0 nodes
GetFileNodes("repo-a/repo-a\pkg\widget.go")  -> 4 nodes   <- what the indexer wrote

Without that, ChangedSymbols came back empty rather than wrong — a different failure, same root.

2 — file-risk normalization

Split by domain: fromGraphKey strips the prefix (ChangedSymbols), fromRepoRel does not (findings, ChangedFiles). TestRankFileRiskNormalizesRepoPrefix still merges the two spellings onto one row, which was the behaviour that normalizer existed for.

3 — end-to-end tests

TestReview_PrefixShadowAttributesOnlyTheChangedFile and TestReviewPack_PrefixShadowAttributesOnlyTheChangedFile drive the real pipeline — MapGitDiffJoinFileNodesrankFileRisk → the pack gates — over a repo whose tree carries a top-level directory named like the prefix. Both files carry the flagged source at the base commit and only the nested one is edited, inside the function so the hunk overlaps a symbol, so a wrong-target join still yields findings and only the attributed path separates the outcomes. They assert findings, ChangedSymbols (against the real graph key, not a /-joined guess), FileRisk and verdict.

Two fixture decisions worth stating, because both were mistakes I made first:

  • Flat paths, not nested. With pkg/widget.go the changed git path and the shadow's graph key differ by separator on Windows, so the raw-lookup defect is masked here. One segment makes them coincide.
  • The edit is inside Load. My first version touched line 1; the file changed, no symbol did, and ChangedSymbols was empty for a reason unrelated to the join.

Limitation I would rather state than have you find: on Windows I cannot sabotage-verify the e2e pair — hunk.FilePath is filepath.Cleaned to repo-a\widget.go, which never matches a /-joined key, so restoring raw-first is masked locally. The linux/macos shards are where they bind. What I can verify here is the unit level: restoring raw-first fails TestJoinFileNodes with "prefix-shadowed repo-relative path must resolve to the nested file", and that fixture is platform-independent.

Existing tests

Rewritten, not deleted. TestJoinFileNodes and TestChangedSymbolsForFiles_RepoPrefixJoin asserted the old "already-prefixed hits raw" tolerance — the defect itself — so they now assert the shadow resolves to the nested file. Their fixtures also build graph keys the way the indexer does instead of hard-coding the /-joined form, which is a POSIX-only spelling.

Validation

  • internal/mcp on windows: 27 → 24 against main, failing test names diffed, newly-broken set empty.
  • internal/review: 7 failures, unchanged and pre-existing — byte-identical with this change stashed. They are cleanPath/filepath.Clean path-spelling assertions, not this change.
  • Focused native-separator selector: ok across analysis, store_sqlite, mcp, resolver, persistence.
  • golangci-lint: 6 staticcheck findings, also pre-existing.

Non-blocking cleanup

changedPathsGraphKeyed is gone — folded into analysis.PathDomain so one vocabulary spans both packages, and GraphKeyedPath now has a real consumer in JoinFileNodes' contract.

@tiendungdev

Copy link
Copy Markdown
Contributor Author

CI is red but not on this change. It needs a re-run, which I cannot trigger from a fork.

The failure is a wall-clock ratio assertion in a package this PR does not touch:

--- FAIL: TestNamedChildrenLinearScaling (7.17s)
    iterator: 10.292818ms -> 246.449711ms  (23.9x growth — O(N) ~10x)
    naive   : 25.70379ms  -> 150.625358ms  (5.9x growth)
    iterator growth 23.9x over a 10x width increase — not linear (want < 20x)
FAIL github.com/zzet/gortex/internal/semantic/tstypes

git diff --name-only origin/main...HEAD touches 0 files under internal/semantic/. The diff is internal/analysis, internal/review and internal/mcp only, and nothing in it reaches tree-sitter node iteration.

The run's own numbers show the measurement was unstable. walkIndex is the loop whose per-op cost is supposed to grow with N — the benchmark contrasts it with the iterator for exactly that reason — yet it grew only 5.9x over a 10x width increase in that run, while the iterator grew 23.9x. Both cannot be true of the same machine behaving normally. The ratio also has a ~10 ms denominator measured over 50 runs against a ~250 ms numerator measured over 15, so scheduler noise on a shared runner lands almost entirely in the numerator.

Locally on windows/amd64, go1.26.6, -count=3 on this branch:

run iterator naive
1 9.0x 11.1x
2 9.8x 12.0x
3 9.2x 12.1x

Three passes, right at the expected ~10x, and the naive walk consistently grows more than the iterator — the opposite of what CI recorded.

Two notes on reading that run, since one of them cost me a detour:

  • The ubuntu shard's log is not evidence of anything. It shows ##[error]The operation was canceled with an orphaned store_sqlite.test and stops after 65 packages — that is fail-fast cancelling it once macOS failed, not a hang. Worth knowing while ci: run full test suite on Windows #652 is in flight: fail-fast: false there would have had both shards report, and the Windows shard's first enumeration will be much easier to read for the same reason.
  • The previous commit on this branch (fdcf19d2) passed the same matrix, and the only code added since is the path-domain work plus tests.

If you would rather not re-run on faith, I am happy to send the scaling test's stabilisation as its own PR — the honest fix is to compare against a same-machine baseline rather than an absolute ratio, or to raise iterLarge's run count so both sides average over comparable work. I have deliberately not touched it here.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third review round — requesting changes.

The previous prefix-shadow fixes are correct in the main review paths, but two blocking correctness issues remain at head a7ca67b.

  1. High — suggest_reviewers(ids=…) double-prefixes graph-keyed paths.

    resolveReviewerChangeset's ids branch returns n.FilePath verbatim (internal/mcp/tools_suggest_reviewers.go:142-153), so those entries are already graph-keyed. The new ownership and co-change lookups at lines 100 and 111 nevertheless force analysis.RepoRelativePath.

    With prefix repo-a, a node path repo-a/pkg/a.go is looked up as repo-a/repo-a/pkg/a.go. Recent-author and co-change reviewer signals disappear. The existing IDs test uses an unprefixed graph, so it cannot expose this regression.

    Please either normalize the IDs branch to repo-relative paths or return/carry PathDomain with the files. Add a prefixed-graph IDs test that verifies CODEOWNERS, recent-author, and co-change signals.

  2. High — Windows file-risk normalization still produces duplicate/wrong rows.

    In internal/review/report.go:131-138, fromGraphKey calls cleanPath before strings.TrimPrefix(file, repoPrefix+"/"). cleanPath uses filepath.Clean. On Windows:

    repo-a/repo-a\widget.go
      -> repo-a\repo-a\widget.go
    

    The forward-slash prefix no longer matches. Changed-symbol impact/coverage stays on a graph-prefixed row while findings and ChangedFiles create a second repo-relative row.

    Normalize both domains to slash/graphpath.Norm form before removing the normalized prefix, and return the documented repo-relative / spelling. Strengthen both prefix-shadow end-to-end tests to require exactly one FileRisk row equal to the changed file; the current NotEqual(shadow) assertion permits both erroneous rows.

  3. Windows CI does not exercise these contracts.

    The selector in .github/workflows/ci.yml:101-106 does not run TestGraphKey, TestJoinFileNodes, TestChangedSymbolsForFiles_RepoPrefixJoin, or either new prefix-shadow end-to-end test. It also omits ./internal/review, where the second blocker lives. Please extend the Windows coverage accordingly.

Non-blocking: GraphKeyedPath currently has only test callers; fixing the IDs path should give it a real production use. No new security/authentication/secret issue was found.

… stripping

Third review round on zzet#646.

1. suggest_reviewers(ids=...) double-prefixed graph-keyed paths.

resolveReviewerChangeset's three sources do not share a vocabulary: ids
returns graph node FilePaths, base (git) and number (forge) return
repo-relative paths. Forcing analysis.RepoRelativePath on all three
looked up repo-a/pkg/auth/login.go as repo-a/repo-a/pkg/auth/login.go, so
the ownership and co-change signals disappeared while CODEOWNERS - which
matches the repo-relative spelling - still answered and the tool looked
healthy.

The resolver now returns the domain with the files and the two lookups
use it. That also gives GraphKeyedPath its first production caller.

My round-two audit missed this: I read the call site's comment, which
says the paths are repo-relative, without walking all three producers
feeding it. The comment was true for two of them.

2. File risk stripped the prefix after filepath.Clean.

fromGraphKey called cleanPath first, and cleanPath ends in filepath.Clean,
so on Windows repo-a/repo-a\widget.go became repo-a\repo-a\widget.go and
the '/'-joined prefix no longer matched. Changed-symbol impact stayed on a
graph-prefixed row while findings and ChangedFiles produced a second
repo-relative row for the same file. Both domains now normalize through
graphpath.Norm before the strip and leave in the documented repo-relative
'/' spelling.

This also fixes three pre-existing Windows failures in internal/review:
TestRankFileRiskUsesImpact, TestRankFileRiskNormalizesRepoPrefix and
TestRankFileRiskExemptsTestFilesFromCoverageDebt. The package goes from
seven failures to four; the remaining four are untouched by this change.

3. Windows coverage.

The selector gains GraphKey, JoinFileNodes, ChangedSymbolsForFiles, both
prefix-shadow end-to-end tests, the new ids test and RankFileRisk, and
./internal/review joins the package list.

Tests. The new prefixed-graph ids test asserts all three reviewer signals
survive; the existing ids tests use an unprefixed graph where both domains
coincide, which is why they could not see the regression. Both
prefix-shadow end-to-end tests now require exactly one FileRisk row equal
to the changed file - the previous NotEqual(shadow) assertion passed even
when both erroneous rows were present.

Each fix is sabotage-verified separately: restoring the ids branch to
RepoRelativePath leaves only the CODEOWNERS reviewer, and restoring
cleanPath-before-strip produces exactly the two rows described above.

Also audited, and clean: every other JoinFileNodes / GraphKey caller.
changedSymbolsForFiles is fed only by forge PR file lists through
resolvePRFiles and prImpactForNumber, so its repo-relative domain is
correct. The only two remaining repoPrefix strips both normalize first and
both have an established domain.
@tiendungdev

Copy link
Copy Markdown
Contributor Author

Both blockers confirmed and fixed, plus the coverage gap. Pushed a1a5b87f.

1 — suggest_reviewers(ids=…)

You were right, and it was my miss. resolveReviewerChangeset's three sources do not share a vocabulary:

source domain
ids graph node FilePathgraph-keyed
base diff.ChangedFiles — repo-relative
number forge file list — repo-relative

My round-two audit read the call site's comment ("the changed-file paths are repo-relative (git / forge)") and stopped there. That comment is true for two of the three producers, and I never walked the third.

The resolver now returns the domain with the files and both lookups use it. That also gives GraphKeyedPath its first production caller, which closes your non-blocking note.

New TestSuggestReviewers_IdsOnPrefixedGraphKeepsAllSignals builds a prefixed graph and asserts CODEOWNERS, recent-author and co-change all survive. Sabotage-verified — forcing the ids branch back to RepoRelativePath:

recent-author signal missing … [{Reviewer:org/secteam Kind:team Reasons:[CODEOWNERS for 1 file]}]

Only CODEOWNERS survives, exactly as you predicted — and exactly why the tool looked healthy.

2 — file-risk normalization

fromGraphKey called cleanPath (which ends in filepath.Clean) before the strip. Both domains now go through graphpath.Norm first and leave in the documented repo-relative / spelling.

Sabotage-verified with the strengthened assertion — restoring cleanPath-before-strip:

[{repo-a\repo-a\widget.go MEDIUM 0} {repo-a/widget.go LOW 1}]
  should have 1 item(s), but has 2

Two rows for one file: impact/coverage on the graph-prefixed row, the finding on the repo-relative one. Your point about NotEqual(shadow) was the important half — the old assertion passed on that output.

Bonus: this also fixes three pre-existing Windows failures in internal/reviewTestRankFileRiskUsesImpact, TestRankFileRiskNormalizesRepoPrefix, TestRankFileRiskExemptsTestFilesFromCoverageDebt. The package goes 7 → 4; the remaining four (TestVerificationCommand, TestRunRelocatesAndBlocks, both TestRenderSummary_*Golden) are untouched by this change and were already red.

3 — Windows coverage

Selector gains GraphKey|JoinFileNodes|ChangedSymbolsForFiles|PrefixShadow|IdsOnPrefixedGraph|RankFileRisk, and ./internal/review joins the package list.

A correction to what I told you last round: I said the ci.yml selector hunk was dropped from this branch. It was not — it was still there from the first revision, which is what you were reading at ci.yml:101-106. I misreported that; sorry.

The audit you asked for, done properly this time

Rather than trusting one comment again, I walked every producer for every consumer of the domain-carrying API:

  • changedSymbolsForFiles is fed only by resolvePRFiles and the forge fetch in prImpactForNumber — four call sites, all forge PR file lists, all repo-relative. Its hardcoded RepoRelativePath is correct.
  • The only two remaining TrimPrefix(…, repoPrefix+"/") sites are report.go's fromGraphKey and reviewRepoRelPath; both normalize first and both have an established domain.

Validation

  • Exact Windows selector, all six packages: ok.
  • internal/mcp whole package: 27 → 24 against main, failing test names diffed, newly-broken set empty.
  • internal/analysis: ok. internal/review: 4, down from 7.
  • golangci-lint: 6 staticcheck findings, all pre-existing.

One environment note on my numbers: Go 1.27 landed on this machine mid-session (for #654), so I pinned every figure above with GOTOOLCHAIN=go1.26.6 to match this branch's go.mod and your CI, rather than reporting results from a toolchain CI does not run.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fourth review round — requesting changes at head a1a5b87.

The previous IDs-mode double-prefixing and file-risk normalization blockers are fixed. Two path-domain correctness gaps remain:

  1. High — internal/analysis/diffmap.go:113: GraphKey returns immediately when repoPrefix is empty, before filepath.FromSlash. On standalone Windows, forge paths such as pkg/auth/x.go remain slash-spelled and miss graph keys stored as pkg\auth\x.go. This silently drops changed symbols and reviewer signals in get_pr_impact and suggest_reviewers(number=...). Only GraphKeyedPath should return unchanged; RepoRelativePath should always pass through filepath.FromSlash and then conditionally receive the prefix. Add a multi-segment empty-prefix Windows regression test; the current a.go fixture cannot expose this.

  2. Medium — internal/mcp/tools_suggest_reviewers.go:82: IDs mode returns graph-keyed paths, but CODEOWNERS still receives that spelling through relForRepo, which only strips an absolute repo root. A path such as repo-a/pkg/auth/login.go is passed instead of pkg/auth/login.go, so root-anchored rules such as /pkg/auth/ do not match. Convert the path to repo-relative form according to fileDomain before MatchFile and matched_files, and cover it with a root-anchored CODEOWNERS rule. The current unanchored pkg/auth/ fixture masks the mismatch.

Test gap: the deleted-file and renamed PreviousPath joins still have no prefixed/prefix-shadow regression cases. Their existing tests use an empty prefix, so they do not bind the new RepoRelativePath contract. Add prefixed delete and rename cases that resolve only the nested old-side graph key and include them in the Windows selector.

The first two behaviors also existed on the base branch, but they are directly inside the GraphKey/PathDomain contract introduced and exercised by this PR, whose purpose is cross-platform native-separator correctness. Current CI is green because the fixtures do not cover these cases. No new security or dead-code concern was found.

…nd CODEOWNERS the repo-relative spelling

Fourth review round on zzet#646.

1. GraphKey returned early on an empty repo prefix, before filepath.FromSlash.

A standalone index carries no prefix but its keys still use native
separators, so a '/'-spelled forge or git path missed them on Windows and
get_pr_impact / suggest_reviewers(number=...) silently lost changed symbols
and reviewer signals. Only GraphKeyedPath returns unchanged now;
RepoRelativePath always converts, then takes the prefix when one applies.

2. CODEOWNERS received a graph key in ids mode.

relForRepo only strips an absolute repo root, so repo-a/pkg/auth/login.go
reached MatchFile with the prefix still on and a root-anchored rule such as
/pkg/auth/ matched nothing. matched_files reported the same spelling back to
the caller. Both now go through the new analysis.RepoRelPath, GraphKey's
inverse: it strips the prefix only for a GraphKeyedPath, because that is the
one domain that carries it by construction.

3. Tests.

- Empty-prefix multi-segment cases for GraphKey and JoinFileNodes. The
  previous a.go fixture has no separator, so it cannot fail either way.
- The ids CODEOWNERS fixture is now root-anchored (/pkg/auth/). The
  unanchored rule also matched the graph-keyed spelling and masked the bug.
- TestJoinHunksToSymbolsPrefixedDeleteAndRename covers the two hunk-less
  change kinds against a prefix-shadowed graph: the delete's Path and the
  rename's PreviousPath must resolve the nested old-side key and never the
  same-named shadow. Their existing tests use an empty prefix and so did not
  bind this contract.
- The Windows selector gains PrefixedDeleteAndRename.

Each fix is sabotage-verified separately: forcing the ids branch back to a
raw graph key leaves only the CODEOWNERS-less reviewers, passing the graph
key to MatchFile drops the codeowner signal entirely, and declaring the
vanished path GraphKeyedPath makes the delete/rename test miss both old-side
symbols.

Four fixtures needed the same correction as TestChangedSymbolsForFiles did
earlier: prToolsTestServer, conflictsTestServer and conflictsBudgetServer
hard-coded '/'-spelled graph keys while supplying the same strings as forge
paths, which describes an index a Windows daemon never writes. They now store
native and supply the forge spelling, which is the shape under test.

Windows: internal/mcp 27 -> 24 against main, failing test names diffed,
newly-broken set empty. internal/analysis passes clean. internal/review keeps
its four pre-existing failures. Lint: 6 staticcheck findings, pre-existing.
@tiendungdev

Copy link
Copy Markdown
Contributor Author

Both blockers and the test gap are addressed. Pushed 49b7e5ec.

1 — GraphKey with an empty prefix

Confirmed. The guard conflated two conditions:

if domain == GraphKeyedPath || repoPrefix == "" { return path }   // before

so a standalone index — no prefix, but still native keys — got the /-spelled path back unconverted. Now only GraphKeyedPath returns unchanged; RepoRelativePath always goes through FromSlash and then takes the prefix if there is one.

Regression: TestGraphKey and TestJoinFileNodes gain empty-prefix multi-segment cases. You were right that the a.go fixture cannot expose this — with no separator in the path, both spellings are identical and the assertion is vacuous.

2 — CODEOWNERS in ids mode

Confirmed, including matched_files. Added analysis.RepoRelPath as GraphKey's inverse and routed both through it. It strips the prefix only for a GraphKeyedPath — the one domain that carries it by construction — so it cannot become the prefix-shadow bug in reverse.

The fixture is now root-anchored (/pkg/auth/) as you asked. Sabotage-verified: passing the graph key straight to MatchFile drops the codeowner signal and leaves only the two person reviewers.

3 — delete / rename old-side joins

TestJoinHunksToSymbolsPrefixedDeleteAndRename drives joinHunksToSymbols against a prefix-shadowed graph with both hunk-less change kinds — the delete's Path and the rename's PreviousPath — and asserts each resolves the nested old-side key and never the same-named shadow. Sabotage-verified: declaring the vanished path GraphKeyedPath makes both old-side symbols disappear.

PrefixedDeleteAndRename is in the Windows selector.

Four more fixtures had the same defect as the code

Fixing (1) turned four tests red — TestGetPRImpact_SuppliedFilesNoForge, TestPRTools_GCXTOONBudget, and both TestConflictsPRs_*. I checked rather than reverted: prToolsTestServer, conflictsTestServer and conflictsBudgetServer all hard-code /-spelled graph keys and hand the same strings back as forge-supplied files. That describes an index a Windows daemon never writes, and it is why those paths looked correct before.

They now store native and supply the forge spelling — the shape actually under test. Same correction TestChangedSymbolsForFiles_RepoPrefixJoin needed in the previous round.

Validation

  • internal/mcp on windows/amd64: 27 → 24 against main, failing test names diffed, newly-broken set empty.
  • internal/analysis: full package clean.
  • internal/review: the same four pre-existing failures, untouched.
  • Exact Windows selector across all six packages: ok.
  • golangci-lint: 6 staticcheck findings, pre-existing.

Two things worth stating plainly, since both cost me a detour:

  • A first combined run showed TestMapGitDiffRepoPrefixJoin and TestMapGitDiffDeletedFile red. They pass in isolation and the package passes clean on a rerun — load-dependent flakes on my machine, not this change. I would rather name them than have them show up later as a mystery.
  • All figures above are pinned with GOTOOLCHAIN=go1.26.6, matching this branch's go.mod. Go 1.27 is installed here for Add worktree and branch graph views #654 and I did not want to report numbers from a toolchain your CI does not run.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well done @tiendungdev!

@zzet
zzet merged commit 7eae340 into zzet:main Aug 23, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants