Setting the file. One moment.
Scan Codebase · Add Webmcp · browserbase/skills · Skills Docs
ContentsBack to the top of the page
3 of 22 scripts/ scan-codebase.mjs
JavaScript · 314 lines · 9 KB
10_000
;
10 const MAX_FILE_BYTES = 512 * 1024 ;
11 const SOURCE_EXTENSIONS = new Set ([
12 ".js" ,
13 ".jsx" ,
14 ".mjs" ,
15 ".cjs" ,
16 ".ts" ,
17 ".tsx" ,
18 ".vue" ,
19 ".svelte" ,
20 ".html" ,
21 ]);
22 const IGNORED_DIRECTORIES = new Set ([
23 ".git" ,
24 ".next" ,
25 ".nuxt" ,
26 ".output" ,
27 ".svelte-kit" ,
28 ".turbo" ,
29 ".vercel" ,
30 "build" ,
31 "coverage" ,
32 "dist" ,
33 "node_modules" ,
34 "out" ,
35 "target" ,
36 "vendor" ,
37 ]);
38 const IGNORED_FILE_PREFIXES = [ ".env" , "credentials" , "secrets" ];
39
40 const SIGNALS = [
41 {
42 kind: "webmcp" ,
43 patterns: [
44 / \b (?:navigator | document) \. modelContext \b / ,
45 / \b registerTool \s * \( / ,
46 / \b toolname \s * =/ i ,
47 ],
48 },
49 {
50 kind: "form" ,
51 patterns: [ /<form \b / i , / \b onSubmit \s * =/ , / \b addEventListener \s * \( \s * ["'] submit ["'] / , / \b useForm \s * \( / ],
52 },
53 {
54 kind: "server-action" ,
55 patterns: [
56 / ^ [\s] * ["'] use server ["'] ; ? / ,
57 / \b export \s + async \s + function \s + (?:action | create | update | delete | submit | send | publish | save) \w * / i ,
58 / \b serverAction \s * \( / ,
59 ],
60 },
61 {
62 kind: "route-handler" ,
63 patterns: [
64 / \b export \s + (?:async \s + ) ? function \s + (?:GET | POST | PUT | PATCH | DELETE) \b / ,
65 / \b (?:router | app) \. (?:get | post | put | patch | delete) \s * \( / ,
66 / \b (?:createBrowserRouter | createRoutesFromElements) \s * \( / ,
67 /<Route \b / ,
68 ],
69 },
70 {
71 kind: "schema" ,
72 patterns: [
73 / \b z \. object \s * \( / ,
74 / \b (?:yup | Joi) \. object \s * \( / ,
75 / \b (?:v | valibot) \. object \s * \( / ,
76 / \b jsonSchema \b / i ,
77 / \b inputSchema \b / ,
78 ],
79 },
80 {
81 kind: "client-state" ,
82 patterns: [
83 / \b (?:localStorage | sessionStorage) \. (?:getItem | setItem | removeItem) \s * \( / ,
84 / \b indexedDB \. (?:open | deleteDatabase) \s * \( / ,
85 / \b local [Ff] orage \. (?:getItem | setItem | removeItem | clear) \s * \( / ,
86 / \b atomWithStorage \s * \( / ,
87 ],
88 },
89 {
90 kind: "network-client" ,
91 patterns: [
92 / \b fetch \s * \( / ,
93 / \b axios \. (?:get | post | put | patch | delete | request) \s * \( / ,
94 / \b (?:useQuery | useMutation | useLazyQuery) \s * \( / ,
95 / \b (?:graphql | gql) \s * `/ ,
96 ],
97 },
98 {
99 kind: "capability-function" ,
100 patterns: [
101 / \b function \s + (?:add | calculate | compute | convert | create | delete | export | fetch | filter | find | get | import | list | load | lookup | move | publish | remove | save | search | send | set | start | submit | toggle | update) \w * \s * \( / i ,
102 / \b (?:const | let | var) \s + (?:add | calculate | compute | convert | create | delete | export | fetch | filter | find | get | import | list | load | lookup | move | publish | remove | save | search | send | set | start | submit | toggle | update) \w * \s * = \s * (?:async \s * ) ? (?: \( [ ^ )] * \) | [A-Za-z_$][\w$] * ) \s * =>/ i ,
103 ],
104 },
105 {
106 kind: "authorization" ,
107 patterns: [
108 / \b (?:authorize | authorization | permission | requireUser | requireAuth | isAdmin | canAccess | csrf | idempotency) \b / i ,
109 ],
110 },
111 ];
112
113 const FRAMEWORK_PACKAGES = new Map ([
114 [ "next" , "Next.js" ],
115 [ "react" , "React" ],
116 [ "react-router" , "React Router" ],
117 [ "react-router-dom" , "React Router" ],
118 [ "@remix-run/react" , "Remix" ],
119 [ "vue" , "Vue" ],
120 [ "nuxt" , "Nuxt" ],
121 [ "svelte" , "Svelte" ],
122 [ "@sveltejs/kit" , "SvelteKit" ],
123 [ "vite" , "Vite" ],
124 [ "astro" , "Astro" ],
125 [ "express" , "Express" ],
126 [ "fastify" , "Fastify" ],
127 [ "hono" , "Hono" ],
128 ]);
129
130 function usage () {
131 return "Usage: scan-codebase.mjs <repository> [--max-files <count>]" ;
132 }
133
134 function parseArgs ( argv ) {
135 let root;
136 let maxFiles = DEFAULT_MAX_FILES ;
137 for ( let index = 0 ; index < argv. length ; index += 1 ) {
138 const value = argv[index];
139 if (value === "--max-files" ) {
140 const parsed = Number (argv[index + 1 ]);
141 if ( ! Number. isInteger (parsed) || parsed < 1 ) {
142 throw new Error ( "--max-files must be a positive integer" );
143 }
144 maxFiles = parsed;
145 index += 1 ;
146 } else if ( ! root) {
147 root = value;
148 } else {
149 throw new Error ( `Unexpected argument: ${ value }` );
150 }
151 }
152 if ( ! root) throw new Error ( usage ());
153 return { root: path. resolve (root), maxFiles };
154 }
155
156 function shouldIgnoreFile ( name ) {
157 const lower = name. toLowerCase ();
158 return IGNORED_FILE_PREFIXES . some (( prefix ) => lower. startsWith (prefix));
159 }
160
161 async function walk ( root , maxFiles ) {
162 const sourceFiles = [];
163 const manifests = [];
164 const queue = [root];
165
166 while (queue. length > 0 ) {
167 const directory = queue. shift ();
168 const entries = await readdir (directory, { withFileTypes: true });
169 entries. sort (( left , right ) => left.name. localeCompare (right.name));
170
171 for ( const entry of entries) {
172 if (entry. isSymbolicLink ()) continue ;
173 const absolute = path. join (directory, entry.name);
174 if (entry. isDirectory ()) {
175 if ( ! IGNORED_DIRECTORIES . has (entry.name)) queue. push (absolute);
176 continue ;
177 }
178 if ( ! entry. isFile () || shouldIgnoreFile (entry.name)) continue ;
179 if (entry.name === "package.json" ) manifests. push (absolute);
180 if ( ! SOURCE_EXTENSIONS . has (path. extname (entry.name). toLowerCase ())) continue ;
181
182 const metadata = await stat (absolute);
183 if (metadata.size <= MAX_FILE_BYTES ) sourceFiles. push (absolute);
184 if (sourceFiles. length > maxFiles) {
185 throw new Error ( `Source file limit exceeded (${ maxFiles }). Narrow the target repository or raise --max-files.` );
186 }
187 }
188 }
189
190 return { sourceFiles, manifests };
191 }
192
193 async function detectFrameworks ( root , manifests ) {
194 const packages = [];
195 const frameworks = new Set ();
196
197 for ( const manifestPath of manifests) {
198 try {
199 const manifest = JSON . parse ( await readFile (manifestPath, "utf8" ));
200 const dependencies = {
201 ... (manifest.dependencies || {}),
202 ... (manifest.devDependencies || {}),
203 };
204 const detected = [];
205 for ( const [ packageName , framework ] of FRAMEWORK_PACKAGES ) {
206 if (packageName in dependencies) {
207 frameworks. add (framework);
208 detected. push (framework);
209 }
210 }
211 if (detected. length > 0 ) {
212 packages. push ({
213 path: path. relative (root, manifestPath) || "package.json" ,
214 frameworks: [ ...new Set (detected)]. sort (),
215 });
216 }
217 } catch {
218 // A malformed or generated package manifest is not a reason to abort the source scan.
219 }
220 }
221
222 return { frameworks: [ ... frameworks]. sort (), packages };
223 }
224
225 function pathSignals ( relativePath ) {
226 const normalized = relativePath. split (path.sep). join ( "/" );
227 const signals = [];
228 if ( /( ^| \/ )app \/ (?: . * \/ ) ? (?:page | layout | route) \. [cm] ? [jt] sx ?$ / . test (normalized)) signals. push ( "framework-route" );
229 if ( /( ^| \/ )pages \/ (?:api \/ ) ? / . test (normalized)) signals. push ( "framework-route" );
230 if ( /( ^| \/ )(?:routes ?| router) \/ / . test (normalized)) signals. push ( "framework-route" );
231 if ( /(?:schema | validator | validation) \. [cm] ? [jt] s $ / . test (normalized)) signals. push ( "schema" );
232 return signals;
233 }
234
235 async function scanSources ( root , sourceFiles ) {
236 const findings = [];
237 const counts = {};
238
239 for ( const absolute of sourceFiles) {
240 const relative = path. relative (root, absolute);
241 for ( const kind of pathSignals (relative)) {
242 findings. push ({ kind, file: relative, line: null , source: "path" });
243 counts[kind] = (counts[kind] || 0 ) + 1 ;
244 }
245
246 const lines = ( await readFile (absolute, "utf8" )). split ( / \r ? \n / );
247 for ( let lineIndex = 0 ; lineIndex < lines. length ; lineIndex += 1 ) {
248 const line = lines[lineIndex];
249 for ( const signal of SIGNALS ) {
250 if (signal.patterns. some (( pattern ) => pattern. test (line))) {
251 findings. push ({
252 kind: signal.kind,
253 file: relative,
254 line: lineIndex + 1 ,
255 source: "content" ,
256 });
257 counts[signal.kind] = (counts[signal.kind] || 0 ) + 1 ;
258 }
259 }
260 }
261 }
262
263 findings. sort (( left , right ) =>
264 left.file. localeCompare (right.file) ||
265 (left.line ?? 0 ) - (right.line ?? 0 ) ||
266 left.kind. localeCompare (right.kind),
267 );
268 return { findings, counts };
269 }
270
271 export async function scanCodebase ( root , { maxFiles = DEFAULT_MAX_FILES } = {}) {
272 const resolvedRoot = path. resolve (root);
273 const rootMetadata = await stat (resolvedRoot);
274 if ( ! rootMetadata. isDirectory ()) throw new Error ( `Not a directory: ${ resolvedRoot }` );
275
276 const { sourceFiles , manifests } = await walk (resolvedRoot, maxFiles);
277 const frameworkInfo = await detectFrameworks (resolvedRoot, manifests);
278 const sourceInfo = await scanSources (resolvedRoot, sourceFiles);
279
280 return {
281 root: resolvedRoot,
282 scannedSourceFiles: sourceFiles. length ,
283 packageManifests: manifests. length ,
284 ... frameworkInfo,
285 counts: sourceInfo.counts,
286 findings: sourceInfo.findings,
287 note: "Findings are leads only. Trace handlers, validation, authorization, side effects, and returned state before exposing a capability." ,
288 };
289 }
290
291 async function main () {
292 const args = parseArgs (process.argv. slice ( 2 ));
293 const result = await scanCodebase (args.root, { maxFiles: args.maxFiles });
294 console. log ( JSON . stringify (result, null , 2 ));
295 }
296
297 // Resolve symlinks on both sides: skills are commonly installed as a symlink
298 // (~/.claude/skills/<name> -> the real directory), and path.resolve alone leaves
299 // the link intact, so the two paths never match and the scanner exits silently.
300 const resolveReal = ( value ) => {
301 try {
302 return realpathSync (value);
303 } catch {
304 return value;
305 }
306 };
307
308 const invokedPath = process.argv[ 1 ] ? resolveReal (path. resolve (process.argv[ 1 ])) : "" ;
309 if (invokedPath === resolveReal ( fileURLToPath ( import . meta .url))) {
310 main (). catch (( error ) => {
311 console. error (error instanceof Error ? error.message : String (error));
312 process.exitCode = 1 ;
313 });
314 }