@shrkcrft/framework-scanners 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extractor-api/extractor-registry.d.ts +19 -0
- package/dist/extractor-api/extractor-registry.d.ts.map +1 -0
- package/dist/extractor-api/extractor-registry.js +36 -0
- package/dist/extractor-api/framework-extractor.d.ts +48 -0
- package/dist/extractor-api/framework-extractor.d.ts.map +1 -0
- package/dist/extractor-api/framework-extractor.js +1 -0
- package/dist/extractors/angular-extractor.d.ts +18 -0
- package/dist/extractors/angular-extractor.d.ts.map +1 -0
- package/dist/extractors/angular-extractor.js +175 -0
- package/dist/extractors/astro-extractor.d.ts +15 -0
- package/dist/extractors/astro-extractor.d.ts.map +1 -0
- package/dist/extractors/astro-extractor.js +128 -0
- package/dist/extractors/django-extractor.d.ts +24 -0
- package/dist/extractors/django-extractor.d.ts.map +1 -0
- package/dist/extractors/django-extractor.js +124 -0
- package/dist/extractors/express-extractor.d.ts +18 -0
- package/dist/extractors/express-extractor.d.ts.map +1 -0
- package/dist/extractors/express-extractor.js +193 -0
- package/dist/extractors/fastapi-extractor.d.ts +19 -0
- package/dist/extractors/fastapi-extractor.d.ts.map +1 -0
- package/dist/extractors/fastapi-extractor.js +135 -0
- package/dist/extractors/fastify-extractor.d.ts +13 -0
- package/dist/extractors/fastify-extractor.d.ts.map +1 -0
- package/dist/extractors/fastify-extractor.js +166 -0
- package/dist/extractors/flask-extractor.d.ts +16 -0
- package/dist/extractors/flask-extractor.d.ts.map +1 -0
- package/dist/extractors/flask-extractor.js +142 -0
- package/dist/extractors/flutter-extractor.d.ts +26 -0
- package/dist/extractors/flutter-extractor.d.ts.map +1 -0
- package/dist/extractors/flutter-extractor.js +137 -0
- package/dist/extractors/graphql-extractor.d.ts +27 -0
- package/dist/extractors/graphql-extractor.d.ts.map +1 -0
- package/dist/extractors/graphql-extractor.js +141 -0
- package/dist/extractors/laravel-extractor.d.ts +22 -0
- package/dist/extractors/laravel-extractor.d.ts.map +1 -0
- package/dist/extractors/laravel-extractor.js +208 -0
- package/dist/extractors/nestjs-extractor.d.ts +18 -0
- package/dist/extractors/nestjs-extractor.d.ts.map +1 -0
- package/dist/extractors/nestjs-extractor.js +222 -0
- package/dist/extractors/nextjs-extractor.d.ts +19 -0
- package/dist/extractors/nextjs-extractor.d.ts.map +1 -0
- package/dist/extractors/nextjs-extractor.js +175 -0
- package/dist/extractors/phoenix-extractor.d.ts +28 -0
- package/dist/extractors/phoenix-extractor.d.ts.map +1 -0
- package/dist/extractors/phoenix-extractor.js +212 -0
- package/dist/extractors/rails-extractor.d.ts +25 -0
- package/dist/extractors/rails-extractor.d.ts.map +1 -0
- package/dist/extractors/rails-extractor.js +180 -0
- package/dist/extractors/react-extractor.d.ts +19 -0
- package/dist/extractors/react-extractor.d.ts.map +1 -0
- package/dist/extractors/react-extractor.js +209 -0
- package/dist/extractors/solid-extractor.d.ts +19 -0
- package/dist/extractors/solid-extractor.d.ts.map +1 -0
- package/dist/extractors/solid-extractor.js +164 -0
- package/dist/extractors/spring-extractor.d.ts +27 -0
- package/dist/extractors/spring-extractor.d.ts.map +1 -0
- package/dist/extractors/spring-extractor.js +279 -0
- package/dist/extractors/svelte-extractor.d.ts +17 -0
- package/dist/extractors/svelte-extractor.d.ts.map +1 -0
- package/dist/extractors/svelte-extractor.js +104 -0
- package/dist/extractors/vue-extractor.d.ts +18 -0
- package/dist/extractors/vue-extractor.d.ts.map +1 -0
- package/dist/extractors/vue-extractor.js +125 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/query/framework-query-api.d.ts +39 -0
- package/dist/query/framework-query-api.d.ts.map +1 -0
- package/dist/query/framework-query-api.js +99 -0
- package/dist/runner/load-pack-extractors.d.ts +36 -0
- package/dist/runner/load-pack-extractors.d.ts.map +1 -0
- package/dist/runner/load-pack-extractors.js +87 -0
- package/dist/runner/run-extractors.d.ts +29 -0
- package/dist/runner/run-extractors.d.ts.map +1 -0
- package/dist/runner/run-extractors.js +144 -0
- package/dist/schema/framework-schema.d.ts +36 -0
- package/dist/schema/framework-schema.d.ts.map +1 -0
- package/dist/schema/framework-schema.js +1 -0
- package/dist/store/framework-store.d.ts +17 -0
- package/dist/store/framework-store.d.ts.map +1 -0
- package/dist/store/framework-store.js +138 -0
- package/package.json +55 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { EdgeKind, NodeKind } from '@shrkcrft/graph';
|
|
3
|
+
export const LARAVEL_EXTRACTOR_SOURCE = 'laravel-extractor@v1';
|
|
4
|
+
const ROUTE_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match']);
|
|
5
|
+
const FAST_FILTER_NEEDLES = [
|
|
6
|
+
'Illuminate\\',
|
|
7
|
+
'extends Controller',
|
|
8
|
+
'extends Model',
|
|
9
|
+
'extends Authenticatable',
|
|
10
|
+
'extends JsonResource',
|
|
11
|
+
'Route::',
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* Laravel framework extractor.
|
|
15
|
+
*
|
|
16
|
+
* Regex-only. Detection:
|
|
17
|
+
* - Class extending `Controller` / `BaseController` / `RestController` → controller.
|
|
18
|
+
* - Class extending `Model` / `Authenticatable` / `Pivot` → model.
|
|
19
|
+
* - Class extending `JsonResource` / `ResourceCollection` → resource.
|
|
20
|
+
* - `Route::get('/path', [Controller::class, 'action'])` (and other verbs) → route.
|
|
21
|
+
* - `Route::resource('users', UserController::class)` → route (RESOURCE).
|
|
22
|
+
*
|
|
23
|
+
* Inside controllers, every `public function name(...)` becomes an
|
|
24
|
+
* action entity wired back to the controller via `HandlesRoute`.
|
|
25
|
+
*
|
|
26
|
+
* Out of scope:
|
|
27
|
+
* - Route groups (`Route::middleware(...)->group(function () { … })`)
|
|
28
|
+
* - Model relations (hasMany, belongsTo, …).
|
|
29
|
+
* - Blade templates.
|
|
30
|
+
*/
|
|
31
|
+
export const laravelExtractor = {
|
|
32
|
+
framework: 'laravel',
|
|
33
|
+
label: 'Laravel',
|
|
34
|
+
fileMatches({ path, content }) {
|
|
35
|
+
if (!path.endsWith('.php'))
|
|
36
|
+
return false;
|
|
37
|
+
for (const needle of FAST_FILTER_NEEDLES) {
|
|
38
|
+
if (content.includes(needle))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
},
|
|
43
|
+
extract(input) {
|
|
44
|
+
const nodes = [];
|
|
45
|
+
const edges = [];
|
|
46
|
+
const lines = input.content.split('\n');
|
|
47
|
+
let currentControllerEntity;
|
|
48
|
+
// Stack of route-group prefixes from chained Route::prefix(...)->group
|
|
49
|
+
// and Route::middleware(...)->group(...) calls. A `->group(function`
|
|
50
|
+
// line pushes a captured prefix (empty when only middleware is
|
|
51
|
+
// applied), and the closing `});` pops.
|
|
52
|
+
const groupPrefixStack = [];
|
|
53
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
54
|
+
const raw = lines[i];
|
|
55
|
+
const trimmed = raw.trimStart();
|
|
56
|
+
// Detect group opens: e.g. `Route::prefix('/api')->group(function () {`
|
|
57
|
+
// or `Route::middleware(['auth'])->prefix('/v1')->group(function () {`.
|
|
58
|
+
// We pull the first `prefix('/x')` we see on the line if any; absent
|
|
59
|
+
// prefix → empty string.
|
|
60
|
+
const groupOpen = /->group\s*\(\s*function/.test(trimmed) && /Route::/.test(trimmed);
|
|
61
|
+
if (groupOpen) {
|
|
62
|
+
// `Route::prefix('/api')` (initial call) and chained
|
|
63
|
+
// `->prefix('/v1')` both need to be captured. We pick the
|
|
64
|
+
// first prefix on the line (outermost wins for nested
|
|
65
|
+
// chains).
|
|
66
|
+
const prefMatch = /(?:Route::|->)\s*prefix\s*\(\s*['"]([^'"]+)['"]/.exec(trimmed);
|
|
67
|
+
const prefix = prefMatch ? prefMatch[1] : '';
|
|
68
|
+
groupPrefixStack.push(prefix);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Closing `});` (typical for the group callback). Pop the
|
|
72
|
+
// innermost prefix.
|
|
73
|
+
if (groupPrefixStack.length > 0 && /^\}\s*\)\s*;?\s*$/.test(trimmed)) {
|
|
74
|
+
groupPrefixStack.pop();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// class FooController extends Controller
|
|
78
|
+
let m = /^(?:abstract\s+|final\s+)*class\s+([A-Z]\w*)\s+extends\s+(\w+)/.exec(trimmed);
|
|
79
|
+
if (m) {
|
|
80
|
+
const className = m[1];
|
|
81
|
+
const baseClass = m[2];
|
|
82
|
+
if (/(?:^|[A-Za-z])Controller$/.test(baseClass) || /BaseController$/.test(baseClass)) {
|
|
83
|
+
const e = makeEntity(input, 'controller', className, { className, baseClass });
|
|
84
|
+
nodes.push(e);
|
|
85
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'controller', line: i + 1 }));
|
|
86
|
+
currentControllerEntity = e;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (baseClass === 'Model' || baseClass === 'Authenticatable' || baseClass === 'Pivot') {
|
|
90
|
+
const e = makeEntity(input, 'model', className, { className, baseClass });
|
|
91
|
+
nodes.push(e);
|
|
92
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'model', line: i + 1 }));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (baseClass === 'JsonResource' || baseClass === 'ResourceCollection') {
|
|
96
|
+
const e = makeEntity(input, 'resource', className, { className, baseClass });
|
|
97
|
+
nodes.push(e);
|
|
98
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'resource', line: i + 1 }));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Unrelated class — stop the controller-action capture.
|
|
102
|
+
currentControllerEntity = undefined;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Closing `}` at column 0 ends the class body.
|
|
106
|
+
if (raw === '}' || raw.trimEnd() === '}') {
|
|
107
|
+
currentControllerEntity = undefined;
|
|
108
|
+
}
|
|
109
|
+
// Public action methods inside a controller body.
|
|
110
|
+
if (currentControllerEntity) {
|
|
111
|
+
m = /^\s+public\s+function\s+([a-zA-Z_]\w*)\s*\(/.exec(raw);
|
|
112
|
+
if (m && !m[1].startsWith('__')) {
|
|
113
|
+
const action = m[1];
|
|
114
|
+
const e = makeEntity(input, 'action', action, {
|
|
115
|
+
controller: currentControllerEntity.label,
|
|
116
|
+
action,
|
|
117
|
+
});
|
|
118
|
+
nodes.push(e);
|
|
119
|
+
edges.push(edge(currentControllerEntity.id, e.id, EdgeKind.HandlesRoute, {
|
|
120
|
+
controller: currentControllerEntity.label,
|
|
121
|
+
action,
|
|
122
|
+
}));
|
|
123
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'action', line: i + 1 }));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Route registrations: Route::get('/path', [Ctrl::class, 'action'])
|
|
127
|
+
// or Route::get('/path', 'Ctrl@action') or Route::resource('x', Ctrl::class)
|
|
128
|
+
const verbMatch = /Route::(\w+)\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*(?:\[([^\]]+)\]|['"]([^'"]+)['"]|([A-Z]\w*)::class))?/.exec(trimmed);
|
|
129
|
+
if (verbMatch) {
|
|
130
|
+
const verb = verbMatch[1];
|
|
131
|
+
const localPath = verbMatch[2];
|
|
132
|
+
const groupPrefix = combinePrefixes(groupPrefixStack);
|
|
133
|
+
if (verb === 'resource' || verb === 'apiResource') {
|
|
134
|
+
// Three possible target shapes: array, string, or bare `Foo::class`.
|
|
135
|
+
const target = verbMatch[5] ?? verbMatch[4] ?? verbMatch[3] ?? '';
|
|
136
|
+
const r = makeRouteEntity(input, verb.toUpperCase(), `${groupPrefix}/${localPath}`, target);
|
|
137
|
+
nodes.push(r);
|
|
138
|
+
edges.push(edge(input.fileNodeId, r.id, EdgeKind.FrameworkDeclares, { subtype: 'route', line: i + 1 }));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!ROUTE_VERBS.has(verb))
|
|
142
|
+
continue;
|
|
143
|
+
const verbUpper = verb.toUpperCase();
|
|
144
|
+
let target = verbMatch[4] ?? '';
|
|
145
|
+
if (verbMatch[3]) {
|
|
146
|
+
// Array form `[Ctrl::class, 'method']`.
|
|
147
|
+
const inside = verbMatch[3];
|
|
148
|
+
const ctrlM = /([A-Z]\w*)::class\s*,\s*['"]([^'"]+)['"]/.exec(inside);
|
|
149
|
+
if (ctrlM)
|
|
150
|
+
target = `${ctrlM[1]}@${ctrlM[2]}`;
|
|
151
|
+
}
|
|
152
|
+
const path = combinePathWithGroup(groupPrefix, localPath);
|
|
153
|
+
const r = makeRouteEntity(input, verbUpper, path, target);
|
|
154
|
+
nodes.push(r);
|
|
155
|
+
edges.push(edge(input.fileNodeId, r.id, EdgeKind.FrameworkDeclares, { subtype: 'route', line: i + 1 }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { nodes, edges };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
function makeEntity(input, subtype, label, extra) {
|
|
162
|
+
return {
|
|
163
|
+
id: `framework:laravel:${subtype}:${input.filePath}#${label}`,
|
|
164
|
+
kind: NodeKind.FrameworkEntity,
|
|
165
|
+
label,
|
|
166
|
+
path: input.filePath,
|
|
167
|
+
tags: ['laravel', subtype],
|
|
168
|
+
data: { framework: 'laravel', subtype, ...extra },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function makeRouteEntity(input, method, path, target) {
|
|
172
|
+
return {
|
|
173
|
+
id: `framework:laravel:route:${input.filePath}#${method}:${path}#${target}`,
|
|
174
|
+
kind: NodeKind.FrameworkEntity,
|
|
175
|
+
label: `${method} ${path}${target ? ` → ${target}` : ''}`,
|
|
176
|
+
path: input.filePath,
|
|
177
|
+
tags: ['laravel', 'route'],
|
|
178
|
+
data: { framework: 'laravel', subtype: 'route', method, path, target },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function edge(from, to, kind, data) {
|
|
182
|
+
return {
|
|
183
|
+
id: createHash('sha1').update(`${from}|${to}|${kind}`).digest('hex'),
|
|
184
|
+
from,
|
|
185
|
+
to,
|
|
186
|
+
kind,
|
|
187
|
+
source: LARAVEL_EXTRACTOR_SOURCE,
|
|
188
|
+
...(data ? { data } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Combine the prefix stack (innermost first → joined left-to-right) into
|
|
193
|
+
* a single leading prefix. Returns '' when no group is active.
|
|
194
|
+
*/
|
|
195
|
+
function combinePrefixes(stack) {
|
|
196
|
+
const parts = stack
|
|
197
|
+
.map((s) => s.replace(/^\//, '').replace(/\/$/, ''))
|
|
198
|
+
.filter((s) => s.length > 0);
|
|
199
|
+
if (parts.length === 0)
|
|
200
|
+
return '';
|
|
201
|
+
return '/' + parts.join('/');
|
|
202
|
+
}
|
|
203
|
+
function combinePathWithGroup(prefix, leaf) {
|
|
204
|
+
if (!prefix)
|
|
205
|
+
return leaf;
|
|
206
|
+
const cleanLeaf = leaf.startsWith('/') ? leaf : '/' + leaf;
|
|
207
|
+
return prefix + cleanLeaf;
|
|
208
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IFrameworkExtractor } from '../extractor-api/framework-extractor.js';
|
|
2
|
+
export declare const NESTJS_EXTRACTOR_SOURCE = "nestjs-extractor@v1";
|
|
3
|
+
/**
|
|
4
|
+
* NestJS extractor.
|
|
5
|
+
*
|
|
6
|
+
* Emits FrameworkEntity nodes (controller / module / provider / route)
|
|
7
|
+
* and connecting edges (FrameworkDeclares from file → entity;
|
|
8
|
+
* HandlesRoute from controller → route).
|
|
9
|
+
*
|
|
10
|
+
* Class-level entities are detected by their decorator names; route
|
|
11
|
+
* methods are detected by HTTP-method decorators on class members.
|
|
12
|
+
* Constructor-injection edges are out of scope for the MVP — they're
|
|
13
|
+
* encoded as a `data.injects` field on the consumer node instead, so
|
|
14
|
+
* later rounds can promote them to edges without changing the
|
|
15
|
+
* detection logic.
|
|
16
|
+
*/
|
|
17
|
+
export declare const nestjsExtractor: IFrameworkExtractor;
|
|
18
|
+
//# sourceMappingURL=nestjs-extractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nestjs-extractor.d.ts","sourceRoot":"","sources":["../../src/extractors/nestjs-extractor.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACpB,MAAM,yCAAyC,CAAC;AAEjD,eAAO,MAAM,uBAAuB,wBAAwB,CAAC;AAe7D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,EAAE,mBAwE7B,CAAC"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
3
|
+
import * as ts from 'typescript';
|
|
4
|
+
import { EdgeKind, NodeKind } from '@shrkcrft/graph';
|
|
5
|
+
export const NESTJS_EXTRACTOR_SOURCE = 'nestjs-extractor@v1';
|
|
6
|
+
const HTTP_METHOD_DECORATORS = new Set([
|
|
7
|
+
'Get',
|
|
8
|
+
'Post',
|
|
9
|
+
'Put',
|
|
10
|
+
'Delete',
|
|
11
|
+
'Patch',
|
|
12
|
+
'Options',
|
|
13
|
+
'Head',
|
|
14
|
+
'All',
|
|
15
|
+
]);
|
|
16
|
+
const FAST_FILTER_NEEDLES = ['@Controller', '@Module', '@Injectable', "from '@nestjs/"];
|
|
17
|
+
/**
|
|
18
|
+
* NestJS extractor.
|
|
19
|
+
*
|
|
20
|
+
* Emits FrameworkEntity nodes (controller / module / provider / route)
|
|
21
|
+
* and connecting edges (FrameworkDeclares from file → entity;
|
|
22
|
+
* HandlesRoute from controller → route).
|
|
23
|
+
*
|
|
24
|
+
* Class-level entities are detected by their decorator names; route
|
|
25
|
+
* methods are detected by HTTP-method decorators on class members.
|
|
26
|
+
* Constructor-injection edges are out of scope for the MVP — they're
|
|
27
|
+
* encoded as a `data.injects` field on the consumer node instead, so
|
|
28
|
+
* later rounds can promote them to edges without changing the
|
|
29
|
+
* detection logic.
|
|
30
|
+
*/
|
|
31
|
+
export const nestjsExtractor = {
|
|
32
|
+
framework: 'nestjs',
|
|
33
|
+
label: 'NestJS',
|
|
34
|
+
fileMatches({ path, content }) {
|
|
35
|
+
if (!/\.(?:t|j)sx?$/.test(path))
|
|
36
|
+
return false;
|
|
37
|
+
for (const needle of FAST_FILTER_NEEDLES) {
|
|
38
|
+
if (content.includes(needle))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
},
|
|
43
|
+
extract(input) {
|
|
44
|
+
const nodes = [];
|
|
45
|
+
const edges = [];
|
|
46
|
+
const sf = parse(input);
|
|
47
|
+
if (!sf)
|
|
48
|
+
return { nodes, edges };
|
|
49
|
+
const visit = (node) => {
|
|
50
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
51
|
+
const decorators = collectDecorators(node);
|
|
52
|
+
if (decorators.length === 0) {
|
|
53
|
+
ts.forEachChild(node, visit);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const ctrl = decorators.find((d) => d.name === 'Controller');
|
|
57
|
+
const mod = decorators.find((d) => d.name === 'Module');
|
|
58
|
+
const inj = decorators.find((d) => d.name === 'Injectable');
|
|
59
|
+
if (ctrl) {
|
|
60
|
+
const basePath = readFirstStringArg(ctrl.callArguments) ?? '';
|
|
61
|
+
const entity = makeEntity(input, node, 'controller', { basePath });
|
|
62
|
+
nodes.push(entity);
|
|
63
|
+
edges.push(edge(input.fileNodeId, entity.id, EdgeKind.FrameworkDeclares, { subtype: 'controller' }));
|
|
64
|
+
// Walk methods to find routes.
|
|
65
|
+
for (const member of node.members) {
|
|
66
|
+
if (!ts.isMethodDeclaration(member) || !member.name)
|
|
67
|
+
continue;
|
|
68
|
+
if (!ts.isIdentifier(member.name))
|
|
69
|
+
continue;
|
|
70
|
+
const methodDecorators = collectDecorators(member);
|
|
71
|
+
for (const d of methodDecorators) {
|
|
72
|
+
if (!HTTP_METHOD_DECORATORS.has(d.name))
|
|
73
|
+
continue;
|
|
74
|
+
const subPath = readFirstStringArg(d.callArguments) ?? '';
|
|
75
|
+
const fullPath = joinRoute(basePath, subPath);
|
|
76
|
+
const route = makeRouteEntity(input, node, member, {
|
|
77
|
+
method: d.name.toUpperCase(),
|
|
78
|
+
path: fullPath,
|
|
79
|
+
});
|
|
80
|
+
nodes.push(route);
|
|
81
|
+
edges.push(edge(entity.id, route.id, EdgeKind.HandlesRoute, {
|
|
82
|
+
method: d.name.toUpperCase(),
|
|
83
|
+
path: fullPath,
|
|
84
|
+
}));
|
|
85
|
+
edges.push(edge(input.fileNodeId, route.id, EdgeKind.FrameworkDeclares, { subtype: 'route' }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (mod) {
|
|
90
|
+
const entity = makeEntity(input, node, 'module', {
|
|
91
|
+
imports: readArrayProperty(mod.callArguments, 'imports'),
|
|
92
|
+
providers: readArrayProperty(mod.callArguments, 'providers'),
|
|
93
|
+
controllers: readArrayProperty(mod.callArguments, 'controllers'),
|
|
94
|
+
exports: readArrayProperty(mod.callArguments, 'exports'),
|
|
95
|
+
});
|
|
96
|
+
nodes.push(entity);
|
|
97
|
+
edges.push(edge(input.fileNodeId, entity.id, EdgeKind.FrameworkDeclares, { subtype: 'module' }));
|
|
98
|
+
}
|
|
99
|
+
else if (inj) {
|
|
100
|
+
const entity = makeEntity(input, node, 'provider', {});
|
|
101
|
+
nodes.push(entity);
|
|
102
|
+
edges.push(edge(input.fileNodeId, entity.id, EdgeKind.FrameworkDeclares, { subtype: 'provider' }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
ts.forEachChild(node, visit);
|
|
106
|
+
};
|
|
107
|
+
ts.forEachChild(sf, visit);
|
|
108
|
+
return { nodes, edges };
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
function collectDecorators(node) {
|
|
112
|
+
if (!ts.canHaveDecorators(node))
|
|
113
|
+
return [];
|
|
114
|
+
const decorators = ts.getDecorators(node) ?? [];
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const d of decorators) {
|
|
117
|
+
const expr = d.expression;
|
|
118
|
+
if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) {
|
|
119
|
+
out.push({ name: expr.expression.text, callArguments: expr.arguments });
|
|
120
|
+
}
|
|
121
|
+
else if (ts.isIdentifier(expr)) {
|
|
122
|
+
out.push({ name: expr.text });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
function readFirstStringArg(args) {
|
|
128
|
+
if (!args || args.length === 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
const a = args[0];
|
|
131
|
+
if (a && ts.isStringLiteral(a))
|
|
132
|
+
return a.text;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
function readArrayProperty(args, name) {
|
|
136
|
+
if (!args || args.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
const a = args[0];
|
|
139
|
+
if (!a || !ts.isObjectLiteralExpression(a))
|
|
140
|
+
return [];
|
|
141
|
+
for (const prop of a.properties) {
|
|
142
|
+
if (!ts.isPropertyAssignment(prop))
|
|
143
|
+
continue;
|
|
144
|
+
if (!ts.isIdentifier(prop.name) || prop.name.text !== name)
|
|
145
|
+
continue;
|
|
146
|
+
if (!ts.isArrayLiteralExpression(prop.initializer))
|
|
147
|
+
continue;
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const el of prop.initializer.elements) {
|
|
150
|
+
if (ts.isIdentifier(el))
|
|
151
|
+
out.push(el.text);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
function joinRoute(base, sub) {
|
|
158
|
+
const b = base.replace(/\/+$/, '');
|
|
159
|
+
const s = sub.replace(/^\/+/, '');
|
|
160
|
+
if (!b && !s)
|
|
161
|
+
return '/';
|
|
162
|
+
if (!b)
|
|
163
|
+
return '/' + s;
|
|
164
|
+
if (!s)
|
|
165
|
+
return '/' + b.replace(/^\/+/, '');
|
|
166
|
+
return '/' + b.replace(/^\/+/, '') + '/' + s;
|
|
167
|
+
}
|
|
168
|
+
function makeEntity(input, cls, subtype, extra) {
|
|
169
|
+
const name = cls.name?.text ?? 'Anonymous';
|
|
170
|
+
const id = `framework:nestjs:${subtype}:${input.filePath}#${name}`;
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
kind: NodeKind.FrameworkEntity,
|
|
174
|
+
label: name,
|
|
175
|
+
path: input.filePath,
|
|
176
|
+
tags: ['nestjs', subtype],
|
|
177
|
+
data: { framework: 'nestjs', subtype, className: name, ...extra },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function makeRouteEntity(input, cls, member, data) {
|
|
181
|
+
const className = cls.name?.text ?? 'Anonymous';
|
|
182
|
+
const methodName = (member.name && ts.isIdentifier(member.name)) ? member.name.text : 'handler';
|
|
183
|
+
const id = `framework:nestjs:route:${input.filePath}#${className}.${methodName}#${data.method}:${data.path}`;
|
|
184
|
+
return {
|
|
185
|
+
id,
|
|
186
|
+
kind: NodeKind.FrameworkEntity,
|
|
187
|
+
label: `${data.method} ${data.path}`,
|
|
188
|
+
path: input.filePath,
|
|
189
|
+
tags: ['nestjs', 'route'],
|
|
190
|
+
data: {
|
|
191
|
+
framework: 'nestjs',
|
|
192
|
+
subtype: 'route',
|
|
193
|
+
className,
|
|
194
|
+
handler: methodName,
|
|
195
|
+
method: data.method,
|
|
196
|
+
path: data.path,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function edge(from, to, kind, data) {
|
|
201
|
+
return {
|
|
202
|
+
id: createHash('sha1').update(`${from}|${to}|${kind}`).digest('hex'),
|
|
203
|
+
from,
|
|
204
|
+
to,
|
|
205
|
+
kind,
|
|
206
|
+
source: NESTJS_EXTRACTOR_SOURCE,
|
|
207
|
+
...(data ? { data } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function parse(input) {
|
|
211
|
+
const ext = nodePath.extname(input.filePath).toLowerCase();
|
|
212
|
+
const kind = ext === '.tsx' ? ts.ScriptKind.TSX
|
|
213
|
+
: ext === '.jsx' ? ts.ScriptKind.JSX
|
|
214
|
+
: ext === '.js' || ext === '.mjs' || ext === '.cjs' ? ts.ScriptKind.JS
|
|
215
|
+
: ts.ScriptKind.TS;
|
|
216
|
+
try {
|
|
217
|
+
return ts.createSourceFile(input.filePath, input.content, ts.ScriptTarget.Latest, true, kind);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IFrameworkExtractor } from '../extractor-api/framework-extractor.js';
|
|
2
|
+
export declare const NEXTJS_EXTRACTOR_SOURCE = "nextjs-extractor@v1";
|
|
3
|
+
/**
|
|
4
|
+
* Next.js extractor.
|
|
5
|
+
*
|
|
6
|
+
* Detects:
|
|
7
|
+
* - App-router files: `app/**\/page.tsx`, `layout.tsx`, `route.ts`,
|
|
8
|
+
* etc. The route path is derived from the file location, ignoring
|
|
9
|
+
* route groups `(...)` and parallel routes `@...`. `route.ts`
|
|
10
|
+
* additionally inspects the content for exported HTTP method names
|
|
11
|
+
* to emit per-method route entities.
|
|
12
|
+
* - Pages-router files: `pages/foo.tsx` → `/foo`, `pages/[id].tsx` →
|
|
13
|
+
* `/:id`. `pages/api/*.ts` becomes `api-route` entities.
|
|
14
|
+
*
|
|
15
|
+
* No content-shape detection — Next.js conventions are entirely
|
|
16
|
+
* filesystem-based for routing.
|
|
17
|
+
*/
|
|
18
|
+
export declare const nextjsExtractor: IFrameworkExtractor;
|
|
19
|
+
//# sourceMappingURL=nextjs-extractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nextjs-extractor.d.ts","sourceRoot":"","sources":["../../src/extractors/nextjs-extractor.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,mBAAmB,EACpB,MAAM,yCAAyC,CAAC;AAEjD,eAAO,MAAM,uBAAuB,wBAAwB,CAAC;AAY7D;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,EAAE,mBAwE7B,CAAC"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { EdgeKind, NodeKind } from '@shrkcrft/graph';
|
|
3
|
+
export const NEXTJS_EXTRACTOR_SOURCE = 'nextjs-extractor@v1';
|
|
4
|
+
// Path-based detection only — Next.js entities are defined by file
|
|
5
|
+
// LOCATION more than by file content. We still emit content-derived
|
|
6
|
+
// metadata (e.g. exported HTTP method names for app-router route.ts).
|
|
7
|
+
const APP_ROUTER_RE = /(?:^|\/)app\/(?:.*\/)?(page|layout|route|loading|error|not-found|template|head)\.(?:tsx?|jsx?)$/;
|
|
8
|
+
const PAGES_ROUTER_RE = /(?:^|\/)pages\/(?!_app|_document|_error|api\/)(.+?)\.(?:tsx?|jsx?)$/;
|
|
9
|
+
const PAGES_API_RE = /(?:^|\/)pages\/api\/(.+?)\.(?:tsx?|jsx?)$/;
|
|
10
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
11
|
+
/**
|
|
12
|
+
* Next.js extractor.
|
|
13
|
+
*
|
|
14
|
+
* Detects:
|
|
15
|
+
* - App-router files: `app/**\/page.tsx`, `layout.tsx`, `route.ts`,
|
|
16
|
+
* etc. The route path is derived from the file location, ignoring
|
|
17
|
+
* route groups `(...)` and parallel routes `@...`. `route.ts`
|
|
18
|
+
* additionally inspects the content for exported HTTP method names
|
|
19
|
+
* to emit per-method route entities.
|
|
20
|
+
* - Pages-router files: `pages/foo.tsx` → `/foo`, `pages/[id].tsx` →
|
|
21
|
+
* `/:id`. `pages/api/*.ts` becomes `api-route` entities.
|
|
22
|
+
*
|
|
23
|
+
* No content-shape detection — Next.js conventions are entirely
|
|
24
|
+
* filesystem-based for routing.
|
|
25
|
+
*/
|
|
26
|
+
export const nextjsExtractor = {
|
|
27
|
+
framework: 'nextjs',
|
|
28
|
+
label: 'Next.js',
|
|
29
|
+
fileMatches({ path }) {
|
|
30
|
+
return APP_ROUTER_RE.test(path) || PAGES_ROUTER_RE.test(path) || PAGES_API_RE.test(path);
|
|
31
|
+
},
|
|
32
|
+
extract(input) {
|
|
33
|
+
const nodes = [];
|
|
34
|
+
const edges = [];
|
|
35
|
+
const appMatch = APP_ROUTER_RE.exec(input.filePath);
|
|
36
|
+
if (appMatch) {
|
|
37
|
+
const role = appMatch[1];
|
|
38
|
+
const routePath = appRouteFromPath(input.filePath);
|
|
39
|
+
if (role === 'route') {
|
|
40
|
+
// route.ts — one entity per exported HTTP method.
|
|
41
|
+
const methods = detectExportedHttpMethods(input.content);
|
|
42
|
+
if (methods.length === 0) {
|
|
43
|
+
// Still emit a placeholder route entity so the file is
|
|
44
|
+
// surfaced; consumers see the empty method list.
|
|
45
|
+
const e = makeEntity(input, 'route', `route ${routePath}`, { kind: 'app-route', routePath, methods: [] });
|
|
46
|
+
nodes.push(e);
|
|
47
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'route' }));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
for (const method of methods) {
|
|
51
|
+
const e = makeEntity(input, 'route', `${method} ${routePath}`, {
|
|
52
|
+
kind: 'app-route',
|
|
53
|
+
routePath,
|
|
54
|
+
method,
|
|
55
|
+
});
|
|
56
|
+
nodes.push(e);
|
|
57
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'route' }));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const subtype = role; // 'page' | 'layout' | 'error' | …
|
|
63
|
+
const e = makeEntity(input, subtype, `${subtype} ${routePath}`, {
|
|
64
|
+
kind: `app-${subtype}`,
|
|
65
|
+
routePath,
|
|
66
|
+
});
|
|
67
|
+
nodes.push(e);
|
|
68
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype }));
|
|
69
|
+
}
|
|
70
|
+
return { nodes, edges };
|
|
71
|
+
}
|
|
72
|
+
const apiMatch = PAGES_API_RE.exec(input.filePath);
|
|
73
|
+
if (apiMatch) {
|
|
74
|
+
const routePath = '/api/' + normalizePagesPath(apiMatch[1]);
|
|
75
|
+
const e = makeEntity(input, 'api-route', `API ${routePath}`, {
|
|
76
|
+
kind: 'pages-api',
|
|
77
|
+
routePath,
|
|
78
|
+
});
|
|
79
|
+
nodes.push(e);
|
|
80
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'api-route' }));
|
|
81
|
+
return { nodes, edges };
|
|
82
|
+
}
|
|
83
|
+
const pageMatch = PAGES_ROUTER_RE.exec(input.filePath);
|
|
84
|
+
if (pageMatch) {
|
|
85
|
+
const routePath = '/' + normalizePagesPath(pageMatch[1]);
|
|
86
|
+
const e = makeEntity(input, 'page', `page ${routePath}`, {
|
|
87
|
+
kind: 'pages-route',
|
|
88
|
+
routePath,
|
|
89
|
+
});
|
|
90
|
+
nodes.push(e);
|
|
91
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'page' }));
|
|
92
|
+
return { nodes, edges };
|
|
93
|
+
}
|
|
94
|
+
return { nodes, edges };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
function appRouteFromPath(filePath) {
|
|
98
|
+
// Slice from the first `app/` segment onward, strip the filename,
|
|
99
|
+
// and remove route groups + parallel route segments.
|
|
100
|
+
const idx = filePath.indexOf('/app/');
|
|
101
|
+
if (idx === -1)
|
|
102
|
+
return '/';
|
|
103
|
+
const tail = filePath.slice(idx + '/app'.length);
|
|
104
|
+
const dirOnly = tail.replace(/\/[^/]+$/, '');
|
|
105
|
+
const segments = dirOnly.split('/').filter((s) => s.length > 0);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const seg of segments) {
|
|
108
|
+
// Route groups `(marketing)` are ignored.
|
|
109
|
+
if (/^\(.+\)$/.test(seg))
|
|
110
|
+
continue;
|
|
111
|
+
// Parallel routes `@modal` are ignored.
|
|
112
|
+
if (seg.startsWith('@'))
|
|
113
|
+
continue;
|
|
114
|
+
// Dynamic segments `[id]` → `:id`, `[...slug]` → `*`, `[[...slug]]` → `*?`.
|
|
115
|
+
if (/^\[\[\.\.\..+\]\]$/.test(seg))
|
|
116
|
+
out.push('*?');
|
|
117
|
+
else if (/^\[\.\.\..+\]$/.test(seg))
|
|
118
|
+
out.push('*');
|
|
119
|
+
else if (/^\[.+\]$/.test(seg))
|
|
120
|
+
out.push(':' + seg.slice(1, -1));
|
|
121
|
+
else
|
|
122
|
+
out.push(seg);
|
|
123
|
+
}
|
|
124
|
+
return out.length === 0 ? '/' : '/' + out.join('/');
|
|
125
|
+
}
|
|
126
|
+
function normalizePagesPath(p) {
|
|
127
|
+
// Strip trailing 'index'; map [id] → :id, etc. Same convention as
|
|
128
|
+
// appRouteFromPath but for the pages-router filename pattern.
|
|
129
|
+
const parts = p.split('/').filter((s) => s.length > 0);
|
|
130
|
+
const out = [];
|
|
131
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
132
|
+
const seg = parts[i];
|
|
133
|
+
if (i === parts.length - 1 && seg === 'index')
|
|
134
|
+
continue;
|
|
135
|
+
if (/^\[\[\.\.\..+\]\]$/.test(seg))
|
|
136
|
+
out.push('*?');
|
|
137
|
+
else if (/^\[\.\.\..+\]$/.test(seg))
|
|
138
|
+
out.push('*');
|
|
139
|
+
else if (/^\[.+\]$/.test(seg))
|
|
140
|
+
out.push(':' + seg.slice(1, -1));
|
|
141
|
+
else
|
|
142
|
+
out.push(seg);
|
|
143
|
+
}
|
|
144
|
+
return out.join('/');
|
|
145
|
+
}
|
|
146
|
+
function detectExportedHttpMethods(content) {
|
|
147
|
+
const found = [];
|
|
148
|
+
for (const method of HTTP_METHODS) {
|
|
149
|
+
// `export async function GET(...)`, `export function POST(`, `export const PUT = …`.
|
|
150
|
+
const re = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+|const\\s+)${method}\\b`);
|
|
151
|
+
if (re.test(content))
|
|
152
|
+
found.push(method);
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
function makeEntity(input, subtype, label, data) {
|
|
157
|
+
return {
|
|
158
|
+
id: `framework:nextjs:${subtype}:${input.filePath}#${label}`,
|
|
159
|
+
kind: NodeKind.FrameworkEntity,
|
|
160
|
+
label,
|
|
161
|
+
path: input.filePath,
|
|
162
|
+
tags: ['nextjs', subtype],
|
|
163
|
+
data: { framework: 'nextjs', subtype, ...data },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function edge(from, to, kind, data) {
|
|
167
|
+
return {
|
|
168
|
+
id: createHash('sha1').update(`${from}|${to}|${kind}`).digest('hex'),
|
|
169
|
+
from,
|
|
170
|
+
to,
|
|
171
|
+
kind,
|
|
172
|
+
source: NEXTJS_EXTRACTOR_SOURCE,
|
|
173
|
+
...(data ? { data } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { IFrameworkExtractor } from '../extractor-api/framework-extractor.js';
|
|
2
|
+
export declare const PHOENIX_EXTRACTOR_SOURCE = "phoenix-extractor@v1";
|
|
3
|
+
/**
|
|
4
|
+
* Phoenix extractor.
|
|
5
|
+
*
|
|
6
|
+
* Regex-only. Detection:
|
|
7
|
+
*
|
|
8
|
+
* - **Controller**: `defmodule X do` followed by
|
|
9
|
+
* `use AppWeb, :controller` OR `use Phoenix.Controller` →
|
|
10
|
+
* controller entity. Public `def action(conn, params)` inside →
|
|
11
|
+
* action entities.
|
|
12
|
+
*
|
|
13
|
+
* - **Router**: any module with `use Phoenix.Router` (or
|
|
14
|
+
* `use AppWeb, :router`). Parses top-level `get|post|...
|
|
15
|
+
* "/path", Controller, :action` lines → route entities.
|
|
16
|
+
*
|
|
17
|
+
* - **Schema**: `use Ecto.Schema` → schema entity (subtype: model).
|
|
18
|
+
*
|
|
19
|
+
* - **LiveView / LiveComponent**: `use Phoenix.LiveView` /
|
|
20
|
+
* `use Phoenix.LiveComponent` → component entity.
|
|
21
|
+
*
|
|
22
|
+
* Out of scope:
|
|
23
|
+
* - `pipeline :foo do ... end` aggregations.
|
|
24
|
+
* - `scope "/path", AppWeb do ... end` prefix nesting.
|
|
25
|
+
* - Channel modules.
|
|
26
|
+
*/
|
|
27
|
+
export declare const phoenixExtractor: IFrameworkExtractor;
|
|
28
|
+
//# sourceMappingURL=phoenix-extractor.d.ts.map
|