From 87a744a2ccf4d6b33c16bfed7e8cbdbf7897a032 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:40:19 -0500 Subject: [PATCH 01/16] Resolve extension hooks with native pre/post scripts Replace repeated command-template hook resolution with ordered native Bash, PowerShell, and Python dispatchers. Keep configuration canonical, render dispatch paths across agent integrations, and exercise parity and rejection cases. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 9 + docs/reference/extensions.md | 12 +- extensions/EXTENSION-API-REFERENCE.md | 33 +- .../commands/speckit.github.taskstoissues.md | 64 +--- scripts/bash/post-hooks.sh | 5 + scripts/bash/pre-hooks.sh | 257 ++++++++++++++ scripts/powershell/post-hooks.ps1 | 4 + scripts/powershell/pre-hooks.ps1 | 178 ++++++++++ scripts/python/post_hooks.py | 8 + scripts/python/pre_hooks.py | 90 +++++ src/specify_cli/agents.py | 7 + src/specify_cli/extensions/__init__.py | 12 +- src/specify_cli/integrations/base.py | 26 ++ templates/commands/analyze.md | 63 +--- templates/commands/checklist.md | 64 +--- templates/commands/clarify.md | 65 +--- templates/commands/constitution.md | 64 +--- templates/commands/converge.md | 75 +---- templates/commands/implement.md | 65 +--- templates/commands/plan.md | 65 +--- templates/commands/specify.md | 65 +--- templates/commands/tasks.md | 65 +--- templates/commands/taskstoissues.md | 64 +--- .../github/test_github_extension.py | 6 +- tests/integrations/test_integration_agy.py | 5 +- .../integrations/test_integration_alquimia.py | 5 +- .../test_integration_base_markdown.py | 4 + .../test_integration_base_skills.py | 14 +- .../test_integration_base_toml.py | 4 + .../test_integration_base_yaml.py | 4 + tests/integrations/test_integration_claude.py | 5 +- tests/integrations/test_integration_cline.py | 4 + tests/integrations/test_integration_codex.py | 6 +- .../integrations/test_integration_copilot.py | 13 +- .../integrations/test_integration_generic.py | 10 +- tests/integrations/test_integration_hermes.py | 8 +- tests/integrations/test_integration_junie.py | 4 + tests/test_command_template_hooks.py | 125 ++++--- tests/test_hook_dispatch_scripts.py | 313 ++++++++++++++++++ 39 files changed, 1071 insertions(+), 819 deletions(-) create mode 100755 scripts/bash/post-hooks.sh create mode 100755 scripts/bash/pre-hooks.sh create mode 100644 scripts/powershell/post-hooks.ps1 create mode 100644 scripts/powershell/pre-hooks.ps1 create mode 100644 scripts/python/post_hooks.py create mode 100644 scripts/python/pre_hooks.py create mode 100644 tests/test_hook_dispatch_scripts.py diff --git a/design/integration.md b/design/integration.md index 117641f0d9..85a00742f0 100644 --- a/design/integration.md +++ b/design/integration.md @@ -75,6 +75,15 @@ adding per-agent wrapper scripts. For `generic`, extension registration resolves the persisted `--commands-dir` rather than the static registry placeholder; `--skills` emits skills into that same directory. +Core command templates also use `{PRE_HOOK_SCRIPT}` and `{POST_HOOK_SCRIPT}`. +Rendering selects the same `sh`, `ps`, or `py` variant as the command's main +script; the shared-infrastructure installer includes the selected native +pre/post entry points. Each variant resolves hooks in its own runtime, without +depending on another variant. The resolver returns ordered hook metadata as +JSON; agent commands themselves remain the responsibility of the agent. The +command/skill registrar resolves these placeholders too when a preset wraps +a core command. + ## Ownership and lifecycle An installation records its files and SHA-256 hashes in diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8d9eaaada7..aa6f03a292 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -273,7 +273,7 @@ Spec Kit stores project-level extension registration and hook configuration in: .specify/extensions.yml ``` -The file contains installed extensions, global settings, and hooks that are surfaced before or after Spec Kit commands. +The file contains installed extensions, global settings, and hooks that are resolved by pre/post scripts before or after Spec Kit commands. ```yaml installed: @@ -315,17 +315,19 @@ Each hook entry supports the following fields: | `extension` | ID of the extension that registered the hook. | | `command` | Extension command associated with the hook. | | `enabled` | Whether the hook is active. Hooks with `enabled: false` are skipped. | -| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the hook is emitted as an automatic hook (includes `EXECUTE_COMMAND` markers). | -| `priority` | Priority metadata for the hook. Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. | +| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the agent invokes it and waits for completion. | +| `priority` | Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Core command dispatchers sort by ascending priority, preserving file order for ties. | | `prompt` | Message shown when asking whether to run an optional hook. | | `description` | Human-readable explanation of what the hook does. | -| `condition` | Optional expression evaluated by `HookExecutor` (using `config.` or `env.` with `is set`, `==`, or `!=`). Current command templates do not evaluate conditions and skip hooks with a non-empty condition. | +| `condition` | Optional expression evaluated by `HookExecutor` (using `config.` or `env.` with `is set`, `==`, or `!=`). Core command dispatchers preserve the existing prompt behavior: skip hooks with a non-empty condition rather than evaluating it. | Hook event names identify when a hook is invoked. They generally use `before_` or `after_`, such as `before_implement`, `after_implement`, `before_tasks`, and `after_tasks`. Extension manifests reject invalid hook priorities during installation. For existing `.specify/extensions.yml` entries, `HookExecutor.get_hooks_for_event()` sorts with `normalize_priority()`: missing values, booleans, non-numeric values rejected by `int()`, and values less than `1` fall back to `10`; numeric strings and finite floats are coerced with `int()`, while non-finite floats are unsupported and may fail instead of falling back. -`HookExecutor.get_hooks_for_event()` returns hooks ordered by `priority`, with lower values first. However, current command templates read hook lists directly and surface them in their configured YAML order rather than using priority ordering. +Core commands run two hook dispatchers, one before and one after their main work, using the project's selected `sh`, `ps`, or `py` script type. Each variant resolves hooks independently and returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. The Python variant needs Python 3 with PyYAML when `.specify/extensions.yml` exists; Bash and PowerShell do not use Python or PyYAML. + +The native Bash and PowerShell resolvers support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported; these variants reject them explicitly rather than silently skipping hooks. The CLI writes hook configuration in this canonical layout without wrapping long scalar values. The Python variant accepts general YAML through PyYAML. ## FAQ diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index a7bece0b89..f53fbc5367 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -652,26 +652,27 @@ hooks: condition: null ``` -### Hook Message Format +### Core Command Hook Dispatch -```markdown -## Extension Hooks - -**Optional Hook**: {extension} -Command: `/{command}` -Description: {description} +Core commands call a pre-hook script before their main work and a post-hook script afterward. The scripts return one JSON result for each event: -Prompt: {prompt} -To execute: `/{command}` +```json +{ + "event": "after_tasks", + "hooks": [ + { + "extension": "jira", + "command": "speckit.jira.specstoissues", + "optional": true, + "priority": 10, + "prompt": "Create Jira issues from tasks?", + "description": "Create issues" + } + ] +} ``` -Or for mandatory hooks: - -```markdown -**Automatic Hook**: {extension} -Executing: `/{command}` -EXECUTE_COMMAND: {command} -``` +Enabled, unconditional hooks are returned in ascending priority order (ties retain configuration order). The agent invokes mandatory commands and offers optional commands without automatically running them. Conditions remain unsupported in core-command dispatch and hooks with non-empty conditions are skipped. Invalid or unreadable configuration produces a nonzero exit and JSON with an `error` and empty `hooks` array; the agent reports that no hooks were checked. --- diff --git a/extensions/github/commands/speckit.github.taskstoissues.md b/extensions/github/commands/speckit.github.taskstoissues.md index 6e587abe75..9c6d166405 100644 --- a/extensions/github/commands/speckit.github.taskstoissues.md +++ b/extensions/github/commands/speckit.github.taskstoissues.md @@ -17,38 +17,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before tasks-to-issues conversion)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} taskstoissues` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -74,33 +43,4 @@ git config --get remote.origin.url ## Post-Execution Checks -**Check for extension hooks (after tasks-to-issues conversion)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} taskstoissues` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/scripts/bash/post-hooks.sh b/scripts/bash/post-hooks.sh new file mode 100755 index 0000000000..583d4f58d3 --- /dev/null +++ b/scripts/bash/post-hooks.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=${BASH_SOURCE[0]%/*} +source "$SCRIPT_DIR/pre-hooks.sh" +resolve_hooks after "$1" diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh new file mode 100755 index 0000000000..9f69c21ae2 --- /dev/null +++ b/scripts/bash/pre-hooks.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash + +hook_json_string() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + value=${value//$'\t'/\\t} + value=${value//$'\b'/\\b} + value=${value//$'\f'/\\f} + printf '"%s"' "$value" +} + +hook_error() { + printf '{"event":' + hook_json_string "$1" + printf ',"hooks":[],"error":' + hook_json_string "$2" + printf '}\n' + return 1 +} + +hook_scalar() { + local raw=$1 inner c escaped i + while [[ $raw == ' '* ]]; do raw=${raw# }; done + while [[ $raw == *' ' ]]; do raw=${raw% }; done + HOOK_QUOTED=false + if [[ $raw == \'* ]]; then + HOOK_QUOTED=true + if (( ${#raw} < 2 )) || [[ $raw != *\' ]]; then + HOOK_ERROR="Unsupported YAML scalar" + return + fi + inner=${raw:1:${#raw}-2} + if [[ ${inner//\'\'/} == *\'* ]]; then HOOK_ERROR="Unsupported YAML scalar"; return; fi + HOOK_SCALAR=${inner//\'\'/\'} + return + fi + if [[ $raw == \"* ]]; then + HOOK_QUOTED=true + if (( ${#raw} < 2 )) || [[ $raw != *\" ]]; then + HOOK_ERROR="Unsupported YAML scalar" + return + fi + inner=${raw:1:${#raw}-2} + HOOK_SCALAR="" + for ((i=0; i<${#inner}; i++)); do + c=${inner:i:1} + if [[ $c != '\' ]]; then HOOK_SCALAR+=$c; continue; fi + ((i++)) + escaped=${inner:i:1} + case $escaped in + n) HOOK_SCALAR+=$'\n' ;; + t) HOOK_SCALAR+=$'\t' ;; + r) HOOK_SCALAR+=$'\r' ;; + b) HOOK_SCALAR+=$'\b' ;; + f) HOOK_SCALAR+=$'\f' ;; + '"'|'\\') HOOK_SCALAR+=$escaped ;; + *) HOOK_ERROR="Unsupported YAML escape"; return ;; + esac + done + return + fi + raw=${raw%%' #'*} + while [[ $raw == *' ' ]]; do raw=${raw% }; done + if [[ $raw == null || $raw == '~' ]]; then raw=""; fi + case $raw in + *'['*|*']'*|*'{'*|*'}'*|*': '*|'&'*|'*'*|'!'*|'|'*|'>'*) + HOOK_ERROR="Unsupported YAML scalar"; return ;; + esac + HOOK_SCALAR=$raw +} + +hook_field() { + local field=$1 key raw + if [[ ! $field =~ ^([a-z_]+):([[:space:]]|$) ]]; then + HOOK_ERROR="Unsupported YAML hook field" + return + fi + key=${BASH_REMATCH[1]} + raw=${field:${#key}+1} + hook_scalar "$raw" + [[ -n $HOOK_ERROR ]] && return + if [[ $key == enabled || $key == optional ]]; then + if [[ $HOOK_QUOTED == true || ( $HOOK_SCALAR != true && $HOOK_SCALAR != false ) ]]; then + HOOK_ERROR="$key must be a boolean" + return + fi + fi + if [[ $key == description || $key == prompt ]]; then + if [[ $HOOK_QUOTED == false && $HOOK_SCALAR =~ ^(true|false|[0-9]+)$ ]]; then + HOOK_ERROR="$key must be a string" + return + fi + fi + case $key in + extension) HOOK_EXT[index]=$HOOK_SCALAR ;; + command) HOOK_CMD[index]=$HOOK_SCALAR ;; + enabled) HOOK_ENABLED[index]=$HOOK_SCALAR ;; + optional) HOOK_OPTIONAL[index]=$HOOK_SCALAR ;; + condition) HOOK_CONDITION[index]=$HOOK_SCALAR ;; + priority) HOOK_PRIORITY[index]=$HOOK_SCALAR ;; + description) HOOK_DESCRIPTION[index]=$HOOK_SCALAR ;; + prompt) HOOK_PROMPT[index]=$HOOK_SCALAR ;; + esac +} + +hook_priority() { + local raw=$1 whole + HOOK_RANK=10 + [[ $raw =~ ^\+?[0-9]+(\.[0-9]+)?$ ]] || return + whole=${raw%%.*} + whole=${whole#+} + (( ${#whole} <= 15 )) || return + HOOK_RANK=$((10#$whole)) + (( HOOK_RANK >= 1 )) || HOOK_RANK=10 +} + +resolve_hooks() { + local phase=$1 command=$2 event config line spaces indent text key value + local section="" target=false empty=false item_indent=-1 index=0 seen=false seen_hooks=false seen_event=false + local i n chosen first=true + local -a HOOK_EXT HOOK_CMD HOOK_ENABLED HOOK_OPTIONAL HOOK_CONDITION + local -a HOOK_PRIORITY HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED + event="${phase}_${command}" + if [[ ! $event =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then + hook_error "" "Invalid hook event: $event" + return 1 + fi + config=.specify/extensions.yml + if [[ ! -e $config ]]; then + printf '{"event":"%s","hooks":[]}\n' "$event" + return + fi + if [[ ! -r $config ]]; then + hook_error "$event" "Could not read .specify/extensions.yml" + return 1 + fi + HOOK_ERROR="" + while IFS= read -r line || [[ -n $line ]]; do + line=${line%$'\r'} + [[ $line =~ ^[[:space:]]*(#|$) ]] && continue + seen=true + if [[ $line == *$'\t'* ]]; then HOOK_ERROR="Tabs are not supported in canonical YAML"; break; fi + spaces=${line%%[! ]*} + indent=${#spaces} + text=${line:indent} + if (( indent == 0 )); then + if [[ $section == installed && $text == '- '* && $text != '- ' ]]; then + hook_scalar "${text:2}" + [[ -n $HOOK_ERROR ]] && break + continue + fi + target=false + item_indent=-1 + if [[ $text == 'hooks:' || $text == 'hooks: {}' ]]; then + if [[ $seen_hooks == true ]]; then HOOK_ERROR="Duplicate hooks mapping"; break; fi + seen_hooks=true + section=hooks + empty=false + elif [[ $text == hooks:* ]]; then + HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping" + elif [[ $text == 'installed:' || $text == 'installed: []' ]]; then + section=installed + elif [[ $text == 'settings:' || $text == 'settings: {}' ]]; then + section=settings + else + HOOK_ERROR="Unsupported YAML top-level layout" + fi + [[ -z $HOOK_ERROR ]] || break + continue + fi + if [[ $section != hooks ]]; then + if [[ $section != settings || $indent != 2 || ! $text =~ ^[a-z_]+:([[:space:]]|$) ]]; then + HOOK_ERROR="Unsupported YAML top-level layout"; break + fi + key=${text%%:*} + hook_scalar "${text:${#key}+1}" + [[ -n $HOOK_ERROR ]] && break + continue + fi + if (( indent == 2 )) && [[ $text =~ ^([a-z][a-z0-9_]*):([[:space:]]|$) ]]; then + key=${BASH_REMATCH[1]} + value=${text:${#key}+1} + while [[ $value == ' '* ]]; do value=${value# }; done + target=false + if [[ $key == "$event" ]]; then + if [[ $seen_event == true ]]; then HOOK_ERROR="Duplicate hook event"; break; fi + seen_event=true + target=true + fi + if [[ -n $value && $value != '[]' ]]; then + HOOK_ERROR="hooks.$key must be a list"; break + fi + empty=false + [[ $value == '[]' ]] && empty=true + item_indent=-1 + continue + fi + if [[ $text == '- '* && ( $indent == 2 || $indent == 4 ) && $empty == false ]]; then + item_indent=$indent + if [[ $target == true ]]; then + index=$((index+1)) + HOOK_ENABLED[index]=true + HOOK_OPTIONAL[index]=true + HOOK_PRIORITY[index]=10 + hook_field "${text:2}" + fi + elif (( item_indent >= 0 && indent == item_indent + 2 )); then + if [[ $target == true ]]; then hook_field "$text"; fi + else + HOOK_ERROR="Unsupported YAML hook layout" + fi + [[ -z $HOOK_ERROR ]] || break + done < "$config" + [[ $seen == true ]] || HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping" + if [[ -z $HOOK_ERROR ]]; then + for ((i=1; i<=index; i++)); do + [[ ${HOOK_ENABLED[i]} == false || -n ${HOOK_CONDITION[i]} ]] && continue + if [[ -z ${HOOK_EXT[i]} || -z ${HOOK_CMD[i]} ]]; then + HOOK_ERROR="hooks.$event needs extension and command"; break + fi + hook_priority "${HOOK_PRIORITY[i]}" + HOOK_RANKS[i]=$HOOK_RANK + done + fi + if [[ -n $HOOK_ERROR ]]; then + hook_error "$event" "$HOOK_ERROR" + return 1 + fi + printf '{"event":"%s","hooks":[' "$event" + for ((n=1; n<=index; n++)); do + chosen=0 + for ((i=1; i<=index; i++)); do + if [[ -n ${HOOK_RANKS[i]} && -z ${HOOK_USED[i]} ]] && + (( chosen == 0 || HOOK_RANKS[i] < HOOK_RANKS[chosen] )); then + chosen=$i + fi + done + (( chosen )) || break + HOOK_USED[chosen]=true + if [[ $first == false ]]; then printf ','; fi + first=false + printf '{"extension":'; hook_json_string "${HOOK_EXT[chosen]}" + printf ',"command":'; hook_json_string "${HOOK_CMD[chosen]}" + printf ',"optional":%s,"description":' "${HOOK_OPTIONAL[chosen]}" + hook_json_string "${HOOK_DESCRIPTION[chosen]}" + printf ',"prompt":'; hook_json_string "${HOOK_PROMPT[chosen]}" + printf ',"priority":%s}' "${HOOK_RANKS[chosen]}" + done + printf ']}\n' +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + resolve_hooks before "$1" +fi diff --git a/scripts/powershell/post-hooks.ps1 b/scripts/powershell/post-hooks.ps1 new file mode 100644 index 0000000000..9464dae497 --- /dev/null +++ b/scripts/powershell/post-hooks.ps1 @@ -0,0 +1,4 @@ +#!/usr/bin/env pwsh + +. "$PSScriptRoot/pre-hooks.ps1" +Invoke-HookResolver after $args[0] diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 new file mode 100644 index 0000000000..ef420550b1 --- /dev/null +++ b/scripts/powershell/pre-hooks.ps1 @@ -0,0 +1,178 @@ +#!/usr/bin/env pwsh + +function Convert-HookScalar { + param([string]$Raw) + $raw = $Raw.Trim() + if ($raw.StartsWith("'")) { + if ($raw.Length -lt 2 -or -not $raw.EndsWith("'") -or $raw.Substring(1, $raw.Length - 2) -replace "''", "" -match "'") { + throw "Unsupported YAML scalar" + } + return @{ Value = $raw.Substring(1, $raw.Length - 2).Replace("''", "'"); Quoted = $true } + } + if ($raw.StartsWith('"')) { + if ($raw.Length -lt 2 -or -not $raw.EndsWith('"')) { throw "Unsupported YAML scalar" } + try { $value = ConvertFrom-Json -InputObject $raw -ErrorAction Stop } + catch { throw "Unsupported YAML escape: $($_.Exception.Message)" } + if ($value -isnot [string]) { throw "Unsupported YAML scalar" } + return @{ Value = $value; Quoted = $true } + } + $raw = ($raw -replace '\s+#.*$', '').TrimEnd() + if ($raw -match '^(~|null)$') { $raw = '' } + if ($raw -match '[\[\]{}]|: |^[&*!|>]') { throw "Unsupported YAML scalar" } + return @{ Value = $raw; Quoted = $false } +} + +function Add-HookField { + param([hashtable]$Hook, [string]$Text) + if ($Text -notmatch '^([a-z_]+):(?:\s|$)(.*)$') { throw "Unsupported YAML hook field" } + $key = $Matches[1] + $parsed = Convert-HookScalar $Matches[2] + $value = $parsed.Value + if ($key -in @('enabled', 'optional') -and ($parsed.Quoted -or $value -notin @('true', 'false'))) { + throw "$key must be a boolean" + } + if ($key -in @('prompt', 'description') -and -not $parsed.Quoted -and $value -match '^(true|false|[0-9]+)$') { + throw "$key must be a string" + } + $Hook[$key] = $value +} + +function Get-HookPriority { + param([string]$Raw) + if ($Raw -notmatch '^[+-]?[0-9]+(\.[0-9]+)?$') { return 10 } + $number = 0.0 + if (-not [double]::TryParse($Raw, [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) { return 10 } + if ($number -lt 1 -or $number -gt [int]::MaxValue) { return 10 } + return [int][Math]::Truncate($number) +} + +function Resolve-HookConfig { + param([string]$Event, [string]$Path) + $text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop + if (-not $text.Trim()) { throw "Invalid .specify/extensions.yml: expected a hooks mapping" } + $hooks = [Collections.Generic.List[hashtable]]::new() + $inHooks = $false + $section = '' + $hooksEmpty = $false + $seenHooks = $false + $seenEvent = $false + $target = $false + $empty = $false + $itemIndent = -1 + $hook = $null + foreach ($line in ($text -split '\r?\n')) { + if ($line -match '^\s*(#|$)') { continue } + if ($line.Contains("`t")) { throw "Tabs are not supported in canonical YAML" } + if ($line -notmatch '^( *)(.*)$') { throw "Unsupported YAML hook layout" } + $indent = $Matches[1].Length + $body = $Matches[2] + if ($indent -eq 0) { + if ($section -eq 'installed' -and $body -match '^- \S') { + $null = Convert-HookScalar $body.Substring(2) + continue + } + $target = $false + if ($body -in @('hooks:', 'hooks: {}')) { + if ($seenHooks) { throw "Duplicate hooks mapping" } + $seenHooks = $true + $inHooks = $true + $section = 'hooks' + $hooksEmpty = $body -eq 'hooks: {}' + } elseif ($body -match '^hooks:') { + throw "Invalid .specify/extensions.yml: expected a hooks mapping" + } elseif ($body -in @('installed:', 'installed: []')) { + $inHooks = $false + $section = 'installed' + } elseif ($body -in @('settings:', 'settings: {}')) { + $inHooks = $false + $section = 'settings' + } else { + throw "Unsupported YAML top-level layout" + } + continue + } + if (-not $inHooks) { + if ($section -eq 'settings' -and $indent -eq 2 -and $body -match '^([a-z_]+):(?:\s|$)(.*)$') { + $null = Convert-HookScalar $Matches[2] + continue + } + throw "Unsupported YAML top-level layout" + } + if ($hooksEmpty) { throw "Unsupported YAML hook layout" } + if ($indent -eq 2 -and $body -match '^([a-z][a-z0-9_]*):(?:\s|$)(.*)$') { + $key = $Matches[1] + $value = $Matches[2].Trim() + $target = $key -eq $Event + if ($target) { + if ($seenEvent) { throw "Duplicate hook event" } + $seenEvent = $true + } + if ($value -notin @('', '[]')) { throw "hooks.$key must be a list" } + $empty = $value -eq '[]' + $itemIndent = -1 + continue + } + if ($indent -in @(2, 4) -and $body.StartsWith('- ') -and -not $empty) { + $itemIndent = $indent + if ($target) { + $hook = @{ enabled = 'true'; optional = 'true'; priority = '10' } + $hooks.Add($hook) + Add-HookField $hook $body.Substring(2) + } + continue + } + if ($itemIndent -ge 0 -and $indent -eq $itemIndent + 2) { + if ($target) { Add-HookField $hook $body } + continue + } + throw "Unsupported YAML hook layout" + } + $ordered = [Collections.Generic.List[hashtable]]::new() + for ($i = 0; $i -lt $hooks.Count; $i++) { + $hook = $hooks[$i] + if ($hook.enabled -eq 'false' -or $hook.condition) { continue } + if (-not $hook.extension -or -not $hook.command) { + throw "hooks.$Event needs extension and command" + } + $ordered.Add(@{ + extension = $hook.extension + command = $hook.command + optional = $hook.optional -eq 'true' + description = if ($hook.description) { $hook.description } else { '' } + prompt = if ($hook.prompt) { $hook.prompt } else { '' } + priority = Get-HookPriority $hook.priority + index = $i + }) + } + $result = @($ordered | Sort-Object priority, index | ForEach-Object { + @{ extension = $_.extension; command = $_.command; optional = $_.optional + description = $_.description; prompt = $_.prompt; priority = $_.priority } + }) + return $result +} + +function Invoke-HookResolver { + param([string]$Phase, [string]$Command) + $event = "${Phase}_${Command}" + try { + if ($event -notmatch '^(before|after)_[a-z][a-z0-9_]*$') { + throw "Invalid hook event: $event" + } + $config = Join-Path (Get-Location) '.specify/extensions.yml' + $hooks = @() + if (Test-Path -LiteralPath $config) { + $hooks = @(Resolve-HookConfig $event $config) + } + @{ event = $event; hooks = $hooks } | ConvertTo-Json -Depth 5 -Compress + exit 0 + } catch { + @{ event = $event; hooks = @(); error = $_.Exception.Message } | + ConvertTo-Json -Depth 5 -Compress + exit 1 + } +} + +if ($MyInvocation.InvocationName -ne '.') { + Invoke-HookResolver before $args[0] +} diff --git a/scripts/python/post_hooks.py b/scripts/python/post_hooks.py new file mode 100644 index 0000000000..d069f4b2f6 --- /dev/null +++ b/scripts/python/post_hooks.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""Resolve extension hooks for a core command's after event.""" + +from pre_hooks import main + + +if __name__ == "__main__": + raise SystemExit(main("after")) diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py new file mode 100644 index 0000000000..1aa5c01900 --- /dev/null +++ b/scripts/python/pre_hooks.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Resolve extension hooks for a core command's before event.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +_EVENT = re.compile(r"^(before|after)_[a-z][a-z0-9_]*$") + + +def resolve(event: str, project_root: Path) -> dict: + """Return enabled, unconditional hooks in priority order.""" + if not _EVENT.fullmatch(event): + raise ValueError(f"Invalid hook event: {event}") + + config_file = project_root / ".specify" / "extensions.yml" + if not config_file.exists(): + return {"event": event, "hooks": []} + + try: + import yaml + except ImportError as exc: + raise RuntimeError("PyYAML is required to read .specify/extensions.yml") from exc + + try: + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Could not read .specify/extensions.yml: {exc}") from exc + if not isinstance(config, dict) or not isinstance(config.get("hooks", {}), dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + + entries = config.get("hooks", {}).get(event, []) + if not isinstance(entries, list): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event} must be a list") + + hooks = [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}] must be a mapping") + if "enabled" in entry and not isinstance(entry["enabled"], bool): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].enabled must be a boolean") + if "optional" in entry and not isinstance(entry["optional"], bool): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].optional must be a boolean") + if entry.get("enabled") is False or entry.get("condition"): + continue + extension, command = entry.get("extension"), entry.get("command") + if not isinstance(extension, str) or not extension or not isinstance(command, str) or not command: + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}] needs extension and command") + for field in ("description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].{field} must be a string") + priority = entry.get("priority", 10) + try: + priority = int(priority) if not isinstance(priority, bool) else 10 + except (TypeError, ValueError, OverflowError): + priority = 10 + if priority < 1: + priority = 10 + hooks.append({ + "extension": extension, + "command": command, + "optional": entry.get("optional", True), + "description": entry.get("description") or "", + "prompt": entry.get("prompt") or "", + "priority": priority, + }) + hooks.sort(key=lambda hook: hook["priority"]) + return {"event": event, "hooks": hooks} + + +def main(phase: str = "before") -> int: + parser = argparse.ArgumentParser(description=f"Resolve {phase} extension hooks") + parser.add_argument("command", help="Core command name, e.g. plan") + args = parser.parse_args() + event = f"{phase}_{args.command}" + try: + result = resolve(event, Path.cwd()) + except (ValueError, RuntimeError) as exc: + print(json.dumps({"event": event, "hooks": [], "error": str(exc)})) + return 1 + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6016773e51..e068d0b366 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -554,6 +554,13 @@ def resolve_skill_placeholders( script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) + if "{PRE_HOOK_SCRIPT}" in body or "{POST_HOOK_SCRIPT}" in body: + from specify_cli.integrations.base import IntegrationBase + + body = IntegrationBase.resolve_hook_script_refs( + body, script_variant or "", project_root + ) + body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) return CommandRegistrar.rewrite_project_relative_paths( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6242dc2c05..09e7c34a9d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -20,6 +20,7 @@ import re import shutil import stat +import sys import tempfile from dataclasses import dataclass from datetime import datetime, timezone @@ -5713,10 +5714,19 @@ def save_project_config(self, config: Dict[str, Any]): Args: config: Configuration dictionary to save """ + class CanonicalHookDumper(yaml.SafeDumper): + pass + + def represent_string(dumper: yaml.SafeDumper, value: str): + style = '"' if "\n" in value or "\r" in value else None + return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) + + CanonicalHookDumper.add_representer(str, represent_string) self.config_file.parent.mkdir(parents=True, exist_ok=True) self.config_file.write_text( yaml.dump( - config, default_flow_style=False, sort_keys=False, allow_unicode=True + config, default_flow_style=False, sort_keys=False, + allow_unicode=True, width=sys.maxsize, Dumper=CanonicalHookDumper, ), encoding="utf-8", ) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e698b9e289..10b98ee447 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -783,6 +783,28 @@ def build_python_invocation( interpreter = shlex.quote(interpreter) return f"{interpreter} {script_command}" + @staticmethod + def resolve_hook_script_refs( + content: str, script_type: str, project_root: Path | None = None + ) -> str: + """Replace hook script placeholders using the command's script variant.""" + if "{PRE_HOOK_SCRIPT}" not in content and "{POST_HOOK_SCRIPT}" not in content: + return content + variant = script_type if script_type in ("sh", "ps", "py") else ( + "ps" if os.name == "nt" else "sh" + ) + for phase, name in (("PRE", "pre"), ("POST", "post")): + if variant == "py": + command = IntegrationBase.build_python_invocation( + f"scripts/python/{name}_hooks.py", project_root + ) + elif variant == "ps": + command = f"scripts/powershell/{name}-hooks.ps1" + else: + command = f"scripts/bash/{name}-hooks.sh" + content = content.replace(f"{{{phase}_HOOK_SCRIPT}}", command) + return content + @staticmethod def select_script_variant( requested: object, script_commands: dict[str, str] @@ -896,6 +918,10 @@ def process_template( ) content = content.replace("{SCRIPT}", script_command) + content = IntegrationBase.resolve_hook_script_refs( + content, selected_script_type or script_type, project_root + ) + # 3. Strip scripts: section from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 7de2d28f0d..0ecb8dd0dd 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -16,38 +16,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before analysis)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_analyze` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Goal. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} analyze` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Goal @@ -203,35 +172,7 @@ Ask the user: "Would you like me to suggest concrete remediation edits for the t ### 9. Check for extension hooks -After reporting, check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_analyze` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} analyze` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Operating Principles diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 6e33f5d422..c40d042607 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -46,38 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before checklist generation)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_checklist` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Execution Steps. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} checklist` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Execution Steps @@ -347,33 +316,4 @@ Sample items: ## Post-Execution Checks -**Check for extension hooks (after checklist generation)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_checklist` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} checklist` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index f48d8bd703..423b787dec 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -20,38 +20,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before clarification)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_clarify` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} clarify` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -240,37 +209,7 @@ Context for prioritization: {ARGS} ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_clarify` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} clarify` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index f2c107a158..6c1c9bbe46 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -39,38 +39,7 @@ and commands read the constitution at runtime and are not modified here. ## Pre-Execution Checks -**Check for extension hooks (before constitution update)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_constitution` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} constitution` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -147,33 +116,4 @@ Write only `.specify/memory/constitution.md`; do not create or modify template s ## Post-Execution Checks -**Check for extension hooks (after constitution update)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_constitution` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} constitution` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/templates/commands/converge.md b/templates/commands/converge.md index f6a44c3e91..5707972c37 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -16,43 +16,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before convergence)**: - -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_converge` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - - ```text - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - - **Mandatory hook** (`optional: false`): - - ```text - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Goal. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} converge` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Goal @@ -241,39 +205,6 @@ Append to the **end** of `tasks.md`, per the append contract: ### 9. Check for extension hooks -After producing the result, check if `.specify/extensions.yml` exists in the project root. - -- If it exists, read it and look for entries under the `hooks.after_converge` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing - any hooks, so users can decide whether to run optional follow-up commands. -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - - ```text - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - - **Mandatory hook** (`optional: false`): - - ```text - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. +Report the convergence outcome before offering optional follow-up hooks. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} converge` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/templates/commands/implement.md b/templates/commands/implement.md index f98ba525de..8a1614f706 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -16,38 +16,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before implementation)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_implement` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} implement` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -178,37 +147,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_implement` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} implement` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/plan.md b/templates/commands/plan.md index d559e517ae..4bf4c6398a 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -24,38 +24,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before planning)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_plan` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} plan` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -73,37 +42,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_plan` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} plan` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 52b2b24565..09edb43e8c 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -20,38 +20,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before specification)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_specify` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} specify` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -235,37 +204,7 @@ Given that feature description, do this: ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_specify` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} specify` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 1b9c4255fe..6bc4e47941 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -25,38 +25,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before tasks generation)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_tasks` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} tasks` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -94,37 +63,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Mandatory Post-Execution Hooks -**You MUST complete this section before reporting completion to the user.** - -Check if `.specify/extensions.yml` exists in the project root. -- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report. -- If it exists, read it and look for entries under the `hooks.after_tasks` key. -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue to the Completion Report. -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` +Run `{POST_HOOK_SCRIPT} tasks` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Completion Report diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index f982448906..2e48314978 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -17,38 +17,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Pre-Execution Checks -**Check for extension hooks (before tasks-to-issues conversion)**: -- Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Pre-Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Pre-Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - - Wait for the result of the hook command before proceeding to the Outline. - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{PRE_HOOK_SCRIPT} taskstoissues` from the project root and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue the core command. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before proceeding; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. ## Outline @@ -74,33 +43,4 @@ git config --get remote.origin.url ## Post-Execution Checks -**Check for extension hooks (after tasks-to-issues conversion)**: -Check if `.specify/extensions.yml` exists in the project root. -- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key -- If the YAML cannot be parsed or is invalid, do not skip silently: tell the user that `.specify/extensions.yml` could not be read (include the parser error) and that no hooks were checked, including any mandatory (`optional: false`) hooks registered there, then continue normally -- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. -- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation -- For each executable hook, output the following based on its `optional` flag: - - **Optional hook** (`optional: true`): - ``` - ## Extension Hooks - - **Optional Hook**: {extension} - Command: `/{command}` - Description: {description} - - Prompt: {prompt} - To execute: `/{command}` - ``` - - **Mandatory hook** (`optional: false`): - ``` - ## Extension Hooks - - **Automatic Hook**: {extension} - Executing: `/{command}` - EXECUTE_COMMAND: {command} - ``` - After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. -- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently +Run `{POST_HOOK_SCRIPT} taskstoissues` from the project root before ending the command and read its JSON result. If it fails or returns `error`, tell the user why no hooks were checked (including mandatory hooks), then continue reporting completion. For each returned hook in order: invoke mandatory commands in this agent and wait for completion before ending; surface optional commands with their prompt and description without executing them automatically. Use the invocation syntax for the installed agent/skills mode. If `hooks` is empty, continue silently. diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 498abaf36d..d7fbc3c07d 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -529,10 +529,10 @@ def test_rendered_skill_points_at_a_script_that_ships(self, tmp_path: Path): class TestCommandBody: def test_preserves_the_hook_contract(self): - """The hook keys are literal strings read out of extensions.yml.""" + """The extension dispatches the same before/after events as core.""" body = COMMAND_FILE.read_text(encoding="utf-8") - assert "hooks.before_taskstoissues" in body - assert "hooks.after_taskstoissues" in body + assert "{PRE_HOOK_SCRIPT} taskstoissues" in body + assert "{POST_HOOK_SCRIPT} taskstoissues" in body def test_git_extension_hooks_still_target_those_keys(self): """The live consumers of the hook contract keep firing.""" diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b3681ee8fc..c3da61ee00 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -341,9 +341,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content rel_key = ".agents/skills/speckit-specify/SKILL.md" assert rel_key in m.files assert ( diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py index 9e25e14cc1..45b4ecab39 100644 --- a/tests/integrations/test_integration_alquimia.py +++ b/tests/integrations/test_integration_alquimia.py @@ -789,9 +789,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") # specify.md has hook sections - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_hook_note_not_in_skills_without_hooks(self, tmp_path): """Skills without hook sections should not get the note.""" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index e94f58c6ff..605599b595 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -248,6 +248,10 @@ def _expected_files(self, script_variant: str) -> list[str]: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", "resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in ["checklist-template.md", "constitution-template.md", "plan-template.md", diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 015152be01..78c19eafef 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -256,19 +256,15 @@ def test_command_refs_use_hyphen_separator(self, tmp_path): ) def test_hook_sections_explain_dotted_command_conversion(self, tmp_path): - """Generated skills with hook sections must explain dotted command conversion.""" + """Generated skills delegate hook selection to the shared scripts.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should explain dotted hook command conversion" - ) - assert content.count("replace dots") == content.count( - "- For each executable hook, output the following" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_hook_note_injected_for_each_instruction_independently(self): """Existing hook notes should not suppress later missing notes.""" @@ -507,6 +503,10 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", ] + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] # Templates files += [ ".specify/templates/checklist-template.md", diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 1b1bb18807..a8357a14b4 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -510,6 +510,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 01914c5988..99c9e544ab 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -424,6 +424,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8671b7c445..c20168d432 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -736,9 +736,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") # specify.md has hook sections - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_hook_note_not_in_skills_without_hooks(self, tmp_path): """Skills without hook sections should not get the note.""" diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index d7350a9633..4218ac711d 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -260,6 +260,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index 9a4dc5442b..0a5818ca02 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -94,10 +94,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" - ) - assert "constructing command invocations" in content + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content assert "constructing slash commands" not in content def test_hook_note_not_in_skills_without_hooks(self): diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index cb882a71a8..3df7600db4 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -309,6 +309,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/post-hooks.sh", + ".specify/scripts/bash/pre-hooks.sh", ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", @@ -374,6 +376,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/post-hooks.ps1", + ".specify/scripts/powershell/pre-hooks.ps1", ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", @@ -685,13 +689,14 @@ def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path): fm = yaml.safe_load(parts[1]) assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field" - def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path): - """Generated skills with hook sections should include shared hook guidance.""" + def test_skills_hook_sections_use_script_dispatch(self, tmp_path): + """Generated skills use the installed hook dispatcher instead of YAML instructions.""" copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md" content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content # -- Template processing ---------------------------------------------- @@ -887,6 +892,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/post-hooks.sh", + ".specify/scripts/bash/pre-hooks.sh", ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index c2eabfcaa7..a1ce7be199 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -2432,10 +2432,8 @@ def test_skill_content_has_hook_command_note(self, tmp_path): ) assert constitution_skill.exists() content = constitution_skill.read_text(encoding="utf-8") - assert ( - "replace dots (`.`) with hyphens (`-`)" in content - ), "generic --skills output is missing the hook-invocation note" - assert "`speckit.git.commit` → `/speckit-git-commit`" in content + assert ".specify/scripts/bash/pre-hooks.sh constitution" in content + assert ".specify/scripts/bash/post-hooks.sh constitution" in content def test_skills_flag_false_keeps_flat_markdown(self, tmp_path): """Without --skills, behavior is unchanged: flat speckit..md files.""" @@ -2615,6 +2613,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/post-hooks.sh", + ".specify/scripts/bash/pre-hooks.sh", ".specify/scripts/bash/resolve-template.sh", ".specify/scripts/bash/setup-plan.sh", ".specify/scripts/bash/setup-tasks.sh", @@ -2763,6 +2763,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/post-hooks.ps1", + ".specify/scripts/powershell/pre-hooks.ps1", ".specify/scripts/powershell/resolve-template.ps1", ".specify/scripts/powershell/setup-plan.ps1", ".specify/scripts/powershell/setup-tasks.ps1", diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index b2106050b7..0b618957b7 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -203,12 +203,8 @@ def test_hook_sections_explain_dotted_command_conversion(self, tmp_path, monkeyp specify_skill = home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should explain dotted hook command conversion" - ) - assert content.count("replace dots") == content.count( - "- For each executable hook, output the following" - ) + assert ".specify/scripts/bash/pre-hooks.sh specify" in content + assert ".specify/scripts/bash/post-hooks.sh specify" in content def test_complete_file_inventory_sh(self, tmp_path, monkeypatch): """Override: Hermes init produces no local SKILL.md files, diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index a6234ba734..64a386eec6 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -210,6 +210,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") + files += [ + f".specify/scripts/{'bash' if script_variant == 'sh' else 'powershell'}/{phase}-hooks.{'sh' if script_variant == 'sh' else 'ps1'}" + for phase in ("pre", "post") + ] for name in [ "checklist-template.md", diff --git a/tests/test_command_template_hooks.py b/tests/test_command_template_hooks.py index d1c7677bae..8594e961ce 100644 --- a/tests/test_command_template_hooks.py +++ b/tests/test_command_template_hooks.py @@ -1,80 +1,75 @@ -"""Command templates must not tell the agent to skip hook checking silently. +"""Core command hook dispatch is present in every rendered script variant.""" -Every core command template reads ``.specify/extensions.yml`` before and after -its main work, looking for ``hooks.before_*`` / ``hooks.after_*`` entries. A -manifest that could not be parsed used to be treated exactly like a manifest -with no hooks: the agent was told to "skip hook checking silently and continue -normally". A mandatory hook (``optional: false``, the kind the bundled ``git`` -extension registers) could therefore be disabled by a single malformed line, -and nothing would say so. - -These tests pin the replacement wording: an unreadable manifest is reported to -the user (the parser error, and the fact that no hooks were checked) before -the command continues. They read the templates as text on purpose: the -behaviour lives in the prompt, so the prompt is what must be checked. -""" - -import re from pathlib import Path import pytest -REPO_ROOT = Path(__file__).parent.parent -TEMPLATES_DIR = REPO_ROOT / "templates" / "commands" +from specify_cli.integrations.base import IntegrationBase +from specify_cli.agents import CommandRegistrar -_HOOK_KEY = re.compile(r"`hooks\.(before|after)_[a-z_]+`") -_PARSE_FAILURE_LINE = re.compile( - r"^.*If the YAML cannot be parsed or is invalid.*$", re.MULTILINE -) -_SILENT = "skip hook checking silently" -# Every clause of the replacement instruction, so that dropping any one of -# them from the templates fails the test: the manifest could not be read, the -# parser error is shown, no hooks were checked, mandatory hooks are named, and -# the command still continues afterwards. -_REPORTED = ( - "could not be read", - "include the parser error", - "no hooks were checked", - "including any mandatory (`optional: false`) hooks", - "then continue", +ROOT = Path(__file__).parent.parent +TEMPLATES = ROOT / "templates" / "commands" +NAMES = ( + "analyze", "checklist", "clarify", "constitution", "converge", + "implement", "plan", "specify", "tasks", "taskstoissues", ) -HOOK_TEMPLATES = sorted( - p.name - for p in TEMPLATES_DIR.glob("*.md") - if _HOOK_KEY.search(p.read_text(encoding="utf-8")) -) +def test_all_core_hook_templates_discovered(): + assert set(NAMES) <= {path.stem for path in TEMPLATES.glob("*.md")} -def test_hook_templates_discovered(): - # Guard: the glob must find the templates that read extensions.yml, - # otherwise the parametrized tests below would pass by vacuity. - assert {"specify.md", "plan.md", "tasks.md", "implement.md"} <= set( - HOOK_TEMPLATES - ) +@pytest.mark.parametrize("name", NAMES) +def test_both_boundaries_and_error_handling(name): + text = (TEMPLATES / f"{name}.md").read_text(encoding="utf-8") + assert f"{{PRE_HOOK_SCRIPT}} {name}" in text + assert f"{{POST_HOOK_SCRIPT}} {name}" in text + assert text.index("{PRE_HOOK_SCRIPT}") < text.index("{POST_HOOK_SCRIPT}") + assert text.count("no hooks were checked") == 2 + assert text.count("including mandatory hooks") == 2 + assert text.count("wait for completion") == 2 + assert text.count("without executing them automatically") == 2 + + +@pytest.mark.parametrize("name", NAMES) +@pytest.mark.parametrize("variant,dir,ext", [ + ("sh", "bash", "sh"), ("ps", "powershell", "ps1"), ("py", "python", "py"), +]) +def test_hook_scripts_render_and_exist(name, variant, dir, ext): + text = (TEMPLATES / f"{name}.md").read_text(encoding="utf-8") + result = IntegrationBase.process_template(text, "copilot", variant) + assert "{PRE_HOOK_SCRIPT}" not in result + assert "{POST_HOOK_SCRIPT}" not in result + for phase in ("pre", "post"): + path = f".specify/scripts/{dir}/{phase}{'-' if variant != 'py' else '_'}hooks.{ext}" + assert path in result + assert (ROOT / "scripts" / dir / f"{phase}{'-' if variant != 'py' else '_'}hooks.{ext}").is_file() -@pytest.mark.parametrize("name", HOOK_TEMPLATES) -def test_unreadable_manifest_is_never_skipped_silently(name: str): - text = (TEMPLATES_DIR / name).read_text(encoding="utf-8") - assert _SILENT not in text, ( - f"{name}: an unreadable .specify/extensions.yml may still be skipped " - "silently, which disables mandatory hooks without saying so" - ) +@pytest.mark.parametrize("name", NAMES) +def test_no_repeated_yaml_hook_instructions(name): + text = (TEMPLATES / f"{name}.md").read_text(encoding="utf-8") + assert "hooks.before_" not in text + assert "hooks.after_" not in text + assert "If the YAML cannot be parsed" not in text -@pytest.mark.parametrize("name", HOOK_TEMPLATES) -def test_every_parse_failure_line_reports_before_continuing(name: str): - text = (TEMPLATES_DIR / name).read_text(encoding="utf-8") - lines = _PARSE_FAILURE_LINE.findall(text) - # One line for the before-hook check, one for the after-hook check. - assert len(lines) >= 2, ( - f"{name}: expected a parse-failure instruction at both hook sites, " - f"found {len(lines)}" + +@pytest.mark.parametrize("variant,dir,ext", [ + ("sh", "bash", "sh"), ("ps", "powershell", "ps1"), ("py", "python", "py"), +]) +def test_preset_wrap_and_skill_registrar_resolve_hook_scripts(tmp_path, variant, dir, ext): + config = tmp_path / ".specify" + config.mkdir() + (config / "init-options.json").write_text( + f'{{"script": "{variant}"}}', encoding="utf-8" + ) + body = "{PRE_HOOK_SCRIPT} plan\n{POST_HOOK_SCRIPT} plan" + rendered = CommandRegistrar.resolve_skill_placeholders( + "copilot", + {"scripts": {variant: "scripts/python/setup_plan.py --json"}}, + body, tmp_path, ) - for line in lines: - for phrase in _REPORTED: - assert phrase in line, ( - f"{name}: parse-failure instruction does not tell the user " - f"{phrase!r}: {line.strip()}" - ) + assert "{PRE_HOOK_SCRIPT}" not in rendered + assert "{POST_HOOK_SCRIPT}" not in rendered + assert f".specify/scripts/{dir}/" in rendered + assert f"hooks.{ext}" in rendered diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py new file mode 100644 index 0000000000..91f33fe91c --- /dev/null +++ b/tests/test_hook_dispatch_scripts.py @@ -0,0 +1,313 @@ +"""Positive and negative coverage for pre/post extension hook resolution.""" + +import json +import shutil +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).parent.parent +PYTHON = ROOT / "scripts" / "python" + + +def run_hook(tmp_path, phase="pre", name="plan", variant="py", extra_env=None): + scripts = { + "py": [sys.executable, str(PYTHON / f"{phase}_hooks.py")], + "sh": [str(ROOT / "scripts" / "bash" / f"{phase}-hooks.sh")], + "ps": ["pwsh", "-NoProfile", "-File", str(ROOT / "scripts" / "powershell" / f"{phase}-hooks.ps1")], + } + env = os.environ.copy() + env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable + if extra_env: + env.update(extra_env) + result = subprocess.run(scripts[variant] + [name], cwd=tmp_path, env=env, capture_output=True, text=True) + return result.returncode, json.loads(result.stdout) + + +def write_config(tmp_path, text): + path = tmp_path / ".specify" / "extensions.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +def test_absent_config_returns_empty(tmp_path, phase, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == {"event": f"{'before' if phase == 'pre' else 'after'}_plan", "hooks": []} + + +def test_hooks_sorted_and_filtered_with_stable_ties(tmp_path): + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "late", "command": "speckit.late.run", "priority": 20, "optional": False}, + {"extension": "first", "command": "speckit.first.run", "priority": 5, "optional": False}, + {"extension": "second", "command": "speckit.second.run", "priority": "5", "prompt": "Run second?"}, + {"extension": "disabled", "command": "speckit.disabled.run", "enabled": False}, + {"extension": "conditional", "command": "speckit.conditional.run", "condition": "env.CI is set"}, + {"extension": "default", "command": "speckit.default.run"}, + ]}}, sort_keys=False)) + code, data = run_hook(tmp_path) + assert code == 0 + assert [h["extension"] for h in data["hooks"]] == ["first", "second", "default", "late"] + assert [h["priority"] for h in data["hooks"]] == [5, 5, 10, 20] + assert data["hooks"][0]["optional"] is False + assert data["hooks"][1]["prompt"] == "Run second?" + assert data["hooks"][2]["optional"] is True + assert data["hooks"][2]["description"] == "" + + +def test_post_rereads_config_and_only_returns_matching_event(tmp_path): + config = write_config(tmp_path, "hooks:\n after_plan: []\n") + assert run_hook(tmp_path, "post")[1]["hooks"] == [] + config.write_text("hooks:\n after_plan:\n - extension: git\n command: speckit.git.commit\n", encoding="utf-8") + assert run_hook(tmp_path, "post")[1]["hooks"][0]["command"] == "speckit.git.commit" + assert run_hook(tmp_path, "pre")[1]["hooks"] == [] + + +@pytest.mark.parametrize("text,part", [ + ("hooks: [wrong]", "hooks mapping"), + ("[]", "hooks mapping"), + ("hooks:\n before_plan: wrong\n", "must be a list"), + ("hooks:\n before_plan:\n - wrong\n", "must be a mapping"), + ("hooks:\n before_plan:\n - extension: git\n", "needs extension and command"), + ("hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n enabled: 'false'\n", "enabled must be a boolean"), + ("hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n optional: 'false'\n", "optional must be a boolean"), + ("hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n prompt: [not, text]\n", "prompt must be a string"), + ("hooks: [\n", "Could not read"), +]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_invalid_config_reports_error(tmp_path, text, part, phase): + if phase == "post": + text = text.replace("before_plan", "after_plan") + write_config(tmp_path, text) + code, data = run_hook(tmp_path, phase) + assert code == 1 + assert part in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("priority,expected", [ + (False, 10), (0, 10), ("bad", 10), ("2", 2), (2.8, 2), +]) +def test_legacy_priority_normalization(tmp_path, priority, expected): + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "priority": priority} + ]}})) + code, data = run_hook(tmp_path) + assert code == 0 + assert data["hooks"][0]["priority"] == expected + + +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_invalid_command_name_does_not_read_config(tmp_path, phase): + write_config(tmp_path, "hooks: [\n") + code, data = run_hook(tmp_path, phase, "../../bad") + assert code == 1 + assert "Invalid hook event" in data["error"] + + +def test_empty_condition_and_enabled_default_still_execute(tmp_path): + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n condition: ''\n") + assert len(run_hook(tmp_path)[1]["hooks"]) == 1 + + +def test_missing_yaml_dependency_is_reported_not_silently_skipped(tmp_path): + write_config(tmp_path, "hooks:\n before_plan: []\n") + result = subprocess.run( + [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert result.returncode == 1 + assert json.loads(result.stdout) == { + "event": "before_plan", "hooks": [], + "error": "PyYAML is required to read .specify/extensions.yml", + } + + +def test_no_yaml_dependency_needed_for_absent_config(tmp_path): + result = subprocess.run( + [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert result.returncode == 0 + assert json.loads(result.stdout)["hooks"] == [] + + +@pytest.mark.parametrize("priority", [float("inf"), float("-inf"), float("nan")]) +def test_nonfinite_priority_does_not_crash(tmp_path, priority): + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "priority": priority} + ]}})) + code, data = run_hook(tmp_path) + assert code == 0 + assert data["hooks"][0]["priority"] == 10 + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_initialized_project_installs_runnable_dispatchers(tmp_path, variant, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--commands", "--script", variant, + ], catch_exceptions=False) + assert result.exit_code == 0, result.output + if variant != "py": + assert not (project / ".specify/scripts/python/pre_hooks.py").exists() + assert not (project / ".specify/scripts/python/post_hooks.py").exists() + write_config(project, "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n") + script_type = variant + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + scripts = { + "sh": [str(project / ".specify/scripts/bash/pre-hooks.sh")], + "ps": ["pwsh", "-NoProfile", "-File", str(project / ".specify/scripts/powershell/pre-hooks.ps1")], + "py": [sys.executable, str(project / ".specify/scripts/python/pre_hooks.py")], + } + env = os.environ.copy() + env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable + run = subprocess.run(scripts[script_type] + ["plan"], cwd=project, env=env, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout)["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_shell_variants_match_python(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, f"hooks:\n {'before' if phase == 'pre' else 'after'}_plan:\n - extension: git\n command: speckit.git.commit\n") + assert run_hook(tmp_path, phase, variant=variant) == run_hook(tmp_path, phase) + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_canonical_cli_output_has_identical_order_and_metadata(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({ + "installed": ["git", "agent-context"], + "settings": {"auto_execute_hooks": True}, + "hooks": { + "before_tasks": [{"extension": "elsewhere", "command": "speckit.elsewhere.run"}], + event: [ + {"extension": "late", "command": "speckit.late.run", "priority": 20, + "prompt": "Don\'t commit: yet?", "description": 'Quote "tab\t' + "x" * 85}, + {"extension": "first", "command": "speckit.first.run", "priority": 5, + "optional": False, "condition": None}, + {"extension": "second", "command": "speckit.second.run", "priority": 5}, + {"extension": "disabled", "command": "speckit.disabled.run", "enabled": False}, + {"extension": "condition", "command": "speckit.condition.run", + "condition": "env.CI is set"}, + ], + }, + }) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + assert [item["extension"] for item in data["hooks"]] == ["first", "second", "late"] + assert data["hooks"][-1]["prompt"] == "Don't commit: yet?" + assert data["hooks"][-1]["description"] == 'Quote "tab\t' + "x" * 85 + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_canonical_cli_writer_keeps_multiline_prompt_on_one_yaml_line(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": "first\nsecond"} + ]}}) + config = (tmp_path / ".specify/extensions.yml").read_text(encoding="utf-8") + assert 'prompt: "first\\nsecond"' in config + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["prompt"] == "first\nsecond" + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_native_resolvers_do_not_invoke_python(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + stub_dir = tmp_path / "stub-bin" + stub_dir.mkdir() + marker = tmp_path / "python-was-called" + for name in ("python", "python3", "py"): + stub = stub_dir / name + stub.write_text(f'#!/bin/sh\nprintf called >> "{marker}"\nexit 97\n', encoding="utf-8") + stub.chmod(0o755) + write_config(tmp_path, f"hooks:\n {'before' if phase == 'pre' else 'after'}_plan:\n - extension: git\n command: speckit.git.commit\n") + code, data = run_hook(tmp_path, phase, variant=variant, extra_env={ + "PATH": f"{stub_dir}{os.pathsep}{os.environ['PATH']}", + "SPECKIT_PYTHON_EXECUTABLE": "python3", + }) + assert code == 0 + assert data["hooks"][0]["command"] == "speckit.git.commit" + assert not marker.exists() + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("text", [ + "hooks:\n before_plan: [wrong]\n", + "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n prompt: |\n multiline\n", + "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n optional: 'false'\n", + "hooks: [\n", + "installed: [\nhooks: {}\n", + "settings:\n auto_execute_hooks: [\nhooks: {}\n", + "installed:\n- 'unfinished\nhooks: {}\n", + "hooks: {}\nhooks: {}\n", + "hooks:\n before_plan: []\n before_plan: []\n", + "settings:\n auto_execute_hooks: true\n bad indentation: nope\nhooks: {}\n", +]) +def test_native_parsers_reject_unsupported_or_invalid_yaml(tmp_path, variant, phase, text): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + if phase == "post": + text = text.replace("before_plan", "after_plan") + write_config(tmp_path, text) + code, data = run_hook(tmp_path, phase=phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps"]) +def test_native_invalid_event_is_valid_json(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + code, data = run_hook(tmp_path, variant=variant, name='bad"name') + assert code == 1 + assert "Invalid hook event" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("priority,expected", [ + ("5", 5), (False, 10), (0, 10), ("invalid", 10), (2.8, 2), +]) +def test_priority_normalization_matches_across_runtimes(tmp_path, variant, priority, expected): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "priority": priority} + ]}})) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["priority"] == expected From c240663222f75d723e5f9b06af9ecaab960eea59 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:58:46 -0500 Subject: [PATCH 02/16] Fix extension hook paths and native YAML scalar parity Keep shared dispatch paths rooted when extension commands render, decode Bash backslash escapes, and reject implicitly typed identifiers in native resolvers. Add cross-runtime and extension rendering regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/bash/pre-hooks.sh | 12 ++++- scripts/powershell/pre-hooks.ps1 | 14 +++-- src/specify_cli/integrations/base.py | 6 +-- .../github/test_github_extension.py | 14 ++++- tests/test_hook_dispatch_scripts.py | 54 +++++++++++++++++++ 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 9f69c21ae2..69e214ef6a 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -56,7 +56,7 @@ hook_scalar() { r) HOOK_SCALAR+=$'\r' ;; b) HOOK_SCALAR+=$'\b' ;; f) HOOK_SCALAR+=$'\f' ;; - '"'|'\\') HOOK_SCALAR+=$escaped ;; + '"'|'\') HOOK_SCALAR+=$escaped ;; *) HOOK_ERROR="Unsupported YAML escape"; return ;; esac done @@ -82,6 +82,16 @@ hook_field() { raw=${field:${#key}+1} hook_scalar "$raw" [[ -n $HOOK_ERROR ]] && return + if [[ $key == extension || $key == command ]] && [[ $HOOK_QUOTED == false ]]; then + local lowered=${HOOK_SCALAR,,} + if [[ -z $HOOK_SCALAR || $lowered =~ ^(true|false|yes|no|on|off|null|~)$ || + $HOOK_SCALAR =~ ^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$ || + $HOOK_SCALAR =~ ^[0-9]+(:[0-9]+)+$ || + $HOOK_SCALAR =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt\ ]|$) ]]; then + HOOK_ERROR="hooks.$event needs extension and command" + return + fi + fi if [[ $key == enabled || $key == optional ]]; then if [[ $HOOK_QUOTED == true || ( $HOOK_SCALAR != true && $HOOK_SCALAR != false ) ]]; then HOOK_ERROR="$key must be a boolean" diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index ef420550b1..f17b3bac14 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -23,11 +23,19 @@ function Convert-HookScalar { } function Add-HookField { - param([hashtable]$Hook, [string]$Text) + param([hashtable]$Hook, [string]$Text, [string]$Event) if ($Text -notmatch '^([a-z_]+):(?:\s|$)(.*)$') { throw "Unsupported YAML hook field" } $key = $Matches[1] $parsed = Convert-HookScalar $Matches[2] $value = $parsed.Value + if ($key -in @('extension', 'command') -and -not $parsed.Quoted -and ( + -not $value -or $value -match '^(true|false|yes|no|on|off|null|~)$' -or + $value -match '^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$' -or + $value -match '^[0-9]+(:[0-9]+)+$' -or + $value -match '^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt ]|$)' + )) { + throw "hooks.$Event needs extension and command" + } if ($key -in @('enabled', 'optional') -and ($parsed.Quoted -or $value -notin @('true', 'false'))) { throw "$key must be a boolean" } @@ -118,12 +126,12 @@ function Resolve-HookConfig { if ($target) { $hook = @{ enabled = 'true'; optional = 'true'; priority = '10' } $hooks.Add($hook) - Add-HookField $hook $body.Substring(2) + Add-HookField $hook $body.Substring(2) $Event } continue } if ($itemIndent -ge 0 -and $indent -eq $itemIndent + 2) { - if ($target) { Add-HookField $hook $body } + if ($target) { Add-HookField $hook $body $Event } continue } throw "Unsupported YAML hook layout" diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 10b98ee447..7902ae9d70 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -796,12 +796,12 @@ def resolve_hook_script_refs( for phase, name in (("PRE", "pre"), ("POST", "post")): if variant == "py": command = IntegrationBase.build_python_invocation( - f"scripts/python/{name}_hooks.py", project_root + f".specify/scripts/python/{name}_hooks.py", project_root ) elif variant == "ps": - command = f"scripts/powershell/{name}-hooks.ps1" + command = f".specify/scripts/powershell/{name}-hooks.ps1" else: - command = f"scripts/bash/{name}-hooks.sh" + command = f".specify/scripts/bash/{name}-hooks.sh" content = content.replace(f"{{{phase}_HOOK_SCRIPT}}", command) return content diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index d7fbc3c07d..6292221212 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -429,8 +429,15 @@ def test_rendered_command_points_at_a_script_that_ships( assert expected in content # The rendered path must resolve to a file the extension ships. assert (project / expected).is_file() - # And it must not have been rewritten into the core script tree. - assert ".specify/scripts/" not in content + # The extension's own script stays local; hook dispatchers are shared. + for phase in ("pre", "post"): + hook_path = { + "sh": f".specify/scripts/bash/{phase}-hooks.sh", + "ps": f".specify/scripts/powershell/{phase}-hooks.ps1", + "py": f".specify/scripts/python/{phase}_hooks.py", + }[variant] + assert hook_path in content + assert hook_path.replace(".specify/scripts/", ".specify/extensions/github/scripts/") not in content @pytest.mark.parametrize("agent", EXTENSION_REGISTRAR_AGENTS) def test_every_extension_registrar_integration_renders_the_command( @@ -522,6 +529,9 @@ def test_rendered_skill_points_at_a_script_that_ships(self, tmp_path: Path): expected = f".specify/extensions/github/{SCRIPT_TWINS['sh']}" assert expected in content assert (project / expected).is_file() + for phase in ("pre", "post"): + assert f".specify/scripts/bash/{phase}-hooks.sh" in content + assert f".specify/extensions/github/scripts/bash/{phase}-hooks.sh" not in content # -- Behaviour parity with the core command ----------------------------------- diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 91f33fe91c..6426ed2dc6 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -241,6 +241,60 @@ def test_canonical_cli_writer_keeps_multiline_prompt_on_one_yaml_line(tmp_path, assert data["hooks"][0]["prompt"] == "first\nsecond" +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_canonical_backslash_escape_matches_python(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + prompt = "Path C:\\workspace\nkeep \\ literal" + HookExecutor(tmp_path).save_project_config({ + "hooks": {event: [ + {"extension": "git", "command": "speckit.git.commit", "prompt": prompt} + ]} + }) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + assert data["hooks"][0]["prompt"] == prompt + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("field,value", [ + ("extension", "false"), ("command", "123"), ("extension", "yes"), + ("command", "0x10"), ("extension", "null"), ("command", "1.2"), + ("extension", "2026-10-08"), ("command", ".nan"), +]) +def test_native_identifiers_reject_implicitly_typed_yaml(tmp_path, variant, phase, field, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config( + tmp_path, + f"hooks:\n {event}:\n - extension: git\n" + f" command: speckit.git.commit\n {field}: {value}\n", + ) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert "needs extension and command" in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_quoted_numeric_identifier_remains_string(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: '123'\n command: 'false'\n") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + + @pytest.mark.parametrize("variant", ["sh", "ps"]) @pytest.mark.parametrize("phase", ["pre", "post"]) def test_native_resolvers_do_not_invoke_python(tmp_path, variant, phase): From 28651875f863edf011d98be9698fb09a4820e2c6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:53:47 -0500 Subject: [PATCH 03/16] Keep hook dispatch portable and migrate legacy configuration Use Bash 3-compatible scalar checks, validate condition types across runtimes, and canonicalize existing extension configuration when shared infrastructure is installed or upgraded. Guard invalid and symlinked configurations and cover old wrapped YAML with executable resolver regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/extensions.md | 2 +- scripts/bash/pre-hooks.sh | 21 +++++-- scripts/powershell/pre-hooks.ps1 | 19 ++++-- scripts/python/pre_hooks.py | 2 + src/specify_cli/__init__.py | 6 +- src/specify_cli/extensions/__init__.py | 15 +++++ .../integrations/test_command_upgrade.py | 59 +++++++++++++++++++ tests/test_hook_dispatch_scripts.py | 49 +++++++++++++++ 8 files changed, 160 insertions(+), 13 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index aa6f03a292..99e7a1ee79 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -327,7 +327,7 @@ Extension manifests reject invalid hook priorities during installation. For exis Core commands run two hook dispatchers, one before and one after their main work, using the project's selected `sh`, `ps`, or `py` script type. Each variant resolves hooks independently and returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. The Python variant needs Python 3 with PyYAML when `.specify/extensions.yml` exists; Bash and PowerShell do not use Python or PyYAML. -The native Bash and PowerShell resolvers support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported; these variants reject them explicitly rather than silently skipping hooks. The CLI writes hook configuration in this canonical layout without wrapping long scalar values. The Python variant accepts general YAML through PyYAML. +The native Bash and PowerShell resolvers support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported; these variants reject them explicitly rather than silently skipping hooks. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout so configurations produced by older versions remain usable (YAML comments and formatting are not retained). The Python variant accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 69e214ef6a..619b5f12bc 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -72,6 +72,16 @@ hook_scalar() { HOOK_SCALAR=$raw } +hook_typed_scalar() { + case $1 in + [Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|[Yy][Ee][Ss]|[Nn][Oo]|[Oo][Nn]|[Oo][Ff][Ff]) + return 0 ;; + esac + [[ $1 =~ ^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$ || + $1 =~ ^[0-9]+(:[0-9]+)+$ || + $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt\ ]|$) ]] +} + hook_field() { local field=$1 key raw if [[ ! $field =~ ^([a-z_]+):([[:space:]]|$) ]]; then @@ -83,15 +93,16 @@ hook_field() { hook_scalar "$raw" [[ -n $HOOK_ERROR ]] && return if [[ $key == extension || $key == command ]] && [[ $HOOK_QUOTED == false ]]; then - local lowered=${HOOK_SCALAR,,} - if [[ -z $HOOK_SCALAR || $lowered =~ ^(true|false|yes|no|on|off|null|~)$ || - $HOOK_SCALAR =~ ^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$ || - $HOOK_SCALAR =~ ^[0-9]+(:[0-9]+)+$ || - $HOOK_SCALAR =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt\ ]|$) ]]; then + if [[ -z $HOOK_SCALAR ]] || hook_typed_scalar "$HOOK_SCALAR"; then HOOK_ERROR="hooks.$event needs extension and command" return fi fi + if [[ $key == condition && $HOOK_QUOTED == false && -n $HOOK_SCALAR ]] && + hook_typed_scalar "$HOOK_SCALAR"; then + HOOK_ERROR="condition must be a string or null" + return + fi if [[ $key == enabled || $key == optional ]]; then if [[ $HOOK_QUOTED == true || ( $HOOK_SCALAR != true && $HOOK_SCALAR != false ) ]]; then HOOK_ERROR="$key must be a boolean" diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index f17b3bac14..53c2599a29 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -28,14 +28,13 @@ function Add-HookField { $key = $Matches[1] $parsed = Convert-HookScalar $Matches[2] $value = $parsed.Value - if ($key -in @('extension', 'command') -and -not $parsed.Quoted -and ( - -not $value -or $value -match '^(true|false|yes|no|on|off|null|~)$' -or - $value -match '^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$' -or - $value -match '^[0-9]+(:[0-9]+)+$' -or - $value -match '^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt ]|$)' - )) { + $typed = -not $parsed.Quoted -and (Test-HookTypedScalar $value) + if ($key -in @('extension', 'command') -and (-not $value -or $typed)) { throw "hooks.$Event needs extension and command" } + if ($key -eq 'condition' -and $typed) { + throw "condition must be a string or null" + } if ($key -in @('enabled', 'optional') -and ($parsed.Quoted -or $value -notin @('true', 'false'))) { throw "$key must be a boolean" } @@ -45,6 +44,14 @@ function Add-HookField { $Hook[$key] = $value } +function Test-HookTypedScalar { + param([string]$Value) + return $Value -match '^(true|false|yes|no|on|off)$' -or + $Value -match '^[+-]?(0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9]+)?|\.[0-9_]+([eE][+-]?[0-9]+)?|\.([iI][nN][fF]|[nN][aA][nN]))$' -or + $Value -match '^[0-9]+(:[0-9]+)+$' -or + $Value -match '^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt ]|$)' +} + function Get-HookPriority { param([string]$Raw) if ($Raw -notmatch '^[+-]?[0-9]+(\.[0-9]+)?$') { return 10 } diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 1aa5c01900..859916c221 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -45,6 +45,8 @@ def resolve(event: str, project_root: Path) -> dict: raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].enabled must be a boolean") if "optional" in entry and not isinstance(entry["optional"], bool): raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].optional must be a boolean") + if entry.get("condition") is not None and not isinstance(entry["condition"], str): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].condition must be a string or null") if entry.get("enabled") is False or entry.get("condition"): continue extension, command = entry.get("extension"), entry.get("command") diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 68ea5e4985..36c0cf5fda 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -171,7 +171,7 @@ def _install_shared_infra( Returns ``True`` on success. """ - return _install_shared_infra_impl( + installed = _install_shared_infra_impl( project_path, script_type, version=get_speckit_version(), @@ -184,6 +184,10 @@ def _install_shared_infra( refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) + from .extensions import HookExecutor + + HookExecutor(project_path).migrate_project_config() + return installed def _install_shared_infra_or_exit( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 09e7c34a9d..48ede06aa5 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5731,6 +5731,21 @@ def represent_string(dumper: yaml.SafeDumper, value: str): encoding="utf-8", ) + def migrate_project_config(self) -> None: + """Rewrite existing hook configuration for the native script resolvers.""" + if self.config_file.is_symlink(): + raise ValueError("Refusing to migrate symlinked .specify/extensions.yml") + if not self.config_file.exists(): + return + try: + config = yaml.safe_load(self.config_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Could not read .specify/extensions.yml: {exc}") from exc + if not isinstance(config, dict) or not isinstance(config.get("hooks", {}), dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + config.setdefault("hooks", {}) + self.save_project_config(config) + def register_extension(self, extension_id: str): """Add extension to the installed list in project config. diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 42d5ea0a6c..e11d52ac86 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -6,9 +6,11 @@ import json # noqa: F401 import os # noqa: F401 import shutil # noqa: F401 +import subprocess from pathlib import Path # noqa: F401 import pytest # noqa: F401 +import yaml from specify_cli import app # noqa: F401 from tests.conftest import strip_ansi # noqa: F401 @@ -27,6 +29,63 @@ ) class TestIntegrationUpgradeDetailed: + @pytest.mark.parametrize("variant", ["sh", "ps"]) + def test_upgrade_migrates_wrapped_legacy_hook_config(self, tmp_path, variant): + project = _init_project(tmp_path, "copilot", integration_options="--commands") + config_file = project / ".specify" / "extensions.yml" + legacy = { + "installed": ["git"], + "settings": {"auto_execute_hooks": True}, + "hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", + "prompt": "A long legacy hook prompt containing multiple words " * 5} + ]}, + } + old_text = yaml.safe_dump(legacy, sort_keys=False) + assert "\n prompt containing" in old_text + config_file.write_text(old_text, encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", "--script", variant, + ]) + assert result.exit_code == 0, result.output + assert yaml.safe_load(config_file.read_text(encoding="utf-8")) == legacy + + command = { + "sh": ["/bin/bash", str(project / ".specify/scripts/bash/pre-hooks.sh")], + "ps": ["pwsh", "-NoProfile", "-File", + str(project / ".specify/scripts/powershell/pre-hooks.ps1")], + }[variant] + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + run = subprocess.run(command + ["plan"], cwd=project, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + assert json.loads(run.stdout)["hooks"][0]["prompt"] == legacy["hooks"]["before_plan"][0]["prompt"] + + def test_upgrade_does_not_overwrite_invalid_hook_config(self, tmp_path): + project = _init_project(tmp_path, "copilot", integration_options="--commands") + config_file = project / ".specify" / "extensions.yml" + invalid = "hooks: [\n" + config_file.write_text(invalid, encoding="utf-8") + + result = _run_in_project(project, ["integration", "upgrade", "copilot", "--script", "sh"]) + assert result.exit_code != 0 + assert "extensions.yml" in result.output + assert config_file.read_text(encoding="utf-8") == invalid + + def test_upgrade_does_not_write_symlinked_hook_config(self, tmp_path): + project = _init_project(tmp_path, "copilot", integration_options="--commands") + config_file = project / ".specify" / "extensions.yml" + external = tmp_path / "external.yml" + legacy = "hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n" + external.write_text(legacy, encoding="utf-8") + config_file.symlink_to(external) + + result = _run_in_project(project, ["integration", "upgrade", "copilot", "--script", "sh"]) + assert result.exit_code != 0 + assert "Refusing to migrate symlinked" in result.output + assert external.read_text(encoding="utf-8") == legacy + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 6426ed2dc6..46eb49612a 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -295,6 +295,55 @@ def test_quoted_numeric_identifier_remains_string(tmp_path, variant, phase): assert data == run_hook(tmp_path, phase)[1] +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_system_bash_resolves_unquoted_identifiers(tmp_path, phase): + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n command: speckit.git.commit\n") + result = subprocess.run( + ["/bin/bash", str(ROOT / "scripts/bash" / f"{phase}-hooks.sh"), "plan"], + cwd=tmp_path, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("value", ["false", "true", "0"]) +def test_non_string_condition_fails_consistently(tmp_path, variant, phase, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n" + f" command: speckit.git.commit\n condition: {value}\n") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert "condition must be a string or null" in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_quoted_condition_remains_a_string(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" + " command: speckit.git.commit\n condition: 'false'\n") + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_null_condition_allows_hook(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" + " command: speckit.git.commit\n condition: null\n") + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert len(data["hooks"]) == 1 + + @pytest.mark.parametrize("variant", ["sh", "ps"]) @pytest.mark.parametrize("phase", ["pre", "post"]) def test_native_resolvers_do_not_invoke_python(tmp_path, variant, phase): From 219c01302ae40a3746e92d241081e93b44d01c43 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:48:56 -0500 Subject: [PATCH 04/16] Validate every hook event and align resolver priorities Reject symlinked extension configuration parents before migration, validate non-target hook fields in native and Python dispatch, and bound priority normalization across all three runtimes. Add negative, ordering, and symlink regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/extensions.md | 2 + scripts/bash/pre-hooks.sh | 22 +++--- scripts/powershell/pre-hooks.ps1 | 21 +++--- scripts/python/pre_hooks.py | 72 +++++++++---------- src/specify_cli/extensions/__init__.py | 2 +- .../integrations/test_command_upgrade.py | 16 +++++ tests/test_hook_dispatch_scripts.py | 40 +++++++++++ 7 files changed, 119 insertions(+), 56 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 99e7a1ee79..004dd6913c 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -327,6 +327,8 @@ Extension manifests reject invalid hook priorities during installation. For exis Core commands run two hook dispatchers, one before and one after their main work, using the project's selected `sh`, `ps`, or `py` script type. Each variant resolves hooks independently and returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. The Python variant needs Python 3 with PyYAML when `.specify/extensions.yml` exists; Bash and PowerShell do not use Python or PyYAML. +All three dispatchers validate hook fields across every configured event before returning the requested event. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. + The native Bash and PowerShell resolvers support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported; these variants reject them explicitly rather than silently skipping hooks. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout so configurations produced by older versions remain usable (YAML comments and formatting are not retained). The Python variant accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 619b5f12bc..6b3d6e9f91 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -110,32 +110,36 @@ hook_field() { fi fi if [[ $key == description || $key == prompt ]]; then - if [[ $HOOK_QUOTED == false && $HOOK_SCALAR =~ ^(true|false|[0-9]+)$ ]]; then + if [[ $HOOK_QUOTED == false && -n $HOOK_SCALAR ]] && + hook_typed_scalar "$HOOK_SCALAR"; then HOOK_ERROR="$key must be a string" return fi fi + [[ $target == true ]] || return case $key in extension) HOOK_EXT[index]=$HOOK_SCALAR ;; command) HOOK_CMD[index]=$HOOK_SCALAR ;; enabled) HOOK_ENABLED[index]=$HOOK_SCALAR ;; optional) HOOK_OPTIONAL[index]=$HOOK_SCALAR ;; condition) HOOK_CONDITION[index]=$HOOK_SCALAR ;; - priority) HOOK_PRIORITY[index]=$HOOK_SCALAR ;; + priority) HOOK_PRIORITY[index]=$HOOK_SCALAR; HOOK_PRIORITY_QUOTED[index]=$HOOK_QUOTED ;; description) HOOK_DESCRIPTION[index]=$HOOK_SCALAR ;; prompt) HOOK_PROMPT[index]=$HOOK_SCALAR ;; esac } hook_priority() { - local raw=$1 whole + local raw=$1 quoted=$2 whole HOOK_RANK=10 [[ $raw =~ ^\+?[0-9]+(\.[0-9]+)?$ ]] || return + [[ $quoted == true && $raw == *.* ]] && return whole=${raw%%.*} whole=${whole#+} - (( ${#whole} <= 15 )) || return + while [[ $whole == 0* && ${#whole} -gt 1 ]]; do whole=${whole#0}; done + (( ${#whole} <= 10 )) || return HOOK_RANK=$((10#$whole)) - (( HOOK_RANK >= 1 )) || HOOK_RANK=10 + (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 } resolve_hooks() { @@ -143,7 +147,7 @@ resolve_hooks() { local section="" target=false empty=false item_indent=-1 index=0 seen=false seen_hooks=false seen_event=false local i n chosen first=true local -a HOOK_EXT HOOK_CMD HOOK_ENABLED HOOK_OPTIONAL HOOK_CONDITION - local -a HOOK_PRIORITY HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED + local -a HOOK_PRIORITY HOOK_PRIORITY_QUOTED HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED event="${phase}_${command}" if [[ ! $event =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then hook_error "" "Invalid hook event: $event" @@ -226,10 +230,10 @@ resolve_hooks() { HOOK_ENABLED[index]=true HOOK_OPTIONAL[index]=true HOOK_PRIORITY[index]=10 - hook_field "${text:2}" fi + hook_field "${text:2}" elif (( item_indent >= 0 && indent == item_indent + 2 )); then - if [[ $target == true ]]; then hook_field "$text"; fi + hook_field "$text" else HOOK_ERROR="Unsupported YAML hook layout" fi @@ -242,7 +246,7 @@ resolve_hooks() { if [[ -z ${HOOK_EXT[i]} || -z ${HOOK_CMD[i]} ]]; then HOOK_ERROR="hooks.$event needs extension and command"; break fi - hook_priority "${HOOK_PRIORITY[i]}" + hook_priority "${HOOK_PRIORITY[i]}" "${HOOK_PRIORITY_QUOTED[i]}" HOOK_RANKS[i]=$HOOK_RANK done fi diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index 53c2599a29..1895d980a8 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -38,10 +38,12 @@ function Add-HookField { if ($key -in @('enabled', 'optional') -and ($parsed.Quoted -or $value -notin @('true', 'false'))) { throw "$key must be a boolean" } - if ($key -in @('prompt', 'description') -and -not $parsed.Quoted -and $value -match '^(true|false|[0-9]+)$') { + if ($key -in @('prompt', 'description') -and -not $parsed.Quoted -and + (Test-HookTypedScalar $value)) { throw "$key must be a string" } $Hook[$key] = $value + if ($key -eq 'priority') { $Hook.priority_quoted = $parsed.Quoted } } function Test-HookTypedScalar { @@ -53,12 +55,13 @@ function Test-HookTypedScalar { } function Get-HookPriority { - param([string]$Raw) + param([string]$Raw, [bool]$Quoted) if ($Raw -notmatch '^[+-]?[0-9]+(\.[0-9]+)?$') { return 10 } + if ($Quoted -and $Raw.Contains('.')) { return 10 } $number = 0.0 if (-not [double]::TryParse($Raw, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) { return 10 } - if ($number -lt 1 -or $number -gt [int]::MaxValue) { return 10 } + if ($number -lt 1 -or [Math]::Truncate($number) -gt [int]::MaxValue) { return 10 } return [int][Math]::Truncate($number) } @@ -130,15 +133,13 @@ function Resolve-HookConfig { } if ($indent -in @(2, 4) -and $body.StartsWith('- ') -and -not $empty) { $itemIndent = $indent - if ($target) { - $hook = @{ enabled = 'true'; optional = 'true'; priority = '10' } - $hooks.Add($hook) - Add-HookField $hook $body.Substring(2) $Event - } + $hook = @{ enabled = 'true'; optional = 'true'; priority = '10'; priority_quoted = $false } + if ($target) { $hooks.Add($hook) } + Add-HookField $hook $body.Substring(2) $Event continue } if ($itemIndent -ge 0 -and $indent -eq $itemIndent + 2) { - if ($target) { Add-HookField $hook $body $Event } + Add-HookField $hook $body $Event continue } throw "Unsupported YAML hook layout" @@ -156,7 +157,7 @@ function Resolve-HookConfig { optional = $hook.optional -eq 'true' description = if ($hook.description) { $hook.description } else { '' } prompt = if ($hook.prompt) { $hook.prompt } else { '' } - priority = Get-HookPriority $hook.priority + priority = Get-HookPriority $hook.priority $hook.priority_quoted index = $i }) } diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 859916c221..88838b79ef 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -33,43 +33,43 @@ def resolve(event: str, project_root: Path) -> dict: if not isinstance(config, dict) or not isinstance(config.get("hooks", {}), dict): raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") - entries = config.get("hooks", {}).get(event, []) - if not isinstance(entries, list): - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event} must be a list") - hooks = [] - for index, entry in enumerate(entries): - if not isinstance(entry, dict): - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}] must be a mapping") - if "enabled" in entry and not isinstance(entry["enabled"], bool): - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].enabled must be a boolean") - if "optional" in entry and not isinstance(entry["optional"], bool): - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].optional must be a boolean") - if entry.get("condition") is not None and not isinstance(entry["condition"], str): - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].condition must be a string or null") - if entry.get("enabled") is False or entry.get("condition"): - continue - extension, command = entry.get("extension"), entry.get("command") - if not isinstance(extension, str) or not extension or not isinstance(command, str) or not command: - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}] needs extension and command") - for field in ("description", "prompt"): - if entry.get(field) is not None and not isinstance(entry[field], str): - raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event}[{index}].{field} must be a string") - priority = entry.get("priority", 10) - try: - priority = int(priority) if not isinstance(priority, bool) else 10 - except (TypeError, ValueError, OverflowError): - priority = 10 - if priority < 1: - priority = 10 - hooks.append({ - "extension": extension, - "command": command, - "optional": entry.get("optional", True), - "description": entry.get("description") or "", - "prompt": entry.get("prompt") or "", - "priority": priority, - }) + for hook_event, entries in config.get("hooks", {}).items(): + if not isinstance(entries, list): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{hook_event} must be a list") + for index, entry in enumerate(entries): + label = f"hooks.{hook_event}[{index}]" + if not isinstance(entry, dict): + raise ValueError(f"Invalid .specify/extensions.yml: {label} must be a mapping") + for field in ("enabled", "optional"): + if field in entry and not isinstance(entry[field], bool): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a boolean") + for field in ("condition", "description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a string or null") + for field in ("extension", "command"): + if field in entry and (not isinstance(entry[field], str) or not entry[field]): + raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + if hook_event != event or entry.get("enabled") is False or entry.get("condition"): + continue + extension, command = entry.get("extension"), entry.get("command") + if not extension or not command: + raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + priority = entry.get("priority", 10) + try: + priority = int(priority) if not isinstance(priority, bool) else 10 + except (TypeError, ValueError, OverflowError): + priority = 10 + if priority < 1 or priority > 2147483647: + priority = 10 + hooks.append({ + "extension": extension, + "command": command, + "optional": entry.get("optional", True), + "description": entry.get("description") or "", + "prompt": entry.get("prompt") or "", + "priority": priority, + }) hooks.sort(key=lambda hook: hook["priority"]) return {"event": event, "hooks": hooks} diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 48ede06aa5..9d3af1d3be 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5733,7 +5733,7 @@ def represent_string(dumper: yaml.SafeDumper, value: str): def migrate_project_config(self) -> None: """Rewrite existing hook configuration for the native script resolvers.""" - if self.config_file.is_symlink(): + if self.config_file.parent.is_symlink() or self.config_file.is_symlink(): raise ValueError("Refusing to migrate symlinked .specify/extensions.yml") if not self.config_file.exists(): return diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index e11d52ac86..2d3c4aa29b 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -86,6 +86,22 @@ def test_upgrade_does_not_write_symlinked_hook_config(self, tmp_path): assert "Refusing to migrate symlinked" in result.output assert external.read_text(encoding="utf-8") == legacy + def test_hook_config_migration_rejects_symlinked_parent(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "project" + project.mkdir() + external = tmp_path / "external" + external.mkdir() + config = external / "extensions.yml" + legacy = "hooks:\n before_plan: []\n" + config.write_text(legacy, encoding="utf-8") + (project / ".specify").symlink_to(external, target_is_directory=True) + + with pytest.raises(ValueError, match="symlinked"): + HookExecutor(project).migrate_project_config() + assert config.read_text(encoding="utf-8") == legacy + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 46eb49612a..9f4ef93508 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -392,6 +392,21 @@ def test_native_parsers_reject_unsupported_or_invalid_yaml(tmp_path, variant, ph assert data["error"] +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("field", ["extension: 'unterminated", "optional: 'false'", "condition: false"]) +def test_non_target_event_fields_are_validated(tmp_path, variant, phase, field): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_tasks" + write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n" + f" command: speckit.git.commit\n {field}\n") + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert data["error"] + + @pytest.mark.parametrize("variant", ["sh", "ps"]) def test_native_invalid_event_is_valid_json(tmp_path, variant): if variant == "ps" and not shutil.which("pwsh"): @@ -414,3 +429,28 @@ def test_priority_normalization_matches_across_runtimes(tmp_path, variant, prior code, data = run_hook(tmp_path, variant=variant) assert code == 0 assert data["hooks"][0]["priority"] == expected + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("priority,expected", [ + (2147483647, 2147483647), + (2147483648, 10), + (2147483647.9, 2147483647), + (10**16, 10), + ("00000000000000000005", 5), + ("2.8", 10), +]) +def test_priority_range_and_coercion_parity(tmp_path, variant, priority, expected): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, yaml.safe_dump({"hooks": {"before_plan": [ + {"extension": "high", "command": "speckit.high.run", "priority": priority}, + {"extension": "default", "command": "speckit.default.run"}, + ]}}, sort_keys=False)) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert [hook["extension"] for hook in data["hooks"]] == ( + ["default", "high"] if expected > 10 else ["high", "default"] + ) + assert next(hook for hook in data["hooks"] if hook["extension"] == "high")["priority"] == expected From 6679c82bd739c9e417762d433f15c61dfa3ef2c5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:02:24 -0500 Subject: [PATCH 05/16] Fix hook parity and bundled GitHub extension CI Reject empty quoted identifiers in Bash for every event, update the upstream deprecation contract to assert scripted dispatch, and bump the GitHub extension and catalog to 1.0.3 so installed copies receive the command update. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.json | 2 +- extensions/github/extension.yml | 2 +- scripts/bash/pre-hooks.sh | 5 +++-- tests/extensions/github/test_github_extension.py | 2 +- tests/extensions/github/test_taskstoissues_deprecation.py | 6 ++++-- tests/test_hook_dispatch_scripts.py | 5 ++++- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/extensions/catalog.json b/extensions/catalog.json index 30e6dd92ac..c20cec03e6 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -66,7 +66,7 @@ "github": { "name": "GitHub Integration", "id": "github", - "version": "1.0.2", + "version": "1.0.3", "description": "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", diff --git a/extensions/github/extension.yml b/extensions/github/extension.yml index c6df8f19a1..158824e823 100644 --- a/extensions/github/extension.yml +++ b/extensions/github/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: github name: "GitHub Integration" - version: "1.0.2" + version: "1.0.3" description: "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 6b3d6e9f91..2138c02105 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -92,8 +92,9 @@ hook_field() { raw=${field:${#key}+1} hook_scalar "$raw" [[ -n $HOOK_ERROR ]] && return - if [[ $key == extension || $key == command ]] && [[ $HOOK_QUOTED == false ]]; then - if [[ -z $HOOK_SCALAR ]] || hook_typed_scalar "$HOOK_SCALAR"; then + if [[ $key == extension || $key == command ]]; then + if [[ -z $HOOK_SCALAR ]] || + { [[ $HOOK_QUOTED == false ]] && hook_typed_scalar "$HOOK_SCALAR"; }; then HOOK_ERROR="hooks.$event needs extension and command" return fi diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 24ac9d12a5..00f3743bf4 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -158,7 +158,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "github" - assert m.version == "1.0.2" + assert m.version == "1.0.3" assert [c["name"] for c in m.commands] == [COMMAND_NAME] def test_manifest_command_files_exist(self): diff --git a/tests/extensions/github/test_taskstoissues_deprecation.py b/tests/extensions/github/test_taskstoissues_deprecation.py index b18324863b..bde771f709 100644 --- a/tests/extensions/github/test_taskstoissues_deprecation.py +++ b/tests/extensions/github/test_taskstoissues_deprecation.py @@ -56,8 +56,10 @@ def test_recommended_extension_is_not_deprecated(): @pytest.mark.parametrize("command", [CORE, EXTENSION], ids=["core", "github"]) def test_taskstoissues_hooks_and_safety_contracts_remain(command): text = command.read_text(encoding="utf-8") - assert "hooks.before_taskstoissues" in text - assert "hooks.after_taskstoissues" in text + assert "{PRE_HOOK_SCRIPT} taskstoissues" in text + assert "{POST_HOOK_SCRIPT} taskstoissues" in text + assert text.index("{PRE_HOOK_SCRIPT}") < text.index("{POST_HOOK_SCRIPT}") + assert text.count("including mandatory hooks") == 2 assert "ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL" in text assert "**Fetch existing issues for deduplication**" in text assert "**Skip** any task whose ID is already present" in text diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 9f4ef93508..c63bd8661e 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -394,7 +394,10 @@ def test_native_parsers_reject_unsupported_or_invalid_yaml(tmp_path, variant, ph @pytest.mark.parametrize("variant", ["sh", "ps", "py"]) @pytest.mark.parametrize("phase", ["pre", "post"]) -@pytest.mark.parametrize("field", ["extension: 'unterminated", "optional: 'false'", "condition: false"]) +@pytest.mark.parametrize("field", [ + "extension: 'unterminated", "extension: ''", 'command: ""', + "optional: 'false'", "condition: false", +]) def test_non_target_event_fields_are_validated(tmp_path, variant, phase, field): if variant == "ps" and not shutil.which("pwsh"): pytest.skip("PowerShell not installed") From db85cb1ff411065f4f3c611b4236c28a04fbb4ff Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:36:42 -0500 Subject: [PATCH 06/16] Run hook shell tests only with a working Bash on Windows Invoke Bash explicitly when Git Bash is usable and skip Bash-only cases otherwise; retain native PowerShell and Python coverage on Windows while preserving Bash coverage on macOS and Linux. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../integrations/test_command_upgrade.py | 6 ++++++ tests/test_hook_dispatch_scripts.py | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 2d3c4aa29b..f388771270 100644 --- a/tests/specify_cli/integrations/test_command_upgrade.py +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -56,6 +56,12 @@ def test_upgrade_migrates_wrapped_legacy_hook_config(self, tmp_path, variant): "ps": ["pwsh", "-NoProfile", "-File", str(project / ".specify/scripts/powershell/pre-hooks.ps1")], }[variant] + if variant == "sh" and os.name == "nt": + from tests.conftest import _has_working_bash + + if not _has_working_bash(): + pytest.skip("working Git Bash not available on Windows") + command[0] = "bash" if variant == "ps" and not shutil.which("pwsh"): pytest.skip("PowerShell not installed") run = subprocess.run(command + ["plan"], cwd=project, capture_output=True, text=True) diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index c63bd8661e..c0c3af4de1 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -5,21 +5,38 @@ import os import subprocess import sys +from functools import lru_cache from pathlib import Path import pytest import yaml +from tests.conftest import _has_working_bash ROOT = Path(__file__).parent.parent PYTHON = ROOT / "scripts" / "python" +@lru_cache(maxsize=1) +def _bash_available(): + return _has_working_bash() + + +def bash_script(path): + if os.name == "nt": + if not _bash_available(): + pytest.skip("working Git Bash not available on Windows") + return ["bash", str(path)] + return [str(path)] + + def run_hook(tmp_path, phase="pre", name="plan", variant="py", extra_env=None): scripts = { "py": [sys.executable, str(PYTHON / f"{phase}_hooks.py")], "sh": [str(ROOT / "scripts" / "bash" / f"{phase}-hooks.sh")], "ps": ["pwsh", "-NoProfile", "-File", str(ROOT / "scripts" / "powershell" / f"{phase}-hooks.ps1")], } + if variant == "sh": + scripts["sh"] = bash_script(ROOT / "scripts" / "bash" / f"{phase}-hooks.sh") env = os.environ.copy() env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable if extra_env: @@ -176,6 +193,8 @@ def test_initialized_project_installs_runnable_dispatchers(tmp_path, variant, mo "ps": ["pwsh", "-NoProfile", "-File", str(project / ".specify/scripts/powershell/pre-hooks.ps1")], "py": [sys.executable, str(project / ".specify/scripts/python/pre_hooks.py")], } + if variant == "sh": + scripts["sh"] = bash_script(project / ".specify/scripts/bash/pre-hooks.sh") env = os.environ.copy() env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable run = subprocess.run(scripts[script_type] + ["plan"], cwd=project, env=env, capture_output=True, text=True) @@ -297,6 +316,8 @@ def test_quoted_numeric_identifier_remains_string(tmp_path, variant, phase): @pytest.mark.parametrize("phase", ["pre", "post"]) def test_system_bash_resolves_unquoted_identifiers(tmp_path, phase): + if os.name == "nt": + pytest.skip("system /bin/bash is only available on POSIX") event = f"{'before' if phase == 'pre' else 'after'}_plan" write_config(tmp_path, f"hooks:\n {event}:\n - extension: git\n command: speckit.git.commit\n") result = subprocess.run( From 333355ddfb47dcbec2a6e15f3990225711ad3359 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:04:47 -0500 Subject: [PATCH 07/16] Fix hook dispatcher compatibility in extension updates Handle metadata-bearing installed entries and validate all hook identifiers across runtimes. Restore shared dispatchers on extension install/update, preserve customized copies, and reinstate skill invocation guidance with cross-runtime and lifecycle regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extensions/github/README.md | 4 ++ scripts/bash/pre-hooks.sh | 56 ++++++++++----- scripts/powershell/pre-hooks.ps1 | 38 ++++++++-- scripts/python/pre_hooks.py | 4 +- src/specify_cli/__init__.py | 5 ++ src/specify_cli/extensions/__init__.py | 42 +++++++++++ src/specify_cli/integrations/base.py | 13 +++- .../integrations/cline/__init__.py | 4 +- .../integrations/junie/__init__.py | 13 ++-- src/specify_cli/shared_infra.py | 15 ++-- .../github/test_github_extension.py | 72 +++++++++++++++++++ .../test_integration_base_skills.py | 4 ++ tests/integrations/test_integration_cline.py | 13 ++++ tests/integrations/test_integration_junie.py | 25 +++++++ tests/test_hook_dispatch_scripts.py | 63 ++++++++++++++++ 15 files changed, 330 insertions(+), 41 deletions(-) diff --git a/extensions/github/README.md b/extensions/github/README.md index 8aaf46a5c3..a2a90fea3f 100644 --- a/extensions/github/README.md +++ b/extensions/github/README.md @@ -53,6 +53,10 @@ specify extension remove github This extension consumes the existing `before_taskstoissues` and `after_taskstoissues` hook points, which are read from `.specify/extensions.yml` at run time. The hook keys are unchanged from the core command, so hooks registered by other extensions — for example the `git` extension's auto-commit hooks — keep firing exactly as before. +Installing or updating the extension also installs the selected shared pre/post +hook dispatchers under `.specify/scripts/` if an older project lacks them. +Unmodified managed copies are refreshed; customized scripts are preserved. + ## Requirements - Spec Kit **0.12.17 or newer**. Extension-local `scripts/...` path rewriting arrived in diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 2138c02105..34b0668937 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -117,7 +117,6 @@ hook_field() { return fi fi - [[ $target == true ]] || return case $key in extension) HOOK_EXT[index]=$HOOK_SCALAR ;; command) HOOK_CMD[index]=$HOOK_SCALAR ;; @@ -130,6 +129,18 @@ hook_field() { esac } +hook_installed_field() { + local field=$1 key raw + if [[ ! $field =~ ^([a-z_]+):([[:space:]]|$) ]]; then + HOOK_ERROR="Unsupported YAML installed entry" + return + fi + key=${BASH_REMATCH[1]} + raw=${field:${#key}+1} + if [[ $raw =~ ^(.*[\"\'])[[:space:]]+#.*$ ]]; then raw=${BASH_REMATCH[1]}; fi + hook_scalar "$raw" +} + hook_priority() { local raw=$1 quoted=$2 whole HOOK_RANK=10 @@ -145,13 +156,13 @@ hook_priority() { resolve_hooks() { local phase=$1 command=$2 event config line spaces indent text key value - local section="" target=false empty=false item_indent=-1 index=0 seen=false seen_hooks=false seen_event=false + local section="" target=false empty=false item_indent=-1 installed_indent=-1 index=0 seen=false seen_hooks=false seen_event=false local i n chosen first=true local -a HOOK_EXT HOOK_CMD HOOK_ENABLED HOOK_OPTIONAL HOOK_CONDITION - local -a HOOK_PRIORITY HOOK_PRIORITY_QUOTED HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED + local -a HOOK_PRIORITY HOOK_PRIORITY_QUOTED HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED HOOK_EVENTS HOOK_TARGETS event="${phase}_${command}" if [[ ! $event =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then - hook_error "" "Invalid hook event: $event" + hook_error "$event" "Invalid hook event: $event" return 1 fi config=.specify/extensions.yml @@ -172,14 +183,21 @@ resolve_hooks() { spaces=${line%%[! ]*} indent=${#spaces} text=${line:indent} - if (( indent == 0 )); then - if [[ $section == installed && $text == '- '* && $text != '- ' ]]; then + if [[ $section == installed && $text == '- '* && ( $indent == 0 || $indent == 2 ) ]]; then + if [[ ${text:2} =~ ^[a-z_]+:([[:space:]]|$) ]]; then + installed_indent=$indent + hook_installed_field "${text:2}" + else + installed_indent=-1 hook_scalar "${text:2}" - [[ -n $HOOK_ERROR ]] && break - continue fi + [[ -n $HOOK_ERROR ]] && break + continue + fi + if (( indent == 0 )); then target=false item_indent=-1 + installed_indent=-1 if [[ $text == 'hooks:' || $text == 'hooks: {}' ]]; then if [[ $seen_hooks == true ]]; then HOOK_ERROR="Duplicate hooks mapping"; break; fi seen_hooks=true @@ -198,6 +216,12 @@ resolve_hooks() { continue fi if [[ $section != hooks ]]; then + if [[ $section == installed && $installed_indent -ge 0 && + $indent -eq $((installed_indent+2)) ]]; then + hook_installed_field "$text" + [[ -n $HOOK_ERROR ]] && break + continue + fi if [[ $section != settings || $indent != 2 || ! $text =~ ^[a-z_]+:([[:space:]]|$) ]]; then HOOK_ERROR="Unsupported YAML top-level layout"; break fi @@ -226,12 +250,12 @@ resolve_hooks() { fi if [[ $text == '- '* && ( $indent == 2 || $indent == 4 ) && $empty == false ]]; then item_indent=$indent - if [[ $target == true ]]; then - index=$((index+1)) - HOOK_ENABLED[index]=true - HOOK_OPTIONAL[index]=true - HOOK_PRIORITY[index]=10 - fi + index=$((index+1)) + HOOK_EVENTS[index]=$key + HOOK_TARGETS[index]=$target + HOOK_ENABLED[index]=true + HOOK_OPTIONAL[index]=true + HOOK_PRIORITY[index]=10 hook_field "${text:2}" elif (( item_indent >= 0 && indent == item_indent + 2 )); then hook_field "$text" @@ -243,10 +267,10 @@ resolve_hooks() { [[ $seen == true ]] || HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping" if [[ -z $HOOK_ERROR ]]; then for ((i=1; i<=index; i++)); do - [[ ${HOOK_ENABLED[i]} == false || -n ${HOOK_CONDITION[i]} ]] && continue if [[ -z ${HOOK_EXT[i]} || -z ${HOOK_CMD[i]} ]]; then - HOOK_ERROR="hooks.$event needs extension and command"; break + HOOK_ERROR="hooks.${HOOK_EVENTS[i]} needs extension and command"; break fi + [[ ${HOOK_TARGETS[i]} != true || ${HOOK_ENABLED[i]} == false || -n ${HOOK_CONDITION[i]} ]] && continue hook_priority "${HOOK_PRIORITY[i]}" "${HOOK_PRIORITY_QUOTED[i]}" HOOK_RANKS[i]=$HOOK_RANK done diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index 1895d980a8..c7b2289fec 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -46,6 +46,16 @@ function Add-HookField { if ($key -eq 'priority') { $Hook.priority_quoted = $parsed.Quoted } } +function Test-InstalledField { + param([string]$Text) + if ($Text -notmatch '^([a-z_]+):(?:\s|$)(.*)$') { + throw "Unsupported YAML installed entry" + } + $raw = $Matches[2] + if ($raw -match '^(.*["''])\s+#.*$') { $raw = $Matches[1] } + $null = Convert-HookScalar $raw +} + function Test-HookTypedScalar { param([string]$Value) return $Value -match '^(true|false|yes|no|on|off)$' -or @@ -78,6 +88,7 @@ function Resolve-HookConfig { $target = $false $empty = $false $itemIndent = -1 + $installedIndent = -1 $hook = $null foreach ($line in ($text -split '\r?\n')) { if ($line -match '^\s*(#|$)') { continue } @@ -85,12 +96,19 @@ function Resolve-HookConfig { if ($line -notmatch '^( *)(.*)$') { throw "Unsupported YAML hook layout" } $indent = $Matches[1].Length $body = $Matches[2] - if ($indent -eq 0) { - if ($section -eq 'installed' -and $body -match '^- \S') { + if ($section -eq 'installed' -and $indent -in @(0, 2) -and $body -match '^- \S') { + if ($body.Substring(2) -match '^[a-z_]+:(?:\s|$)') { + $installedIndent = $indent + Test-InstalledField $body.Substring(2) + } else { + $installedIndent = -1 $null = Convert-HookScalar $body.Substring(2) - continue } + continue + } + if ($indent -eq 0) { $target = $false + $installedIndent = -1 if ($body -in @('hooks:', 'hooks: {}')) { if ($seenHooks) { throw "Duplicate hooks mapping" } $seenHooks = $true @@ -111,6 +129,11 @@ function Resolve-HookConfig { continue } if (-not $inHooks) { + if ($section -eq 'installed' -and $installedIndent -ge 0 -and + $indent -eq $installedIndent + 2) { + Test-InstalledField $body + continue + } if ($section -eq 'settings' -and $indent -eq 2 -and $body -match '^([a-z_]+):(?:\s|$)(.*)$') { $null = Convert-HookScalar $Matches[2] continue @@ -133,8 +156,9 @@ function Resolve-HookConfig { } if ($indent -in @(2, 4) -and $body.StartsWith('- ') -and -not $empty) { $itemIndent = $indent - $hook = @{ enabled = 'true'; optional = 'true'; priority = '10'; priority_quoted = $false } - if ($target) { $hooks.Add($hook) } + $hook = @{ enabled = 'true'; optional = 'true'; priority = '10'; priority_quoted = $false + target = $target; hook_event = $key } + $hooks.Add($hook) Add-HookField $hook $body.Substring(2) $Event continue } @@ -147,10 +171,10 @@ function Resolve-HookConfig { $ordered = [Collections.Generic.List[hashtable]]::new() for ($i = 0; $i -lt $hooks.Count; $i++) { $hook = $hooks[$i] - if ($hook.enabled -eq 'false' -or $hook.condition) { continue } if (-not $hook.extension -or -not $hook.command) { - throw "hooks.$Event needs extension and command" + throw "hooks.$($hook.hook_event) needs extension and command" } + if (-not $hook.target -or $hook.enabled -eq 'false' -or $hook.condition) { continue } $ordered.Add(@{ extension = $hook.extension command = $hook.command diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 88838b79ef..8eb657b67a 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -50,11 +50,11 @@ def resolve(event: str, project_root: Path) -> dict: for field in ("extension", "command"): if field in entry and (not isinstance(entry[field], str) or not entry[field]): raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") - if hook_event != event or entry.get("enabled") is False or entry.get("condition"): - continue extension, command = entry.get("extension"), entry.get("command") if not extension or not command: raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + if hook_event != event or entry.get("enabled") is False or entry.get("condition"): + continue priority = entry.get("priority", 10) try: priority = int(priority) if not isinstance(priority, bool) else 10 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5b7ac6c12f..a8aeb4de7e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -139,6 +139,7 @@ def _install_shared_infra( invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, + hook_dispatchers_only: bool = False, ) -> bool: """Install shared infrastructure files into *project_path*. @@ -161,6 +162,9 @@ def _install_shared_infra( treated as user customizations and preserved with a warning. * Default — only add missing files; existing ones are skipped. + * ``hook_dispatchers_only=True`` — update only pre/post hook resolvers, + leaving unrelated shared infrastructure unchanged. + *refresh_hint* — caller-supplied rich-text fragment shown after the "Preserved customized files" warning to tell the user which flag/command they should re-run with to overwrite their customizations. Each caller @@ -183,6 +187,7 @@ def _install_shared_infra( invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, + hook_dispatchers_only=hook_dispatchers_only, ) from .extensions import HookExecutor diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 9d3af1d3be..16e5dfcbe6 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2556,6 +2556,48 @@ def install_from_directory( f"extension. Install from a copy in a different location instead." ) + needs_hook_dispatchers = False + for command in manifest.commands: + source_file = (source_dir / command["file"]).resolve() + if not source_file.is_relative_to(source_dir.resolve()) or not source_file.is_file(): + continue + try: + command_source = source_file.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise ExtensionError(f"Cannot read extension command '{command['file']}': {exc}") from exc + needs_hook_dispatchers |= ( + "{PRE_HOOK_SCRIPT}" in command_source or "{POST_HOOK_SCRIPT}" in command_source + ) + + if needs_hook_dispatchers: + from .. import _install_shared_infra + from ..integrations.base import get_invocation_prefix + + options = active_options if isinstance(active_options, dict) else {} + script = options.get("script") or ("ps" if os.name == "nt" else "sh") + variant = {"sh": "bash", "ps": "powershell", "py": "python"}.get(script) if isinstance(script, str) else None + if variant is None: + raise ExtensionError(f"Unsupported hook dispatcher script type: {script}") + skills = is_ai_skills_enabled(options) + try: + _install_shared_infra( + self.project_root, + script, + invoke_separator="-" if skills else ".", + invoke_prefix=get_invocation_prefix(options.get("ai"), skills), + refresh_managed=True, + hook_dispatchers_only=True, + ) + except (OSError, ValueError) as exc: + raise ExtensionError(f"Cannot install hook dispatchers: {exc}") from exc + for phase in ("pre", "post"): + name = f"{phase}_hooks.py" if script == "py" else f"{phase}-hooks.{script if script == 'sh' else 'ps1'}" + dispatcher = self.project_root / ".specify" / "scripts" / variant / name + if dispatcher.is_symlink() or not dispatcher.is_file(): + raise ExtensionError( + f"Cannot register extension commands: missing hook dispatcher '{dispatcher}'" + ) + # Remove existing installation AFTER all validations pass so that a # validation failure doesn't leave the user with a half-uninstalled # extension (configs stranded in .backup/). diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7902ae9d70..f82b04fcff 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -42,6 +42,13 @@ "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" ) +_HOOK_INSTRUCTION_PATTERN = ( + r"(?m)^([ \t]*)(" + r"- For each executable hook, output the following[^\r\n]*" + r"|[^\r\n]*For each returned hook in order: invoke mandatory commands[^\r\n]*" + r")(\r\n|\n|$)" +) + _CORE_COMMAND_TEMPLATE_ORDER = ( "analyze", "clarify", @@ -1737,8 +1744,8 @@ def _inject_hook_command_note( ) -> str: """Insert a dot-to-hyphen note before each hook output instruction. - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. + Targets legacy hook-output instructions and scripted hook-dispatch + instructions, matching their indentation. Skips individual instructions that already have the note immediately above them. """ @@ -1770,7 +1777,7 @@ def repl(m: re.Match[str]) -> str: ) return re.sub( - r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + _HOOK_INSTRUCTION_PATTERN, repl, content, ) diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c48a4dd5f8..9221c129c5 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from ..base import MarkdownIntegration +from ..base import MarkdownIntegration, _HOOK_INSTRUCTION_PATTERN from ..manifest import IntegrationManifest @@ -140,7 +140,7 @@ def repl(m: re.Match[str]) -> str: # instruction. This also matches the shared base helper, without which # the per-instruction check above cannot line up. return re.sub( - r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + _HOOK_INSTRUCTION_PATTERN, repl, content, ) diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 2d4a6b32d9..d7120c64a1 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -1,6 +1,6 @@ """Junie integration (JetBrains).""" -from ..base import MarkdownIntegration +from ..base import MarkdownIntegration, _HOOK_INSTRUCTION_PATTERN from ..manifest import IntegrationManifest @@ -94,16 +94,15 @@ def process_template(self, *args, **kwargs): def _inject_hook_command_note(content: str) -> str: """Insert a dot-to-hyphen note before each hook output instruction. - Targets the line ``- For each executable hook, output the following`` - and inserts the note on the line before it, matching its indentation. + Targets legacy and scripted hook instructions, matching indentation. Skips if the note is already present. """ - if "replace dots" in content: - return content - def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) + previous_lines = content[:m.start()].splitlines() + if previous_lines and previous_lines[-1] == indent + _HOOK_COMMAND_NOTE.rstrip("\n"): + return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing # newline. Default to ``\n`` so the note never collapses onto @@ -119,7 +118,7 @@ def repl(m: re.Match[str]) -> str: ) return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + _HOOK_INSTRUCTION_PATTERN, repl, content, ) diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 9649fe697a..4c6072bae3 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -468,6 +468,7 @@ def install_shared_infra( invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, + hook_dispatchers_only: bool = False, ) -> bool: """Install shared scripts and templates into *project_path*. @@ -479,7 +480,8 @@ def install_shared_infra( warning — the safe-destination check refuses to follow them so writes cannot escape the project root). ``refresh_hint`` is shown after the customization warning to tell the user which flag would overwrite their - customizations. + customizations. ``hook_dispatchers_only`` updates only the selected hook + resolvers without touching templates, gitignore, or unrelated scripts. """ from .integrations.manifest import _sha256, _validate_rel_path @@ -576,6 +578,11 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: for src_path in variant_src.rglob("*"): if not src_path.is_file(): continue + if hook_dispatchers_only and src_path.name not in { + "pre-hooks.sh", "post-hooks.sh", "pre-hooks.ps1", + "post-hooks.ps1", "pre_hooks.py", "post_hooks.py", + }: + continue # Python bytecode caches are local artifacts, not # workflow scripts — never install them. if "__pycache__" in src_path.parts: @@ -635,7 +642,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: ) templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) - if templates_src.is_dir(): + if not hook_dispatchers_only and templates_src.is_dir(): dest_templates = project_path / ".specify" / "templates" if _ensure_or_bucket_dir(dest_templates): for src in templates_src.iterdir(): @@ -686,7 +693,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: # tracked in ``speckit.manifest.json`` (not the per-integration manifest) and # is therefore intentionally left in place by ``integration uninstall``. specify_dir = project_path / ".specify" - if _ensure_or_bucket_dir(specify_dir): + if not hook_dispatchers_only and _ensure_or_bucket_dir(specify_dir): gitignore_dst = specify_dir / ".gitignore" gitignore_rel = gitignore_dst.relative_to(project_path).as_posix() seen_rels.add(gitignore_rel) @@ -765,7 +772,7 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: # deletion), scoped to the selected variants, and only for *managed* copies — # a user-customized file (hash diverges), a symlink, or a recovered entry is # preserved by ``_is_managed``. - if scanned_variant_dirs: + if scanned_variant_dirs and not hook_dispatchers_only: stale_removed: list[str] = [] script_prefixes = tuple( f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 00f3743bf4..74dd5e6fee 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -297,6 +297,78 @@ def test_install_copies_command_and_scripts(self, tmp_path: Path): for rel_path in SCRIPT_TWINS.values(): assert (installed / rel_path).is_file(), f"Missing script: {rel_path}" + @pytest.mark.parametrize("variant,folder,extension", [ + ("sh", "bash", "sh"), + ("ps", "powershell", "ps1"), + ("py", "python", "py"), + ]) + def test_install_and_upgrade_restore_shared_hook_dispatchers( + self, tmp_path: Path, variant: str, folder: str, extension: str, + ): + from specify_cli.extensions import ExtensionManager + + specify = tmp_path / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": variant}), encoding="utf-8" + ) + core = tmp_path / ".github/agents/speckit.taskstoissues.agent.md" + core.parent.mkdir(parents=True) + core.write_text("core taskstoissues\n", encoding="utf-8") + scripts = specify / "scripts" / folder + pre = scripts / (f"pre_hooks.{extension}" if variant == "py" else f"pre-hooks.{extension}") + post = scripts / (f"post_hooks.{extension}" if variant == "py" else f"post-hooks.{extension}") + assert not pre.exists() and not post.exists() + + manager = ExtensionManager(tmp_path) + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION) + assert pre.is_file() and post.is_file() + command = tmp_path / ".github" / "agents" / f"{COMMAND_NAME}.agent.md" + assert f".specify/scripts/{folder}/{pre.name}" in command.read_text(encoding="utf-8") + + pre.write_text(pre.read_text(encoding="utf-8") + "\n# customized\n", encoding="utf-8") + post.unlink() + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION, force=True) + assert pre.read_text(encoding="utf-8").endswith("# customized\n") + assert post.is_file() + assert f".specify/scripts/{folder}/{post.name}" in command.read_text(encoding="utf-8") + + def test_install_rejects_symlinked_hook_dispatcher(self, tmp_path: Path): + from specify_cli.extensions import ExtensionError, ExtensionManager + + specify = tmp_path / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}), encoding="utf-8" + ) + scripts = tmp_path / ".specify/scripts/bash" + scripts.mkdir(parents=True) + outside = tmp_path / "outside.sh" + outside.write_text("keep\n", encoding="utf-8") + try: + (scripts / "pre-hooks.sh").symlink_to(outside) + except (OSError, NotImplementedError): + pytest.skip("symlinks unavailable") + manager = ExtensionManager(tmp_path) + with pytest.raises(ExtensionError, match="missing hook dispatcher"): + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION) + assert outside.read_text(encoding="utf-8") == "keep\n" + assert not manager.registry.is_installed("github") + + def test_install_rejects_unsupported_hook_runtime_before_writing(self, tmp_path: Path): + from specify_cli.extensions import ExtensionError, ExtensionManager + + specify = tmp_path / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "invalid"}), encoding="utf-8" + ) + manager = ExtensionManager(tmp_path) + with pytest.raises(ExtensionError, match="Unsupported hook dispatcher script type"): + manager.install_from_directory(EXT_DIR, INSTALL_SPECKIT_VERSION) + assert not (specify / "scripts").exists() + assert not manager.registry.is_installed("github") + def test_remove_uninstalls_cleanly(self, tmp_path: Path): from specify_cli.extensions import ExtensionManager diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 78c19eafef..6eaf75b5b6 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -265,6 +265,10 @@ def test_hook_sections_explain_dotted_command_conversion(self, tmp_path): content = specify_skill.read_text(encoding="utf-8") assert ".specify/scripts/bash/pre-hooks.sh specify" in content assert ".specify/scripts/bash/post-hooks.sh specify" in content + assert content.count("replace dots (`.`) with hyphens (`-`)") == 2 + from specify_cli.integrations.base import get_invocation_prefix + prefix = get_invocation_prefix(self.KEY, True) + assert f"`{prefix}speckit-git-commit`" in content def test_hook_note_injected_for_each_instruction_independently(self): """Existing hook notes should not suppress later missing notes.""" diff --git a/tests/integrations/test_integration_cline.py b/tests/integrations/test_integration_cline.py index 4218ac711d..2714f281f1 100644 --- a/tests/integrations/test_integration_cline.py +++ b/tests/integrations/test_integration_cline.py @@ -90,6 +90,17 @@ def test_cline_hook_instruction_injection(self): assert "replace dots (`.`) with hyphens (`-`)" in injected assert "- For each executable hook, output the following:" in injected + def test_cline_scripted_hook_instruction_injection(self): + cline = get_integration("cline") + instruction = ( + "Run `.specify/scripts/bash/pre-hooks.sh plan` from the project root. " + "For each returned hook in order: invoke mandatory commands in this agent.\n" + ) + injected = cline._inject_hook_command_note(instruction) + assert "`/speckit-git-commit`" in injected + assert injected.count("replace dots (`.`)") == 1 + assert cline._inject_hook_command_note(injected) == injected + def test_cline_hook_instruction_injection_no_trailing_newline(self): """Note must not collapse onto the instruction line when the instruction is the final line with no trailing newline. @@ -186,6 +197,8 @@ def test_setup_creates_files(self, tmp_path): specify_contents = specify_file.read_text(encoding="utf-8") assert "/speckit-plan" in specify_contents assert "/speckit.plan" not in specify_contents + assert specify_contents.count("replace dots (`.`) with hyphens (`-`)") == 2 + assert "`/speckit-git-commit`" in specify_contents def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 64a386eec6..b55c736045 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -93,6 +93,29 @@ def test_junie_hook_instruction_injection(self): assert "replace dots (`.`) with hyphens (`-`)" in injected assert "- For each executable hook, output the following:" in injected + def test_junie_scripted_hook_instruction_injection(self): + junie = get_integration("junie") + instruction = ( + "Run `.specify/scripts/bash/pre-hooks.sh plan` from the project root. " + "For each returned hook in order: invoke mandatory commands in this agent.\n" + ) + injected = junie._inject_hook_command_note(instruction) + assert "`/speckit-git-commit`" in injected + assert injected.count("replace dots (`.`)") == 1 + assert junie._inject_hook_command_note(injected) == injected + + def test_junie_scripted_hook_note_not_suppressed_by_earlier_note(self): + junie = get_integration("junie") + first = junie._inject_hook_command_note( + "- For each executable hook, output the following:\n" + ) + second = ( + "Run `.specify/scripts/bash/post-hooks.sh plan` from the project root. " + "For each returned hook in order: invoke mandatory commands in this agent.\n" + ) + injected = junie._inject_hook_command_note(first + second) + assert injected.count("replace dots (`.`)") == 2 + def test_junie_hook_instruction_injection_no_trailing_newline(self): """Note must not collapse onto the instruction line when the instruction is the final line with no trailing newline. @@ -136,6 +159,8 @@ def test_setup_creates_files(self, tmp_path): specify_contents = specify_file.read_text(encoding="utf-8") assert "/speckit-plan" in specify_contents assert "/speckit.plan" not in specify_contents + assert specify_contents.count("replace dots (`.`) with hyphens (`-`)") == 2 + assert "`/speckit-git-commit`" in specify_contents def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index c0c3af4de1..d862ea1c96 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -129,6 +129,69 @@ def test_invalid_command_name_does_not_read_config(tmp_path, phase): code, data = run_hook(tmp_path, phase, "../../bad") assert code == 1 assert "Invalid hook event" in data["error"] + assert data["event"] == f"{'before' if phase == 'pre' else 'after'}_../../bad" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_installed_mapping_metadata_does_not_hide_hooks(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({ + "installed": [{"id": "git", "version": "1.0.0"}, "agent-context"], + "hooks": {event: [{"extension": "git", "command": "speckit.git.commit"}]}, + }) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path, phase)[1] + assert data["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_documented_indented_installed_mapping(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + 'installed:\n - id: jira\n version: "1.0.0" # Pin to specific version\n' + 'hooks:\n before_plan:\n - extension: git\n command: speckit.git.commit\n' + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["command"] == "speckit.git.commit" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_malformed_installed_mapping_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + "installed:\n- id: jira\n version: [unfinished\n" + "hooks:\n before_plan: []\n" + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("missing", ["extension", "command"]) +def test_non_target_event_requires_identifiers(tmp_path, variant, phase, missing): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + field = "command: speckit.git.commit" if missing == "extension" else "extension: git" + write_config(tmp_path, ( + "hooks:\n" + f" {'after' if phase == 'pre' else 'before'}_plan:\n" + f" - {field}\n" + )) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert "needs extension and command" in data["error"] + assert data["hooks"] == [] def test_empty_condition_and_enabled_default_still_execute(tmp_path): From 990853a73703efc3fa45d11998c473b2abcadf61 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:20:35 -0500 Subject: [PATCH 08/16] Align bundled hook compatibility and native resolver parity Require the first CLI release that can install shared dispatchers; verify actual bundled updates restore missing scripts. Align YAML numeric priority and escaped string handling across Python, Bash, and PowerShell, with red-before-green regressions for canonical output, invalid inputs, and update delivery. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/extensions.md | 2 + extensions/github/README.md | 6 +- extensions/github/extension.yml | 8 +- scripts/bash/pre-hooks.sh | 122 ++++++++++++++++-- scripts/powershell/pre-hooks.ps1 | 79 ++++++++++-- scripts/python/pre_hooks.py | 2 + src/specify_cli/extensions/__init__.py | 4 +- .../test_bundled_extension_update_delivery.py | 22 +++- .../github/test_github_extension.py | 8 +- tests/test_hook_dispatch_scripts.py | 89 +++++++++++++ 10 files changed, 305 insertions(+), 37 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index c2c2739791..c5c193ebc3 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -351,6 +351,8 @@ Core commands run two hook dispatchers, one before and one after their main work All three dispatchers validate hook fields across every configured event before returning the requested event. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. +Native dispatchers also recognize the numeric YAML forms accepted by the Python resolver, including digit separators, hex/binary/octal integers, sexagesimal integers, and decimal floats with exponents. The canonical writer quotes strings that would otherwise be ambiguous to the native parsers and preserves YAML control-character escapes. A NUL character in hook configuration is rejected explicitly because Bash cannot represent it. + The native Bash and PowerShell resolvers support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported; these variants reject them explicitly rather than silently skipping hooks. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout so configurations produced by older versions remain usable (YAML comments and formatting are not retained). The Python variant accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/extensions/github/README.md b/extensions/github/README.md index a2a90fea3f..44e438f1bc 100644 --- a/extensions/github/README.md +++ b/extensions/github/README.md @@ -59,10 +59,8 @@ Unmodified managed copies are refreshed; customized scripts are preserved. ## Requirements -- Spec Kit **0.12.17 or newer**. Extension-local `scripts/...` path rewriting arrived in - 0.12.6, but auto-registered skills did not resolve `__SPECKIT_COMMAND_*__` references - until 0.12.17. Earlier releases cannot render this command correctly in every supported - layout, so `specify extension add github` refuses to install below 0.12.17. +- Spec Kit **1.1.3 or newer**. Older releases cannot resolve the hook + placeholders or install the shared pre/post dispatchers used by this command. - A Git remote pointing at GitHub. - The **GitHub MCP server** available to your coding agent, providing the `list_issues` and `issue_write` tools. diff --git a/extensions/github/extension.yml b/extensions/github/extension.yml index 158824e823..03ec75a617 100644 --- a/extensions/github/extension.yml +++ b/extensions/github/extension.yml @@ -10,11 +10,9 @@ extension: license: MIT requires: - # 0.12.17 is the first release that both rewrites extension-local - # "scripts/..." paths (#3364) and resolves __SPECKIT_COMMAND_*__ tokens in - # auto-registered skills (#3544). Earlier versions cannot render this - # command correctly in every registerable layout. - speckit_version: ">=0.12.17" + # Hook placeholders and shared dispatcher installation first ship in 1.1.3. + # Allow its development build when testing this bundled extension. + speckit_version: ">=1.1.3.dev0" tools: - name: git required: true diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 34b0668937..bab59e594a 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash hook_json_string() { - local value=$1 + local value=$1 code hex char value=${value//\\/\\\\} value=${value//\"/\\\"} value=${value//$'\n'/\\n} @@ -9,9 +9,33 @@ hook_json_string() { value=${value//$'\t'/\\t} value=${value//$'\b'/\\b} value=${value//$'\f'/\\f} + for ((code=1; code<32; code++)); do + printf -v hex '%02X' "$code" + printf -v char '%b' "\\x$hex" + value=${value//$char/\\u00$hex} + done printf '"%s"' "$value" } +hook_unicode() { + local number=$1 bytes + HOOK_UTF8="" + if (( number == 0 || number > 0x10ffff || (number >= 0xd800 && number <= 0xdfff) )); then + HOOK_ERROR="Unsupported YAML escape" + return + fi + if (( number < 0x80 )); then + printf -v bytes '\\x%02X' "$number" + elif (( number < 0x800 )); then + printf -v bytes '\\x%02X\\x%02X' "$((0xc0 | (number >> 6)))" "$((0x80 | (number & 0x3f)))" + elif (( number < 0x10000 )); then + printf -v bytes '\\x%02X\\x%02X\\x%02X' "$((0xe0 | (number >> 12)))" "$((0x80 | ((number >> 6) & 0x3f)))" "$((0x80 | (number & 0x3f)))" + else + printf -v bytes '\\x%02X\\x%02X\\x%02X\\x%02X' "$((0xf0 | (number >> 18)))" "$((0x80 | ((number >> 12) & 0x3f)))" "$((0x80 | ((number >> 6) & 0x3f)))" "$((0x80 | (number & 0x3f)))" + fi + printf -v HOOK_UTF8 '%b' "$bytes" +} + hook_error() { printf '{"event":' hook_json_string "$1" @@ -22,7 +46,7 @@ hook_error() { } hook_scalar() { - local raw=$1 inner c escaped i + local raw=$1 inner c escaped i digits count while [[ $raw == ' '* ]]; do raw=${raw# }; done while [[ $raw == *' ' ]]; do raw=${raw% }; done HOOK_QUOTED=false @@ -56,6 +80,26 @@ hook_scalar() { r) HOOK_SCALAR+=$'\r' ;; b) HOOK_SCALAR+=$'\b' ;; f) HOOK_SCALAR+=$'\f' ;; + a) HOOK_SCALAR+=$'\a' ;; + v) HOOK_SCALAR+=$'\v' ;; + e) HOOK_SCALAR+=$'\033' ;; + N) hook_unicode 0x85; HOOK_SCALAR+=$HOOK_UTF8 ;; + _) hook_unicode 0xa0; HOOK_SCALAR+=$HOOK_UTF8 ;; + L) hook_unicode 0x2028; HOOK_SCALAR+=$HOOK_UTF8 ;; + P) hook_unicode 0x2029; HOOK_SCALAR+=$HOOK_UTF8 ;; + x|u|U) + count=2 + [[ $escaped == u ]] && count=4 + [[ $escaped == U ]] && count=8 + digits=${inner:i+1:count} + if (( ${#digits} != count )) || [[ ! $digits =~ ^[[:xdigit:]]+$ ]]; then + HOOK_ERROR="Unsupported YAML escape"; return + fi + hook_unicode "$((16#$digits))" + [[ -n $HOOK_ERROR ]] && return + HOOK_SCALAR+=$HOOK_UTF8 + i=$((i+count)) + ;; '"'|'\') HOOK_SCALAR+=$escaped ;; *) HOOK_ERROR="Unsupported YAML escape"; return ;; esac @@ -142,15 +186,73 @@ hook_installed_field() { } hook_priority() { - local raw=$1 quoted=$2 whole + local raw=$1 quoted=$2 whole frac="" exponent=0 shift digits part octal=false HOOK_RANK=10 - [[ $raw =~ ^\+?[0-9]+(\.[0-9]+)?$ ]] || return - [[ $quoted == true && $raw == *.* ]] && return - whole=${raw%%.*} - whole=${whole#+} - while [[ $whole == 0* && ${#whole} -gt 1 ]]; do whole=${whole#0}; done - (( ${#whole} <= 10 )) || return - HOOK_RANK=$((10#$whole)) + [[ $raw == -* ]] && return + if [[ $quoted == false && $raw =~ ^\+?0x[0-9a-fA-F_]+$ ]]; then + digits=${raw#+} + digits=${digits:2} + digits=${digits//_/} + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 8 )) || return + HOOK_RANK=$((16#$digits)) + elif [[ $quoted == false && $raw =~ ^\+?0b[01_]+$ ]]; then + digits=${raw#+} + digits=${digits:2} + digits=${digits//_/} + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 31 )) || return + HOOK_RANK=$((2#$digits)) + elif [[ $quoted == false && $raw =~ ^\+?[1-9][0-9_]*(:[0-5]?[0-9])+$ ]]; then + digits=${raw#+} + digits=${digits//_/} + HOOK_RANK=0 + while [[ $digits == *:* ]]; do + part=${digits%%:*} + (( ${#part} <= 10 )) || { HOOK_RANK=10; return; } + HOOK_RANK=$((HOOK_RANK * 60 + 10#$part)) + (( HOOK_RANK <= 2147483647 )) || { HOOK_RANK=10; return; } + digits=${digits#*:} + done + HOOK_RANK=$((HOOK_RANK * 60 + 10#$digits)) + else + if [[ $raw =~ ^\+?[0-9][0-9_]*$ ]]; then + digits=${raw#+} + [[ $quoted == false && $digits =~ ^0[0-7_]+$ ]] && octal=true + digits=${digits//_/} + if [[ $octal == true ]]; then + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 11 )) || return + HOOK_RANK=$((8#$digits)) + (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 + return + fi + elif [[ $quoted == false && $raw =~ ^[+]?[0-9][0-9_]*\.[0-9_]+([eE][+-][0-9]+)?$ ]]; then + digits=${raw#+} + digits=${digits//_/} + if [[ $digits == *[eE]* ]]; then + exponent=${digits##*[eE]} + (( ${#exponent} <= 4 )) || return + digits=${digits%[eE]*} + fi + whole=${digits%%.*} + frac=${digits#*.} + shift=$((exponent - ${#frac})) + digits=$whole$frac + if (( shift >= 0 )); then + (( shift <= 10 )) || return + while (( shift > 0 )); do digits+=0; shift=$((shift-1)); done + else + (( ${#digits} + shift > 0 )) || return + digits=${digits:0:${#digits}+shift} + fi + else + return + fi + while [[ $digits == 0* && ${#digits} -gt 1 ]]; do digits=${digits#0}; done + (( ${#digits} <= 10 )) || return + HOOK_RANK=$((10#$digits)) + fi (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 } diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index c7b2289fec..a48618dab8 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -11,9 +11,49 @@ function Convert-HookScalar { } if ($raw.StartsWith('"')) { if ($raw.Length -lt 2 -or -not $raw.EndsWith('"')) { throw "Unsupported YAML scalar" } - try { $value = ConvertFrom-Json -InputObject $raw -ErrorAction Stop } + $json = [Text.StringBuilder]::new() + for ($i = 0; $i -lt $raw.Length; $i++) { + if ($raw[$i] -ne '\') { + $null = $json.Append($raw[$i]) + continue + } + $i++ + if ($i -ge $raw.Length) { throw "Unsupported YAML escape" } + $escaped = [string]$raw[$i] + if ($escaped -ceq 'x' -or $escaped -ceq 'U') { + $count = if ($escaped -ceq 'x') { 2 } else { 8 } + if ($i + $count -ge $raw.Length) { throw "Unsupported YAML escape" } + $hex = $raw.Substring($i + 1, $count) + if ($hex -notmatch '^[0-9a-fA-F]+$') { throw "Unsupported YAML escape" } + $number = [Convert]::ToInt32($hex, 16) + if ($number -eq 0 -or $number -gt 0x10ffff -or + ($number -ge 0xd800 -and $number -le 0xdfff)) { + throw "Unsupported YAML escape" + } + if ($escaped -ceq 'x') { + $null = $json.Append('\u00').Append($hex) + } else { + $null = $json.Append([char]::ConvertFromUtf32($number)) + } + $i += $count + continue + } + $replacement = switch -CaseSensitive ($escaped) { + 'a' { '\u0007' } + 'v' { '\u000B' } + 'e' { '\u001B' } + 'N' { '\u0085' } + '_' { '\u00A0' } + 'L' { '\u2028' } + 'P' { '\u2029' } + default { '\' + $escaped } + } + $null = $json.Append($replacement) + } + try { $value = ConvertFrom-Json -InputObject $json.ToString() -ErrorAction Stop } catch { throw "Unsupported YAML escape: $($_.Exception.Message)" } if ($value -isnot [string]) { throw "Unsupported YAML scalar" } + if ($value.Contains([char]0)) { throw "Unsupported YAML NUL character" } return @{ Value = $value; Quoted = $true } } $raw = ($raw -replace '\s+#.*$', '').TrimEnd() @@ -66,13 +106,36 @@ function Test-HookTypedScalar { function Get-HookPriority { param([string]$Raw, [bool]$Quoted) - if ($Raw -notmatch '^[+-]?[0-9]+(\.[0-9]+)?$') { return 10 } - if ($Quoted -and $Raw.Contains('.')) { return 10 } - $number = 0.0 - if (-not [double]::TryParse($Raw, [Globalization.NumberStyles]::Float, - [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) { return 10 } - if ($number -lt 1 -or [Math]::Truncate($number) -gt [int]::MaxValue) { return 10 } - return [int][Math]::Truncate($number) + $digits = $Raw.Replace('_', '') + if ($Raw -match '^-') { return 10 } + try { + if (-not $Quoted -and $Raw -cmatch '^\+?0x[0-9a-fA-F_]+$') { + $number = [Convert]::ToInt64($digits.TrimStart('+').Substring(2), 16) + } elseif (-not $Quoted -and $Raw -cmatch '^\+?0b[01_]+$') { + $number = [Convert]::ToInt64($digits.TrimStart('+').Substring(2), 2) + } elseif (-not $Quoted -and $Raw -match '^\+?0[0-7_]+$') { + $number = [Convert]::ToInt64($digits.TrimStart('+'), 8) + } elseif (-not $Quoted -and $Raw -match '^\+?[1-9][0-9_]*(:[0-5]?[0-9])+$') { + $number = [long]0 + foreach ($part in ($digits.TrimStart('+') -split ':')) { + $number = $number * 60 + [long]::Parse($part) + if ($number -gt [int]::MaxValue) { return 10 } + } + } elseif ($Raw -match '^\+?[0-9][0-9_]*$') { + $number = [long]::Parse($digits.TrimStart('+'), [Globalization.CultureInfo]::InvariantCulture) + } elseif (-not $Quoted -and $Raw -match '^\+?[0-9][0-9_]*\.[0-9_]+([eE][+-][0-9]+)?$') { + $number = [double]::Parse( + $digits, [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture + ) + } else { + return 10 + } + if ($number -lt 1 -or $number -ge ([double][int]::MaxValue + 1)) { return 10 } + return [int][Math]::Truncate($number) + } catch [FormatException], [OverflowException] { + return 10 + } } function Resolve-HookConfig { diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 8eb657b67a..37594ca994 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -41,6 +41,8 @@ def resolve(event: str, project_root: Path) -> dict: label = f"hooks.{hook_event}[{index}]" if not isinstance(entry, dict): raise ValueError(f"Invalid .specify/extensions.yml: {label} must be a mapping") + if any(isinstance(value, str) and "\0" in value for value in entry.values()): + raise ValueError(f"Invalid .specify/extensions.yml: {label} contains a NUL character") for field in ("enabled", "optional"): if field in entry and not isinstance(entry[field], bool): raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a boolean") diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 16e5dfcbe6..bffdbc7c72 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5760,7 +5760,9 @@ class CanonicalHookDumper(yaml.SafeDumper): pass def represent_string(dumper: yaml.SafeDumper, value: str): - style = '"' if "\n" in value or "\r" in value else None + if "\0" in value: + raise ValueError("Cannot serialize NUL in native extension hook configuration") + style = '"' if any(char in value for char in "\n\r\x85\u2028\u2029{}[]") else None return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) CanonicalHookDumper.add_representer(str, represent_string) diff --git a/tests/contract/test_bundled_extension_update_delivery.py b/tests/contract/test_bundled_extension_update_delivery.py index f85e745829..e900bdb256 100644 --- a/tests/contract/test_bundled_extension_update_delivery.py +++ b/tests/contract/test_bundled_extension_update_delivery.py @@ -18,6 +18,7 @@ import json import shutil +import tomllib from pathlib import Path from unittest.mock import patch @@ -40,6 +41,10 @@ def _catalog_entries() -> dict[str, dict]: catalog = json.loads((EXTENSIONS_ROOT / "catalog.json").read_text(encoding="utf-8")) return catalog["extensions"] +def _checkout_version() -> str: + """Use this checkout's version, not possibly stale editable-install metadata.""" + return tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + def _bundled_ids() -> list[str]: return sorted( @@ -72,7 +77,7 @@ def _stale_copy(tmp_path: Path, ext_id: str, version: str) -> Path: def _run_update(project_dir: Path, ext_id: str): from specify_cli import app - from specify_cli.extensions import ExtensionCatalog + from specify_cli.extensions import ExtensionCatalog, _commands catalog_info = dict(_catalog_entries()[ext_id]) catalog_info.setdefault("_install_allowed", True) @@ -80,6 +85,7 @@ def _run_update(project_dir: Path, ext_id: str): # catalog is replaced, and downloading must never be attempted for a # bundled extension. with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(_commands, "get_speckit_version", return_value=_checkout_version()), \ patch.object(ExtensionCatalog, "get_extension_info", return_value=catalog_info), \ patch.object( ExtensionCatalog, @@ -109,7 +115,6 @@ def test_drifted_extensions_are_covered(): @pytest.mark.parametrize("ext_id", _bundled_ids()) def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext_id: str): - from specify_cli._assets import get_speckit_version from specify_cli.extensions import ExtensionManager catalog_version = Version(_catalog_entries()[ext_id]["version"]) @@ -124,8 +129,13 @@ def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext project_dir = _make_project(tmp_path) stale_source = _stale_copy(tmp_path, ext_id, PRE_BUMP_VERSION) manager = ExtensionManager(project_dir) - manager.install_from_directory(stale_source, get_speckit_version()) + manager.install_from_directory(stale_source, _checkout_version()) assert manager.registry.get(ext_id)["version"] == PRE_BUMP_VERSION + if ext_id == "github": + for phase in ("pre", "post"): + dispatcher = project_dir / ".specify/scripts/bash" / f"{phase}-hooks.sh" + assert dispatcher.is_file() + dispatcher.unlink() result = _run_update(project_dir, ext_id) @@ -134,19 +144,21 @@ def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext assert f"Updated to v{catalog_version}" in flat, flat assert "Up to date" not in flat, flat assert ExtensionManager(project_dir).registry.get(ext_id)["version"] == str(catalog_version) + if ext_id == "github": + for phase in ("pre", "post"): + assert (project_dir / ".specify/scripts/bash" / f"{phase}-hooks.sh").is_file() @pytest.mark.parametrize("ext_id", _bundled_ids()) def test_current_bundled_install_is_up_to_date(tmp_path: Path, ext_id: str): """The bumped catalog must not re-offer an update to an install that already carries the bundled version, or every fresh install would loop.""" - from specify_cli._assets import get_speckit_version from specify_cli.extensions import ExtensionManager catalog_version = _catalog_entries()[ext_id]["version"] project_dir = _make_project(tmp_path) ExtensionManager(project_dir).install_from_directory( - EXTENSIONS_ROOT / ext_id, get_speckit_version() + EXTENSIONS_ROOT / ext_id, _checkout_version() ) result = _run_update(project_dir, ext_id) diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 74dd5e6fee..5f7a925d23 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -40,10 +40,9 @@ # A released Spec Kit version that satisfies the manifest floor. Installs are # refused below it, so this cannot be an arbitrary synthetic value. -INSTALL_SPECKIT_VERSION = "1.0.12" -# The floor exists because auto-registered skills did not resolve command -# reference tokens until 0.12.17 (#3544). -MIN_SPECKIT_VERSION = "0.12.17" +INSTALL_SPECKIT_VERSION = "1.1.3.dev0" +# Hook placeholders and dispatcher installation first ship with this CLI release. +MIN_SPECKIT_VERSION = "1.1.3.dev0" COMMAND_NAME = "speckit.github.taskstoissues" COMMAND_FILE = EXT_DIR / "commands" / f"{COMMAND_NAME}.md" @@ -201,6 +200,7 @@ def test_version_floor_covers_all_rendering_prerequisites(self): spec = SpecifierSet(floor) assert "0.12.5" not in spec, floor assert "0.12.16" not in spec, floor + assert "1.1.2" not in spec, floor assert MIN_SPECKIT_VERSION in spec, floor # And the version the install tests use must satisfy it. assert INSTALL_SPECKIT_VERSION in spec, floor diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index d862ea1c96..71ffaa4ef0 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -541,3 +541,92 @@ def test_priority_range_and_coercion_parity(tmp_path, variant, priority, expecte ["default", "high"] if expected > 10 else ["high", "default"] ) assert next(hook for hook in data["hooks"] if hook["extension"] == "high")["priority"] == expected + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("raw,expected", [ + ("2_0", 20), ("0x14", 20), ("2.0e+1", 20), + ("0b10100", 20), ("024", 20), ("1:02", 62), + ("+0x14", 20), ("+024", 20), ("1:2", 62), + ("+1:2", 62), ("01:02", 10), ("0:2", 10), + ("0X14", 10), ("0B10100", 10), + ("'0x14'", 10), ("'2_0'", 20), + ("2.0e+99", 10), ("0xGG", 10), +]) +def test_yaml_numeric_priority_parity(tmp_path, variant, raw, expected): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + "hooks:\n before_plan:\n" + " - extension: high\n command: speckit.high.run\n" + f" priority: {raw}\n" + " - extension: default\n command: speckit.default.run\n" + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert next(hook for hook in data["hooks"] if hook["extension"] == "high")["priority"] == expected + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("value", [ + "\a", "\v", "\x01", "\x1b", "\x85", "\xa0", "\u2028", "\u2029", + "\ufeff", "😀", "😀\nmore", +]) +def test_canonical_writer_control_escape_parity(tmp_path, variant, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": f"before{value}after"}, + ]}}) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"][0]["prompt"] == f"before{value}after" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_nul_in_existing_hook_configuration_is_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + 'hooks:\n before_plan:\n' + ' - extension: git\n command: speckit.git.commit\n' + ' prompt: "before\\0after"\n' + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "NUL" in data["error"] or "Unsupported YAML escape" in data["error"] + + +def test_canonical_writer_rejects_nul_without_touching_config(tmp_path): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + config = write_config(tmp_path, "hooks: {}\n") + with pytest.raises(ValueError, match="NUL"): + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": "before\0after"} + ]}}) + assert config.read_text(encoding="utf-8") == "hooks: {}\n" + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("value", [ + "JSvjEuqRsqs$tn]X", "N1Y'dombB*Afj9e;SO}^0^S'", "prefix{suffix", +]) +def test_canonical_writer_quotes_native_reserved_scalar_chars(tmp_path, variant, value): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "prompt": value}, + ]}}) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"][0]["prompt"] == value From dd56df341038d53e00784273a85af253b42e1323 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:40:40 -0500 Subject: [PATCH 09/16] Fix Windows hook parity test environment Use the platform-selected dispatcher in bundled update regressions and decode native resolver output as UTF-8 independent of the Windows locale. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/contract/test_bundled_extension_update_delivery.py | 6 ++++-- tests/test_hook_dispatch_scripts.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/contract/test_bundled_extension_update_delivery.py b/tests/contract/test_bundled_extension_update_delivery.py index e900bdb256..b7d4babceb 100644 --- a/tests/contract/test_bundled_extension_update_delivery.py +++ b/tests/contract/test_bundled_extension_update_delivery.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import os import shutil import tomllib from pathlib import Path @@ -132,8 +133,9 @@ def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext manager.install_from_directory(stale_source, _checkout_version()) assert manager.registry.get(ext_id)["version"] == PRE_BUMP_VERSION if ext_id == "github": + script_dir, suffix = ("powershell", "ps1") if os.name == "nt" else ("bash", "sh") for phase in ("pre", "post"): - dispatcher = project_dir / ".specify/scripts/bash" / f"{phase}-hooks.sh" + dispatcher = project_dir / ".specify" / "scripts" / script_dir / f"{phase}-hooks.{suffix}" assert dispatcher.is_file() dispatcher.unlink() @@ -146,7 +148,7 @@ def test_stale_bundled_install_is_updated_to_catalog_version(tmp_path: Path, ext assert ExtensionManager(project_dir).registry.get(ext_id)["version"] == str(catalog_version) if ext_id == "github": for phase in ("pre", "post"): - assert (project_dir / ".specify/scripts/bash" / f"{phase}-hooks.sh").is_file() + assert (project_dir / ".specify" / "scripts" / script_dir / f"{phase}-hooks.{suffix}").is_file() @pytest.mark.parametrize("ext_id", _bundled_ids()) diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 71ffaa4ef0..cf6f0b10cb 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -41,7 +41,10 @@ def run_hook(tmp_path, phase="pre", name="plan", variant="py", extra_env=None): env["SPECKIT_PYTHON_EXECUTABLE"] = sys.executable if extra_env: env.update(extra_env) - result = subprocess.run(scripts[variant] + [name], cwd=tmp_path, env=env, capture_output=True, text=True) + result = subprocess.run( + scripts[variant] + [name], cwd=tmp_path, env=env, + capture_output=True, text=True, encoding="utf-8", check=False, + ) return result.returncode, json.loads(result.stdout) From 90d499b0ddbec00613d922d0a0a6aa06a6ef1aac Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:06:44 -0500 Subject: [PATCH 10/16] Materialize hook dispatch JSON during extension changes Keep native Bash, PowerShell, and Python entrypoints while moving YAML validation and ordering to CLI writes. Preserve legacy configurations until refreshed, reject stale projections, and cover review parity and lifecycle rollback. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 13 +- docs/reference/extensions.md | 8 +- scripts/bash/pre-hooks.sh | 28 +++- scripts/powershell/pre-hooks.ps1 | 32 +++- scripts/python/pre_hooks.py | 24 +++ src/specify_cli/extensions/__init__.py | 77 ++++++++-- .../github/test_github_extension.py | 2 + .../extensions/test_command_add.py | 15 ++ tests/test_extensions.py | 2 +- tests/test_hook_dispatch_scripts.py | 139 +++++++++++++++++- 10 files changed, 315 insertions(+), 25 deletions(-) diff --git a/design/integration.md b/design/integration.md index 1d9c3ce63c..deba8f9288 100644 --- a/design/integration.md +++ b/design/integration.md @@ -81,11 +81,14 @@ placeholder; `--skills` emits skills into that same directory. Core command templates also use `{PRE_HOOK_SCRIPT}` and `{POST_HOOK_SCRIPT}`. Rendering selects the same `sh`, `ps`, or `py` variant as the command's main script; the shared-infrastructure installer includes the selected native -pre/post entry points. Each variant resolves hooks in its own runtime, without -depending on another variant. The resolver returns ordered hook metadata as -JSON; agent commands themselves remain the responsibility of the agent. The -command/skill registrar resolves these placeholders too when a preset wraps -a core command. +pre/post entry points. The CLI validates hook configuration on write and +materializes ordered per-event JSON with a snapshot of `.specify/extensions.yml`. +Each variant reads the projection in its own runtime, without depending on +another variant or on runtime PyYAML; it rejects stale or missing projections. +The legacy YAML resolver remains for projects not yet refreshed. The resolver +returns ordered hook metadata as JSON; agent commands themselves remain the +responsibility of the agent. The command/skill registrar resolves these +placeholders too when a preset wraps a core command. ## External adapter package contract diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index c5c193ebc3..81cc677ac4 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -347,13 +347,15 @@ Hook event names identify when a hook is invoked. They generally use `before_ --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. + +The CLI validates hook fields across every configured event when materializing the projection. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. Native dispatchers also recognize the numeric YAML forms accepted by the Python resolver, including digit separators, hex/binary/octal integers, sexagesimal integers, and decimal floats with exponents. The canonical writer quotes strings that would otherwise be ambiguous to the native parsers and preserves YAML control-character escapes. A NUL character in hook configuration is rejected explicitly because Bash cannot represent it. -The native Bash and PowerShell resolvers support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported; these variants reject them explicitly rather than silently skipping hooks. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout so configurations produced by older versions remain usable (YAML comments and formatting are not retained). The Python variant accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. +Legacy Bash and PowerShell resolvers (before projection generation) support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported by the legacy path. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout and generates its projection (YAML comments and formatting are not retained). The Python legacy path accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index bab59e594a..7cc7ff01cd 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -108,7 +108,7 @@ hook_scalar() { fi raw=${raw%%' #'*} while [[ $raw == *' ' ]]; do raw=${raw% }; done - if [[ $raw == null || $raw == '~' ]]; then raw=""; fi + if [[ $raw == null || $raw == Null || $raw == NULL || $raw == '~' ]]; then raw=""; fi case $raw in *'['*|*']'*|*'{'*|*'}'*|*': '*|'&'*|'*'*|'!'*|'|'*|'>'*) HOOK_ERROR="Unsupported YAML scalar"; return ;; @@ -227,7 +227,7 @@ hook_priority() { (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 return fi - elif [[ $quoted == false && $raw =~ ^[+]?[0-9][0-9_]*\.[0-9_]+([eE][+-][0-9]+)?$ ]]; then + elif [[ $quoted == false && $raw =~ ^[+]?[0-9][0-9_]*\.[0-9_]*([eE][+-][0-9]+)?$ ]]; then digits=${raw#+} digits=${digits//_/} if [[ $digits == *[eE]* ]]; then @@ -268,6 +268,30 @@ resolve_hooks() { return 1 fi config=.specify/extensions.yml + if [[ -f $config && ! -d .specify/hook-dispatch ]]; then + IFS= read -r line < "$config" + if [[ $line == '# Hook projection: .specify/hook-dispatch' ]]; then + hook_error "$event" "Hook projection is missing; reinstall the extension or refresh the project" + return 1 + fi + fi + if [[ -d .specify/hook-dispatch ]]; then + if [[ ! -f $config || ! -f .specify/hook-dispatch/source.yml || + ! -f .specify/hook-dispatch/events.txt ]] || + ! cmp -s -- "$config" .specify/hook-dispatch/source.yml; then + hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" + return 1 + fi + if [[ -f .specify/hook-dispatch/$event.json ]]; then + cat -- ".specify/hook-dispatch/$event.json" + elif grep -Fxq -- "$event" .specify/hook-dispatch/events.txt; then + hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" + return 1 + else + printf '{"event":"%s","hooks":[]}\n' "$event" + fi + return + fi if [[ ! -e $config ]]; then printf '{"event":"%s","hooks":[]}\n' "$event" return diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index a48618dab8..9f3beae806 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -57,7 +57,7 @@ function Convert-HookScalar { return @{ Value = $value; Quoted = $true } } $raw = ($raw -replace '\s+#.*$', '').TrimEnd() - if ($raw -match '^(~|null)$') { $raw = '' } + if ($raw -cmatch '^(~|null|Null|NULL)$') { $raw = '' } if ($raw -match '[\[\]{}]|: |^[&*!|>]') { throw "Unsupported YAML scalar" } return @{ Value = $raw; Quoted = $false } } @@ -123,7 +123,7 @@ function Get-HookPriority { } } elseif ($Raw -match '^\+?[0-9][0-9_]*$') { $number = [long]::Parse($digits.TrimStart('+'), [Globalization.CultureInfo]::InvariantCulture) - } elseif (-not $Quoted -and $Raw -match '^\+?[0-9][0-9_]*\.[0-9_]+([eE][+-][0-9]+)?$') { + } elseif (-not $Quoted -and $Raw -match '^\+?[0-9][0-9_]*\.[0-9_]*([eE][+-][0-9]+)?$') { $number = [double]::Parse( $digits, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture @@ -263,6 +263,34 @@ function Invoke-HookResolver { throw "Invalid hook event: $event" } $config = Join-Path (Get-Location) '.specify/extensions.yml' + $cache = Join-Path (Get-Location) '.specify/hook-dispatch' + if ((Test-Path -LiteralPath $config -PathType Leaf) -and + -not (Test-Path -LiteralPath $cache -PathType Container)) { + $firstLine = Get-Content -LiteralPath $config -TotalCount 1 -Encoding UTF8 -ErrorAction Stop + if ($firstLine -ceq '# Hook projection: .specify/hook-dispatch') { + throw "Hook projection is missing; reinstall the extension or refresh the project" + } + } + if (Test-Path -LiteralPath $cache -PathType Container) { + $snapshot = Join-Path $cache 'source.yml' + $events = Join-Path $cache 'events.txt' + if (-not (Test-Path -LiteralPath $config -PathType Leaf) -or + -not (Test-Path -LiteralPath $snapshot -PathType Leaf) -or + -not (Test-Path -LiteralPath $events -PathType Leaf) -or + [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) -cne + [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { + throw "Hook projection is stale; reinstall the extension or refresh the project" + } + $projected = Join-Path $cache "$event.json" + if (Test-Path -LiteralPath $projected -PathType Leaf) { + [Console]::WriteLine([IO.File]::ReadAllText($projected, [Text.Encoding]::UTF8).TrimEnd("`r", "`n")) + } elseif (@([IO.File]::ReadAllLines($events, [Text.Encoding]::UTF8)) -ccontains $event) { + throw "Hook projection is incomplete; reinstall the extension or refresh the project" + } else { + @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress + } + exit 0 + } $hooks = @() if (Test-Path -LiteralPath $config) { $hooks = @(Resolve-HookConfig $event $config) diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 37594ca994..9b8611c416 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -18,6 +18,30 @@ def resolve(event: str, project_root: Path) -> dict: raise ValueError(f"Invalid hook event: {event}") config_file = project_root / ".specify" / "extensions.yml" + cache = project_root / ".specify" / "hook-dispatch" + if cache.is_dir(): + try: + if config_file.read_bytes() != (cache / "source.yml").read_bytes(): + raise ValueError("Hook projection is stale; reinstall the extension or refresh the project") + events = (cache / "events.txt").read_text(encoding="utf-8").splitlines() + projection = cache / f"{event}.json" + if not projection.exists(): + if event in events: + raise ValueError("Hook projection is incomplete; reinstall the extension or refresh the project") + return {"event": event, "hooks": []} + result = json.loads(projection.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"Could not read hook projection: {exc}") from exc + if not isinstance(result, dict) or result.get("event") != event or not isinstance(result.get("hooks"), list): + raise ValueError("Invalid hook projection; reinstall the extension or refresh the project") + return result + if config_file.exists(): + try: + with config_file.open(encoding="utf-8") as handle: + if handle.readline().strip() == "# Hook projection: .specify/hook-dispatch": + raise ValueError("Hook projection is missing; reinstall the extension or refresh the project") + except (OSError, UnicodeError) as exc: + raise ValueError(f"Could not read .specify/extensions.yml: {exc}") from exc if not config_file.exists(): return {"event": event, "hooks": []} diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 066785044c..bbe5ddf3a8 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5811,20 +5811,77 @@ def represent_string(dumper: yaml.SafeDumper, value: str): return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) CanonicalHookDumper.add_representer(str, represent_string) - from ..integrations._file_changes import write_text + from ..integrations._file_changes import changing_file, unlink, write_bytes, write_text - self.config_file.parent.mkdir(parents=True, exist_ok=True) - write_text( - self.config_file, - yaml.dump( - config, default_flow_style=False, sort_keys=False, - allow_unicode=True, width=sys.maxsize, Dumper=CanonicalHookDumper, - ), - encoding="utf-8", + hooks = config.get("hooks", {}) + if not isinstance(hooks, dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + projected: dict[str, str] = {} + for event, entries in hooks.items(): + if not isinstance(event, str) or not re.fullmatch(r"(before|after)_[a-z][a-z0-9_]*", event): + raise ValueError(f"Invalid hook event: {event}") + if not isinstance(entries, list): + raise ValueError(f"Invalid .specify/extensions.yml: hooks.{event} must be a list") + selected = [] + for index, entry in enumerate(entries): + label = f"hooks.{event}[{index}]" + if not isinstance(entry, dict): + raise ValueError(f"Invalid .specify/extensions.yml: {label} must be a mapping") + if any(isinstance(value, str) and "\0" in value for value in entry.values()): + raise ValueError(f"Invalid .specify/extensions.yml: {label} contains a NUL character") + for field in ("enabled", "optional"): + if field in entry and not isinstance(entry[field], bool): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a boolean") + for field in ("condition", "description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValueError(f"Invalid .specify/extensions.yml: {label}.{field} must be a string or null") + for field in ("extension", "command"): + if not isinstance(entry.get(field), str) or not entry[field]: + raise ValueError(f"Invalid .specify/extensions.yml: {label} needs extension and command") + if entry.get("enabled", True) is False or entry.get("condition"): + continue + priority = entry.get("priority", DEFAULT_HOOK_PRIORITY) + try: + priority = int(priority) if not isinstance(priority, bool) else DEFAULT_HOOK_PRIORITY + except (TypeError, ValueError, OverflowError): + priority = DEFAULT_HOOK_PRIORITY + if not 1 <= priority <= 2147483647: + priority = DEFAULT_HOOK_PRIORITY + selected.append({ + "extension": entry["extension"], "command": entry["command"], + "optional": entry.get("optional", True), + "description": entry.get("description") or "", + "prompt": entry.get("prompt") or "", "priority": priority, + }) + selected.sort(key=lambda hook: hook["priority"]) + projected[event] = json.dumps({"event": event, "hooks": selected}, ensure_ascii=True) + "\n" + + rendered = "# Hook projection: .specify/hook-dispatch\n" + yaml.dump( + config, default_flow_style=False, sort_keys=False, + allow_unicode=True, width=sys.maxsize, Dumper=CanonicalHookDumper, ) + cache = self.config_file.parent / "hook-dispatch" + if cache.is_symlink(): + raise ValueError("Refusing to write symlinked hook dispatch cache") + if not cache.exists(): + with changing_file(cache): + cache.mkdir(parents=True) + for path in cache.iterdir(): + if path.is_symlink(): + raise ValueError("Refusing to write symlinked hook dispatch cache entry") + snapshot = cache / "source.yml" + unlink(snapshot, missing_ok=True) + for path in cache.glob("*.json"): + unlink(path) + self.config_file.parent.mkdir(parents=True, exist_ok=True) + for event, payload in projected.items(): + write_text(cache / f"{event}.json", payload, encoding="utf-8") + write_bytes(cache / "events.txt", "".join(f"{event}\n" for event in projected).encode("utf-8")) + write_text(self.config_file, rendered, encoding="utf-8") + write_bytes(snapshot, self.config_file.read_bytes()) def migrate_project_config(self) -> None: - """Rewrite existing hook configuration for the native script resolvers.""" + """Validate and project existing hook configuration during project refresh.""" if self.config_file.parent.is_symlink() or self.config_file.is_symlink(): raise ValueError("Refusing to migrate symlinked .specify/extensions.yml") if not self.config_file.exists(): diff --git a/tests/extensions/github/test_github_extension.py b/tests/extensions/github/test_github_extension.py index 5f7a925d23..e3e57e3957 100644 --- a/tests/extensions/github/test_github_extension.py +++ b/tests/extensions/github/test_github_extension.py @@ -193,6 +193,8 @@ def test_version_floor_covers_all_rendering_prerequisites(self): but auto-registered skills did not resolve ``__SPECKIT_COMMAND_*__`` tokens until 0.12.17 (#3544). Versions through 0.12.16 therefore accept the extension but leak a raw command token in skills mode. + Hook placeholders and dispatcher installation require 1.1.3 or later; + 1.1.2 still accepts the extension but cannot run its hook commands. """ from packaging.specifiers import SpecifierSet diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index cddcd6bf21..83745f4d4e 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -38,6 +38,21 @@ class TestExtensionAddCLI: """CLI tests for ``specify extension add``.""" + def test_add_git_materializes_hook_projection(self, project_dir): + git_source = Path(__file__).parents[3] / "extensions" / "git" + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(git_source), "--dev"], catch_exceptions=True + ) + assert result.exit_code == 0, result.output + cache = project_dir / ".specify" / "hook-dispatch" + assert (cache / "source.yml").read_bytes() == ( + project_dir / ".specify" / "extensions.yml" + ).read_bytes() + projected = json.loads((cache / "before_plan.json").read_text(encoding="utf-8")) + assert projected["event"] == "before_plan" + assert [hook["extension"] for hook in projected["hooks"]] == ["git"] + def test_add_dev_links_copilot_agent_when_supported( self, extension_dir, project_dir, temp_dir ): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b512742389..dbdc7a4165 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -8165,7 +8165,7 @@ def test_register_hooks_empty_hooks_purge_survives_corrupt_entry(self, project_d ) config = executor.get_project_config() config["hooks"]["after_tasks"].append("corrupt-non-dict-entry") - executor.save_project_config(config) + executor.config_file.write_text(yaml.safe_dump(config), encoding="utf-8") executor.register_hooks(_StubManifest("ext-a", {})) diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index cf6f0b10cb..f67dd41742 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -215,6 +215,126 @@ def test_missing_yaml_dependency_is_reported_not_silently_skipped(tmp_path): } +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_materialized_projection_resolves_without_yaml_or_native_parsing(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "late", "command": "speckit.late.run", "priority": 20, "prompt": "😀"}, + {"extension": "first", "command": "speckit.first.run", "priority": 5}, + ]}}) + projected = tmp_path / ".specify/hook-dispatch/before_plan.json" + assert projected.is_file() + assert (tmp_path / ".specify/hook-dispatch/events.txt").read_bytes() == b"before_plan\n" + assert [h["extension"] for h in json.loads(projected.read_text(encoding="utf-8"))["hooks"]] == [ + "first", "late", + ] + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][1]["prompt"] == "😀" + assert data == run_hook(tmp_path)[1] + if variant == "py": + result = subprocess.run( + [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], + cwd=tmp_path, capture_output=True, text=True, encoding="utf-8", check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == data + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_rejects_stale_yaml_and_recovers_after_refresh(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + config = tmp_path / ".specify/extensions.yml" + config.write_text( + "hooks:\n before_plan:\n - extension: new\n command: speckit.new.run\n", + encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "stale" in data["error"] + executor.migrate_project_config() + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert [hook["extension"] for hook in data["hooks"]] == ["new"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_missing_entry_fails_instead_of_skipping_hook(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + (tmp_path / ".specify/hook-dispatch/before_plan.json").unlink() + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "incomplete" in data["error"] + assert run_hook(tmp_path, name="tasks", variant=variant)[1]["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_missing_cache_fails_instead_of_using_legacy_yaml(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + (tmp_path / ".specify/hook-dispatch").rename(tmp_path / "removed-cache") + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "missing" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_removes_old_event_after_config_save(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + executor.save_project_config({"hooks": {}}) + assert not (tmp_path / ".specify/hook-dispatch/before_plan.json").exists() + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"] == [] + + +def test_invalid_projected_hook_cannot_replace_existing_projection(tmp_path): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + before = (tmp_path / ".specify/extensions.yml").read_bytes() + with pytest.raises(ValueError, match="condition must be a string or null"): + executor.save_project_config({"hooks": {"after_plan": [ + {"extension": "other", "command": "speckit.other.run", "condition": False}, + ]}}) + assert (tmp_path / ".specify/extensions.yml").read_bytes() == before + assert run_hook(tmp_path)[1]["hooks"][0]["extension"] == "git" + + def test_no_yaml_dependency_needed_for_absent_config(tmp_path): result = subprocess.run( [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], @@ -421,16 +541,30 @@ def test_quoted_condition_remains_a_string(tmp_path, variant): @pytest.mark.parametrize("variant", ["sh", "ps", "py"]) -def test_null_condition_allows_hook(tmp_path, variant): +@pytest.mark.parametrize("value", ["null", "Null", "NULL", "~"]) +def test_null_condition_allows_hook(tmp_path, variant, value): if variant == "ps" and not shutil.which("pwsh"): pytest.skip("PowerShell not installed") write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" - " command: speckit.git.commit\n condition: null\n") + f" command: speckit.git.commit\n condition: {value}\n") code, data = run_hook(tmp_path, variant=variant) assert code == 0 + assert data == run_hook(tmp_path)[1] assert len(data["hooks"]) == 1 +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_mixed_case_null_condition_remains_a_string(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks:\n before_plan:\n - extension: git\n" + " command: speckit.git.commit\n condition: nUll\n") + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"] == [] + + @pytest.mark.parametrize("variant", ["sh", "ps"]) @pytest.mark.parametrize("phase", ["pre", "post"]) def test_native_resolvers_do_not_invoke_python(tmp_path, variant, phase): @@ -549,6 +683,7 @@ def test_priority_range_and_coercion_parity(tmp_path, variant, priority, expecte @pytest.mark.parametrize("variant", ["sh", "ps", "py"]) @pytest.mark.parametrize("raw,expected", [ ("2_0", 20), ("0x14", 20), ("2.0e+1", 20), + ("2.", 2), ("2.e+1", 20), ("+2.", 2), ("0b10100", 20), ("024", 20), ("1:02", 62), ("+0x14", 20), ("+024", 20), ("1:2", 62), ("+1:2", 62), ("01:02", 10), ("0:2", 10), From bd79003ad5f2ae39d15368e84bfbf79a2f641602 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:18:37 -0500 Subject: [PATCH 11/16] Validate projected hooks and serialize config publication Guard generated JSON with SHA-256 and event-shape checks across runtimes; serialize CLI writes with the project lock and cover corrupted files, concurrency, and legacy YAML slash escapes. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 6 +- docs/reference/extensions.md | 2 +- scripts/bash/pre-hooks.sh | 33 ++++++- scripts/powershell/pre-hooks.ps1 | 30 +++++- scripts/python/pre_hooks.py | 18 +++- src/specify_cli/extensions/__init__.py | 56 +++++++----- tests/test_hook_dispatch_scripts.py | 121 +++++++++++++++++++++++++ 7 files changed, 238 insertions(+), 28 deletions(-) diff --git a/design/integration.md b/design/integration.md index deba8f9288..262beb2450 100644 --- a/design/integration.md +++ b/design/integration.md @@ -83,8 +83,10 @@ Rendering selects the same `sh`, `ps`, or `py` variant as the command's main script; the shared-infrastructure installer includes the selected native pre/post entry points. The CLI validates hook configuration on write and materializes ordered per-event JSON with a snapshot of `.specify/extensions.yml`. -Each variant reads the projection in its own runtime, without depending on -another variant or on runtime PyYAML; it rejects stale or missing projections. +CLI writers serialize publication, and each variant checks the snapshot and +generated response digest before returning hooks. Each reads the projection +in its own runtime, without depending on another variant or on runtime PyYAML; +it rejects stale, corrupt, or missing projections. The legacy YAML resolver remains for projects not yet refreshed. The resolver returns ordered hook metadata as JSON; agent commands themselves remain the responsibility of the agent. The command/skill registrar resolves these diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 81cc677ac4..753f2d0fe2 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -349,7 +349,7 @@ Extension manifests reject invalid hook priorities during installation. For exis Core commands run two hook dispatchers, one before and one after their main work, using the project's selected `sh`, `ps`, or `py` script type. Each variant returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. -`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. The scripts compare the saved YAML snapshot with the current file, and fail with refresh guidance if the configuration was manually edited, the projection is incomplete, or its directory is missing. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. +`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify each response against its generated SHA-256 digest. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. The CLI validates hook fields across every configured event when materializing the projection. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 7cc7ff01cd..b920c27377 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -100,7 +100,7 @@ hook_scalar() { HOOK_SCALAR+=$HOOK_UTF8 i=$((i+count)) ;; - '"'|'\') HOOK_SCALAR+=$escaped ;; + '"'|'\'|'/') HOOK_SCALAR+=$escaped ;; *) HOOK_ERROR="Unsupported YAML escape"; return ;; esac done @@ -283,7 +283,36 @@ resolve_hooks() { return 1 fi if [[ -f .specify/hook-dispatch/$event.json ]]; then - cat -- ".specify/hook-dispatch/$event.json" + local projection=".specify/hook-dispatch/$event.json" expected actual response + if [[ ! -f .specify/hook-dispatch/$event.sha256 ]]; then + hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" + return 1 + fi + IFS= read -r expected < ".specify/hook-dispatch/$event.sha256" + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum -- "$projection") || { + hook_error "$event" "Cannot validate hook projection" + return 1 + } + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 -- "$projection") || { + hook_error "$event" "Cannot validate hook projection" + return 1 + } + else + hook_error "$event" "Cannot validate hook projection: sha256sum or shasum is required" + return 1 + fi + if [[ ! $expected =~ ^[0-9a-f]{64}$ || ${actual%% *} != "$expected" ]]; then + hook_error "$event" "Hook projection is invalid; reinstall the extension or refresh the project" + return 1 + fi + if ! IFS= read -r response < "$projection" || + [[ $response != "{\"event\":\"$event\",\"hooks\":["*"]}" ]]; then + hook_error "$event" "Invalid hook projection; reinstall the extension or refresh the project" + return 1 + fi + cat -- "$projection" elif grep -Fxq -- "$event" .specify/hook-dispatch/events.txt; then hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" return 1 diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index 9f3beae806..2745990cb9 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -283,7 +283,35 @@ function Invoke-HookResolver { } $projected = Join-Path $cache "$event.json" if (Test-Path -LiteralPath $projected -PathType Leaf) { - [Console]::WriteLine([IO.File]::ReadAllText($projected, [Text.Encoding]::UTF8).TrimEnd("`r", "`n")) + $digest = Join-Path $cache "$event.sha256" + if (-not (Test-Path -LiteralPath $digest -PathType Leaf)) { + throw "Hook projection is incomplete; reinstall the extension or refresh the project" + } + $bytes = [IO.File]::ReadAllBytes($projected) + $expected = [IO.File]::ReadAllText($digest, [Text.Encoding]::UTF8).Trim() + if ($expected -cnotmatch '^[0-9a-f]{64}$' -or + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -cne $expected.ToUpperInvariant()) { + throw "Hook projection is invalid; reinstall the extension or refresh the project" + } + $text = [Text.Encoding]::UTF8.GetString($bytes) + try { $data = ConvertFrom-Json -InputObject $text -AsHashtable -ErrorAction Stop } + catch { throw "Invalid hook projection: $($_.Exception.Message)" } + if ($data -isnot [Collections.IDictionary] -or $data.event -cne $event -or + $data.hooks -isnot [array]) { + throw "Invalid hook projection; reinstall the extension or refresh the project" + } + foreach ($entry in $data.hooks) { + if ($entry -isnot [Collections.IDictionary] -or + $entry.extension -isnot [string] -or -not $entry.extension -or + $entry.command -isnot [string] -or -not $entry.command -or + $entry.optional -isnot [bool] -or + $entry.description -isnot [string] -or + $entry.prompt -isnot [string] -or + $entry.priority -isnot [long] -or $entry.priority -lt 1) { + throw "Invalid hook projection; reinstall the extension or refresh the project" + } + } + [Console]::WriteLine($text.TrimEnd("`r", "`n")) } elseif (@([IO.File]::ReadAllLines($events, [Text.Encoding]::UTF8)) -ccontains $event) { throw "Hook projection is incomplete; reinstall the extension or refresh the project" } else { diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 9b8611c416..fb09ea1182 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import sys @@ -29,11 +30,26 @@ def resolve(event: str, project_root: Path) -> dict: if event in events: raise ValueError("Hook projection is incomplete; reinstall the extension or refresh the project") return {"event": event, "hooks": []} - result = json.loads(projection.read_text(encoding="utf-8")) + expected = (cache / f"{event}.sha256").read_text(encoding="utf-8").strip() + payload = projection.read_bytes() + if not re.fullmatch("[0-9a-f]{64}", expected) or hashlib.sha256(payload).hexdigest() != expected: + raise ValueError("Hook projection is invalid; reinstall the extension or refresh the project") + result = json.loads(payload.decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ValueError(f"Could not read hook projection: {exc}") from exc if not isinstance(result, dict) or result.get("event") != event or not isinstance(result.get("hooks"), list): raise ValueError("Invalid hook projection; reinstall the extension or refresh the project") + for hook in result["hooks"]: + if (not isinstance(hook, dict) + or any(not isinstance(hook.get(field), str) or not hook[field] + for field in ("extension", "command")) + or not isinstance(hook.get("optional"), bool) + or not isinstance(hook.get("description"), str) + or not isinstance(hook.get("prompt"), str) + or not isinstance(hook.get("priority"), int) + or isinstance(hook["priority"], bool) + or hook["priority"] < 1): + raise ValueError("Invalid hook projection; reinstall the extension or refresh the project") return result if config_file.exists(): try: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index bbe5ddf3a8..64c2f822bd 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5816,7 +5816,7 @@ def represent_string(dumper: yaml.SafeDumper, value: str): hooks = config.get("hooks", {}) if not isinstance(hooks, dict): raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") - projected: dict[str, str] = {} + projected: dict[str, bytes] = {} for event, entries in hooks.items(): if not isinstance(event, str) or not re.fullmatch(r"(before|after)_[a-z][a-z0-9_]*", event): raise ValueError(f"Invalid hook event: {event}") @@ -5854,31 +5854,45 @@ def represent_string(dumper: yaml.SafeDumper, value: str): "prompt": entry.get("prompt") or "", "priority": priority, }) selected.sort(key=lambda hook: hook["priority"]) - projected[event] = json.dumps({"event": event, "hooks": selected}, ensure_ascii=True) + "\n" + projected[event] = ( + json.dumps( + {"event": event, "hooks": selected}, ensure_ascii=True, + separators=(",", ":"), + ) + "\n" + ).encode("utf-8") rendered = "# Hook projection: .specify/hook-dispatch\n" + yaml.dump( config, default_flow_style=False, sort_keys=False, allow_unicode=True, width=sys.maxsize, Dumper=CanonicalHookDumper, ) - cache = self.config_file.parent / "hook-dispatch" - if cache.is_symlink(): - raise ValueError("Refusing to write symlinked hook dispatch cache") - if not cache.exists(): - with changing_file(cache): - cache.mkdir(parents=True) - for path in cache.iterdir(): - if path.is_symlink(): - raise ValueError("Refusing to write symlinked hook dispatch cache entry") - snapshot = cache / "source.yml" - unlink(snapshot, missing_ok=True) - for path in cache.glob("*.json"): - unlink(path) - self.config_file.parent.mkdir(parents=True, exist_ok=True) - for event, payload in projected.items(): - write_text(cache / f"{event}.json", payload, encoding="utf-8") - write_bytes(cache / "events.txt", "".join(f"{event}\n" for event in projected).encode("utf-8")) - write_text(self.config_file, rendered, encoding="utf-8") - write_bytes(snapshot, self.config_file.read_bytes()) + from ..shared_infra import _exclusive_project_lock + + lock = self.config_file.parent / ".hook-dispatch.lock" + with changing_file(lock): + with _exclusive_project_lock(self.project_root, lock.name, context="hook dispatch"): + cache = self.config_file.parent / "hook-dispatch" + if cache.is_symlink(): + raise ValueError("Refusing to write symlinked hook dispatch cache") + if not cache.exists(): + with changing_file(cache): + cache.mkdir(parents=True) + for path in cache.iterdir(): + if path.is_symlink(): + raise ValueError("Refusing to write symlinked hook dispatch cache entry") + snapshot = cache / "source.yml" + unlink(snapshot, missing_ok=True) + for pattern in ("*.json", "*.sha256"): + for path in cache.glob(pattern): + unlink(path) + for event, payload in projected.items(): + write_bytes(cache / f"{event}.json", payload) + write_text( + cache / f"{event}.sha256", hashlib.sha256(payload).hexdigest() + "\n", + encoding="utf-8", + ) + write_bytes(cache / "events.txt", "".join(f"{event}\n" for event in projected).encode("utf-8")) + write_text(self.config_file, rendered, encoding="utf-8") + write_bytes(snapshot, self.config_file.read_bytes()) def migrate_project_config(self) -> None: """Validate and project existing hook configuration during project refresh.""" diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index f67dd41742..e540f73706 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -5,6 +5,9 @@ import os import subprocess import sys +import hashlib +import threading +from contextlib import contextmanager from functools import lru_cache from pathlib import Path @@ -335,6 +338,109 @@ def test_invalid_projected_hook_cannot_replace_existing_projection(tmp_path): assert run_hook(tmp_path)[1]["hooks"][0]["extension"] == "git" +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("replacement", [ + b'{"event":"before_plan","hooks":[\n', + b'{"event":"before_tasks","hooks":[]}\n', + b'{"event":"before_plan","hooks":{}}\n', +]) +def test_corrupt_projection_reports_error(tmp_path, variant, replacement): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + (cache / "before_plan.json").write_bytes(replacement) + (cache / "before_plan.sha256").write_text( + hashlib.sha256(replacement).hexdigest() + "\n", encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "projection" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_projection_modified_without_new_digest_is_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + (tmp_path / ".specify/hook-dispatch/before_plan.json").write_text( + '{"event":"before_plan","hooks":[]}\n', encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "invalid" in data["error"] + + +def test_concurrent_projection_saves_publish_matching_configuration(tmp_path, monkeypatch): + from specify_cli.extensions import HookExecutor + from specify_cli.integrations import _file_changes + from specify_cli import shared_infra + + first_writing = threading.Event() + resume_first = threading.Event() + second_attempted = threading.Event() + second_done = threading.Event() + failures = [] + original_write = _file_changes.write_bytes + original_lock = shared_infra._exclusive_project_lock + + def paused_write(path, content): + if path.name == "before_plan.json" and threading.current_thread().name == "first": + first_writing.set() + if not resume_first.wait(10): + raise TimeoutError("First writer was not released") + return original_write(path, content) + + @contextmanager + def observed_lock(*args, **kwargs): + if threading.current_thread().name == "second": + second_attempted.set() + with original_lock(*args, **kwargs): + yield + + monkeypatch.setattr(_file_changes, "write_bytes", paused_write) + monkeypatch.setattr(shared_infra, "_exclusive_project_lock", observed_lock) + + def save(name): + try: + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": name, "command": f"speckit.{name}.run"}, + ]}}) + except Exception as exc: + failures.append(exc) + finally: + if name == "second": + second_done.set() + + first = threading.Thread(target=save, args=("first",), name="first") + second = threading.Thread(target=save, args=("second",), name="second") + first.start() + try: + assert first_writing.wait(10) + second.start() + assert second_attempted.wait(10) + assert not second_done.wait(0.2) + finally: + resume_first.set() + first.join(10) + if second.ident is not None: + second.join(10) + assert not first.is_alive() and not second.is_alive() + assert not failures + assert HookExecutor(tmp_path).get_project_config()["hooks"]["before_plan"][0]["extension"] == "second" + assert run_hook(tmp_path)[1]["hooks"][0]["extension"] == "second" + + def test_no_yaml_dependency_needed_for_absent_config(tmp_path): result = subprocess.run( [sys.executable, "-I", "-S", str(PYTHON / "pre_hooks.py"), "plan"], @@ -466,6 +572,21 @@ def test_canonical_backslash_escape_matches_python(tmp_path, variant, phase): assert data["hooks"][0]["prompt"] == prompt +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_legacy_yaml_escaped_slash_matches_python(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, ( + 'hooks:\n before_plan:\n' + ' - extension: git\n command: speckit.git.commit\n' + ' prompt: "a\\/b"\n' + )) + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data == run_hook(tmp_path)[1] + assert data["hooks"][0]["prompt"] == "a/b" + + @pytest.mark.parametrize("variant", ["sh", "ps", "py"]) @pytest.mark.parametrize("phase", ["pre", "post"]) @pytest.mark.parametrize("field,value", [ From 517315a471115e1c7284b4171570ff8badf59616 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:15:42 -0500 Subject: [PATCH 12/16] Guard hook projection index and existing installs Validate old hook configuration before extension installs, checksum the event index, and verify canonical hook metadata in Bash. Preserve self-contained runtimes and add corruption and install-time regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 8 +-- docs/reference/extensions.md | 2 +- scripts/bash/pre-hooks.sh | 56 +++++++++++-------- scripts/powershell/pre-hooks.ps1 | 12 +++- scripts/python/pre_hooks.py | 6 +- src/specify_cli/extensions/__init__.py | 19 +++++-- .../extensions/test_command_add.py | 17 ++++++ tests/test_hook_dispatch_scripts.py | 41 ++++++++++++++ 8 files changed, 127 insertions(+), 34 deletions(-) diff --git a/design/integration.md b/design/integration.md index 262beb2450..5eee5c1c5f 100644 --- a/design/integration.md +++ b/design/integration.md @@ -83,10 +83,10 @@ Rendering selects the same `sh`, `ps`, or `py` variant as the command's main script; the shared-infrastructure installer includes the selected native pre/post entry points. The CLI validates hook configuration on write and materializes ordered per-event JSON with a snapshot of `.specify/extensions.yml`. -CLI writers serialize publication, and each variant checks the snapshot and -generated response digest before returning hooks. Each reads the projection -in its own runtime, without depending on another variant or on runtime PyYAML; -it rejects stale, corrupt, or missing projections. +CLI writers serialize publication, and each variant checks the snapshot, +event-index digest, and generated response digest before returning hooks. Each +reads the projection in its own runtime, without depending on another variant +or on runtime PyYAML; it rejects stale, corrupt, or missing projections. The legacy YAML resolver remains for projects not yet refreshed. The resolver returns ordered hook metadata as JSON; agent commands themselves remain the responsibility of the agent. The command/skill registrar resolves these diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 753f2d0fe2..a592bda737 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -349,7 +349,7 @@ Extension manifests reject invalid hook priorities during installation. For exis Core commands run two hook dispatchers, one before and one after their main work, using the project's selected `sh`, `ps`, or `py` script type. Each variant returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. -`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify each response against its generated SHA-256 digest. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. +`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. The CLI validates hook fields across every configured event when materializing the projection. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index b920c27377..d6996deb2f 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -256,6 +256,30 @@ hook_priority() { (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 } +hook_digest_valid() { + local path=$1 digest=$2 expected actual + [[ -f $path && -f $digest ]] || return 1 + IFS= read -r expected < "$digest" + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum -- "$path") || return 1 + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 -- "$path") || return 1 + else + HOOK_ERROR="Cannot validate hook projection: sha256sum or shasum is required" + return 1 + fi + [[ $expected =~ ^[0-9a-f]{64}$ && ${actual%% *} == "$expected" ]] +} + +hook_projection_shape_valid() { + local response=$1 event=$2 + local string='"([^"\\[:cntrl:]]|\\(["\\/bfnrt]|u[[:xdigit:]]{4}))*"' + local required='"([^"\\[:cntrl:]]|\\(["\\/bfnrt]|u[[:xdigit:]]{4}))+"' + local entry='\{"extension":'"$required"',"command":'"$required"',"optional":(true|false),"description":'"$string"',"prompt":'"$string"',"priority":[1-9][0-9]*\}' + local shape='^\{"event":"'"$event"'","hooks":\[('"$entry"'(,'"$entry"')*)?\]\}$' + [[ $response =~ $shape ]] +} + resolve_hooks() { local phase=$1 command=$2 event config line spaces indent text key value local section="" target=false empty=false item_indent=-1 installed_indent=-1 index=0 seen=false seen_hooks=false seen_event=false @@ -282,33 +306,19 @@ resolve_hooks() { hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" return 1 fi + HOOK_ERROR="" + if ! hook_digest_valid .specify/hook-dispatch/events.txt .specify/hook-dispatch/events.txt.sha256; then + hook_error "$event" "${HOOK_ERROR:-Hook projection index is invalid; reinstall the extension or refresh the project}" + return 1 + fi if [[ -f .specify/hook-dispatch/$event.json ]]; then - local projection=".specify/hook-dispatch/$event.json" expected actual response - if [[ ! -f .specify/hook-dispatch/$event.sha256 ]]; then - hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" - return 1 - fi - IFS= read -r expected < ".specify/hook-dispatch/$event.sha256" - if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum -- "$projection") || { - hook_error "$event" "Cannot validate hook projection" - return 1 - } - elif command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 -- "$projection") || { - hook_error "$event" "Cannot validate hook projection" - return 1 - } - else - hook_error "$event" "Cannot validate hook projection: sha256sum or shasum is required" - return 1 - fi - if [[ ! $expected =~ ^[0-9a-f]{64}$ || ${actual%% *} != "$expected" ]]; then - hook_error "$event" "Hook projection is invalid; reinstall the extension or refresh the project" + local projection=".specify/hook-dispatch/$event.json" response + if ! hook_digest_valid "$projection" ".specify/hook-dispatch/$event.sha256"; then + hook_error "$event" "${HOOK_ERROR:-Hook projection is invalid; reinstall the extension or refresh the project}" return 1 fi if ! IFS= read -r response < "$projection" || - [[ $response != "{\"event\":\"$event\",\"hooks\":["*"]}" ]]; then + ! hook_projection_shape_valid "$response" "$event"; then hook_error "$event" "Invalid hook projection; reinstall the extension or refresh the project" return 1 fi diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index 2745990cb9..b62d66e198 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -281,6 +281,16 @@ function Invoke-HookResolver { [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { throw "Hook projection is stale; reinstall the extension or refresh the project" } + $indexDigest = Join-Path $cache 'events.txt.sha256' + if (-not (Test-Path -LiteralPath $indexDigest -PathType Leaf)) { + throw "Hook projection index is incomplete; reinstall the extension or refresh the project" + } + $eventBytes = [IO.File]::ReadAllBytes($events) + $eventHash = [IO.File]::ReadAllText($indexDigest, [Text.Encoding]::UTF8).Trim() + if ($eventHash -cnotmatch '^[0-9a-f]{64}$' -or + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($eventBytes)) -cne $eventHash.ToUpperInvariant()) { + throw "Hook projection index is invalid; reinstall the extension or refresh the project" + } $projected = Join-Path $cache "$event.json" if (Test-Path -LiteralPath $projected -PathType Leaf) { $digest = Join-Path $cache "$event.sha256" @@ -312,7 +322,7 @@ function Invoke-HookResolver { } } [Console]::WriteLine($text.TrimEnd("`r", "`n")) - } elseif (@([IO.File]::ReadAllLines($events, [Text.Encoding]::UTF8)) -ccontains $event) { + } elseif (@([Text.Encoding]::UTF8.GetString($eventBytes) -split "`n") -ccontains $event) { throw "Hook projection is incomplete; reinstall the extension or refresh the project" } else { @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index fb09ea1182..bb3cc10815 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -24,7 +24,11 @@ def resolve(event: str, project_root: Path) -> dict: try: if config_file.read_bytes() != (cache / "source.yml").read_bytes(): raise ValueError("Hook projection is stale; reinstall the extension or refresh the project") - events = (cache / "events.txt").read_text(encoding="utf-8").splitlines() + event_bytes = (cache / "events.txt").read_bytes() + index_digest = (cache / "events.txt.sha256").read_text(encoding="utf-8").strip() + if not re.fullmatch("[0-9a-f]{64}", index_digest) or hashlib.sha256(event_bytes).hexdigest() != index_digest: + raise ValueError("Hook projection index is invalid; reinstall the extension or refresh the project") + events = event_bytes.decode("utf-8").splitlines() projection = cache / f"{event}.json" if not projection.exists(): if event in events: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 64c2f822bd..3ea9bf7df5 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2593,6 +2593,12 @@ def install_from_directory( "{PRE_HOOK_SCRIPT}" in command_source or "{POST_HOOK_SCRIPT}" in command_source ) + if not needs_hook_dispatchers: + try: + HookExecutor(self.project_root).migrate_project_config() + except (OSError, ValueError) as exc: + raise ExtensionError(f"Cannot install extension with invalid hooks: {exc}") from exc + if needs_hook_dispatchers: from .. import _install_shared_infra from ..integrations.base import get_invocation_prefix @@ -5886,11 +5892,16 @@ def represent_string(dumper: yaml.SafeDumper, value: str): unlink(path) for event, payload in projected.items(): write_bytes(cache / f"{event}.json", payload) - write_text( - cache / f"{event}.sha256", hashlib.sha256(payload).hexdigest() + "\n", - encoding="utf-8", + write_bytes( + cache / f"{event}.sha256", + (hashlib.sha256(payload).hexdigest() + "\n").encode("ascii"), ) - write_bytes(cache / "events.txt", "".join(f"{event}\n" for event in projected).encode("utf-8")) + events_payload = "".join(f"{event}\n" for event in projected).encode("utf-8") + write_bytes(cache / "events.txt", events_payload) + write_bytes( + cache / "events.txt.sha256", + (hashlib.sha256(events_payload).hexdigest() + "\n").encode("ascii"), + ) write_text(self.config_file, rendered, encoding="utf-8") write_bytes(snapshot, self.config_file.read_bytes()) diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index 83745f4d4e..ba6f843990 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -53,6 +53,23 @@ def test_add_git_materializes_hook_projection(self, project_dir): assert projected["event"] == "before_plan" assert [hook["extension"] for hook in projected["hooks"]] == ["git"] + @pytest.mark.parametrize("existing", [ + "hooks: [broken]\n", + "hooks:\n before_plan:\n - extension: orphan\n", + ]) + def test_add_git_rejects_invalid_existing_hooks(self, project_dir, existing): + git_source = Path(__file__).parents[3] / "extensions" / "git" + config = project_dir / ".specify" / "extensions.yml" + config.write_text(existing, encoding="utf-8") + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(git_source), "--dev"], catch_exceptions=True + ) + assert result.exit_code != 0 + assert "invalid hooks" in result.output.lower() + assert config.read_text(encoding="utf-8") == existing + assert not ExtensionManager(project_dir).registry.is_installed("git") + def test_add_dev_links_copilot_agent_when_supported( self, extension_dir, project_dir, temp_dir ): diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index e540f73706..4cabb9969f 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -231,6 +231,8 @@ def test_materialized_projection_resolves_without_yaml_or_native_parsing(tmp_pat projected = tmp_path / ".specify/hook-dispatch/before_plan.json" assert projected.is_file() assert (tmp_path / ".specify/hook-dispatch/events.txt").read_bytes() == b"before_plan\n" + assert b"\r" not in (tmp_path / ".specify/hook-dispatch/events.txt.sha256").read_bytes() + assert b"\r" not in (tmp_path / ".specify/hook-dispatch/before_plan.sha256").read_bytes() assert [h["extension"] for h in json.loads(projected.read_text(encoding="utf-8"))["hooks"]] == [ "first", "late", ] @@ -381,6 +383,45 @@ def test_projection_modified_without_new_digest_is_rejected(tmp_path, variant): assert "invalid" in data["error"] +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_truncated_event_index_cannot_hide_mandatory_hooks(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit", "optional": False}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + (cache / "events.txt").write_bytes(b"") + (cache / "before_plan.json").unlink() + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "index" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_semantically_invalid_projected_hook_is_rejected(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + payload = b'{"event":"before_plan","hooks":[{"extension":1}]}\n' + (cache / "before_plan.json").write_bytes(payload) + (cache / "before_plan.sha256").write_text( + hashlib.sha256(payload).hexdigest() + "\n", encoding="utf-8", + ) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "projection" in data["error"] + + def test_concurrent_projection_saves_publish_matching_configuration(tmp_path, monkeypatch): from specify_cli.extensions import HookExecutor from specify_cli.integrations import _file_changes From 5d08982bda86bd6f9e324c4fab0da0d10996a30f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:11:14 -0500 Subject: [PATCH 13/16] Preflight extension hooks and fence projection reads Validate incoming hook fields and projected event names before install mutations. Recheck the projection snapshot before each resolver returns so overlapping saves fail explicitly rather than publishing mixed responses. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/extensions.md | 4 +- scripts/bash/pre-hooks.sh | 26 ++++++- scripts/powershell/pre-hooks.ps1 | 17 ++-- scripts/python/pre_hooks.py | 18 +++-- src/specify_cli/extensions/__init__.py | 19 ++++- .../extensions/test_command_add.py | 55 +++++++++++++ tests/test_hook_dispatch_scripts.py | 77 +++++++++++++++++++ 7 files changed, 199 insertions(+), 17 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index a592bda737..e02f32d3ba 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -349,9 +349,9 @@ Extension manifests reject invalid hook priorities during installation. For exis Core commands run two hook dispatchers, one before and one after their main work, using the project's selected `sh`, `ps`, or `py` script type. Each variant returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. -`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. +`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. They recheck the snapshot before returning, so a concurrent save cannot silently return a mixed response; a reader caught mid-save fails and can be retried. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. -The CLI validates hook fields across every configured event when materializing the projection. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. +The CLI validates hook fields across every configured event when materializing the projection. Extension installation checks projected hook event names (`before_...` or `after_...`) before modifying the project; manifest inspection still accepts other event names. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. Native dispatchers also recognize the numeric YAML forms accepted by the Python resolver, including digit separators, hex/binary/octal integers, sexagesimal integers, and decimal floats with exponents. The canonical writer quotes strings that would otherwise be ambiguous to the native parsers and preserves YAML control-character escapes. A NUL character in hook configuration is rejected explicitly because Bash cannot represent it. diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index d6996deb2f..e2ba574874 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -271,6 +271,16 @@ hook_digest_valid() { [[ $expected =~ ^[0-9a-f]{64}$ && ${actual%% *} == "$expected" ]] } +hook_file_digest() { + local output + if command -v sha256sum >/dev/null 2>&1; then + output=$(sha256sum -- "$1") || return 1 + else + output=$(shasum -a 256 -- "$1") || return 1 + fi + printf '%s' "${output%% *}" +} + hook_projection_shape_valid() { local response=$1 event=$2 local string='"([^"\\[:cntrl:]]|\\(["\\/bfnrt]|u[[:xdigit:]]{4}))*"' @@ -311,6 +321,11 @@ resolve_hooks() { hook_error "$event" "${HOOK_ERROR:-Hook projection index is invalid; reinstall the extension or refresh the project}" return 1 fi + local source_digest result + if ! source_digest=$(hook_file_digest "$config"); then + hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" + return 1 + fi if [[ -f .specify/hook-dispatch/$event.json ]]; then local projection=".specify/hook-dispatch/$event.json" response if ! hook_digest_valid "$projection" ".specify/hook-dispatch/$event.sha256"; then @@ -322,13 +337,20 @@ resolve_hooks() { hook_error "$event" "Invalid hook projection; reinstall the extension or refresh the project" return 1 fi - cat -- "$projection" + result=$response elif grep -Fxq -- "$event" .specify/hook-dispatch/events.txt; then hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" return 1 else - printf '{"event":"%s","hooks":[]}\n' "$event" + result="{\"event\":\"$event\",\"hooks\":[]}" + fi + if [[ ! -f .specify/hook-dispatch/source.yml ]] || + ! cmp -s -- "$config" .specify/hook-dispatch/source.yml || + [[ $(hook_file_digest "$config") != "$source_digest" ]]; then + hook_error "$event" "Hook projection changed during resolution; retry the command" + return 1 fi + printf '%s\n' "$result" return fi if [[ ! -e $config ]]; then diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index b62d66e198..a292b560b4 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -276,9 +276,11 @@ function Invoke-HookResolver { $events = Join-Path $cache 'events.txt' if (-not (Test-Path -LiteralPath $config -PathType Leaf) -or -not (Test-Path -LiteralPath $snapshot -PathType Leaf) -or - -not (Test-Path -LiteralPath $events -PathType Leaf) -or - [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) -cne - [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { + -not (Test-Path -LiteralPath $events -PathType Leaf)) { + throw "Hook projection is stale; reinstall the extension or refresh the project" + } + $source = [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) + if ($source -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { throw "Hook projection is stale; reinstall the extension or refresh the project" } $indexDigest = Join-Path $cache 'events.txt.sha256' @@ -321,12 +323,17 @@ function Invoke-HookResolver { throw "Invalid hook projection; reinstall the extension or refresh the project" } } - [Console]::WriteLine($text.TrimEnd("`r", "`n")) + $result = $text.TrimEnd("`r", "`n") } elseif (@([Text.Encoding]::UTF8.GetString($eventBytes) -split "`n") -ccontains $event) { throw "Hook projection is incomplete; reinstall the extension or refresh the project" } else { - @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress + $result = @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress + } + if ($source -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) -or + $source -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { + throw "Hook projection changed during resolution; retry the command" } + [Console]::WriteLine($result) exit 0 } $hooks = @() diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index bb3cc10815..a10c5fa3a6 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -22,7 +22,8 @@ def resolve(event: str, project_root: Path) -> dict: cache = project_root / ".specify" / "hook-dispatch" if cache.is_dir(): try: - if config_file.read_bytes() != (cache / "source.yml").read_bytes(): + source = config_file.read_bytes() + if source != (cache / "source.yml").read_bytes(): raise ValueError("Hook projection is stale; reinstall the extension or refresh the project") event_bytes = (cache / "events.txt").read_bytes() index_digest = (cache / "events.txt.sha256").read_text(encoding="utf-8").strip() @@ -33,12 +34,15 @@ def resolve(event: str, project_root: Path) -> dict: if not projection.exists(): if event in events: raise ValueError("Hook projection is incomplete; reinstall the extension or refresh the project") - return {"event": event, "hooks": []} - expected = (cache / f"{event}.sha256").read_text(encoding="utf-8").strip() - payload = projection.read_bytes() - if not re.fullmatch("[0-9a-f]{64}", expected) or hashlib.sha256(payload).hexdigest() != expected: - raise ValueError("Hook projection is invalid; reinstall the extension or refresh the project") - result = json.loads(payload.decode("utf-8")) + result = {"event": event, "hooks": []} + else: + expected = (cache / f"{event}.sha256").read_text(encoding="utf-8").strip() + payload = projection.read_bytes() + if not re.fullmatch("[0-9a-f]{64}", expected) or hashlib.sha256(payload).hexdigest() != expected: + raise ValueError("Hook projection is invalid; reinstall the extension or refresh the project") + result = json.loads(payload.decode("utf-8")) + if source != config_file.read_bytes() or source != (cache / "source.yml").read_bytes(): + raise ValueError("Hook projection changed during resolution; retry the command") except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ValueError(f"Could not read hook projection: {exc}") from exc if not isinstance(result, dict) or result.get("event") != event or not isinstance(result.get("hooks"), list): diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3ea9bf7df5..650b9a970b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -433,10 +433,22 @@ def _validate(self): f"Invalid hook '{hook_name}': " "expected a mapping or list of mappings" ) - if not entry.get("command"): + if not isinstance(entry.get("command"), str) or not entry["command"]: raise ValidationError( f"Hook '{hook_name}' missing required 'command' field" ) + for field in ("enabled", "optional"): + if field in entry and not isinstance(entry[field], bool): + raise ValidationError( + f"Hook '{hook_name}' has invalid '{field}': must be a boolean" + ) + for field in ("condition", "description", "prompt"): + if entry.get(field) is not None and not isinstance(entry[field], str): + raise ValidationError( + f"Hook '{hook_name}' has invalid '{field}': must be a string or null" + ) + if any(isinstance(value, str) and "\0" in value for value in entry.values()): + raise ValidationError(f"Hook '{hook_name}' contains a NUL character") if "priority" in entry: priority = entry["priority"] if not isinstance(priority, int) or isinstance(priority, bool): @@ -2462,6 +2474,11 @@ def install_from_directory( # Load and validate manifest manifest_path = source_dir / "extension.yml" manifest = ExtensionManifest(manifest_path) + for event in manifest.hooks: + if not isinstance(event, str) or not re.fullmatch( + r"(before|after)_[a-z][a-z0-9_]*", event + ): + raise ValidationError(f"Invalid hook event for hook projection: {event}") # Check compatibility self.check_compatibility(manifest, speckit_version) diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index ba6f843990..3d2031aaa3 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -70,6 +70,61 @@ def test_add_git_rejects_invalid_existing_hooks(self, project_dir, existing): assert config.read_text(encoding="utf-8") == existing assert not ExtensionManager(project_dir).registry.is_installed("git") + @pytest.mark.parametrize("invalid", [ + {"optional": "false"}, + {"condition": False}, + {"prompt": False}, + {"description": False}, + {"enabled": "true"}, + {"prompt": "invalid\0prompt"}, + ]) + def test_add_rejects_invalid_manifest_hooks_before_install( + self, extension_dir, project_dir, invalid + ): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(project_dir) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "existing", "command": "speckit.existing.run"}, + ]}}) + existing = executor.config_file.read_bytes() + manifest = extension_dir / "extension.yml" + payload = yaml.safe_load(manifest.read_text(encoding="utf-8")) + payload["hooks"]["after_tasks"].update(invalid) + manifest.write_text(yaml.safe_dump(payload), encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code != 0 + assert executor.config_file.read_bytes() == existing + assert not ExtensionManager(project_dir).registry.is_installed("test-ext") + assert not (project_dir / ".specify/extensions/test-ext").exists() + assert not (project_dir / ".github/agents/speckit.test-ext.hello.agent.md").exists() + assert json.loads( + (project_dir / ".specify/hook-dispatch/before_plan.json").read_text(encoding="utf-8") + )["hooks"][0]["extension"] == "existing" + + def test_add_rejects_invalid_manifest_hook_event_before_install( + self, extension_dir, project_dir + ): + manifest = extension_dir / "extension.yml" + payload = yaml.safe_load(manifest.read_text(encoding="utf-8")) + payload["hooks"]["invalid-event"] = payload["hooks"].pop("after_tasks") + manifest.write_text(yaml.safe_dump(payload), encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code != 0 + assert not ExtensionManager(project_dir).registry.is_installed("test-ext") + assert not (project_dir / ".specify/extensions/test-ext").exists() + assert not (project_dir / ".specify/extensions.yml").exists() + def test_add_dev_links_copilot_agent_when_supported( self, extension_dir, project_dir, temp_dir ): diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 4cabb9969f..9bc24cc3a7 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -422,6 +422,83 @@ def test_semantically_invalid_projected_hook_is_rejected(tmp_path, variant): assert "projection" in data["error"] +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +def test_read_during_projection_save_fails_then_recovers(tmp_path, monkeypatch, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + from specify_cli.integrations import _file_changes + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + writing = threading.Event() + resume = threading.Event() + failures = [] + original_write = _file_changes.write_bytes + + def paused_write(path, content): + if path.name == "before_plan.json": + writing.set() + if not resume.wait(10): + raise TimeoutError("Writer was not released") + return original_write(path, content) + + monkeypatch.setattr(_file_changes, "write_bytes", paused_write) + + def save(): + try: + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + except (OSError, ValueError, RuntimeError) as exc: + failures.append(exc) + + writer = threading.Thread(target=save) + writer.start() + try: + assert writing.wait(10) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "projection" in data["error"] + finally: + resume.set() + writer.join(10) + assert not writer.is_alive() and not failures + code, data = run_hook(tmp_path, variant=variant) + assert code == 0 + assert data["hooks"][0]["extension"] == "new" + + +def test_python_reader_rejects_save_after_reading_old_projection(tmp_path, monkeypatch): + from scripts.python import pre_hooks + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + original_read = Path.read_bytes + replaced = False + + def read_and_replace(path): + nonlocal replaced + payload = original_read(path) + if path.name == "before_plan.json" and not replaced: + replaced = True + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + return payload + + monkeypatch.setattr(Path, "read_bytes", read_and_replace) + with pytest.raises(ValueError, match="changed during resolution"): + pre_hooks.resolve("before_plan", tmp_path) + assert replaced + + def test_concurrent_projection_saves_publish_matching_configuration(tmp_path, monkeypatch): from specify_cli.extensions import HookExecutor from specify_cli.integrations import _file_changes From 2fe19116b6357d104a355e320919275ea80ad85f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:28:12 -0500 Subject: [PATCH 14/16] Validate legacy hook events and install selected dispatchers Reject unprojectable event names in every legacy resolver, and provision each hook-bearing extension command's actually selected script variant during installation. Preserve rejection of invalid project script settings and cover fallback, mixed-variant, and negative paths. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 5 +- docs/reference/extensions.md | 4 +- scripts/bash/pre-hooks.sh | 5 +- scripts/powershell/pre-hooks.ps1 | 8 +- scripts/python/pre_hooks.py | 2 + src/specify_cli/extensions/__init__.py | 74 +++++++++----- .../extensions/test_command_add.py | 98 +++++++++++++++++++ tests/test_hook_dispatch_scripts.py | 20 ++++ 8 files changed, 182 insertions(+), 34 deletions(-) diff --git a/design/integration.md b/design/integration.md index 5eee5c1c5f..ef02113740 100644 --- a/design/integration.md +++ b/design/integration.md @@ -80,8 +80,9 @@ placeholder; `--skills` emits skills into that same directory. Core command templates also use `{PRE_HOOK_SCRIPT}` and `{POST_HOOK_SCRIPT}`. Rendering selects the same `sh`, `ps`, or `py` variant as the command's main -script; the shared-infrastructure installer includes the selected native -pre/post entry points. The CLI validates hook configuration on write and +script; extension installation provisions the native pre/post entry points +for each hook-bearing command's selected variant, including fallback variants +when the project preference is unavailable. The CLI validates hook configuration on write and materializes ordered per-event JSON with a snapshot of `.specify/extensions.yml`. CLI writers serialize publication, and each variant checks the snapshot, event-index digest, and generated response digest before returning hooks. Each diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e02f32d3ba..07d8b91442 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -347,7 +347,7 @@ Hook event names identify when a hook is invoked. They generally use `before_ --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. @@ -355,7 +355,7 @@ The CLI validates hook fields across every configured event when materializing t Native dispatchers also recognize the numeric YAML forms accepted by the Python resolver, including digit separators, hex/binary/octal integers, sexagesimal integers, and decimal floats with exponents. The canonical writer quotes strings that would otherwise be ambiguous to the native parsers and preserves YAML control-character escapes. A NUL character in hook configuration is rejected explicitly because Bash cannot represent it. -Legacy Bash and PowerShell resolvers (before projection generation) support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported by the legacy path. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout and generates its projection (YAML comments and formatting are not retained). The Python legacy path accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. +Legacy Bash and PowerShell resolvers (before projection generation) support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, `before_...` or `after_...` event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). All three legacy resolvers reject unsupported event names, including names for events other than the one being resolved. Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported by the legacy path. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout and generates its projection (YAML comments and formatting are not retained). The Python legacy path accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index e2ba574874..323f70de38 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -417,8 +417,11 @@ resolve_hooks() { [[ -n $HOOK_ERROR ]] && break continue fi - if (( indent == 2 )) && [[ $text =~ ^([a-z][a-z0-9_]*):([[:space:]]|$) ]]; then + if (( indent == 2 )) && [[ $text != '- '* && $text =~ ^([^:]+):([[:space:]]|$) ]]; then key=${BASH_REMATCH[1]} + if [[ ! $key =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then + HOOK_ERROR="Invalid hook event: $key"; break + fi value=${text:${#key}+1} while [[ $value == ' '* ]]; do value=${value# }; done target=false diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index a292b560b4..3dcc26b2ea 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -204,9 +204,13 @@ function Resolve-HookConfig { throw "Unsupported YAML top-level layout" } if ($hooksEmpty) { throw "Unsupported YAML hook layout" } - if ($indent -eq 2 -and $body -match '^([a-z][a-z0-9_]*):(?:\s|$)(.*)$') { + if ($indent -eq 2 -and -not $body.StartsWith('- ') -and + $body -match '^([^:]+):(?:\s|$)(.*)$') { $key = $Matches[1] $value = $Matches[2].Trim() + if ($key -cnotmatch '^(before|after)_[a-z][a-z0-9_]*$') { + throw "Invalid hook event: $key" + } $target = $key -eq $Event if ($target) { if ($seenEvent) { throw "Duplicate hook event" } @@ -259,7 +263,7 @@ function Invoke-HookResolver { param([string]$Phase, [string]$Command) $event = "${Phase}_${Command}" try { - if ($event -notmatch '^(before|after)_[a-z][a-z0-9_]*$') { + if ($event -cnotmatch '^(before|after)_[a-z][a-z0-9_]*$') { throw "Invalid hook event: $event" } $config = Join-Path (Get-Location) '.specify/extensions.yml' diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index a10c5fa3a6..9167c5719a 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -83,6 +83,8 @@ def resolve(event: str, project_root: Path) -> dict: hooks = [] for hook_event, entries in config.get("hooks", {}).items(): + if not isinstance(hook_event, str) or not _EVENT.fullmatch(hook_event): + raise ValueError(f"Invalid hook event: {hook_event}") if not isinstance(entries, list): raise ValueError(f"Invalid .specify/extensions.yml: hooks.{hook_event} must be a list") for index, entry in enumerate(entries): diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 650b9a970b..d7d9509359 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2597,7 +2597,12 @@ def install_from_directory( f"extension. Install from a copy in a different location instead." ) - needs_hook_dispatchers = False + from ..integrations.base import IntegrationBase + + requested_script = ( + active_options.get("script") if isinstance(active_options, dict) else None + ) or ("ps" if os.name == "nt" else "sh") + hook_variants: set[str] = set() for command in manifest.commands: source_file = (source_dir / command["file"]).resolve() if not source_file.is_relative_to(source_dir.resolve()) or not source_file.is_file(): @@ -2606,44 +2611,59 @@ def install_from_directory( command_source = source_file.read_text(encoding="utf-8") except (OSError, UnicodeError) as exc: raise ExtensionError(f"Cannot read extension command '{command['file']}': {exc}") from exc - needs_hook_dispatchers |= ( - "{PRE_HOOK_SCRIPT}" in command_source or "{POST_HOOK_SCRIPT}" in command_source - ) + if "{PRE_HOOK_SCRIPT}" not in command_source and "{POST_HOOK_SCRIPT}" not in command_source: + continue + if requested_script not in ("sh", "ps", "py"): + raise ExtensionError( + f"Unsupported hook dispatcher script type: {requested_script}" + ) + frontmatter, _ = CommandRegistrar.parse_frontmatter(command_source) + scripts = frontmatter.get("scripts") or {} + if not isinstance(scripts, dict): + scripts = {} + try: + selected = ( + IntegrationBase.select_script_variant(requested_script, scripts) + if scripts else requested_script + ) + except ValueError as exc: + raise ExtensionError(f"Cannot select hook dispatcher: {exc}") from exc + if selected not in ("sh", "ps", "py"): + raise ExtensionError(f"Unsupported hook dispatcher script type: {selected}") + hook_variants.add(selected) - if not needs_hook_dispatchers: + if not hook_variants: try: HookExecutor(self.project_root).migrate_project_config() except (OSError, ValueError) as exc: raise ExtensionError(f"Cannot install extension with invalid hooks: {exc}") from exc - if needs_hook_dispatchers: + if hook_variants: from .. import _install_shared_infra from ..integrations.base import get_invocation_prefix options = active_options if isinstance(active_options, dict) else {} - script = options.get("script") or ("ps" if os.name == "nt" else "sh") - variant = {"sh": "bash", "ps": "powershell", "py": "python"}.get(script) if isinstance(script, str) else None - if variant is None: - raise ExtensionError(f"Unsupported hook dispatcher script type: {script}") skills = is_ai_skills_enabled(options) - try: - _install_shared_infra( - self.project_root, - script, - invoke_separator="-" if skills else ".", - invoke_prefix=get_invocation_prefix(options.get("ai"), skills), - refresh_managed=True, - hook_dispatchers_only=True, - ) - except (OSError, ValueError) as exc: - raise ExtensionError(f"Cannot install hook dispatchers: {exc}") from exc - for phase in ("pre", "post"): - name = f"{phase}_hooks.py" if script == "py" else f"{phase}-hooks.{script if script == 'sh' else 'ps1'}" - dispatcher = self.project_root / ".specify" / "scripts" / variant / name - if dispatcher.is_symlink() or not dispatcher.is_file(): - raise ExtensionError( - f"Cannot register extension commands: missing hook dispatcher '{dispatcher}'" + for script in sorted(hook_variants): + try: + _install_shared_infra( + self.project_root, + script, + invoke_separator="-" if skills else ".", + invoke_prefix=get_invocation_prefix(options.get("ai"), skills), + refresh_managed=True, + hook_dispatchers_only=True, ) + except (OSError, ValueError) as exc: + raise ExtensionError(f"Cannot install hook dispatchers: {exc}") from exc + variant = {"sh": "bash", "ps": "powershell", "py": "python"}[script] + for phase in ("pre", "post"): + name = f"{phase}_hooks.py" if script == "py" else f"{phase}-hooks.{script if script == 'sh' else 'ps1'}" + dispatcher = self.project_root / ".specify" / "scripts" / variant / name + if dispatcher.is_symlink() or not dispatcher.is_file(): + raise ExtensionError( + f"Cannot register extension commands: missing hook dispatcher '{dispatcher}'" + ) from ..integrations._file_changes import after_file_change, before_file_change, changing_file package_paths = ( diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index 3d2031aaa3..6a0f208a97 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -53,6 +53,104 @@ def test_add_git_materializes_hook_projection(self, project_dir): assert projected["event"] == "before_plan" assert [hook["extension"] for hook in projected["hooks"]] == ["git"] + @pytest.mark.parametrize("preferred,available", [ + ("sh", "py"), ("py", "sh"), ("sh", "ps"), + ]) + def test_add_installs_dispatchers_for_selected_command_variant( + self, extension_dir, project_dir, preferred, available + ): + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": "copilot", "script": preferred}), encoding="utf-8" + ) + (project_dir / ".github/agents").mkdir(parents=True) + (extension_dir / "commands/hello.md").write_text( + "---\n" + "description: Test variant fallback\n" + "scripts:\n" + f" {available}: .specify/scripts/{available}/some-script {{ARGS}}\n" + "---\n\n" + "{PRE_HOOK_SCRIPT}\n" + "{POST_HOOK_SCRIPT}\n", + encoding="utf-8", + ) + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code == 0, result.output + directory = {"sh": "bash", "ps": "powershell", "py": "python"}[available] + suffix = {"sh": "-hooks.sh", "ps": "-hooks.ps1", "py": "_hooks.py"}[available] + rendered = (project_dir / ".github/agents/speckit.test-ext.hello.agent.md").read_text( + encoding="utf-8" + ) + for phase in ("pre", "post"): + relative = f".specify/scripts/{directory}/{phase}{suffix}" + assert relative in rendered + assert (project_dir / relative).is_file() + + def test_add_installs_dispatchers_for_multiple_command_variants( + self, extension_dir, project_dir + ): + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}), encoding="utf-8" + ) + (project_dir / ".github/agents").mkdir(parents=True) + manifest = extension_dir / "extension.yml" + payload = yaml.safe_load(manifest.read_text(encoding="utf-8")) + payload["provides"]["commands"].append({ + "name": "speckit.test-ext.other", + "file": "commands/other.md", + "description": "Second command", + }) + manifest.write_text(yaml.safe_dump(payload), encoding="utf-8") + for name, variant in (("hello", "py"), ("other", "ps")): + (extension_dir / f"commands/{name}.md").write_text( + f"---\nscripts:\n {variant}: run {{ARGS}}\n---\n\n" + "{PRE_HOOK_SCRIPT}\n{POST_HOOK_SCRIPT}\n", encoding="utf-8" + ) + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code == 0, result.output + for name, variant, directory, suffix in ( + ("hello", "py", "python", "_hooks.py"), + ("other", "ps", "powershell", "-hooks.ps1"), + ): + rendered = (project_dir / f".github/agents/speckit.test-ext.{name}.agent.md").read_text( + encoding="utf-8" + ) + for phase in ("pre", "post"): + relative = f".specify/scripts/{directory}/{phase}{suffix}" + assert relative in rendered + assert (project_dir / relative).is_file() + + def test_add_rejects_unrunnable_hook_variant_before_install( + self, extension_dir, project_dir + ): + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}), encoding="utf-8" + ) + (extension_dir / "commands/hello.md").write_text( + "---\nscripts:\n unsupported: run\n---\n\n{PRE_HOOK_SCRIPT}\n", + encoding="utf-8", + ) + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"], catch_exceptions=True + ) + + assert result.exit_code != 0 + assert "No runnable script variant" in result.output + assert not ExtensionManager(project_dir).registry.is_installed("test-ext") + assert not (project_dir / ".specify/extensions/test-ext").exists() + assert not (project_dir / ".specify/extensions.yml").exists() + @pytest.mark.parametrize("existing", [ "hooks: [broken]\n", "hooks:\n before_plan:\n - extension: orphan\n", diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 9bc24cc3a7..883683bbe4 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -117,6 +117,26 @@ def test_invalid_config_reports_error(tmp_path, text, part, phase): assert data["hooks"] == [] +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("invalid_event", ["invalid_event", "before_Bad"]) +def test_legacy_rejects_invalid_event_even_when_other_hooks_are_valid( + tmp_path, variant, phase, invalid_event +): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + requested = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, yaml.safe_dump({"hooks": { + requested: [{"extension": "git", "command": "speckit.git.commit"}], + invalid_event: [], + }}, sort_keys=False)) + + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "hook event" in data["error"] + + @pytest.mark.parametrize("priority,expected", [ (False, 10), (0, 10), ("bad", 10), ("2", 2), (2.8, 2), ]) From c276bcf7d019367dcdf94da0b5726ea500c2c3d9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:59:29 -0500 Subject: [PATCH 15/16] Reject symlinked hook config and closed-map entries Guard direct projection saves against symlinked extension configuration before touching the cache, and reject nested YAML entries after an inline empty hooks map. Cover outside-project writes and native resolver parity. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/extensions.md | 4 +- scripts/bash/pre-hooks.sh | 5 +- src/specify_cli/extensions/__init__.py | 2 + tests/test_hook_dispatch_scripts.py | 87 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 07d8b91442..9d34956604 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -351,11 +351,13 @@ Core commands run two hook dispatchers, one before and one after their main work `specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. They recheck the snapshot before returning, so a concurrent save cannot silently return a mixed response; a reader caught mid-save fails and can be retried. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. +Direct CLI configuration saves refuse a symlinked `.specify/extensions.yml` or `.specify` directory before changing the projection, rather than following the link outside the project. + The CLI validates hook fields across every configured event when materializing the projection. Extension installation checks projected hook event names (`before_...` or `after_...`) before modifying the project; manifest inspection still accepts other event names. Priorities are truncated to integers in the range `1` through `2147483647`; values outside this range, booleans, and non-integer strings fall back to `10`. Quoted integer strings are accepted, but quoted decimal strings are not. Native dispatchers also recognize the numeric YAML forms accepted by the Python resolver, including digit separators, hex/binary/octal integers, sexagesimal integers, and decimal floats with exponents. The canonical writer quotes strings that would otherwise be ambiguous to the native parsers and preserves YAML control-character escapes. A NUL character in hook configuration is rejected explicitly because Bash cannot represent it. -Legacy Bash and PowerShell resolvers (before projection generation) support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, `before_...` or `after_...` event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). All three legacy resolvers reject unsupported event names, including names for events other than the one being resolved. Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported by the legacy path. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout and generates its projection (YAML comments and formatting are not retained). The Python legacy path accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. +Legacy Bash and PowerShell resolvers (before projection generation) support the CLI's pretty-printed `.specify/extensions.yml` layout: a `hooks:` mapping, `before_...` or `after_...` event names indented two spaces, list items under each event, and single-line scalar fields (including quoted strings and common escapes). All three legacy resolvers reject unsupported event names, including names for events other than the one being resolved. An inline empty `hooks: {}` mapping cannot contain nested entries. Other YAML constructs such as anchors, flow-style hook lists, and block scalars are not supported by the legacy path. The CLI writes hook configuration in this canonical layout without wrapping long scalar values; installing or upgrading shared infrastructure rewrites existing valid YAML to this layout and generates its projection (YAML comments and formatting are not retained). The Python legacy path accepts general YAML through PyYAML. A hook `condition` must be a string or null in all variants. ## FAQ diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index 323f70de38..d4457da427 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -292,7 +292,7 @@ hook_projection_shape_valid() { resolve_hooks() { local phase=$1 command=$2 event config line spaces indent text key value - local section="" target=false empty=false item_indent=-1 installed_indent=-1 index=0 seen=false seen_hooks=false seen_event=false + local section="" target=false empty=false hooks_empty=false item_indent=-1 installed_indent=-1 index=0 seen=false seen_hooks=false seen_event=false local i n chosen first=true local -a HOOK_EXT HOOK_CMD HOOK_ENABLED HOOK_OPTIONAL HOOK_CONDITION local -a HOOK_PRIORITY HOOK_PRIORITY_QUOTED HOOK_DESCRIPTION HOOK_PROMPT HOOK_RANKS HOOK_USED HOOK_EVENTS HOOK_TARGETS @@ -390,6 +390,8 @@ resolve_hooks() { seen_hooks=true section=hooks empty=false + hooks_empty=false + [[ $text == 'hooks: {}' ]] && hooks_empty=true elif [[ $text == hooks:* ]]; then HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping" elif [[ $text == 'installed:' || $text == 'installed: []' ]]; then @@ -417,6 +419,7 @@ resolve_hooks() { [[ -n $HOOK_ERROR ]] && break continue fi + if [[ $hooks_empty == true ]]; then HOOK_ERROR="Unsupported YAML hook layout"; break; fi if (( indent == 2 )) && [[ $text != '- '* && $text =~ ^([^:]+):([[:space:]]|$) ]]; then key=${BASH_REMATCH[1]} if [[ ! $key =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index d7d9509359..ac1784b3cf 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -5913,6 +5913,8 @@ def represent_string(dumper: yaml.SafeDumper, value: str): lock = self.config_file.parent / ".hook-dispatch.lock" with changing_file(lock): with _exclusive_project_lock(self.project_root, lock.name, context="hook dispatch"): + if self.config_file.parent.is_symlink() or self.config_file.is_symlink(): + raise ValueError("Refusing to save symlinked .specify/extensions.yml") cache = self.config_file.parent / "hook-dispatch" if cache.is_symlink(): raise ValueError("Refusing to write symlinked hook dispatch cache") diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 883683bbe4..49a6eb16da 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -117,6 +117,32 @@ def test_invalid_config_reports_error(tmp_path, text, part, phase): assert data["hooks"] == [] +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_legacy_empty_hooks_map_rejects_nested_entries(tmp_path, variant, phase): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + event = f"{'before' if phase == 'pre' else 'after'}_plan" + write_config(tmp_path, ( + f"hooks: {{}}\n {event}:\n" + " - extension: git\n command: speckit.git.commit\n" + )) + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert data["error"] + + +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +def test_legacy_empty_hooks_map_without_entries_is_valid(tmp_path, variant): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, "hooks: {}\nsettings:\n auto_execute_hooks: true\n") + assert run_hook(tmp_path, variant=variant) == ( + 0, {"event": "before_plan", "hooks": []} + ) + + @pytest.mark.parametrize("variant", ["py", "sh", "ps"]) @pytest.mark.parametrize("phase", ["pre", "post"]) @pytest.mark.parametrize("invalid_event", ["invalid_event", "before_Bad"]) @@ -1011,6 +1037,67 @@ def test_canonical_writer_rejects_nul_without_touching_config(tmp_path): assert config.read_text(encoding="utf-8") == "hooks: {}\n" +@pytest.mark.parametrize("symlinked", ["config", "directory"]) +def test_direct_save_rejects_symlinks_without_writing_outside_project( + tmp_path, symlinked +): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "project" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "extensions.yml").write_text("outside remains unchanged\n", encoding="utf-8") + specify = project / ".specify" + try: + if symlinked == "config": + specify.mkdir() + (specify / "extensions.yml").symlink_to(outside / "extensions.yml") + else: + specify.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are unavailable") + + with pytest.raises((ValueError, OSError), match="symlink"): + HookExecutor(project).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + + assert (outside / "extensions.yml").read_text(encoding="utf-8") == ( + "outside remains unchanged\n" + ) + assert not (outside / "hook-dispatch").exists() + + +def test_direct_save_symlink_preserves_existing_projection(tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "project" + project.mkdir() + executor = HookExecutor(project) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + cache = project / ".specify/hook-dispatch" + original = {path.name: path.read_bytes() for path in cache.iterdir()} + outside = tmp_path / "outside.yml" + outside.write_text("untouched\n", encoding="utf-8") + executor.config_file.unlink() + try: + executor.config_file.symlink_to(outside) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are unavailable") + + with pytest.raises(ValueError, match="symlinked"): + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + + assert executor.config_file.is_symlink() + assert outside.read_text(encoding="utf-8") == "untouched\n" + assert {path.name: path.read_bytes() for path in cache.iterdir()} == original + + @pytest.mark.parametrize("variant", ["sh", "ps", "py"]) @pytest.mark.parametrize("value", [ "JSvjEuqRsqs$tn]X", "N1Y'dombB*Afj9e;SO}^0^S'", "prefix{suffix", From 74cd9a7e5a9fe9586ed009557028b8bef5c7e0b1 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:41:11 -0500 Subject: [PATCH 16/16] Normalize tracked hook projections across CRLF checkouts Compare YAML snapshots and verify event and response digests after CRLF-to-LF normalization in all three self-contained resolvers. Keep missing and corrupt projections as errors and cover mixed and full Git line-ending conversion. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/integration.md | 3 +- docs/reference/extensions.md | 2 +- scripts/bash/pre-hooks.sh | 56 ++++++++++++++----------- scripts/powershell/pre-hooks.ps1 | 28 ++++++++++--- scripts/python/pre_hooks.py | 14 ++++--- tests/test_hook_dispatch_scripts.py | 63 +++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 37 deletions(-) diff --git a/design/integration.md b/design/integration.md index ef02113740..5dff2f996d 100644 --- a/design/integration.md +++ b/design/integration.md @@ -85,7 +85,8 @@ for each hook-bearing command's selected variant, including fallback variants when the project preference is unavailable. The CLI validates hook configuration on write and materializes ordered per-event JSON with a snapshot of `.specify/extensions.yml`. CLI writers serialize publication, and each variant checks the snapshot, -event-index digest, and generated response digest before returning hooks. Each +event-index digest, and generated response digest after normalizing CRLF line +endings to LF, so tracked projections survive Git checkout conversion. Each reads the projection in its own runtime, without depending on another variant or on runtime PyYAML; it rejects stale, corrupt, or missing projections. The legacy YAML resolver remains for projects not yet refreshed. The resolver diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 9d34956604..f59ada3c24 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -349,7 +349,7 @@ Extension manifests reject invalid hook priorities during installation. For exis Core commands run two hook dispatchers, one before and one after their main work, using the selected `sh`, `ps`, or `py` variant of each command (which may fall back from the project's preferred script type). Installing a hook-bearing extension command provisions its selected dispatchers, including when the extension is updated. Each variant returns JSON containing the event and an ordered `hooks` array (`extension`, `command`, `optional`, `description`, `prompt`, `priority`). An absent configuration or event returns an empty array. An unreadable or invalid configuration returns an `error` and a nonzero exit code; agents must report that no hooks were checked, including mandatory hooks, and then continue the core command. The dispatchers determine the order but do not execute agent commands. -`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. They recheck the snapshot before returning, so a concurrent save cannot silently return a mixed response; a reader caught mid-save fails and can be retried. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. +`specify extension add` and other CLI operations that save `.specify/extensions.yml` also materialize validated, ordered JSON responses under `.specify/hook-dispatch/`. All three script variants use this projection without parsing YAML; the Python script needs only the standard library (but still requires Python 3.11+ on the execution host), and Bash and PowerShell do not invoke Python. CLI saves serialize publication, and the scripts compare the saved YAML snapshot and verify the event index and each response against their generated SHA-256 digests. Readers normalize CRLF to LF before comparison and hashing, so Git line-ending conversion of tracked projection files does not invalidate the generated digests; standalone CR bytes are not normalized. They recheck the snapshot before returning, so a concurrent save cannot silently return a mixed response; a reader caught mid-save fails and can be retried. They fail with refresh guidance if the configuration was manually edited, a projected response was corrupted, the projection is incomplete, or its directory is missing. Bash requires `sed` and either `sha256sum` or `shasum` on the execution host to verify projected responses. Reinstall the affected extension with `specify extension add --force` or refresh the project to regenerate it. Existing projects without a projection continue using their installed YAML resolvers until refreshed; their Python resolver still requires PyYAML. Direct CLI configuration saves refuse a symlinked `.specify/extensions.yml` or `.specify` directory before changing the projection, rather than following the link outside the project. diff --git a/scripts/bash/pre-hooks.sh b/scripts/bash/pre-hooks.sh index d4457da427..31e6483b8d 100755 --- a/scripts/bash/pre-hooks.sh +++ b/scripts/bash/pre-hooks.sh @@ -256,31 +256,37 @@ hook_priority() { (( HOOK_RANK >= 1 && HOOK_RANK <= 2147483647 )) || HOOK_RANK=10 } -hook_digest_valid() { - local path=$1 digest=$2 expected actual - [[ -f $path && -f $digest ]] || return 1 - IFS= read -r expected < "$digest" - if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum -- "$path") || return 1 - elif command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 -- "$path") || return 1 - else - HOOK_ERROR="Cannot validate hook projection: sha256sum or shasum is required" - return 1 - fi - [[ $expected =~ ^[0-9a-f]{64}$ && ${actual%% *} == "$expected" ]] +hook_canonical_file() { + local cr=$'\r' + LC_ALL=C sed "s/${cr}\$//" < "$1" } hook_file_digest() { local output if command -v sha256sum >/dev/null 2>&1; then - output=$(sha256sum -- "$1") || return 1 + output=$(set -o pipefail; hook_canonical_file "$1" | sha256sum) || return 1 + elif command -v shasum >/dev/null 2>&1; then + output=$(set -o pipefail; hook_canonical_file "$1" | shasum -a 256) || return 1 else - output=$(shasum -a 256 -- "$1") || return 1 + return 1 fi printf '%s' "${output%% *}" } +hook_digest_valid() { + local path=$1 digest=$2 expected actual + [[ -f $path && -f $digest ]] || return 1 + IFS= read -r expected < "$digest" + expected=${expected%$'\r'} + if ! command -v sha256sum >/dev/null 2>&1 && + ! command -v shasum >/dev/null 2>&1; then + HOOK_ERROR="Cannot validate hook projection: sha256sum or shasum is required" + return 1 + fi + actual=$(hook_file_digest "$path") || return 1 + [[ $expected =~ ^[0-9a-f]{64}$ && $actual == "$expected" ]] +} + hook_projection_shape_valid() { local response=$1 event=$2 local string='"([^"\\[:cntrl:]]|\\(["\\/bfnrt]|u[[:xdigit:]]{4}))*"' @@ -311,8 +317,7 @@ resolve_hooks() { fi if [[ -d .specify/hook-dispatch ]]; then if [[ ! -f $config || ! -f .specify/hook-dispatch/source.yml || - ! -f .specify/hook-dispatch/events.txt ]] || - ! cmp -s -- "$config" .specify/hook-dispatch/source.yml; then + ! -f .specify/hook-dispatch/events.txt ]]; then hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" return 1 fi @@ -321,8 +326,10 @@ resolve_hooks() { hook_error "$event" "${HOOK_ERROR:-Hook projection index is invalid; reinstall the extension or refresh the project}" return 1 fi - local source_digest result - if ! source_digest=$(hook_file_digest "$config"); then + local source_digest snapshot_digest current_digest result + if ! source_digest=$(hook_file_digest "$config") || + ! snapshot_digest=$(hook_file_digest .specify/hook-dispatch/source.yml) || + [[ $source_digest != "$snapshot_digest" ]]; then hook_error "$event" "Hook projection is stale; reinstall the extension or refresh the project" return 1 fi @@ -333,20 +340,21 @@ resolve_hooks() { return 1 fi if ! IFS= read -r response < "$projection" || - ! hook_projection_shape_valid "$response" "$event"; then + ! hook_projection_shape_valid "${response%$'\r'}" "$event"; then hook_error "$event" "Invalid hook projection; reinstall the extension or refresh the project" return 1 fi - result=$response - elif grep -Fxq -- "$event" .specify/hook-dispatch/events.txt; then + result=${response%$'\r'} + elif grep -Fxq -- "$event" <(hook_canonical_file .specify/hook-dispatch/events.txt); then hook_error "$event" "Hook projection is incomplete; reinstall the extension or refresh the project" return 1 else result="{\"event\":\"$event\",\"hooks\":[]}" fi if [[ ! -f .specify/hook-dispatch/source.yml ]] || - ! cmp -s -- "$config" .specify/hook-dispatch/source.yml || - [[ $(hook_file_digest "$config") != "$source_digest" ]]; then + ! current_digest=$(hook_file_digest "$config") || + ! snapshot_digest=$(hook_file_digest .specify/hook-dispatch/source.yml) || + [[ $current_digest != "$source_digest" || $snapshot_digest != "$source_digest" ]]; then hook_error "$event" "Hook projection changed during resolution; retry the command" return 1 fi diff --git a/scripts/powershell/pre-hooks.ps1 b/scripts/powershell/pre-hooks.ps1 index 3dcc26b2ea..4085263b71 100644 --- a/scripts/powershell/pre-hooks.ps1 +++ b/scripts/powershell/pre-hooks.ps1 @@ -259,6 +259,22 @@ function Resolve-HookConfig { return $result } +function ConvertTo-HookLfBytes { + param([byte[]]$Bytes) + $stream = [IO.MemoryStream]::new($Bytes.Length) + try { + for ($i = 0; $i -lt $Bytes.Length; $i++) { + if ($Bytes[$i] -eq 13 -and $i + 1 -lt $Bytes.Length -and $Bytes[$i + 1] -eq 10) { + continue + } + $stream.WriteByte($Bytes[$i]) + } + return ,$stream.ToArray() + } finally { + $stream.Dispose() + } +} + function Invoke-HookResolver { param([string]$Phase, [string]$Command) $event = "${Phase}_${Command}" @@ -283,15 +299,15 @@ function Invoke-HookResolver { -not (Test-Path -LiteralPath $events -PathType Leaf)) { throw "Hook projection is stale; reinstall the extension or refresh the project" } - $source = [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) - if ($source -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { + $source = [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($config)))) + if ($source -cne [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($snapshot))))) { throw "Hook projection is stale; reinstall the extension or refresh the project" } $indexDigest = Join-Path $cache 'events.txt.sha256' if (-not (Test-Path -LiteralPath $indexDigest -PathType Leaf)) { throw "Hook projection index is incomplete; reinstall the extension or refresh the project" } - $eventBytes = [IO.File]::ReadAllBytes($events) + $eventBytes = ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($events)) $eventHash = [IO.File]::ReadAllText($indexDigest, [Text.Encoding]::UTF8).Trim() if ($eventHash -cnotmatch '^[0-9a-f]{64}$' -or [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($eventBytes)) -cne $eventHash.ToUpperInvariant()) { @@ -303,7 +319,7 @@ function Invoke-HookResolver { if (-not (Test-Path -LiteralPath $digest -PathType Leaf)) { throw "Hook projection is incomplete; reinstall the extension or refresh the project" } - $bytes = [IO.File]::ReadAllBytes($projected) + $bytes = ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($projected)) $expected = [IO.File]::ReadAllText($digest, [Text.Encoding]::UTF8).Trim() if ($expected -cnotmatch '^[0-9a-f]{64}$' -or [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -cne $expected.ToUpperInvariant()) { @@ -333,8 +349,8 @@ function Invoke-HookResolver { } else { $result = @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress } - if ($source -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($config)) -or - $source -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($snapshot))) { + if ($source -cne [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($config)))) -or + $source -cne [Convert]::ToBase64String((ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($snapshot))))) { throw "Hook projection changed during resolution; retry the command" } [Console]::WriteLine($result) diff --git a/scripts/python/pre_hooks.py b/scripts/python/pre_hooks.py index 9167c5719a..69a215a0a3 100644 --- a/scripts/python/pre_hooks.py +++ b/scripts/python/pre_hooks.py @@ -13,6 +13,10 @@ _EVENT = re.compile(r"^(before|after)_[a-z][a-z0-9_]*$") +def _lf_bytes(data: bytes) -> bytes: + return data.replace(b"\r\n", b"\n") + + def resolve(event: str, project_root: Path) -> dict: """Return enabled, unconditional hooks in priority order.""" if not _EVENT.fullmatch(event): @@ -22,10 +26,10 @@ def resolve(event: str, project_root: Path) -> dict: cache = project_root / ".specify" / "hook-dispatch" if cache.is_dir(): try: - source = config_file.read_bytes() - if source != (cache / "source.yml").read_bytes(): + source = _lf_bytes(config_file.read_bytes()) + if source != _lf_bytes((cache / "source.yml").read_bytes()): raise ValueError("Hook projection is stale; reinstall the extension or refresh the project") - event_bytes = (cache / "events.txt").read_bytes() + event_bytes = _lf_bytes((cache / "events.txt").read_bytes()) index_digest = (cache / "events.txt.sha256").read_text(encoding="utf-8").strip() if not re.fullmatch("[0-9a-f]{64}", index_digest) or hashlib.sha256(event_bytes).hexdigest() != index_digest: raise ValueError("Hook projection index is invalid; reinstall the extension or refresh the project") @@ -37,11 +41,11 @@ def resolve(event: str, project_root: Path) -> dict: result = {"event": event, "hooks": []} else: expected = (cache / f"{event}.sha256").read_text(encoding="utf-8").strip() - payload = projection.read_bytes() + payload = _lf_bytes(projection.read_bytes()) if not re.fullmatch("[0-9a-f]{64}", expected) or hashlib.sha256(payload).hexdigest() != expected: raise ValueError("Hook projection is invalid; reinstall the extension or refresh the project") result = json.loads(payload.decode("utf-8")) - if source != config_file.read_bytes() or source != (cache / "source.yml").read_bytes(): + if source != _lf_bytes(config_file.read_bytes()) or source != _lf_bytes((cache / "source.yml").read_bytes()): raise ValueError("Hook projection changed during resolution; retry the command") except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise ValueError(f"Could not read hook projection: {exc}") from exc diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py index 49a6eb16da..264ece448c 100644 --- a/tests/test_hook_dispatch_scripts.py +++ b/tests/test_hook_dispatch_scripts.py @@ -295,6 +295,69 @@ def test_materialized_projection_resolves_without_yaml_or_native_parsing(tmp_pat assert json.loads(result.stdout) == data +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("conversion", ["cache", "all"]) +def test_projection_accepts_git_crlf_checkout(tmp_path, variant, phase, conversion): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({"hooks": {event: [ + {"extension": "git", "command": "speckit.git.commit", "optional": False}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + converted = list(cache.iterdir()) + if conversion == "all": + converted.append(tmp_path / ".specify/extensions.yml") + for path in converted: + data = path.read_bytes() + path.write_bytes(data.replace(b"\n", b"\r\n")) + + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 0, data + assert [entry["command"] for entry in data["hooks"]] == ["speckit.git.commit"] + assert data["hooks"][0]["optional"] is False + assert run_hook(tmp_path, phase, name="tasks", variant=variant) == ( + 0, {"event": f"{'before' if phase == 'pre' else 'after'}_tasks", "hooks": []} + ) + + (cache / f"{event}.json").unlink() + code, data = run_hook(tmp_path, phase, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "incomplete" in data["error"] + + +@pytest.mark.parametrize("variant", ["sh", "ps", "py"]) +@pytest.mark.parametrize("corruption", ["projection", "index"]) +def test_crlf_projection_still_rejects_corrupt_content(tmp_path, variant, corruption): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + from specify_cli.extensions import HookExecutor + + HookExecutor(tmp_path).save_project_config({"hooks": {"before_plan": [ + {"extension": "git", "command": "speckit.git.commit"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + for path in cache.iterdir(): + path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) + if corruption == "projection": + projection = cache / "before_plan.json" + projection.write_bytes(projection.read_bytes().replace( + b"speckit.git.commit", b"speckit.bad.commit" + )) + else: + index = cache / "events.txt" + index.write_bytes(index.read_bytes().replace(b"before_plan", b"after_plan")) + + code, data = run_hook(tmp_path, variant=variant) + assert code == 1 + assert data["hooks"] == [] + assert "invalid" in data["error"] + + @pytest.mark.parametrize("variant", ["sh", "ps", "py"]) def test_projection_rejects_stale_yaml_and_recovers_after_refresh(tmp_path, variant): if variant == "ps" and not shutil.which("pwsh"):