@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 +47 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +822 -0
- package/package.json +52 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|