Files
Manfred Riem 16cfab7724 feat(presets): resolve constitution templates at command time (#3984)
* feat(presets): resolve constitutions at command time

Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): emit composed template content

Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): unify runtime template composition

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84

* fix(presets): secure runtime template resolution

Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): align runtime priority semantics

Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): stop at effective template base

Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): align extension template resolution

Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): resolve dotted command identifiers

Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Avoid orphan feature directories on template errors

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Align malformed preset manifest handling

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Complete runtime resolver parity

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on resolver input errors

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Force UTF-8 and full manifest validation

Force UTF-8 decoding for registry and manifest reads in the Bash and
PowerShell embedded-Python parsers so resolution no longer depends on the
process locale, and validate every manifest template entry's required
fields, type, and strategy consistent with the canonical PresetManifest.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* Fail closed on empty manifests and corrupt registries

Reject manifests missing the provides/templates sections or declaring an
empty template list in all three runtime resolvers, matching the canonical
PresetManifest which treats those as invalid instead of silently degrading a
composing layer to a convention `replace` lookup.

Make a corrupt or unreadable extension registry fail closed in Bash,
PowerShell, and Python instead of swallowing the error and treating every
on-disk extension directory as unregistered-and-enabled, which could activate
a disabled extension.

Read the preset and extension registries as explicit UTF-8 in the PowerShell
resolver so priority/enabled-state decoding no longer depends on the process
code page under Windows PowerShell 5.1.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed when extension registry is not a regular file

The Bash and Python resolvers used is_file()/`-f` to gate reading the
extension `.registry`, which returns false for a directory or a broken
symlink at that path. In those cases the resolvers treated the registry
as absent and scanned every on-disk extension directory as unregistered
and enabled — a fail-open path. Detect any filesystem entry at the
registry path (including broken symlinks) and reject unless it is a
readable regular file. PowerShell now rejects a non-leaf entry explicitly
for parity. Adds directory- and broken-symlink parity regressions.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell

Two remaining fail-open paths for an invalid extension registry:

- The canonical PresetResolver enumerated extensions through
  ExtensionRegistry, whose _load() normalizes a corrupt or unreadable
  registry to an empty mapping. The directory scan then admitted every
  on-disk extension directory as unregistered-and-enabled, so a corrupt
  registry could still supply constitution content at init and through
  constitution-sync materialization. Add a non-invasive is_corrupt()
  probe (recovery behavior for install/enable/disable is unchanged) and
  raise from _get_all_extensions_by_priority() when the registry exists
  but is invalid. _load() now also recovers from OSError/UnicodeDecodeError
  so a directory or unreadable registry no longer crashes construction.

- The PowerShell resolver gated the registry read with Test-Path, which
  returns false for a dangling symlink on Windows, letting a broken
  .registry symlink bypass the guard and enable every on-disk extension.
  Detect the entry via directory enumeration (which observes a broken
  symlink) and reject it unless it is a readable regular file.

Adds canonical corrupt/directory-registry regressions and extends the
broken-symlink parity test to PowerShell.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

* fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt

is_corrupt() gated on Path.exists(), which follows symlinks and returns
False for a dangling .registry symlink — so the canonical PresetResolver
treated it as an absent registry and fell back to scanning every on-disk
extension directory as unregistered-and-enabled, reopening the fail-open
path this guard closes. Detect lexical existence with os.path.lexists and
require a regular file before parsing, so a broken symlink (or directory)
is reported corrupt and resolution fails closed. Adds a canonical
broken-symlink regression alongside the directory case.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84
Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
2026-08-10 10:22:31 -05:00

195 lines
6.2 KiB
Python

