feat: DI-aware call resolution with nominal type matching for TypeScript/NestJS#39
Conversation
* feat: DI-aware call resolution for TypeScript/NestJS codebases The parser couldn't resolve dependency-injected service calls like `this.callService.getById()` because it didn't know that `callService` is an instance of `CallService`. This caused the agentic enhancer to miss critical authorization checks in service layers, producing false positive vulnerability findings. Changes: - typescript_analyzer.js: Extract constructor parameter types as `constructorDeps` metadata on class methods using ts-morph AST - dependency_resolver.js: Use constructorDeps for DI-aware resolution in _resolveMethodCall, with prefix matching for versioned implementations (e.g., CallService -> CallServiceV1) - Agentic enhancer: Add forward-tracing instructions to the prompt so the agent traces into called functions for auth/validation checks - Agentic enhancer: Add get_static_dependencies tool to surface parsed call graph data to the exploration agent - Agentic enhancer: Pass static deps to tool executor before analysis Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add tests for DI-aware call resolution and enhancer tools - test_di_resolution.py: Tests constructor deps extraction from TypeScript AST and DI-aware method resolution in call graphs, including versioned implementations and false positive prevention - test_enhancer_tools.py: Tests resolve_dependencies and the get_static_dependencies tool via ToolExecutor Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
fix(tests): replace missing run_utf8 import with subprocess.run test_di_resolution.py imported `run_utf8` from `utilities.file_io`, which does not exist in this repo. The import made the test module unimportable and broke pytest collection for the file (and any wider collection that included it). Mirror the helper used in test_js_parser.py and call subprocess.run directly. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> @
Manual verificationSample NestJS-style TypeScript: @Injectable()
class UserService {
findById(id: string) { return null; }
}
class UserController {
constructor(private userService: UserService) {}
get(id: string) { return this.userService.findById(id); }
}
|
Local test resultsBuilt a tiny inline NestJS-style fixture with a constructor-injected service and ran the JS analyzer + unit_generator from this branch. Fixture ( // user.service.ts
export class UserService {
findById(id: string) { return { id, name: "alice" }; }
}
// user.controller.ts
import { UserService } from "./user.service";
export class UserController {
constructor(private userService: UserService) {}
get(id: string) { return this.userService.findById(id); }
}Commands run: Outcome:
Side note (unrelated to this PR): there is a self-edge ( |
ar7casper
left a comment
There was a problem hiding this comment.
Substantive feedback on Track A inline below. Before getting into specifics, a structural ask:
Could you split this PR into two? The DI parser work (parsers/javascript/*) and the agentic enhancer prompt rework (utilities/agentic_enhancer/*) are conceptually independent — the prompt change works for every language regardless of whether DI metadata is available, and the parser change benefits the call graph regardless of how the LLM consumes it. They have very different risk profiles too:
- Parser changes are deterministically testable (does the call graph have the right edges? — your unit tests cover this well).
- Prompt changes affect classification across every scan, every language, every unit. We can't tell from unit tests whether the new "trace forward into callees" steps and the "delegated security controls" classification branch make false-positive rates better or worse — that needs a before/after eval on a known dataset.
Splitting would let the parser work merge once the inline-comment discussion settles, and Track B can run through an eval pass independently. If splitting isn't appealing, could you run one before/after eval comparing verdict distributions and post the result here so we have a sanity check before the prompt rewrite hits production?
This PR also addresses one slice of broader JS/TS parser improvements we want to land — route handler detection, decorator extraction, import resolution gaps. Those are out of scope here but are natural follow-ups, which is another reason to keep this PR tightly scoped to the parser DI work.
Agentic enhancer changes (get_static_dependencies tool, prompt rewrite, resolve_dependencies) moved to feat/issue16-07-ts-di-enhancer for independent review and eval. This branch now contains only the DI-aware parser changes as requested in the PR #39 review. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- dependency_resolver.js: prefix matching now collects all candidates before returning; skips resolution when multiple classes share the prefix (e.g. CallService matches both CallServiceV1 and CallServiceMock) to preserve the resolver's no-false-positive property - typescript_analyzer.js: strip generic parameters before PascalCase test so Repository<User> resolves as Repository (covers NestJS TypeORM) - typescript_analyzer.js: document the single-constructor assumption - test_di_resolution.py: add test for ambiguous prefix case Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…hing
Move constructorDeps from per-method entries to a class-level `classes`
table (className -> { constructorDeps, baseTypes }), matching the output
schema of the Python, PHP, Ruby, and Zig parsers. This eliminates N
redundant copies per class and provides a single source of truth.
Also extract implements/extends clauses as baseTypes and use them in the
resolver for nominal type matching. Resolution priority is now:
1. Exact class name match (existing)
2. Exact injected type match (existing)
3. Nominal: unique class that implements/extends the type
4. Prefix: unambiguous prefix match (existing, last resort)
Steps 3 and 4 both return null when multiple candidates match to
preserve the no-false-positive property.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ar7casper
left a comment
There was a problem hiding this comment.
Round-2 review after the split + the round-1 feedback commits.
First, thanks for the substantive response to round-1. You addressed 5 of 6 inline comments + the structural ask cleanly:
| Round-1 comment | Status |
|---|---|
Loose startsWith, multiple matches → silent first-wins |
✅ Fixed in 1bc1331 — collects all candidates, returns null on ambiguity. New test test_ambiguous_prefix_skips_resolution. |
No implements/extends verification |
✅ Fixed in f1d6060 — extracts baseTypes, new nominal-type-match priority 3. New tests for implements/extends extraction + ambiguity. |
Generics Repository<User> skipped |
✅ Fixed — typeName.replace(/<.*$/, '') applied in 3 places. New test test_generic_implements_stripped. |
Setter/property/@Inject injection not handled |
⏭️ Deferred (was a follow-up suggestion, not a blocker). Fine. |
getConstructors()[0] overload assumption |
✅ Documented at line 251. |
constructorDeps duplicated on every method |
✅ Refactored into class-level classes[className] table. Tests assert methods don't carry it. |
| Top-level: split into 2 PRs | ✅ Done in d0e2214; Track B is now #60. |
The nominal-type-match strategy is a real architectural improvement over the suggestion in my comment — well done.
One Medium and three Lows on the new code:
🟡 Medium — Architecture · parsers/javascript/typescript_analyzer.js:274 + parsers/javascript/dependency_resolver.js:278
Issue: this.classes is keyed by class name only, not file-qualified. Compare to the Python parser (function_extractor.py:511): self.classes[class_id] = class_data where class_id includes file path.
A codebase with two UserController classes in different files (e.g., admin/UserController.ts and v2/UserController.ts, common in NestJS multi-module apps) will have last-write-wins behavior in this.classes. The dependency_resolver._resolveMethodCall then looks up this.classes[callerFunc.className] — gets the wrong class entry for everyone but the last-parsed UserController.
Concrete failure mode:
- File A:
UserControllerwithconstructor(private fooService: FooService) - File B:
UserControllerwithconstructor(private barService: BarService) - File B parsed last →
this.classes["UserController"]= file-B's entry - File A's
this.fooService.method()calls: resolver looks up classes["UserController"] (file-B), finds nofooServicedep, returns null. Silent miss of a real edge.
No test covers this case.
Suggestion: key this.classes by ${relativePath}:${className} (file-qualified, matches the Python convention). Update _resolveMethodCall to construct the file-qualified key from callerFile + callerFunc.className. Add a regression test for the same-name-different-file case.
Narrow in real codebases that follow one-class-per-file conventions, but a real correctness gap for multi-module / scoped-name codebases.
🟢 Low — PR description stale
PR body still says: "Updating the agentic enhancer to consume the new metadata when resolving call edges." That sentence describes Track B which is now in #60. Worth updating the body to reflect the parser-DI-only scope.
🟢 Low — Scope creep · agentic_enhancer/repository_index.py (6-line change)
The diff includes a benign cleanup converting with open(...) as f: json.load(f) → read_json(...) from utilities.file_io. Unrelated to DI scope. Either move to a separate cleanup PR, or note in the body that this collateral change is included. Not blocking but dilutes the diff.
🟢 Low — Commit message overstates schema parity
f1d6060 commit message: "matching the output schema of the Python, PHP, Ruby, and Zig parsers." Partially accurate — they all have a top-level classes key, but the Python/PHP/Zig versions include name, file_path, start_line, end_line, methods. JS version only has constructorDeps and baseTypes. Worth being precise about what's matched (top-level key) and what's not (full schema). Not actionable, just a record.
Verdict: ready to merge with the Medium worth resolving first.
The className-collision case is narrow but real — file-qualifying the key is a small change and matches the existing convention in other parsers. The 3 Lows are housekeeping.
…me collisions Keying this.classes by className alone was last-write-wins for NestJS multi-module apps where two files declare a class with the same name. The caller lookup in _resolveMethodCall then fetched the wrong class entry, silently dropping DI edges for the non-last-parsed class. Change both the analyzer (typescript_analyzer.js) and the resolver (dependency_resolver.js) to key by "filePath:className", matching the convention used by function IDs throughout the codebase. The classesByBaseType index now stores qualified keys; the nominal-match filter constructs the same key from each candidate's funcId prefix. Adds a regression test that asserts both entries are present after parsing two same-named classes, and that the resolver produces the correct call edges for each (not the other's service). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Extends DI extraction to cover three additional patterns: - @Inject / @InjectRepository / any @Inject* decorator — type comes from the TypeScript property type annotation (generics stripped as before) - inject(SvcType) functional injection (Angular's inject() API) — type taken from the first call argument Extracted into classEntry.fieldDeps ("filePath:className" -> {propName: typeName}), kept separate from constructorDeps so callers can distinguish the injection style. The resolver now checks both constructorDeps and fieldDeps when resolving this.prop.method() calls. The guard is relaxed to enter the DI path for classes that have only fieldDeps (no constructor injection). Tests: @Inject decorator, @InjectRepository decorator (generic type stripped to Repository), inject() functional form, non-@Inject decorators ignored, full resolver round-trip for field-injected calls. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
|
Thanks for the thorough round-2 review. All items addressed:
|
|
@ar7casper responded here |
Summary
The TypeScript parser didn't extract constructor parameter types or field injection metadata, so dependency-injected service calls (e.g.,
this.userService.findById()) ended up unresolved in the call graph. This caused the agentic enhancer to miss critical authorization checks in service layers.This PR adds DI-aware resolution by:
constructorDeps,fieldDeps, andbaseTypes(implements/extends) per class into aclassestable in the analyzer outputthis.service.method()to the correct target classInjection patterns covered:
constructor(private svc: SvcType)@Inject,@InjectRepository, any@Inject*decorator — type from the TypeScript annotationprivate svc = inject(SvcType)(Angular'sinject()API)Resolution priority:
implementsorextendsthe injected typeCallService→CallServiceV1); skipped if multiple candidates matchSteps 3 and 4 both return
nullon ambiguity, preserving the resolver's no-false-positive property.Analyzer output schema change
Adds a top-level
classesfield toanalyzer_output.json. The key is"filePath:className"(file-qualified, matching the convention used for function IDs):{ "classes": { "src/call.resolver.ts:CallResolver": { "constructorDeps": { "callService": "CallService" }, "baseTypes": ["BaseResolver"] }, "src/user.service.ts:UserService": { "fieldDeps": { "userRepo": "Repository" }, "baseTypes": ["IUserService"] } } }constructorDepsandfieldDepsare no longer stored on individual method entries. The file-qualified key prevents last-write-wins collisions in multi-module apps where two files define a class with the same name.Agentic enhancer changes (surfacing this data to the LLM via
get_static_dependenciesand updating the prompt) are in #60.Collateral:
utilities/agentic_enhancer/repository_index.pyincludes a 6-line cleanup convertingwith open(...) as f: json.load(f)toread_json(...)fromutilities.file_io. Unrelated to DI scope but included as a minor housekeeping change.Addresses item 7 from #16 (does not close the issue).
Test plan
@Inject/@InjectRepositoryfield dep resolves correctlyinject(SvcType)functional injection resolves correctlyimplements→ nominal resolutionRepository<User>) → resolves asRepositoryclassestable; each resolves to the correct service@Injectdecorators not captured as field deps