aboutsummaryrefslogtreecommitdiff
path: root/languages/typescript/claude/hooks/validate-typescript.sh
blob: b76f1dfe4502a7acc2fd10ef36f7e67a26aded99 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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