@foxlight/analyzer 0.1.0

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/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @foxlight/analyzer
2
+
3
+ Static analysis engine for [Foxlight](https://github.com/josegabrielcruz/foxlight) — the open-source front-end intelligence platform.
4
+
5
+ ## What's Inside
6
+
7
+ - **AST Scanner** — TypeScript compiler API-based extraction of imports, exports, JSX usage, and function declarations
8
+ - **Component Detector** — heuristic detection of React/Vue/Svelte/Angular components with cross-referencing
9
+ - **Prop Extractor** — TypeScript type-checker based prop extraction with defaults and JSDoc descriptions
10
+ - **Vue SFC Parser** — parses `.vue` Single File Components (`<script setup>`, `defineProps`, template scanning)
11
+ - **Svelte Parser** — parses `.svelte` files (`export let` props, module scripts, template child detection)
12
+ - **Project Analyzer** — orchestrates full-project analysis with glob matching, multi-framework support, and dependency graph construction
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @foxlight/analyzer
18
+ ```
19
+
20
+ > **Note:** `typescript` is a peer dependency. Make sure it's installed in your project.
21
+
22
+ ## Usage
23
+
24
+ ```typescript
25
+ import { analyzeProject, analyzeFile, detectComponents } from '@foxlight/analyzer';
26
+
27
+ // Analyze an entire project
28
+ const result = await analyzeProject('/path/to/project');
29
+ console.log(result.stats.componentsFound);
30
+ console.log(result.registry.getAllComponents());
31
+
32
+ // Analyze a single file
33
+ const fileAnalysis = await analyzeFile('/path/to/Button.tsx');
34
+ const components = detectComponents(fileAnalysis, 'react');
35
+
36
+ // Parse Vue SFCs
37
+ import { parseVueSFC } from '@foxlight/analyzer';
38
+ const vue = parseVueSFC(sourceCode, 'MyComponent.vue');
39
+
40
+ // Parse Svelte files
41
+ import { parseSvelteFile } from '@foxlight/analyzer';
42
+ const svelte = parseSvelteFile(sourceCode, 'Counter.svelte');
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,160 @@
1
+ import { ImportEdge, ComponentInfo, Framework, PropInfo, FoxlightConfig, ComponentRegistry, DependencyGraph } from '@foxlight/core';
2
+ import ts from 'typescript';
3
+
4
+ /** Raw information extracted from a single source file. */
5
+ interface FileAnalysis {
6
+ filePath: string;
7
+ imports: ImportEdge[];
8
+ exports: ExportInfo[];
9
+ jsxElements: JsxElementInfo[];
10
+ functionDeclarations: FunctionInfo[];
11
+ /** Whether this file contains JSX */
12
+ hasJsx: boolean;
13
+ }
14
+ interface ExportInfo {
15
+ name: string;
16
+ kind: 'function' | 'class' | 'variable' | 'type' | 'interface' | 're-export';
17
+ isDefault: boolean;
18
+ line: number;
19
+ }
20
+ interface JsxElementInfo {
21
+ /** Tag name (e.g. "Button", "div") */
22
+ tagName: string;
23
+ /** Whether this is a component (PascalCase) or native element */
24
+ isComponent: boolean;
25
+ line: number;
26
+ /** Props passed to this element */
27
+ props: string[];
28
+ }
29
+ interface FunctionInfo {
30
+ name: string;
31
+ line: number;
32
+ /** Whether this function returns JSX */
33
+ returnsJsx: boolean;
34
+ /** Parameter names and types */
35
+ parameters: Array<{
36
+ name: string;
37
+ type: string;
38
+ }>;
39
+ isExported: boolean;
40
+ isDefault: boolean;
41
+ isArrowFunction: boolean;
42
+ }
43
+ /**
44
+ * Parse a TypeScript/JavaScript file and extract structural information.
45
+ */
46
+ declare function analyzeFile(filePath: string): Promise<FileAnalysis>;
47
+ /**
48
+ * Parse source code and extract structural information.
49
+ * This is the testable core — accepts raw source text.
50
+ */
51
+ declare function analyzeSource(source: string, filePath: string): FileAnalysis;
52
+
53
+ /**
54
+ * Detect components from a file analysis result.
55
+ * Uses heuristics appropriate for the given framework.
56
+ */
57
+ declare function detectComponents(analysis: FileAnalysis, framework: Framework): ComponentInfo[];
58
+ /**
59
+ * After all files are analyzed, cross-reference to populate `usedBy` fields.
60
+ * This connects the "children" references (forward) to "usedBy" references (backward).
61
+ */
62
+ declare function crossReferenceComponents(components: ComponentInfo[]): ComponentInfo[];
63
+
64
+ /**
65
+ * Create a TypeScript program for type-checking source files.
66
+ * Returns a checker that can resolve types across the project.
67
+ */
68
+ declare function createTypeChecker(filePaths: string[], compilerOptions?: ts.CompilerOptions): ts.TypeChecker | null;
69
+ /**
70
+ * Extract detailed prop information from a function's first parameter
71
+ * using the TypeScript type checker.
72
+ */
73
+ declare function extractPropsFromType(checker: ts.TypeChecker, node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression): PropInfo[];
74
+ /**
75
+ * Extract prop information from a resolved TypeScript type.
76
+ * Handles interfaces, type aliases, intersections, and mapped types.
77
+ */
78
+ declare function extractPropsFromTsType(checker: ts.TypeChecker, type: ts.Type): PropInfo[];
79
+ /**
80
+ * Extract props from a source file by finding component functions
81
+ * and resolving their parameter types.
82
+ */
83
+ declare function extractAllPropsFromFile(checker: ts.TypeChecker, sourceFile: ts.SourceFile): Map<string, PropInfo[]>;
84
+
85
+ /** Result of a full project analysis. */
86
+ interface ProjectAnalysis {
87
+ config: FoxlightConfig;
88
+ registry: ComponentRegistry;
89
+ graph: DependencyGraph;
90
+ stats: AnalysisStats;
91
+ }
92
+ interface AnalysisStats {
93
+ filesScanned: number;
94
+ componentsFound: number;
95
+ importsTracked: number;
96
+ duration: number;
97
+ }
98
+ /**
99
+ * Analyze an entire project directory.
100
+ * This is the main entry point for the analyzer.
101
+ */
102
+ declare function analyzeProject(rootDir: string, configOverrides?: Partial<FoxlightConfig>): Promise<ProjectAnalysis>;
103
+
104
+ /** Extracted information from a .vue file. */
105
+ interface VueSFCAnalysis {
106
+ /** Component name (from filename or defineComponent name) */
107
+ name: string;
108
+ /** Script content (if present) */
109
+ scriptContent: string | null;
110
+ /** Whether the script uses <script setup> */
111
+ isScriptSetup: boolean;
112
+ /** Template content (if present) */
113
+ templateContent: string | null;
114
+ /** Whether the file has scoped styles */
115
+ hasScopedStyles: boolean;
116
+ /** Imports extracted from script block */
117
+ imports: ImportEdge[];
118
+ /** Props extracted from defineProps / props option */
119
+ props: PropInfo[];
120
+ /** Child components referenced in the template */
121
+ childComponents: string[];
122
+ }
123
+ /**
124
+ * Parse a Vue SFC file and extract component information.
125
+ */
126
+ declare function parseVueSFC(source: string, filePath: string): VueSFCAnalysis;
127
+ /**
128
+ * Convert a Vue SFC analysis result to a ComponentInfo.
129
+ */
130
+ declare function vueSFCToComponentInfo(analysis: VueSFCAnalysis, filePath: string): ComponentInfo;
131
+
132
+ /** Extracted information from a .svelte file. */
133
+ interface SvelteFileAnalysis {
134
+ /** Component name (from filename) */
135
+ name: string;
136
+ /** Script content (instance script, if present) */
137
+ scriptContent: string | null;
138
+ /** Module-level script content (context="module", if present) */
139
+ moduleScriptContent: string | null;
140
+ /** Template markup (everything outside script/style blocks) */
141
+ templateContent: string;
142
+ /** Whether the file has scoped styles */
143
+ hasStyles: boolean;
144
+ /** Imports extracted from script blocks */
145
+ imports: ImportEdge[];
146
+ /** Props extracted from `export let` declarations */
147
+ props: PropInfo[];
148
+ /** Child components referenced in the template */
149
+ childComponents: string[];
150
+ }
151
+ /**
152
+ * Parse a Svelte file and extract component information.
153
+ */
154
+ declare function parseSvelteFile(source: string, filePath: string): SvelteFileAnalysis;
155
+ /**
156
+ * Convert a Svelte file analysis result to a ComponentInfo.
157
+ */
158
+ declare function svelteFileToComponentInfo(analysis: SvelteFileAnalysis, filePath: string): ComponentInfo;
159
+
160
+ export { type AnalysisStats, type ExportInfo, type FileAnalysis, type FunctionInfo, type JsxElementInfo, type ProjectAnalysis, type SvelteFileAnalysis, type VueSFCAnalysis, analyzeFile, analyzeProject, analyzeSource, createTypeChecker, crossReferenceComponents, detectComponents, extractAllPropsFromFile, extractPropsFromTsType, extractPropsFromType, parseSvelteFile, parseVueSFC, svelteFileToComponentInfo, vueSFCToComponentInfo };
package/dist/index.js ADDED
@@ -0,0 +1,822 @@
1
+ // src/ast-scanner.ts
2
+ import ts from "typescript";
3
+ import { readFile } from "fs/promises";
4
+ async function analyzeFile(filePath) {
5
+ const source = await readFile(filePath, "utf-8");
6
+ return analyzeSource(source, filePath);
7
+ }
8
+ function analyzeSource(source, filePath) {
9
+ const isJsx = filePath.endsWith(".tsx") || filePath.endsWith(".jsx");
10
+ const scriptKind = isJsx ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
11
+ const sourceFile = ts.createSourceFile(
12
+ filePath,
13
+ source,
14
+ ts.ScriptTarget.Latest,
15
+ true,
16
+ // setParentNodes
17
+ scriptKind
18
+ );
19
+ const imports = [];
20
+ const exports = [];
21
+ const jsxElements = [];
22
+ const functionDeclarations = [];
23
+ let hasJsx = false;
24
+ function visit(node) {
25
+ if (ts.isImportDeclaration(node)) {
26
+ const edge = extractImport(node, filePath, sourceFile);
27
+ if (edge) imports.push(edge);
28
+ }
29
+ if (ts.isExportDeclaration(node)) {
30
+ const info = extractExportDeclaration(node, sourceFile);
31
+ if (info) exports.push(...info);
32
+ }
33
+ if (ts.isFunctionDeclaration(node) && node.name) {
34
+ functionDeclarations.push(extractFunction(node, sourceFile));
35
+ }
36
+ if (ts.isVariableStatement(node)) {
37
+ for (const decl of node.declarationList.declarations) {
38
+ if (ts.isIdentifier(decl.name) && decl.initializer && (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer))) {
39
+ const isExported = hasExportModifier(node);
40
+ functionDeclarations.push(
41
+ extractArrowFunction(decl.name, decl.initializer, isExported, sourceFile)
42
+ );
43
+ }
44
+ }
45
+ if (hasExportModifier(node)) {
46
+ for (const decl of node.declarationList.declarations) {
47
+ if (ts.isIdentifier(decl.name)) {
48
+ exports.push({
49
+ name: decl.name.text,
50
+ kind: "variable",
51
+ isDefault: false,
52
+ line: getLine(decl, sourceFile)
53
+ });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ if (ts.isFunctionDeclaration(node) && node.name && hasExportModifier(node)) {
59
+ const isDefault = hasDefaultModifier(node);
60
+ exports.push({
61
+ name: node.name.text,
62
+ kind: "function",
63
+ isDefault,
64
+ line: getLine(node, sourceFile)
65
+ });
66
+ }
67
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
68
+ hasJsx = true;
69
+ jsxElements.push(extractJsxElement(node, sourceFile));
70
+ }
71
+ if (ts.isExportAssignment(node) && !node.isExportEquals && ts.isIdentifier(node.expression)) {
72
+ exports.push({
73
+ name: node.expression.text,
74
+ kind: "variable",
75
+ isDefault: true,
76
+ line: getLine(node, sourceFile)
77
+ });
78
+ }
79
+ ts.forEachChild(node, visit);
80
+ }
81
+ visit(sourceFile);
82
+ return {
83
+ filePath,
84
+ imports,
85
+ exports,
86
+ jsxElements,
87
+ functionDeclarations,
88
+ hasJsx
89
+ };
90
+ }
91
+ function extractImport(node, filePath, _sourceFile) {
92
+ if (!ts.isStringLiteral(node.moduleSpecifier)) return null;
93
+ const target = node.moduleSpecifier.text;
94
+ const specifiers = [];
95
+ const typeOnly = node.importClause?.isTypeOnly ?? false;
96
+ const clause = node.importClause;
97
+ if (clause) {
98
+ if (clause.name) {
99
+ specifiers.push({
100
+ imported: "default",
101
+ local: clause.name.text
102
+ });
103
+ }
104
+ if (clause.namedBindings) {
105
+ if (ts.isNamedImports(clause.namedBindings)) {
106
+ for (const el of clause.namedBindings.elements) {
107
+ specifiers.push({
108
+ imported: el.propertyName?.text ?? el.name.text,
109
+ local: el.name.text
110
+ });
111
+ }
112
+ }
113
+ if (ts.isNamespaceImport(clause.namedBindings)) {
114
+ specifiers.push({
115
+ imported: "*",
116
+ local: clause.namedBindings.name.text
117
+ });
118
+ }
119
+ }
120
+ }
121
+ return { source: filePath, target, specifiers, typeOnly };
122
+ }
123
+ function extractExportDeclaration(node, sourceFile) {
124
+ const results = [];
125
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
126
+ for (const el of node.exportClause.elements) {
127
+ results.push({
128
+ name: el.name.text,
129
+ kind: "re-export",
130
+ isDefault: false,
131
+ line: getLine(el, sourceFile)
132
+ });
133
+ }
134
+ }
135
+ return results.length > 0 ? results : null;
136
+ }
137
+ function extractFunction(node, sourceFile) {
138
+ return {
139
+ name: node.name?.text ?? "<anonymous>",
140
+ line: getLine(node, sourceFile),
141
+ returnsJsx: containsJsx(node),
142
+ parameters: extractParameters(node),
143
+ isExported: hasExportModifier(node),
144
+ isDefault: hasDefaultModifier(node),
145
+ isArrowFunction: false
146
+ };
147
+ }
148
+ function extractArrowFunction(name, initializer, isExported, sourceFile) {
149
+ return {
150
+ name: name.text,
151
+ line: getLine(name, sourceFile),
152
+ returnsJsx: containsJsx(initializer),
153
+ parameters: extractParameters(initializer),
154
+ isExported,
155
+ isDefault: false,
156
+ isArrowFunction: ts.isArrowFunction(initializer)
157
+ };
158
+ }
159
+ function extractJsxElement(node, sourceFile) {
160
+ const tagName = node.tagName.getText(sourceFile);
161
+ const isComponent = /^[A-Z]/.test(tagName);
162
+ const props = [];
163
+ for (const attr of node.attributes.properties) {
164
+ if (ts.isJsxAttribute(attr) && attr.name) {
165
+ props.push(attr.name.getText(sourceFile));
166
+ }
167
+ }
168
+ return {
169
+ tagName,
170
+ isComponent,
171
+ line: getLine(node, sourceFile),
172
+ props
173
+ };
174
+ }
175
+ function extractParameters(node) {
176
+ return node.parameters.map((param) => ({
177
+ name: param.name.getText(),
178
+ type: param.type?.getText() ?? "unknown"
179
+ }));
180
+ }
181
+ function containsJsx(node) {
182
+ let found = false;
183
+ function walk(n) {
184
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
185
+ found = true;
186
+ return;
187
+ }
188
+ ts.forEachChild(n, walk);
189
+ }
190
+ walk(node);
191
+ return found;
192
+ }
193
+ function hasExportModifier(node) {
194
+ if (!ts.canHaveModifiers(node)) return false;
195
+ const modifiers = ts.getModifiers(node);
196
+ return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
197
+ }
198
+ function hasDefaultModifier(node) {
199
+ if (!ts.canHaveModifiers(node)) return false;
200
+ const modifiers = ts.getModifiers(node);
201
+ return modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
202
+ }
203
+ function getLine(node, sourceFile) {
204
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
205
+ }
206
+
207
+ // src/component-detector.ts
208
+ function detectComponents(analysis, framework) {
209
+ const components = [];
210
+ for (const fn of analysis.functionDeclarations) {
211
+ if (isLikelyComponent(fn, analysis, framework)) {
212
+ components.push(toComponentInfo(fn, analysis, framework));
213
+ }
214
+ }
215
+ return components;
216
+ }
217
+ function isLikelyComponent(fn, _analysis, framework) {
218
+ if (!/^[A-Z]/.test(fn.name)) return false;
219
+ switch (framework) {
220
+ case "react":
221
+ return fn.returnsJsx;
222
+ case "vue":
223
+ return fn.returnsJsx;
224
+ case "svelte":
225
+ return fn.returnsJsx;
226
+ default:
227
+ return fn.returnsJsx && fn.isExported;
228
+ }
229
+ }
230
+ function toComponentInfo(fn, analysis, framework) {
231
+ const id = `${analysis.filePath}#${fn.name}`;
232
+ let exportKind = "named";
233
+ const exp = analysis.exports.find((e) => e.name === fn.name);
234
+ if (exp?.isDefault) {
235
+ exportKind = "default";
236
+ } else if (exp?.kind === "re-export") {
237
+ exportKind = "re-export";
238
+ }
239
+ const props = extractProps(fn);
240
+ const children = analysis.jsxElements.filter((el) => el.isComponent).map((el) => el.tagName);
241
+ const dependencies = analysis.imports.filter((imp) => !imp.target.startsWith(".") && !imp.target.startsWith("/")).map((imp) => imp.target);
242
+ return {
243
+ id,
244
+ name: fn.name,
245
+ filePath: analysis.filePath,
246
+ line: fn.line,
247
+ framework,
248
+ exportKind,
249
+ props,
250
+ children: [...new Set(children)],
251
+ usedBy: [],
252
+ // Populated later by cross-file analysis
253
+ dependencies: [...new Set(dependencies)],
254
+ metadata: {}
255
+ };
256
+ }
257
+ function extractProps(fn) {
258
+ if (fn.parameters.length === 0) return [];
259
+ const firstParam = fn.parameters[0];
260
+ if (!firstParam) return [];
261
+ return [
262
+ {
263
+ name: firstParam.name,
264
+ type: firstParam.type,
265
+ required: true,
266
+ description: "Auto-detected parameter \u2014 full prop extraction requires type analysis"
267
+ }
268
+ ];
269
+ }
270
+ function crossReferenceComponents(components) {
271
+ const byName = /* @__PURE__ */ new Map();
272
+ for (const comp of components) {
273
+ byName.set(comp.name, comp);
274
+ }
275
+ for (const comp of components) {
276
+ for (const childName of comp.children) {
277
+ const child = byName.get(childName);
278
+ if (child && !child.usedBy.includes(comp.id)) {
279
+ child.usedBy.push(comp.id);
280
+ }
281
+ }
282
+ }
283
+ for (const comp of components) {
284
+ comp.children = comp.children.map((name) => byName.get(name)?.id ?? name).filter(Boolean);
285
+ }
286
+ return components;
287
+ }
288
+
289
+ // src/prop-extractor.ts
290
+ import ts2 from "typescript";
291
+ function createTypeChecker(filePaths, compilerOptions) {
292
+ const options = {
293
+ target: ts2.ScriptTarget.ES2022,
294
+ module: ts2.ModuleKind.Node16,
295
+ moduleResolution: ts2.ModuleResolutionKind.Node16,
296
+ jsx: ts2.JsxEmit.ReactJSX,
297
+ strict: true,
298
+ noEmit: true,
299
+ skipLibCheck: true,
300
+ ...compilerOptions
301
+ };
302
+ const program = ts2.createProgram(filePaths, options);
303
+ return program.getTypeChecker();
304
+ }
305
+ function extractPropsFromType(checker, node) {
306
+ const firstParam = node.parameters[0];
307
+ if (!firstParam) return [];
308
+ const paramType = checker.getTypeAtLocation(firstParam);
309
+ return extractPropsFromTsType(checker, paramType);
310
+ }
311
+ function extractPropsFromTsType(checker, type) {
312
+ const props = [];
313
+ const properties = type.getProperties();
314
+ for (const prop of properties) {
315
+ if (isInternalProp(prop.name)) continue;
316
+ const propType = checker.getTypeOfSymbol(prop);
317
+ const typeString = checker.typeToString(propType, void 0, ts2.TypeFormatFlags.NoTruncation);
318
+ const isOptional = (prop.flags & ts2.SymbolFlags.Optional) !== 0;
319
+ const defaultValue = getDefaultValue(prop);
320
+ const description = getJSDocDescription(prop);
321
+ props.push({
322
+ name: prop.name,
323
+ type: typeString,
324
+ required: !isOptional && defaultValue === void 0,
325
+ defaultValue,
326
+ description
327
+ });
328
+ }
329
+ return props;
330
+ }
331
+ function extractAllPropsFromFile(checker, sourceFile) {
332
+ const result = /* @__PURE__ */ new Map();
333
+ function visit(node) {
334
+ if (ts2.isFunctionDeclaration(node) && node.name) {
335
+ const name = node.name.text;
336
+ if (/^[A-Z]/.test(name) && node.parameters.length > 0) {
337
+ const props = extractPropsFromType(checker, node);
338
+ if (props.length > 0) {
339
+ result.set(name, props);
340
+ }
341
+ }
342
+ }
343
+ if (ts2.isVariableStatement(node)) {
344
+ for (const decl of node.declarationList.declarations) {
345
+ if (ts2.isIdentifier(decl.name) && /^[A-Z]/.test(decl.name.text) && decl.initializer && (ts2.isArrowFunction(decl.initializer) || ts2.isFunctionExpression(decl.initializer))) {
346
+ const fn = decl.initializer;
347
+ if (fn.parameters.length > 0) {
348
+ const props = extractPropsFromType(checker, fn);
349
+ if (props.length > 0) {
350
+ result.set(decl.name.text, props);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ ts2.forEachChild(node, visit);
357
+ }
358
+ visit(sourceFile);
359
+ return result;
360
+ }
361
+ function isInternalProp(name) {
362
+ const internals = /* @__PURE__ */ new Set([
363
+ "key",
364
+ "ref",
365
+ "children"
366
+ // Often included but worth keeping — make configurable later
367
+ ]);
368
+ return internals.has(name);
369
+ }
370
+ function getDefaultValue(symbol) {
371
+ const declarations = symbol.getDeclarations();
372
+ if (!declarations || declarations.length === 0) return void 0;
373
+ for (const decl of declarations) {
374
+ if (ts2.isBindingElement(decl) && decl.initializer) {
375
+ return decl.initializer.getText();
376
+ }
377
+ }
378
+ return void 0;
379
+ }
380
+ function getJSDocDescription(symbol) {
381
+ const docs = symbol.getDocumentationComment(void 0);
382
+ if (docs.length === 0) return void 0;
383
+ return docs.map((d) => d.text).join("\n");
384
+ }
385
+
386
+ // src/project-analyzer.ts
387
+ import { readdir, readFile as readFile2 } from "fs/promises";
388
+ import { resolve, relative } from "path";
389
+ import ts3 from "typescript";
390
+ import {
391
+ ComponentRegistry,
392
+ DependencyGraph,
393
+ loadConfig
394
+ } from "@foxlight/core";
395
+
396
+ // src/frameworks/vue-parser.ts
397
+ import { extractImportsFromScript } from "@foxlight/core";
398
+ function parseVueSFC(source, filePath) {
399
+ const name = extractComponentName(filePath);
400
+ const scriptContent = extractBlock(source, "script");
401
+ const isScriptSetup = /<script[^>]*\bsetup\b/.test(source);
402
+ const templateContent = extractBlock(source, "template");
403
+ const hasScopedStyles = /<style[^>]*\bscoped\b/.test(source);
404
+ const imports = scriptContent ? extractImportsFromScript(scriptContent, filePath) : [];
405
+ const props = scriptContent ? extractPropsFromScript(scriptContent, isScriptSetup) : [];
406
+ const childComponents = templateContent ? extractChildComponentsFromTemplate(templateContent) : [];
407
+ return {
408
+ name,
409
+ scriptContent,
410
+ isScriptSetup,
411
+ templateContent,
412
+ hasScopedStyles,
413
+ imports,
414
+ props,
415
+ childComponents
416
+ };
417
+ }
418
+ function vueSFCToComponentInfo(analysis, filePath) {
419
+ return {
420
+ id: `${filePath}#${analysis.name}`,
421
+ name: analysis.name,
422
+ filePath,
423
+ line: 1,
424
+ framework: "vue",
425
+ exportKind: "default",
426
+ props: analysis.props,
427
+ children: analysis.childComponents,
428
+ usedBy: [],
429
+ dependencies: analysis.imports.filter((imp) => !imp.target.startsWith(".") && !imp.target.startsWith("/")).map((imp) => imp.target),
430
+ metadata: {
431
+ isScriptSetup: analysis.isScriptSetup,
432
+ hasScopedStyles: analysis.hasScopedStyles
433
+ }
434
+ };
435
+ }
436
+ function extractBlock(source, tag) {
437
+ const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
438
+ const match = source.match(regex);
439
+ return match?.[1]?.trim() ?? null;
440
+ }
441
+ function extractComponentName(filePath) {
442
+ const fileName = filePath.split("/").pop() ?? "Unknown";
443
+ const baseName = fileName.replace(/\.vue$/, "");
444
+ return baseName.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
445
+ }
446
+ function extractPropsFromScript(script, isScriptSetup) {
447
+ if (isScriptSetup) {
448
+ return extractDefineProps(script);
449
+ }
450
+ return extractOptionsAPIProps(script);
451
+ }
452
+ function extractDefineProps(script) {
453
+ const objectMatch = script.match(/defineProps\(\s*\{([^}]+)\}\s*\)/);
454
+ if (objectMatch?.[1]) {
455
+ return parseObjectProps(objectMatch[1]);
456
+ }
457
+ const genericMatch = script.match(/defineProps<\s*\{([^}]+)\}\s*>\(\)/);
458
+ if (genericMatch?.[1]) {
459
+ return parseTypeProps(genericMatch[1]);
460
+ }
461
+ return [];
462
+ }
463
+ function extractOptionsAPIProps(script) {
464
+ const propsMatch = script.match(/props\s*:\s*\{([^}]+)\}/);
465
+ if (!propsMatch?.[1]) return [];
466
+ return parseObjectProps(propsMatch[1]);
467
+ }
468
+ function parseObjectProps(content) {
469
+ const props = [];
470
+ const entries = content.split(",").map((s) => s.trim()).filter(Boolean);
471
+ for (const entry of entries) {
472
+ const colonIdx = entry.indexOf(":");
473
+ if (colonIdx === -1) {
474
+ props.push({
475
+ name: entry.trim(),
476
+ type: "unknown",
477
+ required: false
478
+ });
479
+ } else {
480
+ const name = entry.slice(0, colonIdx).trim();
481
+ const typeStr = entry.slice(colonIdx + 1).trim();
482
+ props.push({
483
+ name,
484
+ type: typeStr,
485
+ required: typeStr.includes("required") || !typeStr.includes("?")
486
+ });
487
+ }
488
+ }
489
+ return props;
490
+ }
491
+ function parseTypeProps(content) {
492
+ const props = [];
493
+ const entries = content.split(/[;\n]/).map((s) => s.trim()).filter(Boolean);
494
+ for (const entry of entries) {
495
+ const match = entry.match(/(\w+)(\?)?:\s*(.+)/);
496
+ if (match) {
497
+ props.push({
498
+ name: match[1],
499
+ type: match[3].trim().replace(/;$/, ""),
500
+ required: !match[2]
501
+ });
502
+ }
503
+ }
504
+ return props;
505
+ }
506
+ function extractChildComponentsFromTemplate(template) {
507
+ const components = /* @__PURE__ */ new Set();
508
+ const pascalRegex = /<([A-Z][a-zA-Z0-9]+)/g;
509
+ let match;
510
+ while ((match = pascalRegex.exec(template)) !== null) {
511
+ components.add(match[1]);
512
+ }
513
+ const kebabRegex = /<([a-z]+-[a-z-]+)/g;
514
+ while ((match = kebabRegex.exec(template)) !== null) {
515
+ const pascal = match[1].split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
516
+ components.add(pascal);
517
+ }
518
+ return Array.from(components);
519
+ }
520
+
521
+ // src/frameworks/svelte-parser.ts
522
+ import { extractImportsFromScript as extractImportsFromScript2 } from "@foxlight/core";
523
+ function parseSvelteFile(source, filePath) {
524
+ const name = extractComponentName2(filePath);
525
+ const scriptContent = extractInstanceScript(source);
526
+ const moduleScriptContent = extractModuleScript(source);
527
+ const templateContent = extractTemplateContent(source);
528
+ const hasStyles = /<style[\s>]/.test(source);
529
+ const allScriptContent = [moduleScriptContent, scriptContent].filter(Boolean).join("\n");
530
+ const imports = allScriptContent ? extractImportsFromScript2(allScriptContent, filePath) : [];
531
+ const props = scriptContent ? extractExportLetProps(scriptContent) : [];
532
+ const childComponents = extractChildComponentsFromTemplate2(templateContent);
533
+ return {
534
+ name,
535
+ scriptContent,
536
+ moduleScriptContent,
537
+ templateContent,
538
+ hasStyles,
539
+ imports,
540
+ props,
541
+ childComponents
542
+ };
543
+ }
544
+ function svelteFileToComponentInfo(analysis, filePath) {
545
+ return {
546
+ id: `${filePath}#${analysis.name}`,
547
+ name: analysis.name,
548
+ filePath,
549
+ line: 1,
550
+ framework: "svelte",
551
+ exportKind: "default",
552
+ props: analysis.props,
553
+ children: analysis.childComponents,
554
+ usedBy: [],
555
+ dependencies: analysis.imports.filter((imp) => !imp.target.startsWith(".") && !imp.target.startsWith("/")).map((imp) => imp.target),
556
+ metadata: {
557
+ hasModuleScript: analysis.moduleScriptContent !== null,
558
+ hasStyles: analysis.hasStyles
559
+ }
560
+ };
561
+ }
562
+ function extractComponentName2(filePath) {
563
+ const fileName = filePath.split("/").pop() ?? "Unknown";
564
+ const baseName = fileName.replace(/\.svelte$/, "");
565
+ return baseName.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
566
+ }
567
+ function extractInstanceScript(source) {
568
+ const regex = /<script(?![^>]*context\s*=\s*["']module["'])[^>]*>([\s\S]*?)<\/script>/i;
569
+ const match = source.match(regex);
570
+ return match?.[1]?.trim() ?? null;
571
+ }
572
+ function extractModuleScript(source) {
573
+ const regex = /<script[^>]*context\s*=\s*["']module["'][^>]*>([\s\S]*?)<\/script>/i;
574
+ const match = source.match(regex);
575
+ return match?.[1]?.trim() ?? null;
576
+ }
577
+ function extractTemplateContent(source) {
578
+ return source.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").trim();
579
+ }
580
+ function extractExportLetProps(script) {
581
+ const props = [];
582
+ const exportLetRegex = /export\s+let\s+(\w+)\s*(?::\s*([^=;\n]+?))?\s*(?:=\s*([^;\n]+?))?\s*[;\n]/g;
583
+ let match;
584
+ while ((match = exportLetRegex.exec(script)) !== null) {
585
+ const name = match[1];
586
+ const typeAnnotation = match[2]?.trim();
587
+ const defaultValue = match[3]?.trim();
588
+ const required = defaultValue === void 0;
589
+ let type = typeAnnotation ?? "unknown";
590
+ if (type === "unknown" && defaultValue !== void 0) {
591
+ type = inferTypeFromDefault(defaultValue);
592
+ }
593
+ props.push({
594
+ name,
595
+ type,
596
+ required,
597
+ defaultValue
598
+ });
599
+ }
600
+ return props;
601
+ }
602
+ function inferTypeFromDefault(value) {
603
+ if (value === "true" || value === "false") return "boolean";
604
+ if (/^['"]/.test(value)) return "string";
605
+ if (/^-?\d+(\.\d+)?$/.test(value)) return "number";
606
+ if (value.startsWith("[")) return "array";
607
+ if (value.startsWith("{")) return "object";
608
+ if (value === "null") return "null";
609
+ if (value === "undefined") return "undefined";
610
+ return "unknown";
611
+ }
612
+ function extractChildComponentsFromTemplate2(template) {
613
+ const components = /* @__PURE__ */ new Set();
614
+ const pascalRegex = /<([A-Z][a-zA-Z0-9]+)/g;
615
+ let match;
616
+ while ((match = pascalRegex.exec(template)) !== null) {
617
+ components.add(match[1]);
618
+ }
619
+ return Array.from(components);
620
+ }
621
+
622
+ // src/project-analyzer.ts
623
+ async function analyzeProject(rootDir, configOverrides) {
624
+ const startTime = performance.now();
625
+ const config = {
626
+ ...await loadConfig(rootDir),
627
+ ...configOverrides
628
+ };
629
+ const framework = config.framework ?? "unknown";
630
+ const files = await findSourceFiles(config);
631
+ const registry = new ComponentRegistry();
632
+ const allImports = [];
633
+ let totalComponents = 0;
634
+ for (const filePath of files) {
635
+ try {
636
+ if (filePath.endsWith(".vue")) {
637
+ const source = await readFile2(filePath, "utf-8");
638
+ const vueAnalysis = parseVueSFC(source, filePath);
639
+ const component = vueSFCToComponentInfo(vueAnalysis, filePath);
640
+ registry.addComponents([component]);
641
+ totalComponents += 1;
642
+ if (vueAnalysis.scriptContent) {
643
+ const scriptAnalysis = analyzeSource(vueAnalysis.scriptContent, filePath);
644
+ registry.addImports(scriptAnalysis.imports);
645
+ allImports.push(...scriptAnalysis.imports);
646
+ }
647
+ if (vueAnalysis.imports.length > 0) {
648
+ registry.addImports(vueAnalysis.imports);
649
+ allImports.push(...vueAnalysis.imports);
650
+ }
651
+ continue;
652
+ }
653
+ if (filePath.endsWith(".svelte")) {
654
+ const source = await readFile2(filePath, "utf-8");
655
+ const svelteAnalysis = parseSvelteFile(source, filePath);
656
+ const component = svelteFileToComponentInfo(svelteAnalysis, filePath);
657
+ registry.addComponents([component]);
658
+ totalComponents += 1;
659
+ const scriptContent = [svelteAnalysis.moduleScriptContent, svelteAnalysis.scriptContent].filter(Boolean).join("\n");
660
+ if (scriptContent) {
661
+ const scriptAnalysis = analyzeSource(scriptContent, filePath);
662
+ registry.addImports(scriptAnalysis.imports);
663
+ allImports.push(...scriptAnalysis.imports);
664
+ }
665
+ if (svelteAnalysis.imports.length > 0) {
666
+ registry.addImports(svelteAnalysis.imports);
667
+ allImports.push(...svelteAnalysis.imports);
668
+ }
669
+ continue;
670
+ }
671
+ const analysis = await analyzeFile(filePath);
672
+ registry.addImports(analysis.imports);
673
+ allImports.push(...analysis.imports);
674
+ const components = detectComponents(analysis, framework);
675
+ if (components.length > 0) {
676
+ registry.addComponents(components);
677
+ totalComponents += components.length;
678
+ }
679
+ } catch (error) {
680
+ console.warn(`[foxlight] Warning: Failed to analyze ${filePath}:`, error);
681
+ }
682
+ }
683
+ const allComponents = registry.getAllComponents();
684
+ const crossReferenced = crossReferenceComponents(allComponents);
685
+ registry.clear();
686
+ registry.addComponents(crossReferenced);
687
+ registry.addImports(allImports);
688
+ try {
689
+ const program = ts3.createProgram(files, {
690
+ target: ts3.ScriptTarget.ES2022,
691
+ module: ts3.ModuleKind.Node16,
692
+ moduleResolution: ts3.ModuleResolutionKind.Node16,
693
+ jsx: ts3.JsxEmit.ReactJSX,
694
+ strict: true,
695
+ noEmit: true,
696
+ skipLibCheck: true
697
+ });
698
+ const checker = program.getTypeChecker();
699
+ for (const filePath of files) {
700
+ const sourceFile = program.getSourceFile(filePath);
701
+ if (!sourceFile) continue;
702
+ const fileProps = extractAllPropsFromFile(checker, sourceFile);
703
+ for (const [componentName, props] of fileProps) {
704
+ const comp = registry.getAllComponents().find((c) => c.name === componentName && c.filePath === filePath);
705
+ if (comp && props.length > 0) {
706
+ comp.props = props;
707
+ }
708
+ }
709
+ }
710
+ } catch {
711
+ }
712
+ const graph = DependencyGraph.fromImports(allImports);
713
+ const duration = performance.now() - startTime;
714
+ return {
715
+ config,
716
+ registry,
717
+ graph,
718
+ stats: {
719
+ filesScanned: files.length,
720
+ componentsFound: totalComponents,
721
+ importsTracked: allImports.length,
722
+ duration
723
+ }
724
+ };
725
+ }
726
+ async function findSourceFiles(config) {
727
+ const rootDir = resolve(config.rootDir);
728
+ const allFiles = await walkDir(rootDir);
729
+ const includeMatchers = config.include.map((p) => createGlobMatcher(p));
730
+ const excludeMatchers = config.exclude.map((p) => createGlobMatcher(p));
731
+ return allFiles.filter((filePath) => {
732
+ const rel = relative(rootDir, filePath);
733
+ const included = includeMatchers.some((matcher) => matcher(rel));
734
+ if (!included) return false;
735
+ const excluded = excludeMatchers.some((matcher) => matcher(rel));
736
+ return !excluded;
737
+ });
738
+ }
739
+ function createGlobMatcher(pattern) {
740
+ const regexStr = globToRegex(pattern);
741
+ const regex = new RegExp(`^${regexStr}$`, "i");
742
+ return (path) => regex.test(path);
743
+ }
744
+ function globToRegex(pattern) {
745
+ let result = "";
746
+ let i = 0;
747
+ while (i < pattern.length) {
748
+ const char = pattern[i];
749
+ if (char === "*") {
750
+ if (pattern[i + 1] === "*") {
751
+ if (pattern[i + 2] === "/") {
752
+ result += "(?:.+/)?";
753
+ i += 3;
754
+ } else {
755
+ result += ".*";
756
+ i += 2;
757
+ }
758
+ } else {
759
+ result += "[^/]*";
760
+ i++;
761
+ }
762
+ } else if (char === "?") {
763
+ result += "[^/]";
764
+ i++;
765
+ } else if (char === "{") {
766
+ const closeIdx = pattern.indexOf("}", i);
767
+ if (closeIdx === -1) {
768
+ result += "\\{";
769
+ i++;
770
+ } else {
771
+ const alternatives = pattern.slice(i + 1, closeIdx).split(",");
772
+ result += `(?:${alternatives.map(escapeRegex).join("|")})`;
773
+ i = closeIdx + 1;
774
+ }
775
+ } else if (char === ".") {
776
+ result += "\\.";
777
+ i++;
778
+ } else {
779
+ result += escapeRegexChar(char);
780
+ i++;
781
+ }
782
+ }
783
+ return result;
784
+ }
785
+ function escapeRegex(str) {
786
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
787
+ }
788
+ function escapeRegexChar(char) {
789
+ return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
790
+ }
791
+ async function walkDir(dir) {
792
+ const results = [];
793
+ try {
794
+ const entries = await readdir(dir, { withFileTypes: true });
795
+ for (const entry of entries) {
796
+ const fullPath = resolve(dir, entry.name);
797
+ if (entry.isDirectory()) {
798
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
799
+ results.push(...await walkDir(fullPath));
800
+ } else {
801
+ results.push(fullPath);
802
+ }
803
+ }
804
+ } catch {
805
+ }
806
+ return results;
807
+ }
808
+ export {
809
+ analyzeFile,
810
+ analyzeProject,
811
+ analyzeSource,
812
+ createTypeChecker,
813
+ crossReferenceComponents,
814
+ detectComponents,
815
+ extractAllPropsFromFile,
816
+ extractPropsFromTsType,
817
+ extractPropsFromType,
818
+ parseSvelteFile,
819
+ parseVueSFC,
820
+ svelteFileToComponentInfo,
821
+ vueSFCToComponentInfo
822
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@foxlight/analyzer",
3
+ "version": "0.1.0",
4
+ "description": "Static analysis engine for Foxlight — AST scanning, component detection, import resolution.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "dev": "tsup --watch"
17
+ },
18
+ "dependencies": {
19
+ "@foxlight/core": "*"
20
+ },
21
+ "peerDependencies": {
22
+ "typescript": "^5.4.0"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "typescript": {
26
+ "optional": false
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "license": "MIT",
33
+ "author": "Jose Cruz",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/josegabrielcruz/foxlight.git",
37
+ "directory": "packages/analyzer"
38
+ },
39
+ "homepage": "https://github.com/josegabrielcruz/foxlight/tree/master/packages/analyzer#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/josegabrielcruz/foxlight/issues"
42
+ },
43
+ "keywords": [
44
+ "foxlight",
45
+ "static-analysis",
46
+ "ast",
47
+ "component-detection",
48
+ "typescript",
49
+ "vue",
50
+ "svelte"
51
+ ]
52
+ }