@archora/core 1.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/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import type { FileSource } from './fileSource';
|
|
2
|
+
import type {
|
|
3
|
+
AnalyzerWarning,
|
|
4
|
+
DependencyEdge,
|
|
5
|
+
FactConfidence,
|
|
6
|
+
ImportResolutionKind,
|
|
7
|
+
ModuleNode,
|
|
8
|
+
ParsedFile,
|
|
9
|
+
ParsedFileSummary,
|
|
10
|
+
RawImport,
|
|
11
|
+
ScanProgressCallback,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { createParserRegistry, isParseFailure } from './parsers';
|
|
14
|
+
import type { Framework } from './detect';
|
|
15
|
+
import { applyAlias, type PathAlias, type Resolver } from './resolve';
|
|
16
|
+
import { classifyKind, isInfra } from './classify';
|
|
17
|
+
import { classifyModuleRuntime } from './rsc';
|
|
18
|
+
import type { ArchoraConfig } from '../config/frontScopeConfig';
|
|
19
|
+
|
|
20
|
+
export interface BuildGraphInput {
|
|
21
|
+
source: FileSource;
|
|
22
|
+
files: string[];
|
|
23
|
+
resolver: Resolver;
|
|
24
|
+
aliases?: PathAlias[];
|
|
25
|
+
config?: ArchoraConfig;
|
|
26
|
+
framework?: Framework;
|
|
27
|
+
onProgress?: ScanProgressCallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BuildGraphResult {
|
|
31
|
+
modules: ModuleNode[];
|
|
32
|
+
edges: DependencyEdge[];
|
|
33
|
+
parserFacts: ParsedFileSummary[];
|
|
34
|
+
warnings: AnalyzerWarning[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CHUNK_SIZE = 100;
|
|
38
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
39
|
+
const yieldToEventLoop = (): Promise<void> =>
|
|
40
|
+
new Promise((resolve) => {
|
|
41
|
+
setTimeout(resolve, 0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResult> {
|
|
45
|
+
const { source, files, resolver, aliases = [], config, framework, onProgress } = input;
|
|
46
|
+
const registry = createParserRegistry({
|
|
47
|
+
...(config?.dynamicLoaders ? { dynamicLoaders: config.dynamicLoaders } : {}),
|
|
48
|
+
...(framework ? { framework } : {}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const parsed: ParsedFile[] = [];
|
|
52
|
+
const warnings: AnalyzerWarning[] = [];
|
|
53
|
+
|
|
54
|
+
onProgress?.({ phase: 'parse', current: 0, total: files.length });
|
|
55
|
+
for (let i = 0; i < files.length; i += CHUNK_SIZE) {
|
|
56
|
+
const chunk = files.slice(i, i + CHUNK_SIZE);
|
|
57
|
+
const results = await Promise.all(
|
|
58
|
+
chunk.map(async (relPath): Promise<ParsedFile | null> => {
|
|
59
|
+
try {
|
|
60
|
+
const content = await source.read(relPath);
|
|
61
|
+
if (content.length > MAX_FILE_BYTES) {
|
|
62
|
+
warnings.push({
|
|
63
|
+
code: 'parse-failed',
|
|
64
|
+
message: `File exceeds ${MAX_FILE_BYTES} bytes, skipped`,
|
|
65
|
+
file: relPath,
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const result = registry.parse({ relPath, content });
|
|
70
|
+
if (isParseFailure(result)) {
|
|
71
|
+
warnings.push({
|
|
72
|
+
code: 'unsupported-extension',
|
|
73
|
+
message: 'Unsupported extension',
|
|
74
|
+
file: relPath,
|
|
75
|
+
});
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
warnings.push({
|
|
81
|
+
code: 'parse-failed',
|
|
82
|
+
message: 'Parse failed',
|
|
83
|
+
file: relPath,
|
|
84
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
85
|
+
});
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
for (const r of results) if (r) parsed.push(r);
|
|
91
|
+
onProgress?.({
|
|
92
|
+
phase: 'parse',
|
|
93
|
+
current: Math.min(i + CHUNK_SIZE, files.length),
|
|
94
|
+
total: files.length,
|
|
95
|
+
});
|
|
96
|
+
await yieldToEventLoop();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const modules: ModuleNode[] = parsed.map((p) => ({
|
|
100
|
+
id: p.relPath,
|
|
101
|
+
absPath: p.relPath,
|
|
102
|
+
kind: classifyKind(p, p.relPath),
|
|
103
|
+
language: p.language,
|
|
104
|
+
loc: p.loc,
|
|
105
|
+
exports: p.exports,
|
|
106
|
+
isInfra: isInfra(p.relPath),
|
|
107
|
+
runtime: classifyModuleRuntime({
|
|
108
|
+
relPath: p.relPath,
|
|
109
|
+
framework: input.framework ?? 'unknown',
|
|
110
|
+
...(p.directives ? { directives: p.directives } : {}),
|
|
111
|
+
}),
|
|
112
|
+
}));
|
|
113
|
+
const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
|
|
114
|
+
|
|
115
|
+
const moduleIds = new Set(modules.map((m) => m.id));
|
|
116
|
+
const edges: DependencyEdge[] = [];
|
|
117
|
+
|
|
118
|
+
const filesByDir = indexFilesByDir(files);
|
|
119
|
+
|
|
120
|
+
for (const file of parsed) {
|
|
121
|
+
const negativeGlobPatterns = file.imports
|
|
122
|
+
.filter((imp) => imp.pattern === 'glob' && imp.specifier.startsWith('!'))
|
|
123
|
+
.map((imp) => imp.specifier.slice(1));
|
|
124
|
+
for (const imp of file.imports) {
|
|
125
|
+
if (isAssetSpecifier(imp.specifier)) continue;
|
|
126
|
+
// glob/prefix specifiers may start with a bare name ('src/**') -
|
|
127
|
+
// skip the external check for them
|
|
128
|
+
if (
|
|
129
|
+
imp.pattern !== 'glob' &&
|
|
130
|
+
imp.pattern !== 'prefix' &&
|
|
131
|
+
isExternal(imp.specifier) &&
|
|
132
|
+
resolver.hasLocalCandidate?.(imp.specifier) !== true
|
|
133
|
+
) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (imp.pattern === 'glob') {
|
|
138
|
+
if (imp.specifier.startsWith('!')) continue;
|
|
139
|
+
if (isAssetGlobSpecifier(imp.specifier)) continue;
|
|
140
|
+
const fromDir = posixDirnameLocal(file.relPath);
|
|
141
|
+
const matched = expandGlob(
|
|
142
|
+
imp.specifier,
|
|
143
|
+
fromDir,
|
|
144
|
+
files,
|
|
145
|
+
moduleIds,
|
|
146
|
+
negativeGlobPatterns,
|
|
147
|
+
aliases,
|
|
148
|
+
);
|
|
149
|
+
if (matched.length === 0) {
|
|
150
|
+
warnings.push({
|
|
151
|
+
code: 'resolve-failed',
|
|
152
|
+
message: `import.meta.glob("${imp.specifier}") matched no files`,
|
|
153
|
+
file: file.relPath,
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const target of matched) {
|
|
158
|
+
if (target === file.relPath) continue;
|
|
159
|
+
edges.push({
|
|
160
|
+
from: file.relPath,
|
|
161
|
+
to: target,
|
|
162
|
+
kind: imp.kind,
|
|
163
|
+
specifier: imp.specifier,
|
|
164
|
+
resolved: true,
|
|
165
|
+
...edgeMeta(imp, 'glob', 'low', true),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (imp.pattern === 'prefix') {
|
|
172
|
+
// template-literal dynamic import like `import('./mfes/' + x)`:
|
|
173
|
+
// connect to every file under the static prefix dir
|
|
174
|
+
const baseDir = resolvePrefixDir(imp.specifier, file.relPath);
|
|
175
|
+
if (baseDir === null) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
code: 'resolve-failed',
|
|
178
|
+
message: `Cannot resolve dynamic prefix "${imp.specifier}"`,
|
|
179
|
+
file: file.relPath,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const matched = expandPrefix(baseDir, filesByDir, moduleIds);
|
|
184
|
+
if (matched.length === 0) {
|
|
185
|
+
warnings.push({
|
|
186
|
+
code: 'resolve-failed',
|
|
187
|
+
message: `Dynamic prefix "${imp.specifier}" matched no files`,
|
|
188
|
+
file: file.relPath,
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const target of matched) {
|
|
193
|
+
if (target === file.relPath) continue;
|
|
194
|
+
edges.push({
|
|
195
|
+
from: file.relPath,
|
|
196
|
+
to: target,
|
|
197
|
+
kind: imp.kind,
|
|
198
|
+
specifier: imp.specifier,
|
|
199
|
+
resolved: true,
|
|
200
|
+
...edgeMeta(imp, 'prefix', 'medium', true),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resolved = await resolver.resolve(imp.specifier, file.relPath);
|
|
207
|
+
if (resolved && moduleIds.has(resolved)) {
|
|
208
|
+
if (resolved === file.relPath) {
|
|
209
|
+
warnings.push({
|
|
210
|
+
code: 'self-import',
|
|
211
|
+
message: 'Module imports itself',
|
|
212
|
+
file: file.relPath,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
edges.push({
|
|
216
|
+
from: file.relPath,
|
|
217
|
+
to: resolved,
|
|
218
|
+
kind: imp.kind,
|
|
219
|
+
specifier: imp.specifier,
|
|
220
|
+
resolved: true,
|
|
221
|
+
...edgeMeta(imp, imp.kind === 'dynamic' ? 'dynamic-literal' : 'literal', 'high', false),
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
warnings.push({
|
|
225
|
+
code: 'resolve-failed',
|
|
226
|
+
message: `Cannot resolve "${imp.specifier}"`,
|
|
227
|
+
file: file.relPath,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// unplugin-vue-components: PascalCase template tags -> src/components/**/*.vue
|
|
234
|
+
// (vite) or components/**/*.vue (nuxt). runs after static imports for dedupe
|
|
235
|
+
const componentRegistry = buildComponentRegistry(files, framework);
|
|
236
|
+
const existingEdges = new Set(edges.map((e) => `${e.from}\u0001${e.to}`));
|
|
237
|
+
for (const file of parsed) {
|
|
238
|
+
if (!file.templateRefs || file.templateRefs.length === 0) continue;
|
|
239
|
+
for (const name of file.templateRefs) {
|
|
240
|
+
const target = componentRegistry.get(name);
|
|
241
|
+
if (!target || target === file.relPath) continue;
|
|
242
|
+
const key = `${file.relPath}\u0001${target}`;
|
|
243
|
+
if (existingEdges.has(key)) continue;
|
|
244
|
+
existingEdges.add(key);
|
|
245
|
+
edges.push({
|
|
246
|
+
from: file.relPath,
|
|
247
|
+
to: target,
|
|
248
|
+
kind: 'auto-import',
|
|
249
|
+
specifier: name,
|
|
250
|
+
resolved: true,
|
|
251
|
+
confidence: 'medium',
|
|
252
|
+
resolutionKind: 'framework-auto',
|
|
253
|
+
approximate: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { modules, edges, parserFacts, warnings };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function edgeMeta(
|
|
262
|
+
imp: RawImport,
|
|
263
|
+
resolutionKind: ImportResolutionKind,
|
|
264
|
+
confidence: FactConfidence,
|
|
265
|
+
approximate: boolean,
|
|
266
|
+
): Pick<DependencyEdge, 'confidence' | 'resolutionKind' | 'approximate'> {
|
|
267
|
+
return {
|
|
268
|
+
confidence: imp.confidence ?? confidence,
|
|
269
|
+
resolutionKind: imp.resolutionKind ?? resolutionKind,
|
|
270
|
+
approximate: imp.approximate ?? approximate,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function toParsedFileSummary(file: ParsedFile, framework: string): ParsedFileSummary {
|
|
275
|
+
const imports = file.imports.map((imp) => {
|
|
276
|
+
const asset = isAssetSpecifier(imp.specifier);
|
|
277
|
+
const patternKind =
|
|
278
|
+
imp.pattern === 'glob'
|
|
279
|
+
? 'glob'
|
|
280
|
+
: imp.pattern === 'prefix'
|
|
281
|
+
? 'prefix'
|
|
282
|
+
: asset
|
|
283
|
+
? 'asset'
|
|
284
|
+
: imp.kind === 'dynamic'
|
|
285
|
+
? 'dynamic-literal'
|
|
286
|
+
: 'literal';
|
|
287
|
+
return {
|
|
288
|
+
specifier: imp.specifier,
|
|
289
|
+
kind: imp.kind,
|
|
290
|
+
resolutionKind: imp.resolutionKind ?? patternKind,
|
|
291
|
+
confidence:
|
|
292
|
+
imp.confidence ??
|
|
293
|
+
(imp.pattern === 'glob' ? 'low' : imp.pattern === 'prefix' ? 'medium' : 'high'),
|
|
294
|
+
approximate: imp.approximate ?? (imp.pattern === 'glob' || imp.pattern === 'prefix'),
|
|
295
|
+
...(asset ? { isAsset: true } : {}),
|
|
296
|
+
...(imp.pattern === 'glob' ? { globEager: imp.globEager ?? false } : {}),
|
|
297
|
+
...(imp.globImport ? { globImport: imp.globImport } : {}),
|
|
298
|
+
...(imp.pattern === 'glob' && imp.specifier.startsWith('!') ? { negative: true } : {}),
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
const assetFacts = file.imports
|
|
302
|
+
.filter((imp) => isAssetSpecifier(imp.specifier))
|
|
303
|
+
.map((imp) => ({
|
|
304
|
+
specifier: imp.specifier,
|
|
305
|
+
assetKind: assetKindOf(imp.specifier),
|
|
306
|
+
confidence: 'high' as const,
|
|
307
|
+
}));
|
|
308
|
+
const runtime = file.directives?.includes('use server')
|
|
309
|
+
? 'server'
|
|
310
|
+
: file.directives?.includes('use client')
|
|
311
|
+
? 'client'
|
|
312
|
+
: 'shared';
|
|
313
|
+
const runtimeSource =
|
|
314
|
+
file.directives && file.directives.length > 0
|
|
315
|
+
? 'directive'
|
|
316
|
+
: /(^|\/)(server|app\/api)\//u.test(file.relPath) || /\+server\.[jt]s$/u.test(file.relPath)
|
|
317
|
+
? 'framework-convention'
|
|
318
|
+
: 'default';
|
|
319
|
+
return {
|
|
320
|
+
relPath: file.relPath,
|
|
321
|
+
language: file.language,
|
|
322
|
+
loc: file.loc,
|
|
323
|
+
imports,
|
|
324
|
+
exports: file.exports.map((name) => ({ name, kind: exportKindOf(name), confidence: 'medium' })),
|
|
325
|
+
runtimeFacts: [
|
|
326
|
+
{
|
|
327
|
+
runtime,
|
|
328
|
+
source: runtimeSource,
|
|
329
|
+
confidence: runtimeSource === 'default' ? 'low' : 'high',
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
frameworkFacts: [
|
|
333
|
+
...(file.templateRefs ?? []).map((value) => ({
|
|
334
|
+
kind: 'vue-template-ref' as const,
|
|
335
|
+
value,
|
|
336
|
+
confidence: 'medium' as const,
|
|
337
|
+
})),
|
|
338
|
+
...frameworkFactsFromPath(file.relPath, framework),
|
|
339
|
+
],
|
|
340
|
+
routeFacts: routeFactsFromPath(file.relPath, framework),
|
|
341
|
+
stateFacts: file.hasDefineStore
|
|
342
|
+
? [{ kind: 'pinia-store' as const, confidence: 'high' as const }]
|
|
343
|
+
: [],
|
|
344
|
+
assetFacts,
|
|
345
|
+
limitations: limitationsFor(file),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// vite default: src/components/**, nuxt: components/**. first match wins on basename collision.
|
|
350
|
+
function buildComponentRegistry(
|
|
351
|
+
files: string[],
|
|
352
|
+
framework: Framework | undefined,
|
|
353
|
+
): Map<string, string> {
|
|
354
|
+
const registry = new Map<string, string>();
|
|
355
|
+
const re =
|
|
356
|
+
framework === 'nuxt' ? /^components\/|(^|\/)src\/components\//u : /(^|\/)src\/components\//u;
|
|
357
|
+
for (const f of files) {
|
|
358
|
+
if (!re.test(f)) continue;
|
|
359
|
+
if (!f.toLowerCase().endsWith('.vue')) continue;
|
|
360
|
+
const slash = f.lastIndexOf('/');
|
|
361
|
+
const base = (slash === -1 ? f : f.slice(slash + 1)).slice(0, -'.vue'.length);
|
|
362
|
+
if (base.length === 0) continue;
|
|
363
|
+
if (!registry.has(base)) registry.set(base, f);
|
|
364
|
+
}
|
|
365
|
+
return registry;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function indexFilesByDir(files: string[]): Map<string, string[]> {
|
|
369
|
+
const map = new Map<string, string[]>();
|
|
370
|
+
for (const f of files) {
|
|
371
|
+
const dir = posixDirname(f);
|
|
372
|
+
let bucket = map.get(dir);
|
|
373
|
+
if (!bucket) {
|
|
374
|
+
bucket = [];
|
|
375
|
+
map.set(dir, bucket);
|
|
376
|
+
}
|
|
377
|
+
bucket.push(f);
|
|
378
|
+
}
|
|
379
|
+
return map;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// returns null if the prefix would expand to project root or outside the tree
|
|
383
|
+
function resolvePrefixDir(prefix: string, fromRel: string): string | null {
|
|
384
|
+
if (prefix.startsWith('/')) return null;
|
|
385
|
+
const trailingSlash = prefix.endsWith('/');
|
|
386
|
+
const fromDir = posixDirname(fromRel);
|
|
387
|
+
const isRelative = prefix.startsWith('./') || prefix.startsWith('../');
|
|
388
|
+
const joined = isRelative ? posixJoin(fromDir, prefix) : prefix;
|
|
389
|
+
const base = trailingSlash
|
|
390
|
+
? joined
|
|
391
|
+
: (() => {
|
|
392
|
+
const idx = joined.lastIndexOf('/');
|
|
393
|
+
return idx === -1 ? '' : joined.slice(0, idx);
|
|
394
|
+
})();
|
|
395
|
+
if (base === '' || !base.includes('/')) return null;
|
|
396
|
+
return base;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);
|
|
400
|
+
|
|
401
|
+
function globToRegex(glob: string): RegExp {
|
|
402
|
+
let re = '';
|
|
403
|
+
for (let i = 0; i < glob.length; i++) {
|
|
404
|
+
const c = glob[i]!;
|
|
405
|
+
if (c === '*') {
|
|
406
|
+
const next = glob[i + 1];
|
|
407
|
+
if (next === '*') {
|
|
408
|
+
if (glob[i + 2] === '/') {
|
|
409
|
+
re += '(?:.*/)?';
|
|
410
|
+
i += 2;
|
|
411
|
+
} else {
|
|
412
|
+
re += '.*';
|
|
413
|
+
i += 1;
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
re += '[^/]*';
|
|
417
|
+
}
|
|
418
|
+
} else if (c === '?') {
|
|
419
|
+
re += '[^/]';
|
|
420
|
+
} else if (/[.+^$(){}|\\[\]]/u.test(c)) {
|
|
421
|
+
re += `\\${c}`;
|
|
422
|
+
} else {
|
|
423
|
+
re += c;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return new RegExp(`^${re}$`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function expandGlob(
|
|
430
|
+
pattern: string,
|
|
431
|
+
fromDir: string,
|
|
432
|
+
files: string[],
|
|
433
|
+
moduleIds: Set<string>,
|
|
434
|
+
negativePatterns: readonly string[] = [],
|
|
435
|
+
aliases: readonly PathAlias[] = [],
|
|
436
|
+
): string[] {
|
|
437
|
+
const patterns = expandAliasedGlobPattern(pattern, fromDir, aliases);
|
|
438
|
+
const res = patterns.map(globToRegex);
|
|
439
|
+
const negativeRes = negativePatterns
|
|
440
|
+
.flatMap((p) => expandAliasedGlobPattern(p, fromDir, aliases))
|
|
441
|
+
.map(globToRegex);
|
|
442
|
+
const out: string[] = [];
|
|
443
|
+
for (const f of files) {
|
|
444
|
+
if (!moduleIds.has(f)) continue;
|
|
445
|
+
if (res.some((re) => re.test(f))) out.push(f);
|
|
446
|
+
}
|
|
447
|
+
return negativeRes.length === 0 ? out : out.filter((f) => !negativeRes.some((re) => re.test(f)));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function expandAliasedGlobPattern(
|
|
451
|
+
pattern: string,
|
|
452
|
+
fromDir: string,
|
|
453
|
+
aliases: readonly PathAlias[],
|
|
454
|
+
): string[] {
|
|
455
|
+
const anchored = anchorGlobPattern(pattern, fromDir);
|
|
456
|
+
const aliased = applyAlias(anchored, [...aliases]);
|
|
457
|
+
return aliased.length > 0 ? aliased : [anchored];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function anchorGlobPattern(pattern: string, fromDir: string): string {
|
|
461
|
+
// vite globs are relative to the file -- anchor to project root
|
|
462
|
+
if (pattern.startsWith('/')) return pattern.slice(1);
|
|
463
|
+
if (pattern.startsWith('./') || pattern.startsWith('../')) return posixJoin(fromDir, pattern);
|
|
464
|
+
return pattern;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function posixDirnameLocal(p: string): string {
|
|
468
|
+
return posixDirname(p);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function expandPrefix(
|
|
472
|
+
baseDir: string,
|
|
473
|
+
filesByDir: Map<string, string[]>,
|
|
474
|
+
moduleIds: Set<string>,
|
|
475
|
+
): string[] {
|
|
476
|
+
const out: string[] = [];
|
|
477
|
+
const prefix = baseDir === '' ? '' : `${baseDir}/`;
|
|
478
|
+
for (const [dir, bucket] of filesByDir) {
|
|
479
|
+
if (dir !== baseDir && !dir.startsWith(prefix)) continue;
|
|
480
|
+
for (const f of bucket) {
|
|
481
|
+
const ext = f.slice(f.lastIndexOf('.')).toLowerCase();
|
|
482
|
+
if (!SOURCE_EXTS.has(ext)) continue;
|
|
483
|
+
if (!moduleIds.has(f)) continue;
|
|
484
|
+
out.push(f);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function posixDirname(p: string): string {
|
|
491
|
+
const i = p.lastIndexOf('/');
|
|
492
|
+
return i === -1 ? '' : p.slice(0, i);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function posixJoin(a: string, b: string): string {
|
|
496
|
+
const segments = `${a}/${b}`.split('/');
|
|
497
|
+
const stack: string[] = [];
|
|
498
|
+
for (const s of segments) {
|
|
499
|
+
if (s === '' || s === '.') continue;
|
|
500
|
+
if (s === '..') {
|
|
501
|
+
if (stack.length > 0 && stack[stack.length - 1] !== '..') stack.pop();
|
|
502
|
+
else stack.push('..');
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
stack.push(s);
|
|
506
|
+
}
|
|
507
|
+
return stack.join('/');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isExternal(spec: string): boolean {
|
|
511
|
+
if (spec.startsWith('.') || spec.startsWith('/')) return false;
|
|
512
|
+
if (spec.startsWith('@/') || spec.startsWith('~/')) return false;
|
|
513
|
+
return /^[a-z@]/u.test(spec) && !spec.includes('://');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const ASSET_EXTS = new Set([
|
|
517
|
+
'.css',
|
|
518
|
+
'.scss',
|
|
519
|
+
'.sass',
|
|
520
|
+
'.less',
|
|
521
|
+
'.module.css',
|
|
522
|
+
'.module.scss',
|
|
523
|
+
'.json',
|
|
524
|
+
'.html',
|
|
525
|
+
'.svg',
|
|
526
|
+
'.png',
|
|
527
|
+
'.jpg',
|
|
528
|
+
'.jpeg',
|
|
529
|
+
'.gif',
|
|
530
|
+
'.webp',
|
|
531
|
+
'.avif',
|
|
532
|
+
'.ico',
|
|
533
|
+
'.woff',
|
|
534
|
+
'.woff2',
|
|
535
|
+
'.ttf',
|
|
536
|
+
'.otf',
|
|
537
|
+
]);
|
|
538
|
+
|
|
539
|
+
function isAssetGlobSpecifier(specifier: string): boolean {
|
|
540
|
+
return ASSET_EXTS.has(extensionFromPattern(specifier));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function extensionFromPattern(specifier: string): string {
|
|
544
|
+
const clean = (specifier.split('?')[0] ?? specifier).toLowerCase();
|
|
545
|
+
const match = clean.match(/\.([a-z0-9]+)(?:$|[!*,{}[\]/])/u);
|
|
546
|
+
return match ? `.${match[1]}` : '';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function isAssetSpecifier(specifier: string): boolean {
|
|
550
|
+
const clean = specifier.split('?')[0] ?? specifier;
|
|
551
|
+
const lower = clean.toLowerCase();
|
|
552
|
+
for (const ext of ASSET_EXTS) {
|
|
553
|
+
if (lower.endsWith(ext)) return true;
|
|
554
|
+
}
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function assetKindOf(specifier: string): 'style' | 'json' | 'image' | 'font' | 'other' {
|
|
559
|
+
const clean = (specifier.split('?')[0] ?? specifier).toLowerCase();
|
|
560
|
+
if (/\.(css|scss|sass|less)$/u.test(clean)) return 'style';
|
|
561
|
+
if (clean.endsWith('.json')) return 'json';
|
|
562
|
+
if (/\.(svg|png|jpg|jpeg|gif|webp|avif|ico)$/u.test(clean)) return 'image';
|
|
563
|
+
if (/\.(woff2?|ttf|otf)$/u.test(clean)) return 'font';
|
|
564
|
+
return 'other';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function exportKindOf(name: string): 'value' | 'type' | 'default' | 'namespace' | 'named' {
|
|
568
|
+
if (name === 'default') return 'default';
|
|
569
|
+
if (name === '*') return 'namespace';
|
|
570
|
+
return 'named';
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function frameworkFactsFromPath(
|
|
574
|
+
relPath: string,
|
|
575
|
+
framework: string,
|
|
576
|
+
): {
|
|
577
|
+
kind:
|
|
578
|
+
| 'react-lazy'
|
|
579
|
+
| 'next-dynamic'
|
|
580
|
+
| 'nuxt-auto-component'
|
|
581
|
+
| 'nuxt-auto-composable'
|
|
582
|
+
| 'sveltekit-route';
|
|
583
|
+
value: string;
|
|
584
|
+
confidence: FactConfidence;
|
|
585
|
+
}[] {
|
|
586
|
+
const facts: {
|
|
587
|
+
kind:
|
|
588
|
+
| 'react-lazy'
|
|
589
|
+
| 'next-dynamic'
|
|
590
|
+
| 'nuxt-auto-component'
|
|
591
|
+
| 'nuxt-auto-composable'
|
|
592
|
+
| 'sveltekit-route';
|
|
593
|
+
value: string;
|
|
594
|
+
confidence: FactConfidence;
|
|
595
|
+
}[] = [];
|
|
596
|
+
if (
|
|
597
|
+
framework === 'svelte' &&
|
|
598
|
+
/(^|\/)\+(page|layout|server)\.svelte$|(^|\/)\+server\.[jt]s$/u.test(relPath)
|
|
599
|
+
) {
|
|
600
|
+
facts.push({ kind: 'sveltekit-route', value: relPath, confidence: 'medium' });
|
|
601
|
+
}
|
|
602
|
+
if (framework === 'nuxt' && /(^|\/)components\/.+\.vue$/u.test(relPath)) {
|
|
603
|
+
facts.push({ kind: 'nuxt-auto-component', value: relPath, confidence: 'medium' });
|
|
604
|
+
}
|
|
605
|
+
if (framework === 'nuxt' && /(^|\/)composables\/.+\.[cm]?[jt]s$/u.test(relPath)) {
|
|
606
|
+
facts.push({ kind: 'nuxt-auto-composable', value: relPath, confidence: 'medium' });
|
|
607
|
+
}
|
|
608
|
+
return facts;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function routeFactsFromPath(
|
|
612
|
+
relPath: string,
|
|
613
|
+
framework: string,
|
|
614
|
+
): {
|
|
615
|
+
routeKind: 'page' | 'layout' | 'api' | 'middleware' | 'server-route';
|
|
616
|
+
confidence: FactConfidence;
|
|
617
|
+
}[] {
|
|
618
|
+
const facts: {
|
|
619
|
+
routeKind: 'page' | 'layout' | 'api' | 'middleware' | 'server-route';
|
|
620
|
+
confidence: FactConfidence;
|
|
621
|
+
}[] = [];
|
|
622
|
+
if (framework === 'next') {
|
|
623
|
+
if (/(^|\/)app(?:\/.*)?\/page\.[jt]sx?$/u.test(relPath))
|
|
624
|
+
facts.push({ routeKind: 'page', confidence: 'medium' });
|
|
625
|
+
if (/(^|\/)app(?:\/.*)?\/layout\.[jt]sx?$/u.test(relPath))
|
|
626
|
+
facts.push({ routeKind: 'layout', confidence: 'medium' });
|
|
627
|
+
if (/(^|\/)app\/api\/.*\/route\.[jt]s$/u.test(relPath))
|
|
628
|
+
facts.push({ routeKind: 'api', confidence: 'medium' });
|
|
629
|
+
if (/(^|\/)pages\/api\/.*\.[jt]sx?$/u.test(relPath))
|
|
630
|
+
facts.push({ routeKind: 'api', confidence: 'medium' });
|
|
631
|
+
if (/(^|\/)pages\/(?!api\/).*\.[jt]sx?$/u.test(relPath))
|
|
632
|
+
facts.push({ routeKind: 'page', confidence: 'medium' });
|
|
633
|
+
if (/(^|\/)middleware\.[jt]s$/u.test(relPath))
|
|
634
|
+
facts.push({ routeKind: 'middleware', confidence: 'medium' });
|
|
635
|
+
}
|
|
636
|
+
if (framework === 'nuxt') {
|
|
637
|
+
if (/(^|\/)pages\/.*\.vue$/u.test(relPath))
|
|
638
|
+
facts.push({ routeKind: 'page', confidence: 'medium' });
|
|
639
|
+
if (/(^|\/)layouts\/.*\.vue$/u.test(relPath))
|
|
640
|
+
facts.push({ routeKind: 'layout', confidence: 'medium' });
|
|
641
|
+
if (/(^|\/)server\/api\/.*\.[cm]?[jt]s$/u.test(relPath))
|
|
642
|
+
facts.push({ routeKind: 'api', confidence: 'medium' });
|
|
643
|
+
if (/(^|\/)middleware\/.*\.[cm]?[jt]s$/u.test(relPath))
|
|
644
|
+
facts.push({ routeKind: 'middleware', confidence: 'medium' });
|
|
645
|
+
}
|
|
646
|
+
if (framework === 'svelte') {
|
|
647
|
+
if (/(^|\/)\+page\.svelte$/u.test(relPath))
|
|
648
|
+
facts.push({ routeKind: 'page', confidence: 'medium' });
|
|
649
|
+
if (/(^|\/)\+page\.[cm]?[jt]s$/u.test(relPath))
|
|
650
|
+
facts.push({ routeKind: 'page', confidence: 'medium' });
|
|
651
|
+
if (/(^|\/)\+layout\.svelte$/u.test(relPath))
|
|
652
|
+
facts.push({ routeKind: 'layout', confidence: 'medium' });
|
|
653
|
+
if (/(^|\/)\+layout\.[cm]?[jt]s$/u.test(relPath))
|
|
654
|
+
facts.push({ routeKind: 'layout', confidence: 'medium' });
|
|
655
|
+
if (/(^|\/)\+page\.server\.[cm]?[jt]s$/u.test(relPath))
|
|
656
|
+
facts.push({ routeKind: 'server-route', confidence: 'medium' });
|
|
657
|
+
if (/(^|\/)\+server\.[jt]s$/u.test(relPath))
|
|
658
|
+
facts.push({ routeKind: 'server-route', confidence: 'medium' });
|
|
659
|
+
}
|
|
660
|
+
if (framework === 'react') {
|
|
661
|
+
if (/(^|\/)src\/routes\/__root\.[jt]sx?$/u.test(relPath))
|
|
662
|
+
facts.push({ routeKind: 'layout', confidence: 'low' });
|
|
663
|
+
if (/(^|\/)src\/routes\/(?!__root\.)[^/]+\.[jt]sx?$/u.test(relPath))
|
|
664
|
+
facts.push({ routeKind: 'page', confidence: 'low' });
|
|
665
|
+
}
|
|
666
|
+
return facts;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function limitationsFor(file: ParsedFile): string[] {
|
|
670
|
+
const out: string[] = [];
|
|
671
|
+
if (file.imports.some((imp) => imp.pattern === 'glob')) {
|
|
672
|
+
out.push(
|
|
673
|
+
'import.meta.glob edges are expanded statically and marked low-confidence approximate',
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (file.imports.some((imp) => imp.pattern === 'prefix')) {
|
|
677
|
+
out.push('dynamic import prefix edges are approximate');
|
|
678
|
+
}
|
|
679
|
+
if (file.exports.length > 0) {
|
|
680
|
+
out.push('export facts are name-only; type/value provenance is not fully resolved yet');
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|