@@ -25,9 +25,13 @@ const { withDocsBasePath } = await import("@/lib/urls");
2525const { agentSkillMarkdown } = await import ( "@/lib/agent-skill" ) ;
2626const { mcpDiscoveryDocument } = await import ( "@/lib/mcp-discovery" ) ;
2727
28- // Measure against the production base URL so byte budgets are stable regardless
29- // of the local NEXT_PUBLIC_PRISMA_URL, and match the numbers reviewers see live.
30- const baseUrl = process . env . NEXT_PUBLIC_PRISMA_URL ?? "https://www.prisma.io" ;
28+ // Hardcode the production base URL. Byte budgets must be stable and match what
29+ // runs in production, so this must NOT read NEXT_PUBLIC_PRISMA_URL — otherwise the
30+ // budgets would vary by environment and the numbers would not match production.
31+ const baseUrl = "https://www.prisma.io" ;
32+
33+ // Directory of this script (apps/docs/scripts); used for source-level guards.
34+ const scriptDir = dirname ( fileURLToPath ( import . meta. url ) ) ;
3135
3236// Budgets. Agents commonly truncate large text feeds at ~100k chars; we hold a
3337// 50k safety budget for any single file, with earlier warnings so a section can
@@ -133,38 +137,93 @@ if (unmatched.length > CATCHALL_WARN) {
133137 pass ( "Catch-all creep" , `${ unmatched . length } unmatched pages` ) ;
134138}
135139
136- // ── Check 5: markdown directive + description on sample pages ─────────── ──────
137- for ( const slug of [ "orm" , "postgres" , "guides" ] ) {
138- const section = llms . llmsSections . find ( ( s ) => s . slug === slug ) ;
139- const samplePage = section ? llms . filterPagesForLLMsSection ( indexPages , section ) [ 0 ] : undefined ;
140- if ( ! samplePage ) {
141- warn ( `Directive ( ${ slug } )` , `no page found for section " ${ slug } " to sample` ) ;
142- continue ;
143- }
144-
140+ // ── Check 5: markdown directive placement + description across ALL pages ──────
141+ // The per-page markdown must carry the llms.txt directive as a blockquote
142+ // IMMEDIATELY after the H1, so agents fetching any `.md` page get pointed at the
143+ // index. Assert positionally (H1 line, blank line, directive line) across every
144+ // index page — not a sample. getLLMText runs on preprocessed content, so this is
145+ // fast enough to cover the full set.
146+ const directiveFailures : string [ ] = [ ] ;
147+ const missingDescription : string [ ] = [ ] ;
148+ for ( const page of indexPages ) {
145149 let text : string ;
146150 try {
147151 // getLLMText calls page.data.getText("processed"); verified to work under the
148152 // fumadocs loader used by this script (same loader as lint-links.ts).
149- text = await getLLMText ( samplePage ) ;
153+ text = await getLLMText ( page ) ;
150154 } catch ( error ) {
151- fail ( `Directive ( ${ slug } )` , ` getLLMText threw for ${ samplePage . url } : ${ String ( error ) } `) ;
155+ directiveFailures . push ( ` ${ page . url } ( getLLMText threw: ${ String ( error ) } ) `) ;
152156 continue ;
153157 }
154158
155- if ( ! text . includes ( DIRECTIVE_MARKER ) ) {
156- fail ( `Directive (${ slug } )` , `${ samplePage . url } markdown is missing the llms.txt directive` ) ;
157- } else {
158- pass ( `Directive (${ slug } )` , samplePage . url ) ;
159+ const lines = text . split ( "\n" ) ;
160+ const h1Index = lines . findIndex ( ( line ) => line . startsWith ( "# " ) ) ;
161+ // Format is `# Title\n\n> directive…`, so the directive sits two lines below H1.
162+ if ( h1Index === - 1 || ! ( lines [ h1Index + 2 ] ?? "" ) . startsWith ( DIRECTIVE_MARKER ) ) {
163+ directiveFailures . push ( page . url ) ;
159164 }
160165
161- const description = samplePage . data . description ?. trim ( ) ;
166+ const description = page . data . description ?. trim ( ) ;
162167 if ( description && ! text . includes ( description ) ) {
163- warn (
164- `Description (${ slug } )` ,
165- `${ samplePage . url } has a frontmatter description not in its markdown` ,
168+ missingDescription . push ( page . url ) ;
169+ }
170+ }
171+
172+ if ( directiveFailures . length > 0 ) {
173+ fail (
174+ "Directive placement" ,
175+ `${ directiveFailures . length } of ${ indexPages . length } page(s) missing the llms.txt directive immediately after the H1:\n ${ directiveFailures
176+ . slice ( 0 , 10 )
177+ . join ( "\n " ) } `,
178+ ) ;
179+ } else {
180+ pass ( "Directive placement" , `all ${ indexPages . length } pages carry the directive after the H1` ) ;
181+ }
182+
183+ if ( missingDescription . length > 0 ) {
184+ warn (
185+ "Description in markdown" ,
186+ `${ missingDescription . length } page(s) have a frontmatter description not present in their markdown:\n ${ missingDescription
187+ . slice ( 0 , 10 )
188+ . join ( "\n " ) } `,
189+ ) ;
190+ } else {
191+ pass ( "Description in markdown" , "all frontmatter descriptions present in markdown" ) ;
192+ }
193+
194+ // ── Check 5b: HTML surface source guard ──────────────────────────────────────
195+ // The rendered HTML page carries the same directive via a hidden element.
196+ // Rendering React in this script is not worth it; instead guard at the source
197+ // level that the docs page component emits a hidden element referencing llms.txt
198+ // BEFORE it renders <DocsPage. This is a source-level guard, not a render test.
199+ const docsPagePath = join (
200+ scriptDir ,
201+ ".." ,
202+ "src" ,
203+ "app" ,
204+ "(docs)" ,
205+ "(default)" ,
206+ "[[...slug]]" ,
207+ "page.tsx" ,
208+ ) ;
209+ try {
210+ const docsPageSource = readFileSync ( docsPagePath , "utf8" ) ;
211+ const docsPageRenderIndex = docsPageSource . indexOf ( "<DocsPage" ) ;
212+ const llmsRefIndex = docsPageSource . indexOf ( "llms.txt" ) ;
213+ if ( docsPageRenderIndex === - 1 ) {
214+ fail ( "HTML directive source guard" , `<DocsPage not found in ${ docsPagePath } ` ) ;
215+ } else if ( llmsRefIndex === - 1 ) {
216+ fail ( "HTML directive source guard" , `page.tsx does not reference llms.txt` ) ;
217+ } else if ( llmsRefIndex >= docsPageRenderIndex ) {
218+ fail (
219+ "HTML directive source guard" ,
220+ "page.tsx references llms.txt only after <DocsPage; the hidden directive must precede it" ,
166221 ) ;
222+ } else {
223+ pass ( "HTML directive source guard" , "hidden llms.txt directive precedes <DocsPage" ) ;
167224 }
225+ } catch ( error ) {
226+ fail ( "HTML directive source guard" , `could not read ${ docsPagePath } : ${ String ( error ) } ` ) ;
168227}
169228
170229// ── Check 6: common queries resolve to existing pages ────────────────────────
@@ -215,7 +274,6 @@ if (docsSkillMissing.length > 0) {
215274// whose `@/lib/url` import cannot resolve under the docs tsconfig. Read the raw
216275// source and check the frontmatter keys directly (they sit at column 0 in the
217276// template literal body).
218- const scriptDir = dirname ( fileURLToPath ( import . meta. url ) ) ;
219277const siteSkillPath = join ( scriptDir , ".." , ".." , "site" , "src" , "lib" , "agent-skills.ts" ) ;
220278try {
221279 const siteSkillSource = readFileSync ( siteSkillPath , "utf8" ) ;
@@ -229,14 +287,85 @@ try {
229287 fail ( "Site skill frontmatter" , `could not read ${ siteSkillPath } : ${ String ( error ) } ` ) ;
230288}
231289
232- // ── Check 9: MCP discovery document ──────────────────────────────────────────
233- const mcpUrlOk = mcpDiscoveryDocument . url === MCP_URL ;
234- const mcpServersOk =
235- Array . isArray ( mcpDiscoveryDocument . servers ) && mcpDiscoveryDocument . servers . length > 0 ;
236- if ( ! mcpUrlOk || ! mcpServersOk ) {
237- fail ( "MCP discovery" , ! mcpUrlOk ? `url is not ${ MCP_URL } ` : "servers array is missing or empty" ) ;
290+ // ── Check 9: MCP discovery documents (docs + site) ───────────────────────────
291+ // Field-level validation of both discovery payloads so a wrong URL, transport,
292+ // missing version, or empty authentication is caught — not just "an object
293+ // exists". The site payload is built from a template in agent-skills.ts whose
294+ // `@/lib/url` import cannot resolve under the docs tsconfig, so its static fields
295+ // are validated from raw source (same approach as check 8).
296+ const mcpErrors : string [ ] = [ ] ;
297+
298+ // Widen the `as const` literal types to plain strings so the runtime comparisons
299+ // below are meaningful checks (not always-false literal-vs-literal comparisons).
300+ const docsMcp = mcpDiscoveryDocument as {
301+ url : string ;
302+ transport : string ;
303+ version : string ;
304+ servers : readonly { name : string ; url : string ; transport : string ; authentication : string } [ ] ;
305+ } ;
306+ if ( docsMcp . url !== MCP_URL ) {
307+ mcpErrors . push ( `docs: top-level url is "${ docsMcp . url } ", expected "${ MCP_URL } "` ) ;
308+ }
309+ if ( docsMcp . transport !== "http" ) {
310+ mcpErrors . push ( `docs: transport is "${ docsMcp . transport } ", expected "http"` ) ;
311+ }
312+ if ( typeof docsMcp . version !== "string" || docsMcp . version . trim ( ) === "" ) {
313+ mcpErrors . push ( "docs: version is missing or empty" ) ;
314+ }
315+ if ( ! Array . isArray ( docsMcp . servers ) || docsMcp . servers . length === 0 ) {
316+ mcpErrors . push ( "docs: servers array is missing or empty" ) ;
238317} else {
239- pass ( "MCP discovery" , `url ${ MCP_URL } , ${ mcpDiscoveryDocument . servers . length } server(s)` ) ;
318+ docsMcp . servers . forEach ( ( server , i ) => {
319+ if ( server . url !== MCP_URL ) {
320+ mcpErrors . push ( `docs: servers[${ i } ].url is "${ server . url } ", expected "${ MCP_URL } "` ) ;
321+ }
322+ if ( server . transport !== "http" ) {
323+ mcpErrors . push ( `docs: servers[${ i } ].transport is "${ server . transport } ", expected "http"` ) ;
324+ }
325+ if ( typeof server . authentication !== "string" || server . authentication . trim ( ) === "" ) {
326+ mcpErrors . push ( `docs: servers[${ i } ].authentication is missing or empty` ) ;
327+ }
328+ } ) ;
329+ }
330+
331+ try {
332+ const siteSource = readFileSync ( siteSkillPath , "utf8" ) ;
333+ const urlMatch = siteSource . match ( / c o n s t M C P _ S E R V E R _ U R L \s * = \s * " ( [ ^ " ] + ) " / ) ;
334+ if ( ! urlMatch ) {
335+ mcpErrors . push ( "site: could not find the MCP_SERVER_URL constant" ) ;
336+ } else if ( urlMatch [ 1 ] !== MCP_URL ) {
337+ mcpErrors . push ( `site: MCP_SERVER_URL is "${ urlMatch [ 1 ] } ", expected "${ MCP_URL } "` ) ;
338+ }
339+
340+ // Validate the static fields of the buildMcpDiscovery() payload from source.
341+ const discoveryMatch = siteSource . match (
342+ / e x p o r t f u n c t i o n b u i l d M c p D i s c o v e r y \( [ ^ ) ] * \) \s * \{ ( [ \s \S ] * ?) \n \} / ,
343+ ) ;
344+ const discoveryBody = discoveryMatch ?. [ 1 ] ;
345+ if ( ! discoveryBody ) {
346+ mcpErrors . push ( "site: could not find the buildMcpDiscovery() function" ) ;
347+ } else {
348+ if ( ! / \b t r a n s p o r t : \s * " h t t p " / . test ( discoveryBody ) ) {
349+ mcpErrors . push ( 'site: buildMcpDiscovery transport is not "http"' ) ;
350+ }
351+ if ( ! / \b u r l : \s * M C P _ S E R V E R _ U R L \b / . test ( discoveryBody ) ) {
352+ mcpErrors . push ( "site: buildMcpDiscovery url is not MCP_SERVER_URL" ) ;
353+ }
354+ if ( ! / \b a u t h e n t i c a t i o n : \s * " [ ^ " ] + " / . test ( discoveryBody ) ) {
355+ mcpErrors . push ( "site: buildMcpDiscovery server authentication is missing or empty" ) ;
356+ }
357+ }
358+ } catch ( error ) {
359+ mcpErrors . push ( `site: could not read ${ siteSkillPath } : ${ String ( error ) } ` ) ;
360+ }
361+
362+ if ( mcpErrors . length > 0 ) {
363+ fail ( "MCP discovery" , `field validation failed:\n ${ mcpErrors . join ( "\n " ) } ` ) ;
364+ } else {
365+ pass (
366+ "MCP discovery" ,
367+ `docs + site valid: url "${ MCP_URL } ", transport http, ${ docsMcp . servers . length } server(s)` ,
368+ ) ;
240369}
241370
242371// ── Report ───────────────────────────────────────────────────────────────────
0 commit comments