From 4bf9e7038b2c6fcaa5027b7f232e4a7b4d7d6fb6 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Fri, 24 Jul 2026 13:00:41 -0500 Subject: [PATCH 01/11] feat(makefile): autodetect project roots for Python/JS monorepos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make lint/format/fix/test/security now discover each language's project root from its manifest (pyproject.toml/setup.py/setup.cfg for Python, package.json for JS/TS) and run that language's tools with cwd set there, so local config (tsconfig.json, vite.config.ts path aliases) resolves correctly instead of always assuming repo root. New lib/project-discover.sh (sourced helper, mirrors the dispatch_plugin_target pattern rather than duplicating logic per HAS_ block). Optional .devrail.yml `projects:` override for layouts autodetection can't infer. Single-root projects (manifest at repo root, the common case) are unaffected — falls back to "." with byte-identical output when no per-project signal exists. Scoped to Python + JavaScript per the issue's reproduction case; Go/Rust/Ansible root-awareness and dependency-install-before-test (issue #52) are tracked as follow-on stories. Closes #53 Story 15.1 --- .github/workflows/ci.yml | 11 + CHANGELOG.md | 12 + Makefile | 291 ++++++++++-------- STABILITY.md | 1 + lib/project-discover.sh | 130 ++++++++ .../declared-lang-no-manifest/.devrail.yml | 2 + .../declared-lang-no-manifest/script.py | 2 + .../fixtures/monorepo-python-js/.devrail.yml | 3 + tests/fixtures/monorepo-python-js/api/app.py | 2 + .../monorepo-python-js/api/pyproject.toml | 3 + .../api/tests/test_smoke.py | 2 + .../frontend/eslint.config.js | 6 + .../monorepo-python-js/frontend/package.json | 5 + .../frontend/src/__tests__/smoke.test.ts | 8 + .../monorepo-python-js/frontend/src/greet.ts | 3 + .../monorepo-python-js/frontend/src/index.js | 1 + .../monorepo-python-js/frontend/tsconfig.json | 13 + .../frontend/vite.config.ts | 7 + .../monorepo-with-override/.devrail.yml | 5 + .../custom-py-dir/app.py | 2 + .../services/a/pyproject.toml | 1 + .../services/b/pyproject.toml | 1 + .../fixtures/single-root-python/.devrail.yml | 2 + tests/fixtures/single-root-python/app.py | 2 + .../single-root-python/pyproject.toml | 3 + .../single-root-python/tests/test_smoke.py | 2 + tests/test-project-discover.sh | 120 ++++++++ 27 files changed, 512 insertions(+), 128 deletions(-) create mode 100644 lib/project-discover.sh create mode 100644 tests/fixtures/declared-lang-no-manifest/.devrail.yml create mode 100644 tests/fixtures/declared-lang-no-manifest/script.py create mode 100644 tests/fixtures/monorepo-python-js/.devrail.yml create mode 100644 tests/fixtures/monorepo-python-js/api/app.py create mode 100644 tests/fixtures/monorepo-python-js/api/pyproject.toml create mode 100644 tests/fixtures/monorepo-python-js/api/tests/test_smoke.py create mode 100644 tests/fixtures/monorepo-python-js/frontend/eslint.config.js create mode 100644 tests/fixtures/monorepo-python-js/frontend/package.json create mode 100644 tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts create mode 100644 tests/fixtures/monorepo-python-js/frontend/src/greet.ts create mode 100644 tests/fixtures/monorepo-python-js/frontend/src/index.js create mode 100644 tests/fixtures/monorepo-python-js/frontend/tsconfig.json create mode 100644 tests/fixtures/monorepo-python-js/frontend/vite.config.ts create mode 100644 tests/fixtures/monorepo-with-override/.devrail.yml create mode 100644 tests/fixtures/monorepo-with-override/custom-py-dir/app.py create mode 100644 tests/fixtures/multi-root-python/services/a/pyproject.toml create mode 100644 tests/fixtures/multi-root-python/services/b/pyproject.toml create mode 100644 tests/fixtures/single-root-python/.devrail.yml create mode 100644 tests/fixtures/single-root-python/app.py create mode 100644 tests/fixtures/single-root-python/pyproject.toml create mode 100644 tests/fixtures/single-root-python/tests/test_smoke.py create mode 100644 tests/test-project-discover.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00196c1..7882fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,17 @@ jobs: DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Story 15.1: Project-root discovery smoke test (issue #53) + # Verifies lib/project-discover.sh: single-root fallback (regression + # safety), Python+JS monorepo autodetection, multi-root, projects: + # override, and full make _lint/_test integration (cwd + local config + # + vite `@` alias resolution). + - name: Project-root discovery smoke test + run: bash tests/test-project-discover.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Phase 2e: Plugin resolver + lockfile smoke test (Story 13.3) # Drives `make plugins-update` against a local-filesystem git fixture # (no network) covering: SHA passthrough, tag→SHA, branch rejection, diff --git a/CHANGELOG.md b/CHANGELOG.md index 909d5f7..565b234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Issue #53 (Story 15.1):** `make lint`/`format`/`fix`/`test`/`security` + now autodetect per-language project roots in Python+JS monorepos (e.g. + `api/pyproject.toml` + `frontend/package.json`, no manifests at repo + root) and run each language's tools with cwd set there, so local config + (`tsconfig.json`, `vite.config.ts` path aliases, `pyproject.toml`) + resolves correctly. New `lib/project-discover.sh` helper; optional + `.devrail.yml` `projects:` override for layouts autodetection can't + infer. Single-root projects (the common case) are unaffected — output + is byte-identical to previous versions. + ## [1.12.0] - 2026-05-30 ### Added diff --git a/Makefile b/Makefile index b62b87b..a29757c 100644 --- a/Makefile +++ b/Makefile @@ -396,19 +396,23 @@ _plugins-load: _plugins-verify # call to keep behaviour symmetric with the per-language blocks. _lint: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - ruff check . || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && ruff check .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ ran_languages="$${ran_languages}\"bash\","; \ @@ -511,30 +515,33 @@ _lint: _plugins-load fi; \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - js_files=$$(find . \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null); \ - if [ -n "$$js_files" ]; then \ - eslint . || { overall_exit=1; failed_languages="$${failed_languages}\"javascript:eslint\","; }; \ - else \ - echo '{"level":"info","msg":"skipping javascript eslint lint: no JS/TS files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ - if [ -f "tsconfig.json" ]; then \ - tsc --noEmit || { overall_exit=1; failed_languages="$${failed_languages}\"javascript:tsc\","; }; \ - else \ - echo '{"level":"info","msg":"skipping tsc type check: no tsconfig.json found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + js_files=$$(find "$$js_root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null); \ + if [ -n "$$js_files" ]; then \ + (cd "$$js_root" && eslint .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:eslint\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript eslint lint: no JS/TS files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + if [ -f "$$js_root/tsconfig.json" ]; then \ + (cd "$$js_root" && tsc --noEmit) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:tsc\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping tsc type check: no tsconfig.json found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ ran_languages="$${ran_languages}\"rust\","; \ @@ -621,19 +628,23 @@ _lint: _plugins-load # --- _format: language-specific format checking --- _format: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - ruff format --check . || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && ruff format --check .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ ran_languages="$${ran_languages}\"bash\","; \ @@ -701,19 +712,22 @@ _format: _plugins-load fi; \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - js_files=$$(find . \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null); \ - if [ -n "$$js_files" ]; then \ - prettier --check . || { overall_exit=1; failed_languages="$${failed_languages}\"javascript\","; }; \ - else \ - echo '{"level":"info","msg":"skipping javascript format: no JS/TS files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + js_files=$$(find "$$js_root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null); \ + if [ -n "$$js_files" ]; then \ + (cd "$$js_root" && prettier --check .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript format: no JS/TS files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ ran_languages="$${ran_languages}\"rust\","; \ @@ -779,19 +793,23 @@ _format: _plugins-load # --- _fix: language-specific format fixing (in-place) --- _fix: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - ruff format . || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && ruff format .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ ran_languages="$${ran_languages}\"bash\","; \ @@ -859,19 +877,22 @@ _fix: _plugins-load fi; \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - js_files=$$(find . \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null); \ - if [ -n "$$js_files" ]; then \ - prettier --write . || { overall_exit=1; failed_languages="$${failed_languages}\"javascript\","; }; \ - else \ - echo '{"level":"info","msg":"skipping javascript fix: no JS/TS files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + js_files=$$(find "$$js_root" \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' -o -name '*.mjs' -o -name '*.cjs' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null); \ + if [ -n "$$js_files" ]; then \ + (cd "$$js_root" && prettier --write .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript fix: no JS/TS files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ ran_languages="$${ran_languages}\"rust\","; \ @@ -944,25 +965,29 @@ _fix: _plugins-load # --- _test: language-specific test runners --- _test: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ skipped_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - if [ -d "tests" ] || find . -name '*_test.py' -o -name 'test_*.py' 2>/dev/null | grep -q .; then \ - ran_languages="$${ran_languages}\"python\","; \ - pytest || { overall_exit=1; failed_languages="$${failed_languages}\"python\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"python\","; \ - echo '{"level":"info","msg":"skipping python tests: no test files found","language":"python"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + if [ -d "$$py_root/tests" ] || find "$$py_root" -name '*_test.py' -o -name 'test_*.py' 2>/dev/null | grep -q .; then \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && pytest) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$py_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping python tests: no test files found\",\"language\":\"python\",\"root\":\"$$py_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ if find . -name '*.bats' -not -path './.git/*' 2>/dev/null | grep -q .; then \ @@ -1051,19 +1076,22 @@ _test: _plugins-load fi; \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - if find . \( -name '*.test.*' -o -name '*.spec.*' \) -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' 2>/dev/null | grep -q .; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - vitest run || { overall_exit=1; failed_languages="$${failed_languages}\"javascript\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"javascript\","; \ - echo '{"level":"info","msg":"skipping javascript tests: no *.test.* or *.spec.* files found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + if find "$$js_root" \( -name '*.test.*' -o -name '*.spec.*' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null | grep -q .; then \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + (cd "$$js_root" && vitest run) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$js_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping javascript tests: no *.test.* or *.spec.* files found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ @@ -1133,27 +1161,31 @@ _test: _plugins-load # --- _security: language-specific security scanners --- _security: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ failed_languages=""; \ skipped_languages=""; \ if [ -n "$(HAS_PYTHON)" ]; then \ - ran_languages="$${ran_languages}\"python\","; \ - bandit -r . -q || { overall_exit=1; failed_languages="$${failed_languages}\"python:bandit\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ - semgrep --config auto . --quiet 2>/dev/null || { overall_exit=1; failed_languages="$${failed_languages}\"python:semgrep\","; }; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r py_root; do \ + if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ + ran_languages="$${ran_languages}\"$$py_tag\","; \ + (cd "$$py_root" && bandit -r . -q) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:bandit\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + (cd "$$py_root" && semgrep --config auto . --quiet 2>/dev/null) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:semgrep\","; }; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots python); \ fi; \ if [ -n "$(HAS_BASH)" ]; then \ skipped_languages="$${skipped_languages}\"bash\","; \ @@ -1221,19 +1253,22 @@ _security: _plugins-load fi; \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ - if [ -f "package-lock.json" ]; then \ - ran_languages="$${ran_languages}\"javascript\","; \ - npm audit --audit-level=moderate || { overall_exit=1; failed_languages="$${failed_languages}\"javascript:npm-audit\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"javascript\","; \ - echo '{"level":"info","msg":"skipping npm audit: no package-lock.json found","language":"javascript"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r js_root; do \ + if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ + if [ -f "$$js_root/package-lock.json" ]; then \ + ran_languages="$${ran_languages}\"$$js_tag\","; \ + (cd "$$js_root" && npm audit --audit-level=moderate) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:npm-audit\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$js_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping npm audit: no package-lock.json found\",\"language\":\"javascript\",\"root\":\"$$js_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ if [ -f "Cargo.lock" ]; then \ diff --git a/STABILITY.md b/STABILITY.md index e894e1c..fbd09f8 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -32,6 +32,7 @@ DevRail has reached **v1.0** across all repositories. The core standards, toolch | **Pre-commit hooks** | Stable | Conventional commit hook and per-language hooks configured in template repos. | | **Documentation site** | Stable | [devrail.dev](https://devrail.dev) is live with full standards coverage. | | **Plugin loader + resolver + lockfile + build pipeline + execution loop** | Stable (v1.10.x baseline; reference plugin v1.11.x) | Validates `plugin.devrail.yml` manifests, resolves `rev:` to immutable SHAs (`make plugins-update`), records reproducibility metadata in `.devrail.lock`, and auto-builds a project-local extended image (`devrail-local:`) when plugins are declared. Each loaded plugin's `targets` are dispatched inside `_lint`/`_format`/`_fix`/`_test`/`_security` with gate evaluation, `{paths}` interpolation, per-language overrides, and JSON aggregation into the existing event shape. `DEVRAIL_FAIL_FAST=1` short-circuits on plugin failures the same as core. No-op when `plugins:` is absent — v1.9.x behaviour unchanged. **As of v1.11.0** the first reference plugin ([`devrail-plugin-kotlin`](https://github.com/devrail-dev/devrail-plugin-kotlin)) is published; the extraction is **additive** through the v1.x line (Kotlin remains in core for back-compat). v2.0.0 retires the in-core HAS_ blocks. | +| **Monorepo project-root discovery (Python/JS)** | Preview (Story 15.1) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Go/Rust/Ansible root-awareness and dependency-install-before-test (issue #52) are tracked as follow-on stories (15.2–15.4). | ## Consumer responsibilities diff --git a/lib/project-discover.sh b/lib/project-discover.sh new file mode 100644 index 0000000..1c840f1 --- /dev/null +++ b/lib/project-discover.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# lib/project-discover.sh — Project-root autodiscovery for monorepos (Story 15.1) +# +# Purpose: Discover the directory (or directories) a given language's project +# lives in, so Makefile recipes can run that language's tools with +# the correct cwd instead of always assuming repo root. Closes +# issue #53 (tools ignore subdir project configs in monorepos). +# +# Usage: source "${DEVRAIL_LIB}/project-discover.sh" +# while IFS= read -r root; do ...; done < <(discover_project_roots python) +# +# Contract: +# - Output: newline-separated relative paths, no trailing slash, one or +# more per call — NEVER empty. Callers can always safely loop over the +# result without a "did we find anything" branch. +# - `.devrail.yml` `projects:` (list of {path, languages}) overrides +# autodetection for any language it names. Entries not naming a +# language fall through to autodetection for that language. +# - Autodetection rules (see _project_discover_normalize): +# * a manifest at repo root (.) wins outright — output is just "." +# even if unrelated nested manifests exist elsewhere (e.g. inside an +# examples/ directory) — avoids double-running tools over the same +# files once from "." (which already recurses) and again per nested +# dir. +# * no manifest found anywhere — output is "." — preserves +# pre-Story-15.1 behavior verbatim (tools always ran from repo root +# when no per-project signal existed; Story 15.1 AC 4). +# * otherwise — output is every directory containing a manifest (the +# true monorepo case; Story 15.1 AC 1/AC 7). +# - Excludes .git, node_modules, vendor, .venv, venv, dist, build, +# .terraform subtrees (mirrors the per-language find excludes already +# used in _lint/_format/_fix). +# +# Supported languages: python, javascript. Any other language returns "." +# with a warning — extending autodetection to go/rust/ansible is Story 15.3. +# +# Dependencies: lib/log.sh (log_warn), yq (v4+), bash 5+, coreutils (find) + +# Guard against double-sourcing +# shellcheck disable=SC2317 +if [[ -n "${_DEVRAIL_PROJECT_DISCOVER_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +readonly _DEVRAIL_PROJECT_DISCOVER_LOADED=1 + +_PROJECT_DISCOVER_CONFIG="${DEVRAIL_CONFIG:-.devrail.yml}" +_PROJECT_DISCOVER_FIND_EXCLUDES=( + -not -path './.git/*' + -not -path './node_modules/*' + -not -path './vendor/*' + -not -path './.venv/*' + -not -path './venv/*' + -not -path './dist/*' + -not -path './build/*' + -not -path './.terraform/*' +) + +# _project_discover_override emits the projects: path(s) declared for a +# language in .devrail.yml, one per line. Empty if no override applies. +_project_discover_override() { + local language="$1" + [[ -r "${_PROJECT_DISCOVER_CONFIG}" ]] || return 0 + # Delimiter is "::" rather than a tab: yq (mikefarah) does not reliably + # expand \t as an escape in its expression string, so a literal two-char + # backslash-t was previously emitted instead of a real tab, silently + # breaking the awk field split below (caught in Story 15.1 testing). + yq -r '.projects // [] | .[] | [.path, (.languages // [])[]] | join("::")' \ + "${_PROJECT_DISCOVER_CONFIG}" 2>/dev/null | + awk -F'::' -v lang="${language}" '$2 == lang { print $1 }' +} + +# _project_discover_normalize reads candidate directory paths on stdin (one +# per line, already de-duplicated) and applies the "root wins" / "fallback +# to root" rules documented above. Always emits at least one line. +_project_discover_normalize() { + local candidates + candidates="$(cat)" + if [[ -z "${candidates}" ]]; then + printf '.\n' + elif grep -qx '\.' <<<"${candidates}"; then + printf '.\n' + else + printf '%s\n' "${candidates}" + fi +} + +# _project_discover_autodetect_python finds directories containing a Python +# project manifest (pyproject.toml, setup.py, or setup.cfg — first match per +# directory wins so a directory isn't reported twice). +_project_discover_autodetect_python() { + find . \( -name 'pyproject.toml' -o -name 'setup.py' -o -name 'setup.cfg' \) \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# _project_discover_autodetect_javascript finds directories containing a +# package.json. +_project_discover_autodetect_javascript() { + find . -name 'package.json' \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# discover_project_roots emits newline-separated project root +# paths for the given language. Never empty — see _project_discover_normalize. +discover_project_roots() { + local language="${1:?discover_project_roots requires a language}" + + local override + override="$(_project_discover_override "${language}")" + if [[ -n "${override}" ]]; then + printf '%s\n' "${override}" + return 0 + fi + + case "${language}" in + python) _project_discover_autodetect_python ;; + javascript) _project_discover_autodetect_javascript ;; + *) + log_warn "discover_project_roots: no autodetection rule for language '${language}'" + printf '.\n' + ;; + esac +} diff --git a/tests/fixtures/declared-lang-no-manifest/.devrail.yml b/tests/fixtures/declared-lang-no-manifest/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/declared-lang-no-manifest/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/declared-lang-no-manifest/script.py b/tests/fixtures/declared-lang-no-manifest/script.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/declared-lang-no-manifest/script.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/monorepo-python-js/.devrail.yml b/tests/fixtures/monorepo-python-js/.devrail.yml new file mode 100644 index 0000000..1499aa8 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/.devrail.yml @@ -0,0 +1,3 @@ +languages: + - python + - javascript diff --git a/tests/fixtures/monorepo-python-js/api/app.py b/tests/fixtures/monorepo-python-js/api/app.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/api/app.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/monorepo-python-js/api/pyproject.toml b/tests/fixtures/monorepo-python-js/api/pyproject.toml new file mode 100644 index 0000000..f743668 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/api/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "api-demo" +version = "0.1.0" diff --git a/tests/fixtures/monorepo-python-js/api/tests/test_smoke.py b/tests/fixtures/monorepo-python-js/api/tests/test_smoke.py new file mode 100644 index 0000000..82eb92d --- /dev/null +++ b/tests/fixtures/monorepo-python-js/api/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 + 1 == 2 diff --git a/tests/fixtures/monorepo-python-js/frontend/eslint.config.js b/tests/fixtures/monorepo-python-js/frontend/eslint.config.js new file mode 100644 index 0000000..bc377f1 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/eslint.config.js @@ -0,0 +1,6 @@ +export default [ + { + files: ["**/*.js"], + rules: {}, + }, +]; diff --git a/tests/fixtures/monorepo-python-js/frontend/package.json b/tests/fixtures/monorepo-python-js/frontend/package.json new file mode 100644 index 0000000..6e56dec --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/package.json @@ -0,0 +1,5 @@ +{ + "name": "frontend-demo", + "version": "0.1.0", + "type": "module" +} diff --git a/tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts b/tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..bee41a7 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/src/__tests__/smoke.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { greet } from "@/greet"; + +describe("greet", () => { + it("resolves the @ alias and greets", () => { + expect(greet("world")).toBe("hello, world"); + }); +}); diff --git a/tests/fixtures/monorepo-python-js/frontend/src/greet.ts b/tests/fixtures/monorepo-python-js/frontend/src/greet.ts new file mode 100644 index 0000000..38b5dc3 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/src/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `hello, ${name}`; +} diff --git a/tests/fixtures/monorepo-python-js/frontend/src/index.js b/tests/fixtures/monorepo-python-js/frontend/src/index.js new file mode 100644 index 0000000..b41a6df --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/src/index.js @@ -0,0 +1 @@ +export const version = "0.1.0"; diff --git a/tests/fixtures/monorepo-python-js/frontend/tsconfig.json b/tests/fixtures/monorepo-python-js/frontend/tsconfig.json new file mode 100644 index 0000000..1664579 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src/greet.ts"] +} diff --git a/tests/fixtures/monorepo-python-js/frontend/vite.config.ts b/tests/fixtures/monorepo-python-js/frontend/vite.config.ts new file mode 100644 index 0000000..c134e77 --- /dev/null +++ b/tests/fixtures/monorepo-python-js/frontend/vite.config.ts @@ -0,0 +1,7 @@ +export default { + resolve: { + alias: { + "@": new URL("./src", import.meta.url).pathname, + }, + }, +}; diff --git a/tests/fixtures/monorepo-with-override/.devrail.yml b/tests/fixtures/monorepo-with-override/.devrail.yml new file mode 100644 index 0000000..7ad2f7e --- /dev/null +++ b/tests/fixtures/monorepo-with-override/.devrail.yml @@ -0,0 +1,5 @@ +languages: + - python +projects: + - path: custom-py-dir + languages: [python] diff --git a/tests/fixtures/monorepo-with-override/custom-py-dir/app.py b/tests/fixtures/monorepo-with-override/custom-py-dir/app.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/monorepo-with-override/custom-py-dir/app.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/multi-root-python/services/a/pyproject.toml b/tests/fixtures/multi-root-python/services/a/pyproject.toml new file mode 100644 index 0000000..38c626f --- /dev/null +++ b/tests/fixtures/multi-root-python/services/a/pyproject.toml @@ -0,0 +1 @@ +[project] diff --git a/tests/fixtures/multi-root-python/services/b/pyproject.toml b/tests/fixtures/multi-root-python/services/b/pyproject.toml new file mode 100644 index 0000000..38c626f --- /dev/null +++ b/tests/fixtures/multi-root-python/services/b/pyproject.toml @@ -0,0 +1 @@ +[project] diff --git a/tests/fixtures/single-root-python/.devrail.yml b/tests/fixtures/single-root-python/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/single-root-python/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/single-root-python/app.py b/tests/fixtures/single-root-python/app.py new file mode 100644 index 0000000..4693ad3 --- /dev/null +++ b/tests/fixtures/single-root-python/app.py @@ -0,0 +1,2 @@ +def add(a, b): + return a + b diff --git a/tests/fixtures/single-root-python/pyproject.toml b/tests/fixtures/single-root-python/pyproject.toml new file mode 100644 index 0000000..5c2b375 --- /dev/null +++ b/tests/fixtures/single-root-python/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "single-root-demo" +version = "0.1.0" diff --git a/tests/fixtures/single-root-python/tests/test_smoke.py b/tests/fixtures/single-root-python/tests/test_smoke.py new file mode 100644 index 0000000..82eb92d --- /dev/null +++ b/tests/fixtures/single-root-python/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 + 1 == 2 diff --git a/tests/test-project-discover.sh b/tests/test-project-discover.sh new file mode 100644 index 0000000..435e37d --- /dev/null +++ b/tests/test-project-discover.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# tests/test-project-discover.sh — Validate project-root autodiscovery (Story 15.1) +# +# Verifies, against checked-in fixtures under tests/fixtures/: +# 1. discover_project_roots resolves to "." for a single-language repo with +# manifests at repo root (regression safety — Story 15.1 AC 4). +# 2. discover_project_roots finds per-language roots in a two-language +# monorepo (api/ Python + frontend/ JS) with no manifests at root. +# 3. discover_project_roots finds multiple roots for a single language +# (services/a, services/b) and runs each independently. +# 4. .devrail.yml `projects:` overrides autodetection. +# 5. A declared language with no manifest anywhere falls back to "." rather +# than being skipped (also a regression-safety guarantee). +# 6. The full `make _lint`/`make _test` recipes run each language's tools +# with the correct cwd — proving config/alias resolution, not just path +# discovery (frontend/tsconfig.json + vite.config.ts `@` alias). +# +# Usage: bash tests/test-project-discover.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}:${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${REPO_ROOT}/tests/fixtures" + +PASS=0 +FAIL=0 + +# assert_eq EXPECTED ACTUAL CONTEXT +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# discover FIXTURE LANGUAGE -> newline-separated roots (unit-level: sources +# lib/project-discover.sh directly against the fixture dir, no Makefile). +discover() { + local fixture="$1" language="$2" + docker run --rm \ + -v "${REPO_ROOT}/lib:/testlib:ro" \ + -v "${FIXTURES}/${fixture}:/workspace:ro" \ + -w /workspace \ + "$IMAGE" \ + bash -c "source /opt/devrail/lib/log.sh; source /testlib/project-discover.sh; discover_project_roots '${language}'" +} + +echo "==> Unit: single-root-python — python resolves to '.' (regression safety)" +assert_eq "." "$(discover single-root-python python)" "single-root/python" + +echo "==> Unit: monorepo-python-js — python resolves to api, javascript resolves to frontend" +assert_eq "api" "$(discover monorepo-python-js python)" "monorepo/python" +assert_eq "frontend" "$(discover monorepo-python-js javascript)" "monorepo/javascript" + +echo "==> Unit: multi-root-python — two roots for one language" +assert_eq "$(printf 'services/a\nservices/b')" "$(discover multi-root-python python)" "multi-root/python" + +echo "==> Unit: monorepo-with-override — projects: override wins over autodetection" +assert_eq "custom-py-dir" "$(discover monorepo-with-override python)" "override/python" + +echo "==> Unit: declared-lang-no-manifest — falls back to '.' rather than being skipped" +assert_eq "." "$(discover declared-lang-no-manifest python)" "no-manifest/python" + +echo "==> Integration: make _lint on monorepo-python-js — cwd + local config resolution" +LINT_OUT=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${FIXTURES}/monorepo-python-js:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _lint 2>&1) || true +LINT_SUMMARY=$(printf '%s\n' "$LINT_OUT" | grep -o '{"target":"lint".*}' | tail -1) || true +assert_eq "pass" "$(printf '%s' "$LINT_SUMMARY" | jq -r '.status')" "lint/status" +assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$LINT_SUMMARY" | jq -c '.languages')" "lint/languages-tagged-by-root" + +echo "==> Integration: make _test on monorepo-python-js — pytest cwd + vitest @ alias resolution" +TEST_OUT=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${FIXTURES}/monorepo-python-js:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _test 2>&1) || true +TEST_SUMMARY=$(printf '%s\n' "$TEST_OUT" | grep -o '{"target":"test".*}' | tail -1) || true +assert_eq "pass" "$(printf '%s' "$TEST_SUMMARY" | jq -r '.status')" "test/status" +if printf '%s\n' "$TEST_OUT" | grep -q "src/__tests__/smoke.test.ts"; then + echo "PASS [test/vitest-ran]" + PASS=$((PASS + 1)) +else + echo "FAIL [test/vitest-ran]: vitest output for smoke.test.ts not found" >&2 + FAIL=$((FAIL + 1)) +fi + +echo "==> Integration: make _lint on single-root-python — byte-identical unqualified tag (AC 4)" +SINGLE_LINT_OUT=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${FIXTURES}/single-root-python:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _lint 2>&1) || true +SINGLE_LINT_SUMMARY=$(printf '%s\n' "$SINGLE_LINT_OUT" | grep -o '{"target":"lint".*}' | tail -1) || true +assert_eq '["python"]' "$(printf '%s' "$SINGLE_LINT_SUMMARY" | jq -c '.languages')" "single-root/lint-languages-unqualified" + +echo "" +echo "===================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi From b348df5f86e9ae88fe29f7b31419f3674644e48e Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Fri, 24 Jul 2026 14:00:34 -0500 Subject: [PATCH 02/11] fix(makefile): address Story 15.1 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warn (don't silently ignore) when a projects: override path doesn't exist as a directory. - Extend tests/test-project-discover.sh with make _format/_fix/ _security integration coverage (previously only _lint/_test were automated; format/fix/security had only been checked manually). - Rewrite the test script to copy fixtures into a mktemp WORKDIR with a cleanup trap before running make targets, matching tests/test-plugin-loader.sh's convention, instead of bind-mounting tests/fixtures/ directly as a writable workspace — the direct mount let a Docker bind-mount quirk leave a stray root-owned Makefile inside tracked fixture directories. - Remove tests/fixtures/declared-lang-no-manifest/script.py, which contradicted the fixture's own "ships zero .py files" description. Story 15.1 --- lib/project-discover.sh | 4 + .../declared-lang-no-manifest/script.py | 2 - tests/test-project-discover.sh | 105 ++++++++++++++---- 3 files changed, 88 insertions(+), 23 deletions(-) delete mode 100644 tests/fixtures/declared-lang-no-manifest/script.py diff --git a/lib/project-discover.sh b/lib/project-discover.sh index 1c840f1..64ebb40 100644 --- a/lib/project-discover.sh +++ b/lib/project-discover.sh @@ -115,6 +115,10 @@ discover_project_roots() { local override override="$(_project_discover_override "${language}")" if [[ -n "${override}" ]]; then + local override_path + while IFS= read -r override_path; do + [[ -d "${override_path}" ]] || log_warn "projects: path '${override_path}' (language '${language}') does not exist in the repository" + done <<<"${override}" printf '%s\n' "${override}" return 0 fi diff --git a/tests/fixtures/declared-lang-no-manifest/script.py b/tests/fixtures/declared-lang-no-manifest/script.py deleted file mode 100644 index 4693ad3..0000000 --- a/tests/fixtures/declared-lang-no-manifest/script.py +++ /dev/null @@ -1,2 +0,0 @@ -def add(a, b): - return a + b diff --git a/tests/test-project-discover.sh b/tests/test-project-discover.sh index 435e37d..5b28b55 100644 --- a/tests/test-project-discover.sh +++ b/tests/test-project-discover.sh @@ -11,9 +11,22 @@ # 4. .devrail.yml `projects:` overrides autodetection. # 5. A declared language with no manifest anywhere falls back to "." rather # than being skipped (also a regression-safety guarantee). -# 6. The full `make _lint`/`make _test` recipes run each language's tools -# with the correct cwd — proving config/alias resolution, not just path -# discovery (frontend/tsconfig.json + vite.config.ts `@` alias). +# 6. The full `make _lint`/`make _format`/`make _fix`/`make _test`/ +# `make _security` recipes all run each language's tools with the +# correct cwd — proving config/alias resolution, not just path +# discovery (frontend/tsconfig.json + vite.config.ts `@` alias) — and +# tag per-root failures/skips (e.g. "python:api:bandit") the same way +# lint does. +# +# Fixtures are copied into a disposable $WORKDIR before any `make` target +# runs against them (never bind-mounted read-write directly) — running a +# target can leave root-owned artifacts (.ruff_cache/, __pycache__/) or, via +# a Docker bind-mount quirk, an empty placeholder file at any container path +# mounted from a host path that doesn't yet exist (e.g. mounting the real +# Makefile at /workspace/Makefile creates a stray host-side "Makefile" if +# /workspace is itself a live bind mount of a fixture that has none). +# Mounting a throwaway copy, cleaned up via trap, keeps tests/fixtures/ +# clean regardless. Matches the tests/test-plugin-loader.sh convention. # # Usage: bash tests/test-project-discover.sh # Env: @@ -25,6 +38,16 @@ set -euo pipefail IMAGE="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}:${DEVRAIL_TAG:-local}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FIXTURES="${REPO_ROOT}/tests/fixtures" +WORKDIR="$(mktemp -d)" + +cleanup() { + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "$IMAGE" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT PASS=0 FAIL=0 @@ -41,6 +64,18 @@ assert_eq() { fi } +# workspace_for FIXTURE -> path to a disposable copy of the fixture under +# $WORKDIR, created on first use and reused for subsequent calls with the +# same fixture name within this run. +workspace_for() { + local fixture="$1" + local dest="${WORKDIR}/${fixture}" + if [ ! -d "$dest" ]; then + cp -R "${FIXTURES}/${fixture}" "$dest" + fi + printf '%s' "$dest" +} + # discover FIXTURE LANGUAGE -> newline-separated roots (unit-level: sources # lib/project-discover.sh directly against the fixture dir, no Makefile). discover() { @@ -53,6 +88,25 @@ discover() { bash -c "source /opt/devrail/lib/log.sh; source /testlib/project-discover.sh; discover_project_roots '${language}'" } +# run_target FIXTURE MAKE-TARGET -> the JSON summary line for that target +# (e.g. '{"target":"format","status":"pass",...}'). Runs against a disposable +# copy of the fixture with the real Makefile mounted in, matching the +# tests/test-plugin-loader.sh convention. +run_target() { + local fixture="$1" target="$2" + local ws + ws="$(workspace_for "$fixture")" + local out + out=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${ws}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make "_${target}" 2>&1) || true + printf '%s\n' "$out" | grep -o "{\"target\":\"${target}\".*}" | tail -1 || true +} + echo "==> Unit: single-root-python — python resolves to '.' (regression safety)" assert_eq "." "$(discover single-root-python python)" "single-root/python" @@ -70,21 +124,31 @@ echo "==> Unit: declared-lang-no-manifest — falls back to '.' rather than bein assert_eq "." "$(discover declared-lang-no-manifest python)" "no-manifest/python" echo "==> Integration: make _lint on monorepo-python-js — cwd + local config resolution" -LINT_OUT=$(docker run --rm \ - -e DEVRAIL_LOG_FORMAT=json \ - -v "${FIXTURES}/monorepo-python-js:/workspace" \ - -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ - -w /workspace \ - "$IMAGE" \ - make _lint 2>&1) || true -LINT_SUMMARY=$(printf '%s\n' "$LINT_OUT" | grep -o '{"target":"lint".*}' | tail -1) || true +LINT_SUMMARY=$(run_target monorepo-python-js lint) assert_eq "pass" "$(printf '%s' "$LINT_SUMMARY" | jq -r '.status')" "lint/status" assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$LINT_SUMMARY" | jq -c '.languages')" "lint/languages-tagged-by-root" +echo "==> Integration: make _format on monorepo-python-js — cwd-scoped format check" +FORMAT_SUMMARY=$(run_target monorepo-python-js format) +assert_eq "pass" "$(printf '%s' "$FORMAT_SUMMARY" | jq -r '.status')" "format/status" +assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$FORMAT_SUMMARY" | jq -c '.languages')" "format/languages-tagged-by-root" + +echo "==> Integration: make _fix on monorepo-python-js — cwd-scoped autofix" +FIX_SUMMARY=$(run_target monorepo-python-js fix) +assert_eq "pass" "$(printf '%s' "$FIX_SUMMARY" | jq -r '.status')" "fix/status" +assert_eq '["python:api","javascript:frontend"]' "$(printf '%s' "$FIX_SUMMARY" | jq -c '.languages')" "fix/languages-tagged-by-root" + +echo "==> Integration: make _security on monorepo-python-js — per-root failure/skip tagging" +SECURITY_SUMMARY=$(run_target monorepo-python-js security) +assert_eq "fail" "$(printf '%s' "$SECURITY_SUMMARY" | jq -r '.status')" "security/status" +assert_eq '["python:api:bandit"]' "$(printf '%s' "$SECURITY_SUMMARY" | jq -c '.failed')" "security/failed-tagged-by-root" +assert_eq '["javascript:frontend"]' "$(printf '%s' "$SECURITY_SUMMARY" | jq -c '.skipped')" "security/skipped-tagged-by-root" + echo "==> Integration: make _test on monorepo-python-js — pytest cwd + vitest @ alias resolution" +TEST_WS="$(workspace_for monorepo-python-js)" TEST_OUT=$(docker run --rm \ -e DEVRAIL_LOG_FORMAT=json \ - -v "${FIXTURES}/monorepo-python-js:/workspace" \ + -v "${TEST_WS}:/workspace" \ -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ -w /workspace \ "$IMAGE" \ @@ -99,17 +163,16 @@ else FAIL=$((FAIL + 1)) fi -echo "==> Integration: make _lint on single-root-python — byte-identical unqualified tag (AC 4)" -SINGLE_LINT_OUT=$(docker run --rm \ - -e DEVRAIL_LOG_FORMAT=json \ - -v "${FIXTURES}/single-root-python:/workspace" \ - -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ - -w /workspace \ - "$IMAGE" \ - make _lint 2>&1) || true -SINGLE_LINT_SUMMARY=$(printf '%s\n' "$SINGLE_LINT_OUT" | grep -o '{"target":"lint".*}' | tail -1) || true +echo "==> Integration: single-root-python — byte-identical unqualified tags across lint/format/fix/security (AC 4)" +SINGLE_LINT_SUMMARY=$(run_target single-root-python lint) assert_eq '["python"]' "$(printf '%s' "$SINGLE_LINT_SUMMARY" | jq -c '.languages')" "single-root/lint-languages-unqualified" +SINGLE_FORMAT_SUMMARY=$(run_target single-root-python format) +assert_eq '["python"]' "$(printf '%s' "$SINGLE_FORMAT_SUMMARY" | jq -c '.languages')" "single-root/format-languages-unqualified" + +SINGLE_SECURITY_SUMMARY=$(run_target single-root-python security) +assert_eq '["python:bandit"]' "$(printf '%s' "$SINGLE_SECURITY_SUMMARY" | jq -c '.failed')" "single-root/security-failed-unqualified" + echo "" echo "===================================" echo "Results: ${PASS} passed, ${FAIL} failed" From dc0479eb6f5b5375a8d06640415a92c3572f19bc Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Sat, 25 Jul 2026 21:19:03 -0500 Subject: [PATCH 03/11] feat(makefile): install project dependencies before make test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make test now autodetects and installs a project's own dependencies before pytest/vitest run, so tests no longer fail at import time with ModuleNotFoundError/unresolved-import errors (issue #52). Python: uv.lock -> uv export | uv pip install --system; else requirements*.txt -> pip install -r; else pyproject.toml/setup.py -> pip install -e . (all system-installed, --break-system-packages — not uv sync, whose isolated venv the globally-installed pytest can't see into; caught by hand during implementation). JavaScript: package-lock.json -> npm ci. New lib/dependency-install.sh (sourced helper, mirrors Story 15.1's lib/project-discover.sh). New .devrail.yml test.install/test.setup overrides. Container image now ships uv (one line in install-python.sh — uv is PyPI-distributed, no new Dockerfile stage). A failed install fails make test immediately — the test suite never runs against a broken install (a local rc=$? placement bug initially let failures through silently; fixed by moving the exit-code capture into an explicit else branch and verified with a dedicated failing-install fixture). Scoped to uv+pip (Python) and npm (JS) per issue #52's literal FastAPI+Vue reproduction case; poetry/pipenv/pnpm/yarn are not installed in the container and are explicitly deferred. Closes #52 Story 15.2 --- .github/workflows/ci.yml | 13 ++ CHANGELOG.md | 11 ++ Makefile | 13 +- STABILITY.md | 3 +- lib/dependency-install.sh | 162 ++++++++++++++++++ scripts/install-python.sh | 4 +- tests/fixtures/js-npm-deps/.devrail.yml | 2 + .../js-npm-deps/__tests__/smoke.test.js | 8 + tests/fixtures/js-npm-deps/package-lock.json | 21 +++ tests/fixtures/js-npm-deps/package.json | 8 + .../python-install-fails/.devrail.yml | 2 + .../python-install-fails/requirements.txt | 1 + .../python-install-fails/tests/test_smoke.py | 2 + .../python-pyproject-only/.devrail.yml | 2 + .../python-pyproject-only/pyproject.toml | 9 + .../python-pyproject-only/tests/test_smoke.py | 4 + .../python-requirements-deps/.devrail.yml | 2 + .../python-requirements-deps/requirements.txt | 1 + .../tests/test_smoke.py | 4 + tests/fixtures/python-uv-deps/.devrail.yml | 2 + tests/fixtures/python-uv-deps/pyproject.toml | 5 + .../python-uv-deps/tests/test_smoke.py | 4 + tests/fixtures/python-uv-deps/uv.lock | 23 +++ .../test-install-override/.devrail.yml | 4 + .../test-install-override/requirements.txt | 1 + .../test-install-override/tests/test_smoke.py | 4 + .../fixtures/test-setup-ordering/.devrail.yml | 4 + .../test-setup-ordering/tests/test_smoke.py | 5 + tests/test-dependency-install.sh | 142 +++++++++++++++ tests/test-python.sh | 3 +- 30 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 lib/dependency-install.sh create mode 100644 tests/fixtures/js-npm-deps/.devrail.yml create mode 100644 tests/fixtures/js-npm-deps/__tests__/smoke.test.js create mode 100644 tests/fixtures/js-npm-deps/package-lock.json create mode 100644 tests/fixtures/js-npm-deps/package.json create mode 100644 tests/fixtures/python-install-fails/.devrail.yml create mode 100644 tests/fixtures/python-install-fails/requirements.txt create mode 100644 tests/fixtures/python-install-fails/tests/test_smoke.py create mode 100644 tests/fixtures/python-pyproject-only/.devrail.yml create mode 100644 tests/fixtures/python-pyproject-only/pyproject.toml create mode 100644 tests/fixtures/python-pyproject-only/tests/test_smoke.py create mode 100644 tests/fixtures/python-requirements-deps/.devrail.yml create mode 100644 tests/fixtures/python-requirements-deps/requirements.txt create mode 100644 tests/fixtures/python-requirements-deps/tests/test_smoke.py create mode 100644 tests/fixtures/python-uv-deps/.devrail.yml create mode 100644 tests/fixtures/python-uv-deps/pyproject.toml create mode 100644 tests/fixtures/python-uv-deps/tests/test_smoke.py create mode 100644 tests/fixtures/python-uv-deps/uv.lock create mode 100644 tests/fixtures/test-install-override/.devrail.yml create mode 100644 tests/fixtures/test-install-override/requirements.txt create mode 100644 tests/fixtures/test-install-override/tests/test_smoke.py create mode 100644 tests/fixtures/test-setup-ordering/.devrail.yml create mode 100644 tests/fixtures/test-setup-ordering/tests/test_smoke.py create mode 100644 tests/test-dependency-install.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7882fff..6a12c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,19 @@ jobs: DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Story 15.2: Dependency-install-before-test smoke test (issue #52) + # Verifies lib/dependency-install.sh: uv.lock/requirements.txt/ + # pyproject.toml (Python) and package-lock.json (JS) autodetection + # with REAL network installs, test.install/test.setup overrides, a + # failed install failing make test fast (no partial-install test + # runs), and the no-manifest regression case. Requires network + # egress to PyPI/npm — this runner has it. + - name: Dependency install smoke test + run: bash tests/test-dependency-install.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Phase 2e: Plugin resolver + lockfile smoke test (Story 13.3) # Drives `make plugins-update` against a local-filesystem git fixture # (no network) covering: SHA passthrough, tag→SHA, branch rejection, diff --git a/CHANGELOG.md b/CHANGELOG.md index 565b234..f02f3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `.devrail.yml` `projects:` override for layouts autodetection can't infer. Single-root projects (the common case) are unaffected — output is byte-identical to previous versions. +- **Issue #52 (Story 15.2):** `make test` now installs a project's own + dependencies before running `pytest`/`vitest`, so tests no longer fail + at import time with `ModuleNotFoundError`/unresolved-import errors. + Autodetects `uv.lock` (`uv export | uv pip install --system`), + `requirements*.txt` (`pip install -r`), or `pyproject.toml`/`setup.py` + (`pip install -e .`) for Python, and `package-lock.json` (`npm ci`) for + JS/TS, in each project root discovered by Story 15.1. New + `lib/dependency-install.sh` helper; new `.devrail.yml` `test.install`/ + `test.setup` overrides. Container image now ships `uv`. A failed install + fails `make test` immediately — the test suite never runs against a + broken install. Projects with no lockfile/manifest are unaffected. ## [1.12.0] - 2026-05-30 diff --git a/Makefile b/Makefile index a29757c..99f238b 100644 --- a/Makefile +++ b/Makefile @@ -966,6 +966,7 @@ _fix: _plugins-load _test: _plugins-load @. /opt/devrail/lib/plugin-execute.sh; \ . "$${DEVRAIL_LIB:-/opt/devrail/lib}/project-discover.sh"; \ + . "$${DEVRAIL_LIB:-/opt/devrail/lib}/dependency-install.sh"; \ start_time=$$(date +%s%3N); \ overall_exit=0; \ ran_languages=""; \ @@ -974,7 +975,11 @@ _test: _plugins-load if [ -n "$(HAS_PYTHON)" ]; then \ while IFS= read -r py_root; do \ if [ "$$py_root" = "." ]; then py_tag="python"; else py_tag="python:$$py_root"; fi; \ - if [ -d "$$py_root/tests" ] || find "$$py_root" -name '*_test.py' -o -name 'test_*.py' 2>/dev/null | grep -q .; then \ + if ! install_project_deps python "$$py_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:install\","; \ + elif ! run_project_setup "$$py_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag:setup\","; \ + elif [ -d "$$py_root/tests" ] || find "$$py_root" -name '*_test.py' -o -name 'test_*.py' 2>/dev/null | grep -q .; then \ ran_languages="$${ran_languages}\"$$py_tag\","; \ (cd "$$py_root" && pytest) || { overall_exit=1; failed_languages="$${failed_languages}\"$$py_tag\","; }; \ else \ @@ -1078,7 +1083,11 @@ _test: _plugins-load if [ -n "$(HAS_JAVASCRIPT)" ]; then \ while IFS= read -r js_root; do \ if [ "$$js_root" = "." ]; then js_tag="javascript"; else js_tag="javascript:$$js_root"; fi; \ - if find "$$js_root" \( -name '*.test.*' -o -name '*.spec.*' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null | grep -q .; then \ + if ! install_project_deps javascript "$$js_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:install\","; \ + elif ! run_project_setup "$$js_root"; then \ + overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag:setup\","; \ + elif find "$$js_root" \( -name '*.test.*' -o -name '*.spec.*' \) -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' -not -path '*/dist/*' -not -path '*/build/*' 2>/dev/null | grep -q .; then \ ran_languages="$${ran_languages}\"$$js_tag\","; \ (cd "$$js_root" && vitest run) || { overall_exit=1; failed_languages="$${failed_languages}\"$$js_tag\","; }; \ else \ diff --git a/STABILITY.md b/STABILITY.md index fbd09f8..c16dcb4 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -32,7 +32,8 @@ DevRail has reached **v1.0** across all repositories. The core standards, toolch | **Pre-commit hooks** | Stable | Conventional commit hook and per-language hooks configured in template repos. | | **Documentation site** | Stable | [devrail.dev](https://devrail.dev) is live with full standards coverage. | | **Plugin loader + resolver + lockfile + build pipeline + execution loop** | Stable (v1.10.x baseline; reference plugin v1.11.x) | Validates `plugin.devrail.yml` manifests, resolves `rev:` to immutable SHAs (`make plugins-update`), records reproducibility metadata in `.devrail.lock`, and auto-builds a project-local extended image (`devrail-local:`) when plugins are declared. Each loaded plugin's `targets` are dispatched inside `_lint`/`_format`/`_fix`/`_test`/`_security` with gate evaluation, `{paths}` interpolation, per-language overrides, and JSON aggregation into the existing event shape. `DEVRAIL_FAIL_FAST=1` short-circuits on plugin failures the same as core. No-op when `plugins:` is absent — v1.9.x behaviour unchanged. **As of v1.11.0** the first reference plugin ([`devrail-plugin-kotlin`](https://github.com/devrail-dev/devrail-plugin-kotlin)) is published; the extraction is **additive** through the v1.x line (Kotlin remains in core for back-compat). v2.0.0 retires the in-core HAS_ blocks. | -| **Monorepo project-root discovery (Python/JS)** | Preview (Story 15.1) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Go/Rust/Ansible root-awareness and dependency-install-before-test (issue #52) are tracked as follow-on stories (15.2–15.4). | +| **Monorepo project-root discovery (Python/JS)** | Preview (Story 15.1) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Go/Rust/Ansible root-awareness is tracked as a follow-on story (15.3). | +| **Dependency install before `make test` (Python/JS)** | Preview (Story 15.2) | `lib/dependency-install.sh` autodetects and installs a project's dependencies (`uv`/`pip` for Python — container now ships `uv`; `npm` for JS/TS) in each root discovered above, before `pytest`/`vitest` run. Optional `.devrail.yml` `test.install`/`test.setup` overrides. A failed install fails `make test` immediately. `poetry`/`pipenv`/`pnpm`/`yarn` are not yet supported (not installed in the container); `test.services` (ephemeral DB/cache containers) is not yet implemented — both tracked as follow-on stories (15.3–15.4). | ## Consumer responsibilities diff --git a/lib/dependency-install.sh b/lib/dependency-install.sh new file mode 100644 index 0000000..66c5513 --- /dev/null +++ b/lib/dependency-install.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# lib/dependency-install.sh — Dependency installation before `make test` (Story 15.2) +# +# Purpose: Autodetect and install a project's own dependencies before +# pytest/vitest run, so tests don't fail at import time with +# ModuleNotFoundError / unresolved-import errors. Closes issue #52. +# +# Usage: source "${DEVRAIL_LIB}/dependency-install.sh" +# install_project_deps python "$root" || overall_exit=1 +# run_project_setup "$root" || overall_exit=1 +# +# Contract: +# - install_project_deps : installs dependencies for +# in project root (a path relative to the repo root +# the caller is already cwd'd in — the function cd's into it itself). +# Returns the real exit code of the install command. 0 means either +# success or "nothing to install" (no lockfile/manifest found) — never +# swallows a real failure. +# - `.devrail.yml` `test.install` (a shell command string) overrides +# autodetection entirely when present — language-agnostic, applies to +# every root of every declared language. +# - Autodetection (only when no `test.install` override): +# python: uv.lock present -> `uv sync --frozen` +# requirements*.txt present -> `pip install -r ` +# (sorted, first match) +# pyproject.toml/setup.py -> `pip install -e .` +# none of the above -> no-op +# javascript: package-lock.json present -> `npm ci` +# none -> no-op +# Only uv/pip/npm are supported — poetry/pipenv/pnpm/yarn are not +# installed in the container (Story 15.3+ follow-up); their lockfiles +# are intentionally NOT detected here (see Dev Notes in Story 15.2). +# - run_project_setup : runs `.devrail.yml` `test.setup` (a shell +# command string) if present, after a successful install. No-op (0) if +# absent. Also language-agnostic. +# +# Dependencies: lib/log.sh (log_event), yq (v4+), bash 5+, uv, pip, npm + +# Guard against double-sourcing +# shellcheck disable=SC2317 +if [[ -n "${_DEVRAIL_DEPENDENCY_INSTALL_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +readonly _DEVRAIL_DEPENDENCY_INSTALL_LOADED=1 + +# Resolved to an ABSOLUTE path at source time (before any caller `cd`s into +# a project root) — install_project_deps/run_project_setup both cd around +# per-root, and .devrail.yml always lives at the repo root regardless of +# which project root is currently active. +_DEPENDENCY_INSTALL_CONFIG="$(pwd)/${DEVRAIL_CONFIG:-.devrail.yml}" + +# _dependency_install_config_value emits the string value at the +# given yq path in .devrail.yml, or empty if missing/absent/unreadable. +_dependency_install_config_value() { + local yq_path="$1" + [[ -r "${_DEPENDENCY_INSTALL_CONFIG}" ]] || return 0 + yq -r "${yq_path} // \"\"" "${_DEPENDENCY_INSTALL_CONFIG}" 2>/dev/null +} + +# _dependency_install_autodetect_python emits the install command to run +# from within the current directory (assumed to already be the project +# root), or emits nothing when there's no recognized manifest/lockfile. +# +# Installs land in the container's SYSTEM Python site-packages, not an +# isolated venv — this container's model is "tools are installed once, +# globally" (pytest et al. already live in system site-packages), so a +# project dependency has to land there too or the globally-installed +# `pytest` binary (invoked bare, not via any project-local wrapper) will +# never see it. Concretely this means NOT `uv sync` (which creates and +# populates an isolated `.venv/` that bare `pytest` cannot see at all — +# confirmed by hand during Story 15.2 implementation: `uv sync --frozen` +# followed by bare `pytest` still raised ModuleNotFoundError). Instead, +# `uv export` converts the lockfile to a requirements list and `uv pip +# install --system` installs it system-wide, mirroring how the +# requirements.txt/pyproject.toml paths already work below. Both `uv` and +# `pip` need `--break-system-packages` on this container's Debian/PEP 668 +# "externally managed" Python — same flag `scripts/install-python.sh` +# already uses to install the tools themselves. +_dependency_install_autodetect_python() { + if [[ -f "uv.lock" ]]; then + printf 'uv export --frozen --no-hashes --format requirements-txt | uv pip install --system --break-system-packages -r -' + elif compgen -G "requirements*.txt" >/dev/null 2>&1; then + local req_file + req_file="$(compgen -G "requirements*.txt" | sort | head -1)" + printf 'pip install --break-system-packages -r %q' "${req_file}" + elif [[ -f "pyproject.toml" || -f "setup.py" ]]; then + printf 'pip install --break-system-packages -e .' + fi +} + +# _dependency_install_autodetect_javascript emits the install command, or +# nothing when there's no package-lock.json. +_dependency_install_autodetect_javascript() { + if [[ -f "package-lock.json" ]]; then + printf 'npm ci' + fi +} + +# install_project_deps installs dependencies for the +# given language in the given project root. Returns the install command's +# real exit code; 0 (no-op) when there's nothing to install. +install_project_deps() { + local language="${1:?install_project_deps requires a language}" + local root="${2:?install_project_deps requires a root}" + + local override + override="$(_dependency_install_config_value '.test.install')" + + local cmd + if [[ -n "${override}" ]]; then + cmd="${override}" + else + case "${language}" in + python) cmd="$(cd "${root}" && _dependency_install_autodetect_python)" ;; + javascript) cmd="$(cd "${root}" && _dependency_install_autodetect_javascript)" ;; + *) cmd="" ;; + esac + fi + + if [[ -z "${cmd}" ]]; then + return 0 + fi + + log_event info "installing project dependencies" language="${language}" root="${root}" cmd="${cmd}" + # Capture the real exit code inside an explicit `else` — falling through + # past a bare `if ...; then return 0; fi` looks equivalent but is NOT: + # per POSIX, an `if` with no `else` branch taken exits 0 regardless of + # the condition's own status, so `$?` read after `fi` is always 0, not + # the failed command's code. Confirmed by hand during Story 15.2 + # implementation — the original bare-`fi` version silently returned 0 + # on every install failure, so `make test` ran pytest against a broken + # install instead of failing fast (AC 8). + if (cd "${root}" && bash -c "${cmd}"); then + return 0 + else + local rc=$? + log_event error "dependency install failed" language="${language}" root="${root}" cmd="${cmd}" + return "${rc}" + fi +} + +# run_project_setup runs .devrail.yml `test.setup` (if configured) +# in the given root. No-op (0) if absent. +run_project_setup() { + local root="${1:?run_project_setup requires a root}" + + local setup_cmd + setup_cmd="$(_dependency_install_config_value '.test.setup')" + + if [[ -z "${setup_cmd}" ]]; then + return 0 + fi + + log_event info "running test setup" root="${root}" cmd="${setup_cmd}" + if (cd "${root}" && bash -c "${setup_cmd}"); then + return 0 + else + local rc=$? + log_event error "test setup failed" root="${root}" cmd="${setup_cmd}" + return "${rc}" + fi +} diff --git a/scripts/install-python.sh b/scripts/install-python.sh index 6f9050e..e7a5335 100644 --- a/scripts/install-python.sh +++ b/scripts/install-python.sh @@ -12,6 +12,7 @@ # - semgrep (Multi-language SAST) # - pytest (Python test framework) # - mypy (Python static type checker) +# - uv (Python package/dependency manager — Story 15.2) set -euo pipefail @@ -28,7 +29,7 @@ source "${DEVRAIL_LIB}/platform.sh" if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then log_info "install-python.sh — Install Python tooling for DevRail" log_info "Usage: bash scripts/install-python.sh [--help]" - log_info "Tools: ruff, bandit, semgrep, pytest, mypy" + log_info "Tools: ruff, bandit, semgrep, pytest, mypy, uv" exit 0 fi @@ -65,6 +66,7 @@ readonly PYTHON_TOOLS=( "semgrep" "pytest" "mypy" + "uv" ) for tool in "${PYTHON_TOOLS[@]}"; do diff --git a/tests/fixtures/js-npm-deps/.devrail.yml b/tests/fixtures/js-npm-deps/.devrail.yml new file mode 100644 index 0000000..ad7f03b --- /dev/null +++ b/tests/fixtures/js-npm-deps/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - javascript diff --git a/tests/fixtures/js-npm-deps/__tests__/smoke.test.js b/tests/fixtures/js-npm-deps/__tests__/smoke.test.js new file mode 100644 index 0000000..84ee1ac --- /dev/null +++ b/tests/fixtures/js-npm-deps/__tests__/smoke.test.js @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import ms from "ms"; + +describe("ms dependency", () => { + it("is installed and importable", () => { + expect(ms("2 days")).toBe(172800000); + }); +}); diff --git a/tests/fixtures/js-npm-deps/package-lock.json b/tests/fixtures/js-npm-deps/package-lock.json new file mode 100644 index 0000000..7c8b9a3 --- /dev/null +++ b/tests/fixtures/js-npm-deps/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "npm-deps-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm-deps-demo", + "version": "0.1.0", + "dependencies": { + "ms": "2.1.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } +} diff --git a/tests/fixtures/js-npm-deps/package.json b/tests/fixtures/js-npm-deps/package.json new file mode 100644 index 0000000..b38662a --- /dev/null +++ b/tests/fixtures/js-npm-deps/package.json @@ -0,0 +1,8 @@ +{ + "name": "npm-deps-demo", + "version": "0.1.0", + "type": "module", + "dependencies": { + "ms": "2.1.3" + } +} diff --git a/tests/fixtures/python-install-fails/.devrail.yml b/tests/fixtures/python-install-fails/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-install-fails/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-install-fails/requirements.txt b/tests/fixtures/python-install-fails/requirements.txt new file mode 100644 index 0000000..861a8bd --- /dev/null +++ b/tests/fixtures/python-install-fails/requirements.txt @@ -0,0 +1 @@ +this-package-definitely-does-not-exist-anywhere==99.99.99 diff --git a/tests/fixtures/python-install-fails/tests/test_smoke.py b/tests/fixtures/python-install-fails/tests/test_smoke.py new file mode 100644 index 0000000..6370b5d --- /dev/null +++ b/tests/fixtures/python-install-fails/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_should_never_run(): + raise AssertionError("pytest ran despite a failed dependency install — AC 8 violated") diff --git a/tests/fixtures/python-pyproject-only/.devrail.yml b/tests/fixtures/python-pyproject-only/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-pyproject-only/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-pyproject-only/pyproject.toml b/tests/fixtures/python-pyproject-only/pyproject.toml new file mode 100644 index 0000000..6acbf9f --- /dev/null +++ b/tests/fixtures/python-pyproject-only/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "pyproject-only-demo" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["inflection"] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" diff --git a/tests/fixtures/python-pyproject-only/tests/test_smoke.py b/tests/fixtures/python-pyproject-only/tests/test_smoke.py new file mode 100644 index 0000000..36a4afe --- /dev/null +++ b/tests/fixtures/python-pyproject-only/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_dependency_is_importable(): + import inflection + + assert inflection.underscore("HelloWorld") == "hello_world" diff --git a/tests/fixtures/python-requirements-deps/.devrail.yml b/tests/fixtures/python-requirements-deps/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-requirements-deps/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-requirements-deps/requirements.txt b/tests/fixtures/python-requirements-deps/requirements.txt new file mode 100644 index 0000000..bc2ebfe --- /dev/null +++ b/tests/fixtures/python-requirements-deps/requirements.txt @@ -0,0 +1 @@ +humanize==4.11.0 diff --git a/tests/fixtures/python-requirements-deps/tests/test_smoke.py b/tests/fixtures/python-requirements-deps/tests/test_smoke.py new file mode 100644 index 0000000..404e642 --- /dev/null +++ b/tests/fixtures/python-requirements-deps/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_dependency_is_importable(): + import humanize + + assert humanize.naturalsize(1000000) == "1.0 MB" diff --git a/tests/fixtures/python-uv-deps/.devrail.yml b/tests/fixtures/python-uv-deps/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-uv-deps/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-uv-deps/pyproject.toml b/tests/fixtures/python-uv-deps/pyproject.toml new file mode 100644 index 0000000..3a7525d --- /dev/null +++ b/tests/fixtures/python-uv-deps/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "uv-deps-demo" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["first"] diff --git a/tests/fixtures/python-uv-deps/tests/test_smoke.py b/tests/fixtures/python-uv-deps/tests/test_smoke.py new file mode 100644 index 0000000..e76f203 --- /dev/null +++ b/tests/fixtures/python-uv-deps/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_dependency_is_importable(): + from first import first + + assert first([0, False, None, 3, 4]) == 3 diff --git a/tests/fixtures/python-uv-deps/uv.lock b/tests/fixtures/python-uv-deps/uv.lock new file mode 100644 index 0000000..c170ea0 --- /dev/null +++ b/tests/fixtures/python-uv-deps/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "first" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/a2/78a4e6801fbd789c60888afb8e28ccbe629f9a25137bfafecb363db2fb53/first-2.0.2.tar.gz", hash = "sha256:ff285b08c55f8c97ce4ea7012743af2495c9f1291785f163722bd36f6af6d3bf", size = 6964, upload-time = "2019-03-07T10:07:28.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/62/eda58db762d4845c971029becfecf6e85ad71c3bcba7d400598013a222a1/first-2.0.2-py2.py3-none-any.whl", hash = "sha256:8d8e46e115ea8ac652c76123c0865e3ff18372aef6f03c22809ceefcea9dec86", size = 5359, upload-time = "2019-03-07T10:07:26.535Z" }, +] + +[[package]] +name = "uv-deps-demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "first" }, +] + +[package.metadata] +requires-dist = [{ name = "first" }] diff --git a/tests/fixtures/test-install-override/.devrail.yml b/tests/fixtures/test-install-override/.devrail.yml new file mode 100644 index 0000000..2fd8ffd --- /dev/null +++ b/tests/fixtures/test-install-override/.devrail.yml @@ -0,0 +1,4 @@ +languages: + - python +test: + install: "pip install --break-system-packages first" diff --git a/tests/fixtures/test-install-override/requirements.txt b/tests/fixtures/test-install-override/requirements.txt new file mode 100644 index 0000000..861a8bd --- /dev/null +++ b/tests/fixtures/test-install-override/requirements.txt @@ -0,0 +1 @@ +this-package-definitely-does-not-exist-anywhere==99.99.99 diff --git a/tests/fixtures/test-install-override/tests/test_smoke.py b/tests/fixtures/test-install-override/tests/test_smoke.py new file mode 100644 index 0000000..5622ea0 --- /dev/null +++ b/tests/fixtures/test-install-override/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_override_was_used_not_autodetected_requirements_txt(): + from first import first + + assert first([0, None, 5]) == 5 diff --git a/tests/fixtures/test-setup-ordering/.devrail.yml b/tests/fixtures/test-setup-ordering/.devrail.yml new file mode 100644 index 0000000..fc94462 --- /dev/null +++ b/tests/fixtures/test-setup-ordering/.devrail.yml @@ -0,0 +1,4 @@ +languages: + - python +test: + setup: "touch setup-ran.marker" diff --git a/tests/fixtures/test-setup-ordering/tests/test_smoke.py b/tests/fixtures/test-setup-ordering/tests/test_smoke.py new file mode 100644 index 0000000..f7bcf7d --- /dev/null +++ b/tests/fixtures/test-setup-ordering/tests/test_smoke.py @@ -0,0 +1,5 @@ +import os + + +def test_setup_ran_before_tests(): + assert os.path.exists("setup-ran.marker") diff --git a/tests/test-dependency-install.sh b/tests/test-dependency-install.sh new file mode 100644 index 0000000..97bfc02 --- /dev/null +++ b/tests/test-dependency-install.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# tests/test-dependency-install.sh — Validate dependency install before `make test` (Story 15.2) +# +# Verifies, against checked-in fixtures under tests/fixtures/, that `make +# _test` installs a project's own dependencies before pytest/vitest run: +# 1. uv.lock -> `uv export | uv pip install --system` (Python) +# 2. requirements*.txt -> `pip install -r ` (Python) +# 3. pyproject.toml only -> `pip install -e .` (Python) +# 4. package-lock.json -> `npm ci` (JS) +# 5. `.devrail.yml` test.install overrides autodetection +# 6. `.devrail.yml` test.setup runs after install, before the test suite +# 7. A failed install fails `make test` fast — the test suite never runs +# against a broken/partial install (AC 8) +# 8. A project with no lockfile/manifest for a declared language is +# unaffected — no install step runs (AC 7, regression safety; reuses +# Story 15.1's declared-lang-no-manifest fixture) +# +# Every installing case here does a REAL network install against PyPI/npm +# inside the container — this is intentional, not an oversight. The whole +# point of this story is that a real install unblocks a real import; a +# mocked network call would prove nothing about the actual bug in issue +# #52. Requires network egress (present in CI; present in any normal dev +# environment). +# +# Fixtures are copied into a disposable $WORKDIR before any `make` target +# runs against them, never bind-mounted read-write directly — an install +# step creates root-owned artifacts (site-packages entries, node_modules/), +# and Story 15.1's own test script had to be rewritten once already after +# a Docker bind-mount quirk leaked a stray file into tracked fixtures from +# doing exactly that. See tests/test-project-discover.sh for the same +# pattern. +# +# Usage: bash tests/test-dependency-install.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}:${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${REPO_ROOT}/tests/fixtures" +WORKDIR="$(mktemp -d)" + +cleanup() { + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "$IMAGE" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +PASS=0 +FAIL=0 + +# assert_eq EXPECTED ACTUAL CONTEXT +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# run_test FIXTURE -> the JSON summary line for `make _test` against a +# disposable copy of the fixture, with the real Makefile mounted in. +run_test() { + local fixture="$1" + local ws="${WORKDIR}/${fixture}" + cp -R "${FIXTURES}/${fixture}" "$ws" + local out + out=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${ws}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _test 2>&1) || true + printf '%s\n' "$out" | grep -o '{"target":"test".*}' | tail -1 || true +} + +echo "==> python-uv-deps: uv.lock -> uv export | uv pip install --system" +SUMMARY=$(run_test python-uv-deps) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "uv-deps/status" + +echo "==> python-requirements-deps: requirements.txt -> pip install -r" +SUMMARY=$(run_test python-requirements-deps) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "requirements-deps/status" + +echo "==> python-pyproject-only: pyproject.toml only -> pip install -e ." +SUMMARY=$(run_test python-pyproject-only) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "pyproject-only/status" + +echo "==> js-npm-deps: package-lock.json -> npm ci" +SUMMARY=$(run_test js-npm-deps) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "npm-deps/status" + +echo "==> test-install-override: test.install wins over a requirements.txt that would otherwise fail" +SUMMARY=$(run_test test-install-override) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "install-override/status" + +echo "==> test-setup-ordering: test.setup runs after install (no-op here), before the test suite" +SUMMARY=$(run_test test-setup-ordering) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "setup-ordering/status" + +echo "==> python-install-fails: a broken install fails fast — pytest must never run (AC 8)" +INSTALL_FAILS_OUT_WS="${WORKDIR}/python-install-fails-raw" +cp -R "${FIXTURES}/python-install-fails" "$INSTALL_FAILS_OUT_WS" +RAW_OUT=$(docker run --rm \ + -e DEVRAIL_LOG_FORMAT=json \ + -v "${INSTALL_FAILS_OUT_WS}:/workspace" \ + -v "${REPO_ROOT}/Makefile:/workspace/Makefile:ro" \ + -w /workspace \ + "$IMAGE" \ + make _test 2>&1) || true +SUMMARY=$(printf '%s\n' "$RAW_OUT" | grep -o '{"target":"test".*}' | tail -1) || true +assert_eq "fail" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "install-fails/status" +assert_eq '["python:install"]' "$(printf '%s' "$SUMMARY" | jq -c '.failed')" "install-fails/failed-tag" +if printf '%s\n' "$RAW_OUT" | grep -q "pytest ran despite a failed dependency install"; then + echo "FAIL [install-fails/pytest-did-not-run]: pytest executed the test suite despite a failed install" >&2 + FAIL=$((FAIL + 1)) +else + echo "PASS [install-fails/pytest-did-not-run]" + PASS=$((PASS + 1)) +fi + +echo "==> declared-lang-no-manifest (Story 15.1 fixture): no lockfile/manifest -> no install attempted, unaffected" +SUMMARY=$(run_test declared-lang-no-manifest) +assert_eq "skip" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "no-manifest/status" + +echo "" +echo "===================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/tests/test-python.sh b/tests/test-python.sh index 0ae4825..ce11ac3 100644 --- a/tests/test-python.sh +++ b/tests/test-python.sh @@ -18,7 +18,7 @@ source "${DEVRAIL_LIB}/log.sh" if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then log_info "test-python.sh — Validate Python tooling installation" log_info "Usage: bash tests/test-python.sh [--help]" - log_info "Checks: ruff, bandit, semgrep, pytest, mypy" + log_info "Checks: ruff, bandit, semgrep, pytest, mypy, uv" exit 0 fi @@ -52,6 +52,7 @@ check_tool "bandit" "--version" check_tool "semgrep" "--version" check_tool "pytest" "--version" check_tool "mypy" "--version" +check_tool "uv" "--version" if [[ "${FAILURES}" -gt 0 ]]; then log_error "Python tooling validation failed: ${FAILURES} tool(s) missing or broken" From 5bd113bdbc2f1570b51d4128da719953a299c615 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Sat, 25 Jul 2026 21:26:27 -0500 Subject: [PATCH 04/11] fix(makefile): address Story 15.2 code-review findings - requirements.txt now wins outright over requirements-dev.txt (or any other requirements*.txt variant) instead of picking whichever sorts first alphabetically, which silently preferred the -dev file. - Add JS-side AC 7 regression coverage (package.json present, no package-lock.json -> no install attempted), reusing Story 15.1's monorepo-python-js fixture. - Add tests/fixtures/python-multi-requirements/ proving the requirements.txt precedence fix. - Document the repeated-yq-read-per-call trade-off as a deliberate, currently-unproblematic choice rather than a silent gap. Story 15.2 --- lib/dependency-install.sh | 19 ++++++++++++++++++- .../python-multi-requirements/.devrail.yml | 2 ++ .../requirements-dev.txt | 1 + .../requirements.txt | 1 + .../tests/test_smoke.py | 4 ++++ tests/test-dependency-install.sh | 17 +++++++++++++++-- 6 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/python-multi-requirements/.devrail.yml create mode 100644 tests/fixtures/python-multi-requirements/requirements-dev.txt create mode 100644 tests/fixtures/python-multi-requirements/requirements.txt create mode 100644 tests/fixtures/python-multi-requirements/tests/test_smoke.py diff --git a/lib/dependency-install.sh b/lib/dependency-install.sh index 66c5513..69e5bda 100644 --- a/lib/dependency-install.sh +++ b/lib/dependency-install.sh @@ -51,6 +51,14 @@ _DEPENDENCY_INSTALL_CONFIG="$(pwd)/${DEVRAIL_CONFIG:-.devrail.yml}" # _dependency_install_config_value emits the string value at the # given yq path in .devrail.yml, or empty if missing/absent/unreadable. +# +# Re-invokes yq on every call — once per (language, root) pair per `make +# test` run, since install_project_deps/run_project_setup both call this. +# Deliberately not cached: a handful of extra `yq` subprocess spawns per +# run (milliseconds each) hasn't been shown to matter at this project's +# scale, and caching adds real invalidation/staleness complexity for a +# cost that's currently theoretical. Revisit only if this ever actually +# shows up as slow, not preemptively. _dependency_install_config_value() { local yq_path="$1" [[ -r "${_DEPENDENCY_INSTALL_CONFIG}" ]] || return 0 @@ -81,7 +89,16 @@ _dependency_install_autodetect_python() { printf 'uv export --frozen --no-hashes --format requirements-txt | uv pip install --system --break-system-packages -r -' elif compgen -G "requirements*.txt" >/dev/null 2>&1; then local req_file - req_file="$(compgen -G "requirements*.txt" | sort | head -1)" + if [[ -f "requirements.txt" ]]; then + # Plain requirements.txt wins outright when present, even over an + # alphabetically-earlier variant (e.g. requirements-dev.txt sorts + # before requirements.txt since '-' < '.' in ASCII) — a sort-only + # pick would silently install the wrong file for the common + # requirements.txt + requirements-dev.txt layout. + req_file="requirements.txt" + else + req_file="$(compgen -G "requirements*.txt" | sort | head -1)" + fi printf 'pip install --break-system-packages -r %q' "${req_file}" elif [[ -f "pyproject.toml" || -f "setup.py" ]]; then printf 'pip install --break-system-packages -e .' diff --git a/tests/fixtures/python-multi-requirements/.devrail.yml b/tests/fixtures/python-multi-requirements/.devrail.yml new file mode 100644 index 0000000..125a346 --- /dev/null +++ b/tests/fixtures/python-multi-requirements/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - python diff --git a/tests/fixtures/python-multi-requirements/requirements-dev.txt b/tests/fixtures/python-multi-requirements/requirements-dev.txt new file mode 100644 index 0000000..861a8bd --- /dev/null +++ b/tests/fixtures/python-multi-requirements/requirements-dev.txt @@ -0,0 +1 @@ +this-package-definitely-does-not-exist-anywhere==99.99.99 diff --git a/tests/fixtures/python-multi-requirements/requirements.txt b/tests/fixtures/python-multi-requirements/requirements.txt new file mode 100644 index 0000000..4aec4ad --- /dev/null +++ b/tests/fixtures/python-multi-requirements/requirements.txt @@ -0,0 +1 @@ +inflection==0.5.1 diff --git a/tests/fixtures/python-multi-requirements/tests/test_smoke.py b/tests/fixtures/python-multi-requirements/tests/test_smoke.py new file mode 100644 index 0000000..9174a61 --- /dev/null +++ b/tests/fixtures/python-multi-requirements/tests/test_smoke.py @@ -0,0 +1,4 @@ +def test_requirements_txt_wins_over_requirements_dev_txt(): + import inflection + + assert inflection.underscore("HelloWorld") == "hello_world" diff --git a/tests/test-dependency-install.sh b/tests/test-dependency-install.sh index 97bfc02..4dd7a4a 100644 --- a/tests/test-dependency-install.sh +++ b/tests/test-dependency-install.sh @@ -13,7 +13,12 @@ # against a broken/partial install (AC 8) # 8. A project with no lockfile/manifest for a declared language is # unaffected — no install step runs (AC 7, regression safety; reuses -# Story 15.1's declared-lang-no-manifest fixture) +# Story 15.1's declared-lang-no-manifest and monorepo-python-js +# fixtures for the Python and JS sides respectively) +# 9. requirements.txt wins over requirements-dev.txt (or any other +# requirements*.txt variant) rather than picking whichever sorts +# first alphabetically — '-' sorts before '.' in ASCII, so a naive +# sorted-glob pick would silently prefer requirements-dev.txt # # Every installing case here does a REAL network install against PyPI/npm # inside the container — this is intentional, not an oversight. The whole @@ -95,6 +100,10 @@ echo "==> python-pyproject-only: pyproject.toml only -> pip install -e ." SUMMARY=$(run_test python-pyproject-only) assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "pyproject-only/status" +echo "==> python-multi-requirements: requirements.txt wins over requirements-dev.txt (not alphabetical sort)" +SUMMARY=$(run_test python-multi-requirements) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "multi-requirements/status" + echo "==> js-npm-deps: package-lock.json -> npm ci" SUMMARY=$(run_test js-npm-deps) assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "npm-deps/status" @@ -128,10 +137,14 @@ else PASS=$((PASS + 1)) fi -echo "==> declared-lang-no-manifest (Story 15.1 fixture): no lockfile/manifest -> no install attempted, unaffected" +echo "==> declared-lang-no-manifest (Story 15.1 fixture): no lockfile/manifest (Python) -> no install attempted, unaffected" SUMMARY=$(run_test declared-lang-no-manifest) assert_eq "skip" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "no-manifest/status" +echo "==> monorepo-python-js (Story 15.1 fixture): frontend/ has package.json but no package-lock.json -> JS install no-op, unaffected" +SUMMARY=$(run_test monorepo-python-js) +assert_eq "pass" "$(printf '%s' "$SUMMARY" | jq -r '.status')" "js-no-lockfile/status" + echo "" echo "===================================" echo "Results: ${PASS} passed, ${FAIL} failed" From 61fe157ce8837b8f15652b0e7c9854270948f667 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Sat, 25 Jul 2026 21:47:37 -0500 Subject: [PATCH 05/11] feat(makefile): extend project-root discovery to Go and Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make lint/format/fix/test/security now discover Go (go.mod) and Rust (Cargo.toml) module roots the same way Story 15.1 does for Python/JS, and run golangci-lint/gofumpt/go test/govulncheck and cargo clippy/ fmt/test/audit/deny with cwd set there. This closes a real, previously-unfixed instance of issue #53: go test ./..., golangci-lint run ./..., cargo test, cargo clippy, and cargo fmt --check all failed outright ("directory prefix . does not contain main module", "could not find Cargo.toml") when the module wasn't rooted at the repo root — reproduced against real fixtures before writing any code. The epic's original assumption that Go/Rust "already resolve fine from repo root" was wrong. Ansible was in the epic's original scope for this story too, but investigation found ansible-lint already recursively discovers playbooks regardless of cwd — no change needed, so it's excluded here rather than silently given discovery logic it doesn't require. Generalizes Story 15.1's existing lib/project-discover.sh (two new ~10-line autodetect functions, no new library, no new dependency- install component — go test/cargo test already fetch their own deps automatically). Closes #53 Story 15.3 --- CHANGELOG.md | 8 + Makefile | 314 ++++++++++-------- STABILITY.md | 2 +- lib/project-discover.sh | 34 +- tests/fixtures/go-monorepo/.devrail.yml | 2 + .../fixtures/go-monorepo/services/api/go.mod | 3 + .../fixtures/go-monorepo/services/api/main.go | 7 + .../go-monorepo/services/api/main_test.go | 9 + tests/fixtures/go-single-root/.devrail.yml | 2 + tests/fixtures/go-single-root/go.mod | 3 + tests/fixtures/go-single-root/main.go | 7 + tests/fixtures/go-single-root/main_test.go | 9 + tests/fixtures/rust-monorepo/.devrail.yml | 2 + .../rust-monorepo/services/api/Cargo.toml | 4 + .../rust-monorepo/services/api/src/lib.rs | 12 + tests/fixtures/rust-single-root/.devrail.yml | 2 + tests/fixtures/rust-single-root/Cargo.toml | 4 + tests/fixtures/rust-single-root/src/lib.rs | 12 + tests/test-project-discover.sh | 35 ++ 19 files changed, 325 insertions(+), 146 deletions(-) create mode 100644 tests/fixtures/go-monorepo/.devrail.yml create mode 100644 tests/fixtures/go-monorepo/services/api/go.mod create mode 100644 tests/fixtures/go-monorepo/services/api/main.go create mode 100644 tests/fixtures/go-monorepo/services/api/main_test.go create mode 100644 tests/fixtures/go-single-root/.devrail.yml create mode 100644 tests/fixtures/go-single-root/go.mod create mode 100644 tests/fixtures/go-single-root/main.go create mode 100644 tests/fixtures/go-single-root/main_test.go create mode 100644 tests/fixtures/rust-monorepo/.devrail.yml create mode 100644 tests/fixtures/rust-monorepo/services/api/Cargo.toml create mode 100644 tests/fixtures/rust-monorepo/services/api/src/lib.rs create mode 100644 tests/fixtures/rust-single-root/.devrail.yml create mode 100644 tests/fixtures/rust-single-root/Cargo.toml create mode 100644 tests/fixtures/rust-single-root/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f02f3bc..deedecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `test.setup` overrides. Container image now ships `uv`. A failed install fails `make test` immediately — the test suite never runs against a broken install. Projects with no lockfile/manifest are unaffected. +- **Issue #53 (Story 15.3):** extends Story 15.1's project-root discovery + to Go (`go.mod`) and Rust (`Cargo.toml`) — `go test`, `golangci-lint`, + `cargo test`, `cargo clippy`, and `cargo fmt` previously failed outright + ("directory prefix . does not contain main module", "could not find + Cargo.toml") for a monorepo module not rooted at the repo root; they + now run with cwd set to the discovered module root, same as Python/JS. + Ansible needed no equivalent change — `ansible-lint` already discovers + playbooks recursively regardless of cwd. ## [1.12.0] - 2026-05-30 diff --git a/Makefile b/Makefile index 99f238b..cf129a7 100644 --- a/Makefile +++ b/Makefile @@ -500,19 +500,22 @@ _lint: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - go_files=$$(find . -name '*.go' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null); \ - if [ -n "$$go_files" ]; then \ - golangci-lint run ./... || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - echo '{"level":"info","msg":"skipping go lint: no .go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + go_files=$$(find "$$go_root" -name '*.go' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' 2>/dev/null); \ + if [ -n "$$go_files" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && golangci-lint run ./...) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping go lint: no .go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ while IFS= read -r js_root; do \ @@ -544,19 +547,22 @@ _lint: _plugins-load done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ]; then \ - cargo clippy --all-targets --all-features -- -D warnings || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - echo '{"level":"info","msg":"skipping rust lint: no .rs files found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo clippy --all-targets --all-features -- -D warnings) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust lint: no .rs files found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"lint\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ ran_languages="$${ran_languages}\"swift\","; \ @@ -697,19 +703,22 @@ _format: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - go_files=$$(find . -name '*.go' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null); \ - if [ -n "$$go_files" ]; then \ - gofumpt -d . || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - echo '{"level":"info","msg":"skipping go format: no .go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + go_files=$$(find "$$go_root" -name '*.go' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' 2>/dev/null); \ + if [ -n "$$go_files" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && gofumpt -d .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping go format: no .go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ while IFS= read -r js_root; do \ @@ -730,19 +739,22 @@ _format: _plugins-load done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ]; then \ - cargo fmt --all -- --check || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - echo '{"level":"info","msg":"skipping rust format: no .rs files found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo fmt --all -- --check) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust format: no .rs files found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"format\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ ran_languages="$${ran_languages}\"swift\","; \ @@ -862,19 +874,22 @@ _fix: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - go_files=$$(find . -name '*.go' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null); \ - if [ -n "$$go_files" ]; then \ - gofumpt -w . || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - echo '{"level":"info","msg":"skipping go fix: no .go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + go_files=$$(find "$$go_root" -name '*.go' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/node_modules/*' 2>/dev/null); \ + if [ -n "$$go_files" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && gofumpt -w .) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping go fix: no .go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ while IFS= read -r js_root; do \ @@ -895,19 +910,22 @@ _fix: _plugins-load done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ]; then \ - cargo fmt --all || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - echo '{"level":"info","msg":"skipping rust fix: no .rs files found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo fmt --all) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust fix: no .rs files found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"fix\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ ran_languages="$${ran_languages}\"swift\","; \ @@ -1066,19 +1084,22 @@ _test: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - if find . -name '*_test.go' -not -path './.git/*' -not -path './vendor/*' 2>/dev/null | grep -q .; then \ - ran_languages="$${ran_languages}\"go\","; \ - go test ./... || { overall_exit=1; failed_languages="$${failed_languages}\"go\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"go\","; \ - echo '{"level":"info","msg":"skipping go tests: no *_test.go files found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + if find "$$go_root" -name '*_test.go' -not -path '*/.git/*' -not -path '*/vendor/*' 2>/dev/null | grep -q .; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && go test ./...) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$go_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping go tests: no *_test.go files found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ while IFS= read -r js_root; do \ @@ -1103,20 +1124,23 @@ _test: _plugins-load done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - rs_files=$$(find . -name '*.rs' -not -path './.git/*' -not -path './vendor/*' -not -path './target/*' 2>/dev/null); \ - if [ -n "$$rs_files" ] && [ -f "Cargo.toml" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - cargo test --all-targets || { overall_exit=1; failed_languages="$${failed_languages}\"rust\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"rust\","; \ - echo '{"level":"info","msg":"skipping rust tests: no .rs files or Cargo.toml found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + rs_files=$$(find "$$rust_root" -name '*.rs' -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/target/*' 2>/dev/null); \ + if [ -n "$$rs_files" ] && [ -f "$$rust_root/Cargo.toml" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo test --all-targets) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$rust_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping rust tests: no .rs files or Cargo.toml found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"test\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}],\"skipped\":[$${skipped_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ swift_files=$$(find . -name '*.swift' -not -path './.git/*' -not -path './.build/*' -not -path './DerivedData/*' 2>/dev/null); \ @@ -1247,19 +1271,22 @@ _security: _plugins-load fi; \ fi; \ if [ -n "$(HAS_GO)" ]; then \ - if [ -f "go.sum" ]; then \ - ran_languages="$${ran_languages}\"go\","; \ - govulncheck ./... || { overall_exit=1; failed_languages="$${failed_languages}\"go:govulncheck\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"go\","; \ - echo '{"level":"info","msg":"skipping govulncheck: no go.sum found","language":"go"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r go_root; do \ + if [ "$$go_root" = "." ]; then go_tag="go"; else go_tag="go:$$go_root"; fi; \ + if [ -f "$$go_root/go.sum" ]; then \ + ran_languages="$${ran_languages}\"$$go_tag\","; \ + (cd "$$go_root" && govulncheck ./...) || { overall_exit=1; failed_languages="$${failed_languages}\"$$go_tag:govulncheck\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$go_tag\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping govulncheck: no go.sum found\",\"language\":\"go\",\"root\":\"$$go_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots go); \ fi; \ if [ -n "$(HAS_JAVASCRIPT)" ]; then \ while IFS= read -r js_root; do \ @@ -1280,30 +1307,33 @@ _security: _plugins-load done < <(discover_project_roots javascript); \ fi; \ if [ -n "$(HAS_RUST)" ]; then \ - if [ -f "Cargo.lock" ]; then \ - ran_languages="$${ran_languages}\"rust\","; \ - cargo audit || { overall_exit=1; failed_languages="$${failed_languages}\"rust:cargo-audit\","; }; \ - else \ - skipped_languages="$${skipped_languages}\"rust:cargo-audit\","; \ - echo '{"level":"info","msg":"skipping cargo audit: no Cargo.lock found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ - if [ -f "deny.toml" ]; then \ - cargo deny check || { overall_exit=1; failed_languages="$${failed_languages}\"rust:cargo-deny\","; }; \ - else \ - echo '{"level":"info","msg":"skipping cargo deny: no deny.toml found","language":"rust"}' >&2; \ - fi; \ - if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ - end_time=$$(date +%s%3N); \ - duration=$$((end_time - start_time)); \ - echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ - exit $$overall_exit; \ - fi; \ + while IFS= read -r rust_root; do \ + if [ "$$rust_root" = "." ]; then rust_tag="rust"; else rust_tag="rust:$$rust_root"; fi; \ + if [ -f "$$rust_root/Cargo.lock" ]; then \ + ran_languages="$${ran_languages}\"$$rust_tag\","; \ + (cd "$$rust_root" && cargo audit) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag:cargo-audit\","; }; \ + else \ + skipped_languages="$${skipped_languages}\"$$rust_tag:cargo-audit\","; \ + echo "{\"level\":\"info\",\"msg\":\"skipping cargo audit: no Cargo.lock found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + if [ -f "$$rust_root/deny.toml" ]; then \ + (cd "$$rust_root" && cargo deny check) || { overall_exit=1; failed_languages="$${failed_languages}\"$$rust_tag:cargo-deny\","; }; \ + else \ + echo "{\"level\":\"info\",\"msg\":\"skipping cargo deny: no deny.toml found\",\"language\":\"rust\",\"root\":\"$$rust_root\"}" >&2; \ + fi; \ + if [ "$(DEVRAIL_FAIL_FAST)" = "1" ] && [ $$overall_exit -ne 0 ]; then \ + end_time=$$(date +%s%3N); \ + duration=$$((end_time - start_time)); \ + echo "{\"target\":\"security\",\"status\":\"fail\",\"duration_ms\":$$duration,\"languages\":[$${ran_languages%,}],\"failed\":[$${failed_languages%,}]}"; \ + exit $$overall_exit; \ + fi; \ + done < <(discover_project_roots rust); \ fi; \ if [ -n "$(HAS_SWIFT)" ]; then \ skipped_languages="$${skipped_languages}\"swift\","; \ diff --git a/STABILITY.md b/STABILITY.md index c16dcb4..bb2f555 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -32,7 +32,7 @@ DevRail has reached **v1.0** across all repositories. The core standards, toolch | **Pre-commit hooks** | Stable | Conventional commit hook and per-language hooks configured in template repos. | | **Documentation site** | Stable | [devrail.dev](https://devrail.dev) is live with full standards coverage. | | **Plugin loader + resolver + lockfile + build pipeline + execution loop** | Stable (v1.10.x baseline; reference plugin v1.11.x) | Validates `plugin.devrail.yml` manifests, resolves `rev:` to immutable SHAs (`make plugins-update`), records reproducibility metadata in `.devrail.lock`, and auto-builds a project-local extended image (`devrail-local:`) when plugins are declared. Each loaded plugin's `targets` are dispatched inside `_lint`/`_format`/`_fix`/`_test`/`_security` with gate evaluation, `{paths}` interpolation, per-language overrides, and JSON aggregation into the existing event shape. `DEVRAIL_FAIL_FAST=1` short-circuits on plugin failures the same as core. No-op when `plugins:` is absent — v1.9.x behaviour unchanged. **As of v1.11.0** the first reference plugin ([`devrail-plugin-kotlin`](https://github.com/devrail-dev/devrail-plugin-kotlin)) is published; the extraction is **additive** through the v1.x line (Kotlin remains in core for back-compat). v2.0.0 retires the in-core HAS_ blocks. | -| **Monorepo project-root discovery (Python/JS)** | Preview (Story 15.1) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Go/Rust/Ansible root-awareness is tracked as a follow-on story (15.3). | +| **Monorepo project-root discovery (Python/JS/Go/Rust)** | Preview (Stories 15.1, 15.3) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS, `go.mod` for Go, `Cargo.toml` for Rust) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves and Go/Rust tools stop failing outright ("directory prefix . does not contain main module", "could not find Cargo.toml") for a module not rooted at the repo root. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Ansible needs no equivalent — `ansible-lint` already discovers playbooks recursively. | | **Dependency install before `make test` (Python/JS)** | Preview (Story 15.2) | `lib/dependency-install.sh` autodetects and installs a project's dependencies (`uv`/`pip` for Python — container now ships `uv`; `npm` for JS/TS) in each root discovered above, before `pytest`/`vitest` run. Optional `.devrail.yml` `test.install`/`test.setup` overrides. A failed install fails `make test` immediately. `poetry`/`pipenv`/`pnpm`/`yarn` are not yet supported (not installed in the container); `test.services` (ephemeral DB/cache containers) is not yet implemented — both tracked as follow-on stories (15.3–15.4). | ## Consumer responsibilities diff --git a/lib/project-discover.sh b/lib/project-discover.sh index 64ebb40..20afa52 100644 --- a/lib/project-discover.sh +++ b/lib/project-discover.sh @@ -27,12 +27,16 @@ # when no per-project signal existed; Story 15.1 AC 4). # * otherwise — output is every directory containing a manifest (the # true monorepo case; Story 15.1 AC 1/AC 7). -# - Excludes .git, node_modules, vendor, .venv, venv, dist, build, +# - Excludes .git, node_modules, vendor, .venv, venv, dist, build, target, # .terraform subtrees (mirrors the per-language find excludes already # used in _lint/_format/_fix). # -# Supported languages: python, javascript. Any other language returns "." -# with a warning — extending autodetection to go/rust/ansible is Story 15.3. +# Supported languages: python, javascript, go, rust (Story 15.3). Ansible +# was evaluated for Story 15.3 and explicitly excluded — ansible-lint +# already recursively discovers playbooks from cwd regardless of where +# they live, with no root-marker file needed the way go.mod/Cargo.toml +# are for their respective toolchains; there is nothing here for it to +# autodetect. Any other language returns "." with a warning. # # Dependencies: lib/log.sh (log_warn), yq (v4+), bash 5+, coreutils (find) @@ -52,6 +56,7 @@ _PROJECT_DISCOVER_FIND_EXCLUDES=( -not -path './venv/*' -not -path './dist/*' -not -path './build/*' + -not -path './target/*' -not -path './.terraform/*' ) @@ -107,6 +112,27 @@ _project_discover_autodetect_javascript() { _project_discover_normalize } +# _project_discover_autodetect_go finds directories containing a go.mod. +_project_discover_autodetect_go() { + find . -name 'go.mod' \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + +# _project_discover_autodetect_rust finds directories containing a +# Cargo.toml. +_project_discover_autodetect_rust() { + find . -name 'Cargo.toml' \ + "${_PROJECT_DISCOVER_FIND_EXCLUDES[@]}" -print0 2>/dev/null | + xargs -0 -I{} dirname {} | + sort -u | + sed 's#^\./##' | + _project_discover_normalize +} + # discover_project_roots emits newline-separated project root # paths for the given language. Never empty — see _project_discover_normalize. discover_project_roots() { @@ -126,6 +152,8 @@ discover_project_roots() { case "${language}" in python) _project_discover_autodetect_python ;; javascript) _project_discover_autodetect_javascript ;; + go) _project_discover_autodetect_go ;; + rust) _project_discover_autodetect_rust ;; *) log_warn "discover_project_roots: no autodetection rule for language '${language}'" printf '.\n' diff --git a/tests/fixtures/go-monorepo/.devrail.yml b/tests/fixtures/go-monorepo/.devrail.yml new file mode 100644 index 0000000..ace6663 --- /dev/null +++ b/tests/fixtures/go-monorepo/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - go diff --git a/tests/fixtures/go-monorepo/services/api/go.mod b/tests/fixtures/go-monorepo/services/api/go.mod new file mode 100644 index 0000000..61beb9f --- /dev/null +++ b/tests/fixtures/go-monorepo/services/api/go.mod @@ -0,0 +1,3 @@ +module example.com/api + +go 1.22 diff --git a/tests/fixtures/go-monorepo/services/api/main.go b/tests/fixtures/go-monorepo/services/api/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-monorepo/services/api/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-monorepo/services/api/main_test.go b/tests/fixtures/go-monorepo/services/api/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-monorepo/services/api/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/go-single-root/.devrail.yml b/tests/fixtures/go-single-root/.devrail.yml new file mode 100644 index 0000000..ace6663 --- /dev/null +++ b/tests/fixtures/go-single-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - go diff --git a/tests/fixtures/go-single-root/go.mod b/tests/fixtures/go-single-root/go.mod new file mode 100644 index 0000000..485cc0d --- /dev/null +++ b/tests/fixtures/go-single-root/go.mod @@ -0,0 +1,3 @@ +module example.com/single + +go 1.22 diff --git a/tests/fixtures/go-single-root/main.go b/tests/fixtures/go-single-root/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-single-root/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-single-root/main_test.go b/tests/fixtures/go-single-root/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-single-root/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/rust-monorepo/.devrail.yml b/tests/fixtures/rust-monorepo/.devrail.yml new file mode 100644 index 0000000..d61c510 --- /dev/null +++ b/tests/fixtures/rust-monorepo/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - rust diff --git a/tests/fixtures/rust-monorepo/services/api/Cargo.toml b/tests/fixtures/rust-monorepo/services/api/Cargo.toml new file mode 100644 index 0000000..773000d --- /dev/null +++ b/tests/fixtures/rust-monorepo/services/api/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-monorepo/services/api/src/lib.rs b/tests/fixtures/rust-monorepo/services/api/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-monorepo/services/api/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/fixtures/rust-single-root/.devrail.yml b/tests/fixtures/rust-single-root/.devrail.yml new file mode 100644 index 0000000..d61c510 --- /dev/null +++ b/tests/fixtures/rust-single-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - rust diff --git a/tests/fixtures/rust-single-root/Cargo.toml b/tests/fixtures/rust-single-root/Cargo.toml new file mode 100644 index 0000000..61a5efc --- /dev/null +++ b/tests/fixtures/rust-single-root/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "single" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-single-root/src/lib.rs b/tests/fixtures/rust-single-root/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-single-root/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/test-project-discover.sh b/tests/test-project-discover.sh index 5b28b55..48c4884 100644 --- a/tests/test-project-discover.sh +++ b/tests/test-project-discover.sh @@ -17,6 +17,11 @@ # discovery (frontend/tsconfig.json + vite.config.ts `@` alias) — and # tag per-root failures/skips (e.g. "python:api:bandit") the same way # lint does. +# 7. (Story 15.3) discover_project_roots go/rust and full make _lint/ +# _test against real Go/Rust monorepo fixtures — proving this story +# actually fixes the reproduced pre-existing failures +# ("directory prefix . does not contain main module", "could not +# find Cargo.toml") rather than just not crashing. # # Fixtures are copied into a disposable $WORKDIR before any `make` target # runs against them (never bind-mounted read-write directly) — running a @@ -173,6 +178,36 @@ assert_eq '["python"]' "$(printf '%s' "$SINGLE_FORMAT_SUMMARY" | jq -c '.languag SINGLE_SECURITY_SUMMARY=$(run_target single-root-python security) assert_eq '["python:bandit"]' "$(printf '%s' "$SINGLE_SECURITY_SUMMARY" | jq -c '.failed')" "single-root/security-failed-unqualified" +echo "==> Unit (Story 15.3): go-monorepo/rust-monorepo — go/rust resolve to services/api" +assert_eq "services/api" "$(discover go-monorepo go)" "go-monorepo/go" +assert_eq "services/api" "$(discover rust-monorepo rust)" "rust-monorepo/rust" + +echo "==> Unit (Story 15.3): go-single-root/rust-single-root — resolve to '.' (regression safety)" +assert_eq "." "$(discover go-single-root go)" "go-single-root/go" +assert_eq "." "$(discover rust-single-root rust)" "rust-single-root/rust" + +echo "==> Integration (Story 15.3): make _lint/_test on go-monorepo — fixes the reproduced 'directory prefix . does not contain main module' failure" +GO_LINT_SUMMARY=$(run_target go-monorepo lint) +assert_eq "pass" "$(printf '%s' "$GO_LINT_SUMMARY" | jq -r '.status')" "go-monorepo/lint-status" +assert_eq '["go:services/api"]' "$(printf '%s' "$GO_LINT_SUMMARY" | jq -c '.languages')" "go-monorepo/lint-languages-tagged-by-root" +GO_TEST_SUMMARY=$(run_target go-monorepo test) +assert_eq "pass" "$(printf '%s' "$GO_TEST_SUMMARY" | jq -r '.status')" "go-monorepo/test-status" +assert_eq '["go:services/api"]' "$(printf '%s' "$GO_TEST_SUMMARY" | jq -c '.languages')" "go-monorepo/test-languages-tagged-by-root" + +echo "==> Integration (Story 15.3): make _lint/_test on rust-monorepo — fixes the reproduced 'could not find Cargo.toml' failure" +RUST_LINT_SUMMARY=$(run_target rust-monorepo lint) +assert_eq "pass" "$(printf '%s' "$RUST_LINT_SUMMARY" | jq -r '.status')" "rust-monorepo/lint-status" +assert_eq '["rust:services/api"]' "$(printf '%s' "$RUST_LINT_SUMMARY" | jq -c '.languages')" "rust-monorepo/lint-languages-tagged-by-root" +RUST_TEST_SUMMARY=$(run_target rust-monorepo test) +assert_eq "pass" "$(printf '%s' "$RUST_TEST_SUMMARY" | jq -r '.status')" "rust-monorepo/test-status" +assert_eq '["rust:services/api"]' "$(printf '%s' "$RUST_TEST_SUMMARY" | jq -c '.languages')" "rust-monorepo/test-languages-tagged-by-root" + +echo "==> Integration (Story 15.3): go-single-root/rust-single-root — byte-identical unqualified tags (AC 4)" +GO_SINGLE_LINT_SUMMARY=$(run_target go-single-root lint) +assert_eq '["go"]' "$(printf '%s' "$GO_SINGLE_LINT_SUMMARY" | jq -c '.languages')" "go-single-root/lint-languages-unqualified" +RUST_SINGLE_LINT_SUMMARY=$(run_target rust-single-root lint) +assert_eq '["rust"]' "$(printf '%s' "$RUST_SINGLE_LINT_SUMMARY" | jq -c '.languages')" "rust-single-root/lint-languages-unqualified" + echo "" echo "===================================" echo "Results: ${PASS} passed, ${FAIL} failed" From bb8871a002663d894e3e9d733610841837d1c403 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Sat, 25 Jul 2026 21:53:46 -0500 Subject: [PATCH 06/11] fix(makefile): address Story 15.3 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests/fixtures/go-multi-root/ and rust-multi-root/ (two module roots each) plus make _test assertions proving AC 7's multi-root claim, which the first pass never actually tested. - Add _format/_fix/_security integration coverage for the Go/Rust monorepo fixtures (previously only _lint/_test were covered) — the exact class of gap Story 15.1's own review caught, reintroduced here despite this story's Dev Notes explicitly warning against it. Story 15.3 --- tests/fixtures/go-multi-root/.devrail.yml | 2 + .../fixtures/go-multi-root/services/a/go.mod | 3 ++ .../fixtures/go-multi-root/services/a/main.go | 7 ++++ .../go-multi-root/services/a/main_test.go | 9 +++++ .../fixtures/go-multi-root/services/b/go.mod | 3 ++ .../fixtures/go-multi-root/services/b/main.go | 7 ++++ .../go-multi-root/services/b/main_test.go | 9 +++++ tests/fixtures/rust-multi-root/.devrail.yml | 2 + .../rust-multi-root/services/a/Cargo.toml | 4 ++ .../rust-multi-root/services/a/src/lib.rs | 12 ++++++ .../rust-multi-root/services/b/Cargo.toml | 4 ++ .../rust-multi-root/services/b/src/lib.rs | 12 ++++++ tests/test-project-discover.sh | 37 +++++++++++++++++-- 13 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/go-multi-root/.devrail.yml create mode 100644 tests/fixtures/go-multi-root/services/a/go.mod create mode 100644 tests/fixtures/go-multi-root/services/a/main.go create mode 100644 tests/fixtures/go-multi-root/services/a/main_test.go create mode 100644 tests/fixtures/go-multi-root/services/b/go.mod create mode 100644 tests/fixtures/go-multi-root/services/b/main.go create mode 100644 tests/fixtures/go-multi-root/services/b/main_test.go create mode 100644 tests/fixtures/rust-multi-root/.devrail.yml create mode 100644 tests/fixtures/rust-multi-root/services/a/Cargo.toml create mode 100644 tests/fixtures/rust-multi-root/services/a/src/lib.rs create mode 100644 tests/fixtures/rust-multi-root/services/b/Cargo.toml create mode 100644 tests/fixtures/rust-multi-root/services/b/src/lib.rs diff --git a/tests/fixtures/go-multi-root/.devrail.yml b/tests/fixtures/go-multi-root/.devrail.yml new file mode 100644 index 0000000..ace6663 --- /dev/null +++ b/tests/fixtures/go-multi-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - go diff --git a/tests/fixtures/go-multi-root/services/a/go.mod b/tests/fixtures/go-multi-root/services/a/go.mod new file mode 100644 index 0000000..df2941f --- /dev/null +++ b/tests/fixtures/go-multi-root/services/a/go.mod @@ -0,0 +1,3 @@ +module example.com/a + +go 1.22 diff --git a/tests/fixtures/go-multi-root/services/a/main.go b/tests/fixtures/go-multi-root/services/a/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/a/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-multi-root/services/a/main_test.go b/tests/fixtures/go-multi-root/services/a/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/a/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/go-multi-root/services/b/go.mod b/tests/fixtures/go-multi-root/services/b/go.mod new file mode 100644 index 0000000..50290ed --- /dev/null +++ b/tests/fixtures/go-multi-root/services/b/go.mod @@ -0,0 +1,3 @@ +module example.com/b + +go 1.22 diff --git a/tests/fixtures/go-multi-root/services/b/main.go b/tests/fixtures/go-multi-root/services/b/main.go new file mode 100644 index 0000000..d4ebfb9 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/b/main.go @@ -0,0 +1,7 @@ +package main + +func Add(a, b int) int { + return a + b +} + +func main() {} diff --git a/tests/fixtures/go-multi-root/services/b/main_test.go b/tests/fixtures/go-multi-root/services/b/main_test.go new file mode 100644 index 0000000..0421207 --- /dev/null +++ b/tests/fixtures/go-multi-root/services/b/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestAdd(t *testing.T) { + if Add(2, 3) != 5 { + t.Fatal("bad") + } +} diff --git a/tests/fixtures/rust-multi-root/.devrail.yml b/tests/fixtures/rust-multi-root/.devrail.yml new file mode 100644 index 0000000..d61c510 --- /dev/null +++ b/tests/fixtures/rust-multi-root/.devrail.yml @@ -0,0 +1,2 @@ +languages: + - rust diff --git a/tests/fixtures/rust-multi-root/services/a/Cargo.toml b/tests/fixtures/rust-multi-root/services/a/Cargo.toml new file mode 100644 index 0000000..d06892e --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/a/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "a" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-multi-root/services/a/src/lib.rs b/tests/fixtures/rust-multi-root/services/a/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/a/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/fixtures/rust-multi-root/services/b/Cargo.toml b/tests/fixtures/rust-multi-root/services/b/Cargo.toml new file mode 100644 index 0000000..068550f --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/b/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "b" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/rust-multi-root/services/b/src/lib.rs b/tests/fixtures/rust-multi-root/services/b/src/lib.rs new file mode 100644 index 0000000..bf2f73c --- /dev/null +++ b/tests/fixtures/rust-multi-root/services/b/src/lib.rs @@ -0,0 +1,12 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn it_adds() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/tests/test-project-discover.sh b/tests/test-project-discover.sh index 48c4884..943dd4c 100644 --- a/tests/test-project-discover.sh +++ b/tests/test-project-discover.sh @@ -18,10 +18,14 @@ # tag per-root failures/skips (e.g. "python:api:bandit") the same way # lint does. # 7. (Story 15.3) discover_project_roots go/rust and full make _lint/ -# _test against real Go/Rust monorepo fixtures — proving this story -# actually fixes the reproduced pre-existing failures -# ("directory prefix . does not contain main module", "could not -# find Cargo.toml") rather than just not crashing. +# _format/_fix/_test/_security against real Go/Rust monorepo and +# multi-root fixtures — proving this story actually fixes the +# reproduced pre-existing failures ("directory prefix . does not +# contain main module", "could not find Cargo.toml") rather than +# just not crashing, and that multiple module roots run and tag +# independently (AC 7 — added during code-review; the first pass +# only covered single-nested-module lint/test, mirroring the exact +# class of gap Story 15.1's own review caught). # # Fixtures are copied into a disposable $WORKDIR before any `make` target # runs against them (never bind-mounted read-write directly) — running a @@ -208,6 +212,31 @@ assert_eq '["go"]' "$(printf '%s' "$GO_SINGLE_LINT_SUMMARY" | jq -c '.languages' RUST_SINGLE_LINT_SUMMARY=$(run_target rust-single-root lint) assert_eq '["rust"]' "$(printf '%s' "$RUST_SINGLE_LINT_SUMMARY" | jq -c '.languages')" "rust-single-root/lint-languages-unqualified" +echo "==> Integration (Story 15.3): go-multi-root/rust-multi-root — two roots, run and tagged independently (AC 7 — code-review finding, was untested)" +GO_MULTI_TEST_SUMMARY=$(run_target go-multi-root test) +assert_eq "pass" "$(printf '%s' "$GO_MULTI_TEST_SUMMARY" | jq -r '.status')" "go-multi-root/test-status" +assert_eq '["go:services/a","go:services/b"]' "$(printf '%s' "$GO_MULTI_TEST_SUMMARY" | jq -c '.languages')" "go-multi-root/test-languages-tagged-per-root" +RUST_MULTI_TEST_SUMMARY=$(run_target rust-multi-root test) +assert_eq "pass" "$(printf '%s' "$RUST_MULTI_TEST_SUMMARY" | jq -r '.status')" "rust-multi-root/test-status" +assert_eq '["rust:services/a","rust:services/b"]' "$(printf '%s' "$RUST_MULTI_TEST_SUMMARY" | jq -c '.languages')" "rust-multi-root/test-languages-tagged-per-root" + +echo "==> Integration (Story 15.3): go-monorepo/rust-monorepo — _format/_fix/_security (code-review finding: only _lint/_test were covered)" +GO_FORMAT_SUMMARY=$(run_target go-monorepo format) +assert_eq "pass" "$(printf '%s' "$GO_FORMAT_SUMMARY" | jq -r '.status')" "go-monorepo/format-status" +GO_FIX_SUMMARY=$(run_target go-monorepo fix) +assert_eq "pass" "$(printf '%s' "$GO_FIX_SUMMARY" | jq -r '.status')" "go-monorepo/fix-status" +GO_SECURITY_SUMMARY=$(run_target go-monorepo security) +assert_eq "skip" "$(printf '%s' "$GO_SECURITY_SUMMARY" | jq -r '.status')" "go-monorepo/security-status" +assert_eq '["go:services/api"]' "$(printf '%s' "$GO_SECURITY_SUMMARY" | jq -c '.skipped')" "go-monorepo/security-skipped-tagged-by-root" + +RUST_FORMAT_SUMMARY=$(run_target rust-monorepo format) +assert_eq "pass" "$(printf '%s' "$RUST_FORMAT_SUMMARY" | jq -r '.status')" "rust-monorepo/format-status" +RUST_FIX_SUMMARY=$(run_target rust-monorepo fix) +assert_eq "pass" "$(printf '%s' "$RUST_FIX_SUMMARY" | jq -r '.status')" "rust-monorepo/fix-status" +RUST_SECURITY_SUMMARY=$(run_target rust-monorepo security) +assert_eq "pass" "$(printf '%s' "$RUST_SECURITY_SUMMARY" | jq -r '.status')" "rust-monorepo/security-status" +assert_eq '["rust:services/api"]' "$(printf '%s' "$RUST_SECURITY_SUMMARY" | jq -c '.languages')" "rust-monorepo/security-languages-tagged-by-root" + echo "" echo "===================================" echo "Results: ${PASS} passed, ${FAIL} failed" From 1eba31a883181d4e28ebf2afeb2474eeb97d5117 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Mon, 27 Jul 2026 11:40:44 -0500 Subject: [PATCH 07/11] feat(makefile): ephemeral test.services (Postgres/Redis) for make test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .devrail.yml `test.services: [postgres:16, redis:7]` starts throwaway service containers before `make test` and tears them down afterward (pass or fail), injecting DATABASE_URL/REDIS_URL. Orchestration is entirely host-side. The toolchain container has no docker CLI or /var/run/docker.sock mount — deliberately, confirmed by hand before writing any code, to avoid mounting the host's Docker socket into it (a real privilege-escalation surface this feature doesn't need). A new host-side prerequisite, _test-services-up, mirrors _extended-image's existing pattern (including its local-vs- extracted-from-image script resolution, for consumer template repos that inherit this Makefile without scripts/), and feeds two new recursively-expanded flag variables into the existing shared DOCKER_RUN macro — reusing the docker_network/env plumbing from issue #48 rather than inventing a parallel path. A no-op for every target except test. Scoped to postgres:/redis: only, matching the epic's own example; anything else fails fast rather than being silently skipped. test.services and docker_network are mutually exclusive (only one --network flag reaches docker run). A SIGKILL'd run can leave orphaned containers/network behind (a trap can't catch SIGKILL); the next make test run detects and cleans up stale state automatically — verified for real by killing a run mid-flight, not just by reading the trap code. docker-compose.test.yml autodetection is explicitly out of scope. Closes #52 Story 15.4 --- .github/workflows/ci.yml | 17 ++ CHANGELOG.md | 12 + Makefile | 67 +++- STABILITY.md | 3 +- scripts/test-services.sh | 221 ++++++++++++++ .../fixtures/test-services-mutex/.devrail.yml | 6 + .../test-services-pg-redis/.devrail.yml | 6 + .../test-services-pg-redis/requirements.txt | 2 + .../tests/test_services.py | 20 ++ .../test-services-unsupported/.devrail.yml | 5 + tests/test-test-services.sh | 286 ++++++++++++++++++ 11 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 scripts/test-services.sh create mode 100644 tests/fixtures/test-services-mutex/.devrail.yml create mode 100644 tests/fixtures/test-services-pg-redis/.devrail.yml create mode 100644 tests/fixtures/test-services-pg-redis/requirements.txt create mode 100644 tests/fixtures/test-services-pg-redis/tests/test_services.py create mode 100644 tests/fixtures/test-services-unsupported/.devrail.yml create mode 100644 tests/test-test-services.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a12c80..753d570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,23 @@ jobs: DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Story 15.4: Ephemeral test.services smoke test (issue #52) + # Verifies scripts/test-services.sh: postgres/redis alone and + # together (real queries through the injected DATABASE_URL/ + # REDIS_URL), no-services regression, docker_network mutual + # exclusion, unsupported-entry error, and — critically — that + # teardown actually happens, including after a mid-flight SIGKILL + # (checked via `docker ps`/`docker network ls`, not just by reading + # the trap code). Runs `make test` directly on the runner (this is + # host-side orchestration, not something a bind-mounted container + # invocation can exercise), so it needs Docker-in-Docker — already + # available here since every other step in this job uses it. + - name: Test services smoke test + run: bash tests/test-test-services.sh + env: + DEVRAIL_IMAGE: ${{ env.IMAGE_NAME }} + DEVRAIL_TAG: ${{ env.IMAGE_TAG }} + # Phase 2e: Plugin resolver + lockfile smoke test (Story 13.3) # Drives `make plugins-update` against a local-filesystem git fixture # (no network) covering: SHA passthrough, tag→SHA, branch rejection, diff --git a/CHANGELOG.md b/CHANGELOG.md index deedecc..9ebb42c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 now run with cwd set to the discovered module root, same as Python/JS. Ansible needed no equivalent change — `ansible-lint` already discovers playbooks recursively regardless of cwd. +- **Issue #52 (Story 15.4):** `.devrail.yml` `test.services` starts + throwaway `postgres:`/`redis:` containers before `make test` + and tears them down afterward (pass or fail), injecting `DATABASE_URL`/ + `REDIS_URL`. Orchestration is entirely host-side — the toolchain + container has no `docker` CLI or socket access, deliberately — via a + new `_test-services-up` host prerequisite (mirroring `_extended-image`'s + pattern) feeding into the existing `docker_network`/`env` plumbing + (issue #48). `test.services` and `docker_network` are mutually + exclusive. A `SIGKILL`'d run can leave orphaned containers/network + behind (a shell trap can't catch `SIGKILL`); the next `make test` run + detects and cleans up stale state automatically. Projects with no + `test.services` declared are unaffected. ## [1.12.0] - 2026-05-30 diff --git a/Makefile b/Makefile index cf129a7..9e0e4f2 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,16 @@ DEVRAIL_DOCKER_NETWORK := $(shell yq -r '.docker_network // ""' $(DEVRAIL_CONFIG DEVRAIL_NETWORK_FLAG := $(if $(DEVRAIL_DOCKER_NETWORK),--network $(DEVRAIL_DOCKER_NETWORK),) DEVRAIL_VOLUME_FLAGS := $(shell yq -r '.docker_volumes // [] | .[] | "-v " + .' $(DEVRAIL_CONFIG) 2>/dev/null) +# Story 15.4: test.services ephemeral containers. Recursively-expanded (=, +# not :=) so these re-evaluate on every DOCKER_RUN expansion — picking up +# the network/env-file that `_test-services-up` (a host-side prerequisite +# of `test:` only) writes under .devrail/test-services/, mirroring the +# DEVRAIL_RESOLVED_IMAGE / _extended-image pattern. A true no-op for every +# target except `test`: nothing else depends on _test-services-up, so +# these files never exist for lint/format/fix/security/scan/docs/etc. +DEVRAIL_TEST_SERVICES_NETWORK_FLAG = $(if $(wildcard .devrail/test-services/network),--network $(shell cat .devrail/test-services/network),) +DEVRAIL_TEST_SERVICES_ENV_FLAG = $(if $(wildcard .devrail/test-services/env),--env-file .devrail/test-services/env,) + # Ruby lint/format scope. Defaults to the conventional Rails directory set so # rubocop and reek do not descend into vendor/bundle/ (which can hold tens of # thousands of files of installed gem source). Override per-project via: @@ -129,6 +139,8 @@ DOCKER_RUN = docker run --rm \ $(RUBY_DOCKER_ENV) \ $(DEVRAIL_NETWORK_FLAG) \ $(DEVRAIL_VOLUME_FLAGS) \ + $(DEVRAIL_TEST_SERVICES_NETWORK_FLAG) \ + $(DEVRAIL_TEST_SERVICES_ENV_FLAG) \ $(DEVRAIL_RESOLVED_IMAGE) .DEFAULT_GOAL := help @@ -137,7 +149,7 @@ DOCKER_RUN = docker run --rm \ # .PHONY declarations # --------------------------------------------------------------------------- .PHONY: help build lint format fix test security scan docs changelog check install-hooks init release plugins-update -.PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init _plugins-update _plugins-verify _ensure-host-cache _generate-dockerfile _extended-image _devrail-host-bin +.PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init _plugins-update _plugins-verify _ensure-host-cache _generate-dockerfile _extended-image _devrail-host-bin _test-services-up # =========================================================================== # Public targets (run on host, delegate to Docker container) @@ -209,6 +221,50 @@ _extended-image: _ensure-host-cache _devrail-host-bin rm -f .devrail/extended-image-tag; \ fi +# --- _test-services-host-bin: extract test-services.sh + lib/log.sh from container --- +# Story 15.4: consumer template repos inherit this Makefile but not +# scripts/, mirroring _devrail-host-bin's pattern exactly (same cache +# file, same docker create/cp/rm shape). When the dev-toolchain repo +# itself runs (scripts/ present locally) we use the on-disk copy so +# changes take effect without a rebuild. Otherwise extract from the +# resolved core image to .devrail/host-bin/. +_test-services-host-bin: + @if [ -f scripts/test-services.sh ]; then \ + exit 0; \ + fi; \ + expected="$(DEVRAIL_IMAGE):$(DEVRAIL_TAG)"; \ + cached=$$(cat .devrail/host-bin/.image-tag 2>/dev/null || true); \ + if [ "$$cached" = "$$expected" ] && \ + [ -f .devrail/host-bin/scripts/test-services.sh ] && \ + [ -f .devrail/host-bin/lib/log.sh ]; then \ + exit 0; \ + fi; \ + mkdir -p .devrail/host-bin/scripts .devrail/host-bin/lib; \ + echo '{"level":"info","msg":"extracting test-services orchestrator from container","image":"'"$$expected"'","language":"_test-services"}' >&2; \ + cid=$$(docker create "$$expected" /bin/true) || { \ + echo '{"level":"error","msg":"docker create failed for host-bin extraction","image":"'"$$expected"'","language":"_test-services"}' >&2; \ + exit 2; \ + }; \ + trap 'docker rm "$$cid" >/dev/null 2>&1 || true' EXIT; \ + docker cp "$$cid":/opt/devrail/scripts/test-services.sh .devrail/host-bin/scripts/test-services.sh && \ + docker cp "$$cid":/opt/devrail/lib/log.sh .devrail/host-bin/lib/log.sh && \ + chmod +x .devrail/host-bin/scripts/test-services.sh && \ + printf '%s\n' "$$expected" > .devrail/host-bin/.image-tag + +# --- _test-services-up: start declared test.services before `make test` --- +# Story 15.4: HOST-side target, `test:`-only prerequisite. No-op (fast — +# a single yq read) when .devrail.yml has no `test.services`. When +# services ARE declared, starts them and writes network/env state under +# .devrail/test-services/ for DEVRAIL_TEST_SERVICES_NETWORK_FLAG/ +# DEVRAIL_TEST_SERVICES_ENV_FLAG to pick up. +_test-services-up: _ensure-host-cache _test-services-host-bin + @if [ -f scripts/test-services.sh ]; then \ + bash scripts/test-services.sh up; \ + else \ + DEVRAIL_LIB="$$(pwd)/.devrail/host-bin/lib" \ + bash .devrail/host-bin/scripts/test-services.sh up; \ + fi + help: ## Show this help @echo "DevRail dev-toolchain — container image build and validation" @echo "" @@ -277,7 +333,14 @@ scan: _ensure-host-cache _extended-image ## Run universal scanners (trivy, gitle security: _ensure-host-cache _extended-image ## Run language-specific security scanners $(DOCKER_RUN) make _security -test: _ensure-host-cache _extended-image ## Run validation tests +test: _ensure-host-cache _extended-image _test-services-up ## Run validation tests + @trap '\ + if [ -f scripts/test-services.sh ]; then \ + bash scripts/test-services.sh down; \ + else \ + DEVRAIL_LIB="$$(pwd)/.devrail/host-bin/lib" bash .devrail/host-bin/scripts/test-services.sh down; \ + fi \ + ' EXIT; \ $(DOCKER_RUN) make _test # =========================================================================== diff --git a/STABILITY.md b/STABILITY.md index bb2f555..94307f2 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -33,7 +33,8 @@ DevRail has reached **v1.0** across all repositories. The core standards, toolch | **Documentation site** | Stable | [devrail.dev](https://devrail.dev) is live with full standards coverage. | | **Plugin loader + resolver + lockfile + build pipeline + execution loop** | Stable (v1.10.x baseline; reference plugin v1.11.x) | Validates `plugin.devrail.yml` manifests, resolves `rev:` to immutable SHAs (`make plugins-update`), records reproducibility metadata in `.devrail.lock`, and auto-builds a project-local extended image (`devrail-local:`) when plugins are declared. Each loaded plugin's `targets` are dispatched inside `_lint`/`_format`/`_fix`/`_test`/`_security` with gate evaluation, `{paths}` interpolation, per-language overrides, and JSON aggregation into the existing event shape. `DEVRAIL_FAIL_FAST=1` short-circuits on plugin failures the same as core. No-op when `plugins:` is absent — v1.9.x behaviour unchanged. **As of v1.11.0** the first reference plugin ([`devrail-plugin-kotlin`](https://github.com/devrail-dev/devrail-plugin-kotlin)) is published; the extraction is **additive** through the v1.x line (Kotlin remains in core for back-compat). v2.0.0 retires the in-core HAS_ blocks. | | **Monorepo project-root discovery (Python/JS/Go/Rust)** | Preview (Stories 15.1, 15.3) | `lib/project-discover.sh` autodetects per-language project roots (`pyproject.toml`/`setup.py`/`setup.cfg` for Python, `package.json` for JS/TS, `go.mod` for Go, `Cargo.toml` for Rust) and runs `_lint`/`_format`/`_fix`/`_test`/`_security` with cwd set to each discovered root, so local config (tsconfig, vite aliases) resolves and Go/Rust tools stop failing outright ("directory prefix . does not contain main module", "could not find Cargo.toml") for a module not rooted at the repo root. Optional `.devrail.yml` `projects:` overrides autodetection. Single-root projects (manifest at repo root, the common case) are unaffected. Ansible needs no equivalent — `ansible-lint` already discovers playbooks recursively. | -| **Dependency install before `make test` (Python/JS)** | Preview (Story 15.2) | `lib/dependency-install.sh` autodetects and installs a project's dependencies (`uv`/`pip` for Python — container now ships `uv`; `npm` for JS/TS) in each root discovered above, before `pytest`/`vitest` run. Optional `.devrail.yml` `test.install`/`test.setup` overrides. A failed install fails `make test` immediately. `poetry`/`pipenv`/`pnpm`/`yarn` are not yet supported (not installed in the container); `test.services` (ephemeral DB/cache containers) is not yet implemented — both tracked as follow-on stories (15.3–15.4). | +| **Dependency install before `make test` (Python/JS)** | Preview (Story 15.2) | `lib/dependency-install.sh` autodetects and installs a project's dependencies (`uv`/`pip` for Python — container now ships `uv`; `npm` for JS/TS) in each root discovered above, before `pytest`/`vitest` run. Optional `.devrail.yml` `test.install`/`test.setup` overrides. A failed install fails `make test` immediately. `poetry`/`pipenv`/`pnpm`/`yarn` are not yet supported (not installed in the container) — tracked as follow-on work. | +| **Ephemeral test services (`test.services`)** | Preview (Story 15.4) | `scripts/test-services.sh` starts throwaway `postgres:`/`redis:` containers before `make test` and tears them down afterward (pass or fail), injecting `DATABASE_URL`/`REDIS_URL`. Host-side orchestration only — the toolchain container has no `docker` CLI/socket access. Mutually exclusive with `docker_network` for the `test` target. A `SIGKILL`'d run's orphaned containers/network are detected and cleaned up automatically on the next `make test`. `docker-compose.test.yml` autodetection is not implemented. | ## Consumer responsibilities diff --git a/scripts/test-services.sh b/scripts/test-services.sh new file mode 100644 index 0000000..b6ded7f --- /dev/null +++ b/scripts/test-services.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# scripts/test-services.sh — Ephemeral Postgres/Redis containers for `make test` (Story 15.4, HOST script) +# +# Purpose: Orchestrates throwaway service containers for integration tests +# declared via `.devrail.yml` `test.services`. Runs on the HOST +# (needs `docker network`/`docker run` access) — the toolchain +# container itself has no Docker CLI or `/var/run/docker.sock` +# mount, deliberately, so this story does not grant it one (that +# would be a real privilege-escalation surface the feature does +# not need). +# +# Usage: bash scripts/test-services.sh up # called by _test-services-up +# bash scripts/test-services.sh down # called by test:'s cleanup trap +# +# Contract: +# - `up` reads `.devrail.yml` `test.services` (list of `postgres:` / +# `redis:` strings). Empty/absent -> no-op, exit 0. +# - Validates the FULL list before starting anything — any unsupported +# entry exits 2 with nothing started (fail fast, not partial-then-fail). +# - Refuses to run if `docker_network` is ALSO set in `.devrail.yml` +# (exit 2) — only one `--network` flag reaches `docker run`, so the two +# are mutually exclusive for the `test` target. +# - Cleans up any stale state left by an incomplete prior run (a +# SIGKILL'd `make test` can't be caught by a shell trap) before +# starting fresh. +# - Writes state under `.devrail/test-services/`: `network` (name), +# `containers` (one name per line), `env` (KEY=VALUE lines, consumed +# via `docker run --env-file`). +# - `down` tears down every tracked container and the tracked network, +# then removes the state dir. No-op if the state dir doesn't exist. +# +# Supported services: `postgres:` (injects DATABASE_URL), `redis:` +# (injects REDIS_URL). Anything else is a hard error — no silent partial +# support for a service this script doesn't know how to configure or +# generate a connection string for. `docker-compose.test.yml` +# autodetection is explicitly out of scope (Story 15.4 AC 9). +# +# Environment: +# DEVRAIL_CONFIG path to .devrail.yml (default: .devrail.yml) +# DEVRAIL_LOG_FORMAT json (default) or human +# +# Dependencies: lib/log.sh, yq (v4+), docker, bash 5+ + +set -euo pipefail +LC_ALL=C +export LC_ALL + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" +# shellcheck source=../lib/log.sh +source "${DEVRAIL_LIB}/log.sh" + +DEVRAIL_CONFIG="${DEVRAIL_CONFIG:-.devrail.yml}" +readonly STATE_DIR=".devrail/test-services" +readonly READY_TIMEOUT_SECONDS=30 + +# --- Help --- +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + log_info "test-services.sh — Ephemeral Postgres/Redis containers for make test" + log_info "Usage: bash scripts/test-services.sh " + exit 0 +fi + +subcommand="${1:?Usage: bash scripts/test-services.sh }" + +# _service_kind emits "postgres"/"redis"/"" (empty = unsupported). +_service_kind() { + case "$1" in + postgres:*) echo "postgres" ;; + redis:*) echo "redis" ;; + *) echo "" ;; + esac +} + +# _wait_ready polls the service's own readiness check +# (not just "the TCP port is open") up to READY_TIMEOUT_SECONDS. +_wait_ready() { + local kind="$1" container="$2" + local elapsed=0 + while ((elapsed < READY_TIMEOUT_SECONDS)); do + case "${kind}" in + postgres) + if docker exec "${container}" pg_isready -U postgres >/dev/null 2>&1; then + return 0 + fi + ;; + redis) + if [[ "$(docker exec "${container}" redis-cli ping 2>/dev/null)" == "PONG" ]]; then + return 0 + fi + ;; + esac + sleep 1 + elapsed=$((elapsed + 1)) + done + return 1 +} + +# _down tears down every tracked container and the tracked network, then +# removes the state dir. No-op if there's nothing to tear down. Individual +# removal failures are logged and skipped, not fatal — a container that's +# already gone (or a network with a lingering endpoint from a container +# docker itself hasn't reaped yet) shouldn't block cleaning up the rest. +_down() { + if [[ ! -d "${STATE_DIR}" ]]; then + return 0 + fi + + if [[ -f "${STATE_DIR}/containers" ]]; then + local container + while IFS= read -r container; do + [[ -z "${container}" ]] && continue + if ! docker rm -f "${container}" >/dev/null 2>&1; then + log_warn "could not remove container '${container}' (already gone?)" + fi + done <"${STATE_DIR}/containers" + fi + + if [[ -f "${STATE_DIR}/network" ]]; then + local network + network="$(cat "${STATE_DIR}/network")" + if ! docker network rm "${network}" >/dev/null 2>&1; then + log_warn "could not remove network '${network}' (already gone?)" + fi + fi + + rm -rf "${STATE_DIR}" + log_event info "test services torn down" +} + +# _up starts every declared test.services entry. See file header for the +# full contract (fail-fast validation, mutual exclusion with +# docker_network, stale-state cleanup). +_up() { + local services + services="$(yq -r '.test.services // [] | .[]' "${DEVRAIL_CONFIG}" 2>/dev/null)" + + if [[ -z "${services}" ]]; then + return 0 + fi + + if [[ -d "${STATE_DIR}" ]]; then + log_warn "found leftover test-services state from a prior run — cleaning up before starting fresh" + _down + fi + + local devrail_network + devrail_network="$(yq -r '.docker_network // ""' "${DEVRAIL_CONFIG}" 2>/dev/null)" + if [[ -n "${devrail_network}" ]]; then + log_error "test.services and docker_network cannot both be set — the test target can only pass one --network flag to docker run. Remove one of them." 2 + exit 2 + fi + + # Validate the full list before starting anything. + local service kind + while IFS= read -r service; do + [[ -z "${service}" ]] && continue + kind="$(_service_kind "${service}")" + if [[ -z "${kind}" ]]; then + log_error "unsupported test.services entry '${service}' — only postgres: and redis: are supported" 2 + exit 2 + fi + done <<<"${services}" + + mkdir -p "${STATE_DIR}" + local suffix network + suffix="$(date +%s)-$$" + network="devrail-test-${suffix}" + docker network create "${network}" >/dev/null + echo "${network}" >"${STATE_DIR}/network" + log_event info "created ephemeral test-services network" network="${network}" + + : >"${STATE_DIR}/env" + : >"${STATE_DIR}/containers" + + local index=0 container + while IFS= read -r service; do + [[ -z "${service}" ]] && continue + kind="$(_service_kind "${service}")" + index=$((index + 1)) + container="devrail-test-${kind}-${suffix}-${index}" + + case "${kind}" in + postgres) + docker run -d --network "${network}" --name "${container}" \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=devrail -e POSTGRES_DB=devrail_test \ + "${service}" >/dev/null + ;; + redis) + docker run -d --network "${network}" --name "${container}" "${service}" >/dev/null + ;; + esac + echo "${container}" >>"${STATE_DIR}/containers" + log_event info "starting test service" service="${service}" container="${container}" + + if ! _wait_ready "${kind}" "${container}"; then + log_error "test service '${service}' (${container}) did not become ready within ${READY_TIMEOUT_SECONDS}s" 2 + _down + exit 2 + fi + log_event info "test service ready" service="${service}" container="${container}" + + case "${kind}" in + postgres) + echo "DATABASE_URL=postgresql://postgres:devrail@${container}:5432/devrail_test" >>"${STATE_DIR}/env" + ;; + redis) + echo "REDIS_URL=redis://${container}:6379" >>"${STATE_DIR}/env" + ;; + esac + done <<<"${services}" +} + +case "${subcommand}" in +up) _up ;; +down) _down ;; +*) + log_error "unknown subcommand '${subcommand}' — expected 'up' or 'down'" 2 + exit 2 + ;; +esac diff --git a/tests/fixtures/test-services-mutex/.devrail.yml b/tests/fixtures/test-services-mutex/.devrail.yml new file mode 100644 index 0000000..9a56fea --- /dev/null +++ b/tests/fixtures/test-services-mutex/.devrail.yml @@ -0,0 +1,6 @@ +languages: + - python +docker_network: some-existing-network +test: + services: + - postgres:16 diff --git a/tests/fixtures/test-services-pg-redis/.devrail.yml b/tests/fixtures/test-services-pg-redis/.devrail.yml new file mode 100644 index 0000000..d190c88 --- /dev/null +++ b/tests/fixtures/test-services-pg-redis/.devrail.yml @@ -0,0 +1,6 @@ +languages: + - python +test: + services: + - postgres:16 + - redis:7 diff --git a/tests/fixtures/test-services-pg-redis/requirements.txt b/tests/fixtures/test-services-pg-redis/requirements.txt new file mode 100644 index 0000000..a76af2e --- /dev/null +++ b/tests/fixtures/test-services-pg-redis/requirements.txt @@ -0,0 +1,2 @@ +psycopg2-binary==2.9.10 +redis==5.2.1 diff --git a/tests/fixtures/test-services-pg-redis/tests/test_services.py b/tests/fixtures/test-services-pg-redis/tests/test_services.py new file mode 100644 index 0000000..1b5505d --- /dev/null +++ b/tests/fixtures/test-services-pg-redis/tests/test_services.py @@ -0,0 +1,20 @@ +import os + +import psycopg2 +import redis + + +def test_postgres_is_reachable_and_queryable(): + conn = psycopg2.connect(os.environ["DATABASE_URL"]) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1;") + assert cur.fetchone() == (1,) + finally: + conn.close() + + +def test_redis_is_reachable_and_usable(): + client = redis.from_url(os.environ["REDIS_URL"]) + client.set("story-15-4", "works") + assert client.get("story-15-4") == b"works" diff --git a/tests/fixtures/test-services-unsupported/.devrail.yml b/tests/fixtures/test-services-unsupported/.devrail.yml new file mode 100644 index 0000000..4cf8608 --- /dev/null +++ b/tests/fixtures/test-services-unsupported/.devrail.yml @@ -0,0 +1,5 @@ +languages: + - python +test: + services: + - mysql:8 diff --git a/tests/test-test-services.sh b/tests/test-test-services.sh new file mode 100644 index 0000000..75f772e --- /dev/null +++ b/tests/test-test-services.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +# tests/test-test-services.sh — Validate ephemeral test.services orchestration (Story 15.4) +# +# Verifies, against checked-in fixtures under tests/fixtures/: +# 1. postgres:16 alone — real SQL query through the injected DATABASE_URL +# 2. redis:7 alone — real SET/GET through the injected REDIS_URL +# 3. both together — one shared network, both env vars injected +# 4. no test.services declared — make test is unaffected (regression) +# 5. docker_network + test.services both set — fails fast with a clear +# mutual-exclusion error, nothing gets started +# 6. an unsupported service entry (mysql:8) — fails fast with a clear +# error naming it, nothing gets started +# 7. teardown actually happens — checked via `docker ps`/`docker network +# ls` after each service-starting case, not just by the trap code +# existing +# 8. a mid-flight SIGKILL leaves orphaned resources (a shell trap cannot +# intercept SIGKILL), and the *next* `make test` run detects and +# cleans up that stale state before starting fresh +# +# Unlike tests/test-project-discover.sh / tests/test-dependency-install.sh, +# which run `make _test` (an INTERNAL target) inside a bind-mounted +# container, this script runs the PUBLIC `make test` target directly on +# the HOST shell — `_test-services-up` is host-side orchestration (it +# calls `docker network create`/`docker run` itself), so it must be +# invoked the way a real consumer would invoke it: `cd && make +# test`, not wrapped in another docker run. +# +# Requires Docker-in-Docker capability and network egress to pull +# postgres:16/redis:7 if not already cached — the same requirement +# tests/test-dependency-install.sh already documents for PyPI/npm. +# +# Usage: bash tests/test-test-services.sh +# Env: +# DEVRAIL_IMAGE override image name (default: ghcr.io/devrail-dev/dev-toolchain) +# DEVRAIL_TAG override image tag (default: local) + +set -euo pipefail + +IMAGE_NAME="${DEVRAIL_IMAGE:-ghcr.io/devrail-dev/dev-toolchain}" +IMAGE_TAG="${DEVRAIL_TAG:-local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${REPO_ROOT}/tests/fixtures" +WORKDIR="$(mktemp -d)" + +cleanup() { + # Belt-and-braces: remove any test-services resources this run's own + # fixtures might have left behind (should already be none if teardown + # worked, but don't leave orphans behind from a script-level failure). + docker ps -a --filter "name=devrail-test-" --format '{{.Names}}' 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls --filter "name=devrail-test-" --format '{{.Name}}' 2>/dev/null | xargs -r -n1 docker network rm >/dev/null 2>&1 || true + if [ -n "${WORKDIR:-}" ] && [ -d "$WORKDIR" ]; then + docker run --rm -v "$WORKDIR:/cleanup" "${IMAGE_NAME}:${IMAGE_TAG}" \ + sh -c 'rm -rf /cleanup/* /cleanup/.[!.]* 2>/dev/null || true' >/dev/null 2>&1 || true + rmdir "$WORKDIR" 2>/dev/null || rm -rf "$WORKDIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +PASS=0 +FAIL=0 + +assert_eq() { + local expected="$1" actual="$2" context="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]: expected '$expected', got '$actual'" >&2 + FAIL=$((FAIL + 1)) + fi +} + +assert_true() { + local condition="$1" context="$2" + if [ "$condition" = "true" ]; then + echo "PASS [$context]" + PASS=$((PASS + 1)) + else + echo "FAIL [$context]" >&2 + FAIL=$((FAIL + 1)) + fi +} + +# workspace_for FIXTURE [LABEL] -> a fresh disposable copy of the fixture +# + the real Makefile, at a path unique to this call — via `mktemp -d`, +# not a hand-rolled counter. An incrementing global counter was tried +# first and silently didn't work: every call here is invoked as +# `X="$(workspace_for ...)"`, and command substitution always forks a +# subshell in bash, so the counter's increment never escaped back to the +# caller — every call saw the counter at its initial value and collided +# on the same destination name. `cp -R src dest` then nests src *inside* +# dest instead of overlaying it once dest already exists from a prior +# call, corrupting every call after the first (caught by hand: the +# second/third calls' `.devrail.yml` overwrites landed on the right path, +# but leftover root-owned `.pytest_cache`/`__pycache__` from the *first* +# call's `make test` run made the later calls' `rm -rf` of the reused +# path fail with permission errors). +workspace_for() { + local fixture="$1" + local label="${2:-${fixture}}" + local dest + dest="$(mktemp -d "${WORKDIR}/${label}-XXXXXX")" + rmdir "$dest" # mktemp -d creates it; cp -R needs it absent to copy INTO it, not nest under it + cp -R "${FIXTURES}/${fixture}" "$dest" + cp "${REPO_ROOT}/Makefile" "${dest}/Makefile" + printf '%s' "$dest" +} + +# no_test_services_resources -> "true" if no devrail-test-* container or +# network exists on the host right now. +no_test_services_resources() { + local containers networks + containers="$(docker ps -a --filter "name=devrail-test-" --format '{{.Names}}')" + networks="$(docker network ls --filter "name=devrail-test-" --format '{{.Name}}')" + if [ -z "$containers" ] && [ -z "$networks" ]; then + echo "true" + else + echo "false" + fi +} + +# run_make_test WORKSPACE LOGFILE -> prints the real exit code of `make +# test` run from WORKSPACE, output captured to LOGFILE. Uses an explicit +# if/else to capture the exit code — under `set -e`, a bare command +# followed by `rc=$?` aborts the script on failure before `rc=$?` ever +# runs (confirmed the hard way while writing this script: the first +# version used exactly that pattern and silently died mid-suite on the +# first non-zero exit, the same class of bug Story 15.2's review caught +# in lib/dependency-install.sh's `local rc=$?` after a bare `if...fi`). +run_make_test() { + local ws="$1" logfile="$2" + if (cd "$ws" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"$logfile" 2>&1); then + echo 0 + else + echo $? + fi +} + +echo "==> postgres:16 alone — real query through the injected DATABASE_URL" +PG_WS="$(workspace_for test-services-pg-redis)" +cat >"${PG_WS}/.devrail.yml" <<'EOF' +languages: + - python +test: + services: + - postgres:16 +EOF +cat >"${PG_WS}/requirements.txt" <<'EOF' +psycopg2-binary==2.9.10 +EOF +cat >"${PG_WS}/tests/test_services.py" <<'EOF' +import os +import psycopg2 + + +def test_postgres_is_reachable_and_queryable(): + conn = psycopg2.connect(os.environ["DATABASE_URL"]) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1;") + assert cur.fetchone() == (1,) + finally: + conn.close() +EOF +PG_EXIT="$(run_make_test "$PG_WS" "${WORKDIR}/pg.log")" +assert_eq "0" "$PG_EXIT" "postgres-alone/exit-code" +assert_true "$(no_test_services_resources)" "postgres-alone/teardown-clean" + +echo "==> redis:7 alone — real SET/GET through the injected REDIS_URL" +REDIS_WS="$(workspace_for test-services-pg-redis)" +cat >"${REDIS_WS}/.devrail.yml" <<'EOF' +languages: + - python +test: + services: + - redis:7 +EOF +cat >"${REDIS_WS}/requirements.txt" <<'EOF' +redis==5.2.1 +EOF +cat >"${REDIS_WS}/tests/test_services.py" <<'EOF' +import os +import redis + + +def test_redis_is_reachable_and_usable(): + client = redis.from_url(os.environ["REDIS_URL"]) + client.set("story-15-4", "works") + assert client.get("story-15-4") == b"works" +EOF +REDIS_EXIT="$(run_make_test "$REDIS_WS" "${WORKDIR}/redis.log")" +assert_eq "0" "$REDIS_EXIT" "redis-alone/exit-code" +assert_true "$(no_test_services_resources)" "redis-alone/teardown-clean" + +echo "==> postgres + redis together — one network, both env vars injected" +BOTH_WS="$(workspace_for test-services-pg-redis)" +BOTH_EXIT="$(run_make_test "$BOTH_WS" "${WORKDIR}/both.log")" +assert_eq "0" "$BOTH_EXIT" "both/exit-code" +assert_true "$(no_test_services_resources)" "both/teardown-clean" + +echo "==> no test.services declared — make test unaffected (regression)" +NOOP_WS="${WORKDIR}/single-root-python" +cp -R "${FIXTURES}/single-root-python" "$NOOP_WS" +cp "${REPO_ROOT}/Makefile" "${NOOP_WS}/Makefile" +NOOP_EXIT="$(run_make_test "$NOOP_WS" "${WORKDIR}/noop.log")" +assert_eq "0" "$NOOP_EXIT" "no-services/exit-code" +NOOP_OUT="$(cat "${WORKDIR}/noop.log")" +NOOP_SUMMARY=$(printf '%s\n' "$NOOP_OUT" | grep -o '{"target":"test".*}' | tail -1) || true +assert_eq "pass" "$(printf '%s' "$NOOP_SUMMARY" | jq -r '.status')" "no-services/status" +# Note: _test-services-host-bin's extraction step logs unconditionally +# (it fetches the script itself, before the script can even check whether +# services are declared) — that's expected and fine, extraction is cheap +# and cached. What must NOT happen is actual orchestration: no network +# created, no service container started. +if printf '%s\n' "$NOOP_OUT" | grep -qE "created ephemeral test-services network|starting test service"; then + echo "FAIL [no-services/no-orchestration-attempted]: a service network/container was started when no services were declared" >&2 + FAIL=$((FAIL + 1)) +else + echo "PASS [no-services/no-orchestration-attempted]" + PASS=$((PASS + 1)) +fi + +echo "==> docker_network + test.services both set — fails fast, nothing started" +MUTEX_WS="$(workspace_for test-services-mutex)" +MUTEX_EXIT=0 +(cd "$MUTEX_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/mutex.log" 2>&1) || MUTEX_EXIT=$? +assert_eq "2" "$MUTEX_EXIT" "mutex/exit-code" +if grep -q "cannot both be set" "${WORKDIR}/mutex.log"; then + echo "PASS [mutex/clear-error-message]" + PASS=$((PASS + 1)) +else + echo "FAIL [mutex/clear-error-message]: expected a 'cannot both be set' error" >&2 + FAIL=$((FAIL + 1)) +fi +assert_true "$(no_test_services_resources)" "mutex/nothing-started" + +echo "==> unsupported service entry (mysql:8) — fails fast, nothing started" +UNSUPPORTED_WS="$(workspace_for test-services-unsupported)" +UNSUPPORTED_EXIT=0 +(cd "$UNSUPPORTED_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/unsupported.log" 2>&1) || UNSUPPORTED_EXIT=$? +assert_eq "2" "$UNSUPPORTED_EXIT" "unsupported/exit-code" +if grep -q "unsupported test.services entry 'mysql:8'" "${WORKDIR}/unsupported.log"; then + echo "PASS [unsupported/clear-error-message]" + PASS=$((PASS + 1)) +else + echo "FAIL [unsupported/clear-error-message]: expected an error naming 'mysql:8'" >&2 + FAIL=$((FAIL + 1)) +fi +assert_true "$(no_test_services_resources)" "unsupported/nothing-started" + +echo "==> mid-flight SIGKILL leaves orphaned resources; the next run detects and cleans them up" +KILL_WS="$(workspace_for test-services-pg-redis)" +(cd "$KILL_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/kill1.log" 2>&1) & +KILL_PID=$! +sleep 3 +# Kill the backgrounded `make test` process itself, not its process +# group — a non-interactive script doesn't get a separate pgid per +# background job, so a group-kill here would take out this script too +# (confirmed the hard way: the whole test suite died mid-run the first +# time this used `kill -- -$PGID`). Killing just the PID is also the more +# realistic simulation: a docker container already started with `-d` is +# detached and keeps running even after its parent `make`/script process +# is gone, which is exactly the orphan scenario AC 8 needs to reproduce. +kill -9 "$KILL_PID" 2>/dev/null || true +sleep 1 +assert_true "$([ "$(no_test_services_resources)" = "false" ] && echo true || echo false)" "sigkill/orphan-actually-left-behind" + +KILL_RERUN_EXIT="$(run_make_test "$KILL_WS" "${WORKDIR}/kill2.log")" +assert_eq "0" "$KILL_RERUN_EXIT" "sigkill/rerun-succeeds" +if grep -q "leftover test-services state" "${WORKDIR}/kill2.log"; then + echo "PASS [sigkill/stale-state-detected-and-cleaned]" + PASS=$((PASS + 1)) +else + echo "FAIL [sigkill/stale-state-detected-and-cleaned]: expected the rerun to log a leftover-state cleanup" >&2 + FAIL=$((FAIL + 1)) +fi +assert_true "$(no_test_services_resources)" "sigkill/final-teardown-clean" + +echo "" +echo "===================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "===================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi From 457818f92f0ab7de085adc0a2d8bc3842eacc356 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Mon, 27 Jul 2026 11:52:47 -0500 Subject: [PATCH 08/11] fix(makefile): address Story 15.4 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _test-services-host-bin lacked the HAS_PLUGINS_DECLARED-style guard its own doc comment claimed to mirror from _devrail-host-bin, so every consumer repo without a local scripts/ paid a docker create/cp/rm cost on every image-tag bump even when test.services was never declared. Added a matching HAS_TEST_SERVICES_DECLARED guard on both the extraction target and _test-services-up's invocation, and hardened test:'s cleanup trap to check the extracted script's existence rather than assume it, since skipping extraction makes that no longer a safe assumption. Also rejected duplicate service kinds (e.g. two postgres: entries) during validation — previously both containers would start, but the env file's last-line-wins semantics meant the first became silently unreachable via DATABASE_URL for the whole test run. Story 15.4 code review. --- Makefile | 28 ++++++++++++++++++++++------ scripts/test-services.sh | 14 ++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 9e0e4f2..9aad556 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,15 @@ DEVRAIL_DOCKER_NETWORK := $(shell yq -r '.docker_network // ""' $(DEVRAIL_CONFIG DEVRAIL_NETWORK_FLAG := $(if $(DEVRAIL_DOCKER_NETWORK),--network $(DEVRAIL_DOCKER_NETWORK),) DEVRAIL_VOLUME_FLAGS := $(shell yq -r '.docker_volumes // [] | .[] | "-v " + .' $(DEVRAIL_CONFIG) 2>/dev/null) +# HAS_TEST_SERVICES_DECLARED — set when .devrail.yml has a non-empty +# `test.services` list. Mirrors HAS_PLUGINS_DECLARED above: guards +# `_test-services-host-bin`'s extraction AND `_test-services-up`'s +# invocation so a consumer repo that never declares test.services (the +# common case) pays neither the docker create/cp/rm extraction cost nor +# risks invoking a script that was never extracted. +DEVRAIL_TEST_SERVICES_PROBE := $(shell yq -r '.test.services // [] | length' $(DEVRAIL_CONFIG) 2>/dev/null) +HAS_TEST_SERVICES_DECLARED := $(if $(filter-out 0,$(DEVRAIL_TEST_SERVICES_PROBE)),yes,) + # Story 15.4: test.services ephemeral containers. Recursively-expanded (=, # not :=) so these re-evaluate on every DOCKER_RUN expansion — picking up # the network/env-file that `_test-services-up` (a host-side prerequisite @@ -224,12 +233,17 @@ _extended-image: _ensure-host-cache _devrail-host-bin # --- _test-services-host-bin: extract test-services.sh + lib/log.sh from container --- # Story 15.4: consumer template repos inherit this Makefile but not # scripts/, mirroring _devrail-host-bin's pattern exactly (same cache -# file, same docker create/cp/rm shape). When the dev-toolchain repo -# itself runs (scripts/ present locally) we use the on-disk copy so -# changes take effect without a rebuild. Otherwise extract from the -# resolved core image to .devrail/host-bin/. +# file, same docker create/cp/rm shape, same HAS_*_DECLARED upfront guard +# so a repo that never declares test.services — the common case — pays +# no docker create/cp/rm cost at all, not even once per image tag). +# When the dev-toolchain repo itself runs (scripts/ present locally) we +# use the on-disk copy so changes take effect without a rebuild. +# Otherwise extract from the resolved core image to .devrail/host-bin/. _test-services-host-bin: - @if [ -f scripts/test-services.sh ]; then \ + @if [ -z "$(HAS_TEST_SERVICES_DECLARED)" ]; then \ + exit 0; \ + fi; \ + if [ -f scripts/test-services.sh ]; then \ exit 0; \ fi; \ expected="$(DEVRAIL_IMAGE):$(DEVRAIL_TAG)"; \ @@ -260,6 +274,8 @@ _test-services-host-bin: _test-services-up: _ensure-host-cache _test-services-host-bin @if [ -f scripts/test-services.sh ]; then \ bash scripts/test-services.sh up; \ + elif [ -z "$(HAS_TEST_SERVICES_DECLARED)" ]; then \ + exit 0; \ else \ DEVRAIL_LIB="$$(pwd)/.devrail/host-bin/lib" \ bash .devrail/host-bin/scripts/test-services.sh up; \ @@ -337,7 +353,7 @@ test: _ensure-host-cache _extended-image _test-services-up ## Run validation tes @trap '\ if [ -f scripts/test-services.sh ]; then \ bash scripts/test-services.sh down; \ - else \ + elif [ -f .devrail/host-bin/scripts/test-services.sh ]; then \ DEVRAIL_LIB="$$(pwd)/.devrail/host-bin/lib" bash .devrail/host-bin/scripts/test-services.sh down; \ fi \ ' EXIT; \ diff --git a/scripts/test-services.sh b/scripts/test-services.sh index b6ded7f..0cedfb6 100644 --- a/scripts/test-services.sh +++ b/scripts/test-services.sh @@ -151,8 +151,13 @@ _up() { exit 2 fi - # Validate the full list before starting anything. - local service kind + # Validate the full list before starting anything: every entry must be a + # supported kind, and no kind may repeat. A repeat would silently shadow + # itself — DATABASE_URL/REDIS_URL is one env var per kind, so a second + # postgres entry's connection string would overwrite the first's in the + # env file (last line wins), leaving the first container running but + # unreachable via the injected env var for the rest of the test run. + local service kind seen_kinds="" while IFS= read -r service; do [[ -z "${service}" ]] && continue kind="$(_service_kind "${service}")" @@ -160,6 +165,11 @@ _up() { log_error "unsupported test.services entry '${service}' — only postgres: and redis: are supported" 2 exit 2 fi + if [[ " ${seen_kinds} " == *" ${kind} "* ]]; then + log_error "duplicate test.services entry for '${kind}' — only one ${kind}: entry is supported at a time" 2 + exit 2 + fi + seen_kinds="${seen_kinds} ${kind}" done <<<"${services}" mkdir -p "${STATE_DIR}" From afa9d330360e0d8db5317d57b99cad1d50c11736 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Tue, 28 Jul 2026 15:07:14 -0500 Subject: [PATCH 09/11] fix(makefile): drop removed baseUrl from monorepo-python-js test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's fresh full image build picked up typescript@7.0.2 (npm install -g floats to latest), which fully removed the baseUrl compiler option the Story 15.1 tsconfig.json fixture used with "bundler" moduleResolution — tsc now errors (TS5102/TS5090) instead of just deprecation-warning. Local development on this branch never caught this because iterative testing used a fast lib/scripts overlay on an older pinned base image, never a fresh full docker build. paths entries under "bundler" resolution don't need baseUrl — made them explicitly relative instead ("./src/*"), verified against the real image. Re-ran the full existing regression suite (46 project- discover, 12 dependency-install, 19 test-services, 16 plugin-resolver, 11 plugin-build-pipeline, 15 plugin-execution, 4 kotlin-plugin- extraction) against a real `docker build .` image — all green. --- tests/fixtures/monorepo-python-js/frontend/tsconfig.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/fixtures/monorepo-python-js/frontend/tsconfig.json b/tests/fixtures/monorepo-python-js/frontend/tsconfig.json index 1664579..408ecd4 100644 --- a/tests/fixtures/monorepo-python-js/frontend/tsconfig.json +++ b/tests/fixtures/monorepo-python-js/frontend/tsconfig.json @@ -5,9 +5,7 @@ "moduleResolution": "bundler", "strict": true, "noEmit": true, - "ignoreDeprecations": "6.0", - "baseUrl": ".", - "paths": { "@/*": ["src/*"] } + "paths": { "@/*": ["./src/*"] } }, "include": ["src/greet.ts"] } From f72d11118131cfe4d487c4e081bff168ff80bd69 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Tue, 28 Jul 2026 15:38:44 -0500 Subject: [PATCH 10/11] fix(makefile): poll for a live container before the SIGKILL test, not sleep(3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed a different assertion than the one it was written to prove: sigkill/orphan-actually-left-behind failed on GitHub Actions even though it passed locally every time. Root cause: the fixed `sleep 3` before the kill was calibrated on a machine where the pre-container work (host-bin extraction into a cache-empty workspace: docker create + 2 docker cp + docker rm, then network create + container start) reliably finished within 3 seconds. On the CI runner it didn't — the kill fired mid-extraction, before any devrail-test-* resource existed, so there was nothing to orphan and the assertion this case exists to prove never got a chance to be true (the follow-on stale-state-detected assertion failed as a direct consequence). Replaced the fixed sleep with a poll loop that waits for actual evidence (a devrail-test-* container or network existing) up to 60s, then kills. Verified locally: 19/19 still green. --- tests/test-test-services.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test-test-services.sh b/tests/test-test-services.sh index 75f772e..a589b8f 100644 --- a/tests/test-test-services.sh +++ b/tests/test-test-services.sh @@ -252,7 +252,21 @@ echo "==> mid-flight SIGKILL leaves orphaned resources; the next run detects and KILL_WS="$(workspace_for test-services-pg-redis)" (cd "$KILL_WS" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"${WORKDIR}/kill1.log" 2>&1) & KILL_PID=$! -sleep 3 +# Wait for actual evidence a service container exists, not a fixed sleep — +# a fixed sleep (this used `sleep 3`) is calibrated to one machine's Docker +# overhead (host-bin extraction into a brand-new, cache-empty KILL_WS: a +# docker create + 2 docker cp + docker rm round trip, then network create + +# container start) and goes flaky the moment CI's runner is slower or +# faster than whatever machine picked the number (caught for real: this +# passed locally every time but failed in GitHub Actions CI, where the +# kill fired before any devrail-test-* resource existed yet — killing +# during the extraction/build phase leaves nothing to orphan, so the very +# assertion this case exists to prove never got a chance to be true). +elapsed=0 +while [ "$(no_test_services_resources)" = "true" ] && [ "$elapsed" -lt 60 ]; do + sleep 1 + elapsed=$((elapsed + 1)) +done # Kill the backgrounded `make test` process itself, not its process # group — a non-interactive script doesn't get a separate pgid per # background job, so a group-kill here would take out this script too From d7722c92ef7c2333cd2af95be9057305c43d0224 Mon Sep 17 00:00:00 2001 From: Matthew Mellor Date: Tue, 28 Jul 2026 16:11:09 -0500 Subject: [PATCH 11/11] fix(makefile): dump make test output to stderr on run_make_test failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SIGKILL rerun failed in CI (exit 2, expected 0) with no visibility into why — the test only prints PASS/FAIL summaries, never the captured `make test` log, so there's nothing to diagnose a CI-only failure from. This isn't a guess at the underlying cause; it's the missing instrumentation needed to find it without more blind round-trips. Passing locally 19/19 either way (the dump only fires on a nonzero exit). --- tests/test-test-services.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test-test-services.sh b/tests/test-test-services.sh index a589b8f..e990796 100644 --- a/tests/test-test-services.sh +++ b/tests/test-test-services.sh @@ -128,12 +128,21 @@ no_test_services_resources() { # first non-zero exit, the same class of bug Story 15.2's review caught # in lib/dependency-install.sh's `local rc=$?` after a bare `if...fi`). run_make_test() { - local ws="$1" logfile="$2" + local ws="$1" logfile="$2" rc if (cd "$ws" && DEVRAIL_IMAGE="$IMAGE_NAME" DEVRAIL_TAG="$IMAGE_TAG" make test >"$logfile" 2>&1); then - echo 0 + rc=0 else - echo $? + rc=$? fi + # On failure, dump the captured output to stderr (not stdout — this + # function's stdout is captured via $(...) as the exit code) so a CI + # failure shows the real error instead of just a PASS/FAIL summary. + if [ "$rc" -ne 0 ]; then + echo "--- make test output (${logfile}), exit ${rc} ---" >&2 + cat "$logfile" >&2 + echo "--- end output ---" >&2 + fi + echo "$rc" } echo "==> postgres:16 alone — real query through the injected DATABASE_URL"