@archora/core 1.1.0 → 2.0.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 (52) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/README.md +2 -2
  4. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +1 -1
  5. package/src/analyzer/__tests__/analyze.test.ts +41 -0
  6. package/src/analyzer/__tests__/bundle.test.ts +99 -0
  7. package/src/analyzer/__tests__/hotZones.test.ts +128 -0
  8. package/src/analyzer/__tests__/incremental.test.ts +61 -13
  9. package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
  10. package/src/analyzer/__tests__/memoryRisk.test.ts +94 -0
  11. package/src/analyzer/__tests__/metrics.test.ts +39 -0
  12. package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
  13. package/src/analyzer/__tests__/reactParser.test.ts +22 -0
  14. package/src/analyzer/__tests__/recommendations.test.ts +67 -0
  15. package/src/analyzer/__tests__/resolve.test.ts +54 -0
  16. package/src/analyzer/__tests__/rsc.test.ts +133 -3
  17. package/src/analyzer/archDebt.ts +32 -9
  18. package/src/analyzer/buildGraph.ts +75 -3
  19. package/src/analyzer/bundle/analyzeBundle.ts +84 -1
  20. package/src/analyzer/bundle/types.ts +9 -1
  21. package/src/analyzer/hotZones.ts +94 -2
  22. package/src/analyzer/incremental.ts +28 -10
  23. package/src/analyzer/index.ts +3 -1
  24. package/src/analyzer/loadAliases.ts +4 -4
  25. package/src/analyzer/memoryRisk.ts +33 -2
  26. package/src/analyzer/metrics.ts +10 -1
  27. package/src/analyzer/parsers/svelteParser.ts +5 -0
  28. package/src/analyzer/parsers/tsParser.ts +11 -1
  29. package/src/analyzer/recommendations.ts +28 -14
  30. package/src/analyzer/resolve.ts +51 -18
  31. package/src/analyzer/rsc.ts +90 -9
  32. package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
  33. package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
  34. package/src/analyzer/sources/tauriFileSource.ts +2 -2
  35. package/src/analyzer/types.ts +22 -0
  36. package/src/cache/index.ts +18 -3
  37. package/src/diff/__tests__/diffScans.test.ts +64 -1
  38. package/src/diff/diffScans.ts +31 -1
  39. package/src/diff/types.ts +19 -1
  40. package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
  41. package/src/git/computeTemporalCoupling.ts +35 -4
  42. package/src/git/types.ts +14 -1
  43. package/src/index.ts +5 -0
  44. package/src/report/__tests__/buildDeadCodeReport.test.ts +108 -0
  45. package/src/report/buildDeadCodeReport.ts +110 -0
  46. package/src/report/buildFixPlan.ts +14 -69
  47. package/src/search/__tests__/parseQuery.test.ts +13 -13
  48. package/src/search/__tests__/search.test.ts +19 -19
  49. package/src/search/index.ts +39 -39
  50. package/src/search/parseQuery.ts +13 -13
  51. package/src/views/__tests__/analyzerViews.test.ts +6 -0
  52. package/src/views/analyzerViews.ts +1 -6
@@ -14,7 +14,7 @@ import { createParserRegistry, isParseFailure } from './parsers';
14
14
  import type { Framework } from './detect';
15
15
  import { applyAlias, type PathAlias, type Resolver } from './resolve';
16
16
  import { classifyKind, isInfra } from './classify';
17
- import { classifyModuleRuntime } from './rsc';
17
+ import { classifyModuleRuntime, isServerActionsModule } from './rsc';
18
18
  import type { ArchoraConfig } from '../config/frontScopeConfig';
19
19
 
20
20
  export interface BuildGraphInput {
@@ -108,7 +108,10 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
108
108
  relPath: p.relPath,
109
109
  framework: input.framework ?? 'unknown',
110
110
  ...(p.directives ? { directives: p.directives } : {}),
111
+ ...(p.imports.some((i) => i.specifier === 'server-only') ? { importsServerOnly: true } : {}),
112
+ ...(p.imports.some((i) => i.specifier === 'client-only') ? { importsClientOnly: true } : {}),
111
113
  }),
114
+ ...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
112
115
  }));
113
116
  const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
114
117
 
@@ -255,6 +258,33 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
255
258
  }
256
259
  }
257
260
 
