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
92 changes: 67 additions & 25 deletions .claude/hooks/block-prose-punctuation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
#
# PreToolUse hook: block prose-punctuation patterns the webjs convention bans.
#
# Catches four classes of new content in tool calls:
# Catches five classes of new content in tool calls:
#
# 1. U+2014 em-dash, anywhere.
# 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
# lines, headings, blockquotes). Math expressions in code like
# lines, headings, blockquotes, a JSON "description" / "title" /
# "displayName" string value, and a column-0 YAML front-matter
# description: / title: / displayName: line). Math expressions in code like
# `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
# 3. Space-semicolon-space " ; " in PROSE contexts. JS / CSS statement
# terminators (`;\n`) are NOT flagged.
# 3. Space-semicolon-space " ; " in the same PROSE contexts as rule 2.
# JS / CSS statement terminators (`;\n`) are NOT flagged.
# 4. Code-shaped left-hand side immediately followed by a colon and prose:
# - `<code>foo()</code>:` (markdown code-LHS in docs)
# - `<my-tag>:` (custom-element tag with hyphen)
Expand All @@ -19,7 +21,7 @@
# except a `webjs <subcommand>` CLI command and literal code tokens
# (@webjsdev, webjs.dev, "webjs", WEBJS_*, webjsdev/webjs, code spans).
#
# Why this exists: see AGENTS.md "Invariants", item 10. These patterns
# Why this exists: see AGENTS.md "Invariants", item 11. These patterns
# confuse AI agents that try to parse the prose as TypeScript / shorthand-
# method / object-literal syntax, and trip humans reading API docs.
#
Expand Down Expand Up @@ -50,8 +52,13 @@ if [ -z "$new_content" ]; then
exit 0
fi

# Every match below reads from a here-string, never a pipe. `grep -q` exits on
# the first match, which closes a pipe under `printf`, and with `set -o pipefail`
# that SIGPIPE became the pipeline status, so the rule silently skipped on any
# payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB).

# --- 1. U+2014 em-dash --------------------------------------------------
if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
if grep -q $'\xe2\x80\x94' <<< "$new_content"; then
cat >&2 <<'EOF'
BLOCKED: em-dash (U+2014) detected in this tool call.

Expand All @@ -61,7 +68,7 @@ restructured sentence. Do NOT replace it with " - " or " ; " or a
trailing colon on code: those are also banned. See rule 2 / 3 / 4
below for the alternatives.

Rule: AGENTS.md, Invariants section, item 10.
Rule: AGENTS.md, Invariants section, item 11.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
Expand All @@ -85,25 +92,42 @@ block_pause_hyphen=0
# `*` (markdown bold-start would have a letter after, distinguishable),
# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
# like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
block_pause_hyphen=1
fi

# Markdown heading " - " pause: line starts with `#` followed by prose
# and ` - ` pattern.
if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
block_pause_hyphen=1
fi

# Markdown blockquote " - " pause: line starts with `>` followed by prose
# and ` - ` pattern. (Single `>` blockquote, not table.)
if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
if grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
block_pause_hyphen=1
fi

# HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
# closing HTML tag from a prose context, then prose-style ` - `.
if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
block_pause_hyphen=1
fi

# JSON prose-value " - " pause: a string assignment whose KEY is one of the
# three prose-bearing keys this project's JSON uses. Scoping to the key is what
# keeps this off semver ranges, script commands, urls, paths and globs, every
# one of which lives under a different key. Shape, not file path: the Bash
# payload carries no file_path, so a heredoc writing a manifest is covered too.
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
block_pause_hyphen=1
fi

# YAML front-matter " - " pause, same three keys. Anchored at column 0 with no
# leading whitespace, which is what confines it to document front matter: every
# nested YAML mapping is indented, including the workflow-input `description:`
# values in .github/workflows/release.yml.
if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
block_pause_hyphen=1
fi

Expand All @@ -122,13 +146,17 @@ restructured phrasing.
Bad: <li>Foo - bar.</li>
Good: <li>Foo, with bar.</li>

Bad: "description": "A library - for things"
Good: "description": "A library for things"

Plain hyphens are still fine in compound words (`AI-first`), CLI
flags (`--http2`), filenames, ranges, and math expressions in code
(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
` < word > - < word > ` pause-pattern in prose contexts (comments,
markdown headings, blockquotes, HTML prose tags).
markdown headings, blockquotes, HTML prose tags, and a JSON or
front-matter description / title / displayName value).

Rule: AGENTS.md, Invariants section, item 10.
Rule: AGENTS.md, Invariants section, item 11.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
Expand All @@ -138,19 +166,29 @@ fi
# Same prose-context guard as #2.
block_pause_semicolon=0

if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
block_pause_semicolon=1
fi

if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
block_pause_semicolon=1
fi

if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
if grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
block_pause_semicolon=1
fi

if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
block_pause_semicolon=1
fi

# JSON prose-value " ; " pause, same three keys as rule 2.
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
block_pause_semicolon=1
fi

# YAML front-matter " ; " pause, column-0 anchored like rule 2.
if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
block_pause_semicolon=1
fi

Expand All @@ -165,10 +203,14 @@ two sentences (period) or with a conjunction (", and", ", but", ", so").
Good: // Forms work. Links work too.
Good: // Forms work, and links work too.

Bad: "description": "Forms work ; links work too."
Good: "description": "Forms work. Links work too."

Semicolons stay fine inside code (JS statement terminators, CSS
declarations) since those are not flagged.
declarations) since those are not flagged. Only the space-surrounded
form is banned, so an ordinary English semicolon is untouched.

Rule: AGENTS.md, Invariants section, item 10.
Rule: AGENTS.md, Invariants section, item 11.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
Expand All @@ -179,7 +221,7 @@ fi
# lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
# not code, AND the inner code ends in `()` so the colon visually parses
# as a return-type annotation.
if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
if grep -qE '\)</code>:[[:space:]][a-z]' <<< "$new_content"; then
cat >&2 <<'EOF'
BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").

Expand All @@ -190,7 +232,7 @@ parses as a TypeScript return-type annotation. Rewrite verb-led.
Good: <code>repeat()</code> is the keyed list directive
Good: <code>startServer()</code> creates an HTTP(S) server

Rule: AGENTS.md, Invariants section, item 10.
Rule: AGENTS.md, Invariants section, item 11.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
Expand All @@ -199,7 +241,7 @@ fi
# --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
# HTML reserves hyphenated tag names for custom elements (W3C spec), so
# `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then
cat >&2 <<'EOF'
BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").

Expand All @@ -210,7 +252,7 @@ webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
Bad: // <ui-dialog-content>: the centered panel.
Good: // <ui-dialog-content> is the centered panel.

Rule: AGENTS.md, Invariants section, item 10.
Rule: AGENTS.md, Invariants section, item 11.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
Expand All @@ -220,7 +262,7 @@ fi
# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
# lowercase prose. Avoids TS return-type annotations because those never
# appear inside comment lines.
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then
cat >&2 <<'EOF'
BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").

Expand All @@ -231,7 +273,7 @@ webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
Bad: // closest(): null if the click wasn't inside a frame
Good: // closest() returns null when the click wasn't inside a frame

Rule: AGENTS.md, Invariants section, item 10.
Rule: AGENTS.md, Invariants section, item 11.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
9. **No backtick characters inside `html\`...\`` template bodies**, even inside CSS / HTML comments. A nested backtick closes the literal at JS-parse time and 500s in prod.
10. **TypeScript must be erasable.** Set `compilerOptions.erasableSyntaxOnly: true`. No `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators with `emitDecoratorMetadata`, no `import = require`. Types are stripped via Node 24+'s `module.stripTypeScriptTypes` (buildless, no bundler fallback); non-erasable syntax 500s at strip time. Enforced by `erasable-typescript-only` (tsconfig flag) and `no-non-erasable-typescript` (source scan). See `references/typescript.md`.

11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).
11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges, and semicolons and colons stay fine inside code / TS / CSS and inside JSON SYNTAX. A JSON or front-matter `description`, `title`, or `displayName` VALUE is prose, not code, and is scanned like any other prose. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).

12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}>`** (#1155, #1207, #1307). Those are the two shapes the renderer reads, and every near-miss throws rather than producing a form that posts nowhere. The submitter identity rides the pressed button's own `name`/`value` pair (the one channel a browser submits for that button alone), so no `formaction` url is emitted, both entries reach the server, and the dispatcher takes the LAST `__webjs_action` in DOM order, which is the submitter's whenever one was pressed. **Refused on a form:** a quoted `action="${fn}"`, `action=${fn}` on any tag other than `<form>`, `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole, a plain `action="/url"` alongside the bound hole (SSR keeps it and the client drops it), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. **Refused on a submitter:** a control that is not a submit control, an `<input type="image">` (it submits `name.x` / `name.y` coordinates, so the identity never arrives), an `<input type="submit">` (the identity has to occupy its `value`, which on that control is also its visible label, so it would render captioned with the action id and could never be labelled; a `<button>` has no such conflict), a submitter carrying its own `name`, `value`, `form`, or static `formaction`, a `.prop` spelling of any of those (`.name` / `.value` / `.formAction` / `.formMethod` / `.formEnctype` all REFLECT on a submitter, so the write is dropped at SSR and lands in the attribute in the browser), and a second `formaction` hole. **Refused on a BOUND submitter, as a same-element contradiction:** its own `formmethod` other than post, an unparseable `formenctype` such as `text/plain`, and `formmethod="dialog"` (which dismisses a `<dialog>` instead of submitting, so the bound action could never run). A PLAIN submitter's own `formmethod` / `formenctype` is NOT refused: native HTML says the submitter's override wins, the author typed it deliberately, and the form's action simply does not run, so the renderer honours it and the dev-time client guard reports at submit time when a submission holds an identity it cannot deliver. A plain `formaction="/url"` likewise retargets away from the bound action and is the author's business. WebJs supplies `method` and `enctype` (and, on a bound submitter, `formmethod` and `formenctype`) only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` emits nothing so it is supplied, while `method=${null}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **A bound submitter is SELF-SUFFICIENT and asks nothing of the form around it** (#1307). The renderer supplies submission attributes at the level where the action is BOUND, and never overrides what you wrote at that same level: a bound `<form>` gains `method="post"` plus `enctype`, and a bound `<button>` gains `formmethod="post"` plus `formenctype` ON THE BUTTON, which is what React does for a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all. That is why the refusal list above contains no rule about a submitter's NEIGHBOURS: the renderer refuses only a SAME-ELEMENT contradiction, which has no correct fallback, and never a cross-element rule, which always has one (whatever native HTML would do). It also removed a question neither renderer could answer honestly, since a COMPONENT renders its own template in a separate pass with no view of the host page and the client may reconcile a submitter whose form is not in the tree yet. One consequence: no `formaction` url is emitted (an empty one is a conformance error), so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs if `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin the identity is ignored and nothing runs, which the dev-time client guard reports at submit time.

Expand Down
Loading
Loading