From 193d69ba578e05ce935dc9a13295e240ec150e7b Mon Sep 17 00:00:00 2001
From: LaimaWu <317139567+LaimaWu@users.noreply.github.com>
Date: Sun, 18 Oct 2026 22:08:42 +0800
Subject: [PATCH 1/3] refactor(#358): C# imports still bypass the shared
capture path
Move using directives onto @import.path and the CSharp normaliser, retaining targets, sites, order, flags and existing alias behaviour. Remove the redundant extractor; leave bindings to slice 2b.
Pin the shared-capture depth disclosure/cacheability exception, add real-input mutation controls, and advance parser identity to 186 with its quality mirror. No shared binding API changes.
---
CHANGELOG.md | 12 ++
CONTRIBUTING.md | 2 +-
prompts/help-wanted/visual-basic-language.md | 2 +-
queries/csharp/tags.scm | 8 +-
src/ingest_cache.h | 4 +-
src/ingest_importcap.h | 23 +++-
src/ingest_relations.h | 44 +------
src/quality.h | 2 +-
test/childwalkscalecheck.sh | 4 +-
test/csharpcheck.sh | 10 +-
test/csharpimportcap.py | 114 +++++++++++++++++++
test/csharpimportcapfix/Imports.cs | 41 +++++++
test/csharpimportcapfix/Types.cs | 2 +
test/langidentity/manifest.tsv | 1 +
test/qschemetrip.hash | 2 +-
test/qschemetripcheck.sh | 3 +
16 files changed, 221 insertions(+), 53 deletions(-)
create mode 100644 test/csharpimportcap.py
create mode 100644 test/csharpimportcapfix/Imports.cs
create mode 100644 test/csharpimportcapfix/Types.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6de595bdb..3dc236ed7 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)
+
+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 198 (rich 199) invalidates the old extraction cache; the train-30 record format 36 is unchanged.
+
### Changed — Go methods carry their receiver type as scope (#403, slice 9a)
Go methods now carry their receiver type as scope (`sc=`), with `*` and type arguments stripped (`*Stack[T]` → `Stack`),
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 55341dd3c..52cbefd99 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -581,7 +581,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 918f9d92f..9ad010e61 100644
--- a/src/ingest_cache.h
+++ b/src/ingest_cache.h
@@ -345,7 +345,9 @@ 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 = 196; // bump on any grammar/.scm/extraction change
+constexpr std::uint32_t kParserVer = 198; // bump on any grammar/.scm/extraction change
+ // 198 = 2026-10-19 (#358 C# 1a): using directives move to shared captures; import-free deep containers
+ // no longer disclose a cut or lose whole-cache eligibility. Alias/edge facts unchanged; rich tag 199.
// 196 = 2026-10-18 (train 30): strictly above every number a live build used — main 184 (rich 185),
// lane call-result-typing 180 (rich 181), lane fcall-forward 188 (189), lane go-local-var 190 (191),
// lane name-template-dispatch 192 (193), lane h1-annot (train 31) 194 (195); tail-sym and
diff --git a/src/ingest_importcap.h b/src/ingest_importcap.h
index 31a5dcdbb..ba99eca5d 100644
--- a/src/ingest_importcap.h
+++ b/src/ingest_importcap.h
@@ -227,8 +227,23 @@ 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 )
+{
+ 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 +255,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 +271,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 +283,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..c544517f3 100644
--- a/src/ingest_relations.h
+++ b/src/ingest_relations.h
@@ -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/quality.h b/src/quality.h
index f677abdb9..9060b0fe2 100644
--- a/src/quality.h
+++ b/src/quality.h
@@ -2336,7 +2336,7 @@ 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 = 196; // MUST equal ingest.cpp's kParserVer (gated)
+constexpr std::uint32_t kIngestParserVerMirror = 198; // MUST equal ingest.cpp's kParserVer (gated)
// 196 = 2026-10-18 (train 30: above call-result-typing 180/181, fcall-forward
// 188/189, go-local-var 190/191, name-template-dispatch 192/193, h1-annot
// 194/195; see the kParserVer note)
diff --git a/test/childwalkscalecheck.sh b/test/childwalkscalecheck.sh
index 692225986..755244e1b 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 7abac5d07..706fd9d6e 100644
--- a/test/qschemetrip.hash
+++ b/test/qschemetrip.hash
@@ -1 +1 @@
-5af7514356283527ea8833c20b3f63578e930bc6e7d7438e6f7b4d4d4f3726b4
+b3bb57d4aa8bbcda9087d57babd7cf157b907c1912264e6de8fcda6ea02a21a4
diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh
index ed58efa73..5a3a4b57d 100755
--- a/test/qschemetripcheck.sh
+++ b/test/qschemetripcheck.sh
@@ -34,6 +34,9 @@ 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-19, C# import captures (#358 1a), rebased onto train 30: parser tag 196 -> 198 (rich 199) and mirror move together.
+# Import-free deep containers cease to be partial; record format 36 and quality scheme 22 stay unchanged.
+# Re-derived with UPDATE_GOLDEN=1; extraction identity remains part of the quality snapshot key.
# 2026-10-18, train 30 (call-result-typing, go-local-var, fcall-forward, name-template-dispatch, tail-sym, path-owners merged
# onto main b59b0824): RE-DERIVED ONCE with UPDATE_GOLDEN=1 on the merged tree (hash 5af7514356…3726b4). Watched text:
# kParserVer -> 196 (lanes call-result-typing 180 / rich 181, fcall-forward 188 / 189, go-local-var 190 / 191,
From e630ea8e25e8afb1c4d6057929bfb406e7d53f8c Mon Sep 17 00:00:00 2001
From: joyful-ii-V-I <210578314+joyful-ii-V-I@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:46:56 -0400
Subject: [PATCH 2/3] #358 1a: grammar-bump insurance comment, stale C# import
comments, B16 alignment
Optional nits from review: keep the NodeField::Type early return in
normaliseCSharpImport with a one-line note (never taken on the pinned grammar);
captureTagsFacts, captureIncludes-history and ingest_relations header comments
now name C# as moved to the shared capture; align the B16 line in
childwalkscalecheck.sh; fill the hash in the qschemetripcheck RE-PIN LOG line.
No behaviour change.
Co-Authored-By: Claude Sonnet 5.5
---
src/ingest_importcap.h | 1 +
src/ingest_relations.h | 2 +-
src/ingest_sidecap.h | 2 +-
src/lintrules.h | 2 +-
test/childwalkscalecheck.sh | 2 +-
test/qschemetripcheck.sh | 2 +-
6 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/ingest_importcap.h b/src/ingest_importcap.h
index ba99eca5d..c7fee2c51 100644
--- a/src/ingest_importcap.h
+++ b/src/ingest_importcap.h
@@ -233,6 +233,7 @@ ImportSpec normaliseGoImport( TSNode declaration, std::string_view src )
// 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 ) };
diff --git a/src/ingest_relations.h b/src/ingest_relations.h
index c544517f3..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
diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h
index b9b5df0cf..3e55b797f 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/test/childwalkscalecheck.sh b/test/childwalkscalecheck.sh
index 755244e1b..c1acd9ea9 100755
--- a/test/childwalkscalecheck.sh
+++ b/test/childwalkscalecheck.sh
@@ -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) normaliseCSharpImport" 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/qschemetripcheck.sh b/test/qschemetripcheck.sh
index 0ae43ff3f..84e2549dc 100755
--- a/test/qschemetripcheck.sh
+++ b/test/qschemetripcheck.sh
@@ -34,7 +34,7 @@ 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 f156be85 (0.6.8): RE-PINNED with UPDATE_GOLDEN=1 (hash PLACEHOLDER).
+# 2026-10-21, C# import captures (#358 1a, PR #420 by @LaimaWu), merged with main f156be85 (0.6.8): RE-PINNED with UPDATE_GOLDEN=1 (hash 6093f6e27e…5043).
# 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. 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.
From b3d11a37af151784128e14d6329fda26003bd310 Mon Sep 17 00:00:00 2001
From: joyful-ii-V-I <210578314+joyful-ii-V-I@users.noreply.github.com>
Date: Thu, 22 Oct 2026 08:29:39 -0400
Subject: [PATCH 3/3] test: RE-PIN LOG note for the merge with main 320f8d8f
(qschemetrip hash re-derived once, unchanged)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
UPDATE_GOLDEN=1 on the merged tree reproduces 6093f6e27e…5043: main's train-32a numbers (212 / 213) live in comments only, so the watched text did not move.
Co-Authored-By: Claude Sonnet 5.5
---
test/qschemetripcheck.sh | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh
index f18057042..38ba83daf 100755
--- a/test/qschemetripcheck.sh
+++ b/test/qschemetripcheck.sh
@@ -34,7 +34,8 @@ 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).
+# 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.