@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 @@
|
|
|
1
|
+
{"version":3,"file":"phoenix-extractor.d.ts","sourceRoot":"","sources":["../../src/extractors/phoenix-extractor.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,mBAAmB,EACpB,MAAM,yCAAyC,CAAC;AAEjD,eAAO,MAAM,wBAAwB,yBAAyB,CAAC;AAc/D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,gBAAgB,EAAE,mBA8H9B,CAAC"}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { EdgeKind, NodeKind } from '@shrkcrft/graph';
|
|
3
|
+
export const PHOENIX_EXTRACTOR_SOURCE = 'phoenix-extractor@v1';
|
|
4
|
+
const HTTP_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']);
|
|
5
|
+
const FAST_FILTER_NEEDLES = [
|
|
6
|
+
'use Phoenix.Controller',
|
|
7
|
+
'use Phoenix.Router',
|
|
8
|
+
'use Phoenix.LiveView',
|
|
9
|
+
'use Phoenix.LiveComponent',
|
|
10
|
+
'use Ecto.Schema',
|
|
11
|
+
':controller',
|
|
12
|
+
':router',
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Phoenix extractor.
|
|
16
|
+
*
|
|
17
|
+
* Regex-only. Detection:
|
|
18
|
+
*
|
|
19
|
+
* - **Controller**: `defmodule X do` followed by
|
|
20
|
+
* `use AppWeb, :controller` OR `use Phoenix.Controller` →
|
|
21
|
+
* controller entity. Public `def action(conn, params)` inside →
|
|
22
|
+
* action entities.
|
|
23
|
+
*
|
|
24
|
+
* - **Router**: any module with `use Phoenix.Router` (or
|
|
25
|
+
* `use AppWeb, :router`). Parses top-level `get|post|...
|
|
26
|
+
* "/path", Controller, :action` lines → route entities.
|
|
27
|
+
*
|
|
28
|
+
* - **Schema**: `use Ecto.Schema` → schema entity (subtype: model).
|
|
29
|
+
*
|
|
30
|
+
* - **LiveView / LiveComponent**: `use Phoenix.LiveView` /
|
|
31
|
+
* `use Phoenix.LiveComponent` → component entity.
|
|
32
|
+
*
|
|
33
|
+
* Out of scope:
|
|
34
|
+
* - `pipeline :foo do ... end` aggregations.
|
|
35
|
+
* - `scope "/path", AppWeb do ... end` prefix nesting.
|
|
36
|
+
* - Channel modules.
|
|
37
|
+
*/
|
|
38
|
+
export const phoenixExtractor = {
|
|
39
|
+
framework: 'phoenix',
|
|
40
|
+
label: 'Phoenix',
|
|
41
|
+
fileMatches({ path, content }) {
|
|
42
|
+
if (!path.endsWith('.ex') && !path.endsWith('.exs'))
|
|
43
|
+
return false;
|
|
44
|
+
for (const needle of FAST_FILTER_NEEDLES) {
|
|
45
|
+
if (content.includes(needle))
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
},
|
|
50
|
+
extract(input) {
|
|
51
|
+
const nodes = [];
|
|
52
|
+
const edges = [];
|
|
53
|
+
const lines = input.content.split('\n');
|
|
54
|
+
// Discover the current module + its kind (controller / router / live / schema).
|
|
55
|
+
let currentModuleName;
|
|
56
|
+
let currentEntity;
|
|
57
|
+
let currentKind;
|
|
58
|
+
// Stack of `scope "/prefix", AppWeb do … end` prefixes — only used
|
|
59
|
+
// inside a router module. Each `scope` line pushes; each `end`
|
|
60
|
+
// line at the matching depth pops.
|
|
61
|
+
const scopeStack = [];
|
|
62
|
+
// Parallel stack for the optional module argument
|
|
63
|
+
// (`scope "/api", AppWeb do`). When set, the captured controller
|
|
64
|
+
// name is prefixed with this module. Empty string when the scope
|
|
65
|
+
// had no module argument (i.e. only a path prefix).
|
|
66
|
+
const scopeModuleStack = [];
|
|
67
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
68
|
+
const raw = lines[i];
|
|
69
|
+
const trimmed = raw.trimStart();
|
|
70
|
+
const defm = /^defmodule\s+([A-Z][\w.]*)\s+do/.exec(trimmed);
|
|
71
|
+
if (defm) {
|
|
72
|
+
currentModuleName = defm[1];
|
|
73
|
+
currentEntity = undefined;
|
|
74
|
+
currentKind = undefined;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!currentModuleName)
|
|
78
|
+
continue;
|
|
79
|
+
// Bind the module to its Phoenix role via `use` calls.
|
|
80
|
+
if (!currentEntity) {
|
|
81
|
+
const useMatch = /^use\s+([A-Z][\w.]*)(?:,\s*(:?\w+))?/.exec(trimmed);
|
|
82
|
+
if (useMatch) {
|
|
83
|
+
const mod = useMatch[1];
|
|
84
|
+
const arg = useMatch[2] ?? '';
|
|
85
|
+
if (mod === 'Phoenix.Controller' || arg === ':controller')
|
|
86
|
+
currentKind = 'controller';
|
|
87
|
+
else if (mod === 'Phoenix.Router' || arg === ':router')
|
|
88
|
+
currentKind = 'router';
|
|
89
|
+
else if (mod === 'Phoenix.LiveView')
|
|
90
|
+
currentKind = 'live-view';
|
|
91
|
+
else if (mod === 'Phoenix.LiveComponent')
|
|
92
|
+
currentKind = 'live-component';
|
|
93
|
+
else if (mod === 'Ecto.Schema')
|
|
94
|
+
currentKind = 'schema';
|
|
95
|
+
if (currentKind) {
|
|
96
|
+
const e = makeEntity(input, currentKind, currentModuleName, { moduleName: currentModuleName });
|
|
97
|
+
nodes.push(e);
|
|
98
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: currentKind, line: i + 1 }));
|
|
99
|
+
currentEntity = e;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Controller actions: `def action(conn, params)`.
|
|
105
|
+
if (currentKind === 'controller' && currentEntity) {
|
|
106
|
+
const actionMatch = /^def\s+([a-z_][\w?!]*)\s*\(\s*conn\b/.exec(trimmed);
|
|
107
|
+
if (actionMatch) {
|
|
108
|
+
const action = actionMatch[1];
|
|
109
|
+
const e = makeEntity(input, 'action', action, {
|
|
110
|
+
controller: currentEntity.label,
|
|
111
|
+
action,
|
|
112
|
+
});
|
|
113
|
+
nodes.push(e);
|
|
114
|
+
edges.push(edge(currentEntity.id, e.id, EdgeKind.HandlesRoute, {
|
|
115
|
+
controller: currentEntity.label,
|
|
116
|
+
action,
|
|
117
|
+
}));
|
|
118
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'action', line: i + 1 }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Router routes: `get "/path", Controller, :action`.
|
|
122
|
+
if (currentKind === 'router' && currentEntity) {
|
|
123
|
+
// `scope "/prefix", AppWeb do` — push prefix + module arg;
|
|
124
|
+
// matching `end` pops. We track scope opens/closes via the
|
|
125
|
+
// trailing `do` and the line-level `end`.
|
|
126
|
+
const scopeMatch = /^scope\s+"([^"]*)"(?:\s*,\s*([A-Z][\w.]*))?\s*do\b/.exec(trimmed);
|
|
127
|
+
if (scopeMatch) {
|
|
128
|
+
scopeStack.push(scopeMatch[1]);
|
|
129
|
+
scopeModuleStack.push(scopeMatch[2] ?? '');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (/^end\b/.test(trimmed) && scopeStack.length > 0) {
|
|
133
|
+
scopeStack.pop();
|
|
134
|
+
scopeModuleStack.pop();
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const routeMatch = /^(get|post|put|patch|delete|options|head)\s+"([^"]+)"\s*,\s*([A-Z][\w.]*)\s*,\s*:(\w+)/.exec(trimmed);
|
|
138
|
+
if (routeMatch) {
|
|
139
|
+
const verb = routeMatch[1].toUpperCase();
|
|
140
|
+
const localPath = routeMatch[2];
|
|
141
|
+
const rawCtrl = routeMatch[3];
|
|
142
|
+
const action = routeMatch[4];
|
|
143
|
+
if (HTTP_VERBS.has(routeMatch[1])) {
|
|
144
|
+
const path = combineScopes(scopeStack, localPath);
|
|
145
|
+
// Qualify the controller with the innermost scope's module
|
|
146
|
+
// argument if it was set and the captured name isn't
|
|
147
|
+
// already a fully-qualified `Mod.Sub.Controller`.
|
|
148
|
+
const innerModule = scopeModuleStack.length > 0 ? scopeModuleStack[scopeModuleStack.length - 1] : '';
|
|
149
|
+
const ctrl = innerModule && !rawCtrl.includes('.') ? `${innerModule}.${rawCtrl}` : rawCtrl;
|
|
150
|
+
const r = makeRouteEntity(input, currentEntity.label, verb, path, ctrl, action);
|
|
151
|
+
nodes.push(r);
|
|
152
|
+
edges.push(edge(currentEntity.id, r.id, EdgeKind.HandlesRoute, {
|
|
153
|
+
method: verb,
|
|
154
|
+
path,
|
|
155
|
+
controller: ctrl,
|
|
156
|
+
action,
|
|
157
|
+
}));
|
|
158
|
+
edges.push(edge(input.fileNodeId, r.id, EdgeKind.FrameworkDeclares, { subtype: 'route', line: i + 1 }));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { nodes, edges };
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
function makeEntity(input, subtype, label, extra) {
|
|
167
|
+
return {
|
|
168
|
+
id: `framework:phoenix:${subtype}:${input.filePath}#${label}`,
|
|
169
|
+
kind: NodeKind.FrameworkEntity,
|
|
170
|
+
label,
|
|
171
|
+
path: input.filePath,
|
|
172
|
+
tags: ['phoenix', subtype],
|
|
173
|
+
data: { framework: 'phoenix', subtype, ...extra },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function makeRouteEntity(input, routerModule, method, path, controller, action) {
|
|
177
|
+
return {
|
|
178
|
+
id: `framework:phoenix:route:${input.filePath}#${routerModule}#${method}:${path}#${controller}.${action}`,
|
|
179
|
+
kind: NodeKind.FrameworkEntity,
|
|
180
|
+
label: `${method} ${path} → ${controller}.${action}`,
|
|
181
|
+
path: input.filePath,
|
|
182
|
+
tags: ['phoenix', 'route'],
|
|
183
|
+
data: { framework: 'phoenix', subtype: 'route', method, path, controller, action, router: routerModule },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function edge(from, to, kind, data) {
|
|
187
|
+
return {
|
|
188
|
+
id: createHash('sha1').update(`${from}|${to}|${kind}`).digest('hex'),
|
|
189
|
+
from,
|
|
190
|
+
to,
|
|
191
|
+
kind,
|
|
192
|
+
source: PHOENIX_EXTRACTOR_SOURCE,
|
|
193
|
+
...(data ? { data } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Combine an outer scope prefix stack with a leaf path. Treats
|
|
198
|
+
* `/` as the no-op prefix. Returns `/` when both prefix and leaf are
|
|
199
|
+
* empty.
|
|
200
|
+
*/
|
|
201
|
+
function combineScopes(stack, leaf) {
|
|
202
|
+
const prefix = stack
|
|
203
|
+
.map((s) => s.replace(/^\//, '').replace(/\/$/, ''))
|
|
204
|
+
.filter((s) => s.length > 0)
|
|
205
|
+
.join('/');
|
|
206
|
+
const cleanLeaf = leaf.replace(/^\//, '');
|
|
207
|
+
if (!prefix)
|
|
208
|
+
return cleanLeaf ? '/' + cleanLeaf : '/';
|
|
209
|
+
if (!cleanLeaf)
|
|
210
|
+
return '/' + prefix;
|
|
211
|
+
return '/' + prefix + '/' + cleanLeaf;
|
|
212
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IFrameworkExtractor } from '../extractor-api/framework-extractor.js';
|
|
2
|
+
export declare const RAILS_EXTRACTOR_SOURCE = "rails-extractor@v1";
|
|
3
|
+
/**
|
|
4
|
+
* Rails extractor.
|
|
5
|
+
*
|
|
6
|
+
* Regex-only. Detection model:
|
|
7
|
+
*
|
|
8
|
+
* - **Controllers**: `class Name < ApplicationController` (or
|
|
9
|
+
* `ActionController::Base`). Action methods are public `def`s
|
|
10
|
+
* declared inside; we capture the controller as a single entity
|
|
11
|
+
* plus one `route`-style action entity per `def`.
|
|
12
|
+
* - **Models**: `class Name < ApplicationRecord` (or
|
|
13
|
+
* `ActiveRecord::Base`) → model entity.
|
|
14
|
+
* - **Routes**: any file named `routes.rb` is parsed for top-level
|
|
15
|
+
* `resources :name`, `resource :name`, `get '...' => '...'`, and
|
|
16
|
+
* bare `get '...'`. Each emits a route entity.
|
|
17
|
+
*
|
|
18
|
+
* Out of scope:
|
|
19
|
+
* - `namespace :foo do ... end` nesting (we don't merge the prefix
|
|
20
|
+
* into nested route paths).
|
|
21
|
+
* - Before-action filters / authorization DSLs.
|
|
22
|
+
* - Concerns / mixins.
|
|
23
|
+
*/
|
|
24
|
+
export declare const railsExtractor: IFrameworkExtractor;
|
|
25
|
+
//# sourceMappingURL=rails-extractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rails-extractor.d.ts","sourceRoot":"","sources":["../../src/extractors/rails-extractor.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,mBAAmB,EACpB,MAAM,yCAAyC,CAAC;AAEjD,eAAO,MAAM,sBAAsB,uBAAuB,CAAC;AAc3D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,cAAc,EAAE,mBA+G5B,CAAC"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { EdgeKind, NodeKind } from '@shrkcrft/graph';
|
|
3
|
+
export const RAILS_EXTRACTOR_SOURCE = 'rails-extractor@v1';
|
|
4
|
+
const HTTP_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'match']);
|
|
5
|
+
const FAST_FILTER_NEEDLES = [
|
|
6
|
+
'ApplicationController',
|
|
7
|
+
'ActionController::',
|
|
8
|
+
'ApplicationRecord',
|
|
9
|
+
'ActiveRecord::Base',
|
|
10
|
+
'Rails.application.routes.draw',
|
|
11
|
+
'resources :',
|
|
12
|
+
'resource :',
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Rails extractor.
|
|
16
|
+
*
|
|
17
|
+
* Regex-only. Detection model:
|
|
18
|
+
*
|
|
19
|
+
* - **Controllers**: `class Name < ApplicationController` (or
|
|
20
|
+
* `ActionController::Base`). Action methods are public `def`s
|
|
21
|
+
* declared inside; we capture the controller as a single entity
|
|
22
|
+
* plus one `route`-style action entity per `def`.
|
|
23
|
+
* - **Models**: `class Name < ApplicationRecord` (or
|
|
24
|
+
* `ActiveRecord::Base`) → model entity.
|
|
25
|
+
* - **Routes**: any file named `routes.rb` is parsed for top-level
|
|
26
|
+
* `resources :name`, `resource :name`, `get '...' => '...'`, and
|
|
27
|
+
* bare `get '...'`. Each emits a route entity.
|
|
28
|
+
*
|
|
29
|
+
* Out of scope:
|
|
30
|
+
* - `namespace :foo do ... end` nesting (we don't merge the prefix
|
|
31
|
+
* into nested route paths).
|
|
32
|
+
* - Before-action filters / authorization DSLs.
|
|
33
|
+
* - Concerns / mixins.
|
|
34
|
+
*/
|
|
35
|
+
export const railsExtractor = {
|
|
36
|
+
framework: 'rails',
|
|
37
|
+
label: 'Rails',
|
|
38
|
+
fileMatches({ path, content }) {
|
|
39
|
+
if (!path.endsWith('.rb'))
|
|
40
|
+
return false;
|
|
41
|
+
if (/(?:^|\/)routes\.rb$/.test(path))
|
|
42
|
+
return true;
|
|
43
|
+
for (const needle of FAST_FILTER_NEEDLES) {
|
|
44
|
+
if (content.includes(needle))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
},
|
|
49
|
+
extract(input) {
|
|
50
|
+
const nodes = [];
|
|
51
|
+
const edges = [];
|
|
52
|
+
const lines = input.content.split('\n');
|
|
53
|
+
// Controllers + models walk class declarations.
|
|
54
|
+
let currentControllerEntity;
|
|
55
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
56
|
+
const raw = lines[i];
|
|
57
|
+
// Controller
|
|
58
|
+
let m = /^class\s+([A-Z][\w]*)\s*<\s*(?:ApplicationController|ActionController::[A-Za-z]+)/.exec(raw);
|
|
59
|
+
if (m) {
|
|
60
|
+
const e = makeEntity(input, 'controller', m[1], { className: m[1] });
|
|
61
|
+
nodes.push(e);
|
|
62
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'controller', line: i + 1 }));
|
|
63
|
+
currentControllerEntity = e;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Model
|
|
67
|
+
m = /^class\s+([A-Z][\w]*)\s*<\s*(?:ApplicationRecord|ActiveRecord::Base)/.exec(raw);
|
|
68
|
+
if (m) {
|
|
69
|
+
const e = makeEntity(input, 'model', m[1], { className: m[1] });
|
|
70
|
+
nodes.push(e);
|
|
71
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'model', line: i + 1 }));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Controller actions (public def inside a controller body).
|
|
75
|
+
m = /^\s+def\s+([a-z_][\w?!]*)/.exec(raw);
|
|
76
|
+
if (m && currentControllerEntity) {
|
|
77
|
+
const action = m[1];
|
|
78
|
+
// Skip private rails internals.
|
|
79
|
+
if (action.startsWith('_'))
|
|
80
|
+
continue;
|
|
81
|
+
const e = makeEntity(input, 'action', action, {
|
|
82
|
+
controller: currentControllerEntity.label,
|
|
83
|
+
action,
|
|
84
|
+
});
|
|
85
|
+
nodes.push(e);
|
|
86
|
+
edges.push(edge(currentControllerEntity.id, e.id, EdgeKind.HandlesRoute, {
|
|
87
|
+
action,
|
|
88
|
+
controller: currentControllerEntity.label,
|
|
89
|
+
}));
|
|
90
|
+
edges.push(edge(input.fileNodeId, e.id, EdgeKind.FrameworkDeclares, { subtype: 'action', line: i + 1 }));
|
|
91
|
+
}
|
|
92
|
+
// `end` at column 0 closes the controller. Heuristic; works for
|
|
93
|
+
// typical Rails one-class-per-file convention.
|
|
94
|
+
if (/^end\s*$/.test(raw.trimEnd())) {
|
|
95
|
+
currentControllerEntity = undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Routes (only in routes.rb).
|
|
99
|
+
if (/(?:^|\/)routes\.rb$/.test(input.filePath)) {
|
|
100
|
+
// Stack of `namespace :v1 do ... end` prefixes. Each entry is
|
|
101
|
+
// the symbol name (without leading colon), pushed on
|
|
102
|
+
// `namespace :foo do` and popped on the matching `end`.
|
|
103
|
+
const namespaceStack = [];
|
|
104
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
105
|
+
const raw = lines[i].trim();
|
|
106
|
+
if (!raw || raw.startsWith('#'))
|
|
107
|
+
continue;
|
|
108
|
+
// namespace :foo do → push
|
|
109
|
+
const nsMatch = /^namespace\s+:([a-z_]\w*)\s+do\b/.exec(raw);
|
|
110
|
+
if (nsMatch) {
|
|
111
|
+
namespaceStack.push(nsMatch[1]);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// `end` line closes the nearest namespace.
|
|
115
|
+
if (/^end\b/.test(raw) && namespaceStack.length > 0) {
|
|
116
|
+
namespaceStack.pop();
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const prefix = namespaceStack.length > 0 ? '/' + namespaceStack.join('/') : '';
|
|
120
|
+
let m = /^resources\s+:([a-z_]\w*)/.exec(raw);
|
|
121
|
+
if (m) {
|
|
122
|
+
const r = makeRouteEntity(input, m[1], 'RESOURCES', `${prefix}/${m[1]}`, 'index');
|
|
123
|
+
nodes.push(r);
|
|
124
|
+
edges.push(edge(input.fileNodeId, r.id, EdgeKind.FrameworkDeclares, { subtype: 'route', line: i + 1 }));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
m = /^resource\s+:([a-z_]\w*)/.exec(raw);
|
|
128
|
+
if (m) {
|
|
129
|
+
const r = makeRouteEntity(input, m[1], 'RESOURCE', `${prefix}/${m[1]}`, 'show');
|
|
130
|
+
nodes.push(r);
|
|
131
|
+
edges.push(edge(input.fileNodeId, r.id, EdgeKind.FrameworkDeclares, { subtype: 'route', line: i + 1 }));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// `get '/path' => 'controller#action'` OR `get '/path', to: 'controller#action'`
|
|
135
|
+
m = /^(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"](?:\s*(?:=>|,\s*to:)\s*['"]([^'"]+)['"])?/.exec(raw);
|
|
136
|
+
if (m && HTTP_VERBS.has(m[1])) {
|
|
137
|
+
const verb = m[1].toUpperCase();
|
|
138
|
+
const leaf = m[2];
|
|
139
|
+
const path = prefix ? `${prefix}${leaf.startsWith('/') ? '' : '/'}${leaf}` : leaf;
|
|
140
|
+
const target = m[3] ?? '';
|
|
141
|
+
const r = makeRouteEntity(input, 'routes', verb, path, target);
|
|
142
|
+
nodes.push(r);
|
|
143
|
+
edges.push(edge(input.fileNodeId, r.id, EdgeKind.FrameworkDeclares, { subtype: 'route', line: i + 1 }));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { nodes, edges };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
function makeEntity(input, subtype, label, extra) {
|
|
151
|
+
return {
|
|
152
|
+
id: `framework:rails:${subtype}:${input.filePath}#${label}`,
|
|
153
|
+
kind: NodeKind.FrameworkEntity,
|
|
154
|
+
label,
|
|
155
|
+
path: input.filePath,
|
|
156
|
+
tags: ['rails', subtype],
|
|
157
|
+
data: { framework: 'rails', subtype, ...extra },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function makeRouteEntity(input, scope, method, path, target) {
|
|
161
|
+
const id = `framework:rails:route:${input.filePath}#${scope}#${method}:${path}#${target}`;
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
164
|
+
kind: NodeKind.FrameworkEntity,
|
|
165
|
+
label: `${method} ${path}${target ? ` → ${target}` : ''}`,
|
|
166
|
+
path: input.filePath,
|
|
167
|
+
tags: ['rails', 'route'],
|
|
168
|
+
data: { framework: 'rails', subtype: 'route', method, path, target, scope },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function edge(from, to, kind, data) {
|
|
172
|
+
return {
|
|
173
|
+
id: createHash('sha1').update(`${from}|${to}|${kind}`).digest('hex'),
|
|
174
|
+
from,
|
|
175
|
+
to,
|
|
176
|
+
kind,
|
|
177
|
+
source: RAILS_EXTRACTOR_SOURCE,
|
|
178
|
+
...(data ? { data } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IFrameworkExtractor } from '../extractor-api/framework-extractor.js';
|
|
2
|
+
export declare const REACT_EXTRACTOR_SOURCE = "react-extractor@v1";
|
|
3
|
+
/**
|
|
4
|
+
* React extractor.
|
|
5
|
+
*
|
|
6
|
+
* Heuristic detection (no full type information):
|
|
7
|
+
* - **Component**: top-level function / arrow function / class
|
|
8
|
+
* declaration whose name starts with an uppercase letter AND whose
|
|
9
|
+
* body contains a JSX element. False positives in TSX are filtered
|
|
10
|
+
* by the JSX-presence check.
|
|
11
|
+
* - **Hook usage**: any CallExpression whose callee identifier
|
|
12
|
+
* matches `/^use[A-Z]/`. Recorded as a single hook-usage entity
|
|
13
|
+
* per (file, hook name) pair.
|
|
14
|
+
*
|
|
15
|
+
* Emits FrameworkDeclares edges from file → entity, and UsesHook edges
|
|
16
|
+
* from component → hook-usage entity when both can be co-located.
|
|
17
|
+
*/
|
|
18
|
+
export declare const reactExtractor: IFrameworkExtractor;
|
|
19
|
+
//# sourceMappingURL=react-extractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react-extractor.d.ts","sourceRoot":"","sources":["../../src/extractors/react-extractor.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACpB,MAAM,yCAAyC,CAAC;AAEjD,eAAO,MAAM,sBAAsB,uBAAuB,CAAC;AAa3D;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,EAAE,mBA0E5B,CAAC"}
|
|
@@ -0,0 +1,209 @@
|
|
|
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 REACT_EXTRACTOR_SOURCE = 'react-extractor@v1';
|
|
6
|
+
const HOOK_NAME_RE = /^use[A-Z]/;
|
|
7
|
+
const FAST_FILTER_NEEDLES = [
|
|
8
|
+
"from 'react'",
|
|
9
|
+
'from "react"',
|
|
10
|
+
"from 'react/jsx-runtime'",
|
|
11
|
+
'jsx',
|
|
12
|
+
'React.FC',
|
|
13
|
+
'createElement',
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* React extractor.
|
|
17
|
+
*
|
|
18
|
+
* Heuristic detection (no full type information):
|
|
19
|
+
* - **Component**: top-level function / arrow function / class
|
|
20
|
+
* declaration whose name starts with an uppercase letter AND whose
|
|
21
|
+
* body contains a JSX element. False positives in TSX are filtered
|
|
22
|
+
* by the JSX-presence check.
|
|
23
|
+
* - **Hook usage**: any CallExpression whose callee identifier
|
|
24
|
+
* matches `/^use[A-Z]/`. Recorded as a single hook-usage entity
|
|
25
|
+
* per (file, hook name) pair.
|
|
26
|
+
*
|
|
27
|
+
* Emits FrameworkDeclares edges from file → entity, and UsesHook edges
|
|
28
|
+
* from component → hook-usage entity when both can be co-located.
|
|
29
|
+
*/
|
|
30
|
+
export const reactExtractor = {
|
|
31
|
+
framework: 'react',
|
|
32
|
+
label: 'React',
|
|
33
|
+
fileMatches({ path, content }) {
|
|
34
|
+
if (!/\.(?:t|j)sx?$/.test(path))
|
|
35
|
+
return false;
|
|
36
|
+
// Cheap pre-filter: skip files that show no sign of React.
|
|
37
|
+
const lower = content;
|
|
38
|
+
for (const needle of FAST_FILTER_NEEDLES) {
|
|
39
|
+
if (lower.includes(needle))
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
},
|
|
44
|
+
extract(input) {
|
|
45
|
+
const nodes = [];
|
|
46
|
+
const edges = [];
|
|
47
|
+
const sf = parse(input);
|
|
48
|
+
if (!sf)
|
|
49
|
+
return { nodes, edges };
|
|
50
|
+
const components = [];
|
|
51
|
+
const hookNames = new Set();
|
|
52
|
+
// First pass: identify components at the top level.
|
|
53
|
+
for (const stmt of sf.statements) {
|
|
54
|
+
visitTopLevel(stmt, input, components);
|
|
55
|
+
}
|
|
56
|
+
// Second pass: walk every node, collect hook usages, attribute them
|
|
57
|
+
// to enclosing components when possible.
|
|
58
|
+
const enclosingComponent = (n) => {
|
|
59
|
+
for (const c of components) {
|
|
60
|
+
if (isAncestor(c.node, n))
|
|
61
|
+
return c.id;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
const visit = (n) => {
|
|
66
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && HOOK_NAME_RE.test(n.expression.text)) {
|
|
67
|
+
const hookName = n.expression.text;
|
|
68
|
+
hookNames.add(hookName);
|
|
69
|
+
const compId = enclosingComponent(n);
|
|
70
|
+
if (compId) {
|
|
71
|
+
const hookId = `framework:react:hook-usage:${input.filePath}#${hookName}`;
|
|
72
|
+
edges.push(edge(compId, hookId, EdgeKind.UsesHook, { hook: hookName }));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
ts.forEachChild(n, visit);
|
|
76
|
+
};
|
|
77
|
+
ts.forEachChild(sf, visit);
|
|
78
|
+
for (const c of components) {
|
|
79
|
+
const node = {
|
|
80
|
+
id: c.id,
|
|
81
|
+
kind: NodeKind.FrameworkEntity,
|
|
82
|
+
label: c.name,
|
|
83
|
+
path: input.filePath,
|
|
84
|
+
tags: ['react', 'component'],
|
|
85
|
+
data: { framework: 'react', subtype: 'component', name: c.name },
|
|
86
|
+
};
|
|
87
|
+
nodes.push(node);
|
|
88
|
+
edges.push(edge(input.fileNodeId, c.id, EdgeKind.FrameworkDeclares, { subtype: 'component' }));
|
|
89
|
+
}
|
|
90
|
+
for (const hook of hookNames) {
|
|
91
|
+
const node = {
|
|
92
|
+
id: `framework:react:hook-usage:${input.filePath}#${hook}`,
|
|
93
|
+
kind: NodeKind.FrameworkEntity,
|
|
94
|
+
label: hook,
|
|
95
|
+
path: input.filePath,
|
|
96
|
+
tags: ['react', 'hook-usage'],
|
|
97
|
+
data: { framework: 'react', subtype: 'hook-usage', hook },
|
|
98
|
+
};
|
|
99
|
+
nodes.push(node);
|
|
100
|
+
edges.push(edge(input.fileNodeId, node.id, EdgeKind.FrameworkDeclares, { subtype: 'hook-usage' }));
|
|
101
|
+
}
|
|
102
|
+
return { nodes, edges };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
function visitTopLevel(stmt, input, out) {
|
|
106
|
+
// export const Foo = () => <div />;
|
|
107
|
+
if (ts.isVariableStatement(stmt)) {
|
|
108
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
109
|
+
if (!ts.isIdentifier(decl.name))
|
|
110
|
+
continue;
|
|
111
|
+
const name = decl.name.text;
|
|
112
|
+
if (!/^[A-Z]/.test(name))
|
|
113
|
+
continue;
|
|
114
|
+
const init = decl.initializer;
|
|
115
|
+
if (!init)
|
|
116
|
+
continue;
|
|
117
|
+
if (containsJsx(init)) {
|
|
118
|
+
out.push({ id: makeComponentId(input.filePath, name), name, node: init });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// function Foo() { return <div />; }
|
|
124
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name && /^[A-Z]/.test(stmt.name.text)) {
|
|
125
|
+
if (containsJsx(stmt)) {
|
|
126
|
+
out.push({ id: makeComponentId(input.filePath, stmt.name.text), name: stmt.name.text, node: stmt });
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// export default function Foo() {}
|
|
131
|
+
if (ts.isExportAssignment(stmt) && ts.isFunctionExpression(stmt.expression)) {
|
|
132
|
+
const name = stmt.expression.name?.text ?? 'Default';
|
|
133
|
+
if (/^[A-Z]/.test(name) && containsJsx(stmt.expression)) {
|
|
134
|
+
out.push({ id: makeComponentId(input.filePath, name), name, node: stmt.expression });
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// class Foo extends React.Component { render() { return <div />; } }
|
|
139
|
+
if (ts.isClassDeclaration(stmt) && stmt.name && /^[A-Z]/.test(stmt.name.text)) {
|
|
140
|
+
if (extendsComponent(stmt) || containsJsx(stmt)) {
|
|
141
|
+
out.push({ id: makeComponentId(input.filePath, stmt.name.text), name: stmt.name.text, node: stmt });
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function containsJsx(node) {
|
|
147
|
+
let found = false;
|
|
148
|
+
const visit = (n) => {
|
|
149
|
+
if (found)
|
|
150
|
+
return;
|
|
151
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
|
|
152
|
+
found = true;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
ts.forEachChild(n, visit);
|
|
156
|
+
};
|
|
157
|
+
visit(node);
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
function extendsComponent(cls) {
|
|
161
|
+
if (!cls.heritageClauses)
|
|
162
|
+
return false;
|
|
163
|
+
for (const h of cls.heritageClauses) {
|
|
164
|
+
if (h.token !== ts.SyntaxKind.ExtendsKeyword)
|
|
165
|
+
continue;
|
|
166
|
+
for (const t of h.types) {
|
|
167
|
+
const text = t.expression.getText();
|
|
168
|
+
if (text === 'Component' || text === 'React.Component' || text === 'PureComponent' || text === 'React.PureComponent') {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
function isAncestor(ancestor, descendant) {
|
|
176
|
+
let cur = descendant;
|
|
177
|
+
while (cur) {
|
|
178
|
+
if (cur === ancestor)
|
|
179
|
+
return true;
|
|
180
|
+
cur = cur.parent;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
function makeComponentId(path, name) {
|
|
185
|
+
return `framework:react:component:${path}#${name}`;
|
|
186
|
+
}
|
|
187
|
+
function edge(from, to, kind, data) {
|
|
188
|
+
return {
|
|
189
|
+
id: createHash('sha1').update(`${from}|${to}|${kind}`).digest('hex'),
|
|
190
|
+
from,
|
|
191
|
+
to,
|
|
192
|
+
kind,
|
|
193
|
+
source: REACT_EXTRACTOR_SOURCE,
|
|
194
|
+
...(data ? { data } : {}),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function parse(input) {
|
|
198
|
+
const ext = nodePath.extname(input.filePath).toLowerCase();
|
|
199
|
+
const kind = ext === '.tsx' ? ts.ScriptKind.TSX
|
|
200
|
+
: ext === '.jsx' ? ts.ScriptKind.JSX
|
|
201
|
+
: ext === '.js' || ext === '.mjs' || ext === '.cjs' ? ts.ScriptKind.JS
|
|
202
|
+
: ts.ScriptKind.TS;
|
|
203
|
+
try {
|
|
204
|
+
return ts.createSourceFile(input.filePath, input.content, ts.ScriptTarget.Latest, true, kind);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IFrameworkExtractor } from '../extractor-api/framework-extractor.js';
|
|
2
|
+
export declare const SOLID_EXTRACTOR_SOURCE = "solid-extractor@v1";
|
|
3
|
+
/**
|
|
4
|
+
* Solid extractor.
|
|
5
|
+
*
|
|
6
|
+
* Detection (heuristic — no type info):
|
|
7
|
+
* - Top-level `function Component()` / `const Component = (...)` with a
|
|
8
|
+
* name starting with an uppercase letter and a body that produces
|
|
9
|
+
* JSX → component entity.
|
|
10
|
+
* - Uses of `createSignal`, `createEffect`, `createMemo`,
|
|
11
|
+
* `createStore`, etc. → primitive-usage entities, linked to the
|
|
12
|
+
* enclosing component via UsesHook edges.
|
|
13
|
+
*
|
|
14
|
+
* Same shape as the React extractor; lives separately because the
|
|
15
|
+
* detection heuristics (Solid uses primitives instead of hooks) and
|
|
16
|
+
* fast-filter needles differ.
|
|
17
|
+
*/
|
|
18
|
+
export declare const solidExtractor: IFrameworkExtractor;
|
|
19
|
+
//# sourceMappingURL=solid-extractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"solid-extractor.d.ts","sourceRoot":"","sources":["../../src/extractors/solid-extractor.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACpB,MAAM,yCAAyC,CAAC;AAEjD,eAAO,MAAM,sBAAsB,uBAAuB,CAAC;AAY3D;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,cAAc,EAAE,mBAiE5B,CAAC"}
|