From da073298428db3e2539ed0b0b1a759549e12be1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Sat, 14 Mar 2026 00:15:55 +0100 Subject: [PATCH 1/2] Add bats test suite with CI (102 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1: Unit tests for all 13 shell scripts — config parsing, ID generation, decision creation, status updates, index regeneration, annotation detection, snapshot/audit state tracking. Tier 2: Structural checks — dual directory sync, script path references, frontmatter validation, tile.json integrity, shell conventions. Also fixes missing compatibility field in dld-audit-auto SKILL.md (caught by the new structural tests). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 17 +++ .gitmodules | 9 ++ skills/dld-audit-auto/SKILL.md | 1 + tests/bats | 1 + tests/run.sh | 13 ++ tests/test_annotations.bats | 133 ++++++++++++++++++++ tests/test_audit_state.bats | 64 ++++++++++ tests/test_collect_decisions.bats | 95 ++++++++++++++ tests/test_common.bats | 126 +++++++++++++++++++ tests/test_create_decision.bats | 105 ++++++++++++++++ tests/test_helper/bats-assert | 1 + tests/test_helper/bats-support | 1 + tests/test_helper/common.bash | 89 +++++++++++++ tests/test_init_scripts.bats | 108 ++++++++++++++++ tests/test_next_id.bats | 67 ++++++++++ tests/test_regenerate_index.bats | 94 ++++++++++++++ tests/test_snapshot_state.bats | 110 +++++++++++++++++ tests/test_structure.bats | 199 ++++++++++++++++++++++++++++++ tests/test_update_status.bats | 116 +++++++++++++++++ 19 files changed, 1349 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitmodules create mode 160000 tests/bats create mode 100755 tests/run.sh create mode 100644 tests/test_annotations.bats create mode 100644 tests/test_audit_state.bats create mode 100644 tests/test_collect_decisions.bats create mode 100644 tests/test_common.bats create mode 100644 tests/test_create_decision.bats create mode 160000 tests/test_helper/bats-assert create mode 160000 tests/test_helper/bats-support create mode 100644 tests/test_helper/common.bash create mode 100644 tests/test_init_scripts.bats create mode 100644 tests/test_next_id.bats create mode 100644 tests/test_regenerate_index.bats create mode 100644 tests/test_snapshot_state.bats create mode 100644 tests/test_structure.bats create mode 100644 tests/test_update_status.bats diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d8eadf1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Run tests + run: tests/run.sh diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fe9d335 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "tests/bats"] + path = tests/bats + url = https://github.com/bats-core/bats-core.git +[submodule "tests/test_helper/bats-support"] + path = tests/test_helper/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "tests/test_helper/bats-assert"] + path = tests/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert.git diff --git a/skills/dld-audit-auto/SKILL.md b/skills/dld-audit-auto/SKILL.md index 8bd61c7..8fa0cb2 100644 --- a/skills/dld-audit-auto/SKILL.md +++ b/skills/dld-audit-auto/SKILL.md @@ -1,6 +1,7 @@ --- name: dld-audit-auto description: Autonomous audit — detects drift, fixes issues, and opens a PR. Designed for scheduled/CI execution without human interaction. +compatibility: Requires bash and git. Scripts use BASH_SOURCE for path resolution. --- # /dld-audit-auto — Autonomous Audit & Fix diff --git a/tests/bats b/tests/bats new file mode 160000 index 0000000..d9faff0 --- /dev/null +++ b/tests/bats @@ -0,0 +1 @@ +Subproject commit d9faff0d7bc32e7adebc6552446f978118d3ab3b diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..d9c67ae --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Run all DLD Kit tests +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BATS="$SCRIPT_DIR/bats/bin/bats" + +if [[ ! -x "$BATS" ]]; then + echo "Error: bats not found. Run: git submodule update --init --recursive" >&2 + exit 1 +fi + +"$BATS" "$SCRIPT_DIR"/test_*.bats "$@" diff --git a/tests/test_annotations.bats b/tests/test_annotations.bats new file mode 100644 index 0000000..f655055 --- /dev/null +++ b/tests/test_annotations.bats @@ -0,0 +1,133 @@ +#!/usr/bin/env bats +# Tests for annotation scripts: +# dld-audit/scripts/find-annotations.sh +# dld-implement/scripts/verify-annotations.sh + +load 'test_helper/common' + +setup() { + setup_flat_project +} + +teardown() { + teardown_project +} + +# --- find-annotations.sh --- + +@test "find-annotations finds annotation in source file" { + mkdir -p src + echo '// @decision(DL-001)' > src/main.ts + git add -A && git commit -m "add" --quiet + + run bash "$SKILLS_DIR/dld-audit/scripts/find-annotations.sh" + assert_success + assert_output --partial "src/main.ts" + assert_output --partial "DL-001" +} + +@test "find-annotations returns empty with no annotations" { + mkdir -p src + echo 'console.log("hello")' > src/main.ts + git add -A && git commit -m "add" --quiet + + run bash "$SKILLS_DIR/dld-audit/scripts/find-annotations.sh" + assert_success + assert_output "" +} + +@test "find-annotations finds multiple annotations in one file" { + mkdir -p src + cat > src/main.ts <<'EOF' +// @decision(DL-001) +function foo() {} + +// @decision(DL-002) +function bar() {} +EOF + git add -A && git commit -m "add" --quiet + + run bash "$SKILLS_DIR/dld-audit/scripts/find-annotations.sh" + assert_success + assert_output --partial "DL-001" + assert_output --partial "DL-002" +} + +@test "find-annotations excludes node_modules" { + mkdir -p node_modules/pkg + echo '// @decision(DL-001)' > node_modules/pkg/index.js + git add -A && git commit -m "add" --quiet + + run bash "$SKILLS_DIR/dld-audit/scripts/find-annotations.sh" + assert_success + assert_output "" +} + +@test "find-annotations excludes decisions directory" { + create_decision "DL-001" "accepted" + git add -A && git commit -m "add" --quiet + + run bash "$SKILLS_DIR/dld-audit/scripts/find-annotations.sh" + assert_success + # Decision files contain "id: DL-001" but not the annotation pattern, so this + # should be empty regardless. The exclusion matters when decision docs mention + # the annotation pattern in prose. + assert_output "" +} + +@test "find-annotations outputs relative paths" { + mkdir -p src/deep/nested + echo '// @decision(DL-005)' > src/deep/nested/file.py + git add -A && git commit -m "add" --quiet + + run bash "$SKILLS_DIR/dld-audit/scripts/find-annotations.sh" + assert_success + assert_output --partial "src/deep/nested/file.py" + # Should NOT contain the absolute path + refute_output --partial "$TEST_PROJECT/src" +} + +# --- verify-annotations.sh --- + +@test "verify-annotations succeeds when annotation exists" { + mkdir -p src + echo '// @decision(DL-001)' > src/main.ts + + run bash "$SKILLS_DIR/dld-implement/scripts/verify-annotations.sh" DL-001 + assert_success + assert_output --partial "All decisions have code annotations" +} + +@test "verify-annotations fails when annotation missing" { + mkdir -p src + echo 'console.log("hello")' > src/main.ts + + run bash "$SKILLS_DIR/dld-implement/scripts/verify-annotations.sh" DL-001 + assert_failure + assert_output --partial "MISSING" + assert_output --partial "DL-001" +} + +@test "verify-annotations checks multiple IDs" { + mkdir -p src + echo '// @decision(DL-001)' > src/a.ts + echo '// @decision(DL-002)' > src/b.ts + + run bash "$SKILLS_DIR/dld-implement/scripts/verify-annotations.sh" DL-001 DL-002 + assert_success +} + +@test "verify-annotations reports all missing IDs" { + mkdir -p src + echo '// @decision(DL-001)' > src/a.ts + + run bash "$SKILLS_DIR/dld-implement/scripts/verify-annotations.sh" DL-001 DL-002 DL-003 + assert_failure + assert_output --partial "DL-002" + assert_output --partial "DL-003" +} + +@test "verify-annotations fails with no arguments" { + run bash "$SKILLS_DIR/dld-implement/scripts/verify-annotations.sh" + assert_failure +} diff --git a/tests/test_audit_state.bats b/tests/test_audit_state.bats new file mode 100644 index 0000000..a1f9a74 --- /dev/null +++ b/tests/test_audit_state.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats +# Tests for dld-audit/scripts/update-audit-state.sh + +load 'test_helper/common' + +SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-audit/scripts/update-audit-state.sh" +} + +teardown() { + teardown_project +} + +@test "update-audit-state creates state file" { + run bash "$SCRIPT" + assert_success + + [[ -f decisions/.dld-state.yaml ]] + run cat decisions/.dld-state.yaml + assert_output --partial "audit:" + assert_output --partial "last_run:" + assert_output --partial "commit_hash:" +} + +@test "update-audit-state records git commit hash" { + bash "$SCRIPT" + hash="$(git rev-parse --short HEAD)" + + run cat decisions/.dld-state.yaml + assert_output --partial "commit_hash: $hash" +} + +@test "update-audit-state preserves snapshot section" { + cat > decisions/.dld-state.yaml <<'YAML' +snapshot: + last_run: 2026-01-10T08:00:00Z + decisions_included: 5 + artifacts: + SNAPSHOT.md: 2026-01-10T08:00:00Z + OVERVIEW.md: 2026-01-10T08:00:00Z +YAML + + bash "$SCRIPT" + run cat decisions/.dld-state.yaml + assert_output --partial "snapshot:" + assert_output --partial "decisions_included: 5" + assert_output --partial "audit:" +} + +@test "update-audit-state replaces existing audit section" { + cat > decisions/.dld-state.yaml <<'YAML' +audit: + last_run: 2026-01-01T00:00:00Z + commit_hash: old1234 +YAML + + bash "$SCRIPT" + run cat decisions/.dld-state.yaml + refute_output --partial "old1234" + assert_output --partial "audit:" +} diff --git a/tests/test_collect_decisions.bats b/tests/test_collect_decisions.bats new file mode 100644 index 0000000..9a76aec --- /dev/null +++ b/tests/test_collect_decisions.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# Tests for dld-snapshot/scripts/collect-active-decisions.sh + +load 'test_helper/common' + +SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-snapshot/scripts/collect-active-decisions.sh" +} + +teardown() { + teardown_project +} + +@test "collect-active-decisions outputs nothing with no decisions" { + run bash "$SCRIPT" + assert_success + assert_output "" +} + +@test "collect-active-decisions returns only accepted decisions" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "proposed" + create_decision "DL-003" "superseded" + create_decision "DL-004" "accepted" + + run bash "$SCRIPT" + assert_success + assert_output --partial "DL-001" + assert_output --partial "DL-004" + refute_output --partial "id: DL-002" + refute_output --partial "id: DL-003" +} + +@test "collect-active-decisions separates with boundary markers" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "accepted" + + run bash "$SCRIPT" + assert_success + assert_output --partial "===DLD_DECISION_BOUNDARY===" +} + +@test "collect-active-decisions has no boundary before first decision" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "accepted" + + output="$(bash "$SCRIPT")" + # First line should be the frontmatter start, not a boundary + first_line="$(echo "$output" | head -1)" + assert_equal "$first_line" "---" +} + +@test "collect-active-decisions outputs in ascending ID order" { + create_decision "DL-003" "accepted" + create_decision "DL-001" "accepted" + + output="$(bash "$SCRIPT")" + # DL-001 content should appear before DL-003 + pos_001="$(echo "$output" | grep -n "id: DL-001" | head -1 | cut -d: -f1)" + pos_003="$(echo "$output" | grep -n "id: DL-003" | head -1 | cut -d: -f1)" + [[ "$pos_001" -lt "$pos_003" ]] +} + +@test "collect-active-decisions fails when records dir missing" { + rmdir decisions/records + run bash "$SCRIPT" + assert_failure + assert_output --partial "records directory not found" +} + +@test "collect-active-decisions works with namespaced decisions" { + setup_namespaced_project + create_decision "DL-001" "accepted" "billing" + create_decision "DL-002" "proposed" "auth" + create_decision "DL-003" "accepted" "auth" + + run bash "$SCRIPT" + assert_success + assert_output --partial "id: DL-001" + assert_output --partial "id: DL-003" + refute_output --partial "id: DL-002" +} + +@test "collect-active-decisions includes full file content" { + create_decision "DL-001" "accepted" + + run bash "$SCRIPT" + assert_success + assert_output --partial "## Context" + assert_output --partial "## Decision" + assert_output --partial "Test decision content for DL-001" +} diff --git a/tests/test_common.bats b/tests/test_common.bats new file mode 100644 index 0000000..4e83151 --- /dev/null +++ b/tests/test_common.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# Tests for dld-common/scripts/common.sh + +load 'test_helper/common' + +setup() { + setup_flat_project + source "$SKILLS_DIR/dld-common/scripts/common.sh" +} + +teardown() { + teardown_project +} + +# --- get_project_root --- + +@test "get_project_root returns git root" { + result="$(get_project_root)" + assert_equal "$result" "$TEST_PROJECT" +} + +# --- config_get --- + +@test "config_get reads decisions_dir" { + result="$(config_get decisions_dir)" + assert_equal "$result" "decisions" +} + +@test "config_get reads mode" { + result="$(config_get mode)" + assert_equal "$result" "flat" +} + +@test "config_get reads annotation_prefix with quotes" { + result="$(config_get annotation_prefix)" + assert_equal "$result" "@decision" +} + +@test "config_get returns empty for missing field" { + # grep returns exit 1 when no match, so we use run to capture it + run bash -c 'source "'"$SKILLS_DIR"'/dld-common/scripts/common.sh"; config_get nonexistent_field' + # The command may fail (grep exit 1 under pipefail) or return empty — both are acceptable + [[ "$output" == "" ]] +} + +@test "config_get fails when no config file" { + rm dld.config.yaml + run config_get mode + assert_failure + assert_output --partial "dld.config.yaml not found" +} + +@test "config_get handles double-quoted values" { + cat > dld.config.yaml <<'YAML' +decisions_dir: "my-decisions" +mode: flat +YAML + result="$(config_get decisions_dir)" + assert_equal "$result" "my-decisions" +} + +@test "config_get handles single-quoted values" { + cat > dld.config.yaml <<'YAML' +decisions_dir: 'my-decisions' +mode: flat +YAML + result="$(config_get decisions_dir)" + assert_equal "$result" "my-decisions" +} + +# --- get_decisions_dir --- + +@test "get_decisions_dir returns absolute path" { + result="$(get_decisions_dir)" + assert_equal "$result" "$TEST_PROJECT/decisions" +} + +# --- get_records_dir --- + +@test "get_records_dir returns records subdirectory" { + result="$(get_records_dir)" + assert_equal "$result" "$TEST_PROJECT/decisions/records" +} + +# --- get_mode --- + +@test "get_mode returns flat for flat project" { + result="$(get_mode)" + assert_equal "$result" "flat" +} + +@test "get_mode returns namespaced for namespaced project" { + cat > dld.config.yaml <<'YAML' +decisions_dir: decisions +mode: namespaced +namespaces: + - billing + - auth +YAML + result="$(get_mode)" + assert_equal "$result" "namespaced" +} + +# --- get_namespaces --- + +@test "get_namespaces returns namespace list" { + cat > dld.config.yaml <<'YAML' +decisions_dir: decisions +mode: namespaced +namespaces: + - billing + - auth + - shared +YAML + run get_namespaces + assert_success + assert_line --index 0 "billing" + assert_line --index 1 "auth" + assert_line --index 2 "shared" +} + +@test "get_namespaces returns nothing for flat project" { + run get_namespaces + assert_success + assert_output "" +} diff --git a/tests/test_create_decision.bats b/tests/test_create_decision.bats new file mode 100644 index 0000000..9966233 --- /dev/null +++ b/tests/test_create_decision.bats @@ -0,0 +1,105 @@ +#!/usr/bin/env bats +# Tests for dld-decide/scripts/create-decision.sh + +load 'test_helper/common' + +SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-decide/scripts/create-decision.sh" +} + +teardown() { + teardown_project +} + +@test "create-decision creates file with correct frontmatter" { + run bash "$SCRIPT" --id DL-001 --title "Test decision" + assert_success + assert_output --partial "DL-001.md" + + # Verify file exists and has correct structure + [[ -f decisions/records/DL-001.md ]] + run cat decisions/records/DL-001.md + assert_output --partial 'id: DL-001' + assert_output --partial 'title: "Test decision"' + assert_output --partial 'status: proposed' + assert_output --partial 'supersedes: []' + assert_output --partial 'tags: []' + assert_output --partial 'references: []' +} + +@test "create-decision includes tags" { + run bash "$SCRIPT" --id DL-001 --title "Tagged" --tags "api, auth" + assert_success + + run cat decisions/records/DL-001.md + assert_output --partial 'tags: [api, auth]' +} + +@test "create-decision includes supersedes" { + run bash "$SCRIPT" --id DL-002 --title "Superseder" --supersedes "DL-001" + assert_success + + run cat decisions/records/DL-002.md + assert_output --partial 'supersedes: [DL-001]' +} + +@test "create-decision reads body from stdin" { + echo "## Context +Some context here. + +## Decision +We decided this." | bash "$SCRIPT" --id DL-001 --title "With body" --body-stdin + + run cat decisions/records/DL-001.md + assert_output --partial "Some context here." + assert_output --partial "We decided this." +} + +@test "create-decision fails without --id" { + run bash "$SCRIPT" --title "No ID" + assert_failure + assert_output --partial "required" +} + +@test "create-decision fails without --title" { + run bash "$SCRIPT" --id DL-001 + assert_failure + assert_output --partial "required" +} + +@test "create-decision fails if file already exists" { + create_decision "DL-001" "proposed" + run bash "$SCRIPT" --id DL-001 --title "Duplicate" + assert_failure + assert_output --partial "already exists" +} + +@test "create-decision places file in namespace directory" { + setup_namespaced_project + run bash "$SCRIPT" --id DL-001 --title "Billing thing" --namespace billing + assert_success + [[ -f decisions/records/billing/DL-001.md ]] +} + +@test "create-decision includes namespace in frontmatter" { + setup_namespaced_project + bash "$SCRIPT" --id DL-001 --title "Auth thing" --namespace auth + run cat decisions/records/auth/DL-001.md + assert_output --partial 'namespace: auth' +} + +@test "create-decision omits namespace field in flat mode" { + bash "$SCRIPT" --id DL-001 --title "Flat thing" + run grep "namespace:" decisions/records/DL-001.md + assert_failure +} + +@test "create-decision includes ISO-8601 timestamp" { + bash "$SCRIPT" --id DL-001 --title "Timestamped" + # Match ISO-8601 UTC format + run grep -E "^timestamp: [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" decisions/records/DL-001.md + assert_success +} diff --git a/tests/test_helper/bats-assert b/tests/test_helper/bats-assert new file mode 160000 index 0000000..697471b --- /dev/null +++ b/tests/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/tests/test_helper/bats-support b/tests/test_helper/bats-support new file mode 160000 index 0000000..0954abb --- /dev/null +++ b/tests/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96 diff --git a/tests/test_helper/common.bash b/tests/test_helper/common.bash new file mode 100644 index 0000000..604bbd1 --- /dev/null +++ b/tests/test_helper/common.bash @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Shared test helper — sets up a temporary git repo with dld.config.yaml + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' + +# Path to the skills scripts (tessl version — canonical source) +SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/skills" + +# Create a temporary git repo with a flat dld config +setup_flat_project() { + TEST_PROJECT="$(mktemp -d)" + cd "$TEST_PROJECT" + git init --quiet + git commit --allow-empty -m "init" --quiet + + cat > dld.config.yaml <<'YAML' +decisions_dir: decisions +mode: flat +annotation_prefix: '@decision' +YAML + + mkdir -p decisions/records +} + +# Create a temporary git repo with a namespaced dld config +setup_namespaced_project() { + TEST_PROJECT="$(mktemp -d)" + cd "$TEST_PROJECT" + git init --quiet + git commit --allow-empty -m "init" --quiet + + cat > dld.config.yaml <<'YAML' +decisions_dir: decisions +mode: namespaced +namespaces: + - billing + - auth + - shared +annotation_prefix: '@decision' +YAML + + mkdir -p decisions/records/{billing,auth,shared} +} + +# Create a minimal decision file +# Usage: create_decision [namespace] +create_decision() { + local id="$1" + local status="$2" + local namespace="${3:-}" + local title="${4:-Test decision $id}" + + local dir="decisions/records" + if [[ -n "$namespace" ]]; then + dir="decisions/records/$namespace" + mkdir -p "$dir" + fi + + local ns_line="" + if [[ -n "$namespace" ]]; then + ns_line="namespace: $namespace" + fi + + cat > "$dir/$id.md" < dld.config.yaml + run bash "$SKILLS_DIR/dld-init/scripts/create-config.sh" flat + assert_failure + assert_output --partial "already exists" +} + +# --- create-directories.sh --- + +@test "create-directories creates flat structure" { + bash "$SKILLS_DIR/dld-init/scripts/create-config.sh" flat + run bash "$SKILLS_DIR/dld-init/scripts/create-directories.sh" + assert_success + + [[ -d decisions ]] + [[ -d decisions/records ]] +} + +@test "create-directories creates namespaced structure" { + bash "$SKILLS_DIR/dld-init/scripts/create-config.sh" namespaced billing auth + run bash "$SKILLS_DIR/dld-init/scripts/create-directories.sh" + assert_success + + [[ -d decisions/records/billing ]] + [[ -d decisions/records/auth ]] + [[ -f decisions/records/billing/.gitkeep ]] + [[ -f decisions/records/auth/.gitkeep ]] +} + +# --- create-empty-index.sh --- + +@test "create-empty-index creates flat index" { + bash "$SKILLS_DIR/dld-init/scripts/create-config.sh" flat + mkdir -p decisions + run bash "$SKILLS_DIR/dld-init/scripts/create-empty-index.sh" + assert_success + + [[ -f decisions/INDEX.md ]] + run cat decisions/INDEX.md + assert_output --partial "# Decision Log" + assert_output --partial "| ID | Title | Status | Tags |" + refute_output --partial "Namespace" +} + +@test "create-empty-index creates namespaced index" { + bash "$SKILLS_DIR/dld-init/scripts/create-config.sh" namespaced billing auth + mkdir -p decisions + run bash "$SKILLS_DIR/dld-init/scripts/create-empty-index.sh" + assert_success + + run cat decisions/INDEX.md + assert_output --partial "| ID | Title | Status | Namespace | Tags |" +} diff --git a/tests/test_next_id.bats b/tests/test_next_id.bats new file mode 100644 index 0000000..ef7a2c4 --- /dev/null +++ b/tests/test_next_id.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats +# Tests for dld-common/scripts/next-id.sh + +load 'test_helper/common' + +setup() { + setup_flat_project +} + +teardown() { + teardown_project +} + +@test "next-id returns DL-001 with no existing decisions" { + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-001" +} + +@test "next-id returns DL-001 when records dir missing" { + rmdir decisions/records + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-001" +} + +@test "next-id increments from existing decisions" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "proposed" + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-003" +} + +@test "next-id handles gaps in IDs" { + create_decision "DL-001" "accepted" + create_decision "DL-005" "accepted" + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-006" +} + +@test "next-id handles octal-safe numbers (DL-008, DL-009)" { + create_decision "DL-008" "accepted" + create_decision "DL-009" "accepted" + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-010" +} + +@test "next-id works with namespaced decisions" { + setup_namespaced_project + create_decision "DL-001" "accepted" "billing" + create_decision "DL-003" "accepted" "auth" + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-004" +} + +@test "next-id ignores non-decision files" { + create_decision "DL-002" "accepted" + touch decisions/records/README.md + touch decisions/records/notes.txt + run bash "$SKILLS_DIR/dld-common/scripts/next-id.sh" + assert_success + assert_output "DL-003" +} diff --git a/tests/test_regenerate_index.bats b/tests/test_regenerate_index.bats new file mode 100644 index 0000000..77e501d --- /dev/null +++ b/tests/test_regenerate_index.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# Tests for dld-common/scripts/regenerate-index.sh + +load 'test_helper/common' + +SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-common/scripts/regenerate-index.sh" +} + +teardown() { + teardown_project +} + +@test "regenerate-index creates empty index with no decisions" { + run bash "$SCRIPT" + assert_success + assert_output --partial "empty" + + [[ -f decisions/INDEX.md ]] + run cat decisions/INDEX.md + assert_output --partial "# Decision Log" + assert_output --partial "| ID | Title | Status | Tags |" +} + +@test "regenerate-index lists decisions in descending order" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "proposed" + create_decision "DL-003" "accepted" + + bash "$SCRIPT" + run cat decisions/INDEX.md + + # DL-003 should appear before DL-001 + assert_output --partial "DL-003" + assert_output --partial "DL-001" +} + +@test "regenerate-index includes all statuses" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "superseded" + create_decision "DL-003" "deprecated" + + bash "$SCRIPT" + run cat decisions/INDEX.md + assert_output --partial "accepted" + assert_output --partial "superseded" + assert_output --partial "deprecated" +} + +@test "regenerate-index flat mode omits namespace column" { + create_decision "DL-001" "accepted" + bash "$SCRIPT" + + run cat decisions/INDEX.md + assert_output --partial "| ID | Title | Status | Tags |" + refute_output --partial "Namespace" +} + +@test "regenerate-index namespaced mode includes namespace column" { + setup_namespaced_project + create_decision "DL-001" "accepted" "billing" + + bash "$SCRIPT" + run cat decisions/INDEX.md + assert_output --partial "| ID | Title | Status | Namespace | Tags |" +} + +@test "regenerate-index extracts tags correctly" { + create_decision "DL-001" "accepted" + bash "$SCRIPT" + + run cat decisions/INDEX.md + assert_output --partial "test, example" +} + +@test "regenerate-index fails when records dir missing" { + rmdir decisions/records + run bash "$SCRIPT" + assert_failure + assert_output --partial "records directory not found" +} + +@test "regenerate-index overwrites existing INDEX.md" { + echo "old content" > decisions/INDEX.md + create_decision "DL-001" "accepted" + + bash "$SCRIPT" + run cat decisions/INDEX.md + refute_output --partial "old content" + assert_output --partial "# Decision Log" +} diff --git a/tests/test_snapshot_state.bats b/tests/test_snapshot_state.bats new file mode 100644 index 0000000..37497af --- /dev/null +++ b/tests/test_snapshot_state.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats +# Tests for dld-snapshot/scripts/update-snapshot-state.sh + +load 'test_helper/common' + +SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-snapshot/scripts/update-snapshot-state.sh" +} + +teardown() { + teardown_project +} + +@test "update-snapshot-state creates state file" { + create_decision "DL-001" "accepted" + run bash "$SCRIPT" + assert_success + + [[ -f decisions/.dld-state.yaml ]] + run cat decisions/.dld-state.yaml + assert_output --partial "snapshot:" + assert_output --partial "last_run:" + assert_output --partial "decisions_included: 1" +} + +@test "update-snapshot-state tracks highest accepted ID" { + create_decision "DL-001" "accepted" + create_decision "DL-002" "proposed" + create_decision "DL-003" "accepted" + + bash "$SCRIPT" + run cat decisions/.dld-state.yaml + assert_output --partial "decisions_included: 3" +} + +@test "update-snapshot-state ignores non-accepted decisions for highest" { + create_decision "DL-001" "accepted" + create_decision "DL-005" "proposed" + + bash "$SCRIPT" + run cat decisions/.dld-state.yaml + assert_output --partial "decisions_included: 1" +} + +@test "update-snapshot-state includes built-in artifact timestamps" { + create_decision "DL-001" "accepted" + bash "$SCRIPT" + + run cat decisions/.dld-state.yaml + assert_output --partial "SNAPSHOT.md:" + assert_output --partial "OVERVIEW.md:" +} + +@test "update-snapshot-state includes custom artifact timestamps" { + create_decision "DL-001" "accepted" + bash "$SCRIPT" ONBOARDING.md API-CONTRACTS.md + + run cat decisions/.dld-state.yaml + assert_output --partial "SNAPSHOT.md:" + assert_output --partial "OVERVIEW.md:" + assert_output --partial "ONBOARDING.md:" + assert_output --partial "API-CONTRACTS.md:" +} + +@test "update-snapshot-state preserves audit section" { + create_decision "DL-001" "accepted" + + # Create existing state file with audit section + cat > decisions/.dld-state.yaml <<'YAML' +audit: + last_run: 2026-01-10T08:00:00Z + commit_hash: abc1234 +YAML + + bash "$SCRIPT" + run cat decisions/.dld-state.yaml + assert_output --partial "audit:" + assert_output --partial "commit_hash: abc1234" + assert_output --partial "snapshot:" +} + +@test "update-snapshot-state replaces existing snapshot section" { + create_decision "DL-001" "accepted" + + cat > decisions/.dld-state.yaml <<'YAML' +snapshot: + last_run: 2026-01-01T00:00:00Z + decisions_included: 0 + artifacts: + SNAPSHOT.md: 2026-01-01T00:00:00Z + OVERVIEW.md: 2026-01-01T00:00:00Z +YAML + + bash "$SCRIPT" + run cat decisions/.dld-state.yaml + # Old timestamp should be gone + refute_output --partial "2026-01-01T00:00:00Z" + assert_output --partial "decisions_included: 1" +} + +@test "update-snapshot-state handles zero accepted decisions" { + create_decision "DL-001" "proposed" + bash "$SCRIPT" + + run cat decisions/.dld-state.yaml + assert_output --partial "decisions_included: 0" +} diff --git a/tests/test_structure.bats b/tests/test_structure.bats new file mode 100644 index 0000000..501dabc --- /dev/null +++ b/tests/test_structure.bats @@ -0,0 +1,199 @@ +#!/usr/bin/env bats +# Tier 2: Structural and lint checks +# Validates consistency between skills/ and .claude/skills/, script path +# references, frontmatter fields, and tile.json integrity. + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" +} + +# --- Dual directory sync --- + +@test "all skill directories exist in both skills/ and .claude/skills/" { + for dir in "$REPO_ROOT"/skills/dld-*/; do + skill_name="$(basename "$dir")" + [[ -d "$REPO_ROOT/.claude/skills/$skill_name" ]] + done +} + +@test "SKILL.md content matches between skills/ and .claude/skills/ (normalized)" { + for dir in "$REPO_ROOT"/skills/dld-*/; do + skill_name="$(basename "$dir")" + tessl_file="$dir/SKILL.md" + claude_file="$REPO_ROOT/.claude/skills/$skill_name/SKILL.md" + + [[ -f "$tessl_file" ]] || continue + [[ -f "$claude_file" ]] || continue + + # Normalize: replace .claude/skills/ paths with relative, strip frontmatter differences + # The Claude Code version uses absolute .claude/skills/dld-X/ paths while the + # tessl version uses relative ../dld-X/ paths. We normalize the Claude version + # by replacing all .claude/skills/dld-*/ references with ../dld-*/ (cross-skill) + # and scripts/ (same-skill). + # Also strip Tessl-specific notes (allowed per CLAUDE.md — the tessl version + # may have notes about steering rules replacing CLAUDE.md instructions) + tessl_normalized="$(sed \ + -e 's/^compatibility:.*$/__COMPAT_LINE__/' \ + -e '/^> \*\*Note:\*\* If DLD was installed via Tessl/d' \ + "$tessl_file")" + + claude_normalized="$(sed \ + -e 's/^user_invocable:.*$/__COMPAT_LINE__/' \ + -e "s|\.claude/skills/$skill_name/scripts/|scripts/|g" \ + -e "s|\.claude/skills/$skill_name/|./|g" \ + -e 's|\.claude/skills/\(dld-[a-z-]*\)/|../\1/|g' \ + "$claude_file")" + + # Compare ignoring trailing blank lines + if ! diff -B <(echo "$tessl_normalized") <(echo "$claude_normalized") > /dev/null 2>&1; then + echo "SKILL.md mismatch for $skill_name" + diff <(echo "$tessl_normalized") <(echo "$claude_normalized") || true + return 1 + fi + done +} + +@test "shell scripts are identical between skills/ and .claude/skills/" { + for dir in "$REPO_ROOT"/skills/dld-*/scripts/; do + [[ -d "$dir" ]] || continue + skill_name="$(basename "$(dirname "$dir")")" + claude_scripts="$REPO_ROOT/.claude/skills/$skill_name/scripts" + + for script in "$dir"/*.sh; do + [[ -f "$script" ]] || continue + script_name="$(basename "$script")" + claude_script="$claude_scripts/$script_name" + + if [[ ! -f "$claude_script" ]]; then + echo "Missing: .claude/skills/$skill_name/scripts/$script_name" + return 1 + fi + + if ! diff -q "$script" "$claude_script" > /dev/null 2>&1; then + echo "Script mismatch: $skill_name/scripts/$script_name" + diff "$script" "$claude_script" || true + return 1 + fi + done + done +} + +# --- Script path references --- + +@test "all script paths referenced in skills/ SKILL.md files exist" { + for skill_md in "$REPO_ROOT"/skills/dld-*/SKILL.md; do + skill_dir="$(dirname "$skill_md")" + # Extract paths from code blocks that look like script references + grep -E '^\.\./dld-|^scripts/' "$skill_md" 2>/dev/null | while IFS= read -r path; do + resolved="$skill_dir/$path" + if [[ ! -f "$resolved" ]]; then + echo "Missing script: $path (referenced in $skill_md)" + return 1 + fi + done + done +} + +@test "all script paths referenced in .claude/skills/ SKILL.md files exist" { + for skill_md in "$REPO_ROOT"/.claude/skills/dld-*/SKILL.md; do + # Extract .claude/skills/ paths from code blocks + grep -oE '\.claude/skills/dld-[a-z-]+/scripts/[a-z_-]+\.sh' "$skill_md" 2>/dev/null | while IFS= read -r path; do + resolved="$REPO_ROOT/$path" + if [[ ! -f "$resolved" ]]; then + echo "Missing script: $path (referenced in $skill_md)" + return 1 + fi + done + done +} + +# --- Frontmatter validation --- + +@test "all skills/ SKILL.md files have valid frontmatter" { + for skill_md in "$REPO_ROOT"/skills/dld-*/SKILL.md; do + skill_name="$(basename "$(dirname "$skill_md")")" + + # Check frontmatter delimiters + first_line="$(head -1 "$skill_md")" + assert_equal "$first_line" "---" "Missing opening --- in $skill_name" + + # Check name field matches directory + name_field="$(sed -n '/^---$/,/^---$/{ /^name:/p }' "$skill_md" | head -1 | sed 's/^name:[[:space:]]*//')" + assert_equal "$name_field" "$skill_name" "name mismatch in $skill_name" + + # Check description field exists + run grep "^description:" "$skill_md" + assert_success "Missing description in $skill_name" + + # Check compatibility field exists (tessl version) + run grep "^compatibility:" "$skill_md" + assert_success "Missing compatibility in $skill_name" + done +} + +@test "all .claude/skills/ SKILL.md files have user_invocable field" { + for skill_md in "$REPO_ROOT"/.claude/skills/dld-*/SKILL.md; do + skill_name="$(basename "$(dirname "$skill_md")")" + + # dld-common is not user-invocable + if [[ "$skill_name" == "dld-common" ]]; then + continue + fi + + run grep "^user_invocable:" "$skill_md" + assert_success "Missing user_invocable in .claude/skills/$skill_name" + done +} + +# --- tile.json integrity --- + +@test "tile.json references all skill directories" { + for dir in "$REPO_ROOT"/skills/dld-*/; do + skill_name="$(basename "$dir")" + run grep "\"$skill_name\"" "$REPO_ROOT/tile.json" + assert_success "tile.json missing skill: $skill_name" + done +} + +@test "tile.json skill paths point to existing files" { + # Extract paths from tile.json + grep '"path":' "$REPO_ROOT/tile.json" | sed 's/.*"path":[[:space:]]*"\(.*\)".*/\1/' | while IFS= read -r path; do + if [[ ! -f "$REPO_ROOT/$path" ]]; then + echo "tile.json references missing file: $path" + return 1 + fi + done +} + +@test "tile.json steering rule path exists" { + grep '"rules":' "$REPO_ROOT/tile.json" | sed 's/.*"rules":[[:space:]]*"\(.*\)".*/\1/' | while IFS= read -r path; do + if [[ ! -f "$REPO_ROOT/$path" ]]; then + echo "tile.json steering rule missing: $path" + return 1 + fi + done +} + +# --- Shell script conventions --- + +@test "all shell scripts use strict mode (set -euo pipefail)" { + find "$REPO_ROOT/skills" -name '*.sh' -type f | while IFS= read -r script; do + if ! grep -q 'set -euo pipefail' "$script"; then + echo "Missing strict mode: $script" + return 1 + fi + done +} + +@test "all shell scripts have shebang line" { + find "$REPO_ROOT/skills" -name '*.sh' -type f | while IFS= read -r script; do + first_line="$(head -1 "$script")" + if [[ "$first_line" != "#!/usr/bin/env bash" ]]; then + echo "Bad shebang in: $script (got: $first_line)" + return 1 + fi + done +} diff --git a/tests/test_update_status.bats b/tests/test_update_status.bats new file mode 100644 index 0000000..5c76b75 --- /dev/null +++ b/tests/test_update_status.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats +# Tests for dld-common/scripts/update-status.sh + +load 'test_helper/common' + +SCRIPT="" + +setup() { + setup_flat_project + SCRIPT="$SKILLS_DIR/dld-common/scripts/update-status.sh" +} + +teardown() { + teardown_project +} + +@test "update-status changes proposed to accepted" { + create_decision "DL-001" "proposed" + run bash "$SCRIPT" DL-001 accepted + assert_success + assert_output --partial "Updated DL-001 status to accepted" + + run grep "^status:" decisions/records/DL-001.md + assert_output "status: accepted" +} + +@test "update-status changes accepted to superseded" { + create_decision "DL-001" "accepted" + run bash "$SCRIPT" DL-001 superseded + assert_success + + run grep "^status:" decisions/records/DL-001.md + assert_output "status: superseded" +} + +@test "update-status changes accepted to deprecated" { + create_decision "DL-001" "accepted" + run bash "$SCRIPT" DL-001 deprecated + assert_success + + run grep "^status:" decisions/records/DL-001.md + assert_output "status: deprecated" +} + +@test "update-status rejects invalid status" { + create_decision "DL-001" "proposed" + run bash "$SCRIPT" DL-001 invalid + assert_failure + assert_output --partial "invalid status" +} + +@test "update-status fails for nonexistent decision" { + run bash "$SCRIPT" DL-999 accepted + assert_failure + assert_output --partial "not found" +} + +@test "update-status preserves other frontmatter fields" { + create_decision "DL-001" "proposed" + bash "$SCRIPT" DL-001 accepted + + run grep "^title:" decisions/records/DL-001.md + assert_output --partial "Test decision DL-001" + run grep "^tags:" decisions/records/DL-001.md + assert_output --partial "[test, example]" +} + +@test "update-status preserves body content" { + create_decision "DL-001" "proposed" + bash "$SCRIPT" DL-001 accepted + + run grep "Test context for DL-001" decisions/records/DL-001.md + assert_success +} + +@test "update-status only modifies status in frontmatter, not body" { + # Create a decision that mentions "status:" in the body + cat > decisions/records/DL-001.md <<'EOF' +--- +id: DL-001 +title: "Test" +timestamp: 2026-01-15T10:00:00Z +status: proposed +tags: [] +references: [] +--- + +## Context +The status: field in YAML is important. +EOF + + bash "$SCRIPT" DL-001 accepted + + # Frontmatter status should be updated + run sed -n '/^---$/,/^---$/p' decisions/records/DL-001.md + assert_output --partial "status: accepted" + + # Body text should be preserved unchanged + run grep "The status: field in YAML is important." decisions/records/DL-001.md + assert_success +} + +@test "update-status finds decision in namespace subdirectory" { + setup_namespaced_project + create_decision "DL-001" "proposed" "billing" + run bash "$SCRIPT" DL-001 accepted + assert_success + + run grep "^status:" decisions/records/billing/DL-001.md + assert_output "status: accepted" +} + +@test "update-status requires both arguments" { + run bash "$SCRIPT" + assert_failure +} From 642db88c48c6c06acc03818913b3bda9c1a9fba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jimmy=20Utterstr=C3=B6m?= Date: Sat, 14 Mar 2026 00:18:40 +0100 Subject: [PATCH 2/2] Fix CI: set git user identity in test setup GitHub Actions runners don't have a default git identity configured, causing git commit to fail in test setup functions. Co-Authored-By: Claude Opus 4.6 --- tests/test_helper/common.bash | 4 ++++ tests/test_init_scripts.bats | 2 ++ 2 files changed, 6 insertions(+) diff --git a/tests/test_helper/common.bash b/tests/test_helper/common.bash index 604bbd1..0ca06b2 100644 --- a/tests/test_helper/common.bash +++ b/tests/test_helper/common.bash @@ -12,6 +12,8 @@ setup_flat_project() { TEST_PROJECT="$(mktemp -d)" cd "$TEST_PROJECT" git init --quiet + git config user.email "test@test.com" + git config user.name "Test" git commit --allow-empty -m "init" --quiet cat > dld.config.yaml <<'YAML' @@ -28,6 +30,8 @@ setup_namespaced_project() { TEST_PROJECT="$(mktemp -d)" cd "$TEST_PROJECT" git init --quiet + git config user.email "test@test.com" + git config user.name "Test" git commit --allow-empty -m "init" --quiet cat > dld.config.yaml <<'YAML' diff --git a/tests/test_init_scripts.bats b/tests/test_init_scripts.bats index adc8f26..77a33b9 100644 --- a/tests/test_init_scripts.bats +++ b/tests/test_init_scripts.bats @@ -9,6 +9,8 @@ setup() { TEST_PROJECT="$(mktemp -d)" cd "$TEST_PROJECT" git init --quiet + git config user.email "test@test.com" + git config user.name "Test" git commit --allow-empty -m "init" --quiet }