Browse Source

feat(impact): Ruby mixin coverage — link include/extend/prepend to the module

Ruby's primary composition mechanism is the mixin (`include`/`extend`/`prepend`
a module), but these parse as a bare `call` to a method named `include` with the
module as a constant argument, so they created no edge — a concern module mixed
into a class recorded no dependent unless its methods happened to be called
in-repo. On a concern-heavy codebase (Rails) that was a large share of modules,
and ActiveRecord is built almost entirely on `include`d concerns.

Detect `include`/`extend`/`prepend` (bare, no receiver) in the Ruby extractor's
visitNode hook and emit an `implements` edge from the enclosing class/module to
each module argument (`constant` or `scope_resolution`), so editing a concern
surfaces every class that mixes it in. Skips `extend self` / dynamic args, and
`arr.include?(x)` (has a receiver) is unaffected.

Measured (fair cross-file dependent coverage, symbol-bearing source files):
rails/activerecord 84.8% → 93.0%, sidekiq 71.0% → 76.8%. Node count stable
(edges only). Residual is genuine frontiers: classes instantiated by class-string
`constantize` (associations, Arel nodes), generators, version files, and
config/`require`-loaded components. Full suite green; Ruby-only change.

(Follow-up: resolve `require`/`require_relative` to files — helps explicit-require
gems like sidekiq; Rails autoloads so it benefits less.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 weeks ago
parent
commit
44fb978597
3 changed files with 97 additions and 0 deletions
  1. 1 0
      CHANGELOG.md
  2. 60 0
      __tests__/extraction.test.ts
  3. 36 0
      src/extraction/languages/ruby.ts

+ 1 - 0
CHANGELOG.md

@@ -24,6 +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)
 - 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.

+ 60 - 0
__tests__/extraction.test.ts

@@ -3445,6 +3445,66 @@ class Service {
   });
 });
 
+describe('Ruby mixins (include/extend/prepend)', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('links include/extend/prepend to the mixed-in module across files', async () => {
+    const lib = path.join(tempDir, 'lib');
+    fs.mkdirSync(lib, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(lib, 'concerns.rb'),
+      `module Trackable
+  def track; end
+end
+
+module Cacheable
+  def cache; end
+end
+
+module Loggable
+  def log; end
+end
+`
+    );
+    fs.writeFileSync(
+      path.join(lib, 'model.rb'),
+      `class Model
+  include Trackable
+  prepend Cacheable
+  extend Loggable
+end
+`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const model = cg.getNodesByKind('class').find((n) => n.name === 'Model');
+    expect(model).toBeDefined();
+
+    // All three mixin forms create an `implements` edge Model → module, so
+    // editing a concern surfaces every class that mixes it in (across files).
+    for (const moduleName of ['Trackable', 'Cacheable', 'Loggable']) {
+      const mod = cg.getNodesByKind('module').find((n) => n.name === moduleName);
+      expect(mod, moduleName).toBeDefined();
+      const impacted = [...cg.getImpactRadius(mod!.id, 3).nodes.values()].map((n) => n.name);
+      expect(impacted, `${moduleName} should be depended on by Model`).toContain('Model');
+    }
+  });
+});
+
 describe('Full Indexing', () => {
   let tempDir: string;
 

+ 36 - 0
src/extraction/languages/ruby.ts

@@ -17,6 +17,42 @@ export const rubyExtractor: LanguageExtractor = {
   bodyField: 'body',
   paramsField: 'parameters',
   visitNode: (node, ctx) => {
+    // Ruby mixins: `include Mod`, `extend Mod`, `prepend Mod[, Other]` — the
+    // primary composition mechanism (ActiveSupport concerns, Comparable, …).
+    // These parse as a bare `call` to `include`/`extend`/`prepend` with the
+    // module(s) as constant arguments, so without special handling they'd be
+    // mis-extracted as a call to a method named "include" and the module would
+    // record no dependent — even though it's mixed into a class. Emit an
+    // `implements` edge (enclosing class/module → mixed-in module), so editing a
+    // concern surfaces every class that includes it.
+    if (node.type === 'call' && !node.childForFieldName('receiver')) {
+      const method = node.childForFieldName('method');
+      const mname = method?.text;
+      if (mname === 'include' || mname === 'extend' || mname === 'prepend') {
+        const parentId = ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
+        const args = node.childForFieldName('arguments')
+          ?? node.namedChildren.find((c: SyntaxNode) => c.type === 'argument_list');
+        if (parentId && args) {
+          for (let i = 0; i < args.namedChildCount; i++) {
+            const arg = args.namedChild(i);
+            // `Mod` is `constant`, `Foo::Bar` is `scope_resolution`. Skip
+            // `extend self` / dynamic args (`include foo()`).
+            if (arg && (arg.type === 'constant' || arg.type === 'scope_resolution')) {
+              ctx.addUnresolvedReference({
+                fromNodeId: parentId,
+                referenceName: getNodeText(arg, ctx.source),
+                referenceKind: 'implements',
+                filePath: ctx.filePath,
+                line: node.startPosition.row + 1,
+                column: node.startPosition.column,
+              });
+            }
+          }
+          return true; // handled — don't also extract as a call to "include"
+        }
+      }
+    }
+
     if (node.type !== 'module') return false;
 
     const nameNode = node.childForFieldName('name');