Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .claude/hooks/_lib/rules-git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,31 @@ export function checkGitCommand(command, { resolveHeadBranch, resolveRemoteUrl,
return null
}

export function checkGitWorktreeRemove(command) {
if (typeof command !== "string" || !/\bgit\b/.test(command)) return null
// A heredoc/commit-message body that merely NAMES the flag is data, not a
// command — strip bodies before matching, same as checkGitCommand.
const scannable = stripHeredocBodies(command)
// Judge each segment on its own target, mirroring checkGitCommand: the force
// flag must belong to the SAME segment as `worktree remove`, so a later
// `&& npm test -- --force` cannot false-block a plain `git worktree remove`.
for (const segment of scannable.split(/[&|;\n]/)) {
if (!/\bgit\b[\s\S]*\bworktree\s+remove\b/.test(segment)) continue
if (!/(?<![\w-])(?:--force|-f)(?![\w-])/.test(segment)) continue
return {
block: true,
message:
`BLOCKED git command (worktree junction footgun):\n ${command}\n\n` +
"`git worktree remove --force` follows a Windows junction/reparse-point inside the worktree and deletes the\n" +
"TARGET's contents, not the link. SAFE cleanup order:\n" +
" 1. `rmdir` every junction inside the worktree (removes the link, not the target).\n" +
" 2. Verify each junction is gone (`git status` / `dir` shows it removed).\n" +
" 3. THEN `git worktree remove` (no --force needed once the tree carries no reparse points).\n",
}
}
return null
}

export function checkNpmExpoPin(command) {
if (typeof command !== "string" || !/\bnpm\b/.test(command)) return null

Expand Down
135 changes: 135 additions & 0 deletions .claude/hooks/_lib/rules-source.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,141 @@ export function checkCsharpTimezone(filePath, contents) {
}
}

// Line numbers of every `throw` keyword that sits at module scope (bracket
// depth 0), skipping strings and comments. Depth-tracked rather than anchored to
// column 0, so an indented or `if (!x) throw ...` guard-clause form still counts
// — the module-eval crash does not care about indentation.
function moduleScopeThrowLines(contents) {
const lines = []
let depth = 0
let line = 1
let inLineComment = false
let inBlockComment = false
let stringChar = null
for (let i = 0; i < contents.length; i++) {
const ch = contents[i]
const next = contents[i + 1]
if (ch === "\n") {
line++
inLineComment = false
continue
}
if (inLineComment) continue
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false
i++
}
continue
}
if (stringChar) {
if (ch === "\\") i++
else if (ch === stringChar) stringChar = null
continue
}
if (ch === "/" && next === "/") {
inLineComment = true
i++
continue
}
if (ch === "/" && next === "*") {
inBlockComment = true
i++
continue
}
if (ch === '"' || ch === "'" || ch === "`") {
stringChar = ch
continue
}
if (ch === "{" || ch === "(" || ch === "[") {
depth++
continue
}
if (ch === "}" || ch === ")" || ch === "]") {
if (depth > 0) depth--
continue
}
if (depth === 0 && contents.startsWith("throw", i) && !/\w/.test(contents[i - 1] || "") && !/\w/.test(contents[i + 5] || "")) {
lines.push(line)
i += 4
}
}
return lines
}

