Переглянути джерело

fix(extraction): index Swift computed properties so they're findable (#1020) (#1024)

Swift in-class properties are extracted by a dedicated branch in
TreeSitterExtractor.visitNode, not the generic nameField/variableTypes path
swift.ts declares. That branch had a `!isComputed` gate that dropped computed
properties entirely, so `codegraph query`/`codegraph_explore` returned "No
results found" for them — including a SwiftUI view's `var body: some View`,
the most important symbol in any SwiftUI app, and the heavily-read
`var isCloudProxy: Bool` from the report.

Stored properties were already fixed in #708 (v1.0.0); the reporter tested
v0.9.9 and confirmed "still present on main" by inspecting swift.ts only,
missing the dedicated branch — so only the computed-property half was real.

- Computed properties now index as `property` nodes; the getter is walked via
  visitFunctionBody so its calls attribute to the property (a SwiftUI `body`'s
  subview tree becomes the property's callees — the render flow is traceable
  through it), not flattened onto the enclosing type.
- Protocol property requirements (`var x: T { get }`) — a third never-indexed
  category — index as `property` too.
- Routing the getter through visitFunctionBody also stops getter-local
  `let`/`var` declarations from being wrongly node-ified as struct fields
  (the generic child-walk used to do this): Alamofire property 0→348, field
  618→588, idempotent.

Stored/static behavior is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 19 годин тому
батько
коміт
b3f59c717a
3 змінених файлів з 132 додано та 18 видалено
  1. 1 0
      CHANGELOG.md
  2. 82 0
      __tests__/extraction.test.ts
  3. 49 18
      src/extraction/tree-sitter.ts

+ 1 - 0
CHANGELOG.md

