mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
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:
co-authored by
Copilot App
parent
1a60d1b6b8
commit
16cfab7724
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]: "
|
||||
|
||||
Reference in New Issue
Block a user