diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a36d1bf..e770c27 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "make-no-mistakes", - "version": "1.6.0", + "version": "1.7.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, and stash secrets via OS-native prompts. One plugin to make no mistakes.", "owner": { "name": "Luis Andres Pena Castillo", @@ -11,7 +11,7 @@ { "name": "make-no-mistakes", "description": "Dev lifecycle orchestrator: disciplined Linear issue execution with worktree isolation, PR review with Greptile gating, team release sync, E2E test generation and execution, test suite previewer, security pentesting, MoSCoW + RICE prioritization, cross-platform secret stash via OS-native GUI prompts (zenity / kdialog / osascript / Get-Credential), and session management. 18 commands, 6 auto-activating skills, 2 specialized agents.", - "version": "1.6.0", + "version": "1.7.0", "author": { "name": "Luis Andres Pena Castillo", "email": "lapc506@users.noreply.github.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 731facd..95c8a76 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "make-no-mistakes", - "version": "1.6.0", + "version": "1.7.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks. One plugin to make no mistakes.", "author": { "name": "Luis Andres Pena Castillo", diff --git a/hooks/rules/README.md b/hooks/rules/README.md index e2f6881..ea3920a 100644 --- a/hooks/rules/README.md +++ b/hooks/rules/README.md @@ -84,6 +84,43 @@ client-acme client-beta ``` +## Per-install substitutions (opt-in, gitignored) + +Some rules need values that are specific to your team or environment — +for example, the `block-supabase-db-push-prod` rule guards a Supabase +production project ref that varies per organization. Hard-coding such +values in the public rules.yaml would either leak the value upstream or +leave consumers of this toolkit with a rule that silently never fires. + +The build script supports literal-string substitution from a gitignored +`.private/substitutions.json` file. The file is a flat JSON object where +keys are UPPER_SNAKE token names and values are the literal replacement +strings. For each pair, every occurrence of `__TOKEN__` in `rules.yaml` is +replaced before the YAML is parsed, so both the rule patterns and the +test fixtures see the substituted value. + +Example `.private/substitutions.json`: + +```json +{ + "PROD_SUPABASE_REF": "abcdefghij1234567890", + "STAGING_SUPABASE_REF": "klmnopqrst0987654321" +} +``` + +When the file is absent, `__TOKEN__` placeholders remain in `rules.json` +verbatim. The rule still parses and runs; it simply does not fire for any +real-world command (only for commands that happen to contain the literal +placeholder text). This is a deliberately documented inert state — it is +preferable to a silently-broken protection. + +Tokens currently consumed by the published rule set: + +- `PROD_SUPABASE_REF` — production Supabase project ref, used by the + `block-supabase-db-push-prod` rule and its test fixtures. +- `STAGING_SUPABASE_REF` — staging Supabase project ref, used as the + negative-match example in the same rule's test array. + ## Rule families The manifest groups rules into informal families by prefix / domain. @@ -105,6 +142,20 @@ Adding a new family is fine — just keep ids unique and follow the schema. alongside the runtime hook, pre-commit linter, and Storage upload validator. See [DOJ-3924](https://linear.app/dojocoding/issue/DOJ-3924). +- **Database / migration discipline** + (`schema-sql-outside-migrations`, `warn-psql-against-supabase-remote`, + `pr-create-with-migrations-needs-deploy-note`, + `block-supabase-db-push-prod`) — keep schema mutations inside + versioned `supabase/migrations/` files, nudge developers away from + direct `psql` / `pg_dump` / `pg_restore` execution against + `*.supabase.co` hosts, remind PR authors to document migration + deployment, and hard-block `supabase db push` aimed at the production + project ref or `--linked` (which transparently resolves to whichever + project was last linked, possibly production). The production project + ref is configured per install via the substitutions mechanism described + above (`PROD_SUPABASE_REF` token). Added after a discussion surfaced + drift between manually-applied SQL and the migrations directory when + migrations failed to auto-run after a teammate's PR merged. ## Tier 2 — decomposing non-deterministic memories diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index dc6c92f..77df8d4 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -882,5 +882,352 @@ "expected_exit": 0 } ] + }, + { + "id": "schema-sql-outside-migrations", + "description": "Block schema-changing SQL (ALTER/CREATE/DROP/GRANT/REVOKE/CREATE POLICY) in .sql files outside supabase/migrations/", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "\\.sql$", + "not_pattern": "(supabase/migrations/|supabase/tests/|supabase/seed)" + }, + { + "field": "content", + "pattern": "\\b(ALTER TABLE|CREATE TABLE|DROP TABLE|CREATE POLICY|DROP POLICY|GRANT |REVOKE |CREATE FUNCTION|DROP FUNCTION|CREATE TYPE|ALTER TYPE)\\b", + "flags": "i" + } + ], + "action": "block", + "bypass_marker": "schema-sql-outside-migrations", + "memory_ref": "feedback_scripts_not_db.md", + "message": "BLOCKED: schema-changing SQL in a file outside supabase/migrations/.\n\nEvery schema mutation (ALTER/CREATE/DROP/GRANT/POLICY) MUST live in a\ntimestamped migration file, never in ad-hoc scripts or runtime queries.\n\nCreate a new file:\n supabase/migrations/$(date -u +%Y%m%d%H%M%S)_.sql\n\nLegitimate exceptions (use the bypass marker):\n- Schema docs / examples in READMEs — should not be executable SQL\n- Test fixtures under supabase/tests/ — already exempt via not_pattern\n", + "tests": [ + { + "name": "blocks-create-table-in-script", + "input": { + "tool_input": { + "file_path": "scripts/setup.sql", + "content": "CREATE TABLE foo (id int primary key);" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-grant-in-edge-function", + "input": { + "tool_input": { + "file_path": "supabase/functions/foo/migrate.sql", + "content": "GRANT SELECT ON foo TO authenticated;" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-alter-table-in-readme-example", + "input": { + "tool_input": { + "file_path": "docs/example.sql", + "content": "ALTER TABLE foo ADD COLUMN bar text;" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-schema-in-migrations-folder", + "input": { + "tool_input": { + "file_path": "supabase/migrations/20260509000000_foo.sql", + "content": "CREATE TABLE foo (id int primary key);" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-schema-in-tests-folder", + "input": { + "tool_input": { + "file_path": "supabase/tests/foo_test.sql", + "content": "CREATE TABLE temp_test_t (id int);" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-schema-sql-anywhere", + "input": { + "tool_input": { + "file_path": "scripts/query.sql", + "content": "SELECT * FROM foo;" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "scripts/oneshot.sql", + "content": "-- # hook-bypass: schema-sql-outside-migrations\nCREATE TABLE foo (id int primary key);\n" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "warn-psql-against-supabase-remote", + "description": "Warn when psql / pg_dump / pg_restore is executed directly against a *.supabase.co host (drift risk vs versioned migrations)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "\\b(psql|pg_dump|pg_restore|pg_dumpall)\\b.*\\.supabase\\.co", + "flags": "i" + } + ], + "action": "warn", + "bypass_marker": "psql-supabase-remote", + "memory_ref": "feedback_scripts_not_db.md", + "message": "WARNING: direct psql / pg_dump / pg_restore execution against a Supabase\nremote host.\n\nSQL Editor / direct-Postgres-CLI workflows create drift between the\nversioned migrations directory and other devs' local databases.\n\nCorrect workflow:\n 1. Add a migration in supabase/migrations/_.sql\n 2. Test locally: supabase db reset --local\n 3. Push to develop — CI applies the migration to staging automatically\n 4. For production: follow the create-release SOP\n\nUse the bypass marker only for legitimate hotfixes where the PR cycle\nis too slow.\n", + "tests": [ + { + "name": "warns-on-psql-supabase-remote", + "input": { + "tool_input": { + "command": "psql postgresql://user:pass@db.example1.supabase.co:5432/postgres -c 'SELECT 1'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "warns-on-psql-different-supabase-host", + "input": { + "tool_input": { + "command": "psql -h db.example2.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "warns-on-pg-dump-supabase-remote", + "input": { + "tool_input": { + "command": "pg_dump -h db.example1.supabase.co -d postgres -t public.foo > foo.sql" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "warns-on-pg-restore-supabase-remote", + "input": { + "tool_input": { + "command": "pg_restore -h db.example1.supabase.co -d postgres backup.dump" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "allows-psql-localhost", + "input": { + "tool_input": { + "command": "psql -h localhost -p 54322 -d postgres -c 'SELECT 1'" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-psql-supabase-references", + "input": { + "tool_input": { + "command": "curl https://example1.supabase.co/rest/v1/foo" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "psql -h db.example1.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "pr-create-with-migrations-needs-deploy-note", + "description": "Warn when gh pr create body (inline --body) does not mention migrations (caller should verify the PR has no migration files)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "gh[[:space:]]+pr[[:space:]]+create.*--body([[:space:]]|=|$)", + "not_pattern": "--body-file", + "flags": "i" + }, + { + "field": "command", + "not_pattern": "(migration|db push|supabase db|migration-deployment|requires.*staging|requires.*prod)", + "flags": "i" + } + ], + "action": "warn", + "bypass_marker": "pr-create-no-migration-note", + "memory_ref": "feedback_scripts_not_db.md", + "message": "WARNING: gh pr create body does not mention migrations.\n\nIf your PR modifies files under supabase/migrations/, the body MUST\ninclude a \"Migration deployment\" section that lists:\n 1. Which envs need supabase db push (staging auto via CI; prod via release)\n 2. Backfill order if the migrations depend on existing data\n 3. Post-merge verification steps\n\nThis rule does NOT inspect the diff — it only reads the command, and it\nintentionally skips --body-file invocations because it cannot read the\nreferenced file. Use the bypass marker if your PR has no migrations.\n", + "tests": [ + { + "name": "warns-on-pr-create-body-without-migration-mention", + "input": { + "tool_input": { + "command": "gh pr create --base develop --title \"feat: foo\" --body \"Adds feature X\"" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "pr-create-with-migrations-needs-deploy-note" + }, + { + "name": "allows-pr-create-body-mentioning-migration", + "input": { + "tool_input": { + "command": "gh pr create --base develop --title \"feat: foo\" --body \"Adds X. Migration deployment: requires supabase db push on staging\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-pr-create-body-mentioning-db-push", + "input": { + "tool_input": { + "command": "gh pr create --body \"Use db push to apply\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-pr-create-with-body-file", + "input": { + "tool_input": { + "command": "gh pr create --base develop --title \"feat: foo\" --body-file ./pr-body.md" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "gh pr create --body \"release notes only # hook-bypass: pr-create-no-migration-note\"" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "block-supabase-db-push-prod", + "description": "Block supabase db push that targets the PRODUCTION project ref or uses --linked (the linked project may transparently be production)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "supabase[[:space:]]+db[[:space:]]+push.*(__PROD_SUPABASE_REF__|--linked\\b)" + } + ], + "action": "block", + "bypass_marker": "prod-db-push-approved", + "memory_ref": "feedback_never_touch_prod_without_approval.md", + "message": "BLOCKED: supabase db push targeting either the PRODUCTION project ref\nor the currently-linked project (which may be production).\n\nNEVER run against production without explicit user approval — see\nfeedback_never_touch_prod_without_approval.md.\n\nCorrect workflow:\n 1. Merge to main\n 2. The release owner runs the create-release SOP that ships\n migrations to production through CI, not from a developer machine.\n\nUse the bypass marker ONLY when the user has verbally approved the\noperation. For --linked, double-check the active link first:\n supabase status --output json | jq -r '.project_ref'\n\nMarketplace consumers: the production project ref is configured per\ninstall via .private/substitutions.json (PROD_SUPABASE_REF token). See\nhooks/rules/README.md for the substitution workflow.\n", + "tests": [ + { + "name": "blocks-supabase-db-push-prod", + "input": { + "tool_input": { + "command": "supabase db push --db-url postgresql://postgres:pwd@db.__PROD_SUPABASE_REF__.supabase.co:5432/postgres" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-supabase-db-push-prod-with-project-ref-flag", + "input": { + "tool_input": { + "command": "supabase db push --project-ref __PROD_SUPABASE_REF__" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-supabase-db-push-linked", + "input": { + "tool_input": { + "command": "supabase db push --linked" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-supabase-db-push-linked-with-extra-flags", + "input": { + "tool_input": { + "command": "supabase db push --linked --include-all" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-supabase-db-push-staging", + "input": { + "tool_input": { + "command": "supabase db push --project-ref __STAGING_SUPABASE_REF__" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-supabase-db-push-local", + "input": { + "tool_input": { + "command": "supabase db push" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-prod-ref", + "input": { + "tool_input": { + "command": "supabase db push --project-ref __PROD_SUPABASE_REF__ # hook-bypass: prod-db-push-approved" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-linked", + "input": { + "tool_input": { + "command": "supabase db push --linked # hook-bypass: prod-db-push-approved" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index c84303b..2ccb787 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -675,3 +675,313 @@ file_path: 'src/components/Logo.tsx' content: ' // hook-bypass: ds-raw-hex-color' expected_exit: 0 + +# ----------------------------------------------------------------------------- +# Database / migration discipline rules +# +# These four rules prevent the class of drift that occurs when migration files +# fail to auto-run in a downstream environment and the workaround is to apply +# the SQL manually via psql / the Supabase SQL Editor — leaving the migrations +# directory and downstream databases silently out of sync. +# +# Layered defense: +# 1. schema-sql-outside-migrations — block ad-hoc DDL in non-migration files +# 2. warn-psql-against-supabase-remote — nudge away from SQL-Editor-style +# direct execution (psql / pg_dump / pg_restore) against any *.supabase.co +# host +# 3. pr-create-with-migrations-needs-deploy-note — remind the author to +# describe migration deployment when opening a PR +# 4. block-supabase-db-push-prod — hard stop on `supabase db push` aimed at +# the PRODUCTION project ref OR --linked (which may transparently target +# a previously-linked production project); only the create-release SOP +# may apply migrations to prod. +# +# Rule 4 references project-ref placeholders __PROD_SUPABASE_REF__ and +# __STAGING_SUPABASE_REF__. The `npm run build-rules` step substitutes these +# from the per-install gitignored config at .private/substitutions.json +# (UPPER_SNAKE token map). Without the config the placeholders remain +# in rules.json — the rule still parses and runs, but it only fires for +# commands that literally contain the placeholder text (i.e., it is +# documented inert). See hooks/rules/README.md for the substitution workflow. +# ----------------------------------------------------------------------------- + +- id: schema-sql-outside-migrations + description: Block schema-changing SQL (ALTER/CREATE/DROP/GRANT/REVOKE/CREATE POLICY) in .sql files outside supabase/migrations/ + applies_to: [Edit, Write, MultiEdit] + match: + - field: file_path + pattern: '\.sql$' + not_pattern: '(supabase/migrations/|supabase/tests/|supabase/seed)' + - field: content + pattern: '\b(ALTER TABLE|CREATE TABLE|DROP TABLE|CREATE POLICY|DROP POLICY|GRANT |REVOKE |CREATE FUNCTION|DROP FUNCTION|CREATE TYPE|ALTER TYPE)\b' + flags: i + action: block + bypass_marker: schema-sql-outside-migrations + memory_ref: feedback_scripts_not_db.md + message: | + BLOCKED: schema-changing SQL in a file outside supabase/migrations/. + + Every schema mutation (ALTER/CREATE/DROP/GRANT/POLICY) MUST live in a + timestamped migration file, never in ad-hoc scripts or runtime queries. + + Create a new file: + supabase/migrations/$(date -u +%Y%m%d%H%M%S)_.sql + + Legitimate exceptions (use the bypass marker): + - Schema docs / examples in READMEs — should not be executable SQL + - Test fixtures under supabase/tests/ — already exempt via not_pattern + tests: + - name: blocks-create-table-in-script + input: + tool_input: + file_path: 'scripts/setup.sql' + content: 'CREATE TABLE foo (id int primary key);' + expected_exit: 2 + - name: blocks-grant-in-edge-function + input: + tool_input: + file_path: 'supabase/functions/foo/migrate.sql' + content: 'GRANT SELECT ON foo TO authenticated;' + expected_exit: 2 + - name: blocks-alter-table-in-readme-example + input: + tool_input: + file_path: 'docs/example.sql' + content: 'ALTER TABLE foo ADD COLUMN bar text;' + expected_exit: 2 + - name: allows-schema-in-migrations-folder + input: + tool_input: + file_path: 'supabase/migrations/20260509000000_foo.sql' + content: 'CREATE TABLE foo (id int primary key);' + expected_exit: 0 + - name: allows-schema-in-tests-folder + input: + tool_input: + file_path: 'supabase/tests/foo_test.sql' + content: 'CREATE TABLE temp_test_t (id int);' + expected_exit: 0 + - name: allows-non-schema-sql-anywhere + input: + tool_input: + file_path: 'scripts/query.sql' + content: 'SELECT * FROM foo;' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: 'scripts/oneshot.sql' + content: | + -- # hook-bypass: schema-sql-outside-migrations + CREATE TABLE foo (id int primary key); + expected_exit: 0 + +- id: warn-psql-against-supabase-remote + description: Warn when psql / pg_dump / pg_restore is executed directly against a *.supabase.co host (drift risk vs versioned migrations) + applies_to: [Bash] + match: + - field: command + # Cover the full Postgres CLI surface a developer might reach for — + # not just psql. pg_dump / pg_restore / pg_dumpall against the live + # Supabase host are equivalent vectors for ad-hoc reads and writes + # that bypass the versioned migrations workflow. + pattern: '\b(psql|pg_dump|pg_restore|pg_dumpall)\b.*\.supabase\.co' + flags: i + action: warn + bypass_marker: psql-supabase-remote + memory_ref: feedback_scripts_not_db.md + message: | + WARNING: direct psql / pg_dump / pg_restore execution against a Supabase + remote host. + + SQL Editor / direct-Postgres-CLI workflows create drift between the + versioned migrations directory and other devs' local databases. + + Correct workflow: + 1. Add a migration in supabase/migrations/_.sql + 2. Test locally: supabase db reset --local + 3. Push to develop — CI applies the migration to staging automatically + 4. For production: follow the create-release SOP + + Use the bypass marker only for legitimate hotfixes where the PR cycle + is too slow. + tests: + - name: warns-on-psql-supabase-remote + input: + tool_input: + command: "psql postgresql://user:pass@db.example1.supabase.co:5432/postgres -c 'SELECT 1'" + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: warns-on-psql-different-supabase-host + input: + tool_input: + command: "psql -h db.example2.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: warns-on-pg-dump-supabase-remote + input: + tool_input: + command: 'pg_dump -h db.example1.supabase.co -d postgres -t public.foo > foo.sql' + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: warns-on-pg-restore-supabase-remote + input: + tool_input: + command: 'pg_restore -h db.example1.supabase.co -d postgres backup.dump' + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: allows-psql-localhost + input: + tool_input: + command: "psql -h localhost -p 54322 -d postgres -c 'SELECT 1'" + expected_exit: 0 + - name: allows-non-psql-supabase-references + input: + tool_input: + command: 'curl https://example1.supabase.co/rest/v1/foo' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: "psql -h db.example1.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" + expected_exit: 0 + +- id: pr-create-with-migrations-needs-deploy-note + description: Warn when gh pr create body (inline --body) does not mention migrations (caller should verify the PR has no migration files) + applies_to: [Bash] + match: + - field: command + # Require an inline --body argument and explicitly exclude --body-file + # (which sources the body from a separate file the rule cannot read, + # so any keyword check would produce a false positive). Matching --body + # immediately followed by space, "=" or end-of-string anchors the + # positive match away from --body-file's "--body-" substring. + pattern: 'gh[[:space:]]+pr[[:space:]]+create.*--body([[:space:]]|=|$)' + not_pattern: '--body-file' + flags: i + - field: command + not_pattern: '(migration|db push|supabase db|migration-deployment|requires.*staging|requires.*prod)' + flags: i + action: warn + bypass_marker: pr-create-no-migration-note + memory_ref: feedback_scripts_not_db.md + message: | + WARNING: gh pr create body does not mention migrations. + + If your PR modifies files under supabase/migrations/, the body MUST + include a "Migration deployment" section that lists: + 1. Which envs need supabase db push (staging auto via CI; prod via release) + 2. Backfill order if the migrations depend on existing data + 3. Post-merge verification steps + + This rule does NOT inspect the diff — it only reads the command, and it + intentionally skips --body-file invocations because it cannot read the + referenced file. Use the bypass marker if your PR has no migrations. + tests: + - name: warns-on-pr-create-body-without-migration-mention + input: + tool_input: + command: 'gh pr create --base develop --title "feat: foo" --body "Adds feature X"' + expected_exit: 0 + expected_stderr_contains: 'pr-create-with-migrations-needs-deploy-note' + - name: allows-pr-create-body-mentioning-migration + input: + tool_input: + command: 'gh pr create --base develop --title "feat: foo" --body "Adds X. Migration deployment: requires supabase db push on staging"' + expected_exit: 0 + - name: allows-pr-create-body-mentioning-db-push + input: + tool_input: + command: 'gh pr create --body "Use db push to apply"' + expected_exit: 0 + - name: allows-pr-create-with-body-file + input: + tool_input: + command: 'gh pr create --base develop --title "feat: foo" --body-file ./pr-body.md' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: 'gh pr create --body "release notes only # hook-bypass: pr-create-no-migration-note"' + expected_exit: 0 + +- id: block-supabase-db-push-prod + description: Block supabase db push that targets the PRODUCTION project ref or uses --linked (the linked project may transparently be production) + applies_to: [Bash] + match: + - field: command + # Two paths into the production database, both blocked: + # 1. An explicit --project-ref / --db-url containing __PROD_SUPABASE_REF__ + # (substituted at build time from .private/substitutions.json; see + # hooks/rules/README.md). Without the substitution, the literal + # placeholder string remains and the rule is documented inert. + # 2. `supabase db push --linked`, which resolves to whichever project + # the developer most recently ran `supabase link --project-ref ...` + # against. The CLI gives no static way to know whether that link + # points at staging or prod, so we conservatively block it and + # require the bypass marker as the author's explicit ack that they + # verified the linked project is intentional. + pattern: 'supabase[[:space:]]+db[[:space:]]+push.*(__PROD_SUPABASE_REF__|--linked\b)' + action: block + bypass_marker: prod-db-push-approved + memory_ref: feedback_never_touch_prod_without_approval.md + message: | + BLOCKED: supabase db push targeting either the PRODUCTION project ref + or the currently-linked project (which may be production). + + NEVER run against production without explicit user approval — see + feedback_never_touch_prod_without_approval.md. + + Correct workflow: + 1. Merge to main + 2. The release owner runs the create-release SOP that ships + migrations to production through CI, not from a developer machine. + + Use the bypass marker ONLY when the user has verbally approved the + operation. For --linked, double-check the active link first: + supabase status --output json | jq -r '.project_ref' + + Marketplace consumers: the production project ref is configured per + install via .private/substitutions.json (PROD_SUPABASE_REF token). See + hooks/rules/README.md for the substitution workflow. + tests: + - name: blocks-supabase-db-push-prod + input: + tool_input: + command: 'supabase db push --db-url postgresql://postgres:pwd@db.__PROD_SUPABASE_REF__.supabase.co:5432/postgres' + expected_exit: 2 + - name: blocks-supabase-db-push-prod-with-project-ref-flag + input: + tool_input: + command: 'supabase db push --project-ref __PROD_SUPABASE_REF__' + expected_exit: 2 + - name: blocks-supabase-db-push-linked + input: + tool_input: + command: 'supabase db push --linked' + expected_exit: 2 + - name: blocks-supabase-db-push-linked-with-extra-flags + input: + tool_input: + command: 'supabase db push --linked --include-all' + expected_exit: 2 + - name: allows-supabase-db-push-staging + input: + tool_input: + command: 'supabase db push --project-ref __STAGING_SUPABASE_REF__' + expected_exit: 0 + - name: allows-supabase-db-push-local + input: + tool_input: + command: 'supabase db push' + expected_exit: 0 + - name: allows-bypass-marker-prod-ref + input: + tool_input: + command: 'supabase db push --project-ref __PROD_SUPABASE_REF__ # hook-bypass: prod-db-push-approved' + expected_exit: 0 + - name: allows-bypass-marker-linked + input: + tool_input: + command: 'supabase db push --linked # hook-bypass: prod-db-push-approved' + expected_exit: 0 diff --git a/package.json b/package.json index a8a7ffe..b2d6c18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lapc506/make-no-mistakes", - "version": "1.6.0", + "version": "1.7.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks (no SSH+DB, no manual prod, no minified build, no secret leaks, Slack format). OpenCode + Claude Code plugin.", "type": "module", "main": "./dist/index.js", diff --git a/scripts/build-rules.mjs b/scripts/build-rules.mjs index e10cf2d..ec21866 100755 --- a/scripts/build-rules.mjs +++ b/scripts/build-rules.mjs @@ -39,7 +39,58 @@ if (existsSync(forbiddenFile)) { ); } -const source = readFileSync(yamlPath, 'utf8'); +// Optional per-install substitution layer. Lets each consumer of this toolkit +// specialize project-specific values (e.g., Supabase project refs) without +// committing them to the public source tree. The file maps UPPER_SNAKE token +// names to literal replacement strings; for each pair, every "__TOKEN__" +// occurrence in rules.yaml is replaced before YAML parsing. +// +// When the file is absent, "__TOKEN__" remains in the output verbatim. The +// rule still fires for any command that literally contains "__TOKEN__" +// (i.e., effectively inert — a marketplace consumer who hasn't configured a +// substitution gets a documented no-op rule, not a silent broken protection). +// +// Token names must be UPPER_SNAKE_CASE so they can't be confused with regex +// metacharacters or with the existing kebab-case rule / bypass identifiers. +// See hooks/rules/README.md for the full opt-in workflow. +const substFile = join(repoRoot, '.private', 'substitutions.json'); +let SUBSTITUTIONS = {}; +if (existsSync(substFile)) { + try { + SUBSTITUTIONS = JSON.parse(readFileSync(substFile, 'utf8')); + } catch (err) { + console.error(`Failed to parse ${substFile}: ${err.message}`); + process.exit(1); + } + if (typeof SUBSTITUTIONS !== 'object' || Array.isArray(SUBSTITUTIONS)) { + console.error(`${substFile} must be a JSON object of {TOKEN: value} pairs`); + process.exit(1); + } + for (const token of Object.keys(SUBSTITUTIONS)) { + if (!/^[A-Z][A-Z0-9_]*$/.test(token)) { + console.error( + `Invalid substitution token "${token}" in ${substFile}: must be UPPER_SNAKE_CASE`, + ); + process.exit(1); + } + } + console.log( + `Substitutions active (${Object.keys(SUBSTITUTIONS).length} token(s) loaded from .private/substitutions.json)`, + ); +} + +let source = readFileSync(yamlPath, 'utf8'); +for (const [token, value] of Object.entries(SUBSTITUTIONS)) { + if (typeof value !== 'string') { + console.error( + `Substitution value for "${token}" must be a string, got ${typeof value}`, + ); + process.exit(1); + } + // Use split+join (not regex replace) so the value is treated as a literal + // string — no risk of $-backref interpolation in the replacement. + source = source.split(`__${token}__`).join(value); +} const rules = yaml.load(source); if (!Array.isArray(rules)) {