From 0a30a9b706e8cab1be1033f40f35f1c991693147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20V=C3=A9tillard?= <84911237+clementvtrd@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:29:56 +0200 Subject: [PATCH 1/5] chore: run specify init --- .specify/init-options.json | 9 + .specify/integration.json | 15 + .specify/integrations/claude.manifest.json | 17 + .specify/integrations/speckit.manifest.json | 17 + .specify/scripts/bash/check-prerequisites.sh | 195 +++++ .specify/scripts/bash/common.sh | 704 +++++++++++++++++++ .specify/scripts/bash/create-new-feature.sh | 344 +++++++++ .specify/scripts/bash/setup-plan.sh | 83 +++ .specify/scripts/bash/setup-tasks.sh | 91 +++ .specify/templates/checklist-template.md | 40 ++ .specify/templates/constitution-template.md | 50 ++ .specify/templates/plan-template.md | 137 ++-- .specify/templates/spec-template.md | 152 ++-- .specify/templates/tasks-template.md | 269 ++++++- .specify/workflows/speckit/workflow.yml | 77 ++ .specify/workflows/workflow-registry.json | 13 + 16 files changed, 2085 insertions(+), 128 deletions(-) create mode 100644 .specify/init-options.json create mode 100644 .specify/integration.json create mode 100644 .specify/integrations/claude.manifest.json create mode 100644 .specify/integrations/speckit.manifest.json create mode 100755 .specify/scripts/bash/check-prerequisites.sh create mode 100755 .specify/scripts/bash/common.sh create mode 100755 .specify/scripts/bash/create-new-feature.sh create mode 100755 .specify/scripts/bash/setup-plan.sh create mode 100755 .specify/scripts/bash/setup-tasks.sh create mode 100644 .specify/templates/checklist-template.md create mode 100644 .specify/templates/constitution-template.md create mode 100644 .specify/workflows/speckit/workflow.yml create mode 100644 .specify/workflows/workflow-registry.json diff --git a/.specify/init-options.json b/.specify/init-options.json new file mode 100644 index 0000000..f5efbdf --- /dev/null +++ b/.specify/init-options.json @@ -0,0 +1,9 @@ +{ + "ai": "claude", + "ai_skills": true, + "feature_numbering": "sequential", + "here": true, + "integration": "claude", + "script": "sh", + "speckit_version": "0.13.4.dev0" +} diff --git a/.specify/integration.json b/.specify/integration.json new file mode 100644 index 0000000..5f23994 --- /dev/null +++ b/.specify/integration.json @@ -0,0 +1,15 @@ +{ + "version": "0.13.4.dev0", + "integration_state_schema": 1, + "installed_integrations": [ + "claude" + ], + "integration_settings": { + "claude": { + "script": "sh", + "invoke_separator": "-" + } + }, + "integration": "claude", + "default_integration": "claude" +} diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json new file mode 100644 index 0000000..28e462d --- /dev/null +++ b/.specify/integrations/claude.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "claude", + "version": "0.13.4.dev0", + "installed_at": "2026-07-22T14:28:21.419874+00:00", + "files": { + ".claude/skills/speckit-analyze/SKILL.md": "fecd4bf113c3dda58c75d387473c0106fc2dfea97a27bb7c65af94f3f916c188", + ".claude/skills/speckit-clarify/SKILL.md": "c1c2098756ca407530cca11c5b608f517d769962215ddafa013951b81e3e19c5", + ".claude/skills/speckit-constitution/SKILL.md": "7b30696b7d05058c90e799e90b65b74b0f7d46aaa5447011cde63be18c692205", + ".claude/skills/speckit-implement/SKILL.md": "c36b1326054c66ca23e17b00d6c12b1d2af8f7eee69656cefd38459210552cfe", + ".claude/skills/speckit-converge/SKILL.md": "04226b8443797337624983111546d5e5a48d9993a176c4e6d72a4099a0af50d4", + ".claude/skills/speckit-plan/SKILL.md": "7c2af71208f4358b54c58a60508362aa24a82ec2f24f7fc2213f50c10e5d7563", + ".claude/skills/speckit-checklist/SKILL.md": "946c6bc808891436972a11a423f89f0fbd272a79809bb8fd1d29f481ebe02613", + ".claude/skills/speckit-specify/SKILL.md": "63599eda0c392c0a051137adb43db9ef2a8d52fc68a5f5ef43ad19c55c9eca55", + ".claude/skills/speckit-tasks/SKILL.md": "f2e56011107c53edf9a3b7cad987ce8fef73d50cb8a6aa26baa4628950d743f7", + ".claude/skills/speckit-taskstoissues/SKILL.md": "dfe23aaca349cd76e98505dafa9aae1ef4616a0c35a5c79122b9bd881e16b62f" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json new file mode 100644 index 0000000..9d0d449 --- /dev/null +++ b/.specify/integrations/speckit.manifest.json @@ -0,0 +1,17 @@ +{ + "integration": "speckit", + "version": "0.13.4.dev0", + "installed_at": "2026-07-22T14:28:21.428476+00:00", + "files": { + ".specify/scripts/bash/common.sh": "6ff86bf39f6b4684b0f80927dc7a1dadec26b4671988a3fe4d6c2523cbd3aa22", + ".specify/scripts/bash/check-prerequisites.sh": "a7d8a14ecf87332b600cd966b5d0e7cb9d594abce7e4d1ee4372b2b5b3efff06", + ".specify/scripts/bash/create-new-feature.sh": "e0474162655c073c11b870a99d0789042484910343db6a81c447ec456eca633a", + ".specify/scripts/bash/setup-tasks.sh": "cf21ba2212b4dd5b435c5ea8527500cfd27768b86c0bbc7ebc3207759f118d27", + ".specify/scripts/bash/setup-plan.sh": "4469b22960f43c07c33dca00de6dedb252145e9a9ce8fbb0e63be82e02b082ab", + ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", + ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4", + ".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536", + ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", + ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842" + } +} diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh new file mode 100755 index 0000000..bf75140 --- /dev/null +++ b/.specify/scripts/bash/check-prerequisites.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +# Consolidated prerequisite checking script +# +# This script provides unified prerequisite checking for Spec-Driven Development workflow. +# It replaces the functionality previously spread across multiple scripts. +# +# Usage: ./check-prerequisites.sh [OPTIONS] +# +# OPTIONS: +# --json Output in JSON format +# --require-tasks Require tasks.md to exist (for implementation phase) +# --include-tasks Include tasks.md in AVAILABLE_DOCS list +# --paths-only Only output path variables (no validation) +# --help, -h Show help message +# +# OUTPUTS: +# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} +# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md +# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. + +set -e + +# Parse command line arguments +JSON_MODE=false +REQUIRE_TASKS=false +INCLUDE_TASKS=false +PATHS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --require-tasks) + REQUIRE_TASKS=true + ;; + --include-tasks) + INCLUDE_TASKS=true + ;; + --paths-only) + PATHS_ONLY=true + ;; + --help|-h) + cat << 'EOF' +Usage: check-prerequisites.sh [OPTIONS] + +Consolidated prerequisite checking for Spec-Driven Development workflow. + +OPTIONS: + --json Output in JSON format + --require-tasks Require tasks.md to exist (for implementation phase) + --include-tasks Include tasks.md in AVAILABLE_DOCS list + --paths-only Only output path variables (no prerequisite validation) + --help, -h Show this help message + +EXAMPLES: + # Check task prerequisites (plan.md required) + ./check-prerequisites.sh --json + + # Check implementation prerequisites (plan.md + tasks.md required) + ./check-prerequisites.sh --json --require-tasks --include-tasks + + # Get feature paths only (no validation) + ./check-prerequisites.sh --paths-only + +EOF + exit 0 + ;; + *) + echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 + exit 1 + ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths. +# In --paths-only mode this is pure resolution, so pass --no-persist to opt out +# of the feature.json write side effect (issue #3025). +if $PATHS_ONLY; then + _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +else + _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +fi +eval "$_paths_output" +unset _paths_output + +# If paths-only mode, output paths and exit (no validation) +if $PATHS_ONLY; then + if $JSON_MODE; then + # Minimal JSON paths payload (no validation performed) + if has_jq; then + jq -cn \ + --arg repo_root "$REPO_ROOT" \ + --arg branch "$CURRENT_BRANCH" \ + --arg feature_dir "$FEATURE_DIR" \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg tasks "$TASKS" \ + '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' + else + printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ + "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" + fi + else + echo "REPO_ROOT: $REPO_ROOT" + echo "BRANCH: $CURRENT_BRANCH" + echo "FEATURE_DIR: $FEATURE_DIR" + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "TASKS: $TASKS" + fi + exit 0 +fi + +# Validate required directories and files +if [[ ! -d "$FEATURE_DIR" ]]; then + echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 + echo "Run /speckit-specify first to create the feature structure." >&2 + exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-plan first to create the implementation plan." >&2 + exit 1 +fi + +# Check for tasks.md if required +if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then + echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-tasks first to create the task list." >&2 + exit 1 +fi + +# Build list of available documents +docs=() + +# Always check these optional docs +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") + +# Check contracts directory (only if it exists and has files) +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi + +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Include tasks.md if requested and it exists +if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then + docs+=("tasks.md") +fi + +# Output results +if $JSON_MODE; then + # Build JSON array of documents + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" + fi +else + # Text output + echo "FEATURE_DIR:$FEATURE_DIR" + echo "AVAILABLE_DOCS:" + + # Show status of each potential document + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" + + if $INCLUDE_TASKS; then + check_file "$TASKS" "tasks.md" + fi +fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh new file mode 100755 index 0000000..dc60f9f --- /dev/null +++ b/.specify/scripts/bash/common.sh @@ -0,0 +1,704 @@ +#!/usr/bin/env bash +# Common functions and variables for all scripts + +# Find repository root by searching upward for .specify directory +# This is the primary marker for spec-kit projects +find_specify_root() { + local dir="${1:-$(pwd)}" + # Normalize to absolute path to prevent infinite loop with relative paths + # Use -- to handle paths starting with - (e.g., -P, -L) + dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 + local prev_dir="" + while true; do + if [ -d "$dir/.specify" ]; then + echo "$dir" + return 0 + fi + # Stop if we've reached filesystem root or dirname stops changing + if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then + break + fi + prev_dir="$dir" + dir="$(dirname "$dir")" + done + return 1 +} + +# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that +# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit +# command against a member project from a monorepo root without cd. +# +# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute +# project root, or prints an error and returns 1. Strict by design: the path +# must exist and contain .specify/, with no silent fallback to cwd or the +# script-location default (which would silently write to the wrong project). +# +# This is the single resolver: bundled extensions inherit it by sourcing core +# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. +resolve_specify_init_dir() { + local init_root + # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. + # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH + # (which would also echo to stdout and corrupt the captured path). + if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then + echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 + return 1 + fi + if [[ ! -d "$init_root/.specify" ]]; then + echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 + return 1 + fi + printf '%s\n' "$init_root" +} + +# Get repository root, prioritizing .specify directory +# This prevents using a parent repository when spec-kit is initialized in a subdirectory +get_repo_root() { + # Explicit project override wins (see resolve_specify_init_dir). + if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then + resolve_specify_init_dir + return + fi + + # First, look for .specify directory (spec-kit's own marker) + local specify_root + if specify_root=$(find_specify_root); then + echo "$specify_root" + return + fi + + # Final fallback to script location + local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$script_dir/../../.." && pwd) +} + +# Get current feature name from explicit state only. +# Returns the feature identifier or empty string if none is set. +# Feature state is set by SPECIFY_FEATURE (from create-new-feature or +# the git extension) or implicitly via .specify/feature.json. +get_current_branch() { + if [[ -n "${SPECIFY_FEATURE:-}" ]]; then + echo "$SPECIFY_FEATURE" + return + fi + + # No explicit feature set — caller must handle this via feature.json + # in get_feature_paths(). Return empty to signal "unknown". + echo "" +} + +# Safely read .specify/feature.json's "feature_directory" value. +# Prints the raw value (possibly relative) to stdout, or empty string if the file +# is missing, unparseable, or does not contain the key. Always returns 0 so callers +# under `set -e` cannot be aborted by parser failure. +# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. +read_feature_json_feature_directory() { + local repo_root="$1" + local fj="$repo_root/.specify/feature.json" + [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + + # Try parsers in order (jq -> python3 -> grep/sed), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App Execution + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so + # an availability-gated `elif` would pick python3, swallow its failure, and + # never reach the grep/sed fallback -- leaving feature.json unreadable even + # though it is valid (issue #3304). + local _fd='' + if command -v jq >/dev/null 2>&1; then + if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then + _fd='' + fi + fi + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then + # Use Python so pretty-printed/multi-line JSON still parses correctly. + if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then + _fd='' + fi + fi + if [[ -z "$_fd" ]]; then + # Last-resort single-line grep/sed fallback. The `|| true` guards against + # grep returning 1 (no match) aborting under `set -e` / `pipefail`. + _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ + | head -n 1 \ + | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) + fi + + printf '%s' "$_fd" + return 0 +} + +# Persist a feature_directory value to .specify/feature.json. +# Writes only when the file is missing or the value differs from what's stored. +# Accepts the raw (possibly relative) path — callers should pass the original +# user-supplied value, not the normalized absolute path. +_persist_feature_json() { + local repo_root="$1" + local feature_dir_value="$2" + local fj="$repo_root/.specify/feature.json" + + # Strip repo_root prefix if the value is absolute and under repo_root + if [[ "$feature_dir_value" == "$repo_root/"* ]]; then + feature_dir_value="${feature_dir_value#"$repo_root/"}" + fi + + # Read current value (if any) and skip write when unchanged + local current_val + current_val=$(read_feature_json_feature_directory "$repo_root") + if [[ "$current_val" == "$feature_dir_value" ]]; then + return 0 + fi + + # Ensure .specify/ directory exists + mkdir -p "$repo_root/.specify" + + # Write feature.json — prefer jq for safe JSON, fall back to printf + if command -v jq >/dev/null 2>&1; then + jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj" + else + printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj" + fi +} + +get_feature_paths() { + # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass + # --no-persist so pure path resolution never writes .specify/feature.json, + # which would dirty the working tree or overwrite a pinned value (issue #3025). + local no_persist=false + if [[ "${1:-}" == "--no-persist" ]]; then + no_persist=true + shift + fi + + # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in + # get_repo_root propagates as a hard error instead of being masked by `local`. + local repo_root + repo_root=$(get_repo_root) || return 1 + local current_branch + current_branch=$(get_current_branch) + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by specify command) + # 3. Error — no feature context available + local feature_dir + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + # Persist to feature.json so future sessions without the env var still + # work — unless the caller opted out for read-only resolution (#3025). + if [[ "$no_persist" != true ]]; then + _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" + fi + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + local _fd + _fd=$(read_feature_json_feature_directory "$repo_root") + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + else + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2 + return 1 + fi + else + echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 + return 1 + fi + + # When no branch context exists (no SPECIFY_FEATURE, feature resolved via + # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature + # directory basename so CURRENT_BRANCH is a usable identifier rather than + # an empty, misleading value (issue #3026). + if [[ -z "$current_branch" ]]; then + local feature_dir_trimmed="${feature_dir%/}" + current_branch="${feature_dir_trimmed##*/}" + fi + + # Use printf '%q' to safely quote values, preventing shell injection + # via crafted branch names or paths containing special characters + printf 'REPO_ROOT=%q\n' "$repo_root" + printf 'CURRENT_BRANCH=%q\n' "$current_branch" + printf 'FEATURE_DIR=%q\n' "$feature_dir" + printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" + printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" + printf 'TASKS=%q\n' "$feature_dir/tasks.md" + printf 'RESEARCH=%q\n' "$feature_dir/research.md" + printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" + printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" + printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" +} + +# Check if jq is available for safe JSON construction +has_jq() { + command -v jq >/dev/null 2>&1 +} + +get_invoke_separator() { + local repo_root="${1:-$(get_repo_root)}" + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + return 0 + fi + + local integration_json="$repo_root/.specify/integration.json" + local separator="." + local parsed=0 + + if [[ -f "$integration_json" ]]; then + # Try parsers in order (jq -> python3 -> awk), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App + # Execution Alias stub, which passes `command -v` but fails at runtime + # (exit 49). An availability-gated branch would pick python3, swallow + # its failure, and — because this function historically had no text + # fallback — silently return "." even for `-`-separator integrations + # (e.g. forge, cline), yielding wrong command hints (issue #3304). + if command -v jq >/dev/null 2>&1; then + local jq_separator + if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then + case "$jq_separator" in + "."|"-") separator="$jq_separator"; parsed=1 ;; + esac + fi + fi + + if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then + local py_separator + if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as fh: + state = json.load(fh) + key = state.get("default_integration") or state.get("integration") or "" + settings = state.get("integration_settings") + separator = "." + if isinstance(key, str) and isinstance(settings, dict): + entry = settings.get(key) + if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: + separator = entry["invoke_separator"] + print(separator) +except Exception: + sys.exit(1) +PY +); then + case "$py_separator" in + "."|"-") separator="$py_separator"; parsed=1 ;; + esac + fi + fi + + if [[ "$parsed" -eq 0 ]]; then + # Last-resort text fallback for environments with neither jq nor a + # working python3 (e.g. stock Windows + Git Bash). Reads the active + # integration key (default_integration, else integration) and its + # invoke_separator from within the integration_settings object. + # Handles both pretty-printed (the written form) and compact JSON. + # Accumulate all lines into one buffer in END rather than using + # gawk-only whole-file slurp (RS="^$"), so this stays portable to + # the BSD awk on macOS. + local awk_separator + awk_separator=$(awk ' + function keyval(d, name, v) { + if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { + v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v + } + return "" + } + { doc = doc $0 "\n" } + END { + key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") + sep="." + if (key!="") { + settings=doc + if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { + settings=substr(doc, RSTART+RLENGTH-1) + } + if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { + start=RSTART+RLENGTH-1 + depth=0 + obj="" + for (i=start; i<=length(settings); i++) { + c=substr(settings,i,1) + obj=obj c + if (c=="{") depth++ + else if (c=="}") { depth--; if (depth==0) break } + } + if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { + tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) + if (s=="." || s=="-") sep=s + } + } + } + print sep + } + ' "$integration_json" 2>/dev/null) + case "$awk_separator" in + "."|"-") separator="$awk_separator" ;; + esac + fi + fi + + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + printf '%s\n' "$separator" +} + +format_speckit_command() { + local command_name="$1" + local repo_root="${2:-$(get_repo_root)}" + local separator + if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then + separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" + else + separator=$(get_invoke_separator "$repo_root") + _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" + _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" + fi + + command_name="${command_name#/}" + command_name="${command_name#speckit.}" + command_name="${command_name#speckit-}" + command_name="${command_name//./$separator}" + + printf '/speckit%s%s\n' "$separator" "$command_name" +} + +# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). +# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/\\r}" + s="${s//$'\b'/\\b}" + s="${s//$'\f'/\\f}" + # Escape any remaining U+0001-U+001F control characters as \uXXXX. + # (U+0000/NUL cannot appear in bash strings and is excluded.) + # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, + # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. + local LC_ALL=C + local i char code + for (( i=0; i<${#s}; i++ )); do + char="${s:$i:1}" + printf -v code '%d' "'$char" 2>/dev/null || code=256 + if (( code >= 1 && code <= 31 )); then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + done +} + +check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } +check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } + +# Resolve a template name to a file path using the priority stack: +# 1. .specify/templates/overrides/ +# 2. .specify/presets//templates/ (sorted by priority from .registry) +# 3. .specify/extensions//templates/ +# 4. .specify/templates/ (core) +resolve_template() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Priority 1: Project overrides + local override="$base/overrides/${template_name}.md" + [ -f "$override" ] && echo "$override" && return 0 + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + # Read preset IDs sorted by priority (lower number = higher precedence). + # The python3 call is wrapped in an if-condition so that set -e does not + # abort the function when python3 exits non-zero (e.g. invalid JSON). + local sorted_presets="" + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + # python3 succeeded and returned preset IDs — search in priority order + while IFS= read -r preset_id; do + local candidate="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done <<< "$sorted_presets" + fi + # python3 succeeded but registry has no presets — nothing to search + else + # python3 failed (missing, or registry parse error) — fall back to unordered directory scan + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + else + # Fallback: alphabetical directory order (no python3 available) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + fi + + # Priority 3: Extension-provided templates + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + # Skip hidden directories (e.g. .backup, .cache) + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + [ -f "$candidate" ] && echo "$candidate" && return 0 + done + fi + + # Priority 4: Core templates + local core="$base/${template_name}.md" + [ -f "$core" ] && echo "$core" && return 0 + + # Template not found in any location. + # Return 1 so callers can distinguish "not found" from "found". + # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true + return 1 +} + +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh new file mode 100755 index 0000000..e40e8b2 --- /dev/null +++ b/.specify/scripts/bash/create-new-feature.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash + +set -e + +JSON_MODE=false +DRY_RUN=false +ALLOW_EXISTING=false +SHORT_NAME="" +BRANCH_NUMBER="" +USE_TIMESTAMP=false +ARGS=() +i=1 +while [ $i -le $# ]; do + arg="${!i}" + case "$arg" in + --json) + JSON_MODE=true + ;; + --dry-run) + DRY_RUN=true + ;; + --allow-existing-branch) + ALLOW_EXISTING=true + ;; + --short-name) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + # Check if the next argument is another option (starts with --) + if [[ "$next_arg" == --* ]]; then + echo 'Error: --short-name requires a value' >&2 + exit 1 + fi + SHORT_NAME="$next_arg" + ;; + --number) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --number requires a value' >&2 + exit 1 + fi + BRANCH_NUMBER="$next_arg" + ;; + --timestamp) + USE_TIMESTAMP=true + ;; + --help|-h) + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "" + echo "Options:" + echo " --json Output in JSON format" + echo " --dry-run Compute feature name and paths without creating directories or files" + echo " --allow-existing-branch Reuse an existing feature directory if it already exists" + echo " --short-name Provide a custom short name (2-4 words) for the feature" + echo " --number N Specify branch number manually (overrides auto-detection)" + echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 'Add user authentication system' --short-name 'user-auth'" + echo " $0 'Implement OAuth2 integration for API' --number 5" + echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac + i=$((i + 1)) +done + +FEATURE_DESCRIPTION="${ARGS[*]}" +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + exit 1 +fi + +# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') +if [ -z "$FEATURE_DESCRIPTION" ]; then + echo "Error: Feature description cannot be empty or contain only whitespace" >&2 + exit 1 +fi + +MAX_FEATURE_NUMBER=9223372036854775807 + +is_feature_number_in_range() { + local value="$1" + local normalized="${value#"${value%%[!0]*}"}" + [ -n "$normalized" ] || normalized=0 + [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0 + [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1 + # Equal-length digit strings must be compared without arithmetic overflow. + # shellcheck disable=SC2071 + [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]] +} + +# Function to get highest number from specs directory +get_highest_from_specs() { + local specs_dir="$1" + local highest=0 + + if [ -d "$specs_dir" ]; then + for dir in "$specs_dir"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + # Match sequential prefixes (>=3 digits), but skip timestamp dirs. + if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$dirname" | grep -Eo '^[0-9]+') + if is_feature_number_in_range "$number"; then + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number + fi + fi + fi + done + fi + + echo "$highest" +} + +# Function to clean and format a branch name +clean_branch_name() { + local name="$1" + echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' +} + +# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote +# so the persistence hints match the Python variant exactly (printf %q output +# differs between bash versions and from shlex.quote for spaces/metachars). +shell_quote() { + local value="$1" LC_ALL=C + if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then + printf '%s' "$value" + else + local q="'\"'\"'" + printf "'%s'" "${value//\'/$q}" + fi +} + +# Resolve repository root using common.sh functions which prioritize .specify +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +REPO_ROOT=$(get_repo_root) || exit 1 + +cd "$REPO_ROOT" + +SPECS_DIR="$REPO_ROOT/specs" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi + +# Function to generate branch name with stop word filtering and length filtering +generate_branch_name() { + local description="$1" + + # Common stop words to filter out + local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" + + # Convert to lowercase and split into words + local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') + + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) + local meaningful_words=() + for word in $clean_name; do + # Skip empty words + [ -z "$word" ] && continue + + # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) + if ! echo "$word" | grep -qiE "$stop_words"; then + if [ ${#word} -ge 3 ]; then + meaningful_words+=("$word") + # Keep short words that appear as an uppercase acronym in the original. + # Uppercase via tr and match with grep -w (both portable) rather than + # bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX). + elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then + meaningful_words+=("$word") + fi + fi + done + + # If we have meaningful words, use first 3-4 of them + if [ ${#meaningful_words[@]} -gt 0 ]; then + local max_words=3 + if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi + + local result="" + local count=0 + for word in "${meaningful_words[@]}"; do + if [ $count -ge $max_words ]; then break; fi + if [ -n "$result" ]; then result="$result-"; fi + result="$result$word" + count=$((count + 1)) + done + echo "$result" + else + # Fallback to original logic if no meaningful words found + local cleaned=$(clean_branch_name "$description") + echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' + fi +} + +# Generate branch name +if [ -n "$SHORT_NAME" ]; then + # Use provided short name, just clean it up + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +else + # Generate from description with smart filtering + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") +fi + +# Warn if --number and --timestamp are both specified +if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" +fi + +# Determine branch prefix +if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +else + if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2 + exit 1 + fi + + # Bash arithmetic is signed 64-bit; reject digit strings that would wrap. + if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then + echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2 + exit 1 + fi + + # Determine branch number from existing feature directories + if [ -z "$BRANCH_NUMBER" ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then + echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 + exit 1 + fi + BRANCH_NUMBER=$((HIGHEST + 1)) + fi + + # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" +fi + +# GitHub enforces a 244-byte limit on branch names +# Validate and truncate if necessary +MAX_BRANCH_LENGTH=244 +if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then + # Calculate how much we need to trim from suffix + # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 + PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) + MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) + + # Truncate suffix at word boundary if possible + TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) + # Remove trailing hyphen if truncation created one + TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') + + ORIGINAL_BRANCH_NAME="$BRANCH_NAME" + BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" + + >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" + >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" + >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" +fi + +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then + if [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name." + else + >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number." + fi + exit 1 + fi + + mkdir -p "$FEATURE_DIR" + + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" + else + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" + fi + fi + + # Persist to .specify/feature.json so downstream commands can find the feature + _persist_feature_json "$REPO_ROOT" "$FEATURE_DIR" + + # Inform the user how to set feature state in their own shell + printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2 + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2 +fi + +if $JSON_MODE; then + if command -v jq >/dev/null 2>&1; then + if [ "$DRY_RUN" = true ]; then + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + else + jq -cn \ + --arg branch_name "$BRANCH_NAME" \ + --arg spec_file "$SPEC_FILE" \ + --arg feature_num "$FEATURE_NUM" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + fi + else + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + else + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + fi + fi +else + echo "BRANCH_NAME: $BRANCH_NAME" + echo "SPEC_FILE: $SPEC_FILE" + echo "FEATURE_NUM: $FEATURE_NUM" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" + printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" + fi +fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh new file mode 100755 index 0000000..e01dc44 --- /dev/null +++ b/.specify/scripts/bash/setup-plan.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false +ARGS=() + +for arg in "$@"; do + case "$arg" in + --json) + JSON_MODE=true + ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done + +# Get script directory and load common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get all paths and variables from common functions +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Ensure the feature directory exists +mkdir -p "$FEATURE_DIR" + +# Copy plan template if plan doesn't already exist +if [[ -f "$IMPL_PLAN" ]]; then + if $JSON_MODE; then + echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 + else + echo "Plan already exists at $IMPL_PLAN, skipping template copy" + fi +else + TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true + if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$IMPL_PLAN" + if $JSON_MODE; then + echo "Copied plan template to $IMPL_PLAN" >&2 + else + echo "Copied plan template to $IMPL_PLAN" + fi + else + if $JSON_MODE; then + echo "Warning: Plan template not found" >&2 + else + echo "Warning: Plan template not found" + fi + # Create a basic plan file if template doesn't exist + touch "$IMPL_PLAN" + fi +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + jq -cn \ + --arg feature_spec "$FEATURE_SPEC" \ + --arg impl_plan "$IMPL_PLAN" \ + --arg specs_dir "$FEATURE_DIR" \ + --arg branch "$CURRENT_BRANCH" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}' + else + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" + fi +else + echo "FEATURE_SPEC: $FEATURE_SPEC" + echo "IMPL_PLAN: $IMPL_PLAN" + echo "SPECS_DIR: $FEATURE_DIR" + echo "BRANCH: $CURRENT_BRANCH" +fi diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh new file mode 100755 index 0000000..ae0d7bd --- /dev/null +++ b/.specify/scripts/bash/setup-tasks.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate required files +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-plan first to create the implementation plan." >&2 + exit 1 +fi + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit-specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md new file mode 100644 index 0000000..e64065d --- /dev/null +++ b/.specify/templates/checklist-template.md @@ -0,0 +1,40 @@ +# [CHECKLIST TYPE] Checklist: [FEATURE NAME] + +**Purpose**: [Brief description of what this checklist covers] +**Created**: [DATE] +**Feature**: [Link to spec.md or relevant documentation] + +**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements. + + + +## [Category 1] + +- [ ] CHK001 First checklist item with clear action +- [ ] CHK002 Second checklist item +- [ ] CHK003 Third checklist item + +## [Category 2] + +- [ ] CHK004 Another category item +- [ ] CHK005 Item with specific criteria +- [ ] CHK006 Final item in this category + +## Notes + +- Check items off as completed: `[x]` +- Add comments or findings inline +- Link to relevant resources or documentation +- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md new file mode 100644 index 0000000..a4670ff --- /dev/null +++ b/.specify/templates/constitution-template.md @@ -0,0 +1,50 @@ +# [PROJECT_NAME] Constitution + + +## Core Principles + +### [PRINCIPLE_1_NAME] + +[PRINCIPLE_1_DESCRIPTION] + + +### [PRINCIPLE_2_NAME] + +[PRINCIPLE_2_DESCRIPTION] + + +### [PRINCIPLE_3_NAME] + +[PRINCIPLE_3_DESCRIPTION] + + +### [PRINCIPLE_4_NAME] + +[PRINCIPLE_4_DESCRIPTION] + + +### [PRINCIPLE_5_NAME] + +[PRINCIPLE_5_DESCRIPTION] + + +## [SECTION_2_NAME] + + +[SECTION_2_CONTENT] + + +## [SECTION_3_NAME] + + +[SECTION_3_CONTENT] + + +## Governance + + +[GOVERNANCE_RULES] + + +**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] + diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index bfdf414..be1aa88 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -1,60 +1,113 @@ -# Implementation Plan — [FEATURE_NAME] +# Implementation Plan: [FEATURE] -**Constitution version:** 1.0.0 -**Spec reference:** `.specify/specs/[feature-slug]/spec.md` -**Author:** [AUTHOR] -**Date:** [DATE] +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] ---- +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` -## Constitution Check - -Before finalizing this plan, confirm compliance with the active constitution: +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. -| Principle | Check | -|-----------|-------| -| 1 — Broad Compatibility | [ ] No new PHP/PHPStan version constraint tightening | -| 2 — Test Coverage per Rule | [ ] Test class and fixture planned | -| 3 — Documentation per Rule | [ ] README section planned | -| 4 — Open-Source Quality Standards | [ ] strict_types, PSR-12, PHPStan passing | -| 5 — Single-Responsibility Rule Design | [ ] One node type per class confirmed | +## Summary ---- +[Extract from feature spec: primary requirement + technical approach from research] -## Approach +## Technical Context -[Describe the chosen implementation approach and why it was selected over alternatives.] + -### Alternatives considered +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] -| Alternative | Rejected because | -|-------------|-----------------| -| [option] | [reason] | +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] ---- +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] -## File Inventory +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] -| File | Action | Notes | -|------|--------|-------| -| `src/Rules/[Namespace]/[RuleName].php` | Create | Core rule class | -| `tests/Rules/[Namespace]/[RuleName]Test.php` | Create | PHPUnit test | -| `tests/Rules/[Namespace]/fixtures/[name].php` | Create | Test fixture | -| `extension.neon` | Edit | Register rule service | -| `README.md` | Edit | Add rule documentation | +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] ---- +**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] -## Edge Cases & Risks +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] -- [List known edge cases the implementation must handle] -- [List risks, e.g., PHPStan API instability, PHP version quirks] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] ---- +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] -## Validation Steps +## Constitution Check -1. `docker compose run --rm php vendor/bin/phpunit tests/Rules/[Namespace]/[RuleName]Test.php` -2. `docker compose run --rm php composer phpstan` -3. `docker compose run --rm php composer cs-check` -4. Manual review of fixture file to confirm positive/negative cases. +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index b6bf747..ceb2877 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -1,75 +1,131 @@ -# Feature Specification — [FEATURE_NAME] +# Feature Specification: [FEATURE NAME] -**Constitution version:** 1.0.0 -**Status:** [draft | review | approved] -**Author:** [AUTHOR] -**Date:** [DATE] +**Feature Branch**: `[###-feature-name]` ---- +**Created**: [DATE] -## Overview +**Status**: Draft -[One paragraph describing the feature, the problem it solves, and why it belongs in this package.] +**Input**: User description: "$ARGUMENTS" ---- +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - [Brief Title] (Priority: P1) + +[Describe this user journey in plain language] + +**Why this priority**: [Explain the value and why it has this priority level] -## Scope +**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] -### In scope -- [What this feature covers] +**Acceptance Scenarios**: -### Out of scope -- [What this feature explicitly does not cover] +1. **Given** [initial state], **When** [action], **Then** [expected outcome] +2. **Given** [initial state], **When** [action], **Then** [expected outcome] --- -## Rule Definition (if applicable) +### User Story 2 - [Brief Title] (Priority: P2) -**Rule identifier:** `[namespace.ruleName]` -**Node type:** `[PhpParser\Node\...]` -**Namespace / directory:** `src/Rules/[Namespace]/` +[Describe this user journey in plain language] -### Triggers on -```php -// [example code that should produce a violation] -``` +**Why this priority**: [Explain the value and why it has this priority level] -### Does not trigger on -```php -// [example code that should NOT produce a violation] -``` +**Independent Test**: [Describe how this can be tested independently] -### Error message -> [Exact error message string, must be actionable] +**Acceptance Scenarios**: -### Recommended fix -[Describe the pattern the developer should use instead.] +1. **Given** [initial state], **When** [action], **Then** [expected outcome] --- -## Compatibility Requirements +### User Story 3 - [Brief Title] (Priority: P3) - -- Minimum PHP version affected: [e.g., 8.2+] -- PHPStan version requirements: [e.g., ^2.0] -- Any new `composer.json` dependencies: [none | list with justification] +[Describe this user journey in plain language] ---- +**Why this priority**: [Explain the value and why it has this priority level] + +**Independent Test**: [Describe how this can be tested independently] -## Acceptance Criteria +**Acceptance Scenarios**: - -- [ ] Rule class exists at `src/Rules/[Namespace]/[RuleName].php` and is `final readonly`. -- [ ] Rule is registered in `extension.neon`. -- [ ] Test class exists at `tests/Rules/[Namespace]/[RuleName]Test.php`. -- [ ] Fixture file covers at least one triggering and one non-triggering case. -- [ ] `composer test` passes on PHP 8.2, 8.3, 8.4, 8.5. -- [ ] `composer phpstan` passes with no new baseline suppressions (or suppressions justified). -- [ ] `composer cs-check` passes. -- [ ] `README.md` updated with rule documentation (identifier, summary, examples, fix). +1. **Given** [initial state], **When** [action], **Then** [expected outcome] --- -## Open Questions +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + + + +- What happens when [boundary condition]? +- How does system handle [error scenario]? + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] + +## Assumptions + + -- [List any unresolved design or scope questions] +- [Assumption about target users, e.g., "Users have stable internet connectivity"] +- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] +- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] +- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md index a6667d3..d46a1f1 100644 --- a/.specify/templates/tasks-template.md +++ b/.specify/templates/tasks-template.md @@ -1,59 +1,252 @@ -# Tasks — [FEATURE_NAME] +--- + +description: "Task list template for feature implementation" +--- + +# Tasks: [FEATURE NAME] + +**Input**: Design documents from `/specs/[###-feature-name]/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/`, `tests/` at repository root +- **Web app**: `backend/src/`, `frontend/src/` +- **Mobile**: `api/src/`, `ios/src/` or `android/src/` +- Paths shown below assume single project - adjust based on plan.md structure + + + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic structure + +- [ ] T001 Create project structure per implementation plan +- [ ] T002 Initialize [language] project with [framework] dependencies +- [ ] T003 [P] Configure linting and formatting tools -**Constitution version:** 1.0.0 -**Plan reference:** `.specify/specs/[feature-slug]/plan.md` -**Author:** [AUTHOR] -**Date:** [DATE] +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +Examples of foundational tasks (adjust based on your project): + +- [ ] T004 Setup database schema and migrations framework +- [ ] T005 [P] Implement authentication/authorization framework +- [ ] T006 [P] Setup API routing and middleware structure +- [ ] T007 Create base models/entities that all stories depend on +- [ ] T008 Configure error handling and logging infrastructure +- [ ] T009 Setup environment configuration management + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel --- -## Task List +## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 1 + +- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py +- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py +- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) +- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T016 [US1] Add validation and error handling +- [ ] T017 [US1] Add logging for user story 1 operations + +**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently + +--- + +## Phase 4: User Story 2 - [Title] (Priority: P2) + +**Goal**: [Brief description of what this story delivers] -Tasks are ordered by dependency. Mark `[x]` when complete. +**Independent Test**: [How to verify this story works on its own] -### Phase 1 — Rule Implementation +### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ -- [ ] **T1** — Create rule class `src/Rules/[Namespace]/[RuleName].php` - - Implement `Rule<[NodeType]>` - - Add `getNodeType()` returning `[NodeType]::class` - - Add `processNode()` with violation detection logic - - Ensure `final readonly` class modifier - - Add `declare(strict_types=1)` +- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py -- [ ] **T2** — Register rule in `extension.neon` - - Add service entry with `phpstan.rules.rule` tag +### Implementation for User Story 2 -### Phase 2 — Testing (Constitution Principle 2) +- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py +- [ ] T021 [US2] Implement [Service] in src/services/[service].py +- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py +- [ ] T023 [US2] Integrate with User Story 1 components (if needed) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently + +--- + +## Phase 5: User Story 3 - [Title] (Priority: P3) + +**Goal**: [Brief description of what this story delivers] + +**Independent Test**: [How to verify this story works on its own] + +### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ + +- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py +- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py + +### Implementation for User Story 3 + +- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py +- [ ] T027 [US3] Implement [Service] in src/services/[service].py +- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py + +**Checkpoint**: All user stories should now be independently functional + +--- + +[Add more user story phases as needed, following the same pattern] + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] TXXX [P] Documentation updates in docs/ +- [ ] TXXX Code cleanup and refactoring +- [ ] TXXX Performance optimization across all stories +- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ +- [ ] TXXX Security hardening +- [ ] TXXX Run quickstart.md validation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3+)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3) +- **Polish (Final Phase)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable + +### Within Each User Story + +- Tests (if included) MUST be written and FAIL before implementation +- Models before services +- Services before endpoints +- Core implementation before integration +- Story complete before moving to next priority + +### Parallel Opportunities + +- All Setup tasks marked [P] can run in parallel +- All Foundational tasks marked [P] can run in parallel (within Phase 2) +- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) +- All tests for a user story marked [P] can run in parallel +- Models within a story marked [P] can run in parallel +- Different user stories can be worked on in parallel by different team members + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch all tests for User Story 1 together (if tests requested): +Task: "Contract test for [endpoint] in tests/contract/test_[name].py" +Task: "Integration test for [user journey] in tests/integration/test_[name].py" + +# Launch all models for User Story 1 together: +Task: "Create [Entity1] model in src/models/[entity1].py" +Task: "Create [Entity2] model in src/models/[entity2].py" +``` + +--- -- [ ] **T3** — Create fixture file `tests/Rules/[Namespace]/fixtures/[name].php` - - Include at least one triggering case (with inline comment marking expected violation) - - Include at least one non-triggering case +## Implementation Strategy -- [ ] **T4** — Create test class `tests/Rules/[Namespace]/[RuleName]Test.php` - - Extend `PHPStan\Testing\RuleTestCase<[RuleName]>` - - Implement `getRule()` returning a new instance of the rule - - Add `testRule()` calling `$this->analyse()` with fixture path and expected errors +### MVP First (User Story 1 Only) -- [ ] **T5** — Verify test suite passes - - `docker compose run --rm php composer test` — green on all PHP versions +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Test User Story 1 independently +5. Deploy/demo if ready -### Phase 3 — Documentation (Constitution Principle 3) +### Incremental Delivery -- [ ] **T6** — Update `README.md` - - Add rule section with identifier, summary, "Triggers on", "Does not trigger on", - and "Recommended fix" +1. Complete Setup + Foundational → Foundation ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) +3. Add User Story 2 → Test independently → Deploy/Demo +4. Add User Story 3 → Test independently → Deploy/Demo +5. Each story adds value without breaking previous stories -### Phase 4 — Quality Gate (Constitution Principle 4) +### Parallel Team Strategy -- [ ] **T7** — Verify static analysis - - `docker compose run --rm php composer phpstan` — passes, no new baseline entries +With multiple developers: -- [ ] **T8** — Verify code style - - `docker compose run --rm php composer cs-check` — passes +1. Team completes Setup + Foundational together +2. Once Foundational is done: + - Developer A: User Story 1 + - Developer B: User Story 2 + - Developer C: User Story 3 +3. Stories complete and integrate independently --- -## Definition of Done +## Notes -All tasks T1–T8 checked AND a PR reviewer has confirmed compliance with the constitution -(all five principles satisfied). +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml new file mode 100644 index 0000000..f69efea --- /dev/null +++ b/.specify/workflows/speckit/workflow.yml @@ -0,0 +1,77 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + # 0.8.5 is the first release with engine-side resolution of the + # ``integration: "auto"`` default. Older versions would treat "auto" + # as a literal integration key and fail at dispatch. + speckit_version: ">=0.8.5" + integrations: + # The four commands below (specify, plan, tasks, implement) are core + # spec-kit commands provided by every integration. The list here is an + # advisory, non-exhaustive compatibility hint following the documented + # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs + # against any integration the project was initialized with, including + # ones not listed below, as long as that integration provides the four + # core commands referenced in ``steps``. + any: + - "claude" + - "copilot" + - "gemini" + - "opencode" + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json new file mode 100644 index 0000000..837efd7 --- /dev/null +++ b/.specify/workflows/workflow-registry.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "workflows": { + "speckit": { + "name": "Full SDD Cycle", + "version": "1.0.0", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "source": "bundled", + "installed_at": "2026-07-22T14:28:21.438923+00:00", + "updated_at": "2026-07-22T14:28:21.438928+00:00" + } + } +} \ No newline at end of file From ab829133b4f28aa3450b2b9898274778bd156eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20V=C3=A9tillard?= <84911237+clementvtrd@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:24:20 +0200 Subject: [PATCH 2/5] feat: Implement PSR-20 Relative Date Enforcement - Introduced a new static method `isRelativeDateString()` in `Psr20Rule` to detect relative date strings. - Updated `processNode()` to flag any relative date strings passed to `new DateTime` or `new DateTimeImmutable`. - Added a comprehensive set of regex patterns to identify various relative date formats. - Extended test cases in `Psr20RuleTest` to cover new relative date scenarios and ensure existing behaviors are preserved. - Updated the fixture file to include examples of both relative and absolute date strings. - Revised documentation in `README.md` to reflect changes in the rule's behavior and examples. --- .specify/feature.json | 2 +- README.md | 11 +- phpstan-baseline.neon | 116 +++++++++++- .../checklists/requirements.md | 36 ++++ .../contracts/rule-behavior.md | 74 ++++++++ .../data-model.md | 92 +++++++++ specs/002-psr20-ban-relative-dates/plan.md | 125 +++++++++++++ .../quickstart.md | 105 +++++++++++ .../002-psr20-ban-relative-dates/research.md | 80 ++++++++ specs/002-psr20-ban-relative-dates/spec.md | 109 +++++++++++ specs/002-psr20-ban-relative-dates/tasks.md | 174 ++++++++++++++++++ src/Rules/Psr/Psr20Rule.php | 32 +++- tests/Rules/Psr/Psr20RuleTest.php | 17 ++ tests/Rules/Psr/fixtures/psr20.php | 25 +++ 14 files changed, 987 insertions(+), 11 deletions(-) create mode 100644 specs/002-psr20-ban-relative-dates/checklists/requirements.md create mode 100644 specs/002-psr20-ban-relative-dates/contracts/rule-behavior.md create mode 100644 specs/002-psr20-ban-relative-dates/data-model.md create mode 100644 specs/002-psr20-ban-relative-dates/plan.md create mode 100644 specs/002-psr20-ban-relative-dates/quickstart.md create mode 100644 specs/002-psr20-ban-relative-dates/research.md create mode 100644 specs/002-psr20-ban-relative-dates/spec.md create mode 100644 specs/002-psr20-ban-relative-dates/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index 48bae1d..7f9ca21 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-packagist-release-automation" + "feature_directory": "specs/002-psr20-ban-relative-dates" } diff --git a/README.md b/README.md index 08673b8..5a97ca8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ includes: ### `clock.disallowDateTimeNow` — PSR-20 Clock Abstraction -Enforces the [PSR-20](https://www.php-fig.org/psr/psr-20/) recommendation to avoid instantiating `DateTime` or `DateTimeImmutable` with the current time directly. This makes code that depends on the current time testable and respects the clock abstraction. +Enforces the [PSR-20](https://www.php-fig.org/psr/psr-20/) recommendation to avoid instantiating `DateTime` or `DateTimeImmutable` with a relative or implicit date. This makes code that depends on the current time testable and respects the clock abstraction. **Triggers on:** @@ -40,13 +40,18 @@ $a = new DateTime(); $b = new DateTime('now'); $c = new DateTimeImmutable(); $d = new DateTimeImmutable('now'); +$e = new DateTimeImmutable('yesterday'); +$f = new DateTimeImmutable('tomorrow'); +$g = new DateTime('+1 day'); +$h = new DateTimeImmutable('next Monday'); ``` -**Does not trigger on** (explicit non-"now" timestamps are fine): +**Does not trigger on** (absolute date strings and variables are allowed): ```php $a = new DateTime('2023-01-01'); -$b = new DateTimeImmutable('yesterday'); +$b = new DateTimeImmutable('2023-12-31 23:59:59'); +$c = new DateTimeImmutable($dateVariable); ``` **Recommended fix:** inject `Psr\Clock\ClockInterface` and call `$clock->now()`: diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8d214b0..5fbe061 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Avoid using new DateTime\(''\+1 day''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + - message: '#^Avoid using new DateTime\(''now''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' identifier: clock.disallowDateTimeNow @@ -12,12 +18,108 @@ parameters: count: 1 path: tests/Rules/Psr/fixtures/psr20.php + - + message: '#^Avoid using new DateTimeImmutable\(''\+0 seconds''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''\-2 weeks''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''0 days''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''2 days ago''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''friday''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''in 3 weeks''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''last day of this month''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''monday 14\:00\:00''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''next Monday''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''noon''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + - message: '#^Avoid using new DateTimeImmutable\(''now''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' identifier: clock.disallowDateTimeNow count: 1 path: tests/Rules/Psr/fixtures/psr20.php + - + message: '#^Avoid using new DateTimeImmutable\(''today midnight''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''today''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''tomorrow 12\:00''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''tomorrow''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''yesterday noon''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + + - + message: '#^Avoid using new DateTimeImmutable\(''yesterday''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + - message: '#^Avoid using new DateTimeImmutable\(\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' identifier: clock.disallowDateTimeNow @@ -25,10 +127,10 @@ parameters: path: tests/Rules/Psr/fixtures/psr20.php - - message: '#^Avoid using time\(\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' - identifier: clock.disallowTimeFunctions - count: 2 - path: tests/Rules/Psr/fixtures/psr20_functions.php + message: '#^Instantiating DateTimeImmutable with in 3 weeks produces an error\: The timezone could not be found in the database$#' + identifier: new.dateTimeImmutable + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php - message: '#^Avoid using date\(\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' @@ -36,6 +138,12 @@ parameters: count: 4 path: tests/Rules/Psr/fixtures/psr20_functions.php + - + message: '#^Avoid using time\(\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowTimeFunctions + count: 2 + path: tests/Rules/Psr/fixtures/psr20_functions.php + - message: '#^Parameter \#2 \$timestamp of function date expects int\|null, string given\.$#' identifier: argument.type diff --git a/specs/002-psr20-ban-relative-dates/checklists/requirements.md b/specs/002-psr20-ban-relative-dates/checklists/requirements.md new file mode 100644 index 0000000..de2ec5b --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: PSR-20 Relative Date Enforcement + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-22 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Specification is ready for `/speckit-plan`. +- The error identifier update (existing `clock.disallowDateTimeNow`) is explicitly deferred to planning as an assumption, keeping the spec free of implementation detail. +- PHP is referenced as the subject domain (not an implementation choice) since this is a PHP static analysis tool — this is acceptable for a developer-tool spec. diff --git a/specs/002-psr20-ban-relative-dates/contracts/rule-behavior.md b/specs/002-psr20-ban-relative-dates/contracts/rule-behavior.md new file mode 100644 index 0000000..f0c96f0 --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/contracts/rule-behavior.md @@ -0,0 +1,74 @@ +# Contract: Psr20Rule Behavioral Contract + +**Rule identifier**: `clock.disallowDateTimeNow` +**Applies to**: `new DateTime(...)` and `new DateTimeImmutable(...)` constructor expressions. + +--- + +## Input + +A PHP AST node representing `new DateTime` or `new DateTimeImmutable` with zero or more arguments. + +--- + +## Output Contract + +### Violations (error reported) + +The rule MUST report an error for each of the following cases: + +| Case | Example | Error message | +|------|---------|---------------| +| No argument | `new DateTimeImmutable()` | `Avoid using new DateTimeImmutable() directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| String literal `now` (any case) | `new DateTimeImmutable('now')` | `Avoid using new DateTimeImmutable('now') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Relative date keyword | `new DateTimeImmutable('tomorrow')` | `Avoid using new DateTimeImmutable('tomorrow') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Relative day name | `new DateTimeImmutable('monday')` | `Avoid using new DateTimeImmutable('monday') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Signed offset | `new DateTimeImmutable('+1 day')` | `Avoid using new DateTimeImmutable('+1 day') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Unsigned offset | `new DateTimeImmutable('1 day')` | `Avoid using new DateTimeImmutable('1 day') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Ago expression | `new DateTimeImmutable('2 days ago')` | `Avoid using new DateTimeImmutable('2 days ago') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| In expression | `new DateTimeImmutable('in 3 weeks')` | `Avoid using new DateTimeImmutable('in 3 weeks') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Next/last qualifier | `new DateTimeImmutable('next Monday')` | `Avoid using new DateTimeImmutable('next Monday') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Day-of-month selector | `new DateTimeImmutable('last day of this month')` | `Avoid using new DateTimeImmutable('last day of this month') directly. Prefer using \Psr\Clock\ClockInterface instead.` | +| Uppercase relative string | `new DateTimeImmutable('TOMORROW')` | `Avoid using new DateTimeImmutable('TOMORROW') directly. Prefer using \Psr\Clock\ClockInterface instead.` | + +### No Violation (passes silently) + +The rule MUST NOT report an error for: + +| Case | Example | +|------|---------| +| Variable argument | `new DateTimeImmutable($dateString)` | +| ISO date literal | `new DateTimeImmutable('2023-01-15')` | +| ISO date-time literal | `new DateTimeImmutable('2023-12-31 23:59:59')` | +| ISO 8601 with timezone | `new DateTimeImmutable('2023-01-15T12:00:00+00:00')` | +| Time-only literal | `new DateTimeImmutable('12:00:00')` | +| Variadic placeholder | `new DateTimeImmutable(...$args)` | +| Non-`DateTime`/`DateTimeImmutable` class | `new Carbon('tomorrow')` | + +--- + +## Error Message Template + +``` +Avoid using new {ClassName}({QuotedArgument}) directly. Prefer using \Psr\Clock\ClockInterface instead. +``` + +Where: +- `{ClassName}` is `DateTime` or `DateTimeImmutable` +- `{QuotedArgument}` is the quoted string literal (e.g., `'tomorrow'`) or empty string for no-argument case + +--- + +## Unchanged Behaviors (regression surface) + +The following behaviors from the pre-existing rule MUST be preserved: + +| Case | Expected outcome | +|------|-----------------| +| `new DateTime()` | Error: `Avoid using new DateTime() directly...` | +| `new DateTime('now')` | Error: `Avoid using new DateTime('now') directly...` | +| `new DateTimeImmutable()` | Error: `Avoid using new DateTimeImmutable() directly...` | +| `new DateTimeImmutable('now')` | Error: `Avoid using new DateTimeImmutable('now') directly...` | +| `new DateTime('2023-01-01')` | No error | +| `new DateTime($variable)` | No error | +| `new SomeOtherClass('tomorrow')` | No error | diff --git a/specs/002-psr20-ban-relative-dates/data-model.md b/specs/002-psr20-ban-relative-dates/data-model.md new file mode 100644 index 0000000..844fbba --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/data-model.md @@ -0,0 +1,92 @@ +# Data Model: PSR-20 Relative Date Enforcement + +This feature introduces no persistent data. The key design entity is the **relative date pattern set** — a static, in-code catalogue of patterns the rule tests against. + +--- + +## Entity: RelativeDatePattern + +**What it represents**: A single rule that classifies a PHP datetime string literal as "relative" (i.e., clock-dependent at runtime). + +| Attribute | Type | Description | +|-----------|------|-------------| +| `group` | string | Human-readable category (e.g., `keyword`, `signed-offset`) | +| `pattern` | regex | Case-insensitive PCRE pattern applied to the trimmed literal value | +| `examples` | string[] | Representative string literals that this pattern matches | + +**Relationships**: The full set of `RelativeDatePattern` instances together forms the detection catalogue used by `Psr20Rule::isRelativeDateString()`. + +--- + +## Catalogue: Relative Date Patterns + +The complete set of patterns, grouped for clarity: + +### Keywords + +| Pattern | Matches | +|---------|---------| +| `/^now$/i` | `now`, `NOW`, `Now` | +| `/^yesterday$/i` | `yesterday`, `YESTERDAY` | +| `/^today$/i` | `today`, `TODAY` | +| `/^tomorrow$/i` | `tomorrow`, `TOMORROW` | +| `/^noon$/i` | `noon`, `NOON` | +| `/^midnight$/i` | `midnight`, `MIDNIGHT` | + +### Standalone Day Names + +| Pattern | Matches | +|---------|---------| +| `/^(monday\|tuesday\|wednesday\|thursday\|friday\|saturday\|sunday)$/i` | `Monday`, `friday`, `TUESDAY` | + +### Relative Qualifiers + +| Pattern | Matches | +|---------|---------| +| `/^(next\|last\|previous\|this)\s+/i` | `next Monday`, `last week`, `previous year`, `this month` | + +### Offset Formats + +| Pattern | Matches | +|---------|---------| +| `/^[+-]\d+\s+(second\|minute\|hour\|day\|week\|month\|year)s?/i` | `+1 day`, `-2 weeks`, `+3 months`, `-1 second` | +| `/^\d+\s+(second\|minute\|hour\|day\|week\|month\|year)s?\s*$/i` | `1 day`, `2 weeks`, `3 months` | + +### Ago / In + +| Pattern | Matches | +|---------|---------| +| `/\bago\b/i` | `2 days ago`, `1 week ago`, `3 months ago` | +| `/^in\s+\d+\s+(second\|minute\|hour\|day\|week\|month\|year)s?/i` | `in 2 days`, `in 1 week`, `in 3 months` | + +### Day-of-Month Selectors + +| Pattern | Matches | +|---------|---------| +| `/^(first\|last)\s+day\s+of\b/i` | `first day of this month`, `last day of January 2023` | + +### Clock-Relative + +| Pattern | Matches | +|---------|---------| +| `/^(back\|front)\s+of\b/i` | `back of 10`, `front of 11` | + +--- + +## Non-Entity: AbsoluteDateString + +An absolute date string is defined negatively — any string literal that matches none of the patterns above is treated as absolute and allowed. Common absolute formats that the rule correctly allows: + +| Format | Example | +|--------|---------| +| ISO 8601 date | `2023-01-15` | +| ISO 8601 date-time | `2023-01-15 12:00:00` | +| ISO 8601 with timezone | `2023-01-15T12:00:00+00:00` | +| Time only | `12:00:00` | +| US format | `01/15/2023` | + +--- + +## State Transitions + +None — the rule is stateless. Each constructor invocation is evaluated independently. diff --git a/specs/002-psr20-ban-relative-dates/plan.md b/specs/002-psr20-ban-relative-dates/plan.md new file mode 100644 index 0000000..08657a4 --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/plan.md @@ -0,0 +1,125 @@ +# Implementation Plan: PSR-20 Relative Date Enforcement + +**Branch**: `002-psr20-ban-relative-dates` | **Date**: 2026-07-22 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/002-psr20-ban-relative-dates/spec.md` + +## Summary + +Extend `Psr20Rule` to detect all PHP relative-date string literals passed to `new DateTime` or `new DateTimeImmutable` constructors, not just `'now'` and the empty case. The rule adds a private static method that tests string literals against a compiled catalogue of PCRE patterns covering PHP's documented relative date formats. Absolute date strings and variable arguments continue to pass. The rule identifier `clock.disallowDateTimeNow` and error message format are preserved for backward compatibility. Tests, fixture, and README documentation are updated accordingly. + +## Technical Context + +**Language/Version**: PHP ^8.2 + +**Primary Dependencies**: PHPStan ^2.0, php-parser (transitive via PHPStan), PHPUnit ^11 (dev) + +**Storage**: N/A + +**Testing**: PHPUnit 11 via `PHPStan\Testing\RuleTestCase` + +**Target Platform**: PHP CLI (static analysis tool — runs during development/CI) + +**Project Type**: PHPStan extension library + +**Performance Goals**: Rule evaluation per node is O(number of patterns) string matching — negligible overhead. + +**Constraints**: Must remain compatible with PHP 8.2–8.5 and PHPStan ^2.0. No new `require` dependencies. + +**Scale/Scope**: Single rule class, single test class, single fixture file, one README section. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| P1 — Broad Compatibility | PASS | Changes use only PHP 8.2-compatible syntax; no new dependencies | +| P2 — Test Coverage per Rule | PASS | Existing `Psr20RuleTest` is updated; fixture extended with positive and negative cases | +| P3 — Documentation per Rule | PASS | README `clock.disallowDateTimeNow` section updated as part of this change | +| P4 — Open-Source Quality Standards | PASS | `final readonly` class retained; `strict_types=1` present; cs-fixer and phpstan must pass | +| P5 — Single-Responsibility Rule Design | PASS | Single `getNodeType()` return (`New_::class`); logic remains in one class | + +No violations. Complexity Tracking section not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-psr20-ban-relative-dates/ +├── plan.md # This file +├── research.md # Phase 0 — detection approach and decisions +├── data-model.md # Phase 1 — relative date pattern catalogue +├── quickstart.md # Phase 1 — end-to-end validation guide +├── contracts/ +│ └── rule-behavior.md # Phase 1 — triggering/non-triggering contract +├── checklists/ +│ └── requirements.md # Spec quality checklist +└── tasks.md # Phase 2 output (created by /speckit-tasks) +``` + +### Source Code (repository root) + +```text +src/ +└── Rules/ + └── Psr/ + └── Psr20Rule.php # Add isRelativeDateString(); update processNode() + +tests/ +└── Rules/ + └── Psr/ + ├── Psr20RuleTest.php # Update expected errors; add relative-date assertions + └── fixtures/ + └── psr20.php # Extend with relative and absolute date examples + +README.md # Update "Triggers on" / "Does not trigger on" for clock rule +``` + +**Structure Decision**: Single-project layout (Option 1). Changes are confined to the existing `src/Rules/Psr/` and `tests/Rules/Psr/` directories plus the README. + +## Design + +### Core Change: `Psr20Rule::isRelativeDateString()` + +A new private static method is added to `Psr20Rule`. It applies a catalogue of PCRE patterns (see [data-model.md](data-model.md)) against the trimmed string literal value (case-insensitive). Returns `true` if any pattern matches. + +`processNode()` is updated so that, after confirming the first argument is a `Node\Scalar\String_`, it calls `isRelativeDateString()` on the value. If `true`, the error is reported (replacing the existing `'now'`-only check). + +The `'now'` case is subsumed by the new keyword pattern `/^now$/i` and no longer requires special-case handling; the existing behavior is preserved. + +### Fixture and Test Updates + +The existing fixture (`tests/Rules/Psr/fixtures/psr20.php`) is extended with: + +**New error-triggering lines** (relative dates): +- `new DateTimeImmutable('yesterday')` +- `new DateTimeImmutable('tomorrow')` +- `new DateTimeImmutable('+1 day')` +- `new DateTimeImmutable('-2 weeks')` +- `new DateTimeImmutable('next Monday')` +- `new DateTimeImmutable('2 days ago')` +- `new DateTimeImmutable('last day of this month')` + +**Lines that must not trigger** (absolute dates): +- `new DateTimeImmutable('2023-01-15')` — already present; remains a non-error +- `new DateTimeImmutable('2023-12-31 23:59:59')` — add +- `new DateTimeImmutable('2023-01-15T12:00:00+00:00')` — add + +The test's expected-error array is updated to include all new triggering lines with their correct line numbers and error messages. + +### README Update + +The `clock.disallowDateTimeNow` rule section in `README.md` is updated: + +- **Triggers on** block: add representative relative-date examples (`'yesterday'`, `'+1 day'`, `'next Monday'`). +- **Does not trigger on** block: clarify that only absolute date strings and variable arguments pass; remove `'yesterday'` from the passing examples (it currently appears there incorrectly). + +### No New Dependencies + +The detection logic uses PHP's built-in `preg_match` only. No composer packages are added. + +## Complexity Tracking + +No constitution violations — table not required. diff --git a/specs/002-psr20-ban-relative-dates/quickstart.md b/specs/002-psr20-ban-relative-dates/quickstart.md new file mode 100644 index 0000000..04aed08 --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/quickstart.md @@ -0,0 +1,105 @@ +# Quickstart Validation Guide: PSR-20 Relative Date Enforcement + +This guide describes how to validate the updated `Psr20Rule` end-to-end after implementation. + +All commands must be run inside the PHP container: + +```bash +docker compose run --rm php +``` + +--- + +## Prerequisites + +- Docker and Docker Compose installed and running +- Project dependencies installed: + +```bash +docker compose run --rm php composer install +``` + +--- + +## Validation Scenarios + +### Scenario 1: Relative date strings are flagged + +**Setup**: The updated test fixture (`tests/Rules/Psr/fixtures/psr20.php`) must include PHP code with relative date string literals such as `'tomorrow'`, `'+1 day'`, `'yesterday'`, `'next Monday'`. + +**Run**: + +```bash +docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php +``` + +**Expected outcome**: The test passes, confirming that each relative date literal in the fixture maps to an expected error at the correct line. + +--- + +### Scenario 2: Absolute date strings are not flagged + +**Setup**: The fixture must also include lines using absolute date strings such as `'2023-01-15'`, `'2023-12-31 23:59:59'`, and ISO 8601 strings. These lines must NOT appear in the expected-errors array in the test. + +**Run**: + +```bash +docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php +``` + +**Expected outcome**: Test passes with no unexpected errors for absolute date lines. + +--- + +### Scenario 3: Variable arguments are not flagged + +**Setup**: The fixture must include lines using variables as constructor arguments (e.g., `new DateTimeImmutable($someVar)`). These must NOT appear in the expected-errors array. + +**Run**: + +```bash +docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php +``` + +**Expected outcome**: Test passes with no errors for variable-argument lines. + +--- + +### Scenario 4: Existing behaviors are preserved + +**Setup**: The fixture retains the original lines (`new DateTime()`, `new DateTime('now')`, `new DateTimeImmutable()`, `new DateTimeImmutable('now')`). Expected errors for these lines must still be present in the test. + +**Run**: + +```bash +docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php +``` + +**Expected outcome**: All four existing error assertions still pass. + +--- + +### Scenario 5: Full CI pipeline passes + +**Run all three CI checks**: + +```bash +docker compose run --rm php composer test +docker compose run --rm php composer phpstan +docker compose run --rm php composer cs-check +``` + +**Expected outcome**: All three commands exit with code 0 on all supported PHP versions (8.2, 8.3, 8.4, 8.5). + +--- + +## Key Files Touched + +| File | Purpose | +|------|---------| +| `src/Rules/Psr/Psr20Rule.php` | Core rule logic — relative-date detection added | +| `tests/Rules/Psr/Psr20RuleTest.php` | Test — expected errors updated to include relative-date cases | +| `tests/Rules/Psr/fixtures/psr20.php` | Fixture — new lines for relative date and absolute date cases added | +| `README.md` | Rule documentation — "Triggers on" and "Does not trigger on" sections updated | + +See [rule behavior contract](contracts/rule-behavior.md) for the complete list of triggering and non-triggering cases. diff --git a/specs/002-psr20-ban-relative-dates/research.md b/specs/002-psr20-ban-relative-dates/research.md new file mode 100644 index 0000000..af34edf --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/research.md @@ -0,0 +1,80 @@ +# Research: PSR-20 Relative Date Enforcement + +## Decision 1: How to detect "relative" vs "absolute" date strings + +**Decision**: Use a compiled allowlist of regex patterns matching PHP's documented relative date formats. Flag a string literal if it matches any pattern; allow it otherwise. + +**Rationale**: PHP's datetime parser accepts a large set of relative specifiers (documented at https://www.php.net/manual/en/datetime.formats.relative.php). Rather than trying to parse what is "absolute" (very wide set), it is simpler and more predictable to enumerate what is "relative" and flag exactly those patterns. False negatives (missing an obscure relative string) are acceptable; false positives (blocking a legitimate absolute date) are not. + +**Alternatives considered**: +- **Call `DateTimeImmutable::createFromFormat`** at rule-evaluation time to test if the string matches a known absolute format: rejected because it introduces runtime evaluation inside a static analysis rule and requires a known list of absolute formats that may be incomplete. +- **Attempt full parsing with PHP's date parser**: rejected because the parser is highly permissive and many absolute strings would still parse as "relative" in certain contexts. + +--- + +## Decision 2: Relative date patterns to detect + +The following pattern groups cover the PHP-documented relative date formats: + +| Group | Examples | Pattern strategy | +|-------|----------|-----------------| +| Keywords | `now`, `yesterday`, `today`, `tomorrow` | Exact keyword match (case-insensitive) | +| Day of day | `noon`, `midnight` | Exact keyword match | +| Day names (standalone) | `monday`, `friday` | Exact weekday name match | +| Relative qualifiers | `next monday`, `last friday`, `previous year`, `this week` | `^(next\|last\|previous\|this)\s+` prefix | +| Signed offsets | `+1 day`, `-2 weeks`, `+3 months` | `^[+-]\d+\s+` | +| Unsigned offsets with unit | `1 day`, `2 weeks` (when followed by a known time unit) | `^\d+\s+(second\|minute\|hour\|day\|week\|month\|year)s?` | +| Ago | `2 days ago`, `1 week ago` | `\bago\b` anywhere in string | +| In N units | `in 2 days`, `in 1 week` | `^in\s+\d+` prefix | +| Day-of-month selectors | `first day of`, `last day of` | `^(first\|last)\s+day\s+of\b` | +| Clock-relative | `back of 10`, `front of 11` | `^(back\|front)\s+of\b` | + +All matching is case-insensitive. Matching is applied to the trimmed string value. + +**Edge case: `today`** — PHP treats `today` as midnight of the current day, making it relative. The rule flags it. + +**Edge case: time-only strings** (e.g., `'12:00:00'`) — These are absolute time references with no date component. They match none of the relative patterns and are correctly allowed. + +**Edge case: `'+0 seconds'`** — Matches the signed-offset pattern; flagged correctly. + +**Edge case: `'last day of January 2023'`** — Contains `last day of` prefix; flagged (has a relative component). + +--- + +## Decision 3: Rule identifier — keep or replace `clock.disallowDateTimeNow` + +**Decision**: Keep the existing `clock.disallowDateTimeNow` identifier. + +**Rationale**: Any user who has suppressed this error with `// @phpstan-ignore clock.disallowDateTimeNow` would face a breaking change if the identifier changed. The identifier's semantic scope is "PSR-20 clock abstraction compliance", not narrowly "only the `now` literal". Keeping it backward-compatible is the right trade-off. + +**Alternatives considered**: +- **New identifier `clock.disallowRelativeDateTime`**: provides clearer semantics but breaks existing suppressions without a migration path. Rejected in favor of backward compatibility. +- **Emit both old and new identifiers**: PHPStan `RuleErrorBuilder` supports a single identifier per error. Not feasible without a refactor. Rejected. + +--- + +## Decision 4: Error message format + +**Decision**: Extend the existing error message format to name the offending literal. The current format `"Avoid using new $class($context) directly. Prefer using \Psr\Clock\ClockInterface instead."` already supports variable `$context`, so `'tomorrow'`, `'+1 day'`, etc. are naturally substituted in. No structural change needed. + +**Rationale**: The message naturally extends without modification. For empty args, `$context` remains `''` (current behavior). For string literals, `$context` is the quoted string value (e.g., `'tomorrow'`). Consistent with the existing pattern. + +--- + +## Decision 5: Handling the `DateTime` class alongside `DateTimeImmutable` + +**Decision**: Apply the same relative-date detection to both `DateTime` and `DateTimeImmutable`, consistent with the existing rule behavior. + +**Rationale**: Both classes share the same constructor signature and accept the same date formats. The PSR-20 concern (clock dependency) applies equally to both. + +--- + +## Summary of Unknowns Resolved + +| Unknown | Resolution | +|---------|-----------| +| How to classify relative vs absolute strings | Regex allowlist of relative patterns | +| Which relative patterns to cover | PHP documented relative formats (10 pattern groups) | +| Rule identifier strategy | Keep `clock.disallowDateTimeNow` for backward compat | +| Error message changes | None required — existing format already handles it | +| Scope: both `DateTime` and `DateTimeImmutable` | Yes, consistent with existing behavior | diff --git a/specs/002-psr20-ban-relative-dates/spec.md b/specs/002-psr20-ban-relative-dates/spec.md new file mode 100644 index 0000000..3d0d8ef --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/spec.md @@ -0,0 +1,109 @@ +# Feature Specification: PSR-20 Relative Date Enforcement + +**Feature Branch**: `002-psr20-ban-relative-dates` + +**Created**: 2026-07-22 + +**Status**: Draft + +**Input**: User description: "@src/Rules/Psr/Psr20Rule.php currently act only against current timestamp. It needs to be updated to forbid any relative date (tomorrow, +1 day, etc...) but keep possible to instanciate a DateTimeImmutable via its constructor if the date is a variable or an absolute date represented as a string." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Relative Date Strings Are Flagged (Priority: P1) + +A developer writes code that constructs a `DateTime` or `DateTimeImmutable` object using a relative date string such as `'tomorrow'`, `'+1 day'`, `'next Monday'`, or `'last week'`. The rule detects this as a violation and directs the developer to use a clock abstraction instead. + +**Why this priority**: This is the core new capability. The existing rule only blocks `'now'` and empty constructors, leaving many implicit time-dependency patterns undetected. Catching all relative strings is the fundamental goal of this feature. + +**Independent Test**: Can be fully tested by running static analysis on a file containing `new DateTimeImmutable('tomorrow')` and verifying that a violation is reported. + +**Acceptance Scenarios**: + +1. **Given** code containing `new DateTimeImmutable('tomorrow')`, **When** static analysis runs, **Then** a violation is reported directing the developer to use a clock interface. +2. **Given** code containing `new DateTime('+1 day')`, **When** static analysis runs, **Then** a violation is reported. +3. **Given** code containing `new DateTimeImmutable('next Monday')`, **When** static analysis runs, **Then** a violation is reported. +4. **Given** code containing `new DateTimeImmutable('-2 weeks')`, **When** static analysis runs, **Then** a violation is reported. +5. **Given** code containing `new DateTimeImmutable('yesterday')`, **When** static analysis runs, **Then** a violation is reported. +6. **Given** code containing `new DateTimeImmutable('TOMORROW')` (uppercase), **When** static analysis runs, **Then** a violation is reported. +7. **Given** code containing `new DateTimeImmutable('last day of this month')`, **When** static analysis runs, **Then** a violation is reported. + +--- + +### User Story 2 - Absolute Date Strings Are Permitted (Priority: P2) + +A developer constructs a `DateTime` or `DateTimeImmutable` object using a hard-coded absolute date string (e.g., `'2023-01-15'` or `'2023-01-15 12:00:00'`). This is a valid pattern — for example, when setting a known fixed date in a test or configuration — and the rule must not report a violation. + +**Why this priority**: Without this allowance the rule would block legitimate uses of explicitly known dates, making it unnecessarily obstructive. Distinguishing relative from absolute is what makes the rule precise and trustworthy. + +**Independent Test**: Can be fully tested by running static analysis on a file containing `new DateTimeImmutable('2023-01-15')` and verifying no violation is reported. + +**Acceptance Scenarios**: + +1. **Given** code containing `new DateTimeImmutable('2023-01-15')`, **When** static analysis runs, **Then** no violation is reported. +2. **Given** code containing `new DateTime('2023-12-31 23:59:59')`, **When** static analysis runs, **Then** no violation is reported. +3. **Given** code containing `new DateTimeImmutable('2023-01-15T12:00:00+00:00')`, **When** static analysis runs, **Then** no violation is reported. + +--- + +### User Story 3 - Variable Arguments Are Permitted (Priority: P2) + +A developer constructs a `DateTime` or `DateTimeImmutable` object using a variable as the date argument. Because the value is not known at analysis time, the rule allows this usage to avoid false positives. + +**Why this priority**: Blocking variable-based construction would produce false positives in legitimate dynamic scenarios, undermining developer trust in the rule. + +**Independent Test**: Can be fully tested by running static analysis on a file containing `new DateTimeImmutable($dateString)` and verifying no violation is reported. + +**Acceptance Scenarios**: + +1. **Given** code containing `new DateTimeImmutable($dateString)`, **When** static analysis runs, **Then** no violation is reported. +2. **Given** code containing `new DateTime($someVariable)`, **When** static analysis runs, **Then** no violation is reported. + +--- + +### Edge Cases + +- What happens with `'now'` (existing behavior)? → Must still be flagged. +- What happens with an empty constructor `new DateTimeImmutable()` (existing behavior)? → Must still be flagged. +- What happens with a variadic placeholder argument (`...`)? → Must not be flagged (cannot be statically evaluated). +- What happens with relative strings in mixed case (e.g., `'TOMORROW'`, `'Next Monday'`)? → Must be flagged (case-insensitive matching). +- What happens with `'+0 seconds'` or `'0 days'`? → Must be flagged (still a relative expression, even if the offset is zero). +- What happens with time-only strings like `'12:00:00'`? → Must not be flagged (represents an absolute time, not a relative date). +- What happens with strings that combine a fixed month/year and a relative selector (e.g., `'last day of January 2023'`)? → Must be flagged (contains a relative component). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The rule MUST report a violation when `new DateTime` or `new DateTimeImmutable` is called with no arguments. +- **FR-002**: The rule MUST report a violation when `new DateTime` or `new DateTimeImmutable` is called with the string literal `'now'` (case-insensitive). +- **FR-003**: The rule MUST report a violation when `new DateTime` or `new DateTimeImmutable` is called with a string literal that represents a relative date or time. +- **FR-004**: Relative date string detection MUST cover at minimum: `yesterday`, `today`, `tomorrow`, `next `, `last `, `+N `, `-N `, `first day of`, `last day of`, `ago`, and other English-language relative specifiers recognized by the PHP datetime parser. +- **FR-005**: Relative date matching MUST be case-insensitive. +- **FR-006**: The rule MUST NOT report a violation when the first constructor argument is a variable (value not known at analysis time). +- **FR-007**: The rule MUST NOT report a violation when the first constructor argument is a string literal representing an absolute date or date-time (e.g., `'2023-01-15'`, `'2023-12-31 23:59:59'`, ISO 8601 strings). +- **FR-008**: The rule MUST NOT report a violation when the first constructor argument is a variadic placeholder. +- **FR-009**: The error message MUST direct the developer to use `\Psr\Clock\ClockInterface` instead. + +### Key Entities + +- **Relative date string**: A string literal whose value is computed relative to the current moment at runtime (e.g., `'tomorrow'`, `'+1 day'`, `'next Monday'`, `'last week'`, `'yesterday noon'`). +- **Absolute date string**: A string literal that denotes a fixed, explicitly stated date or date-time whose value does not depend on when the code executes (e.g., `'2023-01-15'`, `'2023-01-15 12:00:00'`). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of relative date string patterns in the test fixture are detected as violations by the updated rule. +- **SC-002**: 0 false positives are produced when the rule analyzes code using absolute date string literals or variable arguments. +- **SC-003**: All previously existing passing tests for the rule continue to pass without modification. +- **SC-004**: The full CI pipeline (tests, static analysis, code style) passes on all declared supported PHP versions. + +## Assumptions + +- The rule targets `DateTime` and `DateTimeImmutable` by their unqualified names, consistent with the current behavior. Fully qualified names (e.g., `\DateTimeImmutable`) are also covered. +- The scope does not extend to subclasses, interfaces, or third-party date libraries. +- The definition of "relative date string" is derived from the set of relative formats recognized by the PHP datetime parser, not from a custom arbitrary list. +- Strings that are ambiguous but whose most natural reading is absolute (e.g., pure time strings like `'12:00:00'`) are treated as absolute to minimize false positives. +- The existing rule error identifier may need to be broadened in scope; this is deferred to the planning phase. +- No new dependencies are introduced to implement the detection logic. diff --git a/specs/002-psr20-ban-relative-dates/tasks.md b/specs/002-psr20-ban-relative-dates/tasks.md new file mode 100644 index 0000000..7c013dc --- /dev/null +++ b/specs/002-psr20-ban-relative-dates/tasks.md @@ -0,0 +1,174 @@ +# Tasks: PSR-20 Relative Date Enforcement + +**Input**: Design documents from `specs/002-psr20-ban-relative-dates/` + +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ ✓ + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) + +--- + +## Phase 1: Setup + +**Purpose**: Verify development environment is ready. No project initialization needed — all structure exists. + +- [x] T001 Verify project dependencies are installed: `docker compose run --rm php composer install` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the relative-date detection method to the rule class. All user stories depend on this. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T002 Add private static method `isRelativeDateString(string $value): bool` to `src/Rules/Psr/Psr20Rule.php` — implement all 10 PCRE pattern groups from `specs/002-psr20-ban-relative-dates/data-model.md` (keywords, day names, relative qualifiers, signed/unsigned offsets, ago, in, day-of-month selectors, clock-relative); matching must be case-insensitive on the trimmed string value +- [x] T003 Update `processNode()` in `src/Rules/Psr/Psr20Rule.php` — replace the `'now' === strtolower($firstArg->value)` check with `self::isRelativeDateString($firstArg->value)` and pass the raw string value (not the quoted form) as `$context` to `buildError()` wrapped in single-quotes; the `'now'` case is now subsumed by the keyword pattern + +**Checkpoint**: Rule logic is updated. Run `docker compose run --rm php composer phpstan` — must pass. + +--- + +## Phase 3: User Story 1 — Relative date strings are flagged (Priority: P1) 🎯 MVP + +**Goal**: All PHP relative date string literals passed to `new DateTime`/`new DateTimeImmutable` are reported as violations. + +**Independent Test**: Run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` — all expected errors for relative-date lines must be present. + +### Implementation for User Story 1 + +- [x] T004 [US1] Extend `tests/Rules/Psr/fixtures/psr20.php` — append lines using relative date string literals that must trigger errors: `new DateTimeImmutable('yesterday')` is already on line 10 (leave it); add `new DateTimeImmutable('tomorrow')`, `new DateTime('+1 day')`, `new DateTimeImmutable('next Monday')`, `new DateTimeImmutable('-2 weeks')`, `new DateTimeImmutable('2 days ago')`, `new DateTimeImmutable('last day of this month')`; also cover remaining pattern groups from data-model.md: `new DateTimeImmutable('today')` (keyword), `new DateTimeImmutable('friday')` (standalone day name), `new DateTimeImmutable('in 3 weeks')` (in-expression), `new DateTimeImmutable('noon')` (clock keyword); and edge cases: `new DateTimeImmutable('+0 seconds')` and `new DateTimeImmutable('0 days')` (zero-offset must still flag) +- [x] T005 [US1] Update `tests/Rules/Psr/Psr20RuleTest.php` — add expected error assertions for every relative-date line in the fixture, including the pre-existing `'yesterday'` line (currently line 10, now expected to error); each assertion must match the exact error message format `"Avoid using new {Class}('{arg}') directly. Prefer using \Psr\Clock\ClockInterface instead."` and the correct line number +- [x] T006 [US1] Run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` and confirm all assertions pass + +**Checkpoint**: User Story 1 complete. Every relative date literal in the fixture produces the expected error. Existing errors for empty-arg and `'now'` cases still pass. + +--- + +## Phase 4: User Story 2 — Absolute date strings are permitted (Priority: P2) + +**Goal**: String literals representing absolute dates (e.g., `'2023-01-15'`) produce no violation. + +**Independent Test**: Run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` — no errors for absolute-date lines. + +### Implementation for User Story 2 + +- [x] T007 [US2] Extend `tests/Rules/Psr/fixtures/psr20.php` — add absolute date lines that must NOT trigger errors: `new DateTime('2023-01-01')` is already present (leave it); add `new DateTimeImmutable('2023-12-31 23:59:59')`, `new DateTimeImmutable('2023-01-15T12:00:00+00:00')`, and `new DateTimeImmutable('12:00:00')` (time-only string — must not flag) +- [x] T008 [US2] Confirm those lines are absent from the expected-error array in `tests/Rules/Psr/Psr20RuleTest.php` (no assertion added for them) and run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` + +**Checkpoint**: User Stories 1 and 2 both pass. Absolute date lines produce no errors; relative date lines all produce errors. + +--- + +## Phase 5: User Story 3 — Variable arguments are permitted (Priority: P2) + +**Goal**: Variable arguments (`$var`) passed to `new DateTime`/`new DateTimeImmutable` produce no violation. + +**Independent Test**: Run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` — no errors for variable-arg lines. + +### Implementation for User Story 3 + +- [x] T009 [US3] Extend `tests/Rules/Psr/fixtures/psr20.php` — add variable argument lines that must NOT trigger errors: `new DateTimeImmutable($dateString)` and `new DateTime($someVar)` (declare `$dateString` and `$someVar` as `string` variables above them); also add `new DateTimeImmutable(...$args)` (variadic placeholder — covers FR-008; declare `$args` as `string[]` above it) +- [x] T010 [US3] Confirm those lines are absent from the expected-error array in `tests/Rules/Psr/Psr20RuleTest.php` and run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` + +**Checkpoint**: All three user stories pass. Full test suite is green. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation update and full CI validation. + +- [x] T011 [P] Update `README.md` section for `clock.disallowDateTimeNow` — add `'tomorrow'`, `'+1 day'`, `'next Monday'` to the "Triggers on" code block; remove `'yesterday'` from the "Does not trigger on" block (it now triggers); add `new DateTimeImmutable($dateVariable)` as an allowed example in "Does not trigger on" +- [x] T012 Run full CI pipeline: `docker compose run --rm php composer test && docker compose run --rm php composer phpstan && docker compose run --rm php composer cs-check` — all three must exit 0 + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — **BLOCKS all user stories** +- **User Stories (Phases 3–5)**: All depend on Foundational (Phase 2) + - US1 (Phase 3) must complete first — US2 and US3 fixture tasks append to the same file + - US2 (Phase 4) depends on Phase 3 completing (fixture file must be stable) + - US3 (Phase 5) depends on Phase 4 completing (same fixture file) +- **Polish (Phase 6)**: Depends on all user stories complete + +### User Story Dependencies + +- **US1 (P1)**: Can start after Phase 2 — no dependency on US2/US3 +- **US2 (P2)**: Logically independent but shares the fixture file with US1 — sequence after US1 to avoid conflicts +- **US3 (P2)**: Logically independent but shares the fixture file — sequence after US2 + +### Within Each User Story + +- Fixture extension before test update (need line numbers to write assertions) +- Test update before test run +- Logic change (Phase 2) before any fixture/test work + +### Parallel Opportunities + +- T002 and T003 in Phase 2 must be sequential (T003 depends on T002's change) +- T004, T007, T009 are NOT marked [P] — they all modify the same fixture file and must be sequenced by phase +- T011 (README) is [P] relative to the test run (T012) — can be done while T012 runs + +--- + +## Parallel Example: Foundational Phase + +```bash +# Phase 2 must be sequential — T003 uses the method added in T002: +Task T002: "Add isRelativeDateString() to src/Rules/Psr/Psr20Rule.php" + → then → +Task T003: "Update processNode() in src/Rules/Psr/Psr20Rule.php" +``` + +## Parallel Example: Polish Phase + +```bash +# T011 and T012 can run in parallel: +Task T011: "Update README.md documentation" +Task T012: "Run full CI pipeline" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (verify env) +2. Complete Phase 2: Foundational (add detection logic — **required**) +3. Complete Phase 3: User Story 1 (relative dates flagged) +4. **STOP and VALIDATE**: Run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` +5. Core capability delivered + +### Incremental Delivery + +1. Phase 1 + Phase 2 → Detection logic ready +2. Phase 3 (US1) → Relative dates flagged → MVP delivered +3. Phase 4 (US2) → Absolute dates confirmed passing → Extra test coverage +4. Phase 5 (US3) → Variable args confirmed passing → Full test coverage +5. Phase 6 → README updated + CI green → Ready to merge + +### Single-Developer Strategy + +Work strictly in phase order (1 → 2 → 3 → 4 → 5 → 6). No parallel work needed — this is a single-developer feature touching a small number of files. + +--- + +## Notes + +- [P] tasks = different files or logically independent, no file conflicts at that phase +- [Story] label maps each task to its spec user story for traceability +- The fixture file (`psr20.php`) is extended in multiple phases — always append, never rewrite earlier lines, to preserve line numbers from prior assertions +- Verify line numbers in the fixture match expected-error line numbers in the test before each run +- `'yesterday'` is on line 10 of the existing fixture and currently does NOT error — after T003 (Foundational), it will error; T005 must add this to the expected-error array +- Run `composer cs-check` after any PHP file change to catch formatting issues early diff --git a/src/Rules/Psr/Psr20Rule.php b/src/Rules/Psr/Psr20Rule.php index d79f5cb..7d9a9e6 100644 --- a/src/Rules/Psr/Psr20Rule.php +++ b/src/Rules/Psr/Psr20Rule.php @@ -15,7 +15,8 @@ /** * Enforce the PSR-20 recommendation to avoid using `new \DateTime()` or `new \DateTimeImmutable()` directly in your code. * - * It tracks either `new \DateTime()`, `new \DateTime('now')`, `new \DateTimeImmutable()` and `new \DateTimeImmutable('now')`. + * Flags empty constructors and any relative date string literal (e.g. `'now'`, `'tomorrow'`, `'+1 day'`, `'next Monday'`). + * Absolute date strings (e.g. `'2023-01-15'`) and variable arguments are allowed. * * @implements Rule */ @@ -50,13 +51,38 @@ public function processNode(Node $node, Scope $scope): array $firstArg = $firstArg->value; - if ($firstArg instanceof Node\Scalar\String_ && 'now' === strtolower($firstArg->value)) { - return [$this->buildError($node->class->toString(), '\'now\'')]; + if ($firstArg instanceof Node\Scalar\String_ && self::isRelativeDateString($firstArg->value)) { + return [$this->buildError($node->class->toString(), sprintf("'%s'", $firstArg->value))]; } return []; } + private static function isRelativeDateString(string $value): bool + { + $trimmed = trim($value); + + $patterns = [ + '/^(now|yesterday|today|tomorrow|noon|midnight)\b/i', + '/^(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i', + '/^(next|last|previous|this)\s+/i', + '/^[+-]\d+\s+(second|minute|hour|day|week|month|year)s?/i', + '/^\d+\s+(second|minute|hour|day|week|month|year)s?\s*$/i', + '/\bago\b/i', + '/^in\s+\d+\s+(second|minute|hour|day|week|month|year)s?/i', + '/^(first|last)\s+day\s+of\b/i', + '/^(back|front)\s+of\b/i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $trimmed)) { + return true; + } + } + + return false; + } + private function buildError(string $class, string $context): IdentifierRuleError { return RuleErrorBuilder::message("Avoid using new $class($context) directly. Prefer using \Psr\Clock\ClockInterface instead.") diff --git a/tests/Rules/Psr/Psr20RuleTest.php b/tests/Rules/Psr/Psr20RuleTest.php index 8fd8d26..874ca03 100644 --- a/tests/Rules/Psr/Psr20RuleTest.php +++ b/tests/Rules/Psr/Psr20RuleTest.php @@ -25,6 +25,23 @@ public function testRule(): void ["Avoid using new DateTime('now') directly. Prefer using \Psr\Clock\ClockInterface instead.", 6], ["Avoid using new DateTimeImmutable() directly. Prefer using \Psr\Clock\ClockInterface instead.", 7], ["Avoid using new DateTimeImmutable('now') directly. Prefer using \Psr\Clock\ClockInterface instead.", 8], + ["Avoid using new DateTimeImmutable('yesterday') directly. Prefer using \Psr\Clock\ClockInterface instead.", 10], + ["Avoid using new DateTimeImmutable('tomorrow') directly. Prefer using \Psr\Clock\ClockInterface instead.", 11], + ["Avoid using new DateTime('+1 day') directly. Prefer using \Psr\Clock\ClockInterface instead.", 12], + ["Avoid using new DateTimeImmutable('next Monday') directly. Prefer using \Psr\Clock\ClockInterface instead.", 13], + ["Avoid using new DateTimeImmutable('-2 weeks') directly. Prefer using \Psr\Clock\ClockInterface instead.", 14], + ["Avoid using new DateTimeImmutable('2 days ago') directly. Prefer using \Psr\Clock\ClockInterface instead.", 15], + ["Avoid using new DateTimeImmutable('last day of this month') directly. Prefer using \Psr\Clock\ClockInterface instead.", 16], + ["Avoid using new DateTimeImmutable('today') directly. Prefer using \Psr\Clock\ClockInterface instead.", 17], + ["Avoid using new DateTimeImmutable('friday') directly. Prefer using \Psr\Clock\ClockInterface instead.", 18], + ["Avoid using new DateTimeImmutable('in 3 weeks') directly. Prefer using \Psr\Clock\ClockInterface instead.", 19], + ["Avoid using new DateTimeImmutable('noon') directly. Prefer using \Psr\Clock\ClockInterface instead.", 20], + ["Avoid using new DateTimeImmutable('+0 seconds') directly. Prefer using \Psr\Clock\ClockInterface instead.", 21], + ["Avoid using new DateTimeImmutable('0 days') directly. Prefer using \Psr\Clock\ClockInterface instead.", 22], + ["Avoid using new DateTimeImmutable('yesterday noon') directly. Prefer using \Psr\Clock\ClockInterface instead.", 32], + ["Avoid using new DateTimeImmutable('today midnight') directly. Prefer using \Psr\Clock\ClockInterface instead.", 33], + ["Avoid using new DateTimeImmutable('tomorrow 12:00') directly. Prefer using \Psr\Clock\ClockInterface instead.", 34], + ["Avoid using new DateTimeImmutable('monday 14:00:00') directly. Prefer using \Psr\Clock\ClockInterface instead.", 35], ]); } } diff --git a/tests/Rules/Psr/fixtures/psr20.php b/tests/Rules/Psr/fixtures/psr20.php index aeec88f..1e9cfe6 100644 --- a/tests/Rules/Psr/fixtures/psr20.php +++ b/tests/Rules/Psr/fixtures/psr20.php @@ -8,3 +8,28 @@ $d = new DateTimeImmutable('now'); $e = new DateTime('2023-01-01'); $f = new DateTimeImmutable('yesterday'); +$g = new DateTimeImmutable('tomorrow'); +$h = new DateTime('+1 day'); +$i = new DateTimeImmutable('next Monday'); +$j = new DateTimeImmutable('-2 weeks'); +$k = new DateTimeImmutable('2 days ago'); +$l = new DateTimeImmutable('last day of this month'); +$m = new DateTimeImmutable('today'); +$n = new DateTimeImmutable('friday'); +$o = new DateTimeImmutable('in 3 weeks'); +$p = new DateTimeImmutable('noon'); +$q = new DateTimeImmutable('+0 seconds'); +$r = new DateTimeImmutable('0 days'); +$s = new DateTimeImmutable('2023-12-31 23:59:59'); +$t = new DateTimeImmutable('2023-01-15T12:00:00+00:00'); +$u = new DateTimeImmutable('12:00:00'); +$dateString = '2023-01-01'; +$someVar = '2024-06-15'; +$args = ['2023-01-01']; +$v = new DateTimeImmutable($dateString); +$w = new DateTime($someVar); +$x = new DateTimeImmutable(...$args); +$y = new DateTimeImmutable('yesterday noon'); +$z = new DateTimeImmutable('today midnight'); +$aa = new DateTimeImmutable('tomorrow 12:00'); +$bb = new DateTimeImmutable('monday 14:00:00'); From b6ecf1a8373b71a3c8e5ae903aba517789646517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20V=C3=A9tillard?= <84911237+clementvtrd@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:38:22 +0200 Subject: [PATCH 3/5] test(psr20): add compound modifier and uppercase coverage, sync spec docs - Add fixture lines for 'TOMORROW' (uppercase, US1/AC6) and new \stdClass() (non-clock class must not trigger) with matching test assertions - Regenerate phpstan baseline (31 errors) - Update data-model.md to document \b word boundary patterns and explain compound modifier rationale (replaces stale $ anchors) - Update quickstart.md scenario 1 to include compound modifier examples Co-Authored-By: Claude Sonnet 4.6 --- phpstan-baseline.neon | 6 ++++++ .../002-psr20-ban-relative-dates/data-model.md | 17 ++++++++++------- .../002-psr20-ban-relative-dates/quickstart.md | 2 +- specs/002-psr20-ban-relative-dates/tasks.md | 9 +++++++++ tests/Rules/Psr/Psr20RuleTest.php | 1 + tests/Rules/Psr/fixtures/psr20.php | 2 ++ 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5fbe061..8586228 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -42,6 +42,12 @@ parameters: count: 1 path: tests/Rules/Psr/fixtures/psr20.php + - + message: '#^Avoid using new DateTimeImmutable\(''TOMORROW''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + - message: '#^Avoid using new DateTimeImmutable\(''friday''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' identifier: clock.disallowDateTimeNow diff --git a/specs/002-psr20-ban-relative-dates/data-model.md b/specs/002-psr20-ban-relative-dates/data-model.md index 844fbba..e08f115 100644 --- a/specs/002-psr20-ban-relative-dates/data-model.md +++ b/specs/002-psr20-ban-relative-dates/data-model.md @@ -26,18 +26,21 @@ The complete set of patterns, grouped for clarity: | Pattern | Matches | |---------|---------| -| `/^now$/i` | `now`, `NOW`, `Now` | -| `/^yesterday$/i` | `yesterday`, `YESTERDAY` | -| `/^today$/i` | `today`, `TODAY` | -| `/^tomorrow$/i` | `tomorrow`, `TOMORROW` | -| `/^noon$/i` | `noon`, `NOON` | -| `/^midnight$/i` | `midnight`, `MIDNIGHT` | +| `/^(now\|yesterday\|today\|tomorrow\|noon\|midnight)\b/i` | `now`, `tomorrow`, `noon`, `TOMORROW`, `yesterday noon`, `today midnight` | + +> **Why `\b` instead of `$`**: Using a word boundary rather than end-of-string allows the pattern +> to also catch compound modifier strings such as `'yesterday noon'` or `'tomorrow 12:00'`, where +> a relative keyword is followed by a time component. PHP's datetime parser treats these as +> relative expressions. The six keywords are consolidated into one pattern for efficiency. ### Standalone Day Names | Pattern | Matches | |---------|---------| -| `/^(monday\|tuesday\|wednesday\|thursday\|friday\|saturday\|sunday)$/i` | `Monday`, `friday`, `TUESDAY` | +| `/^(monday\|tuesday\|wednesday\|thursday\|friday\|saturday\|sunday)\b/i` | `Monday`, `friday`, `TUESDAY`, `monday 14:00:00` | + +> **Why `\b` instead of `$`**: Same rationale as Keywords — day names followed by a time +> component (e.g., `'monday 14:00:00'`) are relative and must be caught. ### Relative Qualifiers diff --git a/specs/002-psr20-ban-relative-dates/quickstart.md b/specs/002-psr20-ban-relative-dates/quickstart.md index 04aed08..dbe18d6 100644 --- a/specs/002-psr20-ban-relative-dates/quickstart.md +++ b/specs/002-psr20-ban-relative-dates/quickstart.md @@ -25,7 +25,7 @@ docker compose run --rm php composer install ### Scenario 1: Relative date strings are flagged -**Setup**: The updated test fixture (`tests/Rules/Psr/fixtures/psr20.php`) must include PHP code with relative date string literals such as `'tomorrow'`, `'+1 day'`, `'yesterday'`, `'next Monday'`. +**Setup**: The updated test fixture (`tests/Rules/Psr/fixtures/psr20.php`) must include PHP code with relative date string literals such as `'tomorrow'`, `'+1 day'`, `'yesterday'`, `'next Monday'`, and compound modifiers like `'yesterday noon'` and `'monday 14:00:00'`. **Run**: diff --git a/specs/002-psr20-ban-relative-dates/tasks.md b/specs/002-psr20-ban-relative-dates/tasks.md index 7c013dc..817256f 100644 --- a/specs/002-psr20-ban-relative-dates/tasks.md +++ b/specs/002-psr20-ban-relative-dates/tasks.md @@ -172,3 +172,12 @@ Work strictly in phase order (1 → 2 → 3 → 4 → 5 → 6). No parallel work - Verify line numbers in the fixture match expected-error line numbers in the test before each run - `'yesterday'` is on line 10 of the existing fixture and currently does NOT error — after T003 (Foundational), it will error; T005 must add this to the expected-error array - Run `composer cs-check` after any PHP file change to catch formatting issues early + +--- + +## Phase 7: Convergence + +- [x] T013 [US1] Add `new DateTimeImmutable('TOMORROW')` to `tests/Rules/Psr/fixtures/psr20.php` (append after line 35) and add the corresponding expected error assertion in `tests/Rules/Psr/Psr20RuleTest.php`; then regenerate `phpstan-baseline.neon` and run `docker compose run --rm php vendor/bin/phpunit tests/Rules/Psr/Psr20RuleTest.php` — verifies US1/AC6 (uppercase relative string is flagged) (partial) +- [x] T014 [P] Update `specs/002-psr20-ban-relative-dates/data-model.md` — replace the individual `$`-anchored per-keyword patterns (e.g., `/^now$/i`, `/^tomorrow$/i`) and the standalone day-name pattern with their consolidated `\b` equivalents that match the implementation; add a "Compound Modifiers" note explaining why `\b` is used instead of `$` (partial) +- [x] T015 [P] Add a non-`DateTime`/`DateTimeImmutable` class constructor line to `tests/Rules/Psr/fixtures/psr20.php` (e.g., `$cc = new \stdClass();`) and confirm no expected error for it in `tests/Rules/Psr/Psr20RuleTest.php`; verifies the contract's "Non-DateTime/DateTimeImmutable class MUST NOT trigger" case (partial) +- [x] T016 [P] Update `specs/002-psr20-ban-relative-dates/quickstart.md` Scenario 1 — add at least one compound modifier example (`'yesterday noon'` or `'monday 14:00:00'`) to the example list so the guide reflects the full implemented behavior (partial) diff --git a/tests/Rules/Psr/Psr20RuleTest.php b/tests/Rules/Psr/Psr20RuleTest.php index 874ca03..30f5fe9 100644 --- a/tests/Rules/Psr/Psr20RuleTest.php +++ b/tests/Rules/Psr/Psr20RuleTest.php @@ -42,6 +42,7 @@ public function testRule(): void ["Avoid using new DateTimeImmutable('today midnight') directly. Prefer using \Psr\Clock\ClockInterface instead.", 33], ["Avoid using new DateTimeImmutable('tomorrow 12:00') directly. Prefer using \Psr\Clock\ClockInterface instead.", 34], ["Avoid using new DateTimeImmutable('monday 14:00:00') directly. Prefer using \Psr\Clock\ClockInterface instead.", 35], + ["Avoid using new DateTimeImmutable('TOMORROW') directly. Prefer using \Psr\Clock\ClockInterface instead.", 36], ]); } } diff --git a/tests/Rules/Psr/fixtures/psr20.php b/tests/Rules/Psr/fixtures/psr20.php index 1e9cfe6..8879c77 100644 --- a/tests/Rules/Psr/fixtures/psr20.php +++ b/tests/Rules/Psr/fixtures/psr20.php @@ -33,3 +33,5 @@ $z = new DateTimeImmutable('today midnight'); $aa = new DateTimeImmutable('tomorrow 12:00'); $bb = new DateTimeImmutable('monday 14:00:00'); +$cc = new DateTimeImmutable('TOMORROW'); +$dd = new \stdClass(); From 9cfad2717fd630efe55356150676388f49a91884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20V=C3=A9tillard?= <84911237+clementvtrd@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:02:11 +0200 Subject: [PATCH 4/5] chore: remove .specify directory --- .specify/feature.json | 3 - .specify/init-options.json | 9 - .specify/integration.json | 15 - .specify/integrations/claude.manifest.json | 17 - .specify/integrations/speckit.manifest.json | 17 - .specify/memory/constitution.md | 158 ----- .specify/scripts/bash/check-prerequisites.sh | 195 ----- .specify/scripts/bash/common.sh | 704 ------------------- .specify/scripts/bash/create-new-feature.sh | 344 --------- .specify/scripts/bash/setup-plan.sh | 83 --- .specify/scripts/bash/setup-tasks.sh | 91 --- .specify/templates/checklist-template.md | 40 -- .specify/templates/constitution-template.md | 50 -- .specify/templates/plan-template.md | 113 --- .specify/templates/spec-template.md | 131 ---- .specify/templates/tasks-template.md | 252 ------- .specify/workflows/speckit/workflow.yml | 77 -- .specify/workflows/workflow-registry.json | 13 - 18 files changed, 2312 deletions(-) delete mode 100644 .specify/feature.json delete mode 100644 .specify/init-options.json delete mode 100644 .specify/integration.json delete mode 100644 .specify/integrations/claude.manifest.json delete mode 100644 .specify/integrations/speckit.manifest.json delete mode 100644 .specify/memory/constitution.md delete mode 100755 .specify/scripts/bash/check-prerequisites.sh delete mode 100755 .specify/scripts/bash/common.sh delete mode 100755 .specify/scripts/bash/create-new-feature.sh delete mode 100755 .specify/scripts/bash/setup-plan.sh delete mode 100755 .specify/scripts/bash/setup-tasks.sh delete mode 100644 .specify/templates/checklist-template.md delete mode 100644 .specify/templates/constitution-template.md delete mode 100644 .specify/templates/plan-template.md delete mode 100644 .specify/templates/spec-template.md delete mode 100644 .specify/templates/tasks-template.md delete mode 100644 .specify/workflows/speckit/workflow.yml delete mode 100644 .specify/workflows/workflow-registry.json diff --git a/.specify/feature.json b/.specify/feature.json deleted file mode 100644 index 7f9ca21..0000000 --- a/.specify/feature.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "feature_directory": "specs/002-psr20-ban-relative-dates" -} diff --git a/.specify/init-options.json b/.specify/init-options.json deleted file mode 100644 index f5efbdf..0000000 --- a/.specify/init-options.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ai": "claude", - "ai_skills": true, - "feature_numbering": "sequential", - "here": true, - "integration": "claude", - "script": "sh", - "speckit_version": "0.13.4.dev0" -} diff --git a/.specify/integration.json b/.specify/integration.json deleted file mode 100644 index 5f23994..0000000 --- a/.specify/integration.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "0.13.4.dev0", - "integration_state_schema": 1, - "installed_integrations": [ - "claude" - ], - "integration_settings": { - "claude": { - "script": "sh", - "invoke_separator": "-" - } - }, - "integration": "claude", - "default_integration": "claude" -} diff --git a/.specify/integrations/claude.manifest.json b/.specify/integrations/claude.manifest.json deleted file mode 100644 index 28e462d..0000000 --- a/.specify/integrations/claude.manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "integration": "claude", - "version": "0.13.4.dev0", - "installed_at": "2026-07-22T14:28:21.419874+00:00", - "files": { - ".claude/skills/speckit-analyze/SKILL.md": "fecd4bf113c3dda58c75d387473c0106fc2dfea97a27bb7c65af94f3f916c188", - ".claude/skills/speckit-clarify/SKILL.md": "c1c2098756ca407530cca11c5b608f517d769962215ddafa013951b81e3e19c5", - ".claude/skills/speckit-constitution/SKILL.md": "7b30696b7d05058c90e799e90b65b74b0f7d46aaa5447011cde63be18c692205", - ".claude/skills/speckit-implement/SKILL.md": "c36b1326054c66ca23e17b00d6c12b1d2af8f7eee69656cefd38459210552cfe", - ".claude/skills/speckit-converge/SKILL.md": "04226b8443797337624983111546d5e5a48d9993a176c4e6d72a4099a0af50d4", - ".claude/skills/speckit-plan/SKILL.md": "7c2af71208f4358b54c58a60508362aa24a82ec2f24f7fc2213f50c10e5d7563", - ".claude/skills/speckit-checklist/SKILL.md": "946c6bc808891436972a11a423f89f0fbd272a79809bb8fd1d29f481ebe02613", - ".claude/skills/speckit-specify/SKILL.md": "63599eda0c392c0a051137adb43db9ef2a8d52fc68a5f5ef43ad19c55c9eca55", - ".claude/skills/speckit-tasks/SKILL.md": "f2e56011107c53edf9a3b7cad987ce8fef73d50cb8a6aa26baa4628950d743f7", - ".claude/skills/speckit-taskstoissues/SKILL.md": "dfe23aaca349cd76e98505dafa9aae1ef4616a0c35a5c79122b9bd881e16b62f" - } -} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json deleted file mode 100644 index 9d0d449..0000000 --- a/.specify/integrations/speckit.manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "integration": "speckit", - "version": "0.13.4.dev0", - "installed_at": "2026-07-22T14:28:21.428476+00:00", - "files": { - ".specify/scripts/bash/common.sh": "6ff86bf39f6b4684b0f80927dc7a1dadec26b4671988a3fe4d6c2523cbd3aa22", - ".specify/scripts/bash/check-prerequisites.sh": "a7d8a14ecf87332b600cd966b5d0e7cb9d594abce7e4d1ee4372b2b5b3efff06", - ".specify/scripts/bash/create-new-feature.sh": "e0474162655c073c11b870a99d0789042484910343db6a81c447ec456eca633a", - ".specify/scripts/bash/setup-tasks.sh": "cf21ba2212b4dd5b435c5ea8527500cfd27768b86c0bbc7ebc3207759f118d27", - ".specify/scripts/bash/setup-plan.sh": "4469b22960f43c07c33dca00de6dedb252145e9a9ce8fbb0e63be82e02b082ab", - ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3", - ".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4", - ".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536", - ".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e", - ".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842" - } -} diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md deleted file mode 100644 index 9db590b..0000000 --- a/.specify/memory/constitution.md +++ /dev/null @@ -1,158 +0,0 @@ - - -# Project Constitution — knplabs/phpstan-rules - -**Constitution Version:** 1.0.0 -**Ratification Date:** 2026-07-15 -**Last Amended Date:** 2026-07-15 -**Maintainers:** KnpLabs organization - ---- - -## Project Identity - -**Name:** knplabs/phpstan-rules -**Package type:** PHPStan extension (open-source, MIT) -**Purpose:** Provide PHPStan static-analysis rules shared across KnpLabs organization projects, -made available to the wider PHP community as a Composer package. -**Primary audience:** PHP developers who want to enforce organization-wide coding conventions via -PHPStan. - ---- - -## Core Principles - -### Principle 1 — Broad Compatibility - -Every release MUST remain installable on the widest reasonable range of PHP and PHPStan versions -to maximize adoption. Dropping a supported version is a MAJOR version bump and MUST be explicitly -justified. - -**Non-negotiable rules:** -- `composer.json` `require.php` MUST target `^8.2` or broader (never pin to a single patch). -- `composer.json` `require.phpstan/phpstan` MUST support at least the current stable major series - and, where API-compatible, the previous major. -- CI MUST gate on every declared PHP minor version (currently 8.2, 8.3, 8.4, 8.5). -- No rule implementation MUST use PHP APIs unavailable in the declared minimum version. -- Dependencies MUST be kept minimal; new `require` entries need explicit justification. - -**Rationale:** Restrictive version constraints are the primary reason PHPStan extensions go -un-adopted. A wide compatibility matrix lowers the barrier to entry and encourages community -contributions. - ---- - -### Principle 2 — Test Coverage per Rule - -Every PHPStan rule MUST ship with a dedicated `PHPUnit` / `PHPStan\Testing\RuleTestCase` test -covering both triggering and non-triggering cases. - -**Non-negotiable rules:** -- Each rule class in `src/Rules//` MUST have a corresponding test in - `tests/Rules//` following the naming convention `Test.php`. -- Each test MUST include at least one fixture file in `tests/Rules//fixtures/` that - exercises both the positive (violation) and negative (no violation) paths. -- New rules MUST NOT be registered in `extension.neon` until their test passes on all declared - PHP versions. -- CI MUST run the full test suite (`composer test`) and it MUST be green before merging. - -**Rationale:** Rules without tests are liabilities — they cannot be refactored safely and may -produce false positives or negatives undetected across PHPStan/PHP upgrades. - ---- - -### Principle 3 — Documentation per Rule - -Every PHPStan rule MUST be documented in `README.md` before it is released. - -**Non-negotiable rules:** -- Each rule entry in `README.md` MUST include: - - The rule identifier (e.g., `clock.disallowDateTimeNow`). - - A one-sentence summary of what the rule enforces and why. - - A "Triggers on" code block with at least one violating example. - - A "Does not trigger on" code block with at least one passing example. - - A "Recommended fix" section showing the preferred alternative pattern. -- Documentation MUST be kept in sync with rule behavior; a PR that changes a rule's logic - MUST also update its documentation. -- Rules that are not yet documented MUST NOT appear in a stable release. - -**Rationale:** Without clear documentation, developers cannot evaluate whether a rule is -appropriate for their project, and they cannot understand why a violation was flagged. - ---- - -### Principle 4 — Open-Source Quality Standards - -All source files MUST meet the project's code-quality baseline so that community contributors -can work effectively. - -**Non-negotiable rules:** -- All PHP source files MUST declare `strict_types=1`. -- Code MUST conform to PSR-12 enforced by `php-cs-fixer` (`composer cs-check` MUST pass). -- The codebase itself MUST pass PHPStan analysis at the configured level (`composer phpstan` - MUST pass). -- PHPStan baseline (`phpstan-baseline.neon`) MUST be kept as small as possible; new suppressions - MUST be justified in the PR description. -- Rule classes MUST be `final readonly` where PHP version and design allow. - -**Rationale:** Consistent code style and static analysis lower the cognitive load for contributors -and prevent quality regressions from being introduced silently. - ---- - -### Principle 5 — Single-Responsibility Rule Design - -Each rule class MUST enforce exactly one logical constraint. - -**Non-negotiable rules:** -- A rule MUST be scoped to a single AST node type (one `getNodeType()` return value per class). -- If two related constraints share an AST node, they MUST be implemented as separate classes - unless their logic is inseparably coupled (requires explicit justification in the PR). -- Rule namespaces (`src/Rules//`) MUST group rules by domain, not by node type. -- The rule's error message MUST be actionable: it MUST state what to do instead, not only - what was found wrong. - -**Rationale:** Small, focused rules are easier to test, easier to selectively disable in -downstream projects, and easier for contributors to understand and maintain. - ---- - -## Governance - -### Amendment Procedure - -1. Open a GitHub issue or PR describing the proposed change and its rationale. -2. At least one maintainer MUST review and approve the change. -3. The `CONSTITUTION_VERSION` MUST be bumped according to the semantic rules below. -4. `LAST_AMENDED_DATE` MUST be updated to the merge date. -5. The Sync Impact Report (HTML comment at top of this file) MUST be refreshed. - -### Versioning Policy - -- **MAJOR** bump: backward-incompatible governance change — removing a principle, redefining - a non-negotiable rule in a stricter or incompatible way. -- **MINOR** bump: new principle added, new mandatory section introduced, materially expanded - guidance. -- **PATCH** bump: clarification, wording improvement, typo fix, non-semantic refinement. - -### Compliance Review - -- Constitution compliance MUST be assessed during each PR review. -- Each speckit feature cycle (specify → plan → tasks → implement) MUST reference the active - constitution version. -- If a task or implementation choice would violate a principle, the violation MUST be escalated - before merging — not suppressed silently. diff --git a/.specify/scripts/bash/check-prerequisites.sh b/.specify/scripts/bash/check-prerequisites.sh deleted file mode 100755 index bf75140..0000000 --- a/.specify/scripts/bash/check-prerequisites.sh +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env bash - -# Consolidated prerequisite checking script -# -# This script provides unified prerequisite checking for Spec-Driven Development workflow. -# It replaces the functionality previously spread across multiple scripts. -# -# Usage: ./check-prerequisites.sh [OPTIONS] -# -# OPTIONS: -# --json Output in JSON format -# --require-tasks Require tasks.md to exist (for implementation phase) -# --include-tasks Include tasks.md in AVAILABLE_DOCS list -# --paths-only Only output path variables (no validation) -# --help, -h Show help message -# -# OUTPUTS: -# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]} -# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md -# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc. - -set -e - -# Parse command line arguments -JSON_MODE=false -REQUIRE_TASKS=false -INCLUDE_TASKS=false -PATHS_ONLY=false - -for arg in "$@"; do - case "$arg" in - --json) - JSON_MODE=true - ;; - --require-tasks) - REQUIRE_TASKS=true - ;; - --include-tasks) - INCLUDE_TASKS=true - ;; - --paths-only) - PATHS_ONLY=true - ;; - --help|-h) - cat << 'EOF' -Usage: check-prerequisites.sh [OPTIONS] - -Consolidated prerequisite checking for Spec-Driven Development workflow. - -OPTIONS: - --json Output in JSON format - --require-tasks Require tasks.md to exist (for implementation phase) - --include-tasks Include tasks.md in AVAILABLE_DOCS list - --paths-only Only output path variables (no prerequisite validation) - --help, -h Show this help message - -EXAMPLES: - # Check task prerequisites (plan.md required) - ./check-prerequisites.sh --json - - # Check implementation prerequisites (plan.md + tasks.md required) - ./check-prerequisites.sh --json --require-tasks --include-tasks - - # Get feature paths only (no validation) - ./check-prerequisites.sh --paths-only - -EOF - exit 0 - ;; - *) - echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2 - exit 1 - ;; - esac -done - -# Source common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get feature paths. -# In --paths-only mode this is pure resolution, so pass --no-persist to opt out -# of the feature.json write side effect (issue #3025). -if $PATHS_ONLY; then - _paths_output=$(get_feature_paths --no-persist) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -else - _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -fi -eval "$_paths_output" -unset _paths_output - -# If paths-only mode, output paths and exit (no validation) -if $PATHS_ONLY; then - if $JSON_MODE; then - # Minimal JSON paths payload (no validation performed) - if has_jq; then - jq -cn \ - --arg repo_root "$REPO_ROOT" \ - --arg branch "$CURRENT_BRANCH" \ - --arg feature_dir "$FEATURE_DIR" \ - --arg feature_spec "$FEATURE_SPEC" \ - --arg impl_plan "$IMPL_PLAN" \ - --arg tasks "$TASKS" \ - '{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}' - else - printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \ - "$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")" - fi - else - echo "REPO_ROOT: $REPO_ROOT" - echo "BRANCH: $CURRENT_BRANCH" - echo "FEATURE_DIR: $FEATURE_DIR" - echo "FEATURE_SPEC: $FEATURE_SPEC" - echo "IMPL_PLAN: $IMPL_PLAN" - echo "TASKS: $TASKS" - fi - exit 0 -fi - -# Validate required directories and files -if [[ ! -d "$FEATURE_DIR" ]]; then - echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 - echo "Run /speckit-specify first to create the feature structure." >&2 - exit 1 -fi - -if [[ ! -f "$IMPL_PLAN" ]]; then - echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-plan first to create the implementation plan." >&2 - exit 1 -fi - -# Check for tasks.md if required -if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then - echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-tasks first to create the task list." >&2 - exit 1 -fi - -# Build list of available documents -docs=() - -# Always check these optional docs -[[ -f "$RESEARCH" ]] && docs+=("research.md") -[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") - -# Check contracts directory (only if it exists and has files) -if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then - docs+=("contracts/") -fi - -[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") - -# Include tasks.md if requested and it exists -if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then - docs+=("tasks.md") -fi - -# Output results -if $JSON_MODE; then - # Build JSON array of documents - if has_jq; then - if [[ ${#docs[@]} -eq 0 ]]; then - json_docs="[]" - else - json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) - fi - jq -cn \ - --arg feature_dir "$FEATURE_DIR" \ - --argjson docs "$json_docs" \ - '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}' - else - if [[ ${#docs[@]} -eq 0 ]]; then - json_docs="[]" - else - json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) - json_docs="[${json_docs%,}]" - fi - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" - fi -else - # Text output - echo "FEATURE_DIR:$FEATURE_DIR" - echo "AVAILABLE_DOCS:" - - # Show status of each potential document - check_file "$RESEARCH" "research.md" - check_file "$DATA_MODEL" "data-model.md" - check_dir "$CONTRACTS_DIR" "contracts/" - check_file "$QUICKSTART" "quickstart.md" - - if $INCLUDE_TASKS; then - check_file "$TASKS" "tasks.md" - fi -fi diff --git a/.specify/scripts/bash/common.sh b/.specify/scripts/bash/common.sh deleted file mode 100755 index dc60f9f..0000000 --- a/.specify/scripts/bash/common.sh +++ /dev/null @@ -1,704 +0,0 @@ -#!/usr/bin/env bash -# Common functions and variables for all scripts - -# Find repository root by searching upward for .specify directory -# This is the primary marker for spec-kit projects -find_specify_root() { - local dir="${1:-$(pwd)}" - # Normalize to absolute path to prevent infinite loop with relative paths - # Use -- to handle paths starting with - (e.g., -P, -L) - dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1 - local prev_dir="" - while true; do - if [ -d "$dir/.specify" ]; then - echo "$dir" - return 0 - fi - # Stop if we've reached filesystem root or dirname stops changing - if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then - break - fi - prev_dir="$dir" - dir="$(dirname "$dir")" - done - return 1 -} - -# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that -# *contains* .specify/), for non-interactive / CI use — e.g. running a Spec Kit -# command against a member project from a monorepo root without cd. -# -# Precondition: SPECIFY_INIT_DIR is non-empty. Echoes the validated absolute -# project root, or prints an error and returns 1. Strict by design: the path -# must exist and contain .specify/, with no silent fallback to cwd or the -# script-location default (which would silently write to the wrong project). -# -# This is the single resolver: bundled extensions inherit it by sourcing core -# (e.g. the git extension's create-new-feature-branch) rather than duplicating it. -resolve_specify_init_dir() { - local init_root - # Normalize: relative paths resolve against $(pwd); a trailing slash collapses. - # CDPATH="" so a relative value cannot be resolved against the caller's CDPATH - # (which would also echo to stdout and corrupt the captured path). - if ! init_root="$(CDPATH="" cd -- "$SPECIFY_INIT_DIR" 2>/dev/null && pwd)"; then - echo "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $SPECIFY_INIT_DIR" >&2 - return 1 - fi - if [[ ! -d "$init_root/.specify" ]]; then - echo "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $init_root" >&2 - return 1 - fi - printf '%s\n' "$init_root" -} - -# Get repository root, prioritizing .specify directory -# This prevents using a parent repository when spec-kit is initialized in a subdirectory -get_repo_root() { - # Explicit project override wins (see resolve_specify_init_dir). - if [[ -n "${SPECIFY_INIT_DIR:-}" ]]; then - resolve_specify_init_dir - return - fi - - # First, look for .specify directory (spec-kit's own marker) - local specify_root - if specify_root=$(find_specify_root); then - echo "$specify_root" - return - fi - - # Final fallback to script location - local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - (cd "$script_dir/../../.." && pwd) -} - -# Get current feature name from explicit state only. -# Returns the feature identifier or empty string if none is set. -# Feature state is set by SPECIFY_FEATURE (from create-new-feature or -# the git extension) or implicitly via .specify/feature.json. -get_current_branch() { - if [[ -n "${SPECIFY_FEATURE:-}" ]]; then - echo "$SPECIFY_FEATURE" - return - fi - - # No explicit feature set — caller must handle this via feature.json - # in get_feature_paths(). Return empty to signal "unknown". - echo "" -} - -# Safely read .specify/feature.json's "feature_directory" value. -# Prints the raw value (possibly relative) to stdout, or empty string if the file -# is missing, unparseable, or does not contain the key. Always returns 0 so callers -# under `set -e` cannot be aborted by parser failure. -# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed. -read_feature_json_feature_directory() { - local repo_root="$1" - local fj="$repo_root/.specify/feature.json" - [[ -f "$fj" ]] || { printf '%s' ''; return 0; } - - # Try parsers in order (jq -> python3 -> grep/sed), falling through on - # failure. Selection is by *parse success*, not mere availability: on - # Windows `python3` commonly resolves to the Microsoft Store App Execution - # Alias stub, which passes `command -v` but fails at runtime (exit 49), so - # an availability-gated `elif` would pick python3, swallow its failure, and - # never reach the grep/sed fallback -- leaving feature.json unreadable even - # though it is valid (issue #3304). - local _fd='' - if command -v jq >/dev/null 2>&1; then - if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then - _fd='' - fi - fi - if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then - # Use Python so pretty-printed/multi-line JSON still parses correctly. - if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then - _fd='' - fi - fi - if [[ -z "$_fd" ]]; then - # Last-resort single-line grep/sed fallback. The `|| true` guards against - # grep returning 1 (no match) aborting under `set -e` / `pipefail`. - _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ - | head -n 1 \ - | sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' ) - fi - - printf '%s' "$_fd" - return 0 -} - -# Persist a feature_directory value to .specify/feature.json. -# Writes only when the file is missing or the value differs from what's stored. -# Accepts the raw (possibly relative) path — callers should pass the original -# user-supplied value, not the normalized absolute path. -_persist_feature_json() { - local repo_root="$1" - local feature_dir_value="$2" - local fj="$repo_root/.specify/feature.json" - - # Strip repo_root prefix if the value is absolute and under repo_root - if [[ "$feature_dir_value" == "$repo_root/"* ]]; then - feature_dir_value="${feature_dir_value#"$repo_root/"}" - fi - - # Read current value (if any) and skip write when unchanged - local current_val - current_val=$(read_feature_json_feature_directory "$repo_root") - if [[ "$current_val" == "$feature_dir_value" ]]; then - return 0 - fi - - # Ensure .specify/ directory exists - mkdir -p "$repo_root/.specify" - - # Write feature.json — prefer jq for safe JSON, fall back to printf - if command -v jq >/dev/null 2>&1; then - jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj" - else - printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj" - fi -} - -get_feature_paths() { - # Read-only callers (e.g. check-prerequisites.sh --paths-only) pass - # --no-persist so pure path resolution never writes .specify/feature.json, - # which would dirty the working tree or overwrite a pinned value (issue #3025). - local no_persist=false - if [[ "${1:-}" == "--no-persist" ]]; then - no_persist=true - shift - fi - - # Split decl/assignment so a SPECIFY_INIT_DIR validation failure in - # get_repo_root propagates as a hard error instead of being masked by `local`. - local repo_root - repo_root=$(get_repo_root) || return 1 - local current_branch - current_branch=$(get_current_branch) - - # Resolve feature directory. Priority: - # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) - # 2. .specify/feature.json "feature_directory" key (persisted by specify command) - # 3. Error — no feature context available - local feature_dir - if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then - feature_dir="$SPECIFY_FEATURE_DIRECTORY" - # Normalize relative paths to absolute under repo root - [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" - # Persist to feature.json so future sessions without the env var still - # work — unless the caller opted out for read-only resolution (#3025). - if [[ "$no_persist" != true ]]; then - _persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY" - fi - elif [[ -f "$repo_root/.specify/feature.json" ]]; then - local _fd - _fd=$(read_feature_json_feature_directory "$repo_root") - if [[ -n "$_fd" ]]; then - feature_dir="$_fd" - # Normalize relative paths to absolute under repo root - [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" - else - echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2 - return 1 - fi - else - echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2 - return 1 - fi - - # When no branch context exists (no SPECIFY_FEATURE, feature resolved via - # SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature - # directory basename so CURRENT_BRANCH is a usable identifier rather than - # an empty, misleading value (issue #3026). - if [[ -z "$current_branch" ]]; then - local feature_dir_trimmed="${feature_dir%/}" - current_branch="${feature_dir_trimmed##*/}" - fi - - # Use printf '%q' to safely quote values, preventing shell injection - # via crafted branch names or paths containing special characters - printf 'REPO_ROOT=%q\n' "$repo_root" - printf 'CURRENT_BRANCH=%q\n' "$current_branch" - printf 'FEATURE_DIR=%q\n' "$feature_dir" - printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" - printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" - printf 'TASKS=%q\n' "$feature_dir/tasks.md" - printf 'RESEARCH=%q\n' "$feature_dir/research.md" - printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md" - printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md" - printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts" -} - -# Check if jq is available for safe JSON construction -has_jq() { - command -v jq >/dev/null 2>&1 -} - -get_invoke_separator() { - local repo_root="${1:-$(get_repo_root)}" - if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then - printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" - return 0 - fi - - local integration_json="$repo_root/.specify/integration.json" - local separator="." - local parsed=0 - - if [[ -f "$integration_json" ]]; then - # Try parsers in order (jq -> python3 -> awk), falling through on - # failure. Selection is by *parse success*, not mere availability: on - # Windows `python3` commonly resolves to the Microsoft Store App - # Execution Alias stub, which passes `command -v` but fails at runtime - # (exit 49). An availability-gated branch would pick python3, swallow - # its failure, and — because this function historically had no text - # fallback — silently return "." even for `-`-separator integrations - # (e.g. forge, cline), yielding wrong command hints (issue #3304). - if command -v jq >/dev/null 2>&1; then - local jq_separator - if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then - case "$jq_separator" in - "."|"-") separator="$jq_separator"; parsed=1 ;; - esac - fi - fi - - if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then - local py_separator - if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null -import json -import sys - -try: - with open(sys.argv[1], encoding="utf-8") as fh: - state = json.load(fh) - key = state.get("default_integration") or state.get("integration") or "" - settings = state.get("integration_settings") - separator = "." - if isinstance(key, str) and isinstance(settings, dict): - entry = settings.get(key) - if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}: - separator = entry["invoke_separator"] - print(separator) -except Exception: - sys.exit(1) -PY -); then - case "$py_separator" in - "."|"-") separator="$py_separator"; parsed=1 ;; - esac - fi - fi - - if [[ "$parsed" -eq 0 ]]; then - # Last-resort text fallback for environments with neither jq nor a - # working python3 (e.g. stock Windows + Git Bash). Reads the active - # integration key (default_integration, else integration) and its - # invoke_separator from within the integration_settings object. - # Handles both pretty-printed (the written form) and compact JSON. - # Accumulate all lines into one buffer in END rather than using - # gawk-only whole-file slurp (RS="^$"), so this stays portable to - # the BSD awk on macOS. - local awk_separator - awk_separator=$(awk ' - function keyval(d, name, v) { - if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { - v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v - } - return "" - } - { doc = doc $0 "\n" } - END { - key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") - sep="." - if (key!="") { - settings=doc - if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { - settings=substr(doc, RSTART+RLENGTH-1) - } - if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { - start=RSTART+RLENGTH-1 - depth=0 - obj="" - for (i=start; i<=length(settings); i++) { - c=substr(settings,i,1) - obj=obj c - if (c=="{") depth++ - else if (c=="}") { depth--; if (depth==0) break } - } - if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { - tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) - if (s=="." || s=="-") sep=s - } - } - } - print sep - } - ' "$integration_json" 2>/dev/null) - case "$awk_separator" in - "."|"-") separator="$awk_separator" ;; - esac - fi - fi - - _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" - _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" - printf '%s\n' "$separator" -} - -format_speckit_command() { - local command_name="$1" - local repo_root="${2:-$(get_repo_root)}" - local separator - if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then - separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE" - else - separator=$(get_invoke_separator "$repo_root") - _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" - _SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator" - fi - - command_name="${command_name#/}" - command_name="${command_name#speckit.}" - command_name="${command_name#speckit-}" - command_name="${command_name//./$separator}" - - printf '/speckit%s%s\n' "$separator" "$command_name" -} - -# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable). -# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259). -json_escape() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\"/\\\"}" - s="${s//$'\n'/\\n}" - s="${s//$'\t'/\\t}" - s="${s//$'\r'/\\r}" - s="${s//$'\b'/\\b}" - s="${s//$'\f'/\\f}" - # Escape any remaining U+0001-U+001F control characters as \uXXXX. - # (U+0000/NUL cannot appear in bash strings and is excluded.) - # LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes, - # so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact. - local LC_ALL=C - local i char code - for (( i=0; i<${#s}; i++ )); do - char="${s:$i:1}" - printf -v code '%d' "'$char" 2>/dev/null || code=256 - if (( code >= 1 && code <= 31 )); then - printf '\\u%04x' "$code" - else - printf '%s' "$char" - fi - done -} - -check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } -check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } - -# Resolve a template name to a file path using the priority stack: -# 1. .specify/templates/overrides/ -# 2. .specify/presets//templates/ (sorted by priority from .registry) -# 3. .specify/extensions//templates/ -# 4. .specify/templates/ (core) -resolve_template() { - local template_name="$1" - local repo_root="$2" - local base="$repo_root/.specify/templates" - - # Priority 1: Project overrides - local override="$base/overrides/${template_name}.md" - [ -f "$override" ] && echo "$override" && return 0 - - # Priority 2: Installed presets (sorted by priority from .registry) - local presets_dir="$repo_root/.specify/presets" - if [ -d "$presets_dir" ]; then - local registry_file="$presets_dir/.registry" - if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then - # Read preset IDs sorted by priority (lower number = higher precedence). - # The python3 call is wrapped in an if-condition so that set -e does not - # abort the function when python3 exits non-zero (e.g. invalid JSON). - local sorted_presets="" - if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " -import json, sys, os -try: - with open(os.environ['SPECKIT_REGISTRY']) as f: - data = json.load(f) - presets = data.get('presets', {}) - for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): - if isinstance(meta, dict) and meta.get('enabled', True) is not False: - print(pid) -except Exception: - sys.exit(1) -" 2>/dev/null); then - if [ -n "$sorted_presets" ]; then - # python3 succeeded and returned preset IDs — search in priority order - while IFS= read -r preset_id; do - local candidate="$presets_dir/$preset_id/templates/${template_name}.md" - [ -f "$candidate" ] && echo "$candidate" && return 0 - done <<< "$sorted_presets" - fi - # python3 succeeded but registry has no presets — nothing to search - else - # python3 failed (missing, or registry parse error) — fall back to unordered directory scan - for preset in "$presets_dir"/*/; do - [ -d "$preset" ] || continue - local candidate="$preset/templates/${template_name}.md" - [ -f "$candidate" ] && echo "$candidate" && return 0 - done - fi - else - # Fallback: alphabetical directory order (no python3 available) - for preset in "$presets_dir"/*/; do - [ -d "$preset" ] || continue - local candidate="$preset/templates/${template_name}.md" - [ -f "$candidate" ] && echo "$candidate" && return 0 - done - fi - fi - - # Priority 3: Extension-provided templates - local ext_dir="$repo_root/.specify/extensions" - if [ -d "$ext_dir" ]; then - for ext in "$ext_dir"/*/; do - [ -d "$ext" ] || continue - # Skip hidden directories (e.g. .backup, .cache) - case "$(basename "$ext")" in .*) continue;; esac - local candidate="$ext/templates/${template_name}.md" - [ -f "$candidate" ] && echo "$candidate" && return 0 - done - fi - - # Priority 4: Core templates - local core="$base/${template_name}.md" - [ -f "$core" ] && echo "$core" && return 0 - - # Template not found in any location. - # Return 1 so callers can distinguish "not found" from "found". - # Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true - return 1 -} - -# Resolve a template name to composed content using composition strategies. -# Reads strategy metadata from preset manifests and composes content -# from multiple layers using prepend, append, or wrap strategies. -# -# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") -# Returns composed content string on stdout; exit code 1 if not found. -resolve_template_content() { - local template_name="$1" - local repo_root="$2" - local base="$repo_root/.specify/templates" - - # Collect all layers (highest priority first) - local -a layer_paths=() - local -a layer_strategies=() - - # Priority 1: Project overrides (always "replace") - local override="$base/overrides/${template_name}.md" - if [ -f "$override" ]; then - layer_paths+=("$override") - layer_strategies+=("replace") - fi - - # Priority 2: Installed presets (sorted by priority from .registry) - local presets_dir="$repo_root/.specify/presets" - if [ -d "$presets_dir" ]; then - local registry_file="$presets_dir/.registry" - local sorted_presets="" - if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then - if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " -import json, sys, os -try: - with open(os.environ['SPECKIT_REGISTRY']) as f: - data = json.load(f) - presets = data.get('presets', {}) - for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): - if isinstance(meta, dict) and meta.get('enabled', True) is not False: - print(pid) -except Exception: - sys.exit(1) -" 2>/dev/null); then - if [ -n "$sorted_presets" ]; then - local yaml_warned=false - while IFS= read -r preset_id; do - # Read strategy and file path from preset manifest - local strategy="replace" - local manifest_file="" - local manifest="$presets_dir/$preset_id/preset.yml" - if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then - # Requires PyYAML; falls back to replace/convention if unavailable - local result - local py_stderr - py_stderr=$(mktemp) - result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " -import sys, os -try: - import yaml -except ImportError: - print('yaml_missing', file=sys.stderr) - print('replace\t') - sys.exit(0) -try: - with open(os.environ['SPECKIT_MANIFEST']) as f: - data = yaml.safe_load(f) - for t in data.get('provides', {}).get('templates', []): - if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': - print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) - sys.exit(0) - print('replace\t') -except Exception: - print('replace\t') -" 2>"$py_stderr") - local parse_status=$? - if [ $parse_status -eq 0 ] && [ -n "$result" ]; then - IFS=$'\t' read -r strategy manifest_file <<< "$result" - strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') - fi - if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then - echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 - yaml_warned=true - fi - rm -f "$py_stderr" - fi - # Try manifest file path first, then convention path - local candidate="" - if [ -n "$manifest_file" ]; then - # Reject absolute paths and parent traversal - case "$manifest_file" in - /*|*../*|../*) manifest_file="" ;; - esac - fi - if [ -n "$manifest_file" ]; then - local mf="$presets_dir/$preset_id/$manifest_file" - [ -f "$mf" ] && candidate="$mf" - fi - if [ -z "$candidate" ]; then - local cf="$presets_dir/$preset_id/templates/${template_name}.md" - [ -f "$cf" ] && candidate="$cf" - fi - if [ -n "$candidate" ]; then - layer_paths+=("$candidate") - layer_strategies+=("$strategy") - fi - done <<< "$sorted_presets" - fi - else - # python3 failed — fall back to unordered directory scan (replace only) - for preset in "$presets_dir"/*/; do - [ -d "$preset" ] || continue - local candidate="$preset/templates/${template_name}.md" - if [ -f "$candidate" ]; then - layer_paths+=("$candidate") - layer_strategies+=("replace") - fi - done - fi - else - # No python3 or registry — fall back to unordered directory scan (replace only) - for preset in "$presets_dir"/*/; do - [ -d "$preset" ] || continue - local candidate="$preset/templates/${template_name}.md" - if [ -f "$candidate" ]; then - layer_paths+=("$candidate") - layer_strategies+=("replace") - fi - done - fi - fi - - # Priority 3: Extension-provided templates (always "replace") - local ext_dir="$repo_root/.specify/extensions" - if [ -d "$ext_dir" ]; then - for ext in "$ext_dir"/*/; do - [ -d "$ext" ] || continue - case "$(basename "$ext")" in .*) continue;; esac - local candidate="$ext/templates/${template_name}.md" - if [ -f "$candidate" ]; then - layer_paths+=("$candidate") - layer_strategies+=("replace") - fi - done - fi - - # Priority 4: Core templates (always "replace") - local core="$base/${template_name}.md" - if [ -f "$core" ]; then - layer_paths+=("$core") - layer_strategies+=("replace") - fi - - local count=${#layer_paths[@]} - [ "$count" -eq 0 ] && return 1 - - # Check if any layer uses a non-replace strategy - local has_composition=false - for s in "${layer_strategies[@]}"; do - [ "$s" != "replace" ] && has_composition=true && break - done - - # If the top (highest-priority) layer is replace, it wins entirely — - # lower layers are irrelevant regardless of their strategies. - if [ "${layer_strategies[0]}" = "replace" ]; then - cat "${layer_paths[0]}" - return 0 - fi - - if [ "$has_composition" = false ]; then - cat "${layer_paths[0]}" - return 0 - fi - - # Find the effective base: scan from highest priority (index 0) downward - # to find the nearest replace layer. Only compose layers above that base. - local base_idx=-1 - local i - for (( i=0; i=0; i-- )); do - local path="${layer_paths[$i]}" - local strat="${layer_strategies[$i]}" - local layer_content - # Preserve trailing newlines - layer_content=$(cat "$path"; printf x) - layer_content="${layer_content%x}" - - case "$strat" in - replace) content="$layer_content" ;; - prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; - append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; - wrap) - case "$layer_content" in - *'{CORE_TEMPLATE}'*) ;; - *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; - esac - while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do - local before="${layer_content%%\{CORE_TEMPLATE\}*}" - local after="${layer_content#*\{CORE_TEMPLATE\}}" - layer_content="${before}${content}${after}" - done - content="$layer_content" - ;; - *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; - esac - done - - printf '%s' "$content" - return 0 -} diff --git a/.specify/scripts/bash/create-new-feature.sh b/.specify/scripts/bash/create-new-feature.sh deleted file mode 100755 index e40e8b2..0000000 --- a/.specify/scripts/bash/create-new-feature.sh +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env bash - -set -e - -JSON_MODE=false -DRY_RUN=false -ALLOW_EXISTING=false -SHORT_NAME="" -BRANCH_NUMBER="" -USE_TIMESTAMP=false -ARGS=() -i=1 -while [ $i -le $# ]; do - arg="${!i}" - case "$arg" in - --json) - JSON_MODE=true - ;; - --dry-run) - DRY_RUN=true - ;; - --allow-existing-branch) - ALLOW_EXISTING=true - ;; - --short-name) - if [ $((i + 1)) -gt $# ]; then - echo 'Error: --short-name requires a value' >&2 - exit 1 - fi - i=$((i + 1)) - next_arg="${!i}" - # Check if the next argument is another option (starts with --) - if [[ "$next_arg" == --* ]]; then - echo 'Error: --short-name requires a value' >&2 - exit 1 - fi - SHORT_NAME="$next_arg" - ;; - --number) - if [ $((i + 1)) -gt $# ]; then - echo 'Error: --number requires a value' >&2 - exit 1 - fi - i=$((i + 1)) - next_arg="${!i}" - if [[ "$next_arg" == --* ]]; then - echo 'Error: --number requires a value' >&2 - exit 1 - fi - BRANCH_NUMBER="$next_arg" - ;; - --timestamp) - USE_TIMESTAMP=true - ;; - --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " - echo "" - echo "Options:" - echo " --json Output in JSON format" - echo " --dry-run Compute feature name and paths without creating directories or files" - echo " --allow-existing-branch Reuse an existing feature directory if it already exists" - echo " --short-name Provide a custom short name (2-4 words) for the feature" - echo " --number N Specify branch number manually (overrides auto-detection)" - echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" - echo " --help, -h Show this help message" - echo "" - echo "Examples:" - echo " $0 'Add user authentication system' --short-name 'user-auth'" - echo " $0 'Implement OAuth2 integration for API' --number 5" - echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" - exit 0 - ;; - *) - ARGS+=("$arg") - ;; - esac - i=$((i + 1)) -done - -FEATURE_DESCRIPTION="${ARGS[*]}" -if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 - exit 1 -fi - -# Trim whitespace and validate description is not empty (e.g., user passed only whitespace) -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') -if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Error: Feature description cannot be empty or contain only whitespace" >&2 - exit 1 -fi - -MAX_FEATURE_NUMBER=9223372036854775807 - -is_feature_number_in_range() { - local value="$1" - local normalized="${value#"${value%%[!0]*}"}" - [ -n "$normalized" ] || normalized=0 - [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0 - [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1 - # Equal-length digit strings must be compared without arithmetic overflow. - # shellcheck disable=SC2071 - [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]] -} - -# Function to get highest number from specs directory -get_highest_from_specs() { - local specs_dir="$1" - local highest=0 - - if [ -d "$specs_dir" ]; then - for dir in "$specs_dir"/*; do - [ -d "$dir" ] || continue - dirname=$(basename "$dir") - # Match sequential prefixes (>=3 digits), but skip timestamp dirs. - if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$dirname" | grep -Eo '^[0-9]+') - if is_feature_number_in_range "$number"; then - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number - fi - fi - fi - done - fi - - echo "$highest" -} - -# Function to clean and format a branch name -clean_branch_name() { - local name="$1" - echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' -} - -# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote -# so the persistence hints match the Python variant exactly (printf %q output -# differs between bash versions and from shlex.quote for spaces/metachars). -shell_quote() { - local value="$1" LC_ALL=C - if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then - printf '%s' "$value" - else - local q="'\"'\"'" - printf "'%s'" "${value//\'/$q}" - fi -} - -# Resolve repository root using common.sh functions which prioritize .specify -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -REPO_ROOT=$(get_repo_root) || exit 1 - -cd "$REPO_ROOT" - -SPECS_DIR="$REPO_ROOT/specs" -if [ "$DRY_RUN" != true ]; then - mkdir -p "$SPECS_DIR" -fi - -# Function to generate branch name with stop word filtering and length filtering -generate_branch_name() { - local description="$1" - - # Common stop words to filter out - local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$" - - # Convert to lowercase and split into words - local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g') - - # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) - local meaningful_words=() - for word in $clean_name; do - # Skip empty words - [ -z "$word" ] && continue - - # Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms) - if ! echo "$word" | grep -qiE "$stop_words"; then - if [ ${#word} -ge 3 ]; then - meaningful_words+=("$word") - # Keep short words that appear as an uppercase acronym in the original. - # Uppercase via tr and match with grep -w (both portable) rather than - # bash's 4+ "^^" case expansion (breaks on macOS bash 3.2) and \b (non-POSIX). - elif printf '%s' "$description" | grep -qw -- "$(printf '%s' "$word" | tr '[:lower:]' '[:upper:]')"; then - meaningful_words+=("$word") - fi - fi - done - - # If we have meaningful words, use first 3-4 of them - if [ ${#meaningful_words[@]} -gt 0 ]; then - local max_words=3 - if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi - - local result="" - local count=0 - for word in "${meaningful_words[@]}"; do - if [ $count -ge $max_words ]; then break; fi - if [ -n "$result" ]; then result="$result-"; fi - result="$result$word" - count=$((count + 1)) - done - echo "$result" - else - # Fallback to original logic if no meaningful words found - local cleaned=$(clean_branch_name "$description") - echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//' - fi -} - -# Generate branch name -if [ -n "$SHORT_NAME" ]; then - # Use provided short name, just clean it up - BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") -else - # Generate from description with smart filtering - BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") -fi - -# Warn if --number and --timestamp are both specified -if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then - >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" - BRANCH_NUMBER="" -fi - -# Determine branch prefix -if [ "$USE_TIMESTAMP" = true ]; then - FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" -else - if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then - echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2 - exit 1 - fi - - # Bash arithmetic is signed 64-bit; reject digit strings that would wrap. - if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then - echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2 - exit 1 - fi - - # Determine branch number from existing feature directories - if [ -z "$BRANCH_NUMBER" ]; then - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then - echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2 - exit 1 - fi - BRANCH_NUMBER=$((HIGHEST + 1)) - fi - - # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) - FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" -fi - -# GitHub enforces a 244-byte limit on branch names -# Validate and truncate if necessary -MAX_BRANCH_LENGTH=244 -if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then - # Calculate how much we need to trim from suffix - # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 - PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) - MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) - - # Truncate suffix at word boundary if possible - TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH) - # Remove trailing hyphen if truncation created one - TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//') - - ORIGINAL_BRANCH_NAME="$BRANCH_NAME" - BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}" - - >&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit" - >&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)" - >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" -fi - -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -SPEC_FILE="$FEATURE_DIR/spec.md" - -if [ "$DRY_RUN" != true ]; then - if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then - if [ "$USE_TIMESTAMP" = true ]; then - >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name." - else - >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number." - fi - exit 1 - fi - - mkdir -p "$FEATURE_DIR" - - if [ ! -f "$SPEC_FILE" ]; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" - fi - fi - - # Persist to .specify/feature.json so downstream commands can find the feature - _persist_feature_json "$REPO_ROOT" "$FEATURE_DIR" - - # Inform the user how to set feature state in their own shell - printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2 - printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2 -fi - -if $JSON_MODE; then - if command -v jq >/dev/null 2>&1; then - if [ "$DRY_RUN" = true ]; then - jq -cn \ - --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ - --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' - else - jq -cn \ - --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ - --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' - fi - else - if [ "$DRY_RUN" = true ]; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" - else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" - fi - fi -else - echo "BRANCH_NAME: $BRANCH_NAME" - echo "SPEC_FILE: $SPEC_FILE" - echo "FEATURE_NUM: $FEATURE_NUM" - if [ "$DRY_RUN" != true ]; then - printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" - printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" - fi -fi diff --git a/.specify/scripts/bash/setup-plan.sh b/.specify/scripts/bash/setup-plan.sh deleted file mode 100755 index e01dc44..0000000 --- a/.specify/scripts/bash/setup-plan.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Parse command line arguments -JSON_MODE=false -ARGS=() - -for arg in "$@"; do - case "$arg" in - --json) - JSON_MODE=true - ;; - --help|-h) - echo "Usage: $0 [--json]" - echo " --json Output results in JSON format" - echo " --help Show this help message" - exit 0 - ;; - *) - ARGS+=("$arg") - ;; - esac -done - -# Get script directory and load common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths and variables from common functions -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -eval "$_paths_output" -unset _paths_output - -# Ensure the feature directory exists -mkdir -p "$FEATURE_DIR" - -# Copy plan template if plan doesn't already exist -if [[ -f "$IMPL_PLAN" ]]; then - if $JSON_MODE; then - echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2 - else - echo "Plan already exists at $IMPL_PLAN, skipping template copy" - fi -else - TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true - if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then - cp "$TEMPLATE" "$IMPL_PLAN" - if $JSON_MODE; then - echo "Copied plan template to $IMPL_PLAN" >&2 - else - echo "Copied plan template to $IMPL_PLAN" - fi - else - if $JSON_MODE; then - echo "Warning: Plan template not found" >&2 - else - echo "Warning: Plan template not found" - fi - # Create a basic plan file if template doesn't exist - touch "$IMPL_PLAN" - fi -fi - -# Output results -if $JSON_MODE; then - if has_jq; then - jq -cn \ - --arg feature_spec "$FEATURE_SPEC" \ - --arg impl_plan "$IMPL_PLAN" \ - --arg specs_dir "$FEATURE_DIR" \ - --arg branch "$CURRENT_BRANCH" \ - '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}' - else - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \ - "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" - fi -else - echo "FEATURE_SPEC: $FEATURE_SPEC" - echo "IMPL_PLAN: $IMPL_PLAN" - echo "SPECS_DIR: $FEATURE_DIR" - echo "BRANCH: $CURRENT_BRANCH" -fi diff --git a/.specify/scripts/bash/setup-tasks.sh b/.specify/scripts/bash/setup-tasks.sh deleted file mode 100755 index ae0d7bd..0000000 --- a/.specify/scripts/bash/setup-tasks.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Parse command line arguments -JSON_MODE=false - -for arg in "$@"; do - case "$arg" in - --json) JSON_MODE=true ;; - --help|-h) - echo "Usage: $0 [--json]" - echo " --json Output results in JSON format" - echo " --help Show this help message" - exit 0 - ;; - *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; - esac -done - -# Source common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get feature paths -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -eval "$_paths_output" -unset _paths_output - -# Validate required files -if [[ ! -f "$IMPL_PLAN" ]]; then - echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-plan first to create the implementation plan." >&2 - exit 1 -fi - -if [[ ! -f "$FEATURE_SPEC" ]]; then - echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 - echo "Run /speckit-specify first to create the feature structure." >&2 - exit 1 -fi - -# Build available docs list -docs=() -[[ -f "$RESEARCH" ]] && docs+=("research.md") -[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") -if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then - docs+=("contracts/") -fi -[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") - -# Resolve tasks template through override stack -TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true -if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then - echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 - echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 - exit 1 -fi - -# Output results -if $JSON_MODE; then - if has_jq; then - if [[ ${#docs[@]} -eq 0 ]]; then - json_docs="[]" - else - json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) - fi - jq -cn \ - --arg feature_dir "$FEATURE_DIR" \ - --argjson docs "$json_docs" \ - --arg tasks_template "${TASKS_TEMPLATE:-}" \ - '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' - else - if [[ ${#docs[@]} -eq 0 ]]; then - json_docs="[]" - else - json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) - json_docs="[${json_docs%,}]" - fi - printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ - "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" - fi -else - echo "FEATURE_DIR: $FEATURE_DIR" - echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" - echo "AVAILABLE_DOCS:" - check_file "$RESEARCH" "research.md" - check_file "$DATA_MODEL" "data-model.md" - check_dir "$CONTRACTS_DIR" "contracts/" - check_file "$QUICKSTART" "quickstart.md" -fi diff --git a/.specify/templates/checklist-template.md b/.specify/templates/checklist-template.md deleted file mode 100644 index e64065d..0000000 --- a/.specify/templates/checklist-template.md +++ /dev/null @@ -1,40 +0,0 @@ -# [CHECKLIST TYPE] Checklist: [FEATURE NAME] - -**Purpose**: [Brief description of what this checklist covers] -**Created**: [DATE] -**Feature**: [Link to spec.md or relevant documentation] - -**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements. - - - -## [Category 1] - -- [ ] CHK001 First checklist item with clear action -- [ ] CHK002 Second checklist item -- [ ] CHK003 Third checklist item - -## [Category 2] - -- [ ] CHK004 Another category item -- [ ] CHK005 Item with specific criteria -- [ ] CHK006 Final item in this category - -## Notes - -- Check items off as completed: `[x]` -- Add comments or findings inline -- Link to relevant resources or documentation -- Items are numbered sequentially for easy reference diff --git a/.specify/templates/constitution-template.md b/.specify/templates/constitution-template.md deleted file mode 100644 index a4670ff..0000000 --- a/.specify/templates/constitution-template.md +++ /dev/null @@ -1,50 +0,0 @@ -# [PROJECT_NAME] Constitution - - -## Core Principles - -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - - -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - - -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - - -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - - -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - - -## [SECTION_2_NAME] - - -[SECTION_2_CONTENT] - - -## [SECTION_3_NAME] - - -[SECTION_3_CONTENT] - - -## Governance - - -[GOVERNANCE_RULES] - - -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md deleted file mode 100644 index be1aa88..0000000 --- a/.specify/templates/plan-template.md +++ /dev/null @@ -1,113 +0,0 @@ -# Implementation Plan: [FEATURE] - -**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] - -**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` - -**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. - -## Summary - -[Extract from feature spec: primary requirement + technical approach from research] - -## Technical Context - - - -**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] - -**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] - -**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] - -**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] - -**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] - -**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION] - -**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] - -**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] - -**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -[Gates determined based on constitution file] - -## Project Structure - -### Documentation (this feature) - -```text -specs/[###-feature]/ -├── plan.md # This file (/speckit-plan command output) -├── research.md # Phase 0 output (/speckit-plan command) -├── data-model.md # Phase 1 output (/speckit-plan command) -├── quickstart.md # Phase 1 output (/speckit-plan command) -├── contracts/ # Phase 1 output (/speckit-plan command) -└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) -``` - -### Source Code (repository root) - - -```text -# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) -src/ -├── models/ -├── services/ -├── cli/ -└── lib/ - -tests/ -├── contract/ -├── integration/ -└── unit/ - -# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) -backend/ -├── src/ -│ ├── models/ -│ ├── services/ -│ └── api/ -└── tests/ - -frontend/ -├── src/ -│ ├── components/ -│ ├── pages/ -│ └── services/ -└── tests/ - -# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) -api/ -└── [same as backend above] - -ios/ or android/ -└── [platform-specific structure: feature modules, UI flows, platform tests] -``` - -**Structure Decision**: [Document the selected structure and reference the real -directories captured above] - -## Complexity Tracking - -> **Fill ONLY if Constitution Check has violations that must be justified** - -| Violation | Why Needed | Simpler Alternative Rejected Because | -|-----------|------------|-------------------------------------| -| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | -| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md deleted file mode 100644 index ceb2877..0000000 --- a/.specify/templates/spec-template.md +++ /dev/null @@ -1,131 +0,0 @@ -# Feature Specification: [FEATURE NAME] - -**Feature Branch**: `[###-feature-name]` - -**Created**: [DATE] - -**Status**: Draft - -**Input**: User description: "$ARGUMENTS" - -## User Scenarios & Testing *(mandatory)* - - - -### User Story 1 - [Brief Title] (Priority: P1) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] -2. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -### User Story 2 - [Brief Title] (Priority: P2) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -### User Story 3 - [Brief Title] (Priority: P3) - -[Describe this user journey in plain language] - -**Why this priority**: [Explain the value and why it has this priority level] - -**Independent Test**: [Describe how this can be tested independently] - -**Acceptance Scenarios**: - -1. **Given** [initial state], **When** [action], **Then** [expected outcome] - ---- - -[Add more user stories as needed, each with an assigned priority] - -### Edge Cases - - - -- What happens when [boundary condition]? -- How does system handle [error scenario]? - -## Requirements *(mandatory)* - - - -### Functional Requirements - -- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] -- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] -- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] -- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] -- **FR-005**: System MUST [behavior, e.g., "log all security events"] - -*Example of marking unclear requirements:* - -- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] -- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] - -### Key Entities *(include if feature involves data)* - -- **[Entity 1]**: [What it represents, key attributes without implementation] -- **[Entity 2]**: [What it represents, relationships to other entities] - -## Success Criteria *(mandatory)* - - - -### Measurable Outcomes - -- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] -- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] -- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] -- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] - -## Assumptions - - - -- [Assumption about target users, e.g., "Users have stable internet connectivity"] -- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"] -- [Assumption about data/environment, e.g., "Existing authentication system will be reused"] -- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"] diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md deleted file mode 100644 index d46a1f1..0000000 --- a/.specify/templates/tasks-template.md +++ /dev/null @@ -1,252 +0,0 @@ ---- - -description: "Task list template for feature implementation" ---- - -# Tasks: [FEATURE NAME] - -**Input**: Design documents from `/specs/[###-feature-name]/` - -**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ - -**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification. - -**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - -## Path Conventions - -- **Single project**: `src/`, `tests/` at repository root -- **Web app**: `backend/src/`, `frontend/src/` -- **Mobile**: `api/src/`, `ios/src/` or `android/src/` -- Paths shown below assume single project - adjust based on plan.md structure - - - -## Phase 1: Setup (Shared Infrastructure) - -**Purpose**: Project initialization and basic structure - -- [ ] T001 Create project structure per implementation plan -- [ ] T002 Initialize [language] project with [framework] dependencies -- [ ] T003 [P] Configure linting and formatting tools - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented - -**⚠️ CRITICAL**: No user story work can begin until this phase is complete - -Examples of foundational tasks (adjust based on your project): - -- [ ] T004 Setup database schema and migrations framework -- [ ] T005 [P] Implement authentication/authorization framework -- [ ] T006 [P] Setup API routing and middleware structure -- [ ] T007 Create base models/entities that all stories depend on -- [ ] T008 Configure error handling and logging infrastructure -- [ ] T009 Setup environment configuration management - -**Checkpoint**: Foundation ready - user story implementation can now begin in parallel - ---- - -## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️ - -> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** - -- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 1 - -- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py -- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py -- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013) -- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py -- [ ] T016 [US1] Add validation and error handling -- [ ] T017 [US1] Add logging for user story 1 operations - -**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently - ---- - -## Phase 4: User Story 2 - [Title] (Priority: P2) - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️ - -- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 2 - -- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py -- [ ] T021 [US2] Implement [Service] in src/services/[service].py -- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py -- [ ] T023 [US2] Integrate with User Story 1 components (if needed) - -**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently - ---- - -## Phase 5: User Story 3 - [Title] (Priority: P3) - -**Goal**: [Brief description of what this story delivers] - -**Independent Test**: [How to verify this story works on its own] - -### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️ - -- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py -- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py - -### Implementation for User Story 3 - -- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py -- [ ] T027 [US3] Implement [Service] in src/services/[service].py -- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py - -**Checkpoint**: All user stories should now be independently functional - ---- - -[Add more user story phases as needed, following the same pattern] - ---- - -## Phase N: Polish & Cross-Cutting Concerns - -**Purpose**: Improvements that affect multiple user stories - -- [ ] TXXX [P] Documentation updates in docs/ -- [ ] TXXX Code cleanup and refactoring -- [ ] TXXX Performance optimization across all stories -- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ -- [ ] TXXX Security hardening -- [ ] TXXX Run quickstart.md validation - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies - can start immediately -- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories -- **User Stories (Phase 3+)**: All depend on Foundational phase completion - - User stories can then proceed in parallel (if staffed) - - Or sequentially in priority order (P1 → P2 → P3) -- **Polish (Final Phase)**: Depends on all desired user stories being complete - -### User Story Dependencies - -- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories -- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable -- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable - -### Within Each User Story - -- Tests (if included) MUST be written and FAIL before implementation -- Models before services -- Services before endpoints -- Core implementation before integration -- Story complete before moving to next priority - -### Parallel Opportunities - -- All Setup tasks marked [P] can run in parallel -- All Foundational tasks marked [P] can run in parallel (within Phase 2) -- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows) -- All tests for a user story marked [P] can run in parallel -- Models within a story marked [P] can run in parallel -- Different user stories can be worked on in parallel by different team members - ---- - -## Parallel Example: User Story 1 - -```bash -# Launch all tests for User Story 1 together (if tests requested): -Task: "Contract test for [endpoint] in tests/contract/test_[name].py" -Task: "Integration test for [user journey] in tests/integration/test_[name].py" - -# Launch all models for User Story 1 together: -Task: "Create [Entity1] model in src/models/[entity1].py" -Task: "Create [Entity2] model in src/models/[entity2].py" -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 1 Only) - -1. Complete Phase 1: Setup -2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) -3. Complete Phase 3: User Story 1 -4. **STOP and VALIDATE**: Test User Story 1 independently -5. Deploy/demo if ready - -### Incremental Delivery - -1. Complete Setup + Foundational → Foundation ready -2. Add User Story 1 → Test independently → Deploy/Demo (MVP!) -3. Add User Story 2 → Test independently → Deploy/Demo -4. Add User Story 3 → Test independently → Deploy/Demo -5. Each story adds value without breaking previous stories - -### Parallel Team Strategy - -With multiple developers: - -1. Team completes Setup + Foundational together -2. Once Foundational is done: - - Developer A: User Story 1 - - Developer B: User Story 2 - - Developer C: User Story 3 -3. Stories complete and integrate independently - ---- - -## Notes - -- [P] tasks = different files, no dependencies -- [Story] label maps task to specific user story for traceability -- Each user story should be independently completable and testable -- Verify tests fail before implementing -- Commit after each task or logical group -- Stop at any checkpoint to validate story independently -- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence diff --git a/.specify/workflows/speckit/workflow.yml b/.specify/workflows/speckit/workflow.yml deleted file mode 100644 index f69efea..0000000 --- a/.specify/workflows/speckit/workflow.yml +++ /dev/null @@ -1,77 +0,0 @@ -schema_version: "1.0" -workflow: - id: "speckit" - name: "Full SDD Cycle" - version: "1.0.0" - author: "GitHub" - description: "Runs specify → plan → tasks → implement with review gates" - -requires: - # 0.8.5 is the first release with engine-side resolution of the - # ``integration: "auto"`` default. Older versions would treat "auto" - # as a literal integration key and fail at dispatch. - speckit_version: ">=0.8.5" - integrations: - # The four commands below (specify, plan, tasks, implement) are core - # spec-kit commands provided by every integration. The list here is an - # advisory, non-exhaustive compatibility hint following the documented - # ``any: [...]`` schema -- it is NOT a closed set. The workflow runs - # against any integration the project was initialized with, including - # ones not listed below, as long as that integration provides the four - # core commands referenced in ``steps``. - any: - - "claude" - - "copilot" - - "gemini" - - "opencode" - -inputs: - spec: - type: string - required: true - prompt: "Describe what you want to build" - integration: - type: string - default: "auto" - prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" - scope: - type: string - default: "full" - enum: ["full", "backend-only", "frontend-only"] - -steps: - - id: specify - command: speckit.specify - integration: "{{ inputs.integration }}" - input: - args: "{{ inputs.spec }}" - - - id: review-spec - type: gate - message: "Review the generated spec before planning." - options: [approve, reject] - on_reject: abort - - - id: plan - command: speckit.plan - integration: "{{ inputs.integration }}" - input: - args: "{{ inputs.spec }}" - - - id: review-plan - type: gate - message: "Review the plan before generating tasks." - options: [approve, reject] - on_reject: abort - - - id: tasks - command: speckit.tasks - integration: "{{ inputs.integration }}" - input: - args: "{{ inputs.spec }}" - - - id: implement - command: speckit.implement - integration: "{{ inputs.integration }}" - input: - args: "{{ inputs.spec }}" diff --git a/.specify/workflows/workflow-registry.json b/.specify/workflows/workflow-registry.json deleted file mode 100644 index 837efd7..0000000 --- a/.specify/workflows/workflow-registry.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "schema_version": "1.0", - "workflows": { - "speckit": { - "name": "Full SDD Cycle", - "version": "1.0.0", - "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", - "source": "bundled", - "installed_at": "2026-07-22T14:28:21.438923+00:00", - "updated_at": "2026-07-22T14:28:21.438928+00:00" - } - } -} \ No newline at end of file From 370b3b1f0a4d7c77c7d50ee3229026c27b8d8e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20V=C3=A9tillard?= <84911237+clementvtrd@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:24:25 +0200 Subject: [PATCH 5/5] feat: add support for days abbreviations --- phpstan-baseline.neon | 6 ++++++ src/Rules/Psr/Psr20Rule.php | 1 + tests/Rules/Psr/Psr20RuleTest.php | 1 + tests/Rules/Psr/fixtures/psr20.php | 1 + 4 files changed, 9 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8586228..bf25017 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,6 +66,12 @@ parameters: count: 1 path: tests/Rules/Psr/fixtures/psr20.php + - + message: '#^Avoid using new DateTimeImmutable\(''mon''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' + identifier: clock.disallowDateTimeNow + count: 1 + path: tests/Rules/Psr/fixtures/psr20.php + - message: '#^Avoid using new DateTimeImmutable\(''monday 14\:00\:00''\) directly\. Prefer using \\Psr\\Clock\\ClockInterface instead\.$#' identifier: clock.disallowDateTimeNow diff --git a/src/Rules/Psr/Psr20Rule.php b/src/Rules/Psr/Psr20Rule.php index 7d9a9e6..c778573 100644 --- a/src/Rules/Psr/Psr20Rule.php +++ b/src/Rules/Psr/Psr20Rule.php @@ -65,6 +65,7 @@ private static function isRelativeDateString(string $value): bool $patterns = [ '/^(now|yesterday|today|tomorrow|noon|midnight)\b/i', '/^(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/i', + '/^(mon|tue|wed|thu|fri|sat|sun)\b/i', '/^(next|last|previous|this)\s+/i', '/^[+-]\d+\s+(second|minute|hour|day|week|month|year)s?/i', '/^\d+\s+(second|minute|hour|day|week|month|year)s?\s*$/i', diff --git a/tests/Rules/Psr/Psr20RuleTest.php b/tests/Rules/Psr/Psr20RuleTest.php index 30f5fe9..e7842da 100644 --- a/tests/Rules/Psr/Psr20RuleTest.php +++ b/tests/Rules/Psr/Psr20RuleTest.php @@ -43,6 +43,7 @@ public function testRule(): void ["Avoid using new DateTimeImmutable('tomorrow 12:00') directly. Prefer using \Psr\Clock\ClockInterface instead.", 34], ["Avoid using new DateTimeImmutable('monday 14:00:00') directly. Prefer using \Psr\Clock\ClockInterface instead.", 35], ["Avoid using new DateTimeImmutable('TOMORROW') directly. Prefer using \Psr\Clock\ClockInterface instead.", 36], + ["Avoid using new DateTimeImmutable('mon') directly. Prefer using \Psr\Clock\ClockInterface instead.", 37], ]); } } diff --git a/tests/Rules/Psr/fixtures/psr20.php b/tests/Rules/Psr/fixtures/psr20.php index 8879c77..d2efe0d 100644 --- a/tests/Rules/Psr/fixtures/psr20.php +++ b/tests/Rules/Psr/fixtures/psr20.php @@ -34,4 +34,5 @@ $aa = new DateTimeImmutable('tomorrow 12:00'); $bb = new DateTimeImmutable('monday 14:00:00'); $cc = new DateTimeImmutable('TOMORROW'); +$ee = new DateTimeImmutable('mon'); $dd = new \stdClass();