261
+ // Nuxt auto-import: composables/** are exposed globally, a consumer calls
262
+ // useFoo() with no import. Resolve the call against a name -> composable file
263
+ // registry. unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
264
+ const composableRegistry = buildComposableRegistry(parsed, framework);
265
+ if (composableRegistry.size > 0) {
266
+ for (const file of parsed) {
267
+ if (!file.callIdentifiers || file.callIdentifiers.length === 0) continue;
268
+ for (const name of file.callIdentifiers) {
269
+ const target = composableRegistry.get(name);
270
+ if (!target || target === file.relPath) continue;
271
+ const key = `${file.relPath}\u0001${target}`;
272
+ if (existingEdges.has(key)) continue;
273
+ existingEdges.add(key);
274
+ edges.push({
275
+ from: file.relPath,
276
+ to: target,
277
+ kind: 'auto-import',
278
+ specifier: name,
279
+ resolved: true,
280
+ confidence: 'medium',
281
+ resolutionKind: 'framework-auto',
282
+ approximate: true,
283
+ });
284
+ }
285
+ }
286
+ }
287
+
258
288
  return { modules, edges, parserFacts, warnings };
259
289
  }
260
290
 
@@ -365,6 +395,44 @@ function buildComponentRegistry(
365
395
  return registry;
366
396
  }
367
397
 
