guardrail-ship 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 (119) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +7 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/mock-implementation.d.ts +1 -0
  6. package/dist/mock-implementation.d.ts.map +1 -0
  7. package/dist/mock-implementation.js +2 -0
  8. package/dist/mock-implementation.js.map +1 -0
  9. package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts +5 -0
  10. package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts.map +1 -0
  11. package/dist/mockproof/__tests__/import-graph-scanner.test.js +92 -0
  12. package/dist/mockproof/__tests__/import-graph-scanner.test.js.map +1 -0
  13. package/dist/mockproof/import-graph-scanner.d.ts +93 -0
  14. package/dist/mockproof/import-graph-scanner.d.ts.map +1 -0
  15. package/dist/mockproof/import-graph-scanner.js +411 -0
  16. package/dist/mockproof/import-graph-scanner.js.map +1 -0
  17. package/dist/mockproof/index.d.ts +10 -0
  18. package/dist/mockproof/index.d.ts.map +1 -0
  19. package/dist/mockproof/index.js +10 -0
  20. package/dist/mockproof/index.js.map +1 -0
  21. package/dist/reality-mode/auth-enforcer.d.ts +13 -0
  22. package/dist/reality-mode/auth-enforcer.d.ts.map +1 -0
  23. package/dist/reality-mode/auth-enforcer.js +90 -0
  24. package/dist/reality-mode/auth-enforcer.js.map +1 -0
  25. package/dist/reality-mode/explorer/critical-flows.d.ts +71 -0
  26. package/dist/reality-mode/explorer/critical-flows.d.ts.map +1 -0
  27. package/dist/reality-mode/explorer/critical-flows.js +463 -0
  28. package/dist/reality-mode/explorer/critical-flows.js.map +1 -0
  29. package/dist/reality-mode/explorer/flow-parser.d.ts +52 -0
  30. package/dist/reality-mode/explorer/flow-parser.d.ts.map +1 -0
  31. package/dist/reality-mode/explorer/flow-parser.js +250 -0
  32. package/dist/reality-mode/explorer/flow-parser.js.map +1 -0
  33. package/dist/reality-mode/explorer/index.d.ts +11 -0
  34. package/dist/reality-mode/explorer/index.d.ts.map +1 -0
  35. package/dist/reality-mode/explorer/index.js +11 -0
  36. package/dist/reality-mode/explorer/index.js.map +1 -0
  37. package/dist/reality-mode/explorer/runtime-explorer.d.ts +35 -0
  38. package/dist/reality-mode/explorer/runtime-explorer.d.ts.map +1 -0
  39. package/dist/reality-mode/explorer/runtime-explorer.js +688 -0
  40. package/dist/reality-mode/explorer/runtime-explorer.js.map +1 -0
  41. package/dist/reality-mode/explorer/surface-discovery.d.ts +60 -0
  42. package/dist/reality-mode/explorer/surface-discovery.d.ts.map +1 -0
  43. package/dist/reality-mode/explorer/surface-discovery.js +357 -0
  44. package/dist/reality-mode/explorer/surface-discovery.js.map +1 -0
  45. package/dist/reality-mode/explorer/types.d.ts +275 -0
  46. package/dist/reality-mode/explorer/types.d.ts.map +1 -0
  47. package/dist/reality-mode/explorer/types.js +8 -0
  48. package/dist/reality-mode/explorer/types.js.map +1 -0
  49. package/dist/reality-mode/fake-success-detector.d.ts +10 -0
  50. package/dist/reality-mode/fake-success-detector.d.ts.map +1 -0
  51. package/dist/reality-mode/fake-success-detector.js +76 -0
  52. package/dist/reality-mode/fake-success-detector.js.map +1 -0
  53. package/dist/reality-mode/index.d.ts +14 -0
  54. package/dist/reality-mode/index.d.ts.map +1 -0
  55. package/dist/reality-mode/index.js +14 -0
  56. package/dist/reality-mode/index.js.map +1 -0
  57. package/dist/reality-mode/reality-scanner.d.ts +48 -0
  58. package/dist/reality-mode/reality-scanner.d.ts.map +1 -0
  59. package/dist/reality-mode/reality-scanner.js +516 -0
  60. package/dist/reality-mode/reality-scanner.js.map +1 -0
  61. package/dist/reality-mode/report-generator.d.ts +11 -0
  62. package/dist/reality-mode/report-generator.d.ts.map +1 -0
  63. package/dist/reality-mode/report-generator.js +233 -0
  64. package/dist/reality-mode/report-generator.js.map +1 -0
  65. package/dist/reality-mode/traffic-classifier.d.ts +14 -0
  66. package/dist/reality-mode/traffic-classifier.d.ts.map +1 -0
  67. package/dist/reality-mode/traffic-classifier.js +131 -0
  68. package/dist/reality-mode/traffic-classifier.js.map +1 -0
  69. package/dist/reality-mode/types.d.ts +90 -0
  70. package/dist/reality-mode/types.d.ts.map +1 -0
  71. package/dist/reality-mode/types.js +2 -0
  72. package/dist/reality-mode/types.js.map +1 -0
  73. package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts +5 -0
  74. package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts.map +1 -0
  75. package/dist/ship-badge/__tests__/ship-badge-generator.test.js +146 -0
  76. package/dist/ship-badge/__tests__/ship-badge-generator.test.js.map +1 -0
  77. package/dist/ship-badge/index.d.ts +9 -0
  78. package/dist/ship-badge/index.d.ts.map +1 -0
  79. package/dist/ship-badge/index.js +9 -0
  80. package/dist/ship-badge/index.js.map +1 -0
  81. package/dist/ship-badge/ship-badge-generator.d.ts +136 -0
  82. package/dist/ship-badge/ship-badge-generator.d.ts.map +1 -0
  83. package/dist/ship-badge/ship-badge-generator.js +681 -0
  84. package/dist/ship-badge/ship-badge-generator.js.map +1 -0
  85. package/package.json +20 -0
  86. package/src/index.ts +7 -0
  87. package/src/mock-implementation.ts +0 -0
  88. package/src/mockproof/__tests__/import-graph-scanner.test.ts +115 -0
  89. package/src/mockproof/import-graph-scanner.d.ts +93 -0
  90. package/src/mockproof/import-graph-scanner.d.ts.map +1 -0
  91. package/src/mockproof/import-graph-scanner.js +482 -0
  92. package/src/mockproof/import-graph-scanner.ts +540 -0
  93. package/src/mockproof/index.ts +18 -0
  94. package/src/reality-mode/auth-enforcer.ts +97 -0
  95. package/src/reality-mode/explorer/critical-flows.ts +504 -0
  96. package/src/reality-mode/explorer/flow-parser.ts +293 -0
  97. package/src/reality-mode/explorer/index.ts +22 -0
  98. package/src/reality-mode/explorer/runtime-explorer.ts +715 -0
  99. package/src/reality-mode/explorer/surface-discovery.ts +498 -0
  100. package/src/reality-mode/explorer/templates/example-flows/auth-flow.yaml +41 -0
  101. package/src/reality-mode/explorer/templates/example-flows/checkout-flow.yaml +66 -0
  102. package/src/reality-mode/explorer/templates/example-flows/contact-form.yaml +43 -0
  103. package/src/reality-mode/explorer/templates/github-action.yml +132 -0
  104. package/src/reality-mode/explorer/types.ts +356 -0
  105. package/src/reality-mode/fake-success-detector.ts +89 -0
  106. package/src/reality-mode/index.ts +19 -0
  107. package/src/reality-mode/reality-scanner.d.ts +123 -0
  108. package/src/reality-mode/reality-scanner.d.ts.map +1 -0
  109. package/src/reality-mode/reality-scanner.js +526 -0
  110. package/src/reality-mode/reality-scanner.ts +576 -0
  111. package/src/reality-mode/report-generator.ts +253 -0
  112. package/src/reality-mode/traffic-classifier.ts +169 -0
  113. package/src/reality-mode/types.ts +95 -0
  114. package/src/ship-badge/__tests__/ship-badge-generator.test.ts +162 -0
  115. package/src/ship-badge/index.ts +16 -0
  116. package/src/ship-badge/ship-badge-generator.d.ts +136 -0
  117. package/src/ship-badge/ship-badge-generator.d.ts.map +1 -0
  118. package/src/ship-badge/ship-badge-generator.js +779 -0
  119. package/src/ship-badge/ship-badge-generator.ts +873 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Ship Badge Generator
