@archora/core 1.1.0 → 1.3.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 (35) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/analyzer/__tests__/analyze.test.ts +41 -0
  4. package/src/analyzer/__tests__/bundle.test.ts +99 -0
  5. package/src/analyzer/__tests__/incremental.test.ts +61 -13
  6. package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
  7. package/src/analyzer/__tests__/metrics.test.ts +39 -0
  8. package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
  9. package/src/analyzer/__tests__/reactParser.test.ts +22 -0
  10. package/src/analyzer/__tests__/resolve.test.ts +27 -0
  11. package/src/analyzer/__tests__/rsc.test.ts +71 -0
  12. package/src/analyzer/archDebt.ts +32 -9
  13. package/src/analyzer/buildGraph.ts +73 -2
  14. package/src/analyzer/bundle/analyzeBundle.ts +84 -1
  15. package/src/analyzer/bundle/types.ts +9 -1
  16. package/src/analyzer/incremental.ts +26 -9
  17. package/src/analyzer/index.ts +1 -0
  18. package/src/analyzer/loadAliases.ts +4 -4
  19. package/src/analyzer/metrics.ts +10 -1
  20. package/src/analyzer/parsers/svelteParser.ts +5 -0
  21. package/src/analyzer/parsers/tsParser.ts +11 -1
  22. package/src/analyzer/recommendations.ts +13 -3
  23. package/src/analyzer/resolve.ts +22 -14
  24. package/src/analyzer/rsc.ts +73 -9
  25. package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
  26. package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
  27. package/src/analyzer/sources/tauriFileSource.ts +2 -2
  28. package/src/analyzer/types.ts +5 -0
  29. package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
  30. package/src/git/computeTemporalCoupling.ts +30 -3
  31. package/src/git/types.ts +14 -1
  32. package/src/search/__tests__/parseQuery.test.ts +13 -13
  33. package/src/search/__tests__/search.test.ts +19 -19
  34. package/src/search/index.ts +39 -39
  35. package/src/search/parseQuery.ts +13 -13
@@ -43,6 +43,26 @@ describe('classifyModuleRuntime', () => {
43
43
  ).toBe('server');
44
44
  });
45
45
 
