@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.
Files changed (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. package/src/views/analyzerViews.ts +1294 -0
@@ -0,0 +1,455 @@
1
+ // Инкрементальный пере-скан: на вход — предыдущий ScanResult и список
2
+ // изменённых относительных путей. Если все изменения — это «pure modify»
3
+ // существующих модулей со static-импортами, переразрешаем только их
4
+ // outgoing-рёбра и пересчитываем downstream-метрики/циклы. Иначе fallback
5
+ // на полный `analyze()`.
6
+ //
7
+ // Подход «простой вариант» из плана 15.2: оптимизация только для типичного
8
+ // IDE-save сценария. Глоб/префикс-импорты, auto-import шаблонов, изменения
9
+ // конфигов и добавление/удаление файлов перенаправляются в полный пайплайн —
10
+ // корректность важнее скорости, а эти случаи редки на сохранении одного файла.
11
+
12
+ import type { FileSource } from './fileSource';
13
+ import type {
14
+ AnalyzerWarning,
15
+ DependencyEdge,
16
+ FactConfidence,
17
+ ImportResolutionKind,
18
+ ModuleNode,
19
+ ParsedFile,
20
+ ParsedFileSummary,
21
+ RawImport,
22
+ ScanResult,
23
+ } from './types';
24
+ import { analyze, type AnalyzeOptions } from './index';
25
+ import { loadAliases } from './loadAliases';
26
+ import { createResolver } from './resolve';
27
+ import { createParserRegistry, isParseFailure } from './parsers';
28
+ import { classifyKind, isInfra } from './classify';
29
+ import { classifyModuleRuntime, detectRscLeaks } from './rsc';
30
+ import { detectCycles } from './cycles';
31
+ import { computeMetrics } from './metrics';
32
+ import { rankHotZones } from './hotZones';
33
+ import { detectLayerViolations } from './layers';
34
+ import { computeArchDebt } from './archDebt';
35
+ import { computeRecommendations } from './recommendations';
36
+ import { applySignalSuppressions, buildArchitectureSignals } from './signals';
37
+ import { discoverEntryPoints } from './entryPoints';
38
+ import { checkContracts } from './contracts';
39
+ import { loadArchoraConfig } from '../config/frontScopeConfig';
40
+ import type { Framework } from './detect';
41
+
42
+ export interface IncrementalAnalyzeInput {
43
+ prev: ScanResult;
44
+ source: FileSource;
45
+ /** Relative paths reported by the watcher (already debounced & deduped). */
46
+ changedFiles: string[];
47
+ /**
48
+ * Files that exist now but were not in the previous scan. Currently any
49
+ * non-empty value forces a full rescan: re-resolving prior `resolve-failed`
50
+ * warnings against the new module ids is not (yet) implemented and would
51
+ * otherwise leak stale warnings. Optional - watchers built on
52
+ * `incrementalAnalyze` directly without persistent cache pass undefined.
53
+ */
54
+ addedFiles?: string[];
55
+ /**
56
+ * Files that were in the previous scan but are missing now. Handled on the
57
+ * fast path: matching modules and incident edges are dropped and downstream
58
+ * metrics are recomputed. Resolution warnings keyed to the removed file are
59
+ * stripped. Edges that previously pointed *at* a removed module become
60
+ * `resolve-failed` warnings against their `from` files - matching what a
61
+ * cold scan would emit.
62
+ */
63
+ removedFiles?: string[];
64
+ options?: AnalyzeOptions;
65
+ }
66
+
67
+ const CONFIG_PATTERNS: readonly RegExp[] = [
68
+ /^tsconfig.*\.json$/u,
69
+ /^vite\.config\.(?:ts|js|mts|mjs|cts|cjs)$/u,
70
+ /^\.archora\.json$/u,
71
+ /^package\.json$/u,
72
+ /^nuxt\.config\.(?:ts|js|mts|mjs)$/u,
73
+ /^svelte\.config\.(?:ts|js|mts|mjs)$/u,
74
+ ];
75
+
76
+ function isConfigFile(rel: string): boolean {
77
+ return CONFIG_PATTERNS.some((re) => re.test(rel));
78
+ }
79
+
80
+ /**
81
+ * Re-run the analysis using the previous scan as a baseline. The function may
82
+ * either fast-path (re-parsing only the changed modules and reusing existing
83
+ * graph data) or fall back to a full {@link analyze} call when fast-path
84
+ * preconditions don't hold. Either way, the returned `ScanResult` has the same
85
+ * structure and semantics as a full scan.
86
+ */
87
+ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promise<ScanResult> {
88
+ const { prev, source, changedFiles, options } = input;
89
+ const addedFiles = input.addedFiles ?? [];
90
+ const removedFiles = input.removedFiles ?? [];
91
+ const start = Date.now();
92
+
93
+ if (changedFiles.length === 0 && addedFiles.length === 0 && removedFiles.length === 0) {
94
+ return prev;
95
+ }
96
+
97
+ // Adds force a full rescan: previously `resolve-failed` imports could now
98
+ // resolve to the new files, and we don't track which warnings to retract.
99
+ if (addedFiles.length > 0) return analyze(source, options);
100
+
101
+ const anyConfig = [...changedFiles, ...removedFiles].some(isConfigFile);
102
+ if (anyConfig) return analyze(source, options);
103
+
104
+ const moduleIds = new Set(prev.modules.map((m) => m.id));
105
+ const allChangedKnown = changedFiles.every((f) => moduleIds.has(f));
106
+ const allRemovedKnown = removedFiles.every((f) => moduleIds.has(f));
107
+ if (!allChangedKnown || !allRemovedKnown) return analyze(source, options);
108
+
109
+ // Verify each changed file still exists (a delete masquerading as a modify
110
+ // from a stale watcher batch would otherwise produce a stale module entry).
111
+ for (const rel of changedFiles) {
112
+ if (!(await source.exists(rel))) return analyze(source, options);
113
+ }
114
+
115
+ const config = await loadArchoraConfig(source);
116
+ const aliases = await loadAliases(source, prev.project);
117
+ const resolver = createResolver(source, { aliases });
118
+ const registry = createParserRegistry({
119
+ ...(config.dynamicLoaders ? { dynamicLoaders: config.dynamicLoaders } : {}),
120
+ ...(prev.project.detectedFramework !== 'unknown'
121
+ ? { framework: prev.project.detectedFramework as Framework }
122
+ : {}),
123
+ });
124
+
125
+ // Re-parse changed files. Any glob/prefix/template-auto-import involvement
126
+ // pushes us off the fast-path: those edge kinds are rebuilt inside
127
+ // buildGraph from the full file list and are not safe to recompute per-file.
128
+ const reparsed = new Map<string, ParsedFile>();
129
+ const newWarnings: AnalyzerWarning[] = [];
130
+ for (const rel of changedFiles) {
131
+ let content: string;
132
+ try {
133
+ content = await source.read(rel);
134
+ } catch (err) {
135
+ newWarnings.push({
136
+ code: 'parse-failed',
137
+ message: 'Read failed during incremental rescan',
138
+ file: rel,
139
+ detail: err instanceof Error ? err.message : String(err),
140
+ });
141
+ return analyze(source, options);
142
+ }
143
+ const result = registry.parse({ relPath: rel, content });
144
+ if (isParseFailure(result)) return analyze(source, options);
145
+ if (result.imports.some((i) => i.pattern === 'glob' || i.pattern === 'prefix')) {
146
+ return analyze(source, options);
147
+ }
148
+ if (result.templateRefs && result.templateRefs.length > 0) {
149
+ return analyze(source, options);
150
+ }
151
+ reparsed.set(rel, result);
152
+ }
153
+
154
+ // Build new modules array: drop removed entries, replace changed ones.
155
+ const removedSet = new Set(removedFiles);
156
+ const updatedModules: ModuleNode[] = [];
157
+ for (const m of prev.modules) {
158
+ if (removedSet.has(m.id)) continue;
159
+ const p = reparsed.get(m.id);
160
+ if (!p) {
161
+ updatedModules.push(m);
162
+ continue;
163
+ }
164
+ updatedModules.push({
165
+ id: p.relPath,
166
+ absPath: p.relPath,
167
+ kind: classifyKind(p, p.relPath),
168
+ language: p.language,
169
+ loc: p.loc,
170
+ exports: p.exports,
171
+ isInfra: isInfra(p.relPath),
172
+ runtime: classifyModuleRuntime({
173
+ relPath: p.relPath,
174
+ framework: prev.project.detectedFramework as Framework,
175
+ ...(p.directives ? { directives: p.directives } : {}),
176
+ }),
177
+ });
178
+ }
179
+ const updatedModuleIds = new Set(updatedModules.map((m) => m.id));
180
+
181
+ // Replace outgoing edges for changed modules and drop incident edges of
182
+ // removed ones. Edges that previously pointed *at* a removed module become
183
+ // unresolved imports - record them as `resolve-failed` warnings on the
184
+ // surviving `from` file (matches what a cold scan would emit).
185
+ const changedSet = new Set(changedFiles);
186
+ const preservedEdges: DependencyEdge[] = [];
187
+ for (const e of prev.edges) {
188
+ if (changedSet.has(e.from)) continue;
189
+ if (removedSet.has(e.from)) continue;
190
+ if (removedSet.has(e.to)) {
191
+ newWarnings.push({
192
+ code: 'resolve-failed',
193
+ message: `Cannot resolve "${e.specifier}"`,
194
+ file: e.from,
195
+ detail: `previously resolved to "${e.to}" which was deleted`,
196
+ });
197
+ continue;
198
+ }
199
+ preservedEdges.push(e);
200
+ }
201
+
202
+ const newEdges: DependencyEdge[] = [];
203
+ for (const [rel, parsed] of reparsed) {
204
+ for (const imp of parsed.imports) {
205
+ // we already bailed on glob/prefix above
206
+ if (imp.pattern === 'glob' || imp.pattern === 'prefix') continue;
207
+ if (isAssetSpecifier(imp.specifier)) continue;
208
+ if (isExternal(imp.specifier)) continue;
209
+ const resolved = await resolver.resolve(imp.specifier, rel);
210
+ if (resolved && updatedModuleIds.has(resolved)) {
211
+ if (resolved === rel) {
212
+ newWarnings.push({
213
+ code: 'self-import',
214
+ message: 'Module imports itself',
215
+ file: rel,
216
+ });
217
+ }
218
+ newEdges.push({
219
+ from: rel,
220
+ to: resolved,
221
+ kind: imp.kind,
222
+ specifier: imp.specifier,
223
+ resolved: true,
224
+ ...edgeMeta(imp, imp.kind === 'dynamic' ? 'dynamic-literal' : 'literal', 'high', false),
225
+ });
226
+ } else {
227
+ newWarnings.push({
228
+ code: 'resolve-failed',
229
+ message: `Cannot resolve "${imp.specifier}"`,
230
+ file: rel,
231
+ });
232
+ }
233
+ }
234
+ }
235
+
236
+ const allEdges = preservedEdges.concat(newEdges);
237
+
238
+ // Downstream pure recomputation. These are O(V+E) and dominated by graph
239
+ // size, not file IO, so they're cheap to re-run wholesale.
240
+ const cycles = detectCycles(updatedModules, allEdges);
241
+ const entries = await discoverEntryPoints({
242
+ source,
243
+ moduleIds: updatedModules.map((m) => m.id),
244
+ framework: prev.project.detectedFramework as Framework,
245
+ ...(config.entryPoints ? { configEntryPoints: config.entryPoints } : {}),
246
+ });
247
+ const metrics = computeMetrics({ modules: updatedModules, edges: allEdges, cycles, entries });
248
+ for (const m of updatedModules) {
249
+ if (entries.includes(m.id) && m.kind === 'unknown') m.kind = 'entry';
250
+ }
251
+ const hotZones = rankHotZones({
252
+ modules: updatedModules,
253
+ metrics,
254
+ topN: options?.topHotZones ?? 10,
255
+ });
256
+ const layerViolations = detectLayerViolations(updatedModules, allEdges, config.layerOverrides);
257
+ const archDebt = computeArchDebt({
258
+ modules: updatedModules,
259
+ cycles,
260
+ layerViolations,
261
+ metrics,
262
+ hotZoneCount: hotZones.length,
263
+ });
264
+ const contractViolations = checkContracts({
265
+ modules: updatedModules,
266
+ edges: allEdges,
267
+ metrics,
268
+ cycles,
269
+ ...(config.contracts ? { contracts: config.contracts } : {}),
270
+ });
271
+
272
+ const rscLeaks = detectRscLeaks({ modules: updatedModules, edges: allEdges });
273
+ if (rscLeaks.length > 0) contractViolations.unshift(...rscLeaks);
274
+
275
+ const recommendations = computeRecommendations({
276
+ modules: updatedModules,
277
+ edges: allEdges,
278
+ metrics,
279
+ cycles,
280
+ layerViolations,
281
+ hotZones,
282
+ entries,
283
+ typeOnlyCandidates: [],
284
+ contractViolations,
285
+ });
286
+ const parserFacts = mergeParserFacts(prev.parserFacts ?? [], reparsed);
287
+
288
+ // Carry over original analyzer warnings, drop any keyed to the changed or
289
+ // removed files (they're being re-emitted, or no longer relevant), and
290
+ // append our own.
291
+ const carriedWarnings = prev.warnings.filter(
292
+ (w) => !w.file || (!changedSet.has(w.file) && !removedSet.has(w.file)),
293
+ );
294
+ const warnings = [...carriedWarnings, ...newWarnings];
295
+ const builtSignals = buildArchitectureSignals({
296
+ recommendations,
297
+ warnings,
298
+ parserFacts,
299
+ ...(config.signals?.insightLimit !== undefined
300
+ ? { insightLimit: config.signals.insightLimit }
301
+ : {}),
302
+ ...(config.signals?.minInsightSeverity
303
+ ? { minInsightSeverity: config.signals.minInsightSeverity }
304
+ : {}),
305
+ ...(config.signals?.minInsightConfidence
306
+ ? { minInsightConfidence: config.signals.minInsightConfidence }
307
+ : {}),
308
+ });
309
+ const signals =
310
+ config.signals?.suppressions && config.signals.suppressions.length > 0
311
+ ? applySignalSuppressions(builtSignals.signals, config.signals.suppressions).signals
312
+ : builtSignals.signals;
313
+ const { insights } = builtSignals;
314
+
315
+ return {
316
+ project: prev.project,
317
+ modules: updatedModules,
318
+ edges: allEdges,
319
+ cycles,
320
+ metrics,
321
+ hotZones,
322
+ layerViolations,
323
+ archDebt,
324
+ recommendations,
325
+ signals,
326
+ insights,
327
+ parserFacts,
328
+ contractViolations,
329
+ scannedAt: new Date().toISOString(),
330
+ durationMs: Date.now() - start,
331
+ warnings,
332
+ };
333
+ }
334
+
335
+ function edgeMeta(
336
+ imp: RawImport,
337
+ resolutionKind: ImportResolutionKind,
338
+ confidence: FactConfidence,
339
+ approximate: boolean,
340
+ ): Pick<DependencyEdge, 'confidence' | 'resolutionKind' | 'approximate'> {
341
+ return {
342
+ confidence: imp.confidence ?? confidence,
343
+ resolutionKind: imp.resolutionKind ?? resolutionKind,
344
+ approximate: imp.approximate ?? approximate,
345
+ };
346
+ }
347
+
348
+ function mergeParserFacts(
349
+ prevFacts: readonly ParsedFileSummary[],
350
+ reparsed: ReadonlyMap<string, ParsedFile>,
351
+ ): ParsedFileSummary[] {
352
+ const changed = new Set(reparsed.keys());
353
+ const next = prevFacts.filter((f) => !changed.has(f.relPath));
354
+ for (const parsed of reparsed.values()) next.push(toParsedFileSummary(parsed));
355
+ return next.sort((a, b) => a.relPath.localeCompare(b.relPath));
356
+ }
357
+
358
+ function toParsedFileSummary(file: ParsedFile): ParsedFileSummary {
359
+ return {
360
+ relPath: file.relPath,
361
+ language: file.language,
362
+ loc: file.loc,
363
+ imports: file.imports.map((imp) => ({
364
+ specifier: imp.specifier,
365
+ kind: imp.kind,
366
+ resolutionKind:
367
+ imp.resolutionKind ??
368
+ (isAssetSpecifier(imp.specifier)
369
+ ? 'asset'
370
+ : imp.kind === 'dynamic'
371
+ ? 'dynamic-literal'
372
+ : 'literal'),
373
+ confidence: imp.confidence ?? 'high',
374
+ approximate: imp.approximate ?? false,
375
+ ...(isAssetSpecifier(imp.specifier) ? { isAsset: true } : {}),
376
+ })),
377
+ exports: file.exports.map((name) => ({
378
+ name,
379
+ kind: name === 'default' ? 'default' : 'named',
380
+ confidence: 'medium',
381
+ })),
382
+ runtimeFacts: [
383
+ {
384
+ runtime: file.directives?.includes('use server')
385
+ ? 'server'
386
+ : file.directives?.includes('use client')
387
+ ? 'client'
388
+ : 'shared',
389
+ source: file.directives && file.directives.length > 0 ? 'directive' : 'default',
390
+ confidence: file.directives && file.directives.length > 0 ? 'high' : 'low',
391
+ },
392
+ ],
393
+ frameworkFacts: (file.templateRefs ?? []).map((value) => ({
394
+ kind: 'vue-template-ref',
395
+ value,
396
+ confidence: 'medium',
397
+ })),
398
+ routeFacts: [],
399
+ stateFacts: file.hasDefineStore ? [{ kind: 'pinia-store', confidence: 'high' }] : [],
400
+ assetFacts: file.imports
401
+ .filter((imp) => isAssetSpecifier(imp.specifier))
402
+ .map((imp) => ({
403
+ specifier: imp.specifier,
404
+ assetKind: assetKindOf(imp.specifier),
405
+ confidence: 'high',
406
+ })),
407
+ limitations: file.exports.length > 0 ? ['export facts are name-only'] : [],
408
+ };
409
+ }
410
+
411
+ function isExternal(spec: string): boolean {
412
+ if (spec.startsWith('.') || spec.startsWith('/')) return false;
413
+ if (spec.startsWith('@/') || spec.startsWith('~/')) return false;
414
+ return /^[a-z@]/u.test(spec) && !spec.includes('://');
415
+ }
416
+
417
+ const ASSET_EXTS = new Set([
418
+ '.css',
419
+ '.scss',
420
+ '.sass',
421
+ '.less',
422
+ '.module.css',
423
+ '.module.scss',
424
+ '.json',
425
+ '.svg',
426
+ '.png',
427
+ '.jpg',
428
+ '.jpeg',
429
+ '.gif',
430
+ '.webp',
431
+ '.avif',
432
+ '.ico',
433
+ '.woff',
434
+ '.woff2',
435
+ '.ttf',
436
+ '.otf',
437
+ ]);
438
+
439
+ function isAssetSpecifier(specifier: string): boolean {
440
+ const clean = specifier.split('?')[0] ?? specifier;
441
+ const lower = clean.toLowerCase();
442
+ for (const ext of ASSET_EXTS) {
443
+ if (lower.endsWith(ext)) return true;
444
+ }
445
+ return false;
446
+ }
447
+
448
+ function assetKindOf(specifier: string): 'style' | 'json' | 'image' | 'font' | 'other' {
449
+ const clean = (specifier.split('?')[0] ?? specifier).toLowerCase();
450
+ if (/\.(css|scss|sass|less)$/u.test(clean)) return 'style';
451
+ if (clean.endsWith('.json')) return 'json';
452
+ if (/\.(svg|png|jpg|jpeg|gif|webp|avif|ico)$/u.test(clean)) return 'image';
453
+ if (/\.(woff2?|ttf|otf)$/u.test(clean)) return 'font';
454
+ return 'other';
455
+ }