@@ -23,6 +23,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Claude Code's front-load prompt hook now fires for non-English prompts. The optional hook that injects CodeGraph context for structural questions only recognized English keywords, so a structural question written in Chinese — or any non-Latin-script language — silently injected nothing: the hook looked like it wasn't wired up despite a correct setup, with no error to explain why. The gate is now language-aware. It recognizes Chinese structural keywords (如何/流程/调用/依赖/实现/架构…), and — in any language — a prompt that names a real code symbol from your project, such as `getUserId`, `article_publish`, `user.login`, or `parseToken()` (the name is checked against the index, so an ordinary word that merely looks like code doesn't trigger it). Non-structural prompts ("fix this typo", in any language) stay a no-op as before, so nothing fires where there's no structural answer to give. Thanks @whinc for the detailed report and repro. (#994)
 - The background auto-sync server now starts for projects kept on an ExFAT or FAT external drive (and some network mounts). Those filesystems don't support the operations the server relies on to coordinate and to listen locally, so it failed immediately and re-logged the same error on every retry — background indexing was broken, so you had to run `codegraph sync` by hand after changes. (The MCP tools, the prompt hook, and manual `codegraph index`/`sync` were unaffected, since none of them need the server.) The server now works around those limitations automatically — falling back to a different coordination method and relocating its local socket to your system temp directory — so background indexing works there exactly like anywhere else, with no configuration needed. Verified end-to-end on real removable-drive filesystems on macOS, Linux, and Windows. Thanks @zengwenliang416 for the detailed report. (#997)
 - If you use CodeGraph as a library, the `QueryBuilder.deleteResolvedReferences()` helper no longer throws "too many SQL variables" when handed a very large list of ids — it issued one unbounded query, so a list longer than SQLite's parameter limit aborted the call. It now splits the work into batches like every other bulk query in the API. CodeGraph's own indexing and reference resolution never called this method (they use a different, already-batched path), so the CLI and MCP server were unaffected. Thanks @inth3shadows for the static analysis. (#1001)
+- Swift computed properties are now indexed, so you can search for them. A computed property — a `var isCloudProxy: Bool { … }` read all over a codebase, a SwiftUI view's `var body: some View { … }`, a protocol's `var title: String { get }` requirement — produced no symbol at all, so `codegraph query` and `codegraph_explore` answered "No results found" for it, and an agent that trusts an empty result would wrongly conclude the property doesn't exist. Computed properties (and protocol property requirements) are now graph symbols you can find and explore like anything else. A computed property's getter is also read as its body, so a SwiftUI view's `body` links to the subviews and helpers it builds — making a view's render flow traceable through the property. Stored properties were already indexed; this closes the computed-property gap. Thanks @monochrome3694 for the precise report and repro. (#1020)
 
 
 ## [1.1.1] - 2026-06-24

+ 82 - 0
__tests__/extraction.test.ts

@@ -1437,6 +1437,88 @@ protocol UploadConvertible: URLRequestConvertible {
     // UploadConvertible extends URLRequestConvertible
     expect(extendsRefs.find((r) => r.referenceName === 'URLRequestConvertible')).toBeDefined();
   });
+
+  it('indexes Swift properties so they are findable: computed → property, stored → field, static → constant/variable (#1020)', () => {
+    const code = `
+struct ReproConfig {
+    let reproStoredValue: Int
+    var reproComputedFlag: Bool {
+        reproStoredValue > 0
+    }
+    static let sharedLimit = 10
+    static var sharedCount = 0
+    func reproControlMethod() -> Bool {
+        reproComputedFlag
+    }
+}
+
+final class ReproService {
+    private let reproClassStored: String = "x"
+    var reproClassComputed: Int { reproClassStored.count }
+}
+`;
+    const result = extractFromSource('Repro.swift', code);
+    const byName = (name: string) => result.nodes.find((n) => n.name === name);
+
+    // Computed properties are the regression this fix targets: before #1020 they
+    // were dropped entirely, so search/explore returned nothing for them.
+    expect(byName('reproComputedFlag')?.kind).toBe('property');
+    expect(byName('reproClassComputed')?.kind).toBe('property');
+
+    // Stored instance properties stay `field` (fixed earlier in #708 — guard it).
+    expect(byName('reproStoredValue')?.kind).toBe('field');
+    expect(byName('reproClassStored')?.kind).toBe('field');
+
+    // `static let`/`static var` members remain shared constant/variable nodes.
+    expect(byName('sharedLimit')?.kind).toBe('constant');
+    expect(byName('sharedCount')?.kind).toBe('variable');
+
+    // The control method is unaffected.
+    expect(byName('reproControlMethod')?.kind).toBe('method');
+  });
+
+  it("attributes a computed property's getter calls to the property, not the type (SwiftUI body flow) (#1020)", () => {
+    const code = `
+struct GreetingView {
+    let name: String
+    var body: some View {
+        let prefix = "Hi"
+        return VStack {
+            Text(greeting(prefix))
+        }
+    }
+    func greeting(_ p: String) -> String { p }
+}
+`;
+    const result = extractFromSource('View.swift', code);
+    const body = result.nodes.find((n) => n.kind === 'property' && n.name === 'body');
+    expect(body).toBeDefined();
+
+    // The getter's call to greeting() must originate from `body` (so a SwiftUI
+    // view's render flow is reachable through the property), not flatten onto the
+    // enclosing struct.
+    const callsFromBody = result.unresolvedReferences.filter(
+      (r) => r.fromNodeId === body!.id && r.referenceKind === 'calls'
+    );
+    expect(callsFromBody.some((r) => r.referenceName === 'greeting')).toBe(true);
+
+    // The getter is walked as a body, so a local declared inside it is NOT
+    // node-ified (locals are the data-flow frontier we leave uncovered). Before
+    // this fix the generic walker treated such a local as a struct `field`.
+    expect(result.nodes.find((n) => n.name === 'prefix')).toBeUndefined();
+  });
+
+  it('indexes a Swift protocol property requirement as a findable property (#1020)', () => {
+    const code = `
+protocol Themable {
+    var accentColor: Color { get }
+    var title: String { get set }
+}
+`;
+    const result = extractFromSource('Themable.swift', code);
+    expect(result.nodes.find((n) => n.name === 'accentColor')?.kind).toBe('property');
+    expect(result.nodes.find((n) => n.name === 'title')?.kind).toBe('property');
+  });
 });
 
 describe('Kotlin Extraction', () => {

+ 49 - 18
src/extraction/tree-sitter.ts

@@ -986,32 +986,46 @@ export class TreeSitterExtractor {
       this.scanFnRefSubtree(node, 0);
       skipChildren = true; // extractVariable handles children
     }
-    // Swift stored properties inside a type. Swift instance properties aren't
-    // extracted as their own nodes, but a property's PROPERTY WRAPPER
-    // (`@Argument`/`@Published`/`@State`/custom) and declared type ARE
-    // dependencies — attribute them to the enclosing type so the wrapper/type
-    // files get dependents. Don't skipChildren: an initializer's calls still
-    // matter. (Other languages extract properties via property/field types.)
+    // Swift properties inside a type. A stored instance property becomes a `field`
+    // node; a `static let`/`static var` member becomes `constant`/`variable`
+    // (Swift's `static`-namespacing idiom — value-reference edges can then target
+    // it); a COMPUTED property (getter block, no stored value) becomes a `property`
+    // node whose getter is walked below so its calls attribute to it. A property's
+    // PROPERTY WRAPPER (`@Argument`/`@Published`/`@State`/custom) and declared type
+    // are dependencies attributed to the enclosing type. (Other languages extract
+    // properties via property/field types.)
     else if (
       this.language === 'swift' &&
-      nodeType === 'property_declaration' &&
+      (nodeType === 'property_declaration' || nodeType === 'protocol_property_declaration') &&
       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, {
+      let computedPropId: string | undefined;
+      if (nameNode) {
+        if (isComputed) {
+          // Computed property — accessed like a property but its getter holds real
+          // logic. Index as `property` so search/explore find it (#1020: computed
+          // props such as a heavily-read `var isCloudProxy: Bool` returned "No
+          // results found"); pushed below so the getter's calls attribute to it
+          // rather than flattening onto the owning type (SwiftUI `var body: some
+          // View { … }` — the whole subview tree — is the canonical case).
+          const prop = this.createNode('property', getNodeText(nameNode, this.source), node, {
             visibility: this.extractor.getVisibility?.(node),
-            isStatic,
+            isStatic: this.extractor.isStatic?.(node) ?? false,
           });
+          computedPropId = prop?.id;
+        } else {
+          // A `static let`/`static var` member is a SHARED constant of the type
+          // (esp. in `enum`/`struct`); an instance stored property stays a `field`
+          // (per-instance — Swift instance properties otherwise aren't own nodes).
+          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);
@@ -1036,6 +1050,23 @@ export class TreeSitterExtractor {
           walkAttrArgs(modifiers);
         }
       }
+      // A computed property's getter holds real logic — walk it with the property
+      // node pushed so its calls/instantiations attribute to the property (a
+      // SwiftUI `body`'s subview tree becomes the property's callees). skipChildren
+      // then stops the generic walker from re-walking the getter (and the
+      // modifiers/type annotation already handled above).
+      if (computedPropId) {
+        const getter = node.namedChildren.find(
+          (c: SyntaxNode) =>
+            c.type === 'computed_property' || c.type === 'protocol_property_requirements',
+        );
+        if (getter) {
+          this.nodeStack.push(computedPropId);
+          this.visitFunctionBody(getter, '');
+          this.nodeStack.pop();
+        }
+        skipChildren = true;
+      }
     }
     // `export_statement` itself is not extracted — the walker descends
     // into children, where the inner declaration (lexical_declaration,