Просмотр исходного кода

feat(extraction): add Swift to value-reference edges (static let / enum-struct namespaced constants)

Top-level `let` and `static let` in a `struct`/`enum`/`class` (Swift's
shared-constant idiom — it namespaces constants in `enum`/`struct`) now extract
as `constant` and participate in impact analysis; instance stored `let`s stay
`field`, and computed properties (getter, no stored value) are skipped.

Reuses the Kotlin machinery: the name nests
`property_declaration → pattern → simple_identifier` (resolved via
firstSimpleIdentifier), and the reader-scan already matched `simple_identifier`.
Two Swift-specific touches: the value-ref target gate was widened to accept
`struct:`/`enum:` parents (every other language's targets sit at
file:/class:/module:), and computed properties are detected and skipped. Node
creation slots into the existing Swift property_declaration handler, so the
property-wrapper / type-annotation dependency extraction (@Published/@State) is
untouched.

Validated S/M/L (Alamofire / swift-argument-parser / swift-nio): node count
identical on/off, genuine static-let constants, computed properties skipped,
0-1 sibling-type collisions per repo. Coverage now 14 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 1 неделя назад
Родитель
Сommit
a3acc3626e

+ 2 - 1
CHANGELOG.md

@@ -11,9 +11,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 
 ### New Features
 ### New Features
 
 
-- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, and Kotlin now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
+- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, and Swift now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
 - C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it.
 - C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it.
 - Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`.
 - Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`.
+- Swift top-level `let`s and `static let` constants (including those namespaced in an `enum`/`struct`, the common Swift pattern) are now indexed as constants and participate in the constant-reader impact analysis above — change a `static let defaultRetryLimit` or an `enum Constants { static let … }` and the same-file code that reads it shows up as affected. Computed properties and per-instance `let`s are not treated as constants.
 
 
 ### Fixes
 ### Fixes
 
 

+ 49 - 0
__tests__/value-reference-edges.test.ts

@@ -548,6 +548,55 @@ describe('value-reference edges', () => {
     expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
     expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
   });
   });
 
 
