@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
|
@@ -56,4 +56,43 @@ describe('computeMetrics', () => {
|
|
|
56
56
|
expect(m['c']!.depth).toBe(2);
|
|
57
57
|
expect(m['d']!.depth).toBe(3);
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
it('collapses an SCC so cycle members share one condensation depth', () => {
|
|
61
|
+
// a -> b -> c -> b (b,c form a cycle), then c -> d.
|
|
62
|
+
const modules = [mod('a'), mod('b'), mod('c'), mod('d')];
|
|
63
|
+
const edges = [edge('a', 'b'), edge('b', 'c'), edge('c', 'b'), edge('c', 'd')];
|
|
64
|
+
const cycles = detectCycles(modules, edges);
|
|
65
|
+
const m = computeMetrics({ modules, edges, cycles, entries: ['a'] });
|
|
66
|
+
expect(m['a']!.depth).toBe(0);
|
|
67
|
+
// b and c are one condensed node -> equal depth, exactly one hop past `a`.
|
|
68
|
+
expect(m['b']!.depth).toBe(1);
|
|
69
|
+
expect(m['c']!.depth).toBe(1);
|
|
70
|
+
// d sits one hop past the condensed {b,c}.
|
|
71
|
+
expect(m['d']!.depth).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('takes the longest path to a node reachable by two routes (diamond)', () => {
|
|
75
|
+
// a -> b -> d and a -> c -> d, plus a long leg a -> b -> e -> d.
|
|
76
|
+
const modules = [mod('a'), mod('b'), mod('c'), mod('d'), mod('e')];
|
|
77
|
+
const edges = [
|
|
78
|
+
edge('a', 'b'),
|
|
79
|
+
edge('a', 'c'),
|
|
80
|
+
edge('b', 'd'),
|
|
81
|
+
edge('c', 'd'),
|
|
82
|
+
edge('b', 'e'),
|
|
83
|
+
edge('e', 'd'),
|
|
84
|
+
];
|
|
85
|
+
const m = computeMetrics({ modules, edges, cycles: [], entries: ['a'] });
|
|
86
|
+
expect(m['d']!.depth).toBe(3); // a -> b -> e -> d is the longest route
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('weights hotness by file size at equal coupling (sizeFactor)', () => {
|
|
90
|
+
// c1 and c2 are symmetric (both imported by `a`, both import `z`) so their
|
|
91
|
+
// coupling is identical; only the file size differs.
|
|
92
|
+
const modules = [mod('a'), mod('c1', 10), mod('c2', 5000), mod('z')];
|
|
93
|
+
const edges = [edge('a', 'c1'), edge('a', 'c2'), edge('c1', 'z'), edge('c2', 'z')];
|
|
94
|
+
const m = computeMetrics({ modules, edges, cycles: [], entries: ['a'] });
|
|
95
|
+
expect(m['c1']!.couplingScore).toBe(m['c2']!.couplingScore);
|
|
96
|
+
expect(m['c2']!.hotnessScore).toBeGreaterThan(m['c1']!.hotnessScore);
|
|
97
|
+
});
|
|
59
98
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { analyze } from '../index';
|
|
3
|
+
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
4
|
+
|
|
5
|
+
const nuxtProject = (files: Record<string, string>) =>
|
|
6
|
+
createInMemoryFileSource('/p', {
|
|
7
|
+
'package.json': JSON.stringify({ dependencies: { nuxt: '^3.0.0' } }),
|
|
8
|
+
'tsconfig.json': '{}',
|
|
9
|
+
'app.vue': `<template><NuxtPage /></template>`,
|
|
10
|
+
...files,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('buildGraph: Nuxt auto-import composable resolution', () => {
|
|
14
|
+
it('emits an auto-import edge from a page using a composable by filename convention', async () => {
|
|
15
|
+
const src = nuxtProject({
|
|
16
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
17
|
+
const n = useCounter();
|
|
18
|
+
</script>
|
|
19
|
+
<template><div>{{ n }}</div></template>`,
|
|
20
|
+
'composables/useCounter.ts': `export default function useCounter() { return 0; }`,
|
|
21
|
+
});
|
|
22
|
+
const result = await analyze(src);
|
|
23
|
+
const edge = result.edges.find(
|
|
24
|
+
(e) => e.from === 'pages/index.vue' && e.to === 'composables/useCounter.ts',
|
|
25
|
+
);
|
|
26
|
+
expect(edge).toBeDefined();
|
|
27
|
+
expect(edge?.kind).toBe('auto-import');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('resolves a composable by its named export, not just the filename', async () => {
|
|
31
|
+
const src = nuxtProject({
|
|
32
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
33
|
+
const s = useSession();
|
|
34
|
+
</script>
|
|
35
|
+
<template><div /></template>`,
|
|
36
|
+
'composables/auth.ts': `export function useSession() { return null; }`,
|
|
37
|
+
});
|
|
38
|
+
const result = await analyze(src);
|
|
39
|
+
const edge = result.edges.find(
|
|
40
|
+
(e) => e.from === 'pages/index.vue' && e.to === 'composables/auth.ts',
|
|
41
|
+
);
|
|
42
|
+
expect(edge?.kind).toBe('auto-import');
|
|
43
|
+
expect(edge?.specifier).toBe('useSession');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does NOT duplicate the edge when the composable is already statically imported', async () => {
|
|
47
|
+
const src = nuxtProject({
|
|
48
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
49
|
+
import { useCounter } from '~/composables/useCounter';
|
|
50
|
+
const n = useCounter();
|
|
51
|
+
</script>
|
|
52
|
+
<template><div /></template>`,
|
|
53
|
+
'composables/useCounter.ts': `export function useCounter() { return 0; }`,
|
|
54
|
+
'tsconfig.json': JSON.stringify({
|
|
55
|
+
compilerOptions: { baseUrl: '.', paths: { '~/*': ['./*'] } },
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
const result = await analyze(src);
|
|
59
|
+
const edges = result.edges.filter(
|
|
60
|
+
(e) => e.from === 'pages/index.vue' && e.to === 'composables/useCounter.ts',
|
|
61
|
+
);
|
|
62
|
+
expect(edges).toHaveLength(1);
|
|
63
|
+
expect(edges[0]?.kind).toBe('static');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does NOT create an edge for an identifier with no matching composable (Nuxt built-ins)', async () => {
|
|
67
|
+
const src = nuxtProject({
|
|
68
|
+
'pages/index.vue': `<script setup lang="ts">
|
|
69
|
+
const r = useRoute();
|
|
70
|
+
const f = useFetch('/api/x');
|
|
71
|
+
</script>
|
|
72
|
+
<template><div /></template>`,
|
|
73
|
+
'composables/useCounter.ts': `export function useCounter() { return 0; }`,
|
|
74
|
+
});
|
|
75
|
+
const result = await analyze(src);
|
|
76
|
+
const autoImports = result.edges.filter(
|
|
77
|
+
(e) => e.from === 'pages/index.vue' && e.kind === 'auto-import',
|
|
78
|
+
);
|
|
79
|
+
expect(autoImports).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not emit a self-edge when a composable calls a sibling-named helper in its own file', async () => {
|
|
83
|
+
const src = nuxtProject({
|
|
84
|
+
'composables/useCounter.ts': `export function useCounter() { return useCounter; }`,
|
|
85
|
+
});
|
|
86
|
+
const result = await analyze(src);
|
|
87
|
+
const selfEdges = result.edges.filter(
|
|
88
|
+
(e) => e.from === 'composables/useCounter.ts' && e.to === 'composables/useCounter.ts',
|
|
89
|
+
);
|
|
90
|
+
expect(selfEdges).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('buildGraph: plain Vue (non-Nuxt) does not guess auto-import composables', () => {
|
|
95
|
+
it('does not create composable auto-import edges without Nuxt (documented limitation)', async () => {
|
|
96
|
+
const src = createInMemoryFileSource('/p', {
|
|
97
|
+
'package.json': JSON.stringify({ dependencies: { vue: '^3.0.0' } }),
|
|
98
|
+
'tsconfig.json': '{}',
|
|
99
|
+
'src/App.vue': `<script setup lang="ts">
|
|
100
|
+
const n = useCounter();
|
|
101
|
+
</script>
|
|
102
|
+
<template><div /></template>`,
|
|
103
|
+
'src/composables/useCounter.ts': `export function useCounter() { return 0; }`,
|
|
104
|
+
});
|
|
105
|
+
const result = await analyze(src);
|
|
106
|
+
const autoImports = result.edges.filter((e) => e.kind === 'auto-import');
|
|
107
|
+
expect(autoImports).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -33,6 +33,28 @@ describe('reactParser (via registry, framework=react)', () => {
|
|
|
33
33
|
expect(specs).toEqual(expect.arrayContaining(['./A', './B']));
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it('captures next/dynamic(() => import(...)) as a dynamic edge', () => {
|
|
37
|
+
const file = parseReact(
|
|
38
|
+
'src/App.tsx',
|
|
39
|
+
`import dynamic from 'next/dynamic';
|
|
40
|
+
const Chart = dynamic(() => import('./widgets/Chart'));
|
|
41
|
+
export const App = () => null;`,
|
|
42
|
+
);
|
|
43
|
+
const dynamic = file.imports.filter((i) => i.kind === 'dynamic');
|
|
44
|
+
expect(dynamic.map((i) => i.specifier)).toContain('./widgets/Chart');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('captures next/dynamic with an options object as a dynamic edge', () => {
|
|
48
|
+
const file = parseReact(
|
|
49
|
+
'src/App.tsx',
|
|
50
|
+
`import dynamic from 'next/dynamic';
|
|
51
|
+
const Map = dynamic(() => import('./widgets/Map'), { ssr: false });
|
|
52
|
+
export const App = () => null;`,
|
|
53
|
+
);
|
|
54
|
+
const dynamic = file.imports.filter((i) => i.kind === 'dynamic');
|
|
55
|
+
expect(dynamic.map((i) => i.specifier)).toContain('./widgets/Map');
|
|
56
|
+
});
|
|
57
|
+
|
|
36
58
|
it('captures static imports of JSX components', () => {
|
|
37
59
|
const file = parseReact(
|
|
38
60
|
'src/App.tsx',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { computeRecommendations } from '../recommendations';
|
|
3
3
|
import type { Cycle, DependencyEdge, ModuleMetrics, ModuleNode } from '../types';
|
|
4
|
+
import type { TemporalCoupling } from '../../git/types';
|
|
4
5
|
|
|
5
6
|
function module(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
|
|
6
7
|
return {
|
|
@@ -168,4 +169,70 @@ describe('computeRecommendations', () => {
|
|
|
168
169
|
const r = recs.find((x) => x.kind === 'unused-utility')!;
|
|
169
170
|
expect(r.params.name).toBe('dead.ts');
|
|
170
171
|
});
|
|
172
|
+
|
|
173
|
+
it('only surfaces hidden cross-boundary temporal couplings, ranked, capped at 10', () => {
|
|
174
|
+
function coupling(
|
|
175
|
+
a: string,
|
|
176
|
+
b: string,
|
|
177
|
+
hidden: boolean,
|
|
178
|
+
crossBoundary: boolean,
|
|
179
|
+
risk: number,
|
|
180
|
+
): TemporalCoupling {
|
|
181
|
+
return {
|
|
182
|
+
a,
|
|
183
|
+
b,
|
|
184
|
+
coOccurrences: 5,
|
|
185
|
+
scoreA: 0.8,
|
|
186
|
+
scoreB: 0.8,
|
|
187
|
+
score: 0.8,
|
|
188
|
+
hidden,
|
|
189
|
+
crossBoundary,
|
|
190
|
+
risk,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const temporalCoupling: TemporalCoupling[] = [
|
|
194
|
+
coupling('features/a.ts', 'entities/b.ts', true, true, 0.9),
|
|
195
|
+
coupling('features/c.ts', 'shared/d.ts', true, true, 0.7),
|
|
196
|
+
coupling('shared/x.ts', 'shared/y.ts', true, false, 0.95), // same group: dropped
|
|
197
|
+
coupling('features/e.ts', 'entities/f.ts', false, true, 0.95), // visible: dropped
|
|
198
|
+
];
|
|
199
|
+
const recs = computeRecommendations({
|
|
200
|
+
modules: [],
|
|
201
|
+
edges: [],
|
|
202
|
+
metrics: {},
|
|
203
|
+
cycles: [],
|
|
204
|
+
layerViolations: [],
|
|
205
|
+
hotZones: [],
|
|
206
|
+
temporalCoupling,
|
|
207
|
+
});
|
|
208
|
+
const temporal = recs.filter((r) => r.kind === 'temporal-coupling');
|
|
209
|
+
expect(temporal).toHaveLength(2);
|
|
210
|
+
// Input order is risk-sorted by the detector and preserved.
|
|
211
|
+
expect(temporal[0]!.modules).toEqual(['features/a.ts', 'entities/b.ts']);
|
|
212
|
+
expect(temporal[1]!.modules).toEqual(['features/c.ts', 'shared/d.ts']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('caps temporal-coupling recommendations at 10', () => {
|
|
216
|
+
const temporalCoupling: TemporalCoupling[] = Array.from({ length: 15 }, (_, i) => ({
|
|
217
|
+
a: `features/a${i}.ts`,
|
|
218
|
+
b: `entities/b${i}.ts`,
|
|
219
|
+
coOccurrences: 5,
|
|
220
|
+
scoreA: 0.8,
|
|
221
|
+
scoreB: 0.8,
|
|
222
|
+
score: 0.8,
|
|
223
|
+
hidden: true,
|
|
224
|
+
crossBoundary: true,
|
|
225
|
+
risk: 1 - i * 0.01,
|
|
226
|
+
}));
|
|
227
|
+
const recs = computeRecommendations({
|
|
228
|
+
modules: [],
|
|
229
|
+
edges: [],
|
|
230
|
+
metrics: {},
|
|
231
|
+
cycles: [],
|
|
232
|
+
layerViolations: [],
|
|
233
|
+
hotZones: [],
|
|
234
|
+
temporalCoupling,
|
|
235
|
+
});
|
|
236
|
+
expect(recs.filter((r) => r.kind === 'temporal-coupling')).toHaveLength(10);
|
|
237
|
+
});
|
|
171
238
|
});
|
|
@@ -14,6 +14,33 @@ describe('resolve', () => {
|
|
|
14
14
|
expect(aliases).toEqual([{ prefix: '@', targets: ['src'] }]);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
it('resolves a tsconfig wildcard whose target has an interior star to the package index', async () => {
|
|
18
|
+
const src = createInMemoryFileSource('/p', {
|
|
19
|
+
'tsconfig.json': JSON.stringify({
|
|
20
|
+
compilerOptions: {
|
|
21
|
+
baseUrl: '.',
|
|
22
|
+
paths: { '@x/*': ['pkgs/*/src'], '@x/legacy': ['pkgs/legacy/src'] },
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
'pkgs/foo/src/index.ts': 'export const foo = 1;',
|
|
26
|
+
'pkgs/legacy/src/index.ts': 'export const legacy = 1;',
|
|
27
|
+
'src/main.ts': "import { foo } from '@x/foo';",
|
|
28
|
+
});
|
|
29
|
+
const project: ProjectRef = {
|
|
30
|
+
id: 'p',
|
|
31
|
+
name: 'p',
|
|
32
|
+
rootPath: '/p',
|
|
33
|
+
detectedFramework: 'unknown',
|
|
34
|
+
tsconfigPath: 'tsconfig.json',
|
|
35
|
+
};
|
|
36
|
+
const r = createResolver(src, { aliases: await loadAliases(src, project) });
|
|
37
|
+
// `@x/*` -> `pkgs/*/src`: the captured `foo` is substituted at the star,
|
|
38
|
+
// then directory->index resolution lands on the package index file.
|
|
39
|
+
expect(await r.resolve('@x/foo', 'src/main.ts')).toBe('pkgs/foo/src/index.ts');
|
|
40
|
+
// an exact tsconfig path (no star) shadows the wildcard for its own key.
|
|
41
|
+
expect(await r.resolve('@x/legacy', 'src/main.ts')).toBe('pkgs/legacy/src/index.ts');
|
|
42
|
+
});
|
|
43
|
+
|
|
17
44
|
it('resolves @/ alias to src/*.ts and *.vue', async () => {
|
|
18
45
|
const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
|
|
19
46
|
const r = createResolver(src, { aliases: parseTsconfigPaths(await src.read('tsconfig.json')) });
|
|
@@ -43,6 +70,33 @@ describe('resolve', () => {
|
|
|
43
70
|
expect(await r.resolve('@/lib/util', 'src/main.js')).toBe('src/lib/util.js');
|
|
44
71
|
});
|
|
45
72
|
|
|
73
|
+
it('resolves an alias whose target is itself an alias (alias chain)', async () => {
|
|
74
|
+
const src = createInMemoryFileSource('/p', {
|
|
75
|
+
'src/a/foo.ts': 'export const x = 1;',
|
|
76
|
+
'src/entry.ts': "import { x } from '@b/foo';",
|
|
77
|
+
});
|
|
78
|
+
const r = createResolver(src, {
|
|
79
|
+
aliases: [
|
|
80
|
+
{ prefix: '@b', targets: ['@a'] },
|
|
81
|
+
{ prefix: '@a', targets: ['src/a'] },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
expect(await r.resolve('@b/foo', 'src/entry.ts')).toBe('src/a/foo.ts');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('does not loop on a cyclic alias chain', async () => {
|
|
88
|
+
const src = createInMemoryFileSource('/p', {
|
|
89
|
+
'src/entry.ts': "import { x } from '@x/foo';",
|
|
90
|
+
});
|
|
91
|
+
const r = createResolver(src, {
|
|
92
|
+
aliases: [
|
|
93
|
+
{ prefix: '@x', targets: ['@y'] },
|
|
94
|
+
{ prefix: '@y', targets: ['@x'] },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(await r.resolve('@x/foo', 'src/entry.ts')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
46
100
|
it('resolves relative imports', async () => {
|
|
47
101
|
const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-cycles') });
|
|
48
102
|
const r = createResolver(src, { aliases: [] });
|
|
@@ -7,7 +7,11 @@ import { analyze } from '../index';
|
|
|
7
7
|
import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
|
|
8
8
|
import type { DependencyEdge, ModuleNode } from '../types';
|
|
9
9
|
|
|
10
|
-
function mod(
|
|
10
|
+
function mod(
|
|
11
|
+
id: string,
|
|
12
|
+
runtime: ModuleNode['runtime'] = 'shared',
|
|
13
|
+
opts: { isServerActions?: boolean } = {},
|
|
14
|
+
): ModuleNode {
|
|
11
15
|
return {
|
|
12
16
|
id,
|
|
13
17
|
absPath: id,
|
|
@@ -17,6 +21,7 @@ function mod(id: string, runtime: ModuleNode['runtime'] = 'shared'): ModuleNode
|
|
|
17
21
|
exports: [],
|
|
18
22
|
isInfra: false,
|
|
19
23
|
runtime,
|
|
24
|
+
...(opts.isServerActions ? { isServerActions: true } : {}),
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -43,6 +48,26 @@ describe('classifyModuleRuntime', () => {
|
|
|
43
48
|
).toBe('server');
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
it("importing the 'server-only' package marks the module server (any framework)", () => {
|
|
52
|
+
expect(
|
|
53
|
+
classifyModuleRuntime({
|
|
54
|
+
relPath: 'lib/secret.ts',
|
|
55
|
+
framework: 'unknown',
|
|
56
|
+
importsServerOnly: true,
|
|
57
|
+
}),
|
|
58
|
+
).toBe('server');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("importing the 'client-only' package marks the module client", () => {
|
|
62
|
+
expect(
|
|
63
|
+
classifyModuleRuntime({
|
|
64
|
+
relPath: 'lib/browser.ts',
|
|
65
|
+
framework: 'next',
|
|
66
|
+
importsClientOnly: true,
|
|
67
|
+
}),
|
|
68
|
+
).toBe('client');
|
|
69
|
+
});
|
|
70
|
+
|
|
46
71
|
it('Next App Router defaults to server, pages/api server-only', () => {
|
|
47
72
|
expect(classifyModuleRuntime({ relPath: 'app/page.tsx', framework: 'next' })).toBe('server');
|
|
48
73
|
expect(classifyModuleRuntime({ relPath: 'pages/api/hello.ts', framework: 'next' })).toBe(
|
|
@@ -98,6 +123,90 @@ describe('detectRscLeaks', () => {
|
|
|
98
123
|
expect(leaks[0]?.edge?.to).toBe('lib/db.ts');
|
|
99
124
|
});
|
|
100
125
|
|
|
126
|
+
it("does not flag a client importing a 'use server' Server Actions module", () => {
|
|
127
|
+
// The ubiquitous Next.js app-router pattern: a client component imports
|
|
128
|
+
// server actions (forms/mutations). Next compiles them into RPC references;
|
|
129
|
+
// nothing server-only ships to the browser, so this is not a leak.
|
|
130
|
+
const modules = [
|
|
131
|
+
mod('components/cart/modal.tsx', 'client'),
|
|
132
|
+
mod('components/cart/actions.ts', 'server', { isServerActions: true }),
|
|
133
|
+
];
|
|
134
|
+
const edges = [edge('components/cart/modal.tsx', 'components/cart/actions.ts')];
|
|
135
|
+
expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("still flags client -> server-only when the server module is not 'use server'", () => {
|
|
139
|
+
const modules = [
|
|
140
|
+
mod('app/Form.tsx', 'client'),
|
|
141
|
+
mod('lib/secret.ts', 'server'), // server via server-only / convention, no 'use server'
|
|
142
|
+
];
|
|
143
|
+
const edges = [edge('app/Form.tsx', 'lib/secret.ts')];
|
|
144
|
+
const leaks = detectRscLeaks({ modules, edges });
|
|
145
|
+
expect(leaks).toHaveLength(1);
|
|
146
|
+
expect(leaks[0]?.edge?.to).toBe('lib/secret.ts');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not flag a transitive client -> shared -> 'use server' actions chain", () => {
|
|
150
|
+
const modules = [
|
|
151
|
+
mod('app/Form.tsx', 'client'),
|
|
152
|
+
mod('lib/index.ts', 'shared'), // barrel
|
|
153
|
+
mod('lib/actions.ts', 'server', { isServerActions: true }),
|
|
154
|
+
];
|
|
155
|
+
const edges = [edge('app/Form.tsx', 'lib/index.ts'), edge('lib/index.ts', 'lib/actions.ts')];
|
|
156
|
+
expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("flags a client component importing a module poisoned with 'server-only'", async () => {
|
|
160
|
+
const fs = createInMemoryFileSource('/repo', {
|
|
161
|
+
'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
|
|
162
|
+
'app/Form.tsx':
|
|
163
|
+
"'use client';\nimport { getSecret } from '../lib/secret';\nexport default function F(){ return getSecret(); }\n",
|
|
164
|
+
'lib/secret.ts': "import 'server-only';\nexport const getSecret = () => 1;\n",
|
|
165
|
+
});
|
|
166
|
+
const scan = await analyze(fs);
|
|
167
|
+
const leaks = scan.contractViolations.filter((v) => v.kind === 'rsc-leak');
|
|
168
|
+
expect(
|
|
169
|
+
leaks.some((l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/secret.ts'),
|
|
170
|
+
).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('flags a transitive leak: client -> shared barrel -> server', () => {
|
|
174
|
+
const modules = [
|
|
175
|
+
mod('app/Form.tsx', 'client'),
|
|
176
|
+
mod('lib/index.ts', 'shared'), // barrel
|
|
177
|
+
mod('lib/db.ts', 'server'),
|
|
178
|
+
];
|
|
179
|
+
const edges = [
|
|
180
|
+
edge('app/Form.tsx', 'lib/index.ts'), // client -> shared (legal alone)
|
|
181
|
+
edge('lib/index.ts', 'lib/db.ts'), // shared re-exports server
|
|
182
|
+
];
|
|
183
|
+
const leaks = detectRscLeaks({ modules, edges });
|
|
184
|
+
const transitive = leaks.find(
|
|
185
|
+
(l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts',
|
|
186
|
+
);
|
|
187
|
+
expect(transitive).toBeDefined();
|
|
188
|
+
expect(transitive?.kind).toBe('rsc-leak');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('does not flag a client -> shared chain that never reaches a server module', () => {
|
|
192
|
+
const modules = [
|
|
193
|
+
mod('app/Form.tsx', 'client'),
|
|
194
|
+
mod('lib/index.ts', 'shared'),
|
|
195
|
+
mod('lib/util.ts', 'shared'),
|
|
196
|
+
];
|
|
197
|
+
const edges = [edge('app/Form.tsx', 'lib/index.ts'), edge('lib/index.ts', 'lib/util.ts')];
|
|
198
|
+
expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('does not double-report when a client directly imports a server module', () => {
|
|
202
|
+
const modules = [mod('app/Form.tsx', 'client'), mod('lib/db.ts', 'server')];
|
|
203
|
+
const edges = [edge('app/Form.tsx', 'lib/db.ts')];
|
|
204
|
+
const leaks = detectRscLeaks({ modules, edges }).filter(
|
|
205
|
+
(l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts',
|
|
206
|
+
);
|
|
207
|
+
expect(leaks).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
101
210
|
it('does not flag type-only edges or shared modules', () => {
|
|
102
211
|
const modules = [mod('a.ts', 'client'), mod('b.ts', 'server'), mod('shared.ts', 'shared')];
|
|
103
212
|
const edges = [
|
|
@@ -110,14 +219,14 @@ describe('detectRscLeaks', () => {
|
|
|
110
219
|
});
|
|
111
220
|
|
|
112
221
|
describe('rsc-leak end-to-end', () => {
|
|
113
|
-
it('surfaces client->server import as a contract violation in analyze()', async () => {
|
|
222
|
+
it('surfaces client->server-only import as a contract violation in analyze()', async () => {
|
|
114
223
|
const fs = createInMemoryFileSource('/repo', {
|
|
115
224
|
'package.json': JSON.stringify({ name: 'x' }),
|
|
116
225
|
'app/page.tsx':
|
|
117
226
|
"import { db } from '../lib/db';\nexport default function P(){ return db; }\n",
|
|
118
227
|
'app/Form.tsx':
|
|
119
228
|
"'use client';\nimport { db } from '../lib/db';\nexport default function F(){ return db; }\n",
|
|
120
|
-
'lib/db.ts': "'
|
|
229
|
+
'lib/db.ts': "import 'server-only';\nexport const db = 1;\n",
|
|
121
230
|
});
|
|
122
231
|
|
|
123
232
|
const scan = await analyze(fs);
|
|
@@ -127,4 +236,25 @@ describe('rsc-leak end-to-end', () => {
|
|
|
127
236
|
true,
|
|
128
237
|
);
|
|
129
238
|
});
|
|
239
|
+
|
|
240
|
+
it("does not flag a client importing a 'use server' actions module in analyze()", async () => {
|
|
241
|
+
// Mirrors vercel/commerce: components/cart/modal.tsx ('use client') imports
|
|
242
|
+
// components/cart/actions.ts ('use server'). This must not be an rsc-leak.
|
|
243
|
+
const fs = createInMemoryFileSource('/repo', {
|
|
244
|
+
'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
|
|
245
|
+
'components/cart/modal.tsx':
|
|
246
|
+
"'use client';\nimport { addItem } from './actions';\nexport default function M(){ return addItem; }\n",
|
|
247
|
+
'components/cart/actions.ts': "'use server';\nexport async function addItem(){ return 1; }\n",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const scan = await analyze(fs);
|
|
251
|
+
const leaks = scan.contractViolations.filter((v) => v.kind === 'rsc-leak');
|
|
252
|
+
expect(
|
|
253
|
+
leaks.some(
|
|
254
|
+
(l) =>
|
|
255
|
+
l.edge?.from === 'components/cart/modal.tsx' &&
|
|
256
|
+
l.edge?.to === 'components/cart/actions.ts',
|
|
257
|
+
),
|
|
258
|
+
).toBe(false);
|
|
259
|
+
});
|
|
130
260
|
});
|
package/src/analyzer/archDebt.ts
CHANGED
|
@@ -8,20 +8,39 @@ interface Inputs {
|
|
|
8
8
|
hotZoneCount: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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 *
|
|
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 *
|
|
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 *
|
|
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 <
|
|
64
|
-
if (score <
|
|
65
|
-
if (score <
|
|
66
|
-
if (score <
|
|
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
|
}
|