Repository navigation
Fourth cross-app spelling: the C# stage needs a root literal as Path.Combine's first argument, so a specific-file read with a variable base is invisible — and four in one night is the finding #3082
Description
The cross-app guard's C# stage has now produced four distinct spellings it could not see, in one night, each found while fixing the previous one. This issue records the fourth — and argues that four is enough evidence to ask whether the shape is wrong rather than widening it a fifth time.
The fourth spelling
The C# stage's second matcher requires the root literal to be the first Path.Combine argument. A cross-app read of a specific file whose base directory sits in a variable is therefore invisible. Live instances, all confirmed absent from the found set:
Lite/Database/Schema.cs—CollectorStateContractTests:84RemoteCollectorService.DefinitionRunner.csand.QueryStoreBackfill.cs—CollectorStateContractTests:130,QueryStoreStatePruneTests:187,228,230FactCollectorFailureReportingTests:174StoreSqlClockDisciplineTests:619FileGrowthAlertStoreTests:134— which uses a segment-list helper with noPath.Combineat all
This is the most consequential of the four so far, because these name specific files. A path filter entry could genuinely be written for them, which is not true of a whole-tree enumeration.
Nothing is unguarded today. All of these live under Lite/**, which the darling filter reaches, so the suites run. As with the previous three, what is missing is the guard's ability to know that — and its failure direction is the same one: it fails toward not noticing, silently.
It is already pinned as a recorded limitation in TheCSharpMatchers_StillCannotSeeTheseSpellings (PR #3079), so this gap is written down in code rather than implied. Closing this issue means promoting a limitation that is currently honest.
The four, and why the pattern is the finding
| # | issue | what the stage could not see | cause |
|---|---|---|---|
| 1 | #3063 | a read expressed as an MSBuild Compile Include |
population — project XML was never opened |
| 2 | #3067 | Lite.Tests/… from Darling.Tests |
anchor breadth — the app dir cannot see a sibling test tree |
| 3 | #3076 | a bare directory name, no separator | anchor shape — a directory is not a path to the matcher |
| 4 | this | a specific file with the base in a variable | argument position — the root must be Path.Combine's first argument |
Each fix was correct and each revealed the next. That is the signature of re-implementing a language rather than bounding a problem — and it is precisely the argument #3074 makes about the project-file half:
There is no bottom to it, because the guard is re-implementing a language.
#3074's answer for project files was to stop parsing and let MSBuild evaluate (dotnet msbuild -getItem). The C# half has no equivalent yet, and this issue is the evidence that it needs one. A regex over source text asking "which cross-app files does this code read" is the same unbounded problem in a different language.
What to decide, rather than what to build
Do not simply widen the matcher again. The measurement from #3076 is the reason: bare-root tokens number 37 on one arm and 20 on the other, the on-disk filter discriminates nothing (all 57 survive, since a root is a real directory by construction), and only a used-as-a-directory shape cut it to 2. Precision here is bought with structure, not with a looser pattern, and each loosening has to be paid for again.
Three options, none obviously right:
- Semantic analysis over the C# half — a Roslyn-based pass that resolves string-valued expressions reaching a file API, the way
-getItemresolves item paths. Highest precision; a genuinely larger dependency and the first such in this suite. - Accept the boundary and state it. The backstop already runs both suites, so no reference is unguarded; the guard's remaining job is to report what it cannot see. That is what the recorded-limitation pins now do. This option makes that permanent and stops the widening cycle — and is the honest reading if the answer to "will there be a fifth spelling?" is yes.
- Invert the guard. Instead of finding cross-app reads and checking filter reachability, assert that every file a test actually opens at runtime is filter-reachable — which sidesteps spelling entirely, at the cost of needing the tests to run to know.
Requirements if anything is built
- Floor the population per arm, keyed on the tree that matters. A floor aimed at the app directory rather than the test tree passes while a live defect is present — measured in CrossAppGuardCiGateTests anchors on Lite/, so the two Darling.Tests reads of Lite.Tests/ are invisible to it #3067, and re-confirmed as mutations M3/M4 in Fixes #3076 #3079.
- Every spelling still unfound gets a case explicitly absent from the expected set, the shape Fixes #3076 #3079 established. A limitation in code beats one in a comment; Fixes #3076 #3079 also found that three such cases were unobservable where they were first placed, so verify the recorded limitation is actually exercised.
- Symmetry must be structural, not per-arm. Fixes #3076 #3079 spells both arms identically through one loop over
SkuTreesbecause "an arm written as a special case is how the next SKU inherits the sibling bug." - Do not add a
build.ymlpath entry without reading all threedarling:filter blocks (lines 162build, 673darling-pg, 854darling-linux) plusdarling-tree-guards, which has no paths filter by design. CrossAppGuardCiGateTests anchors on Lite/, so the two Darling.Tests reads of Lite.Tests/ are invisible to it #3067's own stated cost argument for such an entry was false and had been committed to the tree since Fixes #3052 #3059.
Provenance
Found by the #3076 lane (PR #3079) after it took the measurement this issue quotes, using the shipped matcher and filter code by reflection rather than a retyped copy. Instances verified absent from the found set rather than assumed.
Activity
erikdarlingdata commentedon Sep 6, 2026
Claude posting for Erik Darling
Resolved by #3084, merged to
devasf4891e9ed23. Closing by hand — a closing keyword does not fire on adev-targeted PR here.Decision: accept the boundary. The matcher was not widened, and the widening was built and run before being rejected rather than argued away:
Lite— the whole tree, for a read naming one directory — because the trailing segment is a variable. That is the trade CrossAppGuardCiGateTests cannot see a cross-app read spelled as a bare directory name, and there is a live one #3076 already refused.Path.Combine([RepoRoot(), .. dir, name])is the live sink). Data-flow analysis, not a wider pattern.Lite.Tests/ParitySource.cslocates the repo root viaDirectory.Exists(Path.Combine(dir.FullName, "Darling")), and the widened arm reports that as a whole-tree read ofDarling— on the Lite arm, which has no exemption list and no backstop job. The only exits areDarling/**in thelitefilter or another carve-out.And the answer to this issue's own question — is a fifth spelling likely — is that there are already seven, and four were unrecorded. Measured by driving the shipped matcher through reflection, blanking one root-token site at a time and diffing the found set, then reading every unfound site individually. Those four were invisible to all three PRs that fixed the first three spellings.
Option 3 (invert the guard) rejected on measurement: it is circular — a
Darling.Storage-only change fires neither filter, so the runtime observation never runs on the change that broke reachability — and instrumenting the reads means ~1,040 BCL file-API call sites.Option 1 (Roslyn) is technically the only thing that reaches the remaining four. The dependency was deliberately not added; it is recorded as a standing option, and the seven-shape census is its acceptance criteria if it is ever taken up.
Nothing is unguarded: every instance lives under
Lite/**, which thedarlingfilter reaches. The boundary is now recorded as a named seven-shape census asserted by equality against empty, plus aCONTRIBUTING.mdnote — because a bound accepted but undocumented is how the next four unrecorded shapes get written.Three false doc claims in #3079 are corrected here: the Lite-arm bare-token count is 21, not 20 (and was 21 at #3079's own merge commit), and "every one of its 20 is a label, an assertion key or one of this class's own root constants" is false for 13 of 21 — ten are first-argument
Path.Combinereads of real files and three name a tree in an invisible spelling. The replacement is an accurate enumeration plus why the variety is irrelevant to what the pin asserts, rather than a corrected numeral attached to a claim that was also wrong.