@eightmedia/flightcheck 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +691 -0
  3. package/README.md +152 -2
  4. package/dist/analyze/fixManifest.d.ts +65 -0
  5. package/dist/analyze/fixManifest.d.ts.map +1 -0
  6. package/dist/analyze/fixManifest.js +588 -0
  7. package/dist/analyze/report.d.ts +283 -0
  8. package/dist/analyze/report.d.ts.map +1 -0
  9. package/dist/analyze/report.js +726 -0
  10. package/dist/analyze/reportTier1.d.ts +46 -0
  11. package/dist/analyze/reportTier1.d.ts.map +1 -0
  12. package/dist/analyze/reportTier1.js +251 -0
  13. package/dist/analyze/stack.d.ts +33 -0
  14. package/dist/analyze/stack.d.ts.map +1 -0
  15. package/dist/analyze/stack.js +188 -0
  16. package/dist/analyze/structuredReport.d.ts +62 -0
  17. package/dist/analyze/structuredReport.d.ts.map +1 -0
  18. package/dist/analyze/structuredReport.js +524 -0
  19. package/dist/analyze.d.ts +3 -0
  20. package/dist/analyze.d.ts.map +1 -0
  21. package/dist/analyze.js +40 -0
  22. package/dist/checks/agent-ready.d.ts +63 -0
  23. package/dist/checks/agent-ready.d.ts.map +1 -0
  24. package/dist/checks/agent-ready.js +219 -0
  25. package/dist/checks/analyze.d.ts +15 -0
  26. package/dist/checks/analyze.d.ts.map +1 -0
  27. package/dist/checks/analyze.js +63 -0
  28. package/dist/checks/crawl.d.ts +28 -0
  29. package/dist/checks/crawl.d.ts.map +1 -0
  30. package/dist/checks/crawl.js +86 -0
  31. package/dist/checks/dns.d.ts +25 -0
  32. package/dist/checks/dns.d.ts.map +1 -0
  33. package/dist/checks/dns.js +80 -0
  34. package/dist/checks/index.d.ts +24 -0
  35. package/dist/checks/index.d.ts.map +1 -0
  36. package/dist/checks/index.js +12 -0
  37. package/dist/checks/issues.d.ts +8 -0
  38. package/dist/checks/issues.d.ts.map +1 -0
  39. package/dist/checks/issues.js +3 -0
  40. package/dist/checks/redirect.d.ts +38 -0
  41. package/dist/checks/redirect.d.ts.map +1 -0
  42. package/dist/checks/redirect.js +209 -0
  43. package/dist/checks/registry.d.ts +8 -0
  44. package/dist/checks/registry.d.ts.map +1 -0
  45. package/dist/checks/registry.js +275 -0
  46. package/dist/checks/robots-txt.d.ts +16 -0
  47. package/dist/checks/robots-txt.d.ts.map +1 -0
  48. package/dist/checks/robots-txt.js +99 -0
  49. package/dist/checks/runner.d.ts +9 -0
  50. package/dist/checks/runner.d.ts.map +1 -0
  51. package/dist/checks/runner.js +110 -0
  52. package/dist/checks/security-headers.d.ts +12 -0
  53. package/dist/checks/security-headers.d.ts.map +1 -0
  54. package/dist/checks/security-headers.js +53 -0
  55. package/dist/checks/sitemap.d.ts +36 -0
  56. package/dist/checks/sitemap.d.ts.map +1 -0
  57. package/dist/checks/sitemap.js +140 -0
  58. package/dist/checks/types.d.ts +72 -0
  59. package/dist/checks/types.d.ts.map +1 -0
  60. package/dist/checks/types.js +20 -0
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.d.ts.map +1 -0
  63. package/dist/cli.js +125 -0
  64. package/dist/crawler/checkGroups.d.ts +7 -0
  65. package/dist/crawler/checkGroups.d.ts.map +1 -0
  66. package/dist/crawler/checkGroups.js +29 -0
  67. package/dist/crawler/crawlState.d.ts +7 -0
  68. package/dist/crawler/crawlState.d.ts.map +1 -0
  69. package/dist/crawler/crawlState.js +6 -0
  70. package/dist/crawler/defaults.d.ts +11 -0
  71. package/dist/crawler/defaults.d.ts.map +1 -0
  72. package/dist/crawler/defaults.js +10 -0
  73. package/dist/crawler/extractors.d.ts +5 -0
  74. package/dist/crawler/extractors.d.ts.map +1 -0
  75. package/dist/crawler/extractors.js +169 -0
  76. package/dist/crawler/jsAssets.d.ts +2 -0
  77. package/dist/crawler/jsAssets.d.ts.map +1 -0
  78. package/dist/crawler/jsAssets.js +22 -0
  79. package/dist/crawler/outputPaths.d.ts +14 -0
  80. package/dist/crawler/outputPaths.d.ts.map +1 -0
  81. package/dist/crawler/outputPaths.js +51 -0
  82. package/dist/crawler/reporter.d.ts +3 -0
  83. package/dist/crawler/reporter.d.ts.map +1 -0
  84. package/dist/crawler/reporter.js +12 -0
  85. package/dist/crawler/robots.d.ts +5 -0
  86. package/dist/crawler/robots.d.ts.map +1 -0
  87. package/dist/crawler/robots.js +29 -0
  88. package/dist/crawler/runCrawler.d.ts +22 -0
  89. package/dist/crawler/runCrawler.d.ts.map +1 -0
  90. package/dist/crawler/runCrawler.js +172 -0
  91. package/dist/crawler/schema.d.ts +87 -0
  92. package/dist/crawler/schema.d.ts.map +1 -0
  93. package/dist/crawler/schema.js +44 -0
  94. package/dist/crawler/secrets.d.ts +4 -0
  95. package/dist/crawler/secrets.d.ts.map +1 -0
  96. package/dist/crawler/secrets.js +70 -0
  97. package/dist/crawler/sitemap.d.ts +8 -0
  98. package/dist/crawler/sitemap.d.ts.map +1 -0
  99. package/dist/crawler/sitemap.js +105 -0
  100. package/dist/crawler/stack.d.ts +3 -0
  101. package/dist/crawler/stack.d.ts.map +1 -0
  102. package/dist/crawler/stack.js +1 -0
  103. package/dist/crawler/stackDetect.d.ts +24 -0
  104. package/dist/crawler/stackDetect.d.ts.map +1 -0
  105. package/dist/crawler/stackDetect.js +604 -0
  106. package/dist/crawler/tier1Extractors.d.ts +5 -0
  107. package/dist/crawler/tier1Extractors.d.ts.map +1 -0
  108. package/dist/crawler/tier1Extractors.js +255 -0
  109. package/dist/crawler/tracking.d.ts +8 -0
  110. package/dist/crawler/tracking.d.ts.map +1 -0
  111. package/dist/crawler/tracking.js +50 -0
  112. package/dist/crawler/urlUtils.d.ts +12 -0
  113. package/dist/crawler/urlUtils.d.ts.map +1 -0
  114. package/dist/crawler/urlUtils.js +53 -0
  115. package/dist/index.d.ts +14 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +7 -0
  118. package/docs/agent-fixes.md +17 -0
  119. package/docs/agent-ready.md +44 -0
  120. package/docs/analyze.md +103 -0
  121. package/docs/check-api.md +142 -0
  122. package/docs/checks.md +116 -0
  123. package/docs/crawler.md +158 -0
  124. package/docs/index.md +24 -0
  125. package/docs/qc-checks-plan.md +796 -0
  126. package/docs/stack.md +99 -0
  127. package/package.json +86 -4
