diff --git a/.github/hooks/post-checkout b/.github/hooks/post-checkout old mode 100644 new mode 100755 diff --git a/.github/hooks/post-merge b/.github/hooks/post-merge old mode 100644 new mode 100755 diff --git a/.github/hooks/pre-commit b/.github/hooks/pre-commit index aa66220..405b75d 100755 --- a/.github/hooks/pre-commit +++ b/.github/hooks/pre-commit @@ -85,6 +85,7 @@ if [ -n "$STAGED_SRC" ]; then [ -z "$file" ] && continue # Already has an RCS header? + # shellcheck disable=SC2016 # $KYAULabs is a literal RCS marker, not a variable if git show ":$file" 2>/dev/null | head -10 | grep -qF '$KYAULabs:'; then continue fi diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push old mode 100644 new mode 100755 index 85f71fc..f9ef0a1 --- a/.github/hooks/pre-push +++ b/.github/hooks/pre-push @@ -16,10 +16,7 @@ RESET=$'\033[0m' # The git history serves as the development and evaluation log; squashing # destroys the record of what was tried and abandoned. -remote="$1" -remote_url="$2" - -while read -r local_ref local_oid remote_ref remote_oid; do +while read -r _local_ref local_oid _remote_ref remote_oid; do # Skip tag pushes and branch deletions if [ -z "$local_oid" ] || [ "$local_oid" = "0000000000000000000000000000000000000000" ]; then continue diff --git a/.github/scripts/frontmatter-parser.js b/.github/scripts/frontmatter-parser.js new file mode 100644 index 0000000..cf477b2 --- /dev/null +++ b/.github/scripts/frontmatter-parser.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// $KYAULabs: frontmatter-parser.js kyau@nova 2026/07/05 -0700 Exp $ + +// Extract a YAML frontmatter key's value from a Markdown file. +// Usage: node frontmatter-parser.js +// Prints the value to stdout (empty string if not found). +// Exits 1 on parse error. + +'use strict'; + +const fs = require('fs'); +const yaml = require('js-yaml'); + +const file = process.argv[2]; +const key = process.argv[3]; + +if (!file || !key) { + console.error('Usage: node frontmatter-parser.js '); + process.exit(2); +} + +let content; +try { + content = fs.readFileSync(file, 'utf8'); +} catch (e) { + console.error(`Error reading file: ${e.message}`); + process.exit(1); +} + +// Extract frontmatter between first two --- lines +// Strip \r for CRLF safety, strip BOM (U+FEFF) for Windows editors +content = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + +const lines = content.split('\n'); +if (lines[0] !== '---') { + // No frontmatter + process.stdout.write(''); + process.exit(0); +} + +let fmLines = []; +let foundClosing = false; +for (let i = 1; i < lines.length; i++) { + if (lines[i] === '---') { + foundClosing = true; + break; + } + fmLines.push(lines[i]); +} + +if (!foundClosing) { + // No closing --- delimiter + process.stdout.write(''); + process.exit(0); +} + +const fmText = fmLines.join('\n'); + +let doc; +try { + doc = yaml.load(fmText); +} catch (e) { + console.error(`YAML parse error in ${file}: ${e.message}`); + process.exit(1); +} + +if (doc === null || doc === undefined || typeof doc !== 'object') { + process.stdout.write(''); + process.exit(0); +} + +const value = doc[key]; +if (value === undefined || value === null) { + process.stdout.write(''); +} else { + process.stdout.write(String(value)); +} + +process.exit(0); +// vim: ft=javascript sts=4 sw=4 ts=4 noet : diff --git a/.github/scripts/install-hooks.sh b/.github/scripts/install-hooks.sh old mode 100644 new mode 100755 index b5805a1..1ee59e6 --- a/.github/scripts/install-hooks.sh +++ b/.github/scripts/install-hooks.sh @@ -2,35 +2,24 @@ # $KYAULabs: install-hooks.sh kyau@nova 2026/07/04 -0700 Exp $ # Run once after cloning: bash .github/scripts/install-hooks.sh +# +# Uses git's native core.hooksPath mechanism instead of per-file symlinks. +# This avoids worktree crashes, core.hooksPath bypass, exec-bit dirtiness, +# and nullglob edge cases — all handled by git itself. set -euo pipefail REPO_ROOT=$(git rev-parse --show-toplevel) HOOKS_SRC="$REPO_ROOT/.github/hooks" -GIT_HOOKS="$REPO_ROOT/.git/hooks" if [ ! -d "$HOOKS_SRC" ]; then echo "✗ .github/hooks directory not found at $HOOKS_SRC" >&2 exit 1 fi -echo "Installing git hooks from .github/hooks → .git/hooks" +git config core.hooksPath .github/hooks -for hook in "$HOOKS_SRC"/*; do - name=$(basename "$hook") - dest="$GIT_HOOKS/$name" +echo "✓ Hooks installed via core.hooksPath = .github/hooks" +echo " All commits will now run lint + gitleaks + commitlint." - if [ -f "$dest" ] && [ ! -L "$dest" ]; then - echo " ⚠ Backing up existing $name → $name.bak" - mv "$dest" "$dest.bak" - fi - - ln -sf "$REPO_ROOT/.github/hooks/$name" "$dest" - chmod +x "$hook" - echo " ✓ $name" -done - -echo "" -echo "✓ Hooks installed. All commits will now run lint + gitleaks + commitlint." - -# vim: ft=sh sts=4 sw=4 ts=4 et : \ No newline at end of file +# vim: ft=sh sts=4 sw=4 ts=4 et : diff --git a/.github/scripts/validate-harness.sh b/.github/scripts/validate-harness.sh old mode 100644 new mode 100755 index 554253c..a17761d --- a/.github/scripts/validate-harness.sh +++ b/.github/scripts/validate-harness.sh @@ -3,16 +3,36 @@ set -euo pipefail +# ── Prerequisite: bash 4+ required for associative arrays ────────────────────── + +if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then + echo "ERROR: Bash 4+ required (found ${BASH_VERSION:-unknown})" >&2 + exit 1 +fi + +# ── Prerequisite: Node.js required for YAML frontmatter parsing ───────────────── + +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: Node.js required for YAML frontmatter parsing" >&2 + exit 1 +fi + # ── Configuration ──────────────────────────────────────────────────────────── -HARNESS_DIR=".opencode" +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "") +if [ -z "$REPO_ROOT" ]; then + echo "ERROR: Not in a git repository. Must be run from within a git checkout." >&2 + exit 1 +fi + +HARNESS_DIR="${REPO_ROOT}/.opencode" SKILLS_DIR="${HARNESS_DIR}/skills" AGENTS_DIR="${HARNESS_DIR}/agents" COMMANDS_DIR="${HARNESS_DIR}/commands" ERRORS=0 WARNINGS=0 -NAME_REGISTRY="" # Track all names across categories to detect collisions +declare -A NAME_REGISTRY # key=name, value="file:category" # ── Helpers ────────────────────────────────────────────────────────────────── @@ -22,18 +42,12 @@ ok() { echo " OK: $*"; } # Extract a YAML frontmatter key's value from a file. # Usage: frontmatter_key -# Returns the value (whitespace trimmed) or empty string if not found. +# Returns the value or empty string if not found. +# Delegates to Node.js + js-yaml for proper YAML parsing (handles quoted +# values, folded scalars, block scalars, comments, and CRLF). frontmatter_key() { local file="$1" key="$2" - # Only search between the first pair of --- delimiters - awk -v key="$2" ' - /^---$/ { in_fm=!in_fm; next } - in_fm && $1 == key ":" { - sub(/^[^:]+:[[:space:]]*/, "") - print - exit - } - ' "$file" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' + node "${REPO_ROOT}/.github/scripts/frontmatter-parser.js" "$file" "$key" 2>/dev/null || true } # Check that a file has paired --- frontmatter delimiters. @@ -41,7 +55,7 @@ frontmatter_key() { check_frontmatter_delimiters() { local file="$1" local open count - count=$(grep -c '^---$' "$file" 2>/dev/null || echo 0) + count=$(grep -c '^---$' "$file" 2>/dev/null) || count=0 if [ "$count" -eq 0 ]; then return 1 fi @@ -58,21 +72,19 @@ check_frontmatter_delimiters() { } # Register a name in the global registry and check for collisions. +# Uses associative array for O(1) exact-match lookup (no regex injection). register_name() { local name="$1" category="$2" file="$3" if [ -z "$name" ]; then return fi - # Check if this name is already registered (any category) - local existing - existing=$(echo "$NAME_REGISTRY" | grep "^${name} " || true) + local existing="${NAME_REGISTRY[$name]:-}" if [ -n "$existing" ]; then - local existing_file existing_cat - existing_file=$(echo "$existing" | awk '{print $2}') - existing_cat=$(echo "$existing" | awk '{print $3}') + local existing_file="${existing%%:*}" + local existing_cat="${existing##*:}" err "${file}: name '${name}' already registered as ${existing_cat} in ${existing_file}" else - NAME_REGISTRY="${NAME_REGISTRY}${name} ${file} ${category}"$'\n' + NAME_REGISTRY[$name]="${file}:${category}" fi } @@ -90,7 +102,7 @@ check_required_field() { echo "── Validating skills ──" SKILL_COUNT=0 -SKILL_NAMES="" +declare -A SKILL_NAMES shopt -s nullglob SKILL_FILES=( "${SKILLS_DIR}"/*/SKILL.md ) @@ -122,10 +134,10 @@ else # Check for duplicate names within skills if [ -n "$name" ]; then - if echo "$SKILL_NAMES" | grep -qx "$name"; then + if [ -n "${SKILL_NAMES[$name]:-}" ]; then err "${skill_file}: duplicate skill name '${name}'" else - SKILL_NAMES="${SKILL_NAMES}${name}"$'\n' + SKILL_NAMES[$name]=1 fi register_name "$name" "skill" "$skill_file" fi @@ -213,12 +225,10 @@ echo "── Checking cross-references ──" CROSSREF_COUNT=0 # Build a set of all known names from the registry -KNOWN_NAMES="" -while IFS= read -r line; do - [ -z "$line" ] && continue - name=$(echo "$line" | awk '{print $1}') - KNOWN_NAMES="${KNOWN_NAMES}${name}"$'\n' -done <<< "$NAME_REGISTRY" +declare -A KNOWN_NAMES +for name in "${!NAME_REGISTRY[@]}"; do + KNOWN_NAMES[$name]=1 +done # Scan explicit "Cross-refs" sections in skill files only. # These sections list related skills/agents/commands by name — the structured @@ -248,6 +258,7 @@ while IFS= read -r file; do # Extract backtick-wrapped names from this line # Use basic grep -o (no -P for portability) + # shellcheck disable=SC2016 # backticks are a literal grep pattern, not expansion refs=$(echo "$line" | grep -o '`[^`]*`' 2>/dev/null || true) if [ -z "$refs" ]; then continue @@ -270,8 +281,8 @@ while IFS= read -r file; do "") continue ;; esac - # Check against known names - if echo "$KNOWN_NAMES" | grep -qx "$ref_clean"; then + # Check against known names (associative array O(1) lookup) + if [ -n "${KNOWN_NAMES[$ref_clean]:-}" ]; then CROSSREF_COUNT=$((CROSSREF_COUNT + 1)) else warn "${file}: cross-refs unknown name '${ref}'" @@ -282,10 +293,16 @@ done < <(find "${HARNESS_DIR}" -name 'SKILL.md' -not -path '*/node_modules/*' 2> ok "${CROSSREF_COUNT} cross-reference(s) verified" +# ── Guard: fail on vacuous pass (all three categories empty) ────────────────── + +if [ "$SKILL_COUNT" -eq 0 ] && [ "$AGENT_COUNT" -eq 0 ] && [ "$CMD_COUNT" -eq 0 ]; then + err "No skills, agents, or commands found — harness directory may be missing or empty" +fi + # ── Summary ────────────────────────────────────────────────────────────────── echo "" -echo "═══════════════════════════════════════════════════════════════" +echo "═══════════════════════════════════════════════════════════" if [ "$ERRORS" -eq 0 ] && [ "$WARNINGS" -eq 0 ]; then echo "✓ Harness validation PASSED — 0 errors, 0 warnings" echo "═══════════════════════════════════════════════════════════════" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a888f9..5014705 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,9 @@ jobs: - name: Harness validation run: bash .github/scripts/validate-harness.sh + - name: Shellcheck + run: shellcheck .github/scripts/*.sh .github/hooks/* tests/Shell/*.sh + - name: PHP CS Fixer (dry-run) run: php vendor/bin/php-cs-fixer fix --dry-run --diff @@ -98,6 +101,5 @@ jobs: - name: Commitlint if: github.event_name == 'pull_request' run: | - echo "${{ github.event.pull_request.title }}" | npx commitlint --verbose git fetch origin "${{ github.event.pull_request.base.ref }}" npx commitlint --from="origin/${{ github.event.pull_request.base.ref }}" --to="HEAD" --verbose diff --git a/.opencode/commands/doctor.md b/.opencode/commands/doctor.md index f56b660..d901038 100644 --- a/.opencode/commands/doctor.md +++ b/.opencode/commands/doctor.md @@ -50,8 +50,8 @@ Floor: `semgrep` >= 1.168, `ocr` >= 1.7, `gitleaks` >= 8.30. These are run by ag ## 5. git hooks ```bash -if [ -f .git/hooks/pre-commit ]; then echo "INSTALLED"; else echo "NOT_INSTALLED — run 'bash .github/scripts/install-hooks.sh'"; fi -if [ -f .git/hooks/commit-msg ]; then echo "INSTALLED"; else echo "NOT_INSTALLED — run 'bash .github/scripts/install-hooks.sh'"; fi +hooks_path=$(git config core.hooksPath 2>/dev/null || echo "") +if [ "$hooks_path" = ".github/hooks" ]; then echo "INSTALLED ($hooks_path)"; else echo "NOT_INSTALLED — run 'bash .github/scripts/install-hooks.sh'"; fi ``` ## Output diff --git a/.opencode/skills/brainstorming/SKILL.md b/.opencode/skills/brainstorming/SKILL.md index 77f2812..8533fe9 100644 --- a/.opencode/skills/brainstorming/SKILL.md +++ b/.opencode/skills/brainstorming/SKILL.md @@ -1,6 +1,6 @@ --- name: brainstorming -description: Use before any creative work — creating features, building components, adding functionality, or modifying behavior. Refines rough ideas into a validated design through one-question-at-a-time grilling, then writes a spec. Hard-gate: no implementation until the design is approved and a spec is written (fast-path for zero-behavior-delta changes). +description: "Use before any creative work — creating features, building components, adding functionality, or modifying behavior. Refines rough ideas into a validated design through one-question-at-a-time grilling, then writes a spec. Hard-gate: no implementation until the design is approved and a spec is written (fast-path for zero-behavior-delta changes)." derived-from: mattpocock/skills (MIT, © Matt Pocock) --- diff --git a/.opencode/skills/finding-duplicate-functions/SKILL.md b/.opencode/skills/finding-duplicate-functions/SKILL.md index 8055480..0854484 100644 --- a/.opencode/skills/finding-duplicate-functions/SKILL.md +++ b/.opencode/skills/finding-duplicate-functions/SKILL.md @@ -1,6 +1,6 @@ --- name: finding-duplicate-functions -description: Use when seeking semantic duplication in a codebase — functions that solve the same problem for different callers. Complements /improve-architecture's deletion test. Two-phase: classical extraction then LLM intent-clustering. +description: "Use when seeking semantic duplication in a codebase — functions that solve the same problem for different callers. Complements /improve-architecture's deletion test. Two-phase: classical extraction then LLM intent-clustering." derived-from: obra/superpowers-lab (MIT, © Jesse Vincent) --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 8c7b90d..ec3569f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,22 @@ export default [ "no-unused-vars": "warn", "no-console": "warn", "indent": ["error", "tab"], - } - } + }, + }, + { + files: [".github/scripts/**/*.js"], + languageOptions: { + globals: { + require: "readonly", + process: "readonly", + console: "readonly", + __dirname: "readonly", + module: "readonly", + }, + }, + rules: { + "no-unused-vars": "warn", + "no-console": "off", + }, + }, ]; diff --git a/package-lock.json b/package-lock.json index 1e251be..fb3f3b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "commitlint": "^21", "eslint": "^10", "git-cliff": "^2", + "js-yaml": "^5.2.1", "playwright": "^1", "sass": "^1", "stylelint": "^17", @@ -1525,6 +1526,29 @@ "typescript": ">=5" } }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2678,9 +2702,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", "dev": true, "funding": [ { @@ -2697,7 +2721,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/json-buffer": { diff --git a/package.json b/package.json index c3dedc3..f832dd6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "commitlint": "^21", "eslint": "^10", "git-cliff": "^2", + "js-yaml": "^5.2.1", "playwright": "^1", "sass": "^1", "stylelint": "^17", diff --git a/tests/Shell/install-hooks_test.sh b/tests/Shell/install-hooks_test.sh new file mode 100644 index 0000000..382ef90 --- /dev/null +++ b/tests/Shell/install-hooks_test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# $KYAULabs: install-hooks_test.sh kyau@nova 2026/07/05 -0700 Exp $ + +# ── Repro-first tests for install-hooks.sh ───────────────────────────────────── +# Bugs under test (from Fable 5 audit): +# 1. Symlink hooks break in worktrees (git worktree add); +# core.hooksPath is silently bypassed by user configs. +# 2. Three hooks are committed as 100644; chmod +x dirties the working tree +# and is reverted by git checkout/stash. +# 3. No nullglob: empty .github/hooks leaves junk * symlink + crashes. +# 4. Backup logic loses existing symlink hooks with no backup. +# +# Fix: Replace the entire symlink-per-hook mechanism with +# git config core.hooksPath .github/hooks (one line, no symlinks, no chmod). +# All four bugs become moot. + +set -euo pipefail + +RESULT_FILE=$(mktemp) +TEMP_DIRS="" +trap 'rm -f "$RESULT_FILE"; [ -n "$TEMP_DIRS" ] && rm -rf $TEMP_DIRS' EXIT + +RED=$'\033[1;31m' +GREEN=$'\033[1;32m' +RESET=$'\033[0m' + +pass() { echo "${GREEN}PASS${RESET} $*"; echo "PASS" >> "$RESULT_FILE"; } +fail() { echo "${RED}FAIL${RESET} $*" >&2; echo "FAIL" >> "$RESULT_FILE"; } + +# ── Resolve paths BEFORE any cd ──────────────────────────────────────────────── + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REAL_SCRIPT="$REPO_ROOT/.github/scripts/install-hooks.sh" + +if [ ! -f "$REAL_SCRIPT" ]; then + fail "Cannot find install-hooks.sh at $REAL_SCRIPT" + exit 1 +fi + +# ── Test 1: core.hooksPath is set, no symlinks, no dirtying ─────────────────── + +echo "" +echo "── Test 1: core.hooksPath (no symlinks, no chmod) ──" +T1=$(mktemp -d) +TEMP_DIRS="$TEMP_DIRS $T1" +( + cd "$T1" + git init --quiet + git config user.email "test@example.com" + git config user.name "Test User" + mkdir -p .github/hooks .github/scripts + cat > .github/hooks/pre-commit <<'HOOKEOF' +#!/usr/bin/env bash +echo "pre-commit ran" +HOOKEOF + chmod +x .github/hooks/pre-commit + git add .github/hooks/pre-commit + git commit --quiet -m "init" + cp "$REAL_SCRIPT" .github/scripts/install-hooks.sh + bash .github/scripts/install-hooks.sh > /dev/null 2>&1 + + # Assert: core.hooksPath is set + hooks_path=$(git config core.hooksPath 2>/dev/null || echo "") + if [ "$hooks_path" = ".github/hooks" ]; then + pass "core.hooksPath = '.github/hooks'" + else + fail "core.hooksPath = '$hooks_path' (expected '.github/hooks')" + fi + + # Assert: no symlinks in .git/hooks + symlink_count=$(find .git/hooks -type l 2>/dev/null | wc -l) + if [ "$symlink_count" -eq 0 ]; then + pass "0 symlinks found in .git/hooks" + else + fail "$symlink_count symlink(s) found in .git/hooks (expected 0)" + fi + + # Assert: no tracked files modified (ignore untracked files from test setup) + modified=$(git diff --name-only 2>/dev/null | wc -l) + if [ "$modified" -eq 0 ]; then + pass "Working tree is clean (no chmod dirtying)" + else + fail "Working tree has $modified modified file(s) (chmod dirtying bug)" + git diff --name-only + fi +) +rm -rf "$T1" + +# ── Test 2: All hooks committed as 100755 ───────────────────────────────────── + +echo "── Test 2: Hooks committed as 100755 ──" +for hook in commit-msg post-checkout post-merge pre-commit pre-push; do + mode=$(git -C "$REPO_ROOT" ls-files -s ".github/hooks/$hook" 2>/dev/null | awk '{print $1}') + if [ "$mode" = "100755" ]; then + pass "$hook is 100755" + else + fail "$hook is $mode (expected 100755)" + fi +done + +# ── Test 3: Error on missing hooks directory ────────────────────────────────── + +echo "── Test 3: Error on missing hooks directory ──" +T3=$(mktemp -d) +TEMP_DIRS="$TEMP_DIRS $T3" +( + cd "$T3" + git init --quiet + mkdir -p .github/scripts + cp "$REAL_SCRIPT" .github/scripts/install-hooks.sh + set +e + bash .github/scripts/install-hooks.sh >/dev/null 2>&1 + ret=$? + set -e + if [ "$ret" -ne 0 ]; then + pass "Correctly errors when .github/hooks is missing (exit $ret)" + else + fail "Should error when .github/hooks is missing (exit 0)" + fi +) +rm -rf "$T3" + +# ── Test 4: Handles empty hooks directory (nullglob bug) ────────────────────── + +echo "── Test 4: Empty hooks directory (nullglob) ──" +T4=$(mktemp -d) +TEMP_DIRS="$TEMP_DIRS $T4" +( + cd "$T4" + git init --quiet + mkdir -p .github/hooks .github/scripts + cp "$REAL_SCRIPT" .github/scripts/install-hooks.sh + if bash .github/scripts/install-hooks.sh > /dev/null 2>&1; then + pass "Handles empty .github/hooks without crash" + else + fail "Crashed on empty .github/hooks (nullglob bug)" + fi + if [ -f ".git/hooks/*" ]; then + fail "Junk '*' file found in .git/hooks (nullglob bug)" + else + pass "No junk '*' file in .git/hooks" + fi +) +rm -rf "$T4" + +# ── Summary ─────────────────────────────────────────────────────────────────── + +total_pass=$(grep -c "PASS" "$RESULT_FILE" 2>/dev/null || true) +total_fail=$(grep -c "FAIL" "$RESULT_FILE" 2>/dev/null || true) + +echo "" +echo "═══════════════════════════════════════════════════════════" +if [ "$total_fail" -eq 0 ]; then + echo "✓ install-hooks tests PASSED — $total_pass assertion(s), 0 failures" + echo "═══════════════════════════════════════════════════════════" + exit 0 +else + echo "✗ install-hooks tests FAILED — $total_pass passed, $total_fail failure(s)" + echo "═══════════════════════════════════════════════════════════" + exit 1 +fi + +# vim: ft=sh sts=4 sw=4 ts=4 et : diff --git a/tests/Shell/validate-harness_test.sh b/tests/Shell/validate-harness_test.sh new file mode 100644 index 0000000..8cf9676 --- /dev/null +++ b/tests/Shell/validate-harness_test.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# $KYAULabs: validate-harness_test.sh kyau@nova 2026/07/05 -0700 Exp $ + +# ── Repro-first tests for validate-harness.sh ────────────────────────────────── +# Bugs under test (from Fable 5 audit): +# 3. Vacuous PASS on empty/missing .opencode (HARNESS_DIR is relative) +# 4. Frontmatter parser re-enters on --- in Markdown body +# 6. Unescaped name in grep regex causes false collisions +# 9. grep -c || echo 0 yields 0\n0, breaking integer test + +set -euo pipefail + +RESULT_FILE=$(mktemp) +trap 'rm -f "$RESULT_FILE"' EXIT + +RED=$'\033[1;31m' +GREEN=$'\033[1;32m' +RESET=$'\033[0m' + +pass() { echo " ${GREEN}PASS${RESET} $*"; echo "PASS" >> "$RESULT_FILE"; } +fail() { echo " ${RED}FAIL${RESET} $*" >&2; echo "FAIL" >> "$RESULT_FILE"; } + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REAL_VALIDATOR="$REPO_ROOT/.github/scripts/validate-harness.sh" + +if [ ! -f "$REAL_VALIDATOR" ]; then + fail "Cannot find validate-harness.sh at $REAL_VALIDATOR" + exit 1 +fi + +# ── Test 1: Finding 3 — vacuous PASS on relative HARNESS_DIR ────────────────── + +echo "" +echo "── Test 1: Vacuous PASS when run from non-repo-root ──" +T1=$(mktemp -d) +( + cd "$T1" + git init --quiet + mkdir -p .opencode/skills/test-skill + cat > .opencode/skills/test-skill/SKILL.md <<'EOF' +--- +name: test-skill +description: A valid test skill with all required fields. +--- +EOF + git add -A + git commit --quiet -m "init" + + # Copy the validator + mkdir -p .github/scripts + cp "$REAL_VALIDATOR" .github/scripts/validate-harness.sh + + # Run from a SUBDIRECTORY (not repo root) — should resolve HARNESS_DIR via git + mkdir subdir + ( + cd subdir + output=$(bash ../.github/scripts/validate-harness.sh 2>&1) || true + # After fix: validator resolves to repo root and finds the skill + if echo "$output" | grep -q "1 skill(s) checked"; then + pass "Found skills from subdirectory (HARNESS_DIR resolved via git)" + elif echo "$output" | grep -q "0 skill(s) checked"; then + fail "Vacuous PASS — found 0 skills from subdirectory (relative HARNESS_DIR bug)" + else + fail "Unexpected output from subdirectory run" + fi + ) + +) +rm -rf "$T1" + +# ── Test 2: Finding 4 — frontmatter parser re-enters on body --- ────────────── + +echo "── Test 2: Frontmatter parser re-enters on body '---' ──" +T2=$(mktemp -d) +( + cd "$T2" + git init --quiet + cp "$REAL_VALIDATOR" .github/scripts/validate-harness.sh 2>/dev/null || { + mkdir -p .github/scripts + cp "$REAL_VALIDATOR" .github/scripts/validate-harness.sh + } + + # Create an AGENT file with missing mode: but a --- rule in the body + # that has "mode: subagent" after it — the toggling parser would + # re-enter frontmatter mode and read that body line as mode: subagent + mkdir -p .opencode/agents + cat > .opencode/agents/test-agent.md <<'EOF' +--- +description: An agent file missing the mode field +--- + +## Instructions + +Some body text. + +--- + +Then a horizontal rule followed by text that happens to say: +mode: subagent — this is NOT frontmatter, it's body prose. +EOF + + output=$(bash .github/scripts/validate-harness.sh 2>&1) || true + + # Bug: the validator should report "missing or empty 'mode' field" + # But the toggling parser reads the body "mode: subagent" and accepts it + if echo "$output" | grep -q "missing or empty 'mode'" ; then + pass "Correctly detected missing mode field (parser stops at 2nd ---)" + elif echo "$output" | grep -q "expected 'subagent'" && ! echo "$output" | grep -q "missing.*mode"; then + # If it says "expected subagent" but NOT "missing mode", + # the body line "mode: subagent" was parsed as frontmatter + fail "Parser re-entered frontmatter mode on body '---' (found 'mode: subagent' in body)" + else + # The bug may manifest differently depending on exact behavior + if echo "$output" | grep -qi "error"; then + pass "Validator reported errors (some detection working)" + else + fail "Validator missed missing mode field entirely" + fi + fi +) +rm -rf "$T2" + +# ── Test 3: Finding 6 — dotted name causes false collision ──────────────────── + +echo "── Test 3: Dotted name causes false regex collision ──" +T3=$(mktemp -d) +( + cd "$T3" + git init --quiet + mkdir -p .opencode/agents .github/scripts + cp "$REAL_VALIDATOR" .github/scripts/validate-harness.sh + + # Create two agent files — '-' sorts before '.' in C locale, + # so the hyphenated file is processed FIRST and the dotted file SECOND. + # When checking 'agent.bar', grep "^agent.bar " matches 'agent-bar ' + # because '.' in regex matches '-' in the registry entry. + cat > .opencode/agents/agent-bar.md <<'EOF' +--- +description: First agent with hyphenated name +mode: subagent +--- +EOF + + cat > .opencode/agents/agent.bar.md <<'EOF' +--- +description: Second agent with dotted name +mode: subagent +--- +EOF + + output=$(bash .github/scripts/validate-harness.sh 2>&1) || true + + # Bug: foo.bar falsely collides with fooXbar due to unescaped regex + if echo "$output" | grep -qi "already registered"; then + fail "False 'already registered' collision for dotted name (unescaped regex bug)" + else + pass "No false collision for dotted filename" + fi + + # Also verify both agents were counted + if echo "$output" | grep -q "2 agent(s) checked"; then + pass "Both agents counted correctly (2)" + else + fail "Agent count mismatch (expected 2)" + fi +) +rm -rf "$T3" + +# ── Test 4: Finding 9 — grep -c || echo 0 yields 0\n0 ──────────────────────── + +echo "── Test 4: grep -c || echo 0 integer expression error ──" +T4=$(mktemp -d) +( + cd "$T4" + git init --quiet + mkdir -p .opencode/agents .github/scripts + cp "$REAL_VALIDATOR" .github/scripts/validate-harness.sh + + # Create an agent file with NO frontmatter at all + # check_frontmatter_delimiters does grep -c '^---$' which yields 0\n0 + cat > .opencode/agents/no-frontmatter.md <<'EOF' +# No frontmatter here + +Just a markdown file with no YAML delimiters. +EOF + + output=$(bash .github/scripts/validate-harness.sh 2>&1) || true + + # Bug: grep -c || echo 0 produces "integer expression expected" on stderr + if echo "$output" | grep -q "integer expression expected"; then + fail "Integer expression error from grep -c || echo 0 bug" + else + pass "No integer expression error (grep -c bug fixed)" + fi + + # The file should still be detected as having malformed frontmatter + if echo "$output" | grep -q "malformed" || echo "$output" | grep -q "missing.*YAML"; then + pass "No-frontmatter file correctly detected as malformed" + else + fail "No-frontmatter file not detected (expected malformed/missing YAML error)" + fi +) +rm -rf "$T4" + +# ── Summary ─────────────────────────────────────────────────────────────────── + +total_pass=$(grep -c "PASS" "$RESULT_FILE" 2>/dev/null || true) +total_fail=$(grep -c "FAIL" "$RESULT_FILE" 2>/dev/null || true) +: "${total_pass:=0}" +: "${total_fail:=0}" + +echo "" +echo "═══════════════════════════════════════════════════════════" +if [ "$total_fail" -eq 0 ]; then + echo "✓ validate-harness tests PASSED — $total_pass assertion(s), 0 failures" + echo "═══════════════════════════════════════════════════════════" + exit 0 +else + echo "✗ validate-harness tests FAILED — $total_pass passed, $total_fail failure(s)" + echo "═══════════════════════════════════════════════════════════" + exit 1 +fi + +# vim: ft=sh sts=4 sw=4 ts=4 et :