Skip to content

feat: DI-aware call resolution with nominal type matching for TypeScript/NestJS#39

Open
joshbouncesecurity wants to merge 7 commits into
knostic:release/2026-05-12from
joshbouncesecurity:feat/issue16-07-ts-di
Open

feat: DI-aware call resolution with nominal type matching for TypeScript/NestJS#39
joshbouncesecurity wants to merge 7 commits into
knostic:release/2026-05-12from
joshbouncesecurity:feat/issue16-07-ts-di

Conversation

@joshbouncesecurity
Copy link
Copy Markdown
Contributor

@joshbouncesecurity joshbouncesecurity commented May 4, 2026

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:

  • Extracting constructorDeps, fieldDeps, and baseTypes (implements/extends) per class into a classes table in the analyzer output
  • Using those deps in the resolver to map this.service.method() to the correct target class

Injection patterns covered:

  • Constructor injection: constructor(private svc: SvcType)
  • Field/decorator injection: @Inject, @InjectRepository, any @Inject* decorator — type from the TypeScript annotation
  • Functional injection: private svc = inject(SvcType) (Angular's inject() API)

Resolution priority:

  1. Exact class name match (existing)
  2. Exact injected type match (constructor or field dep)
  3. New: Nominal match — a unique class that implements or extends the injected type
  4. Prefix match — last resort for versioned names (e.g., CallServiceCallServiceV1); skipped if multiple candidates match

Steps 3 and 4 both return null on ambiguity, preserving the resolver's no-false-positive property.

Analyzer output schema change

Adds a top-level classes field to analyzer_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"]
    }
  }
}

constructorDeps and fieldDeps are 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_dependencies and updating the prompt) are in #60.

Collateral: utilities/agentic_enhancer/repository_index.py includes a 6-line cleanup converting with open(...) as f: json.load(f) to read_json(...) from utilities.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

  • Constructor-injected service call resolves to correct class
  • @Inject / @InjectRepository field dep resolves correctly
  • inject(SvcType) functional injection resolves correctly
  • Injection type is an interface and impl uses implements → nominal resolution
  • Ambiguous implements (two classes implement same interface) → no resolution
  • Generic type annotation (Repository<User>) → resolves as Repository
  • Two files with same class name → both entries in classes table; each resolves to the correct service
  • Non-@Inject decorators not captured as field deps
  • Sample without DI: parser output unchanged
  • Existing JS/TS tests still pass

* 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]>
@
@joshbouncesecurity
Copy link
Copy Markdown
Contributor Author

Manual verification

Sample 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); }
}
  • openant parse <repo-with-above>: call graph for UserController.get includes an edge to UserService.findById (was empty before this PR).
  • Multi-dep constructor (constructor(private a: A, private b: B)): edges resolve for both this.a.x() and this.b.y().
  • Plain JS file (no type annotations): no crash; parser proceeds, no DI edges (graceful degradation).
  • Primitive-typed param (constructor(private name: string)): not treated as a DI target (the type guard skips primitives).
  • On a real NestJS app: visual sanity-check that this.<svc>.<method>() calls resolve to the right class in dataset.json.

@joshbouncesecurity
Copy link
Copy Markdown
Contributor Author

Local test results

Built a tiny inline NestJS-style fixture with a constructor-injected service and ran the JS analyzer + unit_generator from this branch.

Fixture (.worktrees/_fixtures/nest_di/):

// 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:

# Note: had to feed the analyzer a forward-slash file_list to bypass an unrelated
# Windows path bug in this branch (fixed by #46). On Linux/macOS test_pipeline.py
# would have driven this end-to-end with no extra steps.
node typescript_analyzer.js <repo> --files-from posix_list.txt --output analyzer_output.json
node unit_generator.js analyzer_output.json --output dataset.json

Outcome:

  • Analyzer extracts new constructorDeps metadata: 'constructorDeps': {'userService': 'UserService'} on UserController.get
  • In the resulting dataset.json, UserController.get.metadata.direct_calls includes 'user.service.ts:UserService.findById' — the DI-resolved edge that was missing pre-fix ✅
  • UserService.findById.metadata.direct_callers correspondingly includes 'user.controller.ts:UserController.get'
  • Did not separately exercise multi-dep / primitive-typed-param / plain-JS / real-NestJS-app paths — covered by the unit tests in the diff.

Side note (unrelated to this PR): there is a self-edge (UserController.get -> UserController.get) in the dataset that looks spurious. May be worth a quick look but doesn't affect this PR's correctness.

@joshbouncesecurity joshbouncesecurity marked this pull request as ready for review May 12, 2026 07:00
Copy link
Copy Markdown
Collaborator

@ar7casper ar7casper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread libs/openant-core/parsers/javascript/dependency_resolver.js Outdated
Comment thread libs/openant-core/parsers/javascript/dependency_resolver.js Outdated
Comment thread libs/openant-core/parsers/javascript/typescript_analyzer.js
Comment thread libs/openant-core/parsers/javascript/typescript_analyzer.js
Comment thread libs/openant-core/parsers/javascript/typescript_analyzer.js
Comment thread libs/openant-core/parsers/javascript/typescript_analyzer.js Outdated
@joshbouncesecurity joshbouncesecurity marked this pull request as draft May 12, 2026 08:50
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]>
@joshbouncesecurity joshbouncesecurity changed the title feat: DI-aware call resolution for TypeScript/NestJS codebases feat: constructor-DI-aware call resolution for TypeScript/NestJS codebases May 12, 2026
- 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]>
@joshbouncesecurity joshbouncesecurity changed the title feat: constructor-DI-aware call resolution for TypeScript/NestJS codebases feat: constructor-DI-aware call resolution with nominal type matching for TypeScript/NestJS May 12, 2026
Copy link
Copy Markdown
Collaborator

@ar7casper ar7casper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: UserController with constructor(private fooService: FooService)
  • File B: UserController with constructor(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 no fooService dep, 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]>
@joshbouncesecurity joshbouncesecurity marked this pull request as ready for review May 12, 2026 14:58
@joshbouncesecurity joshbouncesecurity marked this pull request as draft May 12, 2026 15:05
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]>
@joshbouncesecurity joshbouncesecurity marked this pull request as ready for review May 12, 2026 15:08
@joshbouncesecurity joshbouncesecurity changed the title feat: constructor-DI-aware call resolution with nominal type matching for TypeScript/NestJS feat: DI-aware call resolution with nominal type matching for TypeScript/NestJS May 12, 2026
@joshbouncesecurity
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough round-2 review. All items addressed:

  • Medium: classes table now keyed by filePath:className — no more last-write-wins for same-named classes across files. Regression test covers both the table and resolver behaviour.
  • Low (stale description): Updated.
  • Low (scope creep): Noted the repository_index.py cleanup in the PR body.
  • Bonus: went ahead and added field/property injection support (@Inject* decorators + Angular's inject()) while we were in here — seemed like the right time.

@joshbouncesecurity
Copy link
Copy Markdown
Contributor Author

@ar7casper responded here

@joshbouncesecurity joshbouncesecurity changed the base branch from master to release/2026-05-12 May 12, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants