diff --git a/.gitignore b/.gitignore index 3b75036..8a21f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .claude/settings.local.json .claude/worktrees/ +.claude/scheduled_tasks.lock __pycache__/ *.py[cod] .pytest_cache/ diff --git a/README.md b/README.md index 8e492ed..f53a909 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,9 @@ coding-scaffold setup update --target . ``` Files that still match their generated checksum are updated in place. Files you edited are preserved -and the new generated version is staged next to them as `.new`. +and the new generated version is staged next to them as `.new`. The full upgrade contract — +`.new` reconciliation recipe, rollback, version pinning, and how to read the CHANGELOG's +breaking-changes — is in [`Upgrading`](docs/docs/wiki/Upgrading.md). If you join an experienced team, connect to its onboarding manifest instead. This pulls the common knowledge base and exposes approved skills, agents, policy, and config locally: diff --git a/docs/docs/wiki/Upgrading.md b/docs/docs/wiki/Upgrading.md new file mode 100644 index 0000000..e9a94f2 --- /dev/null +++ b/docs/docs/wiki/Upgrading.md @@ -0,0 +1,158 @@ +# Upgrading CodingScaffold + +`coding-scaffold setup update` refreshes the generated files in +`.coding-scaffold/` (plus `AGENTS.md`, `CLAUDE.md`, etc.) without losing your +edits. This page explains the contract end-to-end so the upgrade path is +predictable. + +## TL;DR + +1. Upgrade the tool itself: + ```bash + uv tool upgrade coding-scaffold # or: pipx upgrade coding-scaffold + ``` +2. Rerun `setup update` in each project: + ```bash + coding-scaffold setup update --target . + ``` +3. If `.new` files were written, follow the printed reconciliation recipe + (also shown below). +4. Commit the result. Re-run `coding-scaffold eval run` to confirm health. + +## What `setup update` does step-by-step + +`setup update` is **idempotent**, **safe by default**, and **never destroys +user edits**. The flow: + +1. Reads `.coding-scaffold/scaffold-version.json` (the SHA256 snapshot of every + file the scaffold has ever written). +2. Refuses to run if the installed scaffold version is older than the project's + recorded `min_supported_scaffold_version` (see [Version pinning](#version-pinning) + below). Pass `--force` to bypass after reading the migration note. +3. Rebuilds every generated file into a temporary directory using the current + intake + provider + routing inputs. +4. For each generated file: + - **Doesn't exist on disk** → write it. (You can safely delete files you + don't want; the next update will recreate them. To permanently exclude a + file, drop it from the writer set in your fork — there is no per-file + opt-out yet.) + - **Exists, matches snapshot, matches new** → skip. Nothing to do. + - **Exists, matches snapshot, differs from new** → silent rewrite. The + scaffold knows you didn't touch it, so the upstream version wins. + - **Exists, differs from snapshot** → user edited it. Write the new version + as a `.new` sidecar; leave your file alone. +5. Refresh `scaffold-version.json` to reflect the new authoritative shape. + +## The `.new` workflow + +When `setup update` writes `.new`, your edits to `` are preserved +and an upstream version sits next to it for you to merge. The reconciliation +recipe (which `setup update` prints when `.new` files are produced): + +```bash +# 1. Diff the pair so you can see what changed upstream: +diff -u .coding-scaffold/foo.json .coding-scaffold/foo.json.new + +# 2. Merge the upstream changes into your edited file by hand. +# (Or pull both into your editor's three-way merge tool.) + +# 3. Delete the .new sidecar once you're done: +rm .coding-scaffold/foo.json.new + +# 4. Re-run eval to confirm the project is healthy: +coding-scaffold eval run --target . +``` + +Do **not** blindly `mv foo.new foo` — that throws away your edits, defeating +the whole point of `.new`. Always merge. + +If you're confident the upstream version is the right starting point and your +edits should be re-derived from scratch, the deliberate workflow is: + +```bash +git diff -- .coding-scaffold/foo.json # capture your edits in your head +mv .coding-scaffold/foo.json.new .coding-scaffold/foo.json +# re-apply your edits on top, then: +coding-scaffold eval run --target . +``` + +## How to roll back + +`setup update` writes a new git-trackable shape. If something is wrong: + +```bash +# Discard everything the update touched: +git restore .coding-scaffold/ AGENTS.md CLAUDE.md # add other paths if needed +git status # confirm clean + +# Pin to the previous scaffold version so the next `setup update` doesn't +# re-apply the same change: +uv tool install 'coding-scaffold==0.5.1' # or your previous version +# or: pipx install --force 'coding-scaffold==0.5.1' +``` + +There is no built-in rollback; git is the safety net. Run `setup update` only +on a clean working tree so `git restore` is always sufficient. + +## Files you deleted on purpose + +If you intentionally delete a generated file (e.g., you don't want `tools.md`), +the next `setup update` recreates it. CodingScaffold has no per-project opt-out +yet; track this in your team manifest if it matters. The recommended pattern: + +1. Delete the file. +2. Run `setup update`. The file comes back. +3. Either accept it (it's harmless if unused) or re-delete and add a project + policy note explaining why. + +## Version pinning + +`scaffold-version.json` carries a `min_supported_scaffold_version` field +(default: the version of CodingScaffold that wrote the file). `setup update` +refuses to run if the installed scaffold is older than this floor. + +Example failure: + +``` +error: this project was last updated with CodingScaffold 0.6.0, but 0.5.1 is installed. + next: upgrade the scaffold (`pip install -U coding-scaffold` or + `uv tool upgrade coding-scaffold`), or rerun with `--force` after + reading the migration note. + see: https://jrs1986.github.io/CodingScaffold/wiki/Upgrading +``` + +This catches the case where a teammate updates the project with a newer +scaffold but a CI job or another machine still has an older version pinned. +Bypass with `--force` only after confirming the older scaffold's writers can +produce the project shape you actually want. + +## Reading the CHANGELOG for breaking changes + +The CHANGELOG groups changes by release. Look for these sections: + +- **Breaking** — anything that changes the *shape* of generated files (renamed + keys, removed sections, file moves). When `setup update` runs across a + Breaking boundary, it always produces `.new` files for the affected outputs; + read the Breaking section to know what the merge should look like. +- **Deprecated** — features that still work but are scheduled for removal. + Plan to migrate before the next major bump. +- **Stability** — commands moved between `stable`/`preview`/`experimental` + markers. The Stability wiki page (shipped alongside this one) defines what + each marker promises. + +A worked example: if 0.6.0's CHANGELOG says "Renamed `policy.network.allow` +to `policy.network.allowlist`", and your update produced +`.coding-scaffold/policy/network.json.new`, the diff will show exactly that +rename. Merge by renaming the key in your edited file and dropping the +sidecar. + +## When `setup update` is not the right tool + +- **First-time setup** → use `setup run`, not `setup update`. `update` needs an + existing scaffold to compare against. +- **Switching tools** (e.g., from OpenCode to Claude Code) → run + `setup run --target . --tool claude-code` to regenerate the adapter set + cleanly. `setup update` keeps your old tool's files alongside. +- **A breaking-change scenario you want to redo from scratch** → delete + `.coding-scaffold/` and rerun `setup run`. Read the relevant CHANGELOG + Breaking section first. diff --git a/docs/docs/wiki/_meta.json b/docs/docs/wiki/_meta.json index 8276286..a51f156 100644 --- a/docs/docs/wiki/_meta.json +++ b/docs/docs/wiki/_meta.json @@ -9,6 +9,7 @@ "Policy-Packs", "Security", "Tool-Adapters", + "Upgrading", "Advanced-Workflows", "Team-Rollout", "Review-Backlog", diff --git a/src/coding_scaffold/cli.py b/src/coding_scaffold/cli.py index b8a2646..2a4157a 100644 --- a/src/coding_scaffold/cli.py +++ b/src/coding_scaffold/cli.py @@ -154,6 +154,15 @@ def build_parser() -> argparse.ArgumentParser: setup_update = setup_sub.add_parser("update", help="Refresh generated scaffold files without losing edits.") setup_update.add_argument("--target", type=Path, default=Path.cwd(), help="Project directory.") setup_update.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + setup_update.add_argument( + "--force", + action="store_true", + help=( + "Bypass the min_supported_scaffold_version compatibility check. " + "Use when the installed scaffold is older than the project's recorded floor " + "and you've read https://jrs1986.github.io/CodingScaffold/wiki/Upgrading." + ), + ) # NOTE: The parsers below (init, wizard, knowledge-status, context-budget, # compress-context, orchestrate, setup-tool, setup-addon, setup-knowledge, @@ -1690,6 +1699,35 @@ def _cmd_policy(args: argparse.Namespace) -> int: def _cmd_update(args: argparse.Namespace) -> int: target = args.target.expanduser().resolve() + + # Compatibility gate: refuse to update if the installed scaffold is older + # than the project's recorded `min_supported_scaffold_version`. The .new + # workflow assumes the project structure the writers produce matches what + # `setup update` knows how to compare against; a downgrade can write files + # in a shape the old code doesn't recognize and silently clobber edits. + from . import __version__ + from .scaffold_version import compare_versions, read_min_supported_version + + min_required = read_min_supported_version(target) + if min_required and not getattr(args, "force", False): + if compare_versions(__version__, min_required) < 0: + print( + f"error: this project was last updated with CodingScaffold {min_required}, " + f"but {__version__} is installed.", + file=sys.stderr, + ) + print( + " next: upgrade the scaffold " + "(`pip install -U coding-scaffold` or `uv tool upgrade coding-scaffold`), " + "or rerun with `--force` after reading the migration note.", + file=sys.stderr, + ) + print( + " see: https://jrs1986.github.io/CodingScaffold/wiki/Upgrading", + file=sys.stderr, + ) + return 1 + intake = _load_project_intake(target) hardware = probe_hardware() providers = detect_providers(load_local_credentials(target)) @@ -1701,8 +1739,20 @@ def _cmd_update(args: argparse.Namespace) -> int: print(f"Updated {len(result.updated)} generated file(s).") print(f"Staged {len(result.staged)} edited file update(s) as .new.") print(f"Skipped {len(result.skipped)} already-current file(s).") - for path in result.staged: - print(f"Review staged update: {path}") + if result.staged: + print() + print("Reconcile .new files — copy-pasteable next steps:") + print(" 1. Diff each pair so you can see what changed upstream:") + for path in result.staged: + original = str(path)[: -len(".new")] if str(path).endswith(".new") else str(path) + print(f" diff -u {original} {path}") + print(" 2. Merge the upstream changes into your edited file (resolve by hand).") + print(" 3. Delete the .new sidecar once you're done:") + for path in result.staged: + print(f" rm {path}") + print(" 4. Re-run `coding-scaffold eval run --target .` to confirm the project is healthy.") + print() + print("Full upgrade guide: https://jrs1986.github.io/CodingScaffold/wiki/Upgrading") for warning in result.warnings: print(f"Warning: {warning}", file=sys.stderr) return 0 diff --git a/src/coding_scaffold/scaffold_version.py b/src/coding_scaffold/scaffold_version.py index 227fc19..4ce6856 100644 --- a/src/coding_scaffold/scaffold_version.py +++ b/src/coding_scaffold/scaffold_version.py @@ -4,6 +4,8 @@ import json from pathlib import Path +from . import __version__ + SCAFFOLD_VERSION_FILE = ".coding-scaffold/scaffold-version.json" @@ -12,6 +14,13 @@ def write_scaffold_version(target: Path, files: list[Path]) -> Path: root = target.expanduser().resolve() payload = { "version": 1, + # `min_supported_scaffold_version` pins the "do not downgrade past this + # CodingScaffold version" boundary for this project. `setup update` refuses + # to run when the installed scaffold is older than this — see + # ``read_min_supported_version`` and the `--force` flag handling in + # `cli._cmd_update`. Pinned to the current installed version on every + # write so a project upgraded on vN cannot silently regress on vN-1. + "min_supported_scaffold_version": __version__, "files": { display_path(path, root): sha256(path.read_bytes()) for path in sorted(files) @@ -36,10 +45,30 @@ def read_scaffold_version(root: Path) -> dict[str, str]: return {str(key): str(value) for key, value in files.items()} +def read_min_supported_version(root: Path) -> str | None: + """Return ``min_supported_scaffold_version`` from the snapshot, or None. + + None means: the snapshot predates this field (legacy projects) and no version + boundary is in effect. + """ + + path = root / SCAFFOLD_VERSION_FILE + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + value = payload.get("min_supported_scaffold_version") + return str(value) if value else None + + def write_scaffold_hashes(root: Path, hashes: dict[str, str]) -> Path: path = root / SCAFFOLD_VERSION_FILE path.parent.mkdir(parents=True, exist_ok=True) - payload = {"version": 1, "files": dict(sorted(hashes.items()))} + payload = { + "version": 1, + "min_supported_scaffold_version": __version__, + "files": dict(sorted(hashes.items())), + } path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") return path @@ -53,3 +82,28 @@ def display_path(path: Path, root: Path) -> str: return str(path.relative_to(root)) except ValueError: return str(path) + + +def compare_versions(left: str, right: str) -> int: + """Lexicographic semver-ish comparator. Returns -1/0/1. + + Tolerates `0.5.1`, `0.5.1.dev0`, `1.0`, `1`. Pre-release suffixes after the + third part compare lexicographically; this is fine for refusing downgrades + where the exact ordering of dev-versions is not safety-critical. + """ + + def parts(value: str) -> list[object]: + out: list[object] = [] + for chunk in value.split("."): + try: + out.append((0, int(chunk))) + except ValueError: + out.append((1, chunk)) + return out + + a, b = parts(left), parts(right) + if a < b: + return -1 + if a > b: + return 1 + return 0 diff --git a/tests/test_upgrade_compatibility.py b/tests/test_upgrade_compatibility.py new file mode 100644 index 0000000..4c2fd0d --- /dev/null +++ b/tests/test_upgrade_compatibility.py @@ -0,0 +1,184 @@ +"""Coverage for `setup update` version pinning + .new reconciliation output (issue #96).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from coding_scaffold import __version__ +from coding_scaffold.cli import main +from coding_scaffold.scaffold_version import ( + SCAFFOLD_VERSION_FILE, + compare_versions, + read_min_supported_version, + write_scaffold_hashes, + write_scaffold_version, +) + + +# --------------------------------------------------------------------------- +# Snapshot now carries min_supported_scaffold_version +# --------------------------------------------------------------------------- + + +def test_write_scaffold_version_records_min_supported(tmp_path: Path) -> None: + a = tmp_path / ".coding-scaffold" / "a.json" + a.parent.mkdir(parents=True) + a.write_text("x", encoding="utf-8") + write_scaffold_version(tmp_path, [a]) + + payload = json.loads((tmp_path / SCAFFOLD_VERSION_FILE).read_text(encoding="utf-8")) + assert payload["min_supported_scaffold_version"] == __version__ + + +def test_write_scaffold_hashes_records_min_supported(tmp_path: Path) -> None: + write_scaffold_hashes(tmp_path, {"a.md": "h"}) + payload = json.loads((tmp_path / SCAFFOLD_VERSION_FILE).read_text(encoding="utf-8")) + assert payload["min_supported_scaffold_version"] == __version__ + + +def test_read_min_supported_version_returns_none_when_missing(tmp_path: Path) -> None: + assert read_min_supported_version(tmp_path) is None + + +def test_read_min_supported_version_returns_value_when_present(tmp_path: Path) -> None: + target = tmp_path / SCAFFOLD_VERSION_FILE + target.parent.mkdir(parents=True) + target.write_text( + json.dumps({"version": 1, "min_supported_scaffold_version": "99.0.0", "files": {}}), + encoding="utf-8", + ) + assert read_min_supported_version(tmp_path) == "99.0.0" + + +def test_read_min_supported_version_tolerates_corrupt_json(tmp_path: Path) -> None: + target = tmp_path / SCAFFOLD_VERSION_FILE + target.parent.mkdir(parents=True) + target.write_text("not json{{", encoding="utf-8") + assert read_min_supported_version(tmp_path) is None + + +# --------------------------------------------------------------------------- +# compare_versions semantics +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "a,b,expected", + [ + ("0.5.0", "0.5.0", 0), + ("0.5.0", "0.5.1", -1), + ("0.5.1", "0.5.0", 1), + ("0.5", "0.5.0", -1), # short version sorts before its longer expansion + ("1.0.0", "0.9.99", 1), + ("0.5.1.dev0", "0.5.1", 1), # dev suffix sorts after release per str compare + ], +) +def test_compare_versions_orders_correctly(a: str, b: str, expected: int) -> None: + result = compare_versions(a, b) + assert (result, result == 0, result > 0) == (expected, expected == 0, expected > 0) + + +# --------------------------------------------------------------------------- +# setup update refuses to run on incompatible snapshot +# --------------------------------------------------------------------------- + + +def _plant_min_supported(root: Path, version: str) -> None: + target = root / SCAFFOLD_VERSION_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps({"version": 1, "min_supported_scaffold_version": version, "files": {}}), + encoding="utf-8", + ) + + +def test_setup_update_refuses_when_installed_is_older( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _plant_min_supported(tmp_path, "99.99.99") + rc = main(["setup", "update", "--target", str(tmp_path)]) + err = capsys.readouterr().err + assert rc == 1 + assert "99.99.99" in err + assert "next:" in err + assert "Upgrading" in err + + +def test_setup_update_force_flag_bypasses_compatibility( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """--force lets the user override the compatibility refusal. + + We can't run the full refresh in a tmp_path without a fully-wired project, + so we only assert the gate is bypassed (the run proceeds past the refusal + and either succeeds or fails for downstream reasons). + """ + + _plant_min_supported(tmp_path, "99.99.99") + rc = main(["setup", "update", "--target", str(tmp_path), "--force"]) + err = capsys.readouterr().err + # The compatibility refusal message should NOT appear. + assert "but " not in err or "99.99.99" not in err, ( + "compatibility refusal should be bypassed with --force" + ) + # Whatever downstream rc we get, the gate didn't refuse. + assert rc in (0, 1) # don't pin to a single rc — depends on intake/providers + + +def test_setup_update_runs_when_installed_matches_or_newer( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Snapshot with min_supported == installed version should pass the gate.""" + + _plant_min_supported(tmp_path, __version__) + # We don't assert success of the full refresh (it needs an intake); only + # that we don't get the compatibility refusal exit. + main(["setup", "update", "--target", str(tmp_path)]) + err = capsys.readouterr().err + assert "but " + __version__ + " is installed" not in err + + +def test_setup_update_runs_when_snapshot_predates_field( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Legacy snapshots without min_supported_scaffold_version don't get refused.""" + + target = tmp_path / SCAFFOLD_VERSION_FILE + target.parent.mkdir(parents=True) + target.write_text(json.dumps({"version": 1, "files": {}}), encoding="utf-8") + rc = main(["setup", "update", "--target", str(tmp_path)]) + err = capsys.readouterr().err + assert "but" not in err or "is installed" not in err + assert rc in (0, 1) + + +# --------------------------------------------------------------------------- +# Wiki page exists +# --------------------------------------------------------------------------- + + +def test_upgrading_wiki_page_exists_and_covers_required_topics() -> None: + root = Path(__file__).resolve().parent.parent + page = root / "docs" / "docs" / "wiki" / "Upgrading.md" + assert page.exists(), "Upgrading.md is the upgrade contract; it must exist" + text = page.read_text(encoding="utf-8") + for topic in ( + ".new", + "rollback", + "min_supported_scaffold_version", + "breaking", + "CHANGELOG", + "diff -u", + ): + assert topic.lower() in text.lower(), f"Upgrading.md missing topic: {topic!r}" + + +def test_upgrading_wiki_linked_from_meta_json() -> None: + root = Path(__file__).resolve().parent.parent + meta = json.loads( + (root / "docs" / "docs" / "wiki" / "_meta.json").read_text(encoding="utf-8") + ) + assert "Upgrading" in meta