@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
package/README.md CHANGED
@@ -31,13 +31,15 @@ parsing, output formatting, and CI exit codes.
31
31
 
32
32
  ## What it computes
33
33
 
34
- - **Dependency graph** — parses `.ts`, `.tsx`, `.vue` files, resolves `tsconfig` path aliases
34
+ - **Dependency graph** — parses `.ts`, `.tsx`, `.js`, `.jsx`, `.vue` (and `.svelte`, beta), resolves `tsconfig` path aliases including recursive alias→alias chains
35
+ - **Dynamic & framework-auto edges** — `import()`, `React.lazy`, `next/dynamic`, Vue/Nuxt component and Nuxt `composables/` auto-imports
35
36
  - **Cycles** — Tarjan's SCC; classifies as direct (length ≤ 2) or indirect
36
37
  - **Per-module metrics** — fan-in, fan-out, instability, depth, coupling, hotness score
37
38
  - **Layer violations** — FSD-style rules (`shared → entities → features → widgets → pages → app`)
38
39
  - **Contract checks** — boundary rules, package budgets, API stability, bundle thresholds from `.archora.json`
39
- - **Bundle signals** — duplicated modules, heavy chunks (from webpack/rollup stats)
40
- - **Temporal coupling** — modules that change together without a static edge (from `git log`)
40
+ - **RSC boundary leaks** — server/client runtime from directives, `server-only`/`client-only` packages and framework conventions; flags `client → server` imports, direct and transitive
41
+ - **Bundle signals** — duplicated modules, heavy chunks, solo-hot modules, barrel tree-shaking leaks (from webpack/rollup stats)
42
+ - **Temporal coupling** — modules that change together without a static edge (from `git log`), ranked by risk
41
43
 
42
44
  ## FileSource
43
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archora/core",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "description": "Framework-independent analyzer core for Archora. Builds the dependency model, detects cycles, computes metrics and recommendations from any FileSource. No Vue, Pinia or Tauri dependencies.",
6
6
  "author": "Archora <archora@archora.dev>",
package/src/README.md CHANGED
@@ -1,4 +1,4 @@
1
1
  # core
2
2
 
3
- Фреймворк-независимое ядро: analyzer, graph, metrics, rules, report.
4
- Не импортирует Vue/Pinia/Tailwind/Tauri. Должно работать в чистом Node.
3
+ Framework-independent core: analyzer, graph, metrics, rules, report.
4
+ Does not import Vue/Pinia/Tailwind/Tauri. Must run in plain Node.
@@ -38,7 +38,7 @@ exports[`reference corpus snapshots > nuxt-server-routes: stable summary 1`] = `
38
38
 
39
39
  exports[`reference corpus snapshots > react-admin-basic: stable summary 1`] = `
40
40
  {
41
- "archDebtGrade": "B",
41
+ "archDebtGrade": "A",
42
42
  "cycles": 0,
43
43
  "edges": 11,
44
44
  "modules": 11,
@@ -520,3 +520,44 @@ describe('analyze: phantom type-only cycles', () => {
520
520
  expect(result.cycles.some((c) => c.modules.length === 2)).toBe(true);
521
521
  });
522
522
  });
