Преглед на файлове

feat(impact): Ruby require/require_relative resolution — link to the required file

Ruby's other half of cross-file dependency (besides mixins) is `require` — but
require imports created an import node that never resolved to the required file,
so a file pulled in only by `require` (config-loaded components like Sidekiq's
BasicFetch/systemd, or any gem that doesn't rely on autoloading) recorded no
dependent. Mixins alone left Sidekiq at 76.8%.

Emit an `imports` ref for `require "lib/foo"` (load-path — suffix-matched against
the file path, the same mechanism template imports use) and `require_relative
"../foo"` (resolved against the requiring file's directory). Bare gem/stdlib
requires (`require "json"`, no slash) are skipped as external. The ref resolves
to the required FILE node, so the requiring file → required file dependency is
recorded even when the required file's class is reached only dynamically.

Measured (fair cross-file dependent coverage, symbol-bearing source files):
sidekiq 76.8% → 100.0%, rails/activerecord 93.0% → 96.8% (Rails autoloads but
still has explicit requires for sub-components). Node count stable (the import
nodes already existed; this only resolves them). Full suite green; Ruby-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry преди 2 седмици
родител
ревизия
5bccab6
променени са 3 файла, в които са добавени 93 реда и са изтрити 1 реда
  1. 1 1
      CHANGELOG.md
  2. 46 0
      __tests__/extraction.test.ts
  3. 46 0
      src/extraction/tree-sitter.ts

+ 1 - 1
CHANGELOG.md

@@ -24,7 +24,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Kotlin Multiplatform `expect`/`actual` declarations are now connected. A platform implementation — `actual fun`, `actual class`, or an `actual typealias` in a `jvm` / `native` / `js` / `wasm` source set — is linked to the common `expect` declaration it fulfills (including the common case of an `expect class` fulfilled by an `actual typealias`). Previously a caller in common code resolved to the `expect` declaration, so every platform `actual` looked like it had no dependents and editing one showed an empty blast radius; now changing a platform implementation surfaces the common API and everything that uses it. (Kotlin)
 - Scala impact and `codegraph affected` now connect the type graph that typeclass-style code is built on. A parameterized supertype (`trait Monoid[A] extends Semigroup[A] with Serializable`) now links to each parent; a type used in a `val`/`def` signature, as a type argument, or as a context bound (`def f[A: Monoid]`) — including the trailing implicit parameter list (`(implicit M: Monoid[A])`) where typeclass instances are passed — now records a dependency; and `new T[...] { … }` counts as an instantiation. Previously Scala linked only plain calls and bare, non-generic supertypes, so a trait extended with type parameters, used as a type, or required as an implicit looked like nothing depended on it — which on a typeclass-heavy codebase (cats, algebra) was most of the graph. (Scala)
 - PHP impact and `codegraph affected` now understand namespaces and `use` imports. Classes are tracked by their namespaced name, so the many same-named classes a framework defines (Laravel has 7+ `Factory` interfaces, several `Dispatcher`s, across namespaces) are told apart instead of collapsing into one arbitrary match. A `use App\Contracts\Cache\Factory;` now records a dependency on exactly that class — so a contract or interface that's imported and constructor-injected (the dependency-injection pattern) is no longer reported as having no dependents — and parameter, property, and return type-hints are recorded too. Previously PHP ignored namespaces entirely and linked only calls, `new`, and inheritance. (PHP)
-- Ruby impact and `codegraph affected` now follow mixins. `include`, `extend`, and `prepend` of a module — Ruby's primary composition mechanism (ActiveSupport concerns, `Comparable`, `Enumerable`) — now record a dependency on that module, so editing a concern surfaces every class that mixes it in. Previously these were read as a call to a method named `include`, so a module mixed into a class but whose methods are exercised only by application code looked like nothing depended on it — which on a concern-heavy codebase (Rails) was a large share of the modules. (Ruby)
+- Ruby impact and `codegraph affected` now follow mixins and `require`s. `include`, `extend`, and `prepend` of a module — Ruby's primary composition mechanism (ActiveSupport concerns, `Comparable`, `Enumerable`) — now record a dependency on that module, so editing a concern surfaces every class that mixes it in; previously these were read as a call to a method named `include`, so a module whose methods are exercised only by application code looked like nothing depended on it. And `require "lib/foo"` / `require_relative "../foo"` now link to the required file, so a file pulled in only by a `require` (config-loaded components, gems that don't autoload) is no longer reported as having no dependents. Together these took a typical gem from ~71% of its files showing real dependents to ~100%. (Ruby)
 - C# `record` types are now indexed. `record`, `record class`, and `record struct` declarations (everywhere in modern C# — DTOs, value objects, CQRS messages, MediatR notifications) were previously skipped entirely, so every reference, generic type argument (`IEnumerable<MyRecord>`), and `new MyRecord(...)` pointed at nothing and the file defining a record looked like it had no callers or dependents. (#237)
 - Go interfaces now connect to their implementations. Go has no `implements` keyword — a type satisfies an interface just by having the right methods — so CodeGraph now infers that link: a struct whose methods cover an interface's method set is treated as implementing it, and a call through the interface (`API.Marshal(...)`) reaches every concrete implementation. This means a type used only via an interface (the common plugin/strategy pattern — e.g. JSON-codec or renderer implementations selected at runtime) is no longer reported as having no callers or no dependents, and impact now flows from an interface method to the implementations behind it. (#584)
 - Go now records cross-package struct creation. A composite literal like `render.XML{...}` or `pkga.Widget{...}` — including ones registered in a package-level `var registry = map[string]R{...}` — now links to the package that defines the type. Cross-package function calls and type references already resolved; this closes struct instantiation, so a package whose types are only *constructed* elsewhere (a common pattern for interface implementations) is no longer reported as having no dependents. Go type conversions such as `(*Wrapped)(x)` now link to the converted-to type as well.

+ 46 - 0
__tests__/extraction.test.ts

@@ -3503,6 +3503,52 @@ end
       expect(impacted, `${moduleName} should be depended on by Model`).toContain('Model');
     }
   });
