@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.
Files changed (82) hide show
  1. package/dist/extractor-api/extractor-registry.d.ts +19 -0
  2. package/dist/extractor-api/extractor-registry.d.ts.map +1 -0
  3. package/dist/extractor-api/extractor-registry.js +36 -0
  4. package/dist/extractor-api/framework-extractor.d.ts +48 -0
  5. package/dist/extractor-api/framework-extractor.d.ts.map +1 -0
  6. package/dist/extractor-api/framework-extractor.js +1 -0
  7. package/dist/extractors/angular-extractor.d.ts +18 -0
  8. package/dist/extractors/angular-extractor.d.ts.map +1 -0
  9. package/dist/extractors/angular-extractor.js +175 -0
  10. package/dist/extractors/astro-extractor.d.ts +15 -0
  11. package/dist/extractors/astro-extractor.d.ts.map +1 -0
  12. package/dist/extractors/astro-extractor.js +128 -0
  13. package/dist/extractors/django-extractor.d.ts +24 -0
  14. package/dist/extractors/django-extractor.d.ts.map +1 -0
  15. package/dist/extractors/django-extractor.js +124 -0
  16. package/dist/extractors/express-extractor.d.ts +18 -0
  17. package/dist/extractors/express-extractor.d.ts.map +1 -0
  18. package/dist/extractors/express-extractor.js +193 -0
  19. package/dist/extractors/fastapi-extractor.d.ts +19 -0
  20. package/dist/extractors/fastapi-extractor.d.ts.map +1 -0
  21. package/dist/extractors/fastapi-extractor.js +135 -0
  22. package/dist/extractors/fastify-extractor.d.ts +13 -0
  23. package/dist/extractors/fastify-extractor.d.ts.map +1 -0
  24. package/dist/extractors/fastify-extractor.js +166 -0
  25. package/dist/extractors/flask-extractor.d.ts +16 -0
  26. package/dist/extractors/flask-extractor.d.ts.map +1 -0
  27. package/dist/extractors/flask-extractor.js +142 -0
  28. package/dist/extractors/flutter-extractor.d.ts +26 -0
  29. package/dist/extractors/flutter-extractor.d.ts.map +1 -0
  30. package/dist/extractors/flutter-extractor.js +137 -0
  31. package/dist/extractors/graphql-extractor.d.ts +27 -0
  32. package/dist/extractors/graphql-extractor.d.ts.map +1 -0
  33. package/dist/extractors/graphql-extractor.js +141 -0
  34. package/dist/extractors/laravel-extractor.d.ts +22 -0
  35. package/dist/extractors/laravel-extractor.d.ts.map +1 -0
  36. package/dist/extractors/laravel-extractor.js +208 -0
  37. package/dist/extractors/nestjs-extractor.d.ts +18 -0
  38. package/dist/extractors/nestjs-extractor.d.ts.map +1 -0
  39. package/dist/extractors/nestjs-extractor.js +222 -0
  40. package/dist/extractors/nextjs-extractor.d.ts +19 -0
  41. package/dist/extractors/nextjs-extractor.d.ts.map +1 -0
  42. package/dist/extractors/nextjs-extractor.js +175 -0
  43. package/dist/extractors/phoenix-extractor.d.ts +28 -0
  44. package/dist/extractors/phoenix-extractor.d.ts.map +1 -0
  45. package/dist/extractors/phoenix-extractor.js +212 -0
  46. package/dist/extractors/rails-extractor.d.ts +25 -0
  47. package/dist/extractors/rails-extractor.d.ts.map +1 -0
  48. package/dist/extractors/rails-extractor.js +180 -0
  49. package/dist/extractors/react-extractor.d.ts +19 -0
  50. package/dist/extractors/react-extractor.d.ts.map +1 -0
  51. package/dist/extractors/react-extractor.js +209 -0
  52. package/dist/extractors/solid-extractor.d.ts +19 -0
  53. package/dist/extractors/solid-extractor.d.ts.map +1 -0
  54. package/dist/extractors/solid-extractor.js +164 -0
  55. package/dist/extractors/spring-extractor.d.ts +27 -0
  56. package/dist/extractors/spring-extractor.d.ts.map +1 -0
  57. package/dist/extractors/spring-extractor.js +279 -0
  58. package/dist/extractors/svelte-extractor.d.ts +17 -0
  59. package/dist/extractors/svelte-extractor.d.ts.map +1 -0
  60. package/dist/extractors/svelte-extractor.js +104 -0
  61. package/dist/extractors/vue-extractor.d.ts +18 -0
  62. package/dist/extractors/vue-extractor.d.ts.map +1 -0
  63. package/dist/extractors/vue-extractor.js +125 -0
  64. package/dist/index.d.ts +27 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +26 -0
  67. package/dist/query/framework-query-api.d.ts +39 -0
  68. package/dist/query/framework-query-api.d.ts.map +1 -0
  69. package/dist/query/framework-query-api.js +99 -0
  70. package/dist/runner/load-pack-extractors.d.ts +36 -0
  71. package/dist/runner/load-pack-extractors.d.ts.map +1 -0
  72. package/dist/runner/load-pack-extractors.js +87 -0
  73. package/dist/runner/run-extractors.d.ts +29 -0
  74. package/dist/runner/run-extractors.d.ts.map +1 -0
  75. package/dist/runner/run-extractors.js +144 -0
  76. package/dist/schema/framework-schema.d.ts +36 -0
  77. package/dist/schema/framework-schema.d.ts.map +1 -0
  78. package/dist/schema/framework-schema.js +1 -0
  79. package/dist/store/framework-store.d.ts +17 -0
  80. package/dist/store/framework-store.d.ts.map +1 -0
  81. package/dist/store/framework-store.js +138 -0
  82. 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"}