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.