mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(integration): add status reporting (#2674)
* feat(integration): add status reporting * docs(integration): include status in query command docstring * fix(integration): handle Windows extended-length paths in status containment On Windows, os.readlink() (and sometimes Path.resolve()) return paths with the \\?\ extended-length prefix. Comparing such a target against a plain project root via Path.relative_to() spuriously fails, so an in-project dangling symlink was classified as `invalid` instead of `missing` — failing test_status_treats_dangling_symlink_as_missing and the windows-style variant on the Windows CI runners. Centralize the containment check in _is_within_project() and strip the \\?\ / \\?\UNC\ prefix from both sides before relative_to(). Add portable regression tests for the prefix-stripping helper and the containment contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test(integration): restore top-level pytest import after rebase A three-way merge / rebase onto main silently dropped the module-level `import pytest` from test_integration_subcommand.py: main reorganized the import block without it (using only a local `import pytest as _pytest`), while this branch added top-level fixtures and `pytest.skip`/`pytest.raises` usage. The overlapping import-hunk edits resolved by dropping the import, breaking collection with `NameError: name 'pytest' is not defined` on every runner. Re-add the import in the third-party group. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(integration): fix Windows UNC path assertion in status helper test `test_strip_extended_length_prefix_normalizes_windows_paths` compared the str() form of the helper's output against a hand-built string. On Windows, pathlib renders a UNC root with a trailing separator (`\\server\share\`), so the exact string match failed there (`\\server\share\` != `\\server\share`) even though `_strip_extended_length_prefix` behaves correctly — the trailing separator is irrelevant to the `relative_to` containment check it feeds. Compare Path objects (semantic equality) instead of exact strings so the assertion holds on both POSIX and Windows. No production code change needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(integration): make shared-manifest remediation specify --integration The fallback `_manifest_suggestion` for the shared `speckit` manifest (used when no usable default integration is recorded) suggested `specify init --here --force`, which can trigger interactive integration selection. For CI/agent consumers of `integration status`, surface an explicit `--integration <key>` placeholder, matching the file's existing `<key>` suggestion style. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -47,6 +50,32 @@ def _write_invalid_manifest(project, key):
|
||||
return manifest
|
||||
|
||||
|
||||
def _copy_project_template(tmp_path, template):
|
||||
project = tmp_path / "proj"
|
||||
shutil.copytree(template, project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def status_copilot_template(tmp_path_factory):
|
||||
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def status_claude_template(tmp_path_factory):
|
||||
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def copilot_project(tmp_path, status_copilot_template):
|
||||
return _copy_project_template(tmp_path, status_copilot_template)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_project(tmp_path, status_claude_template):
|
||||
return _copy_project_template(tmp_path, status_claude_template)
|
||||
|
||||
|
||||
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
||||
plain = strip_ansi(output)
|
||||
row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
|
||||
@@ -126,6 +155,823 @@ class TestIntegrationList:
|
||||
assert "only supports schema 1" in normalized
|
||||
|
||||
|
||||
# ── status ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationStatus:
|
||||
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_status_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Integration status: OK" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Shared templates target alignment: copilot" in result.output
|
||||
assert "Modified managed files: 0" in result.output
|
||||
assert "Missing managed files: 0" in result.output
|
||||
|
||||
def test_status_json_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["default_integration"] == "copilot"
|
||||
assert payload["installed_integrations"] == ["copilot"]
|
||||
assert payload["recorded_installed_integrations"] == ["copilot"]
|
||||
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
||||
assert payload["multi_install_safe"] is True
|
||||
assert payload["shared_templates_target_alignment"] == "copilot"
|
||||
assert "shared_templates_aligned_to" not in payload
|
||||
assert payload["findings"] == []
|
||||
|
||||
def test_status_reports_invalid_integration_json(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "integration-state-unreadable" in result.output
|
||||
assert "invalid JSON" in result.output
|
||||
assert "Detail:" in result.output
|
||||
assert "Multi-install safe: unknown" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
|
||||
self,
|
||||
copilot_project,
|
||||
):
|
||||
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == []
|
||||
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
||||
assert "Detail:" in payload["findings"][0]["message"]
|
||||
|
||||
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["integration_state_schema"] = 99
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
||||
assert "schema 99" in payload["findings"][0]["message"]
|
||||
assert "supported schema: 1" in payload["findings"][0]["message"]
|
||||
|
||||
def test_status_reports_missing_integration_json(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integration.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "integration-state-missing" in result.output
|
||||
assert ".specify/integration.json is missing" in result.output
|
||||
assert "Multi-install safe: unknown" in result.output
|
||||
|
||||
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
|
||||
self,
|
||||
copilot_project,
|
||||
):
|
||||
(copilot_project / ".specify" / "integration.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == []
|
||||
assert payload["findings"][0]["code"] == "integration-state-missing"
|
||||
|
||||
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"version": "test",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["findings"][0]["code"] == "no-installed-integrations"
|
||||
assert "speckit" in payload["manifests"]
|
||||
assert payload["manifests"]["speckit"]["readable"] is True
|
||||
|
||||
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"version": "test",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["unchecked_manifests"] == 1
|
||||
assert any(
|
||||
item["code"] == "no-installed-integrations"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
assert any(
|
||||
item["code"] == "manifest-missing"
|
||||
and item["integration"] == "speckit"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state.pop("default_integration", None)
|
||||
state.pop("integration", None)
|
||||
state["installed_integrations"] = ["claude"]
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["default_integration"] is None
|
||||
assert any(
|
||||
item["code"] == "default-integration-missing"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state.pop("default_integration", None)
|
||||
state.pop("integration", None)
|
||||
state["installed_integrations"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert [item["code"] for item in payload["findings"]] == [
|
||||
"installed-integrations-invalid",
|
||||
"no-installed-integrations",
|
||||
]
|
||||
|
||||
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["default_integration"] = "copilot"
|
||||
state["integration"] = "copilot"
|
||||
state["installed_integrations"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == ["copilot"]
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert [item["code"] for item in payload["findings"]] == [
|
||||
"installed-integrations-invalid",
|
||||
]
|
||||
|
||||
def test_status_reports_default_integration_not_installed(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["default_integration"] = "codex"
|
||||
state["integration"] = "codex"
|
||||
state["installed_integrations"] = ["claude"]
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["default_integration"] == "codex"
|
||||
assert payload["installed_integrations"] == ["codex", "claude"]
|
||||
assert payload["recorded_installed_integrations"] == ["claude"]
|
||||
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
||||
assert any(
|
||||
item["code"] == "default-integration-not-installed"
|
||||
and "Default integration 'codex' is not listed" in item["message"]
|
||||
for item in payload["findings"]
|
||||
)
|
||||
assert "codex" not in payload["manifests"]
|
||||
assert not any(
|
||||
item["code"] == "manifest-missing" and item.get("integration") == "codex"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = []
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["installed_integrations"] == ["claude"]
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifests"]["claude"]["readable"] is True
|
||||
assert any(
|
||||
item["code"] == "default-integration-not-installed"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_reports_missing_manifest(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "manifest-missing" in result.output
|
||||
assert "Manifest for integration 'copilot' is missing" in result.output
|
||||
|
||||
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
|
||||
_write_invalid_manifest(copilot_project, "copilot")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["unchecked_manifests"] == 1
|
||||
assert payload["manifests"]["copilot"]["readable"] is False
|
||||
assert payload["manifests"]["copilot"]["missing_files"] == []
|
||||
assert payload["manifests"]["copilot"]["modified_files"] == []
|
||||
|
||||
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Integration status: WARNING" in result.output
|
||||
assert "managed-files-modified" in result.output
|
||||
assert "Modified managed files: 1" in result.output
|
||||
|
||||
def test_status_reports_missing_managed_files(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
(copilot_project / first_rel).unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "managed-files-missing" in result.output
|
||||
assert "Missing managed files: 1" in result.output
|
||||
|
||||
def test_status_reports_missing_shared_managed_files(self, copilot_project):
|
||||
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
assert shared_file.exists()
|
||||
shared_file.unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "managed-files-missing" in result.output
|
||||
assert "shared Spec Kit infrastructure" in result.output
|
||||
assert "Missing managed files: 1" in result.output
|
||||
|
||||
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
|
||||
def fail_exists(self):
|
||||
raise AssertionError(f"Path.exists() should not be used for {self}")
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fail_exists)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project.resolve(),
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
|
||||
def fail_exists(self):
|
||||
raise AssertionError(f"Path.exists() should not be used for {self}")
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fail_exists)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["manifests"]["copilot"]["readable"] is True
|
||||
|
||||
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
|
||||
original_resolve = Path.resolve
|
||||
failed = {"done": False}
|
||||
|
||||
def fail_first_project_root_resolve(self, *args, **kwargs):
|
||||
if self == copilot_project and not failed["done"]:
|
||||
failed["done"] = True
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
||||
|
||||
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
|
||||
self,
|
||||
copilot_project,
|
||||
monkeypatch,
|
||||
):
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fail_project_root_resolve(self, *args, **kwargs):
|
||||
if self == copilot_project:
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["manifests"]["copilot"]["readable"] is True
|
||||
assert payload["manifests"]["speckit"]["readable"] is True
|
||||
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
||||
|
||||
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
real_project = tmp_path / "real-project"
|
||||
real_project.mkdir()
|
||||
tracked = real_project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
symlinked_project = tmp_path / "symlinked-project"
|
||||
try:
|
||||
symlinked_project.symlink_to(real_project, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
manifest = IntegrationManifest("test", real_project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
manifest.project_root = symlinked_project.absolute()
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
symlinked_project.absolute(),
|
||||
project_root_is_resolved=False,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
project_root_resolved = project.resolve()
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fail_project_parent_resolve(self, *args, **kwargs):
|
||||
if self == project:
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project_root_resolved,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == ["tracked.md"]
|
||||
assert valid == []
|
||||
|
||||
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
|
||||
from specify_cli import integration_status as status_module
|
||||
|
||||
def fail_load(key, project_root, **kwargs):
|
||||
raise RuntimeError(f"unexpected manifest loader bug for {key}")
|
||||
|
||||
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
|
||||
status_module.build_integration_status_report(copilot_project)
|
||||
|
||||
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
target = copilot_project / first_rel
|
||||
target.unlink()
|
||||
try:
|
||||
target.symlink_to(copilot_project / "missing-target")
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
|
||||
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
regular_stat = tracked.lstat()
|
||||
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
|
||||
tracked.unlink()
|
||||
try:
|
||||
tracked.symlink_to(project / "missing-target")
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
original_lstat = Path.lstat
|
||||
original_is_symlink = Path.is_symlink
|
||||
|
||||
def windows_style_lstat(self):
|
||||
if self == tracked:
|
||||
return regular_stat
|
||||
return original_lstat(self)
|
||||
|
||||
def windows_style_is_symlink(self):
|
||||
if self == tracked:
|
||||
return True
|
||||
return original_is_symlink(self)
|
||||
|
||||
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
|
||||
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project.resolve(),
|
||||
)
|
||||
|
||||
assert missing == ["tracked.md"]
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
|
||||
from specify_cli.integration_status import _strip_extended_length_prefix
|
||||
|
||||
# Build the prefixed strings explicitly so the test is meaningful on
|
||||
# every platform (POSIX won't parse backslash separators, but the
|
||||
# helper operates on the string form). Compare Path objects rather than
|
||||
# their str() form: on Windows pathlib renders a UNC root with a
|
||||
# trailing separator (``\\server\share\``), so an exact string match is
|
||||
# brittle, whereas Path equality captures the intended semantics on
|
||||
# both POSIX and Windows.
|
||||
bs = "\\"
|
||||
assert _strip_extended_length_prefix(
|
||||
Path(f"{bs}{bs}?{bs}C:{bs}proj")
|
||||
) == Path(f"C:{bs}proj")
|
||||
assert _strip_extended_length_prefix(
|
||||
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
|
||||
) == Path(f"{bs}{bs}server{bs}share")
|
||||
# Paths without the prefix are returned unchanged.
|
||||
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
|
||||
|
||||
def test_is_within_project_tolerates_extended_length_prefix(self):
|
||||
from specify_cli.integration_status import _is_within_project
|
||||
|
||||
# A readlink result on POSIX never carries the prefix, so an in-project
|
||||
# child is contained and an outside path is not. The Windows
|
||||
# prefix-stripping branch is exercised by the dangling-symlink tests on
|
||||
# Windows CI; here we lock in the cross-platform containment contract.
|
||||
root = Path("/tmp/project").resolve()
|
||||
assert _is_within_project(root, root / "child")
|
||||
assert not _is_within_project(root, Path("/tmp/other").resolve())
|
||||
|
||||
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
|
||||
link = copilot_project / "outside-link"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["invalid_manifest_paths"] == 1
|
||||
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
|
||||
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "secret.txt"
|
||||
outside_file.write_text("outside project\n", encoding="utf-8")
|
||||
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
tracked_path = copilot_project / first_rel
|
||||
tracked_path.unlink()
|
||||
try:
|
||||
tracked_path.symlink_to(outside_file)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
original_stat = Path.stat
|
||||
|
||||
def fail_tracked_symlink_stat(self, *args, **kwargs):
|
||||
follows_symlinks = kwargs.get("follow_symlinks", True)
|
||||
if self == tracked_path and follows_symlinks:
|
||||
raise AssertionError("Path.stat() should not follow tracked symlinks")
|
||||
return original_stat(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["invalid_manifest_paths"] == 1
|
||||
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
|
||||
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["copilot", "claude"]
|
||||
state["default_integration"] = "copilot"
|
||||
state["integration"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
IntegrationManifest("claude", copilot_project, version="test").save()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unsafe-multi-install" in result.output
|
||||
assert "Multi-install safe: no" in result.output
|
||||
assert "specify integration switch <key>" in result.output
|
||||
|
||||
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["claude", "mystery"]
|
||||
state["default_integration"] = "claude"
|
||||
state["integration"] = "claude"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
IntegrationManifest("mystery", claude_project, version="test").save()
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unknown-integration" in result.output
|
||||
assert "unsafe-multi-install" in result.output
|
||||
assert "remove the stale integration entry" in result.output
|
||||
assert "Multi-install safe: no" in result.output
|
||||
|
||||
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["mystery"]
|
||||
state["default_integration"] = "mystery"
|
||||
state["integration"] = "mystery"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
manifest_finding = next(
|
||||
item for item in payload["findings"]
|
||||
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
|
||||
)
|
||||
assert "remove the stale integration entry" in manifest_finding["suggestion"]
|
||||
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
|
||||
|
||||
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "../../../escape"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
outside_manifest = tmp_path / "escape.manifest.json"
|
||||
outside_manifest.write_text(
|
||||
json.dumps({"integration": unsafe_key, "files": {}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert unsafe_key not in payload["manifests"]
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "bad:key"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "CON"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_reports_managed_file_collisions(self, claude_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["claude", "codex"]
|
||||
state["default_integration"] = "claude"
|
||||
state["integration"] = "claude"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
|
||||
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
|
||||
shared_rel = next(iter(tracked_files))
|
||||
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
|
||||
codex_manifest.record_existing(shared_rel)
|
||||
codex_manifest.save()
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "managed-file-collision" in result.output
|
||||
assert "Integration status: WARNING" in result.output
|
||||
|
||||
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "[red]x[/red]",
|
||||
"installed_integrations": ["[red]x[/red]"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
result = runner.invoke(app, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["default_integration"] == "[red]x[/red]"
|
||||
assert payload["installed_integrations"] == ["[red]x[/red]"]
|
||||
|
||||
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "[red]x[/red]",
|
||||
"installed_integrations": ["[red]x[/red]"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Default integration: [red]x[/red]" in result.output
|
||||
assert "Installed integrations: [red]x[/red]" in result.output
|
||||
|
||||
|
||||
# ── install ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user