46
+ it("importing the 'server-only' package marks the module server (any framework)", () => {
47
+ expect(
48
+ classifyModuleRuntime({
49
+ relPath: 'lib/secret.ts',
50
+ framework: 'unknown',
51
+ importsServerOnly: true,
52
+ }),
53
+ ).toBe('server');
54
+ });
55
+
56
+ it("importing the 'client-only' package marks the module client", () => {
57
+ expect(
58
+ classifyModuleRuntime({
59
+ relPath: 'lib/browser.ts',
60
+ framework: 'next',
61
+ importsClientOnly: true,
62
+ }),
63
+ ).toBe('client');
64
+ });
65
+
46
66
  it('Next App Router defaults to server, pages/api server-only', () => {
47
67
  expect(classifyModuleRuntime({ relPath: 'app/page.tsx', framework: 'next' })).toBe('server');
48
68
  expect(classifyModuleRuntime({ relPath: 'pages/api/hello.ts', framework: 'next' })).toBe(
@@ -98,6 +118,57 @@ describe('detectRscLeaks', () => {
98
118
  expect(leaks[0]?.edge?.to).toBe('lib/db.ts');
99
119
  });
100
120
 
121
+ it("flags a client component importing a module poisoned with 'server-only'", async () => {
122
+ const fs = createInMemoryFileSource('/repo', {
123
+ 'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
124
+ 'app/Form.tsx':
125
+ "'use client';\nimport { getSecret } from '../lib/secret';\nexport default function F(){ return getSecret(); }\n",
126
+ 'lib/secret.ts': "import 'server-only';\nexport const getSecret = () => 1;\n",
127
+ });
128
+ const scan = await analyze(fs);
129
+ const leaks = scan.contractViolations.filter((v) => v.kind === 'rsc-leak');
130
+ expect(
131
+ leaks.some((l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/secret.ts'),
132
+ ).toBe(true);
133
+ });
134
+
135
+ it('flags a transitive leak: client -> shared barrel -> server', () => {
136
+ const modules = [
137
+ mod('app/Form.tsx', 'client'),
138
+ mod('lib/index.ts', 'shared'), // barrel
139
+ mod('lib/db.ts', 'server'),
140
+ ];
141
+ const edges = [
142
+ edge('app/Form.tsx', 'lib/index.ts'), // client -> shared (legal alone)
143
+ edge('lib/index.ts', 'lib/db.ts'), // shared re-exports server
144
+ ];
145
+ const leaks = detectRscLeaks({ modules, edges });
146
+ const transitive = leaks.find(
147
+ (l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts',
148
+ );
149
+ expect(transitive).toBeDefined();
150
+ expect(transitive?.kind).toBe('rsc-leak');
151
+ });
152
+
153
+ it('does not flag a client -> shared chain that never reaches a server module', () => {
154
+ const modules = [
155
+ mod('app/Form.tsx', 'client'),
156
+ mod('lib/index.ts', 'shared'),
157
+ mod('lib/util.ts', 'shared'),
158
+ ];
159
+ const edges = [edge('app/Form.tsx', 'lib/index.ts'), edge('lib/index.ts', 'lib/util.ts')];
160
+ expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
161
+ });
162
+
163
+ it('does not double-report when a client directly imports a server module', () => {
164
+ const modules = [mod('app/Form.tsx', 'client'), mod('lib/db.ts', 'server')];
165
+ const edges = [edge('app/Form.tsx', 'lib/db.ts')];
166
+ const leaks = detectRscLeaks({ modules, edges }).filter(
167
+ (l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts',
168
+ );
169
+ expect(leaks).toHaveLength(1);
170
+ });
171
+
101
172
  it('does not flag type-only edges or shared modules', () => {
102
173
  const modules = [mod('a.ts', 'client'), mod('b.ts', 'server'), mod('shared.ts', 'shared')];
103
174
  const edges = [
@@ -8,20 +8,39 @@ interface Inputs {
8
8
  hotZoneCount: number;
9
9
  }
10
10
 
11
- // composite arch-debt 0..100 (higher = worse).
12
- // weights: cycles 35 / layers 30 / coupling 20 / hot zones 15. each saturates.
11
+ // Composite **heuristic summary** arch-debt 0..100 (higher = worse). Not a
12
+ // measurement but a weighted index of four proven components. Weights are ordered
13
+ // by structural severity and cost-to-fix (not data-calibrated — that needs a
14
+ // labeled corpus, see block A of the plan):
15
+ // - cycles 0.35 — heaviest and most expensive to fix, breaks isolation.
16
+ // - layers 0.30 — architectural erosion, but more local than a cycle.
17
+ // - coupling 0.20 — a continuous soft signal (instability), not a bug per se.
18
+ // - hotZones 0.15 — derived signal, partly overlaps coupling → lowest.
19
+ // Sum = 1.0. Each component saturates, normalized to project size. Surfaced in
20
+ // UI/reports as a "grade summary", not as a precise metric.
21
+ const DEBT_WEIGHTS = { cycles: 0.35, layers: 0.3, coupling: 0.2, hotZones: 0.15 } as const;
22
+
23
+ // Saturation divisors: the share of project modules at which a component nears
24
+ // saturation. cycles: ~5% of modules in cycles is already "bad"; layers stricter (~3%).
25
+ const CYCLE_SATURATION_RATIO = 0.05;
26
+ const LAYER_SATURATION_RATIO = 0.03;
27
+
28
+ // Letter-grade thresholds (score → grade). Ordinal cut-offs of the heuristic
29
+ // summary: <15 A (healthy) … ≥70 F (systemic debt).
30
+ const GRADE_THRESHOLDS = { A: 15, B: 30, C: 50, D: 70 } as const;
31
+
13
32
  export function computeArchDebt(inputs: Inputs): ArchDebt {
14
33
  const realModules = inputs.modules.filter((m) => !m.isInfra);
15
34
  const moduleCount = Math.max(1, realModules.length);
16
35
 
17
36
  const cycleWeight = inputs.cycles.reduce((acc, c) => acc + (c.severity === 'direct' ? 2 : 1), 0);
18
- const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount * 0.05));
37
+ const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount * CYCLE_SATURATION_RATIO));
19
38
 
20
39
  const violationWeight = inputs.layerViolations.reduce(
21
40
  (acc, v) => acc + (v.severity === 'error' ? 3 : 1),
22
41
  0,
23
42
  );
24
- const layerScore = saturate(violationWeight / Math.max(1, moduleCount * 0.03));
43
+ const layerScore = saturate(violationWeight / Math.max(1, moduleCount * LAYER_SATURATION_RATIO));
25
44
 
26
45
  const hotScore = saturate((inputs.hotZoneCount * 10) / moduleCount);
27
46
 
@@ -36,7 +55,11 @@ export function computeArchDebt(inputs: Inputs): ArchDebt {
36
55
  const couplingScore = instCount > 0 ? instSum / instCount : 0;
37
56
 
38
57
  const score = clamp(
39
- 100 * (cycleScore * 0.35 + layerScore * 0.3 + hotScore * 0.15 + couplingScore * 0.2),
58
+ 100 *
59
+ (cycleScore * DEBT_WEIGHTS.cycles +
60
+ layerScore * DEBT_WEIGHTS.layers +
61
+ hotScore * DEBT_WEIGHTS.hotZones +
62
+ couplingScore * DEBT_WEIGHTS.coupling),
40
63
  );
41
64
 
42
65
  return {
@@ -60,9 +83,9 @@ function clamp(x: number): number {
60
83
  }
61
84
 
62
85
  function gradeOf(score: number): ArchDebt['grade'] {
63
- if (score < 15) return 'A';
64
- if (score < 30) return 'B';
65
- if (score < 50) return 'C';
66
- if (score < 70) return 'D';
86
+ if (score < GRADE_THRESHOLDS.A) return 'A';
87
+ if (score < GRADE_THRESHOLDS.B) return 'B';
88
+ if (score < GRADE_THRESHOLDS.C) return 'C';
89
+ if (score < GRADE_THRESHOLDS.D) return 'D';
67
90
  return 'F';
68
91
  }
@@ -108,6 +108,8 @@ 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
  }),
112
114
  }));
113
115
  const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
@@ -255,6 +257,33 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
255
257
  }
256
258
  }
257
259
 
260
+ // Nuxt auto-import: composables/** are exposed globally, a consumer calls
261
+ // useFoo() with no import. Resolve the call against a name -> composable file
262
+ // registry. unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
263
+ const composableRegistry = buildComposableRegistry(parsed, framework);
264
+ if (composableRegistry.size > 0) {
265
+ for (const file of parsed) {
266
+ if (!file.callIdentifiers || file.callIdentifiers.length === 0) continue;
267
+ for (const name of file.callIdentifiers) {
268
+ const target = composableRegistry.get(name);
269
+ if (!target || target === file.relPath) continue;
270
+ const key = `${file.relPath}\u0001${target}`;
271
+ if (existingEdges.has(key)) continue;
272
+ existingEdges.add(key);
273
+ edges.push({
274
+ from: file.relPath,
275
+ to: target,
276
+ kind: 'auto-import',
277
+ specifier: name,
278
+ resolved: true,
279
+ confidence: 'medium',
280
+ resolutionKind: 'framework-auto',
281
+ approximate: true,
282
+ });
283
+ }
284
+ }
285
+ }
286
+
258
287
  return { modules, edges, parserFacts, warnings };
259
288
  }
260
289
 
@@ -365,6 +394,44 @@ function buildComponentRegistry(
365
394
  return registry;
366
395
  }
367
396
 
397
+ // Nuxt auto-import: composables/**/*.{ts,js} are exposed globally. Registry name =
398
+ // named exports + file basename (for `export default`). First-wins on collision.
399
+ // Nuxt only: unplugin-auto-import outside Nuxt is config-driven (see docs limitation).
400
+ const NUXT_COMPOSABLE_EXT = /\.[cm]?[jt]s$/u;
401
+
402
+ // True when one of `rel`'s directory segments equals `segment`. Linear scan, used
403
+ // instead of `(^|\/)<dir>\/.+` regexes: paths come from scanned, untrusted repos,
404
+ // so a `.+`-based pattern that re-anchors on every `/<dir>/` is a ReDoS risk.
405
+ function isUnderDirSegment(rel: string, segment: string): boolean {
406
+ const lastSlash = rel.lastIndexOf('/');
407
+ if (lastSlash < 0) return false;
408
+ return rel.slice(0, lastSlash).split('/').includes(segment);
409
+ }
410
+
411
+ // File under a `composables/` directory with a JS/TS extension (js/ts/mjs/mts/cjs/cts).
412
+ export function isNuxtComposablePath(rel: string): boolean {
413
+ return NUXT_COMPOSABLE_EXT.test(rel) && isUnderDirSegment(rel, 'composables');
414
+ }
415
+
416
+ function buildComposableRegistry(
417
+ parsed: ParsedFile[],
418
+ framework: Framework | undefined,
419
+ ): Map<string, string> {
420
+ const registry = new Map<string, string>();
421
+ if (framework !== 'nuxt') return registry;
422
+ for (const file of parsed) {
423
+ if (!isNuxtComposablePath(file.relPath)) continue;
424
+ const names = new Set<string>();
425
+ for (const exp of file.exports) if (exp !== 'default') names.add(exp);
426
+ const slash = file.relPath.lastIndexOf('/');
427
+ const dot = file.relPath.lastIndexOf('.');
428
+ const base = file.relPath.slice(slash + 1, dot);
429
+ if (base.length > 0) names.add(base);
430
+ for (const name of names) if (!registry.has(name)) registry.set(name, file.relPath);
431
+ }
432
+ return registry;
433
+ }
434
+
368
435
  function indexFilesByDir(files: string[]): Map<string, string[]> {
369
436
  const map = new Map<string, string[]>();
370
437
  for (const f of files) {
@@ -599,10 +666,14 @@ function frameworkFactsFromPath(
599
666
  ) {
600
667
  facts.push({ kind: 'sveltekit-route', value: relPath, confidence: 'medium' });
601
668
  }
602
- if (framework === 'nuxt' && /(^|\/)components\/.+\.vue$/u.test(relPath)) {
669
+ if (
670
+ framework === 'nuxt' &&
671
+ relPath.endsWith('.vue') &&
672
+ isUnderDirSegment(relPath, 'components')
673
+ ) {
603
674
  facts.push({ kind: 'nuxt-auto-component', value: relPath, confidence: 'medium' });
604
675
  }
605
- if (framework === 'nuxt' && /(^|\/)composables\/.+\.[cm]?[jt]s$/u.test(relPath)) {
676
+ if (framework === 'nuxt' && isNuxtComposablePath(relPath)) {
606
677
  facts.push({ kind: 'nuxt-auto-composable', value: relPath, confidence: 'medium' });
607
678
  }
608
679
  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,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,6 +25,7 @@ import { loadAliases } from './loadAliases';
26
25
  import { createResolver } from './resolve';
27
26
  import { createParserRegistry, isParseFailure } from './parsers';
28
27
  import { classifyKind, isInfra } from './classify';
28
+ import { isNuxtComposablePath } from './buildGraph';
29
29
  import { classifyModuleRuntime, detectRscLeaks } from './rsc';
30
30
  import { detectCycles } from './cycles';
31
31
  import { computeMetrics } from './metrics';
@@ -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,6 +184,12 @@ 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
  }),
177
194
  });
178
195
  }
