16cfab7724
* 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
754 lines
24 KiB
Python
754 lines
24 KiB
Python
"""Parity tests for composed runtime template resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import requires_bash
|
|
from tests.parity_helpers import (
|
|
HAS_POWERSHELL,
|
|
bash_cmd,
|
|
clean_env,
|
|
install_composition_stack,
|
|
install_scripts,
|
|
json_stdout,
|
|
make_repo,
|
|
ps_cmd,
|
|
py_cmd,
|
|
run,
|
|
)
|
|
|
|
SCRIPT = "resolve-template"
|
|
TEMPLATE = "constitution-template"
|
|
|
|
|
|
def _setup_repo(tmp_path: Path) -> tuple[Path, str]:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
expected = install_composition_stack(repo, TEMPLATE, "# Core\n")
|
|
return repo, expected
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_emit_composed_template_content(tmp_path: Path) -> None:
|
|
repo, expected = _setup_repo(tmp_path)
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(result.stderr == "" for result in results)
|
|
assert all(
|
|
json_stdout(result)
|
|
== {"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected}
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"without_registry,core_content",
|
|
[
|
|
(True, "# Core\n"),
|
|
(False, "# Café ✓\n"),
|
|
],
|
|
ids=["directory_fallback", "unicode"],
|
|
)
|
|
def test_all_variants_preserve_composition_parity(
|
|
tmp_path: Path, without_registry: bool, core_content: str
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
expected = install_composition_stack(repo, TEMPLATE, core_content)
|
|
if without_registry:
|
|
(repo / ".specify" / "presets" / ".registry").unlink()
|
|
expected = (
|
|
"# Prepended\n\n\n"
|
|
"## Wrapper\n"
|
|
f"{core_content}\n"
|
|
"## End\n\n\n"
|
|
"# Appended\n"
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == expected
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_read_utf8_registry_under_ascii_locale(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Registry/manifest reads must force UTF-8, not the process locale.
|
|
|
|
With UTF-8 mode disabled and a C locale, the interpreter's default text
|
|
encoding is ASCII. Non-ASCII *metadata* in the registry or a manifest must
|
|
still resolve, because the resolvers open those files as UTF-8 explicitly.
|
|
Template content stays ASCII so the pure-Python variant can emit it on the
|
|
ASCII stdout this configuration forces.
|
|
"""
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
expected = install_composition_stack(repo, TEMPLATE, "# Core\n")
|
|
|
|
# Inject non-ASCII metadata into the preset registry and a manifest so a
|
|
# locale-dependent decode would raise instead of resolving cleanly.
|
|
registry = repo / ".specify" / "presets" / ".registry"
|
|
registry_data = json.loads(registry.read_text(encoding="utf-8"))
|
|
registry_data["presets"]["wrap-pack"]["description"] = "Café ✓ wrapper"
|
|
registry.write_text(
|
|
json.dumps(registry_data, separators=(",", ":")) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest = repo / ".specify" / "presets" / "wrap-pack" / "preset.yml"
|
|
manifest.write_text(
|
|
manifest.read_text(encoding="utf-8") + ' description: "Café ✓"\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
env = clean_env()
|
|
# Force the interpreter's default text encoding to ASCII so an unqualified
|
|
# open() would fail on the non-ASCII metadata above.
|
|
env["PYTHONUTF8"] = "0"
|
|
env["PYTHONCOERCECLOCALE"] = "0"
|
|
env["LC_ALL"] = "C"
|
|
env["LANG"] = "C"
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == expected
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"template_name",
|
|
["missing-template", "../../../outside"],
|
|
ids=["missing", "path_traversal"],
|
|
)
|
|
def test_all_variants_reject_unresolvable_template(
|
|
tmp_path: Path, template_name: str
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
(repo / "outside.md").write_text("sensitive content\n", encoding="utf-8")
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, template_name, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, template_name, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, template_name, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 1 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|
|
assert all("sensitive content" not in result.stderr for result in results)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_ignore_traversing_preset_registry_ids(tmp_path: Path) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
outside = repo.parent / "outside"
|
|
outside.mkdir()
|
|
(outside / f"{TEMPLATE}.md").write_text("sensitive content\n", encoding="utf-8")
|
|
presets = repo / ".specify" / "presets"
|
|
presets.mkdir(parents=True)
|
|
(presets / ".registry").write_text(
|
|
'{"presets":{"../../../outside":{"enabled":true,"priority":1}}}\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 1 for result in results)
|
|
assert all("sensitive content" not in result.stdout for result in results)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_support_root_level_preset_convention(tmp_path: Path) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
preset = repo / ".specify" / "presets" / "root-pack"
|
|
preset.mkdir(parents=True)
|
|
(preset / f"{TEMPLATE}.md").write_text("# Root convention\n", encoding="utf-8")
|
|
(repo / ".specify" / "presets" / ".registry").write_text(
|
|
'{"presets":{"root-pack":{"enabled":true,"priority":1}}}\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == "# Root convention\n"
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_honor_extension_registry_state_and_priority(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
extensions = repo / ".specify" / "extensions"
|
|
for extension_id, content in (
|
|
("disabled-ext", "# Disabled\n"),
|
|
("low-priority", "# Low priority\n"),
|
|
("high-priority", "# High priority\n"),
|
|
):
|
|
template_dir = extensions / extension_id / "templates"
|
|
template_dir.mkdir(parents=True)
|
|
(template_dir / f"{TEMPLATE}.md").write_text(content, encoding="utf-8")
|
|
(extensions / ".registry").write_text(
|
|
'{"extensions":{'
|
|
'"disabled-ext":{"enabled":null,"priority":1},'
|
|
'"low-priority":{"enabled":true,"priority":20},'
|
|
'"high-priority":{"enabled":true,"priority":5}'
|
|
"}}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == "# High priority\n"
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_support_root_level_extension_convention(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
extension = repo / ".specify" / "extensions" / "root-extension"
|
|
extension.mkdir(parents=True)
|
|
(extension / f"{TEMPLATE}.md").write_text(
|
|
"# Root extension\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / ".specify" / "extensions" / ".registry").write_text(
|
|
'{"extensions":{"root-extension":{"enabled":true,"priority":1}}}\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == "# Root extension\n"
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_treat_extension_registry_ids_case_sensitively(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
extension = repo / ".specify" / "extensions" / "foo" / "templates"
|
|
extension.mkdir(parents=True)
|
|
(extension / f"{TEMPLATE}.md").write_text(
|
|
"# Lowercase extension\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / ".specify" / "extensions" / ".registry").write_text(
|
|
'{"extensions":{"FOO":{"enabled":true,"priority":1}}}\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == "# Lowercase extension\n"
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"registry_content",
|
|
["{ not valid json", '{"extensions":[]}\n', "[]\n"],
|
|
ids=["invalid_json", "non_mapping_extensions", "non_mapping_root"],
|
|
)
|
|
def test_all_variants_fail_for_malformed_extension_registry(
|
|
tmp_path: Path, registry_content: str
|
|
) -> None:
|
|
"""A corrupt extension registry must fail closed, not silently enable
|
|
every on-disk extension directory as unregistered."""
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
extensions = repo / ".specify" / "extensions"
|
|
template_dir = extensions / "sneaky-ext" / "templates"
|
|
template_dir.mkdir(parents=True)
|
|
(template_dir / f"{TEMPLATE}.md").write_text(
|
|
"# Should not be served\n", encoding="utf-8"
|
|
)
|
|
(extensions / ".registry").write_text(registry_content, encoding="utf-8")
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode != 0 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|
|
assert all(
|
|
"Should not be served" not in result.stdout for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_fail_when_registry_is_a_directory(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A directory at the extension registry path must fail closed, not be
|
|
treated as an absent registry that enables every on-disk extension."""
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
extensions = repo / ".specify" / "extensions"
|
|
template_dir = extensions / "sneaky-ext" / "templates"
|
|
template_dir.mkdir(parents=True)
|
|
(template_dir / f"{TEMPLATE}.md").write_text(
|
|
"# Should not be served\n", encoding="utf-8"
|
|
)
|
|
# Create ``.registry`` as a directory rather than a regular file.
|
|
(extensions / ".registry").mkdir()
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode != 0 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_fail_when_registry_is_broken_symlink(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A broken symlink at the extension registry path must fail closed across
|
|
Bash, Python, and PowerShell resolvers rather than being treated as absent."""
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
extensions = repo / ".specify" / "extensions"
|
|
template_dir = extensions / "sneaky-ext" / "templates"
|
|
template_dir.mkdir(parents=True)
|
|
(template_dir / f"{TEMPLATE}.md").write_text(
|
|
"# Should not be served\n", encoding="utf-8"
|
|
)
|
|
(extensions / ".registry").symlink_to(extensions / "does-not-exist")
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode != 0 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize("base_kind", ["override", "preset"])
|
|
def test_all_variants_ignore_malformed_layers_below_replace_base(
|
|
tmp_path: Path,
|
|
base_kind: str,
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
expected = "# Winning base\r\nBody\r\n"
|
|
presets = repo / ".specify" / "presets"
|
|
|
|
if base_kind == "override":
|
|
override = repo / ".specify" / "templates" / "overrides"
|
|
override.mkdir(parents=True)
|
|
(override / f"{TEMPLATE}.md").write_bytes(expected.encode("utf-8"))
|
|
registry = {"presets": {"broken-pack": {"enabled": True, "priority": 1}}}
|
|
else:
|
|
winning = presets / "winning-pack" / "templates"
|
|
winning.mkdir(parents=True)
|
|
(winning / f"{TEMPLATE}.md").write_bytes(expected.encode("utf-8"))
|
|
registry = {
|
|
"presets": {
|
|
"winning-pack": {"enabled": True, "priority": 1},
|
|
"broken-pack": {"enabled": True, "priority": 2},
|
|
}
|
|
}
|
|
|
|
broken = presets / "broken-pack"
|
|
broken.mkdir(parents=True)
|
|
(broken / "preset.yml").write_text("provides: [\n", encoding="utf-8")
|
|
(presets / ".registry").write_text(
|
|
json.dumps(registry, separators=(",", ":")) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == expected
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
("entries", "expected"),
|
|
[
|
|
(
|
|
[
|
|
("disabled-pack", {"enabled": False, "priority": 0}),
|
|
("numeric-pack", {"enabled": True, "priority": 2}),
|
|
("string-pack", {"enabled": True, "priority": "1"}),
|
|
],
|
|
"# string-pack\n",
|
|
),
|
|
(
|
|
[
|
|
("z-pack", {"enabled": True}),
|
|
("a-pack", {"enabled": True}),
|
|
],
|
|
"# a-pack\n",
|
|
),
|
|
(
|
|
[
|
|
("float-pack", {"enabled": True, "priority": 5.9}),
|
|
("six-pack", {"enabled": True, "priority": 6}),
|
|
],
|
|
"# float-pack\n",
|
|
),
|
|
(
|
|
[
|
|
("a-huge-pack", {"enabled": True, "priority": 2147483648}),
|
|
("z-default-pack", {"enabled": True, "priority": "invalid"}),
|
|
],
|
|
"# z-default-pack\n",
|
|
),
|
|
(
|
|
[
|
|
("decimal-string-pack", {"enabled": True, "priority": "5.9"}),
|
|
("exponent-string-pack", {"enabled": True, "priority": "1e3"}),
|
|
("hex-string-pack", {"enabled": True, "priority": "0x10"}),
|
|
("six-pack", {"enabled": True, "priority": 6}),
|
|
],
|
|
"# six-pack\n",
|
|
),
|
|
],
|
|
ids=[
|
|
"mixed_priorities",
|
|
"equal_priority_id_tiebreaker",
|
|
"float_priority",
|
|
"large_integer_priority",
|
|
"non_integer_numeric_strings",
|
|
],
|
|
)
|
|
def test_all_variants_normalize_and_tiebreak_preset_priorities(
|
|
tmp_path: Path,
|
|
entries: list[tuple[str, dict[str, object]]],
|
|
expected: str,
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
presets = repo / ".specify" / "presets"
|
|
registry: dict[str, object] = {"presets": {}}
|
|
registry_presets = registry["presets"]
|
|
assert isinstance(registry_presets, dict)
|
|
for preset_id, metadata in entries:
|
|
template_dir = presets / preset_id / "templates"
|
|
template_dir.mkdir(parents=True)
|
|
(template_dir / f"{TEMPLATE}.md").write_text(
|
|
f"# {preset_id}\n",
|
|
encoding="utf-8",
|
|
)
|
|
registry_presets[preset_id] = metadata
|
|
(presets / ".registry").write_text(
|
|
json.dumps(registry, separators=(",", ":")) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode == 0 for result in results)
|
|
assert all(
|
|
json_stdout(result)["TEMPLATE_CONTENT"] == expected
|
|
for result in results
|
|
)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_fail_when_wrap_placeholder_is_missing(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
templates = repo / ".specify" / "templates"
|
|
templates.mkdir(parents=True)
|
|
(templates / f"{TEMPLATE}.md").write_text("# Core\n", encoding="utf-8")
|
|
preset = repo / ".specify" / "presets" / "wrap-pack"
|
|
(preset / "templates").mkdir(parents=True)
|
|
(preset / "templates" / f"{TEMPLATE}.md").write_text(
|
|
"# Broken wrapper\n", encoding="utf-8"
|
|
)
|
|
(preset / "preset.yml").write_text(
|
|
"provides:\n"
|
|
" templates:\n"
|
|
" - type: template\n"
|
|
f" name: {TEMPLATE}\n"
|
|
f" file: templates/{TEMPLATE}.md\n"
|
|
" strategy: wrap\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / ".specify" / "presets" / ".registry").write_text(
|
|
'{"presets":{"wrap-pack":{"enabled":true,"priority":1}}}\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode != 0 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|
|
|
|
|
|
@requires_bash
|
|
def test_all_variants_fail_when_yaml_parser_is_unavailable(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo, _ = _setup_repo(tmp_path)
|
|
blocker = tmp_path / "blocker"
|
|
blocker.mkdir()
|
|
(blocker / "yaml.py").write_text(
|
|
"raise ImportError('simulated missing PyYAML')\n",
|
|
encoding="utf-8",
|
|
)
|
|
env = clean_env()
|
|
env["PYTHONPATH"] = str(blocker)
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(
|
|
run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo, env)
|
|
)
|
|
|
|
assert all(result.returncode != 0 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|
|
|
|
|
|
@requires_bash
|
|
def test_bash_fails_when_override_read_fails(tmp_path: Path) -> None:
|
|
repo = make_repo(tmp_path)
|
|
install_scripts(repo, SCRIPT)
|
|
override = repo / ".specify" / "templates" / "overrides"
|
|
override.mkdir(parents=True)
|
|
(override / f"{TEMPLATE}.md").write_text("# Override\n", encoding="utf-8")
|
|
shim_dir = tmp_path / "bin"
|
|
shim_dir.mkdir()
|
|
cat_shim = shim_dir / "cat"
|
|
cat_shim.write_text(
|
|
"#!/bin/sh\n"
|
|
"case \"$1\" in\n"
|
|
" */.specify/templates/overrides/*) exit 1 ;;\n"
|
|
"esac\n"
|
|
"exec /bin/cat \"$@\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
cat_shim.chmod(0o755)
|
|
env = clean_env()
|
|
env["PATH"] = f"{shim_dir}{os.pathsep}{env.get('PATH', '')}"
|
|
|
|
result = run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env)
|
|
|
|
assert result.returncode != 0
|
|
assert result.stdout == ""
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"manifest_content",
|
|
[
|
|
"provides: [\n",
|
|
"",
|
|
"provides:\n templates:\n - null\n",
|
|
"provides:\n templates: {}\n",
|
|
"preset:\n id: wrap-pack\n",
|
|
"provides:\n templates: []\n",
|
|
f"""provides:
|
|
templates:
|
|
- type: template
|
|
name: {TEMPLATE}
|
|
file: null
|
|
strategy: wrap
|
|
""",
|
|
f"""provides:
|
|
templates:
|
|
- type: template
|
|
name: {TEMPLATE}
|
|
file: templates/{TEMPLATE}.md
|
|
strategy: 123
|
|
""",
|
|
f"""provides:
|
|
templates:
|
|
- type: template
|
|
name: {TEMPLATE}
|
|
file: templates/{TEMPLATE}.md
|
|
strategy: wrap
|
|
- type: template
|
|
name: unrelated-template
|
|
file: null
|
|
strategy: append
|
|
""",
|
|
f"""provides:
|
|
templates:
|
|
- name: {TEMPLATE}
|
|
file: templates/{TEMPLATE}.md
|
|
strategy: wrap
|
|
- type: template
|
|
name: unrelated-template
|
|
file: templates/other.md
|
|
""",
|
|
f"""provides:
|
|
templates:
|
|
- type: template
|
|
name: {TEMPLATE}
|
|
file: templates/{TEMPLATE}.md
|
|
strategy: wrap
|
|
- type: template
|
|
name: unrelated-template
|
|
""",
|
|
f"""provides:
|
|
templates:
|
|
- type: template
|
|
name: {TEMPLATE}
|
|
file: templates/{TEMPLATE}.md
|
|
strategy: wrap
|
|
- type: bogus
|
|
name: unrelated-template
|
|
file: templates/other.md
|
|
""",
|
|
f"""provides:
|
|
templates:
|
|
- type: template
|
|
name: {TEMPLATE}
|
|
file: templates/{TEMPLATE}.md
|
|
strategy: wrap
|
|
- type: template
|
|
name: unrelated-template
|
|
file: templates/other.md
|
|
strategy: merge
|
|
""",
|
|
],
|
|
ids=[
|
|
"invalid_yaml",
|
|
"empty_document",
|
|
"non_mapping_template_entry",
|
|
"non_list_templates",
|
|
"missing_provides",
|
|
"empty_templates",
|
|
"non_string_file",
|
|
"non_string_strategy",
|
|
"malformed_entry_after_match",
|
|
"entry_missing_type",
|
|
"entry_missing_file",
|
|
"unsupported_type",
|
|
"unsupported_strategy",
|
|
],
|
|
)
|
|
def test_all_variants_fail_for_malformed_preset_manifest(
|
|
tmp_path: Path,
|
|
manifest_content: str,
|
|
) -> None:
|
|
repo, _ = _setup_repo(tmp_path)
|
|
(
|
|
repo / ".specify" / "presets" / "wrap-pack" / "preset.yml"
|
|
).write_text(manifest_content, encoding="utf-8")
|
|
|
|
results = [
|
|
run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo),
|
|
]
|
|
if HAS_POWERSHELL:
|
|
results.append(run(ps_cmd(repo, SCRIPT, TEMPLATE, "-Json"), repo))
|
|
|
|
assert all(result.returncode != 0 for result in results)
|
|
assert all(result.stdout == "" for result in results)
|