"""Shared helpers for the core-script Python parity tests."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
BASH_DIR = PROJECT_ROOT / "scripts" / "bash"
PS_DIR = PROJECT_ROOT / "scripts" / "powershell"
PY_DIR = PROJECT_ROOT / "scripts" / "python"
HAS_PWSH = shutil.which("pwsh") is not None
WINDOWS_POWERSHELL = (
(shutil.which("powershell.exe") or shutil.which("powershell"))
if os.name == "nt"
else None
)
POWERSHELL_EXE = "pwsh" if HAS_PWSH else WINDOWS_POWERSHELL
HAS_POWERSHELL = POWERSHELL_EXE is not None
def make_repo(tmp_path: Path, name: str = "proj") -> Path:
repo = tmp_path / name
(repo / ".specify").mkdir(parents=True)
return repo
def install_scripts(repo: Path, script: str) -> None:
"""Install the bash/powershell/python twins of a kebab-case script name."""
py_name = script.replace("-", "_")
bash_dir = repo / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(BASH_DIR / "common.sh", bash_dir / "common.sh")
shutil.copy(BASH_DIR / f"{script}.sh", bash_dir / f"{script}.sh")
ps_dir = repo / ".specify" / "scripts" / "powershell"
ps_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(PS_DIR / "common.ps1", ps_dir / "common.ps1")
shutil.copy(PS_DIR / f"{script}.ps1", ps_dir / f"{script}.ps1")
py_dir = repo / ".specify" / "scripts" / "python"
py_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(PY_DIR / "common.py", py_dir / "common.py")
shutil.copy(PY_DIR / f"{py_name}.py", py_dir / f"{py_name}.py")
def bash_cmd(repo: Path, script: str, *args: str) -> list[str]:
return ["bash", str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh"), *args]
def py_cmd(repo: Path, script: str, *args: str) -> list[str]:
py_name = script.replace("-", "_")
return [
sys.executable,
str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py"),
*args,
]
def ps_cmd(repo: Path, script: str, *args: str) -> list[str]:
assert POWERSHELL_EXE, "no PowerShell available; guard the test with HAS_POWERSHELL"
return [
POWERSHELL_EXE,
"-NoProfile",
"-File",
str(repo / ".specify" / "scripts" / "powershell" / f"{script}.ps1"),
*args,
]
def clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def run(
cmd: list[str], repo: Path, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=repo,
capture_output=True,
text=True,
check=False,
env=env if env is not None else clean_env(),
)
def json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)
def write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
def install_composition_stack(
repo: Path, template_name: str, core_content: str
) -> str:
"""Install wrap/prepend/append presets over a core template."""
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True, exist_ok=True)
(templates / f"{template_name}.md").write_text(core_content, encoding="utf-8")
layers = [
("wrap-pack", 1, "wrap", "## Wrapper\n{CORE_TEMPLATE}\n## End\n"),
("prepend-pack", 2, "prepend", "# Prepended\n"),
("append-pack", 3, "append", "# Appended\n"),
]
registry: dict[str, object] = {"presets": {}}
registry_presets = registry["presets"]
assert isinstance(registry_presets, dict)
for preset_id, priority, strategy, content in layers:
preset_dir = repo / ".specify" / "presets" / preset_id
template_dir = preset_dir / "templates"
template_dir.mkdir(parents=True)
(template_dir / f"{template_name}.md").write_text(content, encoding="utf-8")
(preset_dir / "preset.yml").write_text(
"provides:\n"
" templates:\n"
" - type: template\n"
f" name: {template_name}\n"
f" file: templates/{template_name}.md\n"
f" strategy: {strategy}\n",
encoding="utf-8",
)
registry_presets[preset_id] = {
"enabled": True,
"priority": priority,
}
(repo / ".specify" / "presets" / ".registry").write_text(
json.dumps(registry, separators=(",", ":")) + "\n",
encoding="utf-8",
)
appended = "# Appended\n"
prepended = "# Prepended\n"
wrapper = "## Wrapper\n{CORE_TEMPLATE}\n## End\n"
composed = f"{core_content}\n\n{appended}"
composed = f"{prepended}\n\n{composed}"
return wrapper.replace("{CORE_TEMPLATE}", composed)
def break_wrap_layer(repo: Path, template_name: str) -> None:
"""Replace the installed wrap layer with one missing its placeholder."""
(
repo
/ ".specify"
/ "presets"
/ "wrap-pack"
/ "templates"
/ f"{template_name}.md"
).write_text("# Broken wrapper\n", encoding="utf-8")
def normalize_repo_paths(text: str, repo: Path) -> str:
"""Replace the repo path with a placeholder so two-repo runs compare equal."""
repo_paths = sorted({str(repo), str(repo.resolve())}, key=len, reverse=True)
for repo_path in repo_paths:
text = text.replace(repo_path, "<REPO>")
return text.replace("\r\n", "\n")
def normalize_script_names(text: str, repo: Path, script: str) -> str:
"""Replace per-runtime script paths (argv[0] in usage/help output)."""
py_name = script.replace("-", "_")
bash_script = str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh")
py_script = str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py")
return text.replace(bash_script, "<SCRIPT>").replace(py_script, "<SCRIPT>")
def normalize_status_text(text: str) -> str:
return (
text.replace(" ✓ ", " [OK] ")
.replace(" ✗ ", " [FAIL] ")
.replace("\r\n", "\n")
)