diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc5086a1..065a3d968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,38 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). banner stays accurate at any debounce value because it's per-file, not a static "wait N ms" instruction. +- **Angular framework support — modules, selectors, templates, routes, and + control-flow blocks are now statically extracted and wired into the knowledge + graph.** Closes the static-extraction holes for Angular projects so + `trace`/`explore`/`impact` work without falling back to `Read`. All + synthesized edges carry `provenance:'heuristic'` and a + `metadata.synthesizedBy:'angular-*'` channel tag. Six phases: + **(1) NgModule + standalone @Component metadata** — + `@NgModule({ declarations, imports, providers, exports, bootstrap })` and + standalone `@Component({ imports })` emit class→class edges. + **(2) Selector → component/directive** — `` tag selectors, + `[appHighlight]` bracketed attributes, bare attribute directives, and + compound selectors (`a[href]`, `i.anticon`) are matched against a + per-project selector index built from `@Component` / `@Directive` metadata; + generic HTML tags miss the gate and drop silently. + **(3) Template binding → owner-class member** — `(event)="handler($event)"`, + `[prop]="expr"`, `[(banana)]="x"` two-way binding, `*ngFor` / `*ngIf` / + `*ngSwitch` structural directives, and `{{ interpolation }}` are resolved to + methods/properties inside the owner class. + **(4) Router configs** — `Routes` / `Route[]` arrays, `RouterModule.forRoot` / + `forChild`, `provideRouter`, nested `children: [...]`, and + `loadChildren` / `loadComponent` dynamic-import forms (`.then(m => m.X)`, + destructured `({ X }) => X`, `async (await import()).X`). + **(5) Angular 17+ control-flow blocks** — `@if` / `@else if`, `@for` / + `@switch` / `@case`, `@defer`, and `@let` (v18+ template variables) with a + balanced-paren reader for nested expressions. + **(6) Route guards / resolvers** — `canActivate` / `canActivateChild` / + `canDeactivate` / `canMatch` / `canLoad` arrays and `resolve: { key: Resolver }` + objects. Validated on a 4,296-file Angular+Vue hybrid project (1,512 + component HTML templates): 22,672 Angular synthesized edges (12.4% of total), + covering NgModule wiring, selector matching (including directives), template + bindings, and route→component references. + - **Objective-C indexing — `.m`, `.mm`, and content-sniffed `.h` files now parse with full structural extraction (#165).** Adds a tree-sitter-objc extractor that produces `class` nodes for `@interface` / `@implementation` diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 3e9ef12eb..c82c38ec3 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -805,3 +805,567 @@ describe('Java anonymous-class override synthesis — end-to-end', () => { cg.close(); }); }); +describe('Angular end-to-end framework extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('synthesizes NgModule declarations / imports / providers edges to their target classes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-ngmodule-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'ng-fixture', + dependencies: { '@angular/core': '17.0.0', '@angular/common': '17.0.0' }, + }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/bookmark.service.ts'), + `import { Injectable } from '@angular/core';\n` + + `@Injectable({ providedIn: 'root' })\n` + + `export class BookmarkService {\n` + + ` list() { return []; }\n` + + `}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/bookmarks.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'app-bookmarks', template: '
x
' })\n` + + `export class BookmarksComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/shared.module.ts'), + `import { NgModule } from '@angular/core';\n` + + `@NgModule({})\n` + + `export class SharedModule {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/bookmarks.module.ts'), + `import { NgModule } from '@angular/core';\n` + + `import { BookmarksComponent } from './bookmarks.component';\n` + + `import { BookmarkService } from './bookmark.service';\n` + + `import { SharedModule } from './shared.module';\n` + + `@NgModule({\n` + + ` declarations: [BookmarksComponent],\n` + + ` imports: [SharedModule],\n` + + ` providers: [BookmarkService],\n` + + `})\n` + + `export class BookmarksModule {}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const classes = cg.getNodesByKind('class'); + const bookmarksModule = classes.find((n) => n.name === 'BookmarksModule'); + const bookmarksComponent = classes.find((n) => n.name === 'BookmarksComponent'); + const bookmarkService = classes.find((n) => n.name === 'BookmarkService'); + const sharedModule = classes.find((n) => n.name === 'SharedModule'); + expect(bookmarksModule, 'BookmarksModule class node').toBeDefined(); + expect(bookmarksComponent, 'BookmarksComponent class node').toBeDefined(); + expect(bookmarkService, 'BookmarkService class node').toBeDefined(); + expect(sharedModule, 'SharedModule class node').toBeDefined(); + + const outgoing = cg.getOutgoingEdges(bookmarksModule!.id); + const synthesized = outgoing.filter((e) => e.provenance === 'heuristic'); + + const declares = synthesized.find((e) => e.target === bookmarksComponent!.id); + expect(declares, 'BookmarksModule → BookmarksComponent (declarations)').toBeDefined(); + expect(declares!.metadata?.synthesizedBy).toBe('angular-declarations'); + + const imports = synthesized.find((e) => e.target === sharedModule!.id); + expect(imports, 'BookmarksModule → SharedModule (imports)').toBeDefined(); + expect(imports!.metadata?.synthesizedBy).toBe('angular-imports'); + + const providers = synthesized.find((e) => e.target === bookmarkService!.id); + expect(providers, 'BookmarksModule → BookmarkService (providers)').toBeDefined(); + expect(providers!.metadata?.synthesizedBy).toBe('angular-providers'); + + cg.close(); + }); + + it('synthesizes → component edges from inline templates', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-selector-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'ng-sel', dependencies: { '@angular/core': '17.0.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/icon.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'app-icon', standalone: true, template: '' })\n` + + `export class IconComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/card.component.ts'), + `import { Component } from '@angular/core';\n` + + `import { IconComponent } from './icon.component';\n` + + `@Component({\n` + + ` selector: 'app-card', standalone: true, imports: [IconComponent],\n` + + ` template: '
',\n` + + `})\n` + + `export class CardComponent {}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const card = cg.getNodesByKind('class').find((n) => n.name === 'CardComponent'); + const icon = cg.getNodesByKind('class').find((n) => n.name === 'IconComponent'); + expect(card && icon).toBeTruthy(); + + const sel = cg + .getOutgoingEdges(card!.id) + .find((e) => e.target === icon!.id && e.metadata?.synthesizedBy === 'angular-selector'); + expect(sel, 'CardComponent → IconComponent via in template').toBeDefined(); + expect(sel!.metadata?.via).toBe('app-icon'); + + cg.close(); + }); + + it('synthesizes templateUrl-based selector edges (.html sibling)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-tplurl-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'ng-tplurl', dependencies: { '@angular/core': '17.0.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/badge.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'app-badge', standalone: true, template: '' })\n` + + `export class BadgeComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/header.component.ts'), + `import { Component } from '@angular/core';\n` + + `import { BadgeComponent } from './badge.component';\n` + + `@Component({\n` + + ` selector: 'app-header', standalone: true, imports: [BadgeComponent],\n` + + ` templateUrl: './header.component.html',\n` + + `})\n` + + `export class HeaderComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/header.component.html'), + `
\n

