Skip to content

feat(xcresult): resolve test files from declarations, and stop dropping nested suites - #1178

Open
dfrankland wants to merge 12 commits into
mainfrom
dylan/xcresult-declaration-test-locations
Open

feat(xcresult): resolve test files from declarations, and stop dropping nested suites#1178
dfrankland wants to merge 12 commits into
mainfrom
dylan/xcresult-declaration-test-locations

Conversation

@dfrankland

@dfrankland dfrankland commented Aug 29, 2026

Copy link
Copy Markdown
Member

The one caveat, and what it is worth

The caveat: tests registered at runtime

A language server finds a test by reading its declaration in source. A test that has no
declaration — one synthesised at runtime — cannot be found this way, and this is the only
class of test where the failure-summary path can name a file and this path cannot.

That means, concretely:

  • Quick (with Nimble) — describe/it closures become test cases at runtime via
    class_addMethod; there is no func testFoo() anywhere in the source to find.
  • Anything overriding +testInvocations / testInvocations — the XCTest hook for
    generating cases programmatically.
  • Older Objective-C BDD frameworks built on the same trick (Kiwi, Specta).

For these, documentSymbol returns nothing useful and the test gets no file. The two
approaches fail in genuinely disjoint situations, which is why this ships as a flag with the
default path still in place.

Why the caveat is shrinking

The direction of travel is toward tests whose declarations are in the source:

  • Swift Testing (@Test, @Suite), which Apple ships in Xcode 16+ and develops in the
    open at swiftlang/swift-testing, is macro-based: @Test func foo() is a real function
    declaration, so it is exactly what documentSymbol reports.
  • Even its parameterised tests keep one declaration —
    @Test(arguments: [1, 2, 3]) func f(n: Int) expands to many cases from a single
    declaration site, which is the site we want to attribute to anyway.
  • Plain XCTest has always declared func testFoo() in source, so it was never affected.

So the uncovered population is the BDD-closure frameworks specifically, and new Swift test
code is being written against Swift Testing instead. (Directional argument from how these
frameworks work — not a measured adoption figure. generate_junits logs how many files
resolved from a declaration, from the fallback, and from neither, so a run against a real
repository can say how much of that repository this path actually covers.)

What we can delete if runtime-registered tests are not a target

If that population is out of scope, the failure-summary path stops being load-bearing and a
large amount of code goes with it.

Deleted outright

