Skip to content

Commit c0917df

Browse files
authored
fix: scan JSON and front-matter prose values for banned punctuation (#1334)
The prose-punctuation hook gated its pause-hyphen and pause-semicolon rules on four line shapes (a comment, a markdown heading, a blockquote, an HTML prose tag), so a JSON string value matched none of them and invariant 11 shipped straight through. The repo root manifest description and the ui registry description both carried a pause-hyphen because of it, and the repo-wide punctuation cleanup that introduced one of them was not caught. Rules 2 and 3 now also scan a description / title / displayName value, in JSON and in column-0 YAML front matter. The scope is the KEY, not the file, which is what keeps the rule off semver ranges, script commands, urls, paths and globs, since every one of those lives under a different key. Rules 1 through 4 also silently stopped enforcing on a payload past the pipe buffer: grep -q exits on its first match, that closes the pipe under printf, and under pipefail the SIGPIPE became the pipeline status, so the if was false and the rule skipped. Measured 0 of 8 blocks at 200 KB before, 8 of 8 after. Every match now reads from a here-string.
1 parent 4e952b9 commit c0917df

10 files changed

Lines changed: 344 additions & 79 deletions

File tree

.claude/hooks/block-prose-punctuation.sh

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

55+
# Every match below reads from a here-string, never a pipe. `grep -q` exits on
56+
# the first match, which closes a pipe under `printf`, and with `set -o pipefail`
57+
# that SIGPIPE became the pipeline status, so the rule silently skipped on any
58+
# payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB).
59+
5360
# --- 1. U+2014 em-dash --------------------------------------------------
54-
if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
61+
if grep -q $'\xe2\x80\x94' <<< "$new_content"; then
5562
cat >&2 <<'EOF'
5663
BLOCKED: em-dash (U+2014) detected in this tool call.
5764
@@ -61,7 +68,7 @@ restructured sentence. Do NOT replace it with " - " or " ; " or a
6168
trailing colon on code: those are also banned. See rule 2 / 3 / 4
6269
below for the alternatives.
6370
64-
Rule: AGENTS.md, Invariants section, item 10.
71+
Rule: AGENTS.md, Invariants section, item 11.
6572
Hook: .claude/hooks/block-prose-punctuation.sh.
6673
EOF
6774
exit 2
@@ -85,25 +92,42 @@ block_pause_hyphen=0
8592
# `*` (markdown bold-start would have a letter after, distinguishable),
8693
# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
8794
# like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
88-
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
95+
if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
8996
block_pause_hyphen=1
9097
fi
9198

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

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

104111
# HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
105112
# closing HTML tag from a prose context, then prose-style ` - `.
106-
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
113+
if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
114+
block_pause_hyphen=1
115+
fi
116+
117+
# JSON prose-value " - " pause: a string assignment whose KEY is one of the
118+
# three prose-bearing keys this project's JSON uses. Scoping to the key is what
119+
# keeps this off semver ranges, script commands, urls, paths and globs, every
120+
# one of which lives under a different key. Shape, not file path: the Bash
121+
# payload carries no file_path, so a heredoc writing a manifest is covered too.
122+
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
123+
block_pause_hyphen=1
124+
fi
125+
126+
# YAML front-matter " - " pause, same three keys. Anchored at column 0 with no
127+
# leading whitespace, which is what confines it to document front matter: every
128+
# nested YAML mapping is indented, including the workflow-input `description:`
129+
# values in .github/workflows/release.yml.
130+
if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
107131
block_pause_hyphen=1
108132
fi
109133

@@ -122,13 +146,17 @@ restructured phrasing.
122146
Bad: <li>Foo - bar.</li>
123147
Good: <li>Foo, with bar.</li>
124148
149+
Bad: "description": "A library - for things"
150+
Good: "description": "A library for things"
151+
125152
Plain hyphens are still fine in compound words (`AI-first`), CLI
126153
flags (`--http2`), filenames, ranges, and math expressions in code
127154
(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
128155
` < word > - < word > ` pause-pattern in prose contexts (comments,
129-
markdown headings, blockquotes, HTML prose tags).
156+
markdown headings, blockquotes, HTML prose tags, and a JSON or
157+
front-matter description / title / displayName value).
130158
131-
Rule: AGENTS.md, Invariants section, item 10.
159+
Rule: AGENTS.md, Invariants section, item 11.
132160
Hook: .claude/hooks/block-prose-punctuation.sh.
133161
EOF
134162
exit 2
@@ -138,19 +166,29 @@ fi
138166
# Same prose-context guard as #2.
139167
block_pause_semicolon=0
140168

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

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

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

153-
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
181+
if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
182+
block_pause_semicolon=1
183+
fi
184+
185+
# JSON prose-value " ; " pause, same three keys as rule 2.
186+
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
187+
block_pause_semicolon=1
188+
fi
189+
190+
# YAML front-matter " ; " pause, column-0 anchored like rule 2.
191+
if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
154192
block_pause_semicolon=1
155193
fi
156194

@@ -165,10 +203,14 @@ two sentences (period) or with a conjunction (", and", ", but", ", so").
165203
Good: // Forms work. Links work too.
166204
Good: // Forms work, and links work too.
167205
206+
Bad: "description": "Forms work ; links work too."
207+
Good: "description": "Forms work. Links work too."
208+
168209
Semicolons stay fine inside code (JS statement terminators, CSS
169-
declarations) since those are not flagged.
210+
declarations) since those are not flagged. Only the space-surrounded
211+
form is banned, so an ordinary English semicolon is untouched.
170212
171-
Rule: AGENTS.md, Invariants section, item 10.
213+
Rule: AGENTS.md, Invariants section, item 11.
172214
Hook: .claude/hooks/block-prose-punctuation.sh.
173215
EOF
174216
exit 2
@@ -179,7 +221,7 @@ fi
179221
# lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
180222
# not code, AND the inner code ends in `()` so the colon visually parses
181223
# as a return-type annotation.
182-
if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
224+
if grep -qE '\)</code>:[[:space:]][a-z]' <<< "$new_content"; then
183225
cat >&2 <<'EOF'
184226
BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").
185227
@@ -190,7 +232,7 @@ parses as a TypeScript return-type annotation. Rewrite verb-led.
190232
Good: <code>repeat()</code> is the keyed list directive
191233
Good: <code>startServer()</code> creates an HTTP(S) server
192234
193-
Rule: AGENTS.md, Invariants section, item 10.
235+
Rule: AGENTS.md, Invariants section, item 11.
194236
Hook: .claude/hooks/block-prose-punctuation.sh.
195237
EOF
196238
exit 2
@@ -199,7 +241,7 @@ fi
199241
# --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
200242
# HTML reserves hyphenated tag names for custom elements (W3C spec), so
201243
# `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
202-
if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
244+
if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then
203245
cat >&2 <<'EOF'
204246
BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").
205247
@@ -210,7 +252,7 @@ webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
210252
Bad: // <ui-dialog-content>: the centered panel.
211253
Good: // <ui-dialog-content> is the centered panel.
212254
213-
Rule: AGENTS.md, Invariants section, item 10.
255+
Rule: AGENTS.md, Invariants section, item 11.
214256
Hook: .claude/hooks/block-prose-punctuation.sh.
215257
EOF
216258
exit 2
@@ -220,7 +262,7 @@ fi
220262
# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
221263
# lowercase prose. Avoids TS return-type annotations because those never
222264
# appear inside comment lines.
223-
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
265+
if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then
224266
cat >&2 <<'EOF'
225267
BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").
226268
@@ -231,7 +273,7 @@ webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
231273
Bad: // closest(): null if the click wasn't inside a frame
232274
Good: // closest() returns null when the click wasn't inside a frame
233275
234-
Rule: AGENTS.md, Invariants section, item 10.
276+
Rule: AGENTS.md, Invariants section, item 11.
235277
Hook: .claude/hooks/block-prose-punctuation.sh.
236278
EOF
237279
exit 2

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
482482
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.
483483
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`.
484484

485-
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).
485+
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).
486486

487487
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.
488488

0 commit comments

Comments
 (0)