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
This commit is contained in:
Manfred Riem
2026-08-10 10:22:31 -05:00
committed by GitHub
co-authored by Copilot App
parent 1a60d1b6b8
commit 16cfab7724
45 changed files with 2721 additions and 361 deletions
+39
View File
@@ -745,6 +745,13 @@ class ExtensionRegistry:
if not self.registry_path.exists():
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
# A non-regular file (e.g. a directory at the registry path) is not a
# readable registry. Recover to empty so construction — used by the
# install/enable/disable flows — does not crash. Resolution paths that
# must fail closed consult is_corrupt() instead of relying on this.
if not self.registry_path.is_file():
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
@@ -764,6 +771,38 @@ class ExtensionRegistry:
# starting fresh would let a later _save() wipe it.
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
def is_corrupt(self) -> bool:
"""Report whether an existing registry file is present but unreadable.
``_load`` deliberately recovers from a corrupt registry by normalizing
it to an empty mapping so install/enable/disable flows keep working.
Resolution paths, however, must fail closed: a corrupt registry that
normalizes to ``{}`` would otherwise cause every on-disk extension
directory to be admitted as an unregistered, enabled extension. This
probe lets those callers distinguish "no registry" (safe) from
"registry exists but is invalid" (unsafe) without changing recovery
behavior. An absent registry returns ``False``; a directory, broken
or dangling symlink, non-regular file, unreadable file, non-mapping
root, or non-mapping ``extensions`` value returns ``True``.
"""
# os.path.lexists (not Path.exists) so a dangling symlink is detected
# rather than followed to a non-existent target and mistaken for an
# absent registry — which would reopen the fail-open directory scan.
if not os.path.lexists(self.registry_path):
return False
if not self.registry_path.is_file():
return True
try:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return True
if not isinstance(data, dict):
return True
if "extensions" in data and not isinstance(data["extensions"], dict):
return True
return False
def _save(self):
"""Save registry to disk."""
self.extensions_dir.mkdir(parents=True, exist_ok=True)
+45 -24
View File
@@ -56,6 +56,7 @@ from ..shared_infra import (
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync"
def _content_sha256(content: bytes) -> str:
@@ -3602,13 +3603,10 @@ class PresetManager:
stacklevel=2,
)
# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
# Materialize constitution-template changes only for projects that opt
# into the constitution-sync preset. The core /constitution command
# resolves this template on demand; constitution-sync preserves the
# previous install-time behavior for teams that want reviewed snapshots.
self._seed_constitution_from_preset(manifest, dest_dir)
return manifest
@@ -3616,14 +3614,13 @@ class PresetManager:
def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
"""Seed memory/constitution.md from a preset constitution-template.
"""Seed memory/constitution.md when constitution-sync opts into snapshots.
Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` or provides one at a convention path, and the
live memory file is either missing or is an unchanged generated file.
Authored constitutions are never overwritten.
Installing constitution-sync itself materializes the currently resolved
stack. Later preset installs only reconcile when they provide a
``constitution-template``. Authored constitutions are never overwritten.
"""
provides_constitution = any(
provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
) or any(
@@ -3644,7 +3641,7 @@ class PresetManager:
def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
"""Reconcile an opted-in generated constitution without failing a change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
@@ -3656,7 +3653,11 @@ class PresetManager:
)
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
"""Materialize the winning layer when constitution-sync is enabled."""
sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID)
if sync_metadata is None or not sync_metadata.get("enabled", True):
return
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
@@ -4962,6 +4963,18 @@ class PresetResolver:
self._manifest_cache[key] = None
return self._manifest_cache[key]
@staticmethod
def _is_safe_registry_id(value: object) -> bool:
return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None
def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]:
registry = PresetRegistry(self.presets_dir)
return [
(pack_id, metadata)
for pack_id, metadata in registry.list_by_priority()
if self._is_safe_registry_id(pack_id)
]
def _manifest_declared_template(
self, pack_dir: Path, template_name: str, template_type: str
) -> tuple[dict | None, Path | None]:
@@ -5067,6 +5080,16 @@ class PresetResolver:
return []
registry = ExtensionRegistry(self.extensions_dir)
# Fail closed on a corrupt registry. ExtensionRegistry._load() recovers
# by normalizing an unreadable registry to an empty mapping, which would
# otherwise cause the directory scan below to admit every on-disk
# directory as an unregistered, enabled extension — a fail-open path
# that could supply constitution content from an invalid registry state.
if registry.is_corrupt():
raise PresetValidationError(
f"Invalid extension registry {registry.registry_path}: "
"refusing to enumerate extensions"
)
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
# This prevents corrupted entries from being picked up as "unregistered" dirs
registered_extension_ids = registry.keys()
@@ -5078,6 +5101,8 @@ class PresetResolver:
# Only include enabled extensions in the result
for ext_id, metadata in all_registered:
if not self._is_safe_registry_id(ext_id):
continue
# Skip disabled extensions
if not metadata.get("enabled", True):
continue
@@ -5086,7 +5111,7 @@ class PresetResolver:
# Add unregistered directories with implicit priority=10
for ext_dir in self.extensions_dir.iterdir():
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
if not ext_dir.is_dir() or not self._is_safe_registry_id(ext_dir.name):
continue
if ext_dir.name not in registered_extension_ids:
all_extensions.append((10, ext_dir.name, None))
@@ -5152,8 +5177,7 @@ class PresetResolver:
# Priority 2: Installed presets (sorted by priority — lower number wins)
if not skip_presets and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
for pack_id, _metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# The preset manifest is authoritative: if it declares this
# template with an explicit ``file:``, resolve to that path —
@@ -5356,13 +5380,11 @@ class PresetResolver:
return {"path": resolved_str, "source": "project override"}
if str(self.presets_dir) in resolved_str and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
try:
resolved.relative_to(pack_dir)
meta = registry.get(pack_id)
version = meta.get("version", "?") if meta else "?"
version = metadata.get("version", "?")
return {
"path": resolved_str,
"source": f"{pack_id} v{version}",
@@ -5448,8 +5470,7 @@ class PresetResolver:
# Priority 2: Installed presets (sorted by priority — lower number = higher precedence)
if self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, metadata in registry.list_by_priority():
for pack_id, metadata in self._get_all_presets_by_priority():
pack_dir = self.presets_dir / pack_id
# Read strategy and manifest file path from preset manifest
strategy = "replace"
+21 -3
View File
@@ -9,6 +9,7 @@ keeps working.
from __future__ import annotations
import os
import re
from pathlib import Path
import typer
@@ -352,9 +353,26 @@ def preset_resolve(
from .. import _require_specify_project
from . import PresetResolver
is_command = "." in template_name
valid_name = (
re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name)
if is_command
else re.fullmatch(r"[a-z0-9-]+", template_name)
)
if valid_name is None:
typer.echo(
f"Error: invalid template name '{template_name}'; "
"use lowercase letters, digits, and hyphens, with non-empty "
"dot-separated segments for commands",
err=True,
)
raise typer.Exit(1)
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
template_type = "command" if is_command else "template"
layers = resolver.collect_all_layers(template_name, template_type)
safe_template_name = _escape_markup(str(template_name))
if layers:
@@ -377,7 +395,7 @@ def preset_resolve(
if has_composition:
# Verify composition is actually possible
try:
composed = resolver.resolve_content(template_name)
composed = resolver.resolve_content(template_name, template_type)
except Exception as exc:
composed = None
console.print(
@@ -416,7 +434,7 @@ def preset_resolve(
)
else:
# No layers found — fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
result = resolver.resolve_with_source(template_name, template_type)
if result:
console.print(
f" [bold]{safe_template_name}[/bold]: "