Skip to content

fix(detect): relativize manifest keys that spell root differently - #3583

Open
L4XB wants to merge 2 commits into
Graphify-Labs:v8from
L4XB:fix/manifest-stale-key-identity-anchor
Open

L4XB wants to merge 2 commits into
Graphify-Labs:v8from
L4XB:fix/manifest-stale-key-identity-anchor

Conversation

@L4XB

@L4XB L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What

_to_relative_for_storage decides in-root vs out-of-root with os.path.relpath, which compares text. A stored absolute key can name a file that is under root while spelling it differently:

  • the corpus was reached through a symlinked checkout path (~/work -> ~/checkouts/repo, or /tmp -> /private/tmp on macOS)
  • the root differs only in case on a case-insensitive filesystem (default APFS, NTFS)

relpath answers ../…, the key is kept absolute, and the next save writes the fresh relative key beside it. The manifest then holds two keys for one file, and the stale one never expires: it is not pruned (the file exists), and it is relativized to ../… again on every future save.

Measured on v8 (symlinked checkout, save_manifest(..., root=real, scan_corpus=…)):

before: keys=2  absolute=1  ['/…/work/pkg/mod.py', 'pkg/mod.py']
after : keys=1  absolute=0  ['pkg/mod.py']

How

Only when the lexical answer is ../…, ask the filesystem instead of accepting it: walk the key's ancestors for the one that os.path.samefile matches the resolved root, and join the remainder with its original casing preserved. No ancestor matches means genuinely out-of-root, and the key round-trips absolute exactly as before.

The lexical branch is untouched and still runs first, so an in-root key costs one string operation as it does today; the ancestor walk only happens on the branch that previously gave up.

samefile is the right primitive here because it is platform-neutral: os.path.normcase is a no-op off Windows, so a case-based fix could be verified neither locally nor by this repo's Ubuntu CI, while samefile resolves the symlinked form on every platform and the case-variant form on every case-insensitive one.

Tests

tests/test_detect.py:

  • test_save_manifest_relativizes_key_reached_through_symlinked_root — end-to-end, deterministic on every filesystem including CI: the legacy key collapses onto the fresh relative key instead of duplicating it.
  • test_save_manifest_legacy_case_variant_key_does_not_duplicate — the case shape. Asserts the collapse on a case-insensitive filesystem and, on a case-sensitive one, that both keys survive because there the two paths really are different files. No skip, so the test says something on both.
  • test_anchor_under_root_returns_none_when_no_ancestor_is_root — the negative side, unit-level. save_manifest prunes rows whose file no longer exists before relativizing, so the unresolvable out-of-root shape cannot be reached end-to-end and is pinned here.

test_save_manifest_out_of_root_keeps_absolute and test_save_manifest_full_scan_keeps_out_of_root_rows already pin that real out-of-root rows are neither relativized nor pruned; both still pass.

Mutation battery over the new branch, 5 of 7 killed:

mutant
fallback never fires killed
result not wired into the caller killed
remainder joined in walk order killed
unreadable ancestor anchors anyway instead of bailing killed
stops at the first ancestor instead of walking up killed
anchors against raw root instead of resolved root survived — samefile stats both sides, so the two spellings are equivalent; resolving once up-front is kept because the value is already computed for relpath
lexical branch bypassed, always take the identity path survived — the walk is a superset of the lexical answer for in-root keys, which is why the fast path stays first: taking it always would cost one stat per ancestor per file

Full suite: 5350 passed, 25 failed — the same 25 failures origin/v8 produces on this machine (missing optional terraform/ollama extras), unchanged by this branch.

Relation to #3581

This is not the shape reported in #3581 and does not close it. Reproducing that report separates cleanly into two cases, both measured on v8:

[reclone  scan_corpus=True ] after=6  absolute=3   <- the reported 38 stale keys
[move     scan_corpus=True ] after=3  absolute=0   <- already clean today