398
+ // Nuxt auto-import: composables/**/*.{ts,js} are exposed globally. Registry name =
399
+ // named exports + file basename (for `export default`). First-wins on collision.
400
+ // Nuxt only: unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
401
+ const NUXT_COMPOSABLE_EXT = /\.[cm]?[jt]s$/u;
402
+
403
+ // True when one of `rel`'s directory segments equals `segment`. Linear scan, used
404
+ // instead of `(^|\/)<dir>\/.+` regexes: paths come from scanned, untrusted repos,
405
+ // so a `.+`-based pattern that re-anchors on every `/<dir>/` is a ReDoS risk.
406
+ function isUnderDirSegment(rel: string, segment: string): boolean {
407
+ const lastSlash = rel.lastIndexOf('/');
408
+ if (lastSlash < 0) return false;
409
+ return rel.slice(0, lastSlash).split('/').includes(segment);
410
+ }
411
+
412
+ // File under a `composables/` directory with a JS/TS extension (js/ts/mjs/mts/cjs/cts).
413
+ export function isNuxtComposablePath(rel: string): boolean {
414
+ return NUXT_COMPOSABLE_EXT.test(rel) && isUnderDirSegment(rel, 'composables');
415
+ }
416
+
417
+ function buildComposableRegistry(
418
+ parsed: ParsedFile[],
419
+ framework: Framework | undefined,
420
+ ): Map<string, string> {
421
+ const registry = new Map<string, string>();
422
+ if (framework !== 'nuxt') return registry;
423
+ for (const file of parsed) {
424
+ if (!isNuxtComposablePath(file.relPath)) continue;
425
+ const names = new Set<string>();
426
+ for (const exp of file.exports) if (exp !== 'default') names.add(exp);
427
+ const slash = file.relPath.lastIndexOf('/');
428
+ const dot = file.relPath.lastIndexOf('.');
429
+ const base = file.relPath.slice(slash + 1, dot);
430
+ if (base.length > 0) names.add(base);
431
+ for (const name of names) if (!registry.has(name)) registry.set(name, file.relPath);
432
+ }
433
+ return registry;
434
+ }
435
+
368
436
  function indexFilesByDir(files: string[]): Map<string, string[]> {
369
437
  const map = new Map<string, string[]>();
370
438
  for (const f of files) {
@@ -599,10 +667,14 @@ function frameworkFactsFromPath(
599
667
  ) {
600
668
  facts.push({ kind: 'sveltekit-route', value: relPath, confidence: 'medium' });
601
669
  }
602
- if (framework === 'nuxt' && /(^|\/)components\/.+\.vue$/u.test(relPath)) {
670
+ if (
671
+ framework === 'nuxt' &&
672
+ relPath.endsWith('.vue') &&
673
+ isUnderDirSegment(relPath, 'components')
674
+ ) {
603
675
  facts.push({ kind: 'nuxt-auto-component', value: relPath, confidence: 'medium' });
604
676
  }
605
- if (framework === 'nuxt' && /(^|\/)composables\/.+\.[cm]?[jt]s$/u.test(relPath)) {
677
+ if (framework === 'nuxt' && isNuxtComposablePath(relPath)) {
606
678
  facts.push({ kind: 'nuxt-auto-composable', value: relPath, confidence: 'medium' });
607
679
  }
608
680
  return facts;
@@ -6,7 +6,7 @@
6
6
  // are kept on the chunk for size accounting but don't surface as recs (we'd
7
7
  // flag them as "external" otherwise, which is noise on every chunk).
8
8
 
9
- import type { ModuleId, ModuleNode } from '../types';
9
+ import type { DependencyEdge, ModuleId, ModuleNode } from '../types';
10
10
  import {
11
11
  DEFAULT_BUNDLE_THRESHOLDS,
12
12
  type BundleBloat,
@@ -22,6 +22,8 @@ export interface AnalyzeBundleInput {
22
22
  modules: ModuleNode[];
23
23
  stats: ParsedBundleStats;
24
24
  thresholds?: Partial<BundleThresholds>;
25
+ /** Edge graph: needed for barrel-leak (re-export hubs). Without it the signal is skipped. */
26
+ edges?: DependencyEdge[];
25
27
  }
26
28
 
27
29
  export function analyzeBundle(input: AnalyzeBundleInput): BundleReport {
@@ -48,6 +50,18 @@ export function analyzeBundle(input: AnalyzeBundleInput): BundleReport {
48
50
 
49
51
  const totalSize = chunks.reduce((acc, c) => acc + c.size, 0);
50
52
  const bloat = detectBloat(chunks, moduleToChunks, thresholds);
53
+ if (input.edges && input.edges.length > 0) {
54
+ bloat.push(
55
+ ...detectBarrelLeaks(
56
+ input.modules,
57
+ input.edges,
58
+ moduleToChunks,
59
+ chunks,
60
+ thresholds,
61
+ bloat.length,
62
+ ),
63
+ );
64
+ }
51
65
 
52
66
  return {
53
67
  format: input.stats.format,
@@ -140,6 +154,75 @@ function detectBloat(
140
154
  return out;
141
155
  }
142
156
 
157
+ const BARREL_NAME_RE = /(^|\/)index\.[cm]?[jt]sx?$/u;
158
+
159
+ // barrel-leak: a barrel (re-export hub `index.*`) pulls a large share of its
160
+ // re-export targets into one chunk. Symptom of failed tree-shaking — importing the
161
+ // barrel drags the whole directory into the bundle. A "graph × bundle" signal that
162
+ // pure stats tools can't produce: targets come from the import graph, co-location
163
+ // from the chunks.
164
+ function detectBarrelLeaks(
165
+ modules: ModuleNode[],
166
+ edges: DependencyEdge[],
167
+ moduleToChunks: Record<ModuleId, string[]>,
168
+ chunks: BundleChunk[],
169
+ thresholds: BundleThresholds,
170
+ startSerial: number,
171
+ ): BundleBloat[] {
172
+ const out: BundleBloat[] = [];
173
+ let serial = startSerial;
174
+
175
+ const outgoing = new Map<ModuleId, Set<ModuleId>>();
176
+ for (const e of edges) {
177
+ if (e.kind === 'type-only') continue;
178
+ let bucket = outgoing.get(e.from);
179
+ if (!bucket) {
180
+ bucket = new Set();
181
+ outgoing.set(e.from, bucket);
182
+ }
183
+ bucket.add(e.to);
184
+ }
185
+
186
+ for (const b of modules) {
187
+ if (!BARREL_NAME_RE.test(b.id)) continue;
188
+ const bChunks = moduleToChunks[b.id];
189
+ if (!bChunks || bChunks.length === 0) continue;
190
+ const dir = b.id.slice(0, b.id.lastIndexOf('/') + 1);
191
+ // re-export targets in the barrel's subtree (a barrel almost always re-exports siblings)
192
+ const targets = [...(outgoing.get(b.id) ?? [])].filter((t) => t !== b.id && t.startsWith(dir));
193
+ if (targets.length < thresholds.barrelMinModules) continue;
194
+
195
+ const bChunkSet = new Set(bChunks);
196
+ const colocated = targets.filter((t) =>
197
+ (moduleToChunks[t] ?? []).some((c) => bChunkSet.has(c)),
198
+ );
199
+ const share = colocated.length / targets.length;
200
+ if (colocated.length < thresholds.barrelMinModules || share < thresholds.barrelLeakShare) {
201
+ continue;
202
+ }
203
+
204
+ const colocatedSet = new Set(colocated);
205
+ let sizeBytes = 0;
206
+ for (const c of chunks) {
207
+ if (!bChunkSet.has(c.id)) continue;
208
+ for (const m of c.modules)
209
+ if (m.moduleId && colocatedSet.has(m.moduleId)) sizeBytes += m.size;
210
+ }
211
+
212
+ out.push({
213
+ id: `bundle:barrel:${serial++}:${b.id}`,
214
+ kind: 'barrel-leak',
215
+ severity: colocated.length >= thresholds.barrelMinModules * 2 ? 'high' : 'medium',
216
+ message: `Barrel "${displayShortId(b.id)}" pulls ${colocated.length}/${targets.length} re-exported modules into the same chunk (tree-shaking leak)`,
217
+ modules: [b.id],
218
+ chunks: [...bChunkSet],
219
+ detail: { sizeBytes, moduleCount: colocated.length },
220
+ });
221
+ }
222
+
223
+ return out;
224
+ }
225
+
143
226
  function formatBytes(bytes: number): string {
144
227
  if (bytes < 1024) return `${bytes} B`;
145
228
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -29,7 +29,7 @@ export interface BundleChunkModule {
29
29
  size: number;
30
30
  }
31
31
 
32
- export type BundleBloatKind = 'duplicate' | 'heavy-chunk' | 'solo-hot';
32
+ export type BundleBloatKind = 'duplicate' | 'heavy-chunk' | 'solo-hot' | 'barrel-leak';
33
33
 
34
34
  export interface BundleBloat {
35
35
  /** Stable id for grouping/diffing. */
@@ -47,6 +47,8 @@ export interface BundleBloat {
47
47
  sizeBytes?: number;
48
48
  chunkCount?: number;
49
49
  sharePercent?: number;
50
+ /** barrel-leak: number of the barrel's re-export targets that landed in the same chunk. */
51
+ moduleCount?: number;
50
52
  };
51
53
  }
52
54
 
@@ -70,12 +72,18 @@ export interface BundleThresholds {
70
72
  duplicateMinChunks: number;
71
73
  /** Module taking >= share of a heavy chunk triggers `solo-hot` (0..1). */
72
74
  soloHotShare: number;
75
+ /** Barrel re-exporting >= N same-tree modules is a `barrel-leak` candidate. */
76
+ barrelMinModules: number;
77
+ /** Share of a barrel's re-export targets co-located in its chunk to flag (0..1). */
78
+ barrelLeakShare: number;
73
79
  }
74
80
 
75
81
  export const DEFAULT_BUNDLE_THRESHOLDS: BundleThresholds = {
76
82
  heavyChunkBytes: 500_000,
77
83
  duplicateMinChunks: 2,
78
84
  soloHotShare: 0.8,
85
+ barrelMinModules: 8,
86
+ barrelLeakShare: 0.8,
79
87
  };
80
88
 
81
89
  /** Parsed-but-not-yet-analyzed stats. Produced by the format parsers. */
@@ -1,4 +1,4 @@
1
- import type { ModuleId, ModuleMetrics, ModuleNode } from './types';
1
+ import type { ModuleId, ModuleMetrics, ModuleNode, ParsedFileSummary } from './types';
2
2
 
3
3
  export interface RankHotZonesInput {
4
4
  modules: ModuleNode[];
@@ -13,5 +13,97 @@ export function rankHotZones(input: RankHotZonesInput): ModuleId[] {
13
13
  .map((m) => ({ id: m.id, score: metrics[m.id]?.hotnessScore ?? 0 }))
14
14
  .filter((x) => x.score > 0);
15
15
  candidates.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
16
- return candidates.slice(0, topN).map((c) => c.id);
16
+ // Rank the full hotness window first, then drop re-export barrels from it.
17
+ // A barrel's high fan-out is by design, not a risk — but excluding it from the
18
+ // candidate pool before the cut would only backfill the window with lower-
19
+ // signal modules. Removing it from the top-N keeps the surfaced set focused on
20
+ // genuine hot zones without inventing new ones.
21
+ const barrels = new Set(modules.filter((m) => m.isBarrel).map((m) => m.id));
22
+ return candidates
23
+ .slice(0, topN)
24
+ .map((c) => c.id)
25
+ .filter((id) => !barrels.has(id));
26
+ }
27
+
28
+ // A barrel imports the things it re-exports, so it has a non-trivial fan-out;
29
+ // a leaf that defines its own exports has fan-out 0. Below this floor we keep
30
+ // the module even if it is thin (small multi-export utils re-export nothing).
31
+ const BARREL_MIN_FANOUT = 3;
32
+ // Barrels expose a real aggregation surface — too few pass-throughs and it is
33
+ // just a normal module with a couple of imports.
34
+ const BARREL_MIN_SURFACE = 5;
35
+ // Source lines per surface item. Barrels are thin glue (≈1–2 loc per
36
+ // re-export); real modules carry many loc per export.
37
+ const BARREL_MAX_LOC_PER_SURFACE = 4;
38
+
39
+ export interface MarkBarrelModulesInput {
40
+ modules: ModuleNode[];
41
+ metrics: Record<ModuleId, ModuleMetrics>;
42
+ parserFacts?: ParsedFileSummary[];
43
+ }
44
+
45
+ /**
46
+ * Tag re-export barrels so hot-zone ranking can skip them. Runs post-graph
47
+ * because barrel detection needs the fan-out metric plus the module's loc and
48
+ * export count. Mutates `ModuleNode.isBarrel` in place; additive, leaves every
49
+ * other field untouched.
50
+ */
51
+ export function markBarrelModules(input: MarkBarrelModulesInput): void {
52
+ const { modules, metrics, parserFacts } = input;
53
+ const factsByModule = indexParserFacts(parserFacts);
54
+ for (const m of modules) {
55
+ if (isBarrelModule(m, metrics[m.id], factsByModule)) m.isBarrel = true;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A re-export barrel (e.g. `packages/core/index.ts` re-exporting every module)
61
+ * has high fan-out by design — that is its job, not a risk, and "split this
62
+ * module" is bad advice. Detected by THINNESS rather than a re-export ratio:
63
+ * the parser almost never populates `ExportFact.source` (star re-exports, and
64
+ * named re-exports it can't resolve), so the ratio reads 0 even for obvious
65
+ * barrels. A barrel instead imports the things it passes through (fan-out),
66
+ * exposes a wide surface, and carries very few source lines per surface item —
67
+ * it is thin glue, not a module with real logic.
68
+ */
69
+ function isBarrelModule(
70
+ module: ModuleNode,
71
+ metrics: ModuleMetrics | undefined,
72
+ factsByModule: Map<string, ParsedFileSummary>,
73
+ ): boolean {
74
+ if (!metrics) return false;
75
+ const fanOut = metrics.fanOut;
76
+ if (fanOut < BARREL_MIN_FANOUT) return false;
77
+ const summary = matchParserFacts(factsByModule, module.id);
78
+ const ownExportCount = module.exports.length || (summary?.exports.length ?? 0);
79
+ const surface = Math.max(fanOut, ownExportCount);
80
+ if (surface < BARREL_MIN_SURFACE) return false;
81
+ const loc = module.loc || summary?.loc || 0;
82
+ return loc < surface * BARREL_MAX_LOC_PER_SURFACE;
83
+ }
84
+
85
+ function indexParserFacts(
86
+ parserFacts: ParsedFileSummary[] | undefined,
87
+ ): Map<string, ParsedFileSummary> {
88
+ const out = new Map<string, ParsedFileSummary>();
89
+ if (!parserFacts) return out;
90
+ for (const f of parserFacts) out.set(normalizeRel(f.relPath), f);
91
+ return out;
92
+ }
93
+
94
+ function matchParserFacts(
95
+ factsByModule: Map<string, ParsedFileSummary>,
96
+ moduleId: ModuleId,
97
+ ): ParsedFileSummary | undefined {
98
+ const id = normalizeRel(moduleId);
99
+ const direct = factsByModule.get(id);
100
+ if (direct) return direct;
101
+ for (const [rel, f] of factsByModule) {
102
+ if (id.endsWith(`/${rel}`) || rel.endsWith(`/${id}`)) return f;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function normalizeRel(p: string): string {
108
+ return p.replace(/^\.\//u, '');
17
109
  }
@@ -1,13 +1,12 @@
1
- // Инкрементальный пере-скан: на вход предыдущий ScanResult и список
2
- // изменённых относительных путей. Если все изменения это «pure modify»
3
- // существующих модулей со static-импортами, переразрешаем только их
4
- // outgoing-рёбра и пересчитываем downstream-метрики/циклы. Иначе fallback
5
- // на полный `analyze()`.
1
+ // Incremental rescan: takes the previous ScanResult and a list of changed
2
+ // relative paths. If every change is a "pure modify" of existing modules with
3
+ // static imports, we only re-resolve their outgoing edges and recompute
4
+ // downstream metrics/cycles. Otherwise we fall back to a full `analyze()`.
6
5
  //
7
- // Подход «простой вариант» из плана 15.2: оптимизация только для типичного
8
- // IDE-save сценария. Глоб/префикс-импорты, auto-import шаблонов, изменения
9
- // конфигов и добавление/удаление файлов перенаправляются в полный пайплайн
10
- // корректность важнее скорости, а эти случаи редки на сохранении одного файла.
6
+ // The "simple variant" from plan 15.2: optimize only the typical IDE-save
7
+ // scenario. Glob/prefix imports, template auto-imports, config changes and
8
+ // file add/remove are routed through the full pipeline - correctness matters
9
+ // more than speed, and these cases are rare when saving a single file.
11
10
 
12
11
  import type { FileSource } from './fileSource';
13
12
  import type {
@@ -26,7 +25,8 @@ import { loadAliases } from './loadAliases';
26
25
  import { createResolver } from './resolve';
27
26
  import { createParserRegistry, isParseFailure } from './parsers';
28
27
  import { classifyKind, isInfra } from './classify';
29
- import { classifyModuleRuntime, detectRscLeaks } from './rsc';
28
+ import { isNuxtComposablePath } from './buildGraph';
29
+ import { classifyModuleRuntime, detectRscLeaks, isServerActionsModule } from './rsc';
30
30
  import { detectCycles } from './cycles';
31
31
  import { computeMetrics } from './metrics';
32
32
  import { rankHotZones } from './hotZones';
@@ -148,6 +148,17 @@ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promis
148
148
  if (result.templateRefs && result.templateRefs.length > 0) {
149
149
  return analyze(source, options);
150
150
  }
151
+ // Nuxt composable auto-import edges are built from the full file list
152
+ // (the composables/** registry crossed with consumer call identifiers) -
153
+ // like glob/templateRefs, they can only be safely recomputed by a cold scan.
154
+ if (prev.project.detectedFramework === 'nuxt') {
155
+ if (
156
+ isNuxtComposablePath(rel) ||
157
+ (result.callIdentifiers && result.callIdentifiers.length > 0)
158
+ ) {
159
+ return analyze(source, options);
160
+ }
161
+ }
151
162
  reparsed.set(rel, result);
152
163
  }
153
164
 
@@ -173,7 +184,14 @@ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promis
173
184
  relPath: p.relPath,
174
185
  framework: prev.project.detectedFramework as Framework,
175
186
  ...(p.directives ? { directives: p.directives } : {}),
187
+ ...(p.imports.some((i) => i.specifier === 'server-only')
188
+ ? { importsServerOnly: true }
189
+ : {}),
190
+ ...(p.imports.some((i) => i.specifier === 'client-only')
191
+ ? { importsClientOnly: true }
192
+ : {}),
176
193
  }),
194
+ ...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
177
195
  });
178
196
  }
179
197
  const updatedModuleIds = new Set(updatedModules.map((m) => m.id));
@@ -14,7 +14,7 @@ import { detectFramework, type Framework } from './detect';
14
14
  import { detectCycles } from './cycles';
15
15
  import { countBrokenCycles, parseEdgeKey } from './feedbackArcSet';
16
16
  import { computeMetrics } from './metrics';
17
- import { rankHotZones } from './hotZones';
17
+ import { markBarrelModules, rankHotZones } from './hotZones';
18
18
  import { detectLayerViolations } from './layers';
19
19
  import { computeArchDebt } from './archDebt';
20
20
  import { computeRecommendations } from './recommendations';
@@ -181,6 +181,7 @@ export async function analyze(
181
181
  if (entries.includes(m.id) && m.kind === 'unknown') m.kind = 'entry';
182
182
  }
183
183
 
184
+ markBarrelModules({ modules, metrics, parserFacts });
184
185
  const hotZones = rankHotZones({ modules, metrics, topN: options.topHotZones ?? 10 });
185
186
  const layerViolations = detectLayerViolations(modules, edges, config.layerOverrides);
186
187
  const archDebt = computeArchDebt({
@@ -209,6 +210,7 @@ export async function analyze(
209
210
  const bundle = effectiveBundleStats
210
211
  ? analyzeBundle({
211
212
  modules,
213
+ edges,
212
214
  stats: effectiveBundleStats,
213
215
  ...(Object.keys(bundleThresholds).length > 0 ? { thresholds: bundleThresholds } : {}),
214
216
  })
@@ -1,7 +1,7 @@
1
- // Загрузка path-aliases из tsconfig (с цепочкой `extends`) и Vite-конфига.
2
- // Вынесено из `analyze()` для переиспользования в `incrementalAnalyze()`
3
- // при пере-сканировании одного файла нам нужен тот же резолвер, что и при
4
- // полном анализе, иначе разрешение `@/*` сломается на ходу.
1
+ // Loads path aliases from tsconfig (following the `extends` chain) and the
2
+ // Vite config. Pulled out of `analyze()` so `incrementalAnalyze()` can reuse
3
+ // it - a single-file rescan needs the same resolver as a full analysis,
4
+ // otherwise `@/*` resolution breaks midway.
5
5
 
6
6
  import type { FileSource } from './fileSource';
7
7
  import type { ProjectRef } from './types';
@@ -41,7 +41,7 @@ const PATTERNS: readonly RiskPattern[] = [
41
41
  kind: 'timer-cleanup',
42
42
  acquire: 'setTimeout',
43
43
  cleanup: 'clearTimeout',
44
- testAcquire: isNamedCall('setTimeout'),
44
+ testAcquire: isCapturedTimer('setTimeout'),
45
45
  testCleanup: isNamedCall('clearTimeout'),
46
46
  },
47
47
  {
@@ -81,7 +81,9 @@ export async function detectMemoryRisks(
81
81
  module.id,
82
82
  script,
83
83
  ts.ScriptTarget.Latest,
84
- false,
84
+ // Parent links are needed to tell a captured `setTimeout` handle from a
85
+ // discarded fire-and-forget call (see isCapturedResult).
86
+ true,
85
87
  scriptKindFor(module.id),
86
88
  );
87
89
  const context: RiskContext = {
@@ -255,6 +257,35 @@ function isNamedCall(name: string): (node: ts.Node) => boolean {
255
257
  return (node) => ts.isCallExpression(node) && callName(node.expression) === name;
256
258
  }
257
259
 
260
+ // A bare `setTimeout(fn, n)` expression statement is fire-and-forget and needs no
261
+ // cleanup. Flag only when the timer id is captured (assigned, stored on a property,
262
+ // returned, or passed on), since a kept handle signals the author intended it to be
263
+ // cancellable and leaving it uncleared is the suspicious case.
264
+ function isCapturedTimer(name: string): (node: ts.Node) => boolean {
265
+ const matchesCall = isNamedCall(name);
266
+ return (node) => matchesCall(node) && isCapturedResult(node);
267
+ }
268
+
269
+ function isCapturedResult(node: ts.Node): boolean {
270
+ const parent = node.parent;
271
+ if (!parent) return false;
272
+ if (ts.isVariableDeclaration(parent)) return parent.initializer === node;
273
+ if (ts.isPropertyDeclaration(parent)) return parent.initializer === node;
274
+ if (ts.isPropertyAssignment(parent)) return parent.initializer === node;
275
+ if (ts.isBinaryExpression(parent)) {
276
+ return parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === node;
277
+ }
278
+ if (ts.isReturnStatement(parent)) return parent.expression === node;
279
+ if (ts.isCallExpression(parent) || ts.isNewExpression(parent)) {
280
+ return parent.arguments?.some((arg) => arg === node) ?? false;
281
+ }
282
+ // Parenthesized / as-expression wrappers keep the handle reachable.
283
+ if (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent)) {
284
+ return isCapturedResult(parent);
285
+ }
286
+ return false;
287
+ }
288
+
258
289
  function isIdentifierCall(node: ts.CallExpression, name: string): boolean {
259
290
  return ts.isIdentifier(node.expression) && node.expression.text === name;
260
291
  }
@@ -1,5 +1,11 @@
1
1
  import type { Cycle, DependencyEdge, ModuleId, ModuleMetrics, ModuleNode } from './types';
2
2
 
3
+ // Hotness premium for cycle membership: a module in an SCC is riskier than an
4
+ // equally-coupled module outside one (changes ripple both ways). 1.5 = a modest
5
+ // +50%: cycle membership lifts the score but does not override coupling itself.
6
+ // Heuristic default, justified ordinally (cycle > non-cycle), not data-calibrated.
7
+ const CYCLE_HOTNESS_MULTIPLIER = 1.5;
8
+
3
9
  export interface ComputeMetricsInput {
4
10
  modules: ModuleNode[];
5
11
  edges: DependencyEdge[];
@@ -56,8 +62,11 @@ export function computeMetrics(input: ComputeMetricsInput): Record<ModuleId, Mod
56
62
  const instability = sum === 0 ? 0 : fo / sum;
57
63
  const cycle = inCycle.has(id);
58
64
  const coupling = maxCoupling === 0 ? 0 : (rawCoupling.get(id) ?? 0) / maxCoupling;
65
+ // sizeFactor ∈ [1, 2]: size, log2-normalized against the largest file. log
66
+ // (not linear) so one giant file doesn't dominate; the upper bound of 2 caps
67
+ // the size contribution at a doubling.
59
68
  const sizeFactor = maxLoc === 0 ? 1 : 1 + Math.log2(1 + m.loc) / Math.log2(1 + maxLoc);
60
- const hotness = coupling * (cycle ? 1.5 : 1) * sizeFactor;
69
+ const hotness = coupling * (cycle ? CYCLE_HOTNESS_MULTIPLIER : 1) * sizeFactor;
61
70
  result[id] = {
62
71
  fanIn: fi,
63
72
  fanOut: fo,
@@ -1,3 +1,8 @@
1
+ // Beta: extracts script blocks with a regex, without svelte/compiler. Picks up
2
+ // imports (static and dynamic) from `<script>` and `<script context="module">`;
3
+ // the template and `$:` are not parsed (Svelte resolves components via script
4
+ // imports anyway). First-class targets are Vue/React/Nuxt/Next; Svelte is
5
+ // deliberately left short of full AST parsing.
1
6
  import type { ParsedFile } from '../types';
2
7
  import type { TsParser } from './tsParser';
3
8
 
@@ -39,6 +39,7 @@ export function createTsParser(options: TsParserOptions = {}): TsParser {
39
39
 
40
40
  const imports: RawImport[] = [];
41
41
  const exports = new Set<string>();
42
+ const callees = new Set<string>();
42
43
  let hasDefineStore = false;
43
44
 
44
45
  visit(
@@ -46,6 +47,7 @@ export function createTsParser(options: TsParserOptions = {}): TsParser {
46
47
  sf,
47
48
  imports,
48
49
  exports,
50
+ callees,
49
51
  () => {
50
52
  hasDefineStore = true;
51
53
  },
@@ -62,6 +64,7 @@ export function createTsParser(options: TsParserOptions = {}): TsParser {
62
64
  exports: [...exports].sort(),
63
65
  hasDefineStore: hasDefineStore || /\bdefineStore\s*\(/.test(input.content),
64
66
  ...(directives.length > 0 ? { directives } : {}),
67
+ ...(callees.size > 0 ? { callIdentifiers: [...callees].sort() } : {}),
65
68
  };
66
69
  },
67
70
  };
@@ -78,9 +81,14 @@ function visit(
78
81
  sf: ts.SourceFile,
79
82
  imports: RawImport[],
80
83
  exports: Set<string>,
84
+ callees: Set<string>,
81
85
  markDefineStore: () => void,
82
86
  loaders: DynamicLoaderConfig[],
83
87
  ): void {
88
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
89
+ callees.add(node.expression.text);
90
+ }
91
+
84
92
  if (ts.isImportDeclaration(node)) {
85
93
  const spec = literalText(node.moduleSpecifier);
86
94
  if (spec !== null) {
@@ -208,7 +216,9 @@ function visit(
208
216
  exports.add('default');
209
217
  }
210
218
 
211
- ts.forEachChild(node, (child) => visit(child, sf, imports, exports, markDefineStore, loaders));
219
+ ts.forEachChild(node, (child) =>
220
+ visit(child, sf, imports, exports, callees, markDefineStore, loaders),
221
+ );
212
222
  }
213
223
 
214
224
  function literalText(node: ts.Node): string | null {