check-build-drift.sh 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. #!/usr/bin/env bash
  2. #
  3. # The drift gate: regenerate the committed trees from content/agents/**, then fail if the
  4. # build changed anything. This is the structural guarantee the canonical refactor rests on —
  5. # generated trees stay COMMITTED, so a hand-edit to one must turn CI red rather than merely
  6. # being discouraged.
  7. #
  8. # ── Why a write build and not `oac build --check` ────────────────────────────────────────
  9. #
  10. # `check()` (BuildPipeline.ts) compares the PLANNED files against disk and reports orphans
  11. # from the manifest. It never compares `.oac/build-manifest.json` itself — the manifest is
  12. # only ever written by `write()`. Since the manifest is committed (it is the ledger orphan
  13. # pruning reasons from), a `--check` gate would let the manifest drift silently, and a fresh
  14. # clone with a stale ledger prunes the wrong set. So: real build, then ask git.
  15. #
  16. # ── Why before/after and not a bare `git status` ─────────────────────────────────────────
  17. #
  18. # In CI the checkout is clean, so "clean after the build" and "unchanged by the build" are
  19. # the same statement. Locally they are not: `.opencode/agent/eval-runner.md` is a real,
  20. # shipped, hand-authored agent with no canonical source, and any contributor may have
  21. # unrelated work in progress under these roots. Comparing the status BEFORE the build to the
  22. # status AFTER attributes drift to the build rather than to the worktree — pre-existing dirt
  23. # appears in both snapshots and cancels. The gate stays honest and stays runnable locally,
  24. # which is the only way contributors ever see it fail before CI does.
  25. #
  26. set -euo pipefail
  27. # `oac build` resolves content/ against the CWD — from anywhere else it exits 1 with ENOENT.
  28. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
  29. cd "$ROOT"
  30. # The trees `oac build` emits IN PLACE, and therefore everything this gate watches.
  31. # plugins/claude-code/** is deliberately absent: it stages to .tmp/oac-build/ (gitignored)
  32. # and is compared only, never emitted. Permissions no longer block emitting it in place --
  33. # oac.overrides.claude-code settled those -- but prompt content does: the shipped files carry
  34. # dispatch <example> blocks and capability-adapted bodies that canonical has no field for.
  35. # See the docblock in packages/cli/src/commands/build.ts.
  36. GENERATED=(.opencode/agent registry.json .oac/build-manifest.json)
  37. if [ ! -f packages/cli/dist/index.js ]; then
  38. echo "error: packages/cli/dist/index.js is missing — build the CLI first:" >&2
  39. echo " pnpm --dir packages/compatibility-layer run build" >&2
  40. echo " pnpm --dir packages/cli run build" >&2
  41. exit 1
  42. fi
  43. snapshot() { git status --porcelain -- "${GENERATED[@]}"; }
  44. before="$(snapshot)"
  45. bun packages/cli/dist/index.js build
  46. after="$(snapshot)"
  47. if [ "$before" = "$after" ]; then
  48. echo ""
  49. echo "✅ No drift — the generated trees match content/agents/**."
  50. exit 0
  51. fi
  52. cat >&2 <<'EOF'
  53. ❌ The generated trees drift from the canonical source.
  54. Files under .opencode/agent/, registry.json and .oac/build-manifest.json are BUILD OUTPUT.
  55. They are committed, but they are not hand-edited: `oac build` regenerates them from
  56. content/agents/**, and this gate fails when the committed bytes disagree with what the
  57. build produces.
  58. To fix:
  59. 1. Make your change in content/agents/** (the canonical source), not in the output.
  60. 2. Run: make build-canonical
  61. 3. Commit the regenerated files along with your source change.
  62. Paths the build changed:
  63. EOF
  64. # Only the paths whose status the BUILD moved. Diffing the whole roots instead would dump
  65. # every unrelated dirty file in the worktree — including `.opencode/agent/eval-runner.md`,
  66. # which is hand-authored, has no canonical source, and is none of this gate's business.
  67. #
  68. # BOTH sides of the delta matter, and the `<` side is the one that is easy to get wrong.
  69. # When a contributor hand-edits a generated file, the build REWRITES it back to canonical, so
  70. # the path is dirty BEFORE and clean AFTER — it leaves the status as a `<` line, never a `>`.
  71. # Matching only `>` would make the single most likely drift print an empty list.
  72. # `sed 's/^[<>] ...//'` drops diff's marker, then git's two-char status field and its space.
  73. drifted=()
  74. while IFS= read -r line; do
  75. [ -n "$line" ] && drifted+=("$line")
  76. done < <(diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") |
  77. sed -n 's/^[<>] ...//p' | sort -u)
  78. if [ ${#drifted[@]} -eq 0 ]; then
  79. # Unreachable: the snapshots differ, so some path moved. Fail loudly rather than pretend.
  80. echo " (could not attribute the drift to a path — snapshots differ but no path parsed)" >&2
  81. exit 1
  82. fi
  83. printf ' %s\n' "${drifted[@]}" >&2
  84. echo "" >&2
  85. echo "Diff:" >&2
  86. git --no-pager diff -- "${drifted[@]}" >&2
  87. exit 1