3
+ *
4
+ * "One-click shareable proof that your app is real."
5
+ *
6
+ * Generates badges + hosted permalinks for:
7
+ * ✅ No Mock Data Detected
8
+ * ✅ No Localhost/Ngrok
9
+ * ✅ All required env vars present
10
+ * ✅ Billing not simulated
11
+ * ✅ DB is real
12
+ * ✅ OAuth callbacks not localhost
13
+ *
14
+ * Vibecoders slap this on README / landing page / Product Hunt for social proof.
15
+ */
16
+
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import * as crypto from "crypto";
20
+
21
+ export interface ShipCheck {
22
+ id: string;
23
+ name: string;
24
+ shortName: string;
25
+ status: "pass" | "fail" | "warning" | "skip";
26
+ message: string;
27
+ details?: string[];
28
+ }
29
+
30
+ export interface ShipBadgeResult {
31
+ projectId: string;
32
+ projectName: string;
33
+ verdict: "ship" | "no-ship" | "review";
34
+ score: number;
35
+ checks: ShipCheck[];
36
+ badges: ShipBadges;
37
+ timestamp: string;
38
+ expiresAt: string;
39
+ permalink: string;
40
+ embedCode: string;
41
+ }
42
+
43
+ export interface ShipBadges {
44
+ main: string; // Main ship/no-ship badge
45
+ mockData: string; // No mock data badge
46
+ realApi: string; // No localhost/ngrok badge
47
+ envVars: string; // Env vars present badge
48
+ billing: string; // Real billing badge
49
+ database: string; // Real database badge
50
+ oauth: string; // OAuth not localhost badge
51
+ combined: string; // All-in-one badge strip
52
+ }
53
+
54
+ export interface ShipBadgeConfig {
55
+ projectPath: string;
56
+ projectName?: string;
57
+ checks?: string[];
58
+ outputDir?: string;
59
+ baseUrl?: string;
60
+ }
61
+
62
+ const BADGE_COLORS = {
63
+ pass: "#4ade80", // Green
64
+ fail: "#f87171", // Red
65
+ warning: "#fbbf24", // Yellow
66
+ skip: "#9ca3af", // Gray
67
+ ship: "#22c55e", // Bright green
68
+ noship: "#ef4444", // Bright red
69
+ };
70
+
71
+ export class ShipBadgeGenerator {
72
+ /**
73
+ * Run all ship checks and generate badges
74
+ */
75
+ async generateShipBadge(config: ShipBadgeConfig): Promise<ShipBadgeResult> {
76
+ const projectName = config.projectName || path.basename(config.projectPath);
77
+ const projectId = this.generateProjectId(config.projectPath);
78
+
79
+ // Run all checks
80
+ const checks = await this.runAllChecks(config.projectPath);
81
+
82
+ // Calculate verdict
83
+ const { verdict, score } = this.calculateVerdict(checks);
84
+
85
+ // Generate badges
86
+ const badges = this.generateAllBadges(checks, verdict, score);
87
+
88
+ // Generate permalink (would be hosted on Guardrail servers in production)
89
+ const permalink = `https://Guardrail.dev/badge/${projectId}`;
90
+ const embedCode = this.generateEmbedCode(projectId, verdict, projectName);
91
+
92
+ // Save badges if output dir specified
93
+ if (config.outputDir) {
94
+ await this.saveBadges(badges, config.outputDir);
95
+ }
96
+
97
+ const result: ShipBadgeResult = {
98
+ projectId,
99
+ projectName,
100
+ verdict,
101
+ score,
102
+ checks,
103
+ badges,
104
+ timestamp: new Date().toISOString(),
105
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
106
+ permalink,
107
+ embedCode,
108
+ };
109
+
110
+ // Save result JSON
111
+ if (config.outputDir) {
112
+ await fs.promises.writeFile(
113
+ path.join(config.outputDir, "ship-badge-result.json"),
114
+ JSON.stringify(result, null, 2),
115
+ );
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Run all ship-worthiness checks
123
+ */
124
+ private async runAllChecks(projectPath: string): Promise<ShipCheck[]> {
125
+ const checks: ShipCheck[] = [];
126
+
127
+ // 1. No Mock Data
128
+ checks.push(await this.checkNoMockData(projectPath));
129
+
130
+ // 2. No Localhost/Ngrok
131
+ checks.push(await this.checkNoLocalhost(projectPath));
132
+
133
+ // 3. Env Vars Present
134
+ checks.push(await this.checkEnvVars(projectPath));
135
+
136
+ // 4. Real Billing (not simulated)
137
+ checks.push(await this.checkRealBilling(projectPath));
138
+
139
+ // 5. Real Database
140
+ checks.push(await this.checkRealDatabase(projectPath));
141
+
142
+ // 6. OAuth Callbacks
143
+ checks.push(await this.checkOAuthCallbacks(projectPath));
144
+
145
+ return checks;
146
+ }
147
+
148
+ /**
149
+ * Check for mock data patterns
150
+ */
151
+ private async checkNoMockData(projectPath: string): Promise<ShipCheck> {
152
+ const patterns = [
153
+ /MockProvider/g,
154
+ /useMock\(/g,
155
+ /mock-context/g,
156
+ /const\s+mock\w*\s*=/gi,
157
+ /lorem\s+ipsum/gi,
158
+ /john\.doe|jane\.doe/gi,
159
+ /user@example\.com/gi,
160
+ ];
161
+
162
+ const issues: string[] = [];
163
+ const files = await this.findSourceFiles(projectPath);
164
+
165
+ for (const file of files.slice(0, 100)) {
166
+ // Limit for performance
167
+ try {
168
+ const content = await fs.promises.readFile(file, "utf-8");
169
+ const relativePath = path.relative(projectPath, file);
170
+
171
+ // Skip test files
172
+ if (this.isTestFile(relativePath)) continue;
173
+
174
+ for (const pattern of patterns) {
175
+ pattern.lastIndex = 0;
176
+ if (pattern.test(content)) {
177
+ issues.push(`${relativePath}: ${pattern.source}`);
178
+ break;
179
+ }
180
+ }
181
+ } catch (e) {
182
+ // Skip unreadable files
183
+ }
184
+ }
185
+
186
+ return {
187
+ id: "no-mock-data",
188
+ name: "No Mock Data Detected",
189
+ shortName: "Mock Data",
190
+ status: issues.length === 0 ? "pass" : "fail",
191
+ message:
192
+ issues.length === 0
193
+ ? "No mock data patterns found in production code"
194
+ : `Found ${issues.length} mock data patterns`,
195
+ details: issues.slice(0, 5),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Check for localhost/ngrok URLs
201
+ */
202
+ private async checkNoLocalhost(projectPath: string): Promise<ShipCheck> {
203
+ const patterns = [
204
+ /localhost:\d+/g,
205
+ /127\.0\.0\.1:\d+/g,
206
+ /\.ngrok\.io/g,
207
+ /\.ngrok-free\.app/g,
208
+ /jsonplaceholder\.typicode\.com/g,
209
+ ];
210
+
211
+ const issues: string[] = [];
212
+ const configFiles = [
213
+ ".env",
214
+ ".env.production",
215
+ "next.config.js",
216
+ "next.config.mjs",
217
+ "vite.config.ts",
218
+ "vite.config.js",
219
+ "src/config/api.ts",
220
+ "src/lib/api.ts",
221
+ ];
222
+
223
+ for (const configFile of configFiles) {
224
+ const filePath = path.join(projectPath, configFile);
225
+ if (fs.existsSync(filePath)) {
226
+ try {
227
+ const content = await fs.promises.readFile(filePath, "utf-8");
228
+ for (const pattern of patterns) {
229
+ pattern.lastIndex = 0;
230
+ const matches = content.match(pattern);
231
+ if (matches) {
232
+ issues.push(`${configFile}: ${matches[0]}`);
233
+ }
234
+ }
235
+ } catch (e) {
236
+ // Skip
237
+ }
238
+ }
239
+ }
240
+
241
+ return {
242
+ id: "no-localhost",
243
+ name: "No Localhost/Ngrok",
244
+ shortName: "Real URLs",
245
+ status: issues.length === 0 ? "pass" : "fail",
246
+ message:
247
+ issues.length === 0
248
+ ? "No localhost or temporary URLs in config"
249
+ : `Found ${issues.length} localhost/ngrok URLs`,
250
+ details: issues,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Check for required environment variables
256
+ */
257
+ private async checkEnvVars(projectPath: string): Promise<ShipCheck> {
258
+ const requiredVars = [
259
+ "DATABASE_URL",
260
+ "API_URL",
261
+ "NEXTAUTH_URL",
262
+ "NEXTAUTH_SECRET",
263
+ ];
264
+
265
+ const envPath = path.join(projectPath, ".env");
266
+ const envProdPath = path.join(projectPath, ".env.production");
267
+
268
+ let envContent = "";
269
+ if (fs.existsSync(envProdPath)) {
270
+ envContent = await fs.promises.readFile(envProdPath, "utf-8");
271
+ } else if (fs.existsSync(envPath)) {
272
+ envContent = await fs.promises.readFile(envPath, "utf-8");
273
+ }
274
+
275
+ // Also check .env.example to see what's expected
276
+ const examplePath = path.join(projectPath, ".env.example");
277
+ let expectedVars: string[] = [];
278
+ if (fs.existsSync(examplePath)) {
279
+ const exampleContent = await fs.promises.readFile(examplePath, "utf-8");
280
+ expectedVars = exampleContent
281
+ .split("\n")
282
+ .filter((line) => line.includes("=") && !line.startsWith("#"))
283
+ .map((line) => line.split("=")[0]?.trim() || "");
284
+ }
285
+
286
+ const missing: string[] = [];
287
+ const varsToCheck = expectedVars.length > 0 ? expectedVars : requiredVars;
288
+
289
+ for (const varName of varsToCheck) {
290
+ const regex = new RegExp(`^${varName}=.+`, "m");
291
+ if (!regex.test(envContent)) {
292
+ missing.push(varName);
293
+ }
294
+ }
295
+
296
+ const hasEnvFile = fs.existsSync(envPath) || fs.existsSync(envProdPath);
297
+
298
+ return {
299
+ id: "env-vars",
300
+ name: "Environment Variables Present",
301
+ shortName: "Env Vars",
302
+ status: !hasEnvFile ? "skip" : missing.length === 0 ? "pass" : "warning",
303
+ message: !hasEnvFile
304
+ ? "No .env file found"
305
+ : missing.length === 0
306
+ ? "All expected environment variables are set"
307
+ : `Missing ${missing.length} environment variables`,
308
+ details: missing.slice(0, 5),
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Check for real billing (not demo/test)
314
+ */
315
+ private async checkRealBilling(projectPath: string): Promise<ShipCheck> {
316
+ const testKeyPatterns = [
317
+ /sk_test_/g,
318
+ /pk_test_/g,
319
+ /STRIPE_TEST/g,
320
+ /demo_billing/gi,
321
+ /simulate.*payment/gi,
322
+ /fake.*billing/gi,
323
+ ];
324
+
325
+ const issues: string[] = [];
326
+ const files = await this.findSourceFiles(projectPath);
327
+
328
+ // Check for Stripe/billing related files
329
+ const billingFiles = files.filter((f) =>
330
+ /stripe|billing|payment|checkout/i.test(f),
331
+ );
332
+
333
+ if (billingFiles.length === 0) {
334
+ return {
335
+ id: "real-billing",
336
+ name: "Billing Not Simulated",
337
+ shortName: "Billing",
338
+ status: "skip",
339
+ message: "No billing code detected",
340
+ };
341
+ }
342
+
343
+ for (const file of billingFiles) {
344
+ try {
345
+ const content = await fs.promises.readFile(file, "utf-8");
346
+ const relativePath = path.relative(projectPath, file);
347
+
348
+ if (this.isTestFile(relativePath)) continue;
349
+
350
+ for (const pattern of testKeyPatterns) {
351
+ pattern.lastIndex = 0;
352
+ if (pattern.test(content)) {
353
+ issues.push(`${relativePath}: ${pattern.source}`);
354
+ }
355
+ }
356
+ } catch (e) {
357
+ // Skip
358
+ }
359
+ }
360
+
361
+ return {
362
+ id: "real-billing",
363
+ name: "Billing Not Simulated",
364
+ shortName: "Billing",
365
+ status: issues.length === 0 ? "pass" : "fail",
366
+ message:
367
+ issues.length === 0
368
+ ? "No test billing keys or demo billing code found"
369
+ : `Found ${issues.length} test/demo billing patterns`,
370
+ details: issues.slice(0, 5),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Check for real database connection
376
+ */
377
+ private async checkRealDatabase(projectPath: string): Promise<ShipCheck> {
378
+ const fakeDbPatterns = [
379
+ /sqlite:memory/gi,
380
+ /\.sqlite$/gi,
381
+ /mockdb/gi,
382
+ /fake.*database/gi,
383
+ /in-memory.*db/gi,
384
+ ];
385
+
386
+ const envPath = path.join(projectPath, ".env");
387
+ const envProdPath = path.join(projectPath, ".env.production");
388
+
389
+ let dbUrl = "";
390
+
391
+ for (const p of [envProdPath, envPath]) {
392
+ if (fs.existsSync(p)) {
393
+ const content = await fs.promises.readFile(p, "utf-8");
394
+ const match = content.match(/DATABASE_URL=(.+)/);
395
+ if (match) {
396
+ dbUrl = match[1] || "";
397
+ break;
398
+ }
399
+ }
400
+ }
401
+
402
+ if (!dbUrl) {
403
+ return {
404
+ id: "real-database",
405
+ name: "Database Is Real",
406
+ shortName: "Database",
407
+ status: "skip",
408
+ message: "No DATABASE_URL found",
409
+ };
410
+ }
411
+
412
+ const isFake =
413
+ fakeDbPatterns.some((p) => p.test(dbUrl)) || /localhost/.test(dbUrl);
414
+
415
+ return {
416
+ id: "real-database",
417
+ name: "Database Is Real",
418
+ shortName: "Database",
419
+ status: isFake ? "warning" : "pass",
420
+ message: isFake
421
+ ? "Database URL points to local/fake database"
422
+ : "Database URL appears to be a real hosted database",
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Check OAuth callback URLs
428
+ */
429
+ private async checkOAuthCallbacks(projectPath: string): Promise<ShipCheck> {
430
+ const authFiles = [
431
+ "src/app/api/auth/[...nextauth]/route.ts",
432
+ "src/pages/api/auth/[...nextauth].ts",
433
+ "src/lib/auth.ts",
434
+ "src/config/auth.ts",
435
+ ];
436
+
437
+ const issues: string[] = [];
438
+
439
+ for (const authFile of authFiles) {
440
+ const filePath = path.join(projectPath, authFile);
441
+ if (fs.existsSync(filePath)) {
442
+ try {
443
+ const content = await fs.promises.readFile(filePath, "utf-8");
444
+
445
+ if (/callbackUrl.*localhost/i.test(content)) {
446
+ issues.push(`${authFile}: localhost callback URL`);
447
+ }
448
+ if (/redirect.*localhost/i.test(content)) {
449
+ issues.push(`${authFile}: localhost redirect`);
450
+ }
451
+ } catch (e) {
452
+ // Skip
453
+ }
454
+ }
455
+ }
456
+
457
+ // Also check NEXTAUTH_URL
458
+ const envPath = path.join(projectPath, ".env");
459
+ if (fs.existsSync(envPath)) {
460
+ const content = await fs.promises.readFile(envPath, "utf-8");
461
+ const match = content.match(/NEXTAUTH_URL=(.+)/);
462
+ if (match && match[1] && /localhost/i.test(match[1])) {
463
+ issues.push(".env: NEXTAUTH_URL points to localhost");
464
+ }
465
+ }
466
+
467
+ const hasAuthCode = authFiles.some((f) =>
468
+ fs.existsSync(path.join(projectPath, f)),
469
+ );
470
+
471
+ return {
472
+ id: "oauth-callbacks",
473
+ name: "OAuth Callbacks Not Localhost",
474
+ shortName: "OAuth",
475
+ status: !hasAuthCode ? "skip" : issues.length === 0 ? "pass" : "fail",
476
+ message: !hasAuthCode
477
+ ? "No OAuth/auth code detected"
478
+ : issues.length === 0
479
+ ? "OAuth callbacks configured for production"
480
+ : `Found ${issues.length} localhost OAuth issues`,
481
+ details: issues,
482
+ };
483
+ }
484
+
485
+ /**
486
+ * Calculate overall verdict
487
+ */
488
+ private calculateVerdict(checks: ShipCheck[]): {
489
+ verdict: "ship" | "no-ship" | "review";
490
+ score: number;
491
+ } {
492
+ const activeChecks = checks.filter((c) => c.status !== "skip");
493
+ const passed = activeChecks.filter((c) => c.status === "pass").length;
494
+ const failed = activeChecks.filter((c) => c.status === "fail").length;
495
+ const warnings = activeChecks.filter((c) => c.status === "warning").length;
496
+
497
+ const score =
498
+ activeChecks.length > 0
499
+ ? Math.round((passed / activeChecks.length) * 100)
500
+ : 100;
501
+
502
+ let verdict: "ship" | "no-ship" | "review";
503
+ if (failed > 0) {
504
+ verdict = "no-ship";
505
+ } else if (warnings > 0) {
506
+ verdict = "review";
507
+ } else {
508
+ verdict = "ship";
509
+ }
510
+
511
+ return { verdict, score };
512
+ }
513
+
514
+ /**
515
+ * Generate all badge SVGs
516
+ */
517
+ private generateAllBadges(
518
+ checks: ShipCheck[],
519
+ verdict: "ship" | "no-ship" | "review",
520
+ score: number,
521
+ ): ShipBadges {
522
+ const mainColor =
523
+ verdict === "ship"
524
+ ? BADGE_COLORS.ship
525
+ : verdict === "no-ship"
526
+ ? BADGE_COLORS.noship
527
+ : BADGE_COLORS.warning;
528
+
529
+ return {
530
+ main: this.createBadge(
531
+ "Guardrail",
532
+ verdict === "ship"
533
+ ? "🚀 SHIP IT"
534
+ : verdict === "no-ship"
535
+ ? "🛑 NO SHIP"
536
+ : "⚠️ REVIEW",
537
+ mainColor,
538
+ ),
539
+ mockData: this.createCheckBadge(
540
+ checks.find((c) => c.id === "no-mock-data")!,
541
+ ),
542
+ realApi: this.createCheckBadge(
543
+ checks.find((c) => c.id === "no-localhost")!,
544
+ ),
545
+ envVars: this.createCheckBadge(checks.find((c) => c.id === "env-vars")!),
546
+ billing: this.createCheckBadge(
547
+ checks.find((c) => c.id === "real-billing")!,
548
+ ),
549
+ database: this.createCheckBadge(
550
+ checks.find((c) => c.id === "real-database")!,
551
+ ),
552
+ oauth: this.createCheckBadge(
553
+ checks.find((c) => c.id === "oauth-callbacks")!,
554
+ ),
555
+ combined: this.createCombinedBadge(checks, score),
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Create a single check badge
561
+ */
562
+ private createCheckBadge(check: ShipCheck): string {
563
+ const icon =
564
+ check.status === "pass"
565
+ ? "✅"
566
+ : check.status === "fail"
567
+ ? "❌"
568
+ : check.status === "warning"
569
+ ? "⚠️"
570
+ : "⏭️";
571
+
572
+ const color = BADGE_COLORS[check.status];
573
+ const label = check.shortName;
574
+ const value =
575
+ check.status === "pass"
576
+ ? "Pass"
577
+ : check.status === "fail"
578
+ ? "Fail"
579
+ : check.status === "warning"
580
+ ? "Warning"
581
+ : "Skip";
582
+
583
+ return this.createBadge(label, `${icon} ${value}`, color);
584
+ }
585
+
586
+ /**
587
+ * Create a combined badge strip
588
+ */
589
+ private createCombinedBadge(checks: ShipCheck[], score: number): string {
590
+ const passed = checks.filter((c) => c.status === "pass").length;
591
+ const total = checks.filter((c) => c.status !== "skip").length;
592
+
593
+ const color =
594
+ score >= 80
595
+ ? BADGE_COLORS.pass
596
+ : score >= 50
597
+ ? BADGE_COLORS.warning
598
+ : BADGE_COLORS.fail;
599
+
600
+ return this.createBadge(
601
+ "Ship Score",
602
+ `${passed}/${total} (${score}%)`,
603
+ color,
604
+ "for-the-badge",
605
+ );
606
+ }
607
+
608
+ /**
609
+ * Create SVG badge
610
+ */
611
+ private createBadge(
612
+ label: string,
613
+ value: string,
614
+ color: string,
615
+ style: "flat" | "for-the-badge" = "flat",
616
+ ): string {
617
+ const labelWidth = label.length * 7 + 10;
618
+ const valueWidth = value.length * 7 + 10;
619
+ const totalWidth = labelWidth + valueWidth;
620
+ const height = style === "for-the-badge" ? 28 : 20;
621
+ const fontSize = 11;
622
+ const labelBg = "#555";
623
+
624
+ if (style === "for-the-badge") {
625
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}">
626
+ <linearGradient id="smooth" x2="0" y2="100%">
627
+ <stop offset="0" stop-color="#fff" stop-opacity=".7"/>
628
+ <stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
629
+ <stop offset=".9" stop-color="#000" stop-opacity=".3"/>
630
+ <stop offset="1" stop-color="#000" stop-opacity=".5"/>
631
+ </linearGradient>
632
+ <rect rx="4" width="${totalWidth}" height="${height}" fill="${labelBg}"/>
633
+ <rect rx="4" x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${color}"/>
634
+ <rect rx="4" width="${totalWidth}" height="${height}" fill="url(#smooth)"/>
635
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="${fontSize}" font-weight="bold">
636
+ <text x="${labelWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(label)}</text>
637
+ <text x="${labelWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(label)}</text>
638
+ <text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(value)}</text>
639
+ <text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(value)}</text>
640
+ </g>
641
+ </svg>`;
642
+ }
643
+
644
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}">
645
+ <linearGradient id="smooth" x2="0" y2="100%">
646
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
647
+ <stop offset="1" stop-opacity=".1"/>
648
+ </linearGradient>
649
+ <clipPath id="round">
650
+ <rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
651
+ </clipPath>
652
+ <g clip-path="url(#round)">
653
+ <rect width="${labelWidth}" height="${height}" fill="${labelBg}"/>
654
+ <rect x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${color}"/>
655
+ <rect width="${totalWidth}" height="${height}" fill="url(#smooth)"/>
656
+ </g>
657
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="${fontSize}">
658
+ <text x="${labelWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(label)}</text>
659
+ <text x="${labelWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(label)}</text>
660
+ <text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(value)}</text>
661
+ <text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(value)}</text>
662
+ </g>
663
+ </svg>`;
664
+ }
665
+
666
+ /**
667
+ * Generate embed code for README
668
+ */
669
+ private generateEmbedCode(
670
+ projectId: string,
671
+ verdict: string,
672
+ projectName: string,
673
+ ): string {
674
+ return `<!-- Guardrail Ship Badge -->
675
+ [![Guardrail Ship Status](https://Guardrail.dev/api/badge/${projectId}/main.svg)](https://Guardrail.dev/badge/${projectId})
676
+ [![Mock Data](https://Guardrail.dev/api/badge/${projectId}/mock-data.svg)](https://Guardrail.dev/badge/${projectId})
677
+ [![Real APIs](https://Guardrail.dev/api/badge/${projectId}/real-api.svg)](https://Guardrail.dev/badge/${projectId})
678
+ <!-- End Guardrail Ship Badge -->
679
+
680
+ ---
681
+
682
+ **${projectName}** verified by [Guardrail](https://Guardrail.dev) - Stop shipping pretend features.`;
683
+ }
684
+
685
+ /**
686
+ * Save badges to directory
687
+ */
688
+ private async saveBadges(
689
+ badges: ShipBadges,
690
+ outputDir: string,
691
+ ): Promise<void> {
692
+ await fs.promises.mkdir(outputDir, { recursive: true });
693
+
694
+ const files: [keyof ShipBadges, string][] = [
695
+ ["main", "ship-status.svg"],
696
+ ["mockData", "mock-data.svg"],
697
+ ["realApi", "real-api.svg"],
698
+ ["envVars", "env-vars.svg"],
699
+ ["billing", "billing.svg"],
700
+ ["database", "database.svg"],
701
+ ["oauth", "oauth.svg"],
702
+ ["combined", "ship-score.svg"],
703
+ ];
704
+
705
+ for (const [key, filename] of files) {
706
+ await fs.promises.writeFile(
707
+ path.join(outputDir, filename),
708
+ badges[key],
709
+ "utf-8",
710
+ );
711
+ }
712
+ }
713
+
714
+ /**
715
+ * Generate project ID from path
716
+ */
717
+ private generateProjectId(projectPath: string): string {
718
+ const hash = crypto
719
+ .createHash("sha256")
720
+ .update(projectPath)
721
+ .digest("hex")
722
+ .slice(0, 12);
723
+ return hash;
724
+ }
725
+
726
+ /**
727
+ * Find source files
728
+ */
729
+ private async findSourceFiles(projectPath: string): Promise<string[]> {
730
+ const files: string[] = [];
731
+ const extensions = [".ts", ".tsx", ".js", ".jsx"];
732
+ const excludeDirs = [
733
+ "node_modules",
734
+ ".git",
735
+ ".next",
736
+ "dist",
737
+ "build",
738
+ "coverage",
739
+ ];
740
+
741
+ const walk = async (dir: string): Promise<void> => {
742
+ try {
743
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
744
+
745
+ for (const entry of entries) {
746
+ const fullPath = path.join(dir, entry.name);
747
+
748
+ if (entry.isDirectory()) {
749
+ if (
750
+ !excludeDirs.includes(entry.name) &&
751
+ !entry.name.startsWith(".")
752
+ ) {
753
+ await walk(fullPath);
754
+ }
755
+ } else if (entry.isFile()) {
756
+ const ext = path.extname(entry.name);
757
+ if (extensions.includes(ext)) {
758
+ files.push(fullPath);
759
+ }
760
+ }
761
+ }
762
+ } catch (e) {
763
+ // Skip
764
+ }
765
+ };
766
+
767
+ await walk(projectPath);
768
+ return files;
769
+ }
770
+
771
+ /**
772
+ * Check if file is a test file
773
+ */
774
+ private isTestFile(filePath: string): boolean {
775
+ const testPatterns = [
776
+ /__tests__/,
777
+ /\.test\./,
778
+ /\.spec\./,
779
+ /test\//,
780
+ /tests\//,
781
+ /e2e\//,
782
+ /__mocks__/,
783
+ /stories\//,
784
+ ];
785
+ return testPatterns.some((p) => p.test(filePath));
786
+ }
787
+
788
+ /**
789
+ * Escape HTML entities
790
+ */
791
+ private escapeHtml(text: string): string {
792
+ return text
793
+ .replace(/&/g, "&amp;")
794
+ .replace(/</g, "&lt;")
795
+ .replace(/>/g, "&gt;")
796
+ .replace(/"/g, "&quot;")
797
+ .replace(/'/g, "&#039;");
798
+ }
799
+
800
+ /**
801
+ * Generate human-readable report
802
+ */
803
+ generateReport(result: ShipBadgeResult): string {
804
+ const lines: string[] = [];
805
+
806
+ lines.push(
807
+ "╔══════════════════════════════════════════════════════════════╗",
808
+ );
809
+ lines.push(
810
+ "║ 🚀 Guardrail Ship Badge Report 🚀 ║",
811
+ );
812
+ lines.push(
813
+ "╚══════════════════════════════════════════════════════════════╝",
814
+ );
815
+ lines.push("");
816
+
817
+ const verdictEmoji =
818
+ result.verdict === "ship"
819
+ ? "🚀"
820
+ : result.verdict === "no-ship"
821
+ ? "🛑"
822
+ : "⚠️";
823
+ const verdictText =
824
+ result.verdict === "ship"
825
+ ? "SHIP IT!"
826
+ : result.verdict === "no-ship"
827
+ ? "NO SHIP"
828
+ : "NEEDS REVIEW";
829
+
830
+ lines.push(`${verdictEmoji} VERDICT: ${verdictText}`);
831
+ lines.push(` Ship Score: ${result.score}/100`);
832
+ lines.push(` Project: ${result.projectName}`);
833
+ lines.push("");
834
+ lines.push("─".repeat(64));
835
+ lines.push("");
836
+ lines.push("CHECKS:");
837
+ lines.push("");
838
+
839
+ for (const check of result.checks) {
840
+ const icon =
841
+ check.status === "pass"
842
+ ? "✅"
843
+ : check.status === "fail"
844
+ ? "❌"
845
+ : check.status === "warning"
846
+ ? "⚠️"
847
+ : "⏭️";
848
+ lines.push(`${icon} ${check.name}`);
849
+ lines.push(` ${check.message}`);
850
+ if (check.details && check.details.length > 0) {
851
+ for (const detail of check.details) {
852
+ lines.push(` • ${detail}`);
853
+ }
854
+ }
855
+ lines.push("");
856
+ }
857
+
858
+ lines.push("─".repeat(64));
859
+ lines.push("");
860
+ lines.push("ADD TO YOUR README:");
861
+ lines.push("");
862
+ lines.push(result.embedCode);
863
+ lines.push("");
864
+ lines.push("─".repeat(64));
865
+ lines.push(`Permalink: ${result.permalink}`);
866
+ lines.push(`Generated: ${result.timestamp}`);
867
+ lines.push(`Expires: ${result.expiresAt}`);
868
+
869
+ return lines.join("\n");
870
+ }
871
+ }
872
+
873
+ export const shipBadgeGenerator = new ShipBadgeGenerator();