523
+
524
+ describe('analyze: robustness to broken inputs', () => {
525
+ it('does not crash on malformed files and still analyzes the valid ones', async () => {
526
+ const source = createInMemoryFileSource('/proj', {
527
+ 'package.json': JSON.stringify({ name: 'p', version: '0.0.0', dependencies: { vue: '^3' } }),
528
+ 'tsconfig.json': '{}',
529
+ 'src/good.ts': `import { dep } from './dep';\nexport const good = (): number => dep();\n`,
530
+ 'src/dep.ts': `export const dep = (): number => 1;\n`,
531
+ // unterminated string / garbage TS
532
+ 'src/brokenTs.ts': `export const x = "unterminated\nconst y = {{{ <<< @@@ ;`,
533
+ // malformed Vue SFC (unclosed script, broken template)
534
+ 'src/Broken.vue': `<script setup lang="ts">\nimport { z from './nowhere'\n<template><div></span></template>`,
535
+ // empty file
536
+ 'src/empty.ts': ``,
537
+ // NUL / control bytes
538
+ 'src/binary.ts': ` not really code ￿`,
539
+ });
540
+
541
+ // Must resolve, not throw.
542
+ const result = await analyze(source);
543
+
544
+ // The valid pair survived and its edge was resolved despite broken neighbors.
545
+ expect(result.modules.some((m) => m.id === 'src/good.ts')).toBe(true);
546
+ expect(result.edges.some((e) => e.from === 'src/good.ts' && e.to === 'src/dep.ts')).toBe(true);
547
+ // Warnings are accumulated, never thrown.
548
+ expect(Array.isArray(result.warnings)).toBe(true);
549
+ });
550
+
551
+ it('survives a file that exceeds the size cap without aborting the scan', async () => {
552
+ const huge = `export const big = "${'x'.repeat(1_100_000)}";\n`;
553
+ const source = createInMemoryFileSource('/proj', {
554
+ 'package.json': JSON.stringify({ name: 'p', version: '0.0.0' }),
555
+ 'tsconfig.json': '{}',
556
+ 'src/good.ts': `export const good = 1;\n`,
557
+ 'src/huge.ts': huge,
558
+ });
559
+ const result = await analyze(source);
560
+ expect(result.modules.some((m) => m.id === 'src/good.ts')).toBe(true);
561
+ expect(result.warnings.some((w) => w.file === 'src/huge.ts')).toBe(true);
562
+ });
563
+ });
@@ -189,3 +189,102 @@ describe('analyzeBundle', () => {
189
189
  expect(Object.keys(report.moduleToChunks)).toHaveLength(0);
190
190
  });
191
191
  });
