Kaynağa Gözat

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 hafta önce
ebeveyn
işleme
a3acc3626e

+ 2 - 1
CHANGELOG.md

@@ -11,9 +11,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### 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.
 - 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
 

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

@@ -548,6 +548,55 @@ describe('value-reference edges', () => {
     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 () => {
     const prev = process.env.CODEGRAPH_VALUE_REFS;
     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 |
 |---|---|
-| `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. |
 | `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`). |
@@ -119,6 +119,15 @@ targets** (see §3).
   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
   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
   impact radius; shadowed const NOT edged (verified to fail without the guard); JSX-only read
   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 |
 | 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) |
+| 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. |
 
 **🔜 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 |
 |---|---|---|
-| 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 |
 | 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
   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).
-- **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).**
   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**
@@ -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) |
 | 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** |
+| 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
 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
 
-**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`).
 **Motivation:** close the impact-analysis hole for *value consumers*. Static
 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
 
 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
 `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.
 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`),
 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) |
 | 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"
 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
 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`
 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

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

@@ -181,6 +181,43 @@ function cDeclaratorIdentifier(node: SyntaxNode | null): SyntaxNode | 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,
  * not a file/namespace-scope declaration. Walks the parent chain to the root. */
 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).
   // Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
   // 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 readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
   private fileScopeValues = new Map<string, string>();
@@ -575,10 +612,17 @@ export class TreeSitterExtractor {
   private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
     if ((kind === 'constant' || kind === 'variable') && name.length >= 3 && /[A-Z_]/.test(name)) {
       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);
         // How many target nodes carry this name. A conditional def
         // (`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);
             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 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);
             break;
           }
@@ -892,6 +944,21 @@ export class TreeSitterExtractor {
       this.isInsideClassLikeNode()
     ) {
       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) {
         this.extractDecoratorsFor(node, ownerId);
         this.extractVariableTypeAnnotation(node, ownerId);
@@ -2090,6 +2157,18 @@ export class TreeSitterExtractor {
           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 {
       // Generic fallback for other languages
       // Try to find identifier children