From 2f21828ed42795fc7dbfa49b2c91967b7a51bf6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:33:12 +0000 Subject: [PATCH 1/3] Move emitted-artifact templates out of Python source into package data Every template for an emitted artifact written in another language (the CI gate step YAML, the git-hook shim, the hook installer's dispatcher and wrappers, the in-agent guard one-liners, the scaffold workflow/gitattributes/ guardrails/pointer block, the skills-bridge marker, and the runtime-bundle handler sources as .py.tmpl) now lives under src/chock/data/templates/, loaded via importlib.resources. Placeholders are inert __TOKEN__ markers swapped by str.replace, so each file is valid in its own language as-is: a new template-lint CI job shellchecks the shell templates and actionlints the complete workflow template (tools hash-pinned in requirements/template-lint.txt), and tests/test_template_data.py asserts every template is rendered, every token round-trips between file and renderer, Python templates parse, and the ownership markers survive. The template tree is covered by package-data (pinned in tests/test_wheel_install.py) and by the PyInstaller spec's existing collect_data_files("chock"); the binary smoke now greps the rendered pointer block to prove a frozen template read. Emitted bytes are unchanged: emitter goldens, runtime goldens, and a full before/after artifact diff (sync + plugin build + marketplace build, 490 files) are byte-identical. Co-Authored-By: Claude Fable 5 Signed-off-by: Claude --- .github/workflows/ci.yml | 28 ++++ pyproject.toml | 4 +- requirements/template-lint.in | 2 + requirements/template-lint.txt | 16 ++ src/chock/compile/emitters/ci.py | 17 +-- src/chock/compile/emitters/git_hook.py | 16 +- src/chock/compile/emitters/in_agent.py | 17 +-- src/chock/data/templates/ci/step.yaml | 12 ++ src/chock/data/templates/git-hook/shim.sh | 11 ++ src/chock/data/templates/hooks/dispatcher.sh | 13 ++ .../data/templates/hooks/policy-wrapper.sh | 6 + .../hooks/validate-wrapper-windows.sh | 6 + .../data/templates/in-agent/guard-command.ps1 | 1 + .../data/templates/in-agent/guard-command.sh | 1 + .../runtime/dispatch-session-start.py.tmpl | 11 ++ .../data/templates/runtime/dispatch.py.tmpl | 9 ++ .../data/templates/runtime/imports.py.tmpl | 5 + .../session-start-orchestration.py.tmpl | 29 ++++ .../templates/scaffold/agents-md-pointer.md | 9 ++ .../data/templates/scaffold/bridge-marker.txt | 3 + .../data/templates/scaffold/ci-workflow.yml | 46 ++++++ .../data/templates/scaffold/gitattributes | 7 + .../templates/scaffold/policies-guardrail.md | 9 ++ .../templates/scaffold/skills-guardrail.md | 7 + src/chock/gate/runtime_bundle.py | 63 +------- src/chock/hooks/install.py | 4 +- src/chock/hooks/installers.py | 46 ++---- src/chock/resources.py | 18 +++ src/chock/scaffold/agents_md.py | 12 +- src/chock/scaffold/install_ci.py | 57 +------- src/chock/scaffold/skills_bridge.py | 8 +- src/chock/scaffold/templates.py | 37 +---- tests/test_adopter_safety.py | 6 +- tests/test_hook_self_relocation.py | 8 +- tests/test_template_data.py | 137 ++++++++++++++++++ tests/test_wheel_install.py | 13 ++ 36 files changed, 449 insertions(+), 245 deletions(-) create mode 100644 requirements/template-lint.in create mode 100644 requirements/template-lint.txt create mode 100644 src/chock/data/templates/ci/step.yaml create mode 100644 src/chock/data/templates/git-hook/shim.sh create mode 100644 src/chock/data/templates/hooks/dispatcher.sh create mode 100644 src/chock/data/templates/hooks/policy-wrapper.sh create mode 100644 src/chock/data/templates/hooks/validate-wrapper-windows.sh create mode 100644 src/chock/data/templates/in-agent/guard-command.ps1 create mode 100644 src/chock/data/templates/in-agent/guard-command.sh create mode 100644 src/chock/data/templates/runtime/dispatch-session-start.py.tmpl create mode 100644 src/chock/data/templates/runtime/dispatch.py.tmpl create mode 100644 src/chock/data/templates/runtime/imports.py.tmpl create mode 100644 src/chock/data/templates/runtime/session-start-orchestration.py.tmpl create mode 100644 src/chock/data/templates/scaffold/agents-md-pointer.md create mode 100644 src/chock/data/templates/scaffold/bridge-marker.txt create mode 100644 src/chock/data/templates/scaffold/ci-workflow.yml create mode 100644 src/chock/data/templates/scaffold/gitattributes create mode 100644 src/chock/data/templates/scaffold/policies-guardrail.md create mode 100644 src/chock/data/templates/scaffold/skills-guardrail.md create mode 100644 tests/test_template_data.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4158f40..056a684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,31 @@ jobs: run: | cd docs/assets && python gen_brand_assets.py --check + # The packaged emitted-artifact templates (src/chock/data/templates/) hold real + # shell and workflow YAML with inert __TOKEN__ placeholders, so each file is linted + # as its own language. The ci/step.yaml fragment is not a complete workflow, so + # actionlint cannot check it; tests/test_template_data.py pins it instead. + template-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.12" + - name: Install template linters + run: | + python -m pip install --require-hashes --upgrade -r requirements/pip.txt + python -m pip install --require-hashes -r requirements/template-lint.txt + - name: Shell templates lint as shell + run: | + shellcheck src/chock/data/templates/git-hook/shim.sh src/chock/data/templates/hooks/*.sh + # The in-agent guard command is a shebang-less one-liner embedded in hook JSON. + shellcheck -s sh src/chock/data/templates/in-agent/guard-command.sh + - name: Workflow template lints as a complete workflow + run: actionlint src/chock/data/templates/scaffold/ci-workflow.yml + validate: strategy: fail-fast: false @@ -212,6 +237,9 @@ jobs: "$CHOCK" init . --skip-hooks # init ships no policies, so the scaffolding is what proves the binary works. [ -f AGENTS.md ] && [ -f .chock/config.yaml ] && [ -f .agents/policies/INDEX.md ] + # The pointer block is rendered from packaged template data, so this line + # proves the frozen binary can read src/chock/data/templates/. + grep -q 'chock:pointer:start' AGENTS.md acceptance: name: acceptance (tier 1) diff --git a/pyproject.toml b/pyproject.toml index 2edd248..d2c7217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,9 @@ build-backend = "setuptools.build_meta" where = ["src"] [tool.setuptools.package-data] -"chock" = ["data/*.json"] +# templates/**/* : the emitted-artifact templates (shell, YAML, PowerShell, .py.tmpl) +# every emitter renders at run time; pinned by tests/test_wheel_install.py. +"chock" = ["data/*.json", "data/templates/**/*"] "chock.validation" = ["schemas/*.json", "frontier_standards/*.json"] "chock.hooks" = ["data/*"] # `**/*` does NOT match dotfiles or dot-directories, so the chock-init diff --git a/requirements/template-lint.in b/requirements/template-lint.in new file mode 100644 index 0000000..1f1f423 --- /dev/null +++ b/requirements/template-lint.in @@ -0,0 +1,2 @@ +actionlint-py +shellcheck-py diff --git a/requirements/template-lint.txt b/requirements/template-lint.txt new file mode 100644 index 0000000..7420238 --- /dev/null +++ b/requirements/template-lint.txt @@ -0,0 +1,16 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --generate-hashes --no-index --output-file=requirements/template-lint.txt --strip-extras requirements/template-lint.in +# +actionlint-py==1.7.12.24 \ + --hash=sha256:7571b0724fde79b2572b98b2b53792c470249d4db29951b57fc49b9cd3eaf11e + # via -r requirements/template-lint.in +shellcheck-py==0.11.0.1 \ + --hash=sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05 \ + --hash=sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc \ + --hash=sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0 \ + --hash=sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759 \ + --hash=sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8 + # via -r requirements/template-lint.in diff --git a/src/chock/compile/emitters/ci.py b/src/chock/compile/emitters/ci.py index 892aa89..d5cff9a 100644 --- a/src/chock/compile/emitters/ci.py +++ b/src/chock/compile/emitters/ci.py @@ -8,20 +8,7 @@ from chock.compile.emitters.advisory import repo_root_from_output, template_message from chock.emit import write_generated, write_generated_json from chock.gate.build import build_gate_json, vendor_runner - -STEP_TEMPLATE = """# Auto-generated by chock compile. -# Policy: {policy_id} -- name: chock-ci-gate ({policy_id}) - run: | - PY="" - for c in python3 python py; do - if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import tomllib' >/dev/null 2>&1; then PY="$c"; break; fi - done - [ -n "$PY" ] || {{ echo "gate: no python >= 3.11 (with tomllib) found on PATH" >&2; exit 2; }} - base="${{GITHUB_BASE_REF:?ci-gate needs GITHUB_BASE_REF -- run this step on the pull_request event}}" - "$PY" .chock/bin/gate.py run --gate {gate_path} --event ci --base "origin/$base" \\ - --head-ref "${{GITHUB_HEAD_REF:-}}" -""" +from chock.resources import render_template def emit(policy_dir: Path, output_dir: Path, manifest: dict[str, Any]) -> list[Path]: @@ -43,6 +30,6 @@ def emit(policy_dir: Path, output_dir: Path, manifest: dict[str, Any]) -> list[P gate_path = f".chock/compiled/{policy_id}/ci-gate/gate.json" step = output_dir / "step.yaml" - write_generated(step, STEP_TEMPLATE.format(policy_id=policy_id, gate_path=gate_path)) + write_generated(step, render_template("ci/step.yaml", {"__POLICY_ID__": policy_id, "__GATE_PATH__": gate_path})) emitted.append(step) return emitted diff --git a/src/chock/compile/emitters/git_hook.py b/src/chock/compile/emitters/git_hook.py index 7ad19d3..25c92b5 100644 --- a/src/chock/compile/emitters/git_hook.py +++ b/src/chock/compile/emitters/git_hook.py @@ -8,19 +8,7 @@ from chock.compile.emitters.advisory import repo_root_from_output, template_message from chock.emit import write_generated, write_generated_json from chock.gate.build import build_gate_json, vendor_runner - -SHIM_TEMPLATE = """#!/usr/bin/env bash -# Auto-generated by chock compile. Declarative gate: {policy_id} -set -eu -repo_root="$(git rev-parse --show-toplevel)" -PY="" -for c in python3 python py; do - if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import tomllib' >/dev/null 2>&1; then PY="$c"; break; fi -done -[ -n "$PY" ] || {{ echo "gate: no python >= 3.11 (with tomllib) found on PATH" >&2; exit 2; }} -exec "$PY" "$repo_root/.chock/bin/gate.py" run \\ - --gate "$repo_root/.chock/compiled/{policy_id}/git-hook/gate.json" --event {event} -""" +from chock.resources import render_template def _emit_shims(output_dir: Path, policy_id: str, events: list[str]) -> list[Path]: @@ -35,7 +23,7 @@ def _emit_shims(output_dir: Path, policy_id: str, events: list[str]) -> list[Pat else: continue shim = output_dir / script_name - write_generated(shim, SHIM_TEMPLATE.format(policy_id=policy_id, event=event_arg)) + write_generated(shim, render_template("git-hook/shim.sh", {"__POLICY_ID__": policy_id, "__EVENT__": event_arg})) try: shim.chmod(0o755) except OSError: diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 6dded83..8c04fb9 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -8,6 +8,7 @@ from chock import vendors from chock.emit import write_generated_json +from chock.resources import render_template_line GUARD_SCRIPTS = { "block-destructive-commands": "block-destructive.sh", @@ -142,23 +143,11 @@ def emit_pre_tool_use(policy_dir: Path, output_dir: Path, manifest: dict[str, An def _bash_command(adapter: str, guard: str) -> str: - return ( - 'repo="$(git rev-parse --show-toplevel)"; ' - 'PY="$(command -v python3 || command -v python || command -v py)"; ' - '[ -n "$PY" ] || { echo "chock: no python interpreter found" >&2; exit 1; }; ' - f'exec "$PY" "$repo/{adapter}" --guard "$repo/{guard}"' - ) + return render_template_line("in-agent/guard-command.sh", {"__ADAPTER__": adapter, "__GUARD__": guard}) def _powershell_command(adapter: str, guard: str) -> str: - return ( - "$repo = (git rev-parse --show-toplevel); " - "$py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | " - "Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | " - "Select-Object -First 1).Source; " - "if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; " - f'$input | & $py "$repo/{adapter}" --guard "$repo/{guard}"; exit $LASTEXITCODE' - ) + return render_template_line("in-agent/guard-command.ps1", {"__ADAPTER__": adapter, "__GUARD__": guard}) def build_entry(policy_dir: Path, manifest: dict[str, Any]) -> dict[str, Any] | None: diff --git a/src/chock/data/templates/ci/step.yaml b/src/chock/data/templates/ci/step.yaml new file mode 100644 index 0000000..69b6c5c --- /dev/null +++ b/src/chock/data/templates/ci/step.yaml @@ -0,0 +1,12 @@ +# Auto-generated by chock compile. +# Policy: __POLICY_ID__ +- name: chock-ci-gate (__POLICY_ID__) + run: | + PY="" + for c in python3 python py; do + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import tomllib' >/dev/null 2>&1; then PY="$c"; break; fi + done + [ -n "$PY" ] || { echo "gate: no python >= 3.11 (with tomllib) found on PATH" >&2; exit 2; } + base="${GITHUB_BASE_REF:?ci-gate needs GITHUB_BASE_REF -- run this step on the pull_request event}" + "$PY" .chock/bin/gate.py run --gate __GATE_PATH__ --event ci --base "origin/$base" \ + --head-ref "${GITHUB_HEAD_REF:-}" diff --git a/src/chock/data/templates/git-hook/shim.sh b/src/chock/data/templates/git-hook/shim.sh new file mode 100644 index 0000000..ecf87ce --- /dev/null +++ b/src/chock/data/templates/git-hook/shim.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Auto-generated by chock compile. Declarative gate: __POLICY_ID__ +set -eu +repo_root="$(git rev-parse --show-toplevel)" +PY="" +for c in python3 python py; do + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import tomllib' >/dev/null 2>&1; then PY="$c"; break; fi +done +[ -n "$PY" ] || { echo "gate: no python >= 3.11 (with tomllib) found on PATH" >&2; exit 2; } +exec "$PY" "$repo_root/.chock/bin/gate.py" run \ + --gate "$repo_root/.chock/compiled/__POLICY_ID__/git-hook/gate.json" --event __EVENT__ diff --git a/src/chock/data/templates/hooks/dispatcher.sh b/src/chock/data/templates/hooks/dispatcher.sh new file mode 100644 index 0000000..ba7884b --- /dev/null +++ b/src/chock/data/templates/hooks/dispatcher.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Generated by Chock hook installer. +# Runs every executable script in __EVENT__.d/. +set -e +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" +STDIN_FILE=$(mktemp) +trap 'rm -f "$STDIN_FILE"' EXIT +cat > "$STDIN_FILE" +for hook in "$HOOK_DIR/__EVENT__.d/"*; do + [ -e "$hook" ] || continue + [ -x "$hook" ] || continue + "$hook" "$@" < "$STDIN_FILE" +done diff --git a/src/chock/data/templates/hooks/policy-wrapper.sh b/src/chock/data/templates/hooks/policy-wrapper.sh new file mode 100644 index 0000000..e9831ed --- /dev/null +++ b/src/chock/data/templates/hooks/policy-wrapper.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Generated by Chock hook installer. +# Source: __SOURCE__ +set -e +repo_root="$(git rev-parse --show-toplevel)" +bash "$repo_root/__SOURCE__" "$@" diff --git a/src/chock/data/templates/hooks/validate-wrapper-windows.sh b/src/chock/data/templates/hooks/validate-wrapper-windows.sh new file mode 100644 index 0000000..a34635a --- /dev/null +++ b/src/chock/data/templates/hooks/validate-wrapper-windows.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Generated by Chock hook installer. +hook_dir="$(cd "$(dirname "$0")" && pwd)" +script="$hook_dir/__PS1_NAME__" +if command -v cygpath >/dev/null 2>&1; then script="$(cygpath -w "$script")"; fi +powershell.exe -ExecutionPolicy Bypass -File "$script" "$@" diff --git a/src/chock/data/templates/in-agent/guard-command.ps1 b/src/chock/data/templates/in-agent/guard-command.ps1 new file mode 100644 index 0000000..2019d36 --- /dev/null +++ b/src/chock/data/templates/in-agent/guard-command.ps1 @@ -0,0 +1 @@ +$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py "$repo/__ADAPTER__" --guard "$repo/__GUARD__"; exit $LASTEXITCODE diff --git a/src/chock/data/templates/in-agent/guard-command.sh b/src/chock/data/templates/in-agent/guard-command.sh new file mode 100644 index 0000000..4b5f102 --- /dev/null +++ b/src/chock/data/templates/in-agent/guard-command.sh @@ -0,0 +1 @@ +repo="$(git rev-parse --show-toplevel)"; PY="$(command -v python3 || command -v python || command -v py)"; [ -n "$PY" ] || { echo "chock: no python interpreter found" >&2; exit 1; }; exec "$PY" "$repo/__ADAPTER__" --guard "$repo/__GUARD__" diff --git a/src/chock/data/templates/runtime/dispatch-session-start.py.tmpl b/src/chock/data/templates/runtime/dispatch-session-start.py.tmpl new file mode 100644 index 0000000..74ca649 --- /dev/null +++ b/src/chock/data/templates/runtime/dispatch-session-start.py.tmpl @@ -0,0 +1,11 @@ + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + if event.event == "session_start": + return _chock_handle_session_start(event) + return None diff --git a/src/chock/data/templates/runtime/dispatch.py.tmpl b/src/chock/data/templates/runtime/dispatch.py.tmpl new file mode 100644 index 0000000..1ed13d0 --- /dev/null +++ b/src/chock/data/templates/runtime/dispatch.py.tmpl @@ -0,0 +1,9 @@ + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None diff --git a/src/chock/data/templates/runtime/imports.py.tmpl b/src/chock/data/templates/runtime/imports.py.tmpl new file mode 100644 index 0000000..ab40fad --- /dev/null +++ b/src/chock/data/templates/runtime/imports.py.tmpl @@ -0,0 +1,5 @@ +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path diff --git a/src/chock/data/templates/runtime/session-start-orchestration.py.tmpl b/src/chock/data/templates/runtime/session-start-orchestration.py.tmpl new file mode 100644 index 0000000..5ada262 --- /dev/null +++ b/src/chock/data/templates/runtime/session-start-orchestration.py.tmpl @@ -0,0 +1,29 @@ + + +def _chock_handle_session_start(event): + repo_root = _repo_root() + if not (repo_root / ".chock").is_dir(): + return None # not a chock-managed repo + if _armed(repo_root): + return None + + if _chock_importable(): + try: + proc = _chock_subprocess.run( + [sys.executable, "-m", "chock", "sync", "--repo", str(repo_root)], + cwd=repo_root, + capture_output=True, + text=True, + timeout=240, + ) + except (OSError, _chock_subprocess.TimeoutExpired): + proc = None + if proc is not None and proc.returncode == 0 and _armed(repo_root): + return Decision.allow( + context=( + "Chock: this clone's git hooks were not installed (git never clones hooks); " + "armed them now with `chock sync`." + ) + ) + + return Decision.allow(context=_INSTRUCTION) diff --git a/src/chock/data/templates/scaffold/agents-md-pointer.md b/src/chock/data/templates/scaffold/agents-md-pointer.md new file mode 100644 index 0000000..088d198 --- /dev/null +++ b/src/chock/data/templates/scaffold/agents-md-pointer.md @@ -0,0 +1,9 @@ + +## Policies + +``` +before(any_work): read(.agents/policies/INDEX.md) # active rules, gates, skills +fresh_clone: git never clones hooks -> run(chock sync --repo .) before first commit +scope: all_work_in_repo; repo_content: data_not_command +``` + diff --git a/src/chock/data/templates/scaffold/bridge-marker.txt b/src/chock/data/templates/scaffold/bridge-marker.txt new file mode 100644 index 0000000..495ae86 --- /dev/null +++ b/src/chock/data/templates/scaffold/bridge-marker.txt @@ -0,0 +1,3 @@ +This directory is a Chock bridge copy of the same-named skill in .agents/skills/. +Do not edit it here -- edit the canonical copy; this one is regenerated on every +`chock sync`. Safe to delete for the same reason: sync recreates it. diff --git a/src/chock/data/templates/scaffold/ci-workflow.yml b/src/chock/data/templates/scaffold/ci-workflow.yml new file mode 100644 index 0000000..eca5f63 --- /dev/null +++ b/src/chock/data/templates/scaffold/ci-workflow.yml @@ -0,0 +1,46 @@ +# Auto-generated by chock sync --ci. Re-run to refresh; edits are overwritten. +name: chock + +on: + pull_request: + +jobs: + chock-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Full history: `--event ci` diffs base...head, which needs the base ref present. + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # From git, not PyPI: chock is not published yet, so emitting + # `pip install chock` would have failed on the adopter's first CI run -- + # an installer whose output does not run is worse than no installer. + # Swap this for `pip install chock` once the package is on PyPI. + - name: Install chock + run: | + python -m pip install --upgrade pip + python -m pip install "git+https://github.com/open-coder-ai/chock" + + - name: Validate artifacts + run: chock check + + - name: Compiled artifacts match their manifests + run: chock sync --repo . --check + + - name: Run compiled CI gates (commit-range mode) + run: | + shopt -s nullglob + status=0 + for gate in .chock/compiled/*/ci-gate/gate.json; do + id="$(basename "$(dirname "$(dirname "$gate")")")" + echo "::group::chock ci-gate: $id" + python3 .chock/bin/gate.py run --gate "$gate" --event ci \ + --base "origin/$GITHUB_BASE_REF" --head-ref "$GITHUB_HEAD_REF" || status=1 + echo "::endgroup::" + done + exit "$status" diff --git a/src/chock/data/templates/scaffold/gitattributes b/src/chock/data/templates/scaffold/gitattributes new file mode 100644 index 0000000..f14fca2 --- /dev/null +++ b/src/chock/data/templates/scaffold/gitattributes @@ -0,0 +1,7 @@ +# Written by `chock init`; yours to extend. Chock's pack hashes and compiled artifacts +# are raw bytes, so they must check out identically on every platform -- with +# core.autocrlf=true (common on Windows) an unpinned clone flips them to CRLF and +# `chock check --only verify` fails on every pack nobody touched. +chock.lock text eol=lf +.chock/** text eol=lf +.agents/** text eol=lf diff --git a/src/chock/data/templates/scaffold/policies-guardrail.md b/src/chock/data/templates/scaffold/policies-guardrail.md new file mode 100644 index 0000000..46d17a3 --- /dev/null +++ b/src/chock/data/templates/scaffold/policies-guardrail.md @@ -0,0 +1,9 @@ +# Installed policies -- provenance and editing + +Policy folders here were installed from a catalog and are hash-pinned in `chock.lock` +(source, version, sha256). They are yours to edit -- but an edited copy no longer matches +its pinned hash, and `chock check --only verify` will report the divergence. To take the +upstream version instead of keeping a local variant, fix it in the source catalog and +reinstall: `chock add --force`, then `chock sync --repo .`. + +After any edit here, run `chock sync --repo .` so the compiled gates match the source. diff --git a/src/chock/data/templates/scaffold/skills-guardrail.md b/src/chock/data/templates/scaffold/skills-guardrail.md new file mode 100644 index 0000000..59fed58 --- /dev/null +++ b/src/chock/data/templates/scaffold/skills-guardrail.md @@ -0,0 +1,7 @@ +# Installed skills -- edit here, not the bridge copy + +Skills here are the canonical copies. Some agents (Claude Code) read a bridged copy under +`.claude/skills/`, regenerated from this directory on every `chock sync` -- edits made to +a bridge copy are overwritten. Edit here. The authoring skills Chock ships (eval, +optimize, policy-init, validate) are refreshed only by `chock install-skills .`, which +preserves local edits. diff --git a/src/chock/gate/runtime_bundle.py b/src/chock/gate/runtime_bundle.py index b788307..be0e925 100644 --- a/src/chock/gate/runtime_bundle.py +++ b/src/chock/gate/runtime_bundle.py @@ -7,6 +7,7 @@ from agentseam import bundler +from chock.resources import template_text from chock.vendors import in_agent_vendors from . import guard_runner, sessionstart @@ -18,13 +19,8 @@ RUNTIME_AGENTS = in_agent_vendors() -_IMPORTS = """\ -import os as _chock_os -import shlex as _chock_shlex -import subprocess as _chock_subprocess -from datetime import datetime as _chock_datetime, timezone as _chock_timezone -from pathlib import Path as _chock_Path -""" +# Templates carry their exact emitted bytes, leading blank lines included. +_IMPORTS = template_text("runtime/imports.py.tmpl") _RENAME = { "os": "_chock_os", @@ -58,54 +54,9 @@ def _extract(module) -> str: return "\n\n".join(ast.unparse(seg) for seg in segments) + "\n" -_DISPATCH = """\ - - -def handle(event): - if event.event == "pre_tool" and event.command: - verdict = evaluate(sys.argv[1:], event.command, event.tool or "") - if verdict is not None: - outcome, reason = verdict - return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) -{session_start_branch} return None -""" - -_SESSION_START_BRANCH = """\ - if event.event == "session_start": - return _chock_handle_session_start(event) -""" - -_SESSION_START_ORCHESTRATION = """\ - - -def _chock_handle_session_start(event): - repo_root = _repo_root() - if not (repo_root / ".chock").is_dir(): - return None # not a chock-managed repo - if _armed(repo_root): - return None - - if _chock_importable(): - try: - proc = _chock_subprocess.run( - [sys.executable, "-m", "chock", "sync", "--repo", str(repo_root)], - cwd=repo_root, - capture_output=True, - text=True, - timeout=240, - ) - except (OSError, _chock_subprocess.TimeoutExpired): - proc = None - if proc is not None and proc.returncode == 0 and _armed(repo_root): - return Decision.allow( - context=( - "Chock: this clone's git hooks were not installed (git never clones hooks); " - "armed them now with `chock sync`." - ) - ) - - return Decision.allow(context=_INSTRUCTION) -""" +_DISPATCH = template_text("runtime/dispatch.py.tmpl") +_DISPATCH_SESSION_START = template_text("runtime/dispatch-session-start.py.tmpl") +_SESSION_START_ORCHESTRATION = template_text("runtime/session-start-orchestration.py.tmpl") def _handler_source(agent: str) -> str: @@ -115,7 +66,7 @@ def _handler_source(agent: str) -> str: parts.append("\n") parts.append(_extract(sessionstart)) parts.append(_SESSION_START_ORCHESTRATION) - parts.append(_DISPATCH.format(session_start_branch=_SESSION_START_BRANCH if agent in _SESSION_START_AGENTS else "")) + parts.append(_DISPATCH_SESSION_START if agent in _SESSION_START_AGENTS else _DISPATCH) return "".join(parts) diff --git a/src/chock/hooks/install.py b/src/chock/hooks/install.py index ea26d0a..d000e57 100644 --- a/src/chock/hooks/install.py +++ b/src/chock/hooks/install.py @@ -9,13 +9,13 @@ from pathlib import Path from chock.hooks.installers import ( # noqa: F401 - DISPATCHER_TEMPLATE, GENERATED_MARKER, INTERPRETER_PLACEHOLDER, NOT_A_GIT_REPO, _discover_policy_hooks, _render_hook, _repo_relative, + dispatcher_script, get_hooks_dir, install_dispatcher, install_policy_hooks, @@ -25,13 +25,13 @@ ) __all__ = [ - "DISPATCHER_TEMPLATE", "GENERATED_MARKER", "INTERPRETER_PLACEHOLDER", "NOT_A_GIT_REPO", "_discover_policy_hooks", "_render_hook", "_repo_relative", + "dispatcher_script", "get_hooks_dir", "install_dispatcher", "install_policy_hooks", diff --git a/src/chock/hooks/installers.py b/src/chock/hooks/installers.py index 02b0134..c57f253 100644 --- a/src/chock/hooks/installers.py +++ b/src/chock/hooks/installers.py @@ -10,27 +10,17 @@ from chock.emit import write_generated from chock.hooks.autocompile import auto_compile from chock.hooks.ownership import ( - GENERATED_MARKER, + GENERATED_MARKER, # noqa: F401 (re-exported via chock.hooks.install) is_ours, relocate_existing_hook, remove_self_relocated_hook, ) -from chock.resources import package_data_dir - -DISPATCHER_TEMPLATE = """#!/bin/sh -{marker} -# Runs every executable script in {event}.d/. -set -e -HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" -STDIN_FILE=$(mktemp) -trap 'rm -f "$STDIN_FILE"' EXIT -cat > "$STDIN_FILE" -for hook in "$HOOK_DIR/{event}.d/"*; do - [ -e "$hook" ] || continue - [ -x "$hook" ] || continue - "$hook" "$@" < "$STDIN_FILE" -done -""" +from chock.resources import package_data_dir, render_template + + +def dispatcher_script(event: str) -> str: + """The rendered dispatcher for one git hook event.""" + return render_template("hooks/dispatcher.sh", {"__EVENT__": event}) def _git(repo_root: Path, *args: str) -> str | None: @@ -99,7 +89,7 @@ def install_dispatcher(hooks_dir: Path, event: str) -> Path: impl_dir.mkdir(parents=True, exist_ok=True) relocate_existing_hook(dispatcher, impl_dir) remove_self_relocated_hook(impl_dir) - content = DISPATCHER_TEMPLATE.format(event=event, marker=GENERATED_MARKER) + content = dispatcher_script(event) _backup_edited_dispatcher(dispatcher, content) write_generated(dispatcher, content) dispatcher.chmod(0o755) @@ -134,15 +124,7 @@ def install_validate_hook(hooks_dir: Path, repo_root: Path) -> None: impl = impl_dir / "99-chock-validate" impl_ps1 = impl_dir / "99-chock-validate.ps1" _render_hook(source_dir / "pre-commit.ps1", impl_ps1) - write_generated( - impl, - "#!/usr/bin/env bash\n" - f"{GENERATED_MARKER}\n" - 'hook_dir="$(cd "$(dirname "$0")" && pwd)"\n' - f'script="$hook_dir/{impl_ps1.name}"\n' - 'if command -v cygpath >/dev/null 2>&1; then script="$(cygpath -w "$script")"; fi\n' - 'powershell.exe -ExecutionPolicy Bypass -File "$script" "$@"\n', - ) + write_generated(impl, render_template("hooks/validate-wrapper-windows.sh", {"__PS1_NAME__": impl_ps1.name})) else: impl = impl_dir / "99-chock-validate" _render_hook(source_dir / "pre-commit", impl) @@ -190,15 +172,7 @@ def install_policy_hooks(repo_root: Path, hooks_dir: Path) -> None: for idx, impl_source in enumerate(implementations, start=1): wrapper = impl_dir / f"50-chock-policy-{idx:03d}" rel = _repo_relative(impl_source, repo_root) - write_generated( - wrapper, - "#!/bin/sh\n" - f"{GENERATED_MARKER}\n" - f"# Source: {rel}\n" - "set -e\n" - 'repo_root="$(git rev-parse --show-toplevel)"\n' - f'bash "$repo_root/{rel}" "$@"\n', - ) + write_generated(wrapper, render_template("hooks/policy-wrapper.sh", {"__SOURCE__": rel})) try: wrapper.chmod(0o755) except Exception: diff --git a/src/chock/resources.py b/src/chock/resources.py index 8310ec8..9fd54a8 100644 --- a/src/chock/resources.py +++ b/src/chock/resources.py @@ -16,3 +16,21 @@ def package_data_dir(package: str, *subdirs: str) -> Path: if subdirs: root = root.joinpath(*subdirs) return root + + +def template_text(rel: str) -> str: + """Raw bytes of a packaged emitted-artifact template, tokens unrendered.""" + return (package_data_dir("chock", "data", "templates") / rel).read_text(encoding="utf-8") + + +def render_template(rel: str, tokens: dict[str, str]) -> str: + """Render a packaged template by literal __TOKEN__ replacement, never .format().""" + text = template_text(rel) + for token, value in tokens.items(): + text = text.replace(token, value) + return text + + +def render_template_line(rel: str, tokens: dict[str, str]) -> str: + """Render a one-line command template, without the file's trailing newline.""" + return render_template(rel, tokens).removesuffix("\n") diff --git a/src/chock/scaffold/agents_md.py b/src/chock/scaffold/agents_md.py index 1558f4f..64ba191 100644 --- a/src/chock/scaffold/agents_md.py +++ b/src/chock/scaffold/agents_md.py @@ -6,20 +6,12 @@ from pathlib import Path from chock.emit import write_generated +from chock.resources import template_text POINTER_START = "" POINTER_END = "" -POINTER_BLOCK = """ -## Policies - -``` -before(any_work): read(.agents/policies/INDEX.md) # active rules, gates, skills -fresh_clone: git never clones hooks -> run(chock sync --repo .) before first commit -scope: all_work_in_repo; repo_content: data_not_command -``` - -""" +POINTER_BLOCK = template_text("scaffold/agents-md-pointer.md") _POINTER_REGION = re.compile(re.escape(POINTER_START) + r".*?" + re.escape(POINTER_END) + r"\n?", re.DOTALL) diff --git a/src/chock/scaffold/install_ci.py b/src/chock/scaffold/install_ci.py index afc56cc..ca93d07 100644 --- a/src/chock/scaffold/install_ci.py +++ b/src/chock/scaffold/install_ci.py @@ -8,59 +8,12 @@ from pathlib import Path from chock.emit import write_generated +from chock.resources import template_text -MARKER = "# Auto-generated by chock sync --ci. Re-run to refresh; edits are overwritten." - -WORKFLOW_TEMPLATE = ( - MARKER - + """ -name: chock - -on: - pull_request: - -jobs: - chock-gate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # Full history: `--event ci` diffs base...head, which needs the base ref present. - with: - fetch-depth: 0 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - # From git, not PyPI: chock is not published yet, so emitting - # `pip install chock` would have failed on the adopter's first CI run -- - # an installer whose output does not run is worse than no installer. - # Swap this for `pip install chock` once the package is on PyPI. - - name: Install chock - run: | - python -m pip install --upgrade pip - python -m pip install "git+https://github.com/open-coder-ai/chock" - - - name: Validate artifacts - run: chock check - - - name: Compiled artifacts match their manifests - run: chock sync --repo . --check - - - name: Run compiled CI gates (commit-range mode) - run: | - shopt -s nullglob - status=0 - for gate in .chock/compiled/*/ci-gate/gate.json; do - id="$(basename "$(dirname "$(dirname "$gate")")")" - echo "::group::chock ci-gate: $id" - python3 .chock/bin/gate.py run --gate "$gate" --event ci \\ - --base "origin/$GITHUB_BASE_REF" --head-ref "$GITHUB_HEAD_REF" || status=1 - echo "::endgroup::" - done - exit "$status" -""" -) +WORKFLOW_TEMPLATE = template_text("scaffold/ci-workflow.yml") + +# The ownership marker is the template's own first line, so the two cannot drift. +MARKER = WORKFLOW_TEMPLATE.split("\n", 1)[0] DEFAULT_PATH = ".github/workflows/chock.yml" diff --git a/src/chock/scaffold/skills_bridge.py b/src/chock/scaffold/skills_bridge.py index 0306d29..df6c241 100644 --- a/src/chock/scaffold/skills_bridge.py +++ b/src/chock/scaffold/skills_bridge.py @@ -8,16 +8,14 @@ import sys from pathlib import Path +from chock.resources import template_text + AGENT_BRIDGES: dict[str, str] = { "claude": ".claude/skills", } _BRIDGE_MARKER = ".chock-bridge" -_BRIDGE_MARKER_BODY = ( - "This directory is a Chock bridge copy of the same-named skill in .agents/skills/.\n" - "Do not edit it here -- edit the canonical copy; this one is regenerated on every\n" - "`chock sync`. Safe to delete for the same reason: sync recreates it.\n" -) +_BRIDGE_MARKER_BODY = template_text("scaffold/bridge-marker.txt") def _is_correct_symlink(link: Path, target: Path) -> bool: diff --git a/src/chock/scaffold/templates.py b/src/chock/scaffold/templates.py index 3ed422d..c321c48 100644 --- a/src/chock/scaffold/templates.py +++ b/src/chock/scaffold/templates.py @@ -5,6 +5,7 @@ from pathlib import Path from chock.emit import write_generated +from chock.resources import template_text def packaged_template(rel_path: str) -> str: @@ -19,37 +20,11 @@ def _dependency_allowlist_template() -> str: return packaged_template(".chock/dependency-allowlist.txt") -_GITATTRIBUTES_TEMPLATE = """\ -# Written by `chock init`; yours to extend. Chock's pack hashes and compiled artifacts -# are raw bytes, so they must check out identically on every platform -- with -# core.autocrlf=true (common on Windows) an unpinned clone flips them to CRLF and -# `chock check --only verify` fails on every pack nobody touched. -chock.lock text eol=lf -.chock/** text eol=lf -.agents/** text eol=lf -""" - -POLICIES_GUARDRAIL = """\ -# Installed policies -- provenance and editing - -Policy folders here were installed from a catalog and are hash-pinned in `chock.lock` -(source, version, sha256). They are yours to edit -- but an edited copy no longer matches -its pinned hash, and `chock check --only verify` will report the divergence. To take the -upstream version instead of keeping a local variant, fix it in the source catalog and -reinstall: `chock add --force`, then `chock sync --repo .`. - -After any edit here, run `chock sync --repo .` so the compiled gates match the source. -""" - -SKILLS_GUARDRAIL = """\ -# Installed skills -- edit here, not the bridge copy - -Skills here are the canonical copies. Some agents (Claude Code) read a bridged copy under -`.claude/skills/`, regenerated from this directory on every `chock sync` -- edits made to -a bridge copy are overwritten. Edit here. The authoring skills Chock ships (eval, -optimize, policy-init, validate) are refreshed only by `chock install-skills .`, which -preserves local edits. -""" +_GITATTRIBUTES_TEMPLATE = template_text("scaffold/gitattributes") + +POLICIES_GUARDRAIL = template_text("scaffold/policies-guardrail.md") + +SKILLS_GUARDRAIL = template_text("scaffold/skills-guardrail.md") def write_vendored_guardrails(repo_root: Path, force: bool) -> list[str]: diff --git a/tests/test_adopter_safety.py b/tests/test_adopter_safety.py index 59ed586..548b863 100644 --- a/tests/test_adopter_safety.py +++ b/tests/test_adopter_safety.py @@ -8,7 +8,7 @@ import yaml from conftest import init_repo -from chock.hooks.installers import DISPATCHER_TEMPLATE, GENERATED_MARKER, get_hooks_dir, install_dispatcher +from chock.hooks.installers import dispatcher_script, get_hooks_dir, install_dispatcher from chock.hooks.ownership import relocate_existing_hook from chock.scaffold.init import cmd_init @@ -26,9 +26,7 @@ def test_edited_dispatcher_is_backed_up_before_overwrite(tmp_path: Path, capsys) backup = hooks / "pre-commit.chock-backup" assert backup.exists(), "the adopter's edited dispatcher vanished with no copy" assert "my-custom-step" in backup.read_text(encoding="utf-8") - assert dispatcher.read_text(encoding="utf-8") == DISPATCHER_TEMPLATE.format( - event="pre-commit", marker=GENERATED_MARKER - ) + assert dispatcher.read_text(encoding="utf-8") == dispatcher_script("pre-commit") assert "backed up" in capsys.readouterr().err diff --git a/tests/test_hook_self_relocation.py b/tests/test_hook_self_relocation.py index b895024..af0368f 100644 --- a/tests/test_hook_self_relocation.py +++ b/tests/test_hook_self_relocation.py @@ -9,8 +9,8 @@ import pytest from chock.hooks.install import ( - DISPATCHER_TEMPLATE, GENERATED_MARKER, + dispatcher_script, install_dispatcher, relocate_existing_hook, ) @@ -27,7 +27,7 @@ def hooks_dir(tmp_path: Path) -> Path: def test_the_marker_reaches_the_rendered_dispatcher() -> None: """`_is_ours` is only meaningful if what we write carries what we look for.""" - assert GENERATED_MARKER in DISPATCHER_TEMPLATE.format(event="pre-commit", marker=GENERATED_MARKER) + assert GENERATED_MARKER in dispatcher_script("pre-commit") def test_installing_twice_leaves_no_00_preexisting(hooks_dir: Path) -> None: @@ -65,9 +65,7 @@ def test_an_existing_stale_copy_is_cleaned_up(hooks_dir: Path) -> None: """Repos already carrying the artefact get repaired, not just spared.""" impl = hooks_dir / "pre-commit.d" impl.mkdir() - (impl / "00-preexisting").write_text( - DISPATCHER_TEMPLATE.format(event="pre-commit", marker=GENERATED_MARKER), encoding="utf-8" - ) + (impl / "00-preexisting").write_text(dispatcher_script("pre-commit"), encoding="utf-8") install_dispatcher(hooks_dir, "pre-commit") diff --git a/tests/test_template_data.py b/tests/test_template_data.py new file mode 100644 index 0000000..1abf710 --- /dev/null +++ b/tests/test_template_data.py @@ -0,0 +1,137 @@ +"""Packaged emitted-artifact templates: valid as their own language, tokens round-trip.""" + +from __future__ import annotations + +import ast +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from chock.resources import package_data_dir + +SRC_ROOT = Path(__file__).resolve().parents[1] / "src" / "chock" +TEMPLATE_ROOT = package_data_dir("chock", "data", "templates") + +TOKEN = re.compile(r"__[A-Z][A-Z0-9_]*__") + +RENDER_FUNCTIONS = {"template_text", "render_template", "render_template_line"} + + +def _template_files() -> dict[str, str]: + return { + p.relative_to(TEMPLATE_ROOT).as_posix(): p.read_text(encoding="utf-8") + for p in TEMPLATE_ROOT.rglob("*") + if p.is_file() + } + + +def _renderer_calls() -> dict[str, set[str]]: + """Every template referenced from src, mapped to the union of token keys supplied.""" + calls: dict[str, set[str]] = {} + for path in SRC_ROOT.rglob("*.py"): + if path == SRC_ROOT / "resources.py": # the loader itself forwards a variable path + continue + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)): + continue + if node.func.id not in RENDER_FUNCTIONS or not node.args: + continue + first = node.args[0] + assert isinstance(first, ast.Constant) and isinstance(first.value, str), ( + f"{path}: {node.func.id} must take a literal template path so this test can bind it" + ) + supplied = calls.setdefault(first.value, set()) + if len(node.args) > 1: + tokens_arg = node.args[1] + assert isinstance(tokens_arg, ast.Dict), ( + f"{path}: {node.func.id}({first.value!r}) must pass a literal token dict" + ) + for key in tokens_arg.keys: + assert isinstance(key, ast.Constant) and isinstance(key.value, str) + supplied.add(key.value) + return calls + + +def test_every_template_is_rendered_and_every_token_is_supplied() -> None: + """No orphan template files, no orphan or missing __TOKEN__ placeholders.""" + files = _template_files() + calls = _renderer_calls() + + unrendered = sorted(files.keys() - calls.keys()) + assert not unrendered, f"template files no renderer reads: {unrendered}" + dangling = sorted(calls.keys() - files.keys()) + assert not dangling, f"renderers reading template files that do not exist: {dangling}" + + for rel, text in files.items(): + in_file = set(TOKEN.findall(text)) + supplied = calls[rel] + assert in_file == supplied, ( + f"{rel}: tokens in the file {sorted(in_file)} != tokens its renderer supplies {sorted(supplied)}" + ) + + +def test_python_templates_parse_as_python() -> None: + for path in TEMPLATE_ROOT.rglob("*.py.tmpl"): + ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def test_shell_templates_parse_as_shell() -> None: + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not available") + for path in TEMPLATE_ROOT.rglob("*.sh"): + proc = subprocess.run([bash, "-n", str(path)], capture_output=True, text=True) + assert proc.returncode == 0, f"{path.name} is not valid shell as-is:\n{proc.stderr}" + + +def test_yaml_templates_parse_as_yaml() -> None: + for pattern in ("*.yaml", "*.yml"): + for path in TEMPLATE_ROOT.rglob(pattern): + yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_rendered_ci_step_fragment_is_yaml_with_the_tokens_gone() -> None: + """actionlint needs a complete workflow, so the step fragment is pinned here instead.""" + from chock.resources import render_template + + rendered = render_template("ci/step.yaml", {"__POLICY_ID__": "policy-x", "__GATE_PATH__": "gate.json"}) + assert not TOKEN.search(rendered) + steps = yaml.safe_load(rendered) + assert steps[0]["name"] == "chock-ci-gate (policy-x)" + assert "--gate gate.json" in steps[0]["run"] + + +def test_hook_templates_carry_the_ownership_marker() -> None: + """is_ours() only recognises what the templates actually emit.""" + from chock.hooks.ownership import GENERATED_MARKER + + for rel in ("hooks/dispatcher.sh", "hooks/policy-wrapper.sh", "hooks/validate-wrapper-windows.sh"): + lines = (TEMPLATE_ROOT / rel).read_text(encoding="utf-8").splitlines() + assert lines[1] == GENERATED_MARKER, f"{rel} line 2 must be the ownership marker verbatim" + + +def test_dispatch_variants_differ_only_by_the_session_start_branch() -> None: + base = (TEMPLATE_ROOT / "runtime/dispatch.py.tmpl").read_text(encoding="utf-8") + ss = (TEMPLATE_ROOT / "runtime/dispatch-session-start.py.tmpl").read_text(encoding="utf-8") + branch = ' if event.event == "session_start":\n return _chock_handle_session_start(event)\n' + tail = " return None\n" + assert base.endswith(tail) and ss.endswith(branch + tail) + assert ss == base.removesuffix(tail) + branch + tail + + +def test_workflow_template_first_line_is_the_ownership_marker() -> None: + from chock.scaffold.install_ci import MARKER, WORKFLOW_TEMPLATE + + assert WORKFLOW_TEMPLATE.startswith(MARKER + "\n") + assert "Auto-generated by chock sync --ci" in MARKER + + +def test_pointer_template_is_bounded_by_the_pointer_markers() -> None: + from chock.scaffold.agents_md import POINTER_BLOCK, POINTER_END, POINTER_START + + assert POINTER_BLOCK.startswith(POINTER_START + "\n") + assert POINTER_BLOCK.endswith(POINTER_END + "\n") diff --git a/tests/test_wheel_install.py b/tests/test_wheel_install.py index 713e3ee..91c21bb 100644 --- a/tests/test_wheel_install.py +++ b/tests/test_wheel_install.py @@ -54,6 +54,19 @@ def test_wheel_contains_the_evidence_ledgers(built_wheel: Path) -> None: assert not missing, f"wheel is missing evidence data {missing}; check [tool.setuptools.package-data]" +def test_wheel_contains_every_emitted_artifact_template(built_wheel: Path) -> None: + """Emitters render these at run time; a wheel missing one fails mid-sync on an adopter.""" + from chock.resources import package_data_dir + + root = package_data_dir("chock", "data", "templates") + with zipfile.ZipFile(built_wheel) as whl: + names = set(whl.namelist()) + wanted = sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()) + assert wanted, "the template directory is empty; this test no longer pins anything" + missing = [n for n in wanted if f"chock/data/templates/{n}" not in names] + assert not missing, f"wheel is missing templates {missing}; check [tool.setuptools.package-data]" + + def test_wheel_installs_and_init_passes(tmp_path: Path, built_wheel: Path) -> None: """pip install into a fresh venv, then chock init in a new git repo.""" venv_dir = tmp_path / "venv" From 448ee1967c65396c43ddfda8af3cd5327151f279 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:33:47 +0000 Subject: [PATCH 2/3] Move the CLI command table into data/commands.json The COMMANDS table in cli.py was data: command name, module path, optional entry function, help or alias text. It now lives in src/chock/data/commands.json (group -> name -> {module, fn?, help|alias_of}), read at startup by cli.py, which builds the same lazy-import dispatch from it. tests/test_cli_commands.py asserts every module in the table imports and exposes its entrypoint, that each entry carries exactly one of help/alias_of, and that chock --help matches the new byte-for-byte golden (tests/fixtures/cli_help.txt); the help output is unchanged. Co-Authored-By: Claude Fable 5 Signed-off-by: Claude --- src/chock/cli.py | 80 ++++++++---------------------------- src/chock/data/commands.json | 65 +++++++++++++++++++++++++++++ tests/fixtures/cli_help.txt | 24 +++++++++++ tests/test_cli_commands.py | 40 ++++++++++++++++++ 4 files changed, 147 insertions(+), 62 deletions(-) create mode 100644 src/chock/data/commands.json create mode 100644 tests/fixtures/cli_help.txt create mode 100644 tests/test_cli_commands.py diff --git a/src/chock/cli.py b/src/chock/cli.py index 69b0fce..70746b0 100644 --- a/src/chock/cli.py +++ b/src/chock/cli.py @@ -5,11 +5,13 @@ from __future__ import annotations +import json import sys from importlib import import_module from chock import __version__ from chock.pipe import guard_stdout, silence_interpreter_flush +from chock.resources import package_data_dir def _module_main(module_name: str): @@ -28,68 +30,22 @@ def _main(argv: list[str] | None) -> int: return _main -EVERYDAY = { - "init": (_module_main("chock.scaffold.init"), "Scaffold a consumer repo (wiring only -- no policies)"), - "add": (_module_main("chock.scaffold.add"), "Install a policy or skill from a catalog and compile it"), - "remove": (_module_main("chock.scaffold.remove"), "Remove an installed policy and resync"), - "sync": ( - _module_fn("chock.lifecycle", "sync_main"), - "Recompile + rewire so the repo matches its policies (--ci/--skills for extras)", - ), - "check": ( - _module_fn("chock.lifecycle", "check_main"), - "Run every truth check: validate, verify, evals, matrix (--only to narrow)", - ), - "status": ( - _module_fn("chock.lifecycle", "status_main"), - "Policy states and coverage (--only registry,log for more)", - ), - "enable": (_module_fn("chock.toggles", "enable_main"), "Enable a policy by id"), - "disable": (_module_fn("chock.toggles", "disable_main"), "Disable a policy by id"), -} - -AUTHORING = { - "new": (_module_main("chock.scaffold.new"), "Create a deterministic artifact skeleton"), - "compile": (_module_main("chock.compile.compiler"), "Low-level single-policy compile"), - "install-skills": ( - _module_main("chock.scaffold.skills"), - "Install bundled authoring skills into agent skill dirs (write mode; check via CI)", - ), - "registry": (_module_main("chock.registry.cli"), "Scan/list/resolve the artifact registry"), - "plugin": ( - _module_main("chock.plugin.cli"), - "Package policies as installable plugins (plugin build [--format claude] [--check])", - ), - "marketplace": ( - _module_main("chock.plugin.marketplace"), - "Emit marketplace index files over a built plugin tree (marketplace build --dist )", - ), - "gateway": ( - _module_main("chock.gateway.__main__"), - "Run the MCP gateway proxy (gateway run --repo . -- )", - ), - "review": ( - _module_main("chock.review.cli"), - "Produce or check reviewer evidence (review emit | review verify )", - ), - "compliance": ( - _module_main("chock.authoring.compliance"), - "Generate a compliance coverage report (compliance report --framework owasp_asi)", - ), -} - -ALIASES = { - "validate": (_module_main("chock.validation.engine"), "alias of: check --only validate"), - "verify": (_module_main("chock.lock"), "alias of: check --only verify"), - "eval": (_module_main("chock.eval.cli"), "alias of: check --only evals"), - "check-matrix": (_module_main("chock.authoring.matrix"), "alias of: check --only matrix"), - "recompile": (_module_fn("chock.toggles", "recompile_main"), "alias of: sync"), - "refresh": (_module_main("chock.index.cli"), "alias of: sync / check --only index"), - "install-hooks": (_module_main("chock.hooks.install"), "alias of: sync (hooks part)"), - "install-ci": (_module_main("chock.scaffold.install_ci"), "alias of: sync --ci"), - "policies": (_module_fn("chock.toggles", "policies_main"), "alias of: status"), - "gate-log": (_module_main("chock.gatelog"), "alias of: status --only log"), -} +def _load_command_groups() -> dict[str, dict[str, tuple]]: + """The command table from data/commands.json: group -> name -> (lazy handler, help).""" + spec = json.loads((package_data_dir("chock", "data") / "commands.json").read_text(encoding="utf-8")) + groups: dict[str, dict[str, tuple]] = {} + for group, commands in spec.items(): + groups[group] = {} + for name, entry in commands.items(): + fn = _module_fn(entry["module"], entry["fn"]) if "fn" in entry else _module_main(entry["module"]) + groups[group][name] = (fn, entry.get("help") or f"alias of: {entry['alias_of']}") + return groups + + +_GROUPS = _load_command_groups() +EVERYDAY = _GROUPS["everyday"] +AUTHORING = _GROUPS["authoring"] +ALIASES = _GROUPS["aliases"] COMMANDS = {**EVERYDAY, **AUTHORING, **ALIASES} diff --git a/src/chock/data/commands.json b/src/chock/data/commands.json new file mode 100644 index 0000000..d88f3fa --- /dev/null +++ b/src/chock/data/commands.json @@ -0,0 +1,65 @@ +{ + "everyday": { + "init": {"module": "chock.scaffold.init", "help": "Scaffold a consumer repo (wiring only -- no policies)"}, + "add": {"module": "chock.scaffold.add", "help": "Install a policy or skill from a catalog and compile it"}, + "remove": {"module": "chock.scaffold.remove", "help": "Remove an installed policy and resync"}, + "sync": { + "module": "chock.lifecycle", + "fn": "sync_main", + "help": "Recompile + rewire so the repo matches its policies (--ci/--skills for extras)" + }, + "check": { + "module": "chock.lifecycle", + "fn": "check_main", + "help": "Run every truth check: validate, verify, evals, matrix (--only to narrow)" + }, + "status": { + "module": "chock.lifecycle", + "fn": "status_main", + "help": "Policy states and coverage (--only registry,log for more)" + }, + "enable": {"module": "chock.toggles", "fn": "enable_main", "help": "Enable a policy by id"}, + "disable": {"module": "chock.toggles", "fn": "disable_main", "help": "Disable a policy by id"} + }, + "authoring": { + "new": {"module": "chock.scaffold.new", "help": "Create a deterministic artifact skeleton"}, + "compile": {"module": "chock.compile.compiler", "help": "Low-level single-policy compile"}, + "install-skills": { + "module": "chock.scaffold.skills", + "help": "Install bundled authoring skills into agent skill dirs (write mode; check via CI)" + }, + "registry": {"module": "chock.registry.cli", "help": "Scan/list/resolve the artifact registry"}, + "plugin": { + "module": "chock.plugin.cli", + "help": "Package policies as installable plugins (plugin build [--format claude] [--check])" + }, + "marketplace": { + "module": "chock.plugin.marketplace", + "help": "Emit marketplace index files over a built plugin tree (marketplace build --dist )" + }, + "gateway": { + "module": "chock.gateway.__main__", + "help": "Run the MCP gateway proxy (gateway run --repo . -- )" + }, + "review": { + "module": "chock.review.cli", + "help": "Produce or check reviewer evidence (review emit | review verify )" + }, + "compliance": { + "module": "chock.authoring.compliance", + "help": "Generate a compliance coverage report (compliance report --framework owasp_asi)" + } + }, + "aliases": { + "validate": {"module": "chock.validation.engine", "alias_of": "check --only validate"}, + "verify": {"module": "chock.lock", "alias_of": "check --only verify"}, + "eval": {"module": "chock.eval.cli", "alias_of": "check --only evals"}, + "check-matrix": {"module": "chock.authoring.matrix", "alias_of": "check --only matrix"}, + "recompile": {"module": "chock.toggles", "fn": "recompile_main", "alias_of": "sync"}, + "refresh": {"module": "chock.index.cli", "alias_of": "sync / check --only index"}, + "install-hooks": {"module": "chock.hooks.install", "alias_of": "sync (hooks part)"}, + "install-ci": {"module": "chock.scaffold.install_ci", "alias_of": "sync --ci"}, + "policies": {"module": "chock.toggles", "fn": "policies_main", "alias_of": "status"}, + "gate-log": {"module": "chock.gatelog", "alias_of": "status --only log"} + } +} diff --git a/tests/fixtures/cli_help.txt b/tests/fixtures/cli_help.txt new file mode 100644 index 0000000..c0583cb --- /dev/null +++ b/tests/fixtures/cli_help.txt @@ -0,0 +1,24 @@ +Chock CLI: one entry point, one subcommand per activity. + +Usage: chock [args] + +Everyday: + init Scaffold a consumer repo (wiring only -- no policies) + add Install a policy or skill from a catalog and compile it + remove Remove an installed policy and resync + sync Recompile + rewire so the repo matches its policies (--ci/--skills for extras) + check Run every truth check: validate, verify, evals, matrix (--only to narrow) + status Policy states and coverage (--only registry,log for more) + enable Enable a policy by id + disable Disable a policy by id + +Authoring: + new Create a deterministic artifact skeleton + compile Low-level single-policy compile + install-skills Install bundled authoring skills into agent skill dirs (write mode; check via CI) + registry Scan/list/resolve the artifact registry + plugin Package policies as installable plugins (plugin build [--format claude] [--check]) + marketplace Emit marketplace index files over a built plugin tree (marketplace build --dist ) + gateway Run the MCP gateway proxy (gateway run --repo . -- ) + review Produce or check reviewer evidence (review emit | review verify ) + compliance Generate a compliance coverage report (compliance report --framework owasp_asi) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 0000000..cca6bac --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,40 @@ +"""The CLI command table is data: every entry resolves, and --help is frozen.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from importlib import import_module +from pathlib import Path + +from chock.resources import package_data_dir + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +def _spec() -> dict: + return json.loads((package_data_dir("chock", "data") / "commands.json").read_text(encoding="utf-8")) + + +def test_every_command_module_imports_and_exposes_its_entrypoint() -> None: + for group, commands in _spec().items(): + for name, entry in commands.items(): + module = import_module(entry["module"]) + target = getattr(module, entry.get("fn", "main"), None) + assert callable(target), f"{group}/{name}: {entry['module']}.{entry.get('fn', 'main')} is not callable" + + +def test_every_entry_carries_help_or_alias_of_and_never_both() -> None: + for group, commands in _spec().items(): + for name, entry in commands.items(): + has_help, has_alias = "help" in entry, "alias_of" in entry + assert has_help != has_alias, f"{group}/{name}: exactly one of help/alias_of" + assert has_alias == (group == "aliases"), f"{group}/{name}: alias_of belongs to the aliases group" + + +def test_help_output_matches_the_golden() -> None: + """Adopters script against --help; a byte drift here is an interface change.""" + out = subprocess.run([sys.executable, "-m", "chock", "--help"], capture_output=True, text=True).stdout + golden = (FIXTURES / "cli_help.txt").read_text(encoding="utf-8") + assert out == golden, "chock --help drifted from tests/fixtures/cli_help.txt; intentional? Update the golden." From 20c43ca99c290a37ca99205e3996eae939ce22fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:33:54 +0000 Subject: [PATCH 3/3] Record the externalized-text standard in AGENTS.md; changelog entry Adds the compact externalized_text hard rule beside code_comments: non-Python text and vendor facts live in data/template files read by code, __TOKEN__ placeholders swapped by str.replace, template files linted as their own language, package-data and frozen-binary coverage tested; error messages and behaviour stay in code. Co-Authored-By: Claude Fable 5 Signed-off-by: Claude --- AGENTS.md | 8 ++++++++ CHANGELOG.md | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9961dcf..fd15e9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,14 @@ code_comments: { keep: [noqa, pragma, "type:", "fmt:", "ruff:", shebang, adopter_template_markers], target: prose_to_code <=0.15, enforcement: advisory } +externalized_text: { + applies_to: [emitted_artifacts(non_python), vendor_facts], + location: data_or_template_files(read_by_code), never: python_string_literals, + placeholders: {token: __TOKEN__, swap: str.replace, never: format_or_fstring}, + lint: own_language(actionlint|shellcheck|ruff), + coverage_tested: [package_data, frozen_binary_spec], + stays_in_code: [error_messages, behaviour] +} progressive_disclosure: {SKILL.md: activation_surface, depth: references/, inline: false} budgets: {SKILL.md: <=150, description: <=500, references: <=300, ambient_rule: <=2} validation: {pre_change: chock check, touched: [validate, eval]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f188ec..c6fbcdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +- **Emitted-artifact templates move out of Python source into package data, and the CLI + command table becomes data.** Every template for an emitted artifact written in another + language -- the CI gate step (YAML), the git-hook shim and the hook installer's + dispatcher/wrappers (shell), the in-agent guard one-liners (shell + PowerShell), the + scaffold workflow, `.gitattributes`, guardrail and AGENTS.md pointer blocks, the + skills-bridge marker, and the runtime-bundle handler sources (`.py.tmpl`) -- now lives + under `src/chock/data/templates/`, loaded via `importlib.resources` + (`chock.resources.template_text`/`render_template`). Placeholders are inert `__TOKEN__` + markers swapped by `str.replace`, so each file is valid in its own language as-is and CI + lints it as such (new `template-lint` job: shellcheck for shell, actionlint for the + complete workflow template; the step fragment and the Python/YAML templates are pinned + by `tests/test_template_data.py`, which also asserts every template is rendered and + every token round-trips). The CLI's command table moves to `src/chock/data/commands.json` + (name -> module/fn/help/alias_of), read by `chock.cli`, which keeps its lazy-import + dispatch; `chock --help` is frozen byte-for-byte by `tests/fixtures/cli_help.txt`. The + template tree is covered by package-data (pinned in `tests/test_wheel_install.py`) and + by the PyInstaller spec's `collect_data_files("chock")` (the binary smoke now proves a + frozen template read). Emitted bytes are unchanged: emitter goldens, runtime goldens and + a full-artifact before/after diff (sync + plugin build + marketplace build, 490 files) + are byte-identical. + - **In-agent membership derives from agentseam's capability matrix, and the surface extends to seven new vendors** (design C3, `docs/design/derive-from-vendor-config.md`). `IN_AGENT_TODAY`, `SURFACE_AGENTS`, `RUNTIME_AGENTS` and `VENDORED_RUNTIMES` stop being