diff --git a/extensions/EXTENSION-API-REFERENCE.md b/extensions/EXTENSION-API-REFERENCE.md index a7bece0b89..312adb1387 100644 --- a/extensions/EXTENSION-API-REFERENCE.md +++ b/extensions/EXTENSION-API-REFERENCE.md @@ -40,7 +40,7 @@ requires: required: boolean # Optional, default: false provides: - commands: # At least one of commands/templates/scripts/hooks/events required + commands: # At least one of commands/templates/scripts/instructions/hooks/events required - name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$ file: string # Required, relative path to command file description: string # Required @@ -59,6 +59,14 @@ provides: description: string # Optional runtimes: [string] # Optional, subset of: bash, powershell, python + instructions: # Optional, array of always-on instruction blocks (#4200). + # Core validates only; the agent-file writes are performed by + # the opt-in agent-context extension (nothing is written without it). + - file: string # Required, relative path (inside the extension) to a markdown + # rule block; path-safe (no absolute path, no '..'). The + # payload must not contain SPECKIT section markers. + description: string # Optional + config: # Optional, array of config files - name: string # Config file name template: string # Template file path diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..17451a195c 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -182,11 +182,27 @@ What the extension provides. - `commands`: Array of command objects - `templates`: Array of template objects - `scripts`: Array of script objects +- `instructions`: Array of always-on instruction blocks (see below) `hooks` and `events` are separate top-level manifest fields (siblings of `provides`, not nested under it — see [`hooks`](#hooks) below). At least one -of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or -`events` is required. +of `provides.commands`, `provides.templates`, `provides.scripts`, +`provides.instructions`, `hooks`, or `events` is required. + +**Instruction object** (`provides.instructions`, [#4200](https://github.com/github/spec-kit/issues/4200)): + +- `file`: Path to a markdown rule block, relative to the extension root + (path-safe: no absolute paths, no `..`). The payload must not contain the + managed-section markers (``). +- `description`: Optional description. + +Always-on instructions are an **opt-in delivery** mechanism: core only validates +the metadata. The agent-file writes are performed by the `agent-context` +extension, which composes each enabled extension's block into the routed context +file (e.g. `.github/copilot-instructions.md`) inside a namespaced +`` block, and drops it again on disable/remove +at the next refresh. With `agent-context` not installed, declaring +`provides.instructions` writes nothing. **Command object**: diff --git a/extensions/agent-context/INSTRUCTIONS-POC-EVIDENCE.md b/extensions/agent-context/INSTRUCTIONS-POC-EVIDENCE.md new file mode 100644 index 0000000000..46196ab67a --- /dev/null +++ b/extensions/agent-context/INSTRUCTIONS-POC-EVIDENCE.md @@ -0,0 +1,76 @@ +# Extension-contributed always-on instructions — prototype + evidence + +Prototype for [github/spec-kit#4200](https://github.com/github/spec-kit/issues/4200): +let an extension contribute an always-on instruction block that reaches the agent +without any command/hook invocation. Ownership follows the maintainer's decision: +**core validates the metadata only; the opt-in `agent-context` extension composes and +owns the agent-file writes.** With `agent-context` not installed, installing an +extension does not touch agent files. + +**Triggers / lifecycle.** The agent needs no command invocation to *receive* the rules — +they live in the always-on context file. Composition and refresh are performed by +`agent-context` itself: its `speckit.agent-context.update` command and its `after_specify` +/ `after_plan` hooks, so the block is written during normal setup, before the agent runs, +and a disabled or removed extension's block is dropped on the next refresh. A fully +automatic trigger on `extension add`/`remove` would need an extension-lifecycle hook point +in core (none exists today — the event system covers agent-runtime events only), so that is +deliberately left as a follow-up owned by `agent-context`. + +## What changed + +- **Core (`src/specify_cli/extensions/__init__.py`)** — accepts and validates a new + `provides: instructions:` capability (list of `{ file, description? }`), path-safe via + the existing `relative_extension_path_violation` guard, exposed as `.instructions`. + Core performs **no** agent-file writes. An instructions-only extension is valid. +- **`agent-context` (`scripts/python/update_agent_context.py`)** — on update, discovers + installed **and enabled** extensions (reads `.specify/extensions/.registry` + + each `extension.yml` directly, no CLI dependency), reads each `provides.instructions` + file, and merges it into the routed agent context file inside a per-extension + namespaced block: + + ``` + + …rule block… + + ``` + +- **bash / PowerShell twins** — delegate to the Python twin's new + `--emit-extension-blocks` mode, so all three produce **byte-identical** output from a + single implementation. + +## Efficacy + +The lift is about **delivery/reachability**, not content or instruction weighting: the +delivered payload is the same rule block whether it arrives always-on or via a command, so +when it is present the measured conformance gain carries over by construction. Two +measurements, same conformance metric, 2 models × 4 languages × 3 complexity (n=24): + +- **This mechanism's exact output.** Bare vs the block this install path actually writes to + `.github/copilot-instructions.md`, captured byte-for-byte: **+0.123 mean best-practice + conformance, 22 wins / 0 ties / 2 losses** (both losses tiny, on a near-ceiling model). + This is the verified, install-path-accurate figure. +- **Earlier distilled-block pilot** (a shorter, hand-distilled rule block — a *distinct* + experiment with a *distinct* payload): **+0.142 mean** over bare, vs +0.10 for the same + content delivered as on-demand commands. Kept for context, not the headline number. + +## Verification (automated) + +`tests/extensions/test_extension_instructions.py` (13 tests, all passing): + +- **Core validation** — `provides: instructions:` accepted; instructions-only extension is + valid; non-list rejected; entry missing `file` rejected; path traversal (`/abs`, `..`, + `sub/../../..`) rejected. +- **Composition** — enabled extension's block is written into the routed context file with + namespaced markers and byte-exact payload; disabling an extension removes its block on + the next update while leaving the base managed section intact; multiple extensions + coexist in deterministic id order; a path-unsafe manifest entry is skipped; **no agent + file is written when `agent-context` is not configured**; `--emit-extension-blocks` + emits the shared block text. + +Full suite (rebased on current `main`): `pytest` → **6916 passed, 415 skipped** +(the skips are the bash/pwsh cross-execution parity tests, which run on POSIX CI). + +Manual end-to-end (copilot integration) also confirmed: `specify extension add` a +`provides: instructions:` extension + `agent-context` → the rules appear in +`.github/copilot-instructions.md`; `disable`/`enable` remove/restore the block; a project +without `agent-context` gets no agent-file writes. diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 7fbe3ef49a..ec809d841a 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -17,6 +17,7 @@ set -euo pipefail PROJECT_ROOT="$(pwd)" +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" DEFAULT_START="" DEFAULT_END="" @@ -354,6 +355,13 @@ trap 'rm -f "$TMP_SECTION"' EXIT if [[ -n "$PLAN_PATH" ]]; then echo "at $PLAN_PATH" fi + # Extension-contributed always-on instruction blocks (github/spec-kit#4200). + # Delegated to the python twin's --emit-extension-blocks so all three twins + # emit byte-identical block text from a single implementation. + _EXT_BLOCKS="$("$_python" "$_SCRIPT_DIR/../python/update_agent_context.py" --emit-extension-blocks --marker-start "$MARKER_START" --marker-end "$MARKER_END" 2>/dev/null || true)" + if [[ -n "$_EXT_BLOCKS" ]]; then + printf '%s\n' "$_EXT_BLOCKS" + fi echo "$MARKER_END" } > "$TMP_SECTION" diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 91d067cc41..93d549763b 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -457,6 +457,56 @@ $lines = @($MarkerStart, if ($PlanPath) { $lines += "at $PlanPath" } +# Extension-contributed always-on instruction blocks (github/spec-kit#4200): +# delegate to the python twin's --emit-extension-blocks so all three twins emit +# byte-identical block text from a single implementation. +$pyTwin = Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'python') 'update_agent_context.py' +$pyForBlocks = $null +foreach ($candidate in @($env:SPECKIT_PYTHON, 'python3', 'python')) { + if (-not $candidate) { continue } + if (-not (Get-Command $candidate -ErrorAction SilentlyContinue)) { continue } + # Verify the candidate is a real, runnable Python 3 that can import PyYAML + # (the emitter imports yaml). Skips the Windows Store 'python3' alias stub + # and any interpreter without PyYAML, mirroring the config-parse probe above. + try { + & $candidate -c "import sys, yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { $pyForBlocks = $candidate; break } + } catch { } +} +if (-not $pyForBlocks) { + # The base section is written natively below, but extension-contributed + # always-on instruction blocks are composed by the Python emitter only. If no + # Python 3 + PyYAML is on PATH and other extensions are installed (any of which + # may declare provides.instructions), warn instead of silently dropping them. + $registryPath = Join-Path $ProjectRoot '.specify/extensions/.registry' + if (Test-Path -LiteralPath $registryPath) { + try { + $reg = Get-Content -LiteralPath $registryPath -Raw -Encoding UTF8 | ConvertFrom-Json + $others = @($reg.extensions.PSObject.Properties | Where-Object { + $_.Name -ne 'agent-context' -and $_.Value.enabled -ne $false + }) + if ($others.Count -gt 0) { + [Console]::Error.WriteLine("agent-context: Python 3 with PyYAML not found; extension always-on instruction blocks (provides.instructions) were NOT composed. Base context section written. Install PyYAML (pip install pyyaml) or expose a Python 3 on PATH to include them.") + } + } catch { } + } +} +if ($pyForBlocks -and (Test-Path -LiteralPath $pyTwin)) { + # Windows PowerShell decodes native-command stdout using the console code + # page; force UTF-8 so non-ASCII rule text (e.g. em-dashes) survives capture. + $prevOutEnc = [Console]::OutputEncoding + try { + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $emitted = (& $pyForBlocks $pyTwin --emit-extension-blocks --marker-start $MarkerStart --marker-end $MarkerEnd 2>$null | Out-String) + } finally { + [Console]::OutputEncoding = $prevOutEnc + } + if ($emitted) { + $emitted = ($emitted -replace "`r`n", "`n") -replace "`r", "`n" + $emitted = $emitted.TrimEnd("`n") + foreach ($bl in ($emitted -split "`n")) { $lines += $bl } + } +} $lines += $MarkerEnd $Section = ($lines -join "`n") + "`n" diff --git a/extensions/agent-context/scripts/python/update_agent_context.py b/extensions/agent-context/scripts/python/update_agent_context.py index 669ec5bf9d..42f4d46914 100644 --- a/extensions/agent-context/scripts/python/update_agent_context.py +++ b/extensions/agent-context/scripts/python/update_agent_context.py @@ -27,6 +27,12 @@ DEFAULT_START = "" DEFAULT_END = "" +# Any SPECKIT marker comment (the outer managed-section markers or the +# per-extension ``EXT: START/END`` sub-markers). Instruction payloads that +# embed one would collide with the find/replace in _upsert_section and strand +# old content on disable/remove, so such payloads are rejected. +_SPECKIT_MARKER_RE = re.compile(r"") + lines.append(content) + lines.append(f"") + return lines + + def ensure_mdc_frontmatter(content: str) -> str: """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. @@ -298,6 +421,30 @@ def _upsert_section( def main(argv: list[str] | None = None) -> int: args = sys.argv[1:] if argv is None else argv project_root = os.getcwd() + + # --emit-extension-blocks: print only the composed extension instruction + # sub-block lines and exit. Used by the bash/PowerShell twins so all three + # produce identical output from this single implementation. Does not require + # the agent-context config (the twin already validated it before calling). + if "--emit-extension-blocks" in args: + # Twins forward their configured markers so collision-rejection uses the + # SAME markers the upsert will use (custom markers included), not just the + # defaults. Fall back to the defaults when a twin passes nothing. + def _opt(name: str, default: str) -> str: + if name in args: + i = args.index(name) + if i + 1 < len(args): + return args[i + 1] + return default + marker_start = _opt("--marker-start", DEFAULT_START) + marker_end = _opt("--marker-end", DEFAULT_END) + block_lines = _render_extension_block_lines(project_root, marker_start, marker_end) + if block_lines: + # Write bytes with explicit \n so the bash/PowerShell twins receive + # identical separators regardless of OS text-mode newline translation. + sys.stdout.buffer.write("\n".join(block_lines).encode("utf-8")) + return 0 + ext_config = ( f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml" ) @@ -353,7 +500,8 @@ def main(argv: list[str] | None = None) -> int: if not plan_path: plan_path = _resolve_plan_path(project_root) - section = _build_section(marker_start, marker_end, plan_path) + extension_blocks = _render_extension_block_lines(project_root, marker_start, marker_end) + section = _build_section(marker_start, marker_end, plan_path, extension_blocks) for context_file in context_files: ctx_path = os.path.join(project_root, context_file) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..20067b794c 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -377,6 +377,12 @@ def _validate(self): commands = provides.get("commands", []) templates = provides.get("templates", []) scripts = provides.get("scripts", []) + # provides.instructions: always-on rule blocks an extension contributes to + # the agent's context file. Core only validates this metadata; the actual + # agent-file write is owned by the opt-in agent-context extension + # (github/spec-kit#4200). Installing an extension never mutates agent files + # when agent-context is absent. + instructions = provides.get("instructions", []) hooks = self.data.get("hooks") events = self.data.get("events") @@ -386,6 +392,8 @@ def _validate(self): raise ValidationError("Invalid provides.templates: expected a list") if "scripts" in provides and not isinstance(scripts, list): raise ValidationError("Invalid provides.scripts: expected a list") + if "instructions" in provides and not isinstance(instructions, list): + raise ValidationError("Invalid provides.instructions: expected a list") if "hooks" in self.data and not isinstance(hooks, dict): raise ValidationError("Invalid hooks: expected a mapping") if "events" in self.data: @@ -397,16 +405,44 @@ def _validate(self): has_events = bool(events) has_templates = bool(templates) has_scripts = bool(scripts) + has_instructions = bool(instructions) - if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts: + if ( + not has_commands + and not has_hooks + and not has_events + and not has_templates + and not has_scripts + and not has_instructions + ): raise ValidationError( "Extension must provide at least one command, hook, or event " - "(or a declared template/script)" + "(or a declared template/script/instructions block)" ) self._validate_provided_artifacts(templates, section="templates", singular="template") self._validate_provided_artifacts(scripts, section="scripts", singular="script") + # provides.instructions entries carry only a 'file' (they are not invoked, + # so unlike commands/templates they need no 'name'). Validate the path with + # the same shared safety policy used for command files. + for entry in instructions: + if not isinstance(entry, dict): + raise ValidationError( + "Each entry in 'provides.instructions' must be a mapping" + ) + if "file" not in entry: + raise ValidationError("Instruction entry missing 'file'") + reason = relative_extension_path_violation(entry["file"]) + if reason: + raise ValidationError( + f"Invalid instruction file {entry['file']!r}: {reason}" + ) + if "description" in entry and not isinstance(entry["description"], str): + raise ValidationError( + "Instruction entry 'description' must be a string" + ) + # Validate hook values (if present). # Each event is a single mapping or a list of mappings. if hooks: @@ -720,6 +756,11 @@ def scripts(self) -> List[Dict[str, Any]]: """Get list of declared scripts (provides.scripts).""" return self.data.get("provides", {}).get("scripts", []) + @property + def instructions(self) -> List[Dict[str, Any]]: + """Get list of declared always-on instruction blocks (provides.instructions).""" + return self.data.get("provides", {}).get("instructions", []) + @property def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" diff --git a/tests/extensions/test_extension_instructions.py b/tests/extensions/test_extension_instructions.py new file mode 100644 index 0000000000..c09d71ea16 --- /dev/null +++ b/tests/extensions/test_extension_instructions.py @@ -0,0 +1,334 @@ +"""Tests for extension-contributed always-on instructions (github/spec-kit#4200). + +Two layers are covered: + +1. Core manifest validation (``src/specify_cli/extensions``): the ``provides.instructions`` + capability is accepted, validated, and path-safe, and an instructions-only + extension is a valid extension. +2. The ``agent-context`` composition: on update, each installed + enabled extension's + instruction block is merged into the routed agent context file inside a + per-extension namespaced marker block, disabled/removed extensions drop out, + multiple extensions coexist deterministically, path-unsafe entries are skipped, + and nothing is written when agent-context is not configured. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from specify_cli.extensions import ExtensionManifest, ValidationError + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PY_TWIN = ( + PROJECT_ROOT + / "extensions" + / "agent-context" + / "scripts" + / "python" + / "update_agent_context.py" +) + +RULES_A = "# Rules A\n\n- Rule a1\n- Rule a2 with an em-dash \u2014 keep it\n" +RULES_B = "# Rules B\n\n- Rule b1\n" + + +# ── Core manifest validation ──────────────────────────────────────────────── + + +def _manifest(tmp_path: Path, provides_block: str) -> Path: + text = ( + 'schema_version: "1.0"\n' + "extension:\n" + " id: demo\n" + " name: Demo\n" + " version: \"0.1.0\"\n" + " description: d\n" + " author: a\n" + "requires:\n" + ' speckit_version: ">=0.6.0"\n' + "provides:\n" + ) + textwrap.indent(provides_block, " ") + p = tmp_path / "extension.yml" + p.write_text(text, encoding="utf-8") + return p + + +def test_instructions_capability_is_accepted(tmp_path): + m = ExtensionManifest( + _manifest( + tmp_path, + "instructions:\n - file: instructions/best-practices.md\n description: rules\n", + ) + ) + assert m.instructions == [ + {"file": "instructions/best-practices.md", "description": "rules"} + ] + + +def test_instructions_only_extension_is_valid(tmp_path): + # An extension that provides ONLY instructions (no command/hook) is valid. + m = ExtensionManifest( + _manifest(tmp_path, "instructions:\n - file: instructions/rules.md\n") + ) + assert m.instructions and not m.commands + + +def test_instructions_must_be_a_list(tmp_path): + with pytest.raises(ValidationError, match="provides.instructions: expected a list"): + ExtensionManifest(_manifest(tmp_path, "instructions:\n file: rules.md\n")) + + +def test_instruction_entry_requires_file(tmp_path): + with pytest.raises(ValidationError, match="missing 'file'"): + ExtensionManifest( + _manifest(tmp_path, "instructions:\n - description: no file here\n") + ) + + +def test_instruction_description_must_be_a_string(tmp_path): + with pytest.raises(ValidationError, match="'description' must be a string"): + ExtensionManifest( + _manifest( + tmp_path, + "instructions:\n - file: rules.md\n description: [not, a, string]\n", + ) + ) + + +@pytest.mark.parametrize( + "bad_path", + ["/abs/rules.md", "../escape.md", "sub/../../escape.md"], +) +def test_instruction_path_traversal_rejected(tmp_path, bad_path): + with pytest.raises(ValidationError, match="Invalid instruction file"): + ExtensionManifest( + _manifest(tmp_path, f"instructions:\n - file: {bad_path}\n") + ) + + +# ── agent-context composition ─────────────────────────────────────────────── + + +def _install_extension( + project: Path, + ext_id: str, + rules: str, + *, + enabled: bool = True, + file_rel: str = "instructions/rules.md", + declare_instructions: bool = True, +) -> None: + """Materialize an installed extension on disk + register it (no CLI needed).""" + exts = project / ".specify" / "extensions" + ext_dir = exts / ext_id + target = ext_dir / file_rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(rules, encoding="utf-8") + + provides = ( + f"provides:\n instructions:\n - file: {file_rel}\n" + if declare_instructions + else "provides:\n commands:\n - name: demo.noop\n file: cmd.md\n" + ) + (ext_dir / "extension.yml").write_text( + textwrap.dedent( + f"""\ + schema_version: "1.0" + extension: + id: {ext_id} + name: {ext_id} + version: "0.1.0" + description: d + author: a + requires: + speckit_version: ">=0.2.0" + """ + ) + + provides, + encoding="utf-8", + ) + + registry_path = exts / ".registry" + if registry_path.is_file(): + registry = json.loads(registry_path.read_text(encoding="utf-8")) + else: + registry = {"schema_version": "1.0", "extensions": {}} + registry["extensions"][ext_id] = {"version": "0.1.0", "enabled": enabled} + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8") + + +def _configure_agent_context(project: Path, context_file: str = "AGENTS.md") -> None: + cfg = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + cfg.parent.mkdir(parents=True, exist_ok=True) + cfg.write_text( + "context_file: {}\ncontext_files: []\n".format(context_file), + encoding="utf-8", + ) + + +def _run_update(project: Path) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(PY_TWIN)], + cwd=str(project), + capture_output=True, + text=True, + encoding="utf-8", + ) + + +def _managed_section(project: Path, context_file: str = "AGENTS.md") -> str: + p = project / context_file + return p.read_text(encoding="utf-8") if p.is_file() else "" + + +def test_enabled_extension_block_composed_into_context_file(tmp_path): + _configure_agent_context(tmp_path) + _install_extension(tmp_path, "cosmosdb", RULES_A) + + _run_update(tmp_path) + section = _managed_section(tmp_path) + + assert "" in section + assert "" in section + # Payload preserved byte-for-byte (including the em-dash). + assert RULES_A.strip() in section + + +def test_disabled_extension_block_is_removed_on_update(tmp_path): + _configure_agent_context(tmp_path) + _install_extension(tmp_path, "cosmosdb", RULES_A) + _run_update(tmp_path) + assert "EXT:cosmosdb" in _managed_section(tmp_path) + + # Flip enabled -> false and re-run: the block must disappear cleanly. + _install_extension(tmp_path, "cosmosdb", RULES_A, enabled=False) + _run_update(tmp_path) + section = _managed_section(tmp_path) + assert "EXT:cosmosdb" not in section + # Base managed section survives. + assert "" in section and "" in section + + +def test_multiple_extensions_coexist_in_id_order(tmp_path): + _configure_agent_context(tmp_path) + _install_extension(tmp_path, "zeta", RULES_B) + _install_extension(tmp_path, "alpha", RULES_A) + _run_update(tmp_path) + section = _managed_section(tmp_path) + + assert "EXT:alpha" in section and "EXT:zeta" in section + # Deterministic id ordering: alpha before zeta. + assert section.index("EXT:alpha START") < section.index("EXT:zeta START") + + +def test_path_unsafe_instruction_entry_is_skipped(tmp_path): + _configure_agent_context(tmp_path) + # Register an extension whose manifest points outside its dir; the composer + # must skip it rather than read an arbitrary file. + _install_extension(tmp_path, "evil", RULES_A, file_rel="rules.md") + manifest = tmp_path / ".specify" / "extensions" / "evil" / "extension.yml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "- file: rules.md", "- file: ../../../../etc/passwd" + ), + encoding="utf-8", + ) + _run_update(tmp_path) + assert "EXT:evil" not in _managed_section(tmp_path) + + +def test_marker_colliding_instruction_payload_is_skipped(tmp_path): + # A payload that embeds a managed-section marker would corrupt the + # find/replace in _upsert_section and strand content on disable/remove, so it + # is skipped (fail closed) while the base section stays well-formed. + _configure_agent_context(tmp_path) + colliding = "# Bad\n\n\n\nstranded text\n" + _install_extension(tmp_path, "cosmosdb", colliding) + result = _run_update(tmp_path) + assert result.returncode == 0 + section = _managed_section(tmp_path) + assert "EXT:cosmosdb" not in section + assert "stranded text" not in section + # Exactly one base marker pair remains (no duplication/corruption). + assert section.count("") == 1 + assert section.count("") == 1 + + +def test_non_utf8_instruction_file_is_skipped(tmp_path): + # A declared instruction file that is not valid UTF-8 must be skipped like an + # unreadable file (fail closed), never crashing the whole context refresh. + _configure_agent_context(tmp_path) + _install_extension(tmp_path, "good", RULES_A) + _install_extension(tmp_path, "broken", RULES_B) + broken_file = ( + tmp_path / ".specify" / "extensions" / "broken" / "instructions" / "rules.md" + ) + broken_file.write_bytes(b"\xff\xfe bad bytes \x80\x81") + result = _run_update(tmp_path) + assert result.returncode == 0 + section = _managed_section(tmp_path) + assert "EXT:good" in section + assert "EXT:broken" not in section + + +def test_noop_when_agent_context_not_configured(tmp_path): + # No agent-context config present: the update must not write any agent file. + _install_extension(tmp_path, "cosmosdb", RULES_A) + result = _run_update(tmp_path) + assert result.returncode == 0 + assert not (tmp_path / "AGENTS.md").exists() + + +def test_emit_extension_blocks_mode(tmp_path): + # The --emit-extension-blocks mode is the single source of truth shared by the + # bash/PowerShell twins; it prints the namespaced block for enabled extensions. + _install_extension(tmp_path, "cosmosdb", RULES_A) + result = subprocess.run( + [sys.executable, str(PY_TWIN), "--emit-extension-blocks"], + cwd=str(tmp_path), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert result.returncode == 0 + assert "" in result.stdout + assert RULES_A.strip() in result.stdout + + +def test_custom_markers_forwarded_to_emit(tmp_path): + # The twins forward their configured markers via --marker-start/--marker-end; + # a non-colliding payload still composes normally under custom markers. + _install_extension(tmp_path, "cosmosdb", RULES_A) + result = subprocess.run( + [ + sys.executable, str(PY_TWIN), "--emit-extension-blocks", + "--marker-start", "", "--marker-end", "", + ], + cwd=str(tmp_path), capture_output=True, text=True, encoding="utf-8", + ) + assert result.returncode == 0 + assert "" in result.stdout + assert RULES_A.strip() in result.stdout + + +def test_custom_marker_colliding_payload_rejected_in_emit(tmp_path): + # A payload containing the *configured* end marker must be rejected too, not + # only payloads containing the default SPECKIT markers. + _install_extension(tmp_path, "cosmosdb", "# Rules\n\n\n\nstranded\n") + result = subprocess.run( + [ + sys.executable, str(PY_TWIN), "--emit-extension-blocks", + "--marker-start", "", "--marker-end", "", + ], + cwd=str(tmp_path), capture_output=True, text=True, encoding="utf-8", + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + assert "EXT:cosmosdb" not in result.stdout diff --git a/tests/extensions/test_update_agent_context_python_parity.py b/tests/extensions/test_update_agent_context_python_parity.py index 06015bbdc7..008cb87802 100644 --- a/tests/extensions/test_update_agent_context_python_parity.py +++ b/tests/extensions/test_update_agent_context_python_parity.py @@ -116,6 +116,49 @@ def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None: ) +INSTRUCTIONS_RULES = ( + "# Cosmos rules\n\n- Use point reads \u2014 keep RU low\n- Prefer id as partition key\n" +) + + +def install_instructions_extension( + project_root: Path, + ext_id: str, + rules: str, + file_rel: str = "instructions/rules.md", +) -> None: + """Materialize an installed + enabled provides.instructions extension on disk.""" + exts = project_root / ".specify" / "extensions" + ext_dir = exts / ext_id + target = ext_dir / file_rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(rules, encoding="utf-8") + (ext_dir / "extension.yml").write_text( + 'schema_version: "1.0"\n' + "extension:\n" + f" id: {ext_id}\n" + f" name: {ext_id}\n" + ' version: "0.1.0"\n' + " description: d\n" + " author: a\n" + "requires:\n" + ' speckit_version: ">=0.2.0"\n' + "provides:\n" + " instructions:\n" + f" - file: {file_rel}\n", + encoding="utf-8", + ) + registry = exts / ".registry" + data = ( + json.loads(registry.read_text(encoding="utf-8")) + if registry.is_file() + else {"schema_version": "1.0", "extensions": {}} + ) + data["extensions"][ext_id] = {"version": "0.1.0", "enabled": True} + registry.parent.mkdir(parents=True, exist_ok=True) + registry.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]: return ( make_project(tmp_path / "proj-a", **config), @@ -562,3 +605,94 @@ def test_python_upsert_matches_powershell(tmp_path: Path) -> None: assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() + + +# ── Composed extension instructions (#4200) parity ──────────────────────── + + +@requires_posix_bash +def test_python_composes_extension_instructions_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + for repo in (repo_a, repo_b): + add_plan(repo) + install_instructions_extension(repo, "cosmosdb", INSTRUCTIONS_RULES) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content_a = (repo_a / "AGENTS.md").read_bytes() + content_b = (repo_b / "AGENTS.md").read_bytes() + assert content_a == content_b + assert b"" in content_b + # Non-ASCII payload survives byte-for-byte through both twins. + assert "Use point reads \u2014 keep RU low".encode("utf-8") in content_b + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_python_composes_extension_instructions_matching_powershell( + tmp_path: Path, +) -> None: + repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md") + repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md") + for repo in (repo_a, repo_b): + add_plan(repo) + install_instructions_extension(repo, "cosmosdb", INSTRUCTIONS_RULES) + + ps = run_powershell(repo_a) + py = run_python(repo_b) + + assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() + content_b = (repo_b / "AGENTS.md").read_bytes() + assert b"" in content_b + assert "Use point reads \u2014 keep RU low".encode("utf-8") in content_b + + +CUSTOM_MARKERS = {"start": "", "end": ""} + + +@requires_posix_bash +def test_python_composes_instructions_custom_markers_matching_bash( + tmp_path: Path, +) -> None: + repo_a, repo_b = twin_projects( + tmp_path, context_file="AGENTS.md", context_markers=CUSTOM_MARKERS + ) + for repo in (repo_a, repo_b): + add_plan(repo) + install_instructions_extension(repo, "cosmosdb", INSTRUCTIONS_RULES) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content_b = (repo_b / "AGENTS.md").read_bytes() + assert (repo_a / "AGENTS.md").read_bytes() == content_b + # Composed under the configured custom markers, forwarded to the emitter. + assert b"" in content_b and b"" in content_b + assert b"" in content_b + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_python_composes_instructions_custom_markers_matching_powershell( + tmp_path: Path, +) -> None: + repo_a = make_project( + tmp_path / "proj-ps", context_file="AGENTS.md", context_markers=CUSTOM_MARKERS + ) + repo_b = make_project( + tmp_path / "proj-py", context_file="AGENTS.md", context_markers=CUSTOM_MARKERS + ) + for repo in (repo_a, repo_b): + add_plan(repo) + install_instructions_extension(repo, "cosmosdb", INSTRUCTIONS_RULES) + + ps = run_powershell(repo_a) + py = run_python(repo_b) + + assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr + content_b = (repo_b / "AGENTS.md").read_bytes() + assert (repo_a / "AGENTS.md").read_bytes() == content_b + assert b"" in content_b and b"" in content_b + assert b"" in content_b