@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
@@ -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(id: string, runtime: ModuleNode['runtime'] = 'shared'): ModuleNode {
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': "'use server';\nexport const db = 1;\n",
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
  });
@@ -8,20 +8,39 @@ interface Inputs {
8
8
  hotZoneCount: number;
9
9
  }
10
10
 
11
- // composite arch-debt 0..100 (higher = worse).
12
- // weights: cycles 35 / layers 30 / coupling 20 / hot zones 15. each saturates.
11
+ // Composite **heuristic summary** arch-debt 0..100 (higher = worse). Not a
12
+ // measurement but a weighted index of four proven components. Weights are ordered
13
+ // by structural severity and cost-to-fix (not data-calibrated — that needs a
14
+ // labeled corpus, see block A of the plan):
15
+ // - cycles 0.35 — heaviest and most expensive to fix, breaks isolation.
16
+ // - layers 0.30 — architectural erosion, but more local than a cycle.
17
+ // - coupling 0.20 — a continuous soft signal (instability), not a bug per se.
18
+ // - hotZones 0.15 — derived signal, partly overlaps coupling → lowest.
19
+ // Sum = 1.0. Each component saturates, normalized to project size. Surfaced in
20
+ // UI/reports as a "grade summary", not as a precise metric.
21
+ const DEBT_WEIGHTS = { cycles: 0.35, layers: 0.3, coupling: 0.2, hotZones: 0.15 } as const;
22
+
23
+ // Saturation divisors: the share of project modules at which a component nears
24
+ // saturation. cycles: ~5% of modules in cycles is already "bad"; layers stricter (~3%).
25
+ const CYCLE_SATURATION_RATIO = 0.05;
26
+ const LAYER_SATURATION_RATIO = 0.03;
27
+
28
+ // Letter-grade thresholds (score → grade). Ordinal cut-offs of the heuristic
29
+ // summary: <15 A (healthy) … ≥70 F (systemic debt).
30
+ const GRADE_THRESHOLDS = { A: 15, B: 30, C: 50, D: 70 } as const;
31
+
13
32
  export function computeArchDebt(inputs: Inputs): ArchDebt {
14
33
  const realModules = inputs.modules.filter((m) => !m.isInfra);
15
34
  const moduleCount = Math.max(1, realModules.length);
16
35
 
17
36
  const cycleWeight = inputs.cycles.reduce((acc, c) => acc + (c.severity === 'direct' ? 2 : 1), 0);
18
- const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount * 0.05));
37
+ const cycleScore = saturate(cycleWeight / Math.max(1, moduleCount * CYCLE_SATURATION_RATIO));
19
38
 
20
39
  const violationWeight = inputs.layerViolations.reduce(
21
40
  (acc, v) => acc + (v.severity === 'error' ? 3 : 1),
22
41
  0,
23
42
  );
24
- const layerScore = saturate(violationWeight / Math.max(1, moduleCount * 0.03));
43
+ const layerScore = saturate(violationWeight / Math.max(1, moduleCount * LAYER_SATURATION_RATIO));
25
44
 
26
45
  const hotScore = saturate((inputs.hotZoneCount * 10) / moduleCount);
27
46
 
@@ -36,7 +55,11 @@ export function computeArchDebt(inputs: Inputs): ArchDebt {
36
55
  const couplingScore = instCount > 0 ? instSum / instCount : 0;
37
56
 
38
57
  const score = clamp(
39
- 100 * (cycleScore * 0.35 + layerScore * 0.3 + hotScore * 0.15 + couplingScore * 0.2),
58
+ 100 *
59
+ (cycleScore * DEBT_WEIGHTS.cycles +
60
+ layerScore * DEBT_WEIGHTS.layers +
61
+ hotScore * DEBT_WEIGHTS.hotZones +
62
+ couplingScore * DEBT_WEIGHTS.coupling),
40
63
  );
41
64
 
42
65
  return {
@@ -60,9 +83,9 @@ function clamp(x: number): number {
60
83
  }
61
84
 
62
85
  function gradeOf(score: number): ArchDebt['grade'] {
63
- if (score < 15) return 'A';
64
- if (score < 30) return 'B';
65
- if (score < 50) return 'C';
66
- if (score < 70) return 'D';
86
+ if (score < GRADE_THRESHOLDS.A) return 'A';
87
+ if (score < GRADE_THRESHOLDS.B) return 'B';
88
+ if (score < GRADE_THRESHOLDS.C) return 'C';
89
+ if (score < GRADE_THRESHOLDS.D) return 'D';
67
90
  return 'F';
68
91
  }