@archora/core 1.1.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 (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. package/src/views/analyzerViews.ts +1294 -0
@@ -0,0 +1,294 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createNodeFsFileSource } from '../sources/nodeFsFileSource';
3
+ import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
4
+ import { createResolver, parseTsconfigPaths, parseViteAliases } from '../resolve';
5
+ import { loadAliases } from '../loadAliases';
6
+ import { fixturePath } from './_paths';
7
+ import type { ProjectRef } from '../types';
8
+
9
+ describe('resolve', () => {
10
+ it('parses tsconfig paths', () => {
11
+ const aliases = parseTsconfigPaths(
12
+ JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } }),
13
+ );
14
+ expect(aliases).toEqual([{ prefix: '@', targets: ['src'] }]);
15
+ });
16
+
17
+ it('resolves @/ alias to src/*.ts and *.vue', async () => {
18
+ const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
19
+ const r = createResolver(src, { aliases: parseTsconfigPaths(await src.read('tsconfig.json')) });
20
+ expect(await r.resolve('@/utils/date', 'src/main.ts')).toBe('src/utils/date.ts');
21
+ expect(await r.resolve('@/components/UserCard.vue', 'src/pages/Home.vue')).toBe(
22
+ 'src/components/UserCard.vue',
23
+ );
24
+ expect(await r.resolve('@/router', 'src/main.ts')).toBe('src/router/index.ts');
25
+ });
26
+
27
+ it('resolves jsconfig paths', async () => {
28
+ const src = createInMemoryFileSource('/p', {
29
+ 'jsconfig.json': JSON.stringify({
30
+ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } },
31
+ }),
32
+ 'src/main.js': "import util from '@/lib/util';",
33
+ 'src/lib/util.js': 'export default 1;',
34
+ });
35
+ const project: ProjectRef = {
36
+ id: 'p',
37
+ name: 'p',
38
+ rootPath: '/p',
39
+ detectedFramework: 'unknown',
40
+ tsconfigPath: 'jsconfig.json',
41
+ };
42
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
43
+ expect(await r.resolve('@/lib/util', 'src/main.js')).toBe('src/lib/util.js');
44
+ });
45
+
46
+ it('resolves relative imports', async () => {
47
+ const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-cycles') });
48
+ const r = createResolver(src, { aliases: [] });
49
+ expect(await r.resolve('./b', 'src/a.ts')).toBe('src/b.ts');
50
+ });
51
+
52
+ // real-world: `import { card } from '..'` from src/modules/card/buttons/x.ts
53
+ // should land on src/modules/card/index.ts. previously misclassified as
54
+ // a bare specifier and routed through alias resolution (returning null).
55
+ it('resolves "." and ".." to the parent folder index', async () => {
56
+ const src = createInMemoryFileSource('/p', {
57
+ 'src/modules/card/index.ts': 'export const card = 1;',
58
+ 'src/modules/card/buttons/cancel.ts': "import { card } from '..';",
59
+ 'src/modules/card/buttons/index.ts': 'export {};',
60
+ 'src/modules/card/buttons/sibling.ts': "import './';",
61
+ });
62
+ const r = createResolver(src, { aliases: [] });
63
+ expect(await r.resolve('..', 'src/modules/card/buttons/cancel.ts')).toBe(
64
+ 'src/modules/card/index.ts',
65
+ );
66
+ expect(await r.resolve('./', 'src/modules/card/buttons/sibling.ts')).toBe(
67
+ 'src/modules/card/buttons/index.ts',
68
+ );
69
+ expect(await r.resolve('.', 'src/modules/card/buttons/sibling.ts')).toBe(
70
+ 'src/modules/card/buttons/index.ts',
71
+ );
72
+ });
73
+
74
+ it('resolves index.mjs and index.cjs folder imports', async () => {
75
+ const src = createInMemoryFileSource('/p', {
76
+ 'src/esm/index.mjs': 'export const x = 1;',
77
+ 'src/cjs/index.cjs': 'exports.y = 1;',
78
+ 'src/main.ts': "import './esm'; import './cjs';",
79
+ });
80
+ const r = createResolver(src, { aliases: [] });
81
+ expect(await r.resolve('./esm', 'src/main.ts')).toBe('src/esm/index.mjs');
82
+ expect(await r.resolve('./cjs', 'src/main.ts')).toBe('src/cjs/index.cjs');
83
+ });
84
+
85
+ it('loads SvelteKit and Nuxt default aliases', async () => {
86
+ const src = createInMemoryFileSource('/p', {
87
+ 'src/lib/api.ts': 'export const api = 1;',
88
+ 'components/Card.vue': '<template />',
89
+ });
90
+ const svelteProject: ProjectRef = {
91
+ id: 'p',
92
+ name: 'p',
93
+ rootPath: '/p',
94
+ detectedFramework: 'svelte',
95
+ };
96
+ const nuxtProject: ProjectRef = {
97
+ id: 'p',
98
+ name: 'p',
99
+ rootPath: '/p',
100
+ detectedFramework: 'nuxt',
101
+ };
102
+ const svelte = createResolver(src, { aliases: await loadAliases(src, svelteProject) });
103
+ const nuxt = createResolver(src, { aliases: await loadAliases(src, nuxtProject) });
104
+ expect(await svelte.resolve('$lib/api', 'src/routes/+page.svelte')).toBe('src/lib/api.ts');
105
+ expect(await nuxt.resolve('~/components/Card.vue', 'app.vue')).toBe('components/Card.vue');
106
+ expect(await nuxt.resolve('@/components/Card.vue', 'app.vue')).toBe('components/Card.vue');
107
+ });
108
+
109
+ it('loads workspace package aliases from nested package.json files', async () => {
110
+ const src = createInMemoryFileSource('/p', {
111
+ 'packages/ui/package.json': JSON.stringify({ name: '@acme/ui' }),
112
+ 'packages/ui/src/index.ts': 'export const Button = 1;',
113
+ 'packages/ui/src/theme.ts': 'export const theme = 1;',
114
+ 'src/main.ts': "import { Button } from '@acme/ui'; import { theme } from '@acme/ui/theme';",
115
+ });
116
+ const project: ProjectRef = {
117
+ id: 'p',
118
+ name: 'p',
119
+ rootPath: '/p',
120
+ detectedFramework: 'unknown',
121
+ };
122
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
123
+ expect(await r.resolve('@acme/ui', 'src/main.ts')).toBe('packages/ui/src/index.ts');
124
+ expect(await r.resolve('@acme/ui/theme', 'src/main.ts')).toBe('packages/ui/src/theme.ts');
125
+ });
126
+
127
+ it('resolves workspace package exports and blocks private subpaths when exports exist', async () => {
128
+ const src = createInMemoryFileSource('/p', {
129
+ 'packages/ui/package.json': JSON.stringify({
130
+ name: '@acme/ui',
131
+ exports: {
132
+ '.': { import: './src/index.ts', default: './src/index.ts' },
133
+ './theme': './src/theme.ts',
134
+ },
135
+ }),
136
+ 'packages/ui/src/index.ts': 'export const Button = 1;',
137
+ 'packages/ui/src/theme.ts': 'export const theme = 1;',
138
+ 'packages/ui/src/private.ts': 'export const privateApi = 1;',
139
+ 'src/main.ts': '',
140
+ });
141
+ const project: ProjectRef = {
142
+ id: 'p',
143
+ name: 'p',
144
+ rootPath: '/p',
145
+ detectedFramework: 'unknown',
146
+ };
147
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
148
+ expect(await r.resolve('@acme/ui', 'src/main.ts')).toBe('packages/ui/src/index.ts');
149
+ expect(await r.resolve('@acme/ui/theme', 'src/main.ts')).toBe('packages/ui/src/theme.ts');
150
+ expect(await r.resolve('@acme/ui/private', 'src/main.ts')).toBeNull();
151
+ });
152
+
153
+ it('falls through conditional exports when the first condition target is not a source file', async () => {
154
+ const src = createInMemoryFileSource('/p', {
155
+ 'packages/ui/package.json': JSON.stringify({
156
+ name: '@acme/ui',
157
+ exports: {
158
+ '.': {
159
+ types: './dist/index.d.ts',
160
+ import: './src/index.ts',
161
+ default: './src/fallback.ts',
162
+ },
163
+ },
164
+ }),
165
+ 'packages/ui/src/index.ts': 'export const Button = 1;',
166
+ 'packages/ui/src/fallback.ts': 'export const fallback = 1;',
167
+ 'src/main.ts': '',
168
+ });
169
+ const project: ProjectRef = {
170
+ id: 'p',
171
+ name: 'p',
172
+ rootPath: '/p',
173
+ detectedFramework: 'unknown',
174
+ };
175
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
176
+ expect(await r.resolve('@acme/ui', 'src/main.ts')).toBe('packages/ui/src/index.ts');
177
+ });
178
+
179
+ it('resolves package root conditional exports without subpath keys', async () => {
180
+ const src = createInMemoryFileSource('/p', {
181
+ 'packages/ui/package.json': JSON.stringify({
182
+ name: '@acme/ui',
183
+ exports: {
184
+ import: './src/index.ts',
185
+ default: './src/fallback.ts',
186
+ },
187
+ }),
188
+ 'packages/ui/src/index.ts': 'export const Button = 1;',
189
+ 'packages/ui/src/fallback.ts': 'export const fallback = 1;',
190
+ 'src/main.ts': '',
191
+ });
192
+ const project: ProjectRef = {
193
+ id: 'p',
194
+ name: 'p',
195
+ rootPath: '/p',
196
+ detectedFramework: 'unknown',
197
+ };
198
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
199
+ expect(await r.resolve('@acme/ui', 'src/main.ts')).toBe('packages/ui/src/index.ts');
200
+ expect(await r.resolve('@acme/ui/private', 'src/main.ts')).toBeNull();
201
+ });
202
+
203
+ it('resolves package imports from root package.json', async () => {
204
+ const src = createInMemoryFileSource('/p', {
205
+ 'package.json': JSON.stringify({
206
+ imports: {
207
+ '#internal': { import: './src/internal.ts', default: './src/internal.ts' },
208
+ },
209
+ }),
210
+ 'src/internal.ts': 'export const internal = 1;',
211
+ 'src/main.ts': '',
212
+ });
213
+ const project: ProjectRef = {
214
+ id: 'p',
215
+ name: 'p',
216
+ rootPath: '/p',
217
+ detectedFramework: 'unknown',
218
+ };
219
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
220
+ expect(await r.resolve('#internal', 'src/main.ts')).toBe('src/internal.ts');
221
+ });
222
+
223
+ it('resolves workspace package exports and imports wildcard entries', async () => {
224
+ const src = createInMemoryFileSource('/p', {
225
+ 'package.json': JSON.stringify({
226
+ imports: {
227
+ '#domain/*': './src/domain/*.ts',
228
+ },
229
+ }),
230
+ 'packages/ui/package.json': JSON.stringify({
231
+ name: '@acme/ui',
232
+ exports: {
233
+ '.': './src/index.ts',
234
+ './components/*': './src/components/*.vue',
235
+ },
236
+ }),
237
+ 'packages/ui/src/index.ts': 'export const ui = 1;',
238
+ 'packages/ui/src/components/Button.vue': '<template />',
239
+ 'packages/ui/src/private/Button.vue': '<template />',
240
+ 'src/domain/order.ts': 'export const order = 1;',
241
+ 'src/main.ts': '',
242
+ });
243
+ const project: ProjectRef = {
244
+ id: 'p',
245
+ name: 'p',
246
+ rootPath: '/p',
247
+ detectedFramework: 'unknown',
248
+ };
249
+ const r = createResolver(src, { aliases: await loadAliases(src, project) });
250
+ expect(await r.resolve('@acme/ui/components/Button', 'src/main.ts')).toBe(
251
+ 'packages/ui/src/components/Button.vue',
252
+ );
253
+ expect(await r.resolve('@acme/ui/private/Button', 'src/main.ts')).toBeNull();
254
+ expect(await r.resolve('#domain/order', 'src/main.ts')).toBe('src/domain/order.ts');
255
+ });
256
+
257
+ it('parses vite alias object form with path.resolve', () => {
258
+ const aliases = parseViteAliases(`
259
+ import { defineConfig } from 'vite';
260
+ import path from 'node:path';
261
+ export default defineConfig({
262
+ resolve: {
263
+ alias: {
264
+ '@': path.resolve(__dirname, 'src'),
265
+ '~ui': '/packages/ui/src',
266
+ },
267
+ },
268
+ });
269
+ `);
270
+ const map = Object.fromEntries(aliases.map((a) => [a.prefix, a.targets]));
271
+ expect(map['@']).toEqual(['src']);
272
+ expect(map['~ui']).toEqual(['/packages/ui/src']);
273
+ });
274
+
275
+ it('parses vite alias array form with fileURLToPath', () => {
276
+ const aliases = parseViteAliases(`
277
+ import { fileURLToPath, URL } from 'node:url';
278
+ export default {
279
+ resolve: {
280
+ alias: [
281
+ { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
282
+ ],
283
+ },
284
+ };
285
+ `);
286
+ expect(aliases).toEqual([{ prefix: '@', targets: ['src'] }]);
287
+ });
288
+
289
+ it('returns null for unresolved bare imports', async () => {
290
+ const src = await createNodeFsFileSource({ rootPath: fixturePath('sample-vue-app') });
291
+ const r = createResolver(src, { aliases: [] });
292
+ expect(await r.resolve('nonexistent-pkg', 'src/main.ts')).toBeNull();
293
+ });
294
+ });
@@ -0,0 +1,130 @@
1
+ // RSC / server-client boundary detection.
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { classifyModuleRuntime, detectRscLeaks } from '../rsc';
6
+ import { analyze } from '../index';
7
+ import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
8
+ import type { DependencyEdge, ModuleNode } from '../types';
9
+
10
+ function mod(id: string, runtime: ModuleNode['runtime'] = 'shared'): ModuleNode {
11
+ return {
12
+ id,
13
+ absPath: id,
14
+ kind: 'unknown',
15
+ language: 'ts',
16
+ loc: 10,
17
+ exports: [],
18
+ isInfra: false,
19
+ runtime,
20
+ };
21
+ }
22
+
23
+ function edge(from: string, to: string, kind: DependencyEdge['kind'] = 'static'): DependencyEdge {
24
+ return { from, to, kind, specifier: to, resolved: true };
25
+ }
26
+
27
+ describe('classifyModuleRuntime', () => {
28
+ it('directive prologue beats path conventions', () => {
29
+ expect(
30
+ classifyModuleRuntime({
31
+ relPath: 'app/components/Form.tsx',
32
+ framework: 'next',
33
+ directives: ['use client'],
34
+ }),
35
+ ).toBe('client');
36
+
37
+ expect(
38
+ classifyModuleRuntime({
39
+ relPath: 'lib/util.ts',
40
+ framework: 'unknown',
41
+ directives: ['use server'],
42
+ }),
43
+ ).toBe('server');
44
+ });
45
+
46
+ it('Next App Router defaults to server, pages/api server-only', () => {
47
+ expect(classifyModuleRuntime({ relPath: 'app/page.tsx', framework: 'next' })).toBe('server');
48
+ expect(classifyModuleRuntime({ relPath: 'pages/api/hello.ts', framework: 'next' })).toBe(
49
+ 'server',
50
+ );
51
+ expect(classifyModuleRuntime({ relPath: 'lib/util.ts', framework: 'next' })).toBe('shared');
52
+ });
53
+
54
+ it('Nuxt server/ folder is server', () => {
55
+ expect(classifyModuleRuntime({ relPath: 'server/api/hello.ts', framework: 'nuxt' })).toBe(
56
+ 'server',
57
+ );
58
+ expect(classifyModuleRuntime({ relPath: 'pages/index.vue', framework: 'nuxt' })).toBe('shared');
59
+ });
60
+
61
+ it('SvelteKit conventions: +server.ts, *.server.ts, +page.svelte', () => {
62
+ expect(
63
+ classifyModuleRuntime({ relPath: 'src/routes/api/+server.ts', framework: 'svelte' }),
64
+ ).toBe('server');
65
+ expect(
66
+ classifyModuleRuntime({
67
+ relPath: 'src/routes/dashboard/+page.server.ts',
68
+ framework: 'svelte',
69
+ }),
70
+ ).toBe('server');
71
+ expect(classifyModuleRuntime({ relPath: 'src/routes/+page.svelte', framework: 'svelte' })).toBe(
72
+ 'client',
73
+ );
74
+ expect(classifyModuleRuntime({ relPath: 'src/routes/+page.ts', framework: 'svelte' })).toBe(
75
+ 'shared',
76
+ );
77
+ });
78
+ });
79
+
80
+ describe('detectRscLeaks', () => {
81
+ it('flags client -> server but allows server -> client (RSC composition is legal)', () => {
82
+ const modules = [
83
+ mod('app/page.tsx', 'server'),
84
+ mod('app/Form.tsx', 'client'),
85
+ mod('lib/db.ts', 'server'),
86
+ mod('components/Button.tsx', 'client'),
87
+ ];
88
+ const edges = [
89
+ edge('app/Form.tsx', 'lib/db.ts'), // leak
90
+ edge('app/page.tsx', 'components/Button.tsx'), // legal: server renders client
91
+ edge('app/page.tsx', 'lib/db.ts'), // legal: server -> server
92
+ ];
93
+
94
+ const leaks = detectRscLeaks({ modules, edges });
95
+ expect(leaks).toHaveLength(1);
96
+ expect(leaks[0]?.kind).toBe('rsc-leak');
97
+ expect(leaks[0]?.edge?.from).toBe('app/Form.tsx');
98
+ expect(leaks[0]?.edge?.to).toBe('lib/db.ts');
99
+ });
100
+
101
+ it('does not flag type-only edges or shared modules', () => {
102
+ const modules = [mod('a.ts', 'client'), mod('b.ts', 'server'), mod('shared.ts', 'shared')];
103
+ const edges = [
104
+ edge('a.ts', 'b.ts', 'type-only'),
105
+ edge('a.ts', 'shared.ts'),
106
+ edge('b.ts', 'shared.ts'),
107
+ ];
108
+ expect(detectRscLeaks({ modules, edges })).toHaveLength(0);
109
+ });
110
+ });
111
+
112
+ describe('rsc-leak end-to-end', () => {
113
+ it('surfaces client->server import as a contract violation in analyze()', async () => {
114
+ const fs = createInMemoryFileSource('/repo', {
115
+ 'package.json': JSON.stringify({ name: 'x' }),
116
+ 'app/page.tsx':
117
+ "import { db } from '../lib/db';\nexport default function P(){ return db; }\n",
118
+ 'app/Form.tsx':
119
+ "'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",
121
+ });
122
+
123
+ const scan = await analyze(fs);
124
+ const leaks = scan.contractViolations.filter((v) => v.kind === 'rsc-leak');
125
+ expect(leaks.length).toBeGreaterThan(0);
126
+ expect(leaks.some((l) => l.edge?.from === 'app/Form.tsx' && l.edge?.to === 'lib/db.ts')).toBe(
127
+ true,
128
+ );
129
+ });
130
+ });