Skip to content

test(arch): compute module edges from bound symbols, not import scope - #1078

Merged
mforce merged 2 commits into
mainfrom
chore/1071-bound-symbol-edges
Oct 5, 2026
Merged

mforce merged 2 commits into
mainfrom
chore/1071-bound-symbol-edges

Conversation

@mforce

@mforce mforce commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Closes #1071

TL;DR. ModuleLedgerScanner now builds edges from the semantic model, not from using scope. An edge is a type that a node in a module-owned file binds to, or the type that declares the member the node binds to. That includes calls the compiler adds without a name in the source: user-defined conversions, collection-initializer Add and the foreach enumerator pattern. The edge is charged to the node's top-level type. Using directives are skipped. If a sibling project's built assembly is older than that project's source, the walk fails. As a result, the ledger loses six rows that no code realises and gains three real references that the syntax walk missed.

Start here: ModuleLedgerScanner.cs (Scan's project loop, RecordEdges, ReferencedTypes), then RealModuleLedger.Edges.cs. Everything else follows from those two files.

Change map

File What changed and why
Architecture/ModuleLedgerScanner.cs ScanFile keeps the syntax checks (declarations, unowned namespaces, global using) and returns the file's attributions. A new RecordEdges binds each ExpressionSyntax in module-owned files. A member resolves to its declaring type. A type includes its type arguments, element types and pointer targets. A type claimed by #1023 is charged to its claimant, which is how IIdentityProvider becomes an Access type. A compile error in a module-owned file fails the walk, except CS8795, the stub of a generated [GeneratedRegex] partial. Files owned only by Platform are never bound, so Cluckwork.Api's unresolved package references (826 errors) are skipped at no cost. BoundSymbols adds GetConversion(...).MethodSymbol, GetCollectionInitializerSymbolInfo and GetForEachStatementInfo (GetEnumerator, MoveNext, Current, Dispose and the element conversion) to GetSymbolInfo (review round 1, P2). AddStaleAssemblies fails the walk when bin/<Project>.dll is older than the newest .cs in src/<Project>. Stale metadata can bind a member to the wrong type without any diagnostic, so this is checked explicitly (review round 1, P3). Binding runs per file in parallel. The syntax walk's "referenced unowned namespace" check is gone: a referenced src type is always declared somewhere, and that declaration is already checked.
Architecture/CompatibilityExceptionScanner.cs Compile, References, ImplicitUsings and a new Parse become internal. References takes the assembly to exclude. The ledger reuses this machinery instead of building a second compile path. Compat's behaviour is unchanged.
Architecture/RealModuleLedger.Edges.cs Deleted rows: FirstRunProvisioningLatch (Access→Farm), UnassignFlockHandler (Access→FlockManagement), {Adjust,Submit,Void}DailyEntryResponse (EggOperations→FlockManagement) and IFarmModule (Farm→Commerce). Added rows: IIdentityProvider (Access→Commerce), FarmModule (Farm→Commerce) and AccessModule (Access→Farm, because it reads UserRoleAssignment.Id/FlockId, a third real reference the issue did not list). The reasons are updated to match.
Architecture/ModuleLedgerRealTreeTests.cs, CouplingMatrixRealTreeTests.cs One shared Lazy real-tree report, using the pattern CompatibilityExceptionRealTreeTests already follows. Four scans become one.
Architecture/ModuleLedgerTests.cs Ten fixtures assumed that a using alone was an edge. They now use the type they import. Removed tests: block-scoped using (the scope logic is gone) and referenced-unowned (replaced by the compile-error test). Added tests: UsingWithNoUse_IsGreen, MemberAccessOnly_IsAnEdge, ClaimedType_ChargesItsReferencesToItsClaimant, UnboundName_InAModuleType_MakesTheWalkUntrusted_ButNotInAPlatformType, CompilerChosenCall_IsAnEdge (3 cases: implicit conversion, inherited collection-initializer Add, foreach) and SiblingAssemblyOlderThanItsSource_MakesTheWalkUntrusted.
Architecture/Data/coupling-matrix.md Regenerated with CLUCKWORK_REGENERATE_MATRIX=1, not edited by hand. Access→FlockManagement R (4→3), Access→Commerce W (5→6), EggOperations→FlockManagement W (7→4).
docs/decisions/514-module-ledger.md, src/AGENTS.md The record said "the walk is syntax-only" and "an unused using counts as a dependency". It also said "a file-level using is attributed to every top-level type". All three statements are now false and have been rewritten. The record now names the compiler-chosen calls, the inferred-generics gap and the stale-assembly check.

Note on the issue text: its seventh false row (UserFlockAssignment → Farm) was already removed by #1070.

Mutations

All mutations were rerun at 3f610d3. A row marked base ran on origin/main f9bcdb0. Filter: ModuleLedgerRealTreeTests, plus ModuleLedgerTests for the scanner mutations.

Mutation Expected Observed
P2 (Astra's repro): DiscountCeiling gains implicit operator decimal, and CreateExpenseHandler adds decimal? percent = account.MaxDiscount; red red: undeclared … Finance -> Commerce … CreateExpenseHandler
P2 on base green (the gap) green
P2a: drop GetConversion red red: CompilerChosenCall_IsAnEdge (conversion)
P2b: drop collection-initializer Add red red: CompilerChosenCall_IsAnEdge (initializer)
P2c: drop the foreach pattern red red: CompilerChosenCall_IsAnEdge (foreach)
P3: drop the freshness check red red: SiblingAssemblyOlderThanItsSource_…
P3 on the real tree: touch a Domain source, then run with --no-build red red: Cluckwork.Domain.dll is older than src/Cluckwork.Domain/Sales/DiscountCeiling.cs; green after a normal build
MM: CreateExpenseHandler adds _ = account.MaxDiscount?.Percent; (member access only, Commerce is never named) red red: undeclared cross-owner edge Finance -> Commerce … CreateExpenseHandler
MM on base green (the gap) green
MU: CreateExpenseHandler adds an unused using Cluckwork.Domain.Sales; green green
MU on base red (the old false positive) red: undeclared … Finance -> Commerce
M-a: a Finance type names Cluckwork.Domain.Sales.DiscountCeiling red red: undeclared cross-owner edge Finance -> Commerce (also red on base)
M-j: drop SetStepperUnitHandler from cell 1 (the first symbol at base) red red: undeclared cross-owner edge Access -> Commerce (also red on base)
M-j′: drop IIdentityProvider, cell 1's first symbol at head red red: undeclared … Access -> Commerce from …IIdentityProvider
T1: no CS8795 exemption red red: SetLanguageValidator.cs:24: CS8795
T2: the declaring side ignores type claims red red: ClaimedType_… and stale Access -> Commerce :: IIdentityProvider
T3: members do not charge their declaring type red red: MemberAccessOnly_IsAnEdge and stale Farm -> Commerce :: FarmModule
T4: the walk descends into using directives red red: UsingWithNoUse_IsGreen (the first fixture survived; I tightened it until this went red)
T5: bind files owned only by Platform red red: 783 distinct compile errors (Api) and UnboundName_…
T6: ignore type arguments red red: CompoundUsingAlias_IsAnEdge

Measured

base (origin/main) head
Cluckwork.Application.Tests listed tests 701 (merge base 5897e34; origin/main f9bcdb0 has 709, including #1077's tests) 706
Architecture.* 361 366
ModuleLedgerTests 38 43
ModuleLedgerRealTreeTests wall time (12-core host, 3 runs) 4.0–4.2 s 7.2–7.5 s (14 s before parallel binding)
CouplingMatrixRealTreeTests wall time 5.2 s 8.7 s
CompatibilityExceptionRealTreeTests wall time 16.0 s 16.6 s

The file-count floor (400) and the build's preprocessor symbols (#1056/#1061) are unchanged: the parse still goes through ModuleLedgerScanner.ParseOptions. Other suites run on this branch, all green: CompatibilityExceptionTests, CouplingMatrixTests, SourcePreprocessorTests, PeerContractRealTreeTests, AdapterReachRealTreeTests and the remaining Architecture.* classes one by one, plus ImagePin/SchemaDocsTests, RenameAccountDocsTests and TenancyDocsFreshnessTests.

Known limits: inferred generic arguments are not charged unless a name in the source binds to them. Other calls the compiler adds implicitly, such as Deconstruct, GetAwaiter and query-pattern methods, are also not charged; the review's probes found no others. Each per-project compilation references the bin's Cluckwork.*.dll for every sibling project. Stale metadata can bind silently with zero diagnostics (Astra's fixture showed this), so the walk compares timestamps and fails when an assembly is older than its project's newest source. It does not compare content.

ModuleLedgerScanner now binds each module-owned file against a per-project
compilation built with CompatibilityExceptionScanner's parse options,
implicit usings and references. An edge is a type a node binds to, or the
type declaring the member it binds to; using directives are skipped.

Deletes six rows no code realises and declares three real references the
syntax walk missed (FarmModule -> DiscountCeiling, IIdentityProvider ->
EggUnit, AccessModule -> UserRoleAssignment).
@mforce

mforce commented Oct 5, 2026

Copy link
Copy Markdown
Owner Author

Review by Codex (gpt-6-astra) at fdba5dc

Verdict: request changes. One P2 semantic miss and one P3 stale-metadata finding.

  1. P2: charge implicit operator calls to their declaring module. ModuleLedgerScanner.cs:377 only reads GetSymbolInfo(node).Symbol. A compiler-selected implicit conversion is absent from that result. I added public static implicit operator decimal(DiscountCeiling value) => value.Percent; to Commerce's existing value object, then decimal? percent = account.MaxDiscount; _ = percent; after the account null check in Finance's CreateExpenseHandler. The source built and ModuleLedgerRealTreeTests passed 2/2, despite the undeclared Finance → Commerce operator call. The explicit account.MaxDiscount?.Percent control fails correctly. This is a plausible value-object convenience conversion. Collect implicit operation symbols and charge their declaring types. Isolated inherited collection-initializer Add and implicit foreach calls also stayed green, so a conversion-only patch would leave the same traversal gap elsewhere.

  2. P3: stale referenced DLLs can silently produce a green walk. ModuleLedgerScanner.cs:155–156 compiles each project against sibling DLLs without checking freshness. My two-project fixture supplied current source where Platform's H.Current returns Blue.B, but stale metadata where it returns int. Red's var b = H.Current; produced zero diagnostics, zero edges, and a green gate. Re-emitting matching metadata made identical source fail with one undeclared Red → Blue edge and still zero diagnostics. This can occur with --no-build after a referenced-project edit; the normal rebuilding command avoids it, hence P3. Reject stale metadata or use current-source compilation references. The PR's claim that stale DLLs necessarily cause compile errors is false.

Required mutations reproduced against head and its parent, 5897e341:

Mutation Base Head
Member-only account.MaxDiscount?.Percent green red, Finance → Commerce
Unused using Cluckwork.Domain.Sales red green
M-a: Finance names DiscountCeiling red red
M-j: remove SetStepperUnitHandler row red red
T3: omit member declaring-type charging not repeated fixture red; real tree red with stale FarmModule

Twenty isolated syntax/semantic probes caught extension calls, simple nameof, attributes and their arguments, constraints, typeof, patterns, ordinary var, explicit operators, object initializers, constants, method groups, delegates, record with updates, and static imports. The five green misses were implicit conversion, collection initializer, implicit enumeration, inferred generic arguments, and nameof of an overloaded instance member reached through a Platform accessor. The inferred-generic result confirms the disclosed limitation; I am not raising the narrower nameof case separately.

Missing Domain metadata failed with 2,006 diagnostics. Raising the file floor to 10,000 failed both real-tree tests. Three fresh parallel scans produced identical serialized reports: 544 files, floor 400, 73 edges, no errors or stale rows. Per-file lists and ordered merging avoid shared mutation. Compilation helpers and real preprocessor symbols are reused; four real-tree scans became one shared Lazy. Entirely Platform-owned projects are skipped, although Platform files within mixed projects remain in their compilation.

All six removed rows are unbacked by direct references; all three added rows are real. UserFlockAssignment was already absent at base. Fresh coupling-matrix regeneration produced no diff. The doc rewrite describes the explicit-symbol behavior, but decision 514's unchanged lines 54–56 still claim file-level imports are charged to every type. That paragraph contradicts the unused-import control; this is a doc observation outside the requested changed-line P3 scope.

Final restored-head checks passed, one class per process: ModuleLedgerTests 39, ModuleLedgerRealTreeTests 2, CouplingMatrixRealTreeTests 2, CompatibilityExceptionTests 42, CompatibilityExceptionRealTreeTests 2, and SourcePreprocessorTests 9. 96 passed. Thermo-nuclear and ponytail reviews found no separate maintainability finding. All mutations were restored; no product code was retained or pushed.

@mforce

mforce commented Oct 5, 2026

Copy link
Copy Markdown
Owner Author

Both findings are fixed at 3f610d3.

P2: ModuleLedgerScanner.BoundSymbols now charges the calls the compiler adds without a name in the source. In addition to GetSymbolInfo, it reads:

  • GetConversion(expr).MethodSymbol, for user-defined conversions;
  • GetCollectionInitializerSymbolInfo, for initializer elements;
  • GetForEachStatementInfo, for GetEnumerator, MoveNext, Current, Dispose and the element conversion.

Your repro (implicit operator decimal on DiscountCeiling, plus decimal? percent = account.MaxDiscount; in CreateExpenseHandler) now fails with undeclared cross-owner edge Finance -> Commerce. Base f9bcdb0 stays green on the same repro. The new CompilerChosenCall_IsAnEdge theory has three cases: implicit conversion, inherited collection-initializer Add, and foreach. All three were red before the fix. Deleting any one of the three bindings turns its own case red again. Inferred generic arguments remain a stated gap, along with Deconstruct, GetAwaiter and query-pattern methods.

P3: Compiling against source compilations was not the simpler fix, so I added a freshness check. AddStaleAssemblies fails the walk when bin/<Project>.dll is older than the newest .cs in src/<Project>. On the real tree, touching a Domain source and running with --no-build reports Cluckwork.Domain.dll is older than src/Cluckwork.Domain/Sales/DiscountCeiling.cs; a normal build clears it. SiblingAssemblyOlderThanItsSource_MakesTheWalkUntrusted covers both sides, and removing the check turns it red. I corrected the PR body's claim that stale DLLs always cause compile errors.

Doc: decision 514's paragraph saying file-level imports are charged to every type is rewritten.

I reran the full mutation table at 3f610d3; it is in the PR body. Every expected red is red, and the unused-using control stays green. ModuleLedgerRealTreeTests wall time is 4.0–4.2 s on base and 7.2–7.5 s on head. Regenerating the coupling matrix produced no diff.

@mforce

mforce commented Oct 5, 2026

Copy link
Copy Markdown
Owner Author

Re-review by Codex (gpt-6-astra) at 3f610d3

Verdict: approve the reviewed fixes. No new P1/P2/P3 finding in the incremental diff.

The original P2 repro now fails for the right reason: adding the DiscountCeiling conversion and implicitly converting account.MaxDiscount in Finance produces undeclared cross-owner edge Finance -> Commerce. My independent inherited collection-initializer and foreach probes also detect the expected edge. Eight controls for Platform/BCL conversions, collection initializers and foreach remain green. The temporary probe run passed 54 tests, including the 43 committed fixtures.

I found no normal-build false red from the timestamp check:

Flow tested Result
Comment-only source edit, then ordinary dotnet test green
git checkout restores the source, then ordinary dotnet test green
Touch unchanged source, then --no-build expected stale-metadata failure
Ordinary dotnet test after that touch green
Separate Debug build, then --no-build test green
Release test with CI's --no-restore flags green
Separate Release build, then Release --no-build test green
Comment edit, Domain tests, then Application guard using hook flags green

The check compares the implementation DLL in the test project's bin directory, via AppContext.BaseDirectory, not the source project's bin or its reference assembly. I recorded hashes and timestamps. Even when touching unchanged source produced byte-identical DLL output, the implementation DLL and its test-bin copy received newer filesystem timestamps. The separate reference-assembly copy optimization did not leave this guard's input stale.

Current ci.yml checks out, restores, then runs Release tests with --no-restore; it does not use --no-build or reuse binaries from the separate build job. The pre-commit hook also rebuilds both test projects. I reproduced its relevant sequence one class at a time: 38 Domain tests, then the two Application guard tests.

Decision 514 is included in the three-file incremental diff. The obsolete import-scope paragraph is corrected. Both requested quality-review passes found no maintainability issue in the incremental changes.

Final restored-head checks passed: ModuleLedgerTests 43, ModuleLedgerRealTreeTests 2, and CouplingMatrixRealTreeTests 2. Fresh matrix regeneration produced no diff. All reviewer mutations were restored; the worktree is clean.

The disclosed inferred-generic, deconstruction, awaiter and query-pattern gaps remain outside these fixes. Timestamp freshness also remains a modification-time check rather than proof of content identity.

@mforce
mforce merged commit e75532a into main Oct 5, 2026
19 checks passed
@mforce
mforce deleted the chore/1071-bound-symbol-edges branch October 5, 2026 03:42
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.

test(arch): compute module edges from bound symbols, not import scope

1 participant