diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94cd9ce1b..f4a8ec38c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,18 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli
## [Unreleased]
+### Changed — C# using directives share the import capture path (#358, slice 1a, @LaimaWu)
+
+C# `using` directives now use `@import.path` and the CSharp normaliser; the old `csharpUsingTarget`
+and `directiveTargetOf` branch are removed. Targets, directive sites, order, duplicate directives,
+static/global forms and existing binding behaviour are preserved. The known alias gap stays pinned:
+`using X = Foo.Bar` still reports X; alias bindings belong to slice 2b after the shared binds channel (2a).
+
+As with the Python capture move (#382), containers beyond depth 256 without a deeper import no longer
+make a file `extract-partial` or prevent whole-cache reuse. A nonempty import beyond the bound still
+discloses the cut and is not captured. `csharpcheck` arms C6/C7 pin the boundary and the new mechanism.
+Parser tag 214 (rich 215) invalidates the old extraction cache; the record format (36) is unchanged. Thanks to @LaimaWu for the change.
+
### Added — `--for` names the members of its shown owner rows on the compact route (`mem=`)
On a conceptual `--for` answer (root `bundle="compact"`), a shown class, struct or interface/trait row now carries
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5e5b54b7f..850bd10c4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -590,7 +590,7 @@ review will credit it, but it is never a condition for merging the language.
| Family | Shared mechanism today | State | Claimable slices |
|---|---|---|---|
-| 1. Imports | `ingest_importcap.h`: `@import.path` captures, `emitCapturedImport`, one `normaliseImportSpecifier` arm per `DepDialect`. Resolution in `resolve.h`. | partly. C-family, JS/TS, Go and Python read the capture (Python since #382). The rest still go through per-language arms of `directiveTargetOf` (`ingest_relations.h`): `csharpUsingTarget`, `phpUseTarget`, `luaRequireTarget`, `bashSourceTarget`, `rubyRequireTarget`, `elixirDirectiveTarget`, plus Rust `use`/`mod`, Kotlin `import_header` and Swift `import_declaration`. | **1a** one language per PR onto `@import.path` plus its normaliser arm, deleting its arm in `directiveTargetOf`: S each for C#, PHP, Lua, Bash, Kotlin and Swift; M for Rust (`use` trees, `mod`; **wanted**), Ruby (`autoload`) and Elixir (`alias`/`use`), whose directives carry more meaning. Claim on #358. |
+| 1. Imports | `ingest_importcap.h`: `@import.path` captures, `emitCapturedImport`, one `normaliseImportSpecifier` arm per `DepDialect`. Resolution in `resolve.h`. | partly. C-family, JS/TS, Go, Python and C# read the capture (Python since #382). The rest still go through per-language arms of `directiveTargetOf` (`ingest_relations.h`): `phpUseTarget`, `luaRequireTarget`, `bashSourceTarget`, `rubyRequireTarget`, `elixirDirectiveTarget`, plus Rust `use`/`mod`, Kotlin `import_header` and Swift `import_declaration`. | **1a** one language per PR onto `@import.path` plus its normaliser arm, deleting its arm in `directiveTargetOf`: S each for PHP, Lua, Bash, Kotlin and Swift; M for Rust (`use` trees, `mod`; **wanted**), Ruby (`autoload`) and Elixir (`alias`/`use`), whose directives carry more meaning. Claim on #358. |
| 2. Aliases | None. Renames reach the resolver as `RawBind` records (`ingest_cache.h`) from separate producers: Python's `@import.names` / `@import.alias` captures, read in `captureTagsFacts` (`ingest_sidecap.h`) and turned into binds by the Python-only `emitCapturedPythonImportBind` (`ingest_importcap.h`); `captureJsImportFacts`, `captureJsModuleAliases` and `captureGoImportFacts` (`ingest_jsimports.h`), C/C++ aliases in `fnPtrAliasName` (`ingest_binds.h`) and `captureTypeAlias` (`ingest_relations.h`). The readers are separate too: `addTypeAliasBases` and `buildUsingReexports` (`resolve.h`). | duplicated: one "local name → target" concept in about five shapes. | **2a** one shared binds channel (`@import.alias` / `@import.names` → one `RawBind` kind): M, *maintainers*, after the #358 import slices. **2b** after 2a, one language per PR moves its renames onto it: S each (Rust `use a as b`, **wanted**; Kotlin `import x as y`, PHP `use … as`, C# `using X = …`). |
| 3. Types | `captureTagsFacts` (`ingest_sidecap.h`) reads `@definition.*` captures for every language. | shared, but language decisions are `Lang::` branches inside the loop: 36 non-comment `Lang::` mentions, 15 of them Elixir. | **3a** #374: one constexpr per-language hook table that `captureTagsFacts` calls once per capture, one language per commit, Elixir first. M. |
| 4. Inheritance | `captureBases` (`ingest_relations.h`): base clauses for C++, TS/JS, Java, Python, Swift, C#, PHP, Kotlin and Ruby, chosen by node kind (`isBaseTypeNodeIn`). | partly. Rust `impl … for` is a separate pass; Ruby mixins read the same path through `@reference.mixin` captures since slice 4a (#400). | **4a** done (#400): Ruby `include`/`extend`/`prepend` read the shared path through `queries/ruby/tags.scm` captures, `captureRubyMixinBases` retired. **4b** the base-clause node kinds move from `isBaseTypeNodeIn` into `tags.scm` captures: M, all of the languages above. **4c** structural conformance (#386): a class or type whose methods cover a structural base's required methods (Python `typing.Protocol`, Go interfaces, possibly TS interfaces) is listed as an implementor marked `structural=1`, never as a nominal subclass, with a disclosed minimum-method threshold; step (c), one shared method-set check, per-language data for which bases are structural. M; Python and Go first (Go **wanted**). |
diff --git a/prompts/help-wanted/visual-basic-language.md b/prompts/help-wanted/visual-basic-language.md
index 776a13a62..cbdfd3f26 100644
--- a/prompts/help-wanted/visual-basic-language.md
+++ b/prompts/help-wanted/visual-basic-language.md
@@ -310,7 +310,7 @@ Start this only after STEP 0 clears the bar. It follows the path Dart took (comm
### STEP 5: relations, in a separate round
-- **`Imports`.** VB `Imports` is C#'s `using`; see `csharpUsingTarget` and
+- **`Imports`.** VB `Imports` is C#'s `using`; see `normaliseCSharpImport` in `src/ingest_importcap.h` and
`kCsharpImportContainers` in `src/ingest_relations.h`. C# is deliberately absent from the
import-narrowing table in `src/resolve.h`, because a namespace has no one-to-one file.
`test/csharpcheck.sh`'s header explains why. Do the same for VB: capture `Imports` for `--uses`
diff --git a/queries/csharp/tags.scm b/queries/csharp/tags.scm
index 29827b727..610b3d3b0 100644
--- a/queries/csharp/tags.scm
+++ b/queries/csharp/tags.scm
@@ -7,11 +7,15 @@
; - type declarations: class / struct / interface / record / enum -> def nodes (the containers)
; - method / constructor / property declarations -> the def nodes calls resolve TO
; - invocations + object creations -> the call references (edges)
-; - `using` directives -> captured separately by ingest.cpp::captureIncludes (NOT here — matches
-; how every other language's imports are handled: tags.scm never captures imports directly).
+; - `using` directives -> shared @import.path plus the CSharp normaliser; alias bindings remain
+; out of scope (slice 2b after the shared binds channel, 2a).
;
; Deliberately NOT captured (noise, matching Java/Ruby): fields, local variables, attributes.
+; Capture the whole directive, as Go does: the normaliser preserves the old target selection and
+; both Include and Import RawRef stay sited at the directive, including a leading `global`.
+(using_directive) @import.path
+
; ---- definitions ----
(class_declaration
diff --git a/src/ingest_cache.h b/src/ingest_cache.h
index 27a183325..cc46f38f8 100644
--- a/src/ingest_cache.h
+++ b/src/ingest_cache.h
@@ -345,7 +345,11 @@ constexpr std::uint32_t kCacheVersion = 36; // 36: train 30 — strict
// (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) —
// a target FORMAT change → old caches must be rejected.
// 4: Include gained a `bool isAngle` (quote/angle) field
-constexpr std::uint32_t kParserVer = 212; // bump on any grammar/.scm/extraction change
+constexpr std::uint32_t kParserVer = 214; // bump on any grammar/.scm/extraction change
+ // 214 = 2026-10-21 (#358 1a, PR #420, merged with main 320f8d8f): C# using directives move to shared
+ // captures; import-free deep containers no longer disclose a cut or lose whole-cache eligibility.
+ // Alias/edge facts unchanged. Reserved for #420 above main 206 (rich 207) and the train-32 numbers
+ // 212/213 (32a); train 32b goes above. Rich file tag 215. kCacheVersion stays 36 (no record format change).
// 212 = 2026-10-21 (train 32a: member-names + rust-receivers): strictly above every live number — main 206 (rich 207),
// lane rust-receivers 202 (203), lane ix1-slot 204 (205), lane l6-iface 210 (211); #420 reserves 214 (215), so train
// 32b can go above. Rich file tag 213. kCacheVersion stays 36: neither lane changed a record format (rust-receivers
diff --git a/src/ingest_importcap.h b/src/ingest_importcap.h
index 31a5dcdbb..c7fee2c51 100644
--- a/src/ingest_importcap.h
+++ b/src/ingest_importcap.h
@@ -227,8 +227,24 @@ ImportSpec normaliseGoImport( TSNode declaration, std::string_view src )
return out;
}
+// ── DepDialect::CSharp ───────────────────────────────────────────────────────────────────────────────
+// Preserve the walk's target selection, including its known alias gap: with the pinned grammar,
+// `using X = Foo.Bar` has no `type` field, so the first matching child is X. Fixing that or emitting
+// alias bindings belongs to 2b, after the shared binds channel (2a). Namespaces remain deferred in resolve.h.
+ImportSpec normaliseCSharpImport( TSNode directive, std::string_view src )
+{
+ // Grammar-bump insurance: never taken on the pinned grammar (an alias directive has no `type` field), no arm pins it.
+ if( const TSNode aliasType = fieldChild( directive, NodeField::Type ); !ts_node_is_null( aliasType ) )
+ {
+ return { importSpecifierText( aliasType, src ) };
+ }
+ // One cursor scan, including comment-heavy directives (childwalkscalecheck B16).
+ const TSNode c = firstChildOfKind( directive, /*namedOnly=*/true, { "qualified_name", "identifier", "generic_name", "alias_qualified_name" } );
+ return { ts_node_is_null( c ) ? std::string {} : importSpecifierText( c, src ) };
+}
+
// ── the dispatch: ONE normaliser per DepDialect ──────────────────────────────────────────────────────
-// CFamily, Web, Go and Python are the dialects that have moved over so far; every other arm returns an empty ImportSpec,
+// CFamily, Web, Go, Python and CSharp have moved over so far; every other arm returns an empty ImportSpec,
// which is what makes the capture INERT rather than wrong on a language whose tags.scm has not adopted
// `@import.path` yet. Each `case` is deleted as its language moves, and the compiler then names every
// other dialect still to do — the same "one language at a time" landing the issue asks for.
@@ -240,6 +256,7 @@ ImportSpec normaliseImportSpecifier( DepDialect dialect, TSNode directive, std::
case DepDialect::Web: return normaliseWebImport( directive, raw, src );
case DepDialect::Go: return normaliseGoImport( directive, src );
case DepDialect::Python: return { std::string( raw ) }; // preserve the first plain clause's written alias
+ case DepDialect::CSharp: return normaliseCSharpImport( directive, src );
default: return {}; // no other language captures @import.path yet
}
}
@@ -255,6 +272,7 @@ inline bool importsFromCapture( DepDialect dialect ) noexcept
case DepDialect::Web:
case DepDialect::Go:
case DepDialect::Python:
+ case DepDialect::CSharp:
return true;
default:
return false;
@@ -266,9 +284,9 @@ inline bool importsFromCapture( DepDialect dialect ) noexcept
// specifier sits in the call's `arguments`.
TSNode importDirectiveOf( DepDialect dialect, TSNode pathNode ) noexcept
{
- if( dialect == DepDialect::Go )
+ if( dialect == DepDialect::Go || dialect == DepDialect::CSharp )
{
- return pathNode; // the Go capture IS the whole import_declaration
+ return pathNode; // these captures ARE whole directives
}
const TSNode parent = ts_node_parent( pathNode );
const bool hasParent = !ts_node_is_null( parent );
diff --git a/src/ingest_relations.h b/src/ingest_relations.h
index 17a5cfc8a..7e5e675ed 100644
--- a/src/ingest_relations.h
+++ b/src/ingest_relations.h
@@ -7,7 +7,7 @@
// 2026-08-29 split: base-clause type references (isBaseTypeNode/emitBaseRef/captureBases with the
// Rust impl walk), function-like #define body call edges (the macro-edges round), field/composition
// capture (captureFields), and the import/include layer — importName and the per-language
-// specifier readers (C# using, PHP use, JS module loads, preproc include/import), the
+// specifier readers (PHP use, JS module loads, preproc include/import; C# using moved to ingest_importcap.h in #358), the
// import-container tables by language, directiveTargetOf and captureIncludes. Everything that turns
// one file's AST into edges BETWEEN symbols and files. Same contract as every ingest_*.h: reopens
// `namespace rw` and the unnamed namespace inside it — one TU, one unnamed namespace, internal
@@ -1548,41 +1548,12 @@ inline std::string importSpecifierText( TSNode node, std::string_view src )
return std::string( s );
}
-// C# `using Foo.Bar;` / `using static Foo;` / `using X = Foo.Bar;` target extraction, factored out of
-// captureIncludes (kept as its own def to hold captureIncludes' own complexity/LOC down — one AST shape
-// per language stays a one-line call at the use site, matching the existing per-branch grain there).
-//
-// C# namespaces do not map 1:1 onto files (unlike a Python module or a Rust `mod`), so — same as Go/
-// Swift below — this is captured for --uses/--deps visibility only; resolve.h's Rule 3 precise-import
-// narrower (includeLangOf) has no C# entry and DEFERS (kNoFile), matching Java's existing "Other"
-// treatment: the conservative fall-through the P2-D Rule 3 contract requires.
-//
-// The ALIAS form `using X = Foo.Bar;` exposes a `name` field, but it names the ALIAS (`X`), not the
-// aliased type — walk the `type` field instead so the target is the real aliased spelling. The plain/
-// `static` form (`using Foo.Bar;` / `using static Foo;`) has NO named field at all: the grammar's
-// `$._name` production is hidden-inlined, so its resolved concrete node (a qualified_name for a dotted
-// path, else a bare identifier/generic_name) is just an unnamed-field child of using_directive — scan
-// for the first one.
-inline std::string csharpUsingTarget( TSNode usingNode, std::string_view src )
-{
- if( const TSNode aliasType = fieldChild( usingNode, NodeField::Type ); !ts_node_is_null( aliasType ) )
- {
- return importSpecifierText( aliasType, src );
- }
-
- // O(children): `using /*…*/ System.Text;` puts the comments in the using_directive itself — 16 000 of
- // them measured 36x the identical flood inside a method body (test/childwalkscalecheck.sh, arm B16)
- const TSNode c = firstChildOfKind( usingNode, /*namedOnly=*/true, { "qualified_name", "identifier", "generic_name", "alias_qualified_name" } );
- return ts_node_is_null( c ) ? std::string {} : importSpecifierText( c, src );
-}
-
-// csharpUsingTarget's PHP sibling: the written specifier of a `use` statement. Node shapes read off
+// PHP: the written specifier of a `use` statement. Node shapes read off
// tree-sitter-php v0.24.2's node-types.json and confirmed on a real parse:
// use Foo\Bar; namespace_use_declaration > namespace_use_clause > qualified_name
// use Bar; namespace_use_declaration > namespace_use_clause > name
// use Foo\Bar as Baz; the same, plus an `alias:` field we deliberately ignore — the DEPENDENCY is
-// the real name, not the local nickname (mirroring csharpUsingTarget, which
-// also reports the aliased target rather than the alias)
+// the real name, not the local nickname
// use function Foo\bar; a `type:` field on the CLAUSE ("function"/"const"); the specifier is
// unchanged, so nothing here needs to branch on it
// use Foo\{A, B}; namespace_use_declaration > namespace_name + namespace_use_group — the
@@ -1632,9 +1603,8 @@ inline std::string phpUseTarget( TSNode useNode, std::string_view src )
}
// TS/JS `require("./x")` and dynamic `import("./x")` → the written specifier, or empty when this call
-// expression is not a module load. Its own function for the same reason csharpUsingTarget and phpUseTarget
-// are: one AST shape per language stays a one-line call at the use site rather than one branch plus one nested
-// scan. #358: its readers are now the DepDialect::Web normaliser (src/ingest_importcap.h — the captured
+// expression is not a module load. Like phpUseTarget, one AST shape stays a one-line call at the use site
+// rather than one branch plus a nested scan. #358: its readers are now the DepDialect::Web normaliser (src/ingest_importcap.h — the captured
// `require`/`import` call) and captureJsImportFacts (src/ingest_jsimports.h — the binding a destructured
// require makes); directiveTargetOf no longer reads it, because the walk no longer visits TS/JS.
//
@@ -2641,7 +2611,7 @@ constexpr std::uint16_t kMaxImportContainerDepth = 256;
// at all. Split out of captureIncludes so the walk that FINDS directives and the per-grammar table that
// READS them stay separately readable — the walk is one shape, this is one branch per grammar spelling.
//
-// Node types confirmed per grammar: Swift import_declaration; Rust use_declaration + mod_item; C# using_directive; plus the kParserVer-81 four (bash
+// Node types confirmed per grammar: Swift import_declaration; Rust use_declaration + mod_item; plus the kParserVer-81 four (bash
// `command`, lua `function_call`, ruby `call`, elixir `call`). LEVER-B B0: non-C imports capture the
// CLEAN written specifier via grammar child fields (module path / quoted specifier / use argument), not a
// sliced clause — the sound resolver input.
@@ -2799,10 +2769,6 @@ DirectiveTarget directiveTargetOf( TSNode n, const char* t, std::string_view src
// landing. Keep the best-effort target for --uses / --deps back-compat.
target = importClauseTarget( n, src );
}
- else if( kindIs( t, "using_directive" ) )
- { // C# `using Foo.Bar;` / `using static Foo;` / `using X = Foo.Bar;`
- target = csharpUsingTarget( n, src ); // see csharpUsingTarget for the shape rationale
- }
else if( kindIs( t, "namespace_use_declaration" ) )
{ // PHP `use Foo\Bar;` / `use Foo\Bar as Baz;` / `use function Foo\bar;` / `use Foo\{A, B};`
// Captured for --uses / --deps visibility, NEVER precise-resolved: PHP has no entry in
diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h
index b401bcf44..a3f1c2025 100644
--- a/src/ingest_sidecap.h
+++ b/src/ingest_sidecap.h
@@ -1639,7 +1639,7 @@ void captureSideFacts( const LangEntry& le, std::uint32_t fileId, std::string_vi
#endif
// Elixir directives share the lexical tags context below (ElixirContext), and as of #358 so do the
- // C-family, Go, Python and TypeScript/JavaScript ones: their `@import.path` captures are normalised and emitted
+ // C-family, C#, Go, Python and TypeScript/JavaScript ones: their `@import.path` captures are normalised and emitted
// by captureTagsFacts, and captureIncludes no longer reads a preproc_include / preproc_call /
// import_statement / export_statement / require() call at all. The walk is pure cost for those
// languages — it descends only allowlisted containers, and the tags query already reaches the same
diff --git a/src/lintrules.h b/src/lintrules.h
index ea992b4ec..2647cd65a 100644
--- a/src/lintrules.h
+++ b/src/lintrules.h
@@ -206,7 +206,7 @@ inline Lang langOfPath( std::string_view path ) noexcept
// ingest.cpp's captureIncludes(): every language below has a node-type branch there (Cpp/C/ObjC's
// preproc_include/#import, Python/TS/JS's import_statement(_from), Rust's use_declaration/mod_item,
// Go/Swift/Java's import_declaration — Java shares that node-type SPELLING with Go/Swift so it is
-// captured too, even though only best-effort resolved — and C#'s using_directive; PHP's
+// captured too, even though only best-effort resolved — and C#'s using_directive (captured via @import.path since #358); PHP's
// namespace_use_declaration joined them in the PHP/Lua port round).
//
// kParserVer 81 (the four-language import round) ADDED Bash/Ruby/Lua/Elixir. Each now has its own
diff --git a/src/quality.h b/src/quality.h
index 7470c7d61..d903649a1 100644
--- a/src/quality.h
+++ b/src/quality.h
@@ -2337,7 +2337,9 @@ inline std::string cacheRootKeyHex( const std::string& root )
// not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder
// that broke that fails the build on the undeclared name rather than passing.
constexpr std::uint32_t kIngestCacheVersionMirror = 36; // MUST equal ingest.cpp's kCacheVersion (gated); 36 = train 30 (above call-result-typing's and fcall-forward's 33, name-template-dispatch's 35, main's 34), 34 = train 29 (above lane arity-min's and call-result-typing's 33), 33 = train 29 lane arity-min: def record paramsMin (the train renumbers), 33 = lane call-result-typing: RawBind resultIndex + unwrapDepth, 33 = lane fcall-forward: ref `forwarded` byte, 35 = lane name-template-dispatch: RefRole Pattern 11, 32 = train 28: RefRole Subscript 9 + Inert 10, 31 = train 27: lego-transitive's inherit-ref baseBind (the lane's 30), 29 = FE-B ref memberPath/memberCtor + 4 bind kinds, 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150)
-constexpr std::uint32_t kIngestParserVerMirror = 212; // MUST equal ingest.cpp's kParserVer (gated)
+constexpr std::uint32_t kIngestParserVerMirror = 214; // MUST equal ingest.cpp's kParserVer (gated)
+ // 214 = 2026-10-21 (#358 1a, PR #420: above main 206/207 and train 32a 212/213;
+ // see the kParserVer note)
// 212 = 2026-10-21 (train 32a: above main 206/207, rust-receivers 202/203, ix1-slot
// 204/205, l6-iface 210/211; #420 reserves 214/215; see the kParserVer note)
// 206 = 2026-10-18 (#409, PR #414, renumbered again at merge: above main 196/197,
diff --git a/test/childwalkscalecheck.sh b/test/childwalkscalecheck.sh
index 692225986..c1acd9ea9 100755
--- a/test/childwalkscalecheck.sh
+++ b/test/childwalkscalecheck.sh
@@ -239,7 +239,7 @@ for n in ( 1000, 16000 ):
# captureFields, the declarator walk — a reference_declarator `T & /*…*/ m`
w( "fielddecl/n%d/big.cpp" % n, [ "struct T { int q; };", "struct S", "{", " T &" ] + [ C ] * n + [ " m;", "};" ] )
w( "fielddecl_off/n%d/big.cpp" % n, [ "struct T { int q; };", "struct S", "{", " T & m;" ] + [ C ] * n + [ "};" ] )
- # csharpUsingTarget — a using_directive; its control floods a method BODY (see the header: a class body
+ # normaliseCSharpImport — a using_directive; its control floods a method BODY (see the header: a class body
# or the file root is an ancestor of every def, and the ancestor scan would read the flood too)
w( "csusing/n%d/big.cs" % n, [ "using" ] + [ C ] * n + [ " System.Text;", "namespace A { class K { void M() {} } }" ] )
w( "csusing_off/n%d/big.cs" % n, [ "using System.Text;", "namespace A { class K { void M() {" ] + [ C ] * n + [ "} } }" ] )
@@ -519,7 +519,7 @@ pair(){ # $1 = label, $2 = walk fixture, $3 = control fixture, $4 = what the pai
pair "(B13) captureMacroBodyCalls" macroparams macroparams_off "a preproc_params list 16000 children wide vs the identical flood outside the #define"
pair "(B14) captureFields/type" fieldtype fieldtype_off "a field's qualified_identifier type 16000 children wide vs the identical flood after the field"
pair "(B15) captureFields/declarator" fielddecl fielddecl_off "a field's reference_declarator 16000 children wide vs the identical flood after the field"
-pair "(B16) csharpUsingTarget" csusing csusing_off "a using_directive 16000 children wide vs the identical flood inside a method body"
+pair "(B16) normaliseCSharpImport" csusing csusing_off "a using_directive 16000 children wide vs the identical flood inside a method body"
pair "(B17) phpUseTarget" phpuse phpuse_off "a namespace_use_declaration 16000 children wide vs the identical flood after it"
pair "(B18) jsModuleLoadTarget" jsrequire jsrequire_off "require()'s arguments 16000 children wide vs the identical flood after the statement"
pair "(B19) rubyNamespaceOnly" rubyns rubyns_off "a class body_statement 16000 children wide vs the identical flood after the class"
diff --git a/test/csharpcheck.sh b/test/csharpcheck.sh
index 1c55b5aa4..c9c23eb30 100755
--- a/test/csharpcheck.sh
+++ b/test/csharpcheck.sh
@@ -23,8 +23,8 @@
# - `class Greeter : IGreeter` (base_list) emits an extends use-site: --uses=IGreeter shows
# role="extends" in_id="Greeter"; --lego=IGreeter lists Greeter as its one implementor.
# (§P8 2026-07-28: the attribute was in=, renamed to in_id= — see src/main.cpp runUses.)
-# - `using System;` / `using CsharpFix.Services;` are captured as imports (ingest.cpp::
-# captureIncludes' using_directive branch, NOT tags.scm — matches every other language):
+# - `using System;` / `using CsharpFix.Services;` are captured as imports (the
+# shared @import.path capture and CSharp normaliser):
# --uses=System shows role="import" p="Program.cs:1"; --deps lists both targets
# verbatim (System, CsharpFix.Services) — proving both the bare-identifier and the dotted
# qualified_name using-directive shapes are captured.
@@ -223,6 +223,12 @@ grep -q "MAIN_EDGE_GONE:True" "$TMP/mut_check" \
&& ok "mutation: renamed Program.cs call site -> Main -> SayHello edge vanished (non-tautological)" \
|| no "mutation: Main -> SayHello edge survived a renamed call site — the edge assertion is a tautology"
+if python3 "$ROOT/test/csharpimportcap.py" "$BIN" "$ROOT" "$TMP"; then
+ ok "C# slice 1a capture, legacy aliases, negative controls and depth disclosure"
+else
+ no "C# slice 1a capture arms failed"
+fi
+
# ─── Summary ──────────────────────────────────────────────────────────────────
echo
if [ "$fail" -eq 0 ]; then
diff --git a/test/csharpimportcap.py b/test/csharpimportcap.py
new file mode 100644
index 000000000..cce1581a9
--- /dev/null
+++ b/test/csharpimportcap.py
@@ -0,0 +1,114 @@
+"""C# slice 1a arms, invoked by csharpcheck.sh. Every output comes from the supplied binary."""
+import pathlib
+import shutil
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+
+binary, root, scratch = map(pathlib.Path, sys.argv[1:4])
+assert sys.argv[4:] in ([], ['--legacy']), 'unknown C# test contract'
+legacy = sys.argv[4:] == ['--legacy']
+
+
+def run(where, *args):
+ proc = subprocess.run([str(binary), str(where), *args], capture_output=True, text=True)
+ if proc.returncode != 0:
+ raise RuntimeError((args, proc.returncode, proc.stderr))
+ return proc.stdout
+
+
+def targets(where):
+ xml = ET.fromstring(run(where, '--deps', '--limit=100000', '--no-cache'))
+ assert xml.tag == 'deps'
+ return [(f.attrib['p'], [i.attrib['t'] for i in f.findall('inc')]) for f in xml.findall('f')]
+
+
+fixture = root / 'test/csharpimportcapfix'
+expected = ['System', 'Demo.Widget', 'Alias', 'GlobalAlias', 'Demo.Widget', 'Demo.Widget', 'Demo',
+ 'global::Demo.Widget', 'Generic', 'Pair', 'Items', 'Maybe', 'Ptr', 'Demo', 'Demo',
+ 'Demo /* middle */ . Widget', 'CommentAlias', 'Nested']
+
+
+def check_rows(where):
+ assert targets(where) == [('Imports.cs', expected)], targets(where)
+
+
+check_rows(fixture)
+print(' PASS C# C1: exact targets, directive order, duplicates, comments, static/global and complex aliases')
+for name, line in [('System', 1), ('Alias', 3), ('GlobalAlias', 4), ('Generic', 9), ('Nested', 21)]:
+ xml = ET.fromstring(run(fixture, '--uses=' + name, '--no-cache'))
+ assert any(u.get('role') == 'import' and u.get('p') == 'Imports.cs:' + str(line) for u in xml.findall('u')), (name, line)
+print(' PASS C# C2: Import use-sites stay at their directives, including global and nested aliases')
+
+# Known gap, slice 2b after the shared binds channel (2a): an alias still names itself in Include,
+# and using/static do not narrow these three calls to the RHS class. This refactor must not fix it.
+xml = ET.fromstring(run(fixture, '--callees=Run', '--no-cache'))
+assert xml.get('graph_ambiguous') == '3', xml.attrib
+assert [(s.get('n'), s.get('p')) for s in xml.findall('s')] == [('Ping', 'Types.cs:1'), ('Ping', 'Types.cs:2')]
+print(' PASS C# C3 known-gap (2b): alias remains Alias; all three Ping calls retain both candidates')
+
+# Mutate real source and run the identical row checker. A changed alias and a missing directive
+# must each fail it; checking the mutation itself is only the presence guard, never the verdict.
+for name, old, new in [('rename', 'using Alias =', 'using Renamed ='), ('remove', 'using System;\n', '')]:
+ work = scratch / name
+ shutil.copytree(fixture, work)
+ source = work / 'Imports.cs'
+ original = source.read_text()
+ assert old in original
+ source.write_text(original.replace(old, new, 1))
+ assert source.read_text() != original
+ try:
+ check_rows(work)
+ except AssertionError:
+ print(' PASS C# C4 control: identical row checker rejects actual ' + name + ' mutation')
+ else:
+ raise AssertionError('row checker accepted ' + name)
+
+cache = scratch / 'using.cache'
+fresh = run(fixture, '--deps', '--no-cache')
+cold = run(fixture, '--deps', '--cache=' + str(cache))
+warm = run(fixture, '--deps', '--cache=' + str(cache))
+assert fresh == cold == warm
+print(' PASS C# C5: using fixture fresh/cold/warm dependency bytes identical')
+
+# Observable rows alone also pass on the old walk. Check the actual embedded query, not merely
+# the new source beside an old binary. --legacy validates BASE's old contract before implementation.
+captured = b'(using_directive) @import.path' in binary.read_bytes()
+retired = 'csharpUsingTarget' not in (root / 'src/ingest_relations.h').read_text()
+assert captured == (not legacy), 'C# capture mechanism does not match the requested BASE/HEAD contract'
+if not legacy:
+ assert retired, 'retired C# extractor remains in HEAD source'
+print(' PASS C# C7: ' + ('BASE still uses the old walk' if legacy else 'embedded @import.path; old extractor deleted'))
+
+# Shared-capture disclosure, as in Python #382: a nonempty import beyond the bound is partial;
+# deep containers without an import no longer trigger the retired walk's disclosure. Edges do not widen.
+for depth in [254, 255, 256, 257, 258]:
+ for mode in ['using', 'empty', 'top']:
+ work = scratch / ('depth-' + str(depth) + '-' + mode)
+ work.mkdir()
+ pairs, pp = divmod(depth, 2)
+ source = ('using Top.Level;\n' if mode == 'top' else '')
+ source += ''.join('namespace N%d {\n' % i for i in range(pairs))
+ source += '#if FLAG\n' if pp else ''
+ source += 'using Deep.Target;\n' if mode == 'using' else '\n'
+ source += '#endif\n' if pp else ''
+ source += '}\n' * pairs
+ (work / 'Probe.cs').write_text(source)
+ expected_rows = [('Probe.cs', ['Top.Level'])] if mode == 'top' else (
+ [('Probe.cs', ['Deep.Target'])] if mode == 'using' and depth <= 256 else [])
+ assert targets(work) == expected_rows, (depth, mode, targets(work))
+ cache_file = scratch / (work.name + '.cache')
+ cold_stat = None
+ for cache_arg in ['--no-cache', '--cache=' + str(scratch / (work.name + '.cache')),
+ '--cache=' + str(scratch / (work.name + '.cache'))]:
+ skipped = ET.fromstring(run(work, '--skipped', cache_arg)).find('skipped')
+ assert skipped is not None
+ partial = depth > 256 and (legacy or mode == 'using')
+ assert int(skipped.get('extract_partial', '0')) == int(partial), (depth, mode, skipped.attrib)
+ if cache_arg != '--no-cache':
+ stat = cache_file.stat()
+ stamp = (stat.st_ino, stat.st_mtime_ns)
+ if cold_stat is not None:
+ assert (stamp != cold_stat) == partial, (depth, mode, cold_stat, stamp)
+ cold_stat = stamp
+print(' PASS C# C6: 254/255/256/257/258 boundary, using/empty/top; cold/warm disclosure and cache eligibility')
diff --git a/test/csharpimportcapfix/Imports.cs b/test/csharpimportcapfix/Imports.cs
new file mode 100644
index 000000000..c2fd70a98
--- /dev/null
+++ b/test/csharpimportcapfix/Imports.cs
@@ -0,0 +1,41 @@
+using System;
+using Demo.Widget;
+using Alias = Demo.Widget;
+global using GlobalAlias = Demo.Widget;
+using static Demo.Widget;
+global using static Demo.Widget;
+global using Demo;
+using global::Demo.Widget;
+using Generic = Demo.Box;
+using Pair = (int left, string right);
+using Items = Demo.Widget[];
+using Maybe = Demo.Widget?;
+using unsafe Ptr = int*;
+using Demo;
+using Demo;
+using /* before */ Demo /* middle */ . Widget;
+using /* before */ CommentAlias /* alias */ = Demo . Widget;
+
+namespace Outer
+{
+ using Nested = Demo.Widget;
+ public class Caller
+ {
+ public static void Run(bool chooseWidget)
+ {
+ Alias.Ping();
+ if (chooseWidget)
+ {
+ Widget.Ping();
+ }
+ else
+ {
+ Ping();
+ }
+ }
+ public void Resource()
+ {
+ using var resource = new Disposable();
+ }
+ }
+}
diff --git a/test/csharpimportcapfix/Types.cs b/test/csharpimportcapfix/Types.cs
new file mode 100644
index 000000000..0c823c3b7
--- /dev/null
+++ b/test/csharpimportcapfix/Types.cs
@@ -0,0 +1,2 @@
+namespace Demo { public class Widget { public static void Ping() {} } public class Box {} }
+namespace Other { public class Widget { public static void Ping() {} } }
diff --git a/test/langidentity/manifest.tsv b/test/langidentity/manifest.tsv
index 5218a6927..8d19fdcfa 100644
--- a/test/langidentity/manifest.tsv
+++ b/test/langidentity/manifest.tsv
@@ -28,6 +28,7 @@ rb test/rubyconstfix
rb test/rubyrecvfix
cs test/csharpfix
cs test/csharpcondfix
+cs test/csharpimportcapfix
php test/phpfix
lua test/luafix
lua test/luarequirefix
diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash
index e60bed005..f0f14347c 100644
--- a/test/qschemetrip.hash
+++ b/test/qschemetrip.hash
@@ -1 +1 @@
-c4422a58e4e5431372e8b06423ab8791a0c73496499b838c3dd548825962d607
+6093f6e27e694801716d56755d0b336eab930d19c11e7c962f04495d0a465043
diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh
index e6ec064df..38ba83daf 100755
--- a/test/qschemetripcheck.sh
+++ b/test/qschemetripcheck.sh
@@ -34,6 +34,11 @@ SRC="$ROOT/src/quality.h"
ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (2026-08-29 ingest.cpp section split); the hashed CONCAT label keeps its historical spelling so the pin holds
PIN="$ROOT/test/qschemetrip.hash"
# RE-PIN LOG (the pin is a bare hash, so its justification has to live here).
+# 2026-10-21, C# import captures (#358 1a, PR #420 by @LaimaWu), merged with main 320f8d8f (train 32a): RE-PINNED with UPDATE_GOLDEN=1 (hash 6093f6e27e…5043; re-derived once on the merged tree, unchanged: main's
+# train-32a numbers live in comments only).
+# Watched text moved: kParserVer 206 -> 214 (rich file tag 215), quality.h's kIngestParserVerMirror with it. 214 is above main 206 / 207 and the
+# train-32a numbers 212 / 213 (below). Import-free deep containers cease to be partial. kCacheVersion stays 36 and kQSnapCacheScheme stays 22: no record
+# format moved. The entries below are kept as written; their hashes are superseded by this one.
# 2026-10-21, train 32a (member-names 781a22fb, rust-receivers dd378171 merged onto main f156be85): RE-DERIVED ONCE with
# UPDATE_GOLDEN=1 on the merged tree (a667ffc55e…8a05 -> c4422a58e4…2d607). Watched text: kParserVer 206 -> 212 (rich file tag
# 213; above main 206 / 207, lane rust-receivers 202 / 203, ix1-slot 204 / 205, l6-iface 210 / 211; #420 reserves 214 / 215) and