@@ -209,6 +209,7 @@ export async function analyze(
209
209
  const bundle = effectiveBundleStats
210
210
  ? analyzeBundle({
211
211
  modules,
212
+ edges,
212
213
  stats: effectiveBundleStats,
213
214
  ...(Object.keys(bundleThresholds).length > 0 ? { thresholds: bundleThresholds } : {}),
214
215
  })
@@ -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';
@@ -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 {
@@ -271,8 +271,9 @@ export function computeRecommendations(inputs: {
271
271
  // Round to 2 decimals so i18n templates render `0.83` not `0.8333…`.
272
272
  score: Math.round(c.score * 100) / 100,
273
273
  },
274
- // 0.5 .. 0.85same band as misplaced-by-layer / cycle-break candidates.
275
- weight: 0.5 + Math.min(0.35, c.score * 0.4),
274
+ // 0.5 .. 0.9driven by risk (strength + evidence + hidden + cross-boundary),
275
+ // so a cross-boundary missing-abstraction outranks a same-folder pair.
276
+ weight: 0.5 + Math.min(0.4, c.risk * 0.45),
276
277
  });
277
278
  }
278
279
  }
@@ -393,12 +394,21 @@ function bundleParams(b: BundleBloat): Recommendation['params'] {
393
394
  if (b.detail?.sizeBytes !== undefined) p['sizeBytes'] = b.detail.sizeBytes;
394
395
  if (b.detail?.chunkCount !== undefined) p['chunkCount'] = b.detail.chunkCount;
395
396
  if (b.detail?.sharePercent !== undefined) p['sharePercent'] = b.detail.sharePercent;
397
+ if (b.detail?.moduleCount !== undefined) p['moduleCount'] = b.detail.moduleCount;
396
398
  return p;
397
399
  }
