@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.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- 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
|
+
}
|