getadvantage 0.1.0 → 0.2.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/checks.mjs CHANGED
@@ -1,327 +1,327 @@
1
- // Ship-Safe — the read-only pre-deploy checks.
2
- //
3
- // Every check returns a `result(status, label, detail, extra)`:
4
- // pass (✓) · warn (⚠) · fail (✗ → NO-GO).
5
- // Nothing here mutates the repo. The secret patterns + dirty-tree reasoning are
6
- // lifted from getAdvantage's own conventions (CLAUDE.md hard-rule #2 and
7
- // app/lib/safety.ts) so the gate matches what the project already enforces.
8
-
9
- import { execFileSync } from "node:child_process";
10
- import { readFileSync, statSync } from "node:fs";
11
- import path from "node:path";
12
- import { result, fingerprint, git, gitRaw, gitSafe } from "./util.mjs";
13
-
14
- // ===========================================================================
15
- // a. DIRTY-TREE GUARD
16
- // ===========================================================================
17
- // `vercel --prod` ships the WORKING TREE, not a commit — so any tracked,
18
- // modified/staged file (possibly another concurrent session's uncommitted
19
- // work) would ship live. BLOCK on tracked changes; WARN on untracked (often
20
- // scratch). This is the single biggest known foot-gun in this repo.
21
- export function checkDirtyTree(cwd) {
22
- // gitRaw (NOT git) — porcelain's leading status columns are space-significant,
23
- // so we must not trim the output before parsing.
24
- const porcelain = gitRaw(["status", "--porcelain"], { cwd });
25
- if (!porcelain.trim()) {
26
- return result("pass", "Dirty-tree guard", "Working tree is clean — nothing unintended would ship.");
27
- }
28
-
29
- const lines = porcelain.split("\n").filter((l) => l.length > 0);
30
- const tracked = []; // modified / staged / renamed / deleted tracked files
31
- const untracked = []; // ?? — new files git isn't tracking yet
32
-
33
- for (const line of lines) {
34
- // Porcelain v1: XY<space>path (X=staged, Y=worktree). "??" = untracked.
35
- const xy = line.slice(0, 2);
36
- const file = line.slice(3);
37
- if (xy === "??") untracked.push(file);
38
- else tracked.push(`${xy.trim() || xy} ${file}`);
39
- }
40
-
41
- if (tracked.length > 0) {
42
- return result(
43
- "fail",
44
- "Dirty-tree guard",
45
- `${tracked.length} tracked file(s) modified/staged — a 'vercel --prod' would ship this unintended work.`,
46
- [
47
- ...tracked.slice(0, 20).map((t) => t),
48
- ...(tracked.length > 20 ? [`…and ${tracked.length - 20} more`] : []),
49
- "Commit, stash, or revert before shipping. Deploy from a clean detached worktree of the intended commit.",
50
- ],
51
- );
52
- }
53
-
54
- // Only untracked files → warn, list them (they're usually scratch/logs).
55
- return result(
56
- "warn",
57
- "Dirty-tree guard",
58
- `${untracked.length} untracked file(s) present (not tracked — likely scratch, but confirm they shouldn't ship).`,
59
- [
60
- ...untracked.slice(0, 20),
61
- ...(untracked.length > 20 ? [`…and ${untracked.length - 20} more`] : []),
62
- ],
63
- );
64
- }
65
-
66
- // ===========================================================================
67
- // b. SECRET SCAN
68
- // ===========================================================================
69
- // Patterns reuse getAdvantage's own definitions:
70
- // • CLAUDE.md hard-rule #2: sk-proj / sk-live / sk_live / whsec_ / vcp_ /
71
- // KV_REST / "Bearer " / long token literals.
72
- // • app/lib/safety.ts SECRET_PATTERNS: OpenAI sk-, Stripe sk_live_/rk_live_,
73
- // AWS AKIA, GitHub PAT (classic + fine-grained), Google OAuth, Slack,
74
- // SendGrid, private-key blocks.
75
- // A match BLOCKS (✗); we print the file + a masked FINGERPRINT, never the
76
- // full secret. Binary/lockfiles/node_modules/.git are skipped.
77
-
78
- const SECRET_PATTERNS = [
79
- // --- from app/lib/safety.ts ---
80
- { id: "openai", label: "OpenAI secret key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/ },
81
- { id: "stripe-live", label: "Stripe live secret key", re: /\bsk_live_[A-Za-z0-9]{20,}/ },
82
- { id: "stripe-restricted", label: "Stripe restricted key", re: /\brk_live_[A-Za-z0-9]{20,}/ },
83
- { id: "aws", label: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
84
- { id: "github-pat", label: "GitHub personal access token", re: /\bghp_[A-Za-z0-9]{36}\b/ },
85
- { id: "github-fine", label: "GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/ },
86
- { id: "google-oauth", label: "Google OAuth secret", re: /\bGOCSPX-[A-Za-z0-9_-]{20,}\b/ },
87
- { id: "slack", label: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
88
- { id: "private-key", label: "Private key block", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
89
- { id: "sendgrid", label: "SendGrid key", re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/ },
90
- // --- from CLAUDE.md hard-rule #2 (the pre-commit scan literals) ---
91
- { id: "stripe-webhook", label: "Stripe webhook secret (whsec_)", re: /\bwhsec_[A-Za-z0-9]{20,}/ },
92
- { id: "vercel-token", label: "Vercel token (vcp_)", re: /\bvcp_[A-Za-z0-9]{20,}/ },
93
- { id: "kv-rest", label: "KV/Redis REST credential", re: /\bKV_REST_API_(?:URL|TOKEN|READ_ONLY_TOKEN)\s*=\s*\S+/ },
94
- // "Bearer <token>" literal. The CLAUDE.md pre-commit scan lists "Bearer " as a
95
- // tell. We capture the token and only flag it if it LOOKS like a real
96
- // credential (mixed case + a digit, ≥20 chars) — so legitimate test fixtures
97
- // and placeholders like `Bearer test_cron_secret...` or `Bearer ${token}`
98
- // don't trip a false NO-GO. The `validate` predicate runs on the captured group.
99
- {
100
- id: "bearer",
101
- label: "Bearer auth token literal",
102
- re: /\bBearer\s+([A-Za-z0-9_\-.]{20,})/,
103
- validate: (tok) => /[a-z]/.test(tok) && /[A-Z]/.test(tok) && /[0-9]/.test(tok),
104
- },
105
- ];
106
-
107
- // Files we never scan (binary-ish, generated, vendored, or the env files that
108
- // are gitignored by design and legitimately hold secrets locally).
109
- const SKIP_DIR = new Set([".git", "node_modules", ".next", ".vercel", ".data", "dist", "build", "coverage"]);
110
- const SKIP_BASENAME = new Set([
111
- "package-lock.json",
112
- "pnpm-lock.yaml",
113
- "yarn.lock",
114
- ".env",
115
- ".env.local",
116
- ".env.development.local",
117
- ".env.production.local",
118
- ]);
119
- const SKIP_EXT = new Set([
120
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".pdf",
121
- ".woff", ".woff2", ".ttf", ".eot", ".mp4", ".webm", ".zip", ".gz",
122
- ".lock", ".map",
123
- ]);
124
-
125
- const MAX_FILE_BYTES = 2_000_000; // skip very large files (same cap as safety.ts corpus)
126
-
127
- /** Heuristic: does this buffer look binary? (null byte in the first 4KB) */
128
- function looksBinary(buf) {
129
- const n = Math.min(buf.length, 4096);
130
- for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
131
- return false;
132
- }
133
-
134
- /** Files to scan: tracked + staged + (added) untracked-but-not-ignored, deduped.
135
- * We deliberately DON'T scan gitignored files (the local .env lives there and
136
- * is supposed to hold secrets). */
137
- function filesToScan(cwd) {
138
- const set = new Set();
139
- // Tracked files.
140
- for (const f of gitSafe(["ls-files"], { cwd }).split("\n")) if (f) set.add(f);
141
- // Untracked-but-not-ignored (new files a dev created; --others honours .gitignore).
142
- for (const f of gitSafe(["ls-files", "--others", "--exclude-standard"], { cwd }).split("\n")) if (f) set.add(f);
143
- return [...set];
144
- }
145
-
146
- export function checkSecrets(cwd) {
147
- const files = filesToScan(cwd);
148
- const hits = []; // { file, label, fp }
149
-
150
- for (const rel of files) {
151
- const base = path.basename(rel);
152
- const ext = path.extname(rel).toLowerCase();
153
- if (SKIP_BASENAME.has(base)) continue;
154
- if (SKIP_EXT.has(ext)) continue;
155
- // Skip anything inside a skipped directory.
156
- if (rel.split(/[\\/]/).some((seg) => SKIP_DIR.has(seg))) continue;
157
-
158
- const abs = path.join(cwd, rel);
159
- let buf;
160
- try {
161
- const st = statSync(abs);
162
- if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
163
- buf = readFileSync(abs);
164
- } catch {
165
- continue; // unreadable / deleted-but-staged etc.
166
- }
167
- if (looksBinary(buf)) continue;
168
- const text = buf.toString("utf8");
169
-
170
- for (const p of SECRET_PATTERNS) {
171
- const m = text.match(p.re);
172
- if (!m) continue;
173
- // If the pattern has a capture group + validator (e.g. Bearer), apply the
174
- // validator to the captured token so test fixtures/placeholders don't trip.
175
- const token = m[1] ?? m[0];
176
- if (p.validate && !p.validate(token)) continue;
177
- hits.push({ file: rel, label: p.label, fp: fingerprint(token) });
178
- }
179
- }
180
-
181
- if (hits.length === 0) {
182
- return result(
183
- "pass",
184
- "Secret scan",
185
- `Scanned ${files.length} tracked/staged file(s) — no leaked-secret patterns matched.`,
186
- );
187
- }
188
-
189
- // De-dupe identical (file,label) pairs for a tidy report.
190
- const seen = new Set();
191
- const lines = [];
192
- for (const h of hits) {
193
- const k = `${h.file}::${h.label}`;
194
- if (seen.has(k)) continue;
195
- seen.add(k);
196
- lines.push(`${h.file} → ${h.label}: ${h.fp}`);
197
- }
198
- return result(
199
- "fail",
200
- "Secret scan",
201
- `${lines.length} possible secret(s) in committed/staged files — remove + rotate before shipping.`,
202
- lines.slice(0, 30),
203
- );
204
- }
205
-
206
- // ===========================================================================
207
- // c. BUILD + TYPECHECK
208
- // ===========================================================================
209
- // Default: `npx tsc --noEmit` (fast). `--build` also runs `npm run build`.
210
- // BLOCK on either failing; print the tail of the output so the founder sees
211
- // the actual error without scrollback.
212
-
213
- function runCapture(cmd, args, cwd) {
214
- try {
215
- const out = execFileSync(cmd, args, {
216
- cwd,
217
- encoding: "utf8",
218
- stdio: ["ignore", "pipe", "pipe"],
219
- maxBuffer: 32 * 1024 * 1024,
220
- // On Windows npx/tsc are .cmd shims — shell:true lets them resolve.
221
- shell: process.platform === "win32",
222
- });
223
- return { ok: true, out };
224
- } catch (e) {
225
- const out = `${e.stdout || ""}${e.stderr || ""}`.trim();
226
- return { ok: false, out: out || String(e.message || e) };
227
- }
228
- }
229
-
230
- function tail(text, n = 25) {
231
- const lines = text.split("\n").filter(Boolean);
232
- return lines.slice(-n);
233
- }
234
-
235
- export function checkTypecheck(cwd) {
236
- const r = runCapture("npx", ["--yes", "tsc", "--noEmit"], cwd);
237
- if (r.ok) {
238
- return result("pass", "Typecheck (tsc --noEmit)", "TypeScript compiled with no type errors.");
239
- }
240
- return result(
241
- "fail",
242
- "Typecheck (tsc --noEmit)",
243
- "tsc reported type errors — fix them before shipping.",
244
- tail(r.out, 25),
245
- );
246
- }
247
-
248
- export function checkBuild(cwd) {
249
- const r = runCapture("npm", ["run", "build"], cwd);
250
- if (r.ok) {
251
- return result("pass", "Production build (npm run build)", "Next build completed successfully.");
252
- }
253
- return result(
254
- "fail",
255
- "Production build (npm run build)",
256
- "The production build failed — fix it before shipping.",
257
- tail(r.out, 30),
258
- );
259
- }
260
-
261
- // ===========================================================================
262
- // d. SCHEMA-BUMP CHECK
263
- // ===========================================================================
264
- // db.ts uses a SCHEMA_VERSION sentinel: additive DDL is SKIPPED on an existing
265
- // prod DB whose stored version is already >= SCHEMA_VERSION. So a DDL change
266
- // that forgets to bump SCHEMA_VERSION builds + passes every proof on a fresh
267
- // PGlite DB, then silently no-ops in prod. We diff db.ts (committed vs the
268
- // merge-base with main, PLUS any uncommitted edits): if DDL-ish lines changed
269
- // but the `const SCHEMA_VERSION = N;` line did NOT, WARN loudly.
270
-
271
- const DB_PATH = "app/lib/server/db.ts";
272
- const DDL_RE = /\b(CREATE\s+TABLE|ADD\s+COLUMN|ALTER\s+TABLE|CREATE\s+(?:UNIQUE\s+)?INDEX|DROP\s+(?:TABLE|COLUMN|INDEX))\b/i;
273
- const VERSION_RE = /SCHEMA_VERSION\s*=\s*\d+/;
274
-
275
- export function checkSchemaBump(cwd, baseRef = "main") {
276
- // Combined diff = (merge-base…HEAD committed) + (working-tree uncommitted).
277
- // Use `git diff base...HEAD` for the committed delta on this lane, then a
278
- // plain `git diff HEAD` for anything not yet committed. We never EDIT db.ts —
279
- // this only READS the diff.
280
- const haveBase = !!gitSafe(["rev-parse", "--verify", "--quiet", baseRef], { cwd });
281
- let committedDiff = "";
282
- if (haveBase) {
283
- // base...HEAD = changes on HEAD since it diverged from base (symmetric range).
284
- committedDiff = gitSafe(["diff", `${baseRef}...HEAD`, "--", DB_PATH], { cwd });
285
- }
286
- const uncommittedDiff = gitSafe(["diff", "HEAD", "--", DB_PATH], { cwd });
287
- const combined = `${committedDiff}\n${uncommittedDiff}`;
288
-
289
- // Only consider ADDED/REMOVED lines (diff bodies, prefixed +/-), not context.
290
- const changedLines = combined
291
- .split("\n")
292
- .filter((l) => /^[+-]/.test(l) && !/^[+-]{3}\s/.test(l)); // skip +++/--- headers
293
-
294
- if (changedLines.length === 0) {
295
- if (!haveBase) {
296
- return result(
297
- "pass",
298
- "Schema-bump check",
299
- `No changes to ${DB_PATH} in the working tree (base ref '${baseRef}' not found, so the committed lane delta was skipped).`,
300
- );
301
- }
302
- return result("pass", "Schema-bump check", `No changes to ${DB_PATH} vs ${baseRef}.`);
303
- }
304
-
305
- const ddlChanged = changedLines.some((l) => DDL_RE.test(l));
306
- const versionChanged = changedLines.some((l) => VERSION_RE.test(l));
307
-
308
- if (ddlChanged && !versionChanged) {
309
- return result(
310
- "warn",
311
- "Schema-bump check",
312
- `${DB_PATH} has DDL-ish changes but SCHEMA_VERSION was NOT bumped.`,
313
- [
314
- "Additive DDL (CREATE TABLE / ADD COLUMN / ALTER / CREATE INDEX) is SKIPPED on an existing prod DB",
315
- "whose stored version already matches — so it builds + passes proofs on fresh PGlite, then silently",
316
- "no-ops in prod. Bump `const SCHEMA_VERSION` (and coordinate the migration). See the schema-version memory.",
317
- ...changedLines.filter((l) => DDL_RE.test(l)).slice(0, 6).map((l) => l.trim()),
318
- ],
319
- );
320
- }
321
-
322
- if (ddlChanged && versionChanged) {
323
- return result("pass", "Schema-bump check", `${DB_PATH} changed DDL and SCHEMA_VERSION was bumped alongside it.`);
324
- }
325
-
326
- return result("pass", "Schema-bump check", `${DB_PATH} changed but no DDL-shaped statements were touched.`);
327
- }
1
+ // Ship-Safe — the read-only pre-deploy checks.
2
+ //
3
+ // Every check returns a `result(status, label, detail, extra)`:
4
+ // pass (✓) · warn (⚠) · fail (✗ → NO-GO).
5
+ // Nothing here mutates the repo. The secret patterns + dirty-tree reasoning are
6
+ // lifted from getAdvantage's own conventions (CLAUDE.md hard-rule #2 and
7
+ // app/lib/safety.ts) so the gate matches what the project already enforces.
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { readFileSync, statSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { result, fingerprint, git, gitRaw, gitSafe } from "./util.mjs";
13
+
14
+ // ===========================================================================
15
+ // a. DIRTY-TREE GUARD
16
+ // ===========================================================================
17
+ // `vercel --prod` ships the WORKING TREE, not a commit — so any tracked,
18
+ // modified/staged file (possibly another concurrent session's uncommitted
19
+ // work) would ship live. BLOCK on tracked changes; WARN on untracked (often
20
+ // scratch). This is the single biggest known foot-gun in this repo.
21
+ export function checkDirtyTree(cwd) {
22
+ // gitRaw (NOT git) — porcelain's leading status columns are space-significant,
23
+ // so we must not trim the output before parsing.
24
+ const porcelain = gitRaw(["status", "--porcelain"], { cwd });
25
+ if (!porcelain.trim()) {
26
+ return result("pass", "Dirty-tree guard", "Working tree is clean — nothing unintended would ship.");
27
+ }
28
+
29
+ const lines = porcelain.split("\n").filter((l) => l.length > 0);
30
+ const tracked = []; // modified / staged / renamed / deleted tracked files
31
+ const untracked = []; // ?? — new files git isn't tracking yet
32
+
33
+ for (const line of lines) {
34
+ // Porcelain v1: XY<space>path (X=staged, Y=worktree). "??" = untracked.
35
+ const xy = line.slice(0, 2);
36
+ const file = line.slice(3);
37
+ if (xy === "??") untracked.push(file);
38
+ else tracked.push(`${xy.trim() || xy} ${file}`);
39
+ }
40
+
41
+ if (tracked.length > 0) {
42
+ return result(
43
+ "fail",
44
+ "Dirty-tree guard",
45
+ `${tracked.length} tracked file(s) modified/staged — a 'vercel --prod' would ship this unintended work.`,
46
+ [
47
+ ...tracked.slice(0, 20).map((t) => t),
48
+ ...(tracked.length > 20 ? [`…and ${tracked.length - 20} more`] : []),
49
+ "Commit, stash, or revert before shipping. Deploy from a clean detached worktree of the intended commit.",
50
+ ],
51
+ );
52
+ }
53
+
54
+ // Only untracked files → warn, list them (they're usually scratch/logs).
55
+ return result(
56
+ "warn",
57
+ "Dirty-tree guard",
58
+ `${untracked.length} untracked file(s) present (not tracked — likely scratch, but confirm they shouldn't ship).`,
59
+ [
60
+ ...untracked.slice(0, 20),
61
+ ...(untracked.length > 20 ? [`…and ${untracked.length - 20} more`] : []),
62
+ ],
63
+ );
64
+ }
65
+
66
+ // ===========================================================================
67
+ // b. SECRET SCAN
68
+ // ===========================================================================
69
+ // Patterns reuse getAdvantage's own definitions:
70
+ // • CLAUDE.md hard-rule #2: sk-proj / sk-live / sk_live / whsec_ / vcp_ /
71
+ // KV_REST / "Bearer " / long token literals.
72
+ // • app/lib/safety.ts SECRET_PATTERNS: OpenAI sk-, Stripe sk_live_/rk_live_,
73
+ // AWS AKIA, GitHub PAT (classic + fine-grained), Google OAuth, Slack,
74
+ // SendGrid, private-key blocks.
75
+ // A match BLOCKS (✗); we print the file + a masked FINGERPRINT, never the
76
+ // full secret. Binary/lockfiles/node_modules/.git are skipped.
77
+
78
+ const SECRET_PATTERNS = [
79
+ // --- from app/lib/safety.ts ---
80
+ { id: "openai", label: "OpenAI secret key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/ },
81
+ { id: "stripe-live", label: "Stripe live secret key", re: /\bsk_live_[A-Za-z0-9]{20,}/ },
82
+ { id: "stripe-restricted", label: "Stripe restricted key", re: /\brk_live_[A-Za-z0-9]{20,}/ },
83
+ { id: "aws", label: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
84
+ { id: "github-pat", label: "GitHub personal access token", re: /\bghp_[A-Za-z0-9]{36}\b/ },
85
+ { id: "github-fine", label: "GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/ },
86
+ { id: "google-oauth", label: "Google OAuth secret", re: /\bGOCSPX-[A-Za-z0-9_-]{20,}\b/ },
87
+ { id: "slack", label: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
88
+ { id: "private-key", label: "Private key block", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
89
+ { id: "sendgrid", label: "SendGrid key", re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/ },
90
+ // --- from CLAUDE.md hard-rule #2 (the pre-commit scan literals) ---
91
+ { id: "stripe-webhook", label: "Stripe webhook secret (whsec_)", re: /\bwhsec_[A-Za-z0-9]{20,}/ },
92
+ { id: "vercel-token", label: "Vercel token (vcp_)", re: /\bvcp_[A-Za-z0-9]{20,}/ },
93
+ { id: "kv-rest", label: "KV/Redis REST credential", re: /\bKV_REST_API_(?:URL|TOKEN|READ_ONLY_TOKEN)\s*=\s*\S+/ },
94
+ // "Bearer <token>" literal. The CLAUDE.md pre-commit scan lists "Bearer " as a
95
+ // tell. We capture the token and only flag it if it LOOKS like a real
96
+ // credential (mixed case + a digit, ≥20 chars) — so legitimate test fixtures
97
+ // and placeholders like `Bearer test_cron_secret...` or `Bearer ${token}`
98
+ // don't trip a false NO-GO. The `validate` predicate runs on the captured group.
99
+ {
100
+ id: "bearer",
101
+ label: "Bearer auth token literal",
102
+ re: /\bBearer\s+([A-Za-z0-9_\-.]{20,})/,
103
+ validate: (tok) => /[a-z]/.test(tok) && /[A-Z]/.test(tok) && /[0-9]/.test(tok),
104
+ },
105
+ ];
106
+
107
+ // Files we never scan (binary-ish, generated, vendored, or the env files that
108
+ // are gitignored by design and legitimately hold secrets locally).
109
+ const SKIP_DIR = new Set([".git", "node_modules", ".next", ".vercel", ".data", "dist", "build", "coverage"]);
110
+ const SKIP_BASENAME = new Set([
111
+ "package-lock.json",
112
+ "pnpm-lock.yaml",
113
+ "yarn.lock",
114
+ ".env",
115
+ ".env.local",
116
+ ".env.development.local",
117
+ ".env.production.local",
118
+ ]);
119
+ const SKIP_EXT = new Set([
120
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".pdf",
121
+ ".woff", ".woff2", ".ttf", ".eot", ".mp4", ".webm", ".zip", ".gz",
122
+ ".lock", ".map",
123
+ ]);
124
+
125
+ const MAX_FILE_BYTES = 2_000_000; // skip very large files (same cap as safety.ts corpus)
126
+
127
+ /** Heuristic: does this buffer look binary? (null byte in the first 4KB) */
128
+ function looksBinary(buf) {
129
+ const n = Math.min(buf.length, 4096);
130
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
131
+ return false;
132
+ }
133
+
134
+ /** Files to scan: tracked + staged + (added) untracked-but-not-ignored, deduped.
135
+ * We deliberately DON'T scan gitignored files (the local .env lives there and
136
+ * is supposed to hold secrets). */
137
+ function filesToScan(cwd) {
138
+ const set = new Set();
139
+ // Tracked files.
140
+ for (const f of gitSafe(["ls-files"], { cwd }).split("\n")) if (f) set.add(f);
141
+ // Untracked-but-not-ignored (new files a dev created; --others honours .gitignore).
142
+ for (const f of gitSafe(["ls-files", "--others", "--exclude-standard"], { cwd }).split("\n")) if (f) set.add(f);
143
+ return [...set];
144
+ }
145
+
146
+ export function checkSecrets(cwd) {
147
+ const files = filesToScan(cwd);
148
+ const hits = []; // { file, label, fp }
149
+
150
+ for (const rel of files) {
151
+ const base = path.basename(rel);
152
+ const ext = path.extname(rel).toLowerCase();
153
+ if (SKIP_BASENAME.has(base)) continue;
154
+ if (SKIP_EXT.has(ext)) continue;
155
+ // Skip anything inside a skipped directory.
156
+ if (rel.split(/[\\/]/).some((seg) => SKIP_DIR.has(seg))) continue;
157
+
158
+ const abs = path.join(cwd, rel);
159
+ let buf;
160
+ try {
161
+ const st = statSync(abs);
162
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
163
+ buf = readFileSync(abs);
164
+ } catch {
165
+ continue; // unreadable / deleted-but-staged etc.
166
+ }
167
+ if (looksBinary(buf)) continue;
168
+ const text = buf.toString("utf8");
169
+
170
+ for (const p of SECRET_PATTERNS) {
171
+ const m = text.match(p.re);
172
+ if (!m) continue;
173
+ // If the pattern has a capture group + validator (e.g. Bearer), apply the
174
+ // validator to the captured token so test fixtures/placeholders don't trip.
175
+ const token = m[1] ?? m[0];
176
+ if (p.validate && !p.validate(token)) continue;
177
+ hits.push({ file: rel, label: p.label, fp: fingerprint(token) });
178
+ }
179
+ }
180
+
181
+ if (hits.length === 0) {
182
+ return result(
183
+ "pass",
184
+ "Secret scan",
185
+ `Scanned ${files.length} tracked/staged file(s) — no leaked-secret patterns matched.`,
186
+ );
187
+ }
188
+
189
+ // De-dupe identical (file,label) pairs for a tidy report.
190
+ const seen = new Set();
191
+ const lines = [];
192
+ for (const h of hits) {
193
+ const k = `${h.file}::${h.label}`;
194
+ if (seen.has(k)) continue;
195
+ seen.add(k);
196
+ lines.push(`${h.file} → ${h.label}: ${h.fp}`);
197
+ }
198
+ return result(
199
+ "fail",
200
+ "Secret scan",
201
+ `${lines.length} possible secret(s) in committed/staged files — remove + rotate before shipping.`,
202
+ lines.slice(0, 30),
203
+ );
204
+ }
205
+
206
+ // ===========================================================================
207
+ // c. BUILD + TYPECHECK
208
+ // ===========================================================================
209
+ // Default: `npx tsc --noEmit` (fast). `--build` also runs `npm run build`.
210
+ // BLOCK on either failing; print the tail of the output so the founder sees
211
+ // the actual error without scrollback.
212
+
213
+ function runCapture(cmd, args, cwd) {
214
+ try {
215
+ const out = execFileSync(cmd, args, {
216
+ cwd,
217
+ encoding: "utf8",
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ maxBuffer: 32 * 1024 * 1024,
220
+ // On Windows npx/tsc are .cmd shims — shell:true lets them resolve.
221
+ shell: process.platform === "win32",
222
+ });
223
+ return { ok: true, out };
224
+ } catch (e) {
225
+ const out = `${e.stdout || ""}${e.stderr || ""}`.trim();
226
+ return { ok: false, out: out || String(e.message || e) };
227
+ }
228
+ }
229
+
230
+ function tail(text, n = 25) {
231
+ const lines = text.split("\n").filter(Boolean);
232
+ return lines.slice(-n);
233
+ }
234
+
235
+ export function checkTypecheck(cwd) {
236
+ const r = runCapture("npx", ["--yes", "tsc", "--noEmit"], cwd);
237
+ if (r.ok) {
238
+ return result("pass", "Typecheck (tsc --noEmit)", "TypeScript compiled with no type errors.");
239
+ }
240
+ return result(
241
+ "fail",
242
+ "Typecheck (tsc --noEmit)",
243
+ "tsc reported type errors — fix them before shipping.",
244
+ tail(r.out, 25),
245
+ );
246
+ }
247
+
248
+ export function checkBuild(cwd) {
249
+ const r = runCapture("npm", ["run", "build"], cwd);
250
+ if (r.ok) {
251
+ return result("pass", "Production build (npm run build)", "Next build completed successfully.");
252
+ }
253
+ return result(
254
+ "fail",
255
+ "Production build (npm run build)",
256
+ "The production build failed — fix it before shipping.",
257
+ tail(r.out, 30),
258
+ );
259
+ }
260
+
261
+ // ===========================================================================
262
+ // d. SCHEMA-BUMP CHECK
263
+ // ===========================================================================
264
+ // db.ts uses a SCHEMA_VERSION sentinel: additive DDL is SKIPPED on an existing
265
+ // prod DB whose stored version is already >= SCHEMA_VERSION. So a DDL change
266
+ // that forgets to bump SCHEMA_VERSION builds + passes every proof on a fresh
267
+ // PGlite DB, then silently no-ops in prod. We diff db.ts (committed vs the
268
+ // merge-base with main, PLUS any uncommitted edits): if DDL-ish lines changed
269
+ // but the `const SCHEMA_VERSION = N;` line did NOT, WARN loudly.
270
+
271
+ const DB_PATH = "app/lib/server/db.ts";
272
+ const DDL_RE = /\b(CREATE\s+TABLE|ADD\s+COLUMN|ALTER\s+TABLE|CREATE\s+(?:UNIQUE\s+)?INDEX|DROP\s+(?:TABLE|COLUMN|INDEX))\b/i;
273
+ const VERSION_RE = /SCHEMA_VERSION\s*=\s*\d+/;
274
+
275
+ export function checkSchemaBump(cwd, baseRef = "main") {
276
+ // Combined diff = (merge-base…HEAD committed) + (working-tree uncommitted).
277
+ // Use `git diff base...HEAD` for the committed delta on this lane, then a
278
+ // plain `git diff HEAD` for anything not yet committed. We never EDIT db.ts —
279
+ // this only READS the diff.
280
+ const haveBase = !!gitSafe(["rev-parse", "--verify", "--quiet", baseRef], { cwd });
281
+ let committedDiff = "";
282
+ if (haveBase) {
283
+ // base...HEAD = changes on HEAD since it diverged from base (symmetric range).
284
+ committedDiff = gitSafe(["diff", `${baseRef}...HEAD`, "--", DB_PATH], { cwd });
285
+ }
286
+ const uncommittedDiff = gitSafe(["diff", "HEAD", "--", DB_PATH], { cwd });
287
+ const combined = `${committedDiff}\n${uncommittedDiff}`;
288
+
289
+ // Only consider ADDED/REMOVED lines (diff bodies, prefixed +/-), not context.
290
+ const changedLines = combined
291
+ .split("\n")
292
+ .filter((l) => /^[+-]/.test(l) && !/^[+-]{3}\s/.test(l)); // skip +++/--- headers
293
+
294
+ if (changedLines.length === 0) {
295
+ if (!haveBase) {
296
+ return result(
297
+ "pass",
298
+ "Schema-bump check",
299
+ `No changes to ${DB_PATH} in the working tree (base ref '${baseRef}' not found, so the committed lane delta was skipped).`,
300
+ );
301
+ }
302
+ return result("pass", "Schema-bump check", `No changes to ${DB_PATH} vs ${baseRef}.`);
303
+ }
304
+
305
+ const ddlChanged = changedLines.some((l) => DDL_RE.test(l));
306
+ const versionChanged = changedLines.some((l) => VERSION_RE.test(l));
307
+
308
+ if (ddlChanged && !versionChanged) {
309
+ return result(
310
+ "warn",
311
+ "Schema-bump check",
312
+ `${DB_PATH} has DDL-ish changes but SCHEMA_VERSION was NOT bumped.`,
313
+ [
314
+ "Additive DDL (CREATE TABLE / ADD COLUMN / ALTER / CREATE INDEX) is SKIPPED on an existing prod DB",
315
+ "whose stored version already matches — so it builds + passes proofs on fresh PGlite, then silently",
316
+ "no-ops in prod. Bump `const SCHEMA_VERSION` (and coordinate the migration). See the schema-version memory.",
317
+ ...changedLines.filter((l) => DDL_RE.test(l)).slice(0, 6).map((l) => l.trim()),
318
+ ],
319
+ );
320
+ }
321
+
322
+ if (ddlChanged && versionChanged) {
323
+ return result("pass", "Schema-bump check", `${DB_PATH} changed DDL and SCHEMA_VERSION was bumped alongside it.`);
324
+ }
325
+
326
+ return result("pass", "Schema-bump check", `${DB_PATH} changed but no DDL-shaped statements were touched.`);
327
+ }