398
400
 
399
401
  function bundleWeight(b: BundleBloat): number {
400
402
  // duplicates feel more actionable than just-large chunks; weight them up.
401
- const base = b.kind === 'duplicate' ? 0.85 : b.kind === 'heavy-chunk' ? 0.7 : 0.6;
403
+ // barrel-leak is a concrete, fixable tree-shaking miss - rank near duplicates.
404
+ const base =
405
+ b.kind === 'duplicate'
406
+ ? 0.85
407
+ : b.kind === 'barrel-leak'
408
+ ? 0.8
409
+ : b.kind === 'heavy-chunk'
410
+ ? 0.7
411
+ : 0.6;
402
412
  const bump = b.severity === 'high' ? 0.1 : b.severity === 'medium' ? 0.05 : 0;
403
413
  return Math.min(0.95, base + bump);
404
414
  }
@@ -199,6 +199,25 @@ export interface Resolver {
199
199
 
200
200
  export function createResolver(source: FileSource, config: ResolverConfig): Resolver {
201
201
  const cache = new Map<string, string | null>();
202
+ // Resolve an alias whose target is itself an alias (alias→alias chain).
203
+ // `visited` guards against cyclic aliases (@x→@y→@x).
204
+ const resolveAliased = async (spec: string, visited: Set<string>): Promise<string | null> => {
205
+ if (visited.has(spec)) return null;
206
+ visited.add(spec);
207
+ const aliased = applyAlias(spec, config.aliases);
208
+ if (aliased.length === 0) return null;
209
+ for (const candidate of aliased) {
210
+ const found = await tryFile(source, candidate);
211
+ if (found) return found;
212
+ }
213
+ for (const candidate of aliased) {
214
+ if (isBare(candidate) && hasAliasCandidate(candidate, config.aliases)) {
215
+ const chained = await resolveAliased(candidate, visited);
216
+ if (chained) return chained;
217
+ }
218
+ }
219
+ return null;
220
+ };
202
221
  return {
203
222
  hasLocalCandidate(specifier): boolean {
204
223
  return hasAliasCandidate(specifier, config.aliases);
@@ -207,20 +226,9 @@ export function createResolver(source: FileSource, config: ResolverConfig): Reso
207
226
  const cacheKey = `${fromRel}\u0001${specifier}`;
208
227
  if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
209
228
  if (isBare(specifier)) {
210
- const aliased = applyAlias(specifier, config.aliases);
211
- if (aliased.length === 0) {
212
- cache.set(cacheKey, null);
213
- return null;
214
- }
215
- for (const candidate of aliased) {
216
- const found = await tryFile(source, candidate);
217
- if (found) {
218
- cache.set(cacheKey, found);
219
- return found;
220
- }
221
- }
222
- cache.set(cacheKey, null);
223
- return null;
229
+ const found = await resolveAliased(specifier, new Set());
230
+ cache.set(cacheKey, found);
231
+ return found;
224
232
  }
225
233
 
226
234
  const fromDir = posixDirname(fromRel);