Skip to content

Commit 3f871ae

Browse files
committed
feat: ast grep for routes analysis
1 parent c3fd2ba commit 3f871ae

File tree

5 files changed

+288
-74
lines changed

5 files changed

+288
-74
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
},
9595
"prettier": "@adonisjs/prettier-config",
9696
"dependencies": {
97+
"@ast-grep/napi": "^0.40.0",
9798
"jiti": "^2.4.2",
9899
"ts-morph": "^27.0.2"
99100
}

src/analyzer/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ export async function runAnalysis(options: AnalysisOptions): Promise<AnalysisRes
5252
}
5353

5454
const collector = new StatisticsCollector({
55-
routes: [] as any[],
5655
project,
5756
config,
57+
cwd,
5858
})
5959

6060
const summary = await collector.collect()

src/classifier_registry.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { Classifier } from './classifiers/base_classifier.js'
2+
import { ControllerClassifier } from './classifiers/controller_classifier.js'
3+
import { ServiceClassifier } from './classifiers/service_classifier.js'
4+
import { ModelClassifier } from './classifiers/model_classifier.js'
5+
import { MiddlewareClassifier } from './classifiers/middleware_classifier.js'
6+
import { ValidatorClassifier } from './classifiers/validator_classifier.js'
7+
import { CommandClassifier } from './classifiers/command_classifier.js'
8+
import { ListenerClassifier } from './classifiers/listener_classifier.js'
9+
import { EventClassifier } from './classifiers/event_classifier.js'
10+
import { ExceptionClassifier } from './classifiers/exception_classifier.js'
11+
import { TestClassifier } from './classifiers/test_classifier.js'
12+
import type { StatsConfig } from './types.js'
13+
14+
/**
15+
* Manages registration and retrieval of classifiers
16+
*/
17+
export class ClassifierRegistry {
18+
private classifiers: Classifier[] = []
19+
#config: StatsConfig
20+
21+
constructor(config: StatsConfig) {
22+
this.#config = config
23+
}
24+
25+
/**
26+
* Register all classifiers (default and custom)
27+
*/
28+
async registerAll(): Promise<void> {
29+
this.registerDefaultClassifiers()
30+
await this.registerCustomClassifiers()
31+
}
32+
33+
/**
34+
* Register the default classifiers
35+
*/
36+
private registerDefaultClassifiers(): void {
37+
this.classifiers.push(new ControllerClassifier())
38+
this.classifiers.push(new ServiceClassifier())
39+
this.classifiers.push(new ModelClassifier())
40+
this.classifiers.push(new MiddlewareClassifier())
41+
this.classifiers.push(new ValidatorClassifier())
42+
this.classifiers.push(new CommandClassifier())
43+
this.classifiers.push(new ListenerClassifier())
44+
this.classifiers.push(new EventClassifier())
45+
this.classifiers.push(new ExceptionClassifier())
46+
this.classifiers.push(new TestClassifier())
47+
}
48+
49+
/**
50+
* Register custom classifiers (called during collect)
51+
*/
52+
private async registerCustomClassifiers(): Promise<void> {
53+
if (this.#config.customClassifiers.length === 0) {
54+
return
55+
}
56+
57+
for (const classifierPath of this.#config.customClassifiers) {
58+
try {
59+
const classifierModule = await import(classifierPath)
60+
const ClassifierClass = classifierModule.default || Object.values(classifierModule)[0]
61+
if (ClassifierClass && typeof ClassifierClass === 'function') {
62+
this.classifiers.push(new ClassifierClass())
63+
}
64+
} catch (error) {
65+
console.error(`Error importing custom classifier ${classifierPath}:`, error)
66+
}
67+
}
68+
}
69+
70+
/**
71+
* Get all registered classifiers
72+
*/
73+
getClassifiers(): Classifier[] {
74+
return this.classifiers
75+
}
76+
77+
/**
78+
* Find a classifier by name
79+
*/
80+
findByName(name: string): Classifier | undefined {
81+
return this.classifiers.find((c) => c.name() === name)
82+
}
83+
}

