From 519ac23ebbaef91ab11fbe34644842be89474527 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 29 Aug 2026 11:11:24 -0400 Subject: [PATCH] fix(#3839): hook tables say PreToolUse (validate-commit) and SessionStart (session-state) (#4041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3839): docs hook tables must match surface registrations (failing first) * docs(#3839): hook tables say PreToolUse for validate-commit, SessionStart for session-state gsd-validate-commit.sh is registered PreToolUse (src/runtime-hooks-surface.cts; its exit-2 block IS the contract — a post-tool hook cannot prevent a commit) and gsd-session-state.sh is registered SessionStart (session orientation, not post-tool tracking). Both rows said PostToolUse in ARCHITECTURE.md and the three INVENTORY locales; the issue asked for a neighbouring-row scan, which is how the session-state row was found. All other rows in the four tables verify against the surface. * fix(#3839): review fold-ins — 10 more wrong rows in ko-KR/pt-BR/zh-CN, parser authority + drift pins Adversarial review found the same two wrong rows shipped in five more files the issue's table missed (ko-KR ARCHITECTURE+INVENTORY, pt-BR ARCHITECTURE+INVENTORY, zh-CN ARCHITECTURE) — all fixed; DOC_TABLES now covers all ten shipped tables. The parity parser unioned only the Kimi mirror list, silently exempting agent-isolation-guard (registered via the dynamic preToolEvent push): probes are now parsed too, with bare hook names resolved against hooks/ ground truth and dynamic event variables resolved to their canonical (non-Gemini) events; an exact-set pin replaces the loose size guard. allow-test-rule marker carries the issue ref; unverified-ceiling 280→281 (audited: the new marker is legitimate — the suite reads product docs whose text is the contract). * fix(#3839): register the hook-table parity suite in the docs-guard lane The new suite reads ten docs/ paths, so lint-docs-guard-registration requires it in the docs-guard registry — the first GREEN bench run caught the omission (the RED run's docs-guard failures were the same signal, previously misread as marker fallout). * chore(#3839): changeset fragment (pr number backfilled after PR creation) * chore(#3839): backfill changeset PR number (4041) --------- Co-authored-by: sim --- .changeset/jolly-badgers-cheer.md | 5 + docs/ARCHITECTURE.md | 4 +- docs/INVENTORY.md | 4 +- docs/ja-JP/INVENTORY.md | 4 +- docs/ko-KR/ARCHITECTURE.md | 4 +- docs/ko-KR/INVENTORY.md | 4 +- docs/pt-BR/ARCHITECTURE.md | 4 +- docs/pt-BR/INVENTORY.md | 4 +- docs/zh-CN/ARCHITECTURE.md | 4 +- docs/zh-CN/INVENTORY.md | 4 +- scripts/docs-guard-registry.cjs | 14 ++ ...low-test-rule-refs.unverified-ceiling.json | 2 +- scripts/lint-test-file-count.allowlist.json | 3 +- tests/docs-hooks-table-parity.test.cjs | 170 ++++++++++++++++++ 14 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 .changeset/jolly-badgers-cheer.md create mode 100644 tests/docs-hooks-table-parity.test.cjs diff --git a/.changeset/jolly-badgers-cheer.md b/.changeset/jolly-badgers-cheer.md new file mode 100644 index 000000000..b1f50cd0b --- /dev/null +++ b/.changeset/jolly-badgers-cheer.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4041 +--- +the shipped hook tables documented gsd-validate-commit.sh as PostToolUse (it is registered PreToolUse — exit-2 blocking is its contract) and gsd-session-state.sh as PostToolUse (registered SessionStart); 18 wrong rows corrected across ARCHITECTURE.md and INVENTORY.md in en/ja-JP/zh-CN/ko-KR/pt-BR, with a new docs-vs-surface parity suite guarding all ten tables (#3839) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 28b951269..4c4d3ffe2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -295,8 +295,8 @@ Runtime hooks that integrate with the host AI agent: | `gsd-read-injection-scanner.js` | `PostToolUse` | Scans Read tool output for injected instructions in untrusted content | | `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in via `hooks.workflow_guard`) | | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on files not yet read in the session | -| `gsd-session-state.sh` | `PostToolUse` | Session state tracking for shell-based runtimes | -| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional commit enforcement | +| `gsd-session-state.sh` | `SessionStart` | Session state tracking for shell-based runtimes | +| `gsd-validate-commit.sh` | `PreToolUse` | Commit validation for conventional commit enforcement | | `gsd-phase-boundary.sh` | `PostToolUse` | Phase boundary detection for workflow transitions | See [`docs/INVENTORY.md`](INVENTORY.md#hooks) for the authoritative hook roster. diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index b7408c99f..869a153e1 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -712,8 +712,8 @@ Full listing: `hooks/`. | `gsd-write-guard.js` | `PreToolUse` | Hard-blocks a whole-file `Write` that catastrophically shrinks a curated `.planning/` artifact (ROADMAP.md, milestone roadmaps, STATE.md); override via the single-use sentinel `.planning/.gsd-allow-shrink` (workflow steps) or `GSD_ALLOW_PLANNING_SHRINK=1` (interactive) (#2255, fix 3 of #973) | | `gsd-config-reload.js` | `FileChanged` | Hot-reloads GSD config context when `.planning/config.json` changes mid-session (#770) | | `gsd-ensure-canonical-path.js` | `SessionStart` | Symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve in marketplace plugin installs; no-op in classic installs, self-heals after `claude plugin update` (#997) | -| `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes | -| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement | +| `gsd-session-state.sh` | `SessionStart` | Session-state tracking for shell-based runtimes | +| `gsd-validate-commit.sh` | `PreToolUse` | Commit validation for conventional-commit enforcement | | `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions | | `gsd-graphify-update.sh` | `PostToolUse` | Auto-rebuild knowledge graph after main HEAD advances (opt-in, default off — #3347) | | `gsd-node-runner.sh` | (helper) | Portable node resolver managed JS hook commands route through under `--portable-hooks`: install-time node path first, then `command -v node`, then well-known layouts — resolves at hook-fire time so a shared config root works in every environment (#3662) | diff --git a/docs/ja-JP/INVENTORY.md b/docs/ja-JP/INVENTORY.md index eb3895167..573c60872 100644 --- a/docs/ja-JP/INVENTORY.md +++ b/docs/ja-JP/INVENTORY.md @@ -476,8 +476,8 @@ | `gsd-worktree-path-guard.js` | `PreToolUse` | ワークツリールート外の絶対パスを持つ Edit/Write/MultiEdit をハードブロック(PR #579、#260) | | `gsd-agent-isolation-guard.js` | `PreToolUse` | プロジェクトの解決済みディスパッチ分離が `harness-worktree` の場合、ハーネス分離パラメータを欠く executor の `Agent()` ディスパッチをハードブロック(#3045) | | `gsd-write-guard.js` | `PreToolUse` | キュレーションされた `.planning/` アーティファクト(ROADMAP.md、マイルストーンロードマップ、STATE.md)を大幅に縮小するファイル全体の `Write` をハードブロック。使い捨てセンチネル `.planning/.gsd-allow-shrink`(ワークフローステップ)または `GSD_ALLOW_PLANNING_SHRINK=1`(対話時)でオーバーライド(#2255、#973 の修正 3) | -| `gsd-session-state.sh` | `PostToolUse` | シェルベースランタイム向けのセッション状態追跡 | -| `gsd-validate-commit.sh` | `PostToolUse` | Conventional Commit 適用のためのコミットバリデーション | +| `gsd-session-state.sh` | `SessionStart` | シェルベースランタイム向けのセッション状態追跡 | +| `gsd-validate-commit.sh` | `PreToolUse` | Conventional Commit 適用のためのコミットバリデーション | | `gsd-phase-boundary.sh` | `PostToolUse` | ワークフロー遷移のためのフェーズ境界検出 | | `gsd-graphify-update.sh` | `PostToolUse` | メイン HEAD が進んだ後にナレッジグラフを自動再ビルド(オプトイン、デフォルトオフ — #3347) | diff --git a/docs/ko-KR/ARCHITECTURE.md b/docs/ko-KR/ARCHITECTURE.md index f8871e2b0..1fffeb259 100644 --- a/docs/ko-KR/ARCHITECTURE.md +++ b/docs/ko-KR/ARCHITECTURE.md @@ -247,8 +247,8 @@ GSD 워크플로우에 thinking 클래스 모델(o3, o4-mini, Gemini 2.5 Pro)을 | `gsd-read-injection-scanner.js` | `PostToolUse` | 신뢰할 수 없는 콘텐츠에서 주입된 지시 사항을 위한 Read 도구 출력 스캔 | | `gsd-workflow-guard.js` | `PreToolUse` | GSD 워크플로우 컨텍스트 외부의 파일 편집 감지 (자문적, `hooks.workflow_guard`를 통한 옵트인) | | `gsd-read-guard.js` | `PreToolUse` | 세션에서 아직 읽지 않은 파일에 Edit/Write를 방지하는 자문적 가드 | -| `gsd-session-state.sh` | `PostToolUse` | 쉘 기반 런타임을 위한 세션 상태 추적 | -| `gsd-validate-commit.sh` | `PostToolUse` | 컨벤셔널 커밋 시행을 위한 커밋 검증 | +| `gsd-session-state.sh` | `SessionStart` | 쉘 기반 런타임을 위한 세션 상태 추적 | +| `gsd-validate-commit.sh` | `PreToolUse` | 컨벤셔널 커밋 시행을 위한 커밋 검증 | | `gsd-phase-boundary.sh` | `PostToolUse` | 워크플로우 전환을 위한 단계 경계 감지 | 권위 있는 11개 훅 목록은 [`docs/INVENTORY.md`](INVENTORY.md#hooks-11-shipped)를 참조하라. diff --git a/docs/ko-KR/INVENTORY.md b/docs/ko-KR/INVENTORY.md index 04a3bc1f1..a997331f9 100644 --- a/docs/ko-KR/INVENTORY.md +++ b/docs/ko-KR/INVENTORY.md @@ -476,8 +476,8 @@ | `gsd-worktree-path-guard.js` | `PreToolUse` | 워크트리 루트 외부의 절대 경로로 Edit/Write/MultiEdit를 하드 차단 (PR #579, #260) | | `gsd-agent-isolation-guard.js` | `PreToolUse` | 프로젝트의 해석된 디스패치 격리가 `harness-worktree`일 때 하네스 격리 매개변수가 누락된 executor `Agent()` 디스패치를 하드 차단 (#3045) | | `gsd-write-guard.js` | `PreToolUse` | 큐레이션된 `.planning/` 아티팩트(ROADMAP.md, 마일스톤 로드맵, STATE.md)를 치명적으로 축소하는 전체 파일 `Write`를 하드 차단. 일회용 센티널 `.planning/.gsd-allow-shrink`(워크플로 단계) 또는 `GSD_ALLOW_PLANNING_SHRINK=1`(대화형)로 우회 가능 (#2255, #973의 수정 3) | -| `gsd-session-state.sh` | `PostToolUse` | 셸 기반 런타임을 위한 세션 상태 추적 | -| `gsd-validate-commit.sh` | `PostToolUse` | 컨벤셔널 커밋 적용을 위한 커밋 검증 | +| `gsd-session-state.sh` | `SessionStart` | 셸 기반 런타임을 위한 세션 상태 추적 | +| `gsd-validate-commit.sh` | `PreToolUse` | 컨벤셔널 커밋 적용을 위한 커밋 검증 | | `gsd-phase-boundary.sh` | `PostToolUse` | 워크플로우 전환을 위한 단계 경계 감지 | | `gsd-graphify-update.sh` | `PostToolUse` | 메인 HEAD 진행 후 지식 그래프 자동 재빌드 (옵트인, 기본 비활성화 — #3347) | diff --git a/docs/pt-BR/ARCHITECTURE.md b/docs/pt-BR/ARCHITECTURE.md index 969610aa6..e0c205cba 100644 --- a/docs/pt-BR/ARCHITECTURE.md +++ b/docs/pt-BR/ARCHITECTURE.md @@ -262,8 +262,8 @@ Hooks de runtime que se integram ao agente de IA anfitrião: | `gsd-read-injection-scanner.js` | `PostToolUse` | Escaneia saídas da ferramenta Read em busca de instruções injetadas em conteúdo não confiável | | `gsd-workflow-guard.js` | `PreToolUse` | Detecta edições de arquivos fora do contexto de workflow do GSD (consultivo, ativado via `hooks.workflow_guard`) | | `gsd-read-guard.js` | `PreToolUse` | Guarda consultivo que impede Edit/Write em arquivos ainda não lidos na sessão | -| `gsd-session-state.sh` | `PostToolUse` | Rastreamento de estado de sessão para runtimes baseados em shell | -| `gsd-validate-commit.sh` | `PostToolUse` | Validação de commit para aplicação de commits convencionais | +| `gsd-session-state.sh` | `SessionStart` | Rastreamento de estado de sessão para runtimes baseados em shell | +| `gsd-validate-commit.sh` | `PreToolUse` | Validação de commit para aplicação de commits convencionais | | `gsd-phase-boundary.sh` | `PostToolUse` | Detecção de limite de fase para transições de workflow | Consulte [`docs/INVENTORY.md`](INVENTORY.md#hooks-11-shipped) para o roster oficial de 11 hooks. diff --git a/docs/pt-BR/INVENTORY.md b/docs/pt-BR/INVENTORY.md index f1e158192..21e39901e 100644 --- a/docs/pt-BR/INVENTORY.md +++ b/docs/pt-BR/INVENTORY.md @@ -476,8 +476,8 @@ Listagem completa: `hooks/`. | `gsd-worktree-path-guard.js` | `PreToolUse` | Bloqueia rigorosamente Edit/Write/MultiEdit com caminhos absolutos fora da raiz do worktree (PR #579, #260) | | `gsd-agent-isolation-guard.js` | `PreToolUse` | Bloqueia rigorosamente um dispatch `Agent()` de executor que não tenha o parâmetro de isolamento do harness quando o isolamento de dispatch resolvido do projeto é `harness-worktree` (#3045) | | `gsd-write-guard.js` | `PreToolUse` | Bloqueia rigorosamente um `Write` de arquivo inteiro que encolhe catastroficamente um artefato curado de `.planning/` (ROADMAP.md, roadmaps de milestone, STATE.md); override via o sentinela de uso único `.planning/.gsd-allow-shrink` (passos de workflow) ou `GSD_ALLOW_PLANNING_SHRINK=1` (interativo) (#2255, correção 3 de #973) | -| `gsd-session-state.sh` | `PostToolUse` | Rastreamento de estado de sessão para runtimes baseados em shell | -| `gsd-validate-commit.sh` | `PostToolUse` | Validação de commit para aplicação de conventional-commit | +| `gsd-session-state.sh` | `SessionStart` | Rastreamento de estado de sessão para runtimes baseados em shell | +| `gsd-validate-commit.sh` | `PreToolUse` | Validação de commit para aplicação de conventional-commit | | `gsd-phase-boundary.sh` | `PostToolUse` | Detecção de limite de fase para transições de workflow | | `gsd-graphify-update.sh` | `PostToolUse` | Reconstrução automática do grafo de conhecimento após o avanço do HEAD principal (opt-in, padrão desativado — #3347) | diff --git a/docs/zh-CN/ARCHITECTURE.md b/docs/zh-CN/ARCHITECTURE.md index 9a0544981..733069b1a 100644 --- a/docs/zh-CN/ARCHITECTURE.md +++ b/docs/zh-CN/ARCHITECTURE.md @@ -247,8 +247,8 @@ GSD Core 是一个**元提示框架**,位于用户与 AI 编码 Agent(Claude | `gsd-read-injection-scanner.js` | `PostToolUse` | 扫描 Read 工具输出中不受信任内容里的注入指令 | | `gsd-workflow-guard.js` | `PreToolUse` | 检测 GSD 工作流上下文之外的文件编辑(建议性,通过 `hooks.workflow_guard` 选择启用) | | `gsd-read-guard.js` | `PreToolUse` | 建议性防护,防止对本会话中尚未读取的文件执行 Edit/Write | -| `gsd-session-state.sh` | `PostToolUse` | 基于 shell 的运行时的会话状态跟踪 | -| `gsd-validate-commit.sh` | `PostToolUse` | 用于规范提交格式执行的提交验证 | +| `gsd-session-state.sh` | `SessionStart` | 基于 shell 的运行时的会话状态跟踪 | +| `gsd-validate-commit.sh` | `PreToolUse` | 用于规范提交格式执行的提交验证 | | `gsd-phase-boundary.sh` | `PostToolUse` | 工作流转换的阶段边界检测 | 请参阅 [`docs/INVENTORY.md`](INVENTORY.md#hooks-11-shipped) 获取权威的 11 个 hook 列表。 diff --git a/docs/zh-CN/INVENTORY.md b/docs/zh-CN/INVENTORY.md index 5df0a30db..3d7b85e24 100644 --- a/docs/zh-CN/INVENTORY.md +++ b/docs/zh-CN/INVENTORY.md @@ -476,8 +476,8 @@ | `gsd-worktree-path-guard.js` | `PreToolUse` | 硬性阻止对 worktree 根目录之外绝对路径执行 Edit/Write/MultiEdit(PR #579,#260) | | `gsd-agent-isolation-guard.js` | `PreToolUse` | 当项目解析出的调度隔离模式为 `harness-worktree` 时,硬性阻止缺少隔离参数的 executor `Agent()` 调度(#3045) | | `gsd-write-guard.js` | `PreToolUse` | 硬性阻止将精选的 `.planning/` 工件(ROADMAP.md、里程碑路线图、STATE.md)灾难性缩减的整文件 `Write`;可通过一次性哨兵文件 `.planning/.gsd-allow-shrink`(工作流步骤)或 `GSD_ALLOW_PLANNING_SHRINK=1`(交互式)覆盖(#2255,#973 的修复 3) | -| `gsd-session-state.sh` | `PostToolUse` | 基于 shell 运行时的会话状态跟踪 | -| `gsd-validate-commit.sh` | `PostToolUse` | 常规提交强制执行的提交验证 | +| `gsd-session-state.sh` | `SessionStart` | 基于 shell 运行时的会话状态跟踪 | +| `gsd-validate-commit.sh` | `PreToolUse` | 常规提交强制执行的提交验证 | | `gsd-phase-boundary.sh` | `PostToolUse` | 工作流过渡的阶段边界检测 | | `gsd-graphify-update.sh` | `PostToolUse` | 在主 HEAD 推进后自动重建知识图谱(可选启用,默认关闭 — #3347) | diff --git a/scripts/docs-guard-registry.cjs b/scripts/docs-guard-registry.cjs index 768a8bc16..2f8d9abb1 100644 --- a/scripts/docs-guard-registry.cjs +++ b/scripts/docs-guard-registry.cjs @@ -215,6 +215,20 @@ const DOCS_GUARD_TESTS = { // Walks docs/*.md and every docs//*.md dir dynamically // (docs-parity-live-registry.test.cjs:42, 428) — deliberately generic. 'tests/docs-parity-live-registry.test.cjs': ['*'], + // #3839: pins every hook-table Event cell in the five locales' ARCHITECTURE + // and INVENTORY files against src/runtime-hooks-surface.cts registrations. + 'tests/docs-hooks-table-parity.test.cjs': [ + 'docs/ARCHITECTURE.md', + 'docs/INVENTORY.md', + 'docs/ja-JP/ARCHITECTURE.md', + 'docs/ja-JP/INVENTORY.md', + 'docs/zh-CN/ARCHITECTURE.md', + 'docs/zh-CN/INVENTORY.md', + 'docs/ko-KR/ARCHITECTURE.md', + 'docs/ko-KR/INVENTORY.md', + 'docs/pt-BR/ARCHITECTURE.md', + 'docs/pt-BR/INVENTORY.md', + ], 'tests/docs-state-md-locale-parity.test.cjs': [ 'docs/reference/state-md.md', 'docs/ja-JP/reference/state-md.md', diff --git a/scripts/lint-allow-test-rule-refs.unverified-ceiling.json b/scripts/lint-allow-test-rule-refs.unverified-ceiling.json index 7518b69f2..0b280d272 100644 --- a/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +++ b/scripts/lint-allow-test-rule-refs.unverified-ceiling.json @@ -1,3 +1,3 @@ { - "maxFiles": 280 + "maxFiles": 281 } diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 4e6cec476..f0badbe74 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -245,11 +245,12 @@ }, "docs": { "files": [ + "docs-hooks-table-parity.test.cjs", "docs-parity-live-registry.test.cjs", "docs-state-md-locale-parity.test.cjs", "docs-update.test.cjs" ], - "issue": "3873", + "issue": "3873 | #3839 \u2014 the hook-event parity suite cross-checks the four docs hook tables against src/runtime-hooks-surface.cts registrations; a generated-docs concern distinct from content freshness and registry parity", "justification": "ADR-3473 \u00a78.8 makes docs/reference/state-md.md and its four locale siblings generated-and-committed and adds the repo's first locale-parity check (verified: no locale-parity lint precedent exists here). That check is a distinct concern from docs-update (content freshness) and docs-parity-live-registry (registry parity); folding it into either would place an unrelated subject inside them purely to satisfy a count. The section it guards is absent from all four translations today and documents the status enum behind #3853." }, "audit": { diff --git a/tests/docs-hooks-table-parity.test.cjs b/tests/docs-hooks-table-parity.test.cjs new file mode 100644 index 000000000..c6488451c --- /dev/null +++ b/tests/docs-hooks-table-parity.test.cjs @@ -0,0 +1,170 @@ +// allow-test-rule: source-text-is-the-product (#3839) +// Reads the docs hook tables and the hook-surface source whose registrations +// ARE the deployed contract — asserting the docs rows match the surface. + +/** + * Docs hook-table parity — docs-hooks-table-parity.test.cjs + * + * #3839: the shipped hook tables documented `gsd-validate-commit.sh` as + * PostToolUse when it is registered PreToolUse (a PreToolUse hook BLOCKS a + * commit via exit 2; a PostToolUse hook cannot — the documented event + * misdescribes the hook's entire contract). The same scan found + * `gsd-session-state.sh` documented PostToolUse while registered + * SessionStart. Nine files carried the wrong rows (ARCHITECTURE.md + + * INVENTORY.md × en, ja-JP, zh-CN, ko-KR, pt-BR). + * + * Truth source: the literal hook-spec array in `buildKimiHooksTomlBlock` + * (src/runtime-hooks-surface.cts) — its own comment pins the invariant + * "mirrors applySettingsJsonHooks' settings.json wiring 1:1" — unioned with + * literal-event probe lines (`settings.hooks..some(… + * referencesHook(…, 'gsd-…'))`). Registrations written through the + * runtime-resolved variables (`preToolEvent`/`postToolEvent`) are NOT + * statically parseable and are covered only via the mirror invariant; hooks + * registered on other surfaces (statusline, plugin-surface) are out of + * scope. Docs rows are exempt when their Event cell is not exactly one + * registered event (multi-event `A` / `B` cells, `statusLine`, `(helper)`, + * host-native names) — note docs/how-to/install-on-your-runtime.md also + * documents these mappings in transposed event-first tables, which this + * hook-first row parser intentionally does not read. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const SURFACE_PATH = path.join(ROOT, 'src', 'runtime-hooks-surface.cts'); + +const DOC_TABLES = [ + 'docs/ARCHITECTURE.md', + 'docs/INVENTORY.md', + 'docs/ja-JP/ARCHITECTURE.md', + 'docs/ja-JP/INVENTORY.md', + 'docs/zh-CN/ARCHITECTURE.md', + 'docs/zh-CN/INVENTORY.md', + 'docs/ko-KR/ARCHITECTURE.md', + 'docs/ko-KR/INVENTORY.md', + 'docs/pt-BR/ARCHITECTURE.md', + 'docs/pt-BR/INVENTORY.md', +]; + +// The exact basename set the surface parser must resolve. If a registration +// disappears or the parser drifts, this pin fails instead of the parity +// checks silently narrowing to a subset. +const EXPECTED_SURFACE_HOOKS = [ + 'gsd-agent-isolation-guard.js', + 'gsd-check-update.js', + 'gsd-config-reload.js', + 'gsd-context-monitor.js', + 'gsd-graphify-update.sh', + 'gsd-phase-boundary.sh', + 'gsd-prompt-guard.js', + 'gsd-read-guard.js', + 'gsd-read-injection-scanner.js', + 'gsd-session-state.sh', + 'gsd-validate-commit.sh', + 'gsd-workflow-guard.js', + 'gsd-worktree-path-guard.js', + 'gsd-write-guard.js', +]; + +/** hook basename → Set of events it is registered under on this surface. */ +function registeredHookEvents() { + const src = fs.readFileSync(SURFACE_PATH, 'utf8'); + const map = new Map(); + // Probe lines name hooks WITHOUT file extensions (`'gsd-session-state'`). + // Resolve a bare name against the actual hooks/ directory (the file's + // extension is ground truth) so both spellings resolve to one entry. + const resolveBase = (name) => { + if (/\.(sh|js|cmd)$/.test(name)) return name; + for (const ext of ['.js', '.sh', '.cmd']) { + if (fs.existsSync(path.join(ROOT, 'hooks', `${name}${ext}`))) return `${name}${ext}`; + } + return name; // no file twin (e.g. a name never documented in docs tables) + }; + const add = (base, event) => { + const key = resolveBase(base); + if (!map.has(key)) map.set(key, new Set()); + map.get(key).add(event); + }; + let m; + // Literal hook-spec array (the Kimi mirror of the settings.json wiring). + const specRe = /event:\s*'([A-Za-z]+)',\s*command:\s*cmd\('([^']+)'\)/g; + while ((m = specRe.exec(src)) !== null) add(path.basename(m[2]), m[1]); + // Probe lines paired with a literal event: + // settings.hooks..some(… referencesHook(…, '')) + const probeRe = /settings\.hooks\.([A-Za-z]+)\.some\(\(entry: HookGroup\) =>\s*\n\s*entry\.hooks && entry\.hooks\.some\(\(h: HookEntry\) => referencesHook\(h as Record, '([^']+)'\)/g; + while ((m = probeRe.exec(src)) !== null) add(m[2], m[1]); + // Probe lines paired with the runtime-resolved variables — statically + // resolved to their non-Gemini canonical events (docs document the + // canonical Claude/GS wiring; BeforeTool/AfterTool are the Gemini twins): + // const preToolEvent = hookEvents === 'gemini' ? 'BeforeTool' : 'PreToolUse' + const dynRe = /settings\.hooks\[(preToolEvent|postToolEvent)\]\.some\(\(entry: HookGroup\) =>\s*\n\s*entry\.hooks && entry\.hooks\.some\(\(h: HookEntry\) => referencesHook\(h as Record, '([^']+)'\)/g; + while ((m = dynRe.exec(src)) !== null) add(m[2], m[1] === 'preToolEvent' ? 'PreToolUse' : 'PostToolUse'); + return map; +} + +/** Hook-table rows: [basename, eventCell] for `gsd-*` hooks. */ +function docHookRows(docPath) { + const lines = fs.readFileSync(path.join(ROOT, docPath), 'utf8').split(/\r?\n/); + const rows = []; + for (const line of lines) { + const m = line.match(/^\|\s*`?(gsd-[a-z0-9-]+\.(?:sh|js|cmd))`?\s*\|\s*`([^`]+)`\s*\|/); + if (m) rows.push([m[1], m[2]]); + } + return rows; +} + +// Cells that are not a single surface event: multi-event (`A` / `B`), +// non-event identifiers (statusLine, host-native names), or placeholders. +const SINGLE_EVENT_CELL = /^[A-Z][A-Za-z]+$/; + +describe('docs hook tables match runtime-hooks-surface registrations', () => { + const surface = registeredHookEvents(); + + test('surface parser resolves the exact expected hook set (no silent drift)', () => { + const withExt = [...surface.keys()].filter((k) => /\.(sh|js|cmd)$/.test(k)).sort(); + assert.deepEqual( + withExt, + [...EXPECTED_SURFACE_HOOKS].sort(), + 'parser must resolve exactly the expected surface registrations' + ); + }); + + for (const doc of DOC_TABLES) { + test(`${doc}: every single-event hook row matches the surface`, () => { + const rows = docHookRows(doc); + assert.ok(rows.length >= 3, `${doc}: hook-table parser found rows (guard against silent table drift)`); + const mismatches = []; + for (const [base, cell] of rows) { + const events = surface.get(base); + if (!events) continue; // not registered on this surface (statusline, plugin-surface hooks…) + if (!SINGLE_EVENT_CELL.test(cell)) continue; // multi-event or non-event cell + if (!events.has(cell)) { + mismatches.push(`${base}: docs say \`${cell}\`, surface registers ${[...events].join(', ')}`); + } + } + assert.deepEqual(mismatches, [], `docs rows must match src/runtime-hooks-surface.cts (#3839)`); + }); + } + + test('#3839 regression pin: the two misdocumented hooks are asserted directly', () => { + assert.ok(surface.get('gsd-validate-commit.sh').has('PreToolUse'), + 'gsd-validate-commit.sh blocks commits — must stay PreToolUse on the surface'); + assert.ok(surface.get('gsd-session-state.sh').has('SessionStart'), + 'gsd-session-state.sh orients the session — must stay SessionStart on the surface'); + // ja-JP/ARCHITECTURE.md's hook table is a shorter translation that never + // listed these two hooks — it stays in the parity loop above but not here. + const PINNED = DOC_TABLES.filter((d) => d !== 'docs/ja-JP/ARCHITECTURE.md'); + for (const doc of PINNED) { + const rows = docHookRows(doc); + const vc = rows.find(([b]) => b === 'gsd-validate-commit.sh'); + assert.ok(vc, `${doc}: validate-commit row present`); + assert.equal(vc[1], 'PreToolUse', `${doc}: validate-commit documented as PreToolUse`); + const ss = rows.find(([b]) => b === 'gsd-session-state.sh'); + assert.ok(ss, `${doc}: session-state row present`); + assert.equal(ss[1], 'SessionStart', `${doc}: session-state documented as SessionStart`); + } + }); +});