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.
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mock-implementation.d.ts +1 -0
- package/dist/mock-implementation.d.ts.map +1 -0
- package/dist/mock-implementation.js +2 -0
- package/dist/mock-implementation.js.map +1 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts +5 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts.map +1 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.js +92 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.js.map +1 -0
- package/dist/mockproof/import-graph-scanner.d.ts +93 -0
- package/dist/mockproof/import-graph-scanner.d.ts.map +1 -0
- package/dist/mockproof/import-graph-scanner.js +411 -0
- package/dist/mockproof/import-graph-scanner.js.map +1 -0
- package/dist/mockproof/index.d.ts +10 -0
- package/dist/mockproof/index.d.ts.map +1 -0
- package/dist/mockproof/index.js +10 -0
- package/dist/mockproof/index.js.map +1 -0
- package/dist/reality-mode/auth-enforcer.d.ts +13 -0
- package/dist/reality-mode/auth-enforcer.d.ts.map +1 -0
- package/dist/reality-mode/auth-enforcer.js +90 -0
- package/dist/reality-mode/auth-enforcer.js.map +1 -0
- package/dist/reality-mode/explorer/critical-flows.d.ts +71 -0
- package/dist/reality-mode/explorer/critical-flows.d.ts.map +1 -0
- package/dist/reality-mode/explorer/critical-flows.js +463 -0
- package/dist/reality-mode/explorer/critical-flows.js.map +1 -0
- package/dist/reality-mode/explorer/flow-parser.d.ts +52 -0
- package/dist/reality-mode/explorer/flow-parser.d.ts.map +1 -0
- package/dist/reality-mode/explorer/flow-parser.js +250 -0
- package/dist/reality-mode/explorer/flow-parser.js.map +1 -0
- package/dist/reality-mode/explorer/index.d.ts +11 -0
- package/dist/reality-mode/explorer/index.d.ts.map +1 -0
- package/dist/reality-mode/explorer/index.js +11 -0
- package/dist/reality-mode/explorer/index.js.map +1 -0
- package/dist/reality-mode/explorer/runtime-explorer.d.ts +35 -0
- package/dist/reality-mode/explorer/runtime-explorer.d.ts.map +1 -0
- package/dist/reality-mode/explorer/runtime-explorer.js +688 -0
- package/dist/reality-mode/explorer/runtime-explorer.js.map +1 -0
- package/dist/reality-mode/explorer/surface-discovery.d.ts +60 -0
- package/dist/reality-mode/explorer/surface-discovery.d.ts.map +1 -0
- package/dist/reality-mode/explorer/surface-discovery.js +357 -0
- package/dist/reality-mode/explorer/surface-discovery.js.map +1 -0
- package/dist/reality-mode/explorer/types.d.ts +275 -0
- package/dist/reality-mode/explorer/types.d.ts.map +1 -0
- package/dist/reality-mode/explorer/types.js +8 -0
- package/dist/reality-mode/explorer/types.js.map +1 -0
- package/dist/reality-mode/fake-success-detector.d.ts +10 -0
- package/dist/reality-mode/fake-success-detector.d.ts.map +1 -0
- package/dist/reality-mode/fake-success-detector.js +76 -0
- package/dist/reality-mode/fake-success-detector.js.map +1 -0
- package/dist/reality-mode/index.d.ts +14 -0
- package/dist/reality-mode/index.d.ts.map +1 -0
- package/dist/reality-mode/index.js +14 -0
- package/dist/reality-mode/index.js.map +1 -0
- package/dist/reality-mode/reality-scanner.d.ts +48 -0
- package/dist/reality-mode/reality-scanner.d.ts.map +1 -0
- package/dist/reality-mode/reality-scanner.js +516 -0
- package/dist/reality-mode/reality-scanner.js.map +1 -0
- package/dist/reality-mode/report-generator.d.ts +11 -0
- package/dist/reality-mode/report-generator.d.ts.map +1 -0
- package/dist/reality-mode/report-generator.js +233 -0
- package/dist/reality-mode/report-generator.js.map +1 -0
- package/dist/reality-mode/traffic-classifier.d.ts +14 -0
- package/dist/reality-mode/traffic-classifier.d.ts.map +1 -0
- package/dist/reality-mode/traffic-classifier.js +131 -0
- package/dist/reality-mode/traffic-classifier.js.map +1 -0
- package/dist/reality-mode/types.d.ts +90 -0
- package/dist/reality-mode/types.d.ts.map +1 -0
- package/dist/reality-mode/types.js +2 -0
- package/dist/reality-mode/types.js.map +1 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts +5 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts.map +1 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.js +146 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.js.map +1 -0
- package/dist/ship-badge/index.d.ts +9 -0
- package/dist/ship-badge/index.d.ts.map +1 -0
- package/dist/ship-badge/index.js +9 -0
- package/dist/ship-badge/index.js.map +1 -0
- package/dist/ship-badge/ship-badge-generator.d.ts +136 -0
- package/dist/ship-badge/ship-badge-generator.d.ts.map +1 -0
- package/dist/ship-badge/ship-badge-generator.js +681 -0
- package/dist/ship-badge/ship-badge-generator.js.map +1 -0
- package/package.json +20 -0
- package/src/index.ts +7 -0
- package/src/mock-implementation.ts +0 -0
- package/src/mockproof/__tests__/import-graph-scanner.test.ts +115 -0
- package/src/mockproof/import-graph-scanner.d.ts +93 -0
- package/src/mockproof/import-graph-scanner.d.ts.map +1 -0
- package/src/mockproof/import-graph-scanner.js +482 -0
- package/src/mockproof/import-graph-scanner.ts +540 -0
- package/src/mockproof/index.ts +18 -0
- package/src/reality-mode/auth-enforcer.ts +97 -0
- package/src/reality-mode/explorer/critical-flows.ts +504 -0
- package/src/reality-mode/explorer/flow-parser.ts +293 -0
- package/src/reality-mode/explorer/index.ts +22 -0
- package/src/reality-mode/explorer/runtime-explorer.ts +715 -0
- package/src/reality-mode/explorer/surface-discovery.ts +498 -0
- package/src/reality-mode/explorer/templates/example-flows/auth-flow.yaml +41 -0
- package/src/reality-mode/explorer/templates/example-flows/checkout-flow.yaml +66 -0
- package/src/reality-mode/explorer/templates/example-flows/contact-form.yaml +43 -0
- package/src/reality-mode/explorer/templates/github-action.yml +132 -0
- package/src/reality-mode/explorer/types.ts +356 -0
- package/src/reality-mode/fake-success-detector.ts +89 -0
- package/src/reality-mode/index.ts +19 -0
- package/src/reality-mode/reality-scanner.d.ts +123 -0
- package/src/reality-mode/reality-scanner.d.ts.map +1 -0
- package/src/reality-mode/reality-scanner.js +526 -0
- package/src/reality-mode/reality-scanner.ts +576 -0
- package/src/reality-mode/report-generator.ts +253 -0
- package/src/reality-mode/traffic-classifier.ts +169 -0
- package/src/reality-mode/types.ts +95 -0
- package/src/ship-badge/__tests__/ship-badge-generator.test.ts +162 -0
- package/src/ship-badge/index.ts +16 -0
- package/src/ship-badge/ship-badge-generator.d.ts +136 -0
- package/src/ship-badge/ship-badge-generator.d.ts.map +1 -0
- package/src/ship-badge/ship-badge-generator.js +779 -0
- 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
|
+
[](https://Guardrail.dev/badge/${projectId})
|
|
676
|
+
[](https://Guardrail.dev/badge/${projectId})
|
|
677
|
+
[](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, "&")
|
|
794
|
+
.replace(/</g, "<")
|
|
795
|
+
.replace(/>/g, ">")
|
|
796
|
+
.replace(/"/g, """)
|
|
797
|
+
.replace(/'/g, "'");
|
|
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();
|