mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae61b31aa5 |
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
@@ -2,32 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.16.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): wrap a non-UTF-8 catalog response (#4011)
|
||||
- fix(events): skip an unreadable command template (#3956)
|
||||
- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
|
||||
- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
|
||||
- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
|
||||
- fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
|
||||
- fix(init): escape user-supplied values in `specify init` output (#3787)
|
||||
- fix: bound response read in integration catalog fetch (#3812)
|
||||
- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
|
||||
- fix(workflows): handle an unreadable run state in `workflow status` (#3999)
|
||||
- feat(init): scaffold managed .specify/.gitignore (#4000)
|
||||
- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
|
||||
- fix(presets): return None for an unreadable layer in resolve_content (#3959)
|
||||
- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
|
||||
- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
|
||||
- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
|
||||
- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
|
||||
- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
|
||||
- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
|
||||
- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
|
||||
|
||||
## [0.16.0] - 2026-08-05
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -139,6 +139,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
|
||||
| Spec Roadmap | Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost. | `process` | Read+Write | [speckit-roadmap](https://github.com/srobroek/speckit-roadmap) |
|
||||
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
|
||||
| Spec Switcher | Navigate between specs — list available specs and switch active feature with git checkout | `process` | Read+Write | [spec-kit-switch](https://github.com/wagner-sousa/spec-kit-switch) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-07T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"adrkit": {
|
||||
@@ -4417,6 +4417,40 @@
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
},
|
||||
"switch": {
|
||||
"name": "Spec Switcher",
|
||||
"id": "switch",
|
||||
"description": "Navigate between specs — list available specs and switch active feature with git checkout",
|
||||
"author": "Wagner Sousa",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/wagner-sousa/spec-kit-switch/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/wagner-sousa/spec-kit-switch",
|
||||
"homepage": "https://github.com/wagner-sousa/spec-kit-switch",
|
||||
"documentation": "https://github.com/wagner-sousa/spec-kit-switch#readme",
|
||||
"changelog": "https://github.com/wagner-sousa/spec-kit-switch/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"navigation",
|
||||
"workflow",
|
||||
"specs",
|
||||
"sdd",
|
||||
"git"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-07T00:00:00Z",
|
||||
"updated_at": "2026-08-07T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
"id": "sync",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.16.1"
|
||||
version = "0.16.1.dev0"
|
||||
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"
|
||||
|
||||
@@ -771,17 +771,7 @@ def _local_manifest_source(arg: str):
|
||||
error_type=BundlerError,
|
||||
label="bundle manifest",
|
||||
)
|
||||
try:
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
except _yaml.YAMLError as exc:
|
||||
# The sibling directory/bundle.yml branches reach YAML through
|
||||
# load_yaml(), which turns a parse failure into a BundlerError. This
|
||||
# branch parses inline, so without this it raises a raw YAMLError --
|
||||
# neither a ValueError nor an OSError -- which escapes
|
||||
# bundle_install()'s `except BundlerError` as a traceback.
|
||||
raise BundlerError(
|
||||
f"Invalid YAML in bundle.yml inside '{candidate}': {exc}"
|
||||
) from exc
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
|
||||
|
||||
@@ -548,15 +548,7 @@ def _resolve_event_command_argv(
|
||||
"""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
try:
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# An unreadable or undecodable template cannot declare a runnable
|
||||
# script. Degrade to "no argv" like every other failure in this
|
||||
# resolver (missing frontmatter, malformed YAML, absent scripts)
|
||||
# instead of leaking a raw traceback through
|
||||
# resolve_and_run_event_command.
|
||||
return None
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
@@ -237,15 +237,6 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
raise IntegrationCatalogError(
|
||||
f"Failed to fetch catalog from {entry.url}: {exc}"
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
# A non-UTF-8 response body fails at .decode() before json.loads()
|
||||
# ever runs, so JSONDecodeError below does not cover it (the two are
|
||||
# sibling ValueError subclasses, not parent/child). Without this the
|
||||
# raw UnicodeDecodeError escapes _get_merged_integrations()'s
|
||||
# "warn and skip this catalog" handler and kills the whole command.
|
||||
raise IntegrationCatalogError(
|
||||
f"Catalog from {entry.url} is not valid UTF-8: {exc}"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid JSON in catalog from {entry.url}: {exc}"
|
||||
|
||||
@@ -186,50 +186,6 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path):
|
||||
"""A malformed bundle.yml inside a .zip must raise BundlerError.
|
||||
|
||||
The zip branch parses YAML inline rather than through load_yaml(), so the
|
||||
raw yaml.YAMLError used to escape. It is neither a ValueError nor an
|
||||
OSError, so nothing upstream caught it.
|
||||
"""
|
||||
artifact = tmp_path / "bad-manifest.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n")
|
||||
|
||||
with pytest.raises(BundlerError, match="Invalid YAML"):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path):
|
||||
"""`bundle install` reports malformed YAML the same way for all 3 sources.
|
||||
|
||||
Directory and bundle.yml sources already exited 1 with an "Invalid YAML"
|
||||
message; the .zip source dumped a yaml.parser.ParserError traceback.
|
||||
"""
|
||||
bad_yaml = "bundle: [unclosed\n id: demo\n"
|
||||
|
||||
directory = tmp_path / "dir-src"
|
||||
directory.mkdir()
|
||||
(directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8")
|
||||
|
||||
manifest_file = tmp_path / "standalone.yml"
|
||||
manifest_file.write_text(bad_yaml, encoding="utf-8")
|
||||
|
||||
artifact = tmp_path / "artifact.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", bad_yaml)
|
||||
|
||||
runner = CliRunner()
|
||||
for source in (directory, manifest_file, artifact):
|
||||
result = runner.invoke(app, ["bundle", "install", str(source)])
|
||||
assert result.exit_code == 1, f"{source.name}: {result.output}"
|
||||
assert result.exception is None or isinstance(
|
||||
result.exception, SystemExit
|
||||
), f"{source.name} leaked {type(result.exception).__name__}"
|
||||
assert "Invalid YAML" in result.output, f"{source.name}: {result.output}"
|
||||
|
||||
|
||||
def test_invalid_local_manifest_is_rejected_before_project_init(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -1263,53 +1263,6 @@ class TestCommandRunner:
|
||||
)
|
||||
|
||||
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
||||
|
||||
assert argv is None
|
||||
|
||||
def test_unreadable_template_returns_none(self, tmp_path):
|
||||
"""A command template that cannot be read must resolve to no argv.
|
||||
|
||||
Every other failure inside ``_resolve_event_command_argv`` — missing
|
||||
frontmatter, malformed YAML, absent scripts — degrades to ``None`` so
|
||||
the dispatcher treats the command as declaring no runnable script.
|
||||
The initial ``read_text`` was the one step outside that boundary: a
|
||||
non-UTF-8 template raised a raw ``UnicodeDecodeError`` through
|
||||
``resolve_and_run_event_command`` and out of ``specify event run``.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
(cmd_dir / "boot.md").write_bytes(
|
||||
b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n"
|
||||
)
|
||||
|
||||
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
||||
assert argv is None
|
||||
|
||||
def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch):
|
||||
"""The same boundary must cover ``OSError`` (e.g. permission denied).
|
||||
|
||||
Mocked rather than chmod-based so the case also holds under
|
||||
privileged CI where permission bits are not enforced.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
template = cmd_dir / "boot.md"
|
||||
template.write_text("---\ndescription: Boot\n---\nBody\n")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def failing_read_text(self_path, *args, **kwargs):
|
||||
if self_path == template:
|
||||
raise PermissionError(13, "Permission denied")
|
||||
return original_read_text(self_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", failing_read_text)
|
||||
|
||||
argv = _resolve_event_command_argv(template, tmp_path, None)
|
||||
assert argv is None
|
||||
|
||||
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
|
||||
|
||||
@@ -392,125 +392,6 @@ class TestCatalogFetch:
|
||||
with pytest.raises(IntegrationCatalogError, match="exceeds maximum size"):
|
||||
cat._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def _patch_urlopen_bytes(self, monkeypatch, bodies):
|
||||
"""Patch urlopen to serve raw *bodies* keyed by URL substring.
|
||||
|
||||
Mirrors ``_patch_urlopen`` but passes the bytes through verbatim: these
|
||||
tests need a body that is not valid UTF-8, which ``json.dumps`` cannot
|
||||
produce.
|
||||
"""
|
||||
|
||||
class _RawResponse:
|
||||
def __init__(self, data, url):
|
||||
self._data = data
|
||||
self._url = url
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
for marker, body in bodies.items():
|
||||
if marker in url:
|
||||
return _RawResponse(body, url)
|
||||
raise AssertionError(f"unexpected URL requested: {url}")
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_wraps_non_utf8_catalog_response(self, tmp_path, monkeypatch):
|
||||
"""Regression: a non-UTF-8 response body must raise IntegrationCatalogError.
|
||||
|
||||
``.decode("utf-8")`` runs before ``json.loads``, so the resulting
|
||||
UnicodeDecodeError is not a JSONDecodeError and slipped past both
|
||||
handlers as a raw traceback.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir(exist_ok=True)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
self._patch_urlopen_bytes(
|
||||
monkeypatch,
|
||||
{"catalog.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}'},
|
||||
)
|
||||
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrationCatalogError, match="not valid UTF-8"):
|
||||
cat._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_search_skips_non_utf8_catalog(self, tmp_path, monkeypatch, capsys):
|
||||
"""A single non-UTF-8 catalog must not take down the whole search.
|
||||
|
||||
``_get_merged_integrations`` is built to warn and continue on a bad
|
||||
catalog; an unwrapped UnicodeDecodeError defeated that entirely.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir(exist_ok=True)
|
||||
(specify / "integration-catalogs.yml").write_text(
|
||||
"catalogs:\n"
|
||||
" - name: broken\n"
|
||||
" url: https://example.com/broken.json\n"
|
||||
" priority: 1\n"
|
||||
" - name: healthy\n"
|
||||
" url: https://example.com/healthy.json\n"
|
||||
" priority: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
healthy = json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"acme-coder": {
|
||||
"name": "Acme Coder",
|
||||
"version": "1.0.0",
|
||||
"description": "Acme integration",
|
||||
}
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
self._patch_urlopen_bytes(
|
||||
monkeypatch,
|
||||
{
|
||||
"broken.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}',
|
||||
"healthy.json": healthy,
|
||||
},
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
results = cat.search()
|
||||
|
||||
assert "acme-coder" in [r["id"] for r in results]
|
||||
assert "broken" in capsys.readouterr().err
|
||||
|
||||
def test_search_by_tag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
|
||||
Reference in New Issue
Block a user