-
-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathdirective-analysis.ts
More file actions
310 lines (269 loc) · 8.7 KB
/
directive-analysis.ts
File metadata and controls
310 lines (269 loc) · 8.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// Copyright 2023-present Eser Ozvataf and other contributors. All rights reserved. Apache-2.0 license.
/**
* Directive analysis utilities for scanning source files.
*
* Identifies files with specific directives like "use client", "use server",
* and extracts their exports for module graph building.
*
* @module
*/
import { JS_FILE_EXTENSIONS } from "@eserstack/standards/patterns";
import { runtime } from "@eserstack/standards/cross-runtime";
/**
* Common JavaScript/TypeScript directives.
*/
export const DIRECTIVES = {
USE_CLIENT: "use client",
USE_SERVER: "use server",
USE_STRICT: "use strict",
} as const;
export type DirectiveName = (typeof DIRECTIVES)[keyof typeof DIRECTIVES];
/**
* A file that matches a directive search.
*/
export interface DirectiveMatch {
/** Absolute file path. */
readonly filePath: string;
/** Relative path from project root. */
readonly relativePath: string;
/** The directive that was found. */
readonly directive: string;
/** Export names found in the file. */
readonly exports: readonly string[];
}
/**
* Options for directive analysis.
*/
export interface DirectiveAnalysisOptions {
/** File extensions to scan */
readonly extensions?: readonly string[];
/** Patterns to skip (default: [/node_modules/, /\.test\./, /\.spec\./]). */
readonly skip?: readonly RegExp[];
/** Project root for relative paths (default: same as scanDir). */
readonly projectRoot?: string;
}
const DEFAULT_SKIP = [/node_modules/, /\.test\./, /\.spec\./] as const;
/**
* Check if file content contains a specific directive at the top.
* Directives must appear before any actual code.
* Handles multi-line JSDoc comments properly.
*/
export const hasDirective = (content: string, directive: string): boolean => {
const lines = content.split("\n");
const normalizedDirective = directive.toLowerCase();
let inMultiLineComment = false;
// Check up to 50 lines to handle long JSDoc comments
for (let i = 0; i < Math.min(50, lines.length); i++) {
const line = lines[i]?.trim() ?? "";
// Track multi-line comment state
if (inMultiLineComment) {
if (line.includes("*/")) {
inMultiLineComment = false;
}
continue;
}
// Skip empty lines and single-line comments
if (line === "" || line.startsWith("//")) {
continue;
}
// Start of multi-line comment
if (line.startsWith("/*")) {
if (!line.includes("*/")) {
inMultiLineComment = true;
}
continue;
}
// Check for directive with either quote style
const doubleQuote = `"${normalizedDirective}";`;
const singleQuote = `'${normalizedDirective}';`;
const lineLower = line.toLowerCase();
if (lineLower === doubleQuote || lineLower === singleQuote) {
return true;
}
// If we hit actual code (import, export, const, etc.), stop looking
if (
line.startsWith("import") ||
line.startsWith("export") ||
line.startsWith("const") ||
line.startsWith("let") ||
line.startsWith("var") ||
line.startsWith("function") ||
line.startsWith("class")
) {
break;
}
}
return false;
};
/**
* Extract export names from file content.
* Finds named exports, default exports, and re-exports.
*/
export const extractExports = (content: string): readonly string[] => {
const exports = new Set<string>();
// Named function exports: export function Name() or export async function Name()
// Use bounded whitespace quantifiers to prevent ReDoS
const namedFunctions = content.matchAll(
/export\s{1,20}(?:async\s{1,20})?function\s{1,20}(\w+)/g,
);
for (const match of namedFunctions) {
if (match[1] !== undefined) {
exports.add(match[1]);
}
}
// Named const/let exports: export const Name =
// Use bounded whitespace quantifiers to prevent ReDoS
const namedConsts = content.matchAll(
/export\s{1,20}(?:const|let)\s{1,20}(\w+)\s{0,20}=/g,
);
for (const match of namedConsts) {
if (match[1] !== undefined) {
exports.add(match[1]);
}
}
// Named class exports: export class Name
// Use bounded whitespace quantifiers to prevent ReDoS
const namedClasses = content.matchAll(
/export\s{1,20}class\s{1,20}(\w+)/g,
);
for (const match of namedClasses) {
if (match[1] !== undefined) {
exports.add(match[1]);
}
}
// Default exports
if (/export\s{1,20}default\s{1,20}/.test(content)) {
exports.add("default");
}
// Named exports from declaration: export { a, b, c }
// Use specific character class to prevent ReDoS (avoid unbounded [^}]+)
const namedExportBlocks = content.matchAll(/export\s*\{([\w\s,]+)\}/g);
for (const match of namedExportBlocks) {
if (match[1] !== undefined) {
const names = match[1].split(",").map((n) => {
// Handle "name as alias" syntax - take the exported name (after "as")
// Use bounded pattern {1,10} to prevent ReDoS on pathological inputs
const parts = n.trim().split(/ {1,10}as {1,10}/);
return (parts[1] ?? parts[0])?.trim();
});
for (const name of names) {
if (name !== undefined && name !== "") {
exports.add(name);
}
}
}
}
return [...exports];
};
/**
* Read file content safely.
*/
const readFileContent = async (filePath: string): Promise<string | null> => {
try {
return await runtime.fs.readTextFile(filePath);
} catch {
return null;
}
};
/**
* Analyze a directory for files containing a specific directive.
*
* @example
* const clientComponents = await analyzeDirectives("./src", "use client");
* for (const match of clientComponents) {
* console.log(`${match.relativePath}: ${match.exports.join(", ")}`);
* }
*/
export const analyzeDirectives = async (
scanDir: string,
directive: string,
options: DirectiveAnalysisOptions = {},
): Promise<readonly DirectiveMatch[]> => {
const extensions = options.extensions ?? JS_FILE_EXTENSIONS;
const skip = options.skip ?? DEFAULT_SKIP;
const projectRoot = options.projectRoot ?? scanDir;
const matches: DirectiveMatch[] = [];
for await (
const entry of runtime.fs.walk(scanDir, {
exts: [...extensions],
skip: [...skip],
})
) {
if (!entry.isFile) continue;
const content = await readFileContent(entry.path);
if (content === null) continue;
if (hasDirective(content, directive)) {
const relativePath = runtime.path.relative(projectRoot, entry.path);
const exports = extractExports(content);
matches.push({
filePath: entry.path,
relativePath,
directive,
exports,
});
}
}
return matches;
};
/**
* Analyze for "use client" directive specifically.
* Convenience function for React Server Components.
*/
export const analyzeClientComponents = (
scanDir: string,
options: DirectiveAnalysisOptions = {},
): Promise<readonly DirectiveMatch[]> =>
analyzeDirectives(scanDir, DIRECTIVES.USE_CLIENT, options);
/**
* Check if file content contains a directive ANYWHERE in the file.
* Unlike hasDirective which only checks the top, this scans the entire content.
* Used for "use server" which can appear at function level.
*/
export const containsDirective = (
content: string,
directive: string,
): boolean => {
const doubleQuote = `"${directive}"`;
const singleQuote = `'${directive}'`;
return content.includes(doubleQuote) || content.includes(singleQuote);
};
/**
* Analyze for "use server" directive specifically.
* Convenience function for React Server Actions.
*
* NOTE: Unlike "use client" which must be at file top, "use server" can appear:
* - At file top (all exports become server actions)
* - At function body top (that function becomes a server action)
* This function scans for "use server" ANYWHERE in the file.
*/
export const analyzeServerActions = async (
scanDir: string,
options: DirectiveAnalysisOptions = {},
): Promise<readonly DirectiveMatch[]> => {
const extensions = options.extensions ?? JS_FILE_EXTENSIONS;
const skip = options.skip ?? DEFAULT_SKIP;
const projectRoot = options.projectRoot ?? scanDir;
const matches: DirectiveMatch[] = [];
for await (
const entry of runtime.fs.walk(scanDir, {
exts: [...extensions],
skip: [...skip],
})
) {
if (!entry.isFile) continue;
const content = await readFileContent(entry.path);
if (content === null) continue;
// Check for "use server" ANYWHERE in file (file-level or function-level)
if (containsDirective(content, DIRECTIVES.USE_SERVER)) {
const relativePath = runtime.path.relative(projectRoot, entry.path);
const exports = extractExports(content);
matches.push({
filePath: entry.path,
relativePath,
directive: DIRECTIVES.USE_SERVER,
exports,
});
}
}
return matches;
};