diff options
Diffstat (limited to 'languages/typescript/claude')
| -rwxr-xr-x | languages/typescript/claude/hooks/validate-typescript.sh | 94 | ||||
| -rw-r--r-- | languages/typescript/claude/settings.json | 80 |
2 files changed, 174 insertions, 0 deletions
diff --git a/languages/typescript/claude/hooks/validate-typescript.sh b/languages/typescript/claude/hooks/validate-typescript.sh new file mode 100755 index 0000000..b76f1df --- /dev/null +++ b/languages/typescript/claude/hooks/validate-typescript.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Validate TypeScript/JavaScript files after Edit/Write/MultiEdit. +# PostToolUse hook: receives tool-call JSON on stdin. +# +# On success: exit 0 silent. +# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude +# sees a structured error in its context, THEN exit 2 to block the tool +# pipeline. stderr still echoes the error for terminal visibility. +# +# Gate: parseability. A file that doesn't parse is never worth passing on. +# Full type checking is deliberately NOT enforced here — it needs the whole +# project graph and its dependencies resolved, which is a build-scale +# operation, not a per-keystroke one. A type error that parses cleanly passes +# this hook; `make typecheck` / `tsc --noEmit` over the project owns it. +# +# Formatting (prettier) is also out: a project picks its own style, so blocking +# on an unconfigured default would impose a contested choice. +# +# Two checkers, because one tool can't do both jobs: +# +# .js/.jsx/.mjs/.cjs → `node --check`, a straight parse. +# .ts/.tsx/.mts/.cts → `tsc`, filtered to syntax-category diagnostics. +# +# `node --check` must NOT be used on TypeScript. It ignores +# --experimental-strip-types, so it is wrong in *both* directions: it rejects +# valid TS (an `interface` declaration reads as a syntax error) and accepts +# broken TS (a genuinely unparseable file exits 0). Measured on node v26.4.0, +# 2026-07-23. tsc is the only correct parser for these extensions. +# +# The tsc call is filtered to TS1xxx codes, which is TypeScript's syntactic +# diagnostic range; TS2xxx and up are semantic (type) errors and are out of +# scope by the paragraph above. Without the filter this hook would block every +# unresolved import in a file whose dependencies aren't installed yet. + +set -u + +# Emit a JSON failure payload and exit 2. Arguments: +# $1 — short failure type (e.g. "TYPESCRIPT SYNTAX ERROR") +# $2 — file path +# $3 — tool output (error body) +fail_json() { + local ctx + ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \ + | jq -Rs .)" + cat <<EOF +{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}} +EOF + printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2 + exit 2 +} + +f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')" +[ -z "$f" ] && exit 0 +[ -f "$f" ] || exit 0 + +# Classify by extension. Match on the basename, not the full path — a parent +# dir can carry a dot (e.g. my.project/) and confuse a path-wide match. +kind="" +base="${f##*/}" +case "$base" in + *.ts | *.tsx | *.mts | *.cts) kind="ts" ;; + *.js | *.jsx | *.mjs | *.cjs) kind="js" ;; + *) exit 0 ;; +esac + +if [ "$kind" = "js" ]; then + command -v node >/dev/null 2>&1 || exit 0 + if ! out="$(node --check "$f" 2>&1)"; then + fail_json "JAVASCRIPT SYNTAX ERROR" "$f" "$out" + fi + exit 0 +fi + +# TypeScript. Prefer a project-local tsc so the project's own version decides, +# falling back to one on PATH. +tsc_bin="" +if [ -x "./node_modules/.bin/tsc" ]; then + tsc_bin="./node_modules/.bin/tsc" +elif command -v tsc >/dev/null 2>&1; then + tsc_bin="tsc" +else + exit 0 # no TypeScript compiler available — don't block the edit +fi + +# --moduleDetection force so a file with no import/export still parses as a +# module rather than tripping global-scope collisions against lib types. +out="$("$tsc_bin" --noEmit --skipLibCheck --target es2022 --moduleDetection force "$f" 2>&1 || true)" +syntax_errors="$(printf '%s\n' "$out" | grep -E 'error TS1[0-9]{3}:' || true)" + +if [ -n "$syntax_errors" ]; then + fail_json "TYPESCRIPT SYNTAX ERROR" "$f" "$syntax_errors" +fi + +exit 0 diff --git a/languages/typescript/claude/settings.json b/languages/typescript/claude/settings.json new file mode 100644 index 0000000..f4c9211 --- /dev/null +++ b/languages/typescript/claude/settings.json @@ -0,0 +1,80 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "permissions": { + "allow": [ + "Bash(make)", + "Bash(make help)", + "Bash(make targets)", + "Bash(make test)", + "Bash(make test *)", + "Bash(make lint)", + "Bash(make fmt)", + "Bash(make coverage)", + "Bash(make coverage-summary)", + "Bash(make typecheck)", + "Bash(make build)", + "Bash(npm test)", + "Bash(npm test *)", + "Bash(npm run *)", + "Bash(npm ci)", + "Bash(npm ls *)", + "Bash(node --check *)", + "Bash(node --test *)", + "Bash(node --version)", + "Bash(tsc --noEmit *)", + "Bash(npx tsc --noEmit *)", + "Bash(eslint *)", + "Bash(prettier --check *)", + "Bash(git status)", + "Bash(git status *)", + "Bash(git diff)", + "Bash(git diff *)", + "Bash(git log)", + "Bash(git log *)", + "Bash(git show)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(git branch)", + "Bash(git branch -v)", + "Bash(git branch -a)", + "Bash(git branch --list *)", + "Bash(git remote)", + "Bash(git remote -v)", + "Bash(git remote show *)", + "Bash(git ls-files *)", + "Bash(git rev-parse *)", + "Bash(git cat-file *)", + "Bash(git stash list)", + "Bash(git stash show *)", + "Bash(jq *)", + "Bash(date)", + "Bash(date *)", + "Bash(which *)", + "Bash(file *)", + "Bash(ls)", + "Bash(ls *)", + "Bash(wc *)", + "Bash(du *)", + "Bash(readlink *)", + "Bash(realpath *)", + "Bash(basename *)", + "Bash(dirname *)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-typescript.sh" + } + ] + } + ] + } +} |