+  it('edges readers to a top-level let and static let in enum/struct, not an instance let (Swift)', async () => {
+    // Swift has no `static` keyword for globals; the shared-constant idiom is a
+    // top-level `let` or a `static let` inside a type — Swift namespaces these in
+    // `enum`/`struct`. Those extract as `constant`; an instance stored `let` is
+    // per-object (`field`, never a target); a *computed* property is skipped.
+    fs.writeFileSync(
+      path.join(dir, 'Demo.swift'),
+      [
+        'let topLevelMax = 100',
+        'enum Constants {',
+        '  static let TIMEOUT_MS = 30',
+        '  static let STATUS_NAMES = ["ok", "fail"]',
+        '}',
+        'struct Widget {',
+        '  static let MAX_RETRIES = 3',
+        '  let instanceField = 1',
+        '  func retries() -> Int { return Widget.MAX_RETRIES }',
+        '  func within(_ n: Int) -> Int { return n < topLevelMax ? n : topLevelMax }',
+        '}',
+        'func labels(_ i: Int) -> String { return Constants.STATUS_NAMES[i] }',
+      ].join('\n'),
+    );
+    cg = index();
+    await cg.indexAll();
+
+    expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['labels']));
+    expect(valueRefReaders(cg, 'MAX_RETRIES')).toEqual(expect.arrayContaining(['retries']));
+    expect(valueRefReaders(cg, 'topLevelMax')).toEqual(expect.arrayContaining(['within']));
+    // An instance `let` is per-object state (kind `field`), never a target.
+    expect(valueRefReaders(cg, 'instanceField')).toEqual([]);
+  });
+
+  it('does NOT edge a Swift static const shadowed by a function-local let of the same name', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'Shadow.swift'),
+      [
+        'enum Config {',
+        '  static let TIMEOUT = 30',
+        '  static func usesConst() -> Int { return TIMEOUT }',
+        '  static func shadows() -> Int { let TIMEOUT = 5; return TIMEOUT }',
+        '}',
+      ].join('\n'),
+    );
+    cg = index();
+    await cg.indexAll();
+
+    expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
+  });
+
   it('emits nothing when CODEGRAPH_VALUE_REFS=0', async () => {
   it('emits nothing when CODEGRAPH_VALUE_REFS=0', async () => {
     const prev = process.env.CODEGRAPH_VALUE_REFS;
     const prev = process.env.CODEGRAPH_VALUE_REFS;
     process.env.CODEGRAPH_VALUE_REFS = '0';
     process.env.CODEGRAPH_VALUE_REFS = '0';

+ 19 - 8
docs/design/value-reference-edges-playbook.md

@@ -45,7 +45,7 @@ agent read-reduction (see §4.3).
 
 
 | Symbol | Role |
 | Symbol | Role |
 |---|---|
 |---|---|
-| `VALUE_REF_LANGS` (static Set) | languages the feature runs for. Currently `typescript`, `javascript`, `tsx`, `go`, `python`, `rust`, `ruby`, `c`, `java`, `csharp`, `php`, `scala`, `kotlin`. **Add the new language here.** |
+| `VALUE_REF_LANGS` (static Set) | languages the feature runs for. Currently `typescript`, `javascript`, `tsx`, `go`, `python`, `rust`, `ruby`, `c`, `java`, `csharp`, `php`, `scala`, `kotlin`, `swift`. **Add the new language here.** |
 | `valueRefsEnabled` | `process.env.CODEGRAPH_VALUE_REFS !== '0'` — default ON, env opts out. |
 | `valueRefsEnabled` | `process.env.CODEGRAPH_VALUE_REFS !== '0'` — default ON, env opts out. |
 | `MAX_VALUE_REF_NODES` (20_000) | per-scope traversal cap (and the shadow-scan cap). |
 | `MAX_VALUE_REF_NODES` (20_000) | per-scope traversal cap (and the shadow-scan cap). |
 | `captureValueRefScope(kind, name, id, node)` | called from `createNode` on every node. Records **targets** (file-scope `const`/`var`) and **reader scopes** (`function`/`method`/`const`/`var`). |
 | `captureValueRefScope(kind, name, id, node)` | called from `createNode` on every node. Records **targets** (file-scope `const`/`var`) and **reader scopes** (`function`/`method`/`const`/`var`). |
@@ -119,6 +119,15 @@ targets** (see §3).
   C++-style tail. One of the cleanest yields — companion-object bit-masks/state consts are a heavy
   C++-style tail. One of the cleanest yields — companion-object bit-masks/state consts are a heavy
   same-file-read idiom. Validated S/M/L (okio/coroutines/ktor); only the bounded val/def-or-class and
   same-file-read idiom. Validated S/M/L (okio/coroutines/ktor); only the bounded val/def-or-class and
   sibling-companion name overlaps remain (shared with Scala/Ruby).
   sibling-companion name overlaps remain (shared with Scala/Ruby).
+- **Swift reused Kotlin + two Swift-specific touches.** Top-level `let` + `static let` in a type are
+  the shared constants (`enum`/`struct` namespace them); instance `let` stays `field`. Nested name
+  (`property_declaration → <name> pattern → simple_identifier`); reader-scan already covered
+  (`simple_identifier`, from Kotlin). Two new things: **(1) the target gate was widened to `struct:`/
+  `enum:` parents** — Swift namespaces constants there (`enum Constants { static let X }`), and every
+  other language's targets are `file:`/`class:`/`module:`; **(2) computed properties are skipped** (a
+  `var x:Int{ … }` getter has no stored value — detect the `computed_property` child). Node creation
+  slots into the *existing* Swift `property_declaration` handler (property-wrapper/type deps), leaving
+  that untouched. Clean parse, no tail. Validated S/M/L (Alamofire/swift-argument-parser/swift-nio).
 - **Tests:** `__tests__/value-reference-edges.test.ts` — same-file readers edged; surfaced in
 - **Tests:** `__tests__/value-reference-edges.test.ts` — same-file readers edged; surfaced in
   impact radius; shadowed const NOT edged (verified to fail without the guard); JSX-only read
   impact radius; shadowed const NOT edged (verified to fail without the guard); JSX-only read
   edged (tsx); `CODEGRAPH_VALUE_REFS=0` emits nothing.
   edged (tsx); `CODEGRAPH_VALUE_REFS=0` emits nothing.
@@ -147,6 +156,7 @@ the bottom of this section).
 | PHP | top-level `const` + class `const` (both already `constant` kind). **Only** change was the reader-scan: a PHP const *reference* is a `name` node. No extractor change, no prune wiring (a `$var` local can't shadow a bare constant). Lower yield — PHP reads consts cross-file more than same-file |
 | PHP | top-level `const` + class `const` (both already `constant` kind). **Only** change was the reader-scan: a PHP const *reference* is a `name` node. No extractor change, no prune wiring (a `$var` local can't shadow a bare constant). Lower yield — PHP reads consts cross-file more than same-file |
 | Scala | top-level `val` (already `constant`) + **`object` val** (the singleton-constant idiom; re-kinded from `field` by walking to the enclosing `object_definition`). `class`/`trait`/`enum` vals stay `field`. `val_definition`/`var_definition` added to the shadow prune. Minor val/def name-collision limit |
 | Scala | top-level `val` (already `constant`) + **`object` val** (the singleton-constant idiom; re-kinded from `field` by walking to the enclosing `object_definition`). `class`/`trait`/`enum` vals stay `field`. `val_definition`/`var_definition` added to the shadow prune. Minor val/def name-collision limit |
 | Kotlin | top-level / `object` / `companion object` `val` (re-kinded from nothing — properties weren't extracted at all). Handled in `visitNode`: nested name (`variable_declaration → simple_identifier`, the C move) + scope-walk for kind (Scala move) + `simple_identifier` in the reader-scan (PHP move) + prune. `class` instance vals stay `field`. Clean — one of the best yields (companion bit-masks) |
 | Kotlin | top-level / `object` / `companion object` `val` (re-kinded from nothing — properties weren't extracted at all). Handled in `visitNode`: nested name (`variable_declaration → simple_identifier`, the C move) + scope-walk for kind (Scala move) + `simple_identifier` in the reader-scan (PHP move) + prune. `class` instance vals stay `field`. Clean — one of the best yields (companion bit-masks) |
+| Swift | top-level `let` + `static let` in `struct`/`enum`/`class`. Reused Kotlin (nested name + `simple_identifier` reader-scan). Two Swift touches: **gate widened to `struct:`/`enum:` parents** (Swift namespaces consts there), and **computed properties skipped**. `class`/instance stored props stay `field`. Slots into the existing Swift property-wrapper handler |
 | **Svelte, Vue, Astro** | **inherited for free** — their extractors re-parse the `<script>`/frontmatter block as `typescript`/`javascript`, which are in `VALUE_REF_LANGS` (verified: a `.svelte` `const` edges its readers). No separate work; no separate matrix row needed. |
 | **Svelte, Vue, Astro** | **inherited for free** — their extractors re-parse the `<script>`/frontmatter block as `typescript`/`javascript`, which are in `VALUE_REF_LANGS` (verified: a `.svelte` `const` edges its readers). No separate work; no separate matrix row needed. |
 
 
 **🔜 Remaining — likely the easy path** (constants are file/module-scope, or top-level; do §5: add
 **🔜 Remaining — likely the easy path** (constants are file/module-scope, or top-level; do §5: add
@@ -160,8 +170,7 @@ column.**
 
 
 | Language | Constant forms | Note |
 | Language | Constant forms | Note |
 |---|---|---|
 |---|---|---|
-| Swift | top-level `let` (file) + `static let` in a type (class) | README notes Swift stored properties aren't extracted as own nodes — check |
-| Dart | top-level `const`/`final` (file) + `static const` (class) | mixed |
+| Dart | top-level `const`/`final` (file) + `static const` (class) | mixed; AST is a flat `static_final_declaration_list`, not a clean property node — messier than Swift |
 | Lua / Luau | file/chunk `local X =` + globals; no `const` keyword | distinctive-name gate (needs `[A-Z_]`) catches fewer — Lua casing varies |
 | Lua / Luau | file/chunk `local X =` + globals; no `const` keyword | distinctive-name gate (needs `[A-Z_]`) catches fewer — Lua casing varies |
 | R | file-scope `X <- …` / `X = …` | |
 | R | file-scope `X <- …` / `X = …` | |
 
 
@@ -328,11 +337,12 @@ The target gate now accepts **`file:`, `class:`, and `module:`** parents. Before
   enclosing class's constant, and strict matching would drop those valid reads. The only real FP
   enclosing class's constant, and strict matching would drop those valid reads. The only real FP
   is the same constant name in *sibling* classes in one file (~1.7% of Ruby targets on rails);
   is the same constant name in *sibling* classes in one file (~1.7% of Ruby targets on rails);
   valid code rarely hits it (a bare sibling-class constant is a NameError in Ruby).
   valid code rarely hits it (a bare sibling-class constant is a NameError in Ruby).
-- **Java `static final` + C# `const`/`static readonly` are DONE** (emitted as `field` → re-kinded to
-  `constant`). **Still untested:** Swift `static let`, Kotlin `companion`/`object`. The gate covers
-  them, but confirm the extractor emits them as `constant`/`variable` nodes with a `class:`/`struct:`
-  parent (Swift stored properties aren't extracted as their own nodes) — and if the parent kind is
-  `struct:`/`interface:` rather than `class:`/`module:`, widen the gate.
+- **Java/C#/Kotlin/Swift class-scope constants are DONE.** The gate now accepts `file:`/`class:`/
+  `module:`/**`struct:`/`enum:`** parents — the `struct:`/`enum:` widening was added for Swift, which
+  namespaces shared constants in `enum`/`struct` (`enum Constants { static let X }`). **Lesson for the
+  next class-scope language:** check the *parent kind* of a sample const (`select … substr(id…)`) — if
+  it's `struct:`/`enum:`/`interface:` and the gate doesn't list it, widen the gate (one line) or the
+  feature silently emits nothing despite the nodes existing.
 - **Confirm the reader-scan matches the language's constant *reference* node type (the PHP lesson).**
 - **Confirm the reader-scan matches the language's constant *reference* node type (the PHP lesson).**
   The reader-scan in `flushValueRefs` matches `identifier` / `constant` / `name`. If the new language
   The reader-scan in `flushValueRefs` matches `identifier` / `constant` / `name`. If the new language
   represents a constant *read* as some other node type, the scan finds nothing and **no edges form**
   represents a constant *read* as some other node type, the scan finds nothing and **no edges form**
@@ -366,6 +376,7 @@ silently does nothing for the new language and intra-file shadowing produces fal
 | PHP | **none** | a `$var` local (`variable_name`) is a different namespace from a bare constant — a local can never shadow a constant, so the prune is a no-op and needs no PHP declarator | **done** (n/a) |
 | PHP | **none** | a `$var` local (`variable_name`) is a different namespace from a bare constant — a local can never shadow a constant, so the prune is a no-op and needs no PHP declarator | **done** (n/a) |
 | Scala | `val_definition`, `var_definition` | `pattern` field (identifier) — catches an object/top-level val shadowed by a method-local `val` | **done** |
 | Scala | `val_definition`, `var_definition` | `pattern` field (identifier) — catches an object/top-level val shadowed by a method-local `val` | **done** |
 | Kotlin | `property_declaration` | `variable_declaration → simple_identifier` (and `bump` accepts `simple_identifier`) — catches an object/companion const shadowed by a method-local `val` | **done** |
 | Kotlin | `property_declaration` | `variable_declaration → simple_identifier` (and `bump` accepts `simple_identifier`) — catches an object/companion const shadowed by a method-local `val` | **done** |
+| Swift | `property_declaration` | `<name> pattern → simple_identifier` (`firstSimpleIdentifier`) — the prune case resolves both Kotlin and Swift shapes; catches a static const shadowed by a method-local `let` | **done** |
 
 
 **The prune rule is `declarators > file-scope-node-count`, NOT `> 1`.** A name can be bound
 **The prune rule is `declarators > file-scope-node-count`, NOT `> 1`.** A name can be bound
 twice *at file scope* legitimately — a **conditional module def** (`try: X = a; except: X = b`,
 twice *at file scope* legitimately — a **conditional module def** (`try: X = a; except: X = b`,

+ 32 - 4
docs/design/value-reference-edges.md

@@ -1,6 +1,6 @@
 # Design + status: same-file value-reference edges
 # Design + status: same-file value-reference edges
 
 
-**Status:** SHIPPED (default-on for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin; `CODEGRAPH_VALUE_REFS=0` disables). The
+**Status:** SHIPPED (default-on for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift; `CODEGRAPH_VALUE_REFS=0` disables). The
 emitter lives in `TreeSitterExtractor.flushValueRefs` (`src/extraction/tree-sitter.ts`).
 emitter lives in `TreeSitterExtractor.flushValueRefs` (`src/extraction/tree-sitter.ts`).
 **Motivation:** close the impact-analysis hole for *value consumers*. Static
 **Motivation:** close the impact-analysis hole for *value consumers*. Static
 extraction edges calls, imports, and inheritance, but never edges a constant to the
 extraction edges calls, imports, and inheritance, but never edges a constant to the
@@ -13,7 +13,7 @@ readers" class of change (the ReScript-PR false positive that motivated the work
 ## TL;DR for a new session
 ## TL;DR for a new session
 
 
 We emit a `references` edge (`metadata: { valueRef: true }`) from a reader symbol to
 We emit a `references` edge (`metadata: { valueRef: true }`) from a reader symbol to
-the **file/package-scope `const`/`var` it reads**, same-file only, for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin. Those edges
+the **file/package-scope `const`/`var` it reads**, same-file only, for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift. Those edges
 flow straight into `getImpactRadius` / `codegraph impact` and the impact trail in
 flow straight into `getImpactRadius` / `codegraph impact` and the impact trail in
 `codegraph_explore` / `codegraph_node` — no agent-behaviour change required.
 `codegraph_explore` / `codegraph_node` — no agent-behaviour change required.
 
 
@@ -46,7 +46,7 @@ The win is **impact-radius correctness**, not agent read-reduction (see "Agent A
    the content-minified bundles guard #1 misses.
    the content-minified bundles guard #1 misses.
 3. **Distinctive-name + same-file** as above.
 3. **Distinctive-name + same-file** as above.
 
 
-## Validation matrix — TS / JS / Go / Python / Rust / Ruby / C / Java / C# / PHP / Scala / Kotlin
+## Validation matrix — TS / JS / Go / Python / Rust / Ruby / C / Java / C# / PHP / Scala / Kotlin / Swift
 
 
 Method per repo: index the same tree twice (value-refs on vs `CODEGRAPH_VALUE_REFS=0`),
 Method per repo: index the same tree twice (value-refs on vs `CODEGRAPH_VALUE_REFS=0`),
 diff node/edge counts, spot-check precision, and measure `codegraph impact` on a few
 diff node/edge counts, spot-check precision, and measure `codegraph impact` on a few
@@ -150,7 +150,15 @@ extracting the nodes first — see below)
 | Kotlin/kotlinx.coroutines | medium | 1,039 | 17,058 (stable) | +210 | all sampled TP; 1 cross-file collision | `BLOCKING_SHIFT` 1→**24**, `TERMINATED` 2→22 (companion bit-masks) |
 | Kotlin/kotlinx.coroutines | medium | 1,039 | 17,058 (stable) | +210 | all sampled TP; 1 cross-file collision | `BLOCKING_SHIFT` 1→**24**, `TERMINATED` 2→22 (companion bit-masks) |
 | ktorio/ktor | large | 2,302 | 43,272 (stable) | +849 | object/companion consts (HTTP header names); flagged collisions are real consts; `TYPE` is a sibling-companion ambiguity | `TYPE` 8→**109**, `FailedPath` 1→22 |
 | ktorio/ktor | large | 2,302 | 43,272 (stable) | +849 | object/companion consts (HTTP header names); flagged collisions are real consts; `TYPE` is a sibling-companion ambiguity | `TYPE` 8→**109**, `FailedPath` 1→22 |
 
 
-Across S/M/L in all twelve languages: node count never moved, the precision guards held, and
+**Swift** (top-level `let` + `static let` in `struct`/`enum`/`class` → `constant`; instance `let` stays `field`; computed properties skipped)
+
+| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
+|---|---|---|---|---|---|---|
+| Alamofire/Alamofire | small | 98 | 4,192 (stable) | +108 | all sampled TP; 0 collisions; computed properties skipped | `defaultRetryLimit` 1→3, `defaultWait` 1→4 |
+| apple/swift-argument-parser | medium | 165 | 4,435 (stable) | +36 | all sampled TP; 1 sibling-type collision (`usageString`) | `usageString` 8→**18**, `labelColumnWidth` 1→2 |
+| apple/swift-nio | large | 554 | 20,136 (stable) | +589 | all sampled TP; 0 collisions; `eventLoop` (static let) verified TP | `CONNECT_DELAYER` 1→**15**, `SINGLE_IPv4_RESULT` 1→12 |
+
+Across S/M/L in all thirteen languages: node count never moved, the precision guards held, and
 the `impact` OFF column is the bug — a const that 80–140 symbols read reports "1 affected"
 the `impact` OFF column is the bug — a const that 80–140 symbols read reports "1 affected"
 without value-refs.
 without value-refs.
 
 
@@ -317,6 +325,26 @@ class the same), plus the sibling-companion case (several `companion object { co
 file collapse to the file-wide target, like Ruby's sibling-class) — both bounded, and every flagged
 file collapse to the file-wide target, like Ruby's sibling-class) — both bounded, and every flagged
 collision investigated was a real object/companion const.
 collision investigated was a real object/companion const.
 
 
+**Swift reused the Kotlin techniques and added two Swift-specific touches.** Swift has no `static`
+keyword for globals; its shared-constant idiom is a top-level `let` or a `static let` inside a type —
+and Swift idiomatically *namespaces* constants in `enum`/`struct` (`enum Constants { static let X }`).
+A property name nests (`property_declaration → <name> pattern → simple_identifier`), the C-style
+problem; the reader-scan already matched `simple_identifier` (added for Kotlin — Swift shares it). The
+kind rule: top-level `let` and `static let` (in any type) → `constant` (`var` → `variable`); an
+*instance* `let`/`var` stays `field` (Swift instance stored properties otherwise aren't own nodes —
+unchanged). The two Swift-specific touches: (1) **the value-ref target gate was widened to `struct:`/
+`enum:` parents**, because Swift namespaces constants in those (every other language's targets sit at
+`file:`/`class:`/`module:`); without it, the heavily-used `enum`/`struct` static consts would all be
+missed. (2) **Computed properties are skipped** — a `var x: Int { … }` has a getter block, no stored
+value, and isn't a constant; the extractor detects the `computed_property` child and emits no node
+(verified: no computed-property leaks across the sweep). The node creation slots into the *existing*
+Swift `property_declaration` handler (which already extracts property-wrapper / type-annotation
+dependencies like `@Published`/`@State`), so that behavior is untouched. Validated S/M/L
+(Alamofire/swift-argument-parser/swift-nio): node count identical on/off, genuine static-let
+constants (`defaultRetryLimit`, swift-nio's `CONNECT_DELAYER`/`SINGLE_IPv4_RESULT` test constants, a
+shared `static let eventLoop` read by 37 methods), computed properties skipped, 0–1 collisions per
+repo (the same sibling-type name-overlap bound as Kotlin/Ruby).
+
 **C++ was attempted and reverted** — the machinery (file/namespace-scope + class `field_declaration`
 **C++ was attempted and reverted** — the machinery (file/namespace-scope + class `field_declaration`
 extraction) is correct on clean C++, but tree-sitter-cpp's parse fidelity on real template/macro-heavy
 extraction) is correct on clean C++, but tree-sitter-cpp's parse fidelity on real template/macro-heavy
 code (and the `.h`→C-grammar routing) leaks class members and parameters to file scope as bogus
 code (and the `.h`→C-grammar routing) leaks class members and parameters to file scope as bogus

+ 86 - 7
src/extraction/tree-sitter.ts

@@ -181,6 +181,43 @@ function cDeclaratorIdentifier(node: SyntaxNode | null): SyntaxNode | null {
   return null;
   return null;
 }
 }
 
 
+/** First `simple_identifier` in `node`'s subtree (breadth-ish, first-found).
+ * Swift's property name nests as `property_declaration → <name> pattern →
+ * bound_identifier → simple_identifier`; this resolves it (and the bound name of
+ * a Kotlin/Swift property declarator for the shadow prune). For a tuple pattern
+ * (`let (a, b)`) it returns the first — acceptable, those are rare for consts. */
+function firstSimpleIdentifier(node: SyntaxNode | null): SyntaxNode | null {
+  const stack: SyntaxNode[] = node ? [node] : [];
+  let guard = 0;
+  while (stack.length > 0 && guard++ < 40) {
+    const n = stack.shift()!;
+    if (n.type === 'simple_identifier') return n;
+    for (let i = 0; i < n.namedChildCount; i++) {
+      const c = n.namedChild(i);
+      if (c) stack.push(c);
+    }
+  }
+  return null;
+}
+
+/** Swift property facts: the bound name, whether it's a `let`, and whether it's
+ * a *computed* property (a getter block, no stored value — never a constant). */
+function swiftPropertyInfo(
+  node: SyntaxNode,
+  source: string,
+): { nameNode: SyntaxNode | null; isLet: boolean; isComputed: boolean } {
+  const pattern =
+    getChildByField(node, 'name') ??
+    node.namedChildren.find((c) => c.type === 'value_binding_pattern' || c.type === 'pattern') ??
+    null;
+  const binding = node.namedChildren.find((c) => c.type === 'value_binding_pattern');
+  const isLet = binding != null && getNodeText(binding, source).trimStart().startsWith('let');
+  const isComputed = node.namedChildren.some(
+    (c) => c.type === 'computed_property' || c.type === 'protocol_property_requirements',
+  );
+  return { nameNode: firstSimpleIdentifier(pattern), isLet, isComputed };
+}
+
 /** True when `node` is (transitively) inside a C function body — i.e. a local,
 /** True when `node` is (transitively) inside a C function body — i.e. a local,
  * not a file/namespace-scope declaration. Walks the parent chain to the root. */
  * not a file/namespace-scope declaration. Walks the parent chain to the root. */
 function hasFunctionAncestor(node: SyntaxNode): boolean {
 function hasFunctionAncestor(node: SyntaxNode): boolean {
@@ -265,7 +302,7 @@ export class TreeSitterExtractor {
   // Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
   // Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
   // Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
   // Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
   // value consumers ("change this constant/table, affect its readers").
   // value consumers ("change this constant/table, affect its readers").
-  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin']);
+  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift']);
   private static readonly MAX_VALUE_REF_NODES = 20_000;
   private static readonly MAX_VALUE_REF_NODES = 20_000;
   private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
   private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
   private fileScopeValues = new Map<string, string>();
   private fileScopeValues = new Map<string, string>();
@@ -575,10 +612,17 @@ export class TreeSitterExtractor {
   private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
   private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
     if ((kind === 'constant' || kind === 'variable') && name.length >= 3 && /[A-Z_]/.test(name)) {
     if ((kind === 'constant' || kind === 'variable') && name.length >= 3 && /[A-Z_]/.test(name)) {
       const parentId = this.nodeStack[this.nodeStack.length - 1];
       const parentId = this.nodeStack[this.nodeStack.length - 1];
-      // file-scope OR class/module-scope constants are targets. Class/module
-      // scope matters for languages (Ruby) that keep nearly all constants inside
-      // a class or module; readers are same-file methods of that type.
-      if (parentId && (parentId.startsWith('file:') || parentId.startsWith('class:') || parentId.startsWith('module:'))) {
+      // file-scope OR class/module/struct/enum-scope constants are targets.
+      // Class/module scope matters for languages (Ruby) that keep nearly all
+      // constants inside a class or module; struct/enum scope matters for Swift,
+      // which namespaces shared constants in `struct`/`enum` (`enum Constants {
+      // static let X }`). Readers are same-file methods of that type.
+      if (
+        parentId &&
+        (parentId.startsWith('file:') || parentId.startsWith('class:') ||
+          parentId.startsWith('module:') || parentId.startsWith('struct:') ||
+          parentId.startsWith('enum:'))
+      ) {
         this.fileScopeValues.set(name, id);
         this.fileScopeValues.set(name, id);
         // How many target nodes carry this name. A conditional def
         // How many target nodes carry this name. A conditional def
         // (`try: X = a; except: X = b`) makes >1 — distinct from a local shadow,
         // (`try: X = a; except: X = b`) makes >1 — distinct from a local shadow,
@@ -666,9 +710,17 @@ export class TreeSitterExtractor {
             if (pat?.type === 'identifier') bump(pat);
             if (pat?.type === 'identifier') bump(pat);
             break;
             break;
           }
           }
-          case 'property_declaration': { // Kotlin  `val X = …` (object/top-level const AND a method-local that shadows it)
+          case 'property_declaration': { // Kotlin / Swift  `val`/`let X = …` (object/static const AND a method-local that shadows it)
+            // Kotlin: variable_declaration → simple_identifier; Swift: a `pattern`
+            // (`<name>` field) → simple_identifier. Resolve either shape.
             const vd = n.namedChildren.find((c) => c.type === 'variable_declaration');
             const vd = n.namedChildren.find((c) => c.type === 'variable_declaration');
-            const id = vd?.namedChildren.find((c) => c.type === 'simple_identifier');
+            const id = vd
+              ? vd.namedChildren.find((c) => c.type === 'simple_identifier')
+              : firstSimpleIdentifier(
+                  getChildByField(n, 'name') ??
+                    n.namedChildren.find((c) => c.type === 'value_binding_pattern' || c.type === 'pattern') ??
+                    null,
+                );
             if (id) bump(id);
             if (id) bump(id);
             break;
             break;
           }
           }
@@ -892,6 +944,21 @@ export class TreeSitterExtractor {
       this.isInsideClassLikeNode()
       this.isInsideClassLikeNode()
     ) {
     ) {
       const ownerId = this.nodeStack[this.nodeStack.length - 1];
       const ownerId = this.nodeStack[this.nodeStack.length - 1];
+      // A `static let`/`static var` member is a SHARED constant of the type
+      // (Swift's `static`-namespacing idiom, esp. in `enum`/`struct`) — extract
+      // it as `constant`/`variable` so value-reference edges can target it. An
+      // instance stored property stays a `field` (per-instance; Swift instance
+      // properties otherwise aren't own nodes — that's unchanged). A *computed*
+      // property (getter, no stored value) is never a constant — skip the node.
+      const { nameNode, isLet, isComputed } = swiftPropertyInfo(node, this.source);
+      if (nameNode && !isComputed) {
+        const isStatic = this.extractor.isStatic?.(node) ?? false;
+        this.createNode(isStatic ? (isLet ? 'constant' : 'variable') : 'field',
+          getNodeText(nameNode, this.source), node, {
+            visibility: this.extractor.getVisibility?.(node),
+            isStatic,
+          });
+      }
       if (ownerId) {
       if (ownerId) {
         this.extractDecoratorsFor(node, ownerId);
         this.extractDecoratorsFor(node, ownerId);
         this.extractVariableTypeAnnotation(node, ownerId);
         this.extractVariableTypeAnnotation(node, ownerId);
@@ -2090,6 +2157,18 @@ export class TreeSitterExtractor {
           this.createNode(kind, name, child, { docstring, signature: initSignature, isExported });
           this.createNode(kind, name, child, { docstring, signature: initSignature, isExported });
         }
         }
       }
       }
+    } else if (this.language === 'swift') {
+      // Swift top-level property (`let X = …` / `var Y = …`). The name nests in
+      // a `pattern`, which the generic fallback can't read, so top-level Swift
+      // constants/globals went unextracted. A top-level `let`→`constant`,
+      // `var`→`variable`; a computed property (getter, no value) is skipped.
+      const { nameNode, isLet, isComputed } = swiftPropertyInfo(node, this.source);
+      if (nameNode && !isComputed) {
+        this.createNode(isLet ? 'constant' : 'variable', getNodeText(nameNode, this.source), node, {
+          docstring,
+          isExported,
+        });
+      }
     } else {
     } else {
       // Generic fallback for other languages
       // Generic fallback for other languages
       // Try to find identifier children
       // Try to find identifier children