Packages

development-driven-specs — the dds toolchain (protocol 2) as a Mix task. A thin shim over the bundled instruments (Python, stdlib); never a port.

Current section

Files

Jump to
dds priv toolchain upgrade.py
Raw

priv/toolchain/upgrade.py

#!/usr/bin/env python3
"""upgrade.py — the framework's update hook as a COMPUTATION.
Mechanical since spec/protocol-2.md Amendment 4 (canon reconcile =
overwrite + re-apply declared divergences) and the hex-only move
(Amendment 7): install/reconcile the canon into docs/, materialize the
agent skills into the harness, re-pin, and LIST pending migrations
(migration docs are agent/human instructions — printed, never
improvised). No three-way merge, no reconstruction of intent, no
second distribution channel.
Roots resolve across all three habitats (repo checkout / installed
wheel / hex priv), same pattern as the canon resolver in check_join.
"""
import os
import re
import shutil
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import check_join # noqa: E402
DDS_SECTION_MARK = "<!-- dds:carried-section -->"
# carried canon file → repo destination (enforced verbatim-modulo-divergences)
CANON_TABLE = {
"principles.md": "docs/principles.md",
"conventions.md": "docs/conventions.md",
"registers.md": "docs/registers.md",
"claim-files.md": "docs/claim-files.md",
"docs/README.md": "docs/README.md",
"docs/adr-README.md": "docs/adr/README.md",
"docs/goals-README.md": "docs/goals/README.md",
"docs/domain-README.md": "docs/domain/README.md",
}
# starting structure, human-owned after install — never reconciled
STUB_TABLE = {
"docs/what-the-product-is.stub.md": "docs/domain/what-the-product-is.md",
"docs/risks-README.md": "docs/risks/README.md",
"docs/governance.stub.md": "docs/governance.md",
}
# mixed-pen / placeholder files: installed once, appended, or reported
SPECIAL = {
"AGENTS.md": "AGENTS.md",
"CLAUDE.md": "CLAUDE.md",
"CODEOWNERS": ".github/CODEOWNERS",
}
def _first_dir(cands):
return next((p for p in cands if os.path.isdir(p)), None)
def canon_root():
return _first_dir([
os.path.join(HERE, "..", "..", "dds-sync", "assets"),
os.path.join(HERE, "canon"),
])
def skills_root():
return _first_dir([
os.path.join(HERE, "..", "..", ".."), # repo: .apm/skills/../.. → .apm? no: HERE=…/dds-check/assets → ../../.. = .apm
os.path.join(HERE, "skills"),
])
def _real_skills_root():
# repo layout: HERE/../../ == .apm/skills ; bundle layout: HERE/skills
repo = os.path.normpath(os.path.join(HERE, "..", ".."))
if os.path.basename(repo) == "skills" and os.path.isdir(repo):
return repo
bundled = os.path.join(HERE, "skills")
return bundled if os.path.isdir(bundled) else None
def migrations_root():
return _first_dir([
os.path.normpath(os.path.join(HERE, "..", "..", "dds-upgrade", "migrations")),
os.path.join(HERE, "migrations"),
])
def bundle_version():
for cand in [
os.path.join(HERE, "VERSION"),
os.path.normpath(os.path.join(HERE, "..", "..", "..", "..", "apm.yml")),
]:
if os.path.isfile(cand):
text = open(cand, encoding="utf-8").read()
m = re.search(r"version:\s*(\S+)", text) if cand.endswith(".yml") else None
return m.group(1) if m else text.strip().splitlines()[0]
try:
from importlib.metadata import version
return version("dds-toolchain")
except Exception:
return "unknown"
def read(path):
try:
return open(path, encoding="utf-8").read()
except OSError:
return None
def write(path, text):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
# ── canon: install / reconcile ───────────────────────────────────────
def install_canon(repo, croot, report):
for src, dst in CANON_TABLE.items():
write(os.path.join(repo, dst), read(os.path.join(croot, src)))
report.append(f"installed {dst}")
for src, dst in STUB_TABLE.items():
target = os.path.join(repo, dst)
if os.path.exists(target):
report.append(f"kept {dst} (stub exists — human-owned)")
else:
write(target, read(os.path.join(croot, src)))
report.append(f"installed {dst} (stub — yours to author)")
for src, dst in SPECIAL.items():
carried = read(os.path.join(croot, src))
target = os.path.join(repo, dst)
existing = read(target)
if existing is None:
write(target, carried)
report.append(f"installed {dst}")
elif DDS_SECTION_MARK in existing or existing == carried:
report.append(f"kept {dst}")
else:
write(target, existing.rstrip() + f"\n\n{DDS_SECTION_MARK}\n\n" + carried)
report.append(f"appended {dst} (carried section under marker — human content untouched)")
report.append("ACTION .github/CODEOWNERS: substitute your team handles (placeholders installed)")
def reconcile_canon(repo, croot, report):
"""Amendment 4, executed: overwrite with the carried canon,
re-apply the repo's declared divergence blocks, surface stale
anchors. Undeclared drift stops the file (never silently lost)."""
chk = check_join.Check(repo, None, None, None)
chk.delta_files = set()
for src, dst in CANON_TABLE.items():
carried = read(os.path.join(croot, src))
local = read(os.path.join(repo, dst))
if local is None:
write(os.path.join(repo, dst), carried)
report.append(f"installed {dst} (was missing)")
continue
chk.findings = []
blocks, whole = chk.parse_divergences(dst, local)
if whole:
report.append(f"kept {dst} (whole-file divergence declared — wholly local)")
continue
malformed = [f["text"] for f in chk.findings]
if malformed:
report.append(f"STOPPED {dst}: malformed divergence block(s) — fix first: {malformed[0][:80]}")
continue
# rebuild what the file SHOULD have been (blocks → anchors), so
# content beyond the declared divergences is reported, never
# silently absorbed. (The old canon is unknowable here; git holds
# the prior state and the diff is the review surface — the CLI
# never commits.)
parts, last = [], 0
for b in sorted(blocks, key=lambda x: x["start"]):
parts.append(local[last : b["start"]])
if b["kind"] in ("replace", "delete"):
parts.append(b["anchor"])
last = b["end"]
parts.append(local[last:])
rebuilt = check_join.norm_ws("".join(parts))
new_text = carried
stale = []
for b in blocks:
anchor = b["anchor"]
if anchor in new_text:
if b["kind"] in ("replace", "delete"):
new_text = new_text.replace(anchor, b["raw"], 1)
else: # insert-after
idx = new_text.index(anchor) + len(anchor)
new_text = new_text[:idx] + "\n" + b["raw"] + new_text[idx:]
else:
stale.append(b)
undeclared = rebuilt != check_join.norm_ws(carried)
write(os.path.join(repo, dst), new_text)
line = f"reconciled {dst}"
if blocks:
line += f" ({len(blocks) - len(stale)} divergence(s) re-applied)"
report.append(line)
if undeclared:
report.append(
f"NOTE {dst}: content beyond the declared divergences was "
"replaced by the new canon (version skew, or undeclared edits) — "
"review the diff; the prior state is in git"
)
for b in stale:
report.append(
f"STALE {dst} · `{b['slug']}`: its anchor no longer appears in the "
"new canon — the block was NOT re-applied; re-base it onto the new text "
"or drop it (its text is preserved in git history)"
)
# ── skills: materialize into the harness ────────────────────────────
def materialize_skills(repo, sroot, report, harness=".claude/skills"):
names = sorted(
d for d in os.listdir(sroot)
if os.path.isdir(os.path.join(sroot, d)) and d.startswith("dds-")
)
for name in names:
src = os.path.join(sroot, name)
dst = os.path.join(repo, harness, name)
if os.path.isdir(dst):
shutil.rmtree(dst)
shutil.copytree(
src, dst,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "__init__.py"),
)
report.append(f"skill {harness}/{name}")
return names
# ── migrations: list, never improvise ────────────────────────────────
def _ver_tuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) or (0,)
def pinned_version(repo):
text = read(os.path.join(repo, ".dds-version")) or ""
m = re.search(r"^version:\s*(\S+)", text, re.M)
return m.group(1) if m else None
def pending_migrations(repo, mroot, to_version):
cur = pinned_version(repo)
out = []
for name in sorted(os.listdir(mroot)):
if not name.endswith(".md"):
continue
v = name[:-3]
if cur and _ver_tuple(v) <= _ver_tuple(cur):
continue
if _ver_tuple(v) > _ver_tuple(to_version):
continue
out.append(os.path.join(mroot, name))
return out
def write_pin(repo, version):
write(
os.path.join(repo, ".dds-version"),
"# development-driven-specs version pin — managed by `dds upgrade`.\n"
f"version: {version}\n"
"protocol: 2\n"
"source: https://hex.pm/packages/dds (and the git tag of the same version)\n",
)
# ── the command ──────────────────────────────────────────────────────
def run(repo):
croot, sroot, mroot = canon_root(), _real_skills_root(), migrations_root()
version = bundle_version()
report = []
if croot is None:
return ["ERROR: carried canon not found in this bundle"], 2
fresh = read(os.path.join(repo, "docs/principles.md")) is None
if fresh:
install_canon(repo, croot, report)
else:
reconcile_canon(repo, croot, report)
if sroot:
materialize_skills(repo, sroot, report)
else:
report.append("skills not bundled here — skipped")
if fresh:
report.append("migrations none (a fresh install lands at the current shape)")
elif mroot:
pend = pending_migrations(repo, mroot, version)
for p in pend:
report.append(f"MIGRATION {os.path.basename(p)[:-3]}: execute {p}")
if not pend:
report.append("migrations none pending")
write_pin(repo, version)
report.append(f"pinned .dds-version → {version} (protocol 2)")
rc = 2 if any(r.startswith(("STOPPED", "ERROR")) for r in report) else 0
return report, rc