diff --git a/.github/actions/dep-scan-core/action.yml b/.github/actions/dep-scan-core/action.yml new file mode 100644 index 0000000..d591890 --- /dev/null +++ b/.github/actions/dep-scan-core/action.yml @@ -0,0 +1,25 @@ +name: Dependency scan core +description: Builds dep-checker, scans template dependencies, and writes dep-report.json plus a step summary. Shared by the PR-facing scan (ci.yml) and the scheduled bot run (dep-checker.yml). Callers must checkout the repo before this step — a local action can't be resolved until the checkout that fetches it has already run. + +runs: + using: composite + steps: + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Build dep-checker + shell: bash + run: make build ARGS="-t dep-checker" + + - name: Run dependency scan + shell: bash + run: ./bin/dep-checker scan --output=dep-report.json + + - name: Show scan summary + shell: bash + run: | + ./bin/dep-checker report --input=dep-report.json --output=dep-report.md + cat dep-report.md + cat dep-report.md >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/dep-checker-process.sh b/.github/scripts/dep-checker-process.sh new file mode 100755 index 0000000..992d0de --- /dev/null +++ b/.github/scripts/dep-checker-process.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Opens (or updates) one PR per outdated/deprecated dependency group found in +# dep-report.json, and tracks deprecations with an issue. Runs from the repo +# root with dep-report.json present (see dep-scan-core action) and requires +# GH_TOKEN + GITHUB_REPOSITORY in the environment. +set -euo pipefail + +NEEDS_WORK=$(jq -r ' + .entries[] | select(.outdated or .deprecated) + | .ecosystem + "|" + .package + "|" + .latest +' dep-report.json | sort -u) + +if [[ -z "$NEEDS_WORK" ]]; then + echo "All template dependencies are up to date." + exit 0 +fi + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +# Helper: map @types/ -> +base_package() { + local pkg="$1" + if [[ "$pkg" == @types/* ]]; then + echo "${pkg#@types/}" + else + echo "$pkg" + fi +} + +update_rank() { + local update_type="$1" + case "$update_type" in + major) echo 3 ;; + minor) echo 2 ;; + patch) echo 1 ;; + *) echo 0 ;; + esac +} + +max_update_type() { + local entries="${1-}" + if [[ -z "$entries" ]]; then + entries=$(cat) + fi + local max=0 + local max_type="unknown" + while IFS= read -r t; do + local rank + rank=$(update_rank "$t") + if [[ "$rank" -gt "$max" ]]; then + max="$rank" + max_type="$t" + fi + done <<< "$entries" + echo "$max_type" +} + +# Track which logical groups have already been processed. +declare -A GROUP_SEEN +MINOR_PATCH_GROUPED=false + +# Process one (ecosystem, package, latest) tuple at a time, but +# combine @types/ with when both exist for npm. +while IFS='|' read -r ECOSYSTEM PACKAGE LATEST; do + echo "" + echo "=== $ECOSYSTEM/$PACKAGE (latest: $LATEST) ===" + + base="$(base_package "$PACKAGE")" + if [[ "$ECOSYSTEM" = "npm" ]]; then + GROUP_ENTRIES=$(jq -c \ + --arg eco "$ECOSYSTEM" \ + --arg pkg "$PACKAGE" \ + --arg base "$base" \ + '.entries[] + | select(.ecosystem == $eco and ( + .package == $pkg or + .package == ("@types/" + $base) or + .package == $base + ))' \ + dep-report.json) + ENTRY_UPDATE_TYPE=$(max_update_type "$(echo "$GROUP_ENTRIES" | jq -r '.update_type')") + else + ENTRY_UPDATE_TYPE=$(max_update_type "$(jq -r \ + --arg eco "$ECOSYSTEM" \ + --arg pkg "$PACKAGE" \ + '.entries[] | select(.ecosystem == $eco and .package == $pkg) | .update_type' \ + dep-report.json)") + fi + + if [[ "$ECOSYSTEM" = "npm" ]] && { [[ "$ENTRY_UPDATE_TYPE" = "minor" ]] || [[ "$ENTRY_UPDATE_TYPE" = "patch" ]]; }; then + GROUP_KEY="npm|minor-patch" + else + GROUP_KEY="${ECOSYSTEM}|${base}|${ENTRY_UPDATE_TYPE}" + fi + if [[ "${GROUP_SEEN[$GROUP_KEY]+_}" ]]; then + echo " Group ${GROUP_KEY} already processed — skipping." + continue + fi + GROUP_SEEN[$GROUP_KEY]=1 + + BRANCH="" + if [[ "$ECOSYSTEM" = "npm" ]]; then + if [[ "$ENTRY_UPDATE_TYPE" = "minor" ]] || [[ "$ENTRY_UPDATE_TYPE" = "patch" ]]; then + if [[ "$MINOR_PATCH_GROUPED" = "true" ]]; then + echo " Minor/patch npm group already processed — skipping." + continue + fi + MINOR_PATCH_GROUPED=true + ENTRIES=$(jq -c ' + .entries[] + | select(.ecosystem == "npm" and (.update_type == "minor" or .update_type == "patch")) + ' dep-report.json) + BRANCH="deps/npm/minor-and-patch" + else + ENTRIES="$GROUP_ENTRIES" + fi + else + ENTRIES=$(jq -c \ + --arg eco "$ECOSYSTEM" \ + --arg pkg "$PACKAGE" \ + '.entries[] | select(.ecosystem == $eco and .package == $pkg)' \ + dep-report.json) + fi + + if [[ -z "$ENTRIES" ]]; then + echo " No entries found for group $GROUP_KEY — skipping." + continue + fi + + if [[ -z "$BRANCH" ]]; then + if [[ "$ECOSYSTEM" = "npm" ]]; then + BRANCH="deps/${ECOSYSTEM}/${base}" + else + BRANCH="deps/${ECOSYSTEM}/${PACKAGE}" + fi + fi + + # Idempotency: skip if an open PR already targets this branch. + OPEN_PRS=$(gh pr list \ + --head "$BRANCH" \ + --state open \ + --json number \ + --jq 'length' \ + --repo "$GITHUB_REPOSITORY") + if [[ "$OPEN_PRS" -gt 0 ]]; then + echo " Open PR already exists on $BRANCH — skipping." + continue + fi + + IS_DEPRECATED=$(echo "$ENTRIES" | jq -r 'select(.deprecated) | .deprecated' | head -1) + NOTICE=$(echo "$ENTRIES" | jq -r 'select(.deprecated) | .deprecation_notice // ""' | head -1) + + # Create a fresh branch from main. + git checkout -B "$BRANCH" origin/main + + # Patch every generator that declares this package group. + # --skip-manifest-bump prevents each patch call from incrementing + # the version independently; we do one bump per generator below. + PATCH_FAILED=false + declare -A GEN_MAX_RANK + declare -A GEN_MAX_TYPE + while IFS= read -r entry; do + GENERATOR=$(echo "$entry" | jq -r '.generator') + ENTRY_PACKAGE=$(echo "$entry" | jq -r '.package') + CURRENT=$(echo "$entry" | jq -r '.current') + ENTRY_LATEST=$(echo "$entry" | jq -r '.latest') + ENTRY_UPDATE_TYPE=$(echo "$entry" | jq -r '.update_type') + + # Track the highest-severity update type seen for each generator. + cur_rank="${GEN_MAX_RANK[$GENERATOR]:-0}" + new_rank=$(update_rank "$ENTRY_UPDATE_TYPE") + if [[ "$new_rank" -gt "$cur_rank" ]]; then + GEN_MAX_RANK[$GENERATOR]="$new_rank" + GEN_MAX_TYPE[$GENERATOR]="$ENTRY_UPDATE_TYPE" + fi + + echo " Patching $GENERATOR: $ENTRY_PACKAGE $CURRENT → ^$ENTRY_LATEST" + if ./bin/dep-checker patch \ + --generator="$GENERATOR" \ + --package="$ENTRY_PACKAGE" \ + --current="$CURRENT" \ + --latest="$ENTRY_LATEST" \ + --skip-manifest-bump > /dev/null; then + echo " OK: $GENERATOR patched" + else + echo " ERROR: patch failed for $GENERATOR" >&2 + PATCH_FAILED=true + break + fi + done <<< "$ENTRIES" + + # Bump each generator's manifest exactly once, using the max + # update type observed for that generator in this branch. + if [[ "$PATCH_FAILED" != "true" ]]; then + for generator in "${!GEN_MAX_TYPE[@]}"; do + bump_type="${GEN_MAX_TYPE[$generator]}" + echo " Bumping manifest for $generator ($bump_type)" + if ! ./bin/dep-checker bump-manifest \ + --generator="$generator" \ + --type="$bump_type"; then + echo " WARN: could not bump manifest for $generator" + fi + done + fi + + if [[ "$PATCH_FAILED" = "true" ]]; then + git checkout main + continue + fi + + # Commit, push, open PR. + CHANGED_GENS=$(echo "$ENTRIES" | jq -r '.generator' | sed 's/^/- `/' | sed 's/$/ `/') + PACKAGE_LABEL=$(echo "$ENTRIES" | jq -r '.package' | sort -u | paste -sd ", " -) + git add generators/ + git commit -m "chore(deps): bump ${PACKAGE_LABEL} in templates" + git push origin "$BRANCH" --force-with-lease + + PR_BODY=$(printf \ + '## Dependency bump\n\nBumps `%s` (%s) in template generators.\n\n### Affected generators\n\n%s\n\n---\n🤖 Generated by [dep-checker](../tree/main/tools/dep-checker)' \ + "$PACKAGE_LABEL" "$ECOSYSTEM" "$CHANGED_GENS") + + gh pr create \ + --title "chore(deps): bump ${PACKAGE_LABEL} in templates" \ + --body "$PR_BODY" \ + --base main \ + --head "$BRANCH" \ + --label "dependencies" \ + --repo "$GITHUB_REPOSITORY" + + # For deprecated packages, also open/reopen a tracking issue. + if [[ "$IS_DEPRECATED" = "true" ]]; then + ISSUE_TITLE="deprecated: ${PACKAGE_LABEL} (${ECOSYSTEM}) used in templates" + + EXISTING=$(gh issue list \ + --search "\"$ISSUE_TITLE\" in:title" \ + --state all \ + --json number,state \ + --jq '.[0] // empty' \ + --repo "$GITHUB_REPOSITORY") + + if [[ -n "$EXISTING" ]]; then + ISSUE_NUM=$(echo "$EXISTING" | jq -r '.number') + ISSUE_STATE=$(echo "$EXISTING" | jq -r '.state') + if [[ "$ISSUE_STATE" = "CLOSED" ]]; then + gh issue reopen "$ISSUE_NUM" --repo "$GITHUB_REPOSITORY" + gh issue comment "$ISSUE_NUM" \ + --body "Package is still deprecated as of $(date -u +%Y-%m-%d). Reopening for review." \ + --repo "$GITHUB_REPOSITORY" + fi + else + ISSUE_BODY=$(printf \ + '## Deprecated package\n\n`%s` (%s) is marked deprecated on its registry.\n\n**Deprecation notice:** %s\n\n### Affected generators\n\n%s\n\n### What to do\n\n- Check whether there is a recommended replacement package\n- Update all affected generators to use the replacement\n- Close this issue once resolved\n\nA version-bump PR has been opened — review whether it resolves the deprecation or whether a package rename is needed.' \ + "$PACKAGE_LABEL" "$ECOSYSTEM" "$NOTICE" "$CHANGED_GENS") + + gh issue create \ + --title "$ISSUE_TITLE" \ + --body "$ISSUE_BODY" \ + --label "dependencies,deprecated" \ + --repo "$GITHUB_REPOSITORY" + fi + fi + + git checkout main + +done <<< "$NEEDS_WORK" diff --git a/.github/scripts/dep-scan-comment-pr.sh b/.github/scripts/dep-scan-comment-pr.sh new file mode 100755 index 0000000..667509c --- /dev/null +++ b/.github/scripts/dep-scan-comment-pr.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Posts (or updates) the dependency-report comment on the current PR from +# pr-dep-report.json. Requires GH_TOKEN, PR_NUMBER, and GITHUB_REPOSITORY in +# the environment (all standard Actions step env). +set -euo pipefail + +ENTRY_COUNT=$(jq '.entries | length' pr-dep-report.json) +if [[ "$ENTRY_COUNT" -eq 0 ]]; then + echo "No tracked dependencies in the changed generators — skipping dep comment." + exit 0 +fi + +./bin/dep-checker report --input=pr-dep-report.json --output=pr-dep-report.md + +MARKER="" +BODY="${MARKER} +$(cat pr-dep-report.md)" + +# Upsert: update existing marker comment, or create a new one. +EXISTING_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ + --jq '[.[] | select(.body | startswith(""))] | first | .id // empty') + +if [[ -n "$EXISTING_ID" ]]; then + gh api "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_ID" \ + --method PATCH \ + --field body="$BODY" + echo "Updated dep comment $EXISTING_ID on PR #$PR_NUMBER." +else + gh pr comment "$PR_NUMBER" --body "$BODY" --repo "$GITHUB_REPOSITORY" + echo "Created dep comment on PR #$PR_NUMBER." +fi diff --git a/.github/scripts/dep-scan-filter-pr.sh b/.github/scripts/dep-scan-filter-pr.sh new file mode 100755 index 0000000..b39f778 --- /dev/null +++ b/.github/scripts/dep-scan-filter-pr.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Filters dep-report.json down to the generators touched by the current PR, +# writing pr-dep-report.json. Requires GH_TOKEN, PR_NUMBER, and +# GITHUB_REPOSITORY in the environment (all standard Actions step env). +set -euo pipefail + +# Generator directories touched by this PR. +# Use --paginate + per_page=100 so large PRs (200+ files) are fully covered. +# --jq extracts one generator name per line per page; sort -u deduplicates +# across pages; jq -R/jq -s converts the text list to a JSON array. +CHANGED_GENS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \ + --paginate \ + --jq '[.[] | select(.filename | startswith("generators/")) | .filename | split("/")[1]] | .[]' \ + | sort -u \ + | jq -R . | jq -s .) + +echo "Changed generators: $CHANGED_GENS" + +if [[ "$CHANGED_GENS" = "[]" ]]; then + echo "No generator files changed in this PR — producing empty filtered report." + jq '. + {entries: []}' dep-report.json > pr-dep-report.json +else + jq --argjson gens "$CHANGED_GENS" \ + '. + {entries: [.entries[] | select(.generator as $g | ($gens | index($g)) != null)]}' \ + dep-report.json > pr-dep-report.json + echo "Filtered to $(jq '.entries | length' pr-dep-report.json) entries from generators: $CHANGED_GENS" +fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..54d91b5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: + workflow_call: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + + - name: Build all binaries + run: make build + + - name: Verify dot binary + run: ./bin/dot version + + - name: Upload binaries artifact + uses: actions/upload-artifact@v7 + with: + name: dot-binaries-linux-amd64 + path: bin/ + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0ffa6..e865e26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,112 +6,44 @@ on: pull_request: branches: [main] +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: validate: - name: Format · Vet · Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - with: - go-version: "1.26" - cache: true - - - name: Check formatting - run: | - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "Unformatted files (run 'make fmt' locally):" - echo "$unformatted" - exit 1 - fi - - - name: Run go vet - run: go vet ./... - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: latest + uses: ./.github/workflows/validate.yml + + commitlint: + uses: ./.github/workflows/commitlint.yml + permissions: + contents: read + pull-requests: write + + dependency-review: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/dependency-review.yml + permissions: + contents: read + pull-requests: write + + dep-scan: + if: github.event_name == 'pull_request' + uses: ./.github/workflows/dep-scan.yml + permissions: + contents: read + pull-requests: write test: - name: Unit tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - with: - go-version: "1.26" - cache: true - - - name: Run tests with race detector - run: go test -race -coverprofile=coverage.out ./... - - - name: Upload coverage - uses: codecov/codecov-action@v7 - with: - files: ./coverage.out - fail_ci_if_error: false + uses: ./.github/workflows/unit-tests.yml build: - name: Build - runs-on: ubuntu-latest needs: [validate, test] - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - with: - go-version: "1.26" - cache: true - - - name: Build all binaries - run: make build - - - name: Verify dot binary - run: ./bin/dot version - - - name: Upload binaries artifact - uses: actions/upload-artifact@v7 - with: - name: dot-binaries-linux-amd64 - path: bin/ - retention-days: 7 + uses: ./.github/workflows/build.yml test-flows: - name: End-to-end flow tests - runs-on: ubuntu-latest needs: [build] - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - with: - go-version: "1.26" - cache: true - - - uses: pnpm/action-setup@v6 - with: - version: latest - - - uses: actions/setup-node@v6 - with: - node-version: "22" - - - name: Download binaries - uses: actions/download-artifact@v8 - with: - name: dot-binaries-linux-amd64 - path: bin/ - - - name: Make binaries executable - run: chmod +x bin/* - - - name: Run end-to-end flow tests - run: ./bin/test-flow -dir tools/test-flow/testdata + uses: ./.github/workflows/test-flows.yml diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8b0970c..64e2cb9 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,14 +1,7 @@ name: Commitlint on: - pull_request: - branches: [main] - push: - branches: [main] - -concurrency: - group: commitlint-${{ github.ref }} - cancel-in-progress: true + workflow_call: jobs: commitlint: diff --git a/.github/workflows/dep-checker.yml b/.github/workflows/dep-checker.yml index c4cdd8b..67de9fe 100644 --- a/.github/workflows/dep-checker.yml +++ b/.github/workflows/dep-checker.yml @@ -1,120 +1,28 @@ name: Template Dependency Check +# The PR-facing half of this check (scan + step summary + PR comment) lives +# in ci.yml's dep-scan job, alongside the rest of the per-PR pipeline. This +# workflow only handles the scheduled/manual bot run that opens dependency +# bump PRs and deprecation issues on its own — it has no PR-review purpose, +# so it stays out of the PR check graph. on: - pull_request: - branches: [main] schedule: # Every Wednesday at 09:00 UTC — offset from Dependabot (Monday) to # avoid PR pile-ups on the same day. - cron: "0 9 * * 3" workflow_dispatch: -# PRs need read + pull-requests write (to post/update the dep comment). -# schedule/dispatch need write to create branches, PRs, and issues. -# Scoped at job level below. permissions: contents: read jobs: - # Runs on every PR and every scheduled/manual trigger. - # On PRs: shows the report as a step summary, comments filtered results on the PR. - # On schedule/dispatch: feeds the report to the process job which opens PRs. scan: name: Scan template dependencies runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Build dep-checker - run: make build ARGS="-t dep-checker" - - - name: Run dependency scan - run: ./bin/dep-checker scan --output=dep-report.json - - - name: Show scan summary - run: | - ./bin/dep-checker report --input=dep-report.json --output=dep-report.md - cat dep-report.md - cat dep-report.md >> $GITHUB_STEP_SUMMARY - - - name: Filter report to PR-changed generators - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - # Generator directories touched by this PR. - # Use --paginate + per_page=100 so large PRs (200+ files) are fully covered. - # --jq extracts one generator name per line per page; sort -u deduplicates - # across pages; jq -R/jq -s converts the text list to a JSON array. - CHANGED_GENS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \ - --paginate \ - --jq '[.[] | select(.filename | startswith("generators/")) | .filename | split("/")[1]] | .[]' \ - | sort -u \ - | jq -R . | jq -s .) - - echo "Changed generators: $CHANGED_GENS" - - if [ "$CHANGED_GENS" = "[]" ]; then - echo "No generator files changed in this PR — producing empty filtered report." - jq '. + {entries: []}' dep-report.json > pr-dep-report.json - else - jq --argjson gens "$CHANGED_GENS" \ - '. + {entries: [.entries[] | select(.generator as $g | ($gens | index($g)) != null)]}' \ - dep-report.json > pr-dep-report.json - echo "Filtered to $(jq '.entries | length' pr-dep-report.json) entries from generators: $CHANGED_GENS" - fi - - - name: Comment PR with dependency changes - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - ENTRY_COUNT=$(jq '.entries | length' pr-dep-report.json) - if [ "$ENTRY_COUNT" -eq 0 ]; then - echo "No tracked dependencies in the changed generators — skipping dep comment." - exit 0 - fi - - ./bin/dep-checker report --input=pr-dep-report.json --output=pr-dep-report.md - - MARKER="" - BODY="${MARKER} - $(cat pr-dep-report.md)" - - # Upsert: update existing marker comment, or create a new one. - EXISTING_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ - --jq '[.[] | select(.body | startswith(""))] | first | .id // empty') - - if [ -n "$EXISTING_ID" ]; then - gh api "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_ID" \ - --method PATCH \ - --field body="$BODY" - echo "Updated dep comment $EXISTING_ID on PR #$PR_NUMBER." - else - gh pr comment "$PR_NUMBER" --body "$BODY" --repo "$GITHUB_REPOSITORY" - echo "Created dep comment on PR #$PR_NUMBER." - fi - - - name: Warn about outstanding major/minor updates - if: github.event_name == 'pull_request' - run: | - if jq -e '.entries[] | select(.outdated and (.update_type == "major" or .update_type == "minor"))' pr-dep-report.json > /dev/null; then - echo "::warning::Outstanding major/minor dependency updates were found in PR-changed generators. Review the dependency report before merging." - fi + - uses: ./.github/actions/dep-scan-core - name: Upload scan report if: always() @@ -124,12 +32,11 @@ jobs: path: dep-report.json retention-days: 7 - # Only runs on schedule or workflow_dispatch — creates PRs and issues. + # Creates PRs and issues from the scan job's report. process: name: Open PRs and issues runs-on: ubuntu-latest needs: scan - if: github.event_name != 'pull_request' environment: ci permissions: contents: write @@ -141,7 +48,7 @@ jobs: fetch-depth: 0 token: ${{ secrets.BOT_TOKEN }} - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -164,264 +71,4 @@ jobs: - name: Process outdated and deprecated dependencies env: GH_TOKEN: ${{ secrets.BOT_TOKEN }} - run: | - set -euo pipefail - - NEEDS_WORK=$(jq -r ' - .entries[] | select(.outdated or .deprecated) - | .ecosystem + "|" + .package + "|" + .latest - ' dep-report.json | sort -u) - - if [ -z "$NEEDS_WORK" ]; then - echo "All template dependencies are up to date." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - # Helper: map @types/ -> - base_package() { - local pkg="$1" - if [[ "$pkg" == @types/* ]]; then - echo "${pkg#@types/}" - else - echo "$pkg" - fi - } - - update_rank() { - case "$1" in - major) echo 3 ;; - minor) echo 2 ;; - patch) echo 1 ;; - *) echo 0 ;; - esac - } - - max_update_type() { - local entries="${1-}" - if [ -z "$entries" ]; then - entries=$(cat) - fi - local max=0 - local max_type="unknown" - while IFS= read -r t; do - local rank - rank=$(update_rank "$t") - if [ "$rank" -gt "$max" ]; then - max="$rank" - max_type="$t" - fi - done <<< "$entries" - echo "$max_type" - } - - # Track which logical groups have already been processed. - declare -A GROUP_SEEN - MINOR_PATCH_GROUPED=false - - # Process one (ecosystem, package, latest) tuple at a time, but - # combine @types/ with when both exist for npm. - while IFS='|' read -r ECOSYSTEM PACKAGE LATEST; do - echo "" - echo "=== $ECOSYSTEM/$PACKAGE (latest: $LATEST) ===" - - base="$(base_package "$PACKAGE")" - if [ "$ECOSYSTEM" = "npm" ]; then - GROUP_ENTRIES=$(jq -c \ - --arg eco "$ECOSYSTEM" \ - --arg pkg "$PACKAGE" \ - --arg base "$base" \ - '.entries[] - | select(.ecosystem == $eco and ( - .package == $pkg or - .package == ("@types/" + $base) or - .package == $base - ))' \ - dep-report.json) - ENTRY_UPDATE_TYPE=$(max_update_type "$(echo "$GROUP_ENTRIES" | jq -r '.update_type')") - else - ENTRY_UPDATE_TYPE=$(max_update_type "$(jq -r \ - --arg eco "$ECOSYSTEM" \ - --arg pkg "$PACKAGE" \ - '.entries[] | select(.ecosystem == $eco and .package == $pkg) | .update_type' \ - dep-report.json)") - fi - - if [ "$ECOSYSTEM" = "npm" ] && { [ "$ENTRY_UPDATE_TYPE" = "minor" ] || [ "$ENTRY_UPDATE_TYPE" = "patch" ]; }; then - GROUP_KEY="npm|minor-patch" - else - GROUP_KEY="${ECOSYSTEM}|${base}|${ENTRY_UPDATE_TYPE}" - fi - if [ "${GROUP_SEEN[$GROUP_KEY]+_}" ]; then - echo " Group ${GROUP_KEY} already processed — skipping." - continue - fi - GROUP_SEEN[$GROUP_KEY]=1 - - BRANCH="" - if [ "$ECOSYSTEM" = "npm" ]; then - if [ "$ENTRY_UPDATE_TYPE" = "minor" ] || [ "$ENTRY_UPDATE_TYPE" = "patch" ]; then - if [ "$MINOR_PATCH_GROUPED" = "true" ]; then - echo " Minor/patch npm group already processed — skipping." - continue - fi - MINOR_PATCH_GROUPED=true - ENTRIES=$(jq -c ' - .entries[] - | select(.ecosystem == "npm" and (.update_type == "minor" or .update_type == "patch")) - ' dep-report.json) - BRANCH="deps/npm/minor-and-patch" - else - ENTRIES="$GROUP_ENTRIES" - fi - else - ENTRIES=$(jq -c \ - --arg eco "$ECOSYSTEM" \ - --arg pkg "$PACKAGE" \ - '.entries[] | select(.ecosystem == $eco and .package == $pkg)' \ - dep-report.json) - fi - - if [ -z "$ENTRIES" ]; then - echo " No entries found for group $GROUP_KEY — skipping." - continue - fi - - if [ -z "$BRANCH" ]; then - if [ "$ECOSYSTEM" = "npm" ]; then - BRANCH="deps/${ECOSYSTEM}/${base}" - else - BRANCH="deps/${ECOSYSTEM}/${PACKAGE}" - fi - fi - - # Idempotency: skip if an open PR already targets this branch. - OPEN_PRS=$(gh pr list \ - --head "$BRANCH" \ - --state open \ - --json number \ - --jq 'length' \ - --repo "$GITHUB_REPOSITORY") - if [ "$OPEN_PRS" -gt 0 ]; then - echo " Open PR already exists on $BRANCH — skipping." - continue - fi - - IS_DEPRECATED=$(echo "$ENTRIES" | jq -r 'select(.deprecated) | .deprecated' | head -1) - NOTICE=$(echo "$ENTRIES" | jq -r 'select(.deprecated) | .deprecation_notice // ""' | head -1) - - # Create a fresh branch from main. - git checkout -B "$BRANCH" origin/main - - # Patch every generator that declares this package group. - # --skip-manifest-bump prevents each patch call from incrementing - # the version independently; we do one bump per generator below. - PATCH_FAILED=false - declare -A GEN_MAX_RANK - declare -A GEN_MAX_TYPE - while IFS= read -r entry; do - GENERATOR=$(echo "$entry" | jq -r '.generator') - ENTRY_PACKAGE=$(echo "$entry" | jq -r '.package') - CURRENT=$(echo "$entry" | jq -r '.current') - ENTRY_LATEST=$(echo "$entry" | jq -r '.latest') - ENTRY_UPDATE_TYPE=$(echo "$entry" | jq -r '.update_type') - - # Track the highest-severity update type seen for each generator. - cur_rank="${GEN_MAX_RANK[$GENERATOR]:-0}" - new_rank=$(update_rank "$ENTRY_UPDATE_TYPE") - if [ "$new_rank" -gt "$cur_rank" ]; then - GEN_MAX_RANK[$GENERATOR]="$new_rank" - GEN_MAX_TYPE[$GENERATOR]="$ENTRY_UPDATE_TYPE" - fi - - echo " Patching $GENERATOR: $ENTRY_PACKAGE $CURRENT → ^$ENTRY_LATEST" - if ./bin/dep-checker patch \ - --generator="$GENERATOR" \ - --package="$ENTRY_PACKAGE" \ - --current="$CURRENT" \ - --latest="$ENTRY_LATEST" \ - --skip-manifest-bump > /dev/null; then - echo " OK: $GENERATOR patched" - else - echo " ERROR: patch failed for $GENERATOR" - PATCH_FAILED=true - break - fi - done <<< "$ENTRIES" - - # Bump each generator's manifest exactly once, using the max - # update type observed for that generator in this branch. - if [ "$PATCH_FAILED" != "true" ]; then - for generator in "${!GEN_MAX_TYPE[@]}"; do - bump_type="${GEN_MAX_TYPE[$generator]}" - echo " Bumping manifest for $generator ($bump_type)" - if ! ./bin/dep-checker bump-manifest \ - --generator="$generator" \ - --type="$bump_type"; then - echo " WARN: could not bump manifest for $generator" - fi - done - fi - - if [ "$PATCH_FAILED" = "true" ]; then - git checkout main - continue - fi - - # Commit, push, open PR. - CHANGED_GENS=$(echo "$ENTRIES" | jq -r '.generator' | sed 's/^/- `/' | sed 's/$/ `/') - PACKAGE_LABEL=$(echo "$ENTRIES" | jq -r '.package' | sort -u | paste -sd ", " -) - git add generators/ - git commit -m "chore(deps): bump ${PACKAGE_LABEL} in templates" - git push origin "$BRANCH" --force-with-lease - - PR_BODY=$(printf \ - '## Dependency bump\n\nBumps `%s` (%s) in template generators.\n\n### Affected generators\n\n%s\n\n---\n🤖 Generated by [dep-checker](../tree/main/tools/dep-checker)' \ - "$PACKAGE_LABEL" "$ECOSYSTEM" "$CHANGED_GENS") - - gh pr create \ - --title "chore(deps): bump ${PACKAGE_LABEL} in templates" \ - --body "$PR_BODY" \ - --base main \ - --head "$BRANCH" \ - --label "dependencies" \ - --repo "$GITHUB_REPOSITORY" - - # For deprecated packages, also open/reopen a tracking issue. - if [ "$IS_DEPRECATED" = "true" ]; then - ISSUE_TITLE="deprecated: ${PACKAGE_LABEL} (${ECOSYSTEM}) used in templates" - - EXISTING=$(gh issue list \ - --search "\"$ISSUE_TITLE\" in:title" \ - --state all \ - --json number,state \ - --jq '.[0] // empty' \ - --repo "$GITHUB_REPOSITORY") - - if [ -n "$EXISTING" ]; then - ISSUE_NUM=$(echo "$EXISTING" | jq -r '.number') - ISSUE_STATE=$(echo "$EXISTING" | jq -r '.state') - if [ "$ISSUE_STATE" = "CLOSED" ]; then - gh issue reopen "$ISSUE_NUM" --repo "$GITHUB_REPOSITORY" - gh issue comment "$ISSUE_NUM" \ - --body "Package is still deprecated as of $(date -u +%Y-%m-%d). Reopening for review." \ - --repo "$GITHUB_REPOSITORY" - fi - else - ISSUE_BODY=$(printf \ - '## Deprecated package\n\n`%s` (%s) is marked deprecated on its registry.\n\n**Deprecation notice:** %s\n\n### Affected generators\n\n%s\n\n### What to do\n\n- Check whether there is a recommended replacement package\n- Update all affected generators to use the replacement\n- Close this issue once resolved\n\nA version-bump PR has been opened — review whether it resolves the deprecation or whether a package rename is needed.' \ - "$PACKAGE_LABEL" "$ECOSYSTEM" "$NOTICE" "$CHANGED_GENS") - - gh issue create \ - --title "$ISSUE_TITLE" \ - --body "$ISSUE_BODY" \ - --label "dependencies,deprecated" \ - --repo "$GITHUB_REPOSITORY" - fi - fi - - git checkout main - - done <<< "$NEEDS_WORK" + run: .github/scripts/dep-checker-process.sh diff --git a/.github/workflows/dep-scan.yml b/.github/workflows/dep-scan.yml new file mode 100644 index 0000000..86d297f --- /dev/null +++ b/.github/workflows/dep-scan.yml @@ -0,0 +1,39 @@ +name: Dependency Scan + +on: + workflow_call: + +jobs: + dep-scan: + name: Scan template dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/dep-scan-core + + - name: Filter report to PR-changed generators + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: .github/scripts/dep-scan-filter-pr.sh + + - name: Comment PR with dependency changes + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: .github/scripts/dep-scan-comment-pr.sh + + - name: Warn about outstanding major/minor updates + run: | + if jq -e '.entries[] | select(.outdated and (.update_type == "major" or .update_type == "minor"))' pr-dep-report.json > /dev/null; then + echo "::warning::Outstanding major/minor dependency updates were found in PR-changed generators. Review the dependency report before merging." + fi + + - name: Upload scan report + if: always() + uses: actions/upload-artifact@v7 + with: + name: dep-report + path: dep-report.json + retention-days: 7 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b7e90f9..08ccfcf 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,12 +1,7 @@ name: Dependency Review on: - pull_request: - branches: [main] - -permissions: - contents: read - pull-requests: write + workflow_call: jobs: dependency-review: diff --git a/.github/workflows/test-flows-clean.yml b/.github/workflows/test-flows-clean.yml new file mode 100644 index 0000000..6a1f9d7 --- /dev/null +++ b/.github/workflows/test-flows-clean.yml @@ -0,0 +1,26 @@ +name: Test Flows Clean Run + +# Monthly integrity check for the test-flow fingerprint cache: runs every +# fixture from scratch (-no-cache), independent of whatever's currently +# cached. PR/push runs trust cache hits unconditionally since the +# fingerprint is content-based rather than a heuristic — this exists to +# catch bugs in the fingerprint logic itself (e.g. a dependency the hash +# misses), not to work around data staleness. +on: + schedule: + # 03:00 UTC on the 1st of each month. + - cron: "0 3 1 * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + uses: ./.github/workflows/build.yml + + test-flows: + needs: build + uses: ./.github/workflows/test-flows.yml + with: + no_cache: true diff --git a/.github/workflows/test-flows.yml b/.github/workflows/test-flows.yml new file mode 100644 index 0000000..1655e42 --- /dev/null +++ b/.github/workflows/test-flows.yml @@ -0,0 +1,90 @@ +name: Test Flows + +on: + workflow_call: + inputs: + no_cache: + description: >- + Bypass the per-fixture cache and run every command from scratch. + Used by the monthly clean-run workflow as an integrity check on + the fingerprint logic itself, independent of cached state. + type: boolean + default: false + +jobs: + list: + name: Discover flow fixtures + runs-on: ubuntu-latest + outputs: + fixtures: ${{ steps.list.outputs.fixtures }} + steps: + - uses: actions/checkout@v7 + + - name: Download binaries + uses: actions/download-artifact@v8 + with: + name: dot-binaries-linux-amd64 + path: bin/ + + - name: Make binaries executable + run: chmod +x bin/* + + - name: List enabled fixtures + id: list + run: echo "fixtures=$(./bin/test-flow -dir tools/test-flow/testdata -list)" >> "$GITHUB_OUTPUT" + + # One job per fixture so results land in parallel instead of streaming + # through a single sequential loop. fail-fast cancels the rest of the + # matrix the moment one fixture fails. + run: + name: "E2E: ${{ matrix.fixture }}" + runs-on: ubuntu-latest + needs: list + strategy: + fail-fast: true + matrix: + fixture: ${{ fromJson(needs.list.outputs.fixtures) }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + version: latest + + - uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Download binaries + uses: actions/download-artifact@v8 + with: + name: dot-binaries-linux-amd64 + path: bin/ + + - name: Make binaries executable + run: chmod +x bin/* + + # Scoped to this matrix job's own fixture — each job only ever reads + # or writes .test-flow-cache/.json, so a shared key across + # the whole matrix would just be unnecessary contention, not a + # correctness issue. The save key includes run_id because actions/ + # cache silently skips saving over an existing exact key; restore-keys + # falls back to the most recent prior save for this fixture. GitHub's + # own eviction (7-day-unused, 10GB/repo) keeps this from growing + # unbounded — entries here are a few hundred bytes each. + - name: Restore fixture cache + if: ${{ !inputs.no_cache }} + uses: actions/cache@v4 + with: + path: .test-flow-cache/${{ matrix.fixture }}.json + key: test-flow-cache-${{ matrix.fixture }}-${{ github.run_id }} + restore-keys: | + test-flow-cache-${{ matrix.fixture }}- + + - name: Run flow fixture + run: ./bin/test-flow -dir tools/test-flow/testdata -only "${{ matrix.fixture }}" ${{ inputs.no_cache && '-no-cache' || '' }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..fb10322 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,25 @@ +name: Unit Tests + +on: + workflow_call: + +jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + + - name: Run tests with race detector + run: go test -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 + with: + files: ./coverage.out + fail_ci_if_error: false diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..8e5b1bd --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,33 @@ +name: Validate + +on: + workflow_call: + +jobs: + validate: + name: Format · Vet · Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Unformatted files (run 'make fmt' locally):" + echo "$unformatted" + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 + with: + version: latest diff --git a/docs/contributor/test-flow.md b/docs/contributor/test-flow.md index 10b8374..d56645f 100644 --- a/docs/contributor/test-flow.md +++ b/docs/contributor/test-flow.md @@ -63,6 +63,9 @@ go run ./tools/test-flow -only turborepo_ts_react # Run multiple fixtures go run ./tools/test-flow -only "turborepo_ts_react,single_go" +# Run up to four fixtures concurrently (the default is GOMAXPROCS) +go run ./tools/test-flow -parallel 4 + # Skip post-gen commands globally (faster, offline) go run ./tools/test-flow -skip-post @@ -96,6 +99,8 @@ make test-flow # equivalent to go run ./tools/test-flow -skip-test | `-skip-post` | `false` | Skip all `PostGenerationCommands` globally. Overrides the fixture's `skip_post_commands`. | | `-skip-test` | `false` | Skip all `TestCommands` globally. Overrides the fixture's `skip_test_commands`. | | `-only NAMES` | (all) | Comma-separated list of fixture `name` values to run. | +| `-parallel N` | `GOMAXPROCS` | Maximum fixtures to run concurrently. Each fixture uses its own scratch directory. | +| `-list` | `false` | Print enabled fixture names as a JSON array and exit. Used by CI to build its test matrix. | | `-keep` | `false` | Do not delete scratch directories after the run. Lets you inspect generated files. | | `-no-cache` | `false` | Ignore cache hits and re-run every case from scratch. Cache entries are still refreshed on success. | | `-keep-going` | `false` | Continue running remaining cases after a failure. Without this flag the runner stops at the **first** failing case (fail-fast is the default). | @@ -104,7 +109,7 @@ make test-flow # equivalent to go run ./tools/test-flow -skip-test ## Fail-fast (default) -The runner stops at the first failing case so you see the failure immediately instead of waiting for the rest of the suite. The summary reports how many cases were skipped: +The runner starts fixtures concurrently up to `-parallel`. On the first failure it stops scheduling new fixtures and cancels active work through its context. A fixture already near completion may still report a result. The summary reports how many cases were skipped: ``` ✗ 1/18 cases failed (10 not run) @@ -121,6 +126,10 @@ make test-flows -- -keep-going # via the Makefile shortcut Failed cases never write to `.test-flow-cache/`, so re-running after a fix only retries cases that didn't pass last time (if combined with the cache). +## CI execution + +CI discovers enabled fixtures with `test-flow -list`, then runs one matrix job per fixture. The matrix uses `fail-fast: true`: all fixture jobs start concurrently, and GitHub cancels queued or in-progress matrix jobs when one fails. + --- ## Case-level cache diff --git a/flows/init.go b/flows/init.go index 18d8327..909bb8b 100644 --- a/flows/init.go +++ b/flows/init.go @@ -292,7 +292,7 @@ func resolveAppGenerators(answers map[string]interface{}, loopStack []flow.LoopF case CLEAN_ARCHITECTURE: out = append(out, inv("backend_architecture_clean_architecture")) case MVC_ARCHITECTURE: - out = append(out, inv("backend_architecture_mvc")) + out = append(out, inv("backend_architecture_mvc_architecture")) case HEXAGONAL_ARCHITECTURE: out = append(out, inv("backend_architecture_hexagonal_architecture")) } diff --git a/generators/backend_architecture_hexagonal_architecture/manifest.go b/generators/backend_architecture_hexagonal_architecture/manifest.go index 5cea2de..b2558a5 100644 --- a/generators/backend_architecture_hexagonal_architecture/manifest.go +++ b/generators/backend_architecture_hexagonal_architecture/manifest.go @@ -5,7 +5,7 @@ import "github.com/version14/dot/pkg/dotapi" // Manifest declares backend-architecture-hexagonal — the generator that scaffolds an // Hexagonal Architecture-based backend structure. var Manifest = dotapi.Manifest{ - Name: "backend_architecture_hexagonal", + Name: "backend_architecture_hexagonal_architecture", Version: "1.0.0", Description: "Scaffolds a base structure for a backend architecture using Hexagonal Architecture", Outputs: []string{ @@ -26,7 +26,7 @@ var Manifest = dotapi.Manifest{ }, Validators: []dotapi.Validator{ { - Name: "backend_architecture_hexagonal", + Name: "backend_architecture_hexagonal_architecture", Checks: []dotapi.Check{ {Type: dotapi.CheckFileExists, Path: "src/adapters/primary/http/controllers/.gitkeep"}, {Type: dotapi.CheckFileExists, Path: "src/adapters/primary/http/routes/.gitkeep"}, diff --git a/generators/backend_architecture_mvc_architecture/manifest.go b/generators/backend_architecture_mvc_architecture/manifest.go index 37a083f..b8823e3 100644 --- a/generators/backend_architecture_mvc_architecture/manifest.go +++ b/generators/backend_architecture_mvc_architecture/manifest.go @@ -5,7 +5,7 @@ import "github.com/version14/dot/pkg/dotapi" // Manifest declares backend-architecture-mvc — the generator that scaffolds an // MVC-based backend structure. var Manifest = dotapi.Manifest{ - Name: "backend_architecture_mvc", + Name: "backend_architecture_mvc_architecture", Version: "1.0.0", Description: "Scaffolds a base structure for a backend architecture using MVC", Outputs: []string{ @@ -22,7 +22,7 @@ var Manifest = dotapi.Manifest{ }, Validators: []dotapi.Validator{ { - Name: "backend_architecture_mvc", + Name: "backend_architecture_mvc_architecture", Checks: []dotapi.Check{ {Type: dotapi.CheckFileExists, Path: "src/controllers/.gitkeep"}, {Type: dotapi.CheckFileExists, Path: "src/views/.gitkeep"}, diff --git a/generators/decorators_hexagonal_adapter/manifest.go b/generators/decorators_hexagonal_adapter/manifest.go index 54a83e2..e74148a 100644 --- a/generators/decorators_hexagonal_adapter/manifest.go +++ b/generators/decorators_hexagonal_adapter/manifest.go @@ -7,7 +7,7 @@ var Manifest = dotapi.Manifest{ Version: "0.2.0", Description: "Wires the decorator router into a Hexagonal project: sample primary HTTP adapter controller, schemas, OpenAPI mount in app.ts", DependsOn: []string{ - "backend_architecture_hexagonal", + "backend_architecture_hexagonal_architecture", "express_decorators_core", "express_openapi_setup", }, diff --git a/generators/decorators_mvc_adapter/manifest.go b/generators/decorators_mvc_adapter/manifest.go index c93930c..f65a1ac 100644 --- a/generators/decorators_mvc_adapter/manifest.go +++ b/generators/decorators_mvc_adapter/manifest.go @@ -7,7 +7,7 @@ var Manifest = dotapi.Manifest{ Version: "0.2.0", Description: "Wires the decorator router into an MVC project: sample controller in src/controllers, schemas in src/shared/validators, OpenAPI mount in app.ts", DependsOn: []string{ - "backend_architecture_mvc", + "backend_architecture_mvc_architecture", "express_decorators_core", "express_openapi_setup", }, diff --git a/generators/postgres_docker_compose/files/docker-compose.yml.tmpl b/generators/postgres_docker_compose/files/docker-compose.yml.tmpl index 0508318..1a261a4 100644 --- a/generators/postgres_docker_compose/files/docker-compose.yml.tmpl +++ b/generators/postgres_docker_compose/files/docker-compose.yml.tmpl @@ -9,7 +9,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: {{.ProjectName}} ports: - - "5433:5432" + - "{{.DBPort}}:5432" volumes: - postgres_data:/var/lib/postgresql/data diff --git a/generators/postgres_docker_compose/generator.go b/generators/postgres_docker_compose/generator.go index 17f0744..ed2d40c 100644 --- a/generators/postgres_docker_compose/generator.go +++ b/generators/postgres_docker_compose/generator.go @@ -3,6 +3,7 @@ package postgresdockercompose import ( "embed" + "github.com/version14/dot/internal/portutil" "github.com/version14/dot/internal/render" "github.com/version14/dot/pkg/dotapi" ) @@ -26,5 +27,6 @@ func (g *Generator) Generate(ctx *dotapi.Context) error { renderer := render.NewLocalFolderRenderer(ctx.State) return renderer.Render(fs, map[string]interface{}{ "ProjectName": projectName, + "DBPort": portutil.PostgresHostPort(projectName), }) } diff --git a/generators/postgres_env_example/generator.go b/generators/postgres_env_example/generator.go index 2b8592e..9cf54d6 100644 --- a/generators/postgres_env_example/generator.go +++ b/generators/postgres_env_example/generator.go @@ -3,6 +3,7 @@ package postgresenvexample import ( "fmt" + "github.com/version14/dot/internal/portutil" "github.com/version14/dot/internal/state" "github.com/version14/dot/pkg/dotapi" ) @@ -25,7 +26,7 @@ func (g *Generator) Generate(ctx *dotapi.Context) error { existing = string(f.Content) } - dbURL := fmt.Sprintf("postgresql://postgres:postgres@localhost:5433/%s", projectName) + dbURL := fmt.Sprintf("postgresql://postgres:postgres@localhost:%d/%s", portutil.PostgresHostPort(projectName), projectName) updated := existing + fmt.Sprintf("\n# PostgreSQL\nDATABASE_URL=%s\n", dbURL) ctx.State.WriteFile(".env.example", []byte(updated), state.ContentRaw) diff --git a/go.mod b/go.mod index bad3ff0..3fb1a81 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/version14/dot go 1.26 require ( + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 gopkg.in/yaml.v3 v3.0.1 @@ -13,7 +14,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect diff --git a/internal/cli/spinner.go b/internal/cli/spinner.go index 2c85972..7639dd1 100644 --- a/internal/cli/spinner.go +++ b/internal/cli/spinner.go @@ -114,6 +114,42 @@ func isStderrTTY() bool { // ── Quiet command runner with spinner UX ─────────────────────────────────── +// CommandProgress receives lifecycle events for each command in a plan run +// via RunCommandsWithProgress. Implementations are called synchronously from +// the goroutine driving the plan (one command at a time, in order), so they +// don't need their own locking — but if an implementation forwards events +// somewhere concurrent (e.g. a UI event loop), that forwarding must be safe +// to call from arbitrary goroutines. +type CommandProgress interface { + // Start fires immediately before a command begins executing. + Start(c commands.PlannedCommand) + // Done fires once a command finishes, successfully or not. output is the + // captured combined stdout/stderr (nil on a dry run). + Done(c commands.PlannedCommand, elapsed time.Duration, output []byte, err error) +} + +// RunCommandsWithProgress executes a list of PlannedCommands sequentially, +// reporting Start/Done to progress for each one. Stops at the first failing +// command and returns its error. +func RunCommandsWithProgress( + ctx context.Context, + runner *commands.Runner, + cmds []commands.PlannedCommand, + progress CommandProgress, +) error { + for _, c := range cmds { + progress.Start(c) + start := time.Now() + output, err := runner.RunOneCaptured(ctx, c) + elapsed := time.Since(start).Round(10 * time.Millisecond) + progress.Done(c, elapsed, output, err) + if err != nil { + return fmt.Errorf("%s: %w", c.Cmd, err) + } + } + return nil +} + // RunCommandsQuiet executes a list of PlannedCommands sequentially with a // Docker-style spinner UX: // @@ -133,29 +169,24 @@ func RunCommandsQuiet( cmds []commands.PlannedCommand, indent int, ) error { - for _, c := range cmds { - if err := runOneQuiet(ctx, runner, c, indent); err != nil { - return err - } - } - return nil + return RunCommandsWithProgress(ctx, runner, cmds, &spinnerProgress{indent: indent}) } -func runOneQuiet( - ctx context.Context, - runner *commands.Runner, - c commands.PlannedCommand, - indent int, -) error { - label := formatCommandLabel(c) +// spinnerProgress is the CommandProgress backing RunCommandsQuiet: it drives +// the same animated-line UX the CLI has always had. +type spinnerProgress struct { + indent int + sp *Spinner +} - sp := StartSpinner(label, indent) - start := time.Now() - output, err := runner.RunOneCaptured(ctx, c) - elapsed := time.Since(start).Round(10 * time.Millisecond) - sp.Stop() +func (p *spinnerProgress) Start(c commands.PlannedCommand) { + p.sp = StartSpinner(formatCommandLabel(c), p.indent) +} - pad := strings.Repeat(" ", indent) +func (p *spinnerProgress) Done(c commands.PlannedCommand, elapsed time.Duration, output []byte, err error) { + p.sp.Stop() + label := formatCommandLabel(c) + pad := strings.Repeat(" ", p.indent) if err != nil { fmt.Fprintf(os.Stderr, "%s%s %s%s%s\n", pad, @@ -164,8 +195,8 @@ func runOneQuiet( timeStyle.Render(" — "+elapsed.String()), failStyle.Render(" FAILED"), ) - printCapturedOutput(output, indent+2) - return fmt.Errorf("%s: %w", c.Cmd, err) + PrintCapturedOutput(output, p.indent+2) + return } fmt.Fprintf(os.Stderr, "%s%s %s%s\n", @@ -174,7 +205,6 @@ func runOneQuiet( label, timeStyle.Render(" — "+elapsed.String()), ) - return nil } // formatCommandLabel returns "" or " background source". @@ -194,9 +224,9 @@ func formatCommandLabel(c commands.PlannedCommand) string { return strings.Join(parts, "") } -// printCapturedOutput dumps the command's combined stdout/stderr beneath the +// PrintCapturedOutput dumps a command's combined stdout/stderr beneath a // failure line, indented and trimmed so very long outputs stay scannable. -func printCapturedOutput(output []byte, indent int) { +func PrintCapturedOutput(output []byte, indent int) { if len(output) == 0 { return } diff --git a/internal/portutil/port.go b/internal/portutil/port.go new file mode 100644 index 0000000..7ccc947 --- /dev/null +++ b/internal/portutil/port.go @@ -0,0 +1,23 @@ +// Package portutil derives stable, per-project host ports for generated +// docker-compose services, so two scaffolded projects don't collide on the +// same fixed port when run side by side. +package portutil + +import "hash/fnv" + +// postgresBase and postgresSpan bound the host ports handed out to +// scaffolded Postgres containers: [40000, 50000). +const ( + postgresBase = 40000 + postgresSpan = 10000 +) + +// PostgresHostPort deterministically maps a project name to a host port for +// its Postgres container. The same name always yields the same port, so the +// docker-compose.yml and .env.example generators can each call this +// independently and stay in agreement. +func PostgresHostPort(projectName string) int { + h := fnv.New32a() + _, _ = h.Write([]byte(projectName)) + return postgresBase + int(h.Sum32()%postgresSpan) +} diff --git a/tools/test-flow/cache_persist.go b/tools/test-flow/cache_persist.go index a2d637a..bfad549 100644 --- a/tools/test-flow/cache_persist.go +++ b/tools/test-flow/cache_persist.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strings" "github.com/version14/dot/internal/generator" "github.com/version14/dot/pkg/dotapi" @@ -18,7 +19,12 @@ import ( // fingerprint algorithm changes. Older entries become invalid automatically. // - v1: initial Cacheable opt-in (default false) // - v2: flipped polarity to NoCache opt-out (default cacheable) -const cacheSchemaVersion = 2 +// - v3: replaced the separate flows-dir/pkg-dotapi/tool-test-flow hashes +// with one denylist-based "core" hash covering the whole repo (see +// coreExcludeDirs); also fixed tool-test-flow previously hashing +// testdata/ wholesale, which invalidated every case on any one +// fixture's edit +const cacheSchemaVersion = 3 // cacheRoot is where successful case fingerprints are persisted. Kept under // the repository root so it follows the working copy (and is gitignored). @@ -43,7 +49,6 @@ type CacheEntry struct { // the invocation list. type CacheKeyInputs struct { CaseFile string // absolute path to the testdata JSON - FlowsDir string // absolute path to the flows/ directory (whole dir is hashed) Invocations []generator.Invocation // resolved generator list Manifests []dotapi.Manifest // matches Invocations SkipPostFlag bool // -skip-post CLI flag @@ -52,12 +57,45 @@ type CacheKeyInputs struct { RepoRoot string // absolute path to the repo root } +// coreExcludeDirs are repo-root-relative directories hashCore never +// descends into, because they already have a narrower, more precise rule, or +// because they're tooling/environment scaffolding rather than source that +// can affect a fixture's behaviour: +// - generators/ → hashed per invoked generator, below +// - tools/test-flow/testdata/ → hashed per case, via the case-file hash +// - docs/, bin/ → never affect fixture behaviour +// - .git/, cacheRoot → pure bookkeeping; hashing them is either noise +// (.git churns without content changes) or self-referential (cacheRoot +// holds the fingerprints this function produces) +// - .claude/, .zed/, .githooks/ → editor/agent config, not tool source; +// also the most likely thing to get touched mid-run by an active +// coding-agent session sharing this checkout, which would otherwise +// make the cache flaky for reasons that have nothing to do with the +// fixtures under test +var coreExcludeDirs = []string{ + ".git", + cacheRoot, + "docs", + "bin", + "generators", + ".claude", + ".zed", + ".githooks", + filepath.Join("tools", "test-flow", "testdata"), +} + // ComputeFingerprint hashes everything that can plausibly change a case's -// behaviour: the testdata file, every involved generator's source tree, the -// flow definition file, the Manifest schema (pkg/dotapi), and the test-flow -// runner itself. CLI flags that change command execution (`-skip-post`, -// `-skip-test`) are folded in too so different modes get different cache -// slots. +// behaviour: the testdata file, every invoked generator's source tree, and +// — as one "core" hash shared by every case — the rest of the repo. Editing +// anything outside the excluded dirs (engine code, flow graphs, plugins, CI +// config, build files, ...) is assumed relevant to every fixture, since it's +// rarely obvious from the outside which fixtures a change like that could +// touch; over-invalidating is the safe failure mode. Markdown never affects +// behaviour so it's excluded everywhere in the core walk (generators/ keeps +// its own .md templates hashed in full via the per-generator rule, since +// there they're shipped output, not background docs). CLI flags that change +// command execution (`-skip-post`, `-skip-test`) are folded in too so +// different modes get different cache slots. func ComputeFingerprint(in CacheKeyInputs) (string, error) { h := sha256.New() fmt.Fprintf(h, "schema:%d\n", cacheSchemaVersion) @@ -68,12 +106,6 @@ func ComputeFingerprint(in CacheKeyInputs) (string, error) { } fmt.Fprintf(h, "case:%s\n", sha256Bytes(caseBytes)) - if in.FlowsDir != "" { - if flowsHash, err := hashDir(in.FlowsDir); err == nil { - fmt.Fprintf(h, "flows-dir:%s\n", flowsHash) - } - } - // Hash every involved generator's source tree. Order by name so the // fingerprint is stable regardless of resolver output order. names := make([]string, 0, len(in.Invocations)) @@ -94,16 +126,12 @@ func ComputeFingerprint(in CacheKeyInputs) (string, error) { fmt.Fprintf(h, "gen:%s:%s\n", name, genHash) } - // pkg/dotapi controls the Manifest schema; touching it reasonably - // invalidates every case. - if dotapiHash, err := hashDir(filepath.Join(in.RepoRoot, "pkg", "dotapi")); err == nil { - fmt.Fprintf(h, "pkg-dotapi:%s\n", dotapiHash) - } - - // The test-flow tool itself can change semantics (cache logic included); - // hashing its source guarantees the cache invalidates on tool edits. - if toolHash, err := hashDir(filepath.Join(in.RepoRoot, "tools", "test-flow")); err == nil { - fmt.Fprintf(h, "tool-test-flow:%s\n", toolHash) + if in.RepoRoot != "" { + coreHash, err := hashCore(in.RepoRoot) + if err != nil { + return "", fmt.Errorf("hash core: %w", err) + } + fmt.Fprintf(h, "core:%s\n", coreHash) } fmt.Fprintf(h, "skip-post:%t\n", in.SkipPostFlag) @@ -211,6 +239,75 @@ func sha256Bytes(b []byte) string { return hex.EncodeToString(sum[:]) } +// hashCore hashes every file under repoRoot except coreExcludeDirs and *.md +// files — see ComputeFingerprint for why. Structurally the same walk as +// hashDir, just with a skip predicate; kept separate rather than +// parameterizing hashDir because the two have different failure semantics +// (hashDir treats a single file as a one-element directory; hashCore always +// operates on the repo root). +type coreFileEntry struct { + path string + hash string +} + +func hashCore(repoRoot string) (string, error) { + var files []coreFileEntry + + err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error { + return walkCoreEntry(repoRoot, path, d, err, &files) + }) + if err != nil { + return "", err + } + + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + + h := sha256.New() + for _, f := range files { + fmt.Fprintf(h, "%s\x00%s\n", f.path, f.hash) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// walkCoreEntry is the WalkDir callback for hashCore: it skips +// coreExcludeDirs and *.md files, hashing everything else into files. +func walkCoreEntry(repoRoot, path string, d fs.DirEntry, err error, files *[]coreFileEntry) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(repoRoot, path) + if relErr != nil { + return relErr + } + if rel == "." { + return nil + } + if d.IsDir() { + if isCoreExcludedDir(rel) { + return fs.SkipDir + } + return nil + } + if strings.EqualFold(filepath.Ext(rel), ".md") { + return nil + } + b, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + *files = append(*files, coreFileEntry{path: rel, hash: sha256Bytes(b)}) + return nil +} + +func isCoreExcludedDir(rel string) bool { + for _, excl := range coreExcludeDirs { + if rel == excl { + return true + } + } + return false +} + // hashDir produces a content hash that depends on every file under root — // sorted by path so ordering is deterministic. Symlinks and hidden files // are included; that's deliberate (test-flow templates can hide anywhere). diff --git a/tools/test-flow/cache_persist_test.go b/tools/test-flow/cache_persist_test.go new file mode 100644 index 0000000..b34170b --- /dev/null +++ b/tools/test-flow/cache_persist_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// write creates path (and its parent dirs) under root with the given +// content, failing the test on any error. +func write(t *testing.T, root, path, content string) { + t.Helper() + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(full), err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", full, err) + } +} + +// newCoreRepo builds a minimal synthetic repo tree exercising every +// coreExcludeDirs entry plus one "real" core file, and returns its root. +func newCoreRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + write(t, root, "internal/cli/runner.go", "package cli") + write(t, root, "README.md", "# hello") + write(t, root, "docs/guide.md", "# guide") + write(t, root, "bin/dot", "binary") + write(t, root, "generators/react_app/generator.go", "package reactapp") + write(t, root, "tools/test-flow/main.go", "package main") + write(t, root, "tools/test-flow/testdata/some_case.json", `{"name":"x"}`) + write(t, root, ".test-flow-cache/some_case.json", `{"fingerprint":"x"}`) + write(t, root, ".git/HEAD", "ref: refs/heads/main") + return root +} + +func TestHashCore_ExcludesDenylistedDirs(t *testing.T) { + root := newCoreRepo(t) + before, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore: %v", err) + } + + // Editing anything under an excluded dir must NOT change the hash. + excludedEdits := map[string]string{ + "docs/guide.md": "# guide, but different now", + "bin/dot": "a completely different binary", + "generators/react_app/generator.go": "package reactapp\n\n// edited", + "tools/test-flow/testdata/some_case.json": `{"name":"y"}`, + ".test-flow-cache/some_case.json": `{"fingerprint":"y"}`, + ".git/HEAD": "ref: refs/heads/other", + "README.md": "# hello, but different now", + ".claude/skills/foo/SKILL.md": "# foo skill, but different now", + ".zed/settings.json": `{"theme":"dark"}`, + ".githooks/pre-commit": "#!/bin/sh\necho edited", + } + for path, content := range excludedEdits { + write(t, root, path, content) + after, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore after editing %s: %v", path, err) + } + if after != before { + t.Errorf("editing excluded path %q changed the core hash; expected it to be ignored", path) + } + } +} + +func TestHashCore_MarkdownExcludedAnywhereInCore(t *testing.T) { + root := newCoreRepo(t) + before, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore: %v", err) + } + + // A new .md file outside any excluded dir (e.g. a root-level doc) must + // not affect the core hash either. + write(t, root, "CONTRIBUTING.md", "# contributing") + after, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore after adding markdown: %v", err) + } + if after != before { + t.Errorf("adding a .md file changed the core hash; markdown should never affect it") + } +} + +func TestHashCore_DetectsRealCoreChanges(t *testing.T) { + root := newCoreRepo(t) + before, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore: %v", err) + } + + // A change to a genuine core file (engine code, not under any excluded + // dir, not markdown) MUST change the hash. + write(t, root, "internal/cli/runner.go", "package cli\n\n// behaviour changed") + after, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore after editing core file: %v", err) + } + if after == before { + t.Errorf("editing internal/cli/runner.go did not change the core hash; core changes must invalidate every fixture") + } +} + +func TestHashCore_DetectsTestFlowToolChangesOutsideTestdata(t *testing.T) { + root := newCoreRepo(t) + before, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore: %v", err) + } + + // tools/test-flow/testdata/ is excluded, but the rest of tools/test-flow + // (the runner itself) is not — this is the nested-exclusion case. + write(t, root, "tools/test-flow/main.go", "package main\n\n// behaviour changed") + after, err := hashCore(root) + if err != nil { + t.Fatalf("hashCore after editing tools/test-flow/main.go: %v", err) + } + if after == before { + t.Errorf("editing tools/test-flow/main.go did not change the core hash; only testdata/ should be excluded, not the whole tool") + } +} diff --git a/tools/test-flow/main.go b/tools/test-flow/main.go index ae4c307..c50c619 100644 --- a/tools/test-flow/main.go +++ b/tools/test-flow/main.go @@ -2,11 +2,14 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "os" "os/signal" "path/filepath" + "runtime" + "sync" "syscall" "github.com/version14/dot/flows" @@ -48,7 +51,14 @@ func main() { keep := flag.Bool("keep", false, "do not delete per-case scratch dirs (so you can inspect outputs)") noCache := flag.Bool("no-cache", false, "ignore cache hits and re-run every case from scratch (cache entries are still refreshed on success)") keepGoing := flag.Bool("keep-going", false, "continue running remaining cases after a failure (default: stop at the first failure)") + parallel := flag.Int("parallel", runtime.GOMAXPROCS(0), "number of cases to run concurrently") + list := flag.Bool("list", false, "print enabled case names as a JSON array and exit") + plain := flag.Bool("plain", false, "force the sequential text log even on an interactive terminal (auto-used when stdin/stdout aren't a TTY, e.g. in CI)") flag.Parse() + if *parallel < 1 { + fmt.Fprintln(os.Stderr, "test-flow: -parallel must be at least 1") + os.Exit(2) + } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() @@ -70,6 +80,13 @@ func main() { fmt.Fprintln(os.Stderr, "test-flow: no cases match -only filter") os.Exit(2) } + if *list { + if err := json.NewEncoder(os.Stdout).Encode(enabledCaseNames(cases)); err != nil { + fmt.Fprintln(os.Stderr, "test-flow:", err) + os.Exit(2) + } + return + } rt, err := cli.DefaultRuntime() if err != nil { @@ -77,9 +94,6 @@ func main() { os.Exit(2) } - rep := NewReporter(len(cases)) - results := make([]*Result, 0, len(cases)) - repoRoot, err := os.Getwd() if err != nil { fmt.Fprintln(os.Stderr, "test-flow:", err) @@ -96,64 +110,203 @@ func main() { repoRoot: repoRoot, } - // Fail-fast by default — stop the loop on the first failing case so - // developers see the failure immediately instead of waiting for the - // remaining cases to finish. Pass -keep-going to run every case. - stopped := false - for _, tc := range cases { - if tc.Disabled { - continue + totalCases := len(enabledCaseNames(cases)) + + pool := poolConfig{parallel: *parallel, keepGoing: *keepGoing} + + var results []*Result + var stopped bool + if *plain || !isInteractive() { + rep := NewPlainReporter(totalCases) + results, stopped = runCases(ctx, cases, registry, rt, rep, opts, pool) + } else { + tr := NewTableReporter(cases, cancel) + done := make(chan struct{}) + go func() { + results, stopped = runCases(ctx, cases, registry, rt, tr, opts, pool) + tr.Finish() + close(done) + }() + if _, err := tr.Run(); err != nil { + fmt.Fprintln(os.Stderr, "test-flow: tui:", err) } + <-done + } - def, ok := registry.Get(tc.FlowID) - if !ok { - r := &Result{Case: tc, Err: fmt.Errorf("unknown flow_id %q", tc.FlowID)} - rep.CaseStart(tc.Name, tc.FlowID) - rep.Step("flow lookup", false, "", r.Err) - rep.CaseEnd(false) - results = append(results, r) - if !*keepGoing { - stopped = true - break - } - continue + if stopped { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "Stopped after the first failure (pass -keep-going to run every case).") + } + + if Summarize(os.Stdout, results, totalCases) > 0 { + os.Exit(1) + } +} + +// isInteractive reports whether both stdin and stdout are attached to a +// terminal — the table reporter needs raw-mode input on stdin and an +// in-place-redrawable stdout. Piped/redirected output (CI, `| tee`, etc.) +// falls back to the plain sequential log. +func isInteractive() bool { + for _, f := range []*os.File{os.Stdin, os.Stdout} { + fi, err := f.Stat() + if err != nil || fi.Mode()&os.ModeCharDevice == 0 { + return false } + } + return true +} - caseOpts := opts - caseOpts.caseFile = tc.SourcePath - caseOpts.flowsDir = flowsDir(repoRoot) +type caseJob struct { + index int + case_ *TestCase +} + +// poolConfig controls how runCases schedules work across its worker pool, +// as opposed to caseOptions which controls how each individual case runs. +type poolConfig struct { + parallel int + keepGoing bool +} + +// runCases executes independent fixtures with a bounded worker pool. A failed +// case cancels work that has not started yet by default; -keep-going disables +// that cancellation and lets every selected fixture complete. +func runCases( + ctx context.Context, + cases []*TestCase, + registry *flows.Registry, + rt *cli.Runtime, + rep Reporter, + opts caseOptions, + pool poolConfig, +) ([]*Result, bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() - r := runOne(ctx, tc, def, rt, rep, caseOpts) - results = append(results, r) - if !r.Pass() && !*keepGoing { - stopped = true - break + p := &runPool{ + ctx: ctx, + cancel: cancel, + cases: cases, + registry: registry, + rt: rt, + rep: rep, + opts: opts, + keepGoing: pool.keepGoing, + jobs: make(chan caseJob), + results: make([]*Result, len(cases)), + } + return p.run(pool.parallel) +} + +// runPool holds the shared state a bounded worker pool needs to schedule and +// track fixtures, so each pool operation (worker, enqueue, one job) can live +// in its own small method instead of one large closure-heavy function. +type runPool struct { + ctx context.Context + cancel context.CancelFunc + cases []*TestCase + registry *flows.Registry + rt *cli.Runtime + rep Reporter + opts caseOptions + keepGoing bool + + jobs chan caseJob + results []*Result + wg sync.WaitGroup + cancelOnce sync.Once + stopped bool + stoppedMu sync.Mutex +} + +func (p *runPool) run(parallel int) ([]*Result, bool) { + enabled := len(enabledCaseNames(p.cases)) + if parallel > enabled { + parallel = enabled + } + p.wg.Add(parallel) + for range parallel { + go p.worker() + } + + p.enqueue() + close(p.jobs) + p.wg.Wait() + + completed := make([]*Result, 0, enabled) + for _, result := range p.results { + if result != nil { + completed = append(completed, result) } } + p.stoppedMu.Lock() + defer p.stoppedMu.Unlock() + return completed, p.stopped +} - if stopped { - fmt.Fprintln(os.Stdout) - fmt.Fprintln(os.Stdout, "Stopped at first failure (pass -keep-going to run every case).") +func (p *runPool) enqueue() { + for i, tc := range p.cases { + if tc.Disabled { + continue + } + select { + case <-p.ctx.Done(): + return + case p.jobs <- caseJob{index: i, case_: tc}: + } } +} - totalCases := 0 - for _, c := range cases { - if !c.Disabled { - totalCases++ +func (p *runPool) worker() { + defer p.wg.Done() + for { + select { + case <-p.ctx.Done(): + return + case job, ok := <-p.jobs: + if !ok { + return + } + p.runJob(job) } } - if Summarize(os.Stdout, results, totalCases) > 0 { - os.Exit(1) +} + +func (p *runPool) runJob(job caseJob) { + tc := job.case_ + def, ok := p.registry.Get(tc.FlowID) + var result *Result + if !ok { + result = &Result{Case: tc, Err: fmt.Errorf("unknown flow_id %q", tc.FlowID)} + p.rep.CaseStart(job.index, tc.Name, tc.FlowID) + p.rep.Step(job.index, stepKeyFlow, "flow lookup", false, "", result.Err) + p.rep.CaseEnd(job.index, false) + } else { + caseOpts := p.opts + caseOpts.caseFile = tc.SourcePath + result = runOne(p.ctx, job.index, tc, def, p.rt, p.rep, caseOpts) + } + p.results[job.index] = result + + if !result.Pass() && !p.keepGoing { + p.cancelOnce.Do(func() { + p.stoppedMu.Lock() + p.stopped = true + p.stoppedMu.Unlock() + p.cancel() + }) } } -// flowsDir returns the absolute path to the flows/ directory. The cache -// fingerprint hashes the whole directory so any edit to a flow definition -// invalidates every case (that's the desired behaviour: it's hard to tell -// from a flow ID alone which Go file produced it, and over-invalidation is -// safer than missing a relevant change). -func flowsDir(repoRoot string) string { - return filepath.Join(repoRoot, "flows") +func enabledCaseNames(cases []*TestCase) []string { + names := make([]string, 0, len(cases)) + for _, tc := range cases { + if !tc.Disabled { + names = append(names, tc.Name) + } + } + return names } // filterCases narrows cases to those whose Name appears in the comma-separated diff --git a/tools/test-flow/reporter.go b/tools/test-flow/reporter.go index 712fc3a..70f86d2 100644 --- a/tools/test-flow/reporter.go +++ b/tools/test-flow/reporter.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "sync" "github.com/charmbracelet/lipgloss" @@ -27,7 +28,41 @@ func (r *Result) Pass() bool { return r.Err == nil && len(r.Diffs) == 0 } -// ── StepReporter — live, hierarchical progress logging ──────────────────── +// Reporter receives live progress events from runOne/runCommandList as a +// case executes. idx is the case's position in the original cases slice +// (stable across runs, unlike an auto-incrementing counter, so concurrent +// workers can report into the right row of a table). key identifies which +// pipeline stage an event belongs to (see stepKey* constants below) — plain +// text reporters can ignore it, table reporters use it to pick a column. +// +// Two implementations exist: PlainReporter (sequential text log, used for +// non-interactive output such as CI) and TableReporter (live redrawing +// table, used on an interactive terminal). +type Reporter interface { + CaseStart(idx int, name, flowID string) + Step(idx int, key, label string, ok bool, detail string, err error) + Substep(idx int, key, label string, count int) + // SubStart fires immediately before one command in a Substep group + // begins running — it's what lets a table show "what's running now". + SubStart(idx int, key, label string) + Sub(idx int, key, label string, ok bool, detail string, err error) + CaseEnd(idx int, pass bool) +} + +// Step keys, shared between runOne/runCommandList and both Reporter +// implementations. +const ( + stepKeyFlow = "flow" + stepKeyVerify = "verify" + stepKeyResolved = "resolved" + stepKeyFiles = "files" + stepKeyValidate = "validate" + stepKeyCache = "cache" + stepKeyPost = "post" + stepKeyTest = "test" +) + +// ── PlainReporter — sequential, hierarchical progress logging ───────────── var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4")) @@ -38,7 +73,8 @@ var ( headStyle = lipgloss.NewStyle().Bold(true) ) -// StepReporter prints a structured tree per test case: +// PlainReporter prints a structured tree per test case, in the order events +// arrive (cases running concurrently interleave, but each line is atomic): // // [2/3] turborepo_ts_react (flow=monorepo) // ✓ flow — 6 nodes visited @@ -53,18 +89,21 @@ var ( // ✓ [3/4] pnpm exec vite build — 6.8s // ✓ [4/4] pnpm exec vite — background, ready+stop in 4.0s // PASS -type StepReporter struct { +type PlainReporter struct { w io.Writer idx int total int + mu sync.Mutex } -func NewReporter(total int) *StepReporter { - return &StepReporter{w: os.Stdout, total: total} +func NewPlainReporter(total int) *PlainReporter { + return &PlainReporter{w: os.Stdout, total: total} } // CaseStart begins a new case block. -func (r *StepReporter) CaseStart(name, flowID string) { +func (r *PlainReporter) CaseStart(_ int, name, flowID string) { + r.mu.Lock() + defer r.mu.Unlock() r.idx++ prefix := indexStyle.Render(fmt.Sprintf("[%d/%d]", r.idx, r.total)) suffix := dimStyle.Render(fmt.Sprintf("(flow=%s)", flowID)) @@ -72,12 +111,16 @@ func (r *StepReporter) CaseStart(name, flowID string) { } // Step prints an inline pass/fail step at one level of indent. -func (r *StepReporter) Step(label string, ok bool, detail string, err error) { +func (r *PlainReporter) Step(_ int, _, label string, ok bool, detail string, err error) { + r.mu.Lock() + defer r.mu.Unlock() fmt.Fprintf(r.w, " %s %s%s%s\n", mark(ok), padLabel(label), formatDetail(detail), formatErr(err)) } // Substep introduces a group with N children that follow as Sub() entries. -func (r *StepReporter) Substep(label string, count int) { +func (r *PlainReporter) Substep(_ int, _, label string, count int) { + r.mu.Lock() + defer r.mu.Unlock() fmt.Fprintf(r.w, " %s %s %s\n", dimStyle.Render("→"), headStyle.Render(label), @@ -85,13 +128,23 @@ func (r *StepReporter) Substep(label string, count int) { ) } +// SubStart is a no-op for PlainReporter — Sub() below already prints once +// the command finishes, which is all a sequential log needs. +func (r *PlainReporter) SubStart(_ int, _, _ string) { + // no-op: Sub() prints once the command finishes. +} + // Sub prints one child entry under a Substep. -func (r *StepReporter) Sub(label string, ok bool, detail string, err error) { +func (r *PlainReporter) Sub(_ int, _, label string, ok bool, detail string, err error) { + r.mu.Lock() + defer r.mu.Unlock() fmt.Fprintf(r.w, " %s %s%s%s\n", mark(ok), padLabel(label), formatDetail(detail), formatErr(err)) } // CaseEnd prints the case verdict line. -func (r *StepReporter) CaseEnd(pass bool) { +func (r *PlainReporter) CaseEnd(_ int, pass bool) { + r.mu.Lock() + defer r.mu.Unlock() if pass { fmt.Fprintf(r.w, " %s\n", okStyle.Render("PASS")) } else { diff --git a/tools/test-flow/runner.go b/tools/test-flow/runner.go index 9d20636..458e49a 100644 --- a/tools/test-flow/runner.go +++ b/tools/test-flow/runner.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "strings" + "sync" "time" "github.com/version14/dot/flows" @@ -15,6 +17,27 @@ import ( "github.com/version14/dot/pkg/dotapi" ) +// dockerComposeMu serializes post-gen/test commands across cases that spin +// up a docker-compose service (e.g. postgres_docker_compose). Every such +// fixture scaffolds a project named "my-app", so concurrent `docker compose +// up` calls fight over the same container name and host port. Real projects +// don't hit this — two differently-named scaffolds already get distinct +// ports (see internal/portutil) — this is purely to keep the fixed-name test +// fixtures from clobbering each other when test-flow runs cases in parallel. +var dockerComposeMu sync.Mutex + +// usesDockerCompose reports whether any invoked generator manages a +// docker-compose service, so runOne knows to serialize that case's commands +// against other docker-compose cases. +func usesDockerCompose(mans []dotapi.Manifest) bool { + for _, m := range mans { + if strings.Contains(m.Name, "docker_compose") { + return true + } + } + return false +} + // invocationNames returns just the names from a slice of Invocations, in order. func invocationNames(invs []generator.Invocation) []string { out := make([]string, len(invs)) @@ -149,37 +172,39 @@ type caseOptions struct { noCache bool // when true, ignore cache hits and refresh entries caseFile string // absolute path to the testdata JSON for this case repoRoot string // absolute path to the dot repo root - flowsDir string // absolute path to the flows/ directory } // runOne drives one TestCase through the full pipeline: // // flow → spec → generators → persist → validators → post-gen → test commands // -// Each step is logged via the StepReporter. The function returns a Result -// the caller passes to Summarize. Any per-step failure is captured in Result; -// the function does not panic or os.Exit. +// Each step is reported via rep, keyed by caseIdx (the case's stable position +// in the original cases slice) so concurrent workers report into the right +// row/case. The function returns a Result the caller passes to Summarize. +// Any per-step failure is captured in Result; the function does not panic or +// os.Exit. func runOne( ctx context.Context, + caseIdx int, tc *TestCase, def *flows.FlowDef, rt *cli.Runtime, - rep *StepReporter, + rep Reporter, opts caseOptions, ) *Result { r := &Result{Case: tc} - rep.CaseStart(tc.Name, tc.FlowID) + rep.CaseStart(caseIdx, tc.Name, tc.FlowID) // Step 1: scaffold (flow → generators → persist → .dot files). scratch, err := os.MkdirTemp(opts.tempDirRoot, "dot-test-"+tc.FlowID+"-*") if err != nil { r.Err = fmt.Errorf("mkdir temp: %w", err) - rep.Step("scaffold", false, "", err) + rep.Step(caseIdx, stepKeyFlow, "scaffold", false, "", err) return r } defer func() { if opts.keepScratch { - rep.Step("scratch dir kept", true, scratch, nil) + rep.Step(caseIdx, stepKeyFiles, "scratch dir kept", true, scratch, nil) return } _ = os.RemoveAll(scratch) @@ -199,41 +224,41 @@ func runOne( }) if err != nil { r.Err = fmt.Errorf("scaffold: %w", err) - rep.Step("scaffold", false, time.Since(scaffoldStart).String(), err) + rep.Step(caseIdx, stepKeyFlow, "scaffold", false, time.Since(scaffoldStart).String(), err) return r } r.Scaffold = res r.ProjectRoot = res.ProjectRoot - rep.Step("flow", true, fmt.Sprintf("%d nodes visited", len(res.Spec.VisitedNodes)), nil) + rep.Step(caseIdx, stepKeyFlow, "flow", true, fmt.Sprintf(""), nil) if len(tc.ExpectedIDs) > 0 && !equalStringSlice(tc.ExpectedIDs, res.Spec.VisitedNodes) { r.Diffs = append(r.Diffs, fmt.Sprintf( "visited mismatch:\n expected: %v\n actual: %v", tc.ExpectedIDs, res.Spec.VisitedNodes, )) - rep.Step("verify visited", false, "", fmt.Errorf("mismatch")) + rep.Step(caseIdx, stepKeyVerify, "verify visited", false, "", fmt.Errorf("mismatch")) } else if len(tc.ExpectedIDs) > 0 { - rep.Step("verify visited", true, "matches expected", nil) + rep.Step(caseIdx, stepKeyVerify, "verify visited", true, "", nil) } - rep.Step("resolved generators", true, fmt.Sprintf("%s", joinNames(res.Invocations)), nil) - rep.Step("scaffolded files", true, fmt.Sprintf("→ %s", res.ProjectRoot), nil) + rep.Step(caseIdx, stepKeyResolved, "resolved generators", true, "", nil) + rep.Step(caseIdx, stepKeyFiles, "scaffolded files", true, "", nil) // Step 2: validators (run against the on-disk project). failures, err := generator.RunValidators(res.ProjectRoot, res.Manifests) if err != nil { r.Err = fmt.Errorf("validators: %w", err) - rep.Step("validators", false, "", err) + rep.Step(caseIdx, stepKeyValidate, "validators", false, "", err) return r } if len(failures) > 0 { for _, f := range failures { r.Diffs = append(r.Diffs, "validator: "+f.String()) } - rep.Step("validators", false, fmt.Sprintf("%d failures", len(failures)), nil) + rep.Step(caseIdx, stepKeyValidate, "validators", false, fmt.Sprintf("%d failures", len(failures)), nil) } else { - rep.Step("validators", true, fmt.Sprintf("%d passed", countChecks(res.Manifests)), nil) + rep.Step(caseIdx, stepKeyValidate, "validators", true, "", nil) } // Step 2.5: case-level cache check. Skips post-gen + test commands when @@ -242,7 +267,6 @@ func runOne( cacheHit := false fingerprint, fpErr := ComputeFingerprint(CacheKeyInputs{ CaseFile: opts.caseFile, - FlowsDir: opts.flowsDir, Invocations: res.Invocations, Manifests: res.Manifests, SkipPostFlag: opts.skipPostCommands || tc.SkipPostCommands, @@ -252,35 +276,45 @@ func runOne( }) if fpErr != nil { - rep.Step("cache fingerprint", false, "", fpErr) + rep.Step(caseIdx, stepKeyCache, "cache fingerprint", false, "", fpErr) } else if !opts.noCache { entry, err := LoadCacheEntry(opts.repoRoot, tc.Name) if err != nil { - rep.Step("cache load", false, "", err) + rep.Step(caseIdx, stepKeyCache, "cache load", false, "", err) } if entry != nil && entry.Fingerprint == fingerprint && AllCommandsCacheable(res.Manifests) { - rep.Step("cache", true, "HIT — skipping post-gen + test commands", nil) + rep.Step(caseIdx, stepKeyCache, "cache", true, "", nil) cacheHit = true } else if entry != nil && entry.Fingerprint == fingerprint && !AllCommandsCacheable(res.Manifests) { blocking := NonCacheableCommands(res.Manifests) detail := fmt.Sprintf("%d non-cacheable command(s) — running anyway", len(blocking)) - rep.Step("cache", true, detail, nil) + rep.Step(caseIdx, stepKeyCache, "cache", true, detail, nil) } } + // Serialize the command-running window for docker-compose-backed cases; + // see dockerComposeMu docs above for why. + if !cacheHit && usesDockerCompose(res.Manifests) { + dockerComposeMu.Lock() + defer dockerComposeMu.Unlock() + } + // Step 3: post-generation commands (skipped on cache hit). if cacheHit { // Cache hit short-circuits both post-gen and test commands. } else if !opts.skipPostCommands && !tc.SkipPostCommands { postPlan := cli.PlanPostGenCommands(res.Spec, res.Manifests) if len(postPlan) > 0 { - rep.Substep("post-gen commands", len(postPlan)) - if cmdErr := runCommandList(ctx, res.ProjectRoot, postPlan); cmdErr != nil { + rep.Substep(caseIdx, stepKeyPost, "post-gen commands", len(postPlan)) + if output, cmdErr := runCommandList(ctx, res.ProjectRoot, postPlan, rep, caseIdx, stepKeyPost); cmdErr != nil { r.Diffs = append(r.Diffs, "post-gen: "+cmdErr.Error()) + if diff := formatCapturedOutputDiff(output); diff != "" { + r.Diffs = append(r.Diffs, diff) + } } } } else { - rep.Step("post-gen commands", true, "skipped", nil) + rep.Step(caseIdx, stepKeyPost, "post-gen commands", true, "skipped", nil) } // Step 4: test commands (incl. background dev servers). @@ -289,13 +323,16 @@ func runOne( } else if !opts.skipTestCommands && !tc.SkipTestCommands { testPlan := cli.PlanTestCommands(res.Spec, res.Manifests) if len(testPlan) > 0 { - rep.Substep("test commands", len(testPlan)) - if cmdErr := runCommandList(ctx, res.ProjectRoot, testPlan); cmdErr != nil { + rep.Substep(caseIdx, stepKeyTest, "test commands", len(testPlan)) + if output, cmdErr := runCommandList(ctx, res.ProjectRoot, testPlan, rep, caseIdx, stepKeyTest); cmdErr != nil { r.Diffs = append(r.Diffs, "test: "+cmdErr.Error()) + if diff := formatCapturedOutputDiff(output); diff != "" { + r.Diffs = append(r.Diffs, diff) + } } } } else { - rep.Step("test commands", true, "skipped", nil) + rep.Step(caseIdx, stepKeyTest, "test commands", true, "skipped", nil) } // Persist a fresh cache entry on full success. Failed runs intentionally @@ -310,49 +347,99 @@ func runOne( Generators: invocationNames(res.Invocations), } if err := SaveCacheEntry(opts.repoRoot, entry); err != nil { - rep.Step("cache save", false, "", err) + rep.Step(caseIdx, stepKeyCache, "cache save", false, "", err) } } - rep.CaseEnd(r.Pass()) + rep.CaseEnd(caseIdx, r.Pass()) return r } -// runCommandList executes each PlannedCommand in order with a Docker-style -// spinner UX (animated while running, ✓/✗ + elapsed when done, full output -// only on failure). Implementation lives in cli.RunCommandsQuiet so the same -// behaviour is shared with `dot scaffold`. +// runCommandList executes each PlannedCommand in order, reporting per-command +// Start/Done events to rep under (caseIdx, key) — that's what lets a table +// reporter show "what's running now" instead of a scrolling command log. +// cli.RunCommandsWithProgress is shared with `dot scaffold`'s spinner UX; here +// it's driven by reporterProgress instead. // -// The reporter's Substep header is printed by the caller; this function only -// renders the per-command lines (indented 4 spaces to nest under the header). +// On failure it returns the captured combined stdout/stderr of the failing +// command, when the caller should hold onto it for later (see reporterProgress +// docs below) — otherwise nil, since it's already been printed live. func runCommandList( ctx context.Context, projectRoot string, plan []commands.PlannedCommand, -) error { + rep Reporter, + caseIdx int, + key string, +) ([]byte, error) { runner := commands.NewRunner(projectRoot, dotapi.DiscardLogger{}) - return cli.RunCommandsQuiet(ctx, runner, plan, 4) + // TableReporter owns the terminal (raw mode, its own redraw loop) for the + // life of the run — a direct fmt.Fprint to stderr from here would race its + // repaints and corrupt the screen (this is exactly what garbled the TUI + // when a command failed: PrintCapturedOutput used to always write straight + // to stderr). So table runs hold onto the output and let runOne fold it + // into the Result's Diffs, which only get printed after the table quits + // and hands the terminal back. The plain reporter has no such conflict — + // it still gets the output printed live, as before. + _, isTable := rep.(*TableReporter) + progress := &reporterProgress{rep: rep, caseIdx: caseIdx, key: key, live: !isTable} + err := cli.RunCommandsWithProgress(ctx, runner, plan, progress) + return progress.deferredOutput, err } -func countChecks(mans []dotapi.Manifest) int { - n := 0 - for _, m := range mans { - for _, v := range m.Validators { - n += len(v.Checks) +// reporterProgress adapts a Reporter to cli.CommandProgress so per-command +// lifecycle events flow into the same case/column the rest of runOne reports +// into. +type reporterProgress struct { + rep Reporter + caseIdx int + key string + live bool // true: safe to print captured output to stderr immediately + + deferredOutput []byte // set on failure when !live +} + +func (p *reporterProgress) Start(c commands.PlannedCommand) { + p.rep.SubStart(p.caseIdx, p.key, commandLabel(c)) +} + +func (p *reporterProgress) Done(c commands.PlannedCommand, elapsed time.Duration, output []byte, err error) { + label := commandLabel(c) + if err != nil { + p.rep.Sub(p.caseIdx, p.key, label, false, elapsed.String(), err) + if p.live { + cli.PrintCapturedOutput(output, 6) + } else { + p.deferredOutput = output } + return } - return n + p.rep.Sub(p.caseIdx, p.key, label, true, elapsed.String(), nil) } -func joinNames(invs []generator.Invocation) string { - out := "" - for i, inv := range invs { - if i > 0 { - out += ", " - } - out += inv.Name +// commandLabel is the plain-text (unstyled) label for one command — callers +// apply their own styling/truncation. +func commandLabel(c commands.PlannedCommand) string { + if c.Background { + return c.Cmd + " (background)" } - return out + return c.Cmd +} + +// formatCapturedOutputDiff renders a failing command's captured output as one +// Result.Diffs entry, capped so a runaway command can't blow up the summary. +func formatCapturedOutputDiff(output []byte) string { + trimmed := strings.TrimRight(string(output), "\n") + if trimmed == "" { + return "" + } + const maxLines = 40 + lines := strings.Split(trimmed, "\n") + if len(lines) > maxLines { + omitted := len(lines) - maxLines + lines = append([]string{fmt.Sprintf("… (%d earlier lines omitted)", omitted)}, lines[len(lines)-maxLines:]...) + } + return "output:\n " + strings.Join(lines, "\n ") } func equalStringSlice(a, b []string) bool { diff --git a/tools/test-flow/table.go b/tools/test-flow/table.go new file mode 100644 index 0000000..919c03a --- /dev/null +++ b/tools/test-flow/table.go @@ -0,0 +1,552 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// TableReporter is the interactive Reporter: a single live-redrawing table, +// one row per test case, one column per pipeline step. It replaces the old +// scrolling step-by-step command log with a compact "what's happening right +// now" view — cells show a spinner + a dim, truncated label for whatever +// command is currently running, then settle to ✓/✗ once the step finishes. +type TableReporter struct { + program *tea.Program +} + +// NewTableReporter builds the table (one row per case, in cases' original +// order) and wires cancel to Ctrl-C / SIGINT so the table can still abort the +// run — bubbletea puts the terminal in raw mode, which stops the OS from +// turning a Ctrl-C keypress into SIGINT on its own. +func NewTableReporter(cases []*TestCase, cancel context.CancelFunc) *TableReporter { + m := newTableModel(cases, cancel) + // AltScreen gives us a fixed-size, fully-owned viewport that bubbletea + // repaints cleanly frame-to-frame. Without it, once the table grows + // taller than the terminal (common with 30+ fixtures), the inline + // renderer can't reconcile old vs. new frames and old lines start + // bleeding through — that's what caused the header/first rows to look + // corrupted. AltScreen + the height-aware windowing in View() fix both. + return &TableReporter{program: tea.NewProgram(m, tea.WithAltScreen())} +} + +// Run blocks until the table quits (Finish was called and the final frame +// has been drawn, or the user cancelled). +func (t *TableReporter) Run() (tea.Model, error) { + return t.program.Run() +} + +// Finish tells the table every case has completed; it draws once more, then +// quits so the caller's own summary can print below it. +func (t *TableReporter) Finish() { + t.program.Send(msgAllDone{}) +} + +func (t *TableReporter) CaseStart(idx int, name, flowID string) { + t.program.Send(msgCaseStart{idx: idx, name: name, flowID: flowID}) +} + +func (t *TableReporter) Step(idx int, key, label string, ok bool, detail string, err error) { + t.program.Send(msgStep{idx: idx, key: key, ok: ok, detail: detail, err: err}) +} + +func (t *TableReporter) Substep(idx int, key, label string, count int) { + t.program.Send(msgSubstep{idx: idx, key: key, count: count}) +} + +func (t *TableReporter) SubStart(idx int, key, label string) { + t.program.Send(msgSubStart{idx: idx, key: key, label: label}) +} + +func (t *TableReporter) Sub(idx int, key, label string, ok bool, detail string, err error) { + t.program.Send(msgSub{idx: idx, key: key, label: label, ok: ok, err: err}) +} + +func (t *TableReporter) CaseEnd(idx int, pass bool) { + t.program.Send(msgCaseEnd{idx: idx, pass: pass}) +} + +// ── messages ──────────────────────────────────────────────────────────── + +type msgCaseStart struct { + idx int + name, flowID string +} +type msgStep struct { + idx int + key string + ok bool + detail string + err error +} +type msgSubstep struct { + idx int + key string + count int +} +type msgSubStart struct { + idx int + key string + label string +} +type msgSub struct { + idx int + key string + label string + ok bool + err error +} +type msgCaseEnd struct { + idx int + pass bool +} +type msgAllDone struct{} +type tickMsg time.Time + +func tickCmd() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +// ── cell / row state ─────────────────────────────────────────────────── + +type cellStatus int + +const ( + cellPending cellStatus = iota + cellRunning + cellPass + cellFail + cellSkip +) + +type cellState struct { + status cellStatus + detail string + total int // for post/test: number of commands in the substep + doneCount int // for post/test: commands finished so far +} + +type rowState struct { + name string + flowID string + status cellStatus // overall row status + disabled bool + cells map[string]*cellState +} + +type column struct { + key string + label string + width int +} + +var tableColumns = []column{ + {stepKeyFlow, "FLOW", 4}, + {stepKeyVerify, "VERIFY", 6}, + {stepKeyResolved, "GENS", 4}, + {stepKeyFiles, "FILES", 5}, + {stepKeyValidate, "VALIDATE", 7}, + {stepKeyCache, "CACHE", 5}, + {stepKeyPost, "POST-GEN", 30}, + {stepKeyTest, "TEST-CMD", 30}, +} + +const nameColWidth = 34 + +// ── model ────────────────────────────────────────────────────────────── + +type tableModel struct { + rows []*rowState + total int // enabled (non-disabled) case count + completed int + spinnerFrame int + cancel context.CancelFunc + cancelled bool + quitting bool + width int + height int // 0 until the first tea.WindowSizeMsg arrives +} + +func newTableModel(cases []*TestCase, cancel context.CancelFunc) *tableModel { + rows := make([]*rowState, len(cases)) + total := 0 + for i, tc := range cases { + row := &rowState{name: tc.Name, cells: map[string]*cellState{}} + if tc.Disabled { + row.status = cellSkip + row.disabled = true + } else { + row.status = cellPending + total++ + } + rows[i] = row + } + return &tableModel{rows: rows, total: total, cancel: cancel} +} + +func (m *tableModel) Init() tea.Cmd { + return tickCmd() +} + +func (m *tableModel) cell(idx int, key string) *cellState { + row := m.rows[idx] + cs, ok := row.cells[key] + if !ok { + cs = &cellState{} + row.cells[key] = cs + } + return cs +} + +func (m *tableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.abort() + } + return m, nil + + case tea.InterruptMsg: + m.abort() + return m, nil + + case tickMsg: + m.spinnerFrame++ + return m, tickCmd() + + case msgCaseStart: + m.handleCaseStart(msg) + return m, nil + + case msgStep: + m.handleStep(msg) + return m, nil + + case msgSubstep: + m.handleSubstep(msg) + return m, nil + + case msgSubStart: + m.handleSubStart(msg) + return m, nil + + case msgSub: + m.handleSub(msg) + return m, nil + + case msgCaseEnd: + m.handleCaseEnd(msg) + return m, nil + + case msgAllDone: + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +func (m *tableModel) handleCaseStart(msg msgCaseStart) { + row := m.rows[msg.idx] + row.status = cellRunning + row.flowID = msg.flowID +} + +func (m *tableModel) handleStep(msg msgStep) { + cs := m.cell(msg.idx, msg.key) + if msg.ok { + cs.status = cellPass + cs.detail = msg.detail + } else { + cs.status = cellFail + cs.detail = errDetail(msg.detail, msg.err) + } +} + +func (m *tableModel) handleSubstep(msg msgSubstep) { + cs := m.cell(msg.idx, msg.key) + cs.status = cellRunning + cs.total = msg.count + cs.doneCount = 0 + cs.detail = fmt.Sprintf("0/%d", msg.count) +} + +func (m *tableModel) handleSubStart(msg msgSubStart) { + cs := m.cell(msg.idx, msg.key) + cs.status = cellRunning + cs.detail = fmt.Sprintf("%d/%d %s", cs.doneCount+1, cs.total, msg.label) +} + +func (m *tableModel) handleSub(msg msgSub) { + cs := m.cell(msg.idx, msg.key) + cs.doneCount++ + if !msg.ok { + cs.status = cellFail + cs.detail = msg.label + } else if cs.doneCount >= cs.total { + cs.status = cellPass + cs.detail = fmt.Sprintf("%d cmd(s)", cs.total) + } +} + +// handleCaseEnd records the case's final verdict and settles any column no +// event ever touched (step wasn't applicable — e.g. no ExpectedIDs so +// "verify" never ran) to a dim "not run" marker instead of spinning forever. +func (m *tableModel) handleCaseEnd(msg msgCaseEnd) { + row := m.rows[msg.idx] + if msg.pass { + row.status = cellPass + } else { + row.status = cellFail + } + for _, col := range tableColumns { + if _, ok := row.cells[col.key]; !ok { + row.cells[col.key] = &cellState{status: cellSkip} + } + } + m.completed++ +} + +func (m *tableModel) abort() { + if m.cancelled { + return + } + m.cancelled = true + if m.cancel != nil { + m.cancel() + } +} + +// ── view ─────────────────────────────────────────────────────────────── + +const tuiGrayColor = "#888888" + +var ( + tuiHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(tuiGrayColor)) + tuiIdxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(tuiGrayColor)) + tuiNameRun = lipgloss.NewStyle() + tuiNamePass = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575")) + tuiNameFail = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5F87")) + tuiNamePending = lipgloss.NewStyle().Foreground(lipgloss.Color(tuiGrayColor)) + tuiSpinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")) + tuiOkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575")) + tuiFailStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5F87")) + tuiDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(tuiGrayColor)) + tuiFooterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(tuiGrayColor)) +) + +func (m *tableModel) View() string { + var b strings.Builder + + passed, failed, running := m.statusCounts() + pending := m.total - passed - failed - running + + // Reserve one line for the header and one for the footer; whatever's + // left is how many rows we can show at once. Before the first + // WindowSizeMsg (height == 0) show everything — that first frame is + // replaced almost immediately once bubbletea reports the real size. + available := len(m.rows) + if m.height > 2 { + available = m.height - 2 + } + start, end := m.visibleWindow(available) + + m.writeHeader(&b) + m.writeRows(&b, start, end) + m.writeFooter(&b, passed, failed, running, pending, start, end) + + return b.String() +} + +func (m *tableModel) statusCounts() (passed, failed, running int) { + for _, row := range m.rows { + if row.disabled { + continue + } + switch row.status { + case cellPass: + passed++ + case cellFail: + failed++ + case cellRunning: + running++ + } + } + return passed, failed, running +} + +func (m *tableModel) writeHeader(b *strings.Builder) { + b.WriteString(padCell(tuiHeaderStyle.Render("#"), 4)) + b.WriteString(padCell(tuiHeaderStyle.Render("TEST"), nameColWidth)) + for _, col := range tableColumns { + b.WriteString(padCell(tuiHeaderStyle.Render(col.label), col.width)) + } + b.WriteString("\n") +} + +func (m *tableModel) writeRows(b *strings.Builder, start, end int) { + for i := start; i < end; i++ { + row := m.rows[i] + if row.disabled { + continue + } + b.WriteString(padCell(tuiIdxStyle.Render(fmt.Sprintf("%d", i+1)), 4)) + b.WriteString(padCell(styledName(row), nameColWidth)) + for _, col := range tableColumns { + b.WriteString(padCell(renderCell(row.cells[col.key], col.width, m.spinnerFrame), col.width)) + } + b.WriteString("\n") + } +} + +func (m *tableModel) writeFooter(b *strings.Builder, passed, failed, running, pending, start, end int) { + footer := fmt.Sprintf("%d passed · %d failed · %d running · %d pending", passed, failed, running, pending) + if start > 0 || end < len(m.rows) { + footer += fmt.Sprintf(" · rows %d-%d/%d", start+1, end, len(m.rows)) + } + footer += " (ctrl+c to cancel)" + b.WriteString(tuiFooterStyle.Render(footer)) + b.WriteString("\n") +} + +// visibleWindow picks which [start, end) slice of rows to render given +// available display rows, auto-scrolling to keep whatever's currently +// running in view (falling back to the first not-yet-started case, then to +// the tail once everything's finished) — that's what keeps the header and +// the actually-interesting rows from scrolling out of sight on a run with +// more fixtures than fit on screen. +func (m *tableModel) visibleWindow(available int) (int, int) { + n := len(m.rows) + if available >= n || available <= 0 { + return 0, n + } + + focus := -1 + for i, row := range m.rows { + if row.status == cellRunning { + focus = i + break + } + } + if focus == -1 { + for i, row := range m.rows { + if !row.disabled && row.status == cellPending { + focus = i + break + } + } + } + if focus == -1 { + // Nothing running or pending — everything's done. Show the tail so + // the final results (most recently completed) stay visible. + start := n - available + if start < 0 { + start = 0 + } + return start, n + } + + start := focus - available/4 + if start+available > n { + start = n - available + } + if start < 0 { + start = 0 + } + return start, start + available +} + +func styledName(row *rowState) string { + name := truncatePlain(row.name, nameColWidth-1) + switch row.status { + case cellPass: + return tuiNamePass.Render(name) + case cellFail: + return tuiNameFail.Render(name) + case cellRunning: + return tuiNameRun.Render(name) + default: + return tuiNamePending.Render(name) + } +} + +func renderCell(cs *cellState, width int, spinnerFrame int) string { + if cs == nil { + return tuiDimStyle.Render("·") + } + switch cs.status { + case cellPending: + return tuiDimStyle.Render("·") + case cellSkip: + return tuiDimStyle.Render("–") + case cellRunning: + glyph := tuiSpinnerStyle.Render(spinnerFrames[spinnerFrame%len(spinnerFrames)]) + detail := truncatePlain(cs.detail, width-2) + if detail == "" { + return glyph + } + return glyph + " " + tuiDimStyle.Render(detail) + case cellPass: + detail := truncatePlain(cs.detail, width-2) + if detail == "" { + return tuiOkStyle.Render("✓") + } + return tuiOkStyle.Render("✓") + " " + tuiDimStyle.Render(detail) + case cellFail: + detail := truncatePlain(cs.detail, width-2) + if detail == "" { + return tuiFailStyle.Render("✗") + } + return tuiFailStyle.Render("✗") + " " + tuiFailStyle.Render(detail) + } + return "" +} + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// ── string helpers (ANSI-aware padding/truncation) ──────────────────── + +// padCell right-pads (or leaves as-is if already wide enough) s to width +// visible columns, using lipgloss.Width so embedded ANSI styling doesn't +// count against the budget. Always leaves at least one space of gutter. +func padCell(s string, width int) string { + w := lipgloss.Width(s) + if w >= width { + return s + " " + } + return s + strings.Repeat(" ", width-w+1) +} + +// truncatePlain shortens plain (unstyled) text to at most width runes, +// appending an ellipsis when it had to cut. Caller applies styling after. +func truncatePlain(s string, width int) string { + if width <= 0 { + return "" + } + r := []rune(s) + if len(r) <= width { + return s + } + if width == 1 { + return string(r[:1]) + } + return string(r[:width-1]) + "…" +} + +func errDetail(detail string, err error) string { + if err == nil { + return detail + } + if detail == "" { + return err.Error() + } + return detail + ": " + err.Error() +}