192
+
193
+ describe('analyzeBundle: barrel-leak (graph × bundle)', () => {
194
+ const siblings = Array.from({ length: 8 }, (_, i) => `src/ui/C${i + 1}.ts`);
195
+ const barrelModules = [
196
+ mod('src/ui/index.ts'),
197
+ ...siblings.map((s) => mod(s)),
198
+ mod('src/app/Page.tsx'),
199
+ ];
200
+ const barrelEdges = [
201
+ ...siblings.map((s) => ({
202
+ from: 'src/ui/index.ts',
203
+ to: s,
204
+ kind: 'static' as const,
205
+ specifier: `./${s.slice('src/ui/'.length, -'.ts'.length)}`,
206
+ resolved: true,
207
+ })),
208
+ {
209
+ from: 'src/app/Page.tsx',
210
+ to: 'src/ui/index.ts',
211
+ kind: 'static' as const,
212
+ specifier: '@/ui',
213
+ resolved: true,
214
+ },
215
+ ];
216
+
217
+ it('flags a barrel that pulls its whole directory into one chunk', () => {
218
+ const stats = parseBundleStats(
219
+ {
220
+ chunks: [
221
+ {
222
+ id: 'main',
223
+ files: ['main.js'],
224
+ size: 100_000,
225
+ modules: [
226
+ { name: 'src/ui/index.ts', size: 1_000 },
227
+ ...siblings.map((s) => ({ name: s, size: 5_000 })),
228
+ { name: 'src/app/Page.tsx', size: 2_000 },
229
+ ],
230
+ },
231
+ ],
232
+ },
233
+ { rootPath: '/repo' },
234
+ );
235
+ const report = analyzeBundle({ modules: barrelModules, edges: barrelEdges, stats });
236
+ const leak = report.bloat.find((b) => b.kind === 'barrel-leak');
237
+ expect(leak).toBeDefined();
238
+ expect(leak?.modules).toEqual(['src/ui/index.ts']);
239
+ expect(leak?.detail?.moduleCount).toBe(8);
240
+ expect(leak?.detail?.sizeBytes).toBe(40_000);
241
+ });
242
+
243
+ it('does not flag when only a few siblings co-locate with the barrel', () => {
244
+ const stats = parseBundleStats(
245
+ {
246
+ chunks: [
247
+ {
248
+ id: 'main',
249
+ files: ['main.js'],
250
+ size: 30_000,
251
+ modules: [
252
+ { name: 'src/ui/index.ts', size: 1_000 },
253
+ ...siblings.slice(0, 3).map((s) => ({ name: s, size: 5_000 })),
254
+ ],
255
+ },
256
+ {
257
+ id: 'other',
258
+ files: ['other.js'],
259
+ size: 25_000,
260
+ modules: siblings.slice(3).map((s) => ({ name: s, size: 5_000 })),
261
+ },
262
+ ],
263
+ },
264
+ { rootPath: '/repo' },
265
+ );
266
+ const report = analyzeBundle({ modules: barrelModules, edges: barrelEdges, stats });
267
+ expect(report.bloat.some((b) => b.kind === 'barrel-leak')).toBe(false);
268
+ });
269
+
270
+ it('skips barrel-leak detection when no edges are supplied', () => {
271
+ const stats = parseBundleStats(
272
+ {
273
+ chunks: [
274
+ {
275
+ id: 'main',
276
+ files: ['main.js'],
277
+ size: 100_000,
278
+ modules: [
279
+ { name: 'src/ui/index.ts', size: 1_000 },
280
+ ...siblings.map((s) => ({ name: s, size: 5_000 })),
281
+ ],
282
+ },
283
+ ],
284
+ },
285
+ { rootPath: '/repo' },
286
+ );
287
+ const report = analyzeBundle({ modules: barrelModules, stats });
288
+ expect(report.bloat.some((b) => b.kind === 'barrel-leak')).toBe(false);
289
+ });
290
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markBarrelModules, rankHotZones } from '../hotZones';
3
+ import type { ModuleMetrics, ModuleNode, ParsedFileSummary } from '../types';
4
+
5
+ function module(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
6
+ return {
7
+ id,
8
+ absPath: `/${id}`,
9
+ kind: 'unknown',
10
+ language: 'ts',
11
+ loc: 1,
12
+ exports: [],
13
+ isInfra: false,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function metrics(overrides: Partial<ModuleMetrics> = {}): ModuleMetrics {
19
+ return {
20
+ fanIn: 0,
21
+ fanOut: 0,
22
+ instability: 0,
23
+ depth: 0,
24
+ inCycle: false,
25
+ couplingScore: 0,
26
+ hotnessScore: 0,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function summary(relPath: string, overrides: Partial<ParsedFileSummary> = {}): ParsedFileSummary {
32
+ return {
33
+ relPath,
34
+ language: 'ts',
35
+ loc: 1,
36
+ imports: [],
37
+ exports: [],
38
+ runtimeFacts: [],
39
+ frameworkFacts: [],
40
+ routeFacts: [],
41
+ stateFacts: [],
42
+ assetFacts: [],
43
+ limitations: [],
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('markBarrelModules', () => {
49
+ it('flags a thin index barrel with fan-out >= 3 even when no export source is enumerated', () => {
50
+ // Pinia `packages/pinia/src/index.ts`: fan-out 8, 66 named re-exports the
51
+ // parser couldn't resolve a source for, ~70 loc — thin glue.
52
+ const modules = [
53
+ module('packages/pinia/src/index.ts', {
54
+ loc: 70,
55
+ exports: Array.from({ length: 66 }, (_, i) => `reexport${i}`),
56
+ }),
57
+ ];
58
+ const m = { 'packages/pinia/src/index.ts': metrics({ fanOut: 8 }) };
59
+ markBarrelModules({
60
+ modules,
61
+ metrics: m,
62
+ parserFacts: [summary('packages/pinia/src/index.ts', { loc: 70 })],
63
+ });
64
+ expect(modules[0]?.isBarrel).toBe(true);
65
+ });
66
+
67
+ it('flags a mid/small package barrel below the old fan-out fallback', () => {
68
+ // VueUse `packages/math/index.ts`: fan-out 18, ~20 loc.
69
+ const modules = [module('packages/math/index.ts', { loc: 20 })];
70
+ const m = { 'packages/math/index.ts': metrics({ fanOut: 18 }) };
71
+ markBarrelModules({ modules, metrics: m, parserFacts: [] });
72
+ expect(modules[0]?.isBarrel).toBe(true);
73
+ });
74
+
75
+ it('does not flag a fat module with many source lines per export', () => {
76
+ // VueUse `useStorage/index.ts`: loc 328, surface 7 → 46 loc/item.
77
+ const modules = [module('packages/core/useStorage/index.ts', { loc: 328 })];
78
+ const m = { 'packages/core/useStorage/index.ts': metrics({ fanOut: 5 }) };
79
+ markBarrelModules({
80
+ modules,
81
+ metrics: m,
82
+ parserFacts: [summary('packages/core/useStorage/index.ts', { loc: 328 })],
83
+ });
84
+ expect(modules[0]?.isBarrel).toBeUndefined();
85
+ });
86
+
87
+ it('does not flag a fan-out-0 multi-export util that re-exports nothing', () => {
88
+ // VueUse `shared/utils/is.ts`: fan-out 0, 13 own exports — a leaf, not glue.
89
+ const modules = [
90
+ module('packages/shared/utils/is.ts', {
91
+ loc: 30,
92
+ exports: Array.from({ length: 13 }, (_, i) => `is${i}`),
93
+ }),
94
+ ];
95
+ const m = { 'packages/shared/utils/is.ts': metrics({ fanOut: 0 }) };
96
+ markBarrelModules({ modules, metrics: m, parserFacts: [] });
97
+ expect(modules[0]?.isBarrel).toBeUndefined();
98
+ });
99
+
100
+ it('does not flag a thin module whose surface is below the floor', () => {
101
+ // Two imports, two re-exports: not an aggregation surface.
102
+ const modules = [module('src/glue.ts', { loc: 3 })];
103
+ const m = { 'src/glue.ts': metrics({ fanOut: 3 }) };
104
+ markBarrelModules({ modules, metrics: m, parserFacts: [] });
105
+ expect(modules[0]?.isBarrel).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ describe('rankHotZones', () => {
110
+ it('drops barrels from the ranked window without backfilling lower-signal modules', () => {
111
+ const modules = [module('barrel/index.ts', { isBarrel: true }), module('a.ts'), module('b.ts')];
112
+ const m = {
113
+ 'barrel/index.ts': metrics({ hotnessScore: 5 }),
114
+ 'a.ts': metrics({ hotnessScore: 3 }),
115
+ 'b.ts': metrics({ hotnessScore: 1 }),
116
+ };
117
+ expect(rankHotZones({ modules, metrics: m, topN: 2 })).toEqual(['a.ts']);
118
+ });
119
+
120
+ it('excludes infra modules', () => {
121
+ const modules = [module('infra.ts', { isInfra: true }), module('app.ts')];
122
+ const m = {
123
+ 'infra.ts': metrics({ hotnessScore: 9 }),
124
+ 'app.ts': metrics({ hotnessScore: 2 }),
125
+ };
126
+ expect(rankHotZones({ modules, metrics: m })).toEqual(['app.ts']);
127
+ });
128
+ });
@@ -1,7 +1,7 @@
1
- // Контракт: incrementalAnalyze должен либо вернуть результат, эквивалентный
2
- // полному `analyze()` после применения тех же изменений к источнику, либо
3
- // прозрачно делегировать в analyze(). В обоих случаях downstream-данные
4
- // (modules/edges/cycles/metrics) должны соответствовать пост-стейту источника.
1
+ // Contract: incrementalAnalyze must either return a result equivalent to a
2
+ // full `analyze()` after applying the same changes to the source, or
3
+ // transparently delegate to analyze(). In both cases the downstream data
4
+ // (modules/edges/cycles/metrics) must match the post-state of the source.
5
5
 
6
6
  import { describe, expect, it } from 'vitest';
7
7
  import { analyze } from '../index';
@@ -16,7 +16,7 @@ function project(files: Record<string, string>) {
16
16
  }
17
17
 
18
18
  describe('incrementalAnalyze', () => {
19
- it('пере-парс одного файла даёт тот же результат, что и полный analyze пост-стейта', async () => {
19
+ it('re-parsing a single file yields the same result as a full analyze of the post-state', async () => {
20
20
  const before = {
21
21
  'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
22
22
  'src/b.ts': 'export const b = 1;\n',
@@ -32,13 +32,13 @@ describe('incrementalAnalyze', () => {
32
32
  changedFiles: ['src/a.ts'],
33
33
  });
34
34
 
35
- // структурное равенство по ключевым полям (durationMs/scannedAt отбрасываем)
35
+ // structural equality on the key fields (durationMs/scannedAt dropped)
36
36
  expect(sortEdges(got.edges)).toEqual(sortEdges(expected.edges));
37
37
  expect(got.modules.find((m) => m.id === 'src/a.ts')?.exports).toEqual(['a']);
38
38
  expect(got.cycles).toEqual(expected.cycles);
39
39
  });
40
40
 
41
- it('изменение типа модуля (loc, exports) попадает в результат', async () => {
41
+ it('a change to module shape (loc, exports) lands in the result', async () => {
42
42
  const before = {
43
43
  'src/util.ts': 'export const x = 1;\n',
44
44
  'src/main.ts': "import { x } from '@/util';\nexport default x;\n",
@@ -61,7 +61,7 @@ describe('incrementalAnalyze', () => {
61
61
  expect(util?.loc).toBe(prev.modules.find((m) => m.id === 'src/util.ts')!.loc + 2);
62
62
  });
63
63
 
64
- it('появление цикла после правки детектируется', async () => {
64
+ it('a cycle appearing after an edit is detected', async () => {
65
65
  const before = {
66
66
  'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
67
67
  'src/b.ts': 'export const b = 1;\n',
@@ -82,7 +82,7 @@ describe('incrementalAnalyze', () => {
82
82
  expect(got.cycles[0]?.modules.sort()).toEqual(['src/a.ts', 'src/b.ts']);
83
83
  });
84
84
 
85
- it('изменение конфигурационного файла → fallback на полный analyze', async () => {
85
+ it('a config file change → fallback to a full analyze', async () => {
86
86
  const before = {
87
87
  'src/a.ts': 'export const a = 1;\n',
88
88
  };
@@ -98,12 +98,12 @@ describe('incrementalAnalyze', () => {
98
98
  source: newSource,
99
99
  changedFiles: ['tsconfig.json', 'src/b.ts'],
100
100
  });
101
- // если прошло через analyze, новый алиас и новый файл подхвачены
101
+ // if it went through analyze, the new alias and new file are picked up
102
102
  expect(got.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/b.ts']);
103
103
  expect(got.edges.find((e) => e.from === 'src/b.ts')?.to).toBe('src/a.ts');
104
104
  });
105
105
 
106
- it('новый файл (не в prev) → fallback на полный analyze', async () => {
106
+ it('a new file (not in prev) → fallback to a full analyze', async () => {
107
107
  const before = {
108
108
  'src/a.ts': 'export const a = 1;\n',
109
109
  };
@@ -121,7 +121,7 @@ describe('incrementalAnalyze', () => {
121
121
  expect(got.modules.map((m) => m.id).sort()).toEqual(['src/a.ts', 'src/new.ts']);
122
122
  });
123
123
 
124
- it('удалённый файл → fallback на полный analyze', async () => {
124
+ it('a deleted file → fallback to a full analyze', async () => {
125
125
  const before = {
126
126
  'src/a.ts': "import { b } from '@/b';\nexport const a = b;\n",
127
127
  'src/b.ts': 'export const b = 1;\n',
@@ -137,7 +137,55 @@ describe('incrementalAnalyze', () => {
137
137
  expect(got.modules.map((m) => m.id)).toEqual(['src/a.ts']);
138
138
  });
139
139
 
140
- it('пустой changedFiles возвращает prev без работы', async () => {
140
+ it('incremental preserves runtime from the server-only package (== cold)', async () => {
141
+ const before = {
142
+ 'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
143
+ 'app/Form.tsx':
144
+ "'use client';\nimport { getSecret } from '../lib/secret';\nexport default function F(){ return getSecret(); }\n",
145
+ 'lib/secret.ts': "import 'server-only';\nexport const getSecret = () => 1;\n",
146
+ };
147
+ const prev = await analyze(project(before));
148
+ const after = {
149
+ ...before,
150
+ 'lib/secret.ts': "import 'server-only';\n// touched\nexport const getSecret = () => 1;\n",
151
+ };
152
+ const expected = await analyze(project(after));
153
+ const got = await incrementalAnalyze({
154
+ prev,
155
+ source: project(after),
156
+ changedFiles: ['lib/secret.ts'],
157
+ });
158
+ expect(got.modules.find((m) => m.id === 'lib/secret.ts')?.runtime).toBe('server');
159
+ const rsc = (v: { kind: string }): boolean => v.kind === 'rsc-leak';
160
+ expect(got.contractViolations.filter(rsc).length).toBe(
161
+ expected.contractViolations.filter(rsc).length,
162
+ );
163
+ });
164
+
165
+ it('incremental preserves the Nuxt composable auto-import edge (== cold)', async () => {
166
+ const before = {
167
+ 'package.json': JSON.stringify({ dependencies: { nuxt: '^3.0.0' } }),
168
+ 'app.vue': '<template><NuxtPage /></template>',
169
+ 'pages/index.vue':
170
+ '<script setup lang="ts">\nconst n = useCounter();\n</script>\n<template><div>{{ n }}</div></template>',
171
+ 'composables/useCounter.ts': 'export function useCounter() { return 0; }',
172
+ };
173
+ const prev = await analyze(project(before));
174
+ const after = {
175
+ ...before,
176
+ 'pages/index.vue':
177
+ '<script setup lang="ts">\n// touched\nconst n = useCounter();\n</script>\n<template><div>{{ n }}</div></template>',
178
+ };
179
+ const expected = await analyze(project(after));
180
+ const got = await incrementalAnalyze({
181
+ prev,
182
+ source: project(after),
183
+ changedFiles: ['pages/index.vue'],
184
+ });
185
+ expect(sortEdges(got.edges)).toEqual(sortEdges(expected.edges));
186
+ });
187
+
188
+ it('empty changedFiles → returns prev without doing work', async () => {
141
189
  const before = { 'src/a.ts': 'export const a = 1;\n' };
142
190
  const prev = await analyze(project(before));
143
191
  const got = await incrementalAnalyze({
@@ -0,0 +1,77 @@
1
+ // Controlled precision/recall for layer violations (plan A3).
2
+ //
3
+ // Real OSS projects rarely declare FSD layers, so there is no external oracle.
4
+ // Instead we measure on a fixture with a KNOWN ground truth: a fixed set of
5
+ // legal (top-down / same-layer) edges and a fixed set of injected violations
6
+ // (bottom-up imports). The detector must flag exactly the violations.
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { detectLayerViolations } from '../layers';
10
+ import type { DependencyEdge, ModuleNode } from '../types';
11
+
12
+ const mod = (id: string): ModuleNode => ({
13
+ id,
14
+ absPath: `/${id}`,
15
+ kind: 'unknown',
16
+ language: 'ts',
17
+ loc: 1,
18
+ exports: [],
19
+ isInfra: false,
20
+ });
21
+ const edge = (from: string, to: string): DependencyEdge => ({
22
+ from,
23
+ to,
24
+ kind: 'static',
25
+ specifier: to,
26
+ resolved: true,
27
+ });
28
+
29
+ // One module per FSD layer (low → high: shared < entities < features < widgets < pages < app).
30
+ const A = 'src/app/A.ts';
31
+ const P = 'src/pages/P.ts';
32
+ const W = 'src/widgets/W.ts';
33
+ const F = 'src/features/F.ts';
34
+ const F2 = 'src/features/F2.ts';
35
+ const E = 'src/entities/E.ts';
36
+ const S = 'src/shared/S.ts';
37
+ const modules = [A, P, W, F, F2, E, S].map(mod);
38
+
39
+ // Legal: top-down (and same-layer) — must NOT be flagged.
40
+ const legal: DependencyEdge[] = [
41
+ edge(A, P),
42
+ edge(A, S),
43
+ edge(P, W),
44
+ edge(W, F),
45
+ edge(F, E),
46
+ edge(E, S),
47
+ edge(F, F2),
48
+ ];
49
+
50
+ // Injected violations: bottom-up imports — the ground-truth positives.
51
+ const violating: DependencyEdge[] = [
52
+ edge(S, E),
53
+ edge(S, A),
54
+ edge(E, F),
55
+ edge(F, W),
56
+ edge(W, P),
57
+ edge(P, A),
58
+ ];
59
+
60
+ describe('layer violations — precision/recall on injected ground truth (A3)', () => {
61
+ it('flags exactly the injected violations: precision = recall = 1.0', () => {
62
+ const known = new Set(violating.map((e) => `${e.from}->${e.to}`));
63
+ const detected = detectLayerViolations(modules, [...legal, ...violating]);
64
+ const detectedPairs = detected.map((v) => `${v.from}->${v.to}`);
65
+
66
+ const tp = detectedPairs.filter((p) => known.has(p)).length;
67
+ const fp = detectedPairs.filter((p) => !known.has(p)).length;
68
+ const fn = [...known].filter((p) => !detectedPairs.includes(p)).length;
69
+
70
+ const precision = tp / (tp + fp);
71
+ const recall = tp / (tp + fn);
72
+
73
+ expect(precision).toBe(1); // no legal edge mis-flagged
74
+ expect(recall).toBe(1); // every injected violation caught
75
+ expect(tp).toBe(violating.length);
76
+ });
77
+ });
@@ -111,6 +111,100 @@ describe('memory risk analysis', () => {
111
111
  ]);
112
112
  });
113
113
 
114
+ it('does not flag a bare fire-and-forget setTimeout', async () => {
115
+ const result = await analyze(
116
+ createInMemoryFileSource('/p', {
117
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
118
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
119
+ 'src/App.tsx': `
120
+ import { useEffect } from 'react';
121
+
122
+ export function App() {
123
+ useEffect(() => {
124
+ setTimeout(() => window.removeEventListener('x', () => {}), 100);
125
+ }, []);
126
+ return null;
127
+ }
128
+ `,
129
+ }),
130
+ );
131
+
132
+ expect((result.memoryRisks ?? []).filter((risk) => risk.kind === 'timer-cleanup')).toEqual([]);
133
+ });
134
+
135
+ it('flags a captured setTimeout handle without clearTimeout', async () => {
136
+ const result = await analyze(
137
+ createInMemoryFileSource('/p', {
138
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
139
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
140
+ 'src/App.tsx': `
141
+ import { useEffect } from 'react';
142
+
143
+ export function App() {
144
+ useEffect(() => {
145
+ const id = setTimeout(() => {}, 100);
146
+ return undefined;
147
+ }, []);
148
+ return null;
149
+ }
150
+ `,
151
+ }),
152
+ );
153
+
154
+ expect(result.memoryRisks).toEqual([
155
+ expect.objectContaining({
156
+ kind: 'timer-cleanup',
157
+ moduleId: 'src/App.tsx',
158
+ framework: 'react',
159
+ }),
160
+ ]);
161
+ });
162
+
163
+ it('keeps a captured setTimeout with clearTimeout clean', async () => {
164
+ const result = await analyze(
165
+ createInMemoryFileSource('/p', {
166
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
167
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
168
+ 'src/App.tsx': `
169
+ import { useEffect } from 'react';
170
+
171
+ export function App() {
172
+ useEffect(() => {
173
+ const id = setTimeout(() => {}, 100);
174
+ return () => clearTimeout(id);
175
+ }, []);
176
+ return null;
177
+ }
178
+ `,
179
+ }),
180
+ );
181
+
182
+ expect((result.memoryRisks ?? []).filter((risk) => risk.kind === 'timer-cleanup')).toEqual([]);
183
+ });
184
+
185
+ it('still flags setInterval without clearInterval', async () => {
186
+ const result = await analyze(
187
+ createInMemoryFileSource('/p', {
188
+ 'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }),
189
+ 'tsconfig.json': JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
190
+ 'src/App.tsx': `
191
+ import { useEffect } from 'react';
192
+
193
+ export function App() {
194
+ useEffect(() => {
195
+ setInterval(() => {}, 1000);
196
+ }, []);
197
+ return null;
198
+ }
199
+ `,
200
+ }),
201
+ );
202
+
203
+ expect(result.memoryRisks).toEqual([
204
+ expect.objectContaining({ kind: 'timer-cleanup', moduleId: 'src/App.tsx' }),
205
+ ]);
206
+ });
207
+
114
208
  it('does not flag Next server modules as browser memory risks', async () => {
115
209
  const result = await analyze(
116
210
  createInMemoryFileSource('/p', {