App

\n \n
\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const header = cg.getNodesByKind('class').find((n) => n.name === 'HeaderComponent'); + const badge = cg.getNodesByKind('class').find((n) => n.name === 'BadgeComponent'); + expect(header && badge).toBeTruthy(); + + const sel = cg + .getOutgoingEdges(header!.id) + .find((e) => e.target === badge!.id && e.metadata?.synthesizedBy === 'angular-selector'); + expect(sel, 'HeaderComponent → BadgeComponent via external templateUrl').toBeDefined(); + + cg.close(); + }); + + it('synthesizes attribute-directive [appHighlight] selector edges', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-attr-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'ng-attr', dependencies: { '@angular/core': '17.0.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/highlight.directive.ts'), + `import { Directive } from '@angular/core';\n` + + `@Directive({ selector: '[appHighlight]', standalone: true })\n` + + `export class HighlightDirective {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/host.component.ts'), + `import { Component } from '@angular/core';\n` + + `import { HighlightDirective } from './highlight.directive';\n` + + `@Component({\n` + + ` selector: 'app-host', standalone: true, imports: [HighlightDirective],\n` + + ` template: '

x

',\n` + + `})\n` + + `export class HostComponent {}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const host = cg.getNodesByKind('class').find((n) => n.name === 'HostComponent'); + const dir = cg.getNodesByKind('class').find((n) => n.name === 'HighlightDirective'); + expect(host && dir).toBeTruthy(); + + const sel = cg + .getOutgoingEdges(host!.id) + .find((e) => e.target === dir!.id && e.metadata?.synthesizedBy === 'angular-selector'); + expect(sel, 'HostComponent → HighlightDirective via [appHighlight]').toBeDefined(); + expect(sel!.metadata?.via).toBe('[appHighlight]'); + + cg.close(); + }); + + it('synthesizes template-binding → owner-class-member edges (event / interpolation / structural)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-tpl-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'ng-tpl', dependencies: { '@angular/core': '17.0.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/form.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({\n` + + ` selector: 'app-form', standalone: true,\n` + + ` template: \`\n` + + `

{{ userName }}

\n` + + ` \n` + + `
  • {{ item }}
  • \n` + + ` \`,\n` + + `})\n` + + `export class FormComponent {\n` + + ` userName = 'alice';\n` + + ` items: string[] = [];\n` + + ` isDisabled = false;\n` + + ` onSubmit() { /* ... */ }\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const form = cg.getNodesByKind('class').find((n) => n.name === 'FormComponent'); + expect(form, 'FormComponent class node').toBeDefined(); + + const tplEdges = cg + .getOutgoingEdges(form!.id) + .filter((e) => e.metadata?.synthesizedBy === 'angular-template'); + + // Collect the names of every node a template edge points at. The TS extractor + // emits class fields as `method` (per typescript.ts methodTypes) — we union + // every reasonable member kind to avoid being too narrow. + const memberNodes = [ + ...cg.getNodesByKind('method'), + ...cg.getNodesByKind('property'), + ...cg.getNodesByKind('field'), + ...cg.getNodesByKind('variable'), + ]; + const targetNames = new Set(); + for (const e of tplEdges) { + const node = memberNodes.find((n) => n.id === e.target); + if (node) targetNames.add(node.name); + } + expect(targetNames.has('onSubmit'), 'event binding → onSubmit').toBe(true); + expect(targetNames.has('userName'), 'interpolation → userName').toBe(true); + expect(targetNames.has('isDisabled'), 'property binding → isDisabled').toBe(true); + expect(targetNames.has('items'), 'structural *ngFor → items').toBe(true); + + cg.close(); + }); + + it('emits route nodes and route→component / route→lazy-module references end-to-end', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-routes-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'ng-routes', + dependencies: { '@angular/core': '17.0.0', '@angular/router': '17.0.0' }, + }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/home.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'app-home', standalone: true, template: '

    home

    ' })\n` + + `export class HomeComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/admin.module.ts'), + `import { NgModule } from '@angular/core';\n` + + `@NgModule({})\n` + + `export class AdminModule {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/app.routes.ts'), + `import { Routes } from '@angular/router';\n` + + `import { HomeComponent } from './home.component';\n` + + `export const routes: Routes = [\n` + + ` { path: '', component: HomeComponent },\n` + + ` { path: 'admin', loadChildren: () => import('./admin.module').then(m => m.AdminModule) },\n` + + `];\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const routes = cg.getNodesByKind('route'); + const root = routes.find((n) => n.name === '/'); + const admin = routes.find((n) => n.name === '/admin'); + expect(root, '/ route node').toBeDefined(); + expect(admin, '/admin route node').toBeDefined(); + + const home = cg.getNodesByKind('class').find((n) => n.name === 'HomeComponent'); + const adminMod = cg.getNodesByKind('class').find((n) => n.name === 'AdminModule'); + expect(home && adminMod).toBeTruthy(); + + const rootToHome = cg.getOutgoingEdges(root!.id).find((e) => e.target === home!.id); + expect(rootToHome, 'route(/) → HomeComponent').toBeDefined(); + expect(rootToHome!.kind).toBe('references'); + + const adminToMod = cg.getOutgoingEdges(admin!.id).find((e) => e.target === adminMod!.id); + expect(adminToMod, 'route(/admin) → AdminModule (lazy)').toBeDefined(); + expect(adminToMod!.kind).toBe('references'); + + cg.close(); + }); + + it('joins nested children paths into a single route URL', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-children-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'ng-children', + dependencies: { '@angular/core': '17.0.0', '@angular/router': '17.0.0' }, + }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + for (const name of ['AdminLayout', 'UsersComponent', 'ReportsComponent']) { + const file = name.replace(/Component$|Layout$/, (s) => s === 'Layout' ? '.layout' : '.component').toLowerCase(); + fs.writeFileSync( + path.join(tmpDir, 'src/app', `${file}.ts`), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'x', standalone: true, template: '' })\n` + + `export class ${name} {}\n` + ); + } + fs.writeFileSync( + path.join(tmpDir, 'src/app/app.routes.ts'), + `import { Routes } from '@angular/router';\n` + + `import { AdminLayout } from './adminlayout.layout';\n` + + `import { UsersComponent } from './userscomponent.component';\n` + + `import { ReportsComponent } from './reportscomponent.component';\n` + + `export const routes: Routes = [\n` + + ` {\n` + + ` path: 'admin',\n` + + ` component: AdminLayout,\n` + + ` children: [\n` + + ` { path: 'users', component: UsersComponent },\n` + + ` { path: 'reports', component: ReportsComponent },\n` + + ` ],\n` + + ` },\n` + + `];\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const routeNames = cg.getNodesByKind('route').map((n) => n.name).sort(); + expect(routeNames).toEqual(['/admin', '/admin/reports', '/admin/users']); + + cg.close(); + }); + + it('catches Angular 17 block syntax: @if / @for / @switch', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ng17-blocks-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'p', dependencies: { '@angular/core': '17.0.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/list.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'l', standalone: true, template: \`\n` + + ` @if (loading) {

    ...

    }\n` + + ` @for (item of products; track item.id) {
  • {{ item.name }}
  • }\n` + + ` @switch (mode) {\n` + + ` @case ('a') { }\n` + + ` @default { }\n` + + ` }\n` + + `\` })\n` + + `export class ListComponent {\n` + + ` loading = true;\n` + + ` products: any[] = [];\n` + + ` mode = 'a';\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + const list = cg.getNodesByKind('class').find((n) => n.name === 'ListComponent')!; + const edges = cg.getOutgoingEdges(list.id).filter((e) => e.metadata?.synthesizedBy === 'angular-template'); + + const memberNodes = [ + ...cg.getNodesByKind('method'), + ...cg.getNodesByKind('property'), + ...cg.getNodesByKind('field'), + ...cg.getNodesByKind('variable'), + ]; + const captured = new Set(); + for (const e of edges) { + const n = memberNodes.find((m) => m.id === e.target); + if (n) captured.add(n.name); + } + expect(captured.has('loading'), '@if(loading) → loading').toBe(true); + expect(captured.has('products'), '@for(item of products) → products').toBe(true); + expect(captured.has('mode'), '@switch(mode) → mode').toBe(true); + + cg.close(); + }); + + it('catches @let v18 template variable expressions', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ng18-let-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'p', dependencies: { '@angular/core': '18.0.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/page.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'p', standalone: true, template: \`\n` + + ` @let displayName = user.name;\n` + + `

    {{ displayName }}

    \n` + + `\` })\n` + + `export class PageComponent {\n` + + ` user = { name: 'a' };\n` + + `}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + const page = cg.getNodesByKind('class').find((n) => n.name === 'PageComponent')!; + const edges = cg.getOutgoingEdges(page.id).filter((e) => e.metadata?.synthesizedBy === 'angular-template'); + const memberNodes = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('property'), ...cg.getNodesByKind('field'), ...cg.getNodesByKind('variable')]; + const captured = new Set(); + for (const e of edges) { + const n = memberNodes.find((m) => m.id === e.target); + if (n) captured.add(n.name); + } + // `user` is the only class member referenced (displayName is a template-local). + expect(captured.has('user'), '@let displayName = user.name → user').toBe(true); + + cg.close(); + }); + + it('emits route → guard / resolver references for canActivate / canMatch / resolve', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ng-guards-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'p', + dependencies: { '@angular/core': '17.0.0', '@angular/router': '17.0.0' }, + }) + ); + fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/auth.guard.ts'), + `import { Injectable } from '@angular/core';\n` + + `@Injectable({ providedIn: 'root' })\n` + + `export class AuthGuard { canActivate() { return true; } }\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/role.guard.ts'), + `import { Injectable } from '@angular/core';\n` + + `@Injectable({ providedIn: 'root' })\n` + + `export class RoleGuard { canActivate() { return true; } }\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/user.resolver.ts'), + `import { Injectable } from '@angular/core';\n` + + `@Injectable({ providedIn: 'root' })\n` + + `export class UserResolver { resolve() { return null; } }\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/home.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'h', standalone: true, template: '' })\n` + + `export class HomeComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app.routes.ts'), + `import { Routes } from '@angular/router';\n` + + `import { HomeComponent } from './home.component';\n` + + `import { AuthGuard } from './auth.guard';\n` + + `import { RoleGuard } from './role.guard';\n` + + `import { UserResolver } from './user.resolver';\n` + + `export const routes: Routes = [\n` + + ` {\n` + + ` path: 'home',\n` + + ` component: HomeComponent,\n` + + ` canActivate: [AuthGuard, RoleGuard],\n` + + ` resolve: { user: UserResolver },\n` + + ` },\n` + + `];\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const route = cg.getNodesByKind('route').find((n) => n.name === '/home')!; + const authGuard = cg.getNodesByKind('class').find((n) => n.name === 'AuthGuard')!; + const roleGuard = cg.getNodesByKind('class').find((n) => n.name === 'RoleGuard')!; + const userResolver = cg.getNodesByKind('class').find((n) => n.name === 'UserResolver')!; + + const out = cg.getOutgoingEdges(route.id); + expect(out.find((e) => e.target === authGuard.id), 'route → AuthGuard').toBeDefined(); + expect(out.find((e) => e.target === roleGuard.id), 'route → RoleGuard').toBeDefined(); + expect(out.find((e) => e.target === userResolver.id), 'route → UserResolver').toBeDefined(); + + // impact-style query: AuthGuard should know it's used by /home. + expect(cg.getCallers(authGuard.id).length, 'callers(AuthGuard) ≥ 1').toBeGreaterThanOrEqual(1); + + cg.close(); + }); + + it('synthesizes standalone-component imports edges (Angular v17+ shape)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-standalone-')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'ng-standalone-fixture', + dependencies: { '@angular/core': '17.0.0' }, + }) + ); + fs.mkdirSync(path.join(tmpDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/app/icon.component.ts'), + `import { Component } from '@angular/core';\n` + + `@Component({ selector: 'app-icon', standalone: true, template: '' })\n` + + `export class IconComponent {}\n` + ); + fs.writeFileSync( + path.join(tmpDir, 'src/app/page.component.ts'), + `import { Component } from '@angular/core';\n` + + `import { IconComponent } from './icon.component';\n` + + `@Component({\n` + + ` selector: 'app-page', standalone: true,\n` + + ` imports: [IconComponent],\n` + + ` template: '',\n` + + `})\n` + + `export class PageComponent {}\n` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const classes = cg.getNodesByKind('class'); + const page = classes.find((n) => n.name === 'PageComponent'); + const icon = classes.find((n) => n.name === 'IconComponent'); + expect(page).toBeDefined(); + expect(icon).toBeDefined(); + + const edge = cg + .getOutgoingEdges(page!.id) + .find((e) => e.target === icon!.id && e.provenance === 'heuristic'); + expect(edge, 'PageComponent → IconComponent (standalone imports)').toBeDefined(); + expect(edge!.metadata?.synthesizedBy).toBe('angular-imports'); + + cg.close(); + }); +}); diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index c0e874908..eb54d5a22 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1597,3 +1597,383 @@ export class UsersController { expect(references.map((r) => r.referenceName)).toEqual(['real']); }); }); + +import { angularResolver, __internal as angularInternal } from '../src/resolution/frameworks/angular'; + +describe('angularResolver — decorator scanning helpers', () => { + it('extracts bare identifiers from a metadata array field', () => { + const args = `{ declarations: [FooComponent, BarComponent], imports: [CommonModule] }`; + expect(angularInternal.extractFieldArrayIdentifiers(args, 'declarations')).toEqual([ + 'FooComponent', + 'BarComponent', + ]); + expect(angularInternal.extractFieldArrayIdentifiers(args, 'imports')).toEqual([ + 'CommonModule', + ]); + }); + + it('prefers useClass over the DI token in provider objects', () => { + const args = `{ providers: [{ provide: TOKEN, useClass: FooService }] }`; + expect(angularInternal.extractFieldArrayIdentifiers(args, 'providers')).toEqual([ + 'FooService', + ]); + }); + + it('falls back to the DI token when only useValue/useFactory is given', () => { + const args = `{ providers: [{ provide: API_URL, useValue: 'https://x' }] }`; + expect(angularInternal.extractFieldArrayIdentifiers(args, 'providers')).toEqual([ + 'API_URL', + ]); + }); + + it('strips trailing .forRoot(...) / .forChild(...) module calls', () => { + const args = `{ imports: [RouterModule.forRoot(routes), SharedModule.forChild()] }`; + expect(angularInternal.extractFieldArrayIdentifiers(args, 'imports')).toEqual([ + 'RouterModule', + 'SharedModule', + ]); + }); + + it('silently drops spreads and complex expressions', () => { + const args = `{ declarations: [...COMMON_DECLARATIONS, FooComponent, env.prod ? A : B] }`; + expect(angularInternal.extractFieldArrayIdentifiers(args, 'declarations')).toEqual([ + 'FooComponent', + ]); + }); + + it('isStandaloneComponent recognizes standalone: true', () => { + expect(angularInternal.isStandaloneComponent(`{ standalone: true, imports: [] }`)).toBe(true); + expect(angularInternal.isStandaloneComponent(`{ standalone: false }`)).toBe(false); + }); + + it('treats v17+ implicit-standalone (imports: present, no standalone field) as standalone', () => { + expect(angularInternal.isStandaloneComponent(`{ imports: [CommonModule] }`)).toBe(true); + }); + + it('does not flag a classic NgModule-only component as standalone', () => { + expect(angularInternal.isStandaloneComponent(`{ selector: 'app-foo', templateUrl: './foo.html' }`)).toBe(false); + }); + + it('extracts string-literal field values', () => { + const args = `{ selector: 'app-foo', templateUrl: './foo.component.html' }`; + expect(angularInternal.extractFieldString(args, 'selector')).toBe('app-foo'); + expect(angularInternal.extractFieldString(args, 'templateUrl')).toBe('./foo.component.html'); + }); + + it('returns null for non-literal field values (template: `inline...`) safely', () => { + // backtick literal IS captured (template: `
    ...
    `) + const args1 = `{ template: \`
    \` }`; + expect(angularInternal.extractFieldString(args1, 'template')).toBe('
    '); + // computed value isn't + const args2 = `{ template: getTemplate() }`; + expect(angularInternal.extractFieldString(args2, 'template')).toBe(null); + }); + + it('classAfterDecorator finds the class through stacked decorators + modifiers', () => { + const src = `@NgModule({}) @Other() export abstract class AdminModule {}`; + const end = src.indexOf(')') + 1; // end of first decorator's args + const cls = angularInternal.classAfterDecorator(src, end); + expect(cls?.className).toBe('AdminModule'); + }); + + it('classAfterDecorator returns null when no class follows (e.g. function decorator)', () => { + const src = `@SomeDecorator() function notAClass() {}`; + const end = src.indexOf(')') + 1; + expect(angularInternal.classAfterDecorator(src, end)).toBe(null); + }); + + it('resolveTemplatePath handles ./ and ../', () => { + expect( + angularInternal.resolveTemplatePath('src/app/foo.component.ts', './foo.component.html', '/proj') + ).toBe('src/app/foo.component.html'); + expect( + angularInternal.resolveTemplatePath('src/app/foo/foo.component.ts', '../templates/foo.html', '/proj') + ).toBe('src/app/templates/foo.html'); + }); + + it('splits selector lists and extracts tag + attribute variants', () => { + expect(angularInternal.splitSelectorList('app-foo, [appFoo]')).toEqual(['app-foo', '[appFoo]']); + expect(angularInternal.extractTagFromSelector('app-foo')).toBe('app-foo'); + expect(angularInternal.extractTagFromSelector('a[href]')).toBe(null); + expect(angularInternal.extractTagFromSelector('[appFoo]')).toBe(null); + expect(angularInternal.extractAttributeFromSelector('button[appFoo]')).toBe('appFoo'); + }); + + it('indexes compound selectors without degrading them to bare HTML tags', () => { + const owner = { + id: 'dir', + name: 'Dir', + qualifiedName: 'dir.ts::Dir', + kind: 'class', + filePath: 'dir.ts', + language: 'typescript', + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 0, + updatedAt: 1, + } as const; + + const entries = angularInternal.selectorEntriesForVariant( + 'img[src], a[href], i.anticon, [nz-icon], app-card', + owner + ); + expect(entries.map((e) => e.key)).toEqual([ + 'tagattr:img[src]', + 'tagattr:a[href]', + 'tagclass:i.anticon', + 'attr:nz-icon', + 'tag:app-card', + ]); + }); + + it('parses template tag attributes for selector matching', () => { + const parsed = angularInternal.parseTemplateTagAttributes( + ` [nz-icon] class="anticon anticon-user" href="/x" [src]="iconUrl"` + ); + expect([...parsed.attrs].sort()).toEqual(['class', 'href', 'nz-icon', 'src'].sort()); + expect([...parsed.classes].sort()).toEqual(['anticon', 'anticon-user'].sort()); + }); + + it('findClassDecorators captures NgModule args even with nested objects', () => { + const src = `@NgModule({ providers: [{ provide: X, useFactory: () => new Y() }], declarations: [F] }) export class M {}`; + const hits = angularInternal.findClassDecorators(src, ['NgModule']); + expect(hits).toHaveLength(1); + expect(hits[0].name).toBe('NgModule'); + // The full args were captured (balanced-paren reader didn't truncate at the inner `()`). + expect(hits[0].args.includes('declarations:')).toBe(true); + }); +}); + +describe('angularResolver.extract — router config', () => { + it('emits a route node + component reference for `const routes: Routes = [...]`', () => { + const src = ` +import { Routes } from '@angular/router'; +import { AdminComponent } from './admin.component'; +export const routes: Routes = [ + { path: 'admin', component: AdminComponent }, +]; +`; + const { nodes, references } = angularResolver.extract!('app.routes.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('/admin'); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('AdminComponent'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + expect(references[0].referenceKind).toBe('references'); + }); + + it('emits route + lazy-module ref for `loadChildren: () => import(...).then(m=>m.X)`', () => { + const src = ` +import { Routes } from '@angular/router'; +const routes: Routes = [ + { path: 'admin', loadChildren: () => import('./admin').then(m => m.AdminModule) }, +]; +`; + const { nodes, references } = angularResolver.extract!('routing.ts', src); + expect(nodes).toHaveLength(1); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('AdminModule'); + }); + + it('emits route + lazy-component ref for `loadComponent`', () => { + const src = ` +const routes: Routes = [ + { path: 'page', loadComponent: () => import('./page').then(m => m.PageComponent) }, +]; +`; + const { nodes, references } = angularResolver.extract!('app.routes.ts', src); + expect(nodes).toHaveLength(1); + expect(references).toHaveLength(1); + expect(references[0].referenceName).toBe('PageComponent'); + }); + + it('handles RouterModule.forRoot([...]) and forChild([...])', () => { + const src = ` +@NgModule({ + imports: [ + RouterModule.forRoot([ + { path: '', component: HomeComponent }, + { path: 'about', component: AboutComponent }, + ]), + ], +}) +export class AppRoutingModule {} +`; + const { nodes, references } = angularResolver.extract!('app-routing.module.ts', src); + expect(nodes.map((n) => n.name).sort()).toEqual(['/', '/about']); + expect(references.map((r) => r.referenceName).sort()).toEqual(['AboutComponent', 'HomeComponent']); + }); + + it('joins parent path onto children: [...] recursively', () => { + const src = ` +const routes: Routes = [ + { + path: 'admin', + component: AdminLayout, + children: [ + { path: 'users', component: UsersComponent }, + { path: 'reports', component: ReportsComponent }, + ], + }, +]; +`; + const { nodes, references } = angularResolver.extract!('app.routes.ts', src); + expect(nodes.map((n) => n.name).sort()).toEqual(['/admin', '/admin/reports', '/admin/users']); + // Three component refs in total — parent + two children. + expect(references.map((r) => r.referenceName).sort()).toEqual([ + 'AdminLayout', + 'ReportsComponent', + 'UsersComponent', + ]); + }); + + it('handles provideRouter([...]) standalone-bootstrap form', () => { + const src = ` +import { provideRouter } from '@angular/router'; +bootstrapApplication(App, { + providers: [ + provideRouter([ + { path: 'dash', component: DashComponent }, + ]), + ], +}); +`; + const { nodes, references } = angularResolver.extract!('main.ts', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('/dash'); + expect(references[0].referenceName).toBe('DashComponent'); + }); + + it('returns empty result for unrelated TS files (cheap gate)', () => { + const { nodes, references } = angularResolver.extract!('utils.ts', 'export function add(a, b) { return a + b; }\n'); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); +}); + +describe('angularResolver — router-config helpers', () => { + it('extracts component identifier from a route object', () => { + const obj = `{ path: 'admin', component: AdminComponent }`; + expect(angularInternal.extractFieldIdent(obj, 'component')).toBe('AdminComponent'); + }); + + it('extractLazyImportTarget reads `() => import(...).then(m => m.X)` form', () => { + const obj = `{ path: 'admin', loadChildren: () => import('./admin').then(m => m.AdminModule) }`; + expect(angularInternal.extractLazyImportTarget(obj, 'loadChildren')).toBe('AdminModule'); + }); + + it('extractLazyImportTarget reads destructured `({ X }) => X` form', () => { + const obj = `{ path: 'p', loadComponent: () => import('./p').then(({ PageComponent }) => PageComponent) }`; + expect(angularInternal.extractLazyImportTarget(obj, 'loadComponent')).toBe('PageComponent'); + }); + + it('extractLazyImportTarget reads `(await import(...)).X` async form', () => { + const obj = `{ path: 'p', loadChildren: async () => (await import('./p')).PMod }`; + expect(angularInternal.extractLazyImportTarget(obj, 'loadChildren')).toBe('PMod'); + }); + + it('joinRoutePath composes parent + sub correctly', () => { + expect(angularInternal.joinRoutePath('', 'admin')).toBe('/admin'); + expect(angularInternal.joinRoutePath('/admin', 'users')).toBe('/admin/users'); + expect(angularInternal.joinRoutePath('/admin/', '/users')).toBe('/users'); // absolute child + expect(angularInternal.joinRoutePath('/admin', '')).toBe('/admin'); // index child + }); +}); + +describe('angularResolver — Phase 6 helpers (guard / resolver extraction)', () => { + it('extractFieldObjectValueIdentifiers reads `resolve: { k: V, k2: V2 }`', () => { + const obj = `{ path: 'x', resolve: { user: UserResolver, role: RoleResolver } }`; + expect(angularInternal.extractFieldObjectValueIdentifiers(obj, 'resolve')).toEqual([ + 'UserResolver', + 'RoleResolver', + ]); + }); + + it('extractFieldObjectValueIdentifiers drops string-literal values', () => { + const obj = `{ resolve: { user: UserResolver, role: 'admin' } }`; + expect(angularInternal.extractFieldObjectValueIdentifiers(obj, 'resolve')).toEqual([ + 'UserResolver', + ]); + }); + + it('extractFieldObjectValueIdentifiers returns [] when field is missing or not an object', () => { + expect(angularInternal.extractFieldObjectValueIdentifiers(`{ resolve: SomeArray }`, 'resolve')).toEqual([]); + expect(angularInternal.extractFieldObjectValueIdentifiers(`{ path: 'x' }`, 'resolve')).toEqual([]); + }); + + it('ROUTE_GUARD_FIELDS list includes the 5 canonical guard fields', () => { + expect(angularInternal.ROUTE_GUARD_FIELDS).toEqual([ + 'canActivate', + 'canActivateChild', + 'canDeactivate', + 'canMatch', + 'canLoad', + ]); + }); +}); + +describe('angularResolver.extract — Phase 6 guards and resolvers', () => { + it('emits route → guard references for canActivate / canMatch', () => { + const src = ` +const routes: Routes = [ + { path: 'admin', component: A, canActivate: [AuthGuard, RoleGuard], canMatch: [FeatureGuard] }, +]; +`; + const { nodes, references } = angularResolver.extract!('app.routes.ts', src); + expect(nodes).toHaveLength(1); + const names = references.map((r) => r.referenceName).sort(); + expect(names).toEqual(['A', 'AuthGuard', 'FeatureGuard', 'RoleGuard']); + }); + + it('emits route → resolver references for resolve: { k: V }', () => { + const src = ` +const routes: Routes = [ + { path: 'profile', component: P, resolve: { user: UserResolver, settings: SettingsResolver } }, +]; +`; + const { references } = angularResolver.extract!('app.routes.ts', src); + const names = references.map((r) => r.referenceName).sort(); + expect(names).toEqual(['P', 'SettingsResolver', 'UserResolver']); + }); +}); + +describe('angularResolver.detect', () => { + function fakeContext(files: Record): import('../src/resolution/types').ResolutionContext { + return { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p in files, + readFile: (p) => files[p] ?? null, + getProjectRoot: () => '/proj', + getAllFiles: () => Object.keys(files), + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + } + + it('detects via @angular/* in package.json', () => { + const ctx = fakeContext({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '17.0.0' } }), + }); + expect(angularResolver.detect(ctx)).toBe(true); + }); + + it('detects via decorator usage in conventional files when package.json is missing', () => { + const ctx = fakeContext({ + 'src/app/foo.component.ts': `import { Component } from '@angular/core';\n@Component({ selector: 'foo' }) export class FooComponent {}\n`, + }); + expect(angularResolver.detect(ctx)).toBe(true); + }); + + it('does NOT match an unrelated TS project', () => { + const ctx = fakeContext({ + 'package.json': JSON.stringify({ dependencies: { lodash: '*' } }), + 'src/lib.ts': 'export function add(a: number, b: number) { return a + b; }\n', + }); + expect(angularResolver.detect(ctx)).toBe(false); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index c3047569e..9bc467254 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -24,6 +24,7 @@ import type { Edge, Node } from '../types'; import type { QueryBuilder } from '../db/queries'; import type { ResolutionContext } from './types'; +import { angularMetadataEdges, angularSelectorEdges, angularTemplateEdges } from './frameworks/angular'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i; @@ -859,6 +860,9 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const rnEventEdgesList = rnEventEdges(ctx); const fabricNativeEdges = fabricNativeImplEdges(ctx); const mybatisEdges = mybatisJavaXmlEdges(queries); + const angularMeta = angularMetadataEdges(ctx); + const angularSel = angularSelectorEdges(ctx); + const angularTpl = angularTemplateEdges(ctx); const merged: Edge[] = []; const seen = new Set(); @@ -874,8 +878,17 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...rnEventEdgesList, ...fabricNativeEdges, ...mybatisEdges, + ...angularMeta, + ...angularSel, + ...angularTpl, ]) { - const key = `${e.source}>${e.target}`; + // Key on channel as well as endpoints — two synthesizers may both link the + // same pair through different mechanisms (Angular: imports + selector + + // template), and the metadata is what tells the agent which one. Within a + // single synthesizer each channel is already deduped, so this only changes + // behavior at the cross-synthesizer merge boundary. + const channel = (e.metadata && typeof e.metadata.synthesizedBy === 'string') ? e.metadata.synthesizedBy : ''; + const key = `${e.source}>${e.target}>${channel}`; if (seen.has(key)) continue; seen.add(key); merged.push(e); diff --git a/src/resolution/frameworks/angular.ts b/src/resolution/frameworks/angular.ts new file mode 100644 index 000000000..954c9640d --- /dev/null +++ b/src/resolution/frameworks/angular.ts @@ -0,0 +1,1744 @@ +/** + * Angular Framework Resolver + * + * Handles Angular's decorator-based component / module / DI system across the + * pieces that are invisible to a pure tree-sitter pass: + * + * - @Component({ selector, templateUrl, template, standalone, imports, ... }) + * - @NgModule({ declarations, imports, providers, exports, bootstrap }) + * - @Injectable({ providedIn }) + * - @Directive({ selector }) + * - @Pipe({ name }) + * - Router config: const routes: Routes = [...] + RouterModule.forRoot/forChild + * + * Like the other framework extractors this is regex-over-source (comment- + * stripped), not AST traversal — same approach as nestjsResolver. The bulk of + * Angular's "static" structure lives in decorator argument literals, which + * tree-sitter sees only as nested call/object/array expressions. + * + * Architecture — three layers, all using the same scanning primitives: + * 1. `extract()` — per-file: emits route nodes (RouterModule.forRoot, etc.) + * and unresolved references from those nodes to component handlers. + * Module/component edges are NOT emitted here because their source node + * is the TS-extracted class, which `extract()` doesn't have access to. + * 2. `angularMetadataEdges()` synthesizer — cross-file: emits class→class + * edges for @NgModule({declarations,imports,providers,exports,bootstrap}) + * and standalone @Component({imports}). Runs after the TS extractor so + * it can look up class nodes by name. + * 3. `buildAngularSelectorIndex()` / `buildAngularTemplateOwnerIndex()` — + * consumed by the template + selector synthesizers (phases 2 & 3). + */ + +import { Edge, Node } from '../../types'; +import { + FrameworkResolver, + UnresolvedRef, + ResolvedRef, + ResolutionContext, +} from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; + +type JsLang = 'typescript' | 'javascript'; + +/** + * NgModule metadata fields whose value is an array of identifiers that all + * resolve to other module / component / service classes. Each becomes an edge. + */ +const NG_MODULE_ARRAY_FIELDS = [ + 'declarations', + 'imports', + 'providers', + 'exports', + 'bootstrap', +] as const; + +/** + * Filename suffixes that strongly hint a file is Angular. Used by detect() + * for the no-package.json fallback (monorepo subprojects without their own + * package.json). + */ +const ANGULAR_FILE_HINTS = [ + '.component.ts', + '.module.ts', + '.service.ts', + '.directive.ts', + '.pipe.ts', + '.guard.ts', + '.resolver.ts', +]; + +/** + * Kinds eligible to be the TARGET of an Angular metadata edge. Modules, + * components, services, directives, and pipes are all `class` in tree-sitter + * output; `function` is the legacy InjectionToken / factory shape. + */ +const TARGET_KINDS = new Set(['class', 'component', 'function']); + +export const angularResolver: FrameworkResolver = { + name: 'angular', + languages: ['typescript', 'javascript'], + + detect(context: ResolutionContext): boolean { + const packageJson = context.readFile('package.json'); + if (packageJson) { + try { + const pkg = JSON.parse(packageJson); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + if (Object.keys(deps).some((k) => k.startsWith('@angular/'))) { + return true; + } + } catch { + // Invalid JSON — fall through. + } + } + + const allFiles = context.getAllFiles(); + for (const file of allFiles) { + if (!ANGULAR_FILE_HINTS.some((suffix) => file.endsWith(suffix))) continue; + const content = context.readFile(file); + if ( + content && + (content.includes('@angular/') || + content.includes('@Component(') || + content.includes('@NgModule(') || + content.includes('@Directive(') || + content.includes('@Injectable(')) + ) { + return true; + } + } + + return false; + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Convention-based fallback: FooComponent → foo.component.ts, FooService → + // foo.service.ts, etc. Mirrors nestjsResolver's PROVIDER_CONVENTIONS pattern. + for (const [suffix, convention] of PROVIDER_CONVENTIONS) { + if (!suffix.test(ref.referenceName)) continue; + const candidates = context + .getNodesByName(ref.referenceName) + .filter((n) => n.kind === 'class'); + if (candidates.length === 0) return null; + const preferred = candidates.find((n) => n.filePath.includes(convention)); + const target = preferred ?? candidates[0]!; + return { + original: ref, + targetNodeId: target.id, + confidence: preferred ? 0.85 : 0.7, + resolvedBy: 'framework', + }; + } + return null; + }, + + extract(filePath, content) { + if (!/\.(m?js|tsx?|cjs)$/.test(filePath)) return { nodes: [], references: [] }; + // Cheap gate before the comment-stripped scan: the file must look like a + // routing config (Routes type alias, RouterModule.for*, or provideRouter). + if ( + !content.includes(': Routes') && + !content.includes(': Route[]') && + !content.includes('RouterModule.') && + !content.includes('provideRouter') + ) { + return { nodes: [], references: [] }; + } + return extractAngularRoutes(filePath, content); + }, +}; + +// --------------------------------------------------------------------------- +// Convention-based resolution (consumed by resolver.resolve) +// --------------------------------------------------------------------------- + +const PROVIDER_CONVENTIONS: Array<[RegExp, string]> = [ + [/Component$/, '.component.'], + [/Module$/, '.module.'], + [/Service$/, '.service.'], + [/Directive$/, '.directive.'], + [/Pipe$/, '.pipe.'], + [/Guard$/, '.guard.'], + [/Resolver$/, '.resolver.'], + [/Interceptor$/, '.interceptor.'], +]; + +// --------------------------------------------------------------------------- +// Synthesizer-callable: NgModule + standalone Component metadata edges +// --------------------------------------------------------------------------- + +/** + * Emit class→class edges for Angular module / component metadata. Every + * identifier appearing in @NgModule({declarations|imports|providers|exports| + * bootstrap}: [...]) and standalone @Component({imports: [...]}) becomes a + * `calls` edge tagged with `synthesizedBy:'angular-'`. + * + * We deliberately reuse `kind: 'calls'` (not `references`) because trace / + * explore / impact walk `calls` edges to follow flows — emitting on the + * Vue/React-style channel means Angular module structure surfaces in the + * same agent queries without any tool-side wiring. + * + * Resolution rule for each identifier `Name`: + * 1. Prefer a class node literally named `Name` whose file path matches + * the convention (`*Component` → `*.component.ts`, …). + * 2. Fall back to any class node named `Name`. + * 3. Drop unresolved identifiers (spread elements, dynamic imports, …) + * silently — half-bridged edges are worse than none (CLAUDE.md). + */ +export function angularMetadataEdges(ctx: ResolutionContext): Edge[] { + const edges: Edge[] = []; + const seen = new Set(); + + for (const file of ctx.getAllFiles()) { + if (!file.endsWith('.ts') && !file.endsWith('.tsx')) continue; + const content = ctx.readFile(file); + if (!content) continue; + // Cheap gate before the more expensive comment-stripped scan. + if (!content.includes('@NgModule(') && !content.includes('@Component(')) continue; + + const safe = stripCommentsForRegex(content, 'typescript'); + const fileNodes = ctx.getNodesInFile(file); + + for (const hit of findClassDecorators(safe, ['NgModule', 'Component'])) { + const cls = classAfterDecorator(safe, hit.end); + if (!cls) continue; + const owner = fileNodes.find( + (n) => n.name === cls.className && TARGET_KINDS.has(n.kind) + ); + if (!owner) continue; + + const fields = + hit.name === 'NgModule' + ? NG_MODULE_ARRAY_FIELDS + : isStandaloneComponent(hit.args) + ? (['imports'] as const) + : ([] as const); + + for (const field of fields) { + const identifiers = extractFieldArrayIdentifiers(hit.args, field); + for (const name of identifiers) { + const target = resolveTargetByName(name, file, ctx); + if (!target || target.id === owner.id) continue; + const key = `${owner.id}>${target.id}>angular-${field}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: owner.id, + target: target.id, + kind: 'calls', + line: cls.classLine, + provenance: 'heuristic', + metadata: { + synthesizedBy: `angular-${field}`, + via: name, + registeredAt: `${file}:${cls.classLine}`, + }, + }); + } + } + } + } + + return edges; +} + +/** + * Pick the best `Name` resolution among same-named class nodes. Prefers a + * class whose file path matches the Angular naming convention + * (`FooComponent` → `*.component.ts`), then falls back to first match. + * Returns `null` when nothing exists — half-bridged edges are worse than none. + */ +function resolveTargetByName( + name: string, + fromFile: string, + ctx: ResolutionContext +): Node | null { + const candidates = ctx.getNodesByName(name).filter((n) => TARGET_KINDS.has(n.kind)); + if (candidates.length === 0) return null; + + // Same-file wins outright (avoids cross-file mis-match in monorepos). + const sameFile = candidates.find((n) => n.filePath === fromFile); + if (sameFile) return sameFile; + + // Convention match: FooComponent in *.component.ts. + for (const [suffix, convention] of PROVIDER_CONVENTIONS) { + if (!suffix.test(name)) continue; + const conventionMatch = candidates.find((n) => n.filePath.includes(convention)); + if (conventionMatch) return conventionMatch; + } + + return candidates[0]!; +} + +// --------------------------------------------------------------------------- +// Phase 2 synthesizer: / [appFoo] → component class edges +// --------------------------------------------------------------------------- + +const NG_OPEN_TAG_RE = /<([a-z][a-z0-9-]*)([^>]*)>/gi; +const NG_TEMPLATE_ATTR_RE = /(?:^|\s)(?:\[\(?([a-zA-Z_][\w-]*)\)?\]|([a-zA-Z_][\w-]*))(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|[^\s>]+))?/g; +const NG_CLASS_RE = /\S+/g; + +/** + * Per-template emission cap. Defensive against god-templates whose tag fan-out + * would otherwise dominate the synthesized-edge budget. Same shape as Vue's + * MAX_JSX_CHILDREN. + */ +const MAX_ANGULAR_TEMPLATE_EDGES = 60; + +/** + * Emit `owner-component → child-component` / `owner → directive-class` edges + * for every selector match in every component's template. Source point is + * the owner @Component class node (so `trace`/`callees`/`impact` find the + * relationship from the parent class). + * + * Gate: a tag/attribute must appear in the selector index built from + * `@Component({ selector })` / `@Directive({ selector })` metadata. Generic + * HTML tags (`
    `, `