Skip to content

Commit c67197c

Browse files
authored
Swift extractor extension july 2 26 (#62)
* Swift: resolve modules at SPM/XcodeGen target level via project.yml instead of by leaf directory * fix(swift): stop flagging methods of parse-flattened types and extension-property helpers as dead code * Swift: overhaul perf analysis accuracy (259→81 findings)—bounded loops, label-aware recursion, Swift I/O gating, subscript fix, performs_io closure & inherited-call resolution * Swift+Ruby extractors tag a module_role (production/test/tooling) on module facts and collapse XcodeGen test bundles into one module each * fix(swift): eliminate false module dependency cycles by dropping collision-prone type-reference edges for target-resolved files and excluding test-role modules from the module graph * Fixing lint
1 parent 637c00b commit c67197c

20 files changed

Lines changed: 3253 additions & 79 deletions

ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ Each extractor is detected by characteristic project files and then parses what
524524

525525
**Kotlin** is Android-aware: it detects Jetpack Compose (`@Composable`), Hilt DI (`@HiltViewModel`, `@Module`, `@AndroidEntryPoint`), Room (`@Entity`, `@Dao`, `@Database`), ViewModels, Repositories, Use Cases, and Workers.
526526

527-
**Swift** is iOS-aware: SwiftUI views (`View`/`App`/`Scene`), UIKit (`UIViewController`/`UIView` subclasses), Combine view models (`ObservableObject`, `@Observable`), architectural roles (Repositories, Use Cases, Coordinators, Services, DI containers), and `@MainActor`.
527+
**Swift** (tree-sitter) emits symbol facts for classes, structs, enums, protocols, and extensions plus their methods, initializers, and properties, named `<targetDir>.<Type>.<member>` — where `<targetDir>` is the file's resolved SPM/XcodeGen *target* module (parsed from `Package.swift` and `project.yml`), not its leaf directory. Members declared inside a type are classified `symbol_kind: method`; free functions stay `function`. It walks bodies for the call graph: same-type `self.`/`self?.` dispatch, member calls on any receiver (`coordinator?.show()`, `delegate?.tap()`), and cross-`extension` calls all become `calls` edges — emitted as bare short names at walk time (extraction is parallel-per-file) and bound in a serial post-pass against a project-wide method index (unique name → the qualified `dir.Type.method`, ambiguous → the bare name still matched by short name, unmatched → dropped so stdlib/framework calls don't create phantom edges). A further post-pass resolves **inherited-method calls** — a subclass or protocol conformer calling a base-class / protocol-extension method — by walking the caller type's supertype chain (from the `implements` edges) nearest-first and rewriting the otherwise-dangling call target to the declaring ancestor's method fact (`dir.DataModel.runRequest`), so class/protocol hierarchies are traversable for impact analysis, dead-code, and the performs_io closure. `Foo()` → `instantiates`, constructor/property DI → `injects`, SwiftUI `View`→`ViewModel` → `depends_on`, and custom-operator usage (`a <- b`, but not stdlib operators like `+`/`<=`) → a `calls` edge to the operator. Top-level calls in `#!/usr/bin/swift` scripts are captured via a file-scope reference fact. Like the other AST extractors, it walks function/method bodies — and also computed-property getters and `willSet`/`didSet` observers — for the standard complexity metrics `cyclomatic`, `loop_depth`, `loop_count`, `calls_in_loop`, and `recursive_self`, which the enterprise `analyze_performance` tool consumes; syntactic `for`/`while`/`repeat-while` and iterator closures (`map`/`forEach`/`filter`/…) count as loops, but **constant-bounded loops do not add scaling depth** — a literal integer range (`for i in 0..<10`), a literal-bound `stride(...)`, or an iterator over an array/dictionary literal or ALL-CAPS constant (`STOP_CHARS.forEach`) runs a fixed number of times, so it never inflates a genuine O(n) into a false O(n²)/O(n³). A method whose body invokes a network/file I/O primitive (`URLSession`/`dataTask`/`.data(for:)`, Alamofire `request`/`download`/`upload`, `Data(contentsOf:)`) is tagged `io_direct`; a serial post-pass then propagates that up the call graph into a transitive `performs_io` prop — crossing ambiguous kept-bare member-call edges by expanding them through the method-name index (bounded) rather than mutating the graph — so the enterprise `analyze_performance` tool can flag a per-iteration network N+1 (a loop calling a method that transitively hits the network) even when the I/O sits behind wrapper layers. It is **iOS-aware**: SwiftUI views (`View`/`App`/`Scene`), UIKit (`UIViewController`/`UIView` subclasses), Combine view models (`ObservableObject`, `@Observable`), architectural roles (Repositories, Use Cases, Coordinators, Services, DI containers), and `@MainActor`. *Limitation:* the vendored tree-sitter-swift grammar cannot parse a few advanced constructs — notably a tuple-type metatype `(A, B).self` (e.g. `withTaskGroup(of: (UUID, Result<T, Error>).self)`) — and its error recovery then flattens the whole enclosing type to file scope, so that file's type node is lost and its methods surface as top-level `function` symbols (~3% of files in a large iOS codebase). Dead-code detection stays accurate on these — a member call whose method was flattened falls back to resolving against the top-level function of that name (a rare same-name collision biases toward a missed lead, never a false accusation) — but the type's coupling/impact edges are degraded for the affected file until the construct is removed or the grammar gains support.
528528

