Skip to content

Commit 46e9a9d

Browse files
authored
Merge pull request #415 from PhysShell/research/n3-pilot-g0
docs(evidence): N3 pilot G0 — PASS-G0 (scope identity)
2 parents 76bc422 + 80734ff commit 46e9a9d

6 files changed

Lines changed: 689 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# N3 bounded pilot, gate G0 (scope identity) — pre-registration
2+
3+
Written 2026-10-20, before the G0 filter was written or run. What had been read before this file:
4+
5+
- the D11 triage of the 7 holdout DI001 findings (`evidence/holdout/ownnet/di_sample.tsv` of the direction
6+
research, private), which names the cause of each FP;
7+
- `ownlang/di.py` (`find_captive_dependencies`);
8+
- the field names of one `services` fact in the OrchardCore holdout facts. No DI001 result was recomputed.
9+
10+
Authority: owner decision `ACCEPT-PHASE0 / GO-N3-BOUNDED-PILOT / NO-PRODUCTION`, given on PR #414. G0 budget:
11+
0.25 agent-day. G1–G4 start only after a G0 PASS, and get one agent-day in total. The first failed mandatory gate
12+
stops the pilot (`KILL-N3-PILOT`). The budget is not extended.
13+
14+
## Question
15+
16+
Can product registrations be told apart from test registrations, and can each registration be resolved through its
17+
own implementation, using only the facts the extractor already emits and the repository's project files? The DI
18+
graph must not be rebuilt.
19+
20+
N3 needs this because its warning must rest on a proven scoped lifetime of a concrete object. If the registration
21+
graph cannot say "scoped" without the 7/7 FP of D11, a detached-work rule built on it inherits them.
22+
23+
## Known failure (the target)
24+
25+
7 of 7 product DI001 findings on OrchardCore (holdout, target `5ddc4594`, Own.NET `e7d33e33`) are FP. The D11 triage
26+
gives two causes:
27+
28+
- **test registrations merged in (6).** `IExtensionManager` (5 findings) and `IMediaTokenService` (1) are
29+
`AddSingleton` in product code. The `scoped` lifetime comes from test registrations (`StubExtensionManager`,
30+
`MediaTokenTests.cs`). The core keys the graph by service name, and the last registration wins.
31+
- **implementations merged by service name (1).** `IDeploymentTargetProvider`: the singleton implementation
32+
`FileDownloadDeploymentTargetProvider` takes only `IStringLocalizer`. The scoped dependency belongs to another
33+
implementation of the same interface. The core starts each walk from `by_name[s.name]`, not from the registration
34+
`s` itself.
35+
36+
## Method (fixed now)
37+
38+
A research script outside the product tree reads saved `facts.json` files and the repository sources. Production
39+
code, OwnIR, CI and goldens are not changed. The script applies four rules:
40+
41+
1. **Test project.** A registration belongs to the project whose `.csproj` is in the nearest ancestor directory of
42+
its `file`. The project is a test project when its `.csproj` sets `<IsTestProject>true</IsTestProject>` or
43+
references one of `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.v3`, `NUnit`, `MSTest.TestFramework`, `MSTest`,
44+
`TUnit` or `BenchmarkDotNet`. Path names are not used for this decision. The path regex of the direction
45+
research (`sweep_summary.py`) is reported next to it, as a comparison only.
46+
2. **Drop test registrations.** Registrations in test projects are removed. A registration with no owning
47+
`.csproj` is kept and counted.
48+
3. **Own implementation at the root.** The walk for a singleton registration starts from that registration's own
49+
`deps`, not from the node of the same service name.
50+
4. **Proven lifetime only.** A dependency name counts as scoped only when every remaining registration of that
51+
name is scoped. It is followed as transient only when every remaining registration is transient. Mixed
52+
lifetimes make the name unknown, and unknown adds no edge. The number of mixed names is reported.
53+
54+
The DI001 rule itself (singleton → scoped, through transients) does not change.
55+
56+
## Inputs
57+
58+
- **Target.** OrchardCore holdout facts as saved by D11 (Own.NET `e7d33e33`). They are not re-extracted.
59+
- **Positive controls (must keep firing).**
60+
- `frontend/roslyn/samples/DiCaptiveSample.cs`: exactly four DI001 (`EmailSender -> AppDbContext`,
61+
`ReportService -> UnitOfWork -> AppDbContext`, `CacheService -> IRepo`, `PrimaryCtorService -> AppDbContext`)
62+
and silence on `Metrics -> Clock` and `PublicCtorOnly`, as its CI assertions pin today.
63+
- Corpus pair `rrr-pi-rate/StockPriceSheetPrintService#60` (C10): DI001 present on `base`, absent on `fix`, as in
64+
the D6 ledger.
65+
- **New-finding check.** The other 14 holdout repositories and the 13 design-B sweep repositories, all from saved
66+
facts. Rule 3 can add findings that the current core does not report, so every finding that the current core
67+
does not report is listed and triaged by reading the code.
68+
69+
## Verdict rules
70+
71+
`PASS-G0` needs all of:
72+
73+
- all 7 known OrchardCore FPs are gone, each by a rule named above;
74+
- both positive controls hold exactly;
75+
- every new finding is triaged, and none is a FP. An `UNCLEAR` counts as a FP;
76+
- elapsed work within 0.25 agent-day.
77+
78+
`KILL-N3-PILOT` at G0 if any of the following:
79+
80+
- a known FP survives the four rules and removing it needs another mechanism;
81+
- a positive control is lost;
82+
- a new finding is a FP or `UNCLEAR`;
83+
- the budget runs out before a verdict.
84+
85+
No rule may be added or changed after the first run. A fifth rule invented after the run is a G0 failure, not a
86+
fix.
87+
88+
## Out of scope for G0 (input to G1)
89+
90+
- Framework registrations (`AddDbContext`, `AddHttpClient`, controllers, Razor components). Their lifetime comes from
91+
the framework, not from an `Add*` call in the repo. G1 must say how N3 gets "scoped" for them; G0 does not test it.
92+
- Open generics, factory lambdas, assembly scanning: unknown, as today.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# N3 bounded pilot, gate G0 (scope identity) — report
2+
3+
**Result: `PASS-G0`, by the pre-registered rules.** Three limits below bind G1.
4+
5+
Rules: [`00-preregistration-g0.md`](00-preregistration-g0.md), committed in `14be6c33` before the filter was
6+
written or run. No rule was changed after the first run.
7+
8+
Nothing in production, OwnIR, CI or goldens was touched. The script reads saved `facts.json` files, `.csproj` files
9+
and the unchanged core (`ownlang/di.py`).
10+
11+
## 1. What was run
12+
13+
- **Script.** [`scripts/g0_scope_identity.py`](scripts/g0_scope_identity.py).
14+
- **Facts.** The facts saved by the direction research (private), produced by the Own.NET `e7d33e33` Release
15+
extractor:
16+
- 15 holdout repositories;
17+
- 13 design-B sweep repositories;
18+
- corpus pair rrr-pi-rate#60, `base` and `fix`.
19+
20+
`frontend/roslyn/samples/DiCaptiveSample.cs` was extracted with the same binary. `di.py` and the sample are
21+
unchanged between `e7d33e33` and `main` (`76bc4222`).
22+
- **Baseline.** The current core, `find_captive_dependencies`, over the same facts.
23+
- **Self-check.**
24+
- With all three rules switched off, the script's walk reproduces the core's findings in all 30 inputs, with 0
25+
differences.
26+
- A second run produced identical output files.
27+
28+
Command:
29+
30+
```
31+
python -I docs/evidence/n3-pilot-2026-10/scripts/g0_scope_identity.py --ownnet . \
32+
--research <direction-research root> --out <dir> \
33+
--extra "sample/DiCaptiveSample=<facts of DiCaptiveSample.cs>@."
34+
```
35+
36+
## 2. Verdict, criterion by criterion
37+
38+
| Criterion | Result |
39+
|---|---|
40+
| All 7 known OrchardCore FPs gone, each by a named rule | yes, 7/7 (§3) |
41+
| `DiCaptiveSample`: exactly four DI001, silence on `Metrics -> Clock` and `PublicCtorOnly` | yes: `EmailSender -> AppDbContext`, `ReportService -> UnitOfWork -> AppDbContext`, `CacheService -> IRepo`, `PrimaryCtorService -> AppDbContext`; nothing else |
42+
| rrr-pi-rate#60: DI001 on `base`, none on `fix` | yes: `IDiscordBotMessageReceiver -> ITriggerReportService` on `base`; 0 on `fix` |
43+
| New findings over 27 holdout and sweep repositories, all triaged, none FP or `UNCLEAR` | 0 new findings |
44+
| Within 0.25 agent-day | yes |
45+
46+
## 3. Which rule removed which FP (ablation)
47+
48+
[`data/g0_ablation.tsv`](data/g0_ablation.tsv) records every subset of rules 2, 3 and 4. A `1` means the finding is
49+
still reported.
50+
51+
| Known FP (OrchardCore) | none | 2 | 3 | 4 | 2+3 | 2+4 | 3+4 | 2+3+4 |
52+
|---|---:|---:|---:|---:|---:|---:|---:|---:|
53+
| `IShellHost -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
54+
| `ICompositionStrategy -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
55+
| `IShellContainerFactory -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
56+
| `IShellDescriptorManager -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
57+
| `IPageApplicationModelProvider -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
58+
| `MediaCommandParser -> IMediaTokenService` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
59+
| `IDeploymentTargetProvider -> RemoteInstanceService` | 1 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
60+
61+
- Rule 3 (walk from the registration's own implementation) removes the implementation-merge FP. Nothing else
62+
removes it.
63+
- Rule 4 (proven lifetime only) removes the six test-registration FPs.
64+
- Rule 2 (drop test registrations) removes none of the seven (limit 1).
65+
- Partial subsets add findings: rule 3 alone gives 2 new ones, rules 3+4 give 1. All of them are in nopCommerce test
66+
code (`src/Tests/Nop.Tests/BaseNopTest.cs:482`, `ITaskScheduler`). With all three rules on, there are 0.
67+
68+
## 4. Limits that bind G1
69+
70+
1. **The test-project rule missed the project that holds the FP source.** `test/OrchardCore.Tests` references
71+
`xunit.v3.mtp-v2`, which is not in the fixed package list, so rule 1 classed it as product.
72+
- The `StubExtensionManager` and `MediaTokenTests` registrations stayed in the graph. They were neutralised only
73+
because product code registers the same names as singleton, so rule 4 saw mixed lifetimes.
74+
- A scoped registration that exists only in an unrecognised test project would still read as proven scoped.
75+
- One existing DI001 on a test-file registration also stays: `AttachedMediaFieldFileService ->
76+
IUserAssetFolderNameProvider`, `ViewMediaFolderAuthorizationHandlerTests.cs:461`.
77+
- Over all inputs, rule 1 marks 603 of 4,503 registrations as test, and the comparison path regex marks 1,682.
78+
The regex also covers samples, docs and benchmarks, so the two numbers do not measure the same thing.
79+
- Package-name matching is brittle. G1 must not treat "not in a recognised test project" as proof of product
80+
code.
81+
2. **Rule 4 trades recall for precision.** Names with mixed lifetimes become unknown: 48 over all inputs: 23 in
82+
OrchardCore, 11 in Orleans, 6 in bitwarden, 8 in eShop, grpc-dotnet, Quartz.NET and nopCommerce. For N3 this direction is acceptable:
83+
unknown means silence.
84+
3. **A known FP survives G0.** In Orleans, `DhtGrainLocator -> IGrainContext` (`DefaultSiloServices.cs:156`) was
85+
triaged FP in the sweep: the singleton is built by a factory lambda that never resolves `IGrainContext`. It is not
86+
one of the seven targets, and G0 does not fix it. The two nopCommerce `ElFinderService` findings that remain were
87+
triaged true but benign in the sweep.
88+
4. **G0 does not cover where N3's scoped objects mostly come from.** In the Phase 0 instances these are
89+
`HttpContext`, `DbContext` registered by `AddDbContext`, and controllers or other per-request framework types.
90+
Their lifetime is set by the framework, not by an `Add*` call in the repository, so the registration graph alone
91+
cannot prove them scoped. G1 has to settle this first, and a framework lifetime table must be fixed before any
92+
run.
93+
94+
## 5. Files
95+
96+
- [`00-preregistration-g0.md`](00-preregistration-g0.md): the rules, committed before the run.
97+
- [`scripts/g0_scope_identity.py`](scripts/g0_scope_identity.py): the filter and the ablation.
98+
- [`data/g0_summary.tsv`](data/g0_summary.tsv): per input, the registrations, test-project and path-regex counts,
99+
mixed-lifetime names, and DI001 counts for the current core and for G0.
100+
- [`data/g0_findings.tsv`](data/g0_findings.tsv): every current-core finding, marked `kept` or `removed`, and any
101+
`NEW` finding.
102+
- [`data/g0_ablation.tsv`](data/g0_ablation.tsv): every finding under every rule subset.
103+
104+
The input facts and the third-party source trees are in the direction-research repository (private) and are not
105+
committed here.

0 commit comments

Comments
 (0)