Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ Each extractor is detected by characteristic project files and then parses what

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

**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`.
**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.

**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.

Expand Down
17 changes: 16 additions & 1 deletion internal/engine/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,22 @@ import (
// 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³).
// 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.
// 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.
const cacheVersion = "v36"
// 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.
// 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.
// 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.
// 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.)
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
const cacheVersion = "v51"

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
17 changes: 17 additions & 0 deletions internal/explainers/common/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,26 @@ func ResolveRelativeImport(sourceModule, target string) string {
// store: module name -> list of internal modules it imports. External imports
// are dropped and relative imports are normalized. Every declared module is
// present as a key (with a possibly-empty edge list).
//
// Test-role modules (test bundles / spec trees, tagged module_role=test) are
// excluded as both nodes and edge endpoints: they are not part of the production
// architecture, and a test target normally imports the very module it exercises,
// which would otherwise drag test bundles into cycle, layer-violation, and
// depth findings (the classic "the cycle chain mixes Tests/ and Sources/"
// artifact). This mirrors package-metrics, which already filters non-production
// roles. Modules with an absent or non-test role are kept (consumers treat an
// absent role as included).
func BuildModuleGraph(store *facts.Store) map[string][]string {
graph := make(map[string][]string)

modules := store.ByKind(facts.KindModule)
moduleNames := make(map[string]bool)
testModules := make(map[string]bool)
for _, m := range modules {
if role, _ := m.Props[facts.PropModuleRole].(string); role == facts.ModuleRoleTest {
testModules[m.Name] = true
continue
}
moduleNames[m.Name] = true
if _, ok := graph[m.Name]; !ok {
graph[m.Name] = nil
Expand All @@ -82,6 +96,9 @@ func BuildModuleGraph(store *facts.Store) map[string][]string {
deps := store.ByKind(facts.KindDependency)
for _, dep := range deps {
sourceModule := FileDir(dep.File)
if testModules[sourceModule] {
continue // edge out of a test bundle — not production architecture
}

for _, rel := range dep.Relations {
if rel.Kind != facts.RelImports {
Expand Down
1 change: 1 addition & 0 deletions internal/extractors/rubyextractor/packwerk.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func parsePackwerk(repoPath string) *packwerkInfo {
"language": "ruby",
"framework": "rails",
"packwerk": true,
"module_role": facts.ModuleRoleProduction,
"enforce_dependencies": pkgCfg.EnforceDependencies,
"enforce_privacy": pkgCfg.EnforcePrivacy,
}
Expand Down
3 changes: 2 additions & 1 deletion internal/extractors/rubyextractor/ruby.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ func (e *RubyExtractor) Extract(ctx context.Context, repoPath string, files []st
continue
}
props := map[string]any{
"language": "ruby",
"language": "ruby",
"module_role": facts.ModuleRoleForPath(dir),
}
if isRails {
props["framework"] = "rails"
Expand Down
Loading
Loading