mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(resolution): resolve constructor-expression receivers (#2708) `Service(db).do_work()` emitted no CALLS edge, so the caller was missing from `impact(direction: "upstream")` and `context()` while the two-step spelling of the same call (`s = Service(db)` then `s.do_work()`) resolved. The receiver reaches `resolveCompoundReceiverClass` intact — Case 0 in `receiver-bound-calls` routes it there because the text contains `(`. The free-call branch then only knew one shape: a function whose return-type binding names a class. A class has no return-type binding, so `Service` resolved to nothing and the member call was dropped. Handle the constructor shape: in languages that construct without a `new` keyword (Python, Kotlin, Swift, Scala) a free call naming a class IS a constructor call, so the expression's type is that class. The existing return-type path still runs first and wins, keeping this strictly additive — `new`-keyword languages never reach the new line because their receiver text keeps the keyword (`new Service(db)`), which matches no class binding. Verified on the issue's 4-file repro: `route_inline` now emits `CALLS → Service.do_work` and `impactedCount` goes 1 → 2. Note the issue's second ask — degrading `epistemic` to `lower-bound` when a receiver goes unresolved — is NOT addressed here. `computeEpistemicBoundary` keys only on the target's own heritage edges and runs at query time against the index, while unresolved references live in an in-memory `resolutionOutcomes[]` that is never persisted. That needs unresolved-receiver counts in the index first, so it is left for a follow-up. Tests: new `python-inline-constructor-receiver` fixture plus three integration cases (inline resolves, two-step still resolves, no cross-class fan-out). Two of the three fail without the source change. Full `test/integration/resolvers` suite passes (2928 tests) — the fix is shared across every language, so no-regression coverage matters more than the new cases. Python captures golden regenerated: additions only, no existing digest changed, confirming capture output is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * refactor(resolution): state the construction rule once, cover every spelling (#2708) The first commit fixed `Service(db).do_work()` by special-casing a bare class-name callee inside the free-call branch of the compound receiver resolver. That was the right rule in the wrong place: it covered one surface syntax out of three, and asserted rather than declared which languages it applied to. Probing the same shape across languages showed the bug is wider: | spelling | languages | dropped before? | |------------------------|--------------------|-----------------| | `Service(db).m()` | Python | yes | | `new Service(db).m()` | JS/TS, Java, C# | yes | | `Service.new.m()` | Ruby | yes | | both forms | PHP, Swift, Dart, | no — already | | | Kotlin | resolved | So the rule is stated once — "constructing a class yields an instance of that class" — and the per-language surface syntax is declared through a new `ScopeResolver.constructionSyntax` hook, matching how this file already gates language-varying behaviour (`stripReceiverCastExpressions`, `hoistTypeBindingsToModule`). Shared pipeline code names no language. - `bare: true` — Python - `keyword: 'new'` — JS/TS, Java, C# - `selector: 'new'` — Ruby, including the parenthesis-less `Service.new` spelling that reaches the chain walker rather than the call branch Opt-in is per-language for two reasons. Correctness: `bare` would mistype `stat(&st).field` in C, where a struct and a function may share a name. Evidence: PHP, Swift, Dart and Kotlin resolve this shape already, so they stay unwired instead of carrying a declaration that changes nothing — each verified by diffing analyzer output between builds with and without the change, not assumed. The keyword gate also keeps a bare factory call honest: in a `new` language, `makeOther(db).doWork()` still resolves through the factory's return type and is never read as constructing a same-named class. Tests: TypeScript fixture (inline `new`, a plain `.js` file for the javascript provider, two-step, and the factory guard) and a Ruby fixture (`Service.new` with and without an argument list, plus two-step). With the source change stashed, the inline cases fail and the factory/two-step cases still pass. The Python cases from the first commit are unchanged. No Kotlin fixture: its cases passed without the change, so they would document coverage this commit does not provide. Full `test/integration/resolvers` + `test/unit/scope-resolution`: 4234 passed, 1 skipped. Ruby captures golden regenerated — additions only, no existing digest changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * fix(resolution): only treat a construction selector as construction on the class itself (#2708) The `selector: 'new'` rule fired on any receiver whose type was class-like, which is true both when the receiver IS the class constant (`Factory.new`) and when it is a value of that class (`factory.new`). `isClassLike(...)` cannot tell those apart, so an instance receiver took the construction path too and skipped the member lookup that should have run. That replaced a CORRECT edge with a wrong one. Measured against the base build on a class defining an instance method `new` returning a `Product`: factory = Factory.new; factory.new.run before this PR: Product#run (correct) after this PR: Factory#run (wrong) Track whether resolution currently sits on the class constant or on a value of that class, and apply the selector rule only to the former. The head of a chain is a class constant only when it resolved straight to a class binding rather than through a typeBinding; every hop past it yields a value, so the flag clears. The `obj.method()` branch derives the same fact from whether `objExpr` is a bare name resolving to that class. `Factory.new.run` keeps the behaviour this PR introduced (Factory#run), which is itself a fix over the base build's Product#run. KNOWN LIMITATION, now documented on the contract field and asserted by a test so a future change to it is deliberate: a class-level override (`def self.new` returning another type) is still read as construction. The scope model records no staticness per member, so `def new` and `def self.new` are indistinguishable at this layer; separating them needs the language provider to record staticness first. An earlier attempt to use `TypeRef.source` as a proxy was abandoned after tracing showed Ruby records body-inferred return types as `return-annotation` too, so it does not discriminate. Tests: `ruby-construction-selector` fixture pins all three shapes — class constant, instance receiver, and the documented class-level-override limitation. Ruby resolver suites: 185 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * fix(resolution): resolve generic construction receivers (#2708) `new Box<string>().unwrap()` reached the class lookup as `Box<string>`, which names no class binding, so the member edge was still dropped while the non-generic spelling resolved. `new Foo<T>()` is ordinary in all three keyword-wired languages, so the fix covered a materially narrower slice of real code than intended. Retry the lookup on the base name via `stripTemplateArguments` — the same normalization `resolveClassBindingForName` already applies to typed receivers in the sibling `receiver-bound-calls` pass. The exact-name lookup still runs first, so a class whose name legitimately contains `<` is unaffected. Measured on the probe that first showed the gap: before: | viaGeneric | Class:src/box.ts:Box | (construction edge only) after: | viaGeneric | Method:src/box.ts:Box.get#0 | (member edge resolved) Tests: `viaGenericCtor` added to the typescript-inline-constructor-receiver fixture, asserting both the target file and that the resolved id is `Box`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * fix(resolution): resolve construction in the chain-head position (#2708) `new Service(db).inner.deep()` emitted only the construction edge. The chain walker seeds its starting class from the head segment, which arrives as `new Service(db)` and reduces via `stripCallParens` to `new Service` — no binding and no class of that name, so the walk was never seeded and every segment after it resolved to nothing. Seed the head through the same construction rule the call branch already uses. A constructed value is an instance, so the class-constant flag from the previous commit correctly stays false — `new Factory().new` does not get the selector treatment. The gap was asymmetric across the languages this PR wires: Python's bare form strips to a plain `Service` and was already seeded, so only the keyword languages were affected. Tests: `viaChainHead` added to the typescript-inline-constructor-receiver fixture. Note the fixture annotates `readonly inner: Inner` explicitly — with an unannotated initializer the walk stops at the field, which is field-type inference and a separate concern from head seeding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * fix(resolution): match the construction keyword by token, not by one space (#2708) The keyword form was matched with `startsWith(`${keyword} `)`, so only a single space separated `new` from the type. Any other trivia the source used — a tab, a line break — failed the match and the member-call edge was lost. Match the keyword as a whole token followed by one or more whitespace characters instead. `newService()` still fails the match, which is the point: it is an ordinary call, not a construction, and must keep resolving through its own return type. The keyword is escaped before it enters the pattern. It comes from a language provider rather than from user input, but a keyword containing a regex metacharacter would otherwise build a silently wrong pattern. Tests: tab-separated and newline-separated `new` added to the typescript-inline-constructor-receiver fixture. Note these cases only survive because `gitnexus/test/fixtures/` is listed in the repo-root `.prettierignore` — running prettier from inside `gitnexus/` does not pick that file up and normalizes the tab away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * fix(resolution): resolve qualified construction callees (#2708) `new ns.Service().doWork()` emitted only the construction edge. The call branch splits the callee at its last `.` before construction is considered, so a qualified type name was routed into `obj.method()` resolution as if `ns` were a receiver and `Service` a member. A keyword-marked expression is never a member call, so resolve it as construction before the split. The callee lookup now also handles a dotted name: an unambiguous `qualifiedNames` match first, then the trailing simple name, mirroring how receiver resolution elsewhere in this pass degrades. Measured: before: | viaQualified | Class:src/svc.ts:Service | (construction only) after: | viaQualified | Method:src/svc.ts:Service.doWork#0 | Bare-form qualified construction (Python `models.User(db).save()`) is NOT addressed here: that shape currently emits no edges at all, including no construction edge, so it is a namespace-import resolution gap upstream of this pass rather than a construction-typing one. Tests: `viaQualifiedCtor` added to the typescript-inline-constructor-receiver fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * fix(java): drop the unreachable constructionSyntax declaration (#2708) Java was wired `{ keyword: 'new' }`, and the PR described it as one of the languages that needed the fix. Measuring both ways shows it never did: Java resolves `new Svc().doWork()` identically with and without the change, because `java/captures.ts` (#2564) already rewrites an `object_creation_expression` receiver to the constructed type's simple name, so the raw `new Svc()` text never reaches this resolver. The decisive evidence is generics: Java resolves `new Box<User>().doWork()`, which the keyword path could not do before the template-argument fix earlier in this series — the resolution demonstrably comes from the capture rewrite, not from here. Removing the declaration rather than leaving it as defensive configuration: an unreachable per-language opt-in reads as coverage that does not exist, and the contract now records why Java is excluded so the omission is not mistaken for an oversight. Verified after removal: the Java probe still resolves both the inline and two-step spellings, and the Java resolver suites pass (252 passed, 1 skipped). An earlier coordinator measurement in this review claimed Java WAS broken on base; that comparison was invalid (the "without fix" build had not been rebuilt). Corrected here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * refactor(resolution): state the selector rule once and derive its option type (#2708) Two follow-ups from review, no behaviour change (643 resolver tests pass unchanged before and after): The `Class.new` selector rule was written out twice — in the `obj.method()` branch and again in the chain walker — against differently named locals, while the construction helper's own doc comment claimed the rule was stated in exactly one place. Both sites ask the identical question, so they now call one `isConstructionSelectorHop` predicate, and the doc comment says what is actually true. `ResolveCompoundReceiverOptions.constructionSyntax` re-declared the contract's object shape by hand. It was the file's first object-shaped duplicate, and because the value arrives as a non-literal variable, TypeScript's excess property check would not fire: a sub-field added to the contract later would type-check and then be silently ignored here. It is now derived with `ScopeResolver['constructionSyntax']`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * test(resolution): cover the C# construction path and pin the wiring inventory (#2708) Three coverage gaps from review, no behaviour change. C# had no fixture despite being the only keyword-wired language whose behaviour genuinely depends on the construction rule — measured absent on base and present on head. `csharp-inline-constructor-receiver` covers the inline spelling, the two-step spelling, and a static factory that must keep resolving through its return type rather than being read as construction. The TypeScript two-step assertion checked only `toContain('Service')`, and the same fixture defines `LegacyService` — `'LegacyService'.includes('Service')` is true, so the assertion could not distinguish the two targets. It now pins `targetFilePath` the way its sibling assertions already do. Nothing guarded the deliberate opt-in set, so an accidental wiring of a language that already resolves the shape, or a silent loss of one that needs it, would pass the whole suite. `construction-syntax-wiring.test.ts` pins the inventory in both directions: exactly which languages declare `constructionSyntax` and with which spelling, and that java/php/swift/dart/ kotlin stay unwired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * chore(storage): bump INCREMENTAL_SCHEMA_VERSION to 23 for the #2708 edge changes This series changes which CALLS edges are emitted for source whose CONTENT has not changed — inline constructor receivers that previously emitted nothing now resolve, and the Ruby selector fix moves one edge back to the member it always belonged to. That is precisely the class of change the version-history block in this file requires a bump for, and the reuse gate is a strict equality on the persisted stamp. Without it, every existing v22 index passes the gate on the next `analyze` — or is served by the same-commit "already up to date" fast path — and keeps returning the pre-fix graph for unchanged files. `impact(direction: "upstream")` and `context()` would go on omitting the very callers #2708 is about, with no warning, until something unrelated forced a full re-analyze. The fix would have shipped without reaching anyone who already had an index. Precedent is unbroken across the recent resolution PRs: #2723 → v22, #2699 → v21, #2695 → v20, #2563 → v14, each with its own rationale paragraph. This adds v23 in the same form. The pinned assertion in call-summary-schema-version.test.ts moves with it, as that test documents it is designed to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * chore(bench): re-baseline the fixture-corpus fingerprints for #2708 Both bench harnesses fingerprint an entire fixture corpus by directory prefix (`bench/python-scope/measure.mjs:38`, `bench/scope-capture/measure.mjs:76`), so every fixture directory this series adds moves a committed baseline. Neither script writes the baseline itself — running without `--check` only prints, and the file is edited deliberately, which is what its own comment asks for. Regenerated, last in the series so the fixture set was final: bench/python-scope/baseline-fingerprint.txt 36e29abc… -> f120df92… bench/scope-capture/baselines.json ruby 070e4e11… -> fea3edf8… typescript 281e9548… -> cad25be9… csharp e05dc274… -> 05a85bae… CI only ever reported the python drift, because the benchmarks job runs the python step first and aborts there; the cross-language step never ran. Both were verified locally after the update: [measure --check] PASS (capture fingerprint + scaling) [import-target-fingerprint --check] PASS (resolver fingerprint) [scope-capture --check] PASS (15 languages) The `csharp` and `ruby` entries moved because of the fixtures added earlier in this series, not the original ones — a reminder that this baseline moves with any fixture addition, not just the one that first triggered it. Captures goldens regenerated alongside (csharp, ruby); both additive only, no existing digest changed. The python golden did not move: no `python-*` fixture was added after its last regeneration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqnsJK3Cnbu3bjdZbzgMMP * Update tests for passesReuseGate function --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1861 lines
76 KiB
TypeScript
1861 lines
76 KiB
TypeScript
/**
|
|
* Ruby: require_relative imports, include heritage (mixins), attr_* properties,
|
|
* calls, member calls, ambiguous disambiguation, local shadow,
|
|
* constructor-inferred type resolution
|
|
*/
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
import path from 'path';
|
|
import {
|
|
FIXTURES,
|
|
CROSS_FILE_FIXTURES,
|
|
getRelationships,
|
|
getNodesByLabel,
|
|
getNodesByLabelFull,
|
|
findDanglingEdges,
|
|
edgeSet,
|
|
runPipelineFromRepo,
|
|
type PipelineResult,
|
|
} from './helpers.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Heritage: require_relative imports + include heritage + attr_* properties + calls
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby require_relative, heritage & property resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-app'), () => {});
|
|
}, 60000);
|
|
|
|
// --- Node detection ---
|
|
|
|
it('detects 3 classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']);
|
|
});
|
|
|
|
it('detects 3 modules (labeled as Trait for class-like registry lookup)', () => {
|
|
// Ruby `module` declarations are relabeled to `Trait` during ingestion so
|
|
// they participate in `lookupClassByName` and scope-resolution's heritage
|
|
// resolution. This is the single source of truth for Ruby module detection
|
|
// in the graph.
|
|
expect(getNodesByLabel(result, 'Trait')).toEqual(['Cacheable', 'Loggable', 'Serializable']);
|
|
expect(getNodesByLabel(result, 'Module')).toEqual([]);
|
|
});
|
|
|
|
it('detects methods on classes and modules', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('persist');
|
|
expect(methods).toContain('run_validations');
|
|
expect(methods).toContain('greet_user');
|
|
expect(methods).toContain('serialize_data');
|
|
expect(methods).toContain('create_user');
|
|
});
|
|
|
|
it('detects singleton method (def self.factory) as Method', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('factory');
|
|
});
|
|
|
|
it('emits CALLS from singleton method: factory → run_validations', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(e) => e.source === 'factory' && e.target === 'run_validations',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0].sourceLabel).toBe('Method');
|
|
});
|
|
|
|
// --- Import resolution via require_relative ---
|
|
|
|
it('resolves 5 require_relative imports to IMPORTS edges', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const importEdges = edgeSet(imports);
|
|
expect(importEdges).toContain('user.rb → base_model.rb');
|
|
expect(importEdges).toContain('user.rb → serializable.rb');
|
|
expect(importEdges).toContain('user.rb → loggable.rb');
|
|
expect(importEdges).toContain('user.rb → cacheable.rb');
|
|
expect(importEdges).toContain('service.rb → user.rb');
|
|
});
|
|
|
|
it('resolves bare require to IMPORTS edge', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const bareRequire = imports.find(
|
|
(e) =>
|
|
e.sourceFilePath.includes('base_model.rb') && e.targetFilePath.includes('serializable.rb'),
|
|
);
|
|
expect(bareRequire).toBeDefined();
|
|
});
|
|
|
|
// --- Heritage: include → IMPLEMENTS ---
|
|
|
|
it('emits IMPLEMENTS edge for include Serializable with reason "include"', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
const edge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.rel.reason).toBe('include');
|
|
});
|
|
|
|
it('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
const edge = implements_.find((e) => e.source === 'User' && e.target === 'Loggable');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.rel.reason).toBe('extend');
|
|
});
|
|
|
|
it('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
const edge = implements_.find((e) => e.source === 'User' && e.target === 'Cacheable');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.rel.reason).toBe('prepend');
|
|
});
|
|
|
|
// --- Extends: class inheritance ---
|
|
|
|
it('emits EXTENDS edge: User → BaseModel', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
const edges = edgeSet(extends_);
|
|
expect(edges).toContain('User → BaseModel');
|
|
});
|
|
|
|
// --- Property nodes: attr_accessor, attr_reader, attr_writer ---
|
|
|
|
it('creates Property nodes for attr_accessor :id and :created_at', () => {
|
|
const props = getNodesByLabel(result, 'Property');
|
|
expect(props).toContain('id');
|
|
expect(props).toContain('created_at');
|
|
});
|
|
|
|
it('creates Property nodes for attr_reader :name and attr_writer :email', () => {
|
|
const props = getNodesByLabel(result, 'Property');
|
|
expect(props).toContain('name');
|
|
expect(props).toContain('email');
|
|
});
|
|
|
|
it('emits HAS_PROPERTY from User to attr_reader :name', () => {
|
|
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
|
|
const edge = hasProperty.find((e) => e.source === 'User' && e.target === 'name');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => {
|
|
const hasProperty = getRelationships(result, 'HAS_PROPERTY');
|
|
const edge = hasProperty.find((e) => e.source === 'BaseModel' && e.target === 'id');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
// --- Call resolution: method-level attribution ---
|
|
|
|
it('emits method-level CALLS: create_user → persist (member call)', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(e) => e.source === 'create_user' && e.target === 'persist',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0].sourceLabel).toBe('Method');
|
|
expect(calls[0].targetLabel).toBe('Method');
|
|
});
|
|
|
|
it('emits method-level CALLS: create_user → greet_user (member call)', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(e) => e.source === 'create_user' && e.target === 'greet_user',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
expect(calls[0].sourceLabel).toBe('Method');
|
|
expect(calls[0].targetLabel).toBe('Method');
|
|
});
|
|
|
|
it('emits method-level CALLS: greet_user → persist (bare call)', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(e) => e.source === 'greet_user' && e.target === 'persist',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('emits method-level CALLS: greet_user → serialize_data (bare call)', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(e) => e.source === 'greet_user' && e.target === 'serialize_data',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('emits method-level CALLS: persist → run_validations (bare call)', () => {
|
|
const calls = getRelationships(result, 'CALLS').filter(
|
|
(e) => e.source === 'persist' && e.target === 'run_validations',
|
|
);
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
// --- Heritage edges point to real graph nodes ---
|
|
|
|
it('all heritage edges point to real graph nodes', () => {
|
|
for (const edge of [
|
|
...getRelationships(result, 'EXTENDS'),
|
|
...getRelationships(result, 'IMPLEMENTS'),
|
|
]) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
}
|
|
});
|
|
|
|
// --- No OVERRIDES edges target Property nodes ---
|
|
|
|
it('no OVERRIDES edges target Property nodes', () => {
|
|
const overrides = getRelationships(result, 'METHOD_OVERRIDES');
|
|
for (const edge of overrides) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
expect(target!.label).not.toBe('Property');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Calls: arity-based disambiguation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby call resolution with arity filtering', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const auditCall = calls.find((c) => c.target === 'write_audit');
|
|
expect(auditCall).toBeDefined();
|
|
expect(auditCall!.source).toBe('run_task');
|
|
expect(auditCall!.targetFilePath).toContain('one_arg.rb');
|
|
expect(auditCall!.rel.reason).toBe('import-resolved');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Member-call resolution: obj.method() resolves through pipeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby member-call resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-member-calls'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves process_user → persist_record as a member call on User', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'persist_record');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.source).toBe('process_user');
|
|
expect(saveCall!.targetFilePath).toContain('user.rb');
|
|
});
|
|
|
|
it('detects User class and persist_record method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('persist_record');
|
|
});
|
|
|
|
it('emits HAS_METHOD edge from User to persist_record', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const edge = hasMethod.find((e) => e.source === 'User' && e.target === 'persist_record');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Ruby qualified class names', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-types'), () => {});
|
|
}, 60000);
|
|
|
|
it('stores distinct qualified names for same-named classes across modules', () => {
|
|
const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User');
|
|
expect(users).toHaveLength(2);
|
|
expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([
|
|
'Admin.User',
|
|
'Services.Auth.User',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Qualified-base heritage: `class C < Outer::Super` (scope_resolution super-
|
|
// class) must emit EXTENDS (#1951). The bare control `class D < Base` keeps the
|
|
// original path unchanged, and `include Mixin` flows through the unchanged
|
|
// mixin → IMPLEMENTS lane. Scope-resolution owns these edges since #942.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby qualified-base heritage resolution (#1951)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-base'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits EXTENDS for scoped (C < Outer::Super) and bare (D < Base) bases', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const edges = edgeSet(extends_);
|
|
// Scoped superclass resolves by its trailing bare name (Outer::Super → Super).
|
|
expect(edges).toContain('C → Super');
|
|
// Bare control resolves unchanged.
|
|
expect(edges).toContain('D → Base');
|
|
});
|
|
|
|
it('emits IMPLEMENTS for the include Mixin (unchanged mixin lane): C → Mixin', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
const edge = implements_.find((e) => e.source === 'C' && e.target === 'Mixin');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.rel.reason).toBe('include');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ambiguous: Handler in two dirs, require_relative disambiguates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby ambiguous symbol resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-ambiguous'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects 2 Handler classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes.filter((n) => n === 'Handler').length).toBe(2);
|
|
expect(classes).toContain('UserHandler');
|
|
});
|
|
|
|
it('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
expect(extends_[0].source).toBe('UserHandler');
|
|
expect(extends_[0].target).toBe('Handler');
|
|
expect(extends_[0].targetFilePath).toBe('models/handler.rb');
|
|
});
|
|
|
|
it('import edge points to models/ not other/', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
expect(imports.length).toBe(1);
|
|
expect(imports[0].targetFilePath).toBe('models/handler.rb');
|
|
});
|
|
|
|
it('all heritage edges point to real graph nodes', () => {
|
|
for (const edge of getRelationships(result, 'EXTENDS')) {
|
|
const target = result.graph.getNode(edge.rel.targetId);
|
|
expect(target).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local shadow: same-file definition takes priority over imported name
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby local definition shadows import', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-local-shadow'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves run_app → do_work to same-file definition, not the imported one', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const doWorkCall = calls.find((c) => c.target === 'do_work' && c.source === 'run_app');
|
|
expect(doWorkCall).toBeDefined();
|
|
expect(doWorkCall!.targetFilePath).toContain('app.rb');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constructor-inferred type resolution: user = User.new; user.save → User.save
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby constructor-inferred type resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-constructor-type-inference'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects User, Repo, and AppService classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('AppService');
|
|
});
|
|
|
|
it('detects save on User and Repo, cleanup on all three', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods.filter((m) => m === 'save').length).toBe(2);
|
|
expect(methods.filter((m) => m === 'cleanup').length).toBe(3);
|
|
});
|
|
|
|
it('resolves user.save to models/user.rb via constructor-inferred type', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/user.rb',
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
expect(userSave!.source).toBe('process_entities');
|
|
});
|
|
|
|
it('resolves repo.save to models/repo.rb via constructor-inferred type', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath === 'models/repo.rb',
|
|
);
|
|
expect(repoSave).toBeDefined();
|
|
expect(repoSave!.source).toBe('process_entities');
|
|
});
|
|
|
|
it('emits exactly 2 save CALLS edges (one per receiver type)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((c) => c.target === 'save');
|
|
expect(saveCalls.length).toBe(2);
|
|
});
|
|
|
|
it('resolves self.process_entities to services/app.rb (unique method)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const selfCall = calls.find((c) => c.source === 'greet' && c.target === 'process_entities');
|
|
expect(selfCall).toBeDefined();
|
|
expect(selfCall!.targetFilePath).toContain('app.rb');
|
|
});
|
|
|
|
it('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const selfCleanup = calls.find((c) => c.source === 'greet' && c.target === 'cleanup');
|
|
expect(selfCleanup).toBeDefined();
|
|
expect(selfCleanup!.targetFilePath).toContain('app.rb');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// self.save resolves to enclosing class's own save method
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby self resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-self-this-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes, each with a save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']);
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves self.save inside User#process to User#save, not Repo#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.targetFilePath).toBe('lib/models/user.rb');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parent class resolution: < BaseModel + include Module
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby parent resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-parent-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects BaseModel and User classes plus Serializable module (Trait)', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']);
|
|
// Ruby modules are labeled Trait — see the "detects 3 modules" test above.
|
|
expect(getNodesByLabel(result, 'Trait')).toEqual(['Serializable']);
|
|
});
|
|
|
|
it('emits EXTENDS edge: User < BaseModel', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
expect(extends_[0].source).toBe('User');
|
|
expect(extends_[0].target).toBe('BaseModel');
|
|
});
|
|
|
|
it('emits IMPLEMENTS edge: User includes Serializable', () => {
|
|
const implements_ = getRelationships(result, 'IMPLEMENTS');
|
|
const includeEdge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable');
|
|
expect(includeEdge).toBeDefined();
|
|
expect(includeEdge!.rel.reason).toBe('include');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ruby super: standalone keyword calls same-named method on parent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby super resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-super-resolution'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects BaseModel, User, and Repo classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']);
|
|
});
|
|
|
|
it('emits EXTENDS edge: User < BaseModel', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(extends_.length).toBe(1);
|
|
expect(extends_[0].source).toBe('User');
|
|
expect(extends_[0].target).toBe('BaseModel');
|
|
});
|
|
|
|
it('detects save methods on all three classes', () => {
|
|
const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ruby constant constructor: SERVICE = UserService.new; SERVICE.process
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby constant constructor binding resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-constructor'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects UserService class with process and validate methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('process');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('validate');
|
|
});
|
|
|
|
it('resolves SERVICE.process() via constant constructor binding', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const processCall = calls.find(
|
|
(c) => c.target === 'process' && c.targetFilePath === 'models.rb',
|
|
);
|
|
expect(processCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves SERVICE.validate() via constant constructor binding', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const validateCall = calls.find(
|
|
(c) => c.target === 'validate' && c.targetFilePath === 'models.rb',
|
|
);
|
|
expect(validateCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// YARD annotation type resolution: @param repo [UserRepo] → repo.save resolves
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby YARD annotation type resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-annotations'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects UserRepo, User, and UserService classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserRepo');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
|
|
});
|
|
|
|
it('detects save, find_by_name, greet, and create methods', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('save');
|
|
expect(methods).toContain('find_by_name');
|
|
expect(methods).toContain('greet');
|
|
expect(methods).toContain('create');
|
|
});
|
|
|
|
it('resolves repo.save to UserRepo#save via YARD @param annotation', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'create');
|
|
expect(saveCall).toBeDefined();
|
|
expect(saveCall!.targetFilePath).toContain('models.rb');
|
|
});
|
|
|
|
it('resolves user.greet to User#greet via YARD @param annotation', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'create');
|
|
expect(greetCall).toBeDefined();
|
|
expect(greetCall!.targetFilePath).toContain('models.rb');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Namespaced constructor: svc = Models::UserService.new; svc.process()
|
|
// Tests scope_resolution receiver handling for Ruby namespaced classes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby namespaced constructor resolution (Models::UserService.new)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-namespaced-constructor'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects UserService class with process and validate methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('process');
|
|
expect(methods).toContain('validate');
|
|
});
|
|
|
|
it('resolves svc.process() via namespaced constructor Models::UserService.new', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const processCall = calls.find(
|
|
(c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'),
|
|
);
|
|
expect(processCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves svc.validate() via namespaced constructor Models::UserService.new', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const validateCall = calls.find(
|
|
(c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'),
|
|
);
|
|
expect(validateCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Return type inference: user = get_user('alice'); user.save
|
|
// Ruby's scanConstructorBinding captures assignment nodes with call RHS.
|
|
// Combined with YARD @return annotation parsing, the pipeline resolves
|
|
// `user.save` to User#save (not Repo#save) via return type disambiguation.
|
|
// The fixture has BOTH User#save and Repo#save — fuzzy matching alone
|
|
// cannot disambiguate, so return type inference must be working.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby return type inference via function call', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-return-type'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User and Repo classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
|
|
});
|
|
|
|
it('detects get_user and get_repo methods', () => {
|
|
expect(getNodesByLabel(result, 'Method')).toContain('get_user');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('get_repo');
|
|
});
|
|
|
|
it('detects save method on both User and Repo (disambiguation required)', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
// Both classes have save — fuzzy match alone cannot resolve this
|
|
expect(methods.filter((m) => m === 'save').length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.save to User#save via YARD @return [User] on get_user()', () => {
|
|
// With both User#save and Repo#save in scope, resolving user.save
|
|
// requires return type inference: get_user() → @return [User] → user is User
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) =>
|
|
c.target === 'save' &&
|
|
c.source === 'process_user' &&
|
|
c.targetFilePath.includes('models.rb'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('repo.rb'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ruby constant LHS factory call: SERVICE = build_service() with YARD @return
|
|
// Verifies that constant assignments (uppercase LHS) from plain function calls
|
|
// are captured by scanConstructorBinding, not just identifier assignments.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby constant factory call resolution (SERVICE = build_service())', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-factory-call'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects UserService and AdminService classes with process and validate methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('AdminService');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('process');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('validate');
|
|
});
|
|
|
|
it('resolves SERVICE.process() to UserService#process via constant factory call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const processCall = calls.find(
|
|
(c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'),
|
|
);
|
|
expect(processCall).toBeDefined();
|
|
const wrongCall = calls.find(
|
|
(c) =>
|
|
c.target === 'process' &&
|
|
c.sourceFilePath?.includes('app.rb') &&
|
|
c.targetFilePath.includes('admin_service.rb'),
|
|
);
|
|
expect(wrongCall).toBeUndefined();
|
|
});
|
|
|
|
it('resolves SERVICE.validate() to UserService#validate via constant factory call', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const validateCall = calls.find(
|
|
(c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'),
|
|
);
|
|
expect(validateCall).toBeDefined();
|
|
const wrongCall = calls.find(
|
|
(c) =>
|
|
c.target === 'validate' &&
|
|
c.sourceFilePath?.includes('app.rb') &&
|
|
c.targetFilePath.includes('admin_service.rb'),
|
|
);
|
|
expect(wrongCall).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Ruby YARD generic type annotations (Hash<Symbol, User>)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-generics'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects UserRepo, AdminRepo, and DataService classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserRepo');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('AdminRepo');
|
|
expect(getNodesByLabel(result, 'Class')).toContain('DataService');
|
|
});
|
|
|
|
it('detects save and find_all on both repos, plus sync and audit methods', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('save');
|
|
expect(methods).toContain('find_all');
|
|
expect(methods).toContain('sync');
|
|
expect(methods).toContain('audit');
|
|
});
|
|
|
|
it('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'sync' && c.targetFilePath.includes('models.rb'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('does NOT resolve cache param to a class (Hash<Symbol, UserRepo> is a generic container)', () => {
|
|
// The @param cache [Hash<Symbol, UserRepo>] should extract type "Hash" — not "UserRepo".
|
|
// Since Hash is not a class in the fixture, no type binding is created for cache.
|
|
// This verifies the bracket-balanced split doesn't break on the inner comma.
|
|
const calls = getRelationships(result, 'CALLS');
|
|
// No calls should originate from cache.* since cache has no resolved type
|
|
const cacheCall = calls.find(
|
|
(c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'),
|
|
);
|
|
expect(cacheCall).toBeUndefined();
|
|
});
|
|
|
|
it('resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
// audit() calls admin_repo.save — should resolve via the alternate YARD format
|
|
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit');
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const findCall = calls.find((c) => c.target === 'find_all' && c.source === 'audit');
|
|
expect(findCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chained method calls: svc.get_user.save
|
|
// Tests that Ruby's `call` node uses `method` and `receiver` fields correctly
|
|
// for chain extraction — the tree-sitter-ruby grammar differs from other languages.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby chained method call resolution (Phase 5 review fix)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-chain-call'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User, Repo, UserService and App classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('User');
|
|
expect(classes).toContain('Repo');
|
|
expect(classes).toContain('UserService');
|
|
expect(classes).toContain('App');
|
|
});
|
|
|
|
it('detects save methods on both User and Repo', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
const saveMethods = methods.filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('detects get_user method on UserService', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('get_user');
|
|
});
|
|
|
|
it('resolves svc.get_user.save to User#save via chain resolution', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rb'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('does NOT resolve svc.get_user.save to Repo#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const repoSave = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rb'),
|
|
);
|
|
expect(repoSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ruby for-in loop: for user in users — YARD @param resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby for-in loop resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-for-in-loop'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User class with save method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
});
|
|
|
|
it('resolves user.save in for-in to User#save', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const userSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('user'),
|
|
);
|
|
expect(userSave).toBeDefined();
|
|
});
|
|
|
|
it('does NOT resolve user.save to Repo#save (negative)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const wrongSave = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('repo'),
|
|
);
|
|
expect(wrongSave).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 8: Field/property type resolution via YARD @return annotations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Field type resolution (Ruby)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-types'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects classes: Address, User', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
|
|
});
|
|
|
|
it('detects Property nodes for attr_accessor fields', () => {
|
|
const properties = getNodesByLabel(result, 'Property');
|
|
expect(properties).toContain('address');
|
|
expect(properties).toContain('name');
|
|
expect(properties).toContain('city');
|
|
});
|
|
|
|
it('emits HAS_PROPERTY edges linking properties to classes', () => {
|
|
const propEdges = getRelationships(result, 'HAS_PROPERTY');
|
|
expect(propEdges.length).toBe(3);
|
|
expect(edgeSet(propEdges)).toContain('User → address');
|
|
expect(edgeSet(propEdges)).toContain('User → name');
|
|
expect(edgeSet(propEdges)).toContain('Address → city');
|
|
});
|
|
|
|
it('resolves user.address.save → Address#save via YARD @return [Address]', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((e) => e.target === 'save');
|
|
const addressSave = saveCalls.find(
|
|
(e) => e.source === 'process_user' && e.targetFilePath.includes('models'),
|
|
);
|
|
expect(addressSave).toBeDefined();
|
|
});
|
|
|
|
it('Property nodes contain expected field names', () => {
|
|
const properties = getNodesByLabelFull(result, 'Property');
|
|
|
|
const city = properties.find((p) => p.name === 'city');
|
|
expect(city).toBeDefined();
|
|
|
|
const name = properties.find((p) => p.name === 'name');
|
|
expect(name).toBeDefined();
|
|
|
|
const addr = properties.find((p) => p.name === 'address');
|
|
expect(addr).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 8: Field type disambiguation — both User and Address have save()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Field type disambiguation (Ruby)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-type-disambig'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects both User#save and Address#save', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
const saveMethods = methods.filter((m) => m === 'save');
|
|
expect(saveMethods.length).toBe(2);
|
|
});
|
|
|
|
it('resolves user.address.save → Address#save (not User#save)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user');
|
|
expect(saveCalls.length).toBe(1);
|
|
expect(saveCalls[0].targetFilePath).toContain('address');
|
|
expect(saveCalls[0].targetFilePath).not.toContain('user');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ACCESSES write edges from assignment expressions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Write access tracking (Ruby)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-write-access'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits ACCESSES write edges for setter assignments', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const writes = accesses.filter((e) => e.rel.reason === 'write');
|
|
expect(writes.length).toBe(3);
|
|
const nameWrite = writes.find((e) => e.target === 'name');
|
|
const addressWrite = writes.find((e) => e.target === 'address');
|
|
const scoreWrite = writes.find((e) => e.target === 'score');
|
|
expect(nameWrite).toBeDefined();
|
|
expect(nameWrite!.source).toBe('update_user');
|
|
expect(addressWrite).toBeDefined();
|
|
expect(addressWrite!.source).toBe('update_user');
|
|
expect(scoreWrite).toBeDefined();
|
|
expect(scoreWrite!.source).toBe('update_user');
|
|
});
|
|
|
|
it('emits ACCESSES write edge for compound assignment (operator_assignment)', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const writes = accesses.filter((e) => e.rel.reason === 'write');
|
|
const scoreWrite = writes.find((e) => e.target === 'score');
|
|
expect(scoreWrite).toBeDefined();
|
|
expect(scoreWrite!.source).toBe('update_user');
|
|
});
|
|
|
|
it('write ACCESSES edges have confidence 1.0', () => {
|
|
const accesses = getRelationships(result, 'ACCESSES');
|
|
const writes = accesses.filter((e) => e.rel.reason === 'write');
|
|
for (const edge of writes) {
|
|
expect(edge.rel.confidence).toBe(1.0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Call-result variable binding (Phase 9): user = get_user(); user.save
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby call-result variable binding (Tier 2b)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-call-result-binding'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves user.save to User#save via call-result binding', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('app'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method chain binding (Phase 9C): get_user() → .get_address() → .get_city() → .save
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-chain-binding'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves city.save to City#save via method chain', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) =>
|
|
c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('app'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
|
|
// greet is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby grandparent method resolution via MRO (Phase B)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-grandparent-resolution'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('detects A, B, C, Greeting classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('A');
|
|
expect(classes).toContain('B');
|
|
expect(classes).toContain('C');
|
|
expect(classes).toContain('Greeting');
|
|
});
|
|
|
|
it('emits EXTENDS edges: B→A, C→B', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
expect(edgeSet(extends_)).toContain('B → A');
|
|
expect(edgeSet(extends_)).toContain('C → B');
|
|
});
|
|
|
|
it('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.targetFilePath.includes('greeting'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves c.greet to A#greet (method found via MRO walk)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath.includes('a.rb'));
|
|
expect(greetCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ruby default parameter arity resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby default parameter arity resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-default-params'), () => {});
|
|
}, 60000);
|
|
|
|
it('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const greetCalls = calls.filter((c) => c.source === 'process' && c.target === 'greet');
|
|
expect(greetCalls.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 14: Cross-file binding propagation (via synthesized wildcard imports)
|
|
// models/user.rb exports User class with save and get_name methods
|
|
// models/user_factory.rb exports UserFactory with self.get_user -> User.new
|
|
// app.rb requires both, calls UserFactory.get_user then .save / .get_name
|
|
// → user is typed User via cross-file return type propagation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby cross-file binding propagation', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects User class with save and get_name methods', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('save');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('get_name');
|
|
});
|
|
|
|
it('detects UserFactory class and get_user method', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('UserFactory');
|
|
expect(getNodesByLabel(result, 'Method')).toContain('get_user');
|
|
});
|
|
|
|
it('emits IMPORTS edge from app.rb to models', () => {
|
|
const imports = getRelationships(result, 'IMPORTS');
|
|
const edge = imports.find(
|
|
(e) => e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'),
|
|
);
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('resolves user.save in process to User#save via cross-file propagation', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const saveCall = calls.find(
|
|
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'),
|
|
);
|
|
expect(saveCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves user.get_name in process to User#get_name via cross-file propagation', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const getNameCall = calls.find(
|
|
(c) =>
|
|
c.target === 'get_name' && c.source === 'process' && c.targetFilePath.includes('models'),
|
|
);
|
|
expect(getNameCall).toBeDefined();
|
|
});
|
|
|
|
it('emits HAS_METHOD edges linking save and get_name to User', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
|
|
const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name');
|
|
expect(saveEdge).toBeDefined();
|
|
expect(getNameEdge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method Enrichment: visibility (private/protected), isStatic (singleton),
|
|
// parameters, HAS_METHOD edges, member call resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby method enrichment (visibility, isStatic, parameters)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Animal and Dog classes', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toEqual(['Animal', 'Dog']);
|
|
});
|
|
|
|
it('detects all methods including singleton', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('speak');
|
|
expect(methods).toContain('classify');
|
|
expect(methods).toContain('from_habitat');
|
|
expect(methods).toContain('internal_state');
|
|
expect(methods).toContain('energy_level');
|
|
});
|
|
|
|
it('emits HAS_METHOD edges for Animal and Dog', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
// Animal has speak, classify, from_habitat, internal_state
|
|
expect(hasMethod.find((e) => e.source === 'Animal' && e.target === 'speak')).toBeDefined();
|
|
expect(hasMethod.find((e) => e.source === 'Animal' && e.target === 'classify')).toBeDefined();
|
|
expect(
|
|
hasMethod.find((e) => e.source === 'Animal' && e.target === 'from_habitat'),
|
|
).toBeDefined();
|
|
expect(
|
|
hasMethod.find((e) => e.source === 'Animal' && e.target === 'internal_state'),
|
|
).toBeDefined();
|
|
// Dog has speak, energy_level
|
|
expect(hasMethod.find((e) => e.source === 'Dog' && e.target === 'speak')).toBeDefined();
|
|
expect(hasMethod.find((e) => e.source === 'Dog' && e.target === 'energy_level')).toBeDefined();
|
|
});
|
|
|
|
it('marks internal_state as private (when enriched)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const internalState = methods.find(
|
|
(m) => m.name === 'internal_state' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(internalState).toBeDefined();
|
|
// Visibility enrichment requires the MethodExtractor path (worker mode).
|
|
// Sequential fallback (small repos) does not populate visibility.
|
|
if (internalState!.properties.visibility !== undefined) {
|
|
expect(internalState!.properties.visibility).toBe('private');
|
|
}
|
|
});
|
|
|
|
it('marks energy_level as protected (when enriched)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const energyLevel = methods.find(
|
|
(m) => m.name === 'energy_level' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(energyLevel).toBeDefined();
|
|
if (energyLevel!.properties.visibility !== undefined) {
|
|
expect(energyLevel!.properties.visibility).toBe('protected');
|
|
}
|
|
});
|
|
|
|
it('marks classify as static (when enriched)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const classify = methods.find(
|
|
(m) => m.name === 'classify' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(classify).toBeDefined();
|
|
if (classify!.properties.isStatic !== undefined) {
|
|
expect(classify!.properties.isStatic).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('marks from_habitat (class << self) as static and public (when enriched)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const fromHabitat = methods.find(
|
|
(m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(fromHabitat).toBeDefined();
|
|
if (fromHabitat!.properties.isStatic !== undefined) {
|
|
expect(fromHabitat!.properties.isStatic).toBe(true);
|
|
}
|
|
if (fromHabitat!.properties.visibility !== undefined) {
|
|
expect(fromHabitat!.properties.visibility).toBe('public');
|
|
}
|
|
});
|
|
|
|
it('extracts parameterCount for from_habitat(habitat)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const fromHabitat = methods.find(
|
|
(m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(fromHabitat).toBeDefined();
|
|
expect(fromHabitat!.properties.parameterCount).toBe(1);
|
|
});
|
|
|
|
it('marks speak as public (when enriched)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const speak = methods.find(
|
|
(m) => m.name === 'speak' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(speak).toBeDefined();
|
|
// When the MethodExtractor enrichment runs, visibility defaults to public
|
|
if (speak!.properties.visibility !== undefined) {
|
|
expect(speak!.properties.visibility).toBe('public');
|
|
}
|
|
});
|
|
|
|
it('extracts parameterCount for classify(name)', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const classify = methods.find(
|
|
(m) => m.name === 'classify' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(classify).toBeDefined();
|
|
expect(classify!.properties.parameterCount).toBe(1);
|
|
});
|
|
|
|
it('resolves dog.speak member call from main to Dog#speak', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const speakCall = calls.find(
|
|
(c) => c.source === 'main' && c.target === 'speak' && c.targetFilePath.includes('animal'),
|
|
);
|
|
expect(speakCall).toBeDefined();
|
|
});
|
|
|
|
it('emits EXTENDS edge from Dog to Animal', () => {
|
|
const extends_ = getRelationships(result, 'EXTENDS');
|
|
const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal');
|
|
expect(edge).toBeDefined();
|
|
});
|
|
|
|
it('detects main as top-level Method in app.rb', () => {
|
|
// Ruby top-level def is parsed as a method node (tree-sitter `method` type)
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('main');
|
|
});
|
|
});
|
|
|
|
describe('Ruby singleton_class handling (worker path)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {});
|
|
}, 60000);
|
|
|
|
it('keeps Animal as the owner for class << self methods', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
expect(
|
|
hasMethod.find((e) => e.source === 'Animal' && e.target === 'from_habitat'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('marks from_habitat as static', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const fromHabitat = methods.find(
|
|
(m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'),
|
|
);
|
|
expect(fromHabitat).toBeDefined();
|
|
expect(fromHabitat!.properties.isStatic).toBe(true);
|
|
expect(fromHabitat!.properties.parameterCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Overload Dispatch: methods with different arity resolve via receiver type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby overload dispatch (format vs format_with_prefix)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-overload-dispatch'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Formatter class', () => {
|
|
expect(getNodesByLabel(result, 'Class')).toContain('Formatter');
|
|
});
|
|
|
|
it('detects format and format_with_prefix methods', () => {
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('format');
|
|
expect(methods).toContain('format_with_prefix');
|
|
});
|
|
|
|
it('emits HAS_METHOD edges for both methods on Formatter', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
expect(hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format')).toBeDefined();
|
|
expect(
|
|
hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format_with_prefix'),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it('extracts arity for format(value) — 1 parameter', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const format = methods.find((m) => m.name === 'format');
|
|
expect(format).toBeDefined();
|
|
expect(format!.properties.parameterCount).toBe(1);
|
|
});
|
|
|
|
it('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => {
|
|
const methods = getNodesByLabelFull(result, 'Method');
|
|
const fwp = methods.find((m) => m.name === 'format_with_prefix');
|
|
expect(fwp).toBeDefined();
|
|
expect(fwp!.properties.parameterCount).toBe(2);
|
|
});
|
|
|
|
it('resolves f.format call from run to Formatter#format', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const formatCall = calls.find(
|
|
(c) => c.source === 'run' && c.target === 'format' && c.targetFilePath.includes('formatter'),
|
|
);
|
|
expect(formatCall).toBeDefined();
|
|
});
|
|
|
|
it('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const fwpCall = calls.find(
|
|
(c) =>
|
|
c.source === 'run' &&
|
|
c.target === 'format_with_prefix' &&
|
|
c.targetFilePath.includes('formatter'),
|
|
);
|
|
expect(fwpCall).toBeDefined();
|
|
});
|
|
|
|
it('detects run as top-level Method in app.rb', () => {
|
|
// Ruby top-level def is parsed as a method node (tree-sitter `method` type)
|
|
const methods = getNodesByLabel(result, 'Method');
|
|
expect(methods).toContain('run');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SM-9/SM-10: inherited method resolution — Ruby first-wins inheritance walk
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {});
|
|
}, 60000);
|
|
|
|
it('detects Parent and Child classes', () => {
|
|
const classes = getNodesByLabel(result, 'Class');
|
|
expect(classes).toContain('Parent');
|
|
expect(classes).toContain('Child');
|
|
});
|
|
|
|
it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const parentMethodCall = calls.find(
|
|
(c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'),
|
|
);
|
|
expect(parentMethodCall).toBeDefined();
|
|
expect(parentMethodCall!.source).toBe('run');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Namespaced class/module declarations — GRAPH NODE materialization (issue #1975)
|
|
//
|
|
// Follow-up to PR #1972 (F62): the scope query captures the tail constant for
|
|
// `class Foo::Bar` / `module Baz::Qux`, but the legacy structure query never
|
|
// matched the scope_resolution name, so no Class/Trait node was created and the
|
|
// declaration's methods got dangling HAS_METHOD edges. These pipeline-level
|
|
// tests assert the target behavior (a real node + a resolving HAS_METHOD edge).
|
|
// They fail on the pre-fix base — see plan docs/plans/2026-06-02-002-*.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby namespaced class/module definitions — graph nodes (issue #1975)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-namespaced'), () => {});
|
|
}, 60000);
|
|
|
|
// R1/R3: a distinct Class node is materialized for the namespaced class,
|
|
// keyed by its full scoped name (so Foo::Bar and Baz::Bar never collide).
|
|
// The node id matches the HAS_METHOD owner id derived from the same name field;
|
|
// qualifiedName carries the dotted path (Foo.Bar).
|
|
it('materializes a Class node for class Foo::Bar', () => {
|
|
const classes = getNodesByLabelFull(result, 'Class');
|
|
expect(classes.some((c) => c.properties.qualifiedName === 'Foo.Bar')).toBe(true);
|
|
});
|
|
|
|
// R1: deep chain Outer::Middle::Inner → qualifiedName Outer.Middle.Inner.
|
|
it('materializes a Class node for class Outer::Middle::Inner', () => {
|
|
const classes = getNodesByLabelFull(result, 'Class');
|
|
expect(classes.some((c) => c.properties.qualifiedName === 'Outer.Middle.Inner')).toBe(true);
|
|
});
|
|
|
|
// R1: module → Trait (Ruby modules are relabeled Trait for class-like lookup).
|
|
it('materializes a Trait node for module Baz::Qux', () => {
|
|
expect(getNodesByLabel(result, 'Trait')).toContain('Baz::Qux');
|
|
});
|
|
|
|
// R2: methods of namespaced declarations must not produce dangling HAS_METHOD edges.
|
|
it('emits no dangling HAS_METHOD edges for namespaced declarations', () => {
|
|
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
|
|
});
|
|
|
|
// R2: the method resolves to a real owner node (not an 'unknown' dangling source).
|
|
it('owns bar_method under a resolving namespaced class node', () => {
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
const edge = hasMethod.find((e) => e.target === 'bar_method');
|
|
expect(edge).toBeDefined();
|
|
expect(edge!.sourceLabel).toBe('Class');
|
|
});
|
|
});
|
|
|
|
describe('Ruby cross-namespace tail collision — distinct nodes (issue #1975)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-tail-collision'), () => {});
|
|
}, 60000);
|
|
|
|
// R3: Foo::Bar and Baz::Bar share the tail `Bar` but must NOT merge — keying by
|
|
// the full scoped name keeps them two distinct Class nodes.
|
|
it('keeps Foo::Bar and Baz::Bar as two distinct Class nodes', () => {
|
|
const qns = getNodesByLabelFull(result, 'Class')
|
|
.map((c) => c.properties.qualifiedName)
|
|
.filter((q) => q === 'Foo.Bar' || q === 'Baz.Bar')
|
|
.sort();
|
|
expect(qns).toEqual(['Baz.Bar', 'Foo.Bar']);
|
|
});
|
|
|
|
// R2/R3: each namespaced class owns its own method through a resolving node.
|
|
it('owns each method under its own namespaced class (no dangling, no cross-wire)', () => {
|
|
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
|
|
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
|
expect(hasMethod.some((e) => e.target === 'from_foo' && e.sourceLabel === 'Class')).toBe(true);
|
|
expect(hasMethod.some((e) => e.target === 'from_baz' && e.sourceLabel === 'Class')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inline module-nested same-tail collision — distinct nodes (issue #1978)
|
|
//
|
|
// `module Outer; class Inner; end; end` + `module Other; class Inner; end; end`
|
|
// must own their methods through TWO distinct Class nodes (qn Outer.Inner vs
|
|
// Other.Inner). On the pre-fix base both Inner classes merge into one
|
|
// simple-keyed node and from_outer/from_other cross-wire (dangling:0 but wrong).
|
|
// Asserts positive owner-identity by the resolved node's qualifiedName (R7).
|
|
// (Distinct from the compact `Foo::Bar` collision block above, which #1977 fixed.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby inline module-nested same-tail collision — distinct nodes (issue #1978)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-tail-collision'), () => {});
|
|
}, 60000);
|
|
|
|
it('owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)', () => {
|
|
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
|
|
const hm = getRelationships(result, 'HAS_METHOD');
|
|
const ownerQn = (target: string) => {
|
|
const e = hm.find((x) => x.target === target);
|
|
expect(e, `HAS_METHOD -> ${target}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
|
};
|
|
expect(ownerQn('from_outer')).toBe('Outer.Inner');
|
|
expect(ownerQn('from_other')).toBe('Other.Inner');
|
|
});
|
|
|
|
// attr_accessor routes through the property-registration pre-pass — a SEPARATE
|
|
// code path from `def` methods: call-processor.ts (sequential/legacy) and the
|
|
// parse-worker `kind === 'properties'` block (worker). Under qualifiedNodeId the
|
|
// owner must resolve to the QUALIFIED class node (Shapes.Circle); the pre-fix
|
|
// simple `Class:f.rb:Circle` no longer exists and would dangle. Exercised here
|
|
// on an UNAMBIGUOUS nested class (no same-tail sibling) so the assertion is
|
|
// exact on both legs.
|
|
//
|
|
// NOTE: exact owner identity for a routed property under SAME-TAIL nested types
|
|
// (e.g. two `Inner` classes) is a separate resolution-side concern — the
|
|
// registry-primary `emitRubyMixinEdges` bridge resolves the owner by simple
|
|
// tail name (last-wins) and the worker path can emit a duplicate cross-wired
|
|
// edge. That is deferred to the #1978 resolution-side follow-up; the
|
|
// structure-phase HAS_METHOD ownership above is already exact on both legs.
|
|
it('owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)', () => {
|
|
expect(findDanglingEdges(result, ['HAS_PROPERTY'])).toEqual([]);
|
|
const hp = getRelationships(result, 'HAS_PROPERTY');
|
|
const e = hp.find((x) => x.target === 'radius');
|
|
expect(e, 'HAS_PROPERTY -> radius').toBeDefined();
|
|
expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Shapes.Circle');
|
|
});
|
|
|
|
// #1982 resolution-side: SAME-TAIL routed-property owner identity. The
|
|
// pre-fix emitRubyMixinEdges keys its owner map by simple tail (last-wins),
|
|
// so outer_attr / other_attr both attach to whichever `Inner` was processed
|
|
// last. Asserts each routes to its OWN qualified node by qualifiedName, with
|
|
// exactly one (non-duplicated) edge. Registry-primary only.
|
|
it('owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)', () => {
|
|
const hp = getRelationships(result, 'HAS_PROPERTY');
|
|
const ownerQnOf = (prop: string) => {
|
|
const e = hp.find((x) => x.target === prop);
|
|
expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
|
};
|
|
expect(ownerQnOf('outer_attr')).toBe('Outer.Inner');
|
|
expect(ownerQnOf('other_attr')).toBe('Other.Inner');
|
|
expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1);
|
|
expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1);
|
|
});
|
|
|
|
// #1982 resolution-side: SAME-TAIL mixin owner identity (IMPLEMENTS).
|
|
it('routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)', () => {
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const ownerQnOfMixin = (mixinName: string) => {
|
|
const e = impl.find((x) => x.target === mixinName);
|
|
expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
|
};
|
|
expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner');
|
|
expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner');
|
|
});
|
|
});
|
|
|
|
// Same fixture through the WORKER pool. The deferred note flagged that the worker
|
|
// path could emit a DUPLICATE cross-wired same-tail owner edge (the worker emits
|
|
// the __property__/__heritage__ markers, which must now carry the full qualified
|
|
// owner). Asserts worker == sequential: each attr owns its OWN qualified node with
|
|
// exactly one edge (#1982 R7). Registry-primary only.
|
|
describe('Ruby inline module-nested same-tail collision — worker path parity (issue #1982)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-nested-tail-collision'),
|
|
() => {},
|
|
{
|
|
workerPoolSize: 2,
|
|
},
|
|
);
|
|
}, 120000);
|
|
|
|
it('genuinely used the worker pool for the same-tail Ruby fixture', () => {
|
|
expect(result.usedWorkerPool).toBe(true);
|
|
});
|
|
|
|
it('owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)', () => {
|
|
const hp = getRelationships(result, 'HAS_PROPERTY');
|
|
const ownerQnOf = (prop: string) => {
|
|
const e = hp.find((x) => x.target === prop);
|
|
expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
|
};
|
|
expect(ownerQnOf('outer_attr')).toBe('Outer.Inner');
|
|
expect(ownerQnOf('other_attr')).toBe('Other.Inner');
|
|
expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1);
|
|
expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1);
|
|
});
|
|
|
|
// Worker-path parity for the MIXIN (IMPLEMENTS) path — the __heritage__ marker
|
|
// owner must survive worker serialization (not only attr_accessor / HAS_PROPERTY).
|
|
it('routes include OuterMix / OtherMix to their OWN qualified Inner owner on the worker path (IMPLEMENTS, R7)', () => {
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const ownerQnOfMixin = (mixinName: string) => {
|
|
const e = impl.find((x) => x.target === mixinName);
|
|
expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
|
};
|
|
expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner');
|
|
expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner');
|
|
expect(impl.filter((x) => x.target === 'OuterMix')).toHaveLength(1);
|
|
expect(impl.filter((x) => x.target === 'OtherMix')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Same-tail NESTED mixin MODULE collision — distinct Trait nodes (issue #1991)
|
|
//
|
|
// `module App; module Loggable; class S; include Loggable; end; end` +
|
|
// `module Web; module Loggable; class T; include Loggable; end; end`. The
|
|
// structure phase never qualified `module` (Trait) node ids, so both Loggable
|
|
// modules collapsed onto one Trait:app.rb:Loggable node and the bare-name mixin
|
|
// reference cross-wired IMPLEMENTS (first-wins tail). Asserts two distinct Trait
|
|
// nodes and each class IMPLEMENTS its OWN module (positive target identity), not
|
|
// just dangle-free. The IMPLEMENTS routing is registry-primary.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby same-tail nested mixin-module collision — distinct Trait nodes (issue #1991)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-nested-mixin-tail-collision'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('materializes App.Loggable and Web.Loggable as two distinct Trait nodes', () => {
|
|
const qns = getNodesByLabelFull(result, 'Trait')
|
|
.map((n) => n.properties.qualifiedName)
|
|
.filter((q) => q === 'App.Loggable' || q === 'Web.Loggable')
|
|
.sort();
|
|
expect(qns).toEqual(['App.Loggable', 'Web.Loggable']);
|
|
});
|
|
|
|
it('routes S -> App.Loggable and T -> Web.Loggable (no cross-wire, R2)', () => {
|
|
expect(findDanglingEdges(result, ['IMPLEMENTS', 'HAS_METHOD'])).toEqual([]);
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const targetQnOf = (className: string) => {
|
|
const e = impl.find((x) => x.source === className && x.target === 'Loggable');
|
|
expect(e, `IMPLEMENTS from ${className}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.targetId)?.properties.qualifiedName;
|
|
};
|
|
expect(targetQnOf('S')).toBe('App.Loggable');
|
|
expect(targetQnOf('T')).toBe('Web.Loggable');
|
|
expect(impl.filter((x) => x.source === 'S')).toHaveLength(1);
|
|
expect(impl.filter((x) => x.source === 'T')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// Same fixture through the WORKER pool — the __heritage__ marker owner + the
|
|
// qualified module node id must survive worker serialization (#1991 R2/R15).
|
|
describe('Ruby same-tail nested mixin-module collision — worker path parity (issue #1991)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-nested-mixin-tail-collision'),
|
|
() => {},
|
|
{ workerPoolSize: 2 },
|
|
);
|
|
}, 120000);
|
|
|
|
it('genuinely used the worker pool for the same-tail mixin-module fixture', () => {
|
|
expect(result.usedWorkerPool).toBe(true);
|
|
});
|
|
|
|
it('routes S -> App.Loggable and T -> Web.Loggable on the worker path (no cross-wire)', () => {
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const targetQnOf = (className: string) => {
|
|
const e = impl.find((x) => x.source === className && x.target === 'Loggable');
|
|
expect(e, `IMPLEMENTS from ${className}`).toBeDefined();
|
|
return result.graph.getNode(e!.rel.targetId)?.properties.qualifiedName;
|
|
};
|
|
expect(targetQnOf('S')).toBe('App.Loggable');
|
|
expect(targetQnOf('T')).toBe('Web.Loggable');
|
|
expect(impl.filter((x) => x.source === 'S')).toHaveLength(1);
|
|
expect(impl.filter((x) => x.source === 'T')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Nested mixin included by SHORT name — IMPLEMENTS edge must not drop (#1982).
|
|
//
|
|
// `module App; module Loggable; end; class Service; include Loggable; end; end`
|
|
// — `Loggable` is nested (qn App.Loggable) but included by its bare short name.
|
|
// The structure phase materializes a distinct App.Loggable node, but
|
|
// emitRubyMixinEdges keys graphIdByName by FULL qualifiedName while the
|
|
// __heritage__ marker carries the bare arg.text ('Loggable'), so the
|
|
// mixin-target lookup missed and the IMPLEMENTS edge was silently dropped
|
|
// (0 dangling, undetectable). The shipped same-tail fixture only uses TOP-LEVEL
|
|
// mixin modules (full qn == bare name), so it cannot catch this. Asserts the
|
|
// edge exists and resolves by NODE ID (KTD3 — not the normalized qualifiedName
|
|
// property). Registry-primary only (emitRubyMixinEdges is the registry bridge).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby nested mixin by short name — IMPLEMENTS not dropped (issue #1982)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-nested-mixin-shortname'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('emits App.Service -IMPLEMENTS-> App.Loggable for a short-name nested mixin (R1)', () => {
|
|
expect(findDanglingEdges(result, ['IMPLEMENTS'])).toEqual([]);
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const e = impl.find((x) => x.target === 'Loggable');
|
|
expect(e, 'IMPLEMENTS -> Loggable (nested mixin by short name)').toBeDefined();
|
|
// KTD3: discriminate on the resolved node id, not the normalized property.
|
|
// The owner resolves to the QUALIFIED `App.Service` class node — the pre-fix
|
|
// bug dropped the edge entirely, so its presence + qualified owner is the
|
|
// discriminator. (The mixin module is a Trait node keyed by its simple name
|
|
// `Loggable`; Trait-node qualification under same-tail modules is a separate
|
|
// structure-phase concern, deferred.)
|
|
expect(e!.rel.sourceId).toContain('App.Service');
|
|
expect(e!.rel.targetId).toContain('Loggable');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Qualified mixin argument — `::` must not corrupt the __heritage__ marker (#1982).
|
|
//
|
|
// `class Consumer; include Outer::Mixin; end` — the `::` in `arg.text`
|
|
// (`Outer::Mixin`) collided with the ':'-delimited __heritage__ marker field
|
|
// separator (`__heritage__:include:Outer::Mixin:Consumer`), so emitRubyMixinEdges
|
|
// mis-split it and dropped the edge. The marker now embeds the dotted form
|
|
// (`Outer.Mixin`), which both parses correctly and matches the mixin def's
|
|
// qualifiedName. Registry-primary only.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby qualified mixin arg — IMPLEMENTS not corrupted by :: (issue #1982)', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-mixin'), () => {});
|
|
}, 60000);
|
|
|
|
it('emits Consumer -IMPLEMENTS-> Outer.Mixin for include Outer::Mixin (R2)', () => {
|
|
expect(findDanglingEdges(result, ['IMPLEMENTS'])).toEqual([]);
|
|
const impl = getRelationships(result, 'IMPLEMENTS');
|
|
const e = impl.find((x) => x.target === 'Mixin');
|
|
expect(e, 'IMPLEMENTS -> Mixin (qualified mixin arg)').toBeDefined();
|
|
// KTD3: discriminate on the resolved node id (the pre-fix bug dropped the edge).
|
|
expect(e!.rel.sourceId).toContain('Consumer');
|
|
expect(e!.rel.targetId).toContain('Mixin');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inline constructor receiver: Service.new.do_work (#2708)
|
|
// Ruby spells construction as a selector on the class, with or without an
|
|
// argument list, so both `Service.new.do_work` and `Service.new(1).do_work`
|
|
// have to type the receiver as an instance of Service.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby inline constructor receiver resolution', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(
|
|
path.join(FIXTURES, 'ruby-inline-constructor-receiver'),
|
|
() => {},
|
|
);
|
|
}, 60000);
|
|
|
|
it('resolves Service.new.do_work and Service.new(1).do_work to Service#do_work', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
for (const source of ['route_inline', 'route_inline_args']) {
|
|
const call = calls.find((c) => c.source === source && c.target === 'do_work');
|
|
expect(call, `${source} -> do_work`).toMatchObject({
|
|
source,
|
|
target: 'do_work',
|
|
targetFilePath: 'lib/svc.rb',
|
|
});
|
|
}
|
|
});
|
|
|
|
it('keeps the two-step spelling resolving to Service#do_work', () => {
|
|
const calls = getRelationships(result, 'CALLS');
|
|
const twoStep = calls.find((c) => c.source === 'route_twostep' && c.target === 'do_work');
|
|
expect(twoStep).toMatchObject({ target: 'do_work', targetFilePath: 'lib/svc.rb' });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Construction selector vs. an ordinary member named `new` (#2708 follow-up)
|
|
// `Factory.new` constructs, but `factory.new` calls an instance method named
|
|
// `new`, and a class-level `new` carrying a recorded return type must keep it.
|
|
// The selector rule is a fallback behind the return-type lookup, not a
|
|
// short-circuit ahead of it.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Ruby construction selector vs. a real `new` member', () => {
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-construction-selector'), () => {});
|
|
}, 60000);
|
|
|
|
it('treats `Factory.new.run` as construction — Factory#run', () => {
|
|
const call = getRelationships(result, 'CALLS').find(
|
|
(c) => c.source === 'via_class_constant' && c.target === 'run',
|
|
);
|
|
expect(call).toMatchObject({ target: 'run' });
|
|
expect(call!.rel.targetId).toContain('Factory');
|
|
});
|
|
|
|
it('resolves `factory.new.run` through the instance method — Product#run', () => {
|
|
const call = getRelationships(result, 'CALLS').find(
|
|
(c) => c.source === 'via_instance' && c.target === 'run',
|
|
);
|
|
expect(call).toMatchObject({ target: 'run' });
|
|
expect(call!.rel.targetId).toContain('Product');
|
|
});
|
|
|
|
// KNOWN LIMITATION, asserted so a future change to it is deliberate: a
|
|
// class-level `def self.new` returning another type is still read as
|
|
// construction. The scope model records no staticness per member, so
|
|
// `def new` and `def self.new` are indistinguishable at this layer —
|
|
// distinguishing them needs the provider to record it first.
|
|
it('reads an overridden class-level `new` as construction (documented limitation)', () => {
|
|
const call = getRelationships(result, 'CALLS').find(
|
|
(c) => c.source === 'via_annotated_return' && c.target === 'run',
|
|
);
|
|
expect(call).toMatchObject({ target: 'run' });
|
|
expect(call!.rel.targetId).toContain('Annotated');
|
|
});
|
|
});
|