vibepro 0.1.0-alpha.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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,519 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const IGNORED_DIRS = new Set([
5
+ '.git',
6
+ '.next',
7
+ '.turbo',
8
+ '.vibepro',
9
+ 'coverage',
10
+ 'dist',
11
+ 'node_modules',
12
+ 'graphify-out'
13
+ ]);
14
+
15
+ const PAGE_EXTENSIONS = new Set(['.html', '.htm', '.jsx', '.tsx', '.md', '.mdx']);
16
+ const AI_BOTS = ['GPTBot', 'ClaudeBot', 'PerplexityBot'];
17
+
18
+ export async function scanPublicDiscovery(repoRoot) {
19
+ const root = path.resolve(repoRoot);
20
+ const files = await collectPublicFiles(root);
21
+ const metadataContext = await buildAppRouterMetadataContext(root);
22
+ const suppressionConfig = await readPublicDiscoverySuppressions(root);
23
+ const robots = await readFirstExisting(root, ['robots.txt', 'public/robots.txt']);
24
+ const llms = await readFirstExisting(root, ['llms.txt', 'public/llms.txt']);
25
+ const headerConfig = await inspectHeaderConfig(root);
26
+ const result = {
27
+ schema_version: '0.1.0',
28
+ status: 'pass',
29
+ summary: {
30
+ scanned_files: files.length,
31
+ finding_count: 0,
32
+ structured_data_findings: 0,
33
+ metadata_findings: 0,
34
+ eeat_findings: 0,
35
+ image_findings: 0,
36
+ content_findings: 0,
37
+ ai_bot_findings: 0,
38
+ response_header_findings: 0
39
+ },
40
+ route_targets: [],
41
+ structured_data_findings: [],
42
+ metadata_findings: [],
43
+ eeat_findings: [],
44
+ image_findings: [],
45
+ content_findings: [],
46
+ ai_bot_findings: [],
47
+ response_header_findings: [],
48
+ risk_summary: {
49
+ structured_data_findings: { block: 0, review: 0, info: 0 },
50
+ metadata_findings: { block: 0, review: 0, info: 0 },
51
+ eeat_findings: { block: 0, review: 0, info: 0 },
52
+ image_findings: { block: 0, review: 0, info: 0 },
53
+ content_findings: { block: 0, review: 0, info: 0 },
54
+ ai_bot_findings: { block: 0, review: 0, info: 0 },
55
+ response_header_findings: { block: 0, review: 0, info: 0 }
56
+ },
57
+ robots: {
58
+ path: robots?.relativePath ?? null,
59
+ ai_bot_policy: robots ? inspectAiBotPolicy(robots.content) : {}
60
+ },
61
+ llms: {
62
+ path: llms?.relativePath ?? null,
63
+ present: Boolean(llms)
64
+ },
65
+ header_config: headerConfig,
66
+ suppressions: {
67
+ path: suppressionConfig.path,
68
+ entries: suppressionConfig.entries,
69
+ suppressed_findings: [],
70
+ warnings: suppressionConfig.warnings
71
+ }
72
+ };
73
+
74
+ for (const file of files) {
75
+ const content = await readFile(file.absolutePath, 'utf8');
76
+ const target = classifyPublicDiscoveryTarget(file.relativePath, content);
77
+ result.route_targets.push(target);
78
+ if (target.scan_mode === 'skip') continue;
79
+ inspectPage(result, file.relativePath, content, {
80
+ target,
81
+ metadata: resolvePageMetadataContext(file.relativePath, content, metadataContext)
82
+ });
83
+ }
84
+ inspectRepositoryPublicDiscovery(result, { robots, llms, headerConfig });
85
+ applySuppressions(result, suppressionConfig);
86
+
87
+ for (const key of Object.keys(result.risk_summary)) {
88
+ result.risk_summary[key] = summarizeGateEffects(result[key]);
89
+ result.summary[key] = result[key].length;
90
+ }
91
+ result.summary.route_targets = summarizeRouteTargets(result.route_targets);
92
+ result.summary.suppressed_findings = result.suppressions.suppressed_findings.length;
93
+ result.summary.suppression_warnings = result.suppressions.warnings.length;
94
+ result.summary.finding_count = Object.keys(result.risk_summary)
95
+ .reduce((total, key) => total + result[key].length, 0);
96
+ result.status = Object.values(result.risk_summary).some((summary) => summary.block > 0)
97
+ ? 'fail'
98
+ : Object.values(result.risk_summary).some((summary) => summary.review > 0)
99
+ ? 'needs_review'
100
+ : 'pass';
101
+ return result;
102
+ }
103
+
104
+ function inspectPage(result, file, content, context = {}) {
105
+ const plainText = stripMarkup(content);
106
+ const metadata = context.metadata ?? resolvePageMetadataContext(file, content, { layouts: [] });
107
+ if (metadata.title === 'absent') {
108
+ result.metadata_findings.push(finding('missing_title', file, firstLine(content), 'review', '公開ページには検索結果とAI要約の基準になるtitleを明示する。', { evidence: 'absent', target_type: context.target?.target_type }));
109
+ }
110
+ if (metadata.description === 'absent') {
111
+ result.metadata_findings.push(finding('missing_meta_description', file, firstLine(content), 'review', '公開ページには内容を説明するmeta descriptionを明示する。', { evidence: 'absent', target_type: context.target?.target_type }));
112
+ }
113
+ if (!/rel=["']canonical["']|alternates\s*:/i.test(content)) {
114
+ result.metadata_findings.push(finding('missing_canonical_hint', file, firstLine(content), 'info', 'canonical URLまたはNext metadata alternatesを明示すると、引用元URLの揺れを減らせる。', { target_type: context.target?.target_type }));
115
+ }
116
+ if (metadata.social === 'absent') {
117
+ result.metadata_findings.push(finding('missing_social_metadata', file, firstLine(content), 'info', 'OGP/Twitter metadataがないため、共有時の文脈が弱くなる可能性がある。', { evidence: 'absent', target_type: context.target?.target_type }));
118
+ }
119
+ if (metadata.structured_data === 'absent') {
120
+ result.structured_data_findings.push(finding('missing_structured_data_hint', file, firstLine(content), 'review', 'Organization、Article、FAQPage、Productなど、ページ目的に合うschema.org構造化データを検討する。', { evidence: 'absent', target_type: context.target?.target_type }));
121
+ }
122
+ if (!/\b(author|著者|監修|editor|published|datePublished|updated|dateModified)\b/i.test(content)) {
123
+ result.eeat_findings.push(finding('missing_author_or_date_signal', file, firstLine(content), 'review', '著者、公開日、更新日、監修者などのE-E-A-Tシグナルが静的に確認できない。', { target_type: context.target?.target_type }));
124
+ }
125
+ if (!/\b(company|about|contact|privacy|terms|運営会社|会社概要|問い合わせ|プライバシー|利用規約)\b/i.test(content)) {
126
+ result.eeat_findings.push(finding('missing_operator_trust_signal', file, firstLine(content), 'info', '運営者、問い合わせ、ポリシー導線などの信頼シグナルを確認する。', { target_type: context.target?.target_type }));
127
+ }
128
+ inspectImages(result, file, content);
129
+ if (plainText.length > 0 && plainText.length < 600) {
130
+ result.content_findings.push(finding('thin_public_content', file, firstLine(content), 'info', '本文量が少ないため、AI検索で引用できる説明文脈が不足する可能性がある。'));
131
+ }
132
+ if (!/href=["'][^"']+["']|<Link\b|router\.push/i.test(content)) {
133
+ result.content_findings.push(finding('missing_internal_or_external_links', file, firstLine(content), 'info', '関連ページ、根拠資料、問い合わせなどへのリンク導線が静的に確認できない。'));
134
+ }
135
+ if (!/\b(FAQ|よくある質問|Q&A|Question|Answer)\b/i.test(content)) {
136
+ result.content_findings.push(finding('missing_faq_structure_hint', file, firstLine(content), 'info', 'FAQ形式の疑問回答があると、AI検索の質問応答に拾われやすくなる。'));
137
+ }
138
+ }
139
+
140
+ function inspectImages(result, file, content) {
141
+ const imagePattern = /<img\b[^>]*>|<Image\b[^>]*>/gi;
142
+ let match;
143
+ while ((match = imagePattern.exec(content)) !== null) {
144
+ const tag = match[0];
145
+ const line = lineNumberAt(content, match.index);
146
+ if (!/\balt\s*=/.test(tag)) {
147
+ result.image_findings.push(finding('image_missing_alt', file, line, 'review', '画像にはAI/検索/アクセシビリティ向けにaltを明示する。'));
148
+ }
149
+ if (!/\b(width|height)\s*=/.test(tag)) {
150
+ result.image_findings.push(finding('image_missing_dimensions', file, line, 'info', '画像のwidth/heightを明示するとCLSと読み込み品質を安定させやすい。'));
151
+ }
152
+ if (!/\bloading=["']lazy["']|priority\s*=|fetchPriority\s*=/i.test(tag)) {
153
+ result.image_findings.push(finding('image_loading_policy_unspecified', file, line, 'info', '画像のlazy/priority方針が静的に確認できない。'));
154
+ }
155
+ }
156
+ }
157
+
158
+ function inspectRepositoryPublicDiscovery(result, { robots, llms, headerConfig }) {
159
+ if (!robots) {
160
+ result.ai_bot_findings.push(finding('robots_txt_missing', 'robots.txt', 1, 'review', 'AIボットと検索クローラーの許可/拒否方針をrobots.txtで明示する。'));
161
+ } else {
162
+ for (const bot of AI_BOTS) {
163
+ if (!new RegExp(`User-agent:\\s*${escapeRegExp(bot)}\\b`, 'i').test(robots.content)) {
164
+ result.ai_bot_findings.push(finding('ai_bot_policy_missing', robots.relativePath, 1, 'info', `${bot}へのクロール方針が明示されていない。`));
165
+ }
166
+ }
167
+ }
168
+ if (!llms) {
169
+ result.ai_bot_findings.push(finding('llms_txt_missing', 'llms.txt', 1, 'info', 'llms.txtや同等のAI向けサイト説明がないため、任意で追加を検討する。'));
170
+ }
171
+ if (!headerConfig.has_config) {
172
+ result.response_header_findings.push(finding('response_header_config_not_detected', 'headers config', 1, 'info', 'Cache-Control、Content-Encoding、HSTS、X-Content-Type-Options、CSP/frame-ancestorsなどの公開レスポンスヘッダー設定を確認する。'));
173
+ return;
174
+ }
175
+ for (const header of ['cache-control', 'x-content-type-options', 'strict-transport-security']) {
176
+ if (!headerConfig.headers.some((candidate) => candidate.toLowerCase() === header)) {
177
+ result.response_header_findings.push(finding('response_header_not_detected', headerConfig.files.join(', '), 1, 'info', `${header} が静的設定から確認できない。`));
178
+ }
179
+ }
180
+ }
181
+
182
+ async function collectPublicFiles(root) {
183
+ const candidates = await walk(root);
184
+ return candidates
185
+ .filter((file) => PAGE_EXTENSIONS.has(path.extname(file.relativePath).toLowerCase()))
186
+ .filter((file) => isPublicPageFile(file.relativePath))
187
+ .slice(0, 400);
188
+ }
189
+
190
+ function isPublicPageFile(relativePath) {
191
+ if (relativePath === 'index.html') return true;
192
+ if (/^public\/.*\.html?$/i.test(relativePath)) return true;
193
+ if (/^(src\/)?app\/.*\/page\.(jsx|tsx|mdx)$/i.test(relativePath)) return true;
194
+ if (/^(src\/)?pages\/.*\.(jsx|tsx|mdx|md|html?)$/i.test(relativePath)) return true;
195
+ if (/^content\/.*\.(md|mdx|html?)$/i.test(relativePath)) return true;
196
+ return false;
197
+ }
198
+
199
+ function classifyPublicDiscoveryTarget(relativePath, content) {
200
+ const segments = relativePath.split('/');
201
+ const route = routePathForFile(relativePath);
202
+ if (isVerificationHtml(relativePath, content)) {
203
+ return target(relativePath, route, 'verification_file', 'skip', 'site_verification_file');
204
+ }
205
+ if (segments.some((segment) => /^(demo|test|sandbox|playground)$/i.test(segment))) {
206
+ return target(relativePath, route, 'internal_dev_route', 'skip', 'demo_or_dev_segment');
207
+ }
208
+ if (segments.includes('(auth)') || /(^|\/)api\/auth(\/|$)|(^|\/)auth(\/|$)|sign[_-]?in|sign[_-]?up|login/i.test(relativePath)) {
209
+ return target(relativePath, route, 'auth_flow', 'skip', 'auth_route');
210
+ }
211
+ if (segments.includes('(app)') || /(^|\/)(profile|manager|admin|dashboard|mypage)(\/|$)/i.test(relativePath)) {
212
+ return target(relativePath, route, 'private_app_route', 'skip', 'private_app_route');
213
+ }
214
+ if (/log[_-]?viewer|shadow-call|internal|debug|legacy/i.test(relativePath)) {
215
+ return target(relativePath, route, 'internal_dev_route', 'skip', 'internal_route');
216
+ }
217
+ if (/robots\s*:\s*{[^}]*noIndex\s*:\s*true|noindex/i.test(content)) {
218
+ return target(relativePath, route, 'private_app_route', 'skip', 'noindex');
219
+ }
220
+ if (/support|help|faq|contact/i.test(relativePath)) {
221
+ return target(relativePath, route, 'public_utility', 'scan', 'public_utility_route');
222
+ }
223
+ return target(relativePath, route, 'public_seo_target', 'scan', 'public_page');
224
+ }
225
+
226
+ function target(file, route, targetType, scanMode, reason) {
227
+ return { file, route, target_type: targetType, scan_mode: scanMode, reason };
228
+ }
229
+
230
+ function isVerificationHtml(relativePath, content) {
231
+ if (!/^public\/.*\.html?$/i.test(relativePath)) return false;
232
+ const basename = path.basename(relativePath).toLowerCase();
233
+ if (/^google[a-z0-9_-]*\.html$/i.test(basename) && /google-site-verification|google site verification/i.test(content)) return true;
234
+ return /verification/i.test(basename);
235
+ }
236
+
237
+ function routePathForFile(relativePath) {
238
+ let route = relativePath
239
+ .replace(/^src\/app\//, '/')
240
+ .replace(/^app\//, '/')
241
+ .replace(/^src\/pages\//, '/')
242
+ .replace(/^pages\//, '/')
243
+ .replace(/^public\//, '/')
244
+ .replace(/\/page\.(jsx|tsx|mdx)$/i, '')
245
+ .replace(/\.(jsx|tsx|mdx|md|html?)$/i, '')
246
+ .replace(/\/index$/i, '/')
247
+ .replace(/\/\([^/)]+\)/g, '');
248
+ if (!route.startsWith('/')) route = `/${route}`;
249
+ return route.replace(/\/+/g, '/');
250
+ }
251
+
252
+ async function buildAppRouterMetadataContext(root) {
253
+ const files = await walk(root);
254
+ const layouts = [];
255
+ for (const file of files) {
256
+ if (!/^(src\/)?app\/.*\/layout\.(jsx|tsx|mdx)$/i.test(file.relativePath) && !/^(src\/)?app\/layout\.(jsx|tsx|mdx)$/i.test(file.relativePath)) continue;
257
+ const content = await readFile(file.absolutePath, 'utf8');
258
+ layouts.push({
259
+ file: file.relativePath,
260
+ dir: file.relativePath.replace(/\/layout\.(jsx|tsx|mdx)$/i, ''),
261
+ metadata: extractMetadataSignals(content)
262
+ });
263
+ }
264
+ return { layouts };
265
+ }
266
+
267
+ function resolvePageMetadataContext(relativePath, content, context) {
268
+ const local = extractMetadataSignals(content);
269
+ const inherited = matchingLayouts(relativePath, context.layouts ?? [])
270
+ .map((layout) => layout.metadata)
271
+ .reduce((merged, signals) => mergeMetadataSignals(merged, signals), emptyMetadataSignals());
272
+ return {
273
+ title: local.title ? 'local' : inherited.title ? 'inherited' : 'absent',
274
+ description: local.description ? 'local' : inherited.description ? 'inherited' : 'absent',
275
+ social: local.social ? 'local' : inherited.social ? 'inherited' : 'absent',
276
+ structured_data: local.structured_data ? 'local' : inherited.structured_data ? 'inherited' : 'absent'
277
+ };
278
+ }
279
+
280
+ function matchingLayouts(relativePath, layouts) {
281
+ const pageDir = relativePath.replace(/\/page\.(jsx|tsx|mdx)$/i, '');
282
+ return layouts.filter((layout) => pageDir === layout.dir || pageDir.startsWith(`${layout.dir}/`));
283
+ }
284
+
285
+ function extractMetadataSignals(content) {
286
+ return {
287
+ title: hasTitle(content),
288
+ description: hasMetaDescription(content),
289
+ social: /property=["']og:|twitter:|openGraph\s*:|twitter\s*:/i.test(content),
290
+ structured_data: /application\/ld\+json|schema\.org|jsonLd|structuredData/i.test(content)
291
+ };
292
+ }
293
+
294
+ function emptyMetadataSignals() {
295
+ return { title: false, description: false, social: false, structured_data: false };
296
+ }
297
+
298
+ function mergeMetadataSignals(a, b) {
299
+ return {
300
+ title: a.title || b.title,
301
+ description: a.description || b.description,
302
+ social: a.social || b.social,
303
+ structured_data: a.structured_data || b.structured_data
304
+ };
305
+ }
306
+
307
+ async function walk(root, dir = root) {
308
+ const entries = await safeReaddir(dir);
309
+ const files = [];
310
+ for (const entry of entries) {
311
+ if (IGNORED_DIRS.has(entry.name)) continue;
312
+ const absolutePath = path.join(dir, entry.name);
313
+ const relativePath = path.relative(root, absolutePath).replaceAll(path.sep, '/');
314
+ if (entry.isDirectory()) {
315
+ files.push(...await walk(root, absolutePath));
316
+ } else if (entry.isFile()) {
317
+ files.push({ absolutePath, relativePath });
318
+ }
319
+ }
320
+ return files;
321
+ }
322
+
323
+ async function inspectHeaderConfig(root) {
324
+ const candidates = ['vercel.json', 'next.config.js', 'next.config.mjs', 'next.config.ts', 'public/_headers', 'netlify.toml'];
325
+ const files = [];
326
+ const headers = new Set();
327
+ for (const relativePath of candidates) {
328
+ const content = await readOptional(path.join(root, relativePath));
329
+ if (content === null) continue;
330
+ files.push(relativePath);
331
+ for (const header of ['Cache-Control', 'Content-Encoding', 'X-Content-Type-Options', 'X-Frame-Options', 'Strict-Transport-Security', 'Content-Security-Policy']) {
332
+ if (new RegExp(header, 'i').test(content)) headers.add(header.toLowerCase());
333
+ }
334
+ }
335
+ return { has_config: files.length > 0, files, headers: [...headers] };
336
+ }
337
+
338
+ async function readFirstExisting(root, relativePaths) {
339
+ for (const relativePath of relativePaths) {
340
+ const content = await readOptional(path.join(root, relativePath));
341
+ if (content !== null) return { relativePath, content };
342
+ }
343
+ return null;
344
+ }
345
+
346
+ async function readOptional(filePath) {
347
+ try {
348
+ return await readFile(filePath, 'utf8');
349
+ } catch (error) {
350
+ if (error.code === 'ENOENT') return null;
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ async function readPublicDiscoverySuppressions(root) {
356
+ const relativePath = '.vibepro/public-discovery-suppressions.json';
357
+ const content = await readOptional(path.join(root, relativePath));
358
+ if (content === null) return { path: null, entries: [], warnings: [] };
359
+ let parsed;
360
+ try {
361
+ parsed = JSON.parse(content);
362
+ } catch (error) {
363
+ return {
364
+ path: relativePath,
365
+ entries: [],
366
+ warnings: [suppressionWarning('invalid_json', relativePath, `Suppression file is not valid JSON: ${error.message}`)]
367
+ };
368
+ }
369
+ if (!Array.isArray(parsed)) {
370
+ return {
371
+ path: relativePath,
372
+ entries: [],
373
+ warnings: [suppressionWarning('invalid_shape', relativePath, 'Suppression file must be an array')]
374
+ };
375
+ }
376
+ const entries = [];
377
+ const warnings = [];
378
+ for (const [index, entry] of parsed.entries()) {
379
+ if (!entry?.file || !Array.isArray(entry.finding_kinds) || !entry.reason) {
380
+ warnings.push(suppressionWarning('invalid_entry', relativePath, `Suppression entry ${index} requires file, finding_kinds, and reason`));
381
+ continue;
382
+ }
383
+ entries.push({
384
+ file: entry.file,
385
+ finding_kinds: entry.finding_kinds,
386
+ reason: entry.reason,
387
+ expires_at: entry.expires_at ?? null
388
+ });
389
+ }
390
+ return { path: relativePath, entries, warnings };
391
+ }
392
+
393
+ function applySuppressions(result, suppressionConfig) {
394
+ if (suppressionConfig.entries.length === 0) return;
395
+ const knownKinds = new Set();
396
+ const matchedEntries = new Set();
397
+ for (const group of findingGroups()) {
398
+ const kept = [];
399
+ for (const item of result[group]) {
400
+ knownKinds.add(item.kind);
401
+ const suppression = suppressionConfig.entries.find((entry, index) => {
402
+ const matched = globMatches(entry.file, item.file) && entry.finding_kinds.includes(item.kind);
403
+ if (matched) matchedEntries.add(index);
404
+ return matched;
405
+ });
406
+ if (suppression) {
407
+ result.suppressions.suppressed_findings.push({
408
+ ...item,
409
+ suppression: {
410
+ file: suppression.file,
411
+ reason: suppression.reason,
412
+ expires_at: suppression.expires_at
413
+ }
414
+ });
415
+ } else {
416
+ kept.push(item);
417
+ }
418
+ }
419
+ result[group] = kept;
420
+ }
421
+ for (const [index, entry] of suppressionConfig.entries.entries()) {
422
+ for (const kind of entry.finding_kinds) {
423
+ if (!knownKinds.has(kind)) {
424
+ result.suppressions.warnings.push(suppressionWarning('unknown_finding_kind', entry.file, `Suppression entry ${index} references unknown finding kind: ${kind}`));
425
+ }
426
+ }
427
+ if (!matchedEntries.has(index)) {
428
+ result.suppressions.warnings.push(suppressionWarning('unmatched_suppression', entry.file, `Suppression entry ${index} did not match any finding`));
429
+ }
430
+ }
431
+ }
432
+
433
+ function findingGroups() {
434
+ return [
435
+ 'structured_data_findings',
436
+ 'metadata_findings',
437
+ 'eeat_findings',
438
+ 'image_findings',
439
+ 'content_findings',
440
+ 'ai_bot_findings',
441
+ 'response_header_findings'
442
+ ];
443
+ }
444
+
445
+ function suppressionWarning(kind, file, message) {
446
+ return { kind, file, message };
447
+ }
448
+
449
+ function globMatches(pattern, value) {
450
+ const escaped = pattern
451
+ .split('*')
452
+ .map((part) => escapeRegExp(part))
453
+ .join('.*');
454
+ return new RegExp(`^${escaped}$`).test(value);
455
+ }
456
+
457
+ function summarizeRouteTargets(routeTargets) {
458
+ const summary = {};
459
+ for (const item of routeTargets) {
460
+ summary[item.target_type] = (summary[item.target_type] ?? 0) + 1;
461
+ }
462
+ return summary;
463
+ }
464
+
465
+ async function safeReaddir(dir) {
466
+ try {
467
+ return await readdir(dir, { withFileTypes: true });
468
+ } catch (error) {
469
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [];
470
+ throw error;
471
+ }
472
+ }
473
+
474
+ function inspectAiBotPolicy(content) {
475
+ return Object.fromEntries(AI_BOTS.map((bot) => [bot, new RegExp(`User-agent:\\s*${escapeRegExp(bot)}\\b`, 'i').test(content) ? 'explicit' : 'not_detected']));
476
+ }
477
+
478
+ function finding(kind, file, line, gateEffect, recommendation, extra = {}) {
479
+ return { kind, file, line, gate_effect: gateEffect, recommendation, ...extra };
480
+ }
481
+
482
+ function summarizeGateEffects(hits) {
483
+ const summary = { block: 0, review: 0, info: 0 };
484
+ for (const hit of hits) {
485
+ const effect = ['block', 'review', 'info'].includes(hit.gate_effect) ? hit.gate_effect : 'info';
486
+ summary[effect] += 1;
487
+ }
488
+ return summary;
489
+ }
490
+
491
+ function hasTitle(content) {
492
+ return /<title>[^<]+<\/title>/i.test(content) || /\btitle\s*[:=]\s*['"`][^'"`]+['"`]/i.test(content);
493
+ }
494
+
495
+ function hasMetaDescription(content) {
496
+ return /<meta\s+[^>]*name=["']description["'][^>]*content=["'][^"']+["']/i.test(content)
497
+ || /\bdescription\s*[:=]\s*['"`][^'"`]+['"`]/i.test(content);
498
+ }
499
+
500
+ function stripMarkup(content) {
501
+ return content
502
+ .replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
503
+ .replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
504
+ .replace(/<[^>]+>/g, ' ')
505
+ .replace(/\s+/g, ' ')
506
+ .trim();
507
+ }
508
+
509
+ function firstLine(_content) {
510
+ return 1;
511
+ }
512
+
513
+ function lineNumberAt(content, index) {
514
+ return content.slice(0, index).split(/\r?\n/).length;
515
+ }
516
+
517
+ function escapeRegExp(value) {
518
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
519
+ }