@ucptools/validator 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 (121) hide show
  1. package/CLAUDE.md +109 -0
  2. package/CONTRIBUTING.md +113 -0
  3. package/LICENSE +21 -0
  4. package/README.md +203 -0
  5. package/api/analyze-feed.js +140 -0
  6. package/api/badge.js +185 -0
  7. package/api/benchmark.js +177 -0
  8. package/api/directory-stats.ts +29 -0
  9. package/api/directory.ts +73 -0
  10. package/api/generate-compliance.js +143 -0
  11. package/api/generate-schema.js +457 -0
  12. package/api/generate.js +132 -0
  13. package/api/security-scan.js +133 -0
  14. package/api/simulate.js +187 -0
  15. package/api/tsconfig.json +10 -0
  16. package/api/validate.js +1351 -0
  17. package/apify-actor/.actor/actor.json +68 -0
  18. package/apify-actor/.actor/input_schema.json +32 -0
  19. package/apify-actor/APIFY-STORE-LISTING.md +412 -0
  20. package/apify-actor/Dockerfile +8 -0
  21. package/apify-actor/README.md +166 -0
  22. package/apify-actor/main.ts +111 -0
  23. package/apify-actor/package.json +17 -0
  24. package/apify-actor/src/main.js +199 -0
  25. package/docs/BRAND-IDENTITY.md +238 -0
  26. package/docs/BRAND-STYLE-GUIDE.md +356 -0
  27. package/drizzle/0000_black_king_cobra.sql +39 -0
  28. package/drizzle/meta/0000_snapshot.json +309 -0
  29. package/drizzle/meta/_journal.json +13 -0
  30. package/drizzle.config.ts +10 -0
  31. package/examples/full-profile.json +70 -0
  32. package/examples/minimal-profile.json +23 -0
  33. package/package.json +69 -0
  34. package/public/.well-known/ucp +25 -0
  35. package/public/android-chrome-192x192.png +0 -0
  36. package/public/android-chrome-512x512.png +0 -0
  37. package/public/apple-touch-icon.png +0 -0
  38. package/public/brand.css +321 -0
  39. package/public/directory.html +701 -0
  40. package/public/favicon-16x16.png +0 -0
  41. package/public/favicon-32x32.png +0 -0
  42. package/public/favicon.ico +0 -0
  43. package/public/guides/bigcommerce.html +743 -0
  44. package/public/guides/fastucp.html +838 -0
  45. package/public/guides/magento.html +779 -0
  46. package/public/guides/shopify.html +726 -0
  47. package/public/guides/squarespace.html +749 -0
  48. package/public/guides/wix.html +747 -0
  49. package/public/guides/woocommerce.html +733 -0
  50. package/public/index.html +3835 -0
  51. package/public/learn.html +396 -0
  52. package/public/logo.jpeg +0 -0
  53. package/public/og-image-icon.png +0 -0
  54. package/public/og-image.png +0 -0
  55. package/public/robots.txt +6 -0
  56. package/public/site.webmanifest +31 -0
  57. package/public/sitemap.xml +69 -0
  58. package/public/social/linkedin-banner-1128x191.png +0 -0
  59. package/public/social/temp.PNG +0 -0
  60. package/public/social/x-header-1500x500.png +0 -0
  61. package/public/verify.html +410 -0
  62. package/scripts/generate-favicons.js +44 -0
  63. package/scripts/generate-ico.js +23 -0
  64. package/scripts/generate-og-image.js +45 -0
  65. package/scripts/reset-db.ts +77 -0
  66. package/scripts/seed-db.ts +71 -0
  67. package/scripts/setup-benchmark-db.js +70 -0
  68. package/src/api/server.ts +266 -0
  69. package/src/cli/index.ts +302 -0
  70. package/src/compliance/compliance-generator.ts +452 -0
  71. package/src/compliance/index.ts +28 -0
  72. package/src/compliance/templates.ts +338 -0
  73. package/src/compliance/types.ts +170 -0
  74. package/src/db/index.ts +28 -0
  75. package/src/db/schema.ts +84 -0
  76. package/src/feed-analyzer/feed-analyzer.ts +726 -0
  77. package/src/feed-analyzer/index.ts +34 -0
  78. package/src/feed-analyzer/types.ts +354 -0
  79. package/src/generator/index.ts +7 -0
  80. package/src/generator/key-generator.ts +124 -0
  81. package/src/generator/profile-builder.ts +402 -0
  82. package/src/hosting/artifacts-generator.ts +679 -0
  83. package/src/hosting/index.ts +6 -0
  84. package/src/index.ts +105 -0
  85. package/src/security/index.ts +15 -0
  86. package/src/security/security-scanner.ts +604 -0
  87. package/src/security/types.ts +55 -0
  88. package/src/services/directory.ts +434 -0
  89. package/src/simulator/agent-simulator.ts +941 -0
  90. package/src/simulator/index.ts +7 -0
  91. package/src/simulator/types.ts +170 -0
  92. package/src/types/generator.ts +140 -0
  93. package/src/types/index.ts +7 -0
  94. package/src/types/ucp-profile.ts +140 -0
  95. package/src/types/validation.ts +89 -0
  96. package/src/validator/index.ts +194 -0
  97. package/src/validator/network-validator.ts +417 -0
  98. package/src/validator/rules-validator.ts +297 -0
  99. package/src/validator/sdk-validator.ts +330 -0
  100. package/src/validator/structural-validator.ts +476 -0
  101. package/tests/fixtures/non-compliant-profile.json +25 -0
  102. package/tests/fixtures/official-sample-profile.json +75 -0
  103. package/tests/integration/benchmark.test.ts +207 -0
  104. package/tests/integration/database.test.ts +163 -0
  105. package/tests/integration/directory-api.test.ts +268 -0
  106. package/tests/integration/simulate-api.test.ts +230 -0
  107. package/tests/integration/validate-api.test.ts +269 -0
  108. package/tests/setup.ts +15 -0
  109. package/tests/unit/agent-simulator.test.ts +575 -0
  110. package/tests/unit/compliance-generator.test.ts +374 -0
  111. package/tests/unit/directory-service.test.ts +272 -0
  112. package/tests/unit/feed-analyzer.test.ts +517 -0
  113. package/tests/unit/lint-suggestions.test.ts +423 -0
  114. package/tests/unit/official-samples.test.ts +211 -0
  115. package/tests/unit/pdf-report.test.ts +390 -0
  116. package/tests/unit/sdk-validator.test.ts +531 -0
  117. package/tests/unit/security-scanner.test.ts +410 -0
  118. package/tests/unit/validation.test.ts +390 -0
  119. package/tsconfig.json +20 -0
  120. package/vercel.json +34 -0
  121. package/vitest.config.ts +22 -0
