diff --git a/.github/workflows/test-hooks.yml b/.github/workflows/test-hooks.yml index 269f3fb..212b541 100644 --- a/.github/workflows/test-hooks.yml +++ b/.github/workflows/test-hooks.yml @@ -14,7 +14,9 @@ on: jobs: test: - runs-on: ubuntu-latest + # Use Blacksmith managed runners (same pattern as dojo-os) to avoid the + # GitHub-hosted billing block. Drop-in replacement for ubuntu-latest. + runs-on: blacksmith-2vcpu-ubuntu-2204 steps: - uses: actions/checkout@v4 diff --git a/hooks/rules/README.md b/hooks/rules/README.md index 215739f..e2f6881 100644 --- a/hooks/rules/README.md +++ b/hooks/rules/README.md @@ -84,6 +84,28 @@ client-acme client-beta ``` +## Rule families + +The manifest groups rules into informal families by prefix / domain. +Adding a new family is fine — just keep ids unique and follow the schema. + +- **Bash safety** (`ssh-db-mutation`, `gcloud-missing-project`, + `prod-ops-no-approval`, `destructive-db-ops`, `manual-edge-fn-deploy`) + — block / warn on dangerous shell invocations. +- **File safety** (`minified-build-output`, `secrets-hardcoded`) + — block writes of minified build artifacts and hardcoded credentials. +- **Slack style** (`slack-unicode-bullets`, `slack-tables-no-codeblock`, + `slack-spanish-tildes`) — warn-only formatting nudges. +- **Design System** (`ds-arbitrary-breakpoint`, `ds-deep-ui-import`, + `ds-arbitrary-fixed-width-in-ds-component`, + `ds-raw-hex-color-in-source`) — block / warn at write-time when AI + proposes Edit/Write/MultiEdit changes that violate the Dojo Design + System contract (preset breakpoints, barrel imports, flexible widths, + token-only colors). Added in DOJ-3924 as the 4th enforcement layer + alongside the runtime hook, pre-commit linter, and Storage upload + validator. See + [DOJ-3924](https://linear.app/dojocoding/issue/DOJ-3924). + ## Tier 2 — decomposing non-deterministic memories Many narrative-style guidelines can be converted to deterministic rules diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 8a60be8..dc6c92f 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -539,5 +539,348 @@ "expected_exit": 0 } ] + }, + { + "id": "ds-arbitrary-breakpoint", + "description": "Block arbitrary Tailwind breakpoints (min-[Xpx]) in TSX/JSX/TS source — use sm:/md:/lg:/xl:/2xl: preset breakpoints", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "\\.(tsx|jsx|ts|js)$" + }, + { + "field": "content", + "pattern": "min-\\[[0-9]+px\\]" + } + ], + "action": "block", + "bypass_marker": "ds-arbitrary-breakpoint", + "memory_ref": "feedback_design_system_no_arbitrary_breakpoints.md", + "message": "BLOCKED: arbitrary Tailwind breakpoint detected (min-[Xpx]).\n\nUse Tailwind's preset breakpoints instead: sm: (640px), md: (768px),\nlg: (1024px), xl: (1280px), 2xl: (1536px). Arbitrary values fragment\nthe responsive design language and break consistency across the app.\n\nIf a non-preset breakpoint is genuinely required, add\n\"// hook-bypass: ds-arbitrary-breakpoint\" to the line and justify\nin the PR description.\n", + "tests": [ + { + "name": "blocks-min-1200px-in-tsx", + "input": { + "tool_input": { + "file_path": "src/components/foo.tsx", + "content": "
x
" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-min-768-arbitrary", + "input": { + "tool_input": { + "file_path": "src/pages/Bar.tsx", + "content": "
y
" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-preset-md", + "input": { + "tool_input": { + "file_path": "src/components/foo.tsx", + "content": "
x
" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-tsx-files", + "input": { + "tool_input": { + "file_path": "docs/example.md", + "content": "Use min-[1200px] for ..." + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "src/components/foo.tsx", + "content": "
x
// hook-bypass: ds-arbitrary-breakpoint" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "ds-deep-ui-import", + "description": "Block deep imports from @/components/ui/ — always import from the barrel @/components/ui", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "\\.(tsx|jsx|ts)$" + }, + { + "field": "content", + "pattern": "from[[:space:]]+[\"']@/components/ui/[a-zA-Z][a-zA-Z0-9_-]+[\"']" + } + ], + "action": "block", + "bypass_marker": "ds-deep-ui-import", + "memory_ref": "feedback_design_system_barrel_imports.md", + "message": "BLOCKED: deep import from @/components/ui/ detected.\n\nAlways import Design System components from the barrel:\n import { Button, Card, TextInput } from \"@/components/ui\"\n\nDeep imports like @/components/ui/button bypass the barrel-controlled\nsurface and make refactors brittle. The barrel is the public API.\n", + "tests": [ + { + "name": "blocks-deep-button-import", + "input": { + "tool_input": { + "file_path": "src/pages/Foo.tsx", + "content": "import { Button } from \"@/components/ui/button\"" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-deep-textinput-import", + "input": { + "tool_input": { + "file_path": "src/components/Bar.tsx", + "content": "import { TextInput } from '@/components/ui/text-input'" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-barrel-import", + "input": { + "tool_input": { + "file_path": "src/pages/Foo.tsx", + "content": "import { Button, Card } from \"@/components/ui\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-ds-import", + "input": { + "tool_input": { + "file_path": "src/pages/Foo.tsx", + "content": "import { foo } from \"@/lib/utils\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "src/pages/Foo.tsx", + "content": "import { Button } from \"@/components/ui/button\" // hook-bypass: ds-deep-ui-import" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "ds-arbitrary-fixed-width-in-ds-component", + "description": "Block arbitrary fixed widths (w-[Xpx]) inside DS components — they must be flexible (w-full + max-w)", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "(^|/)src/components/ui/" + }, + { + "field": "content", + "pattern": "(^|[^a-zA-Z-])w-\\[[0-9]+px\\]" + } + ], + "action": "block", + "bypass_marker": "ds-arbitrary-fixed-width", + "memory_ref": "feedback_design_system_no_fixed_pixel_widths.md", + "message": "BLOCKED: arbitrary fixed pixel width in a DS component.\n\nDS components live under src/components/ui/ and must be flexible.\nUse w-full + max-w-[Xpx] instead of w-[Xpx], so the component\nadapts to its container.\n\nApplication-level usage (outside src/components/ui/) is exempt —\nthis rule only fires when editing the DS components themselves.\n", + "tests": [ + { + "name": "blocks-w-pixel-in-ui-button", + "input": { + "tool_input": { + "file_path": "src/components/ui/button.tsx", + "content": "" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-w-pixel-in-ui-card", + "input": { + "tool_input": { + "file_path": "src/components/ui/card.tsx", + "content": "
y
" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-w-pixel-in-ui-button-absolute-path", + "input": { + "tool_input": { + "file_path": "/home/user/repo/src/components/ui/button.tsx", + "content": "" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-w-pixel-outside-ds", + "input": { + "tool_input": { + "file_path": "src/pages/Settings.tsx", + "content": "
page-specific
" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-w-full-max-w", + "input": { + "tool_input": { + "file_path": "src/components/ui/button.tsx", + "content": "" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "src/components/ui/special.tsx", + "content": "
px-perfect-icon
// hook-bypass: ds-arbitrary-fixed-width" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "ds-raw-hex-color-in-source", + "description": "Warn on raw hex color literals in TSX/TS source — colors must come from Tailwind tokens (dojo-primary-*, etc.)", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "\\.(tsx|jsx|ts|js)$", + "not_pattern": "(tailwind\\.config|theme/|design-system/|design-tokens|\\.test\\.|\\.stories\\.)" + }, + { + "field": "content", + "pattern": "#[0-9a-fA-F]{6}\\b" + } + ], + "action": "warn", + "bypass_marker": "ds-raw-hex-color", + "memory_ref": "feedback_design_system_token_only_colors.md", + "message": "WARNING: raw hex color literal found in source.\n\nUse a Tailwind token instead (e.g., bg-dojo-primary-lilac, text-dojo-foreground).\nThe token system is the single source of truth for color and theming.\n\nExempt paths (NOT flagged): tailwind.config.*, theme/, design-system/,\ndesign-tokens, .test.*, .stories.*. Use the bypass marker if a hex is\ngenuinely required outside those paths (e.g. embedding a brand color\nin a logo SVG that the design system doesn't yet expose).\n", + "tests": [ + { + "name": "warns-on-raw-hex-in-tsx", + "input": { + "tool_input": { + "file_path": "src/components/foo.tsx", + "content": "
x
" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "ds-raw-hex-color-in-source" + }, + { + "name": "warns-on-raw-hex-in-ts", + "input": { + "tool_input": { + "file_path": "src/lib/util.ts", + "content": "const PRIMARY = \"#C980FC\"" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "ds-raw-hex-color-in-source" + }, + { + "name": "allows-tailwind-config", + "input": { + "tool_input": { + "file_path": "tailwind.config.ts", + "content": "lilac: \"#C980FC\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-theme-file", + "input": { + "tool_input": { + "file_path": "src/theme/tokens.ts", + "content": "export const PRIMARY = \"#C980FC\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-test-file", + "input": { + "tool_input": { + "file_path": "src/components/foo.test.tsx", + "content": "expect(color).toBe(\"#FF7151\")" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-design-system-folder", + "input": { + "tool_input": { + "file_path": "src/design-system/tokens.ts", + "content": "export const PRIMARY = \"#C980FC\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-design-tokens-file", + "input": { + "tool_input": { + "file_path": "src/lib/design-tokens.ts", + "content": "export const LILAC = \"#C980FC\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "src/components/Logo.tsx", + "content": " // hook-bypass: ds-raw-hex-color" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 59e17b1..c84303b 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -410,3 +410,268 @@ tool_input: text: 'La migración fue exitosa' expected_exit: 0 + +# ----------------------------------------------------------------------------- +# Design System rules (DOJ-3924) +# +# These four rules implement AI-write-time enforcement for the Dojo Design +# System. They are the 4th enforcement layer, complementing the dojo-os +# repo's existing layers: +# 1. useDsRules React hook (passive, runtime-only) +# 2. ds-lint pre-commit hook (post-author, post-write) +# 3. validate-storage-upload Edge Function (Storage bucket uploads) +# 4. THIS layer — PreToolUse on Edit/Write/MultiEdit (write-time, blocks +# the violation BEFORE the file is touched, tightest feedback loop) +# +# Linear: DOJ-3924 (Phase G of DOJ-3439 design-system-linter). +# ----------------------------------------------------------------------------- + +- id: ds-arbitrary-breakpoint + description: 'Block arbitrary Tailwind breakpoints (min-[Xpx]) in TSX/JSX/TS source — use sm:/md:/lg:/xl:/2xl: preset breakpoints' + applies_to: [Edit, Write, MultiEdit] + match: + - field: file_path + pattern: '\.(tsx|jsx|ts|js)$' + - field: content + pattern: 'min-\[[0-9]+px\]' + action: block + bypass_marker: ds-arbitrary-breakpoint + memory_ref: feedback_design_system_no_arbitrary_breakpoints.md + message: | + BLOCKED: arbitrary Tailwind breakpoint detected (min-[Xpx]). + + Use Tailwind's preset breakpoints instead: sm: (640px), md: (768px), + lg: (1024px), xl: (1280px), 2xl: (1536px). Arbitrary values fragment + the responsive design language and break consistency across the app. + + If a non-preset breakpoint is genuinely required, add + "// hook-bypass: ds-arbitrary-breakpoint" to the line and justify + in the PR description. + tests: + - name: blocks-min-1200px-in-tsx + input: + tool_input: + file_path: 'src/components/foo.tsx' + content: '
x
' + expected_exit: 2 + - name: blocks-min-768-arbitrary + input: + tool_input: + file_path: 'src/pages/Bar.tsx' + content: '
y
' + expected_exit: 2 + - name: allows-preset-md + input: + tool_input: + file_path: 'src/components/foo.tsx' + content: '
x
' + expected_exit: 0 + - name: allows-non-tsx-files + input: + tool_input: + file_path: 'docs/example.md' + content: 'Use min-[1200px] for ...' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: 'src/components/foo.tsx' + content: '
x
// hook-bypass: ds-arbitrary-breakpoint' + expected_exit: 0 + +- id: ds-deep-ui-import + description: Block deep imports from @/components/ui/ — always import from the barrel @/components/ui + applies_to: [Edit, Write, MultiEdit] + match: + - field: file_path + pattern: '\.(tsx|jsx|ts)$' + - field: content + pattern: 'from[[:space:]]+["'']@/components/ui/[a-zA-Z][a-zA-Z0-9_-]+["'']' + action: block + bypass_marker: ds-deep-ui-import + memory_ref: feedback_design_system_barrel_imports.md + message: | + BLOCKED: deep import from @/components/ui/ detected. + + Always import Design System components from the barrel: + import { Button, Card, TextInput } from "@/components/ui" + + Deep imports like @/components/ui/button bypass the barrel-controlled + surface and make refactors brittle. The barrel is the public API. + tests: + - name: blocks-deep-button-import + input: + tool_input: + file_path: 'src/pages/Foo.tsx' + content: 'import { Button } from "@/components/ui/button"' + expected_exit: 2 + - name: blocks-deep-textinput-import + input: + tool_input: + file_path: 'src/components/Bar.tsx' + content: "import { TextInput } from '@/components/ui/text-input'" + expected_exit: 2 + - name: allows-barrel-import + input: + tool_input: + file_path: 'src/pages/Foo.tsx' + content: 'import { Button, Card } from "@/components/ui"' + expected_exit: 0 + - name: allows-non-ds-import + input: + tool_input: + file_path: 'src/pages/Foo.tsx' + content: 'import { foo } from "@/lib/utils"' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: 'src/pages/Foo.tsx' + content: 'import { Button } from "@/components/ui/button" // hook-bypass: ds-deep-ui-import' + expected_exit: 0 + +- id: ds-arbitrary-fixed-width-in-ds-component + description: Block arbitrary fixed widths (w-[Xpx]) inside DS components — they must be flexible (w-full + max-w) + applies_to: [Edit, Write, MultiEdit] + match: + # Anchor on start-of-string OR a leading "/" so the rule fires for both + # relative paths ("src/components/ui/...") and absolute paths + # ("/home/user/repo/src/components/ui/..."). Claude Code's Edit/Write + # tools may pass either form at runtime. + - field: file_path + pattern: '(^|/)src/components/ui/' + - field: content + # Require the `w` to NOT be preceded by a letter/dash so we don't + # mis-flag legitimate composite utilities like max-w-[200px] or + # min-w-[120px]. ERE has no lookahead, so we anchor on a leading + # boundary of "start-of-line OR non-[a-zA-Z-] character". + pattern: '(^|[^a-zA-Z-])w-\[[0-9]+px\]' + action: block + bypass_marker: ds-arbitrary-fixed-width + memory_ref: feedback_design_system_no_fixed_pixel_widths.md + message: | + BLOCKED: arbitrary fixed pixel width in a DS component. + + DS components live under src/components/ui/ and must be flexible. + Use w-full + max-w-[Xpx] instead of w-[Xpx], so the component + adapts to its container. + + Application-level usage (outside src/components/ui/) is exempt — + this rule only fires when editing the DS components themselves. + tests: + - name: blocks-w-pixel-in-ui-button + input: + tool_input: + file_path: 'src/components/ui/button.tsx' + content: '' + expected_exit: 2 + - name: blocks-w-pixel-in-ui-card + input: + tool_input: + file_path: 'src/components/ui/card.tsx' + content: '
y
' + expected_exit: 2 + # Regression: Claude Code passes absolute paths to Edit/Write at runtime. + # Without the (^|/) anchor the rule would silently allow these (the bug + # Greptile flagged in PR #14). + - name: blocks-w-pixel-in-ui-button-absolute-path + input: + tool_input: + file_path: '/home/user/repo/src/components/ui/button.tsx' + content: '' + expected_exit: 2 + - name: allows-w-pixel-outside-ds + input: + tool_input: + file_path: 'src/pages/Settings.tsx' + content: '
page-specific
' + expected_exit: 0 + - name: allows-w-full-max-w + input: + tool_input: + file_path: 'src/components/ui/button.tsx' + content: '' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: 'src/components/ui/special.tsx' + content: '
px-perfect-icon
// hook-bypass: ds-arbitrary-fixed-width' + expected_exit: 0 + +- id: ds-raw-hex-color-in-source + description: Warn on raw hex color literals in TSX/TS source — colors must come from Tailwind tokens (dojo-primary-*, etc.) + applies_to: [Edit, Write, MultiEdit] + match: + - field: file_path + pattern: '\.(tsx|jsx|ts|js)$' + not_pattern: '(tailwind\.config|theme/|design-system/|design-tokens|\.test\.|\.stories\.)' + - field: content + pattern: '#[0-9a-fA-F]{6}\b' + action: warn + bypass_marker: ds-raw-hex-color + memory_ref: feedback_design_system_token_only_colors.md + message: | + WARNING: raw hex color literal found in source. + + Use a Tailwind token instead (e.g., bg-dojo-primary-lilac, text-dojo-foreground). + The token system is the single source of truth for color and theming. + + Exempt paths (NOT flagged): tailwind.config.*, theme/, design-system/, + design-tokens, .test.*, .stories.*. Use the bypass marker if a hex is + genuinely required outside those paths (e.g. embedding a brand color + in a logo SVG that the design system doesn't yet expose). + tests: + - name: warns-on-raw-hex-in-tsx + input: + tool_input: + file_path: 'src/components/foo.tsx' + content: '
x
' + expected_exit: 0 + expected_stderr_contains: 'ds-raw-hex-color-in-source' + - name: warns-on-raw-hex-in-ts + input: + tool_input: + file_path: 'src/lib/util.ts' + content: 'const PRIMARY = "#C980FC"' + expected_exit: 0 + expected_stderr_contains: 'ds-raw-hex-color-in-source' + - name: allows-tailwind-config + input: + tool_input: + file_path: 'tailwind.config.ts' + content: 'lilac: "#C980FC"' + expected_exit: 0 + - name: allows-theme-file + input: + tool_input: + file_path: 'src/theme/tokens.ts' + content: 'export const PRIMARY = "#C980FC"' + expected_exit: 0 + - name: allows-test-file + input: + tool_input: + file_path: 'src/components/foo.test.tsx' + content: 'expect(color).toBe("#FF7151")' + expected_exit: 0 + # Lock in the design-system/ exemption so a future regex tweak that + # accidentally narrows not_pattern fails this test. + - name: allows-design-system-folder + input: + tool_input: + file_path: 'src/design-system/tokens.ts' + content: 'export const PRIMARY = "#C980FC"' + expected_exit: 0 + # Lock in the design-tokens exemption (matches both folder and filename). + - name: allows-design-tokens-file + input: + tool_input: + file_path: 'src/lib/design-tokens.ts' + content: 'export const LILAC = "#C980FC"' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: 'src/components/Logo.tsx' + content: ' // hook-bypass: ds-raw-hex-color' + expected_exit: 0