In the reporter's re-clone case the old checkout still exists on disk, so the stale rows survive the Path(f).exists() prune and are then protected by the _in_root(f) guard in the seed loop, which deliberately never prunes out-of-root rows (pinned by test_save_manifest_full_scan_keeps_out_of_root_rows). Changing that is a semantics decision about --include/symlinked corpora, so I have left it alone and written the measurements up on the issue instead.

What this PR fixes is the adjacent case that has no such trade-off: a key that names a file which really is inside root, only spelled differently. There the duplicate row is unambiguously wrong.

``_to_relative_for_storage`` compares text. A stored absolute key naming a
file under ``root`` through a symlinked checkout path, or with different
case on a case-insensitive filesystem, relativizes to ``../...`` and is
kept absolute for ever. The next save writes the fresh relative key beside
it, so the manifest describes the same file twice and the stale row never
expires.

When the lexical answer says out-of-root, ask the filesystem before
accepting it: walk the key's ancestors for the one that ``os.path.samefile``
matches the resolved root, and join the remainder with its original casing.
Keys with no such ancestor still round-trip absolute, and the lexical
fast path is unchanged, so in-root keys pay nothing.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Fixes manifest key relativization so an absolute key that spells root differently — a case variant on Windows/APFS or a symlinked checkout — is recognized as in-root by filesystem identity rather than lexical text, preventing an upgrade from leaving a stale absolute key beside its new relative twin (#3581). Adds _anchor_under_root, which walks the key's ancestors for one that os.path.samefile-matches the resolved root and rejoins the remainder with original casing; it runs only after the lexical relpath attempt yields ../, returns None for genuinely out-of-root paths or any ancestor it can't stat, and leaves those keys absolute as before.

No blocking issues surfaced. 4 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2496 functions depend on the 538 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 637 callers, 45 callees
  • new: _rebuild_code() — 123 callers, 53 callees
  • new: detect() — 112 callers, 15 callees
  • new: _extract_generic() — 18 callers, 29 callees
  • new: save_manifest() — 42 callers, 11 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • …and 45 more — each is listed as a finding

Verification — 2496 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 984 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

52 of 283 test file(s) selected (18%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_atomic_writes.py — impact
  • tests/test_build.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_claude_cli_backend.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_detect.py — impact, changed-test
  • tests/test_dotnet.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_file_slice.py — impact
  • tests/test_ignore_file_encoding.py — impact
  • tests/test_image_vision.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_incremental_mtime_collision.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_long_path_hashing.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_mcp_ingest.py — impact
  • tests/test_multilang.py — impact
  • tests/test_non_regular_files.py — impact
  • tests/test_office_incremental.py — impact
  • tests/test_office_limits.py — impact
  • tests/test_ollama.py — impact
  • tests/test_out_dir_evidence.py — impact
  • tests/test_oversized_document_slicing.py — impact
  • tests/test_package_json_subpath_imports.py — impact
  • tests/test_pdf_slicing.py — impact
  • tests/test_pdf_token_estimate.py — impact
  • tests/test_phantom_external_import.py — impact
  • tests/test_pipeline.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_stale_prune.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_unmapped_at_alias_resolution.py — impact
  • tests/test_vue_extraction.py — impact
  • … and 2 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_to\_relative\_for\_storage.

The verifier did not have enough to check \_to\_relative\_for\_storage, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

· 53 more finding(s) on lines outside this diff (see the check run).

…Linux

The case-sensitive branch asserted the variant key stays absolute, but on a
case-sensitive filesystem that path does not exist at all, so save_manifest's
existing "drop rows whose file is gone" prune removed the row before anything
relativized it. The branch was measuring that prune rather than the anchoring
decision, and failed on every CI job.

Create the variant file when the probe says case-sensitive, so what the branch
measures is that `os.path.samefile` refuses to anchor a genuinely different
file. Verified against the same shape locally with two distinct directory
names: the fresh key relativizes, the other keeps its absolute key.
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.

1 participant