Setting the file. One moment.
Verify Claim · Vercel Optimize · vercel-labs/agent-skills · Skills Docs
ContentsBack to the top of the page — line 196
This file
Number 7.152
Position 152 of 155
Type JavaScript
Size 60 KB
Lines 1,296 lib/ verify-claim.mjs
JavaScript · 1,296 lines · 60 KB
'./project-facts.mjs'
;
9 import { canonicalizeRoute } from './route-normalize.mjs' ;
10
11 const execFileP = promisify (execFile);
12 const cacheInvalidationFileCache = new Map ();
13
14 // Bad inputs surface as `unsupported` — never throws.
15 export async function verifyClaim ( claim ) {
16 if ( ! claim || typeof claim !== 'object' ) {
17 return { disposition: 'unverifiable' , reason: 'claim is not an object' };
18 }
19 switch (claim.type) {
20 case 'file_exists' : return verifyFileExists (claim);
21 case 'pattern_count' : return verifyPatternCount (claim);
22 case 'pattern_exists' : return verifyPatternExists (claim);
23 case 'pattern_absent' : return verifyPatternAbsent (claim);
24 case 'code_snippet' : return verifyCodeSnippet (claim);
25 case 'repo_count' : return verifyRepoCount (claim);
26 case 'citation_in_library' : return verifyCitationInLibrary (claim);
27 case 'citation_applies_to_version' : return verifyCitationAppliesToVersion (claim);
28 case 'cache_vary_matches_dynamic_inputs' : return verifyCacheVaryMatchesDynamicInputs (claim);
29 case 'cache_vary_cardinality_safe' : return verifyCacheVaryCardinalitySafe (claim);
30 case 'next_cached_not_found_causal_support' : return verifyNextCachedNotFoundCausalSupport (claim);
31 case 'next_stable_cache_api_for_version' : return verifyNextStableCacheApiForVersion (claim);
32 case 'next_runtime_cache_api_for_version' : return verifyNextRuntimeCacheApiForVersion (claim);
33 case 'next_cache_components_runtime_cache_preference' : return verifyNextCacheComponentsRuntimeCachePreference (claim);
34 case 'next_cache_life_single_execution' : return verifyNextCacheLifeSingleExecution (claim);
35 case 'next_cache_lifetime_freshness_supported' : return verifyNextCacheLifetimeFreshnessSupported (claim);
36 case 'next_cache_components_route_chain_file' : return verifyNextCacheComponentsRouteChainFile (claim);
37 case 'next_cache_life_cdn_header_semantics' : return verifyNextCacheLifeCdnHeaderSemantics (claim);
38 case 'image_response_headers_citation' : return verifyImageResponseHeadersCitation (claim);
39 case 'next_image_priority_api_for_version' : return verifyNextImagePriorityApiForVersion (claim);
40 case 'next_cache_components_route_segment_config' : return verifyNextCacheComponentsRouteSegmentConfig (claim);
41 case 'next_route_revalidate_static_prereq' : return verifyNextRouteRevalidateStaticPrereq (claim);
42 case 'next_cache_tag_invalidation_supported' : return verifyNextCacheTagInvalidationSupported (claim);
43 case 'cache_rec_not_error_dominated_or_acknowledged' : return verifyCacheRecNotErrorDominatedOrAcknowledged (claim);
44 case 'cache_control_header_syntax' : return verifyCacheControlHeaderSyntax (claim);
45 case 'cache_control_headers_citation' : return verifyCacheControlHeadersCitation (claim);
46 case 'cache_policy_positive_or_no_ready_rec' : return verifyCachePolicyPositiveOrNoReadyRec (claim);
47 case 'cache_404_long_ttl_safety' : return verifyCache404LongTtlSafety (claim);
48 case 'route_error_not_found_status_and_scope' : return verifyRouteErrorNotFoundStatusAndScope (claim);
49 case 'immutable_dynamic_route_safety' : return verifyImmutableDynamicRouteSafety (claim);
50 case 'auth_guard_parallelization_safety' : return verifyAuthGuardParallelizationSafety (claim);
51 case 'parallelization_impact_not_overclaimed' : return verifyParallelizationImpactNotOverclaimed (claim);
52 case 'parallelization_not_cpu_bound_work' : return verifyParallelizationNotCpuBoundWork (claim);
53 case 'runtime_error_cause_supported' : return verifyRuntimeErrorCauseSupported (claim);
54 case 'vercel_ignore_command_project_state' : return verifyVercelIgnoreCommandProjectState (claim);
55 case 'turbo_build_cache_safety' : return verifyTurboBuildCacheSafety (claim);
56 case 'does_not_contradict_project_config' : return verifyNoProjectConfigContradiction (claim);
57 default :
58 return { disposition: 'unverifiable' , reason: `unknown claim type: ${ claim . type }` };
59 }
60 }
61
62 // Catches "enable fluid compute" recs that the brief negative-space filter let through.
63 async function verifyNoProjectConfigContradiction ({ rec , projectFacts }) {
64 if ( ! rec) return { disposition: 'unsupported' , reason: 'no rec attached to claim' };
65 if ( ! Array. isArray (projectFacts) || projectFacts. length === 0 ) {
66 return { disposition: 'unverifiable' , reason: 'no project facts available' };
67 }
68 const hits = findRecContradictions (rec, projectFacts);
69 if (hits. length === 0 ) {
70 return { disposition: 'verified' , reason: 'rec does not contradict any already-on project setting' };
71 }
72 const ids = hits. map (( h ) => h.id). join ( ', ' );
73 return {
74 disposition: 'failed' ,
75 reason: `rec contradicts project config: recommends toggling on already-enabled ${ ids }` ,
76 };
77 }
78
79 async function verifyFileExists ( claim ) {
80 const { file } = claim;
81 if ( ! file) return { disposition: 'unsupported' , reason: 'file_exists requires file' };
82 try {
83 await firstAccessiblePath (claim);
84 return { disposition: 'verified' , reason: `${ file } exists` };
85 } catch {
86 return { disposition: 'failed' , reason: `${ file } does not exist` };
87 }
88 }
89
90 async function verifyPatternCount ( claim ) {
91 const { file , pattern , expected } = claim;
92 if ( ! file || ! pattern) return { disposition: 'unsupported' , reason: 'pattern_count requires file + pattern' };
93 let content;
94 try { ({ content } = await readClaimFile (claim)); }
95 catch { return { disposition: 'failed' , reason: `cannot read ${ file }` }; }
96
97 // "42" alone (from `filename:42`) is a line number, not a pattern.
98 if ( / ^ \d +$ / . test (pattern. trim ())) {
99 return { disposition: 'unsupported' , reason: 'pattern looks like a line number, not a pattern' };
100 }
101
102 const re = compilePattern (pattern, 'g' );
103 const matches = content. match (re) ?? [];
104 const actual = matches. length ;
105 return actual === expected
106 ? { disposition: 'verified' , actual, expected, reason: 'exact count match' }
107 : { disposition: 'failed' , actual, expected, reason: `count mismatch: claim=${ expected }, actual=${ actual }` };
108 }
109
110 async function verifyPatternExists ( claim ) {
111 const { file , pattern } = claim;
112 if ( ! file || ! pattern) return { disposition: 'unsupported' , reason: 'pattern_exists requires file + pattern' };
113 try {
114 const { content } = await readClaimFile (claim);
115 const found = compilePattern (pattern, '' ). test (content);
116 return { disposition: found ? 'verified' : 'failed' , reason: found ? 'pattern present' : 'pattern not found' };
117 } catch {
118 return { disposition: 'failed' , reason: `cannot read ${ file }` };
119 }
120 }
121
122 async function verifyPatternAbsent ( claim ) {
123 const { file , pattern } = claim;
124 if ( ! file || ! pattern) return { disposition: 'unsupported' , reason: 'prose-of-absence: claim requires file + pattern to verify' };
125 try {
126 const { content } = await readClaimFile (claim);
127 const found = compilePattern (pattern, '' ). test (content);
128 return { disposition: ! found ? 'verified' : 'failed' , reason: ! found ? 'pattern absent as claimed' : 'pattern present despite claim of absence' };
129 } catch {
130 return { disposition: 'failed' , reason: `cannot read ${ file }` };
131 }
132 }
133
134 async function verifyCodeSnippet ( claim ) {
135 const { file , snippet , repoRoot = '.' } = claim;
136 if ( ! file || ! snippet) return { disposition: 'unsupported' , reason: 'code_snippet requires file + snippet' };
137 try {
138 const { content } = await readClaimFile (claim);
139 const norm = ( s ) => s. replace ( / \s + / g , ' ' ). trim ();
140 if ( norm (content). includes ( norm (snippet))) {
141 return { disposition: 'verified' , reason: 'snippet found in cited file' };
142 }
143 const elsewhere = await snippetFoundElsewhere (repoRoot, snippet, file);
144 if (elsewhere) {
145 return { disposition: 'unsupported' , reason: `snippet exists in ${ elsewhere }, not in cited ${ file }` };
146 }
147 return { disposition: 'failed' , reason: 'snippet not found in cited file or repo' };
148 } catch {
149 return { disposition: 'failed' , reason: `cannot read ${ file }` };
150 }
151 }
152
153 async function verifyRepoCount ({ pattern , expected , repoRoot = '.' }) {
154 if ( ! pattern || expected == null ) return { disposition: 'unsupported' , reason: 'repo_count requires pattern + expected' };
155 let actual = 0 ;
156 const re = compilePattern (pattern, '' );
157 for await ( const path of walkFiles (repoRoot)) {
158 try {
159 const content = await readFile (path, 'utf-8' );
160 if (re. test (content)) actual ++ ;
161 } catch {}
162 }
163 return actual === expected
164 ? { disposition: 'verified' , actual, expected, reason: 'exact file count match' }
165 : { disposition: 'failed' , actual, expected, reason: `file count: claim=${ expected }, actual=${ actual }` };
166 }
167
168 async function verifyCitationInLibrary ({ url }) {
169 if ( ! url) return { disposition: 'unsupported' , reason: 'citation_in_library requires url' };
170 if ( / ^ [\w-] + : [\w-] +$ / . test (url)) {
171 return { disposition: 'verified' , reason: 'skill-rule reference format (allowed)' };
172 }
173 const known = await isKnownUrl (url);
174 return known
175 ? { disposition: 'verified' , reason: 'URL in curated library' }
176 : { disposition: 'failed' , reason: 'URL not in curated library — likely hallucination' };
177 }
178
179 async function verifyCitationAppliesToVersion ({ url , framework , frameworkVersion }) {
180 if ( ! url || ! framework || ! frameworkVersion) {
181 return { disposition: 'unsupported' , reason: 'requires url + framework + frameworkVersion' };
182 }
183 const fakeRec = { citations: [url] };
184 const { rec , strippedVersion , strippedUnknown } = await sanitizeCitations (fakeRec, framework, frameworkVersion);
185 if (strippedUnknown. length > 0 ) {
186 return { disposition: 'failed' , reason: 'URL not in library' };
187 }
188 if (strippedVersion. length > 0 ) {
189 return { disposition: 'failed' , reason: `URL not applicable to ${ framework }@${ frameworkVersion }` };
190 }
191 return rec.citations. length > 0
192 ? { disposition: 'verified' , reason: `URL applies to ${ framework }@${ frameworkVersion }` }
193 : { disposition: 'unsupported' , reason: 'sanitizer stripped all citations for unknown reason' };
194 }
195
196 async function verifyCacheVaryMatchesDynamicInputs ({ rec , files , repoRoot = '.' , projectRootDirectory = null }) {
197 if ( ! rec || ! Array. isArray (files) || files. length === 0 ) {
198 return { disposition: 'unsupported' , reason: 'cache_vary_matches_dynamic_inputs requires rec + files' };
199 }
200
201 let usesVercelGeo = false ;
202 for ( const file of files) {
203 try {
204 const { content } = await readClaimFile ({ file, repoRoot, projectRootDirectory });
205 if ( / \b geolocation \s * \( / . test (content) ||
206 / \b \w + \. geo \? ? \. / . test (content) ||
207 / ['"] x-vercel-ip-(?:country | country-region | city | latitude | longitude | postal-code | timezone) ['"] / i . test (content)) {
208 usesVercelGeo = true ;
209 break ;
210 }
211 } catch {}
212 }
213 if ( ! usesVercelGeo) {
214 return { disposition: 'verified' , reason: 'cache rec does not touch Vercel geolocation inputs' };
215 }
216
217 const text = [rec.what, rec.why, rec.fix, rec.currentBehavior, rec.desiredBehavior, rec.verify]
218 . filter (Boolean)
219 . join ( ' \n ' );
220 const hasCoarseGeoVary = hasHeaderValue (text, 'Vary' , /(?: ^| , \s * )X-Vercel-IP-(?:Country | Country-Region | City)(?: \s * , |$ )/ i );
221 if (hasCoarseGeoVary) {
222 return { disposition: 'verified' , reason: 'cache rec varies by a coarse Vercel geolocation header for geolocation-dependent output' };
223 }
224 return {
225 disposition: 'failed' ,
226 reason: 'cache rec touches Vercel geolocation but does not vary by a coarse Vercel geolocation header such as X-Vercel-IP-Country, X-Vercel-IP-Country-Region, or X-Vercel-IP-City' ,
227 };
228 }
229
230 async function verifyCacheVaryCardinalitySafe ({ rec }) {
231 if ( ! rec) return { disposition: 'unsupported' , reason: 'cache_vary_cardinality_safe requires rec' };
232 const text = recText (rec);
233 const varyValues = extractHeaderValues (text, 'Vary' ). join ( ', ' );
234 if ( ! varyValues) {
235 return { disposition: 'verified' , reason: 'no concrete Vary header value detected' };
236 }
237 if ( / \b X-Vercel-IP-(?:Latitude | Longitude | Postal-Code) \b / i . test (varyValues)) {
238 return {
239 disposition: 'failed' ,
240 reason: 'Vary on X-Vercel-IP-Latitude, X-Vercel-IP-Longitude, or X-Vercel-IP-Postal-Code creates very high-cardinality CDN cache keys; use a coarser geography header when safe, or leave the response uncached' ,
241 };
242 }
243 return { disposition: 'verified' , reason: 'Vary header avoids known high-cardinality geolocation headers' };
244 }
245
246 async function verifyNextCachedNotFoundCausalSupport ({ rec }) {
247 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cached_not_found_causal_support requires rec' };
248 const text = recText (rec);
249 const citations = Array. isArray (rec.citations) ? rec.citations. join ( ' \n ' ) : '' ;
250 const hasSpecificCitation = /nextjs \. org \/ docs \/ app \/ api-reference \/ functions \/ not-found/ i . test (citations) &&
251 /nextjs \. org \/ docs \/ app \/ api-reference \/ directives \/ use-cache/ i . test (citations);
252 const hasRuntimeStack = / \b (?:stack | logs ?| trace) \b [\s\S] {0,120}\b (?:NEXT_ | notFound | NEXT_HTTP_ERROR_FALLBACK | Error:) \b / i . test (text);
253 if (hasSpecificCitation || hasRuntimeStack) {
254 return { disposition: 'verified' , reason: 'cached notFound causal claim has Next-specific citation or runtime stack evidence' };
255 }
256 return {
257 disposition: 'failed' ,
258 reason: 'notFound() inside use cache was claimed as a 5xx cause without Next-specific citation or runtime stack evidence' ,
259 };
260 }
261
262 async function verifyNextStableCacheApiForVersion ({ rec , framework , frameworkVersion }) {
263 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_stable_cache_api_for_version requires rec' };
264 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
265 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
266 if ( ! Number. isFinite (major) || major < 16 ) {
267 return { disposition: 'verified' , reason: 'stable Next.js 16 cache API requirement does not apply' };
268 }
269 const text = recText (rec);
270 if ( / \b unstable_(?:cacheLife | cacheTag) \b / . test (text)) {
271 return {
272 disposition: 'failed' ,
273 reason: 'Next.js 16 rec uses unstable cache API; use cacheLife/cacheTag from next/cache' ,
274 };
275 }
276 if ( / \b revalidateTag \s * \( [ ^ )] * ['"`][ ^ '"`] + ['"`]\s * \) / . test (text) &&
277 ! / \b revalidateTag \s * \( [ ^ )] * ['"`][ ^ '"`] + ['"`]\s * ,/ . test (text)) {
278 return {
279 disposition: 'failed' ,
280 reason: 'Next.js 16 revalidateTag examples must include the cache-life profile argument' ,
281 };
282 }
283 return { disposition: 'verified' , reason: 'Next.js 16 cache API usage matches stable names' };
284 }
285
286 async function verifyNextRuntimeCacheApiForVersion ({ rec , framework , frameworkVersion }) {
287 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_runtime_cache_api_for_version requires rec' };
288 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
289 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
290 if ( ! Number. isFinite (major) || major < 16 ) {
291 return { disposition: 'verified' , reason: 'Next.js 16 Runtime Cache API requirement does not apply' };
292 }
293 const text = recText (rec);
294 const citations = Array. isArray (rec.citations) ? rec.citations. join ( ' \n ' ) : '' ;
295 if ( / \b unstable_cache \b / . test (text) &&
296 ( / \b Runtime Cache \b / i . test (text) || /vercel \. com \/ docs \/ caching \/ runtime-cache/ i . test (citations))) {
297 return {
298 disposition: 'failed' ,
299 reason: 'Next.js 16 Runtime Cache recommendations must use use cache: remote or fetch with force-cache, not unstable_cache' ,
300 };
301 }
302 return { disposition: 'verified' , reason: 'Next.js Runtime Cache API usage matches project version' };
303 }
304
305 async function verifyNextCacheComponentsRuntimeCachePreference ({ rec , framework , cacheComponents }) {
306 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_components_runtime_cache_preference requires rec' };
307 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
308 if (cacheComponents !== true ) {
309 return { disposition: 'verified' , reason: 'Cache Components not detected as enabled' };
310 }
311 const text = recText (rec);
312 if ( / \b use cache: \s * remote \b / i . test (text)) {
313 return { disposition: 'verified' , reason: 'recommendation uses framework-native remote cache for Cache Components project' };
314 }
315 if ( / \b (?:fallback | only if | when Cache Components (?:is | are) unavailable | if cacheComponents is false) \b [ ^ .\n] {0,180}\b (?:Runtime Cache | @vercel \/ functions | getCache \s * \( )/ i . test (text)) {
316 return { disposition: 'verified' , reason: 'Runtime Cache is framed as a fallback, not the primary Cache Components path' };
317 }
318 return {
319 disposition: 'failed' ,
320 reason: 'Next.js Cache Components is enabled; prefer `use cache: remote` before recommending lower-level Runtime Cache APIs' ,
321 };
322 }
323
324 async function verifyNextCacheLifeSingleExecution ({ rec , framework , frameworkVersion }) {
325 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_life_single_execution requires rec' };
326 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
327 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
328 if ( ! Number. isFinite (major) || major < 16 ) {
329 return { disposition: 'verified' , reason: 'Next.js 16 cacheLife execution rule does not apply' };
330 }
331 const text = recText (rec);
332 const calls = [ ... text. matchAll ( / \b cacheLife \s * \( / g )]. map (( m ) => m.index ?? - 1 ). filter (( i ) => i >= 0 );
333 if (calls. length <= 1 ) {
334 return { disposition: 'verified' , reason: 'at most one cacheLife() call appears in the recommendation' };
335 }
336 for ( let i = 0 ; i < calls. length - 1 ; i ++ ) {
337 const between = text. slice (calls[i], calls[i + 1 ]);
338 if ( / \b (?:if | else | switch | case) \b| [?:] / . test (between)) continue ;
339 return {
340 disposition: 'failed' ,
341 reason: 'multiple cacheLife() calls appear on one recommended code path; only one should execute per function invocation' ,
342 };
343 }
344 return { disposition: 'verified' , reason: 'multiple cacheLife() calls appear only in separate control-flow branches' };
345 }
346
347 async function verifyNextCacheLifetimeFreshnessSupported ({ rec , files , repoRoot = '.' , projectRootDirectory = null }) {
348 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_lifetime_freshness_supported requires rec' };
349 const text = recText (rec);
350 if ( ! / \b cacheLife \s * \( / . test (text)) {
351 return { disposition: 'verified' , reason: 'no cacheLife() lifetime change detected' };
352 }
353
354 const tags = dedupeCacheTags ([
355 ... extractCacheTags (text),
356 ...await extractCacheTagsFromFiles (files, repoRoot, projectRootDirectory),
357 ]);
358 if (tags. length === 0 ) {
359 if ( cacheLifeNeedsContentFreshnessProof (text)) {
360 return {
361 disposition: 'failed' ,
362 reason: 'cacheLife() lengthens content-derived data without cacheTag/revalidateTag evidence; add invalidation evidence or keep the finding out of the ready-to-apply list' ,
363 };
364 }
365 return { disposition: 'unverifiable' , reason: 'cacheLife() rec has no cacheTag evidence to verify against invalidation' };
366 }
367
368 const recTextAsFile = [{ path: '<recommendation>' , content: text }];
369 const invalidationFiles = [
370 ... recTextAsFile,
371 ...await readCacheInvalidationFiles (repoRoot, projectRootDirectory),
372 ];
373 const missing = tags. filter (( tag ) => ! tagHasMatchingInvalidation (tag, invalidationFiles));
374 if (missing. length === 0 ) {
375 return { disposition: 'verified' , reason: 'cacheLife() freshness change has matching cache tag invalidation evidence' };
376 }
377 return {
378 disposition: 'failed' ,
379 reason: `cacheLife() would lengthen tagged content without matching revalidateTag/updateTag evidence for: ${ missing . map (( t ) => t . label ). join ( ', ' ) }` ,
380 };
381 }
382
383 async function verifyNextCacheComponentsRouteChainFile ({ rec , framework , frameworkVersion , cacheComponents , signals }) {
384 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_components_route_chain_file requires rec' };
385 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
386 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
387 if ( ! Number. isFinite (major) || major < 16 ) {
388 return { disposition: 'verified' , reason: 'Cache Components route-chain check does not apply' };
389 }
390 if (cacheComponents !== true ) {
391 return { disposition: 'verified' , reason: 'Cache Components not detected as enabled' };
392 }
393 const targetRoute = routeFromCandidateRef (rec.candidateRef);
394 if ( ! targetRoute) {
395 return { disposition: 'unverifiable' , reason: 'Cache Components layout recommendation has no route candidateRef' };
396 }
397 const routeRows = Array. isArray (signals?.codebase?.routes) ? signals.codebase.routes : [];
398 if (routeRows. length === 0 ) {
399 return { disposition: 'unverifiable' , reason: 'codebase route map unavailable for layout route-chain check' };
400 }
401 const layoutFiles = recommendationFilesFromRec (rec)
402 . filter (( file ) => /( ^| \/ )layout \. (?:tsx ?| jsx ? ) $ / . test ( String (file)));
403 if (layoutFiles. length === 0 ) {
404 return { disposition: 'verified' , reason: 'no layout files named in recommendation' };
405 }
406 const layoutRoutes = routeRows. filter (( route ) =>
407 route?.type === 'layout' &&
408 route?.file &&
409 layoutFiles. some (( file ) => pathSuffixMatches (file, route.file))
410 );
411 if (layoutRoutes. length === 0 ) {
412 return {
413 disposition: 'failed' ,
414 reason: 'Cache Components recommendation cites a layout file that is not present in the scanned route map' ,
415 };
416 }
417 const target = normalizeRouteForLayoutMatch (targetRoute);
418 const matchingLayout = layoutRoutes. find (( layout ) =>
419 layoutAppliesToCandidateRoute (layout.routePath, target)
420 );
421 if (matchingLayout) {
422 return {
423 disposition: 'verified' ,
424 reason: `layout ${ matchingLayout . file } is in the observed route chain for ${ targetRoute }` ,
425 };
426 }
427 return {
428 disposition: 'failed' ,
429 reason: 'Cache Components recommendation cites a layout file outside the observed route chain for this candidate route' ,
430 };
431 }
432
433 async function verifyNextCacheLifeCdnHeaderSemantics ({ rec , framework , frameworkVersion }) {
434 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_life_cdn_header_semantics requires rec' };
435 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
436 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
437 if ( ! Number. isFinite (major) || major < 15 ) {
438 return { disposition: 'verified' , reason: 'Cache Components cacheLife semantics do not apply to this Next.js version' };
439 }
440 return {
441 disposition: 'failed' ,
442 reason: 'cacheLife() controls the Cache Components lifetime and defaults to the default profile when omitted; do not claim it emits CDN Cache-Control headers or that missing cacheLife alone makes a route run per request without production header evidence' ,
443 };
444 }
445
446 async function verifyImageResponseHeadersCitation ({ rec , framework }) {
447 if ( ! rec) return { disposition: 'unsupported' , reason: 'image_response_headers_citation requires rec' };
448 if (framework && framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
449 const citations = Array. isArray (rec.citations) ? rec.citations. join ( ' \n ' ) : '' ;
450 if ( /nextjs \. org \/ docs \/ app \/ api-reference \/ functions \/ image-response/ i . test (citations)) {
451 return { disposition: 'verified' , reason: 'ImageResponse header option is backed by the ImageResponse API reference' };
452 }
453 return {
454 disposition: 'failed' ,
455 reason: 'ImageResponse header changes need the ImageResponse API reference citation' ,
456 };
457 }
458
459 async function verifyNextImagePriorityApiForVersion ({ rec , framework , frameworkVersion }) {
460 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_image_priority_api_for_version requires rec' };
461 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
462 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
463 if ( ! Number. isFinite (major) || major < 16 ) {
464 return { disposition: 'verified' , reason: 'next/image priority deprecation does not apply before Next.js 16' };
465 }
466 const text = recText (rec);
467 if ( / \b (?:preload | fetchPriority | loading \s * = \s * ['"`] eager ['"`] | loading: \s * ['"`] eager ['"`] ) \b / . test (text) &&
468 ! /<Image \b [ ^ >] *\b priority(?: \s | = | >)/ i . test (text)) {
469 return { disposition: 'verified' , reason: 'Next.js 16 image preload guidance uses the replacement API' };
470 }
471 return {
472 disposition: 'failed' ,
473 reason: 'Next.js 16 deprecates next/image priority; use preload, fetchPriority, or loading="eager" based on the image loading intent' ,
474 };
475 }
476
477 async function verifyNextCacheComponentsRouteSegmentConfig ({ rec , framework , frameworkVersion , cacheComponents }) {
478 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_components_route_segment_config requires rec' };
479 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
480 const major = parseInt ( String (frameworkVersion ?? '' ). match ( / \d + / )?.[ 0 ] ?? '' , 10 );
481 if ( ! Number. isFinite (major) || major < 16 ) {
482 return { disposition: 'verified' , reason: 'Cache Components route segment config restriction does not apply' };
483 }
484 if (cacheComponents !== true ) {
485 return { disposition: 'verified' , reason: 'Cache Components not detected as enabled' };
486 }
487 const text = recText (rec);
488 if ( / \b route segment config options ?\b [ ^ .\n] {0,120}\b (?:Route Handlers ?| handlers ? ) \b [ ^ .\n] {0,120}\b (?:no longer apply | do not apply | removed) \b / i . test (text)) {
489 return {
490 disposition: 'failed' ,
491 reason: 'Route Segment Config still has Route Handler options; with Cache Components only dynamic, revalidate, and fetchCache are removed' ,
492 };
493 }
494 const blocked = [
495 / \b dynamicParams \b / . test (text) ? 'dynamicParams' : null ,
496 / \b fetchCache \b / . test (text) ? 'fetchCache' : null ,
497 / \b export \s + const \s + dynamic \b / . test (text) ? 'dynamic' : null ,
498 / \b export \s + const \s + revalidate \b / . test (text) ? 'revalidate' : null ,
499 ]. filter (Boolean);
500 if (blocked. length === 0 ) {
501 return { disposition: 'verified' , reason: 'no removed route segment config option detected' };
502 }
503 return {
504 disposition: 'failed' ,
505 reason: `Next.js ${ major } project has Cache Components enabled; route segment config option(s) ${ blocked . join ( ', ' ) } are removed and must not be recommended` ,
506 };
507 }
508
509 async function verifyNextRouteRevalidateStaticPrereq ({ rec , framework , cacheComponents , repoRoot = '.' , projectRootDirectory = null }) {
510 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_route_revalidate_static_prereq requires rec' };
511 if (framework !== 'next' ) return { disposition: 'verified' , reason: 'not a Next.js project' };
512 if (cacheComponents === true ) {
513 return { disposition: 'verified' , reason: 'Cache Components route-segment restrictions are handled separately' };
514 }
515 const files = recommendationFilesFromRec (rec)
516 . filter (( file ) => /( ^| \/ )app \/ . + \/ (?:page | layout | template) \. (?:tsx ?| jsx ? ) $ / . test ( String (file)) ||
517 /( ^| \/ )(?:page | layout | template) \. (?:tsx ?| jsx ? ) $ / . test ( String (file)));
518 if (files. length === 0 ) {
519 return { disposition: 'verified' , reason: 'route-level revalidate recommendation does not target a page/layout/template file' };
520 }
521
522 const dynamicHits = [];
523 for ( const file of files) {
524 const routeChain = await readNextRouteChainFiles (file, repoRoot, projectRootDirectory);
525 if (routeChain. length === 0 ) {
526 return { disposition: 'unverifiable' , reason: `could not inspect route chain for ${ file }` };
527 }
528 for ( const entry of routeChain) {
529 const hit = firstDynamicRouteChainReason (entry.content);
530 if (hit) dynamicHits. push ( `${ entry . relative }:${ hit }` );
531 }
532 }
533 if (dynamicHits. length > 0 ) {
534 return {
535 disposition: 'failed' ,
536 reason: `route-level revalidate can be defeated by request-time APIs or auth helpers in the route chain (${ dynamicHits . slice ( 0 , 3 ). join ( ', ' ) }); prove the route is ISR/static from next build output or move the dynamic read out before recommending revalidate` ,
537 };
538 }
539 return { disposition: 'verified' , reason: 'no request-time API or common auth helper detected in the recommended route chain' };
540 }
541
542 async function verifyNextCacheTagInvalidationSupported ({ rec , repoRoot = '.' , projectRootDirectory = null }) {
543 if ( ! rec) return { disposition: 'unsupported' , reason: 'next_cache_tag_invalidation_supported requires rec' };
544 const tags = extractCacheTags ( recText (rec));
545 if (tags. length === 0 ) {
546 return { disposition: 'unsupported' , reason: 'cache invalidation claim did not include parseable cacheTag() values' };
547 }
548
549 let files;
550 try {
551 files = await readCacheInvalidationFiles (repoRoot, projectRootDirectory);
552 } catch {
553 return { disposition: 'unverifiable' , reason: 'could not scan repo for matching revalidateTag/updateTag calls' };
554 }
555
556 const missing = [];
557 for ( const tag of tags) {
558 if ( ! tagHasMatchingInvalidation (tag, files)) missing. push (tag.label);
559 }
560 if (missing. length === 0 ) {
561 return { disposition: 'verified' , reason: 'every claimed cacheTag has a matching revalidateTag/updateTag path' };
562 }
563 return {
564 disposition: 'failed' ,
565 reason: `cache invalidation was claimed for tag(s) without matching revalidateTag/updateTag evidence: ${ missing . join ( ', ' ) }` ,
566 };
567 }
568
569 async function verifyCacheRecNotErrorDominatedOrAcknowledged ({ rec , signals }) {
570 if ( ! rec) return { disposition: 'unsupported' , reason: 'cache_rec_not_error_dominated_or_acknowledged requires rec' };
571 const route = routeFromCandidateRef (rec.candidateRef);
572 if ( ! route) return { disposition: 'unverifiable' , reason: 'cache recommendation has no route candidateRef' };
573 const status = functionStatusForRoute (signals, route);
574 if ( ! status || status.total <= 0 ) {
575 return { disposition: 'unverifiable' , reason: 'no function status metrics available for cache route' };
576 }
577 const errorRate = status.errors / status.total;
578 if (errorRate <= 0.2 ) {
579 return { disposition: 'verified' , reason: `function 5xx rate is not dominant (${ formatPct ( errorRate ) })` };
580 }
581 const text = recText (rec);
582 if ( / \b (?:5xx | 500 | errors ?| error-rate | non-error | successful | 2xx | after \s + (?:fixing | resolving) \s + errors ? ) \b / i . test (text)) {
583 return { disposition: 'verified' , reason: `cache recommendation acknowledges high 5xx share (${ formatPct ( errorRate ) })` };
584 }
585 return {
586 disposition: 'failed' ,
587 reason: `route has high function 5xx share (${ formatPct ( errorRate ) }); cache impact must exclude or acknowledge error traffic` ,
588 };
589 }
590
591 async function verifyCacheControlHeaderSyntax ({ rec }) {
592 if ( ! rec) return { disposition: 'unsupported' , reason: 'cache_control_header_syntax requires rec' };
593 const values = [
594 ... extractHeaderValues ( recText (rec), 'Cache-Control' ),
595 ... extractHeaderValues ( recText (rec), 'CDN-Cache-Control' ),
596 ... extractHeaderValues ( recText (rec), 'Vercel-CDN-Cache-Control' ),
597 ];
598 if (values. length === 0 ) {
599 return { disposition: 'unverifiable' , reason: 'no parseable Cache-Control header value in recommendation' };
600 }
601 const invalid = values. find (( value ) => hasEmptyCacheDirective (value));
602 if (invalid) {
603 return {
604 disposition: 'failed' ,
605 reason: `Cache-Control header contains an empty directive: ${ invalid }` ,
606 };
607 }
608 return { disposition: 'verified' , reason: 'cache header directives are syntactically non-empty' };
609 }
610
611 async function verifyCacheControlHeadersCitation ({ rec }) {
612 if ( ! rec) return { disposition: 'unsupported' , reason: 'cache_control_headers_citation requires rec' };
613 const citations = Array. isArray (rec.citations) ? rec.citations. join ( ' \n ' ) : '' ;
614 if ( /vercel \. com \/ docs \/ caching \/ (?:cache-control-headers | cdn-cache)/ i . test (citations)) {
615 return { disposition: 'verified' , reason: 'Cache-Control change is backed by Vercel cache documentation' };
616 }
617 return {
618 disposition: 'failed' ,
619 reason: 'Cache-Control header changes need Vercel cache documentation citation' ,
620 };
621 }
622
623 async function verifyCachePolicyPositiveOrNoReadyRec ({ rec }) {
624 if ( ! rec) return { disposition: 'unsupported' , reason: 'cache_policy_positive_or_no_ready_rec requires rec' };
625 const text = recText (rec);
626 const positivePolicy = / \b (?:s-maxage | stale-while-revalidate | CDN-Cache-Control | Vercel-CDN-Cache-Control | Cache-Control: \s * public | next: \s * \{ \s * revalidate | revalidate \s * [:=]\s * \d | cacheLife \s * \( | cacheTag \s * \( | ['"`] use cache(?:: \s * remote) ? ['"`] | Runtime Cache | getCache \s * \( | force-cache) \b / i . test (text);
627 if (positivePolicy) {
628 return { disposition: 'verified' , reason: 'cache recommendation names a positive cache policy' };
629 }
630 if ( / \b (?:no-store | no-cache | cache: \s * ['"`] no-store ['"`] ) \b / i . test (text)) {
631 return {
632 disposition: 'failed' ,
633 reason: 'cache candidates must not ship a no-store-only recommendation; if no-store is correct, report no change instead' ,
634 };
635 }
636 return {
637 disposition: 'failed' ,
638 reason: 'cache candidate recommendation does not name a cache policy; specify CDN headers, framework cache, Runtime Cache, or report no change' ,
639 };
640 }
641
642 async function verifyCache404LongTtlSafety ({ rec }) {
643 if ( ! rec) return { disposition: 'unsupported' , reason: 'cache_404_long_ttl_safety requires rec' };
644 const text = recText (rec);
645 if ( / \b (?:leave | keep | leaving | keeping) \b [ ^ .\n] {0,120}\b (?:404 | not [- ] found | notFound | not found branch | not-found branch) \b [ ^ .\n] {0,120}\b (?:uncached | no-store | no-cache | short | separate) \b / i . test (text) ||
646 / \b (?:404 | not [- ] found | notFound | not found branch | not-found branch) \b [ ^ .\n] {0,120}\b (?:uncached | no-store | no-cache | short | separate) \b / i . test (text)) {
647 return { disposition: 'verified' , reason: 'recommendation keeps 404/not-found caching separate or uncached' };
648 }
649 if ( / \b (?:both | all) \b [ ^ .\n] {0,120}\b Response \b [ ^ .\n] {0,120}\b (?:404 | not [- ] found | notFound | not found branch | not-found branch) \b / i . test (text) ||
650 / \b (?:add | set | include) \b [ ^ .\n] {0,160}\b (?:Cache-Control | s-maxage | stale-while-revalidate | CDN-Cache-Control | Vercel-CDN-Cache-Control) \b [ ^ .\n] {0,220}\b (?:each | every | all | both | \d +| four) \b [ ^ .\n] {0,120}\b Response \b [ ^ .\n] {0,160}\b (?:404 | not [- ] found | notFound | not found branch | not-found branch) \b / i . test (text) ||
651 / \b (?:404 | not [- ] found | notFound | not found branch | not-found branch) \b [ ^ .\n] {0,160}\b (?:s-maxage | stale-while-revalidate | CDN-Cache-Control | Vercel-CDN-Cache-Control) \b / i . test (text)) {
652 return {
653 disposition: 'failed' ,
654 reason: 'long shared caching for 404/not-found branches needs explicit freshness evidence; leave those branches uncached or short-lived' ,
655 };
656 }
657 return {
658 disposition: 'failed' ,
659 reason: 'cache recommendation mentions a 404/not-found branch without explicitly keeping that branch uncached or short-lived' ,
660 };
661 }
662
663 async function verifyRouteErrorNotFoundStatusAndScope ({ rec }) {
664 if ( ! rec) return { disposition: 'unsupported' , reason: 'route_error_not_found_status_and_scope requires rec' };
665 const text = recText (rec);
666 const hasExplicit404 = / \b status \s * : \s * 404 \b / i . test (text);
667 if ( ! hasExplicit404) {
668 return {
669 disposition: 'failed' ,
670 reason: 'not-found error handling must set an explicit 404 status; a markdown/body-only Response defaults to 200' ,
671 };
672 }
673 if ( routeErrorFixExplicitlyConvertsUnexpectedErrorsToNotFound (text)) {
674 return {
675 disposition: 'failed' ,
676 reason: 'route-error 404 fixes must not convert unexpected exceptions into not-found responses; classify expected misses and preserve 5xx behavior for unknown errors' ,
677 };
678 }
679 const classifiesKnownMiss = / \b (?:known | expected | missing | not [- ] found | not found | ENOENT | NoSuchKey | content [- ] miss | file [- ] miss) \b [ ^ .\n] {0,160}\b (?:only | separate | classif | branch | guard | case) \b / i . test (text) ||
680 / \b (?:only | separate | classif | branch | guard | case) \b [ ^ .\n] {0,160}\b (?:known | expected | missing | not [- ] found | not found | ENOENT | NoSuchKey | content [- ] miss | file [- ] miss) \b / i . test (text);
681 const preservesUnknownErrors = / \b (?:unknown | unexpected | all other | other) \b [ ^ .\n] {0,180}\b (?:rethrow | throw | 500 | 5xx | surface | preserv | remain visible | do not convert) \b / i . test (text) ||
682 / \b (?:rethrow | throw | 500 | 5xx | surface | preserv | remain visible | do not convert) \b [ ^ .\n] {0,180}\b (?:unknown | unexpected | all other | other) \b / i . test (text);
683 if (classifiesKnownMiss && preservesUnknownErrors) {
684 return { disposition: 'verified' , reason: 'catch path separates expected misses from unknown errors and sets status 404' };
685 }
686 if ( routeErrorFixBroadlyCatchesNotFound (text)) {
687 return {
688 disposition: 'failed' ,
689 reason: 'route-error 404 fixes must classify expected misses before returning not-found and must not turn generic catch blocks into 404 responses' ,
690 };
691 }
692 return {
693 disposition: 'failed' ,
694 reason: 'route-error 404 fixes must classify expected misses separately and preserve logging or 5xx behavior for unknown errors' ,
695 };
696 }
697
698 function routeErrorFixExplicitlyConvertsUnexpectedErrorsToNotFound ( text ) {
699 return / \b unexpected \s + exceptions ?\b [ ^ .\n] {0,180}\b (?:degrade | convert | return | become | map) \b [ ^ .\n] {0,160}\b (?:404 | not [- ] found | not found | notFound) \b / i . test (text) ||
700 / \b (?:404 | not [- ] found | not found | notFound) \b [ ^ .\n] {0,160}\b (?:for | on) \b [ ^ .\n] {0,80}\b (?:any | all | unexpected | unknown) \b [ ^ .\n] {0,80}\b exceptions ?\b / i . test (text) ||
701 / \b (?:any | all | unexpected | unknown) \b [ ^ .\n] {0,80}\b exceptions ?\b [ ^ .\n] {0,160}\b (?:404 | not [- ] found | not found | notFound) \b / i . test (text);
702 }
703
704 function routeErrorFixBroadlyCatchesNotFound ( text ) {
705 return / \b (?:catch | catch \s * \( [ ^ )] * \) ) \b [ ^ .\n] {0,220}\b (?:return | respond | degrade | convert) \b [ ^ .\n] {0,160}\b (?:404 | not [- ] found | not found | notFound) \b / i . test (text);
706 }
707
708 async function verifyImmutableDynamicRouteSafety ({ rec }) {
709 if ( ! rec) return { disposition: 'unsupported' , reason: 'immutable_dynamic_route_safety requires rec' };
710 const text = recText (rec);
711 if ( / \b (?:content [- ] hash(?:ed) ?| hashed | fingerprint(?:ed) ?| versioned \s + URL | URL \s + changes \s + when \s + bytes \s + change) \b / i . test (text)) {
712 return { disposition: 'verified' , reason: 'immutable cache header is tied to a byte-versioned URL' };
713 }
714 if ( / \b Vercel-CDN-Cache-Control \b / i . test (text) && ! /(?: ^| [ ^ A-Za-z-] )Cache-Control \s * : \s * [ ^ .\n] *\b immutable \b / i . test (text)) {
715 return { disposition: 'verified' , reason: 'immutable directive is scoped away from browser Cache-Control' };
716 }
717 return {
718 disposition: 'failed' ,
719 reason: 'immutable browser caching on a dynamic route requires a content-hashed or otherwise byte-versioned URL' ,
720 };
721 }
722
723 async function verifyAuthGuardParallelizationSafety ({ rec }) {
724 if ( ! rec) return { disposition: 'unsupported' , reason: 'auth_guard_parallelization_safety requires rec' };
725 const text = recText (rec);
726 if ( / \b (?:query | lookup | fetch) \b [ ^ .\n] {0,120}\b (?:constrained | scoped | filtered) \b [ ^ .\n] {0,120}\b (?:email | user | owner | ownership | session | account | tenant | permission | auth)/ i . test (text) ||
727 / \b (?:preserve | keep | retain) \b [ ^ .\n] {0,120}\b (?:auth | authorization | ownership | permission | access) \s + (?:check | guard | gate) \b [ ^ .\n] {0,120}\b (?:before | ahead of | prior to | sequential | not parallel)/ i . test (text)) {
728 return { disposition: 'verified' , reason: 'parallelization recommendation preserves the auth/ownership guard' };
729 }
730 if ( / \b Promise \. all \s * \( [\s\S] {0,500} (?:private | secret | token | registrant | ticket | payment | account | user) \w * [\s\S] {0,500} (?:owns | owner | ownership | authorize | auth | permission | access) \w * / i . test (text) ||
731 / \b Promise \. all \s * \( [\s\S] {0,500} (?:owns | owner | ownership | authorize | auth | permission | access) \w * [\s\S] {0,500} (?:private | secret | token | registrant | ticket | payment | account | user) \w * / i . test (text)) {
732 return {
733 disposition: 'failed' ,
734 reason: 'parallelization may fetch private data before the ownership/auth check has passed; combine the authorized query or keep the guard sequential' ,
735 };
736 }
737 return {
738 disposition: 'unverifiable' ,
739 reason: 'auth-sensitive parallelization needs explicit evidence that private data is not fetched before authorization' ,
740 };
741 }
742
743 async function verifyParallelizationImpactNotOverclaimed ({ rec }) {
744 if ( ! rec) return { disposition: 'unsupported' , reason: 'parallelization_impact_not_overclaimed requires rec' };
745 const text = recText (rec);
746 if ( / \b (?:measured | trace | span | profile | instrumented) \b [ ^ .\n] {0,120}\b (?:duration | round [- ] trip | query | helper | await) \b / i . test (text)) {
747 return { disposition: 'verified' , reason: 'parallelization impact claim cites measured helper/span duration' };
748 }
749 return {
750 disposition: 'failed' ,
751 reason: 'parallelization impact promises a helper/round-trip-sized drop without measured helper or span timing' ,
752 };
753 }
754
755 async function verifyParallelizationNotCpuBoundWork ({ rec }) {
756 if ( ! rec) return { disposition: 'unsupported' , reason: 'parallelization_not_cpu_bound_work requires rec' };
757 const text = recText (rec);
758 if ( / \b (?:measured | trace | span | profile | instrumented) \b [ ^ .\n] {0,160}\b (?:wait | I \/ O | io | network | fetch | database | query | CMS | API) \b / i . test (text)) {
759 return { disposition: 'verified' , reason: 'parallelization target cites measured wait/I/O time' };
760 }
761 if ( / \b (?:cpu \. p95 | CPU p95 | cpu p95 | CPU-bound | compute-bound | in-process compute | compileMDX | MDX compilation | compilation | render compute) \b / i . test (text)) {
762 return {
763 disposition: 'failed' ,
764 reason: 'parallelization targets CPU/compile work without measured independent wait time; Promise.all is not a safe latency fix for CPU-bound work' ,
765 };
766 }
767 return { disposition: 'verified' , reason: 'parallelization target is not described as CPU-bound work' };
768 }
769
770 async function verifyRuntimeErrorCauseSupported ({ rec }) {
771 if ( ! rec) return { disposition: 'unsupported' , reason: 'runtime_error_cause_supported requires rec' };
772 const text = recText (rec);
773 const hasRuntimeStack = / \b (?:stack | logs ?| trace) \b [\s\S] {0,220}\b (?:Error: | ENOENT | ETIMEDOUT | ECONNRESET | NEXT_ | at \s + [\w./[ \] ()-] + (?:: \d + ) ? )/ i . test (text);
774 if (hasRuntimeStack) {
775 return { disposition: 'verified' , reason: 'runtime error cause is backed by logs or stack evidence' };
776 }
777 return {
778 disposition: 'failed' ,
779 reason: 'runtime error root cause was claimed without runtime logs or stack evidence' ,
780 };
781 }
782
783 async function verifyVercelIgnoreCommandProjectState ({ rec , signals }) {
784 if ( ! rec) return { disposition: 'unsupported' , reason: 'vercel_ignore_command_project_state requires rec' };
785 const project = signals?.project;
786 if ( ! project || typeof project !== 'object' ) {
787 return { disposition: 'unverifiable' , reason: 'project configuration unavailable for Ignored Build Step check' };
788 }
789 const text = recText (rec);
790 if ( typeof project.commandForIgnoringBuildStep === 'string' && project.commandForIgnoringBuildStep. trim () !== '' ) {
791 return {
792 disposition: 'failed' ,
793 reason: 'project already has an Ignored Build Step command configured; do not recommend adding another without evidence the current command is insufficient' ,
794 };
795 }
796 if (project.enableAffectedProjectsDeployments === true &&
797 / \b (?:Ignored Build Step | ignoreCommand | turbo-ignore | skip unaffected | unaffected projects ? ) \b / i . test (text)) {
798 return {
799 disposition: 'failed' ,
800 reason: 'project already has Vercel skip-unaffected deployments enabled; do not recommend another build-skipping change without evidence that automatic skipping is unavailable or insufficient' ,
801 };
802 }
803 return { disposition: 'verified' , reason: 'project config does not contradict Ignored Build Step recommendation' };
804 }
805
806 async function verifyTurboBuildCacheSafety ({ rec , files , repoRoot = '.' , projectRootDirectory = null , framework }) {
807 if ( ! rec) return { disposition: 'unsupported' , reason: 'turbo_build_cache_safety requires rec' };
808 const candidateFiles = Array. isArray (files) ? files : [];
809 const turboFiles = candidateFiles. filter (( file ) => /( ^| \/ )turbo \. json $ / . test ( String (file)));
810 if (turboFiles. length === 0 ) {
811 return { disposition: 'unverifiable' , reason: 'Turbo build-cache recommendation has no turbo.json file to inspect' };
812 }
813
814 const text = recText (rec);
815 for ( const turboFile of turboFiles) {
816 let turbo;
817 try {
818 const { content } = await readClaimFile ({ file: turboFile, repoRoot, projectRootDirectory });
819 turbo = parseJsonLike (content);
820 } catch {
821 return { disposition: 'unverifiable' , reason: `cannot parse ${ turboFile } for Turbo cache safety` };
822 }
823 const buildTask = turbo?.tasks?.build ?? turbo?.pipeline?.build ?? null ;
824 const outputs = Array. isArray (buildTask?.outputs) ? buildTask.outputs. map (String) : [];
825 const pkgFile = siblingPackageJson (turboFile);
826 const pkg = await readOptionalJsonFile ({ file: pkgFile, repoRoot, projectRootDirectory });
827 const buildScript = typeof pkg?.scripts?.build === 'string' ? pkg.scripts.build : '' ;
828 const hasNext = framework === 'next' || Boolean (pkg?.dependencies?.next || pkg?.devDependencies?.next);
829
830 if ( buildScriptHasMigrationSideEffect (buildScript) && ! recSeparatesTurboBuildSideEffects (text)) {
831 return {
832 disposition: 'failed' ,
833 reason: 'Turbo build caching is unsafe for this build task because the package build script runs migrations or other side effects; split those steps before caching the build output' ,
834 };
835 }
836
837 if (hasNext && outputs. length > 0 && ! outputs. some (( output ) => / \. next(?: \/ | \*\* )/ . test (output))) {
838 return {
839 disposition: 'failed' ,
840 reason: 'Turbo build cache outputs do not include Next.js build output (`.next/**`); fix the output contract before enabling build caching' ,
841 };
842 }
843 }
844
845 return { disposition: 'verified' , reason: 'Turbo build cache recommendation does not conflict with local build scripts or outputs' };
846 }
847
848 function siblingPackageJson ( file ) {
849 return join ( dirname ( String (file)), 'package.json' );
850 }
851
852 async function readOptionalJsonFile ( claim ) {
853 try {
854 const { content } = await readClaimFile (claim);
855 return JSON . parse (content);
856 } catch {
857 return null ;
858 }
859 }
860
861 function parseJsonLike ( content ) {
862 return JSON . parse (
863 String (content)
864 . replace ( / \/\* [\s\S] *? \*\/ / g , '' )
865 . replace ( /( ^| [ ^ :] ) \/\/ . *$ / gm , '$1' )
866 . replace ( /, \s * ( [} \] ] )/ g , '$1' )
867 );
868 }
869
870 function buildScriptHasMigrationSideEffect ( script ) {
871 return / \b (?:payload \s + migrate | prisma \s + migrate | knex \s + migrate | sequelize \s + db:migrate | db:migrate | migrate(?:: | \s |$ ) | migration) \b / i . test ( String (script));
872 }
873
874 function recSeparatesTurboBuildSideEffects ( text ) {
875 return / \b (?:split | separate | move | keep) \b [ ^ .\n] {0,180}\b (?:migrations ?| side effects ?| payload migrate | prisma migrate) \b [ ^ .\n] {0,180}\b (?:outside | before | uncached | separate) \b / i . test (text) ||
876 / \b (?:cache | enable caching for) \b [ ^ .\n] {0,120}\b (?:buildonly | pure build | next build) \b [ ^ .\n] {0,180}\b (?:not | without | after separating) \b [ ^ .\n] {0,120}\b (?:migrations ?| side effects ? ) \b / i . test (text);
877 }
878
879 function recText ( rec ) {
880 return [rec?.what, rec?.why, rec?.fix, rec?.currentBehavior, rec?.desiredBehavior, rec?.verify]
881 . filter (Boolean)
882 . join ( ' \n ' );
883 }
884
885 function extractHeaderValues ( text , header ) {
886 const escaped = escapeRegExp (header);
887 const values = [];
888 const quotedKey = new RegExp ( `['" \` ]${ escaped }['" \` ] \\ s*: \\ s*['" \` ]([^'" \`\\ n]+)['" \` ]` , 'gi' );
889 for ( const m of text. matchAll (quotedKey)) values. push (m[ 1 ]. trim ());
890 const bareKey = new RegExp ( ` \\ b${ escaped } \\ b \\ s*: \\ s*['" \` ]?([^'" \`\\ n]+)['" \` ]?` , 'gi' );
891 for ( const m of text. matchAll (bareKey)) values. push ( cleanHeaderValue (m[ 1 ]));
892 return Array. from ( new Set (values. filter (Boolean)));
893 }
894
895 function hasHeaderValue ( text , header , valuePattern ) {
896 return extractHeaderValues (text, header). some (( value ) => valuePattern. test (value));
897 }
898
899 function cleanHeaderValue ( value ) {
900 return String (value)
901 . replace ( / [).;] +$ / g , '' )
902 . replace ( / \s + and \s + . *$ / i , '' )
903 . trim ();
904 }
905
906 function hasEmptyCacheDirective ( value ) {
907 return String (value). split ( ',' ). some (( part ) => part. trim () === '' );
908 }
909
910 function extractCacheTags ( text ) {
911 const tags = [];
912 const callRe = / \b cacheTag \s * \( ( [ ^ )] * ) \) / gs ;
913 for ( const call of text. matchAll (callRe)) {
914 const args = call[ 1 ] ?? '' ;
915 for ( const m of args. matchAll ( / ['"] ( [ ^ '"] + ) ['"] / g )) {
916 tags. push ({ kind: 'exact' , value: m[ 1 ], label: m[ 1 ] });
917 }
918 for ( const m of args. matchAll ( /`( [ ^ `] + )`/ g )) {
919 const raw = m[ 1 ];
920 const prefix = raw. split ( '${' )[ 0 ];
921 if (raw. includes ( '${' ) && prefix) {
922 tags. push ({ kind: 'prefix' , value: prefix, label: raw });
923 } else if ( ! raw. includes ( '${' )) {
924 tags. push ({ kind: 'exact' , value: raw, label: raw });
925 }
926 }
927 }
928 const seen = new Set ();
929 return tags. filter (( tag ) => {
930 const key = `${ tag . kind } \u0000 ${ tag . value }` ;
931 if (seen. has (key)) return false ;
932 seen. add (key);
933 return true ;
934 });
935 }
936
937 async function extractCacheTagsFromFiles ( files , repoRoot , projectRootDirectory ) {
938 const out = [];
939 if ( ! Array. isArray (files)) return out;
940 for ( const file of files) {
941 try {
942 const { content } = await readClaimFile ({ file, repoRoot, projectRootDirectory });
943 out. push ( ... extractCacheTags (content));
944 } catch {}
945 }
946 return out;
947 }
948
949 function dedupeCacheTags ( tags ) {
950 const seen = new Set ();
951 return tags. filter (( tag ) => {
952 const key = `${ tag . kind } \u0000 ${ tag . value }` ;
953 if (seen. has (key)) return false ;
954 seen. add (key);
955 return true ;
956 });
957 }
958
959 async function readCacheInvalidationFiles ( repoRoot , projectRootDirectory ) {
960 const cacheKey = `${ normalize ( repoRoot || '.' ) } \u0000 ${ normalizeProjectRootDirectory ( projectRootDirectory ) ?? ''}` ;
961 if (cacheInvalidationFileCache. has (cacheKey)) return cacheInvalidationFileCache. get (cacheKey);
962 const baseRoot = normalize (repoRoot || '.' );
963 const projectRoot = normalizeProjectRootDirectory (projectRootDirectory);
964 const root = projectRoot ? join (baseRoot, projectRoot) : baseRoot;
965 try {
966 await access (root);
967 } catch {
968 cacheInvalidationFileCache. set (cacheKey, []);
969 return [];
970 }
971 const rgFiles = await rgRelevantFiles (root);
972 if (Array. isArray (rgFiles)) {
973 const files = [];
974 for ( const path of rgFiles. slice ( 0 , 500 )) {
975 try {
976 files. push ({ path, content: await readFile (path, 'utf-8' ) });
977 } catch {}
978 }
979 cacheInvalidationFileCache. set (cacheKey, files);
980 return files;
981 }
982 const files = [];
983 for await ( const path of walkFiles (root)) {
984 try {
985 const content = await readFile (path, 'utf-8' );
986 if ( ! / \b (?:revalidateTag | updateTag) \s * \( |\b tags \s * :/ . test (content)) continue ;
987 files. push ({ path, content });
988 } catch {}
989 }
990 cacheInvalidationFileCache. set (cacheKey, files);
991 return files;
992 }
993
994 async function rgRelevantFiles ( root ) {
995 try {
996 const { stdout } = await execFileP ( 'rg' , [
997 '-l' ,
998 '--glob' , '!node_modules/**' ,
999 '--glob' , '!.next/**' ,
1000 '--glob' , '!.vercel/**' ,
1001 '--glob' , '!.turbo/**' ,
1002 '--glob' , '!dist/**' ,
1003 '--glob' , '!build/**' ,
1004 '--glob' , '!coverage/**' ,
1005 '--glob' , '!content/**' ,
1006 '--glob' , '!fixtures/**' ,
1007 '--glob' , '!migrations/**' ,
1008 '--glob' , '!public/**' ,
1009 '--glob' , '*.{ts,tsx,js,jsx,mjs,cjs}' ,
1010 String. raw ` \b (?:revalidateTag|updateTag) \s * \( | \b tags \s *:` ,
1011 root,
1012 ], { maxBuffer: 10 * 1024 * 1024 });
1013 return stdout. split ( / \r ? \n / ). filter (Boolean);
1014 } catch (err) {
1015 if (err?.code === 1 ) return [];
1016 return null ;
1017 }
1018 }
1019
1020 function tagHasMatchingInvalidation ( tag , files ) {
1021 return files. some (({ content }) => {
1022 if ( hasLiteralInvalidation (content, tag)) return true ;
1023 return hasConfigDrivenInvalidation (content, tag, files);
1024 });
1025 }
1026
1027 function hasLiteralInvalidation ( content , tag ) {
1028 if (tag.kind === 'exact' ) {
1029 const escaped = escapeRegExp (tag.value);
1030 return new RegExp ( ` \\ b(?:revalidateTag|updateTag) \\ s* \\ ( \\ s*['" \` ]${ escaped }['" \` ]` ). test (content);
1031 }
1032 const escaped = escapeRegExp (tag.value);
1033 return new RegExp ( ` \\ b(?:revalidateTag|updateTag) \\ s* \\ ( \\ s* \` ?${ escaped }` ). test (content);
1034 }
1035
1036 function hasConfigDrivenInvalidation ( content , tag , files ) {
1037 if ( ! / \b revalidateTag \s * \( \s * \w + / . test (content)) return false ;
1038 return files. some (( file ) => configContainsTag (file.content, tag));
1039 }
1040
1041 function configContainsTag ( content , tag ) {
1042 if (tag.kind === 'exact' ) {
1043 const escaped = escapeRegExp (tag.value);
1044 return new RegExp ( ` \\ btags \\ s*: \\ s* \\ [[^ \\ ]]*['" \` ]${ escaped }['" \` ]` , 's' ). test (content);
1045 }
1046 const escaped = escapeRegExp (tag.value);
1047 return new RegExp ( ` \\ btags \\ s*: \\ s* \\ [[^ \\ ]]* \` ?${ escaped }` , 's' ). test (content);
1048 }
1049
1050 function routeFromCandidateRef ( ref ) {
1051 if ( typeof ref !== 'string' ) return null ;
1052 const idx = ref. indexOf ( ':' );
1053 if (idx < 0 ) return null ;
1054 const route = ref. slice (idx + 1 );
1055 return route && route !== '<account>' && ! route. startsWith ( '<account>#' ) ? route : null ;
1056 }
1057
1058 function functionStatusForRoute ( signals , route ) {
1059 const rows = signals?.metrics?.fnStatusByRoute?.rows;
1060 if ( ! Array. isArray (rows)) return null ;
1061 const target = canonicalizeRoute (route);
1062 let total = 0 ;
1063 let errors = 0 ;
1064 for ( const row of rows) {
1065 const rowRoute = row?.route ?? row?.path;
1066 if ( ! rowRoute || canonicalizeRoute (rowRoute) !== target) continue ;
1067 const value = numberValue (row?.value);
1068 if (value == null ) continue ;
1069 total += value;
1070 if ( / ^ 5/ . test ( String (row?.http_status ?? '' ))) errors += value;
1071 }
1072 return total > 0 ? { total, errors } : null ;
1073 }
1074
1075 function numberValue ( value ) {
1076 if ( typeof value === 'number' && Number. isFinite (value)) return value;
1077 if ( typeof value === 'string' && value. trim () !== '' ) {
1078 const n = Number (value. replace ( /,/ g , '' ));
1079 return Number. isFinite (n) ? n : null ;
1080 }
1081 return null ;
1082 }
1083
1084 function asArray ( v ) {
1085 return Array. isArray (v) ? v : [];
1086 }
1087
1088 function formatPct ( n ) {
1089 return `${ ( n * 100 ). toFixed ( 1 ) }%` ;
1090 }
1091
1092 function cacheLifeNeedsContentFreshnessProof ( text ) {
1093 return / \b cacheLife \s * \( \s * ['"`] (?:hours | days | weeks | max) ['"`]\s * \) / i . test (text) &&
1094 / \b (?:CMS | Contentful | Payload | Sanity | WordPress | docs ?| guides ?| navigation | nav | content | article | blog | OpenAPI | metadata | get [A-Z][\w] * (?:By | For | From) ? \w * ) \b / . test (text);
1095 }
1096
1097 function recommendationFilesFromRec ( rec ) {
1098 return Array. from ( new Set ([
1099 ... asArray (rec?.affectedFiles),
1100 ... asArray (rec?.findingRefs). map (( ref ) => String (ref). match ( / ^ ( . +? ): \d +$ / )?.[ 1 ]). filter (Boolean),
1101 ]));
1102 }
1103
1104 async function readNextRouteChainFiles ( file , repoRoot , projectRootDirectory ) {
1105 const normalized = normalizeProjectRootDirectory (file);
1106 if ( ! normalized) return [];
1107 const appIdx = normalized. split ( '/' ). lastIndexOf ( 'app' );
1108 if (appIdx === - 1 ) {
1109 try {
1110 const { path , content } = await readClaimFile ({ file, repoRoot, projectRootDirectory });
1111 return [{ path, relative: normalized, content }];
1112 } catch {
1113 return [];
1114 }
1115 }
1116
1117 const parts = normalized. split ( '/' );
1118 const appParts = parts. slice ( 0 , appIdx + 1 );
1119 const routeDirs = parts. slice (appIdx + 1 , - 1 );
1120 const candidates = new Set ([normalized]);
1121 for ( let depth = 0 ; depth <= routeDirs. length ; depth ++ ) {
1122 const dir = [ ... appParts, ... routeDirs. slice ( 0 , depth)]. join ( '/' );
1123 for ( const base of [ 'layout' , 'template' ]) {
1124 for ( const ext of [ 'tsx' , 'ts' , 'jsx' , 'js' ]) candidates. add ( `${ dir }/${ base }.${ ext }` );
1125 }
1126 }
1127
1128 const out = [];
1129 for ( const candidate of candidates) {
1130 try {
1131 const { path , content } = await readClaimFile ({ file: candidate, repoRoot, projectRootDirectory });
1132 out. push ({ path, relative: candidate, content });
1133 } catch {}
1134 }
1135 return out;
1136 }
1137
1138 function firstDynamicRouteChainReason ( content ) {
1139 const text = String (content ?? '' );
1140 const direct = text. match ( / \b (cookies | headers | draftMode | connection) \s * \( / );
1141 if (direct) return `${ direct [ 1 ] }()` ;
1142 const helper = text. match ( / \b (withAuth | getServerSession | auth | currentUser) \s * \( / );
1143 if (helper) return `${ helper [ 1 ] }()` ;
1144 if ( /from \s + ['"] next \/ headers ['"] / . test (text)) return 'next/headers import' ;
1145 return null ;
1146 }
1147
1148 function pathSuffixMatches ( candidateFile , routeFile ) {
1149 const candidate = normalizeProjectRootDirectory (candidateFile);
1150 const route = normalizeProjectRootDirectory (routeFile);
1151 if ( ! candidate || ! route) return false ;
1152 return candidate === route || candidate. endsWith ( `/${ route }` ) || route. endsWith ( `/${ candidate }` );
1153 }
1154
1155 function normalizeRouteForLayoutMatch ( route ) {
1156 const normalized = canonicalizeRoute ( String (route ?? '' ));
1157 return normalized. startsWith ( '/' ) ? normalized : `/${ normalized }` ;
1158 }
1159
1160 function layoutAppliesToCandidateRoute ( layoutPath , targetRoute ) {
1161 if ( typeof layoutPath !== 'string' || typeof targetRoute !== 'string' ) return false ;
1162 const layout = normalizeRouteForLayoutMatch (layoutPath);
1163 const target = normalizeRouteForLayoutMatch (targetRoute);
1164 if (layout === '/' ) return true ;
1165
1166 let layoutTokens = layout. split ( '/' ). filter (Boolean);
1167 const targetTokens = target. split ( '/' ). filter (Boolean);
1168 if (layoutTokens. length > targetTokens. length && isDynamicPlaceholder (layoutTokens[ 0 ])) {
1169 layoutTokens = layoutTokens. slice ( 1 );
1170 } else if (layoutTokens. length > 0 &&
1171 targetTokens. length > 0 &&
1172 isDynamicPlaceholder (layoutTokens[ 0 ]) &&
1173 layoutTokens[ 1 ] === targetTokens[ 0 ]) {
1174 layoutTokens = layoutTokens. slice ( 1 );
1175 }
1176 if (layoutTokens. length === 0 ) return true ;
1177 if (layoutTokens. length > targetTokens. length ) return false ;
1178
1179 let literalMatches = 0 ;
1180 for ( let i = 0 ; i < layoutTokens. length ; i ++ ) {
1181 const layoutToken = layoutTokens[i];
1182 const targetToken = targetTokens[i];
1183 if ( isCatchAllPlaceholder (layoutToken)) return literalMatches > 0 ;
1184 if (layoutToken === targetToken) {
1185 literalMatches += 1 ;
1186 continue ;
1187 }
1188 if ( isDynamicPlaceholder (layoutToken)) continue ;
1189 return false ;
1190 }
1191 return literalMatches > 0 ;
1192 }
1193
1194 function isDynamicPlaceholder ( segment ) {
1195 return / ^ \[ (?: \. {3} ) ? . + \] $ / . test ( String (segment ?? '' ));
1196 }
1197
1198 function isCatchAllPlaceholder ( segment ) {
1199 return / ^ \[\[ ? \. {3} . + \] ? \] $ / . test ( String (segment ?? '' ));
1200 }
1201
1202 function escapeRegExp ( s ) {
1203 return String (s). replace ( / [.*+?^${}()|[ \]\\ ] / g , ' \\ $&' );
1204 }
1205
1206 // Supports `/pattern/flags` literal-regex form OR plain escaped string. Caller flags merge with embedded flags via Set dedup.
1207 function compilePattern ( pattern , flags ) {
1208 const m = pattern. match ( / ^ \/ ( . + ) \/ ( [gimsu] * ) $ / );
1209 if (m) {
1210 const mergedFlags = [ ...new Set (((m[ 2 ] || '' ) + (flags || '' )). split ( '' ))]. join ( '' );
1211 return new RegExp (m[ 1 ], mergedFlags);
1212 }
1213 return new RegExp (pattern. replace ( / [.+^${}()|[ \]\\ ?*] / g , ' \\ $&' ), flags);
1214 }
1215
1216 async function readClaimFile ( claim ) {
1217 const path = await firstAccessiblePath (claim);
1218 return { path, content: await readFile (path, 'utf-8' ) };
1219 }
1220
1221 async function firstAccessiblePath ({ repoRoot = '.' , file , projectRootDirectory = null }) {
1222 let lastErr;
1223 for ( const p of repoPaths (repoRoot, file, projectRootDirectory)) {
1224 try {
1225 await access (p);
1226 return p;
1227 } catch (err) {
1228 lastErr = err;
1229 }
1230 }
1231 throw lastErr ?? new Error ( `cannot access ${ file }` );
1232 }
1233
1234 function repoPaths ( repoRoot , file , projectRootDirectory = null ) {
1235 if ( ! file) return [];
1236 if ( isAbsolute (file)) return [file];
1237 const out = [ join (repoRoot, file)];
1238 const projectRoot = normalizeProjectRootDirectory (projectRootDirectory);
1239 const normalizedFile = normalizeProjectRootDirectory (file);
1240 if (projectRoot && normalizedFile && ! normalizedFile. startsWith ( `${ projectRoot }/` )) {
1241 out. push ( join (repoRoot, projectRoot, file));
1242 }
1243 return Array. from ( new Set (out. map (( p ) => normalize (p))));
1244 }
1245
1246 function normalizeProjectRootDirectory ( value ) {
1247 if ( typeof value !== 'string' || value. trim () === '' ) return null ;
1248 return value. replace ( / \\ / g , '/' ). replace ( / ^ \.\/ + / , '' ). replace ( / \/ +$ / , '' );
1249 }
1250
1251 async function* walkFiles ( root , skip = new Set ([
1252 'node_modules' ,
1253 '.next' ,
1254 '.vercel' ,
1255 '.turbo' ,
1256 'dist' ,
1257 'build' ,
1258 'coverage' ,
1259 '.git' ,
1260 'content' ,
1261 'fixtures' ,
1262 'migrations' ,
1263 'public' ,
1264 ])) {
1265 let entries;
1266 try {
1267 entries = await readdir (root, { withFileTypes: true });
1268 } catch {
1269 return ;
1270 }
1271 for ( const e of entries) {
1272 const path = join (root, e.name);
1273 if (e. isDirectory ()) {
1274 if (skip. has (e.name)) continue ;
1275 yield* walkFiles (path, skip);
1276 continue ;
1277 }
1278 if ( ! e. isFile ()) continue ;
1279 if ( ! / \. (tsx ?| jsx ?| mjs | cjs) $ / . test (e.name)) continue ;
1280 yield path;
1281 }
1282 }
1283
1284 async function snippetFoundElsewhere ( root , snippet , exceptFile ) {
1285 const norm = ( s ) => s. replace ( / \s + / g , ' ' ). trim ();
1286 const target = norm (snippet);
1287 if (target. length < 20 ) return null ;
1288 for await ( const path of walkFiles (root)) {
1289 if (path. endsWith (exceptFile)) continue ;
1290 try {
1291 const content = await readFile (path, 'utf-8' );
1292 if ( norm (content). includes (target)) return path;
1293 } catch {}
1294 }
1295 return null ;
1296 }