@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.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/README.md +2 -2
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +1 -1
- package/src/analyzer/__tests__/analyze.test.ts +41 -0
- package/src/analyzer/__tests__/bundle.test.ts +99 -0
- package/src/analyzer/__tests__/hotZones.test.ts +128 -0
- package/src/analyzer/__tests__/incremental.test.ts +61 -13
- package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +94 -0
- package/src/analyzer/__tests__/metrics.test.ts +39 -0
- package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
- package/src/analyzer/__tests__/reactParser.test.ts +22 -0
- package/src/analyzer/__tests__/recommendations.test.ts +67 -0
- package/src/analyzer/__tests__/resolve.test.ts +54 -0
- package/src/analyzer/__tests__/rsc.test.ts +133 -3
- package/src/analyzer/archDebt.ts +32 -9
- package/src/analyzer/buildGraph.ts +75 -3
- package/src/analyzer/bundle/analyzeBundle.ts +84 -1
- package/src/analyzer/bundle/types.ts +9 -1
- package/src/analyzer/hotZones.ts +94 -2
- package/src/analyzer/incremental.ts +28 -10
- package/src/analyzer/index.ts +3 -1
- package/src/analyzer/loadAliases.ts +4 -4
- package/src/analyzer/memoryRisk.ts +33 -2
- package/src/analyzer/metrics.ts +10 -1
- package/src/analyzer/parsers/svelteParser.ts +5 -0
- package/src/analyzer/parsers/tsParser.ts +11 -1
- package/src/analyzer/recommendations.ts +28 -14
- package/src/analyzer/resolve.ts +51 -18
- package/src/analyzer/rsc.ts +90 -9
- package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
- package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
- package/src/analyzer/sources/tauriFileSource.ts +2 -2
- package/src/analyzer/types.ts +22 -0
- package/src/cache/index.ts +18 -3
- package/src/diff/__tests__/diffScans.test.ts +64 -1
- package/src/diff/diffScans.ts +31 -1
- package/src/diff/types.ts +19 -1
- package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
- package/src/git/computeTemporalCoupling.ts +35 -4
- package/src/git/types.ts +14 -1
- package/src/index.ts +5 -0
- package/src/report/__tests__/buildDeadCodeReport.test.ts +108 -0
- package/src/report/buildDeadCodeReport.ts +110 -0
- package/src/report/buildFixPlan.ts +14 -69
- package/src/search/__tests__/parseQuery.test.ts +13 -13
- package/src/search/__tests__/search.test.ts +19 -19
- package/src/search/index.ts +39 -39
- package/src/search/parseQuery.ts +13 -13
- package/src/views/__tests__/analyzerViews.test.ts +6 -0
- 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`
|
|
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
|
-
- **
|
|
40
|
-
- **
|
|
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": "
|
|
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
|
-
|
|
4
|
-
|
|
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": "
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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('
|
|
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
|
-
//
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
//
|
|
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('
|
|
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('
|
|
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('
|
|
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', {
|