@archora/core 1.3.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/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__/hotZones.test.ts +128 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +94 -0
- package/src/analyzer/__tests__/recommendations.test.ts +67 -0
- package/src/analyzer/__tests__/resolve.test.ts +27 -0
- package/src/analyzer/__tests__/rsc.test.ts +62 -3
- package/src/analyzer/buildGraph.ts +2 -1
- package/src/analyzer/hotZones.ts +94 -2
- package/src/analyzer/incremental.ts +2 -1
- package/src/analyzer/index.ts +2 -1
- package/src/analyzer/memoryRisk.ts +33 -2
- package/src/analyzer/recommendations.ts +15 -11
- package/src/analyzer/resolve.ts +29 -4
- package/src/analyzer/rsc.ts +18 -1
- package/src/analyzer/types.ts +17 -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/computeTemporalCoupling.ts +5 -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/views/__tests__/analyzerViews.test.ts +6 -0
- package/src/views/analyzerViews.ts +1 -6
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,
|
|
@@ -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
|
+
});
|
|
@@ -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', {
|
|
@@ -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')) });
|
|
@@ -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
|
|
|
@@ -118,6 +123,39 @@ describe('detectRscLeaks', () => {
|
|
|
118
123
|
expect(leaks[0]?.edge?.to).toBe('lib/db.ts');
|
|
119
124
|
});
|
|
120
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
|
+
|
|
121
159
|
it("flags a client component importing a module poisoned with 'server-only'", async () => {
|
|
122
160
|
const fs = createInMemoryFileSource('/repo', {
|
|
123
161
|
'package.json': JSON.stringify({ dependencies: { next: '^14.0.0' } }),
|
|
@@ -181,14 +219,14 @@ describe('detectRscLeaks', () => {
|
|
|
181
219
|
});
|
|
182
220
|
|
|
183
221
|
describe('rsc-leak end-to-end', () => {
|
|
184
|
-
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 () => {
|
|
185
223
|
const fs = createInMemoryFileSource('/repo', {
|
|
186
224
|
'package.json': JSON.stringify({ name: 'x' }),
|
|
187
225
|
'app/page.tsx':
|
|
188
226
|
"import { db } from '../lib/db';\nexport default function P(){ return db; }\n",
|
|
189
227
|
'app/Form.tsx':
|
|
190
228
|
"'use client';\nimport { db } from '../lib/db';\nexport default function F(){ return db; }\n",
|
|
191
|
-
'lib/db.ts': "'
|
|
229
|
+
'lib/db.ts': "import 'server-only';\nexport const db = 1;\n",
|
|
192
230
|
});
|
|
193
231
|
|
|
194
232
|
const scan = await analyze(fs);
|
|
@@ -198,4 +236,25 @@ describe('rsc-leak end-to-end', () => {
|
|
|
198
236
|
true,
|
|
199
237
|
);
|
|
200
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
|
+
});
|
|
201
260
|
});
|
|
@@ -14,7 +14,7 @@ import { createParserRegistry, isParseFailure } from './parsers';
|
|
|
14
14
|
import type { Framework } from './detect';
|
|
15
15
|
import { applyAlias, type PathAlias, type Resolver } from './resolve';
|
|
16
16
|
import { classifyKind, isInfra } from './classify';
|
|
17
|
-
import { classifyModuleRuntime } from './rsc';
|
|
17
|
+
import { classifyModuleRuntime, isServerActionsModule } from './rsc';
|
|
18
18
|
import type { ArchoraConfig } from '../config/frontScopeConfig';
|
|
19
19
|
|
|
20
20
|
export interface BuildGraphInput {
|
|
@@ -111,6 +111,7 @@ export async function buildGraph(input: BuildGraphInput): Promise<BuildGraphResu
|
|
|
111
111
|
...(p.imports.some((i) => i.specifier === 'server-only') ? { importsServerOnly: true } : {}),
|
|
112
112
|
...(p.imports.some((i) => i.specifier === 'client-only') ? { importsClientOnly: true } : {}),
|
|
113
113
|
}),
|
|
114
|
+
...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
|
|
114
115
|
}));
|
|
115
116
|
const parserFacts = parsed.map((p) => toParsedFileSummary(p, input.framework ?? 'unknown'));
|
|
116
117
|
|
package/src/analyzer/hotZones.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleId, ModuleMetrics, ModuleNode } from './types';
|
|
1
|
+
import type { ModuleId, ModuleMetrics, ModuleNode, ParsedFileSummary } from './types';
|
|
2
2
|
|
|
3
3
|
export interface RankHotZonesInput {
|
|
4
4
|
modules: ModuleNode[];
|
|
@@ -13,5 +13,97 @@ export function rankHotZones(input: RankHotZonesInput): ModuleId[] {
|
|
|
13
13
|
.map((m) => ({ id: m.id, score: metrics[m.id]?.hotnessScore ?? 0 }))
|
|
14
14
|
.filter((x) => x.score > 0);
|
|
15
15
|
candidates.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
16
|
-
|
|
16
|
+
// Rank the full hotness window first, then drop re-export barrels from it.
|
|
17
|
+
// A barrel's high fan-out is by design, not a risk — but excluding it from the
|
|
18
|
+
// candidate pool before the cut would only backfill the window with lower-
|
|
19
|
+
// signal modules. Removing it from the top-N keeps the surfaced set focused on
|
|
20
|
+
// genuine hot zones without inventing new ones.
|
|
21
|
+
const barrels = new Set(modules.filter((m) => m.isBarrel).map((m) => m.id));
|
|
22
|
+
return candidates
|
|
23
|
+
.slice(0, topN)
|
|
24
|
+
.map((c) => c.id)
|
|
25
|
+
.filter((id) => !barrels.has(id));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A barrel imports the things it re-exports, so it has a non-trivial fan-out;
|
|
29
|
+
// a leaf that defines its own exports has fan-out 0. Below this floor we keep
|
|
30
|
+
// the module even if it is thin (small multi-export utils re-export nothing).
|
|
31
|
+
const BARREL_MIN_FANOUT = 3;
|
|
32
|
+
// Barrels expose a real aggregation surface — too few pass-throughs and it is
|
|
33
|
+
// just a normal module with a couple of imports.
|
|
34
|
+
const BARREL_MIN_SURFACE = 5;
|
|
35
|
+
// Source lines per surface item. Barrels are thin glue (≈1–2 loc per
|
|
36
|
+
// re-export); real modules carry many loc per export.
|
|
37
|
+
const BARREL_MAX_LOC_PER_SURFACE = 4;
|
|
38
|
+
|
|
39
|
+
export interface MarkBarrelModulesInput {
|
|
40
|
+
modules: ModuleNode[];
|
|
41
|
+
metrics: Record<ModuleId, ModuleMetrics>;
|
|
42
|
+
parserFacts?: ParsedFileSummary[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tag re-export barrels so hot-zone ranking can skip them. Runs post-graph
|
|
47
|
+
* because barrel detection needs the fan-out metric plus the module's loc and
|
|
48
|
+
* export count. Mutates `ModuleNode.isBarrel` in place; additive, leaves every
|
|
49
|
+
* other field untouched.
|
|
50
|
+
*/
|
|
51
|
+
export function markBarrelModules(input: MarkBarrelModulesInput): void {
|
|
52
|
+
const { modules, metrics, parserFacts } = input;
|
|
53
|
+
const factsByModule = indexParserFacts(parserFacts);
|
|
54
|
+
for (const m of modules) {
|
|
55
|
+
if (isBarrelModule(m, metrics[m.id], factsByModule)) m.isBarrel = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A re-export barrel (e.g. `packages/core/index.ts` re-exporting every module)
|
|
61
|
+
* has high fan-out by design — that is its job, not a risk, and "split this
|
|
62
|
+
* module" is bad advice. Detected by THINNESS rather than a re-export ratio:
|
|
63
|
+
* the parser almost never populates `ExportFact.source` (star re-exports, and
|
|
64
|
+
* named re-exports it can't resolve), so the ratio reads 0 even for obvious
|
|
65
|
+
* barrels. A barrel instead imports the things it passes through (fan-out),
|
|
66
|
+
* exposes a wide surface, and carries very few source lines per surface item —
|
|
67
|
+
* it is thin glue, not a module with real logic.
|
|
68
|
+
*/
|
|
69
|
+
function isBarrelModule(
|
|
70
|
+
module: ModuleNode,
|
|
71
|
+
metrics: ModuleMetrics | undefined,
|
|
72
|
+
factsByModule: Map<string, ParsedFileSummary>,
|
|
73
|
+
): boolean {
|
|
74
|
+
if (!metrics) return false;
|
|
75
|
+
const fanOut = metrics.fanOut;
|
|
76
|
+
if (fanOut < BARREL_MIN_FANOUT) return false;
|
|
77
|
+
const summary = matchParserFacts(factsByModule, module.id);
|
|
78
|
+
const ownExportCount = module.exports.length || (summary?.exports.length ?? 0);
|
|
79
|
+
const surface = Math.max(fanOut, ownExportCount);
|
|
80
|
+
if (surface < BARREL_MIN_SURFACE) return false;
|
|
81
|
+
const loc = module.loc || summary?.loc || 0;
|
|
82
|
+
return loc < surface * BARREL_MAX_LOC_PER_SURFACE;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function indexParserFacts(
|
|
86
|
+
parserFacts: ParsedFileSummary[] | undefined,
|
|
87
|
+
): Map<string, ParsedFileSummary> {
|
|
88
|
+
const out = new Map<string, ParsedFileSummary>();
|
|
89
|
+
if (!parserFacts) return out;
|
|
90
|
+
for (const f of parserFacts) out.set(normalizeRel(f.relPath), f);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function matchParserFacts(
|
|
95
|
+
factsByModule: Map<string, ParsedFileSummary>,
|
|
96
|
+
moduleId: ModuleId,
|
|
97
|
+
): ParsedFileSummary | undefined {
|
|
98
|
+
const id = normalizeRel(moduleId);
|
|
99
|
+
const direct = factsByModule.get(id);
|
|
100
|
+
if (direct) return direct;
|
|
101
|
+
for (const [rel, f] of factsByModule) {
|
|
102
|
+
if (id.endsWith(`/${rel}`) || rel.endsWith(`/${id}`)) return f;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeRel(p: string): string {
|
|
108
|
+
return p.replace(/^\.\//u, '');
|
|
17
109
|
}
|
|
@@ -26,7 +26,7 @@ import { createResolver } from './resolve';
|
|
|
26
26
|
import { createParserRegistry, isParseFailure } from './parsers';
|
|
27
27
|
import { classifyKind, isInfra } from './classify';
|
|
28
28
|
import { isNuxtComposablePath } from './buildGraph';
|
|
29
|
-
import { classifyModuleRuntime, detectRscLeaks } from './rsc';
|
|
29
|
+
import { classifyModuleRuntime, detectRscLeaks, isServerActionsModule } from './rsc';
|
|
30
30
|
import { detectCycles } from './cycles';
|
|
31
31
|
import { computeMetrics } from './metrics';
|
|
32
32
|
import { rankHotZones } from './hotZones';
|
|
@@ -191,6 +191,7 @@ export async function incrementalAnalyze(input: IncrementalAnalyzeInput): Promis
|
|
|
191
191
|
? { importsClientOnly: true }
|
|
192
192
|
: {}),
|
|
193
193
|
}),
|
|
194
|
+
...(isServerActionsModule(p.directives) ? { isServerActions: true } : {}),
|
|
194
195
|
});
|
|
195
196
|
}
|
|
196
197
|
const updatedModuleIds = new Set(updatedModules.map((m) => m.id));
|
package/src/analyzer/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { detectFramework, type Framework } from './detect';
|
|
|
14
14
|
import { detectCycles } from './cycles';
|
|
15
15
|
import { countBrokenCycles, parseEdgeKey } from './feedbackArcSet';
|
|
16
16
|
import { computeMetrics } from './metrics';
|
|
17
|
-
import { rankHotZones } from './hotZones';
|
|
17
|
+
import { markBarrelModules, rankHotZones } from './hotZones';
|
|
18
18
|
import { detectLayerViolations } from './layers';
|
|
19
19
|
import { computeArchDebt } from './archDebt';
|
|
20
20
|
import { computeRecommendations } from './recommendations';
|
|
@@ -181,6 +181,7 @@ export async function analyze(
|
|
|
181
181
|
if (entries.includes(m.id) && m.kind === 'unknown') m.kind = 'entry';
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
markBarrelModules({ modules, metrics, parserFacts });
|
|
184
185
|
const hotZones = rankHotZones({ modules, metrics, topN: options.topHotZones ?? 10 });
|
|
185
186
|
const layerViolations = detectLayerViolations(modules, edges, config.layerOverrides);
|
|
186
187
|
const archDebt = computeArchDebt({
|