src/route_counter.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { parse, Lang } from '@ast-grep/napi'
2+
import { readFileSync, readdirSync, statSync } from 'node:fs'
3+
import { existsSync } from 'node:fs'
4+
import { resolve, join, normalize } from 'node:path'
5+
import type { Project } from 'ts-morph'
6+
7+
/**
8+
* Route counter using AST grep to detect routes in AdonisJS applications
9+
*/
10+
export class RouteCounter {
11+
#project: Project
12+
#cwd: string
13+
14+
constructor(project: Project, cwd: string) {
15+
this.#project = project
16+
this.#cwd = cwd
17+
}
18+
19+
/**
20+
* Find all TypeScript files in a directory recursively
21+
*/
22+
private findTypeScriptFiles(dir: string): string[] {
23+
const files: string[] = []
24+
25+
if (!existsSync(dir)) {
26+
return files
27+
}
28+
29+
try {
30+
const entries = readdirSync(dir)
31+
32+
for (const entry of entries) {
33+
const fullPath = join(dir, entry)
34+
const stat = statSync(fullPath)
35+
36+
if (stat.isDirectory()) {
37+
files.push(...this.findTypeScriptFiles(fullPath))
38+
} else if (stat.isFile() && entry.endsWith('.ts')) {
39+
files.push(fullPath)
40+
}
41+
}
42+
} catch (error) {
43+
console.error(`Error reading directory ${dir}:`, error)
44+
}
45+
46+
return files
47+
}
48+
49+
/**
50+
* Normalize file path for consistent comparison
51+
*/
52+
private normalizePath(filePath: string): string {
53+
return normalize(filePath.replace(/\\/g, '/')).toLowerCase()
54+
}
55+
56+
/**
57+
* Count routes using AST grep patterns
58+
*/
59+
async countRoutes(): Promise<number> {
60+
let routeCount = 0
61+
const processedFiles = new Set<string>()
62+
63+
const startDir = resolve(this.#cwd, 'start')
64+
const startFiles = this.findTypeScriptFiles(startDir)
65+
66+
for (const routeFile of startFiles) {
67+
if (!existsSync(routeFile)) {
68+
continue
69+
}
70+
71+
const normalizedPath = this.normalizePath(routeFile)
72+
if (processedFiles.has(normalizedPath)) {
73+
continue
74+
}
75+
76+
processedFiles.add(normalizedPath)
77+
try {
78+
const content = readFileSync(routeFile, 'utf-8')
79+
routeCount += this.countRoutesInContent(content)
80+
} catch (error) {
81+
console.error(`Error reading route file ${routeFile}:`, error)
82+
}
83+
}
84+
85+
const sourceFiles = this.#project.getSourceFiles()
86+
for (const sourceFile of sourceFiles) {
87+
const filePath = sourceFile.getFilePath()
88+
const normalizedPath = this.normalizePath(filePath)
89+
90+
if (normalizedPath.includes('/tests/')) {
91+
continue
92+
}
93+
94+
if (processedFiles.has(normalizedPath)) {
95+
continue
96+
}
97+
98+
if (!normalizedPath.includes('/start/') && !normalizedPath.includes('/routes/')) {
99+
continue
100+
}
101+
102+
processedFiles.add(normalizedPath)
103+
try {
104+
const content = sourceFile.getFullText()
105+
routeCount += this.countRoutesInContent(content)
106+
} catch (error) {
107+
console.error(`Error reading source file ${filePath}:`, error)
108+
}
109+
}
110+
111+
return routeCount
112+
}
113+
114+
/**
115+
* Count routes in a given file content using AST grep
116+
* Handles both single-line and multiline route definitions
117+
*/
118+
private countRoutesInContent(content: string): number {
119+
let count = 0
120+
121+
try {
122+
const ast = parse(Lang.TypeScript, content)
123+
const root = ast.root()
124+
125+
const routePatterns = [
126+
// Pattern: router.get('/path', () => { ... })
127+
'$VAR.on($_)',
128+
'$VAR.get($_, $_)',
129+
'$VAR.post($_, $_)',
130+
'$VAR.put($_, $_)',
131+
'$VAR.patch($_, $_)',
132+
'$VAR.delete($_, $_)',
133+
'$VAR.head($_, $_)',
134+
'$VAR.options($_, $_)',
135+
'$VAR.any($_, $_)',
136+
'$VAR.match($_, $_, $_)',
137+
'$VAR.resource($_, $_)',
138+
// Routes with only path (no handler) - these are less common but possible
139+
'$VAR.get($_)',
140+
'$VAR.post($_)',
141+
'$VAR.put($_)',
142+
'$VAR.patch($_)',
143+
'$VAR.delete($_)',
144+
'$VAR.head($_)',
145+
'$VAR.options($_)',
146+
'$VAR.any($_)',
147+
]
148+
149+
for (const pattern of routePatterns) {
150+
const matches = root.findAll(pattern)
151+
for (const match of matches) {
152+
// Get the method name from the match
153+
const methodCall = match.text()
154+
155+
// Verify this is actually a route method call
156+
// Check for common router variable names or method patterns
157+
const isRouteCall =
158+
methodCall.includes('.get(') ||
159+
methodCall.includes('.post(') ||
160+
methodCall.includes('.put(') ||
161+
methodCall.includes('.patch(') ||
162+
methodCall.includes('.delete(') ||
163+
methodCall.includes('.head(') ||
164+
methodCall.includes('.options(') ||
165+
methodCall.includes('.any(') ||
166+
methodCall.includes('.match(') ||
167+
methodCall.includes('.on(') ||
168+
methodCall.includes('.resource(')
169+
170+
if (isRouteCall) {
171+
if (methodCall.includes('.resource(')) {
172+
// Resource routes create 7 routes (index, show, store, update, destroy, edit, create)
173+
count += 7
174+
} else {
175+
// Regular route method calls
176+
count += 1
177+
}
178+
}
179+
}
180+
}
181+
} catch (error) {
182+
console.error(`Error parsing content:`, error)
183+
}
184+
185+
return count
186+
}
187+
}

0 commit comments

Comments
 (0)