@@ -0,0 +1,588 @@
1
+ import { buildReportAnalysis } from "./report.js";
2
+ import { defaultManifestPathForInput, defaultReportPathForInput } from "../crawler/outputPaths.js";
3
+ export const FIX_MANIFEST_VERSION = 1;
4
+ const A11Y_TIER1_IDS = new Set([
5
+ "missing-alt",
6
+ "empty-alt",
7
+ "generic-alt",
8
+ "heading-skip",
9
+ "multiple-h1",
10
+ "unlabeled-input",
11
+ "missing-lang",
12
+ "positive-tabindex",
13
+ ]);
14
+ const BEST_PRACTICE_WARNING_KEYS = new Set([
15
+ "noindex",
16
+ "nofollow",
17
+ "missing-template",
18
+ "main-missing",
19
+ "main-multiple",
20
+ ]);
21
+ const STACK_SEARCH_HINTS = {
22
+ "Next.js": [
23
+ "Detect the router: App Router (app/layout.tsx, generateMetadata, metadata export) vs Pages Router (pages/_app.tsx, pages/_document.tsx, next/head <Head>)",
24
+ "App Router: app/layout.tsx, app/**/layout.tsx, generateMetadata, metadata export",
25
+ "Pages Router: pages/_app.tsx, pages/_document.tsx, next/head, shared <Head>/Page wrapper component",
26
+ "lib/seo or central metadata helper",
27
+ ],
28
+ Nuxt: ["app.vue", "nuxt.config", "useHead", "useSeoMeta"],
29
+ Gatsby: ["gatsby-config", "gatsby-ssr", "react-helmet", "Seo component"],
30
+ Astro: ["src/layouts", "src/components/Head", "astro.config"],
31
+ Sanity: ["sanity", "cdn.sanity.io", "@sanity/image-url", "image alt", "schema"],
32
+ Contentful: ["contentful", "ctfassets.net"],
33
+ WordPress: ["wp-content/themes", "functions.php", "header.php"],
34
+ Netlify: ["netlify.toml", "public/_headers", "[[headers]]"],
35
+ Vercel: ["vercel.json", "next.config", "headers() in next.config"],
36
+ };
37
+ const STACK_MCP_SERVERS = {
38
+ Netlify: "netlify",
39
+ Vercel: "vercel",
40
+ Sanity: "sanity",
41
+ Contentful: "contentful",
42
+ };
43
+ const CMS_STACK_NAMES = new Set(["Sanity", "Contentful", "WordPress", "Strapi", "Prismic"]);
44
+ /**
45
+ * SEO fields whose value is unambiguously editorial content (authored per page), not markup.
46
+ * When a CMS is detected these belong in a CMS-content package (authored with approval),
47
+ * not a repo code change. Deliberately conservative: title/metaTitle/og.title and h1 are usually
48
+ * *rendered by code* from a content field, so a missing one is often a template bug — those stay
49
+ * in the repo package where the agent can inspect the template and decide. Markup fields
50
+ * (canonical, og.url, colorScheme, twitter.card) also stay in the repo package.
51
+ */
52
+ const CONTENT_SEO_FIELDS = new Set(["metaDescription", "og.description"]);
53
+ const IMAGE_ALT_IDS = new Set(["missing-alt", "empty-alt", "generic-alt"]);
54
+ function detectedCmsServer(profile) {
55
+ for (const detection of profile.detections) {
56
+ if (detection.confidence === "low")
57
+ continue;
58
+ if (CMS_STACK_NAMES.has(detection.name)) {
59
+ return STACK_MCP_SERVERS[detection.name] ?? detection.name.toLowerCase();
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+ /** Heuristic: a non-200 URL that looks like crawler junk rather than a real page. */
65
+ function reviewReason(url, status) {
66
+ if (status === 200)
67
+ return null;
68
+ const decoded = (() => {
69
+ try {
70
+ return decodeURIComponent(url);
71
+ }
72
+ catch {
73
+ return url;
74
+ }
75
+ })();
76
+ if (/@/.test(decoded))
77
+ return "URL contains an email address — likely a mis-parsed link, not a page";
78
+ if (/\[[^\]]+\]/.test(decoded))
79
+ return "URL contains a dynamic-route placeholder (e.g. [slug]) — not a real page";
80
+ if (/(?:\d[\s-]*){6,}/.test(decoded))
81
+ return "URL looks like a phone number crawled as a path";
82
+ if (status === null)
83
+ return "Page could not be fetched (error)";
84
+ if (status === 404)
85
+ return "Page returned 404 — broken or non-existent route";
86
+ if (status === 401 || status === 403)
87
+ return "Page returned auth error — likely a protected endpoint, not a public page";
88
+ return `Page returned status ${status} — investigate before adding metadata`;
89
+ }
90
+ const REPO_SEARCH_BASE = [
91
+ "Search the codebase for the root layout, shared head, or site-wide metadata configuration",
92
+ "Search for generateMetadata, default metadata exports, shared SEO helpers, or <Head> usage",
93
+ "Match the project's existing metadata pattern — do not assume file paths from stack hints alone",
94
+ ];
95
+ const TEMPLATE_SEARCH_BASE = [
96
+ "Search the codebase for the template route or layout tied to <meta name=\"template\"> value",
97
+ "Look for page components, route segments, or CMS template mappings for this template name",
98
+ ];
99
+ const PAGE_SEARCH_BASE = [
100
+ "Search for the route file or page component matching each URL path",
101
+ "Prefer the project's existing page-level metadata and content patterns",
102
+ ];
103
+ function formatScope(count, total) {
104
+ if (total === 0)
105
+ return "n/a";
106
+ if (count === total)
107
+ return "site-wide";
108
+ return `${count}/${total} page(s)`;
109
+ }
110
+ function stackManifestEntries(profile, totalPages) {
111
+ return profile.detections.map((detection) => ({
112
+ name: detection.name,
113
+ categories: detection.categories,
114
+ confidence: detection.confidence,
115
+ scope: detection.pageCount > 0
116
+ ? detection.pageCount === totalPages
117
+ ? "site-wide"
118
+ : `${detection.pageCount}/${totalPages} page(s)`
119
+ : "origin",
120
+ evidence: detection.evidence,
121
+ }));
122
+ }
123
+ function stackSearchHints(profile, minConfidence = "medium") {
124
+ const hints = new Set();
125
+ const rank = { low: 0, medium: 1, high: 2 };
126
+ for (const detection of profile.detections) {
127
+ if (rank[detection.confidence] < rank[minConfidence])
128
+ continue;
129
+ for (const hint of STACK_SEARCH_HINTS[detection.name] ?? []) {
130
+ hints.add(hint);
131
+ }
132
+ }
133
+ return [...hints];
134
+ }
135
+ function mcpServersForStack(profile, names) {
136
+ const servers = new Set();
137
+ for (const detection of profile.detections) {
138
+ if (detection.confidence === "low")
139
+ continue;
140
+ const server = STACK_MCP_SERVERS[detection.name];
141
+ if (server)
142
+ servers.add(server);
143
+ }
144
+ for (const name of names) {
145
+ const server = STACK_MCP_SERVERS[name];
146
+ if (server)
147
+ servers.add(server);
148
+ }
149
+ return [...servers];
150
+ }
151
+ function issueFromCheck(issue, scope) {
152
+ return {
153
+ id: issue.id,
154
+ severity: issue.severity,
155
+ scope,
156
+ todo: issue.message,
157
+ };
158
+ }
159
+ function seoIssuesFromFields(fields, scope, count, total) {
160
+ return fields.map((field) => ({
161
+ id: field,
162
+ severity: "error",
163
+ scope,
164
+ count,
165
+ todo: `Missing ${field} on ${formatScope(count, total)}`,
166
+ }));
167
+ }
168
+ function warningIssues(keys, scope, count, total) {
169
+ const labels = {
170
+ noindex: "Remove noindex from robots meta",
171
+ nofollow: "Remove nofollow from robots meta",
172
+ "missing-template": "Add template meta tag",
173
+ "main-missing": "Add main landmark",
174
+ "main-multiple": "Ensure exactly one main landmark",
175
+ };
176
+ return keys
177
+ .filter((key) => BEST_PRACTICE_WARNING_KEYS.has(key))
178
+ .map((key) => ({
179
+ id: key,
180
+ severity: key === "noindex" || key === "nofollow" ? "warning" : "review",
181
+ scope,
182
+ count,
183
+ todo: labels[key] ?? key,
184
+ }));
185
+ }
186
+ function a11yIssues(keys, scope, count, total) {
187
+ return keys
188
+ .filter((key) => A11Y_TIER1_IDS.has(key))
189
+ .map((key) => ({
190
+ id: key,
191
+ severity: "warning",
192
+ scope,
193
+ count,
194
+ todo: `Fix a11y issue: ${key}`,
195
+ }));
196
+ }
197
+ function buildRepoPackage(options) {
198
+ return {
199
+ id: options.id,
200
+ promptId: options.promptId,
201
+ fixSurface: "repo",
202
+ priority: options.priority,
203
+ scope: options.scope,
204
+ template: options.template,
205
+ issues: options.issues,
206
+ locationDiscovery: "search-codebase",
207
+ searchHints: options.searchHints,
208
+ mcpServers: options.mcpServers ?? [],
209
+ prTitle: options.prTitle,
210
+ autoFixable: true,
211
+ };
212
+ }
213
+ /**
214
+ * Page-specific SEO issues, deduped against site-wide/template coverage (uses remainingFields,
215
+ * not raw missingFields — so canonical/og.url already fixed site-wide do not reappear here).
216
+ * `fieldFilter` selects markup fields (repo) vs content fields (CMS).
217
+ */
218
+ function collectPageSeoIssues(pageFixes, fieldFilter) {
219
+ const issues = [];
220
+ for (const { item, remainingFields } of pageFixes) {
221
+ for (const field of remainingFields.filter(fieldFilter)) {
222
+ issues.push({
223
+ id: field,
224
+ severity: "error",
225
+ scope: "page-specific",
226
+ todo: `Missing ${field}`,
227
+ urls: [item.url],
228
+ });
229
+ }
230
+ }
231
+ return issues;
232
+ }
233
+ /** Page-specific best-practice issues (template meta, robots, landmarks), deduped. */
234
+ function collectPageBestPracticeIssues(pageFixes) {
235
+ const issues = [];
236
+ for (const { item, remainingWarningKeys } of pageFixes) {
237
+ for (const key of remainingWarningKeys.filter((entry) => BEST_PRACTICE_WARNING_KEYS.has(entry))) {
238
+ issues.push({
239
+ id: key,
240
+ severity: key === "noindex" || key === "nofollow" ? "warning" : "review",
241
+ scope: "page-specific",
242
+ todo: `Fix ${key} on page`,
243
+ urls: [item.url],
244
+ });
245
+ }
246
+ }
247
+ return issues;
248
+ }
249
+ /**
250
+ * Page-specific a11y issues with granular IDs (empty-alt, heading-skip, …), deduped.
251
+ * `idFilter` selects image-alt issues (CMS content) vs structural a11y (repo).
252
+ */
253
+ function collectPageA11yIssues(pageFixes, idFilter) {
254
+ const issues = [];
255
+ for (const { item, remainingA11yIssues } of pageFixes) {
256
+ for (const issue of remainingA11yIssues.filter((entry) => idFilter(entry.id))) {
257
+ issues.push({
258
+ id: issue.id,
259
+ severity: "warning",
260
+ scope: "page-specific",
261
+ todo: issue.message,
262
+ urls: [item.url],
263
+ });
264
+ }
265
+ }
266
+ return issues;
267
+ }
268
+ function buildWorkPackages(analysis, tier1, stackProfile) {
269
+ const packages = [];
270
+ const { totalPages, redirectOnly } = analysis;
271
+ const stackHints = stackSearchHints(stackProfile);
272
+ if (tier1.redirect && tier1.redirect.issues.length > 0) {
273
+ packages.push({
274
+ id: "infra-redirect",
275
+ promptId: "infra-redirect",
276
+ fixSurface: "hosting",
277
+ priority: 1,
278
+ scope: "origin",
279
+ issues: tier1.redirect.issues.map((issue) => issueFromCheck(issue, "origin")),
280
+ locationDiscovery: "search-codebase",
281
+ searchHints: [
282
+ "Search for redirect rules in hosting config (netlify.toml, vercel.json, _redirects)",
283
+ "Search for middleware or server redirect handlers",
284
+ ...stackHints.filter((hint) => /netlify|vercel|middleware|redirect/i.test(hint)),
285
+ ],
286
+ mcpServers: mcpServersForStack(stackProfile, []),
287
+ prTitle: "fix(infra): correct domain redirect configuration",
288
+ autoFixable: true,
289
+ });
290
+ }
291
+ if (analysis.securityPages.length > 0) {
292
+ const secretIssues = [];
293
+ for (const page of analysis.securityPages) {
294
+ for (const secret of page.secrets) {
295
+ secretIssues.push({
296
+ id: `secret-${secret.provider}-${secret.type}`,
297
+ severity: secret.severity,
298
+ scope: "page-specific",
299
+ todo: `Rotate or remove exposed ${secret.provider} ${secret.type}`,
300
+ urls: [page.url],
301
+ });
302
+ }
303
+ }
304
+ packages.push({
305
+ id: "secrets",
306
+ promptId: "secrets",
307
+ fixSurface: "manual",
308
+ priority: 2,
309
+ scope: "site-wide",
310
+ issues: secretIssues,
311
+ locationDiscovery: "manual",
312
+ searchHints: [
313
+ "Search the codebase for hardcoded keys matching the reported provider and source file",
314
+ "Check environment variables and deployment secrets — do not commit real values",
315
+ ],
316
+ mcpServers: [],
317
+ prTitle: "fix(security): remove exposed secrets",
318
+ autoFixable: false,
319
+ });
320
+ }
321
+ const siteSeoFields = [...analysis.siteWideFields];
322
+ if (siteSeoFields.length > 0) {
323
+ packages.push(buildRepoPackage({
324
+ id: "seo-site-wide",
325
+ promptId: "seo-site-wide",
326
+ priority: 10,
327
+ scope: "site-wide",
328
+ issues: seoIssuesFromFields(siteSeoFields, "site-wide", totalPages, totalPages),
329
+ searchHints: [...REPO_SEARCH_BASE, ...stackHints],
330
+ prTitle: "fix(seo): add site-wide metadata and social tags",
331
+ }));
332
+ }
333
+ const siteWarnings = [...analysis.siteWideWarningKeys].filter((key) => BEST_PRACTICE_WARNING_KEYS.has(key));
334
+ if (siteWarnings.length > 0) {
335
+ packages.push(buildRepoPackage({
336
+ id: "best-practices-site-wide",
337
+ promptId: "best-practices-site-wide",
338
+ priority: 11,
339
+ scope: "site-wide",
340
+ issues: warningIssues(siteWarnings, "site-wide", totalPages, totalPages),
341
+ searchHints: [...REPO_SEARCH_BASE, "main landmark", "<main>", "robots meta", 'meta name="template"'],
342
+ prTitle: "fix: resolve site-wide best-practice issues",
343
+ }));
344
+ }
345
+ const siteA11y = [...analysis.siteWideTier1Keys].filter((key) => A11Y_TIER1_IDS.has(key));
346
+ if (siteA11y.length > 0) {
347
+ packages.push(buildRepoPackage({
348
+ id: "a11y-site-wide",
349
+ promptId: "a11y-static",
350
+ priority: 12,
351
+ scope: "site-wide",
352
+ issues: a11yIssues(siteA11y, "site-wide", totalPages, totalPages),
353
+ searchHints: [...REPO_SEARCH_BASE, "shared components", "image alt", "html lang"],
354
+ mcpServers: mcpServersForStack(stackProfile, ["Sanity"]),
355
+ prTitle: "fix(a11y): resolve site-wide accessibility issues",
356
+ }));
357
+ }
358
+ for (const [template, fields] of analysis.templateFieldMap) {
359
+ if (fields.size === 0)
360
+ continue;
361
+ const items = analysis.templateGroups.get(template) ?? [];
362
+ packages.push(buildRepoPackage({
363
+ id: `seo-template-${template}`,
364
+ promptId: "seo-template",
365
+ priority: 20,
366
+ scope: `template: ${template}`,
367
+ template,
368
+ issues: seoIssuesFromFields([...fields], `template: ${template}`, items.length, totalPages),
369
+ searchHints: [...TEMPLATE_SEARCH_BASE, `template meta value: ${template}`, ...stackHints],
370
+ prTitle: `fix(seo): add metadata for ${template} template`,
371
+ }));
372
+ }
373
+ for (const [template, keys] of analysis.templateWarningKeyMap) {
374
+ const warningKeys = [...keys].filter((key) => BEST_PRACTICE_WARNING_KEYS.has(key));
375
+ if (warningKeys.length === 0)
376
+ continue;
377
+ const items = analysis.templateGroups.get(template) ?? [];
378
+ packages.push(buildRepoPackage({
379
+ id: `best-practices-template-${template}`,
380
+ promptId: "best-practices-template",
381
+ priority: 21,
382
+ scope: `template: ${template}`,
383
+ template,
384
+ issues: warningIssues(warningKeys, `template: ${template}`, items.length, totalPages),
385
+ searchHints: [...TEMPLATE_SEARCH_BASE, `template meta value: ${template}`],
386
+ prTitle: `fix: resolve best-practice issues for ${template} template`,
387
+ }));
388
+ }
389
+ for (const [template, keys] of analysis.templateTier1KeyMap) {
390
+ const a11yKeys = [...keys].filter((key) => A11Y_TIER1_IDS.has(key));
391
+ if (a11yKeys.length === 0)
392
+ continue;
393
+ const items = analysis.templateGroups.get(template) ?? [];
394
+ packages.push(buildRepoPackage({
395
+ id: `a11y-template-${template}`,
396
+ promptId: "a11y-static",
397
+ priority: 22,
398
+ scope: `template: ${template}`,
399
+ template,
400
+ issues: a11yIssues(a11yKeys, `template: ${template}`, items.length, totalPages),
401
+ searchHints: [...TEMPLATE_SEARCH_BASE, `template meta value: ${template}`, "image alt"],
402
+ mcpServers: mcpServersForStack(stackProfile, ["Sanity"]),
403
+ prTitle: `fix(a11y): resolve accessibility issues for ${template} template`,
404
+ }));
405
+ }
406
+ if (!redirectOnly) {
407
+ // Only generate fix todos for pages that actually exist (HTTP 200). Non-200 URLs
408
+ // (404 emails/phone numbers, dynamic-route placeholders, protected endpoints) are
409
+ // crawler artifacts — they go to reviewItems instead of getting "add metadata" todos.
410
+ const actionablePageFixes = analysis.pageFixes.filter((fix) => fix.item.status === 200);
411
+ const cmsServer = detectedCmsServer(stackProfile);
412
+ const isContentField = (field) => CONTENT_SEO_FIELDS.has(field);
413
+ const isImageAlt = (id) => IMAGE_ALT_IDS.has(id);
414
+ // Repo (markup) SEO fields — when no CMS, content fields fall through here too.
415
+ const seoRepoFilter = cmsServer ? (field) => !isContentField(field) : () => true;
416
+ const pageSeo = collectPageSeoIssues(actionablePageFixes, seoRepoFilter);
417
+ if (pageSeo.length > 0) {
418
+ packages.push(buildRepoPackage({
419
+ id: "seo-pages",
420
+ promptId: "seo-page",
421
+ priority: 30,
422
+ scope: "page-specific",
423
+ issues: pageSeo,
424
+ searchHints: [...PAGE_SEARCH_BASE, ...stackHints],
425
+ prTitle: "fix(seo): resolve page-specific metadata issues",
426
+ }));
427
+ }
428
+ const pageBest = collectPageBestPracticeIssues(actionablePageFixes);
429
+ if (pageBest.length > 0) {
430
+ packages.push(buildRepoPackage({
431
+ id: "best-practices-pages",
432
+ promptId: "best-practices-page",
433
+ priority: 31,
434
+ scope: "page-specific",
435
+ issues: pageBest,
436
+ searchHints: [...PAGE_SEARCH_BASE, "main landmark", "<main>", 'meta name="template"', "robots meta"],
437
+ prTitle: "fix: resolve page-specific best-practice issues",
438
+ }));
439
+ }
440
+ // Structural a11y (heading order, lang, labels) is always a repo fix. Image alt is
441
+ // routed to the CMS-content package when a CMS owns the images.
442
+ const a11yRepoFilter = cmsServer ? (id) => !isImageAlt(id) : () => true;
443
+ const pageA11y = collectPageA11yIssues(actionablePageFixes, a11yRepoFilter);
444
+ if (pageA11y.length > 0) {
445
+ packages.push(buildRepoPackage({
446
+ id: "a11y-pages",
447
+ promptId: "a11y-static",
448
+ priority: 32,
449
+ scope: "page-specific",
450
+ issues: pageA11y,
451
+ searchHints: [...PAGE_SEARCH_BASE, "heading order", "html lang", "form labels"],
452
+ prTitle: "fix(a11y): resolve page-specific accessibility issues",
453
+ }));
454
+ }
455
+ // CMS-content package: editorial fields (descriptions, alt text) authored per page.
456
+ // Gated by the base prompt's CMS approval flow — never written silently.
457
+ if (cmsServer) {
458
+ const contentIssues = [
459
+ ...collectPageSeoIssues(actionablePageFixes, isContentField),
460
+ ...collectPageA11yIssues(actionablePageFixes, isImageAlt),
461
+ ];
462
+ if (contentIssues.length > 0) {
463
+ packages.push({
464
+ id: "cms-content",
465
+ promptId: "cms-content",
466
+ fixSurface: "cms",
467
+ priority: 33,
468
+ scope: "page-specific",
469
+ issues: contentIssues,
470
+ locationDiscovery: "cms-studio",
471
+ searchHints: [
472
+ `Inspect the ${cmsServer} dataset/schema for the documents behind each URL`,
473
+ "Identify the fields backing meta description, social description, and image alt text",
474
+ "Propose values and get user approval before any CMS write (see base instructions)",
475
+ ],
476
+ mcpServers: mcpServersForStack(stackProfile, []),
477
+ prTitle: "chore(cms): author missing descriptions and image alt text",
478
+ autoFixable: true,
479
+ });
480
+ }
481
+ }
482
+ }
483
+ if (tier1.securityHeaders && tier1.securityHeaders.issues.length > 0) {
484
+ packages.push({
485
+ id: "infra-headers",
486
+ promptId: "infra-security-headers",
487
+ fixSurface: "hosting",
488
+ priority: 40,
489
+ scope: "origin",
490
+ issues: tier1.securityHeaders.issues.map((issue) => issueFromCheck(issue, "origin")),
491
+ locationDiscovery: "search-codebase",
492
+ searchHints: [
493
+ "Search for netlify.toml, vercel.json, next.config headers, or middleware security headers",
494
+ ...stackHints.filter((hint) => /netlify|vercel|next.config|headers/i.test(hint)),
495
+ ],
496
+ mcpServers: mcpServersForStack(stackProfile, []),
497
+ prTitle: "fix(infra): add missing security headers",
498
+ autoFixable: true,
499
+ });
500
+ }
501
+ const robotsIssues = tier1.robotsTxt?.issues ?? [];
502
+ const sitemapIssues = tier1.sitemap?.issues ?? [];
503
+ if (robotsIssues.length > 0 || sitemapIssues.length > 0) {
504
+ packages.push({
505
+ id: "infra-robots-sitemap",
506
+ promptId: "infra-robots-sitemap",
507
+ fixSurface: "repo",
508
+ priority: 41,
509
+ scope: "origin",
510
+ issues: [...robotsIssues, ...sitemapIssues].map((issue) => issueFromCheck(issue, "origin")),
511
+ locationDiscovery: "search-codebase",
512
+ searchHints: [
513
+ "Search for public/robots.txt, robots route, or sitemap generation (next-sitemap, build script, cron)",
514
+ "sitemap.xml",
515
+ "robots.txt",
516
+ ],
517
+ mcpServers: [],
518
+ prTitle: "fix(infra): fix robots.txt and sitemap issues",
519
+ autoFixable: true,
520
+ });
521
+ }
522
+ if (tier1.dns && tier1.dns.issues.length > 0) {
523
+ packages.push({
524
+ id: "infra-dns",
525
+ promptId: "infra-dns",
526
+ fixSurface: "dns",
527
+ priority: 50,
528
+ scope: "origin",
529
+ issues: tier1.dns.issues.map((issue) => issueFromCheck(issue, "origin")),
530
+ locationDiscovery: "dns-panel",
531
+ searchHints: ["SPF", "DMARC", "TXT records", tier1.dns.hostname],
532
+ mcpServers: [],
533
+ prTitle: "fix(infra): update DNS mail security records",
534
+ autoFixable: false,
535
+ });
536
+ }
537
+ if (tier1.agentReady && tier1.agentReady.issues.length > 0) {
538
+ packages.push({
539
+ id: "agent-ready",
540
+ promptId: "agent-ready",
541
+ fixSurface: "repo",
542
+ priority: 42,
543
+ scope: "origin",
544
+ issues: tier1.agentReady.issues.map((issue) => issueFromCheck(issue, "origin")),
545
+ locationDiscovery: "search-codebase",
546
+ searchHints: [
547
+ "public/robots.txt",
548
+ "llms.txt",
549
+ "Content-Signal",
550
+ "Accept: text/markdown",
551
+ "middleware",
552
+ "Link header",
553
+ "/.well-known/mcp/server-card.json",
554
+ "/.well-known/agent-skills/index.json",
555
+ ],
556
+ mcpServers: [],
557
+ prTitle: "feat(agent): improve agent readiness discoverability",
558
+ autoFixable: true,
559
+ });
560
+ }
561
+ return packages.sort((a, b) => a.priority - b.priority);
562
+ }
563
+ export async function buildFixManifest(sourcePath, records, tier1 = {}, options = {}) {
564
+ const analysis = await buildReportAnalysis(records, tier1, {
565
+ redirectOnly: options.redirectOnly ?? false,
566
+ stackProfile: options.stackProfile,
567
+ });
568
+ const reportPath = options.reportPath ?? defaultReportPathForInput(sourcePath);
569
+ const reviewItems = [];
570
+ if (!analysis.redirectOnly) {
571
+ for (const fix of analysis.pageFixes) {
572
+ const reason = reviewReason(fix.item.url, fix.item.status);
573
+ if (reason)
574
+ reviewItems.push({ url: fix.item.url, status: fix.item.status, reason });
575
+ }
576
+ }
577
+ return {
578
+ version: FIX_MANIFEST_VERSION,
579
+ generatedAt: new Date().toISOString(),
580
+ source: sourcePath,
581
+ reportPath,
582
+ redirectOnly: analysis.redirectOnly,
583
+ stack: stackManifestEntries(analysis.stackProfile, analysis.totalPages),
584
+ workPackages: buildWorkPackages(analysis, tier1, analysis.stackProfile),
585
+ reviewItems,
586
+ };
587
+ }
588
+ export { defaultManifestPathForInput };