Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 60 additions & 113 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,42 +214,39 @@ def test_setup_copies_shared_templates(self, tmp_path):
class TestBuildCommandInvocation:
"""Tests for build_command_invocation across integration types."""

def test_base_core_command_dotted(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"

def test_base_core_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("plan") == "/speckit.plan"

def test_base_core_command_with_args(self):
i = StubIntegration()
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"

def test_base_extension_command(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"

def test_base_extension_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"

def test_skills_core_command(self):
@pytest.mark.parametrize(
("integration_key", "command_name", "args", "expected"),
[
("stub", "speckit.plan", "", "/speckit.plan"),
("stub", "plan", "", "/speckit.plan"),
("stub", "plan", "my feature", "/speckit.plan my feature"),
("stub", "speckit.git.commit", "", "/speckit.git.commit"),
("stub", "git.commit", "", "/speckit.git.commit"),
("forge", "speckit.plan", "", "/speckit-plan"),
("forge", "plan", "", "/speckit-plan"),
("forge", "speckit.git.commit", "fix typo", "/speckit-git-commit fix typo"),
("cline", "speckit.plan", "", "/speckit-plan"),
("cline", "git.commit", "fix typo", "/speckit-git-commit fix typo"),
("codex", "speckit.plan", "", "$speckit-plan"),
("codex", "plan", "", "$speckit-plan"),
("codex", "speckit.git.commit", "", "$speckit-git-commit"),
("codex", "git.commit", "fix typo", "$speckit-git-commit fix typo"),
("kimi", "speckit.plan", "", "/skill:speckit-plan"),
("kimi", "git.commit", "fix typo", "/skill:speckit-git-commit fix typo"),
],
)
def test_native_invocation_styles(
self, integration_key, command_name, args, expected
):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.plan") == "$speckit-plan"
assert i.build_command_invocation("plan") == "$speckit-plan"

def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit"
assert i.build_command_invocation("git.commit") == "$speckit-git-commit"
integration = (
StubIntegration()
if integration_key == "stub"
else get_integration(integration_key)
)

def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo"
assert integration.build_command_invocation(command_name, args) == expected

@pytest.mark.parametrize("integration_key", ["codex", "zcode"])
def test_dollar_skill_post_processing_is_idempotent(self, integration_key):
Expand Down Expand Up @@ -301,65 +298,40 @@ def test_get_invocation_prefix_skill_colon(self):
assert get_invocation_prefix("codex", True) == "$"
assert get_invocation_prefix("claude", True) == "/"

def test_forge_core_command_hyphenated(self):
"""Forge installs hyphenated slash-commands (/speckit-<name>), so the
dispatch invocation must be hyphenated too — not the dotted default it
would inherit from MarkdownIntegration."""
from specify_cli.integrations import get_integration
i = get_integration("forge")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"

def test_forge_extension_command_hyphenated(self):
from specify_cli.integrations import get_integration
i = get_integration("forge")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert (
i.build_command_invocation("speckit.git.commit", "fix typo")
== "/speckit-git-commit fix typo"
)

def test_cline_core_command_hyphenated(self):
"""Cline installs hyphenated slash-commands (/speckit-<name>), so the
dispatch invocation must be hyphenated too — not the dotted default it
would inherit from MarkdownIntegration."""
from specify_cli.integrations import get_integration
i = get_integration("cline")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"

def test_cline_extension_command_hyphenated(self):
from specify_cli.integrations import get_integration
i = get_integration("cline")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert (
i.build_command_invocation("speckit.git.commit", "fix typo")
== "/speckit-git-commit fix typo"
)


class TestResolveCommandRefs:
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""

def test_dot_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run `/speckit.plan` to plan."

def test_hyphen_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."

def test_dollar_prefix_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-", "$")
assert result == "Run `$speckit-plan` to plan."
@pytest.mark.parametrize(
("placeholder", "separator", "prefix", "expected"),
[
("PLAN", ".", "/", "/speckit.plan"),
("PLAN", "-", "/", "/speckit-plan"),
("PLAN", "-", "$", "$speckit-plan"),
("PLAN", "-", "/skill:", "/skill:speckit-plan"),
("GIT_COMMIT", ".", "/", "/speckit.git.commit"),
("GIT_COMMIT", "-", "/", "/speckit-git-commit"),
(
"AGENT-CONTEXT_UPDATE",
".",
"/",
"/speckit.agent-context.update",
),
(
"AGENT-CONTEXT_UPDATE",
"-",
"/",
"/speckit-agent-context-update",
),
("V2_PLAN", ".", "/", "/speckit.v2.plan"),
],
)
def test_command_reference_rendering(
self, placeholder, separator, prefix, expected
):
text = f"Run __SPECKIT_COMMAND_{placeholder}__ now."
result = IntegrationBase.resolve_command_refs(text, separator, prefix)

def test_skill_colon_prefix_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-", "/skill:")
assert result == "Run `/skill:speckit-plan` to plan."
assert result == f"Run {expected} now."

def test_process_template_kimi_uses_skill_colon_prefix(self):
"""process_template must use /skill: prefix for Kimi without relying on
Expand All @@ -376,26 +348,6 @@ def test_multiple_placeholders(self):
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"

def test_extension_command_dot(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run /speckit.git.commit to commit."

def test_extension_command_hyphen(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run /speckit-git-commit to commit."

def test_hyphenated_command_dot(self):
text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run /speckit.agent-context.update to refresh."

def test_hyphenated_command_hyphen(self):
text = "Run __SPECKIT_COMMAND_AGENT-CONTEXT_UPDATE__ to refresh."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run /speckit-agent-context-update to refresh."

def test_no_placeholders_unchanged(self):
text = "No placeholders here."
assert IntegrationBase.resolve_command_refs(text, ".") == text
Expand Down Expand Up @@ -452,11 +404,6 @@ def test_placeholder_adjacent_to_text(self):
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "foo/speckit.planbar"

def test_placeholder_with_digits(self):
text = "__SPECKIT_COMMAND_V2_PLAN__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan"


class TestResolvePythonInterpreter:
def test_returns_python_on_path(self, monkeypatch):
Expand Down
180 changes: 180 additions & 0 deletions tests/integrations/test_integration_characterization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Cross-integration characterization tests for stable delivery contracts."""

from __future__ import annotations

import warnings
from pathlib import Path

import pytest
from typer.testing import CliRunner

from specify_cli import app
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest


@pytest.mark.parametrize(
(
"integration_key",
"options",
"installed_command",
"expected_invocation",
"expected_reference",
"companion",
),
[
(
"amp",
{},
".agents/commands/speckit.specify.md",
"/speckit.specify",
"/speckit.plan",
None,
),
(
"forge",
{},
".forge/commands/speckit.specify.md",
"/speckit-specify",
"/speckit-plan",
None,
),
(
"codex",
{},
".agents/skills/speckit-specify/SKILL.md",
"$speckit-specify",
"$speckit-plan",
None,
),
(
"kimi",
{},
".kimi-code/skills/speckit-specify/SKILL.md",
"/skill:speckit-specify",
"/skill:speckit-plan",
None,
),
(
"bob",
{},
".bob/skills/speckit-specify/SKILL.md",
"/speckit-specify",
"/speckit-plan",
None,
),
(
"bob",
{"legacy_commands": True},
".bob/commands/speckit.specify.md",
"/speckit.specify",
"/speckit.plan",
None,
),
(
"copilot",
{},
".github/skills/speckit-specify/SKILL.md",
"/speckit-specify",
"/speckit-plan",
None,
),
(
"copilot",
{"commands": True},
".github/agents/speckit.specify.agent.md",
"",
"/speckit.plan",
".github/prompts/speckit.specify.prompt.md",
),
],
)
def test_installed_names_invocations_and_rendered_references_stay_aligned(
tmp_path: Path,
integration_key: str,
options: dict[str, bool],
installed_command: str,
expected_invocation: str,
expected_reference: str,
companion: str | None,
) -> None:
"""Installed names, native invocation syntax, and rendered refs agree."""
integration = type(get_integration(integration_key))()
manifest = IntegrationManifest(integration_key, tmp_path)

with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
integration.setup(tmp_path, manifest, parsed_options=options)

command_path = tmp_path / installed_command
assert command_path.is_file()
content = command_path.read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_" not in content
assert expected_reference in content
invocation = (
integration.build_command_invocation(
"speckit.specify", project_root=tmp_path
)
if integration_key == "bob"
else integration.build_command_invocation("speckit.specify")
)
assert invocation == expected_invocation
if companion is not None:
prompt_path = tmp_path / companion
assert prompt_path.read_text(encoding="utf-8") == (
"---\nagent: speckit.specify\n---\n"
)


@pytest.mark.parametrize(
("integration_key", "expected", "excluded", "guidance"),
[
(
"command-code",
"$speckit-constitution",
"/speckit.constitution",
(),
),
("forge", "/speckit-plan", "/speckit.plan", ()),
(
"kimi",
"/skill:speckit-constitution",
"/speckit.constitution",
("Optional skills that you can use for your specs",),
),
("copilot", "/speckit-plan", "/speckit.plan", ()),
(
"dsh",
"/speckit-plan",
"/speckit.plan",
("Start DSH", "dsh web", ".dsh/skills"),
),
],
)
def test_init_completion_guidance_matches_installed_integration(
tmp_path: Path,
integration_key: str,
expected: str,
excluded: str,
guidance: tuple[str, ...],
) -> None:
project = tmp_path / integration_key
result = CliRunner().invoke(
app,
[
"init",
str(project),
"--integration",
integration_key,
"--ignore-agent-tools",
"--script",
"sh",
],
catch_exceptions=False,
)

assert result.exit_code == 0, result.output
assert expected in result.output
assert excluded not in result.output
for snippet in guidance:
assert snippet in result.output
Loading
Loading