+
+  it('resolves require / require_relative to the required file', async () => {
+    const lib = path.join(tempDir, 'lib');
+    fs.mkdirSync(path.join(lib, 'app'), { recursive: true });
+
+    // A leaf file whose class is referenced only dynamically — so without
+    // require resolution it would look like nothing depends on it.
+    fs.writeFileSync(
+      path.join(lib, 'app', 'fetcher.rb'),
+      `module App
+  class Fetcher
+    def fetch; end
+  end
+end
+`
+    );
+    // Pulled in by a load-path `require` …
+    fs.writeFileSync(
+      path.join(lib, 'app', 'worker.rb'),
+      `require "app/fetcher"
+
+module App
+  class Worker; end
+end
+`
+    );
+    // … and a sibling pulled in by `require_relative`.
+    fs.writeFileSync(
+      path.join(lib, 'app', 'boot.rb'),
+      `require_relative "fetcher"
+`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    // The require edges target fetcher.rb's FILE node. Editing it should reach
+    // BOTH the load-path requirer (worker.rb) and the require_relative one
+    // (boot.rb) — without require resolution its file would have no dependents.
+    const fetcher = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('app/fetcher.rb'));
+    expect(fetcher, 'fetcher.rb indexed').toBeDefined();
+    const reached = [...cg.getImpactRadius(fetcher!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(reached.some((p) => p.endsWith('app/worker.rb'))).toBe(true);
+    expect(reached.some((p) => p.endsWith('app/boot.rb'))).toBe(true);
+  });
 });
 
 describe('Full Indexing', () => {

+ 46 - 0
src/extraction/tree-sitter.ts

@@ -1779,6 +1779,13 @@ export class TreeSitterExtractor {
           const parentId = this.nodeStack[this.nodeStack.length - 1];
           if (parentId) this.emitPhpUseRefs(node, parentId);
         }
+        // Ruby `require "lib/foo"` / `require_relative "../foo"` — resolve to the
+        // required FILE so a file pulled in only by `require` (config-loaded
+        // components, gems that don't autoload) records a cross-file dependency.
+        if (this.language === 'ruby' && node.type === 'call') {
+          const parentId = this.nodeStack[this.nodeStack.length - 1];
+          if (parentId) this.emitRubyRequireRefs(node, parentId);
+        }
         return;
       }
       // Hook returned null — fall through to multi-import inline handlers only
@@ -2048,6 +2055,45 @@ export class TreeSitterExtractor {
     if (qn) this.pushPhpUseRef(getNodeText(qn, this.source), fromNodeId, node);
   }
 
+  /**
+   * Ruby `require`/`require_relative` → an `imports` ref to the required FILE.
+   * `require "sidekiq/fetch"` is load-path-relative (matched by file-path suffix
+   * via {@link matchByFilePath}); `require_relative "../foo"` is resolved against
+   * this file's directory. Bare gem/stdlib requires (`require "json"`, no slash)
+   * are skipped — they're external. The path form (a `/` + `.rb`) makes the ref
+   * resolve to the file node, so a file pulled in only by `require` — not by a
+   * resolved constant/call — still records a cross-file dependency.
+   */
+  private emitRubyRequireRefs(node: SyntaxNode, fromNodeId: string): void {
+    const method = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
+    const mname = method ? getNodeText(method, this.source) : '';
+    if (mname !== 'require' && mname !== 'require_relative') return;
+    const argList = node.namedChildren.find((c: SyntaxNode) => c.type === 'argument_list');
+    const str = argList?.namedChildren.find((c: SyntaxNode) => c.type === 'string');
+    const content = str?.namedChildren.find((c: SyntaxNode) => c.type === 'string_content');
+    if (!content) return;
+    const req = getNodeText(content, this.source).trim();
+    if (!req) return;
+
+    let refPath: string;
+    if (mname === 'require_relative') {
+      const slash = this.filePath.lastIndexOf('/');
+      const dir = slash >= 0 ? this.filePath.slice(0, slash) : '';
+      refPath = path.posix.normalize(dir ? `${dir}/${req}` : req);
+    } else {
+      refPath = req; // load-path require — suffix-matched against the file path
+    }
+    if (!refPath.includes('/')) return; // bare gem/stdlib require — external
+    if (!refPath.endsWith('.rb')) refPath += '.rb';
+    this.unresolvedReferences.push({
+      fromNodeId,
+      referenceName: refPath,
+      referenceKind: 'imports',
+      line: node.startPosition.row + 1,
+      column: node.startPosition.column,
+    });
+  }
+
   /** Convert a PHP FQN `Foo\Bar\Baz` to the stored `Foo\Bar::Baz` and emit an `imports` ref. */
   private pushPhpUseRef(fqn: string, fromNodeId: string, node: SyntaxNode): void {
     const clean = fqn.replace(/^\\/, '');