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
4 changes: 3 additions & 1 deletion .github/workflows/test-hooks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions hooks/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
343 changes: 343 additions & 0 deletions hooks/rules/rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<div className=\"min-[1200px]:flex\">x</div>"
}
},
"expected_exit": 2
},
{
"name": "blocks-min-768-arbitrary",
"input": {
"tool_input": {
"file_path": "src/pages/Bar.tsx",
"content": "<div className=\"min-[768px]:block\">y</div>"
}
},
"expected_exit": 2
},
{
"name": "allows-preset-md",
"input": {
"tool_input": {
"file_path": "src/components/foo.tsx",
"content": "<div className=\"md:flex\">x</div>"
}
},
"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": "<div className=\"min-[1380px]:hidden\">x</div> // hook-bypass: ds-arbitrary-breakpoint"
}
},
"expected_exit": 0
}
]
},
{
"id": "ds-deep-ui-import",
"description": "Block deep imports from @/components/ui/<file> — 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/<file> 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": "<button className=\"w-[120px]\">x</button>"
}
},
"expected_exit": 2
},
{
"name": "blocks-w-pixel-in-ui-card",
"input": {
"tool_input": {
"file_path": "src/components/ui/card.tsx",
"content": "<div className=\"w-[300px] p-4\">y</div>"
}
},
"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": "<button className=\"w-[120px]\">x</button>"
}
},
"expected_exit": 2
},
{
"name": "allows-w-pixel-outside-ds",
"input": {
"tool_input": {
"file_path": "src/pages/Settings.tsx",
"content": "<div className=\"w-[300px]\">page-specific</div>"
}
},
"expected_exit": 0
},
{
"name": "allows-w-full-max-w",
"input": {
"tool_input": {
"file_path": "src/components/ui/button.tsx",
"content": "<button className=\"w-full max-w-[200px]\">x</button>"
}
},
"expected_exit": 0
},
{
"name": "allows-bypass-marker",
"input": {
"tool_input": {
"file_path": "src/components/ui/special.tsx",
"content": "<div className=\"w-[42px]\">px-perfect-icon</div> // 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": "<div style={{ color: \"#FF7151\" }}>x</div>"
}
},
"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": "<svg fill=\"#FF7151\" /> // hook-bypass: ds-raw-hex-color"
}
},
"expected_exit": 0
}
]
}
]
Loading
Loading