Setting the file. One moment.
Support Topics · Vercel Optimize · vercel-labs/agent-skills · Skills Docs
ContentsBack to the top of the page — line 264
This file
Number 7.148
Position 148 of 155
Type JavaScript
Size 13 KB
Lines 355 lib/ support-topics.mjs
JavaScript · 355 lines · 13 KB
9
lookupUrl,
10 matchesFrameworkVersion,
11 } from './citations.mjs' ;
12
13 const HERE = dirname ( fileURLToPath ( import . meta .url));
14 const TOPICS_DIR = join ( HERE , '..' , 'references' , 'support-topics' );
15
16 export const SUPPORT_TOPIC_LIMIT = 3 ;
17 export const SUPPORT_TOPIC_TOTAL_CHAR_LIMIT = 2400 ;
18 const DEFAULT_MAX_BRIEF_CHARS = 900 ;
19
20 export const KNOWN_CANDIDATE_KINDS = new Set ([
21 ... gates
22 . map (( g ) => g.metadata?.id)
23 . filter (( id ) => id && id !== 'scanner-driven' ),
24 ... SCANNER_GATES . map (( g ) => g.id),
25 ]);
26
27 export async function supportTopicSubset ({
28 candidate ,
29 signals = {},
30 framework ,
31 version ,
32 profile ,
33 frameworkPlaybookId ,
34 maxTopics = SUPPORT_TOPIC_LIMIT ,
35 maxChars = SUPPORT_TOPIC_TOTAL_CHAR_LIMIT ,
36 } = {}) {
37 const stack = signals?.stack ?? signals?.codebase?.stack ?? {};
38 const fw = framework ?? stack.framework ?? 'unknown' ;
39 const fwVersion = version ?? stack.frameworkVersion ?? 'unknown' ;
40 const candidates = await loadSupportTopics ();
41 const selected = [];
42 let usedChars = 0 ;
43
44 const sorted = candidates
45 . filter (( t ) => t.status === 'active' )
46 . filter (( t ) => matchesCandidateKind (t, candidate?.kind))
47 . filter (( t ) => matchesFrameworks (t.frameworks, fw, fwVersion))
48 . filter (( t ) => matchesOptionalList (t.profiles, profile))
49 . filter (( t ) => matchesOptionalList (t.frameworkPlaybooks, frameworkPlaybookId))
50 . filter (( t ) => matchesRouter (t.routers, stack))
51 . filter (( t ) => matchesCandidateMetrics (t.metrics, candidate))
52 . filter (( t ) => matchesCandidateRoutePatterns (t.routePatterns, candidate))
53 . filter (( t ) => matchesScannerPatterns (t.scannerPatterns, candidate))
54 . sort (( a , b ) => b.priority - a.priority || a.id. localeCompare (b.id));
55
56 for ( const topic of sorted) {
57 if ( !await topicCitationsApply (topic, candidate?.kind, fw, fwVersion)) continue ;
58 if (selected. length >= maxTopics) break ;
59 const renderedChars = topic.title. length + topic.body. length + topic.id. length + 20 ;
60 if (selected. length > 0 && usedChars + renderedChars > maxChars) continue ;
61 selected. push (topic);
62 usedChars += renderedChars;
63 }
64
65 return selected;
66 }
67
68 export async function loadSupportTopics ({ includeDraft = false } = {}) {
69 let names = [];
70 try {
71 names = await readdir ( TOPICS_DIR );
72 } catch (err) {
73 if (err?.code === 'ENOENT' ) return [];
74 throw err;
75 }
76
77 const topics = [];
78 for ( const name of names. sort ()) {
79 if ( ! name. endsWith ( '.md' ) || name === 'README.md' ) continue ;
80 const path = join ( TOPICS_DIR , name);
81 const raw = await readFile (path, 'utf-8' );
82 const topic = parseSupportTopic (raw, path);
83 if (includeDraft || topic.status === 'active' ) topics. push (topic);
84 }
85 return topics. sort (( a , b ) => a.id. localeCompare (b.id));
86 }
87
88 export async function validateSupportTopics () {
89 const topics = await loadSupportTopics ({ includeDraft: true });
90 const errors = [];
91 const seen = new Set ();
92 for ( const topic of topics) {
93 errors. push ( ...await validateSupportTopic (topic));
94 if (seen. has (topic.id)) errors. push ( `${ topic . path }: duplicate topic id "${ topic . id }"` );
95 seen. add (topic.id);
96 }
97 return { ok: errors. length === 0 , errors, topics };
98 }
99
100 export function renderSupportTopics ( topics = []) {
101 if ( ! Array. isArray (topics) || topics. length === 0 ) return [];
102 const lines = [];
103 lines. push ( '## Support topics (investigation guardrails)' );
104 lines. push ( '' );
105 lines. push ( 'These are deterministic, candidate-scoped hints selected from `references/support-topics/`. They do not create recommendations. Use them only to decide what evidence to check, what to rule out, and when to abstain.' );
106 lines. push ( '' );
107 for ( const topic of topics) {
108 lines. push ( `### ${ topic . title } ( \` ${ topic . id } \` )` );
109 lines. push ( '' );
110 lines. push (topic.body. trim ());
111 lines. push ( '' );
112 }
113 return lines;
114 }
115
116 export function parseSupportTopic ( raw , path = '<memory>' ) {
117 const { frontmatter , body } = splitFrontmatter (raw, path);
118 const metadata = parseFrontmatter (frontmatter, path);
119 return normalizeTopic ({ ... metadata, body: body. trim (), path });
120 }
121
122 function splitFrontmatter ( raw , path ) {
123 const text = String (raw ?? '' );
124 if ( ! text. startsWith ( '--- \n ' )) {
125 throw new Error ( `${ path }: support topic must start with --- frontmatter` );
126 }
127 const end = text. indexOf ( ' \n --- \n ' , 4 );
128 if (end === - 1 ) {
129 throw new Error ( `${ path }: support topic frontmatter must end with ---` );
130 }
131 return {
132 frontmatter: text. slice ( 4 , end),
133 body: text. slice (end + ' \n --- \n ' . length ),
134 };
135 }
136
137 function parseFrontmatter ( src , path ) {
138 const out = {};
139 for ( const rawLine of src. split ( ' \n ' )) {
140 const line = rawLine. trim ();
141 if ( ! line || line. startsWith ( '#' )) continue ;
142 const m = line. match ( / ^ ( [A-Za-z][A-Za-z0-9] * ): \s * ( . * ) $ / );
143 if ( ! m) throw new Error ( `${ path }: unsupported frontmatter line "${ rawLine }"` );
144 const [, key , value ] = m;
145 out[key] = parseFrontmatterValue (value, path, key);
146 }
147 return out;
148 }
149
150 function parseFrontmatterValue ( value , path , key ) {
151 if (value. startsWith ( '[' )) {
152 try {
153 const parsed = JSON . parse (value);
154 if ( ! Array. isArray (parsed)) throw new Error ( 'not an array' );
155 return parsed;
156 } catch (err) {
157 throw new Error ( `${ path }: ${ key } must use strict JSON array syntax (${ err . message })` );
158 }
159 }
160 if ( / ^ - ? \d + (?: \. \d + ) ?$ / . test (value)) return Number (value);
161 if (value === 'true' ) return true ;
162 if (value === 'false' ) return false ;
163 if (value === 'null' ) return null ;
164 const quoted = value. match ( / ^ "( . * )" $ / ) ?? value. match ( / ^ '( . * )' $ / );
165 return quoted ? quoted[ 1 ] : value;
166 }
167
168 function normalizeTopic ( topic ) {
169 const maxBriefChars = Number. isFinite (topic.maxBriefChars)
170 ? topic.maxBriefChars
171 : DEFAULT_MAX_BRIEF_CHARS ;
172 return {
173 id: topic.id,
174 title: topic.title,
175 status: topic.status,
176 candidateKinds: toStringArray (topic.candidateKinds),
177 frameworks: toStringArray (topic.frameworks),
178 profiles: toStringArray (topic.profiles),
179 frameworkPlaybooks: toStringArray (topic.frameworkPlaybooks),
180 routers: toStringArray (topic.routers),
181 metrics: toStringArray (topic.metrics),
182 routePatterns: toStringArray (topic.routePatterns),
183 scannerPatterns: toStringArray (topic.scannerPatterns),
184 billingDimensions: toStringArray (topic.billingDimensions),
185 citations: toStringArray (topic.citations),
186 priority: Number (topic.priority),
187 maxBriefChars,
188 body: topic.body,
189 path: topic.path,
190 };
191 }
192
193 async function validateSupportTopic ( topic ) {
194 const errors = [];
195 const label = topic.path ?? topic.id ?? '<topic>' ;
196 const fileId = basename (label). replace ( / \. md $ / , '' );
197
198 if ( ! / ^ [a-z0-9] + (?:- [a-z0-9] + ) *$ / . test (topic.id ?? '' )) {
199 errors. push ( `${ label }: id must be kebab-case` );
200 }
201 if (fileId !== topic.id) errors. push ( `${ label }: filename must match id` );
202 if ( ! nonEmptyString (topic.title)) errors. push ( `${ label }: title is required` );
203 if ( ! [ 'active' , 'draft' , 'deprecated' ]. includes (topic.status)) {
204 errors. push ( `${ label }: status must be active, draft, or deprecated` );
205 }
206 if ( ! Number. isFinite (topic.priority)) errors. push ( `${ label }: priority must be a number` );
207 if ( ! Number. isFinite (topic.maxBriefChars) || topic.maxBriefChars < 200 || topic.maxBriefChars > 1400 ) {
208 errors. push ( `${ label }: maxBriefChars must be between 200 and 1400` );
209 }
210 if ( ! nonEmptyArray (topic.candidateKinds)) {
211 errors. push ( `${ label }: candidateKinds must be a non-empty array` );
212 } else {
213 for ( const kind of topic.candidateKinds) {
214 if (kind !== '*' && ! KNOWN_CANDIDATE_KINDS . has (kind)) {
215 errors. push ( `${ label }: unknown candidate kind "${ kind }"` );
216 }
217 }
218 }
219 if ( ! nonEmptyArray (topic.frameworks)) {
220 errors. push ( `${ label }: frameworks must be a non-empty array` );
221 } else {
222 for ( const fw of topic.frameworks) {
223 if (fw !== '*' && ! / ^ [\w-] + @/ . test (fw)) {
224 errors. push ( `${ label }: framework "${ fw }" must be "*" or "framework@range"` );
225 }
226 }
227 }
228 if ( ! nonEmptyArray (topic.citations)) {
229 errors. push ( `${ label }: citations must be a non-empty array` );
230 } else {
231 for ( const citation of topic.citations) {
232 if ( !await knownCitation (citation)) {
233 errors. push ( `${ label }: unknown citation "${ citation }"` );
234 }
235 }
236 }
237 for ( const pattern of topic.routePatterns) {
238 try {
239 new RegExp (pattern);
240 } catch (err) {
241 errors. push ( `${ label }: invalid routePatterns regex "${ pattern }" (${ err . message })` );
242 }
243 }
244 for ( const heading of [
245 '## Investigation Brief' ,
246 '## Evidence To Check' ,
247 '## Do Not Recommend When' ,
248 '## Verification' ,
249 ]) {
250 if ( ! topic.body. includes (heading)) errors. push ( `${ label }: missing heading "${ heading }"` );
251 }
252 if (topic.body. length > topic.maxBriefChars) {
253 errors. push ( `${ label }: body length ${ topic . body . length } exceeds maxBriefChars ${ topic . maxBriefChars }` );
254 }
255 if ( /https ? : \/\/ / . test (topic.body)) {
256 errors. push ( `${ label }: put URLs in frontmatter citations, not body text` );
257 }
258 if ( / \/ Users \/ | (?: ^| [\s`"'] )apps \/ [ ^ /\s`"'] + \/ | [A-Za-z0-9_-] + \. ts: \d + / . test (topic.body)) {
259 errors. push ( `${ label }: body leaks internal implementation details` );
260 }
261 return errors;
262 }
263
264 function matchesCandidateKind ( topic , candidateKind ) {
265 if ( ! candidateKind) return false ;
266 return topic.candidateKinds. includes ( '*' ) || topic.candidateKinds. includes (candidateKind);
267 }
268
269 function matchesFrameworks ( frameworks , framework , version ) {
270 return frameworks. some (( pattern ) =>
271 pattern === '*' || matchesFrameworkVersion (pattern, framework, version)
272 );
273 }
274
275 function matchesOptionalList ( values , actual ) {
276 if ( ! Array. isArray (values) || values. length === 0 ) return true ;
277 return values. includes ( '*' ) || (actual != null && values. includes (actual));
278 }
279
280 function matchesRouter ( routers , stack ) {
281 if ( ! Array. isArray (routers) || routers. length === 0 ) return true ;
282 if (routers. includes ( '*' )) return true ;
283 return (routers. includes ( 'app' ) && stack?.hasAppRouter)
284 || (routers. includes ( 'pages' ) && stack?.hasPagesRouter);
285 }
286
287 function matchesCandidateMetrics ( metrics , candidate ) {
288 if ( ! Array. isArray (metrics) || metrics. length === 0 ) return true ;
289 if (metrics. includes ( '*' )) return true ;
290 const observed = new Set ([
291 candidate?.evidence?.metric,
292 ... (candidate?.evidence?.issues ?? []). map (( i ) => i?.metric),
293 ]. filter (Boolean). map (( m ) => String (m). toUpperCase ()));
294 return metrics. some (( m ) => observed. has ( String (m). toUpperCase ()));
295 }
296
297 function matchesCandidateRoutePatterns ( patterns , candidate ) {
298 if ( ! Array. isArray (patterns) || patterns. length === 0 ) return true ;
299 if (patterns. includes ( '*' )) return true ;
300 const route = candidate?.route ?? candidate?.path;
301 if ( typeof route !== 'string' || route. length === 0 ) return false ;
302 return patterns. some (( p ) => new RegExp (p). test (route));
303 }
304
305 function matchesScannerPatterns ( patterns , candidate ) {
306 if ( ! Array. isArray (patterns) || patterns. length === 0 ) return true ;
307 const observed = new Set ([
308 ... (candidate?.evidence?.patterns ?? []),
309 ... (candidate?.evidence?.deepDive?.patterns ?? []),
310 ]. filter (Boolean));
311 if (observed.size === 0 ) return false ;
312 return patterns. some (( p ) => observed. has (p));
313 }
314
315 function topicCitationsApply ( topic , candidateKind , framework , version ) {
316 if ( ! candidateKind) return false ;
317 return topic.citations. every (( citation ) =>
318 citationApplies (citation, candidateKind, framework, version)
319 );
320 }
321
322 async function citationApplies ( citation , candidateKind , framework , version ) {
323 const lib = await loadLibrary ();
324 const rule = lib.ruleSkillRefs. find (( r ) => `${ r . skill }:${ r . rule }` === citation);
325 if (rule) {
326 return rule.applicableFrameworks. includes ( '*' )
327 || rule.applicableFrameworks. some (( p ) => matchesFrameworkVersion (p, framework, version));
328 }
329
330 const url = lib.urls. find (( u ) => u.url === citation);
331 if ( ! url) return false ;
332 const kindOk = ! Array. isArray (url.appliesTo)
333 || url.appliesTo. length === 0
334 || url.appliesTo. includes (candidateKind);
335 const versionOk = url.applicableFrameworks. includes ( '*' )
336 || url.applicableFrameworks. some (( p ) => matchesFrameworkVersion (p, framework, version));
337 return kindOk && versionOk;
338 }
339
340 async function knownCitation ( citation ) {
341 return Boolean ( await lookupUrl (citation) || await lookupSkillRule (citation));
342 }
343
344 function toStringArray ( value ) {
345 if ( ! Array. isArray (value)) return [];
346 return value. filter (( v ) => typeof v === 'string' && v. length > 0 );
347 }
348
349 function nonEmptyArray ( value ) {
350 return Array. isArray (value) && value. length > 0 ;
351 }
352
353 function nonEmptyString ( value ) {
354 return typeof value === 'string' && value. trim (). length > 0 ;
355 }