diff --git a/design/integration.md b/design/integration.md index b110bbafca..4dd8a31238 100644 --- a/design/integration.md +++ b/design/integration.md @@ -78,6 +78,26 @@ 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; 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 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 +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. Preset installation +provisions selected dispatchers only when the effective command references +hooks; removal resolves the surviving command without the removed preset and +provisions its selected variant before restoring hook-bearing lower layers in +older projects that lack them. + ## External adapter package contract A standalone ZIP, tar.gz, or tgz archive needs only these root files (a single diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index e39ff9d697..a56bf00c03 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -295,7 +295,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: @@ -337,17 +337,30 @@ 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` | Projected hook entries use integer values from `1` through `2147483647`; 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 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/`. Enable, disable, and remove reject malformed hook configuration, a non-directory hook-dispatch cache, or non-file entries in the cache before changing extension registry entries or installed files; direct configuration saves check cache entries before deleting the previous snapshot. Read-only hook queries remain tolerant of malformed configuration. 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. + +Shared infrastructure installation validates existing hook configuration before writing shared scripts or their manifest. +Preset installation provisions missing hook dispatchers only when the effective command references them; removal resolves the surviving command and provisions its selected variant before restoring hook-bearing lower layers. Replace-only commands without hook references do not require dispatchers. + +Direct CLI configuration saves and extension mutations refuse a symlinked `.specify/extensions.yml` (including dangling links) or `.specify` directory before changing extension state or 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. Empty events require explicit `[]` and an empty hooks mapping requires `hooks: {}`; bare keys are invalid rather than empty hook lists. 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/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index 2be2f43123..9403821087 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -663,26 +663,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/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/README.md b/extensions/github/README.md index 8aaf46a5c3..44e438f1bc 100644 --- a/extensions/github/README.md +++ b/extensions/github/README.md @@ -53,12 +53,14 @@ 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 - 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/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/extensions/github/extension.yml b/extensions/github/extension.yml index c6df8f19a1..03ec75a617 100644 --- a/extensions/github/extension.yml +++ b/extensions/github/extension.yml @@ -3,18 +3,16 @@ 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 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/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..e47b912243 --- /dev/null +++ b/scripts/bash/pre-hooks.sh @@ -0,0 +1,525 @@ +#!/usr/bin/env bash + +hook_json_string() { + local value=$1 code hex char + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\n'/\\n} + value=${value//$'\r'/\\r} + 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" + printf ',"hooks":[],"error":' + hook_json_string "$2" + printf '}\n' + return 1 +} + +hook_scalar() { + local raw=$1 inner c escaped i digits count + 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' ;; + 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 + done + return + fi + raw=${raw%%' #'*} + while [[ $raw == *' ' ]]; do raw=${raw% }; done + if [[ $raw == null || $raw == Null || $raw == NULL || $raw == '~' ]]; then raw=""; fi + case $raw in + *'['*|*']'*|*'{'*|*'}'*|*': '*|'&'*|'*'*|'!'*|'|'*|'>'*) + HOOK_ERROR="Unsupported YAML scalar"; return ;; + esac + 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 + 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 == 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 + 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" + return + fi + fi + if [[ $key == description || $key == prompt ]]; then + if [[ $HOOK_QUOTED == false && -n $HOOK_SCALAR ]] && + hook_typed_scalar "$HOOK_SCALAR"; 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; HOOK_PRIORITY_QUOTED[index]=$HOOK_QUOTED ;; + description) HOOK_DESCRIPTION[index]=$HOOK_SCALAR ;; + prompt) HOOK_PROMPT[index]=$HOOK_SCALAR ;; + 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 frac="" exponent=0 shift digits part octal=false + HOOK_RANK=10 + [[ $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 +} + +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=$(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 + 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 rest priority + 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 ]] || return 1 + rest=$response + while [[ $rest =~ \"priority\":([1-9][0-9]*) ]]; do + priority=${BASH_REMATCH[1]} + (( ${#priority} < 10 || (${#priority} == 10 && priority <= 2147483647) )) || return 1 + rest=${rest#*${BASH_REMATCH[0]}} + done +} + +resolve_hooks() { + local phase=$1 command=$2 event config line spaces indent text key value + local section="" target=false empty=false hooks_empty=false pending_hooks=false item_indent=-1 installed_indent=-1 index=0 seen=false seen_hooks=false seen_event=false pending_event="" + 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 + event="${phase}_${command}" + if [[ ! $event =~ ^(before|after)_[a-z][a-z0-9_]*$ ]]; then + hook_error "$event" "Invalid hook event: $event" + 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 ]]; then + 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 + 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 + 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 + 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" || + ! 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%$'\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 ]] || + ! 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 + printf '%s\n' "$result" + return + fi + 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 [[ $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}" + fi + [[ -n $HOOK_ERROR ]] && break + continue + fi + if (( indent == 0 )); then + if [[ -n $pending_event ]]; then HOOK_ERROR="hooks.$pending_event must be a list"; break; fi + if [[ $pending_hooks == true ]]; then HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping"; break; fi + 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 + section=hooks + empty=false + hooks_empty=false + [[ $text == 'hooks: {}' ]] && hooks_empty=true + pending_hooks=false + [[ $text == 'hooks:' ]] && pending_hooks=true + 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 == 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 + key=${text%%:*} + hook_scalar "${text:${#key}+1}" + [[ -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 + if [[ -n $pending_event ]]; then HOOK_ERROR="hooks.$pending_event must be a list"; break; fi + key=${BASH_REMATCH[1]} + pending_hooks=false + 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 + 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 + pending_event="" + [[ -z $value ]] && pending_event=$key + item_indent=-1 + continue + fi + if [[ $text == '- '* && ( $indent == 2 || $indent == 4 ) && $empty == false ]]; then + pending_event="" + item_indent=$indent + 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" + 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 && $pending_hooks == true ]]; then HOOK_ERROR="Invalid .specify/extensions.yml: expected a hooks mapping"; fi + if [[ -z $HOOK_ERROR && -n $pending_event ]]; then HOOK_ERROR="hooks.$pending_event must be a list"; fi + if [[ -z $HOOK_ERROR ]]; then + for ((i=1; i<=index; i++)); do + if [[ -z ${HOOK_EXT[i]} || -z ${HOOK_CMD[i]} ]]; then + 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 + 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..9c85b30423 --- /dev/null +++ b/scripts/powershell/pre-hooks.ps1 @@ -0,0 +1,395 @@ +#!/usr/bin/env pwsh + +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + +function Get-HookSha256 { + param([byte[]]$Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '') } + finally { $sha.Dispose() } +} + +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" } + $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() + if ($raw -cmatch '^(~|null|Null|NULL)$') { $raw = '' } + if ($raw -match '[\[\]{}]|: |^[&*!|>]') { throw "Unsupported YAML scalar" } + return @{ Value = $raw; Quoted = $false } +} + +function Add-HookField { + 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 + $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" + } + 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-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 + $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, [bool]$Quoted) + $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 { + 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 + $pendingHooks = $false + $seenHooks = $false + $seenEvent = $false + $target = $false + $empty = $false + $pendingEvent = '' + $itemIndent = -1 + $installedIndent = -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 ($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 + } + if ($indent -eq 0) { + if ($pendingEvent) { throw "hooks.$pendingEvent must be a list" } + if ($pendingHooks) { throw "Invalid .specify/extensions.yml: expected a hooks mapping" } + $target = $false + $installedIndent = -1 + if ($body -in @('hooks:', 'hooks: {}')) { + if ($seenHooks) { throw "Duplicate hooks mapping" } + $seenHooks = $true + $inHooks = $true + $section = 'hooks' + $hooksEmpty = $body -eq 'hooks: {}' + $pendingHooks = $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 '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 + } + throw "Unsupported YAML top-level layout" + } + if ($hooksEmpty) { throw "Unsupported YAML hook layout" } + if ($indent -eq 2 -and -not $body.StartsWith('- ') -and + $body -match '^([^:]+):(?:\s|$)(.*)$') { + if ($pendingEvent) { throw "hooks.$pendingEvent must be a list" } + $key = $Matches[1] + $pendingHooks = $false + $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" } + $seenEvent = $true + } + if ($value -notin @('', '[]')) { throw "hooks.$key must be a list" } + $empty = $value -eq '[]' + $pendingEvent = if ($value -eq '') { $key } else { '' } + $itemIndent = -1 + continue + } + if ($indent -in @(2, 4) -and $body.StartsWith('- ') -and -not $empty) { + $pendingEvent = '' + $itemIndent = $indent + $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 + } + if ($itemIndent -ge 0 -and $indent -eq $itemIndent + 2) { + Add-HookField $hook $body $Event + continue + } + throw "Unsupported YAML hook layout" + } + if ($pendingEvent) { throw "hooks.$pendingEvent must be a list" } + if ($pendingHooks) { throw "Invalid .specify/extensions.yml: expected a hooks mapping" } + $ordered = [Collections.Generic.List[hashtable]]::new() + for ($i = 0; $i -lt $hooks.Count; $i++) { + $hook = $hooks[$i] + if (-not $hook.extension -or -not $hook.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 + optional = $hook.optional -eq 'true' + description = if ($hook.description) { $hook.description } else { '' } + prompt = if ($hook.prompt) { $hook.prompt } else { '' } + priority = Get-HookPriority $hook.priority $hook.priority_quoted + 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 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}" + try { + if ($event -cnotmatch '^(before|after)_[a-z][a-z0-9_]*$') { + 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)) { + throw "Hook projection is stale; reinstall the extension or refresh the project" + } + $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 = ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($events)) + $eventHash = [IO.File]::ReadAllText($indexDigest, [Text.Encoding]::UTF8).Trim() + if ($eventHash -cnotmatch '^[0-9a-f]{64}$' -or + (Get-HookSha256 $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" + if (-not (Test-Path -LiteralPath $digest -PathType Leaf)) { + throw "Hook projection is incomplete; reinstall the extension or refresh the project" + } + $bytes = ConvertTo-HookLfBytes ([IO.File]::ReadAllBytes($projected)) + $expected = [IO.File]::ReadAllText($digest, [Text.Encoding]::UTF8).Trim() + if ($expected -cnotmatch '^[0-9a-f]{64}$' -or + (Get-HookSha256 $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 -ErrorAction Stop } + catch { throw "Invalid hook projection: $($_.Exception.Message)" } + if ($data -isnot [pscustomobject] -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 [pscustomobject] -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] -and $entry.priority -isnot [int]) -or + $entry.priority -lt 1 -or $entry.priority -gt 2147483647) { + throw "Invalid hook projection; reinstall the extension or refresh the project" + } + } + $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 { + $result = @{ event = $event; hooks = @() } | ConvertTo-Json -Depth 5 -Compress + } + 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) + exit 0 + } + $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..360eccfd87 --- /dev/null +++ b/scripts/python/pre_hooks.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Resolve extension hooks for a core command's before event.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +_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): + 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: + 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 = _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") + events = event_bytes.decode("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") + result = {"event": event, "hooks": []} + else: + expected = (cache / f"{event}.sha256").read_text(encoding="utf-8").strip() + 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 != _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 + 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 not 1 <= hook["priority"] <= 2147483647): + 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": []} + + 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") + + 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): + 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") + 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") + 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 + 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} + + +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/__init__.py b/src/specify_cli/__init__.py index 544ef1cfef..c68bb68585 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -140,6 +140,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*. @@ -162,6 +163,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 @@ -172,7 +176,11 @@ def _install_shared_infra( Returns ``True`` on success. """ - return _install_shared_infra_impl( + from .extensions import HookExecutor + + hook_executor = HookExecutor(project_path) + hook_executor.load_project_config_for_update() + installed = _install_shared_infra_impl( project_path, script_type, version=get_speckit_version(), @@ -184,7 +192,10 @@ def _install_shared_infra( invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, + hook_dispatchers_only=hook_dispatchers_only, ) + hook_executor.migrate_project_config() + return installed def _install_shared_infra_or_exit( diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 72966998a5..59a14596c3 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -556,6 +556,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 d03cb85770..218902114b 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 @@ -432,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): @@ -2461,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) @@ -2579,6 +2597,73 @@ def install_from_directory( f"extension. Install from a copy in a different location instead." ) + 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(): + 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 + 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 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 hook_variants: + from .. import _install_shared_infra + from ..integrations.base import get_invocation_prefix + + options = active_options if isinstance(active_options, dict) else {} + skills = is_ai_skills_enabled(options) + 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 = ( @@ -3543,6 +3628,8 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if not self.registry.is_installed(extension_id): return False + HookExecutor(self.project_root).load_project_config_for_update() + # Get registered commands and skills before removal metadata = self.registry.get(extension_id) registered_commands = ( @@ -5753,22 +5840,160 @@ def get_project_config(self) -> Dict[str, Any]: "hooks": {}, } - def save_project_config(self, config: Dict[str, Any]): + @staticmethod + def _validate_hook_dispatch_cache(cache: Path) -> None: + """Reject cache paths that cannot be safely replaced by regular files.""" + try: + if cache.is_symlink(): + raise ValueError("Refusing to use symlinked hook dispatch cache") + if cache.exists() and not cache.is_dir(): + raise ValueError("Refusing to use non-directory hook dispatch cache") + if cache.is_dir(): + for path in cache.iterdir(): + if path.is_symlink(): + raise ValueError("Refusing to use symlinked hook dispatch cache entry") + if not path.is_file(): + raise ValueError(f"Refusing to use non-file hook dispatch cache entry: {path.name}") + except OSError as exc: + raise ValueError(f"Cannot inspect hook dispatch cache: {exc}") from exc + + def load_project_config_for_update(self) -> Dict[str, Any]: + """Load and validate hook configuration before any extension mutation.""" + if self.config_file.parent.is_symlink(): + raise ValueError("Refusing to load symlinked .specify/extensions.yml") + cache = self.config_file.parent / "hook-dispatch" + self._validate_hook_dispatch_cache(cache) + if self.config_file.is_symlink(): + raise ValueError("Refusing to load symlinked .specify/extensions.yml") + if not self.config_file.exists(): + return {"installed": [], "settings": {"auto_execute_hooks": True}, "hooks": {}} + try: + config = yaml.safe_load(self.config_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Invalid .specify/extensions.yml: {exc}") from exc + if not isinstance(config, dict): + raise ValueError("Invalid .specify/extensions.yml: expected a mapping") + self.save_project_config(config, validate_only=True) + return config + + def save_project_config(self, config: Dict[str, Any], *, validate_only: bool = False): """Save project-level extension configuration. Args: config: Configuration dictionary to save """ - from ..integrations._file_changes import write_text + class CanonicalHookDumper(yaml.SafeDumper): + pass - 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 - ), - encoding="utf-8", + def represent_string(dumper: yaml.SafeDumper, value: str): + 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) + from ..integrations._file_changes import changing_file, unlink, write_bytes, write_text + + hooks = config.get("hooks", {}) + if not isinstance(hooks, dict): + raise ValueError("Invalid .specify/extensions.yml: expected a hooks mapping") + 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}") + 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, + 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, ) + if validate_only: + return + 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"): + 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" + self._validate_hook_dispatch_cache(cache) + if not cache.exists(): + with changing_file(cache): + cache.mkdir(parents=True) + 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_bytes( + cache / f"{event}.sha256", + (hashlib.sha256(payload).hexdigest() + "\n").encode("ascii"), + ) + 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()) + + def migrate_project_config(self) -> None: + """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(): + 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. @@ -5776,7 +6001,7 @@ def register_extension(self, extension_id: str): Args: extension_id: ID of extension to register """ - config = self.get_project_config() + config = self.load_project_config_for_update() # Ensure config is a dict (defensive) if not isinstance(config, dict): @@ -5795,7 +6020,7 @@ def unregister_extension(self, extension_id: str): Args: extension_id: ID of extension to unregister """ - config = self.get_project_config() + config = self.load_project_config_for_update() if not isinstance(config, dict): config = {} @@ -5872,7 +6097,7 @@ def register_hooks(self, manifest: ExtensionManifest): # Always ensure the extension is in the installed list self.register_extension(manifest.id) - config = self.get_project_config() + config = self.load_project_config_for_update() # Ensure config is a dict (defensive) changed = False @@ -5975,7 +6200,7 @@ def unregister_hooks(self, extension_id: str): # Always remove from installed list (Feedback from review) self.unregister_extension(extension_id) - config = self.get_project_config() + config = self.load_project_config_for_update() if not isinstance(config, dict): config = {} @@ -6251,7 +6476,7 @@ def enable_hooks(self, extension_id: str): Args: extension_id: Extension ID """ - config = self.get_project_config() + config = self.load_project_config_for_update() if "hooks" not in config: return @@ -6270,7 +6495,7 @@ def disable_hooks(self, extension_id: str): Args: extension_id: Extension ID """ - config = self.get_project_config() + config = self.load_project_config_for_update() if "hooks" not in config: return diff --git a/src/specify_cli/extensions/command_disable.py b/src/specify_cli/extensions/command_disable.py index 16ef6683e1..5eeca2e5db 100644 --- a/src/specify_cli/extensions/command_disable.py +++ b/src/specify_cli/extensions/command_disable.py @@ -42,6 +42,12 @@ def extension_disable( console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already disabled[/yellow]") raise typer.Exit(0) + try: + config = hook_executor.load_project_config_for_update() + except ValueError as exc: + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc + from .. import load_init_options if load_init_options(project_root).get("ai") == "generic": @@ -56,7 +62,6 @@ def extension_disable( manager.registry.update(extension_id, {"enabled": False}) # Disable hooks in extensions.yml - config = hook_executor.get_project_config() if "hooks" in config: for hook_name in config["hooks"]: for hook in config["hooks"][hook_name]: diff --git a/src/specify_cli/extensions/command_enable.py b/src/specify_cli/extensions/command_enable.py index 34bab2f279..fcae1c90d1 100644 --- a/src/specify_cli/extensions/command_enable.py +++ b/src/specify_cli/extensions/command_enable.py @@ -42,6 +42,12 @@ def extension_enable( console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]") raise typer.Exit(0) + try: + config = hook_executor.load_project_config_for_update() + except ValueError as exc: + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc + manager.registry.update(extension_id, {"enabled": True}) from .. import load_init_options @@ -83,7 +89,6 @@ def extension_enable( raise typer.Exit(1) from exc # Enable hooks in extensions.yml - config = hook_executor.get_project_config() if "hooks" in config: for hook_name in config["hooks"]: for hook in config["hooks"][hook_name]: diff --git a/src/specify_cli/extensions/command_remove.py b/src/specify_cli/extensions/command_remove.py index c210dae96b..101bef2deb 100644 --- a/src/specify_cli/extensions/command_remove.py +++ b/src/specify_cli/extensions/command_remove.py @@ -68,7 +68,11 @@ def extension_remove( raise typer.Exit(0) # Remove extension - success = manager.remove(extension_id, keep_config=keep_config) + try: + success = manager.remove(extension_id, keep_config=keep_config) + except ValueError as exc: + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc if success: console.print(f"\n[green]✓[/green] Extension '{_escape_markup(str(display_name))}' removed successfully") diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 6eee0513d6..03cfd00eac 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", @@ -803,6 +810,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".specify/scripts/python/{name}_hooks.py", project_root + ) + elif variant == "ps": + command = f".specify/scripts/powershell/{name}-hooks.ps1" + else: + command = f".specify/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] @@ -916,6 +945,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] = [] @@ -1731,8 +1764,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. """ @@ -1764,7 +1797,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 db6961b5eb..694073d5a1 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 21ecac1f83..f112060799 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/presets/_manager.py b/src/specify_cli/presets/_manager.py index 963bb5c640..eda4838c11 100644 --- a/src/specify_cli/presets/_manager.py +++ b/src/specify_cli/presets/_manager.py @@ -435,6 +435,9 @@ def install_from_directory( registered_commands: Dict[str, List[str]] = {} registered_skills: Dict[str, List[str]] = {} try: + self._ensure_hook_dispatchers_for_commands([ + t["name"] for t in manifest.templates if t.get("type") == "command" + ]) # Register command overrides with AI agents and persist the result # immediately so cleanup can recover even if installation stops # before later phases complete. @@ -782,6 +785,10 @@ def remove(self, pack_id: str) -> bool: # names from registered_commands are still unregistered. pass + self._ensure_hook_dispatchers_for_commands( + sorted(removed_cmd_names), exclude_preset_id=pack_id + ) + affected_skill_dirs: Dict[ Path, tuple[Optional[str], List[str]] ] = {} diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py index d171eb28dd..ac4f5b3285 100644 --- a/src/specify_cli/presets/_manager_commands.py +++ b/src/specify_cli/presets/_manager_commands.py @@ -1,6 +1,7 @@ """Agent command registration and reconciliation for installed presets.""" import copy +import os from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set @@ -15,8 +16,9 @@ resolve_active_agent_for_registration, ) from ..extensions import ExtensionRegistry +from ..integrations.base import IntegrationBase from ..integrations._file_changes import write_text as _write_text -from ._manifest import PresetManifest +from ._manifest import PresetError, PresetManifest, PresetValidationError from ._resolver import PresetResolver @@ -92,6 +94,69 @@ def _substitute_core_template( class _PresetCommandMethods: """Command artifact methods shared through PresetManager's lifecycle state.""" + def _ensure_hook_dispatchers_for_commands( + self, command_names: List[str], *, exclude_preset_id: str | None = None + ) -> None: + """Provision dispatchers for the command that will be rendered.""" + if not command_names: + return + registrar = self._command_registrar() + resolver = PresetResolver(self.project_root) + options = load_init_options(self.project_root) + requested = options.get("script") or ("ps" if os.name == "nt" else "sh") + variants = set() + for name in command_names: + try: + content = resolver.resolve_content( + name, "command", exclude_preset_id=exclude_preset_id + ) + except PresetValidationError: + # Registration reports invalid composition through its existing warning path. + continue + if content is None: + continue + frontmatter, body = registrar.parse_frontmatter(content) + if frontmatter.get("strategy") == "wrap" and "{CORE_TEMPLATE}" in body: + body, core_frontmatter = _substitute_core_template( + body, name, self.project_root, registrar + ) + for key in ("scripts", "agent_scripts"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + if "{PRE_HOOK_SCRIPT}" not in body and "{POST_HOOK_SCRIPT}" not in body: + continue + scripts = frontmatter.get("scripts") + if not isinstance(scripts, dict): + scripts = {} + try: + variant = ( + IntegrationBase.select_script_variant(requested, scripts) + if scripts else requested + ) + except ValueError as exc: + raise PresetError(f"Cannot select hook dispatcher: {exc}") from exc + if variant not in ("sh", "ps", "py"): + raise PresetError(f"Unsupported hook dispatcher script type: {variant}") + variants.add(variant) + + from .. import _install_shared_infra + + for variant in sorted(variants): + directory = {"sh": "bash", "ps": "powershell", "py": "python"}[variant] + suffix = {"sh": ".sh", "ps": ".ps1", "py": ".py"}[variant] + paths = [ + self.project_root / ".specify" / "scripts" / directory + / (f"{phase}_hooks.py" if variant == "py" else f"{phase}-hooks{suffix}") + for phase in ("pre", "post") + ] + if any(path.is_symlink() or not path.is_file() for path in paths): + _install_shared_infra( + self.project_root, variant, hook_dispatchers_only=True, + refresh_managed=True, + ) + if any(path.is_symlink() or not path.is_file() for path in paths): + raise PresetError(f"Cannot render preset command: missing hook dispatcher for {variant}") + def _register_commands( self, manifest: PresetManifest, diff --git a/src/specify_cli/presets/_resolver.py b/src/specify_cli/presets/_resolver.py index ea1c4e03f9..d815e568d0 100644 --- a/src/specify_cli/presets/_resolver.py +++ b/src/specify_cli/presets/_resolver.py @@ -508,6 +508,8 @@ def collect_all_layers( self, template_name: str, template_type: str = "template", + *, + exclude_preset_id: str | None = None, ) -> List[Dict[str, Any]]: """Collect all layers in the priority stack for a template. @@ -561,6 +563,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) if self.presets_dir.exists(): for pack_id, metadata in self._get_all_presets_by_priority(): + if pack_id == exclude_preset_id: + continue pack_dir = self.presets_dir / pack_id # Read strategy and manifest file path from preset manifest strategy = "replace" @@ -736,6 +740,8 @@ def resolve_content( self, template_name: str, template_type: str = "template", + *, + exclude_preset_id: str | None = None, ) -> Optional[str]: """Resolve a template name and return composed content. @@ -755,7 +761,9 @@ def resolve_content( Returns: Composed content string, or None if not found """ - layers = self.collect_all_layers(template_name, template_type) + layers = self.collect_all_layers( + template_name, template_type, exclude_preset_id=exclude_preset_id + ) if not layers: return None diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 134e329758..503d04aff7 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -477,6 +477,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*. @@ -488,7 +489,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 @@ -585,6 +587,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: @@ -644,7 +651,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(): @@ -695,7 +702,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) @@ -774,7 +781,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/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 dea7e7cfe5..81c5ad13c3 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.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-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 @@ -82,33 +51,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/contract/test_bundled_extension_update_delivery.py b/tests/contract/test_bundled_extension_update_delivery.py index f85e745829..b7d4babceb 100644 --- a/tests/contract/test_bundled_extension_update_delivery.py +++ b/tests/contract/test_bundled_extension_update_delivery.py @@ -17,7 +17,9 @@ from __future__ import annotations import json +import os import shutil +import tomllib from pathlib import Path from unittest.mock import patch @@ -40,6 +42,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 +78,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 +86,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 +116,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 +130,14 @@ 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": + script_dir, suffix = ("powershell", "ps1") if os.name == "nt" else ("bash", "sh") + for phase in ("pre", "post"): + dispatcher = project_dir / ".specify" / "scripts" / script_dir / f"{phase}-hooks.{suffix}" + assert dispatcher.is_file() + dispatcher.unlink() result = _run_update(project_dir, ext_id) @@ -134,19 +146,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" / script_dir / f"{phase}-hooks.{suffix}").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 718c1c8421..e3e57e3957 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" @@ -158,7 +157,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): @@ -194,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 @@ -201,6 +202,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 @@ -297,6 +299,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 @@ -429,8 +503,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 +603,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 ----------------------------------- @@ -529,10 +613,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/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/integrations/test_cli.py b/tests/integrations/test_cli.py index ed6864253b..6811c3c10e 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -826,6 +826,23 @@ def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsy assert "Could not read shared infrastructure manifest" in captured.out assert "A new shared manifest will be created" in captured.out + @pytest.mark.parametrize("invalid", ["hooks: [broken]\n", "hooks: [\n"]) + def test_shared_infra_validates_hooks_before_writing(self, tmp_path, invalid): + from specify_cli import _install_shared_infra + + project = tmp_path / "invalid-hooks" + specify = project / ".specify" + specify.mkdir(parents=True) + config = specify / "extensions.yml" + config.write_text(invalid, encoding="utf-8") + + with pytest.raises(ValueError, match="extensions.yml"): + _install_shared_infra(project, "sh", force=True) + + assert config.read_text(encoding="utf-8") == invalid + assert not (specify / "scripts").exists() + assert not (specify / "integrations/speckit.manifest.json").exists() + def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys): """Non-UTF-8 shared manifests warn before falling back to a new manifest.""" from specify_cli import _install_shared_infra 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..6eaf75b5b6 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -256,19 +256,19 @@ 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 + 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.""" @@ -507,6 +507,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..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 @@ -260,6 +273,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..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 @@ -210,6 +235,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/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index cddcd6bf21..3165ab0c00 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -9,6 +9,7 @@ import json import os import stat +import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -38,6 +39,193 @@ 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"] + + @pytest.mark.parametrize("preferred,available", [ + ("sh", "py"), pytest.param("py", "sh", marks=pytest.mark.skipif( + sys.platform == "win32", reason="Bash fallback is unavailable on Windows" + )), ("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", + ]) + 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") + + @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/specify_cli/extensions/test_command_remove.py b/tests/specify_cli/extensions/test_command_remove.py index a7a340b401..83c0d38e13 100644 --- a/tests/specify_cli/extensions/test_command_remove.py +++ b/tests/specify_cli/extensions/test_command_remove.py @@ -5,19 +5,175 @@ from __future__ import annotations +import shutil from pathlib import Path from types import SimpleNamespace from unittest.mock import patch +import pytest from typer.testing import CliRunner from specify_cli import app from specify_cli.extensions import ( ExtensionManager, ExtensionRegistry, + HookExecutor, ) +@pytest.mark.parametrize("command", ["enable", "disable", "remove"]) +@pytest.mark.parametrize("invalid", [ + "hooks: [broken]\n", + "hooks:\n before_plan: [broken]\n", + "hooks: [\n", +]) +def test_mutations_preserve_state_on_invalid_hook_config( + tmp_path, extension_dir, command, invalid +): + project = tmp_path / "project" + project.mkdir() + (project / ".specify").mkdir() + manager = ExtensionManager(project) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + manager.registry.update("test-ext", {"enabled": command == "disable"}) + config = HookExecutor(project).config_file + config.write_text(invalid, encoding="utf-8") + original = config.read_bytes() + registry = manager.registry.get("test-ext") + extension_path = manager.extensions_dir / "test-ext" + files = {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} + cache = project / ".specify/hook-dispatch" + projection = {p.name: p.read_bytes() for p in cache.iterdir()} + + args = ["extension", command, "test-ext"] + if command == "remove": + args.append("--force") + with patch.object(Path, "cwd", return_value=project): + result = CliRunner().invoke(app, args, catch_exceptions=True) + + assert result.exit_code == 1, result.output + assert "Invalid .specify/extensions.yml" in result.output + assert config.read_bytes() == original + assert manager.registry.get("test-ext") == registry + assert {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} == files + assert {p.name: p.read_bytes() for p in cache.iterdir()} == projection + + +@pytest.mark.parametrize("command", ["enable", "disable", "remove"]) +def test_mutations_preserve_state_on_hook_cache_file_collision( + tmp_path, extension_dir, command +): + project = tmp_path / "project" + project.mkdir() + (project / ".specify").mkdir() + manager = ExtensionManager(project) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + manager.registry.update("test-ext", {"enabled": command == "disable"}) + config = HookExecutor(project).config_file + original = config.read_bytes() + registry = manager.registry.get("test-ext") + extension_path = manager.extensions_dir / "test-ext" + files = {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} + cache = project / ".specify/hook-dispatch" + shutil.rmtree(cache) + cache.write_text("collision", encoding="utf-8") + + args = ["extension", command, "test-ext"] + if command == "remove": + args.append("--force") + with patch.object(Path, "cwd", return_value=project): + result = CliRunner().invoke(app, args, catch_exceptions=True) + + assert result.exit_code == 1, result.output + assert "hook dispatch cache" in result.output + assert config.read_bytes() == original + assert manager.registry.get("test-ext") == registry + assert {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} == files + assert cache.read_text(encoding="utf-8") == "collision" + + +@pytest.mark.parametrize("command", ["enable", "disable", "remove"]) +@pytest.mark.parametrize("entry", ["orphan.json", "orphan.sha256", "unrelated"]) +def test_mutations_preserve_projection_on_cache_directory_entry( + tmp_path, extension_dir, command, entry +): + project = tmp_path / "project" + project.mkdir() + (project / ".specify").mkdir() + manager = ExtensionManager(project) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + manager.registry.update("test-ext", {"enabled": command == "disable"}) + registry = manager.registry.get("test-ext") + extension_path = manager.extensions_dir / "test-ext" + files = {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} + executor = HookExecutor(project) + config = executor.config_file + original = config.read_bytes() + cache = project / ".specify/hook-dispatch" + (cache / entry).mkdir() + projection = {p.name: p.read_bytes() for p in cache.iterdir() if p.is_file()} + + args = ["extension", command, "test-ext"] + if command == "remove": + args.append("--force") + with patch.object(Path, "cwd", return_value=project): + result = CliRunner().invoke(app, args, catch_exceptions=True) + + assert result.exit_code == 1, result.output + assert "non-file hook dispatch cache entry" in result.output + assert config.read_bytes() == original + assert manager.registry.get("test-ext") == registry + assert {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} == files + assert (cache / entry).is_dir() + assert {p.name: p.read_bytes() for p in cache.iterdir() if p.is_file()} == projection + + +@pytest.mark.parametrize("command", ["enable", "disable", "remove"]) +def test_mutations_preserve_state_on_dangling_config_symlink( + tmp_path, extension_dir, command +): + project = tmp_path / "project" + project.mkdir() + (project / ".specify").mkdir() + manager = ExtensionManager(project) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + manager.registry.update("test-ext", {"enabled": command == "disable"}) + registry = manager.registry.get("test-ext") + extension_path = manager.extensions_dir / "test-ext" + files = {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} + cache = project / ".specify/hook-dispatch" + projection = {p.name: p.read_bytes() for p in cache.iterdir()} + config = HookExecutor(project).config_file + config.unlink() + target = tmp_path / "missing-extensions.yml" + try: + config.symlink_to(target) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + assert not config.exists() + + args = ["extension", command, "test-ext"] + if command == "remove": + args.append("--force") + with patch.object(Path, "cwd", return_value=project): + result = CliRunner().invoke(app, args, catch_exceptions=True) + + assert result.exit_code == 1, result.output + assert "symlinked .specify/extensions.yml" in result.output + assert config.is_symlink() and not target.exists() + assert manager.registry.get("test-ext") == registry + assert {p.relative_to(extension_path): p.read_bytes() + for p in extension_path.rglob("*") if p.is_file()} == files + assert {p.name: p.read_bytes() for p in cache.iterdir()} == projection + + class TestExtensionRemoveCLI: """CLI tests for `specify extension remove` confirmation prompt wording.""" diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py index 42d5ea0a6c..26b2999efa 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,85 @@ ) 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 == "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) + 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 load 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/specify_cli/presets/test_manager_commands.py b/tests/specify_cli/presets/test_manager_commands.py index d83ef12d17..981a5a92af 100644 --- a/tests/specify_cli/presets/test_manager_commands.py +++ b/tests/specify_cli/presets/test_manager_commands.py @@ -1,5 +1,6 @@ """Tests for preset command artifacts in specify_cli.presets._manager_commands.""" +import json from pathlib import Path import pytest @@ -3078,6 +3079,208 @@ def _make_wrap_preset_dir( class TestRemoveReconciliation: """Test that removing a preset re-registers the next layer's command.""" + def test_replace_install_ignores_discarded_core_hook_refs(self, project_dir, temp_dir): + core = project_dir / ".specify/templates/commands" + core.mkdir(parents=True, exist_ok=True) + (core / "specify.md").write_text( + "---\nscripts:\n sh: .specify/scripts/bash/check-prerequisites.sh\n" + "---\n\n{PRE_HOOK_SCRIPT} specify\n", encoding="utf-8" + ) + (project_dir / ".gemini/commands").mkdir(parents=True) + preset = _make_wrap_preset_dir( + temp_dir, "replace-no-hooks", "speckit.specify", "before", "after" + ) + (preset / "commands/speckit.specify.md").write_text( + "---\ndescription: Independent replacement\nstrategy: replace\n" + "---\n\nReplacement without hook references.\n", encoding="utf-8" + ) + scripts = project_dir / ".specify/scripts/bash" + scripts.mkdir(parents=True) + outside = temp_dir / "missing-hook.sh" + try: + (scripts / "pre-hooks.sh").symlink_to(outside) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + + manager = PresetManager(project_dir) + manager.install_from_directory(preset, "0.1.5") + + command = project_dir / ".gemini/commands/speckit.specify.toml" + assert manager.registry.is_installed("replace-no-hooks") + assert "Replacement without hook references." in command.read_text(encoding="utf-8") + assert ".specify/scripts/bash/pre-hooks.sh" not in command.read_text(encoding="utf-8") + assert (scripts / "pre-hooks.sh").is_symlink() + assert not (scripts / "post-hooks.sh").exists() + + def test_replace_install_provisions_its_own_hook_refs(self, project_dir, temp_dir): + (project_dir / ".gemini/commands").mkdir(parents=True) + preset = _make_wrap_preset_dir( + temp_dir, "replace-with-hooks", "speckit.specify", "before", "after" + ) + (preset / "commands/speckit.specify.md").write_text( + "---\ndescription: Hook-bearing replacement\nstrategy: replace\n" + "---\n\n{PRE_HOOK_SCRIPT} specify\n", encoding="utf-8" + ) + dispatchers = project_dir / ".specify/scripts/bash" + assert not (dispatchers / "pre-hooks.sh").exists() + + PresetManager(project_dir).install_from_directory(preset, "0.1.5") + + command = project_dir / ".gemini/commands/speckit.specify.toml" + assert ".specify/scripts/bash/pre-hooks.sh" in command.read_text(encoding="utf-8") + assert (dispatchers / "pre-hooks.sh").is_file() + assert (dispatchers / "post-hooks.sh").is_file() + + @pytest.mark.parametrize("lower_hooks", [True, False]) + def test_remove_provisions_only_surviving_replacement_variant( + self, project_dir, temp_dir, lower_hooks + ): + core = project_dir / ".specify/templates/commands" + core.mkdir(parents=True, exist_ok=True) + (core / "specify.md").write_text( + "---\nscripts:\n sh: .specify/scripts/bash/check-prerequisites.sh\n" + "---\n\n{PRE_HOOK_SCRIPT} specify\n", encoding="utf-8" + ) + (project_dir / ".gemini/commands").mkdir(parents=True) + manager = PresetManager(project_dir) + lower = _make_wrap_preset_dir( + temp_dir, "lower-replace", "speckit.specify", "lower", "lower" + ) + lower_body = "{PRE_HOOK_SCRIPT} specify" if lower_hooks else "No hooks here." + (lower / "commands/speckit.specify.md").write_text( + "---\ndescription: Lower replacement\nstrategy: replace\n" + "scripts:\n py: .specify/scripts/python/check_prerequisites.py\n" + f"---\n\n{lower_body}\n", encoding="utf-8" + ) + manager.install_from_directory(lower, "0.1.5", priority=20) + upper = _make_wrap_preset_dir( + temp_dir, "upper-replace", "speckit.specify", "upper", "upper" + ) + (upper / "commands/speckit.specify.md").write_text( + "---\ndescription: Upper replacement\nstrategy: replace\n" + "---\n\nUpper command without hooks.\n", encoding="utf-8" + ) + manager.install_from_directory(upper, "0.1.5", priority=10) + python_dispatchers = project_dir / ".specify/scripts/python" + if lower_hooks: + (python_dispatchers / "pre_hooks.py").unlink() + (python_dispatchers / "post_hooks.py").unlink() + + assert manager.remove("upper-replace") + + command = project_dir / ".gemini/commands/speckit.specify.toml" + text = command.read_text(encoding="utf-8") + if lower_hooks: + assert ".specify/scripts/python/pre_hooks.py" in text + assert (python_dispatchers / "pre_hooks.py").is_file() + assert (python_dispatchers / "post_hooks.py").is_file() + else: + assert "No hooks here." in text + assert not (python_dispatchers / "pre_hooks.py").exists() + assert not (project_dir / ".specify/scripts/bash/pre-hooks.sh").exists() + + @pytest.mark.parametrize("script,variant,suffix,agent", [ + ("sh", "bash", ".sh", "gemini"), ("ps", "powershell", ".ps1", "gemini"), + ("py", "python", ".py", "gemini"), ("py", "python", ".py", "claude"), + ]) + def test_wrap_install_provisions_missing_hook_dispatchers( + self, project_dir, temp_dir, script, variant, suffix, agent + ): + core = project_dir / ".specify/templates/commands" + core.mkdir(parents=True, exist_ok=True) + (core / "specify.md").write_text( + "---\ndescription: Core specify\nscripts:\n sh: .specify/scripts/bash/check-prerequisites.sh\n" + " ps: .specify/scripts/powershell/check-prerequisites.ps1\n" + " py: .specify/scripts/python/check_prerequisites.py\n" + "---\n\n{PRE_HOOK_SCRIPT} specify\n\nCore work\n\n{POST_HOOK_SCRIPT} specify\n", + encoding="utf-8", + ) + (project_dir / ".specify/init-options.json").write_text( + json.dumps({"ai": agent, "script": script, "ai_skills": agent == "claude"}), + encoding="utf-8", + ) + output_dir = project_dir / (".claude/skills" if agent == "claude" else ".gemini/commands") + output_dir.mkdir(parents=True) + preset = _make_wrap_preset_dir( + temp_dir, "hook-wrap", "speckit.specify", "before", "after" + ) + dispatchers = project_dir / ".specify/scripts" / variant + assert not dispatchers.exists() + + manager = PresetManager(project_dir) + manager.install_from_directory(preset, "0.1.5") + + command = ( + output_dir / "speckit-specify/SKILL.md" + if agent == "claude" else output_dir / "speckit.specify.toml" + ) + assert command.is_file() + for phase in ("pre", "post"): + name = f"{phase}_hooks.py" if script == "py" else f"{phase}-hooks{suffix}" + assert f".specify/scripts/{variant}/{name}" in command.read_text(encoding="utf-8") + assert (dispatchers / name).is_file() + + def test_wrap_install_rejects_unavailable_hook_dispatcher(self, project_dir, temp_dir): + from specify_cli.presets import PresetError + + core = project_dir / ".specify/templates/commands" + core.mkdir(parents=True, exist_ok=True) + (core / "specify.md").write_text( + "---\nscripts:\n sh: .specify/scripts/bash/check-prerequisites.sh\n" + "---\n\n{PRE_HOOK_SCRIPT} specify\n", + encoding="utf-8", + ) + (project_dir / ".gemini/commands").mkdir(parents=True) + preset = _make_wrap_preset_dir( + temp_dir, "blocked-hook-wrap", "speckit.specify", "before", "after" + ) + dispatchers = project_dir / ".specify/scripts/bash" + dispatchers.mkdir(parents=True) + target = temp_dir / "unavailable-hook.sh" + try: + (dispatchers / "pre-hooks.sh").symlink_to(target) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"file symlinks unavailable: {exc}") + + manager = PresetManager(project_dir) + with pytest.raises(PresetError, match="missing hook dispatcher"): + manager.install_from_directory(preset, "0.1.5") + + assert not manager.registry.is_installed("blocked-hook-wrap") + assert not (project_dir / ".gemini/commands/speckit.specify.toml").exists() + assert (dispatchers / "pre-hooks.sh").is_symlink() + assert not target.exists() + + def test_wrap_remove_restores_missing_hook_dispatchers(self, project_dir, temp_dir): + core = project_dir / ".specify/templates/commands" + core.mkdir(parents=True, exist_ok=True) + (core / "specify.md").write_text( + "---\nscripts:\n sh: .specify/scripts/bash/check-prerequisites.sh\n" + "---\n\n{PRE_HOOK_SCRIPT} specify\n\n{POST_HOOK_SCRIPT} specify\n", + encoding="utf-8", + ) + (project_dir / ".gemini/commands").mkdir(parents=True) + manager = PresetManager(project_dir) + manager.install_from_directory( + _make_wrap_preset_dir(temp_dir, "lower-wrap", "speckit.specify", "lower", "lower"), + "0.1.5", priority=20, + ) + manager.install_from_directory( + _make_wrap_preset_dir(temp_dir, "upper-wrap", "speckit.specify", "upper", "upper"), + "0.1.5", priority=10, + ) + dispatchers = project_dir / ".specify/scripts/bash" + (dispatchers / "pre-hooks.sh").unlink() + (dispatchers / "post-hooks.sh").unlink() + + assert manager.remove("upper-wrap") + + command = project_dir / ".gemini/commands/speckit.specify.toml" + assert "lower" in command.read_text(encoding="utf-8") + assert ".specify/scripts/bash/pre-hooks.sh" in command.read_text(encoding="utf-8") + assert (dispatchers / "pre-hooks.sh").is_file() + assert (dispatchers / "post-hooks.sh").is_file() + def test_install_composes_extension_command_and_rewrites_subdir_paths_for_non_skill_agent( self, project_dir, temp_dir ): 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_extension_registration.py b/tests/test_extension_registration.py index 4dc4d1beb2..d3df040f76 100644 --- a/tests/test_extension_registration.py +++ b/tests/test_extension_registration.py @@ -171,20 +171,17 @@ def test_unregister_hooks_no_hooks_key(self, project_dir): assert "ext-no-hooks" not in config["installed"] def test_unregister_hooks_corrupted_config(self, project_dir): - """Resilience: unregister_hooks should gracefully handle corrupted config.""" + """A non-mapping config must not be overwritten during removal.""" # Create a corrupted config (root is a list) config_path = project_dir / ".specify" / "extensions.yml" config_path.write_text(yaml.dump(["corrupted", "list"])) executor = HookExecutor(project_dir) - # Should not raise even with corrupted config - executor.unregister_hooks("non-existent") - - # Config should remain as-is or be handled gracefully - config = executor.get_project_config() - # If it's corrupted, it's returned as-is or handled by defensive logic - assert config is not None + original = config_path.read_bytes() + with pytest.raises(ValueError, match="expected a mapping"): + executor.unregister_hooks("non-existent") + assert config_path.read_bytes() == original def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path): """Multiple Extensions: unregister_hooks should only remove target extension's hooks.""" @@ -300,7 +297,7 @@ def test_register_extension_mixed_type_installed(self, project_dir): assert True not in config["installed"] def test_unregister_hooks_null_hook_values(self, project_dir): - """Regression: hooks: {after_tasks: null} must not crash in unregister_hooks().""" + """A null hook event must not be silently removed.""" executor = HookExecutor(project_dir) # Manually write a config with null hook event value @@ -310,14 +307,13 @@ def test_unregister_hooks_null_hook_values(self, project_dir): "hooks": {"after_tasks": None} })) - # Should not raise TypeError when iterating None - executor.unregister_hooks("broken-ext") - - config = executor.get_project_config() - assert "broken-ext" not in config["installed"] + original = config_path.read_bytes() + with pytest.raises(ValueError, match="hooks.after_tasks must be a list"): + executor.unregister_hooks("broken-ext") + assert config_path.read_bytes() == original def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path): - """Regression: register_hooks() must handle non-list hook event values in config.""" + """Registration must preserve, not replace, an invalid event value.""" executor = HookExecutor(project_dir) # Manually write a config with null hook event value @@ -349,13 +345,10 @@ def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path): manifest = ExtensionManifest(manifest_path) - # Should not raise TypeError when trying to append to None - executor.register_hooks(manifest) - - config = executor.get_project_config() - assert "new-ext" in config["installed"] - assert isinstance(config["hooks"]["after_tasks"], list) - assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"]) + original = config_path.read_bytes() + with pytest.raises(ValueError, match="hooks.after_tasks must be a list"): + executor.register_hooks(manifest) + assert config_path.read_bytes() == original def test_register_extension_already_present_in_corrupted_list(self, project_dir): """Regression: if extension is already present but list has non-strings, it must still be sanitized.""" @@ -423,7 +416,7 @@ def test_unregister_extension_corrupted_installed(self, project_dir): config = executor.get_project_config() assert config["installed"] == [] def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path): - """Regression: register_hooks() must sanitize hook event lists by dropping non-dicts.""" + """Registration must not discard malformed entries from an existing list.""" executor = HookExecutor(project_dir) config_path = project_dir / ".specify" / "extensions.yml" @@ -454,33 +447,25 @@ def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path): manifest_path.write_text(yaml.dump(manifest_data)) manifest = ExtensionManifest(manifest_path) - executor.register_hooks(manifest) - - config = executor.get_project_config() - hooks = config["hooks"]["after_tasks"] - - # Should have 2 valid dict hooks, and 0 non-dict items - assert len(hooks) == 2 - assert all(isinstance(h, dict) for h in hooks) - assert any(h.get("extension") == "other" for h in hooks) - assert any(h.get("extension") == "new-ext" for h in hooks) + original = config_path.read_bytes() + with pytest.raises(ValueError, match=r"hooks.after_tasks\[0\] must be a mapping"): + executor.register_hooks(manifest) + assert config_path.read_bytes() == original def test_unregister_extension_scalar_root(self, project_dir): - """Hardening: unregister_extension should handle scalar root config.""" + """A scalar root must not be replaced by a default during removal.""" executor = HookExecutor(project_dir) config_path = project_dir / ".specify" / "extensions.yml" config_path.write_text(yaml.dump(123)) - # Should not crash and should normalize to {} - executor.unregister_extension("any-ext") - - config = executor.get_project_config() - assert isinstance(config, dict) - assert config["installed"] == [] + original = config_path.read_bytes() + with pytest.raises(ValueError, match="expected a mapping"): + executor.unregister_extension("any-ext") + assert config_path.read_bytes() == original def test_unregister_hooks_scalar_hook_values(self, project_dir): - """Regression: unregister_hooks() must handle scalar hook event values.""" + """A scalar event value must not be silently removed.""" executor = HookExecutor(project_dir) config_path = project_dir / ".specify" / "extensions.yml" @@ -489,9 +474,7 @@ def test_unregister_hooks_scalar_hook_values(self, project_dir): "hooks": {"after_tasks": 123} })) - # Should not raise TypeError when iterating - executor.unregister_hooks("some-ext") - - config = executor.get_project_config() - assert "some-ext" not in config["installed"] - assert "after_tasks" not in config["hooks"] + original = config_path.read_bytes() + with pytest.raises(ValueError, match="hooks.after_tasks must be a list"): + executor.unregister_hooks("some-ext") + assert config_path.read_bytes() == original diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b512742389..17342eba70 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -8153,9 +8153,8 @@ def test_register_hooks_dropping_all_hooks_purges_orphans(self, project_dir): hooks = executor.get_project_config()["hooks"] assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"] - def test_register_hooks_empty_hooks_purge_survives_corrupt_entry(self, project_dir): - """A corrupt non-dict entry already on disk does not break the - empty-hooks orphan purge; it is dropped and valid entries survive.""" + def test_register_hooks_empty_hooks_rejects_corrupt_entry(self, project_dir): + """Mutations cannot silently discard a malformed existing hook entry.""" executor = HookExecutor(project_dir) executor.register_hooks( _StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}}) @@ -8165,12 +8164,17 @@ 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") + original = executor.config_file.read_bytes() - executor.register_hooks(_StubManifest("ext-a", {})) + with pytest.raises(ValueError, match="must be a mapping"): + executor.register_hooks(_StubManifest("ext-a", {})) + assert executor.config_file.read_bytes() == original hooks = executor.get_project_config()["hooks"] - assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"] + assert [e["command"] for e in hooks["after_tasks"]] == [ + "speckit.ext-a.go", "speckit.ext-b.go", + ] def test_register_hooks_duplicate_command_moves_to_end(self, project_dir): """A command repeated in one manifest keeps the last value and the last diff --git a/tests/test_hook_dispatch_scripts.py b/tests/test_hook_dispatch_scripts.py new file mode 100644 index 0000000000..2b21483c3b --- /dev/null +++ b/tests/test_hook_dispatch_scripts.py @@ -0,0 +1,1353 @@ +"""Positive and negative coverage for pre/post extension hook resolution.""" + +import json +import shutil +import os +import subprocess +import sys +import hashlib +import threading +from contextlib import contextmanager +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, ps_executable="pwsh"): + scripts = { + "py": [sys.executable, str(PYTHON / f"{phase}_hooks.py")], + "sh": [str(ROOT / "scripts" / "bash" / f"{phase}-hooks.sh")], + "ps": [ps_executable, "-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: + env.update(extra_env) + 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) + + +@pytest.mark.skipif(os.name != "nt" or not shutil.which("powershell.exe"), + reason="Windows PowerShell 5.1 is available only on Windows") +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_windows_powershell_51_projection_and_failures(tmp_path, phase): + from specify_cli.extensions import HookExecutor + + version = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], + capture_output=True, text=True, check=True, + ) + assert version.stdout.strip().startswith("5.1.") + + event = f"{'before' if phase == 'pre' else 'after'}_plan" + HookExecutor(tmp_path).save_project_config({"hooks": {event: [ + {"extension": "éxtension", "command": "speckit.git.配置", "optional": False}, + ]}}) + + def resolve(name="plan"): + return run_hook(tmp_path, phase, name, variant="ps", ps_executable="powershell.exe") + + code, response = resolve() + assert code == 0, response + assert response["hooks"][0]["extension"] == "éxtension" + assert response["hooks"][0]["command"] == "speckit.git.配置" + assert response["hooks"][0]["optional"] is False + assert resolve("tasks") == (0, {"event": f"{'before' if phase == 'pre' else 'after'}_tasks", "hooks": []}) + + cache = tmp_path / ".specify/hook-dispatch" + projection = cache / f"{event}.json" + projection.write_bytes(projection.read_bytes().replace(b"speckit.git.", b"speckit.bad.")) + code, response = resolve() + assert code == 1 + assert response["hooks"] == [] + assert "invalid" in response["error"] + + malformed = json.dumps({"event": event, "hooks": {}}).encode("utf-8") + projection.write_bytes(malformed) + (cache / f"{event}.sha256").write_text( + hashlib.sha256(malformed).hexdigest() + "\n", encoding="ascii" + ) + code, response = resolve() + assert code == 1 + assert response["hooks"] == [] + assert "Invalid hook projection" in response["error"] + + projection.unlink() + code, response = resolve() + assert code == 1 + assert "incomplete" in response["error"] + + index = cache / "events.txt" + index.write_bytes(index.read_bytes() + b"before_bad\n") + code, response = resolve("tasks") + assert code == 1 + assert "invalid" in response["error"] + + +@pytest.mark.skipif(os.name != "nt" or not shutil.which("powershell.exe"), + reason="Windows PowerShell 5.1 is available only on Windows") +@pytest.mark.parametrize("phase", ["pre", "post"]) +def test_windows_powershell_51_legacy_utf8_output(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.配置\n" + )) + code, response = run_hook( + tmp_path, phase, variant="ps", ps_executable="powershell.exe" + ) + assert code == 0, response + assert response["hooks"][0]["command"] == "speckit.git.配置" + + +def test_hook_resolver_avoids_powershell_7_only_projection_apis(): + script = (ROOT / "scripts/powershell/pre-hooks.ps1").read_text(encoding="utf-8") + assert "HashData(" not in script + assert "ToHexString(" not in script + assert "-AsHashtable" not in script + + +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("variant", ["py", "sh", "ps"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("text", [ + "hooks:\n before_plan:\n", + "hooks:\n before_plan:\n after_tasks: []\n", + "hooks:\n before_tasks:\n before_plan: []\n", + "hooks:\n before_plan:\nsettings: {}\n", +]) +def test_legacy_null_hook_event_rejected(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, variant=variant) + assert code == 1, data + assert "must be a list" in data["error"] + assert data["hooks"] == [] + + +@pytest.mark.parametrize("variant", ["py", "sh", "ps"]) +@pytest.mark.parametrize("text", ["hooks:\n", "hooks:\nsettings: {}\n"]) +def test_legacy_null_hooks_mapping_rejected(tmp_path, variant, text): + if variant == "ps" and not shutil.which("pwsh"): + pytest.skip("PowerShell not installed") + write_config(tmp_path, text) + code, data = run_hook(tmp_path, variant=variant) + assert code == 1, data + assert "hooks mapping" in data["error"] + 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"]) +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), +]) +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"] + 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): + 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", + } + + +@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 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", + ] + 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"]) +@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"\r\n", b"\n").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"\r\n", b"\n").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"): + 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"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@pytest.mark.parametrize("priority,valid", [ + (1, True), (2147483647, True), (2147483648, False), + (999999999999999999999, False), +]) +def test_projection_priority_bounds_match_across_runtimes( + tmp_path, variant, phase, priority, valid +): + 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"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + projection = cache / f"{event}.json" + response = json.loads(projection.read_text(encoding="utf-8")) + response["hooks"][0]["priority"] = priority + payload = (json.dumps(response, ensure_ascii=True, separators=(",", ":")) + "\n").encode("utf-8") + projection.write_bytes(payload) + (cache / f"{event}.sha256").write_text( + hashlib.sha256(payload).hexdigest() + "\n", encoding="ascii" + ) + + code, result = run_hook(tmp_path, phase, variant=variant) + assert code == (0 if valid else 1), result + if valid: + assert result["hooks"][0]["priority"] == priority + else: + assert result["hooks"] == [] + assert "Invalid hook projection" in result["error"] + + +@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" + + +@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"] + + +@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"] + + +@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 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"], + 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")], + } + 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) + 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", "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"]) +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", [ + ("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("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( + ["/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"]) +@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" + 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): + 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", "py"]) +@pytest.mark.parametrize("phase", ["pre", "post"]) +@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") + 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"): + 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 + + +@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 + + +@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), + ("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("symlinked", ["config", "dangling-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 in ("config", "dangling-config"): + specify.mkdir() + target = outside / ("missing.yml" if symlinked == "dangling-config" else "extensions.yml") + (specify / "extensions.yml").symlink_to(target) + 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" + ) + if symlinked == "dangling-config": + assert not (outside / "missing.yml").exists() + 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("entry", ["orphan.json", "orphan.sha256", "unrelated"]) +def test_direct_save_rejects_cache_directory_without_touching_projection(tmp_path, entry): + from specify_cli.extensions import HookExecutor + + executor = HookExecutor(tmp_path) + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "old", "command": "speckit.old.run"}, + ]}}) + cache = tmp_path / ".specify/hook-dispatch" + (cache / entry).mkdir() + original = {path.name: path.read_bytes() for path in cache.iterdir() if path.is_file()} + config = executor.config_file.read_bytes() + + with pytest.raises(ValueError, match="non-file hook dispatch cache entry"): + executor.save_project_config({"hooks": {"before_plan": [ + {"extension": "new", "command": "speckit.new.run"}, + ]}}) + + assert executor.config_file.read_bytes() == config + assert (cache / entry).is_dir() + assert {path.name: path.read_bytes() for path in cache.iterdir() if path.is_file()} == original + + +@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