@@ -0,0 +1,1351 @@
1
+ /**
2
+ * Vercel Serverless Function: Validate UCP Profile + AI Readiness
3
+ * POST /api/validate
4
+ *
5
+ * Checks:
6
+ * 1. UCP Profile at /.well-known/ucp
7
+ * 2. Schema.org JSON-LD (MerchantReturnPolicy, shippingDetails)
8
+ * 3. Product Schema Quality & Completeness
9
+ * 4. Content Consistency Analysis
10
+ * 5. Overall AI Readiness Score
11
+ * 6. Industry Benchmark Comparison
12
+ */
13
+
14
+ import pg from 'pg';
15
+
16
+ const { Pool } = pg;
17
+
18
+ let benchmarkPool = null;
19
+
20
+ function getBenchmarkPool() {
21
+ if (!benchmarkPool && process.env.DATABASE_URL) {
22
+ benchmarkPool = new Pool({
23
+ connectionString: process.env.DATABASE_URL,
24
+ ssl: { rejectUnauthorized: false },
25
+ max: 3,
26
+ idleTimeoutMillis: 30000,
27
+ });
28
+ }
29
+ return benchmarkPool;
30
+ }
31
+
32
+ /**
33
+ * Record score and calculate percentile
34
+ */
35
+ async function recordAndGetBenchmark(score) {
36
+ const pool = getBenchmarkPool();
37
+ if (!pool) {
38
+ return null;
39
+ }
40
+
41
+ try {
42
+ const bucket = Math.floor(score / 10) * 10;
43
+
44
+ // Record the score
45
+ await pool.query(`
46
+ UPDATE benchmark_stats SET count = count + 1 WHERE score_bucket = $1
47
+ `, [bucket]);
48
+
49
+ await pool.query(`
50
+ UPDATE benchmark_summary
51
+ SET total_validations = total_validations + 1,
52
+ avg_score = (avg_score * total_validations + $1) / (total_validations + 1),
53
+ updated_at = NOW()
54
+ WHERE id = 1
55
+ `, [score]);
56
+
57
+ // Calculate percentile
58
+ const distResult = await pool.query(`
59
+ SELECT score_bucket, count, SUM(count) OVER (ORDER BY score_bucket) as cumulative
60
+ FROM benchmark_stats ORDER BY score_bucket
61
+ `);
62
+
63
+ const summaryResult = await pool.query('SELECT total_validations, avg_score FROM benchmark_summary WHERE id = 1');
64
+ const total = summaryResult.rows[0]?.total_validations || 1;
65
+ const avgScore = Math.round((summaryResult.rows[0]?.avg_score || 50) * 10) / 10;
66
+
67
+ let belowCount = 0;
68
+ for (const row of distResult.rows) {
69
+ if (row.score_bucket < bucket) {
70
+ belowCount = parseInt(row.cumulative);
71
+ } else if (row.score_bucket === bucket) {
72
+ belowCount = parseInt(row.cumulative) - Math.floor(parseInt(row.count) / 2);
73
+ break;
74
+ }
75
+ }
76
+
77
+ const percentile = Math.round((belowCount / total) * 100);
78
+
79
+ return {
80
+ percentile,
81
+ total_validations: total,
82
+ avg_score: avgScore,
83
+ };
84
+ } catch (error) {
85
+ console.error('Benchmark error:', error);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ const VERSION_REGEX = /^\d{4}-\d{2}-\d{2}$/;
91
+
92
+ // Recommended product fields for AI commerce
93
+ const PRODUCT_FIELDS = {
94
+ required: ['name', 'offers'],
95
+ recommended: ['description', 'image', 'brand', 'sku', 'aggregateRating'],
96
+ optional: ['gtin', 'mpn', 'review', 'category', 'color', 'material', 'weight']
97
+ };
98
+
99
+ const OFFER_FIELDS = {
100
+ required: ['price', 'priceCurrency'],
101
+ recommended: ['availability', 'hasMerchantReturnPolicy', 'shippingDetails'],
102
+ optional: ['priceValidUntil', 'itemCondition', 'seller']
103
+ };
104
+
105
+ async function fetchProfile(domain) {
106
+ // Try both /.well-known/ucp and /.well-known/ucp.json
107
+ const urls = [
108
+ `https://${domain}/.well-known/ucp`,
109
+ `https://${domain}/.well-known/ucp.json`,
110
+ ];
111
+
112
+ for (const url of urls) {
113
+ try {
114
+ const res = await fetch(url, {
115
+ headers: {
116
+ 'Accept': 'application/json',
117
+ 'User-Agent': 'UCP-Validator/1.0 (https://ucptools.dev)'
118
+ },
119
+ signal: AbortSignal.timeout(10000),
120
+ });
121
+
122
+ if (!res.ok) continue;
123
+
124
+ const text = await res.text();
125
+ // Check if response looks like JSON (not HTML)
126
+ if (text.trim().startsWith('<')) continue;
127
+
128
+ const profile = JSON.parse(text);
129
+ return { profile, profileUrl: url };
130
+ } catch (e) {
131
+ // Try next URL
132
+ continue;
133
+ }
134
+ }
135
+
136
+ return { profile: null, error: 'No UCP profile found at /.well-known/ucp or /.well-known/ucp.json' };
137
+ }
138
+
139
+ async function fetchHomepage(domain) {
140
+ const url = `https://${domain}`;
141
+ try {
142
+ const res = await fetch(url, {
143
+ headers: {
144
+ 'Accept': 'text/html',
145
+ 'User-Agent': 'UCP-Validator/1.0 (https://ucptools.dev)'
146
+ },
147
+ signal: AbortSignal.timeout(15000),
148
+ });
149
+ if (!res.ok) {
150
+ return { html: null, error: `HTTP ${res.status}` };
151
+ }
152
+ const html = await res.text();
153
+ return { html };
154
+ } catch (e) {
155
+ return { html: null, error: e.message };
156
+ }
157
+ }
158
+
159
+ function extractJsonLd(html) {
160
+ const schemas = [];
161
+ const regex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
162
+ let match;
163
+ while ((match = regex.exec(html)) !== null) {
164
+ try {
165
+ const parsed = JSON.parse(match[1]);
166
+ if (Array.isArray(parsed)) {
167
+ schemas.push(...parsed);
168
+ } else {
169
+ schemas.push(parsed);
170
+ }
171
+ } catch (e) {
172
+ // Invalid JSON-LD, skip
173
+ }
174
+ }
175
+ return schemas;
176
+ }
177
+
178
+ function findInSchema(schemas, type) {
179
+ const results = [];
180
+ function search(obj) {
181
+ if (!obj || typeof obj !== 'object') return;
182
+ if (Array.isArray(obj)) {
183
+ obj.forEach(search);
184
+ return;
185
+ }
186
+ if (obj['@type'] === type || (Array.isArray(obj['@type']) && obj['@type'].includes(type))) {
187
+ results.push(obj);
188
+ }
189
+ Object.values(obj).forEach(search);
190
+ }
191
+ schemas.forEach(search);
192
+ return results;
193
+ }
194
+
195
+ function getStringLength(val) {
196
+ if (typeof val === 'string') return val.length;
197
+ if (typeof val === 'object' && val !== null) {
198
+ // Handle schema.org structured values
199
+ if (val['@value']) return String(val['@value']).length;
200
+ if (val.name) return String(val.name).length;
201
+ }
202
+ return 0;
203
+ }
204
+
205
+ function hasValue(val) {
206
+ if (val === null || val === undefined) return false;
207
+ if (typeof val === 'string') return val.trim().length > 0;
208
+ if (Array.isArray(val)) return val.length > 0;
209
+ if (typeof val === 'object') return Object.keys(val).length > 0;
210
+ return true;
211
+ }
212
+
213
+ function validateProductQuality(products) {
214
+ const issues = [];
215
+ const recommendations = [];
216
+ let totalCompleteness = 0;
217
+
218
+ products.forEach((product, idx) => {
219
+ let productScore = 0;
220
+ let maxScore = 0;
221
+ const productName = product.name || `Product ${idx + 1}`;
222
+
223
+ // Required fields (30 points each)
224
+ PRODUCT_FIELDS.required.forEach(field => {
225
+ maxScore += 30;
226
+ if (hasValue(product[field])) {
227
+ productScore += 30;
228
+ } else {
229
+ issues.push({
230
+ severity: 'error',
231
+ code: `PRODUCT_MISSING_${field.toUpperCase()}`,
232
+ category: 'product_quality',
233
+ message: `Product "${productName}" missing required field: ${field}`,
234
+ });
235
+ }
236
+ });
237
+
238
+ // Recommended fields (15 points each)
239
+ PRODUCT_FIELDS.recommended.forEach(field => {
240
+ maxScore += 15;
241
+ if (hasValue(product[field])) {
242
+ productScore += 15;
243
+ } else {
244
+ recommendations.push({
245
+ field,
246
+ message: `Add ${field} to improve AI product matching`,
247
+ priority: 'high'
248
+ });
249
+ }
250
+ });
251
+
252
+ // Optional fields (5 points each)
253
+ PRODUCT_FIELDS.optional.forEach(field => {
254
+ maxScore += 5;
255
+ if (hasValue(product[field])) {
256
+ productScore += 5;
257
+ }
258
+ });
259
+
260
+ // Description quality check
261
+ if (product.description) {
262
+ const descLength = getStringLength(product.description);
263
+ if (descLength < 50) {
264
+ issues.push({
265
+ severity: 'warn',
266
+ code: 'PRODUCT_SHORT_DESCRIPTION',
267
+ category: 'content_quality',
268
+ message: `Product "${productName}" has very short description (${descLength} chars)`,
269
+ hint: 'Descriptions under 50 chars may cause AI hallucinations. Aim for 150-300 chars.'
270
+ });
271
+ } else if (descLength < 100) {
272
+ recommendations.push({
273
+ field: 'description',
274
+ message: `Consider expanding description for "${productName}" (currently ${descLength} chars)`,
275
+ priority: 'medium'
276
+ });
277
+ }
278
+ } else {
279
+ issues.push({
280
+ severity: 'warn',
281
+ code: 'PRODUCT_NO_DESCRIPTION',
282
+ category: 'content_quality',
283
+ message: `Product "${productName}" has no description`,
284
+ hint: 'Missing descriptions increase risk of AI hallucinations'
285
+ });
286
+ }
287
+
288
+ // Image check
289
+ if (!product.image) {
290
+ issues.push({
291
+ severity: 'warn',
292
+ code: 'PRODUCT_NO_IMAGE',
293
+ category: 'content_quality',
294
+ message: `Product "${productName}" has no image`,
295
+ hint: 'Products without images may be deprioritized by AI agents'
296
+ });
297
+ }
298
+
299
+ // Validate offers
300
+ const offers = Array.isArray(product.offers) ? product.offers : (product.offers ? [product.offers] : []);
301
+
302
+ if (offers.length === 0) {
303
+ issues.push({
304
+ severity: 'error',
305
+ code: 'PRODUCT_NO_OFFERS',
306
+ category: 'product_quality',
307
+ message: `Product "${productName}" has no offers/pricing`,
308
+ });
309
+ } else {
310
+ offers.forEach((offer, offerIdx) => {
311
+ // Price check
312
+ if (!hasValue(offer.price)) {
313
+ issues.push({
314
+ severity: 'error',
315
+ code: 'OFFER_NO_PRICE',
316
+ category: 'product_quality',
317
+ message: `Product "${productName}" offer missing price`,
318
+ });
319
+ } else {
320
+ // Price format validation
321
+ const price = parseFloat(offer.price);
322
+ if (isNaN(price) || price < 0) {
323
+ issues.push({
324
+ severity: 'error',
325
+ code: 'OFFER_INVALID_PRICE',
326
+ category: 'content_quality',
327
+ message: `Product "${productName}" has invalid price: ${offer.price}`,
328
+ });
329
+ }
330
+ }
331
+
332
+ // Currency check
333
+ if (!hasValue(offer.priceCurrency)) {
334
+ issues.push({
335
+ severity: 'warn',
336
+ code: 'OFFER_NO_CURRENCY',
337
+ category: 'product_quality',
338
+ message: `Product "${productName}" offer missing priceCurrency`,
339
+ hint: 'Add ISO 4217 currency code (e.g., "USD", "EUR")'
340
+ });
341
+ }
342
+
343
+ // Availability check
344
+ if (!hasValue(offer.availability)) {
345
+ recommendations.push({
346
+ field: 'availability',
347
+ message: `Add availability status to "${productName}"`,
348
+ priority: 'high'
349
+ });
350
+ }
351
+
352
+ // Shipping details deep validation
353
+ if (offer.shippingDetails) {
354
+ const shipping = Array.isArray(offer.shippingDetails) ? offer.shippingDetails[0] : offer.shippingDetails;
355
+
356
+ if (!shipping.shippingRate && !shipping.freeShippingThreshold) {
357
+ issues.push({
358
+ severity: 'warn',
359
+ code: 'SHIPPING_NO_RATE',
360
+ category: 'shipping_quality',
361
+ message: `Product "${productName}" shippingDetails missing shippingRate`,
362
+ hint: 'AI agents need shipping costs to complete purchases'
363
+ });
364
+ }
365
+
366
+ if (!shipping.deliveryTime) {
367
+ issues.push({
368
+ severity: 'warn',
369
+ code: 'SHIPPING_NO_DELIVERY_TIME',
370
+ category: 'shipping_quality',
371
+ message: `Product "${productName}" shippingDetails missing deliveryTime`,
372
+ hint: 'Delivery estimates help AI agents make purchase decisions'
373
+ });
374
+ }
375
+
376
+ if (!shipping.shippingDestination) {
377
+ recommendations.push({
378
+ field: 'shippingDestination',
379
+ message: `Add shippingDestination to clarify where you ship`,
380
+ priority: 'medium'
381
+ });
382
+ }
383
+ }
384
+
385
+ // Return policy deep validation
386
+ if (offer.hasMerchantReturnPolicy) {
387
+ const policy = offer.hasMerchantReturnPolicy;
388
+
389
+ if (!policy.returnPolicyCategory) {
390
+ issues.push({
391
+ severity: 'warn',
392
+ code: 'RETURN_NO_CATEGORY',
393
+ category: 'return_policy',
394
+ message: `Return policy missing returnPolicyCategory`,
395
+ hint: 'Use MerchantReturnFiniteReturnWindow, MerchantReturnNotPermitted, etc.'
396
+ });
397
+ }
398
+
399
+ if (!policy.merchantReturnDays && policy.returnPolicyCategory?.includes('FiniteReturnWindow')) {
400
+ issues.push({
401
+ severity: 'warn',
402
+ code: 'RETURN_NO_DAYS',
403
+ category: 'return_policy',
404
+ message: `Return policy missing merchantReturnDays`,
405
+ hint: 'Specify how many days customers have to return'
406
+ });
407
+ }
408
+
409
+ if (!policy.returnFees) {
410
+ recommendations.push({
411
+ field: 'returnFees',
412
+ message: `Add returnFees to clarify who pays return shipping`,
413
+ priority: 'medium'
414
+ });
415
+ }
416
+ }
417
+ });
418
+ }
419
+
420
+ totalCompleteness += (productScore / maxScore) * 100;
421
+ });
422
+
423
+ const avgCompleteness = products.length > 0 ? Math.round(totalCompleteness / products.length) : 0;
424
+
425
+ return {
426
+ issues,
427
+ recommendations: [...new Map(recommendations.map(r => [r.field, r])).values()], // Dedupe
428
+ completeness: avgCompleteness
429
+ };
430
+ }
431
+
432
+ function validateSchema(schemas) {
433
+ const issues = [];
434
+ const recommendations = [];
435
+
436
+ // Check for Organization/WebSite (basic presence)
437
+ const orgs = findInSchema(schemas, 'Organization');
438
+ const websites = findInSchema(schemas, 'WebSite');
439
+ const products = findInSchema(schemas, 'Product');
440
+ const breadcrumbs = findInSchema(schemas, 'BreadcrumbList');
441
+
442
+ if (orgs.length === 0 && websites.length === 0) {
443
+ issues.push({
444
+ severity: 'warn',
445
+ code: 'SCHEMA_NO_ORG',
446
+ category: 'schema',
447
+ message: 'No Organization or WebSite schema found',
448
+ hint: 'Add Organization schema for better AI recognition'
449
+ });
450
+ }
451
+
452
+ // Check Organization completeness
453
+ if (orgs.length > 0) {
454
+ const org = orgs[0];
455
+ if (!org.name) {
456
+ issues.push({
457
+ severity: 'warn',
458
+ code: 'ORG_NO_NAME',
459
+ category: 'schema',
460
+ message: 'Organization schema missing name'
461
+ });
462
+ }
463
+ if (!org.logo) {
464
+ recommendations.push({
465
+ field: 'logo',
466
+ message: 'Add logo to Organization schema',
467
+ priority: 'medium'
468
+ });
469
+ }
470
+ if (!org.contactPoint && !org.email && !org.telephone) {
471
+ recommendations.push({
472
+ field: 'contactPoint',
473
+ message: 'Add contact information to Organization',
474
+ priority: 'low'
475
+ });
476
+ }
477
+ }
478
+
479
+ // Check for MerchantReturnPolicy (Jan 2026 requirement)
480
+ const returnPolicies = findInSchema(schemas, 'MerchantReturnPolicy');
481
+ const hasReturnPolicyInOffer = products.some(p =>
482
+ p.offers?.hasMerchantReturnPolicy ||
483
+ (Array.isArray(p.offers) && p.offers.some(o => o.hasMerchantReturnPolicy))
484
+ );
485
+
486
+ if (returnPolicies.length === 0 && !hasReturnPolicyInOffer) {
487
+ issues.push({
488
+ severity: 'error',
489
+ code: 'SCHEMA_NO_RETURN_POLICY',
490
+ category: 'schema',
491
+ message: 'Missing MerchantReturnPolicy schema (required Jan 2026)',
492
+ hint: 'Add hasMerchantReturnPolicy to Product offers for AI commerce eligibility'
493
+ });
494
+ } else {
495
+ const policies = returnPolicies.length > 0 ? returnPolicies :
496
+ products.flatMap(p => {
497
+ if (p.offers?.hasMerchantReturnPolicy) return [p.offers.hasMerchantReturnPolicy];
498
+ if (Array.isArray(p.offers)) return p.offers.map(o => o.hasMerchantReturnPolicy).filter(Boolean);
499
+ return [];
500
+ });
501
+
502
+ const uniquePolicies = new Set();
503
+ policies.forEach((policy) => {
504
+ const key = JSON.stringify(policy);
505
+ if (uniquePolicies.has(key)) return;
506
+ uniquePolicies.add(key);
507
+
508
+ if (!policy.applicableCountry) {
509
+ issues.push({
510
+ severity: 'warn',
511
+ code: 'SCHEMA_RETURN_NO_COUNTRY',
512
+ category: 'schema',
513
+ message: 'MerchantReturnPolicy missing applicableCountry',
514
+ hint: 'Add ISO 3166-1 alpha-2 country code (e.g., "US")'
515
+ });
516
+ }
517
+ if (!policy.returnPolicyCategory) {
518
+ issues.push({
519
+ severity: 'warn',
520
+ code: 'SCHEMA_RETURN_NO_CATEGORY',
521
+ category: 'schema',
522
+ message: 'MerchantReturnPolicy missing returnPolicyCategory',
523
+ hint: 'Use schema.org/MerchantReturnFiniteReturnWindow or similar'
524
+ });
525
+ }
526
+ });
527
+ }
528
+
529
+ // Check for shippingDetails (Jan 2026 requirement)
530
+ const hasShippingDetails = products.some(p =>
531
+ p.offers?.shippingDetails ||
532
+ (Array.isArray(p.offers) && p.offers.some(o => o.shippingDetails))
533
+ );
534
+ const shippingSpecs = findInSchema(schemas, 'OfferShippingDetails');
535
+
536
+ if (!hasShippingDetails && shippingSpecs.length === 0) {
537
+ issues.push({
538
+ severity: 'error',
539
+ code: 'SCHEMA_NO_SHIPPING',
540
+ category: 'schema',
541
+ message: 'Missing shippingDetails schema (required Jan 2026)',
542
+ hint: 'Add shippingDetails to Product offers for AI commerce eligibility'
543
+ });
544
+ }
545
+
546
+ // Product quality validation
547
+ let productQuality = { issues: [], recommendations: [], completeness: 0 };
548
+ if (products.length > 0) {
549
+ productQuality = validateProductQuality(products);
550
+ issues.push(...productQuality.issues);
551
+ recommendations.push(...productQuality.recommendations);
552
+ }
553
+
554
+ // Breadcrumb check for navigation
555
+ if (breadcrumbs.length === 0 && products.length > 0) {
556
+ recommendations.push({
557
+ field: 'BreadcrumbList',
558
+ message: 'Add BreadcrumbList schema for better navigation context',
559
+ priority: 'low'
560
+ });
561
+ }
562
+
563
+ return {
564
+ issues,
565
+ recommendations,
566
+ stats: {
567
+ orgs: orgs.length,
568
+ products: products.length,
569
+ returnPolicies: returnPolicies.length,
570
+ productCompleteness: productQuality.completeness
571
+ }
572
+ };
573
+ }
574
+
575
+ function validateProfile(profile) {
576
+ const issues = [];
577
+
578
+ if (!profile || typeof profile !== 'object') {
579
+ issues.push({ severity: 'error', code: 'UCP_MISSING_ROOT', category: 'ucp', path: '$', message: 'Profile must be a JSON object' });
580
+ return issues;
581
+ }
582
+
583
+ if (!profile.ucp || typeof profile.ucp !== 'object') {
584
+ issues.push({ severity: 'error', code: 'UCP_MISSING_ROOT', category: 'ucp', path: '$.ucp', message: 'Missing required "ucp" object' });
585
+ return issues;
586
+ }
587
+
588
+ const ucp = profile.ucp;
589
+
590
+ // Version validation
591
+ if (!ucp.version) {
592
+ issues.push({ severity: 'error', code: 'UCP_MISSING_VERSION', category: 'ucp', path: '$.ucp.version', message: 'Missing version field' });
593
+ } else if (!VERSION_REGEX.test(ucp.version)) {
594
+ issues.push({ severity: 'error', code: 'UCP_INVALID_VERSION', category: 'ucp', path: '$.ucp.version', message: `Invalid version: ${ucp.version}`, hint: 'Use YYYY-MM-DD format' });
595
+ }
596
+
597
+ // Services validation
598
+ if (!ucp.services || typeof ucp.services !== 'object') {
599
+ issues.push({ severity: 'error', code: 'UCP_MISSING_SERVICES', category: 'ucp', path: '$.ucp.services', message: 'Missing services' });
600
+ } else {
601
+ for (const [name, svc] of Object.entries(ucp.services)) {
602
+ if (!svc.version) issues.push({ severity: 'error', code: 'UCP_INVALID_SERVICE', category: 'ucp', path: `$.ucp.services["${name}"].version`, message: `Service "${name}" missing version` });
603
+ if (!svc.spec) issues.push({ severity: 'error', code: 'UCP_INVALID_SERVICE', category: 'ucp', path: `$.ucp.services["${name}"].spec`, message: `Service "${name}" missing spec` });
604
+ if (!svc.rest && !svc.mcp && !svc.a2a && !svc.embedded) {
605
+ issues.push({ severity: 'warn', code: 'UCP_NO_TRANSPORT', category: 'ucp', path: `$.ucp.services["${name}"]`, message: `Service "${name}" has no transport bindings` });
606
+ }
607
+ if (svc.rest?.endpoint) {
608
+ if (!svc.rest.endpoint.startsWith('https://')) {
609
+ issues.push({ severity: 'error', code: 'UCP_ENDPOINT_NOT_HTTPS', category: 'ucp', path: `$.ucp.services["${name}"].rest.endpoint`, message: 'Endpoint must use HTTPS' });
610
+ }
611
+ if (svc.rest.endpoint.endsWith('/')) {
612
+ issues.push({ severity: 'warn', code: 'UCP_TRAILING_SLASH', category: 'ucp', path: `$.ucp.services["${name}"].rest.endpoint`, message: 'Remove trailing slash' });
613
+ }
614
+ }
615
+ }
616
+ }
617
+
618
+ // Capabilities validation
619
+ if (!ucp.capabilities || !Array.isArray(ucp.capabilities)) {
620
+ issues.push({ severity: 'error', code: 'UCP_MISSING_CAPABILITIES', category: 'ucp', path: '$.ucp.capabilities', message: 'Missing capabilities array' });
621
+ } else {
622
+ const capNames = new Set(ucp.capabilities.map(c => c.name));
623
+ ucp.capabilities.forEach((cap, i) => {
624
+ const path = `$.ucp.capabilities[${i}]`;
625
+ if (!cap.name) issues.push({ severity: 'error', code: 'UCP_INVALID_CAP', category: 'ucp', path: `${path}.name`, message: 'Missing name' });
626
+ if (!cap.version) issues.push({ severity: 'error', code: 'UCP_INVALID_CAP', category: 'ucp', path: `${path}.version`, message: 'Missing version' });
627
+ if (!cap.spec) issues.push({ severity: 'error', code: 'UCP_INVALID_CAP', category: 'ucp', path: `${path}.spec`, message: 'Missing spec' });
628
+ if (!cap.schema) issues.push({ severity: 'error', code: 'UCP_INVALID_CAP', category: 'ucp', path: `${path}.schema`, message: 'Missing schema' });
629
+
630
+ // Namespace checks
631
+ if (cap.name?.startsWith('dev.ucp.')) {
632
+ if (cap.spec && !cap.spec.startsWith('https://ucp.dev/')) {
633
+ issues.push({ severity: 'error', code: 'UCP_NS_MISMATCH', category: 'ucp', path: `${path}.spec`, message: 'dev.ucp.* spec must be on ucp.dev' });
634
+ }
635
+ if (cap.schema && !cap.schema.startsWith('https://ucp.dev/')) {
636
+ issues.push({ severity: 'error', code: 'UCP_NS_MISMATCH', category: 'ucp', path: `${path}.schema`, message: 'dev.ucp.* schema must be on ucp.dev' });
637
+ }
638
+ }
639
+
640
+ // Extension checks
641
+ if (cap.extends && !capNames.has(cap.extends)) {
642
+ issues.push({ severity: 'error', code: 'UCP_ORPHAN_EXT', category: 'ucp', path: `${path}.extends`, message: `Parent "${cap.extends}" not found` });
643
+ }
644
+ });
645
+
646
+ // Signing keys check
647
+ const hasOrder = ucp.capabilities.some(c => c.name === 'dev.ucp.shopping.order');
648
+ if (hasOrder && (!profile.signing_keys || profile.signing_keys.length === 0)) {
649
+ issues.push({ severity: 'error', code: 'UCP_MISSING_KEYS', category: 'ucp', path: '$.signing_keys', message: 'Order requires signing_keys' });
650
+ }
651
+ }
652
+
653
+ return issues;
654
+ }
655
+
656
+ /**
657
+ * Generate actionable lint suggestions with code snippets, severity, and doc links
658
+ */
659
+ function generateLintSuggestions(ucpIssues, schemaIssues, hasUcp, profile, schemaStats) {
660
+ const suggestions = [];
661
+
662
+ // Issue code to suggestion mapping
663
+ const suggestionMap = {
664
+ // Critical - Blocks AI agent functionality
665
+ 'UCP_FETCH_FAILED': {
666
+ severity: 'critical',
667
+ title: 'Create a UCP Profile',
668
+ impact: 'AI shopping agents cannot discover your store without a UCP profile',
669
+ fix: 'Create a file at /.well-known/ucp with your store configuration',
670
+ codeSnippet: `{
671
+ "ucp": {
672
+ "version": "2026-05-01",
673
+ "services": {
674
+ "dev.ucp.shopping": {
675
+ "version": "1.0.0",
676
+ "spec": "https://ucp.dev/specs/shopping/1.0",
677
+ "rest": {
678
+ "schema": "https://yourstore.com/api/openapi.json",
679
+ "endpoint": "https://yourstore.com/api/v1"
680
+ }
681
+ }
682
+ },
683
+ "capabilities": [
684
+ {
685
+ "name": "dev.ucp.shopping.catalog",
686
+ "version": "1.0.0",
687
+ "spec": "https://ucp.dev/caps/catalog/1.0",
688
+ "schema": "https://ucp.dev/caps/catalog/1.0/schema.json"
689
+ }
690
+ ]
691
+ }
692
+ }`,
693
+ docLink: 'https://ucp.dev/docs/getting-started',
694
+ generatorLink: '/generate',
695
+ },
696
+ 'UCP_MISSING_ROOT': {
697
+ severity: 'critical',
698
+ title: 'Add "ucp" Root Object',
699
+ impact: 'Profile cannot be parsed without the required root structure',
700
+ fix: 'Wrap your configuration in a "ucp" object',
701
+ codeSnippet: `{
702
+ "ucp": {
703
+ "version": "2026-05-01",
704
+ "services": { ... },
705
+ "capabilities": [ ... ]
706
+ }
707
+ }`,
708
+ docLink: 'https://ucp.dev/docs/profile-structure',
709
+ },
710
+ 'UCP_MISSING_VERSION': {
711
+ severity: 'critical',
712
+ title: 'Add UCP Version',
713
+ impact: 'Agents cannot determine compatibility without a version',
714
+ fix: 'Add a version field in YYYY-MM-DD format',
715
+ codeSnippet: `"version": "2026-05-01"`,
716
+ docLink: 'https://ucp.dev/docs/versioning',
717
+ },
718
+ 'UCP_INVALID_VERSION': {
719
+ severity: 'critical',
720
+ title: 'Fix Version Format',
721
+ impact: 'Invalid version format will cause parsing errors',
722
+ fix: 'Use YYYY-MM-DD format (e.g., 2026-05-01)',
723
+ codeSnippet: `"version": "2026-05-01"`,
724
+ docLink: 'https://ucp.dev/docs/versioning',
725
+ },
726
+ 'UCP_MISSING_SERVICES': {
727
+ severity: 'critical',
728
+ title: 'Add Services Configuration',
729
+ impact: 'No services means AI agents have nothing to interact with',
730
+ fix: 'Add at least one service (shopping service recommended)',
731
+ codeSnippet: `"services": {
732
+ "dev.ucp.shopping": {
733
+ "version": "1.0.0",
734
+ "spec": "https://ucp.dev/specs/shopping/1.0",
735
+ "rest": {
736
+ "schema": "https://yourstore.com/api/openapi.json",
737
+ "endpoint": "https://yourstore.com/api/v1"
738
+ }
739
+ }
740
+ }`,
741
+ docLink: 'https://ucp.dev/docs/services',
742
+ },
743
+ 'UCP_MISSING_CAPABILITIES': {
744
+ severity: 'critical',
745
+ title: 'Add Capabilities Array',
746
+ impact: 'Without capabilities, agents cannot perform any actions',
747
+ fix: 'Add at least catalog capability for product discovery',
748
+ codeSnippet: `"capabilities": [
749
+ {
750
+ "name": "dev.ucp.shopping.catalog",
751
+ "version": "1.0.0",
752
+ "spec": "https://ucp.dev/caps/catalog/1.0",
753
+ "schema": "https://ucp.dev/caps/catalog/1.0/schema.json"
754
+ }
755
+ ]`,
756
+ docLink: 'https://ucp.dev/docs/capabilities',
757
+ },
758
+ 'UCP_MISSING_KEYS': {
759
+ severity: 'critical',
760
+ title: 'Add Signing Keys for Order Capability',
761
+ impact: 'Order transactions cannot be verified without signing keys',
762
+ fix: 'Generate Ed25519 keypair and add public key to profile',
763
+ codeSnippet: `"signing_keys": [
764
+ {
765
+ "id": "key-1",
766
+ "type": "Ed25519",
767
+ "public_key": "YOUR_BASE64_PUBLIC_KEY",
768
+ "created_at": "2026-01-01T00:00:00Z"
769
+ }
770
+ ]`,
771
+ docLink: 'https://ucp.dev/docs/signing-keys',
772
+ },
773
+ 'UCP_ENDPOINT_NOT_HTTPS': {
774
+ severity: 'critical',
775
+ title: 'Use HTTPS for Endpoints',
776
+ impact: 'HTTP endpoints are insecure and rejected by AI agents',
777
+ fix: 'Change endpoint URLs to use https://',
778
+ codeSnippet: `"endpoint": "https://yourstore.com/api/v1"`,
779
+ docLink: 'https://ucp.dev/docs/security',
780
+ },
781
+ 'UCP_NS_MISMATCH': {
782
+ severity: 'critical',
783
+ title: 'Fix Namespace Origin',
784
+ impact: 'Spec/schema URLs must match the capability namespace',
785
+ fix: 'dev.ucp.* capabilities must use ucp.dev URLs',
786
+ codeSnippet: `{
787
+ "name": "dev.ucp.shopping.catalog",
788
+ "spec": "https://ucp.dev/caps/catalog/1.0",
789
+ "schema": "https://ucp.dev/caps/catalog/1.0/schema.json"
790
+ }`,
791
+ docLink: 'https://ucp.dev/docs/namespaces',
792
+ },
793
+
794
+ // Schema critical errors
795
+ 'SCHEMA_NO_RETURN_POLICY': {
796
+ severity: 'critical',
797
+ title: 'Add MerchantReturnPolicy Schema',
798
+ impact: 'Required for AI commerce eligibility (Jan 2026 deadline)',
799
+ fix: 'Add MerchantReturnPolicy to your product offers',
800
+ codeSnippet: `{
801
+ "@type": "Product",
802
+ "offers": {
803
+ "@type": "Offer",
804
+ "hasMerchantReturnPolicy": {
805
+ "@type": "MerchantReturnPolicy",
806
+ "applicableCountry": "US",
807
+ "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
808
+ "merchantReturnDays": 30,
809
+ "returnFees": "https://schema.org/FreeReturn"
810
+ }
811
+ }
812
+ }`,
813
+ docLink: 'https://schema.org/MerchantReturnPolicy',
814
+ generatorLink: '/generate?tab=schema',
815
+ },
816
+ 'SCHEMA_NO_SHIPPING': {
817
+ severity: 'critical',
818
+ title: 'Add OfferShippingDetails Schema',
819
+ impact: 'Required for AI commerce eligibility (Jan 2026 deadline)',
820
+ fix: 'Add shippingDetails to your product offers',
821
+ codeSnippet: `{
822
+ "@type": "Product",
823
+ "offers": {
824
+ "@type": "Offer",
825
+ "shippingDetails": {
826
+ "@type": "OfferShippingDetails",
827
+ "shippingRate": {
828
+ "@type": "MonetaryAmount",
829
+ "value": "5.99",
830
+ "currency": "USD"
831
+ },
832
+ "deliveryTime": {
833
+ "@type": "ShippingDeliveryTime",
834
+ "handlingTime": {
835
+ "@type": "QuantitativeValue",
836
+ "minValue": 1,
837
+ "maxValue": 2,
838
+ "unitCode": "d"
839
+ },
840
+ "transitTime": {
841
+ "@type": "QuantitativeValue",
842
+ "minValue": 3,
843
+ "maxValue": 5,
844
+ "unitCode": "d"
845
+ }
846
+ },
847
+ "shippingDestination": {
848
+ "@type": "DefinedRegion",
849
+ "addressCountry": "US"
850
+ }
851
+ }
852
+ }
853
+ }`,
854
+ docLink: 'https://schema.org/OfferShippingDetails',
855
+ generatorLink: '/generate?tab=schema',
856
+ },
857
+
858
+ // Warnings - Reduces AI readiness score
859
+ 'UCP_NO_TRANSPORT': {
860
+ severity: 'warning',
861
+ title: 'Add Transport Binding',
862
+ impact: 'Service has no way for agents to communicate with it',
863
+ fix: 'Add REST, MCP, or A2A transport binding',
864
+ codeSnippet: `"rest": {
865
+ "schema": "https://yourstore.com/api/openapi.json",
866
+ "endpoint": "https://yourstore.com/api/v1"
867
+ }`,
868
+ docLink: 'https://ucp.dev/docs/transports',
869
+ },
870
+ 'UCP_TRAILING_SLASH': {
871
+ severity: 'warning',
872
+ title: 'Remove Trailing Slash from Endpoint',
873
+ impact: 'May cause URL concatenation issues',
874
+ fix: 'Remove the trailing / from endpoint URLs',
875
+ docLink: 'https://ucp.dev/docs/endpoints',
876
+ },
877
+ 'UCP_ORPHAN_EXT': {
878
+ severity: 'warning',
879
+ title: 'Fix Orphaned Extension',
880
+ impact: 'Capability extends a parent that does not exist',
881
+ fix: 'Add the parent capability or remove the extends field',
882
+ docLink: 'https://ucp.dev/docs/extensions',
883
+ },
884
+ 'SCHEMA_NO_ORG': {
885
+ severity: 'warning',
886
+ title: 'Add Organization Schema',
887
+ impact: 'AI agents may not recognize your business identity',
888
+ fix: 'Add Organization or WebSite schema to your pages',
889
+ codeSnippet: `{
890
+ "@type": "Organization",
891
+ "name": "Your Store Name",
892
+ "url": "https://yourstore.com",
893
+ "logo": "https://yourstore.com/logo.png",
894
+ "contactPoint": {
895
+ "@type": "ContactPoint",
896
+ "contactType": "customer service",
897
+ "email": "support@yourstore.com"
898
+ }
899
+ }`,
900
+ docLink: 'https://schema.org/Organization',
901
+ },
902
+ 'ORG_NO_NAME': {
903
+ severity: 'warning',
904
+ title: 'Add Organization Name',
905
+ impact: 'Organization schema is incomplete without a name',
906
+ fix: 'Add name property to Organization schema',
907
+ codeSnippet: `"name": "Your Store Name"`,
908
+ docLink: 'https://schema.org/Organization',
909
+ },
910
+ 'SCHEMA_RETURN_NO_COUNTRY': {
911
+ severity: 'warning',
912
+ title: 'Add Country to Return Policy',
913
+ impact: 'Return policy scope is unclear without country',
914
+ fix: 'Add ISO 3166-1 alpha-2 country code',
915
+ codeSnippet: `"applicableCountry": "US"`,
916
+ docLink: 'https://schema.org/MerchantReturnPolicy',
917
+ },
918
+ 'SCHEMA_RETURN_NO_CATEGORY': {
919
+ severity: 'warning',
920
+ title: 'Add Return Policy Category',
921
+ impact: 'Policy type unclear to AI agents',
922
+ fix: 'Specify the return window type',
923
+ codeSnippet: `"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow"`,
924
+ docLink: 'https://schema.org/MerchantReturnPolicy',
925
+ },
926
+
927
+ // Product quality issues
928
+ 'PRODUCT_MISSING_NAME': {
929
+ severity: 'critical',
930
+ title: 'Add Product Name',
931
+ impact: 'Products cannot be identified without names',
932
+ fix: 'Add name property to Product schema',
933
+ codeSnippet: `"name": "Product Name"`,
934
+ docLink: 'https://schema.org/Product',
935
+ },
936
+ 'PRODUCT_MISSING_OFFERS': {
937
+ severity: 'critical',
938
+ title: 'Add Product Offers',
939
+ impact: 'No pricing = no purchases',
940
+ fix: 'Add offers with price and currency',
941
+ codeSnippet: `"offers": {
942
+ "@type": "Offer",
943
+ "price": "29.99",
944
+ "priceCurrency": "USD",
945
+ "availability": "https://schema.org/InStock"
946
+ }`,
947
+ docLink: 'https://schema.org/Offer',
948
+ },
949
+ 'PRODUCT_NO_DESCRIPTION': {
950
+ severity: 'warning',
951
+ title: 'Add Product Description',
952
+ impact: 'AI agents may hallucinate product details',
953
+ fix: 'Add detailed description (150-300 chars recommended)',
954
+ codeSnippet: `"description": "Detailed product description that helps AI agents understand what this product is and its key features."`,
955
+ docLink: 'https://schema.org/Product',
956
+ },
957
+ 'PRODUCT_SHORT_DESCRIPTION': {
958
+ severity: 'warning',
959
+ title: 'Expand Product Description',
960
+ impact: 'Short descriptions may cause AI hallucinations',
961
+ fix: 'Aim for 150-300 characters with key details',
962
+ docLink: 'https://schema.org/Product',
963
+ },
964
+ 'PRODUCT_NO_IMAGE': {
965
+ severity: 'warning',
966
+ title: 'Add Product Image',
967
+ impact: 'Visual context helps AI product matching',
968
+ fix: 'Add high-quality product image URL',
969
+ codeSnippet: `"image": "https://yourstore.com/images/product.jpg"`,
970
+ docLink: 'https://schema.org/Product',
971
+ },
972
+ };
973
+
974
+ // Process all issues
975
+ const allIssues = [...ucpIssues, ...schemaIssues];
976
+ const processedCodes = new Set();
977
+
978
+ allIssues.forEach(issue => {
979
+ // Handle dynamic codes
980
+ let mappedCode = issue.code;
981
+ if (issue.code?.startsWith('PRODUCT_MISSING_')) {
982
+ const field = issue.code.replace('PRODUCT_MISSING_', '');
983
+ if (field === 'NAME' || field === 'OFFERS') {
984
+ mappedCode = issue.code;
985
+ }
986
+ }
987
+
988
+ const template = suggestionMap[mappedCode];
989
+ if (template && !processedCodes.has(mappedCode)) {
990
+ processedCodes.add(mappedCode);
991
+ suggestions.push({
992
+ severity: template.severity,
993
+ title: template.title,
994
+ code: issue.code,
995
+ path: issue.path,
996
+ impact: template.impact,
997
+ fix: template.fix,
998
+ codeSnippet: template.codeSnippet,
999
+ docLink: template.docLink,
1000
+ generatorLink: template.generatorLink,
1001
+ });
1002
+ }
1003
+ });
1004
+
1005
+ // Add contextual suggestions based on missing features
1006
+ if (!hasUcp) {
1007
+ // Already covered by UCP_FETCH_FAILED
1008
+ } else {
1009
+ // Check for missing recommended capabilities
1010
+ const capabilities = profile?.ucp?.capabilities?.map(c => c.name) || [];
1011
+
1012
+ if (!capabilities.includes('dev.ucp.shopping.checkout') && capabilities.length > 0) {
1013
+ suggestions.push({
1014
+ severity: 'info',
1015
+ title: 'Consider Adding Checkout Capability',
1016
+ code: 'SUGGESTION_ADD_CHECKOUT',
1017
+ impact: 'Enables AI agents to complete purchases on your site',
1018
+ fix: 'Add checkout capability to support full purchase flow',
1019
+ codeSnippet: `{
1020
+ "name": "dev.ucp.shopping.checkout",
1021
+ "version": "1.0.0",
1022
+ "spec": "https://ucp.dev/caps/checkout/1.0",
1023
+ "schema": "https://ucp.dev/caps/checkout/1.0/schema.json"
1024
+ }`,
1025
+ docLink: 'https://ucp.dev/docs/capabilities#checkout',
1026
+ generatorLink: '/generate',
1027
+ });
1028
+ }
1029
+ }
1030
+
1031
+ // Schema recommendations
1032
+ if (schemaStats.products === 0 && hasUcp) {
1033
+ suggestions.push({
1034
+ severity: 'info',
1035
+ title: 'Add Product Schema for Better AI Discovery',
1036
+ code: 'SUGGESTION_ADD_PRODUCTS',
1037
+ impact: 'Product schemas help AI agents understand your catalog',
1038
+ fix: 'Add JSON-LD Product schema to product pages',
1039
+ codeSnippet: `<script type="application/ld+json">
1040
+ {
1041
+ "@context": "https://schema.org",
1042
+ "@type": "Product",
1043
+ "name": "Product Name",
1044
+ "description": "Product description...",
1045
+ "image": "https://yourstore.com/product.jpg",
1046
+ "offers": {
1047
+ "@type": "Offer",
1048
+ "price": "29.99",
1049
+ "priceCurrency": "USD"
1050
+ }
1051
+ }
1052
+ </script>`,
1053
+ docLink: 'https://schema.org/Product',
1054
+ generatorLink: '/generate?tab=schema',
1055
+ });
1056
+ }
1057
+
1058
+ // Sort by severity
1059
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
1060
+ suggestions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
1061
+
1062
+ return suggestions;
1063
+ }
1064
+
1065
+ function calculateReadinessScore(ucpIssues, schemaIssues, hasUcp, productCompleteness) {
1066
+ // Scoring model: UCP is the PRIMARY requirement for AI commerce readiness
1067
+ // Sites without UCP are capped at 40 points max (Grade F)
1068
+ // Sites with UCP start at a higher baseline and can reach 100
1069
+
1070
+ if (!hasUcp) {
1071
+ // NO UCP PATH: Maximum possible score is 40 (always Grade F)
1072
+ // This reflects that without UCP, AI agents cannot complete purchases
1073
+ let score = 40; // Start at max possible without UCP
1074
+
1075
+ // Schema quality can improve score within the 0-40 range
1076
+ const schemaErrors = schemaIssues.filter(i => i.severity === 'error' && i.category === 'schema').length;
1077
+ const schemaWarnings = schemaIssues.filter(i => i.severity === 'warn' && i.category === 'schema').length;
1078
+ score -= Math.min(25, schemaErrors * 8 + schemaWarnings * 3);
1079
+
1080
+ // Product quality
1081
+ const productErrors = schemaIssues.filter(i => i.severity === 'error' && (i.category === 'product_quality' || i.category === 'content_quality')).length;
1082
+ const productWarnings = schemaIssues.filter(i => i.severity === 'warn' && (i.category === 'product_quality' || i.category === 'content_quality' || i.category === 'shipping_quality')).length;
1083
+ score -= Math.min(15, productErrors * 5 + productWarnings * 2);
1084
+
1085
+ return Math.max(0, Math.round(score));
1086
+ }
1087
+
1088
+ // HAS UCP PATH: Start at 100, minimum floor of 45 (ensures UCP sites always score higher than non-UCP)
1089
+ let score = 100;
1090
+
1091
+ // UCP quality (35 points max deduction)
1092
+ const ucpErrors = ucpIssues.filter(i => i.severity === 'error').length;
1093
+ const ucpWarnings = ucpIssues.filter(i => i.severity === 'warn').length;
1094
+ score -= Math.min(35, ucpErrors * 12 + ucpWarnings * 4);
1095
+
1096
+ // Schema section (30 points max deduction)
1097
+ const schemaErrors = schemaIssues.filter(i => i.severity === 'error' && i.category === 'schema').length;
1098
+ const schemaWarnings = schemaIssues.filter(i => i.severity === 'warn' && i.category === 'schema').length;
1099
+ score -= Math.min(30, schemaErrors * 10 + schemaWarnings * 3);
1100
+
1101
+ // Product quality section (20 points max deduction)
1102
+ const productErrors = schemaIssues.filter(i => i.severity === 'error' && (i.category === 'product_quality' || i.category === 'content_quality')).length;
1103
+ const productWarnings = schemaIssues.filter(i => i.severity === 'warn' && (i.category === 'product_quality' || i.category === 'content_quality' || i.category === 'shipping_quality')).length;
1104
+ score -= Math.min(20, productErrors * 6 + productWarnings * 2);
1105
+
1106
+ // Bonus for high product completeness
1107
+ if (productCompleteness >= 80) {
1108
+ score = Math.min(100, score + 5);
1109
+ }
1110
+
1111
+ // Floor: UCP sites never score below 45 (always higher than max non-UCP of 40)
1112
+ return Math.max(45, Math.round(score));
1113
+ }
1114
+
1115
+ function getGrade(score) {
1116
+ if (score >= 90) return 'A';
1117
+ if (score >= 80) return 'B';
1118
+ if (score >= 70) return 'C';
1119
+ if (score >= 60) return 'D';
1120
+ return 'F';
1121
+ }
1122
+
1123
+ function getReadinessLevel(score, hasUcp, schemaIssues) {
1124
+ const hasCriticalSchema = schemaIssues.some(i =>
1125
+ i.code === 'SCHEMA_NO_RETURN_POLICY' || i.code === 'SCHEMA_NO_SHIPPING'
1126
+ );
1127
+
1128
+ // Without UCP, site is never "ready" - max level is "limited"
1129
+ if (!hasUcp) {
1130
+ if (score >= 30) {
1131
+ return { level: 'limited', label: 'Limited Readiness (No UCP)', color: '#EA580C' };
1132
+ }
1133
+ return { level: 'not_ready', label: 'Not Ready', color: '#DC2626' };
1134
+ }
1135
+
1136
+ // With UCP, can achieve full readiness
1137
+ if (score >= 90 && !hasCriticalSchema) {
1138
+ return { level: 'ready', label: 'AI Commerce Ready', color: '#16A34A' };
1139
+ }
1140
+ if (score >= 70) {
1141
+ return { level: 'partial', label: 'Partially Ready', color: '#CA8A04' };
1142
+ }
1143
+ if (score >= 50) {
1144
+ return { level: 'limited', label: 'Limited Readiness', color: '#EA580C' };
1145
+ }
1146
+ return { level: 'not_ready', label: 'Not Ready', color: '#DC2626' };
1147
+ }
1148
+
1149
+ export default async function handler(req, res) {
1150
+ // CORS headers
1151
+ res.setHeader('Access-Control-Allow-Origin', '*');
1152
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
1153
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1154
+
1155
+ if (req.method === 'OPTIONS') {
1156
+ return res.status(200).end();
1157
+ }
1158
+
1159
+ if (req.method !== 'POST') {
1160
+ return res.status(405).json({ error: 'Method not allowed' });
1161
+ }
1162
+
1163
+ const { domain } = req.body;
1164
+
1165
+ if (!domain) {
1166
+ return res.status(400).json({ error: 'Missing required field: domain' });
1167
+ }
1168
+
1169
+ const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/$/, '').split('/')[0];
1170
+
1171
+ // Fetch UCP profile and homepage in parallel
1172
+ const [ucpResult, homepageResult] = await Promise.all([
1173
+ fetchProfile(cleanDomain),
1174
+ fetchHomepage(cleanDomain)
1175
+ ]);
1176
+
1177
+ const { profile, profileUrl, error: ucpError } = ucpResult;
1178
+ const { html, error: htmlError } = homepageResult;
1179
+
1180
+ // Validate UCP profile
1181
+ let ucpIssues = [];
1182
+ let ucpVersion = null;
1183
+ let hasUcp = false;
1184
+
1185
+ if (ucpError || !profile) {
1186
+ ucpIssues.push({
1187
+ severity: 'error',
1188
+ code: 'UCP_FETCH_FAILED',
1189
+ category: 'ucp',
1190
+ path: '$.well-known/ucp',
1191
+ message: ucpError || 'Failed to fetch profile',
1192
+ hint: 'Create a UCP profile at /.well-known/ucp'
1193
+ });
1194
+ } else {
1195
+ hasUcp = true;
1196
+ ucpIssues = validateProfile(profile);
1197
+ ucpVersion = profile?.ucp?.version;
1198
+ }
1199
+
1200
+ // Validate Schema.org
1201
+ let schemaIssues = [];
1202
+ let schemaRecommendations = [];
1203
+ let schemaStats = { orgs: 0, products: 0, returnPolicies: 0, productCompleteness: 0 };
1204
+
1205
+ if (html) {
1206
+ const schemas = extractJsonLd(html);
1207
+ const schemaResult = validateSchema(schemas);
1208
+ schemaIssues = schemaResult.issues;
1209
+ schemaRecommendations = schemaResult.recommendations;
1210
+ schemaStats = schemaResult.stats;
1211
+ } else {
1212
+ schemaIssues.push({
1213
+ severity: 'warn',
1214
+ code: 'SCHEMA_FETCH_FAILED',
1215
+ category: 'schema',
1216
+ message: 'Could not fetch homepage to check schema',
1217
+ });
1218
+ }
1219
+
1220
+ // Combine all issues
1221
+ const allIssues = [...ucpIssues, ...schemaIssues];
1222
+
1223
+ // Calculate scores
1224
+ const readinessScore = calculateReadinessScore(ucpIssues, schemaIssues, hasUcp, schemaStats.productCompleteness);
1225
+ const grade = getGrade(readinessScore);
1226
+ const readiness = getReadinessLevel(readinessScore, hasUcp, schemaIssues);
1227
+
1228
+ // Record to benchmark and get percentile (non-blocking)
1229
+ const benchmark = await recordAndGetBenchmark(readinessScore);
1230
+
1231
+ // Generate actionable lint suggestions
1232
+ const lintSuggestions = generateLintSuggestions(ucpIssues, schemaIssues, hasUcp, profile, schemaStats);
1233
+
1234
+ // Separate UCP score (for backwards compatibility)
1235
+ const ucpErrors = ucpIssues.filter(i => i.severity === 'error').length;
1236
+ const ucpScore = hasUcp ? Math.max(0, 100 - ucpErrors * 20 - ucpIssues.filter(i => i.severity === 'warn').length * 5) : 0;
1237
+
1238
+ // Categorize issues for frontend
1239
+ const issuesByCategory = {
1240
+ ucp: ucpIssues,
1241
+ schema: schemaIssues.filter(i => i.category === 'schema'),
1242
+ product_quality: schemaIssues.filter(i => i.category === 'product_quality' || i.category === 'content_quality'),
1243
+ shipping: schemaIssues.filter(i => i.category === 'shipping_quality'),
1244
+ return_policy: schemaIssues.filter(i => i.category === 'return_policy'),
1245
+ };
1246
+
1247
+ return res.status(200).json({
1248
+ ok: ucpErrors === 0 && hasUcp,
1249
+ domain: cleanDomain,
1250
+ profile_url: profileUrl || `https://${cleanDomain}/.well-known/ucp`,
1251
+ ucp_version: ucpVersion,
1252
+
1253
+ // AI Readiness
1254
+ ai_readiness: {
1255
+ score: readinessScore,
1256
+ grade: grade,
1257
+ level: readiness.level,
1258
+ label: readiness.label,
1259
+ },
1260
+
1261
+ // Industry Benchmark
1262
+ benchmark: benchmark ? {
1263
+ percentile: benchmark.percentile,
1264
+ comparison: `Your site scores better than ${benchmark.percentile}% of sites analyzed`,
1265
+ total_sites_analyzed: benchmark.total_validations,
1266
+ average_score: benchmark.avg_score,
1267
+ } : null,
1268
+
1269
+ // SDK Validation Badge
1270
+ sdk_validation: {
1271
+ validated: true,
1272
+ sdk_version: '0.1.0',
1273
+ compliant: hasUcp && ucpErrors === 0,
1274
+ badge: hasUcp && ucpErrors === 0
1275
+ ? 'Validated using Official UCP SDK v0.1.0'
1276
+ : null,
1277
+ },
1278
+
1279
+ // UCP specific
1280
+ ucp: {
1281
+ found: hasUcp,
1282
+ score: ucpScore,
1283
+ issues: ucpIssues.map(i => ({
1284
+ severity: i.severity,
1285
+ code: i.code,
1286
+ message: i.message,
1287
+ hint: i.hint,
1288
+ })),
1289
+ },
1290
+
1291
+ // Schema.org
1292
+ schema: {
1293
+ checked: !!html,
1294
+ stats: schemaStats,
1295
+ issues: schemaIssues.filter(i => i.category === 'schema').map(i => ({
1296
+ severity: i.severity,
1297
+ code: i.code,
1298
+ message: i.message,
1299
+ hint: i.hint,
1300
+ })),
1301
+ },
1302
+
1303
+ // Product Quality (NEW)
1304
+ product_quality: {
1305
+ completeness: schemaStats.productCompleteness,
1306
+ issues: schemaIssues.filter(i => i.category === 'product_quality' || i.category === 'content_quality').map(i => ({
1307
+ severity: i.severity,
1308
+ code: i.code,
1309
+ message: i.message,
1310
+ hint: i.hint,
1311
+ })),
1312
+ recommendations: schemaRecommendations.slice(0, 10).map(r => ({
1313
+ field: r.field,
1314
+ message: r.message,
1315
+ priority: r.priority,
1316
+ })),
1317
+ },
1318
+
1319
+ // Shipping Quality (NEW)
1320
+ shipping: {
1321
+ issues: schemaIssues.filter(i => i.category === 'shipping_quality').map(i => ({
1322
+ severity: i.severity,
1323
+ code: i.code,
1324
+ message: i.message,
1325
+ hint: i.hint,
1326
+ })),
1327
+ },
1328
+
1329
+ // All issues combined (backwards compatible)
1330
+ issues: allIssues.map(i => ({
1331
+ severity: i.severity,
1332
+ code: i.code,
1333
+ message: i.message,
1334
+ hint: i.hint,
1335
+ category: i.category,
1336
+ })),
1337
+
1338
+ // Lint Suggestions (NEW - Issue #9)
1339
+ lint_suggestions: lintSuggestions.map(s => ({
1340
+ severity: s.severity,
1341
+ title: s.title,
1342
+ code: s.code,
1343
+ path: s.path,
1344
+ impact: s.impact,
1345
+ fix: s.fix,
1346
+ code_snippet: s.codeSnippet,
1347
+ doc_link: s.docLink,
1348
+ generator_link: s.generatorLink,
1349
+ })),
1350
+ });
1351
+ }