diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6382ab5..7402fd4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- Angular Router registered route arrays now link to exact component classes, including nested children and static lazy imports; imported route edits refresh their registrations during sync. + - RedwoodSDK registered routes now link to their page or API handlers, preserving prefixes and method tables while excluding middleware from page roots. - Astro pages now link to their exact components and source-declared navigation; exported HTTP methods in `.ts`/`.js` endpoints link to their handlers, while type-only declarations and `.mjs` files are excluded. diff --git a/__tests__/angular-routes.test.ts b/__tests__/angular-routes.test.ts new file mode 100644 index 000000000..131918ad3 --- /dev/null +++ b/__tests__/angular-routes.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { routeRoots } from '../src/ui-server/api/route-roots'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'javascript']); +}); +describe('registered Angular routes', () => { + let cg: CodeGraph | undefined; + let dir: string; + const write = (file: string, source: string) => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + }; + const setup = () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-')); + write('package.json', JSON.stringify({ dependencies: { '@angular/router': '21.0.0' } })); + write('src/home.ts', 'export class Home {}'); + write('src/user.ts', 'export class User {}'); + write('src/other/home.ts', 'export class Home {}'); + }; + afterEach(() => { + cg?.close(); + cg = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + // angular/angular@9a58353b1b680f162a55969965ae6a90ae20316d: + // adev/src/content/tutorials/learn-angular/steps/14-routerLink/answer/src/app/app.routes.ts + it('binds the official tutorial registration and refreshes imported arrays after reopening', async () => { + setup(); + write( + 'src/app.routes.ts', + `import {Routes} from '@angular/router'; import {Home} from './home'; import {User} from './user'; export const routes: Routes = [{path:'',component:Home},{path:'user',component:User}];`, + ); + write( + 'src/app.config.ts', + `import {provideRouter} from '@angular/router'; import {routes} from './app.routes'; export const appConfig = {providers:[provideRouter(routes)]};`, + ); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual(['/', '/user']); + const roots = routeRoots(cg, routes); + for (const [url, name, file] of [ + ['/', 'Home', 'src/home.ts'], + ['/user', 'User', 'src/user.ts'], + ]) { + const root = roots.get(routes.find((n) => n.name === url)!.id)!.node; + expect([root.name, root.filePath]).toEqual([name, file]); + } + cg.close(); + cg = await CodeGraph.open(dir); + write( + 'src/app.routes.ts', + `import {Home} from './home'; export const routes = [{path:'new',component:Home}];`, + ); + await cg.sync(); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/new']); + fs.unlinkSync(path.join(dir, 'src/app.routes.ts')); + await cg.sync(); + expect(cg.getNodesByKind('route')).toEqual([]); + write( + 'src/app.routes.ts', + `import {Home} from './home'; export const routes = [{path:'added',component:Home}];`, + ); + await cg.sync(); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/added']); + }); + it('composes children and lazy components and arrays without indexing orphans', async () => { + setup(); + write( + 'src/lazy.ts', + `import {User} from './user'; export const CHILDREN = [{path:':id',component:User}];`, + ); + write('src/default.ts', 'export default class Default {}'); + write( + 'src/app.ts', + `import {provideRouter as router} from '@angular/router'; import {Home} from './home'; const unused = [{path:'orphan',component:Home}]; export const providers = [router([{path:'admin',children:[{path:'',component:Home},{path:'user',loadComponent:()=>import('./user').then(m=>m.User)}]},{path:'lazy',loadChildren:()=>import('./lazy').then(m=>m.CHILDREN)},{path:'default',loadComponent:()=>import('./default')},{path:'**',component:Home}])];`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect( + cg + .getNodesByKind('route') + .map((n) => n.name) + .sort(), + ).toEqual(['/*', '/admin', '/admin/user', '/default', '/lazy/:id']); + }); + it('follows forChild only through a mounted lazy NgModule', async () => { + setup(); + write( + 'src/child.ts', + `import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; import {User} from './user'; @NgModule({imports:[RouterModule.forChild([{path:'user',component:User}])]}) export class ChildModule {}`, + ); + write( + 'src/orphan.ts', + `import {RouterModule} from '@angular/router'; import {Home} from './home'; const orphan = RouterModule.forChild([{path:'orphan',component:Home}]);`, + ); + write( + 'src/app.ts', + `import {RouterModule as Router} from '@angular/router'; export const routes = Router.forRoot([{path:'admin',loadChildren:()=>import('./child').then(m=>m.ChildModule)}]);`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/admin/user']); + }); + it.each([ + `const routes = [{path:'orphan',component:Home}];`, + `function wrapper(provideRouter){return provideRouter([{path:'shadow',component:Home}])}`, + `provideRouter([{path:dynamic,component:Home}]);`, + `provideRouter([{path:'x',matcher:match,component:Home}]);`, + `provideRouter([{path:'x',outlet:'other',component:Home}]);`, + `provideRouter([{path:'x',redirectTo:'other',component:Home}]);`, + `provideRouter([{path:'x',component:Home,...extra}]);`, + `provideRouter([{path:'x',component:Home,path:'other'}]);`, + `provideRouter([{path:'x',loadComponent:()=>factory()}]);`, + `{ const provideRouter = other; provideRouter([{path:'shadow',component:Home}]); }`, + `false && provideRouter([{path:'never',component:Home}]);`, + `provideRouter({path:'object',component:Home});`, + `const routes = [{path:'old',component:Home}]; routes.pop(); provideRouter(routes);`, + `const routes = [{path:'old',component:Home}]; routes[0].path = 'new'; provideRouter(routes);`, + ])('rejects unsupported declarations: %s', async (body) => { + setup(); + write( + 'src/app.ts', + `import {provideRouter} from '@angular/router'; import {Home} from './home'; ${body}`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it.each([false, true])( + 'detects newly introduced Angular registrations (scoped=%s)', + async (scoped) => { + setup(); + write('package.json', '{}'); + cg = await CodeGraph.init(dir, { index: true }); + write('package.json', JSON.stringify({ dependencies: { '@angular/router': '21.0.0' } })); + write( + 'src/app.ts', + `import {provideRouter} from '@angular/router'; import {Home} from './home'; export const providers = [provideRouter([{path:'new',component:Home}])];`, + ); + await cg.sync(scoped ? { paths: ['src/app.ts'] } : undefined); + expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['/new']); + }, + ); + it('keeps matching parents, preferring a default child as the page root', async () => { + setup(); + write( + 'src/app.ts', + `import {provideRouter} from '@angular/router'; import {Home} from './home'; import {User} from './user'; provideRouter([{path:'empty',component:Home,children:[]},{path:'parent',component:Home,children:[{path:'child',component:User}]},{path:'index',component:Home,children:[{path:'',component:User}]}]);`, + ); + cg = await CodeGraph.init(dir, { index: true }); + const routes = cg.getNodesByKind('route'); + expect(routes.map((n) => n.name).sort()).toEqual([ + '/empty', + '/index', + '/parent', + '/parent/child', + ]); + expect(routeRoots(cg, routes).get(routes.find((n) => n.name === '/index')!.id)!.node.name).toBe( + 'User', + ); + }); + it.each(['routes.length=0;', 'const alias=routes; alias.length=0;', `routes['pop']();`])( + 'ignores mutated imported arrays: %s', + async (mutation) => { + setup(); + write( + 'src/routes.ts', + `import {Home} from './home'; export const routes=[{path:'stale',component:Home}];`, + ); + write( + 'src/app.ts', + `import {provideRouter} from '@angular/router'; import {routes} from './routes'; ${mutation} provideRouter(routes);`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toEqual([]); + }, + ); + it('ignores forChild outside NgModule imports', async () => { + setup(); + write( + 'src/child.ts', + `import {NgModule} from '@angular/core'; import {RouterModule} from '@angular/router'; import {Home} from './home'; @NgModule({providers:[{provide:'token',useValue:RouterModule.forChild([{path:'fake',component:Home}])}]}) export class Child {}`, + ); + write( + 'src/app.ts', + `import {provideRouter} from '@angular/router'; provideRouter([{path:'parent',loadChildren:()=>import('./child').then(m=>m.Child)}]);`, + ); + cg = await CodeGraph.init(dir, { index: true }); + expect(cg.getNodesByKind('route')).toEqual([]); + }); + it.runIf(fs.existsSync(path.resolve('dist/index.js')))( + 'enriches fresh compiled parse/store workers', + () => { + setup(); + write( + 'src/routes.ts', + `import {Home} from './home'; export const routes = [{path:'',component:Home}];`, + ); + write( + 'src/app.ts', + `import {provideRouter} from '@angular/router'; import {routes} from './routes'; export const providers=[provideRouter(routes)];`, + ); + const script = `const {CodeGraph}=require(${JSON.stringify(path.resolve('dist/index.js'))}); (async()=>{const cg=await CodeGraph.init(${JSON.stringify(dir)},{index:true});const r=cg.getNodesByKind('route')[0];console.log(JSON.stringify([r.name,cg.getOutgoingEdges(r.id).filter(e=>e.kind==='references').map(e=>cg.getNode(e.target)?.name)]));cg.close();})().catch(e=>{console.error(e);process.exit(1)});`; + const output = execFileSync(process.execPath, ['-e', script], { + encoding: 'utf8', + timeout: 60000, + env: { + ...process.env, + CODEGRAPH_PARALLEL_RESOLVE_MIN: '1', + CODEGRAPH_RESOLVE_WORKERS: '2', + CODEGRAPH_PARSE_WORKERS: '2', + }, + }); + expect(JSON.parse(output.trim().split('\n').at(-1)!)).toEqual(['/', ['Home']]); + }, + ); +}); diff --git a/docs/design/PLAN-application-router-coverage.md b/docs/design/PLAN-application-router-coverage.md index 234d34b8f..7ba85eb2a 100644 --- a/docs/design/PLAN-application-router-coverage.md +++ b/docs/design/PLAN-application-router-coverage.md @@ -1,11 +1,11 @@ -Status: 5/13 — preparing step 5 PR (RedwoodSDK); steps 1–4 published as PRs #3–6 +Status: 6/13 — Angular Router verified; publishing step 6 before Analog - [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear. - [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear. - [x] 3 Remix and React Router file conventions — registered default conventions bind exact page components, optional navigation and explicit config coexistence; layout/resource controls and config-only full/scoped/reopened sync pass; build passes, 102 WASM tests pass, full native suite 4,415 pass / 46 skip; independent review clear. - [x] 4 Astro route completion — exact page components, declared method handlers and local source navigation; false-anchor/type/export controls and full/scoped/new-framework sync pass; build passes, 195 WASM tests pass, full native suite 4,435 pass / 46 skip; independent review clear. - [x] 5 RedwoodSDK — registered literal trees, prefixes and method tables bind exact handlers; JSX evidence classifies pages, interrupters/ambiguous declarations excluded; handler edit/delete and new-framework sync pass; build passes, 91 WASM tests pass, full native suite 4,459 pass / 46 skip; independent review clear. -- [ ] 6 Angular Router — registered literal route tables, nested children, component references, and statically resolvable lazy modules/components. Gate: shared proof, router registration and nesting, plus unregistered objects and custom matcher negatives. +- [x] 6 Angular Router — registered arrays, nested children and static lazy imports bind exact classes; imported table add/edit/delete after reopening and fresh workers pass; build passes, 117 WASM focused/control tests pass, full native suite 4,484 pass / 46 skip; independent review clear. - [ ] 7 Analog — default file routes and page components using Analog-specific index, dot, parameter, and layout conventions. Gate: shared proof and fixtures that distinguish parent layouts from matching pages. - [ ] 8 Solid Router — imported JSX/config declarations, nested path composition, and router base. Gate: shared proof, nested matching semantics, component links, and unrelated JSX negatives. - [ ] 9 SolidStart — pinned-version file routes, default page exports, and HTTP-method exports. Gate: shared proof, page/API coexistence, layouts, and dynamic parameters. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 40a9accc1..9f2d0d8b6 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -44,6 +44,9 @@ guessed. | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) | | Astro | `frameworks/astro.ts` | — | `astro-routes.test.ts` | pinned endpoint fixture; exact page components and source navigation | | RedwoodSDK | `frameworks/redwood.ts` | — | `redwood-routes.test.ts` | pinned 1.7.3 typed-routes worker; exact page and API roots | +| Angular Router | `frameworks/angular.ts` | — | `angular-routes.test.ts` | pinned official tutorial registration; exact class roots and imported-array sync | + +Angular reads registered `provideRouter` / `RouterModule.forRoot` literal or constant arrays, nested children and relative imports. Static lazy imports can select component classes, route arrays or NgModules registering `forChild`. [Pinned tutorial](https://github.com/angular/angular/blob/9a58353b1b680f162a55969965ae6a90ae20316d/adev/src/content/tutorials/learn-angular/steps/14-routerLink/answer/src/app/app.routes.ts), [loading semantics](https://angular.dev/guide/routing/loading-strategies). Parent-side extraction enriches both ordinary and fresh worker storage without depending on database insertion order. Routes belong to their registration file; sync refreshes registrations after source changes, including after reopening. Redirects, named outlets, custom matchers, conditional registrations, dynamic loaders, path aliases, re-export modules and spread objects are excluded. No navigation is inferred. RedwoodSDK follows imported `defineApp` registrations with literal/local-constant arrays, `route`, `index`, `render`, `layout` and `prefix`. Handler arrays bind only their last handler. Standard method tables remain method-qualified; ordinary handlers remain `ANY` unless the handler returns JSX. [Pinned worker fixture](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/playground/typed-routes/src/worker.tsx), [router semantics](https://github.com/redwoodjs/sdk/blob/39da7118f712bd86450e493cb2c213815b1893bb/sdk/src/runtime/lib/router.ts#L682). Tests cover handler classification changes/deletion and fresh workers. Cross-file route arrays, custom methods, mutated builders, dynamic paths, duplicate/computed/spread method tables and wrapped/anonymous exported components remain unsupported; no navigation is inferred. diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 40760614c..03e6d613c 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -37,9 +37,12 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by | **Vue Router** / **Nuxt** | Vue route tables; `.vue` pages in `pages/` or Nuxt 4 `app/pages/`, dynamic/optional/catch-all segments and route groups; `server/api/` and `server/routes/` with method suffixes; route middleware | | **Astro** | `src/pages/` `.astro` pages linked to components; `.ts`/`.js` HTTP-method exports linked to handlers; anchors and `Astro.redirect` link to local pages | | **RedwoodSDK** | Registered `defineApp` trees with `route`, `index`, `render`, `layout`, `prefix` and standard method tables; exact handlers and JSX page classification | +| **Angular Router** | `provideRouter` / `RouterModule.forRoot` arrays, nested children, relative component imports and static lazy components/route arrays/NgModules | Route resolution is automatic — there's nothing to configure. If a framework file is recognized, its routes appear in the graph after the next index or sync. +Angular follows literal or constant route arrays from runtime router imports. `forChild` contributes routes only through a statically imported lazy NgModule. Route nodes belong to the registration file, and imported table changes refresh that owner. Redirects, named outlets, custom matchers, dynamic factories, conditional registrations, spread objects, path aliases and re-export modules remain unsupported. No navigation is inferred. + RedwoodSDK handler arrays use the final handler as the route root. A route stays `ANY /path` until its handler is shown to return JSX. Cross-file route arrays, custom methods, mutations, dynamic paths, ambiguous method tables and wrapped/anonymous exported components remain unsupported. Astro supports default roots, `[param]`/`[...rest]` filenames and exported handlers, including `ALL` as `ANY`. Type-only exports, underscore-prefixed paths and `.mjs` endpoints are excluded. Custom routing configuration, Markdown/MDX, cross-file re-exports, client transition calls and navigation to rest routes remain unsupported. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 14e1c7e91..3b2e27cf1 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -33,6 +33,7 @@ import { logDebug, logWarn } from '../errors'; import { validatePathWithinRoot, normalizePath } from '../utils'; import ignore, { Ignore } from 'ignore'; import { detectFrameworks } from '../resolution/frameworks'; +import { extractAngularRoutes, isAngularRegistrationFile } from '../resolution/frameworks/angular'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; @@ -1769,6 +1770,7 @@ export class ExtractionOrchestrator { const commitYield = createYielder(); const storeResult = async (filePath: string, content: string, stats: fs.Stats, result: ExtractionResult): Promise => { + result = await this.enrichAngularRoutes(filePath, content, result); processed++; // WAL hard-cap backstop: between files (never mid-transaction), pause @@ -1803,7 +1805,7 @@ export class ExtractionOrchestrator { await storeWriter.waitBelow(STORE_WRITER_WINDOW); } else { const materialized = materializeKernelResult(result, filePath, language); - await this.storeExtractionResult(filePath, content, language, stats, materialized, commitYield); + await this.storeExtractionResult(filePath, content, language, stats, materialized, commitYield, true); } if (result.errors.length > 0) { @@ -2365,19 +2367,31 @@ export class ExtractionOrchestrator { } } + private async enrichAngularRoutes(filePath: string, content: string, result: ExtractionResult): Promise { + if (!this.ensureDetectedFrameworks().includes('angular') || !isAngularRegistrationFile(content)) return result; + await loadGrammarsForLanguages(['typescript', 'javascript']); + result = materializeKernelResult(result, filePath, detectLanguage(filePath)!); + const angular = extractAngularRoutes(filePath, content, this.buildDetectionContext([])); + result.nodes.push(...angular.nodes); + result.unresolvedReferences.push(...angular.references); + return result; + } + private async storeExtractionResult( filePath: string, content: string, language: Language, stats: fs.Stats, result: ExtractionResult, - onYield?: MaybeYield + onYield?: MaybeYield, + angularEnriched = false ): Promise { // A kernel result can arrive as an undecoded buffer transport (empty // node/edge arrays, tables riding in kernelBuffers). Decode it before // storing — persisting the transport as-is records the file as having no // symbols at all (#1541). No-op for already-decoded results. result = materializeKernelResult(result, filePath, language); + if (!angularEnriched) result = await this.enrichAngularRoutes(filePath, content, result); // Bulk inserts run in bounded sub-transactions with a yield between, so a // giant generated file (tens of thousands of symbols) can't block the @@ -2872,6 +2886,19 @@ export class ExtractionOrchestrator { : previous.includes('react-router-files'); this.detectedFrameworkNames = null; const detected = this.ensureDetectedFrameworks(currentFiles); + if (detected.includes('angular') || this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:angular:'))) { + const scope = this.scopedSyncMatcher(); + for (const filePath of new Set([...this.queries.getAllFilePaths(), ...currentFiles])) { + if (!/\.[cm]?[jt]s$/.test(filePath) || filesToIndex.includes(filePath) || scope.ignores(filePath)) continue; + const full = validatePathWithinRoot(this.rootDir, filePath); + if (!full || !fs.existsSync(full)) continue; + if (!isAngularRegistrationFile(fs.readFileSync(full, 'utf-8'))) continue; + filesToIndex.push(filePath); + this.conventionInvalidatedFiles.add(filePath); + changedFilePaths.push(filePath); + filesModified++; + } + } if (scopedPaths?.length) { this.detectedFrameworkNames = [...new Set([ ...previous.filter((name) => name !== 'react-router-files'), ...detected, diff --git a/src/resolution/frameworks/angular.ts b/src/resolution/frameworks/angular.ts new file mode 100644 index 000000000..843c44d73 --- /dev/null +++ b/src/resolution/frameworks/angular.ts @@ -0,0 +1,431 @@ +import type { Node as SyntaxNode, Tree } from 'web-tree-sitter'; +import type { FrameworkResolver, FrameworkExtractionResult, ResolutionContext } from '../types'; +import { detectLanguage, getParser } from '../../extraction/grammars'; +import { resolveImportPath } from '../import-resolver'; +import { dependsOn } from './package-deps'; + +const FUNCTIONS = new Set([ + 'arrow_function', + 'function_expression', + 'function_declaration', + 'method_definition', +]); +const unwrap = (raw: SyntaxNode | null | undefined): SyntaxNode | null => { + let node = raw ?? null; + while ( + node && + ['parenthesized_expression', 'as_expression', 'satisfies_expression'].includes(node.type) + ) + node = node.namedChildren[0] ?? null; + return node; +}; +const literal = (node: SyntaxNode | null | undefined): string | null => + node?.type === 'string' && !node.text.includes('\\') ? node.text.slice(1, -1) : null; +type Location = { file: string; node: SyntaxNode }; +type Module = { + tree: Tree; + locals: Map; + imports: Map; + exports: Map; + mutated: Set; +}; + +export const isAngularRegistrationFile = (content: string): boolean => + content.includes('@angular/router') && /\b(?:provideRouter|forRoot)\b/.test(content); + +/** Resolves source bindings without depending on the order files reach the database. */ +class AngularSource { + private modules = new Map(); + constructor( + private context: ResolutionContext, + private owner: string, + private content: string, + ) {} + close(): void { + for (const module of this.modules.values()) module?.tree.delete(); + } + module(file: string): Module | null { + if (this.modules.has(file)) return this.modules.get(file)!; + const content = file === this.owner ? this.content : this.context.readFile(file); + const parser = getParser(detectLanguage(file)!); + const tree = content === null ? null : parser?.parse(content); + if (!tree) { + this.modules.set(file, null); + return null; + } + const module: Module = { + tree, + locals: new Map(), + imports: new Map(), + exports: new Map(), + mutated: new Set(), + }; + this.modules.set(file, module); + for (const statement of tree.rootNode.namedChildren) { + if ( + statement.type === 'import_statement' && + !statement.children.some((n) => n.type === 'type') + ) { + const source = literal(statement.childForFieldName('source')); + if (source === null) continue; + const clause = statement.namedChildren.find((n) => n.type === 'import_clause'); + for (const spec of clause?.namedChildren ?? []) { + if (spec.type === 'identifier') + module.imports.set(spec.text, { source, name: 'default' }); + for (const item of spec.type === 'named_imports' ? spec.namedChildren : []) { + if (item.type !== 'import_specifier' || item.children.some((n) => n.type === 'type')) + continue; + const name = item.childForFieldName('name')?.text; + if (name) + module.imports.set(item.childForFieldName('alias')?.text ?? name, { source, name }); + } + } + } + const exported = statement.type === 'export_statement'; + const declaration = exported ? statement.childForFieldName('declaration') : statement; + if ( + declaration?.type === 'lexical_declaration' && + declaration.children.some((n) => n.type === 'const') + ) { + for (const item of declaration.namedChildren) { + const name = item.childForFieldName('name'); + const value = item.childForFieldName('value'); + if (name?.type === 'identifier' && value) { + module.locals.set(name.text, value); + if (exported) module.exports.set(name.text, name.text); + } + } + } else if ( + declaration && + ['class_declaration', 'abstract_class_declaration'].includes(declaration.type) + ) { + const name = declaration.childForFieldName('name')?.text; + if (name) { + module.locals.set(name, declaration); + if (exported) + module.exports.set( + statement.children.some((n) => n.type === 'default') ? 'default' : name, + name, + ); + } + } + if ( + exported && + !statement.childForFieldName('source') && + !statement.children.some((n) => n.type === 'type') + ) { + const value = statement.childForFieldName('value'); + if (statement.children.some((n) => n.type === 'default') && value?.type === 'identifier') + module.exports.set('default', value.text); + if (statement.children.some((n) => n.type === 'default') && value?.type === 'array') { + module.locals.set('default', value); + module.exports.set('default', 'default'); + } + for (const spec of statement.descendantsOfType('export_specifier')) { + const name = spec.childForFieldName('name')?.text; + if (name && !spec.children.some((n) => n.type === 'type')) + module.exports.set(spec.childForFieldName('alias')?.text ?? name, name); + } + } + } + for (const expression of tree.rootNode.descendantsOfType([ + 'assignment_expression', + 'augmented_assignment_expression', + 'update_expression', + 'call_expression', + ])) { + let receiver = + expression.type === 'call_expression' + ? expression.childForFieldName('function') + : (expression.childForFieldName('left') ?? expression.childForFieldName('argument')); + if ( + expression.type === 'call_expression' && + !['member_expression', 'subscript_expression'].includes(receiver?.type ?? '') + ) + continue; + while (receiver && ['member_expression', 'subscript_expression'].includes(receiver.type)) + receiver = receiver.childForFieldName('object'); + if ( + receiver?.type === 'identifier' && + module.locals.get(receiver.text)?.type !== 'class_declaration' + ) + module.mutated.add(receiver.text); + } + let changed = true; + while (changed) { + changed = false; + for (const [name, raw] of module.locals) { + const alias = unwrap(raw); + if ( + alias?.type !== 'identifier' || + (!module.mutated.has(name) && !module.mutated.has(alias.text)) + ) + continue; + for (const binding of [name, alias.text]) + if (!module.mutated.has(binding)) { + module.mutated.add(binding); + changed = true; + } + } + } + return module; + } + imported(file: string, source: string, name: string, depth: number): Location | null { + if (depth > 32 || !source.startsWith('.')) return null; + const target = resolveImportPath(source, file, detectLanguage(file)!, this.context); + const local = target && this.module(target)?.exports.get(name); + return target && local ? this.binding(target, local, depth + 1) : null; + } + binding(file: string, name: string, depth = 0): Location | null { + if (depth > 32) return null; + const module = this.module(file); + if (module?.mutated.has(name)) return null; + const local = module?.locals.get(name); + if (local) return this.value({ file, node: local }, depth + 1); + const imported = module?.imports.get(name); + return imported ? this.imported(file, imported.source, imported.name, depth + 1) : null; + } + value(location: Location, depth = 0): Location | null { + if (depth > 32) return null; + const node = unwrap(location.node); + if (!node) return null; + return node.type === 'identifier' + ? this.binding(location.file, node.text, depth + 1) + : { file: location.file, node }; + } + helper(file: string, node: SyntaxNode | null, name: string): boolean { + const imported = node?.type === 'identifier' && this.module(file)?.imports.get(node.text); + return !!imported && imported.source === '@angular/router' && imported.name === name; + } + lazy(file: string, raw: SyntaxNode): Location | null { + const fn = unwrap(raw); + if (!fn || !['arrow_function', 'function_expression'].includes(fn.type)) return null; + let body = unwrap(fn.childForFieldName('body')); + if (body?.type === 'statement_block') { + const statements = body.namedChildren.filter((n) => n.type !== 'comment'); + body = + statements.length === 1 && statements[0]?.type === 'return_statement' + ? unwrap(statements[0].namedChildren[0]) + : null; + } + let name = 'default'; + if ( + body?.type === 'call_expression' && + body.childForFieldName('function')?.type === 'member_expression' + ) { + const member = body.childForFieldName('function')!; + if (member.childForFieldName('property')?.text !== 'then') return null; + const callback = body.childForFieldName('arguments')?.namedChildren[0]; + const parameter = + callback?.childForFieldName('parameter') ?? + callback?.childForFieldName('parameters')?.namedChildren[0]; + const selected = unwrap(callback?.childForFieldName('body')); + if ( + callback?.type !== 'arrow_function' || + selected?.type !== 'member_expression' || + parameter?.type !== 'identifier' || + selected.childForFieldName('object')?.text !== parameter.text + ) + return null; + name = selected.childForFieldName('property')?.text ?? ''; + body = unwrap(member.childForFieldName('object')); + } + if (body?.type !== 'call_expression' || body.childForFieldName('function')?.type !== 'import') + return null; + const source = literal(body.childForFieldName('arguments')?.namedChildren[0]); + return source === null ? null : this.imported(file, source, name, 0); + } +} + +function properties(node: SyntaxNode): Map | null { + if (node.type !== 'object') return null; + const found = new Map(); + for (const item of node.namedChildren) { + if (item.type === 'comment') continue; + const key = item.childForFieldName('key'); + const name = + item.type === 'shorthand_property_identifier' + ? item.text + : key?.type === 'property_identifier' + ? key.text + : literal(key); + const value = + item.type === 'shorthand_property_identifier' ? item : item.childForFieldName('value'); + if (!name || !value || found.has(name)) return null; + found.set(name, value); + } + return found; +} + +export function extractAngularRoutes( + filePath: string, + content: string, + context: ResolutionContext, +): FrameworkExtractionResult { + const result: FrameworkExtractionResult = { nodes: [], references: [] }; + if (!isAngularRegistrationFile(content)) return result; + const source = new AngularSource(context, filePath, content); + try { + const module = source.module(filePath); + if (!module) return result; + const visit = (raw: Location, prefix: string, site: SyntaxNode, depth = 0): void => { + if (depth > 32) return; + const location = source.value(raw); + if (!location) return; + const { file, node } = location; + if (node.type === 'array') { + for (const child of node.namedChildren) + if (child.type !== 'comment' && child.type !== 'spread_element') + visit({ file, node: child }, prefix, site, depth + 1); + return; + } + if (node.type === 'class_declaration') { + const declaration = node.parent?.type === 'export_statement' ? node.parent : node; + for (const decorator of declaration.namedChildren.filter((n) => n.type === 'decorator')) { + const invocation = decorator.namedChildren[0]; + const imported = invocation?.childForFieldName('function'); + const binding = + imported?.type === 'identifier' && source.module(file)?.imports.get(imported.text); + if (!binding || binding.source !== '@angular/core' || binding.name !== 'NgModule') + continue; + const metadata = invocation?.childForFieldName('arguments')?.namedChildren[0]; + const imports = metadata && properties(metadata)?.get('imports'); + if (imports?.type !== 'array') continue; + for (const call of imports.namedChildren) { + if (call.type !== 'call_expression') continue; + const fn = call.childForFieldName('function'); + if ( + fn?.type === 'member_expression' && + source.helper(file, fn.childForFieldName('object'), 'RouterModule') && + fn.childForFieldName('property')?.text === 'forChild' + ) { + const routes = call.childForFieldName('arguments')?.namedChildren[0]; + const table = routes && source.value({ file, node: routes }); + if (table?.node.type === 'array') visit(table, prefix, site, depth + 1); + } + } + } + return; + } + const fields = properties(node); + if (!fields || ['redirectTo', 'matcher', 'outlet'].some((key) => fields.has(key))) return; + const segment = literal(fields.get('path')); + if (segment === null) return; + const routePath = + '/' + + [prefix, segment === '**' ? '*' : segment].join('/').split('/').filter(Boolean).join('/'); + const children = fields.get('children'); + const lazyChildren = fields.get('loadChildren'); + const childStart = result.nodes.length; + const table = children && source.value({ file, node: children }); + if (children && table?.node.type !== 'array') return; + if (table?.node.type === 'array') visit(table, routePath, site, depth + 1); + if (lazyChildren) { + const loaded = source.lazy(file, lazyChildren); + if (!loaded || !['array', 'class_declaration'].includes(loaded.node.type)) return; + visit(loaded, routePath, site, depth + 1); + } + if (result.nodes.slice(childStart).some((n) => n.name === routePath)) return; + const component = fields.get('component'); + const lazyComponent = fields.get('loadComponent'); + const target = component + ? source.binding(file, component.text) + : lazyComponent + ? source.lazy(file, lazyComponent) + : null; + if (!target || target.node.type !== 'class_declaration') return; + const name = target.node.childForFieldName('name')?.text; + if (!name) return; + const id = `route:angular:${filePath}:${site.startIndex}:${file}:${node.startIndex}:${routePath}`; + if (result.nodes.some((n) => n.id === id)) return; + result.nodes.push({ + id, + kind: 'route', + name: routePath, + qualifiedName: `${filePath}::${routePath}`, + filePath, + language: detectLanguage(filePath)!, + startLine: site.startPosition.row + 1, + endLine: site.endPosition.row + 1, + startColumn: site.startPosition.column, + endColumn: site.endPosition.column, + updatedAt: Date.now(), + }); + result.references.push({ + fromNodeId: id, + referenceName: 'angular-component:' + JSON.stringify([target.file, name]), + referenceKind: 'references', + filePath, + language: detectLanguage(filePath)!, + line: site.startPosition.row + 1, + column: site.startPosition.column, + }); + }; + const registrations = (node: SyntaxNode): void => { + if ( + FUNCTIONS.has(node.type) || + [ + 'statement_block', + 'binary_expression', + 'ternary_expression', + 'if_statement', + 'switch_statement', + 'for_statement', + 'while_statement', + ].includes(node.type) + ) + return; + if (node.type === 'call_expression') { + const fn = node.childForFieldName('function'); + if ( + source.helper(filePath, fn, 'provideRouter') || + (fn?.type === 'member_expression' && + source.helper(filePath, fn.childForFieldName('object'), 'RouterModule') && + fn.childForFieldName('property')?.text === 'forRoot') + ) { + const routes = node.childForFieldName('arguments')?.namedChildren[0]; + const table = routes && source.value({ file: filePath, node: routes }); + if (table?.node.type === 'array') visit(table, '', node); + return; + } + } + for (const child of node.namedChildren) registrations(child); + }; + registrations(module.tree.rootNode); + return result; + } finally { + source.close(); + } +} + +export const angularResolver: FrameworkResolver = { + name: 'angular', + languages: ['typescript', 'javascript'], + detect: (context) => dependsOn(context, '@angular/router'), + claimsReference: (name) => name.startsWith('angular-component:'), + resolve(ref, context) { + if ( + !ref.fromNodeId.startsWith('route:angular:') || + !ref.referenceName.startsWith('angular-component:') + ) + return null; + let target: unknown; + try { + target = JSON.parse(ref.referenceName.slice('angular-component:'.length)); + } catch { + return null; + } + if ( + !Array.isArray(target) || + target.length !== 2 || + !target.every((v) => typeof v === 'string') + ) + return null; + const candidates = context + .getNodesInFile(target[0]!) + .filter((n) => n.name === target[1] && ['class', 'component'].includes(n.kind)); + return candidates.length === 1 + ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 1, resolvedBy: 'framework' } + : null; + }, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 11c2fb1e8..26323270c 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -21,6 +21,7 @@ import { svelteResolver } from './svelte'; import { vueResolver } from './vue'; import { astroResolver } from './astro'; import { redwoodResolver } from './redwood'; +import { angularResolver } from './angular'; import { djangoResolver, flaskResolver, fastapiResolver } from './python'; import { railsResolver } from './ruby'; import { springResolver } from './java'; @@ -65,6 +66,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ vueRouterResolver, astroResolver, redwoodResolver, + angularResolver, // Python djangoResolver, flaskResolver,