@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,315 @@
1
+ import ts from 'typescript';
2
+ import type { FileSource } from './fileSource';
3
+
4
+ const EXTS_ORDERED = ['.ts', '.tsx', '.vue', '.svelte', '.js', '.jsx', '.mjs', '.cjs'] as const;
5
+ const INDEX_BASES = [
6
+ 'index.ts',
7
+ 'index.tsx',
8
+ 'index.vue',
9
+ 'index.svelte',
10
+ 'index.js',
11
+ 'index.jsx',
12
+ 'index.mjs',
13
+ 'index.cjs',
14
+ ] as const;
15
+
16
+ export interface PathAlias {
17
+ prefix: string;
18
+ targets: string[];
19
+ exact?: boolean;
20
+ }
21
+
22
+ export interface ResolverConfig {
23
+ aliases: PathAlias[];
24
+ }
25
+
26
+ // recognises object/array alias forms. only static expressions are kept
27
+ // (path.resolve/join + fileURLToPath(new URL(...))); we never eval config.
28
+ export function parseViteAliases(content: string): PathAlias[] {
29
+ const sf = ts.createSourceFile('vite.config.ts', content, ts.ScriptTarget.Latest, true);
30
+ const out: PathAlias[] = [];
31
+
32
+ function visit(node: ts.Node): void {
33
+ if (
34
+ ts.isPropertyAssignment(node) &&
35
+ isNameEq(node.name, 'alias') &&
36
+ (ts.isObjectLiteralExpression(node.initializer) ||
37
+ ts.isArrayLiteralExpression(node.initializer))
38
+ ) {
39
+ collectAliasInit(node.initializer, out);
40
+ }
41
+ ts.forEachChild(node, visit);
42
+ }
43
+ visit(sf);
44
+ return dedupeAliases(out);
45
+ }
46
+
47
+ function collectAliasInit(init: ts.Expression, out: PathAlias[]): void {
48
+ if (ts.isObjectLiteralExpression(init)) {
49
+ for (const prop of init.properties) {
50
+ if (!ts.isPropertyAssignment(prop)) continue;
51
+ const key = aliasKeyOf(prop.name);
52
+ if (key === null) continue;
53
+ const target = staticPathOf(prop.initializer);
54
+ if (target === null) continue;
55
+ out.push({ prefix: trimSuffix(trimSuffix(key, '/*'), '/'), targets: [target] });
56
+ }
57
+ return;
58
+ }
59
+ if (ts.isArrayLiteralExpression(init)) {
60
+ for (const el of init.elements) {
61
+ if (!ts.isObjectLiteralExpression(el)) continue;
62
+ let find: string | null = null;
63
+ let replacement: string | null = null;
64
+ for (const prop of el.properties) {
65
+ if (!ts.isPropertyAssignment(prop)) continue;
66
+ if (isNameEq(prop.name, 'find')) {
67
+ if (ts.isStringLiteralLike(prop.initializer)) find = prop.initializer.text;
68
+ } else if (isNameEq(prop.name, 'replacement')) {
69
+ replacement = staticPathOf(prop.initializer);
70
+ }
71
+ }
72
+ if (find !== null && replacement !== null) {
73
+ out.push({ prefix: trimSuffix(trimSuffix(find, '/*'), '/'), targets: [replacement] });
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ function aliasKeyOf(name: ts.PropertyName): string | null {
80
+ if (ts.isStringLiteralLike(name)) return name.text;
81
+ if (ts.isIdentifier(name)) return name.text;
82
+ return null;
83
+ }
84
+
85
+ function isNameEq(name: ts.PropertyName, value: string): boolean {
86
+ if (ts.isIdentifier(name)) return name.text === value;
87
+ if (ts.isStringLiteralLike(name)) return name.text === value;
88
+ return false;
89
+ }
90
+
91
+ function staticPathOf(node: ts.Expression): string | null {
92
+ if (ts.isStringLiteralLike(node)) {
93
+ return node.text.replace(/\/+$/u, '');
94
+ }
95
+ // path.resolve(__dirname, 'x', 'y') / path.join(...)
96
+ if (ts.isCallExpression(node)) {
97
+ const callee = node.expression;
98
+ if (ts.isPropertyAccessExpression(callee)) {
99
+ const obj = callee.expression;
100
+ const method = callee.name.text;
101
+ const isPath = ts.isIdentifier(obj) && obj.text === 'path';
102
+ if (isPath && (method === 'resolve' || method === 'join')) {
103
+ return joinStaticArgs(node.arguments);
104
+ }
105
+ // fileURLToPath(new URL('./src', import.meta.url))
106
+ if (
107
+ ts.isIdentifier(callee.expression) &&
108
+ callee.name.text === 'fileURLToPath' &&
109
+ node.arguments.length === 1 &&
110
+ ts.isNewExpression(node.arguments[0]!) &&
111
+ node.arguments[0]!.arguments &&
112
+ ts.isStringLiteralLike(node.arguments[0]!.arguments[0]!)
113
+ ) {
114
+ const tail = (node.arguments[0]!.arguments[0]! as ts.StringLiteralLike).text;
115
+ return trimSuffix(tail.replace(/^\.\//u, ''), '/');
116
+ }
117
+ }
118
+ if (ts.isIdentifier(callee) && callee.text === 'fileURLToPath') {
119
+ const arg = node.arguments[0];
120
+ if (
121
+ arg &&
122
+ ts.isNewExpression(arg) &&
123
+ arg.arguments &&
124
+ arg.arguments[0] &&
125
+ ts.isStringLiteralLike(arg.arguments[0])
126
+ ) {
127
+ const tail = arg.arguments[0].text;
128
+ return trimSuffix(tail.replace(/^\.\//u, ''), '/');
129
+ }
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function joinStaticArgs(args: ts.NodeArray<ts.Expression>): string | null {
136
+ // first arg may be __dirname / process.cwd() - skip it; rest must be literals
137
+ // FIXME: this misses cases where the cwd-ish first arg is itself a const,
138
+ // e.g. `const ROOT = __dirname; path.join(ROOT, 'foo')`. fine for now.
139
+ const parts: string[] = [];
140
+ for (let i = 0; i < args.length; i++) {
141
+ const a = args[i]!;
142
+ if (i === 0) {
143
+ if (
144
+ ts.isIdentifier(a) ||
145
+ (ts.isCallExpression(a) &&
146
+ ts.isPropertyAccessExpression(a.expression) &&
147
+ a.expression.name.text === 'cwd')
148
+ ) {
149
+ continue;
150
+ }
151
+ if (ts.isStringLiteralLike(a)) {
152
+ parts.push(a.text);
153
+ continue;
154
+ }
155
+ return null;
156
+ }
157
+ if (!ts.isStringLiteralLike(a)) return null;
158
+ parts.push(a.text);
159
+ }
160
+ return parts.join('/').replace(/\/+/gu, '/').replace(/\/+$/u, '');
161
+ }
162
+
163
+ function dedupeAliases(list: PathAlias[]): PathAlias[] {
164
+ const seen = new Set<string>();
165
+ const out: PathAlias[] = [];
166
+ for (const a of list) {
167
+ const key = `${a.prefix}\u0001${a.targets.join('|')}`;
168
+ if (seen.has(key)) continue;
169
+ seen.add(key);
170
+ out.push(a);
171
+ }
172
+ return out.sort((a, b) => b.prefix.length - a.prefix.length);
173
+ }
174
+
175
+ export function parseTsconfigPaths(content: string): PathAlias[] {
176
+ const parsed = ts.parseConfigFileTextToJson('tsconfig.json', content);
177
+ if (parsed.error || !parsed.config) return [];
178
+ const config = parsed.config as {
179
+ compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> };
180
+ };
181
+ const opts = config.compilerOptions;
182
+ if (!opts?.paths) return [];
183
+
184
+ const baseUrl = (opts.baseUrl ?? '.').replace(/\/+$/u, '');
185
+ const aliases: PathAlias[] = [];
186
+ for (const [key, list] of Object.entries(opts.paths)) {
187
+ const prefix = trimSuffix(key, '/*');
188
+ const targets = list.map((t) => joinPosix(baseUrl, trimSuffix(t, '/*')));
189
+ aliases.push({ prefix, targets });
190
+ }
191
+ aliases.sort((a, b) => b.prefix.length - a.prefix.length);
192
+ return aliases;
193
+ }
194
+
195
+ export interface Resolver {
196
+ resolve(specifier: string, fromRel: string): Promise<string | null>;
197
+ hasLocalCandidate?(specifier: string): boolean;
198
+ }
199
+
200
+ export function createResolver(source: FileSource, config: ResolverConfig): Resolver {
201
+ const cache = new Map<string, string | null>();
202
+ return {
203
+ hasLocalCandidate(specifier): boolean {
204
+ return hasAliasCandidate(specifier, config.aliases);
205
+ },
206
+ async resolve(specifier, fromRel): Promise<string | null> {
207
+ const cacheKey = `${fromRel}\u0001${specifier}`;
208
+ if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null;
209
+ if (isBare(specifier)) {
210
+ const aliased = applyAlias(specifier, config.aliases);
211
+ if (aliased.length === 0) {
212
+ cache.set(cacheKey, null);
213
+ return null;
214
+ }
215
+ for (const candidate of aliased) {
216
+ const found = await tryFile(source, candidate);
217
+ if (found) {
218
+ cache.set(cacheKey, found);
219
+ return found;
220
+ }
221
+ }
222
+ cache.set(cacheKey, null);
223
+ return null;
224
+ }
225
+
226
+ const fromDir = posixDirname(fromRel);
227
+ const target = joinPosix(fromDir, specifier);
228
+ const resolved = await tryFile(source, target);
229
+ cache.set(cacheKey, resolved);
230
+ return resolved;
231
+ },
232
+ };
233
+ }
234
+
235
+ function hasAliasCandidate(spec: string, aliases: PathAlias[]): boolean {
236
+ for (const alias of aliases) {
237
+ if (alias.exact) {
238
+ if (spec === alias.prefix) return true;
239
+ if (spec.startsWith(`${alias.prefix}/`)) return true;
240
+ continue;
241
+ }
242
+ const isWildcard = alias.prefix.length > 0;
243
+ if (isWildcard && (spec === alias.prefix || spec.startsWith(`${alias.prefix}/`))) return true;
244
+ if (alias.prefix === '' && !isWildcard) return true;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ export function applyAlias(spec: string, aliases: PathAlias[]): string[] {
250
+ for (const alias of aliases) {
251
+ if (alias.exact) {
252
+ if (spec === alias.prefix) return alias.targets;
253
+ continue;
254
+ }
255
+ const isWildcard = alias.prefix.length > 0;
256
+ if (isWildcard && (spec === alias.prefix || spec.startsWith(`${alias.prefix}/`))) {
257
+ const suffix = spec.slice(alias.prefix.length).replace(/^\//u, '');
258
+ return alias.targets.map((t) => (suffix ? joinPosix(t, suffix) : t));
259
+ }
260
+ if (alias.prefix === '' && !isWildcard) {
261
+ return alias.targets.map((t) => joinPosix(t, spec));
262
+ }
263
+ }
264
+ return [];
265
+ }
266
+
267
+ async function tryFile(source: FileSource, candidate: string): Promise<string | null> {
268
+ const norm = normalizePosix(candidate);
269
+ if (await source.exists(norm)) return norm;
270
+ for (const ext of EXTS_ORDERED) {
271
+ const p = `${norm}${ext}`;
272
+ if (await source.exists(p)) return p;
273
+ }
274
+ for (const idx of INDEX_BASES) {
275
+ const p = joinPosix(norm, idx);
276
+ if (await source.exists(p)) return p;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ function isBare(spec: string): boolean {
282
+ // `.` and `..` are folder-imports of the current/parent dir respectively;
283
+ // not bare specifiers. resolver should follow them like `./` / `../`.
284
+ if (spec === '.' || spec === '..') return false;
285
+ return !spec.startsWith('./') && !spec.startsWith('../') && !spec.startsWith('/');
286
+ }
287
+
288
+ function trimSuffix(s: string, suffix: string): string {
289
+ return s.endsWith(suffix) ? s.slice(0, -suffix.length) : s;
290
+ }
291
+
292
+ function joinPosix(...parts: string[]): string {
293
+ const filtered = parts.filter((p) => p && p !== '.');
294
+ return normalizePosix(filtered.join('/'));
295
+ }
296
+
297
+ function posixDirname(p: string): string {
298
+ const i = p.lastIndexOf('/');
299
+ return i === -1 ? '' : p.slice(0, i);
300
+ }
301
+
302
+ function normalizePosix(p: string): string {
303
+ const segments = p.split('/');
304
+ const out: string[] = [];
305
+ for (const seg of segments) {
306
+ if (seg === '' || seg === '.') continue;
307
+ if (seg === '..') {
308
+ if (out.length > 0 && out[out.length - 1] !== '..') out.pop();
309
+ else out.push('..');
310
+ continue;
311
+ }
312
+ out.push(seg);
313
+ }
314
+ return out.join('/');
315
+ }
@@ -0,0 +1,120 @@
1
+ // RSC / server-client boundary detection.
2
+ //
3
+ // Two pieces:
4
+ // 1. `classifyModuleRuntime` - decides whether a module is server / client /
5
+ // shared, based on directives parsed from the file plus framework
6
+ // conventions (Next app/, Nuxt server/, SvelteKit `+server.ts`/
7
+ // `*.server.*`).
8
+ // 2. `detectRscLeaks` - emits a `ContractViolation` (`kind: 'rsc-leak'`)
9
+ // for every static edge crossing a hard boundary (server importing
10
+ // client, client importing server-only).
11
+ //
12
+ // Directives win over conventions. A `'use client'` file in `app/` is
13
+ // client. `server-only` / `client-only` packages are not modeled separately
14
+ // here - the directive system covers the same cases for first-party code,
15
+ // and we don't have a way to type-check third-party code without doing real
16
+ // resolution.
17
+
18
+ import type { ContractViolation } from './contracts';
19
+ import type { DependencyEdge, ModuleId, ModuleNode, ModuleRuntime, ParsedFile } from './types';
20
+ import type { Framework } from './detect';
21
+
22
+ export interface ClassifyRuntimeInput {
23
+ relPath: string;
24
+ framework: Framework;
25
+ directives?: ParsedFile['directives'];
26
+ }
27
+
28
+ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntime {
29
+ // 1. directives win.
30
+ if (input.directives && input.directives.length > 0) {
31
+ if (input.directives.includes('use server')) return 'server';
32
+ if (input.directives.includes('use client')) return 'client';
33
+ }
34
+
35
+ const path = input.relPath.replace(/\\/gu, '/');
36
+
37
+ // 2. SvelteKit conventions (work for any framework value because users
38
+ // pin them by file name, not framework detection):
39
+ // - `+server.ts` / `+server.js` -> endpoint, server only
40
+ // - `*.server.ts` (and `+page.server.ts`, `+layout.server.ts`) -> server
41
+ // - `+page.svelte`, `+layout.svelte` -> client
42
+ // - `+page.ts`, `+layout.ts` -> shared (universal)
43
+ if (/(^|\/)\+server\.[mc]?[jt]s$/u.test(path)) return 'server';
44
+ if (/\.server\.[mc]?[jt]sx?$/u.test(path)) return 'server';
45
+ if (/\.client\.[mc]?[jt]sx?$/u.test(path)) return 'client';
46
+ if (/(^|\/)\+(?:page|layout)\.svelte$/u.test(path)) return 'client';
47
+
48
+ // 3. Framework-specific folder conventions.
49
+ if (input.framework === 'next') {
50
+ // Next App Router: `app/` files are server components by default.
51
+ // The 'use client' directive (handled above) opts a tree into client.
52
+ // `pages/api/**` is server-only (legacy router).
53
+ if (/(^|\/)pages\/api\//u.test(path)) return 'server';
54
+ if (/(^|\/)app\//u.test(path)) return 'server';
55
+ }
56
+
57
+ if (input.framework === 'nuxt') {
58
+ // Nuxt: `server/**` is server-side (API routes, plugins, middleware).
59
+ // `pages/`, `components/`, `composables/`, `app.vue` are universal but
60
+ // primarily client; default to 'shared' so we don't flag SSR-friendly
61
+ // imports of utilities.
62
+ if (/(^|\/)server\//u.test(path)) return 'server';
63
+ }
64
+
65
+ return 'shared';
66
+ }
67
+
68
+ export interface DetectRscLeaksInput {
69
+ modules: ModuleNode[];
70
+ edges: DependencyEdge[];
71
+ }
72
+
73
+ /**
74
+ * One violation per offending edge. `kind: 'rsc-leak'` lets the UI/CLI
75
+ * single it out from user-defined contract rules.
76
+ */
77
+ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[] {
78
+ const moduleById = new Map<ModuleId, ModuleNode>();
79
+ for (const m of input.modules) moduleById.set(m.id, m);
80
+
81
+ const out: ContractViolation[] = [];
82
+ let serial = 0;
83
+
84
+ for (const e of input.edges) {
85
+ if (e.kind === 'type-only') continue;
86
+ const from = moduleById.get(e.from);
87
+ const to = moduleById.get(e.to);
88
+ if (!from || !to) continue;
89
+ const leak = classifyLeak(from.runtime ?? 'shared', to.runtime ?? 'shared');
90
+ if (!leak) continue;
91
+ out.push({
92
+ id: `rsc-leak:${serial++}:${e.from}\u0001${e.to}`,
93
+ kind: 'rsc-leak',
94
+ ruleName: 'rsc-leak',
95
+ severity: 'error',
96
+ message: leak.message(from.id, to.id),
97
+ modules: [from.id, to.id],
98
+ edge: { from: from.id, to: to.id, specifier: e.specifier },
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ interface LeakKind {
105
+ message(from: ModuleId, to: ModuleId): string;
106
+ }
107
+
108
+ function classifyLeak(fromRt: ModuleRuntime, toRt: ModuleRuntime): LeakKind | null {
109
+ // Only `client -> server` is a hard leak: importing server-only code from
110
+ // a client bundle ships server-side dependencies (db drivers, secrets) to
111
+ // the browser. The reverse (`server -> client`) is the standard pattern -
112
+ // server components compose client components and the framework inserts
113
+ // the boundary automatically. `shared` is universal on either side.
114
+ if (fromRt === 'client' && toRt === 'server') {
115
+ return {
116
+ message: (f, t) => `client module "${f}" imports server-only "${t}"`,
117
+ };
118
+ }
119
+ return null;
120
+ }