what size why it goes
src/xcresult_legacy.rs ~700 lines is the legacy path
legacy JSON schema ~2,980 lines only feeds the types that parse get object
its generator script ~320 lines maintains that schema
dump- / verify-failure-summaries.py 2 scripts fixture tooling for failure shapes
tests/data/*.legacy.junit.xml 4 files expected output for a path that is gone
petgraph dependency only xcresult_legacy.rs uses it, nowhere else in the workspace

Reduced

what from → to what is left
src/file_attribution.rs ~468 → ~90 lines only ReportedPath; the whole candidate cascade, TestIdentity and FileCandidate go, with 7 of its 9 test groups
src/xcresult.rs the FileAttribution enum collapses entirely and the two constructors merge back into one
src/xcrun.rs xcresulttool_get_object and xcresulttool_get_object_id go; their only callers are legacy
build.rs, src/types.rs two schemas → one legacy_schema module goes
src/main.rs, cli/ both experimental flags (12 references) and the clap conflicts_with + env=false wart go with them
tests/xcresult.rs 37 references to the failure-summary flag; every #[case] pair collapses to a single test

Roughly ~4,000 lines of checked-in code and schema, ~1,200 of it hand-written Rust.

The point is not the line count. get object disappears, so the unbounded per-test
summary fetch — 6 GB of JSON at a 48 GB peak on one timed-out test — becomes unreachable
by construction rather than by a bound, and the xcresulttool surface drops from four
commands to two.


Six commits. The first two are independent, so the second can be cherry-picked on its own; then two of test coverage, a fix for a pre-existing bug the coverage exposed, and a correctness fix for module collisions.


1. feat(xcresult): resolve test files from declarations, not failures

An .xcresult records where a failure was raised, never where a test is declared — there is no per-test declaration site anywhere in the bundle, and a passing test's summary is 638 bytes with no path at all. Everything in file_attribution.rs is therefore a proxy, and a failure raised inside a helper attributes the test to the helper's file, which is where codeowners are resolved from.

Behind --use-experimental-xcresult-test-locations (env TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS, hidden on upload), ask a language server instead. textDocument/documentSymbol over the checkout names the type containing each method, which is exactly the Suite/case pair an xcresult identifier already gives us.

This is not a new class of dependency — it is the same shape as what we ship today (shell out to an Xcode tool, parse structured output), and sourcekit-lsp/clangd ship in the Command Line Tools as well as Xcode, whereas xcresulttool ships only in Xcode. documentSymbol rather than workspace/symbol is deliberate: the latter is index-backed and makes the server index the checkout, while this parses one file on demand and needs neither an index nor a build.

It also changes which xcresulttool calls we make

default flag on
results get test-results tests same
run start time get object --legacy (whole ActionsInvocationRecord) get test-results summary
per-test failure summary get object --id <summaryRef> per failure never issued
file failure call stack → file_attribution.rs cascade documentSymbol over the checkout

The per-test summary fetch is unbounded — one timed-out test has been measured producing 6 GB of JSON at a 48 GB peak footprint, because we fetch the whole object to read failure_summaries.values.first(). The declaration path cannot reach that object at all, so this is structural rather than a bound.

Where it is worse

Tests registered at runtime have no declaration to find, and the two approaches fail in disjoint situations — which is why this is a flag and not a replacement. See The one caveat above for which frameworks, and why the intended sourceLocation fallback does not currently cover them.

New files

  • xcresult/src/lsp.rs — minimal LSP JSON-RPC client. Reader thread + recv_timeout; once a request times out the stream cannot be resynchronised (a late reply would be read as the answer to the next request), so the process is killed and later calls refused rather than an upload waiting on a dead server. Replies null to client/registerCapability/workspace/configuration instead of ignoring them.
  • xcresult/src/test_locations.rs — the (suite, case) → file:line index: ObjC ± prefixes, Class(Category) normalisation, suiteless top-level tests, superclass chaining, and a checkout scan ranked so files named after a suite go first.

The file list comes from a checkout scan, not the build log. The build log gives exact target ownership, but reading it costs a legacy get object call — the thing this path exists to avoid. The trade is that two same-named suites in different modules can collide.

2. fix(xcresult): stop dropping nested test suites — now #1183

A suite nested inside another suite, and every test it declared, was silently discarded. The traversal took only a suite's direct Test Case children, so a Test Suite child was never visited.

The symptom is worse than losing tests. Against the new fixture the pre-fix traversal emits:

tests="2" failures="0"     # before
tests="4" failures="1"     # after

The inner suite's two tests are dropped and its failure goes with them, so a run containing a failing test reports a clean bill of health.

JUnit has no nested <testsuite>, so a nested suite is now flattened into one of its own under a dot-qualified name (Bundle.Outer.Inner) — the convention the bundle prefix already used. The change is additive: an outer suite with no direct cases still emits its empty <testsuite> exactly as before, and the inner ones appear alongside it.

This is on the shared traversal, so it applies to the default path, not just the new flag.

3. test(xcresult): cover a nested suite and passing tests with a real bundle

Adds the nested-and-passing scenario, which closes both fixture gaps at once — see Fixtures.

4. test(xcresult): run every bundle through the declaration path as a regression net

Everything the flag was not designed around, checked for perturbation — see Every other bundle.

6. feat(xcresult): break a declaration collision with the target that ran the test

Scan order used to decide which of two same-named suites in different modules won a (suite, case) — a coin flip between two modules' files, and the one way this path can be confidently wrong where the failure-summary path cannot. nodeIdentifierURL is test://com.apple.xcode/<scheme>/<target>/<suite>/<case>, so the target is already in hand from the field the ids come from; record now prefers a candidate under a directory named for it. No extra xcresulttool call, and it works for a passing test, unlike anything derived from the failure.

Strictly a tie-break: where no candidate is under the target, or the test has none, the first file scanned still wins, so no file that was reported before stops being reported.

5. fix(xcresult): read a copy of the bundle instead of migrating the caller's — now #1184

A pre-existing bug on the shared path, found because the regression net made two tests read one fixture. xcresulttool migrates a bundle that predates database.sqlite3 in place on first read, so today the uploader either writes into a build artifact it was only asked to read, or fails outright when it cannot:

bundle format writable read-only
older (Data + Info.plist) mutateddatabase.sqlite3 written in exit 64, no JUnit
already migrated untouched fine
Error: "database.sqlite3" couldn't be moved because you don't have
permission to access "test4.xcresult".

Read-only artifact mounts are ordinary in CI, so this is a real failure mode, not a theoretical one. It is also why two concurrent readers of one bundle race.

Both constructors now copy into a TempDir and read that. The copy is unconditional rather than keyed on whether a migration would happen — sniffing the format to save a copy trades a correctness guarantee for work that takes well under a second on a 64 MB bundle. This is on the shared path, so the default path is fixed too, not just the flag.

test_reading_a_bundle_neither_writes_to_it_nor_needs_it_writable pins both halves; it fails without the copy.


Testing

cargo test -p xcresult125 tests pass on macOS (82 unit, 43 integration), plus the CLI's own suite.

The split is deliberate, because the parts that genuinely need macOS are narrower than they look:

  • src/test_locations.rs — symbol mapping, inheritance walk (including a cycle), category normalisation, superclass regex, scan ranking and caps, LSP wire framing (byte-vs-char, lowercase header, truncation).
  • src/xcresult.rs — suite flattening and the attribution join, against a canned Tests value and a seeded index (TestLocationIndex::declaring, test-only).
  • tests/xcresult.rs — the macOS tests that actually drive sourcekit-lsp and clangd over the checked-in packages in tests/fixture-src/.

What the declaration path is proven to do

Each of these drives a real language server over a real bundle. The last two columns are
what the existing paths report for the same test, so the comparison is against what we
ship rather than against nothing.

test shape legacy failure-summary
..._give_a_crashed_test_its_file fatalError in a dependency with zero call-stack frames; and a trait failure raised after the test's own frame is gone none none
..._give_a_passing_test_its_file three tests that passed, so no failure summary exists to read none none
..._prefer_the_tests_own_file_over_a_vendored_dependency helper under SourcePackages/checkouts/ none the test's own file
..._resolve_an_objc_test_through_clangd ObjC XCTFail in a shared category none the test's own file
..._prefer_the_tests_own_file_over_an_in_repo_helper helper in the test target the helper's file the test's own file
..._find_a_top_level_swift_testing_function suiteless @Test func failed by a helper the helper's file the test's own file
..._keep_ids_and_timestamps_identical_to_the_legacy_path equivalence against the path in production

So the declaration path matches the experimental failure-summary path wherever that path
can name a file at all, beats the legacy path everywhere, and is the only one of the
three that can name a file for a crash with no stack or for a test that passed.

It is not a superset, in two directions

Worth being precise, because the flag's fallback is what is supposed to cover the gap:

  • Coverage. A runtime-registered test (Quick's class_addMethod, +testInvocations) has no declaration to find. It is meant to fall back to the modern API's sourceLocation — but that field, while in the schema, is emitted in none of the 14 bundles here, so the fallback never fires and such a test gets no file, where the failure-summary path reads the call stack and would name one. The declaration path never issues get object, so it cannot see a stack; the failure path never scans the checkout, so it cannot see a declaration. Genuinely disjoint.
  • Correctness. The index comes from a checkout scan rather than the build log, so two same-named suites in different modules can both declare the same (suite, case). That is now broken by target rather than by scan order — see commit 6.

generate_junits now logs the split — N from a declaration, N from the fallback, N unresolved — so whether the fallback ever fires on real bundles is answerable rather than assumed.

Disambiguating from the failure instead would not have worked: the modern API gives only the failure message's File.swift:12: basename, that basename is where the failure was raised (usually a helper, matching neither candidate), and a passing test has no failure message at all — so the signal is absent exactly where the collision is most likely to go unnoticed.

The passing-test case asserts each test's status alongside its file, so a fixture that
drifted to all-failing could not keep it green while proving nothing.

Every other bundle, as a regression net

The five scenarios above were built to exercise this path, which says nothing about the
bundles that were not. test_the_declaration_flag_moves_the_file_and_nothing_else runs
every bundle in the suite through both paths and asserts they agree on suite, name, id,
status and timestamp — file is the one thing allowed to move and the one thing not
compared. That is cheaper and far less brittle than checking in a near-duplicate of every
expected JUnit differing only where the flag is meant to differ.

It is not vacuous: reverting the startTime rounding below fails all twelve cases, so
the bug that originally showed up on one fixture would now be caught on every one of them.

upload_bundle_using_xcresult in the CLI is parameterised over the flag too, so it is
covered end-to-end through argument parsing and the upload, not only at the crate boundary.

Each case unpacks its own copy of its bundle, which is load-bearing: xcresulttool
migrates a bundle in place on first read, so a second concurrent reader of a freshly
unpacked one races to create its database.sqlite3 and fails with

Error: "database.sqlite3" couldn't be moved to "test4.xcresult" because an item with the same name already exists.

Sharing the existing fixtures made test_complex_xcresult_with_valid_path fail under
parallelism until each case got its own copy.

Fixtures

Both gaps that were open are now closed by nested-and-passing, captured through regenerate.sh like every other scenario (93MB of unreferenced symbolication data pruned to 0; 30KB checked in). Its inner @Suite is declared in a different file from the suite containing it, so resolving it needs a per-test declaration rather than the enclosing suite's file, and three of its four tests pass.

Its shape is structural rather than a failure, which is the one thing verify-failure-summaries.py cannot express, so it is checked by a sibling verify-test-structure.py that asserts the nested suite, the pass/fail split, and the presence of the nodeIdentifierURL ids derive from.


What running it on a Mac found

Three things were flagged as unverifiable off macOS. All three were checked; two held, and the third turned up a real bug.

nodeIdentifierURL is emitted. Parsed out of the tree of every bundle in tests/data/: 546 test cases, 0 missing it. The silent fallback to nodeIdentifier — which would have given every xcresult test case in the product a new identity — is never reached. The new fixture's structural verifier now pins this so it cannot regress unnoticed.

startTime is Unix epoch seconds, not an Apple reference-date offset. test1.xcresult reports 1727723571.1592024-09-30 19:12:51Z, and the legacy record's own string for the same run is "2024-09-30T12:12:51.159-0700".

But the conversion was lossy, which is what the equivalence test actually caught:

declarations: 2024-09-30 19:12:51.158999919 +00:00
legacy:       2024-09-30 19:12:51.159       +00:00

(start_time.fract() * 1e9) as u32 extracts nanoseconds from an f64 whose ULP at epoch magnitude is 238 ns, so those digits were float noise. Both sources carry milliseconds — xcresulttool prints three decimals and the legacy parser reads %.3f — so this now rounds to the millisecond via from_timestamp_millis. Ids were byte-identical throughout; only the timestamps moved.

No snapshot diffs. Every existing expected JUnit was left untouched, because no pre-existing fixture had a nested suite.

The five LSP-driving tests passed first run. sourcekit-lsp and clangd both resolve under the installed Xcode; no debugging was needed.

A compile break the test suite could not see

coalesce_junit_path_wrappers was refactored to take an XCResultOptions, but the unit tests inside cli/src/context.rs still passed the old eight arguments. cargo test -p xcresult never compiles them, so this survived — CI would have failed. Fixed in the commit that introduced it, which now builds and passes on its own.


Known warts

  • conflicts_with and an env var set to false. The flag is incompatible with --use-experimental-failure-summary (which tunes a code path this one does not run), enforced by clap rather than a runtime warning. clap correctly ignores a default-sourced value for conflicts, but treats an env-supplied value as present regardless of what it says — so TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS=false together with --use-experimental-failure-summary is a hard conflict error. Unset the variable to roll back rather than setting it to false. Documented in CONTRIBUTING.md.
  • context/src/junit/parser.rs is in this diff and is unrelated. trunk fmt runs cargo fmt workspace-wide, and that file is not rustfmt-clean on main under the pinned toolchain. Reverting it is unstable — anyone running trunk fmt re-applies it.
  • trunk check's clippy still cannot run to completion, for an environmental reason rather than a code one: --all-targets --all-features pulls in rb-sys, which fails with Failed to setup stable API. Confirmed to fail identically on main. cargo clippy -p xcresult -p trunk-analytics-cli --all-targets is clean, and trunk fmt is clean on every changed file.
  • classname on a nested test names the outer suite (OuterSuite, not OuterSuite.InnerSuite), because it is the first component of the identifier. Pre-existing behaviour on the shared traversal that only becomes visible now that nested tests are emitted at all; it does not feed the id, which is a UUIDv5 over org#repo#identifierURL. Left alone deliberately.

Docs

xcresult/CONTRIBUTING.md covers suite flattening as a stated purpose of the crate, the new flag and --repo-root, what the declaration path buys and where it is worse, the clap wart, the id-stability argument, cost and limits, and the fixture coverage above. tests/fixture-src/README.md documents the new scenario and which verifier enforces it.

🤖 Generated with Claude Code

@trunk-io

trunk-io Bot commented Aug 29, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

dfrankland and others added 3 commits August 28, 2026 18:13
An `.xcresult` records where a failure was *raised*, never where a test is
declared — a passing test's summary is 638 bytes with no path at all. So the
file we report is inferred from the failure, and a failure raised inside a
helper hands the test to whoever owns the helper.

Behind `--use-experimental-xcresult-test-locations` (env
`TRUNK_USE_EXPERIMENTAL_XCRESULT_TEST_LOCATIONS`), ask a language server
instead: `documentSymbol` over the checkout names the type containing each
method, which is the `Suite`/`case` pair an xcresult identifier already gives
us. `sourcekit-lsp` and `clangd` ship in the Command Line Tools as well as
Xcode, so this is the same shape as what we already do — shell out to an Xcode
tool, parse structured output — not a new class of dependency.

The flag also changes which calls we make. The declaration path issues
`get test-results tests` and `get test-results summary`, and never
`get object --legacy`, so the unbounded per-test summary fetch — 6 GB of JSON
and a 48 GB peak footprint on one timed-out test — is not reachable from it.

Ids do not move: `nodeIdentifierURL` on the modern API is the legacy record's
`identifierURL` under another name, and an integration test pins both paths to
the same ids and timestamps.

A test with no declaration to find (Quick, `+testInvocations`) falls back to
the modern API's own `sourceLocation`, vetted against the same vendored-path
rules as the failure-summary path — the two fail in disjoint situations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A suite nested inside another suite, and every test it declared, was discarded:
the traversal took only a suite's direct `Test Case` children, so a `Test Suite`
child was never visited. A swift-testing bundle with 7 tests emitted 4
testcases, with the outer suite left as an empty `<testsuite>` — the tests were
simply missing from the upload, silently.

JUnit has no nested `<testsuite>`, so a nested suite is flattened into one of
its own under a dot-qualified name (`Bundle.Outer.Inner`), which is the
convention the bundle prefix already used. The change is additive: an outer
suite with no direct cases still emits its empty `<testsuite>` exactly as
before, and the inner ones now appear alongside it.

This is on the shared traversal, so it applies to the default path, not only to
`--use-experimental-xcresult-test-locations`.

No checked-in fixture bundle appears to contain a nested suite (none of the
expected JUnit files has an empty `<testsuite>`, and the bundle blobs are
Apple's compressed encoding, so this could not be confirmed off macOS). If the
macOS suite reports a snapshot diff, that is a fixture that did have the bug —
the added testcases are the fix working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndle

Both shapes the two preceding commits changed were proven only by unit test
against a canned `Tests` value, because no captured bundle had either: none of
the scenarios has a suite nested in a suite, and none has a test that passed.

`nested-and-passing` captures both at once. Its inner `@Suite` is declared in a
different file from the suite containing it, so resolving it needs a per-test
declaration rather than the enclosing suite's file, and three of its four tests
pass, so no failure summary names a file for them at all.

Run against the pre-fix traversal the bundle emits `tests="2" failures="0"` —
the inner suite is never visited, so its two tests are dropped and a run with a
failing test reports no failures. That is the symptom the flattening fix was
worth making, and it now has a bundle behind it.

The shape is structural rather than a failure, which is the one thing
`verify-failure-summaries.py` cannot express, so `regenerate.sh` checks this
scenario with a sibling `verify-test-structure.py` that asserts the nested suite,
the pass/fail split, and the presence of the `nodeIdentifierURL` the ids derive
from.

The declaration-path tests now assert each test's status alongside its file, so a
fixture that drifted to all-failing could no longer keep the passing case green
while proving nothing, and the crash scenario's test is named for the crash it
covers rather than only for the reason no failure summary can serve it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrankland
dfrankland force-pushed the dylan/xcresult-declaration-test-locations branch from e3a2385 to 4f4dea0 Compare August 29, 2026 19:37
dfrankland and others added 3 commits August 29, 2026 13:07
…gression net

The flag was proven on the five fixtures built to exercise it, which says nothing
about the bundles it was not designed around — and those are most of them.

Rather than snapshot each bundle a second time, which would mean checking in a
near-duplicate of every expected JUnit differing only where the flag is supposed
to differ, this asserts the invariant directly: for every bundle the suite reads,
the declaration path and the default path agree on suite, name, id, status and
timestamp, and the reported file is never a vendored path. `file` is the one
thing allowed to move, and it is the one thing not compared.

It is not a vacuous check. Reverting the `startTime` rounding fails all twelve
cases, so the millisecond bug this suite only caught on a single fixture would
now be caught on every one of them.

Each case unpacks its own copy of its bundle. `xcresulttool` migrates a bundle in
place on first read, and pointing a second concurrent reader at a freshly
unpacked one races to create its `database.sqlite3`:

    Error: "database.sqlite3" couldn't be moved to "test4.xcresult"
    because an item with the same name already exists.

Sharing the existing fixtures would have made every bundle a two-reader race and
turned `test_complex_xcresult_with_valid_path` intermittent.

The CLI's own xcresult upload test is parameterised over the flag too, so the
path is covered end-to-end through argument parsing and the upload rather than
only at the crate boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ler's

`xcresulttool` migrates a bundle that predates `database.sqlite3` in place the
first time it is read. Two things follow, neither of them ours to do:

- an upload writes into a build artifact it was only asked to read, and
- the read fails outright when that directory is not writable:

      Error: "database.sqlite3" couldn't be moved because you don't have
      permission to access "test4.xcresult".

  which is `exit 64` and no JUnit at all, on the read-only artifact mounts CI
  systems hand out.

It is also why two readers of one bundle race, which is what made
`test_complex_xcresult_with_valid_path` fail once a second test read the same
fixture.

Both constructors now copy the bundle into a `TempDir` and read that, so the
caller's directory is never written to and never needs to be writable. This is
on the shared path, so the default one is fixed too, not just the flag. The copy
is unconditional rather than keyed on whether a migration would happen: sniffing
the format to save a copy trades a correctness guarantee for work we already do
in well under a second on a 64 MB bundle.

The declaration path's fallback is instrumented while here. It is meant to catch
runtime-registered tests by reading the modern API's `sourceLocation`, but that
field is emitted in none of the bundles in `tests/data/`, so it never fires and
such a test gets no file at all. `generate_junits` now logs how many files came
from a declaration, from the fallback, and from neither, so whether that holds
against real-world bundles is answerable rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n the test

The declaration index is built from a checkout scan rather than the build log,
because reading a build log costs the legacy `get object` call this path exists
to avoid. The trade was that two same-named suites in different modules both
declare the same `(suite, case)`, and `declarations` is a
`HashMap<TestKey, DeclarationSite>`, so whichever file the scan reached first
won and nothing recorded that there had been a choice.

Scan order is arbitrary, so that was a coin flip between two modules' files. It
is the one way this path can be confidently wrong where the failure-summary path
cannot, since that one reads the frame that actually ran — and for codeowners a
wrong file is worse than no file at all.

`nodeIdentifierURL` is `test://com.apple.xcode/<scheme>/<target>/<suite>/<case>`,
so the target is already in hand from the field the ids are derived from. `record`
now prefers a candidate lying under a directory named for that target, which
needs no extra `xcresulttool` call and works for a passing test as well as a
failing one — unlike anything derived from the failure, which a passing test does
not have.

Where no candidate is under the target, or the test has no target, the first file
scanned still wins, so this is strictly a tie-break and never removes a file that
would have been reported before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrankland
dfrankland force-pushed the dylan/xcresult-declaration-test-locations branch from d317c14 to eb72de1 Compare August 31, 2026 17:11
@dfrankland

Copy link
Copy Markdown
Member Author

@claude review

@dfrankland dfrankland left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

In general, let's try to use crates (if a popular, well supported option exists) instead of writing our own code. And in addition, make sure tests attempt to use the public API surface via tests dir instead of inline. Inline tests are still allowed, but should be reserved for small assertions that are meant to enforce things that can't be done at compile time.

In addition, are we able to check how fast searching for a document symbol is in a large Swift / ObjC repo?

Comment thread xcresult/src/lsp.rs Outdated
Comment thread xcresult/src/test_locations.rs
Comment thread xcresult/src/lsp.rs
Comment thread xcresult/src/lsp.rs
Comment thread xcresult/src/lsp.rs Outdated
Comment thread xcresult/src/lsp.rs
Comment thread xcresult/src/test_locations.rs Outdated
Comment thread xcresult/src/test_locations.rs Outdated
Comment thread xcresult/src/test_locations.rs
Comment thread xcresult/src/xcresult.rs Outdated
Comment thread xcresult/src/test_locations.rs Outdated
A test declared on a base class runs again under every concrete subclass, and
the supertype chain reported the base class's file for both. The reported file
is what codeowners are resolved from, so that handed the subclass's failures to
whoever owns the base class — the misattribution `file_attribution` exists to
prevent, one level up. The concrete suite chose to run the test, so it is the
one reported.

That deletes the chain, and with it the inheritance-clause regex: `superclass`,
`SUPERCLASS`, `DECLARATION_HEAD_LINES`, the `supertypes` map and its cycle
guard. Across three large iOS checkouts 99.7% of the edges it built pointed at
`XCTestCase` or `NSObject`, which no declaration can ever resolve to.

Where a failure surfaced is no longer a fallback either. It names the file the
failure was raised in rather than the one the test is written in, so reporting
it resolves the wrong codeowners; no file resolves none, which is recoverable.
A test with no declaration to find is runtime-registered (Quick,
`+testInvocations`) and now lands in the `unresolved` counter.

Crates replace the hand-rolled protocol code:

- `lsp-server` and `lsp-types` own the framing, method names and payload
  shapes. That also fixes a latent bug: the old `file_uri` emitted
  `file://tests/…` for a relative root, where `tests` parses as the authority
  and a path component is silently lost. `url::Url::from_file_path` encodes and
  absolutizes, and only the URI is absolute — reported paths are unchanged.
- `ignore` walks the checkout. Its extensions are registered explicitly because
  the built-in Objective-C type maps `.h`, which no clang server can answer
  `documentSymbol` for on its own, and `SKIPPED_DIRECTORIES` stays as an
  override over `.gitignore`.

`Limits` is settable per run, because the right values depend on the repo: the
clang server answers around 9 files/s against sourcekit-lsp's ~180, so an
Objective-C heavy checkout needs the budget and file cap well above the
defaults. The budget is spent per server kind rather than across both, which a
shared deadline let a large Swift tree exhaust before clangd started, and a
server that stops answering is replaced rather than abandoned.

Two fixtures cover the shapes involved. The Objective-C one earned its keep
immediately: it caught the suite fallback being used as the condition for
having resolved a test, which ended the scan at the first file naming a suite
and would have collapsed every test to its suite's file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrankland

Copy link
Copy Markdown
Member Author

Review addressed in efa56ef — all 11 inline threads replied to and resolved. Answering the two general points and the open question here, since they span several threads.

"Is documentSymbol fast enough in a large Swift / Objective-C repo?"

Measured, rather than estimated. Harness replicates Resolver::parse exactly — same server args, didOpen(full text) → documentSymboldidClose, one server process for the whole batch — over 1000 files per repo against real checkouts (firefox-ios 3173 .swift, WordPress-iOS 3287 .swift, firebase-ios-sdk 866 .m/.mm).

firefox-ios WordPress-iOS firebase-ios-sdk
server sourcekit-lsp sourcekit-lsp clangd
total wall (1000 files) 6.2 s 5.2 s 91.6 s
throughput 162 files/s 193 files/s 9 files/s
p50 / p90 / p99 2.6 / 5.3 / 16 ms 2.4 / 4.9 / 13 ms 46 / 339 / 682 ms
files inside a 60s budget ~9,900 ~12,200 ~570

Swift is a non-issue and does not degrade across the batch (first 100 ≈ 8.6 ms, last 100 ≈ 2.0 ms — the early cost is warmup). clangd is ~20× slower per file and is the binding constraint, so max_files: 2000 is comfortable for Swift and unreachable for Objective-C. Hence making all of it configurable rather than picking better constants.

Two things the measurement turned up that were not in the review:

  • Both servers shared one deadline set once in resolve, and Swift is parsed first, so a large Swift tree could exhaust the budget before clangd began. Budget is now per server kind.
  • 71 of 866 Objective-C responses (8%) came back empty. Not chased in this PR, but noting it — CLANG_EXTENSIONS excludes .h, and adding headers would roughly double the slowest path, so that is a trade to make with these numbers in hand.

"Prefer crates over our own code"

lsp-server (framing + JSON-RPC), lsp-types (methods, payloads, SymbolKind), url (path → URI), ignore (the walk). Net effect on the two files involved is −524 lines against +902, most of the additions being fixtures and tests. Two departures are argued on their threads: lsp-server rather than jsonrpsee, since jsonrpsee has no stdio transport and would not have removed the framing; and explicit globs rather than ignore’s add_defaults(), which maps .h.

"Tests against the public surface in tests/"

New coverage is all real bundles in tests/: two fixtures (inherited-test, objc-category) in a new tests/declaration_locations.rs over a shared tests/common/mod.rs. Inline tests that duplicated existing integration coverage are deleted rather than moved; four more went with the hand-rolled framing they tested. tests/xcresult.rs is down from 1226 to 1091 lines with no existing test relocated. Details and what stays inline are on the two tests/ threads.

Worth a second look: the objc-category fixture caught a bug I had introduced, which is the one change here I would most want reviewed. The suite fallback was being used as the condition for a test having been resolved, so the scan stopped at the first file naming a suite — collapsing every test to its suite’s file instead of its own. lookup (reporting) and method_declaration (stop condition) are now separate, with the reasoning in a doc comment and an inline test pinning the invariant.

Verification: 220 tests passing across xcresult and trunk-analytics-cli, cargo fmt clean, trunk check clean on all 36 modified files.

dfrankland and others added 2 commits September 3, 2026 14:16
…eady prove

Inline tests were carrying 1033 lines across five modules. Sorting them by why
they were inline rather than by where they sat:

`file_attribution`'s 22 tests only ever touched public API — `ReportedPath`,
`TestIdentity::is_named_by`, `FileCandidate::from_failure_summary` and
`from_issue_summary` are all `pub` — so they move to `tests/` verbatim. The one
exception called the private `stack_frames`; with no `fileName` and no location
every candidate offered is a stack frame, so it reaches the same filtering
through `from_failure_summary` and asserts the provenance too.

`xcresult`'s tests observed only public output but built their input by struct
literal over private fields, and five of them turned out to duplicate coverage
that already exists against real bundles:

- `nested_suites_are_flattened` and `a_passing_test_case_has_no_file` are both
  encoded in `test-nested-and-passing.junit.xml`, which is compared byte for
  byte — the three passing cases carry no `file` attribute and the failing one
  does.
- `a_failure_raised_elsewhere` is the two `prefer_the_tests_own_file_over_*`
  tests.
- `a_nested_test_case_is_attributed` is
  `test_declaration_locations_give_a_passing_test_its_file` plus the id
  comparison in the flag-parity test.
- `a_category_records_against_the_class_it_extends` and
  `an_inherited_test_resolves_to_the_concrete_suites_file` are the two fixtures
  added earlier on this branch, which prove the same thing from a real bundle
  rather than from hand-written symbol JSON.

The sixth is expressible without the private seam: run the declaration path
against a checkout that declares nothing, and no test gets a file at all.

That leaves `TestLocationIndex::declaring` unused — the `#[cfg(test)]` seam for
seeding an index without a language server — so it is deleted. Nothing was made
public to get here.

What stays inline, and why it cannot move:

- `xcresult_legacy` reaches three private associated functions, and its public
  entry points read a bundle, so its fifteen-case precedence tables would need
  fifteen `.xcresult` fixtures.
- `test_locations` asserts on `TestKey`'s private fields, and the rest are
  negative cases or private pure functions with no capturable fixture.
- `lsp`'s three assertions cover `file_uri`, where both failure modes are
  silent: a server that cannot parse a URI answers with no symbols.

One coverage loss worth naming: the `Skipped` and `Expected Failure` statuses
are no longer pinned. `Passed` and `Failed` are covered by real bundles, and
`find_test_case_file` keys on `node_identifier` without inspecting `result`.

  xcresult.rs          841 -> 516 lines, 0 inline tests
  file_attribution.rs  468 -> 271 lines, 0 inline tests
  inline test lines   1033 -> 447

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two fixtures added on this branch were checked by appending `#[case::]` entries
to `test_the_declaration_flag_moves_the_file_and_nothing_else`, which lives in
`tests/xcresult.rs`. That put new tests in the file everything else was being moved
out of, and the only alternative at the time looked like duplicating the 135-line
host test.

Extracting its assertion to `tests/common` removes the choice: the check is now
`assert_the_declaration_flag_moves_only_the_file`, and each set of fixtures runs it
from wherever its own tests live. The twelve original cases stay in `xcresult.rs`
against the older bundles; the two new ones move next to the fixtures they cover.

`tests/xcresult.rs` now has no test that was not already there before the branch —
its only removals are the four helpers that moved into `tests/common`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dfrankland
dfrankland marked this pull request as ready for review September 3, 2026 22:46
dfrankland and others added 3 commits September 3, 2026 22:58
Copying was introduced to stop `xcresulttool` migrating a bundle in place, which
writes into a directory we were only asked to read and fails where it is not
writable. It cost a full copy of every bundle on every upload to buy that.

The copy is not what it looked like. Removing it and running the suite four times
produced no races at all: the shared fixtures are current-format bundles that
carry `database.sqlite3` already, so nothing migrates and nothing contends. The
only failure was the test written to pin the read-only guarantee, on the one
fixture that genuinely predates the file.

So the trade is narrower than the copy implied — it protects bundles in a format
Xcode no longer writes, and nothing else. Reading in place is what the CLI did
before this branch, and the older format is now an accepted limitation rather
than something every upload pays to avoid.

`tests/bundle_reading.rs` pins both halves so the behaviour is documented rather
than rediscovered: a read-only current-format bundle is readable and comes back
byte-identical on disk, and a read-only legacy one fails its in-place migration.
The second test is the limitation itself; if it ever starts passing, the
migration behaviour changed and the note in `xcresult.rs` is stale.

Its `entries` and `set_writable` helpers move to `tests/common` on the way, since
both halves need them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every test this branch added to `tests/xcresult.rs` is about the declaration path,
and they had accumulated at the end of a file that was already the longest in the
crate. They move to `tests/declaration_locations.rs`, where the rest of that
coverage already lives:

- the seven `test_declaration_locations_*` tests,
- `test_a_nested_suite_is_flattened_rather_than_dropped`, and
- `test_the_declaration_flag_moves_the_file_and_nothing_else`, whose twelve cases
  rejoin the two that were split off into `declaration_locations.rs` earlier.

`assert_junit` is now called from both files, so it joins the rest of the harness
in `tests/common`, which also picks up a blanket `allow(dead_code)`: the module is
compiled into each test binary separately, so whatever one binary does not call is
dead from its point of view.

Measured against `main` rather than the branch tip, `tests/xcresult.rs` now has no
test this PR did not find there — 601 lines, all of it pre-existing. The suite is
unchanged at 110 tests, and no case was dropped in the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six of them differed only in which fixture they read: unpack a bundle, ask
`declaration_files` for it, assert a case resolves to the file it is written in.
They become one table of six cases, which also drops five of the file's fixture
`lazy_static`s — each case unpacks its own bundle.

`keep_ids_and_timestamps_identical_to_the_legacy_path` looked subsumed by the
flag-parity test, whose comparison already covers both fields, and was not: it
carried a guard the parity assertion lacked. Two paths that both emit an empty id
compare equal, so the comparison can pass while proving nothing — which is the
failure mode worth catching, since a missing `nodeIdentifierURL` would silently
re-identify every xcresult test case in the product and a `startTime` read against
the wrong epoch would put every timestamp three decades out.

The guard moves into the shared assertion instead, so all fifteen parity cases get
it rather than one bundle, and the test folds in as the fifteenth case. Checked
against every fixture first: all 558 cases across the fifteen bundles carry both
fields, so the guard holds. `shape` now returns its columns field-wise so the guard
reads the id and timestamp rather than sniffing their rendering, and blanking the
id in `shape` makes all fifteen cases fail, so the guard is doing something.

  declaration_locations.rs  486 -> 339 lines, 13 tests -> 7
  suite unchanged at 110

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant