launchprep 0.0.1 → 0.1.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.
@@ -0,0 +1,529 @@
1
+ // The last of the tier-1 checks.
2
+ //
3
+ // Discipline, same as everywhere else in this directory: read the value, not the
4
+ // identifier. Judge the line, not a 200-character neighbourhood. Require evidence
5
+ // that the thing is used the dangerous way before saying a word.
6
+
7
+ const finding = (id, title, severity, file, line, detail, fix) =>
8
+ ({ id, title, severity, file, line, detail, fix });
9
+
10
+ const lineOf = (t, i) => t.slice(0, i).split('\n').length;
11
+ const lineAt = (t, i) => {
12
+ const a = t.lastIndexOf('\n', i) + 1;
13
+ const b = t.indexOf('\n', i);
14
+ return t.slice(a, b === -1 ? t.length : b);
15
+ };
16
+ // the whole call expression starting at i, bounded so we never judge a neighbour
17
+ const callAt = (t, i, max = 260) => {
18
+ const open = t.indexOf('(', i);
19
+ if (open === -1) return t.slice(i, i + max);
20
+ let depth = 0;
21
+ for (let j = open; j < Math.min(t.length, open + max); j++) {
22
+ if (t[j] === '(') depth++;
23
+ else if (t[j] === ')' && --depth === 0) return t.slice(i, j + 1);
24
+ }
25
+ return t.slice(i, i + max);
26
+ };
27
+ const isJS = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
28
+ const isServer = (p) =>
29
+ /\/(api|routes?|server|actions?|controllers?|handlers?|jobs?|workers?|lib|services?)\//.test(p);
30
+
31
+ // union of every manifest in the repo - monorepos keep the root one empty
32
+ const depNames = (repo) => {
33
+ const names = new Set();
34
+ for (const f of repo.find(/(^|\/)package\.json$/)) {
35
+ try {
36
+ const m = JSON.parse(f.text || '');
37
+ for (const k of Object.keys({ ...m.dependencies, ...m.devDependencies })) names.add(k);
38
+ } catch {}
39
+ }
40
+ for (const f of repo.find(/requirements.*\.txt$|(^|\/)(Gemfile|pyproject\.toml)$/))
41
+ (f.text || '').split('\n').forEach(l => {
42
+ const m = l.match(/^\s*(?:gem\s+['"])?([A-Za-z0-9_.-]+)/);
43
+ if (m) names.add(m[1].toLowerCase());
44
+ });
45
+ return names;
46
+ };
47
+ const anyText = (repo, pathRe) =>
48
+ repo.files.filter(f => f.text && pathRe.test(f.path)).map(f => f.text).join('\n');
49
+
50
+ export const BATCH4_CHECKS = [
51
+
52
+ // ── DATA-003 ────────────────────────────────────────────────────────────────
53
+ // Postgres does NOT index a foreign key column. It indexes primary keys and
54
+ // unique constraints and nothing else. Every join through an unindexed FK is a
55
+ // sequential scan that is invisible at 100 rows and fatal at 100,000.
56
+ { id: 'DATA-003', run(repo, profile) {
57
+ const db = profile.stack?.database;
58
+ if (!['postgres', 'supabase', 'mysql', 'planetscale'].includes(db)) return [];
59
+ const out = [];
60
+
61
+ // ---- Prisma ----
62
+ // Coverage is per MODEL. An @@index([scheduleId]) on Booking says nothing
63
+ // about Host.scheduleId. And one finding per schema, not one per relation:
64
+ // cal.com has 23 uncovered columns across 147 relation declarations, and
65
+ // reporting each occurrence produced 605 findings nobody would ever read.
66
+ for (const f of repo.find(/\.prisma$/)) {
67
+ const t = f.text; if (!t) continue;
68
+ if (/provider\s*=\s*"mysql"/.test(t)) continue; // MySQL indexes FKs itself
69
+ const cols = []; let firstAt = 0, total = 0;
70
+ const models = /model\s+\w+\s*\{([\s\S]*?)\n\}/g;
71
+ let mm;
72
+ while ((mm = models.exec(t))) {
73
+ const body = mm[1], base = mm.index;
74
+ const indexed = new Set();
75
+ let m;
76
+ const idx = /@@(?:index|unique|id)\s*\(\s*\[\s*([A-Za-z0-9_]+)/g;
77
+ while ((m = idx.exec(body))) indexed.add(m[1]);
78
+ const inline = /^\s*([A-Za-z0-9_]+)\s+\S+.*@(?:id|unique)\b/gm;
79
+ while ((m = inline.exec(body))) indexed.add(m[1]);
80
+ const rel = /@relation\s*\([^)]*fields:\s*\[\s*([A-Za-z0-9_,\s]+?)\s*\]/g;
81
+ while ((m = rel.exec(body))) {
82
+ const lead = m[1].split(',')[0].trim();
83
+ if (!lead || indexed.has(lead)) continue;
84
+ if (!total) firstAt = base + m.index;
85
+ total++;
86
+ if (!cols.includes(lead)) cols.push(lead);
87
+ }
88
+ }
89
+ if (!total) continue;
90
+ const eg = cols.slice(0, 4).join(', ') + (cols.length > 4 ? `, and ${cols.length - 4} more` : '');
91
+ out.push(finding('DATA-003',
92
+ `${total} foreign key${total === 1 ? '' : 's'} in this schema ${total === 1 ? 'has' : 'have'} no index`,
93
+ 'high', f.path, lineOf(t, firstAt),
94
+ `Postgres indexes primary keys and unique columns — never foreign keys. Every query that joins or filters on ${eg} reads the whole table. At a hundred rows nobody notices; at a hundred thousand everything is slow at once, and the code never changed.`,
95
+ `Add @@index([column]) for each — start with ${cols[0]}.`));
96
+ }
97
+
98
+ // ---- raw SQL ----
99
+ // Only when there is no Prisma schema. A Prisma project's migrations are
100
+ // GENERATED from the schema - they are a historical log, and every one that
101
+ // ever created a table would report again. That was 240 findings in the
102
+ // audit corpus for six repositories.
103
+ const sql = repo.find(/\.sql$/).filter(f => f.text);
104
+ if (sql.length && !repo.has(/\.prisma$/)) {
105
+ const indexed = new Set();
106
+ for (const f of sql) {
107
+ let m;
108
+ const ci = /CREATE\s+(?:UNIQUE\s+)?INDEX[^(]*\(\s*"?([A-Za-z0-9_]+)/gi;
109
+ while ((m = ci.exec(f.text))) indexed.add(m[1].toLowerCase());
110
+ const pk = /(?:PRIMARY\s+KEY|UNIQUE)\s*\(\s*"?([A-Za-z0-9_]+)/gi;
111
+ while ((m = pk.exec(f.text))) indexed.add(m[1].toLowerCase());
112
+ }
113
+ // one finding for the schema, not one per migration
114
+ const cols = []; let total = 0, at = null;
115
+ for (const f of sql) {
116
+ let m;
117
+ const fk = /(?:FOREIGN\s+KEY\s*\(\s*"?([A-Za-z0-9_]+)"?\s*\)|^\s*"?([A-Za-z0-9_]+)"?\s+[A-Za-z]+[^,\n]*\bREFERENCES\b)/gim;
118
+ while ((m = fk.exec(f.text))) {
119
+ const col = (m[1] || m[2] || '').toLowerCase();
120
+ if (!col || indexed.has(col) || cols.includes(col)) continue;
121
+ if (!at) at = { path: f.path, line: lineOf(f.text, m.index) };
122
+ total++; cols.push(col);
123
+ }
124
+ }
125
+ if (at) out.push(finding('DATA-003',
126
+ `${total} foreign key${total === 1 ? '' : 's'} with no index`, 'high',
127
+ at.path, at.line,
128
+ `Postgres never indexes a foreign key for you. Joins and cascading deletes through ${cols.slice(0, 3).join(', ')} scan the entire table.`,
129
+ `CREATE INDEX ON the table (${cols[0]});`));
130
+ }
131
+ return out;
132
+ }},
133
+
134
+ // ── TEN-002 ─────────────────────────────────────────────────────────────────
135
+ // An index on (created_at, tenant_id) does not help a query filtered by tenant.
136
+ // Only the LEADING column of an index is usable on its own.
137
+ { id: 'TEN-002', run(repo, profile) {
138
+ if (profile.tenancy !== 'multi-tenant-shared-db') return [];
139
+ const TENANT = /^(tenant_?id|org(?:anization)?_?id|workspace_?id|account_?id|company_?id|team_?id)$/i;
140
+ const out = [];
141
+ for (const f of repo.find(/\.(sql|prisma)$/)) {
142
+ const t = f.text; if (!t) continue;
143
+ // does a tenant column exist at all?
144
+ let col = null, at = 0, m;
145
+ const decl = /^\s*"?([A-Za-z0-9_]+)"?\s+(?:String|Int|BigInt|uuid|UUID|text|varchar|integer|bigint)/gmi;
146
+ while ((m = decl.exec(t))) if (TENANT.test(m[1])) { col = m[1]; at = m.index; break; }
147
+ if (!col) continue;
148
+ // is it the leading column of ANY index?
149
+ const leads = new Set();
150
+ const idx = /(?:@@(?:index|unique)\s*\(\s*\[\s*|CREATE\s+(?:UNIQUE\s+)?INDEX[^(]*\(\s*"?)([A-Za-z0-9_]+)/gi;
151
+ while ((m = idx.exec(t))) leads.add(m[1].toLowerCase());
152
+ if (leads.has(col.toLowerCase())) continue;
153
+ out.push(finding('TEN-002', `No index leads with ${col}`, 'medium',
154
+ f.path, lineOf(t, at),
155
+ `Every query in a shared-database product filters by ${col} first. Only the first column of an index can be used on its own, so an index on anything else does not help — each customer's page load reads every customer's rows.`,
156
+ `Add a composite index that starts with ${col}, e.g. (${col}, created_at).`));
157
+ }
158
+ return out;
159
+ }},
160
+
161
+ // ── RU-006 ──────────────────────────────────────────────────────────────────
162
+ // MySQL's "utf8" is utf8mb3 - three bytes, which cannot hold an emoji.
163
+ { id: 'RU-006', run(repo, profile) {
164
+ if (!profile.stack || profile.stack.database === 'none') return [];
165
+ const out = [];
166
+ const BAD = /(?:DEFAULT\s+CHARSET\s*=\s*|CHARACTER\s+SET\s+|charset:\s*|encoding:\s*)utf8(?!mb4)\b|COLLATE\s*=?\s*utf8_(?!mb4)/gi;
167
+ for (const f of repo.files) {
168
+ const t = f.text;
169
+ if (!t || !/\.(sql|ya?ml|cnf|ini|toml)$/.test(f.path)) continue;
170
+ let m; BAD.lastIndex = 0;
171
+ while ((m = BAD.exec(t))) {
172
+ out.push(finding('RU-006', 'Text columns cannot store emoji', 'high',
173
+ f.path, lineOf(t, m.index),
174
+ "MySQL's \"utf8\" is three bytes per character, which is not enough for an emoji, and not enough for a good deal of Chinese, Japanese and Korean. Saving a name with an emoji in it does not truncate — it throws, and the request fails.",
175
+ 'Use utf8mb4 and collation utf8mb4_unicode_ci. Existing tables need converting, not just the default.'));
176
+ break; // one per file is the point; the fix is the same everywhere
177
+ }
178
+ }
179
+ return out;
180
+ }},
181
+
182
+ // ── MOB-004 ─────────────────────────────────────────────────────────────────
183
+ { id: 'MOB-004', run(repo, profile) {
184
+ const s = profile.surface;
185
+ if (!['mobile-ios', 'mobile-android'].includes(s)) return [];
186
+ const out = [];
187
+
188
+ if (s === 'mobile-ios') {
189
+ const plists = repo.find(/Info\.plist$/);
190
+ const plist = plists.map(f => f.text || '').join('\n');
191
+ if (!plists.length) return [];
192
+ const NEEDS = [
193
+ [/AVCaptureDevice|UIImagePickerController|\.camera\b/, 'NSCameraUsageDescription', 'the camera'],
194
+ [/CLLocationManager|requestWhenInUseAuthorization/, 'NSLocationWhenInUseUsageDescription', 'location'],
195
+ [/PHPhotoLibrary|PHPickerViewController/, 'NSPhotoLibraryUsageDescription', 'the photo library'],
196
+ [/AVAudioSession|AVAudioRecorder|requestRecordPermission/, 'NSMicrophoneUsageDescription', 'the microphone'],
197
+ [/CNContactStore/, 'NSContactsUsageDescription', 'contacts'],
198
+ ];
199
+ const code = anyText(repo, /\.(swift|m|mm|h)$/);
200
+ for (const [use, key, human] of NEEDS) {
201
+ if (!use.test(code) || plist.includes(key)) continue;
202
+ out.push(finding('MOB-004', `Uses ${human} with no ${key}`, 'high',
203
+ plists[0].path, 1,
204
+ `iOS kills the app the instant it asks for ${human} without a usage string, and App Review rejects the build before that. This is not a warning — the submission is refused.`,
205
+ `Add <key>${key}</key> to Info.plist with one plain sentence saying why you need it.`));
206
+ }
207
+ return out;
208
+ }
209
+
210
+ // Android: a dangerous permission declared but never requested at runtime
211
+ const manifests = repo.find(/AndroidManifest\.xml$/);
212
+ if (!manifests.length) return [];
213
+ const code = anyText(repo, /\.(kt|java)$/);
214
+ const requestsAtRuntime =
215
+ /requestPermissions|ActivityResultContracts\.RequestPermission|shouldShowRequestPermissionRationale|Manifest\.permission\./.test(code);
216
+ if (requestsAtRuntime) return [];
217
+ const DANGEROUS = /android\.permission\.(CAMERA|ACCESS_FINE_LOCATION|ACCESS_COARSE_LOCATION|RECORD_AUDIO|READ_CONTACTS|READ_EXTERNAL_STORAGE)/;
218
+ for (const f of manifests) {
219
+ const m = (f.text || '').match(DANGEROUS);
220
+ if (!m) continue;
221
+ out.push(finding('MOB-004', `${m[1]} declared but never requested`, 'high',
222
+ f.path, lineOf(f.text, f.text.indexOf(m[0])),
223
+ `Since Android 6 a manifest entry only makes a permission askable — it does not grant it. The first call throws SecurityException and the app crashes, on every device, every time.`,
224
+ 'Request it at runtime and handle the refusal before you use the feature.'));
225
+ break;
226
+ }
227
+ return out;
228
+ }},
229
+
230
+ // ── UP-008 ──────────────────────────────────────────────────────────────────
231
+ // sharp and PIL both drop metadata by default, so their presence is the fix.
232
+ // This only fires when the raw bytes go straight to storage.
233
+ { id: 'UP-008', run(repo, profile) {
234
+ if (!profile.has_file_uploads || profile.data_sensitivity === 'none') return [];
235
+ const deps = depNames(repo);
236
+ const STRIPPERS = ['sharp', 'jimp', 'pillow', 'PIL', 'imagemagick', 'exifr',
237
+ 'piexifjs', 'exif-be-gone', 'image-size', 'squoosh', 'mini-svg-data-uri'];
238
+ if (STRIPPERS.some(d => deps.has(d))) return [];
239
+ const code = anyText(repo, /\.(ts|tsx|js|jsx|mjs|py|rb|php)$/);
240
+ if (/sharp\(|Image\.open|MiniMagick|exiftool|-strip|withoutMetadata|piexif/.test(code)) return [];
241
+
242
+ // does it actually accept a format that carries GPS?
243
+ for (const f of repo.files) {
244
+ const t = f.text;
245
+ if (!t || !/\.(ts|tsx|js|jsx|mjs|py|rb|php)$/.test(f.path)) continue;
246
+ const m = t.match(/(?:image\/jpe?g|image\/heic|\.jpe?g['"]|\.heic['"])/i);
247
+ if (!m) continue;
248
+ if (!/upload|multer|putObject|createReadStream|\.save\(|storage/i.test(t)) continue;
249
+ return [finding('UP-008', 'Photo uploads keep their GPS coordinates', 'medium',
250
+ f.path, lineOf(t, t.indexOf(m[0])),
251
+ 'A photo taken on a phone carries the exact place and time it was taken. Stored and served as-is, anyone who downloads a user\'s picture learns where they live.',
252
+ 'Run uploads through sharp — it drops metadata by default — or strip EXIF before writing.')];
253
+ }
254
+ return [];
255
+ }},
256
+
257
+ // ── API-010 ─────────────────────────────────────────────────────────────────
258
+ // Neither fetch nor python-requests has a default timeout. Nothing times out.
259
+ { id: 'API-010', run(repo, profile) {
260
+ if (profile.stage !== 'production') return [];
261
+ const out = [];
262
+ for (const f of repo.files) {
263
+ const t = f.text; if (!t) continue;
264
+ const py = /\.py$/.test(f.path);
265
+ if (!py && !(isJS(f.path) && isServer(f.path))) continue;
266
+
267
+ const re = py
268
+ ? /\brequests\.(?:get|post|put|patch|delete)\s*\(/g
269
+ : /\b(?:fetch|axios(?:\.(?:get|post|put|patch|delete))?)\s*\(/g;
270
+ let m;
271
+ while ((m = re.exec(t))) {
272
+ const call = callAt(t, m.index);
273
+ // only outbound: an absolute URL or an env-var base. A relative path is
274
+ // this app talking to itself and is somebody else's problem.
275
+ if (!/https?:\/\/|process\.env\.|os\.environ|_URL\b|_ENDPOINT\b|BASE_URL/.test(call)) continue;
276
+ if (/timeout|AbortSignal|signal\s*:|AbortController/.test(call)) continue;
277
+ out.push(finding('API-010', 'Outbound call with no timeout', 'medium',
278
+ f.path, lineOf(t, m.index),
279
+ 'Neither fetch nor requests times out on its own. If the other end stops answering — not refuses, just stops — this waits forever, and so does the user, and so does the worker handling their request.',
280
+ py ? 'Pass timeout=10 to every requests call.'
281
+ : 'Pass signal: AbortSignal.timeout(10_000), or timeout: 10000 for axios.'));
282
+ }
283
+ }
284
+ return out;
285
+ }},
286
+
287
+ // ── AIOP-016 ────────────────────────────────────────────────────────────────
288
+ { id: 'AIOP-016', run(repo, profile) {
289
+ if (!profile.calls_llm) return [];
290
+ const out = [];
291
+ for (const f of repo.files) {
292
+ const t = f.text; if (!t || !isJS(f.path) && !/\.py$/.test(f.path)) continue;
293
+ if (!/anthropic|openai|Anthropic\(|OpenAI\(|messages\.create|chat\.completions/i.test(t)) continue;
294
+ // \b matters: without it, axiosResponse.body matches on its trailing 'e'
295
+ const re = /\b(?:e|err|error|ex)\.(?:message|response|body|detail)\b/g;
296
+ let m;
297
+ while ((m = re.exec(t))) {
298
+ const line = lineAt(t, m.index);
299
+ if (/console\.|logger\.|log\(|logging\.|capture|Sentry/.test(line)) continue; // logging is right
300
+ if (!/(res\.|Response\.json|json\(|send\(|return\s*\{|jsonify|content\s*:|message\s*:)/.test(line)) continue;
301
+ out.push(finding('AIOP-016', 'The provider\'s error text is shown to the user', 'medium',
302
+ f.path, lineOf(t, m.index),
303
+ 'Provider errors name the model, your organisation id, your rate limits and sometimes your account tier. Your user sees your bill, and a stranger learns which vendor to attack.',
304
+ 'Log the real error, show the user one sentence and a reference id.'));
305
+ }
306
+ }
307
+ return out;
308
+ }},
309
+
310
+ // ── NEXT-010 ────────────────────────────────────────────────────────────────
311
+ { id: 'NEXT-010', run(repo, profile) {
312
+ if (profile.stack?.framework !== 'next' || !profile.calls_llm) return [];
313
+ const out = [];
314
+ for (const f of repo.find(/app\/.*\/route\.(ts|js)$|pages\/api\/.*\.(ts|js)$/)) {
315
+ const t = f.text; if (!t) continue;
316
+ if (!/messages\.create|chat\.completions|generateText|streamText|anthropic|openai/i.test(t)) continue;
317
+ // Two routers, two spellings. App Router exports maxDuration/runtime at the
318
+ // top level; Pages Router puts them inside `export const config = {...}`.
319
+ // Only handling the first flagged 77 supabase routes that WERE configured.
320
+ if (/export\s+const\s+(?:maxDuration|runtime|config)\b/.test(t)) continue;
321
+ if (/\b(?:maxDuration|runtime)\s*:/.test(t)) continue;
322
+ out.push(finding('NEXT-010', 'Model call in a route with the default timeout', 'medium',
323
+ f.path, 1,
324
+ 'A serverless function is killed at its default limit — ten seconds on most plans. A model answering a real question routinely takes longer, so the request dies mid-sentence and the user sees a blank failure. You are still billed for the tokens.',
325
+ "Add export const maxDuration = 60 to this file, or stream the response."));
326
+ }
327
+ return out;
328
+ }},
329
+
330
+ // ── NEXT-012 ────────────────────────────────────────────────────────────────
331
+ { id: 'NEXT-012', run(repo, profile) {
332
+ if (profile.stack?.framework !== 'next') return [];
333
+ const out = [];
334
+ for (const f of repo.find(/(^|\/)app\/.*page\.(tsx|jsx|ts|js)$/)) {
335
+ const t = f.text; if (!t) continue;
336
+ if (!/export\s+default\s+async\s+function|await\s+(?:fetch|db|prisma|sql|supabase)/.test(t)) continue;
337
+ if (/<Suspense/.test(t)) continue;
338
+ const dir = f.path.replace(/[^/]+$/, '');
339
+ if (repo.files.some(x => x.path.startsWith(dir) && /loading\.(tsx|jsx|ts|js)$/.test(x.path))) continue;
340
+ out.push(finding('NEXT-012', 'Nothing shown while this page loads its data', 'low',
341
+ f.path, 1,
342
+ 'The browser stays on the previous page with no sign anything happened. On a slow connection people press the link again, or decide it is broken and leave.',
343
+ `Add loading.tsx next to this file, or wrap the data part in <Suspense>.`));
344
+ }
345
+ return out;
346
+ }},
347
+
348
+ // ── INF-002 ─────────────────────────────────────────────────────────────────
349
+ { id: 'INF-002', run(repo, profile) {
350
+ if (profile.stage !== 'production') return [];
351
+ if (!['vps', 'aws', 'gcp'].includes(profile.stack?.host)) return [];
352
+ const out = [];
353
+ for (const f of repo.find(/docker-compose.*\.ya?ml$/)) {
354
+ const t = f.text; if (!t) continue;
355
+ if (/restart\s*:/.test(t) && /healthcheck\s*:/.test(t)) continue;
356
+ const missing = [!/restart\s*:/.test(t) && 'no restart policy',
357
+ !/healthcheck\s*:/.test(t) && 'no healthcheck'].filter(Boolean);
358
+ out.push(finding('INF-002', `Containers have ${missing.join(' and ')}`, 'medium',
359
+ f.path, 1,
360
+ 'On your own server nothing brings the app back. It crashes at three in the morning, stays down until you notice, and no page anywhere says so.',
361
+ 'Add restart: unless-stopped and a healthcheck to each service.'));
362
+ }
363
+ for (const f of repo.find(/\.service$/)) {
364
+ const t = f.text; if (!t || !/\[Service\]/.test(t) || /Restart\s*=/.test(t)) continue;
365
+ out.push(finding('INF-002', 'systemd will not restart this service', 'medium',
366
+ f.path, lineOf(t, t.indexOf('[Service]')),
367
+ 'Without a Restart directive systemd starts the process once. If it exits — a bad deploy, an out-of-memory kill — it stays exited.',
368
+ 'Add Restart=always and RestartSec=5 under [Service].'));
369
+ }
370
+ return out;
371
+ }},
372
+
373
+ // ── OBS-003 ─────────────────────────────────────────────────────────────────
374
+ { id: 'OBS-003', run(repo, profile) {
375
+ if (!['pre-launch', 'production'].includes(profile.stage)) return [];
376
+ if (profile.business_model === 'internal-tool') return [];
377
+ const deps = depNames(repo);
378
+ const LIBS = ['posthog-js', 'posthog-node', '@vercel/analytics', 'plausible-tracker',
379
+ 'mixpanel', 'mixpanel-browser', '@amplitude/analytics-browser', 'analytics',
380
+ '@segment/analytics-next', 'fathom-client', 'umami', 'react-ga4'];
381
+ if (LIBS.some(d => deps.has(d))) return [];
382
+ const html = anyText(repo, /\.(html|tsx|jsx|erb|vue|svelte)$/);
383
+ if (/googletagmanager|gtag\(|plausible\.io|umami\.|posthog|matomo|fathom/i.test(html)) return [];
384
+ const anchor = repo.files.find(f => f.text && /(^|\/)package\.json$/.test(f.path));
385
+ if (!anchor) return [];
386
+ return [finding('OBS-003', 'Nothing measures whether anyone uses this', 'low',
387
+ anchor.path, 1,
388
+ 'You will be able to see that people signed up and not what they did next — which page they left from, which feature nobody opened. Every decision after launch becomes a guess.',
389
+ 'Add one privacy-friendly analytics tool. Plausible and Umami need no cookie banner.')];
390
+ }},
391
+
392
+ // ── UX-006 ──────────────────────────────────────────────────────────────────
393
+ { id: 'UX-006', run(repo, profile) {
394
+ if (!profile.is_public || profile.surface !== 'web-site') return [];
395
+ // the sharp version of this bug: a staging robots.txt shipped to production
396
+ for (const f of repo.find(/(^|\/)robots\.txt$/)) {
397
+ const t = f.text || '';
398
+ if (/^\s*Disallow:\s*\/\s*$/mi.test(t) && !/^\s*Allow:/mi.test(t)) {
399
+ return [finding('UX-006', 'robots.txt hides the whole site from search', 'low',
400
+ f.path, lineOf(t, t.search(/^\s*Disallow:\s*\/\s*$/mi)),
401
+ 'Disallow: / tells every search engine to index nothing. This is almost always a staging file that reached production, and the site simply never appears in Google.',
402
+ 'Remove the blanket Disallow, or scope it to the paths you actually want hidden.')];
403
+ }
404
+ }
405
+ if (repo.has(/(^|\/)sitemap.*\.xml$/) || repo.has(/(^|\/)(app|src)\/sitemap\.(ts|js)$/)) return [];
406
+ const anchor = repo.files.find(f => /(^|\/)(index\.html|package\.json)$/.test(f.path));
407
+ if (!anchor) return [];
408
+ return [finding('UX-006', 'No sitemap', 'low',
409
+ anchor.path, 1,
410
+ 'Search engines find pages by following links. Anything not linked from your home page may never be discovered at all.',
411
+ 'Add a sitemap.xml, or app/sitemap.ts if you are on Next.')];
412
+ }},
413
+
414
+ // ── LEG-003 ─────────────────────────────────────────────────────────────────
415
+ { id: 'LEG-003', run(repo, profile) {
416
+ if (!profile.is_public || !profile.has_accounts) return [];
417
+ if (repo.has(/terms|\/tos[./]|conditions/i)) return [];
418
+ if (repo.grep(/terms of (?:service|use)/i, /\.(tsx|jsx|html|erb|md|vue|svelte)$/).length) return [];
419
+ return [finding('LEG-003', 'People can create accounts with no terms', 'medium',
420
+ 'terms of service', 1,
421
+ 'The terms are what let you close an abusive account, cap what you owe when something breaks, and say the service can change. Without them every one of those is arguable.',
422
+ 'Publish terms and link them from the sign-up form, not just the footer.')];
423
+ }},
424
+
425
+ // ── LEG-006 ─────────────────────────────────────────────────────────────────
426
+ // alt="" is CORRECT for decoration. Only a missing attribute is a failure.
427
+ { id: 'LEG-006', run(repo, profile) {
428
+ if (!['web-app', 'web-site'].includes(profile.surface) || !profile.is_public) return [];
429
+ const out = [];
430
+ for (const f of repo.files) {
431
+ const t = f.text;
432
+ if (!t || !/\.(html|htm|tsx|jsx|erb|vue|svelte)$/.test(f.path)) continue;
433
+ const re = /<img\b[^>]*>/gi;
434
+ let m;
435
+ while ((m = re.exec(t))) {
436
+ const tag = m[0];
437
+ if (/\balt\s*=/.test(tag) || /\baria-hidden\s*=\s*["'{]?true/.test(tag)) continue;
438
+ out.push(finding('LEG-006', 'Image with no alt attribute', 'medium',
439
+ f.path, lineOf(t, m.index),
440
+ 'A screen reader reads the file name instead, which tells a blind visitor nothing. In the US and Australia this is the single most commonly litigated accessibility failure, and the fix is one attribute.',
441
+ 'Add alt="what the image shows". For purely decorative images use alt="" — empty is correct, missing is not.'));
442
+ }
443
+ }
444
+ return out;
445
+ }},
446
+
447
+ // ── DEP-016 ─────────────────────────────────────────────────────────────────
448
+ // "Gating merges" means running on pull_request. A push-only workflow tells you
449
+ // about the breakage after it is already on main.
450
+ { id: 'DEP-016', run(repo, profile) {
451
+ if (!profile.has_ci) return [];
452
+ if (!['b2b-saas', 'marketplace'].includes(profile.business_model)) return [];
453
+ const wf = repo.find(/\.github\/workflows\/.*\.ya?ml$/);
454
+ if (!wf.length) return [];
455
+ const runsTests = wf.some(f => /\b(?:test|jest|vitest|pytest|rspec|go test|cargo test)\b/i.test(f.text || ''));
456
+ if (!runsTests) return [];
457
+ if (wf.some(f => /pull_request/.test(f.text || ''))) return [];
458
+ return [finding('DEP-016', 'CI runs after the merge, not before it', 'low',
459
+ wf[0].path, 1,
460
+ 'These workflows trigger on push only. The tests do run — but they run once the broken code is already on main, so nothing was ever stopped from landing.',
461
+ 'Add pull_request: to the on: trigger, then make the check required in branch protection.')];
462
+ }},
463
+
464
+ // ── AIOP-020 ────────────────────────────────────────────────────────────────
465
+ // The rule was written as "temperature unset for a deterministic task". That
466
+ // advice is now actively wrong: temperature is REMOVED on Opus 5, Sonnet 5,
467
+ // Opus 4.8/4.7 and Fable 5 - passing it returns a 400. The real defect is the
468
+ // same one temperature was a poor proxy for: parsing free text as JSON and
469
+ // hoping. Structured output is the fix that works on every current model.
470
+ { id: 'AIOP-020', run(repo, profile) {
471
+ if (!profile.calls_llm) return [];
472
+ const out = [];
473
+ for (const f of repo.files) {
474
+ const t = f.text; if (!t) continue;
475
+ const py = /\.py$/.test(f.path);
476
+ if (!py && !/\.(ts|tsx|js|jsx|mjs)$/.test(f.path)) continue;
477
+ // must be a real SDK call - "message" and "content" are not evidence
478
+ if (!/messages\.create|chat\.completions\.create|messages\.stream|messages\.parse|generateObject|generateText/.test(t)) continue;
479
+ // already constrained? then there is nothing to say
480
+ if (/output_config|response_format|output_format|strict\s*:\s*true|zodResponseFormat|messages\.parse|generateObject|tool_choice/.test(t)) continue;
481
+
482
+ const re = py ? /json\.loads\s*\(/g : /JSON\.parse\s*\(/g;
483
+ let m;
484
+ while ((m = re.exec(t))) {
485
+ const call = callAt(t, m.index, 160);
486
+ // the argument has to come off the model response, not off a file or a body
487
+ if (!/\.(?:content|text|output_text)\b|completion|choices\[/.test(call)) continue;
488
+ out.push(finding('AIOP-020', 'Model output parsed as JSON with nothing enforcing the shape', 'low',
489
+ f.path, lineOf(t, m.index),
490
+ 'Nothing guarantees the reply is valid JSON. It usually is, which is worse than never — the failure arrives at random, in production, on the request you cannot reproduce.',
491
+ 'Constrain the response instead of hoping: output_config.format with a schema, or a tool with strict: true. (Do not reach for temperature — it is removed on the current Claude models and returns a 400.)'));
492
+ }
493
+ }
494
+ return out;
495
+ }},
496
+
497
+ // ── DEP-011 ─────────────────────────────────────────────────────────────────
498
+ // The rule says "audit surfaces advisories at high or critical". We cannot run
499
+ // an audit - that is execution and network, and the scanner does neither. What
500
+ // IS statically true, and never goes stale, is whether anything at all would
501
+ // tell you. Two ways to be blind: not knowing what you installed, or knowing
502
+ // and having nobody watching it.
503
+ { id: 'DEP-011', scope: 'root', run(repo) {
504
+ // scope:'root' matters. The lockfile lives at the repo root and nowhere
505
+ // else; cal.com's yarn.lock is exactly where it belongs. Run per-workspace
506
+ // and this fires on every package in every monorepo - 133 times in the
507
+ // audit corpus, all of them wrong.
508
+ const hasManifest = repo.has(/(^|\/)package\.json$/);
509
+ if (!hasManifest) return [];
510
+ const LOCKS = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|bun\.lockb?)$/;
511
+ if (!repo.has(LOCKS)) {
512
+ return [finding('DEP-011', 'No lockfile — you cannot tell what you deployed', 'high',
513
+ 'package.json', 1,
514
+ 'Without a lockfile each install resolves versions afresh, so the code on your server is not the code you tested, and when an advisory lands nobody can say whether it applies to you.',
515
+ 'Commit the lockfile your package manager produces. It belongs in git.')];
516
+ }
517
+ const watched =
518
+ repo.has(/\.github\/dependabot\.ya?ml$/) ||
519
+ repo.has(/(^|\/)renovate\.json5?$/) ||
520
+ repo.has(/\.github\/renovate\.json5?$/) ||
521
+ repo.find(/\.github\/workflows\/.*\.ya?ml$/)
522
+ .some(f => /npm audit|pnpm audit|yarn audit|snyk|osv-scanner|trivy|dependency-review/i.test(f.text || ''));
523
+ if (watched) return [];
524
+ return [finding('DEP-011', 'Nothing watches your dependencies for advisories', 'high',
525
+ 'package.json', 1,
526
+ 'A vulnerability will be published in something you depend on. That is not a risk, it is a schedule. Right now there is no mechanism by which you would find out — not a mail, not a failing build, nothing.',
527
+ 'Add .github/dependabot.yml, or Renovate, or a dependency-review step in CI. One file, and it tells you the day it happens.')];
528
+ }},
529
+ ];