mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 23:59:55 +08:00
Compare commits
7 Commits
add-ears-e
...
v0.12.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464d57fe30 | ||
|
|
0da969df14 | ||
|
|
13d2cca154 | ||
|
|
ba1ce366b7 | ||
|
|
295eb221e3 | ||
|
|
5a901a698b | ||
|
|
94c7ec288f |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.8] - 2026-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
- [extension] Add LLM Wiki extension to community catalog (#3361)
|
||||
- Docs: Document missing CLI flags and integrations (#3182)
|
||||
- Docs: Remove Cursor from CLI check list in README (#3184)
|
||||
- feat(extensions): port update-agent-context to Python (#3387)
|
||||
- fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312)
|
||||
- fix(toml): escape control characters so generated command files parse (#3341)
|
||||
- fix(cli): exit cleanly on malformed IPv6 URLs in `extension`/`preset`/`workflow add` (#3369)
|
||||
- fix(github-http): return None on malformed GHES port instead of raising (#3379)
|
||||
- fix(integrations): guard _sha256 against unreadable managed files (#3376)
|
||||
- chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
|
||||
|
||||
## [0.12.7] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -406,7 +406,7 @@ specify init . --force --integration copilot
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
The CLI checks that the selected integration's required CLI tool is installed on your machine when that integration has `requires_cli: True`. If you do not have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --integration copilot --ignore-agent-tools
|
||||
|
||||
@@ -67,6 +67,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
|
||||
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
|
||||
@@ -26,7 +26,7 @@ specify extension add <name>
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| `--dev` | Install from a local directory (for development) |
|
||||
| `--from <url>` | Install from a custom URL instead of the catalog |
|
||||
| `--force` | Overwrite if already installed |
|
||||
| `--force` | Overwrite if the extension is already installed |
|
||||
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
|
||||
|
||||
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
|
||||
|
||||
352
extensions/agent-context/scripts/python/update_agent_context.py
Normal file
352
extensions/agent-context/scripts/python/update_agent_context.py
Normal file
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh the managed Spec Kit section in the coding agent's context file(s).
|
||||
|
||||
Python port of ``update-agent-context.sh`` / ``update-agent-context.ps1``.
|
||||
|
||||
Reads ``context_files`` or ``context_file``, plus ``context_markers.{start,end}``,
|
||||
from the agent-context extension config:
|
||||
.specify/extensions/agent-context/agent-context-config.yml
|
||||
|
||||
Usage: update_agent_context.py [plan_path]
|
||||
|
||||
When ``plan_path`` is omitted, the script derives it from
|
||||
``.specify/feature.json`` (written by /speckit-specify). Falls back to the most
|
||||
recently modified ``specs/*/plan.md`` only when feature.json is absent or its
|
||||
plan does not exist yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_START = "<!-- SPECKIT START -->"
|
||||
DEFAULT_END = "<!-- SPECKIT END -->"
|
||||
|
||||
|
||||
def _err(message: str) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def _get_str(obj: object, *keys: str) -> str:
|
||||
node = obj
|
||||
for key in keys:
|
||||
if isinstance(node, dict) and key in node:
|
||||
node = node[key]
|
||||
else:
|
||||
return ""
|
||||
return node if isinstance(node, str) else ""
|
||||
|
||||
|
||||
def _collect_context_files(data: dict, project_root: str) -> list[str]:
|
||||
"""Resolve the managed context files from config, mirroring the bash logic."""
|
||||
context_files: list[str] = []
|
||||
seen: set[str] = set()
|
||||
case_insensitive = sys.platform.startswith(("win32", "cygwin", "msys"))
|
||||
|
||||
def add(value: object) -> None:
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return
|
||||
key = candidate.casefold() if case_insensitive else candidate
|
||||
if key in seen:
|
||||
return
|
||||
context_files.append(candidate)
|
||||
seen.add(key)
|
||||
|
||||
raw_files = data.get("context_files")
|
||||
if isinstance(raw_files, list):
|
||||
for value in raw_files:
|
||||
add(value)
|
||||
if not context_files:
|
||||
add(_get_str(data, "context_file"))
|
||||
if not context_files:
|
||||
# Self-seed: when the config declares no target, derive one from the
|
||||
# active integration recorded in init-options.json, mapped through the
|
||||
# bundled agent-context-defaults.json file. Independent of the Specify
|
||||
# CLI by design.
|
||||
integration_key = ""
|
||||
try:
|
||||
with open(
|
||||
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
|
||||
) as fh:
|
||||
opts = json.load(fh)
|
||||
if isinstance(opts, dict):
|
||||
value = opts.get("integration") or opts.get("ai") or ""
|
||||
integration_key = value if isinstance(value, str) else ""
|
||||
except Exception:
|
||||
integration_key = ""
|
||||
if integration_key:
|
||||
defaults_path = (
|
||||
f"{project_root}/.specify/extensions/agent-context/"
|
||||
"agent-context-defaults.json"
|
||||
)
|
||||
mapping = {}
|
||||
try:
|
||||
with open(defaults_path, "r", encoding="utf-8") as fh:
|
||||
loaded = json.load(fh)
|
||||
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
|
||||
mapping = agents if isinstance(agents, dict) else {}
|
||||
except Exception:
|
||||
_err(
|
||||
"agent-context: unable to read %s; cannot self-seed the context "
|
||||
"file. Set context_file in the extension config." % defaults_path
|
||||
)
|
||||
mapping = {}
|
||||
add(mapping.get(integration_key, "") or "")
|
||||
if not context_files:
|
||||
_err(
|
||||
"agent-context: no default context file is known for integration "
|
||||
"%s. Set context_file in the extension config to choose one."
|
||||
% integration_key
|
||||
)
|
||||
return context_files
|
||||
|
||||
|
||||
def _validate_context_file(project_root: str, context_file: str) -> str | None:
|
||||
"""Return an error message when the path escapes the project root."""
|
||||
if context_file.startswith("/") or re.match(r"^[A-Za-z]:", context_file):
|
||||
return (
|
||||
"agent-context: context files must be project-relative paths; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
if "\\" in context_file:
|
||||
return (
|
||||
"agent-context: context files must not contain backslash separators; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
if ".." in context_file.split("/"):
|
||||
return (
|
||||
"agent-context: context files must not contain '..' path segments; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
root = Path(project_root).resolve()
|
||||
target = (root / context_file).resolve()
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
return (
|
||||
"agent-context: context file path resolves outside the project root; "
|
||||
f"got '{context_file}'."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_plan_path(project_root: str) -> str:
|
||||
"""Derive the plan path: feature.json first, then the mtime fallback."""
|
||||
plan_path = ""
|
||||
feature_json = Path(project_root) / ".specify" / "feature.json"
|
||||
if feature_json.is_file():
|
||||
feature_dir = ""
|
||||
try:
|
||||
with open(feature_json, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
value = data.get("feature_directory", "")
|
||||
feature_dir = value if isinstance(value, str) else ""
|
||||
except Exception:
|
||||
feature_dir = ""
|
||||
# Normalize backslashes (written by PS on Windows) before path ops.
|
||||
feature_dir = feature_dir.replace("\\", "/").rstrip("/")
|
||||
if feature_dir:
|
||||
# feature_directory may be relative or absolute (absolute paths
|
||||
# outside the project root are preserved as-is), including
|
||||
# drive-qualified paths (C:/...) written by PowerShell on Windows.
|
||||
if feature_dir.startswith("/") or re.match(r"^[A-Za-z]:/", feature_dir):
|
||||
candidate = Path(feature_dir) / "plan.md"
|
||||
else:
|
||||
candidate = Path(project_root) / feature_dir / "plan.md"
|
||||
if candidate.is_file():
|
||||
# Resolve symlinks before comparing so paths like /var/… vs
|
||||
# /private/var/… (macOS) are treated as equivalent.
|
||||
root = Path(project_root).resolve()
|
||||
resolved = candidate.resolve()
|
||||
try:
|
||||
plan_path = resolved.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = resolved.as_posix()
|
||||
|
||||
if not plan_path:
|
||||
root = Path(project_root).resolve()
|
||||
plans = sorted(
|
||||
(root / "specs").glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
try:
|
||||
plan_path = plans[0].relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = ""
|
||||
return plan_path
|
||||
|
||||
|
||||
def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str:
|
||||
lines = [
|
||||
marker_start,
|
||||
"For additional context about technologies to be used, project structure,",
|
||||
"shell commands, and other important information, read the current plan",
|
||||
]
|
||||
if plan_path:
|
||||
lines.append(f"at {plan_path}")
|
||||
lines.append(marker_end)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def ensure_mdc_frontmatter(content: str) -> str:
|
||||
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
|
||||
|
||||
Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
|
||||
``alwaysApply: true``. Prepend it when missing, or repair the value while
|
||||
preserving any existing frontmatter comments/formatting.
|
||||
"""
|
||||
leading_ws = len(content) - len(content.lstrip())
|
||||
leading = content[:leading_ws]
|
||||
stripped = content[leading_ws:]
|
||||
|
||||
if not stripped.startswith("---"):
|
||||
return "---\nalwaysApply: true\n---\n\n" + content
|
||||
|
||||
match = re.match(
|
||||
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
|
||||
stripped,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return "---\nalwaysApply: true\n---\n\n" + content
|
||||
|
||||
opening, fm_text, closing, sep, rest = match.groups()
|
||||
newline = "\r\n" if "\r\n" in opening else "\n"
|
||||
|
||||
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
|
||||
return content
|
||||
|
||||
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
|
||||
fm_text = re.sub(
|
||||
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
|
||||
r"\1alwaysApply: true\2",
|
||||
fm_text,
|
||||
count=1,
|
||||
)
|
||||
elif fm_text.strip():
|
||||
fm_text = fm_text + newline + "alwaysApply: true"
|
||||
else:
|
||||
fm_text = "alwaysApply: true"
|
||||
|
||||
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
|
||||
|
||||
|
||||
def _upsert_section(
|
||||
ctx_path: str, marker_start: str, marker_end: str, section: str
|
||||
) -> None:
|
||||
"""Insert or replace the managed section, then normalize and write."""
|
||||
if os.path.exists(ctx_path):
|
||||
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
|
||||
content = fh.read()
|
||||
s = content.find(marker_start)
|
||||
e = content.find(marker_end, s if s != -1 else 0)
|
||||
if s != -1 and e != -1 and e > s:
|
||||
end_of_marker = e + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = content[:s] + section + content[end_of_marker:]
|
||||
elif s != -1:
|
||||
new_content = content[:s] + section
|
||||
elif e != -1:
|
||||
end_of_marker = e + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = section + content[end_of_marker:]
|
||||
else:
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_content = (content + "\n" + section) if content else section
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
if ctx_path.casefold().endswith(".mdc"):
|
||||
new_content = ensure_mdc_frontmatter(new_content)
|
||||
with open(ctx_path, "wb") as fh:
|
||||
fh.write(new_content.encode("utf-8"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = sys.argv[1:] if argv is None else argv
|
||||
project_root = os.getcwd()
|
||||
ext_config = (
|
||||
f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml"
|
||||
)
|
||||
|
||||
if not os.path.isfile(ext_config):
|
||||
_err(f"agent-context: {ext_config} not found; nothing to do.")
|
||||
return 0
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
_err(
|
||||
"agent-context: PyYAML is required to parse extension config but is "
|
||||
"not available in the current Python environment.\n"
|
||||
" To resolve: pip install pyyaml (or install it into the environment "
|
||||
"used by python3).\n"
|
||||
" Context file will not be updated until PyYAML is importable."
|
||||
)
|
||||
_err("agent-context: skipping update (see above for details).")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(ext_config, "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
_err(
|
||||
f"agent-context: unable to parse {ext_config} ({exc}); "
|
||||
"cannot update context."
|
||||
)
|
||||
_err("agent-context: skipping update (see above for details).")
|
||||
return 0
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
context_files = _collect_context_files(data, project_root)
|
||||
if not context_files:
|
||||
_err(
|
||||
"agent-context: context_files/context_file not set in extension config; "
|
||||
"nothing to do."
|
||||
)
|
||||
return 0
|
||||
|
||||
for context_file in context_files:
|
||||
error = _validate_context_file(project_root, context_file)
|
||||
if error:
|
||||
_err(error)
|
||||
return 1
|
||||
|
||||
marker_start = _get_str(data, "context_markers", "start") or DEFAULT_START
|
||||
marker_end = _get_str(data, "context_markers", "end") or DEFAULT_END
|
||||
|
||||
plan_path = args[0] if args else ""
|
||||
if not plan_path:
|
||||
plan_path = _resolve_plan_path(project_root)
|
||||
|
||||
section = _build_section(marker_start, marker_end, plan_path)
|
||||
|
||||
for context_file in context_files:
|
||||
ctx_path = os.path.join(project_root, context_file)
|
||||
os.makedirs(os.path.dirname(ctx_path) or ".", exist_ok=True)
|
||||
_upsert_section(ctx_path, marker_start, marker_end, section)
|
||||
print(f"agent-context: updated {context_file}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -4375,6 +4375,40 @@
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"wiki": {
|
||||
"name": "LLM Wiki",
|
||||
"id": "wiki",
|
||||
"description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting",
|
||||
"author": "formin",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/formin/spec-kit-wiki",
|
||||
"homepage": "https://github.com/formin/spec-kit-wiki",
|
||||
"documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md",
|
||||
"changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"wiki",
|
||||
"knowledge-base",
|
||||
"docs",
|
||||
"memory",
|
||||
"context-management"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"wireframe": {
|
||||
"name": "Wireframe Visual Feedback Loop",
|
||||
"id": "wireframe",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.8.dev0"
|
||||
version = "0.12.8"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -97,17 +97,26 @@ read_feature_json_feature_directory() {
|
||||
local fj="$repo_root/.specify/feature.json"
|
||||
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
||||
|
||||
# Try parsers in order (jq -> python3 -> grep/sed), falling through on
|
||||
# failure. Selection is by *parse success*, not mere availability: on
|
||||
# Windows `python3` commonly resolves to the Microsoft Store App Execution
|
||||
# Alias stub, which passes `command -v` but fails at runtime (exit 49), so
|
||||
# an availability-gated `elif` would pick python3, swallow its failure, and
|
||||
# never reach the grep/sed fallback -- leaving feature.json unreadable even
|
||||
# though it is valid (issue #3304).
|
||||
local _fd=''
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
fi
|
||||
if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
||||
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
||||
_fd=''
|
||||
fi
|
||||
else
|
||||
fi
|
||||
if [[ -z "$_fd" ]]; then
|
||||
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
||||
|
||||
60
src/specify_cli/_toml_string.py
Normal file
60
src/specify_cli/_toml_string.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Shared TOML string-escaping helpers.
|
||||
|
||||
Both TOML command renderers — ``TomlIntegration`` (gemini, tabnine) in
|
||||
``specify_cli.integrations.base`` and ``CommandRegistrar.render_toml_command``
|
||||
(extension/preset commands) in ``specify_cli.agents`` — need the same rules for
|
||||
detecting characters TOML forbids literally and for emitting a fully-escaped
|
||||
basic string. Keeping one implementation here avoids the two drifting apart if
|
||||
the escaping rules change again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def has_illegal_toml_control(value: str) -> bool:
|
||||
"""True when *value* contains a character TOML forbids literally.
|
||||
|
||||
TOML basic/literal strings (single- or multi-line) allow tab and, in the
|
||||
multiline forms, newlines — but every other control character
|
||||
(``U+0000``–``U+001F`` and ``U+007F``) must be ``\\u``-escaped, which only a
|
||||
basic string can do. A bare carriage return counts too: a multiline basic
|
||||
string treats ``\\r`` as a newline only when paired into ``\\r\\n``; a lone
|
||||
``\\r`` is an illegal control character.
|
||||
"""
|
||||
length = len(value)
|
||||
for i, ch in enumerate(value):
|
||||
code = ord(ch)
|
||||
if ch == "\r":
|
||||
# Only a CR that is part of a CRLF newline is allowed literally.
|
||||
if i + 1 < length and value[i + 1] == "\n":
|
||||
continue
|
||||
return True
|
||||
if (code < 0x20 and ch not in ("\t", "\n")) or code == 0x7F:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def escape_toml_basic(value: str) -> str:
|
||||
"""Render *value* as a single-line basic string, escaping everything.
|
||||
|
||||
Always valid TOML: backslash/quote are escaped, the common control chars
|
||||
use their short escapes, and any remaining control character is emitted as
|
||||
a ``\\uXXXX`` sequence.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for ch in value:
|
||||
code = ord(ch)
|
||||
if ch == "\\":
|
||||
out.append("\\\\")
|
||||
elif ch == '"':
|
||||
out.append('\\"')
|
||||
elif ch == "\n":
|
||||
out.append("\\n")
|
||||
elif ch == "\r":
|
||||
out.append("\\r")
|
||||
elif ch == "\t":
|
||||
out.append("\\t")
|
||||
elif code < 0x20 or code == 0x7F:
|
||||
out.append(f"\\u{code:04x}")
|
||||
else:
|
||||
out.append(ch)
|
||||
return '"' + "".join(out) + '"'
|
||||
@@ -16,6 +16,8 @@ from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
from ._init_options import is_ai_skills_enabled, load_init_options
|
||||
from ._toml_string import escape_toml_basic as _escape_toml_basic
|
||||
from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control
|
||||
from ._utils import relative_extension_path_violation
|
||||
|
||||
|
||||
@@ -258,7 +260,12 @@ class CommandRegistrar:
|
||||
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
|
||||
# produce unparseable TOML — route those to the *literal* form ('''...'''),
|
||||
# which does not process escapes, or to the escaped basic string.
|
||||
if '"""' not in body and "\\" not in body:
|
||||
# Control characters (U+0000–U+001F except tab/newline, U+007F) and a bare
|
||||
# CR are illegal in every TOML string form, so a body containing them must
|
||||
# go to the escaped basic string regardless of which delimiters it uses.
|
||||
if self._has_illegal_toml_control(body):
|
||||
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
|
||||
elif '"""' not in body and "\\" not in body:
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
@@ -271,17 +278,11 @@ class CommandRegistrar:
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
@staticmethod
|
||||
def _render_basic_toml_string(value: str) -> str:
|
||||
"""Render *value* as a TOML basic string literal."""
|
||||
escaped = (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
# Control-char detection and basic-string escaping are shared with the
|
||||
# gemini/tabnine renderer in ``specify_cli.integrations.base`` via
|
||||
# ``specify_cli._toml_string`` so the two never drift apart.
|
||||
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
|
||||
_render_basic_toml_string = staticmethod(_escape_toml_basic)
|
||||
|
||||
def render_yaml_command(
|
||||
self,
|
||||
|
||||
@@ -25,6 +25,9 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .._toml_string import escape_toml_basic as _escape_toml_basic
|
||||
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
|
||||
@@ -953,6 +956,12 @@ class TomlIntegration(IntegrationBase):
|
||||
body = "".join(lines[frontmatter_end + 1 :])
|
||||
return frontmatter, body
|
||||
|
||||
# Control-char detection and basic-string escaping are shared with the
|
||||
# extension/preset renderer in ``specify_cli.agents`` via
|
||||
# ``specify_cli._toml_string`` so the two never drift apart.
|
||||
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
|
||||
_escape_toml_basic = staticmethod(_escape_toml_basic)
|
||||
|
||||
@staticmethod
|
||||
def _render_toml_string(value: str) -> str:
|
||||
"""Render *value* as a TOML string literal.
|
||||
@@ -962,6 +971,12 @@ class TomlIntegration(IntegrationBase):
|
||||
literal string or escaped basic string when delimiters appear in
|
||||
the content.
|
||||
"""
|
||||
# Control characters other than tab/newline (and a bare CR) cannot
|
||||
# appear literally in any TOML string; route them to a fully-escaped
|
||||
# basic string so the generated file stays parseable.
|
||||
if TomlIntegration._has_illegal_toml_control(value):
|
||||
return TomlIntegration._escape_toml_basic(value)
|
||||
|
||||
if "\n" not in value and "\r" not in value:
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
@@ -974,17 +989,7 @@ class TomlIntegration(IntegrationBase):
|
||||
if "'''" not in value and not value.endswith("'"):
|
||||
return "'''\n" + value + "'''"
|
||||
|
||||
return (
|
||||
'"'
|
||||
+ (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
+ '"'
|
||||
)
|
||||
return TomlIntegration._escape_toml_basic(value)
|
||||
|
||||
@staticmethod
|
||||
def _render_toml(description: str, body: str) -> str:
|
||||
|
||||
481
tests/extensions/test_update_agent_context_python_parity.py
Normal file
481
tests/extensions/test_update_agent_context_python_parity.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1.
|
||||
|
||||
Each test prepares two identical project trees, runs the bash script in one
|
||||
and the Python port in the other, then compares exit codes, output (with
|
||||
project roots normalized) and the resulting context-file bytes. PowerShell
|
||||
tests compare the resulting file content only and are skipped when ``pwsh``
|
||||
is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.extensions.test_extension_agent_context import (
|
||||
BASH,
|
||||
EXT_DIR,
|
||||
POWERSHELL,
|
||||
_bundled_script_env,
|
||||
)
|
||||
|
||||
PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py"
|
||||
BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
|
||||
PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
|
||||
requires_posix_bash = pytest.mark.skipif(
|
||||
not BASH or os.name == "nt",
|
||||
reason="POSIX bash required for side-by-side parity runs",
|
||||
)
|
||||
|
||||
|
||||
def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[BASH, str(BASH_SCRIPT), *args],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root, for_bash=True),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(PY_SCRIPT), *args],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(PS_SCRIPT),
|
||||
*args,
|
||||
],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def normalize(text: str, project_root: Path) -> str:
|
||||
return text.replace(str(project_root.resolve()), "__ROOT__").replace(
|
||||
str(project_root), "__ROOT__"
|
||||
)
|
||||
|
||||
|
||||
def write_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write the extension config as JSON (valid YAML, PS-parseable too)."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_files": overrides.get("context_files", []),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{"start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->"},
|
||||
),
|
||||
}
|
||||
cfg_dir = project_root / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
json.dumps(cfg), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def make_project(root: Path, **config: object) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
write_config(root, **config)
|
||||
return root
|
||||
|
||||
|
||||
def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None:
|
||||
plan = project_root / feature_dir / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
(project_root / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
(project_root / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_dir}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]:
|
||||
return (
|
||||
make_project(tmp_path / "proj-a", **config),
|
||||
make_project(tmp_path / "proj-b", **config),
|
||||
)
|
||||
|
||||
|
||||
def assert_parity(
|
||||
bash: subprocess.CompletedProcess,
|
||||
py: subprocess.CompletedProcess,
|
||||
repo_a: Path,
|
||||
repo_b: Path,
|
||||
) -> None:
|
||||
assert py.returncode == bash.returncode, py.stderr + bash.stderr
|
||||
assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a)
|
||||
assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a)
|
||||
|
||||
|
||||
# ── Fresh file and upsert behavior ───────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
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"at specs/001-demo/plan.md" in content_b
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
existing = (
|
||||
"# My project\n\n"
|
||||
"<!-- SPECKIT START -->\nstale section\n<!-- SPECKIT END -->\n"
|
||||
"\nTrailing prose stays.\n"
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "stale section" not in content
|
||||
assert content.startswith("# My project\n")
|
||||
assert "Trailing prose stays." in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@pytest.mark.parametrize(
|
||||
"existing",
|
||||
[
|
||||
"# Doc\n<!-- SPECKIT START -->\ndangling start\n",
|
||||
"dangling end\n<!-- SPECKIT END -->\nrest\n",
|
||||
"no markers at all",
|
||||
],
|
||||
ids=["start-only", "end-only", "no-markers-no-newline"],
|
||||
)
|
||||
def test_python_handles_partial_markers_matching_bash(
|
||||
tmp_path: Path, existing: str
|
||||
) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
|
||||
markers = {"start": "<!-- CTX BEGIN -->", "end": "<!-- CTX FINISH -->"}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
existing = "intro\n<!-- CTX BEGIN -->\nold\n<!-- CTX FINISH -->\noutro\n"
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "<!-- CTX BEGIN -->" in content
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
|
||||
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_files=files)
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert bash.stdout.count("agent-context: updated") == 2
|
||||
for name in ("AGENTS.md", "docs/CONTEXT.md"):
|
||||
assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
existing = b"# Doc\r\n\r\n<!-- SPECKIT START -->\r\nold\r\n<!-- SPECKIT END -->\r\ntail\r\n"
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_bytes(existing)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\r" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None:
|
||||
mdc = ".cursor/rules/specify-rules.mdc"
|
||||
cases = {
|
||||
"missing": "# Rules\n",
|
||||
"false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n",
|
||||
"no-key": "---\ndescription: rules\n---\n\n# Rules\n",
|
||||
}
|
||||
for name, existing in cases.items():
|
||||
repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc)
|
||||
repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
target = repo / mdc
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / mdc).read_text(encoding="utf-8")
|
||||
assert content == (repo_a / mdc).read_text(encoding="utf-8"), name
|
||||
assert "alwaysApply: true" in content, name
|
||||
|
||||
|
||||
# ── Plan-path resolution ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
bash = run_bash(repo_a, "specs/009-explicit/plan.md")
|
||||
py = run_python(repo_b, "specs/009-explicit/plan.md")
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/009-explicit/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
now = time.time()
|
||||
for repo in (repo_a, repo_b):
|
||||
for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)):
|
||||
plan = repo / feature / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
os.utime(plan, (now - age, now - age))
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/001-new/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
now = time.time()
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo, "specs/001-active")
|
||||
stale = repo / "specs" / "000-stale" / "plan.md"
|
||||
stale.parent.mkdir(parents=True, exist_ok=True)
|
||||
stale.write_text("# plan\n", encoding="utf-8")
|
||||
os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/001-active/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\nat " not in content
|
||||
|
||||
|
||||
# ── Config gates and path validation ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_missing_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a = tmp_path / "proj-a"
|
||||
repo_b = tmp_path / "proj-b"
|
||||
repo_a.mkdir()
|
||||
repo_b.mkdir()
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "not found; nothing to do." in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a = tmp_path / "proj-a"
|
||||
repo_b = tmp_path / "proj-b"
|
||||
for repo in (repo_a, repo_b):
|
||||
cfg_dir = repo / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
"context_file: [unclosed\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "cannot update context." in py.stderr
|
||||
assert "agent-context: skipping update (see above for details)." in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_empty_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "context_files/context_file not set" in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / ".specify" / "init-options.json").write_text(
|
||||
json.dumps({"integration": "claude"}), encoding="utf-8"
|
||||
)
|
||||
shutil.copy(
|
||||
EXT_DIR / "agent-context-defaults.json",
|
||||
repo
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-defaults.json",
|
||||
)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"],
|
||||
ids=["absolute", "backslash", "dotdot", "nested-dotdot"],
|
||||
)
|
||||
def test_python_rejects_escaping_paths_matching_bash(
|
||||
tmp_path: Path, bad_path: str
|
||||
) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 1
|
||||
assert not (repo_b / "AGENTS.md").exists()
|
||||
|
||||
|
||||
# ── PowerShell parity (content only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_fresh_context_file_matches_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")
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_upsert_matches_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")
|
||||
existing = (
|
||||
"# My project\n\n"
|
||||
"<!-- SPECKIT START -->\nstale\n<!-- SPECKIT END -->\n"
|
||||
"\ntail\n"
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
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()
|
||||
@@ -291,6 +291,18 @@ class TomlIntegrationTests:
|
||||
"closing delimiter should be inline when body does not end with a quote"
|
||||
)
|
||||
|
||||
def test_toml_string_escapes_control_characters(self):
|
||||
"""A value with control chars / a bare CR must render as parseable TOML.
|
||||
|
||||
TOML forbids literal control characters (U+0000–U+001F except tab and
|
||||
newline, plus U+007F) in every string form, and a bare CR that is not
|
||||
part of a CRLF pair. The renderer used to emit these raw into a basic or
|
||||
``\"\"\"`` multiline string, producing a config file that fails to parse."""
|
||||
value = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
|
||||
rendered = TomlIntegration._render_toml_string(value)
|
||||
parsed = tomllib.loads(f"prompt = {rendered}")
|
||||
assert parsed["prompt"] == value
|
||||
|
||||
def test_toml_is_valid(self, tmp_path):
|
||||
"""Every generated TOML file must parse without errors."""
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
@@ -1795,6 +1795,25 @@ $ARGUMENTS
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
def test_render_toml_command_escapes_control_characters(self):
|
||||
"""Control characters and a lone CR must be escaped so the TOML parses.
|
||||
|
||||
TOML forbids literal control characters (U+0000–U+001F except tab and
|
||||
newline, plus U+007F) in any string, and treats a bare CR outside a
|
||||
CRLF pair as illegal. The renderer used to emit these raw — into a
|
||||
basic string (single-line) or a ``\"\"\"`` multiline string (for a lone
|
||||
CR) — producing a command file that fails to parse."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
body = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "d"}, body, "extension:test-ext"
|
||||
)
|
||||
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["prompt"] == body
|
||||
|
||||
def test_render_toml_command_preserves_backslashes_in_body(self):
|
||||
"""A backslash in the body (e.g. a Windows path) must not break TOML.
|
||||
|
||||
|
||||
@@ -126,6 +126,71 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
|
||||
assert "Feature directory not found" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_survives_broken_python3_stub(plan_repo: Path) -> None:
|
||||
"""A `python3` on PATH that exists but fails at runtime must not defeat
|
||||
feature.json parsing.
|
||||
|
||||
On Windows `python3` typically resolves to the Microsoft Store App Execution
|
||||
Alias stub: it satisfies `command -v python3` yet exits non-zero at runtime.
|
||||
The parser must fall through to the grep/sed fallback on that failure instead
|
||||
of selecting python3 by mere availability and swallowing its error (#3304).
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
|
||||
cwd=plan_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = plan_repo / "specs" / "001-tiny-notes-app"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
|
||||
|
||||
# A stub python3 that mimics the Windows Store alias: on PATH, exits 49.
|
||||
stub_dir = plan_repo / "_stubbin"
|
||||
stub_dir.mkdir()
|
||||
stub = stub_dir / "python3"
|
||||
stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'echo "Python was not found; run without arguments to install from the '
|
||||
'Microsoft Store" >&2\n'
|
||||
"exit 49\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stub.chmod(0o755)
|
||||
|
||||
# A stub jq that shadows any real jq on PATH and also fails, so the parser
|
||||
# cannot short-circuit on jq and must reach the broken python3 stub and then
|
||||
# fall through to grep/sed. Without this, a runner that has jq installed
|
||||
# would parse feature.json via jq and never exercise the fallback this test
|
||||
# is meant to cover.
|
||||
jq_stub = stub_dir / "jq"
|
||||
jq_stub.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'echo "jq: simulated failure" >&2\n'
|
||||
"exit 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
jq_stub.chmod(0o755)
|
||||
|
||||
env = _clean_env()
|
||||
# Prepend the stub dir so the failing jq and python3 stubs take precedence
|
||||
# over any real ones; PATH still needs the real bash utilities for grep/sed.
|
||||
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=plan_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert (feat / "plan.md").is_file()
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_plan_numbered_branch_works_with_feature_json(
|
||||
plan_repo: Path,
|
||||
|
||||
Reference in New Issue
Block a user