529529
**OpenAPI** scans for spec files independently of the main walker (so it finds them even when `*.yaml`/`*.json` are globally ignored), confirming candidates by an `openapi:`/`swagger:` key. It emits one `route` per operation enriched with method, `operationId`, summary, tags, and a spec back-reference; specs under an `openapi/client/` directory are marked `role:"client"`. Gateway extensions (`x-gateway-config`, `x-gateway-capabilities`) are parsed into props.
530530

internal/engine/cache.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,22 @@ import (
5050
// v34: Ruby constant-bounded iterators (`6.times`, `[…].each`, `%w[…]`, ALL-CAPS `CONST.each`) no longer add scaling loop_depth — they run a fixed number of times, so they no longer inflate a genuine O(n) into a false O(n²)/O(n³).
5151
// v35: constant-bounded-loop detection now unwraps trailing size-preserving chain methods (`[a,b].compact.all?`, `%w[…].map.each`), so a bounded literal/constant behind `.compact`/`.uniq`/`.map`/… is still recognized as bounded.
5252
// v36: Ruby module symbols now carry an `abstract` bool prop — true for mixins (modules that define instance methods) and ActiveSupport::Concerns, false for namespace/utility modules — so package-metrics abstractness (A) no longer counts Rails namespaces as abstractions. Bare-constant coupling resolution is also namespace-aware now.
53-
const cacheVersion = "v36"
53+
// v37: Swift resolves modules at the SPM/XcodeGen *target* level instead of by leaf directory — it parses project.yml (and its include: files) so each product target's files form one module (Sources/<Name>), and routes SPM package sources into their target module; symbol names, module facts, and inter-target dependency edges all change accordingly.
54+
// v38: Swift XcodeGen targets sharing one primary source root (e.g. the app plus its SwiftUI-preview and unit-test host targets) now collapse to a single module — the first target by sorted name owns the identity, shadow targets emit no duplicate module fact.
55+
// v39: Swift emits SymbolMethod (not SymbolFunc) for functions declared inside a type, and records member-call edges for any receiver — self?.method() cross-extension/closure dispatch, and lowercase/property-chain receivers (coordinator?.foo(), delegate?.bar()) — resolved against a project-wide method index in a serial post-pass (unique→qualified, ambiguous→bare short name, unmatched→dropped). Also credits the method in Type.foo() and suppresses the phantom `defer` call edge. Fixes coordinator-pattern dead-code false positives.
56+
// v40: Swift captures top-level/file-scope calls (bare `foo()` and `let x = foo()` in #!/usr/bin/swift scripts) as a KindFileRef fact so file-scope-invoked functions aren't flagged dead, and emits call edges for custom-operator usage (infix/prefix `custom_operator`, e.g. `a <- b`) resolved against operator overloads now added to the method index. (Standard-token operators like +/+=/^ are intentionally not tracked to avoid fan-in flooding.)
57+
// v41: Swift custom-operator usage now excludes stdlib operators that the scanner emits as `custom_operator` tokens (multi-char `<=`, `>=`, `??`, `..<`, …) — only genuinely user-defined operators (`<-`) get usage edges, so comparison overloads (Time.<=/>=) no longer collect spurious fan-in / false recursion.
58+
// v42: Swift resolves member calls to top-level functions (funcIndex fallback in resolveMethodCalls) so methods of a type whose body tree-sitter fails to parse — flattened to top-level functions, e.g. ImageUploadModel with a tuple-metatype `(T,U).self` — are no longer seen as dead; and property initializer/computed-getter calls now attach to the property as owner (essential inside `extension` blocks, which push no type owner), so a helper called only from an extension property is no longer flagged dead.
59+
// v43: the v42 property-owner change is now scoped to the ownerless (extension) case only — class/struct property init edges stay attributed to the enclosing type as before, avoiding a broad re-attribution of the coupling graph while still fixing extension-property call capture.
60+
// v44: Swift constant-bounded loops (`for i in 0..<10`, literal-bound `stride(...)`, and iterator closures over an array/dictionary literal or ALL-CAPS constant like `STOP_CHARS.forEach`) no longer add scaling loop_depth — they run a fixed number of times, so they stop inflating a genuine O(n) into a false O(n²)/O(n³) (Swift parity with Ruby v34/v35). Also: computed-property getters and willSet/didSet observers now emit complexity metrics (cyclomatic/loop_depth/loop_count/calls_in_loop/recursive_self), so a loop or per-iteration I/O inside `var x: [T] { … }` or `didSet { for … }` is visible to analyze_performance.
61+
// v45: Swift subscript access (`dict[key]`, `parameters["x"] = 1`) is no longer mistaken for a function call — the tree-sitter grammar models it as a call_expression whose `[...]` is a call_suffix, so a subscript on a local/property whose name collides with a method (`parameters["x"]` inside `func parameters()`) was recorded as a self-call, producing a phantom RelCalls edge and a false `recursive_self` flag. Subscript call-expressions are now detected by their `[` call-suffix delimiter and skipped (their receiver/key are still walked for real calls), removing false recursion findings and phantom call-graph edges.
62+
// v46: Swift `recursive_self` is now argument-label aware — a call that shares the enclosing function's bare name is flagged as recursion only when its argument labels match the function's parameter labels. This stops a call to a DIFFERENT overload/override/stdlib method of the same name being read as self-recursion: an `override func setSelected(_:animated:)` calling `super.setSelected(_:animated:)`, a `decode(key:)` extension calling stdlib `decode(_:forKey:)`, or `loadMore(completion:)` delegating to a sibling `loadMore(service:)`. Call edges are unchanged (dead-code/coupling unaffected); only the recursion signal is refined.
63+
// v47: Swift methods now carry an `io_direct` prop when their body invokes a network/file I/O primitive (URLSession/dataTask/.data(for:), Alamofire request/download/upload, Data(contentsOf:)/String(contentsOf:)), and a transitive `performs_io` prop computed by a serial closure that propagates io_direct up the call graph — crossing ambiguous kept-bare member-call edges by expanding them through the methodIndex candidate sets (bounded), without adding edges to the shared graph. Lets the enterprise analyzer flag a genuine per-iteration network N+1 (a loop calling a method that transitively hits the network) that was previously invisible because the I/O sat behind wrapper layers and ambiguous edges.
64+
// v48: Swift resolves inherited-method calls — a subclass (or protocol conformer) calling a base-class / protocol-extension method used to leave a dangling edge (`dir.runRequest`) because the callee isn't in the enclosing type's own method set. A serial post-pass now rewrites such dangling call targets to the declaring ancestor's method fact (`dir.DataModel.runRequest`) by walking the caller type's supertype chain (nearest-first), so class/protocol hierarchies are traversable for impact_analysis, dead-code, coupling, and the performs_io closure. Only dangling targets whose short name an ancestor declares are rewritten; already-resolved edges are untouched.
65+
// v49: Swift models XcodeGen test-bundle targets (bundle.unit-test/bundle.ui-testing) as one module each, so a test bundle's files (e.g. Tests/Core/**) collapse into a single module instead of exploding into per-leaf-directory modules; and every module fact now carries a normalized `module_role` prop (production/test/tooling/unknown) — derived from the XcodeGen target type, the SPM target vs testTarget call, or a path heuristic for leaf-directory fallback — so package-metrics and other analyses can measure the production population without re-parsing manifests.
66+
// v50: the `module_role` prop is now emitted by the Ruby extractor too (packwerk packages → production; leaf-directory modules → path heuristic), and the path heuristic was hoisted to facts.ModuleRoleForPath and broadened to common cross-language conventions (spec/test/tests + scripts/bin/fastlane/ci_scripts), so Ruby build-tooling modules (fastlane/, Scripts/) are classified as tooling rather than defaulting to the production population.
67+
// v51: Swift no longer emits type-reference-derived module→module dependency edges for files that belong to a resolved SPM/XcodeGen target — those files' cross-module deps are captured completely by their `import X` statements plus the declared target graph, whereas the type-reference pass resolved bare short names through a collision-prone global index (Swift namespaces nested types, so names like Event/State/Coordinator recur across targets) and fabricated impossible back-edges (a Foundation-level target "importing" a feature target) that produced a false module cycle. For loose Swift projects (leaf-directory fallback, no target graph) the pass still runs but now skips any type name defined in more than one module. Fixes the false Swift dependency cycle.
68+
const cacheVersion = "v51"
5469

5570
// extractorCache holds per-extractor facts keyed by a content hash of the files
5671
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/explainers/common/common.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,26 @@ func ResolveRelativeImport(sourceModule, target string) string {
6767
// store: module name -> list of internal modules it imports. External imports
6868
// are dropped and relative imports are normalized. Every declared module is
6969
// present as a key (with a possibly-empty edge list).
70+
//
71+
// Test-role modules (test bundles / spec trees, tagged module_role=test) are
72+
// excluded as both nodes and edge endpoints: they are not part of the production
73+
// architecture, and a test target normally imports the very module it exercises,
74+
// which would otherwise drag test bundles into cycle, layer-violation, and
75+
// depth findings (the classic "the cycle chain mixes Tests/ and Sources/"
76+
// artifact). This mirrors package-metrics, which already filters non-production
77+
// roles. Modules with an absent or non-test role are kept (consumers treat an
78+
// absent role as included).
7079
func BuildModuleGraph(store *facts.Store) map[string][]string {
7180
graph := make(map[string][]string)
7281

7382
modules := store.ByKind(facts.KindModule)
7483
moduleNames := make(map[string]bool)
84+
testModules := make(map[string]bool)
7585
for _, m := range modules {
86+
if role, _ := m.Props[facts.PropModuleRole].(string); role == facts.ModuleRoleTest {
87+
testModules[m.Name] = true
88+
continue
89+
}
7690
moduleNames[m.Name] = true
7791
if _, ok := graph[m.Name]; !ok {
7892
graph[m.Name] = nil
@@ -82,6 +96,9 @@ func BuildModuleGraph(store *facts.Store) map[string][]string {
8296
deps := store.ByKind(facts.KindDependency)
8397
for _, dep := range deps {
8498
sourceModule := FileDir(dep.File)
99+
if testModules[sourceModule] {
100+
continue // edge out of a test bundle — not production architecture
101+
}
85102

86103
for _, rel := range dep.Relations {
87104
if rel.Kind != facts.RelImports {

internal/extractors/rubyextractor/packwerk.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ func parsePackwerk(repoPath string) *packwerkInfo {
160160
"language": "ruby",
161161
"framework": "rails",
162162
"packwerk": true,
163+
"module_role": facts.ModuleRoleProduction,
163164
"enforce_dependencies": pkgCfg.EnforceDependencies,
164165
"enforce_privacy": pkgCfg.EnforcePrivacy,
165166
}

internal/extractors/rubyextractor/ruby.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ func (e *RubyExtractor) Extract(ctx context.Context, repoPath string, files []st
8484
continue
8585
}
8686
props := map[string]any{
87-
"language": "ruby",
87+
"language": "ruby",
88+
"module_role": facts.ModuleRoleForPath(dir),
8889
}
8990
if isRails {
9091
props["framework"] = "rails"

0 commit comments

Comments
 (0)