Setting the file. One moment.
Scan Codebase · Vercel Optimize · vercel-labs/agent-skills · Skills Docs
ContentsBack to the top of the page
Number 7.74
Position 74 of 155
Type JavaScript
Size 11 KB
Lines 313 scripts/ scan-codebase.mjs
JavaScript · 313 lines · 11 KB
import
{
11 detectMonorepoRoot,
12 listWorkspacePackages,
13 buildResolver,
14 resolveWorkspaceImports,
15 } from '../lib/workspace-resolver.mjs' ;
16
17 const SCHEMA_VERSION = '1.0' ;
18 const SKIP_DIRS = new Set ([ 'node_modules' , '.next' , '.vercel' , 'dist' , 'build' , '.git' , 'coverage' , '.turbo' , '__tests__' , 'cypress' ]);
19 const SKIP_FILE_PATTERNS = [ / \. test \. / , / \. spec \. / , / \. d \. ts $ / ];
20
21 async function main () {
22 const rootDir = process.argv[ 2 ] || process. cwd ();
23 process.stderr. write ( `[scan-codebase] scanning ${ rootDir } \n ` );
24
25 const [ stack , files , routes ] = await Promise . all ([
26 detectStack (rootDir),
27 collectFiles (rootDir),
28 enumerateRoutes (rootDir),
29 ]);
30
31 // In a monorepo, route files often re-export from workspace packages. Without
32 // resolving those, sub-agents abstain because the workspace path is outside
33 // their read scope.
34 const monorepoRoot = await detectMonorepoRoot (rootDir);
35 let workspacePackages = [];
36 let resolver = () => null ;
37 if (monorepoRoot) {
38 workspacePackages = await listWorkspacePackages (monorepoRoot);
39 resolver = buildResolver (workspacePackages);
40 process.stderr. write ( `[scan-codebase] monorepo root: ${ monorepoRoot } (${ workspacePackages . length } workspace packages) \n ` );
41 }
42 await enrichRoutesWithWorkspaceImports (routes, rootDir, resolver, monorepoRoot);
43
44 process.stderr. write ( `[scan-codebase] ${ files . length } files, ${ routes . length } routes, ${ scanners . length } scanners \n ` );
45
46 const findings = [];
47 for ( const scanner of scanners) {
48 try {
49 const applicable = filterApplicable (files, scanner.metadata);
50 // Scanners may be sync or async (large-static-asset does fs.stat walks).
51 const found = await scanner. scan ({ files: applicable, rootDir, routes, stack });
52 for ( const f of (found ?? [])) {
53 findings. push ({
54 ... f,
55 route: mapFileToRoute (f.file, routes),
56 });
57 }
58 } catch (err) {
59 process.stderr. write ( `[scan-codebase] scanner ${ scanner . metadata ?. id } threw: ${ err . message } \n ` );
60 }
61 }
62
63 findings. sort (( a , b ) =>
64 a.file. localeCompare (b.file)
65 || (a.line ?? 0 ) - (b.line ?? 0 )
66 || a.pattern. localeCompare (b.pattern)
67 );
68
69 process.stdout. write ( JSON . stringify ({
70 schemaVersion: SCHEMA_VERSION ,
71 scannedAt: new Date (). toISOString (),
72 rootDir,
73 monorepoRoot: monorepoRoot ?? null ,
74 workspacePackages: workspacePackages. map (( p ) => ({ name: p.name, dir: relative (monorepoRoot ?? rootDir, p.dir) })),
75 stack,
76 routes,
77 findings,
78 scannerMetadata: scanners. map (( s ) => ({
79 id: s.metadata.id,
80 title: s.metadata.title,
81 severity: s.metadata.severity,
82 billingDimension: s.metadata.billingDimension,
83 trafficIndependent: s.metadata.trafficIndependent,
84 })),
85 }, null , 2 ) + ' \n ' );
86
87 process.stderr. write ( `[scan-codebase] ${ findings . length } finding(s) \n ` );
88 }
89
90 // Record workspace-package imports per route so the brief allowlists them and
91 // sub-agents can investigate the real source rather than abstaining on a thin
92 // re-export shell. Capped to keep the brief focused (source order ≈ import order,
93 // so the primary view component usually leads).
94 const WORKSPACE_IMPORT_LIMIT_PER_ROUTE = 12 ;
95 async function enrichRoutesWithWorkspaceImports ( routes , scanRootDir , resolver , monorepoRoot ) {
96 if ( ! monorepoRoot) return ;
97 for ( const r of routes) {
98 if ( ! r?.file) continue ;
99 const abs = join (scanRootDir, r.file);
100 const resolved = await resolveWorkspaceImports (abs, resolver, {
101 pureBarrelDepth: 3 ,
102 suffixFanoutDepth: 2 ,
103 perSpecifierCap: 3 ,
104 });
105 if (resolved. length === 0 ) continue ;
106 // Paths must be relative to the monorepo root so they align between signals + verifier.
107 r.workspaceImports = resolved
108 . slice ( 0 , WORKSPACE_IMPORT_LIMIT_PER_ROUTE )
109 . map (( abs ) => relative (monorepoRoot, abs));
110 }
111 }
112
113 async function collectFiles ( root ) {
114 const entries = await readdir (root, { recursive: true , withFileTypes: true });
115 const out = [];
116 for ( const e of entries) {
117 if ( ! e. isFile ()) continue ;
118 const segments = (e.parentPath ?? e.path ?? root). split ( '/' );
119 if (segments. some (( s ) => SKIP_DIRS . has (s))) continue ;
120 if ( SKIP_FILE_PATTERNS . some (( re ) => re. test (e.name))) continue ;
121 if ( ! / \. (tsx ?| jsx ?| mjs | cjs | html | svelte | astro | vue | json) $ / . test (e.name)) continue ;
122
123 const full = join (e.parentPath ?? e.path ?? root, e.name);
124 try {
125 const content = await readFile (full, 'utf-8' );
126 if (content. length > 500_000 ) continue ;
127 out. push ({ path: relative (root, full), content });
128 } catch {}
129 }
130 return out;
131 }
132
133 function filterApplicable ( files , meta ) {
134 const incl = meta.includeGlobs ?? [ '**/*' ];
135 return files. filter (( f ) => incl. some (( g ) => globMatch (g, f.path)));
136 }
137
138 // Tiny glob → regex. Supports **, *, and {a,b} alternation.
139 function globMatch ( pattern , path ) {
140 const re = new RegExp (
141 '^' +
142 pattern
143 . replace ( / [.+^$()|[ \]\\ ] / g , ' \\ $&' )
144 . replace ( / \{ ( [ ^ }] + ) \} / g , ( _ , inner ) => '(' + inner. split ( ',' ). join ( '|' ) + ')' )
145 . replace ( / \*\* / g , '__GLOBSTAR__' )
146 . replace ( / \* / g , '[^/]*' )
147 . replace ( /__GLOBSTAR__/ g , '.*' )
148 + '$'
149 );
150 return re. test (path);
151 }
152
153 async function enumerateRoutes ( root ) {
154 const entries = await readdir (root, { recursive: true , withFileTypes: true });
155 const routes = [];
156 for ( const e of entries) {
157 if ( ! e. isFile ()) continue ;
158 const segments = (e.parentPath ?? e.path ?? root). split ( '/' );
159 if (segments. some (( s ) => SKIP_DIRS . has (s))) continue ;
160
161 const full = join (e.parentPath ?? e.path ?? root, e.name);
162 const rel = relative (root, full);
163
164 // App Router: route groups ((name)), parallel routes (@slot), private folders
165 // (_name), and the top-level page.tsx (no path segment) all need explicit handling.
166 let m = rel. match ( / ^ (?:src \/ ) ? app \/ ( . * ) \/ (page | route | layout) \. (tsx ?| jsx ? ) $ / );
167 if ( ! m) {
168 const top = rel. match ( / ^ (?:src \/ ) ? app \/ (page | route | layout) \. (tsx ?| jsx ? ) $ / );
169 if (top) {
170 routes. push ({
171 routePath: '/' ,
172 file: rel,
173 type: routeEntryType (top[ 1 ]),
174 });
175 continue ;
176 }
177 }
178 if (m) {
179 const stripped = m[ 1 ]
180 . split ( '/' )
181 . filter (( seg ) => ! / ^ \( [ ^ )] + \) $ / . test (seg) && ! / ^ @/ . test (seg) && ! / ^ _/ . test (seg))
182 . join ( '/' )
183 . replace ( / ^ \/ +| \/ +$ / g , '' );
184 const routePath = stripped === '' ? '/' : `/${ stripped }` ;
185 routes. push ({
186 routePath,
187 file: rel,
188 type: routeEntryType (m[ 2 ]),
189 });
190 continue ;
191 }
192
193 // Astro endpoint filenames commonly include the response extension
194 // (`feed.xml.ts`, `robots.txt.ts`). Handle these before the generic
195 // `src/pages` rule, which otherwise treats them as page components.
196 m = rel. match ( / ^ src \/ pages \/ ( . * \. (?:xml | json | txt | rss | atom | svg | png | jpg | jpeg | webp)) \. (tsx ?| jsx ?| mjs | cjs) $ / );
197 if (m) {
198 const name = normalizeRouteFileStem (m[ 1 ]);
199 routes. push ({
200 routePath: name === '' ? '/' : '/' + name,
201 file: rel,
202 type: 'route' ,
203 });
204 continue ;
205 }
206
207 m = rel. match ( / ^ (?:src \/ ) ? pages \/ ( . * ) \. (tsx ?| jsx ? ) $ / );
208 if (m) {
209 const name = m[ 1 ]. replace ( / \/ index $ / , '' ). replace ( / ^ index $ / , '' );
210 const isApi = / ^ api \/ / . test (name);
211 routes. push ({
212 routePath: name === '' ? '/' : '/' + name,
213 file: rel,
214 type: isApi ? 'route' : 'page' ,
215 });
216 continue ;
217 }
218
219 // Nuxt 3/4 pages. Dynamic segments use the same bracket shape as metrics
220 // (`[id]`, `[...slug]`), so keep them intact for route matching.
221 m = rel. match ( / ^ (?:app \/ ) ? pages \/ ( . * ) \. vue $ / );
222 if (m) {
223 const name = normalizeRouteFileStem (m[ 1 ]);
224 routes. push ({
225 routePath: name === '' ? '/' : '/' + name,
226 file: rel,
227 type: 'page' ,
228 });
229 continue ;
230 }
231
232 // Nuxt server routes: server/api/foo.get.ts -> /api/foo,
233 // server/routes/rss.xml.ts -> /rss.xml.
234 m = rel. match ( / ^ server \/ (api | routes) \/ ( . * ) \. (tsx ?| jsx ?| mjs | cjs) $ / );
235 if (m) {
236 const base = m[ 1 ] === 'api' ? 'api/' : '' ;
237 const name = normalizeRouteFileStem ( `${ base }${ m [ 2 ] }` );
238 routes. push ({
239 routePath: name === '' ? '/' : '/' + name,
240 file: rel,
241 type: 'route' ,
242 });
243 continue ;
244 }
245
246 // Astro pages and endpoints. This is limited framework support, but route
247 // mapping still improves reports when Vercel metrics use user-facing paths.
248 m = rel. match ( / ^ src \/ pages \/ ( . * ) \. (astro | tsx ?| jsx ?| mjs | cjs) $ / );
249 if (m) {
250 const name = normalizeRouteFileStem (m[ 1 ]);
251 routes. push ({
252 routePath: name === '' ? '/' : '/' + name,
253 file: rel,
254 type: m[ 2 ] === 'astro' ? 'page' : 'route' ,
255 });
256 continue ;
257 }
258
259 // SvelteKit: +page.svelte = page, +page.server.{ts,js} pairs with it (treat
260 // as page), +server.{ts,js} = API route, +layout.* = ancestor layout context.
261 // Route groups (auth) stripped like Next; dynamic segments [slug]/[...rest]/[[opt]] preserved.
262 m = rel. match ( / ^ src \/ routes \/ ( . * ) \/\+ (page \. svelte | page \. server \. (?:ts | js) | server \. (?:ts | js) | layout \. svelte | layout \. server \. (?:ts | js)) $ / );
263 if (m || / ^ src \/ routes \/\+ (page \. svelte | page \. server \. (?:ts | js) | server \. (?:ts | js) | layout \. svelte | layout \. server \. (?:ts | js)) $ / . test (rel)) {
264 const fileTypeMatch = rel. match ( / \+ (page \. svelte | page \. server \. (?:ts | js) | server \. (?:ts | js) | layout \. svelte | layout \. server \. (?:ts | js)) $ / );
265 const fileType = fileTypeMatch?.[ 1 ] ?? '' ;
266 const segs = (m?.[ 1 ] ?? '' ). split ( '/' ). filter (Boolean)
267 . filter (( seg ) => ! / ^ \( [ ^ )] + \) $ / . test (seg));
268 const routePath = segs. length === 0 ? '/' : '/' + segs. join ( '/' );
269 const type = fileType. startsWith ( 'server' ) ? 'route' : fileType. startsWith ( 'layout' ) ? 'layout' : 'page' ;
270 // When +page.svelte AND +page.server.ts both exist, +page.svelte wins ownership.
271 const existing = type === 'layout' ? null : routes. find (( r ) => r.routePath === routePath && r.type !== 'layout' );
272 if (existing) {
273 if (fileType === 'page.svelte' && existing.type === 'page' ) {
274 existing.file = rel;
275 }
276 continue ;
277 }
278 routes. push ({ routePath, file: rel, type });
279 continue ;
280 }
281 }
282 return routes. sort (( a , b ) =>
283 a.routePath. localeCompare (b.routePath)
284 || routeTypeOrder (a.type) - routeTypeOrder (b.type)
285 || a.file. localeCompare (b.file)
286 );
287 }
288
289 function routeEntryType ( name ) {
290 return name === 'route' ? 'route' : name === 'layout' ? 'layout' : 'page' ;
291 }
292
293 function normalizeRouteFileStem ( stem ) {
294 return String (stem ?? '' )
295 . replace ( / \/ index $ / , '' )
296 . replace ( / ^ index $ / , '' )
297 . replace ( / \. (?:get | post | put | patch | delete | options | head) $ / , '' )
298 . replace ( / ^ \/ +| \/ +$ / g , '' );
299 }
300
301 function routeTypeOrder ( type ) {
302 return type === 'page' ? 0 : type === 'route' ? 1 : type === 'layout' ? 2 : 3 ;
303 }
304
305 function mapFileToRoute ( filePath , routes ) {
306 const r = routes. find (( rt ) => rt.file === filePath);
307 return r?.routePath ?? null ;
308 }
309
310 main (). catch (( err ) => {
311 process.stderr. write ( `[scan-codebase] FAILED: ${ err . message } \n ` );
312 process. exit ( 1 );
313 });