export function checkMobileSupabaseLazy(filePath, contents) {
const n = norm(filePath)
if (!/\/apps\/mobile\/.*supabase\.ts$/.test(n)) return null
if (typeof contents !== "string") return null

const findings = []
for (const lineNumber of moduleScopeThrowLines(contents)) {
findings.push(`${filePath}:${lineNumber} — module-scope throw`)
}
// A direct top-level assignment `const supabase[: Type] = createClient(` is
// eager init; the type annotation is optional (that is how the real file
// declares its state). The lazy `() => createClient(` arrow has `() =>`
// between the `=` and the call, so it does not match — and stays allowed.
for (const match of contents.matchAll(/^(?:export\s+)?const\s+\w+\s*(?::\s*[\w.<>[\]|& ]+?\s*)?=\s*createClient\s*\(/gm)) {
const lineNumber = contents.slice(0, match.index).split("\n").length
findings.push(`${filePath}:${lineNumber} — top-level createClient() init`)
}
if (findings.length === 0) return null
return {
block: true,
message:
`Mobile Supabase must stay lazy in ${filePath}:\n` +
findings.map((f) => ` - ${f}`).join("\n") +
`\n\nKeep the client lazy behind a getSupabaseClient() accessor — never init or throw at module scope.\n` +
`A module-eval throw or top-level createClient() runs during the app's first import and crashes to a grey\n` +
`screen at launch before any error boundary mounts (#172/#174).\n`,
}
}

export function checkEfMigrationRawIndex(filePath, contents) {
const n = norm(filePath)
if (!/\/orbit-api\/.*Migrations\/.*\.cs$/.test(n)) return null
if (typeof contents !== "string") return null

const findings = []
const marker = /migrationBuilder\.Sql\s*\(/g
let call
while ((call = marker.exec(contents))) {
let index = call.index + call[0].length
let depth = 1
const start = index
while (index < contents.length && depth > 0) {
const ch = contents[index]
if (ch === "(") depth++
else if (ch === ")") depth--
index++
}
const sql = contents.slice(start, index - 1)
const lineNumber = contents.slice(0, call.index).split("\n").length
// Check each `;`-separated statement on its own — one statement's IF [NOT]
// EXISTS clause must not mask a sibling statement in the same batched Sql()
// call that lacks its own.
for (const statement of sql.split(";")) {
if (/\bCREATE\s+(?:UNIQUE\s+)?INDEX\b/i.test(statement) && !/\bIF\s+NOT\s+EXISTS\b/i.test(statement)) {
findings.push(`${filePath}:${lineNumber} — raw CREATE INDEX without IF NOT EXISTS`)
}
if (/\bDROP\s+INDEX\b/i.test(statement) && !/\bIF\s+EXISTS\b/i.test(statement)) {
findings.push(`${filePath}:${lineNumber} — raw DROP INDEX without IF EXISTS`)
}
}
}
if (findings.length === 0) return null
return {
block: true,
message:
`Non-idempotent raw index SQL in ${filePath}:\n` +
findings.map((f) => ` - ${f}`).join("\n") +
`\n\nEF applies migrations at startup on Render; a raw CREATE INDEX for an index that already exists throws\n` +
`Postgres 42P07 and fails the deploy. Use CREATE INDEX IF NOT EXISTS / DROP INDEX IF EXISTS (mirrors the\n` +
`Guard-Migrations CI). migrationBuilder.CreateIndex(...) is already idempotent-safe — this only flags raw Sql().\n`,
}
}

export function checkCsharpFluentConfig(filePath, contents) {
const n = norm(filePath)
if (!/\/orbit-api\/src\/Orbit\.Infrastructure\/Persistence\/OrbitDbContext\.cs$/.test(n)) return null
Expand Down
22 changes: 22 additions & 0 deletions .claude/hooks/forbid-ef-migration-raw-index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// PostToolUse adapter for the EF raw-index idempotency guard. Thin: logic in
// _lib/rules-source.mjs (shared with the .opencode/plugin equivalent). Reads the
// edited file. Exits 0 or 2 + stderr. Any error exits 0.

import { readFileSync, existsSync } from "node:fs"
import { readStdinJson, filePathFrom } from "./_lib/io.mjs"
import { checkEfMigrationRawIndex } from "./_lib/rules-source.mjs"

try {
const input = readStdinJson()
const filePath = filePathFrom(input)
if (!filePath || !existsSync(filePath)) process.exit(0)
const verdict = checkEfMigrationRawIndex(filePath, readFileSync(filePath, "utf8"))
if (verdict?.block) {
process.stderr.write(verdict.message)
process.exit(2)
}
process.exit(0)
} catch {
process.exit(0)
}
22 changes: 22 additions & 0 deletions .claude/hooks/forbid-mobile-supabase-eager.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// PostToolUse adapter for the mobile Supabase lazy-client guard. Thin: logic in
// _lib/rules-source.mjs (shared with the .opencode/plugin equivalent). Reads the
// edited file. Exits 0 or 2 + stderr. Any error exits 0.

import { readFileSync, existsSync } from "node:fs"
import { readStdinJson, filePathFrom } from "./_lib/io.mjs"
import { checkMobileSupabaseLazy } from "./_lib/rules-source.mjs"

try {
const input = readStdinJson()
const filePath = filePathFrom(input)
if (!filePath || !existsSync(filePath)) process.exit(0)
const verdict = checkMobileSupabaseLazy(filePath, readFileSync(filePath, "utf8"))
if (verdict?.block) {
process.stderr.write(verdict.message)
process.exit(2)
}
process.exit(0)
} catch {
process.exit(0)
}
5 changes: 3 additions & 2 deletions .claude/hooks/git-guardrails.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { execFileSync } from "node:child_process"
import { readStdinJson } from "./_lib/io.mjs"
import { checkGitCommand } from "./_lib/rules-git.mjs"
import { checkGitCommand, checkGitWorktreeRemove } from "./_lib/rules-git.mjs"

try {
const input = readStdinJson()
Expand All @@ -25,7 +25,8 @@ try {
stdio: ["ignore", "pipe", "ignore"],
}).trim()

const verdict = checkGitCommand(command, { resolveHeadBranch, resolveRemoteUrl, cwd: input?.cwd || process.cwd() })
const verdict =
checkGitCommand(command, { resolveHeadBranch, resolveRemoteUrl, cwd: input?.cwd || process.cwd() }) ?? checkGitWorktreeRemove(command)
if (verdict?.block) {
process.stderr.write(verdict.message)
process.exit(2)
Expand Down
Loading
Loading