diff --git a/AI_AGENTS.md b/AI_AGENTS.md
index d1e24e2..74be4e2 100644
--- a/AI_AGENTS.md
+++ b/AI_AGENTS.md
@@ -208,27 +208,7 @@ At this point, your job is done until review.
Use this phrasing with users:
-> “I’ve captured the intended behaviour so assumptions are explicit and can be enforced later in CI.”
-
----
-
-## Enforcement Comes Later
-
-SpecLeft supports policy enforcement, but **not by default**.
-
-Enforcement is a *separate decision* that happens when:
-- behaviour is agreed
-- scenarios exist
-- the team wants CI guarantees
-
-Example (later):
-
-```bash
-specleft enforce specleft-policy.yml
-```
-
-Planning-first adoption is intentional.
-Do not enforce prematurely.
+> “I’ve captured the intended behaviour so assumptions are explicit and reviewable in CI.”
---
@@ -252,7 +232,6 @@ Proceed only if healthy.
- `specleft status --format json` — see implementation state
- `specleft next --format json` — find next scenario to implement
- `specleft coverage --format json` — coverage by feature
-- `specleft enforce ` — enforce agreed intent in CI
Agents should treat JSON output as the **stable API**.
@@ -264,7 +243,7 @@ SpecLeft is designed to fail **loudly and explicitly**.
- Validation errors mean intent is unclear.
- Missing priority means intent is incomplete.
-- Enforcement failures mean behaviour is not implemented.
+- Test failures mean behaviour is not implemented.
Do not auto-fix intent without user approval.
diff --git a/CLAUDE.md b/CLAUDE.md
index deb282c..3b91aec 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -210,26 +210,6 @@ Use this phrasing with users:
---
-## Enforcement Comes Later
-
-SpecLeft supports policy enforcement, but **not by default**.
-
-Enforcement is a *separate decision* that happens when:
-- behaviour is agreed
-- scenarios exist
-- the team wants CI guarantees
-
-Example (later):
-
-```bash
-specleft enforce specleft-policy.yml
-```
-
-Planning-first adoption is intentional.
-Do not enforce prematurely.
-
----
-
## Programmatic Use (Optional)
All SpecLeft commands support `--format json` for agent use.
@@ -250,7 +230,6 @@ Proceed only if healthy.
- `specleft status --format json` — see implementation state
- `specleft next --format json` — find next scenario to implement
- `specleft coverage --format json` — coverage by feature
-- `specleft enforce ` — enforce agreed intent in CI
Agents should treat JSON output as the **stable API**.
@@ -262,7 +241,7 @@ SpecLeft is designed to fail **loudly and explicitly**.
- Validation errors mean intent is unclear.
- Missing priority means intent is incomplete.
-- Enforcement failures mean behaviour is not implemented.
+- Test failures mean behaviour is not implemented.
Do not auto-fix intent without user approval.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c02782d..f03b2ed 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,12 +41,17 @@ Thank you for your interest in contributing to SpecLeft SDK! This document provi
pytest --version
```
+## Developing with coding agents
+
+1. Follow as much of the WORKFLOW.md as possible for development.
+2. Features have to be tested manually to ensure they work as intended.
+
## Running Tests
### Run All Tests
```bash
-pytest
+make test
```
### Run Tests with Coverage
@@ -109,6 +114,14 @@ specleft_scenario = []
## Code Style Guidelines
+### Linting Shortcut
+
+Use Make commands to run lint
+```bash
+> make lint
+> make lint-fix
+```
+
### Formatting
We use **Black** for code formatting with default settings:
@@ -141,13 +154,7 @@ We use **MyPy** for static type checking:
mypy src/
```
-### Linting Shortcut
-Use Make commands to run lint
-```bash
-> make lint
-> make lint-fix
-```
### Code Style Summary
@@ -253,6 +260,11 @@ Examples:
## Testing Guidelines
+### Manual Testing
+- For new features, we strongly encourage manually testing the functionality to ensure it works as intended.
+- We want to pass a human eye on what we build to ensure it meets the needs of users and agents.
+- This way we will visually see how the CLI commands work, especially in terms of performance.
+
### Writing Tests
- Place tests in the `tests/` directory
@@ -261,7 +273,7 @@ Examples:
- Use descriptive test names that explain what is being tested
- Follow the Arrange-Act-Assert (AAA) pattern
-### Test Example
+### Behaviour Test Example
```python
def test_specleft_decorator_stores_metadata():
@@ -279,16 +291,19 @@ def test_specleft_decorator_stores_metadata():
### Test Coverage
-- Aim for at least 80% code coverage
+- Aim for at least 90% code coverage
- All public APIs should have tests
- Include tests for edge cases and error conditions
+### Feature Coverage
+
+- Always verify that spec coverage is 100% with `specleft status`
+
## Documentation
- Update the README.md for user-facing changes
- Add docstrings to all public functions and classes
- Include examples in docstrings where helpful
-- Update PROGRESS.md when completing implementation phases
## Questions?
diff --git a/GET_STARTED.md b/GET_STARTED.md
index 356a46a..805734f 100644
--- a/GET_STARTED.md
+++ b/GET_STARTED.md
@@ -365,7 +365,6 @@ This creates `.specleft/specs/example-feature.md` with a sample structure — us
- Add priorities to scenarios (`priority: critical`, `high`, `medium`, `low`)
- Use `specleft next` to find the next scenario to implement
-- Set up CI enforcement with `specleft enforce`
- See [AI_AGENTS.md](AI_AGENTS.md) for AI coding agent workflows
---
diff --git a/LICENSE b/LICENSE
index 992c5eb..e672cfd 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,9 +1,17 @@
-SpecLeft Dual Licensing Notice
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
-SpecLeft is dual-licensed.
+Copyright (c) 2026 SpecLeft Contributors
-Open Source (Apache 2.0):
- See LICENSE-OPEN
+Licensed under the Apache License, Version 2.0 (the “License”);
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
-Commercial License:
- See LICENSE-COMMERCIAL
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an “AS IS” BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/LICENSE-COMMERCIAL b/LICENSE-COMMERCIAL
deleted file mode 100644
index ea51a97..0000000
--- a/LICENSE-COMMERCIAL
+++ /dev/null
@@ -1,22 +0,0 @@
-SpecLeft Commercial License
-Version 0.2.0+
-
-NOTICE: This file describes the commercial license governing
-the use of commercial components of SpecLeft, including:
-
-- The licensing logic in `src/specleft/license`
-- Enforcement rules in `src/specleft/enforcement`
-- Signing & validation in `src/specleft/specleft_signing`
-- Any premium policy modules bundled or released separately
-
-By using any of these commercial components (including
-the `specleft enforce` command), you agree to these terms.
-
-Commercial use (including deployment, SaaS, internal use
-by enterprises) requires a valid commercial license key
-issued by SpecLeft.
-
-Licenses are obtained via:
-https://specleft.dev/enforce
-
-All rights not expressly granted are reserved.
diff --git a/LICENSE-OPEN b/LICENSE-OPEN
deleted file mode 100644
index e672cfd..0000000
--- a/LICENSE-OPEN
+++ /dev/null
@@ -1,17 +0,0 @@
-Apache License
-Version 2.0, January 2004
-http://www.apache.org/licenses/
-
-Copyright (c) 2026 SpecLeft Contributors
-
-Licensed under the Apache License, Version 2.0 (the “License”);
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an “AS IS” BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
diff --git a/NOTICE.md b/NOTICE.md
deleted file mode 100644
index b45f579..0000000
--- a/NOTICE.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# NOTICE
-
-SpecLeft is dual-licensed.
-
-## Open License (Apache 2.0)
-Applies to the open-core code in `src/specleft` excluding the commercial
-components listed below.
-
-## Commercial License
-Applies to:
-- `src/specleft/license`
-- `src/specleft/enforcement`
-- `src/specleft/specleft_signing`
-
-See LICENSE-OPEN and LICENSE-COMMERCIAL for full terms.
diff --git a/README.md b/README.md
index 4363c0e..2aaf7a7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@

-# SpecLeft: Planning-First Workflow for pytest
+# SpecLeft: Spec Driven Workflow for Agents

[](https://registry.modelcontextprotocol.io/servers/io.github.specleft/specleft)
@@ -13,7 +13,7 @@ SpecLeft keeps feature intent and test coverage aligned by turning plans into ve
- Designed to be safe for AI agents and CI: no writes without confirmation, JSON output available
- There is no phone home or telemetry mechanism. SpecLeft runs 100% locally and stores data in your local disk.
-SpecLeft works with pytest. It does not replace your test runner or reinterpret existing tests.
+SpecLeft currently works with **Python** and **pytest**. It does not replace your test runner or reinterpret existing tests.
Website: [specleft.dev](https://specleft.dev)
@@ -88,8 +88,13 @@ That flow converts `prd.md` into `.specleft/specs/*.md`, validates the result, p
## What It Is (and Is Not)
-- It is a pytest plugin plus a CLI for planning, spec validation, intuitive TDD workflows, and traceability.
-- It is not a BDD framework, a separate test runner, or a SaaS test management product.
+### It is
+- A test plugin and a CLI for planning, spec validation, intuitive TDD workflows, and traceability.
+
+### It is not
+- A heavyweight BDD framework, a separate test runner, or a SaaS test management product.
+- A static code linting/analysis framework
+- A security analysis tool
## Why Not Conventional BDD
@@ -97,7 +102,9 @@ SpecLeft treats specs as intent (not executable text) and keeps execution in pla
## AI Agents
-If you are integrating SpecLeft into an agent loop, start here:
+If you are integrating SpecLeft into an agent loop, it's recommended to install the MCP server (see in section below).
+
+Otherwise begin with:
```bash
specleft doctor --format json
@@ -127,12 +134,6 @@ For MCP end-to-end smoke testing and CI workflow details, see [docs/mcp-testing.
-## CI Enforcement Early Access
-
-Want to enforce feature coverage and policy checks in CI with `specleft enforce`? Join Early Access to get setup guidance and rollout support.
-
-Learn more: [specleft.dev/enforce](https://specleft.dev/enforce)
-
## Docs
- Getting started: [GET_STARTED.md](https://github.com/SpecLeft/specleft/blob/main/GET_STARTED.md)
@@ -143,13 +144,4 @@ Learn more: [specleft.dev/enforce](https://specleft.dev/enforce)
## License
-SpecLeft is **dual-licensed**:
-
-- **Open Core (Apache 2.0)** for the core engine and non-commercial modules
-- **Commercial License** for enforcement, signing, and license logic
-
-Open-source terms are in [LICENSE-OPEN](https://github.com/SpecLeft/specleft/blob/main/LICENSE-OPEN).
-Commercial terms are in [LICENSE-COMMERCIAL](https://github.com/SpecLeft/specleft/blob/main/LICENSE-COMMERCIAL).
-
-Commercial features (e.g., `specleft enforce`) require a valid license policy file.
-See [NOTICE.md](https://github.com/SpecLeft/specleft/blob/main/NOTICE.md) for licensing scope details.
+SpecLeft is licensed under [Apache License 2.0](https://github.com/SpecLeft/specleft/blob/main/LICENSE).
diff --git a/ROADMAP.md b/ROADMAP.md
index ca251a9..6f104ef 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,6 @@
# SpecLeft Roadmap
-## Current (v0.2.0 - Foundation)
+## Current (v0.4.0 - Foundation)
- ✅ Spec-defined test decoration
- ✅ Step-by-step test tracing
- ✅ Skeleton test generation from JSON specs
@@ -12,24 +12,14 @@
- 📖 **Agent Guide** - Provide clarity and guidance for agents to know how to best proceed in scenarios such as: refactoring, cleanup, regression bugs, features and scenarios.
- 🔄 **Async test handling** - Async tests are now supported with @specleft decorator and step context manager
- 🧪 **Test Stubs** - Create empty test containers as an alternate to test skeletons.
-
-## Planned (v0.3.0 - MCP Server)
- 👾 **MCP Server** - A SpecLeft MCP server to smoother integration with AI agents.
+- ✍️ **Agent Skills** - Integrated agent skills for more autonomous planning and test generation.
-## Future (v0.4.0 and beyond)
-- 🌐 **Assisted Discovery** — Discover existing functionality from brownfield projects and turn them in to feature definitions.
-- 📑 **Agent-Owner Contract** - An organisation / project specific ruleset, which is machine verifable.
-- 🎯 **Test Plan Orchestration** — Manage, chain and orchestrate test execution based on dependencies, priorities, and conditional logic. Build dynamic test workflows.
-- 🤖 **AI-Generated Tests** — Let SpecLeft generate test implementations from your feature specs using LLMs. Reduce boilerplate even further.
-- ✍️ **Agent Skills** - Integrated agent skills for more autonomous planning and test generation.
-- 🔗 **CI/CD Integration** — Native integrations with GitHub Actions, GitLab CI, Jenkins, and other CI platforms for seamless reporting and result tracking.
-- 🔌 **3rd Party Plugin for Syncing Features** - Sync feature specifications with external platforms like Jira and Azure DevOps to maintain alignment between requirements and automated tests.
-- 🔔 **Notifications** - Get real-time updates on test execution and results via Slack, Microsoft Teams, Discord, and other messaging platforms.
-- 📊 **Drift Intelligence** — Aggregate and correlate test results across multiple runs, environments, and branches. Track trends, identify flaky tests, and spot patterns drifting behaviour.
-- 📈 **Enhanced Reporting** — Interactive dashboards with drill-down capabilities, failure analysis, and historical trends. Ideal for compliance reporting.
-- 🎚️ **SpecLeft CLI Filters** — First-class test selection via `--specleft-tag/priority/feature/scenario` flags and pytest config defaults.
+## Future (v0.5.0 and beyond)
+- 🎚️ **SpecLeft CLI Filters** — First-class test selection via `--specleft-tag/priority/feature/scenario` flags and pytest config defaults.
+- 🤖 **AI-Generated Tests** — Let SpecLeft generate test implementations from your feature specs using LLMs. SpecLeft provides richer context and understanding for more robust behaviour testing capability. Reduce boilerplate even further.
## Community & Contributions
-Have ideas? Found a use case we should support? Open an issue or start a discussion—we'd love to hear from you!
+Have ideas? Found a use case we should support? Open an issue or start a discussion — it'd be great to hear from you!
diff --git a/docs/SKILL.md b/docs/SKILL.md
index 11da8b5..fd033bf 100644
--- a/docs/SKILL.md
+++ b/docs/SKILL.md
@@ -108,20 +108,6 @@ Regenerates `.specleft/SKILL.md` and `.specleft/SKILL.md.sha256`.
`specleft doctor --verify-skill --format json`
Adds skill integrity status to standard environment diagnostics.
-## Enforcement
-
-### Enforce policy
-`specleft enforce [POLICY_FILE] --format json [--dir PATH] [--tests PATH] [--ignore-feature-id ID]`
-Default policy: `.specleft/policies/policy.yml`.
-Exit codes: 0 = satisfied, 1 = violated, 2 = license issue.
-
-## License
-
-### License status
-`specleft license status [--file PATH]`
-Show license status and validated policy metadata.
-Default: `.specleft/policies/policy.yml`.
-
## Guide
### Show workflow guide
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index 270f655..63eccd1 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -238,64 +238,6 @@ Options:
--format [table|json] Output format (default: table)
```
-## Enforce
-
-### `specleft enforce`
-
-Validate a cryptographic policy file and enforce coverage/priority rules against the repository's test specifications.
-
-```bash
-specleft enforce [POLICY_FILE] [OPTIONS]
-
-Arguments:
- POLICY_FILE Path to policy YAML file (default: .specleft/policies/policy.yml)
-
-Options:
- --dir PATH Path to features directory (default: .specleft/specs/)
- --format [table|json] Output format (default: table)
- --ignore-feature-id TEXT Exclude feature from enforcement (Enforce+ tier only, repeatable)
- --tests PATH Path to tests directory (default: tests/)
-```
-
-#### Exit Codes
-
-| Code | Meaning |
-|------|---------|
-| 0 | Policy satisfied - all checks passed |
-| 1 | Policy violated - missing scenarios or coverage below threshold |
-| 2 | License issue - invalid signature, expired, evaluation ended, or repo mismatch |
-
-#### Policy Types
-
-**Core Policy** - Priority-based enforcement only
-- Validates that scenarios with specified priorities are implemented
-- Does not support `--ignore-feature-id` flag
-
-**Enforce Policy** - Full enforcement with coverage thresholds
-- Includes priority enforcement
-- Adds coverage threshold validation
-- Supports `--ignore-feature-id` flag for excluding features
-- May include evaluation period for trial licenses
-
-#### Examples
-
-```bash
-# Enforce with default policy location
-specleft enforce
-
-# Enforce with specific policy file
-specleft enforce .specleft/policies/policy-core.yml
-
-# Enforce with JSON output
-specleft enforce --format json
-
-# Exclude a feature from enforcement (Enforce tier only)
-specleft enforce --ignore-feature-id legacy-api
-
-# Exclude multiple features
-specleft enforce --ignore-feature-id legacy-api --ignore-feature-id deprecated-feature
-```
-
## Tests
### `specleft test skeleton`
@@ -346,25 +288,6 @@ Options:
--format [table|json] Output format (default: table)
```
-## License
-
-### `specleft license status`
-
-Show license status and the validated policy file.
-
-```bash
-specleft license status [OPTIONS]
-
-Options:
- --file PATH License policy file to check (default: .specleft/policies/policy.yml)
-```
-
-Example:
-
-```bash
-specleft license status --file .specleft/policies/policy.yml
-```
-
## Plan
### `specleft plan`
diff --git a/features/feature-5-policy-enforcement.md b/features/feature-5-policy-enforcement.md
deleted file mode 100644
index 1b563c6..0000000
--- a/features/feature-5-policy-enforcement.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Feature: Feature 5: Policy Enforcement
-priority: critical
-
-## Scenarios
-
-### Scenario: Enforce critical and high priority scenarios
-- Given a signed policy requiring critical and high scenarios to be implemented
-- And one or more such scenarios are unimplemented
-- When `specleft enforce ` is executed
-- Then the command exits with a non-zero status
-- And the failure message explains which intent was violated
-
-### Scenario: Pass enforcement when intent is satisfied
-- Given all critical and high priority scenarios are implemented
-- When enforcement is executed
-- Then the command exits successfully
-
-### Scenario: Reject invalid or unsigned policies
-- Given a policy file has an invalid or missing signature
-- When enforcement is executed
-- Then enforcement fails with a clear error
-- And no intent evaluation is performed
diff --git a/features/feature-6-ci-experience-messaging.md b/features/feature-6-ci-experience-messaging.md
deleted file mode 100644
index af6d2ee..0000000
--- a/features/feature-6-ci-experience-messaging.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Feature: Feature 6: CI Experience & Messaging
-priority: medium
-
-## Scenarios
-
-### Scenario: CI failure explains intent mismatch
-- Given enforcement fails in CI
-- When output is printed
-- Then the message explains:
- - declared intent,
- - implementation state
- - clear remediation options
-- And no marketing or pricing language is included
-
-### Scenario: Documentation and support links on CI failure
-priority: high
-
-- Given enforcement fails in CI with {package} policy violation
-- When output is printed from `specleft enforce {policy}`
-- Then the message includes "Documentation: "
-- And the message includes "Support: "
-- And both links are actionable and relevant
-
-#### Test Data
-| policy | package |
-|------|------|
-| policy-core.yml | Core+ |
-| policy.yml | Enforce |
diff --git a/pyproject.toml b/pyproject.toml
index a87bed2..536b319 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ specleft = ["py.typed", "templates/*.jinja2"]
[project]
name = "specleft"
-version = "0.3.0"
+version = "0.4.0"
description = "A planning-first CLI for AI coding agents to externalize intent before writing code, with optional CI enforcement for Python projects."
readme = "README.md"
requires-python = ">=3.10"
@@ -18,6 +18,7 @@ authors = [
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
+ "License :: OSI Approved :: Apache Software License",
"Intended Audience :: Developers",
"Topic :: Software Development :: Testing",
"Topic :: Software Development :: Build Tools",
@@ -33,7 +34,6 @@ dependencies = [
"python-frontmatter>=1.0.0",
"python-slugify>=8.0.0",
"pyyaml>=6.0.0",
- "cryptography>=41.0.0",
]
keywords=[
"ai",
@@ -168,10 +168,6 @@ disallow_incomplete_defs = false
module = "frontmatter"
ignore_missing_imports = true
-[[tool.mypy.overrides]]
-module = "specleft_signing.*"
-ignore_missing_imports = true
-
[tool.pytest.ini_options]
minversion = "7.0"
addopts = [
diff --git a/server.json b/server.json
index d8d48f1..c984166 100644
--- a/server.json
+++ b/server.json
@@ -3,7 +3,7 @@
"name": "io.github.SpecLeft/specleft",
"title": "SpecLeft",
"description": "Python intent tracing MCP: map specs to pytest tests, monitor implementation progress, offline-only.",
- "version": "0.3.0",
+ "version": "0.4.0",
"websiteUrl": "https://specleft.dev",
"repository": {
"url": "https://github.com/SpecLeft/specleft",
@@ -14,7 +14,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "specleft",
- "version": "0.3.0",
+ "version": "0.4.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
diff --git a/src/specleft/cli/main.py b/src/specleft/cli/main.py
index 16e26c5..6496c1a 100644
--- a/src/specleft/cli/main.py
+++ b/src/specleft/cli/main.py
@@ -11,11 +11,9 @@
contract,
coverage,
doctor,
- enforce,
features,
guide,
init,
- license_group,
mcp,
next_command,
plan,
@@ -50,8 +48,6 @@ def cli() -> None:
cli.add_command(coverage)
cli.add_command(init)
cli.add_command(contract)
-cli.add_command(enforce)
-cli.add_command(license_group)
cli.add_command(skill_group)
cli.add_command(guide)
cli.add_command(mcp)
diff --git a/src/specleft/commands/__init__.py b/src/specleft/commands/__init__.py
index c6ded42..1e29fb9 100644
--- a/src/specleft/commands/__init__.py
+++ b/src/specleft/commands/__init__.py
@@ -8,11 +8,9 @@
from specleft.commands.contract import contract
from specleft.commands.coverage import coverage
from specleft.commands.doctor import doctor
-from specleft.commands.enforce import enforce
from specleft.commands.features import features
from specleft.commands.guide import guide
from specleft.commands.init import init
-from specleft.commands.license import license_group
from specleft.commands.mcp import mcp
from specleft.commands.next import next_command
from specleft.commands.plan import plan
@@ -24,11 +22,9 @@
"contract",
"coverage",
"doctor",
- "enforce",
"features",
"guide",
"init",
- "license_group",
"mcp",
"next_command",
"plan",
diff --git a/src/specleft/commands/coverage.py b/src/specleft/commands/coverage.py
index 7810f79..422bcc1 100644
--- a/src/specleft/commands/coverage.py
+++ b/src/specleft/commands/coverage.py
@@ -209,7 +209,9 @@ def _print_coverage_table(entries: list[ScenarioStatusEntry]) -> None:
data = metrics.by_execution_time.get(execution_time.value, CoverageTally())
click.echo(_summary_row(execution_time.value, data))
click.echo("━" * 58)
- click.echo("To enforce intent coverage in CI, see: https://specleft.dev/enforce")
+ click.echo(
+ "For CI guidance, see: https://github.com/SpecLeft/specleft/tree/main/docs"
+ )
@click.command("coverage")
diff --git a/src/specleft/commands/enforce.py b/src/specleft/commands/enforce.py
deleted file mode 100644
index 6b00713..0000000
--- a/src/specleft/commands/enforce.py
+++ /dev/null
@@ -1,375 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# Copyright (c) 2026 SpecLeft Contributors
-
-"""Enforce command for policy validation and enforcement."""
-
-from __future__ import annotations
-
-import sys
-from datetime import date
-from pathlib import Path
-from typing import Any, cast
-
-import click
-import yaml
-from specleft.commands.input_validation import validate_id_parameter_multiple
-from specleft.commands.output import json_dumps, resolve_output_format
-from specleft.specleft_signing.schema import PolicyType, SignedPolicy
-from specleft.specleft_signing.verify import VerifyFailure, VerifyResult, verify_policy
-
-from specleft.enforcement.engine import evaluate_policy
-from specleft.license.repo_identity import detect_repo_identity
-from specleft.utils.messaging import print_support_footer
-from specleft.utils.specs_dir import resolve_specs_dir
-
-DEFAULT_POLICY_PATH = Path(".specleft/policies/policy.yml")
-POLICY_DIR = Path(".specleft/policies")
-
-
-def resolve_policy_path(preferred: str | None) -> Path:
- if preferred:
- return Path(preferred)
-
- if DEFAULT_POLICY_PATH.exists():
- return DEFAULT_POLICY_PATH
-
- if POLICY_DIR.exists():
- candidates = sorted(POLICY_DIR.glob("*.yml"))
- candidates.extend(sorted(POLICY_DIR.glob("*.yaml")))
- if len(candidates) == 1:
- return candidates[0]
-
- return DEFAULT_POLICY_PATH
-
-
-def load_policy(path: str) -> SignedPolicy | None:
- """Load and validate a policy file.
-
- Args:
- path: Path to policy YAML file
-
- Returns:
- SignedPolicy if valid, None if loading failed
- """
- try:
- content = yaml.safe_load(Path(path).read_text())
- return SignedPolicy.model_validate(content)
- except FileNotFoundError:
- click.echo(f"Error: Policy file not found: {path}", err=True)
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement"
- )
- return None
- except yaml.YAMLError as e:
- click.echo(f"Error: Invalid YAML in policy file: {e}", err=True)
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement"
- )
- return None
- except Exception as e:
- click.echo(f"Error: {e}", err=True)
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement"
- )
- return None
-
-
-def handle_verification_failure(result: VerifyResult) -> None:
- """Display appropriate error message based on failure type.
-
- Args:
- result: The verification result
- """
- click.echo(f"Error: {result.message}", err=True)
-
- if result.failure == VerifyFailure.EVALUATION_EXPIRED:
- click.echo("", err=True)
- click.echo("To continue using SpecLeft enforcement:", err=True)
- click.echo("", err=True)
- click.echo(" Option 1: Purchase Enforce license", err=True)
- click.echo(" https://specleft.dev/enforce", err=True)
- click.echo("", err=True)
- click.echo(" Option 2: Switch to Core Policy", err=True)
- click.echo(
- " Update your CI to use: specleft enforce .specleft/policies/policy-core.yml",
- err=True,
- )
- click.echo("", err=True)
-
- elif result.failure == VerifyFailure.EXPIRED:
- click.echo("", err=True)
- click.secho(
- "Renew your license at: https://specleft.dev/enforce", err=True, bold=True
- )
-
- elif result.failure == VerifyFailure.REPO_MISMATCH:
- click.echo("", err=True)
- click.echo("This policy file is licensed for a different repository.", err=True)
- click.echo(
- "Contact support@specleft.dev if you need to transfer your license.",
- err=True,
- )
- click.echo("", err=True)
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement"
- )
-
-
-def display_policy_status(policy: SignedPolicy) -> None:
- """Display the current policy status.
-
- Args:
- policy: The active policy
- """
- click.echo("")
- if policy.policy_type == PolicyType.ENFORCE:
- if policy.license.evaluation:
- days = (policy.license.evaluation.ends_at - date.today()).days
- if days >= 11:
- click.echo(
- f"ℹ Enforce policy running in evaluation mode ({days} days remaining)"
- )
- elif 2 <= days <= 10:
- click.secho(
- f"⚠ Evaluation ends in {days} days — upgrade or switch to Core",
- fg="yellow",
- )
- click.echo(" Enforce Policy Info: https://specleft.dev/enforce")
- elif days == 1:
- click.secho(
- "⚠ Evaluation expires tomorrow — CI will block",
- fg="yellow",
- )
- click.echo(" Enforce Policy Info: https://specleft.dev/enforce")
- else:
- click.secho("Enforce Policy active", fg="cyan", bold=True)
- else:
- if policy.license.derived_from:
- click.echo("Core Policy (downgraded from Enforce)")
- else:
- click.secho("Core Policy active", fg="cyan", bold=True)
- click.echo()
-
-
-def display_violations(violations: dict[str, Any]) -> None:
- """Display policy violations in table format.
-
- Args:
- violations: Dictionary with violation details
- """
- if violations["ignored_features"]:
- click.echo(f"Ignored features: {', '.join(violations['ignored_features'])}")
- click.echo()
-
- if violations["priority_violations"]:
- click.secho("Priority violations:", fg="red", bold=True)
- click.echo()
- for pv in violations["priority_violations"]:
- click.echo(
- f" \u2717 {pv['feature_id']}/{pv['scenario_id']} "
- f"({pv['priority']}) - NOT IMPLEMENTED"
- )
- click.echo()
-
- if violations.get("coverage_violations"):
- click.secho("Coverage violations:", fg="red", bold=True)
- click.echo()
- for cv in violations["coverage_violations"]:
- click.secho(
- f" \u2717 Behaviour test coverage below {cv['threshold']}%", bold=True
- )
- click.echo()
- click.echo(f" Current Behaviour Coverage {cv['actual']}%")
- click.echo()
- if not violations["failed"]:
- click.secho("\u2713 All checks passed", fg="green")
-
- click.echo()
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement",
- err=False,
- )
-
-
-def _augment_violations_with_fix_commands(
- violations: dict[str, Any],
-) -> dict[str, Any]:
- payload = dict(violations)
- priority_violations: list[dict[str, Any]] = []
- for violation in violations.get("priority_violations", []):
- entry = dict(violation)
- feature_id = str(entry.get("feature_id", ""))
- priority = str(entry.get("priority", "")).lower()
- if feature_id and priority:
- entry["fix_command"] = (
- f"specleft next --feature {feature_id} --priority {priority} --limit 1"
- )
- priority_violations.append(entry)
- payload["priority_violations"] = priority_violations
-
- coverage_violations: list[dict[str, Any]] = []
- for violation in violations.get("coverage_violations", []):
- entry = dict(violation)
- threshold = entry.get("threshold")
- if threshold is not None:
- entry["fix_command"] = f"specleft coverage --threshold {threshold}"
- coverage_violations.append(entry)
- payload["coverage_violations"] = coverage_violations
- return payload
-
-
-@click.command("enforce")
-@click.argument(
- "policy_file",
- type=click.Path(exists=False),
- required=False,
- default=None,
-)
-@click.option(
- "--format",
- "fmt",
- type=click.Choice(["table", "json"], case_sensitive=False),
- default=None,
- help="Output format. Defaults to table in a terminal and json otherwise.",
-)
-@click.option(
- "--ignore-feature-id",
- "ignored",
- multiple=True,
- callback=validate_id_parameter_multiple,
- help="Exclude feature from evaluation (Enforce only, repeatable).",
-)
-@click.option(
- "--dir",
- "features_dir",
- default=None,
- help="Path to features directory.",
-)
-@click.option(
- "--tests",
- "test_dir",
- default="tests",
- help="Path to tests directory.",
-)
-@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.")
-def enforce(
- policy_file: str | None,
- fmt: str | None,
- ignored: tuple[str, ...],
- features_dir: str | None,
- test_dir: str,
- pretty: bool,
-) -> None:
- """Enforce policy against the source code.
-
- Validates the policy signature, checks license status, and
- evaluates policy rules against the current repository state.
-
- Exit codes:
- 0 - Policy satisfied
- 1 - Policy violated (scenarios/coverage)
- 2 - License issue (signature, expired, repo mismatch)
- """
- from specleft.validator import load_specs_directory
-
- selected_format = resolve_output_format(fmt)
-
- # Load policy
- policy_path = resolve_policy_path(policy_file)
- policy = load_policy(str(policy_path))
- if not policy:
- sys.exit(2)
-
- resolved_features_dir = resolve_specs_dir(features_dir)
-
- try:
- features = load_specs_directory(resolved_features_dir).features
- if not features:
- sys.exit(2)
- except (FileNotFoundError, ValueError):
- click.secho(
- "Warning: No feature units found in directory: "
- f"{resolved_features_dir}/. Nothing to enforce.",
- fg="yellow",
- err=True,
- )
- click.echo()
- click.echo("Have you defined your features files correctly?")
- click.echo()
- click.echo("You can list detected features with:")
- click.echo(f" > specleft features list --dir {resolved_features_dir}")
- click.echo("")
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement",
- err=False,
- )
- sys.exit(2)
-
- # Reject --ignore-feature-id for Core
- if ignored and policy.policy_type == PolicyType.CORE:
- click.echo(
- "Error: --ignore-feature-id requires Enforce policy",
- err=True,
- )
- print_support_footer(
- documentation_url="https://specleft.dev/docs/guides/enforcement"
- )
- sys.exit(1)
-
- # Verify signature, expiry, evaluation
- result = verify_policy(policy)
-
- # Check repository binding
- if result.valid:
- # Keep command attribute in sync for tests patching either location
- cast(Any, enforce).detect_repo_identity = detect_repo_identity
- repo = detect_repo_identity()
- if repo is None:
- result = VerifyResult(
- valid=False,
- failure=VerifyFailure.REPO_MISMATCH,
- message="Cannot detect repository. Ensure git remote 'origin' exists.",
- )
- elif not repo.matches(policy.license.licensed_to):
- result = VerifyResult(
- valid=False,
- failure=VerifyFailure.REPO_MISMATCH,
- message=f"License for '{policy.license.licensed_to}', "
- f"current repo is '{repo.canonical}'",
- )
-
- if not result.valid:
- handle_verification_failure(result)
- sys.exit(2)
-
- # Show policy status (table format only)
- if selected_format == "table":
- display_policy_status(policy)
- click.echo("Checking scenarios...")
- if policy.policy_type == PolicyType.ENFORCE:
- click.echo("Checking coverage...")
- click.echo("")
-
- # Run enforcement
- violations = evaluate_policy(
- policy=policy,
- ignored_features=list(ignored),
- features_dir=str(resolved_features_dir),
- tests_dir=test_dir,
- )
-
- if selected_format == "json":
- click.echo(
- json_dumps(
- _augment_violations_with_fix_commands(violations),
- pretty=pretty,
- )
- )
- else:
- display_violations(violations)
-
- sys.exit(0 if not violations["failed"] else 1)
-
-
-# Expose detect_repo_identity for tests patching specleft.commands.enforce
-cast(Any, enforce).detect_repo_identity = detect_repo_identity
diff --git a/src/specleft/commands/features.py b/src/specleft/commands/features.py
index 04ed88c..e670049 100644
--- a/src/specleft/commands/features.py
+++ b/src/specleft/commands/features.py
@@ -751,7 +751,9 @@ def features_stats(
else:
click.echo(" Cannot calculate coverage without specs.")
click.echo("")
- click.echo("To enforce intent coverage in CI, see: https://specleft.dev/enforce")
+ click.echo(
+ "For CI guidance, see: https://github.com/SpecLeft/specleft/tree/main/docs"
+ )
@features.command("add")
diff --git a/src/specleft/commands/init.py b/src/specleft/commands/init.py
index db54ce4..54bf80d 100644
--- a/src/specleft/commands/init.py
+++ b/src/specleft/commands/init.py
@@ -150,15 +150,7 @@ def _print_init_dry_run(directories: list[Path], files: list[tuple[Path, str]])
def _print_license_notice() -> None:
click.echo("Welcome to SpecLeft (v0.2.x)")
click.echo("")
- click.echo("Core technology is licensed under Apache 2.0.")
- click.echo("Commercial features (e.g., `specleft enforce`) require a valid")
- click.echo("commercial license policy.yml.")
- click.echo("")
- click.echo("To register a license:")
- click.echo(" store the policy file in .specleft/policies/")
- click.echo("")
- click.echo("For details:")
- click.echo(" https://specleft.dev/enforce")
+ click.echo("SpecLeft is licensed under Apache 2.0.")
def _apply_init_plan(
diff --git a/src/specleft/commands/license.py b/src/specleft/commands/license.py
deleted file mode 100644
index b25f696..0000000
--- a/src/specleft/commands/license.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# Copyright (c) 2026 SpecLeft Contributors
-
-"""License management commands."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import click
-from click.core import ParameterSource
-
-from specleft.license.status import DEFAULT_LICENSE_PATH, resolve_license
-
-
-@click.group("license")
-def license_group() -> None:
- """License management commands."""
-
-
-@license_group.command("status")
-@click.option(
- "--file",
- "file_path",
- type=click.Path(dir_okay=False, path_type=Path),
- default=DEFAULT_LICENSE_PATH,
- show_default=True,
- help="License policy file to check.",
-)
-@click.pass_context
-def license_status(ctx: click.Context, file_path: Path) -> None:
- """Show license status information."""
- source = ctx.get_parameter_source("file_path")
- preferred: Path | None = None
- if source != ParameterSource.DEFAULT:
- preferred = file_path
- else:
- preferred = None if file_path == DEFAULT_LICENSE_PATH else file_path
-
- validation = resolve_license(preferred)
-
- click.echo("SpecLeft License Status")
- click.echo("-----------------------")
- click.echo("Core License: Apache 2.0")
-
- if validation.valid and validation.policy:
- policy = validation.policy
- commercial_status = "Active"
- license_type = policy.policy_type.value.capitalize()
- license_id = policy.license.license_id
- if policy.license.evaluation:
- valid_until = policy.license.evaluation.ends_at
- else:
- valid_until = policy.license.expires_at
- licensed_to = policy.license.licensed_to
- else:
- commercial_status = "Inactive"
- license_type = "Unknown"
- license_id = "N/A"
- valid_until = None
- licensed_to = "N/A"
-
- click.echo(f"Commercial License: {commercial_status}")
- click.echo(f"License Type: {license_type}")
- click.echo(f"License ID: {license_id}")
- if valid_until:
- click.echo(f"Valid Until: {valid_until}")
- else:
- click.echo("Valid Until: N/A")
- click.echo(f"Licensed To: {licensed_to}")
-
- validated_path = validation.path
- if validated_path is None and preferred is not None:
- validated_path = preferred
-
- if validated_path is None:
- click.echo("Validated File: (none)")
- else:
- click.echo(f"Validated File: {validated_path}")
diff --git a/src/specleft/commands/plan.py b/src/specleft/commands/plan.py
index 1876fb0..dd86dfe 100644
--- a/src/specleft/commands/plan.py
+++ b/src/specleft/commands/plan.py
@@ -15,7 +15,6 @@
from slugify import slugify
from specleft.commands.output import json_dumps, resolve_output_format
-from specleft.license.status import resolve_license
from specleft.utils.specs_dir import resolve_specs_dir
from specleft.templates.prd_template import (
PRDTemplate,
@@ -721,12 +720,3 @@ def plan(
_print_warning(warning)
_print_plan_summary(feature_count=feature_count, dry_run=dry_run)
_print_plan_results(created=created, skipped=skipped, dry_run=dry_run)
-
- license_validation = resolve_license()
- if not license_validation.valid:
- click.echo("")
- click.echo("Notice: Enforcement capabilities require a commercial license.")
- click.echo("")
- click.echo("A valid license key is not registered.")
- click.echo("Obtain a license:")
- click.echo(" https://specleft.dev/enforce")
diff --git a/src/specleft/commands/status.py b/src/specleft/commands/status.py
index 37f3ed9..8b604cc 100644
--- a/src/specleft/commands/status.py
+++ b/src/specleft/commands/status.py
@@ -487,5 +487,5 @@ def status(
show_only = "implemented"
print_status_table(entries, show_only=show_only)
click.echo(
- "To enforce intent coverage in CI, see: https://specleft.dev/enforce"
+ "For CI guidance, see: https://github.com/SpecLeft/specleft/tree/main/docs"
)
diff --git a/src/specleft/enforcement/__init__.py b/src/specleft/enforcement/__init__.py
deleted file mode 100644
index b8bdfc6..0000000
--- a/src/specleft/enforcement/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-"""Enforcement module for policy evaluation."""
-
-from __future__ import annotations
-
-from specleft.enforcement.engine import evaluate_policy
-
-__all__ = [
- "evaluate_policy",
-]
diff --git a/src/specleft/enforcement/engine.py b/src/specleft/enforcement/engine.py
deleted file mode 100644
index 4897f68..0000000
--- a/src/specleft/enforcement/engine.py
+++ /dev/null
@@ -1,149 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-"""Policy enforcement engine.
-
-Evaluates policy rules against the current repository state,
-checking priority requirements and coverage thresholds.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import TYPE_CHECKING, Any
-
-if TYPE_CHECKING:
- from specleft.specleft_signing.schema import SignedPolicy
-
-
-@dataclass
-class PriorityViolation:
- """A priority rule violation."""
-
- feature_id: str
- scenario_id: str
- priority: str
-
-
-@dataclass
-class CoverageViolation:
- """A coverage threshold violation."""
-
- threshold: int
- actual: float
-
-
-@dataclass
-class EnforcementResult:
- """Result of policy enforcement evaluation."""
-
- failed: bool = False
- ignored_features: list[str] = field(default_factory=list)
- priority_violations: list[PriorityViolation] = field(default_factory=list)
- coverage_violations: list[CoverageViolation] = field(default_factory=list)
-
- def to_dict(self) -> dict[str, Any]:
- """Convert to dictionary for JSON output."""
- return {
- "failed": self.failed,
- "ignored_features": self.ignored_features,
- "priority_violations": [
- {
- "feature_id": v.feature_id,
- "scenario_id": v.scenario_id,
- "priority": v.priority.capitalize(),
- }
- for v in self.priority_violations
- ],
- "coverage_violations": [
- {"threshold": v.threshold, "actual": v.actual}
- for v in self.coverage_violations
- ],
- }
-
-
-def evaluate_policy(
- policy: SignedPolicy,
- ignored_features: list[str] | None = None,
- features_dir: str = "features",
- tests_dir: str = "tests",
-) -> dict[str, Any]:
- """Evaluate policy rules against repository state.
-
- Checks:
- 1. Priority rules: scenarios with must_be_implemented priorities
- 2. Coverage rules: overall implementation percentage (Enforce only)
-
- Args:
- policy: The signed policy to evaluate
- ignored_features: Feature IDs to exclude from evaluation (Enforce only)
- features_dir: Path to features directory
- tests_dir: Path to tests directory
-
- Returns:
- Dictionary with enforcement results
- """
- from specleft.specleft_signing.schema import PolicyType
-
- from specleft.commands.status import build_status_entries
- from specleft.commands.types import ScenarioStatusEntry
- from specleft.validator import load_specs_directory
-
- ignored = set(ignored_features or [])
- result = EnforcementResult(ignored_features=list(ignored))
-
- # Load specs and build status entries
- try:
- config = load_specs_directory(features_dir)
- except (FileNotFoundError, ValueError):
- # No specs found - nothing to enforce
- return result.to_dict()
-
- entries = build_status_entries(config, Path(tests_dir))
- # Filter out ignored features
- filtered_entries: list[ScenarioStatusEntry] = []
- for entry in entries:
- if entry.feature.feature_id not in ignored:
- filtered_entries.append(entry)
-
- # Check priority rules
- for priority_key, rule in policy.rules.priorities.items():
- if rule.must_be_implemented:
- for entry in filtered_entries:
- scenario_priority = (
- entry.scenario.priority.value if entry.scenario.priority else None
- )
- if scenario_priority == priority_key and entry.status != "implemented":
- result.priority_violations.append(
- PriorityViolation(
- feature_id=entry.feature.feature_id,
- scenario_id=entry.scenario.scenario_id,
- priority=priority_key,
- )
- )
-
- # Check coverage rules (Enforce only)
- if policy.policy_type == PolicyType.ENFORCE and policy.rules.coverage:
- coverage_rules = policy.rules.coverage
- total = len(filtered_entries)
- implemented = sum(1 for e in filtered_entries if e.status == "implemented")
-
- actual_percent = (implemented / total) * 100 if total > 0 else 100.0
-
- if (
- coverage_rules.fail_below
- and actual_percent < coverage_rules.threshold_percent
- ):
- result.coverage_violations.append(
- CoverageViolation(
- threshold=coverage_rules.threshold_percent,
- actual=round(actual_percent, 1),
- )
- )
-
- # Set failed flag if any violations
- result.failed = bool(result.priority_violations or result.coverage_violations)
-
- return result.to_dict()
diff --git a/src/specleft/license/__init__.py b/src/specleft/license/__init__.py
deleted file mode 100644
index 7871090..0000000
--- a/src/specleft/license/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
diff --git a/src/specleft/license/repo_identity.py b/src/specleft/license/repo_identity.py
deleted file mode 100644
index 003c604..0000000
--- a/src/specleft/license/repo_identity.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-"""Repository identity detection from git remotes.
-
-Parses git remote URLs to extract owner/repo information
-for license binding verification.
-"""
-
-from __future__ import annotations
-
-import re
-import subprocess
-from dataclasses import dataclass
-
-
-@dataclass
-class RepoIdentity:
- """Repository identity extracted from git remote."""
-
- owner: str
- name: str
-
- @property
- def canonical(self) -> str:
- """Get canonical owner/repo string."""
- return f"{self.owner}/{self.name}"
-
- def matches(self, pattern: str) -> bool:
- """Check if this repo matches a license pattern.
-
- Supports exact match (owner/repo) or wildcard (owner/*).
-
- Args:
- pattern: License pattern like "owner/repo" or "owner/*"
-
- Returns:
- True if pattern matches this repository
- """
- if pattern.endswith("/*"):
- # Wildcard: match owner only
- return self.owner.lower() == pattern[:-2].lower()
- # Exact match
- return self.canonical.lower() == pattern.lower()
-
-
-def detect_repo_identity() -> RepoIdentity | None:
- """Detect repository identity from git remote 'origin'.
-
- Returns:
- RepoIdentity if detection succeeded, None otherwise
- """
- try:
- result = subprocess.run(
- ["git", "remote", "get-url", "origin"],
- capture_output=True,
- text=True,
- timeout=5,
- )
- if result.returncode != 0:
- return None
- return parse_remote_url(result.stdout.strip())
- except (subprocess.TimeoutExpired, FileNotFoundError):
- return None
-
-
-def parse_remote_url(url: str) -> RepoIdentity | None:
- """Parse a git remote URL to extract owner and repo name.
-
- Supports both SSH and HTTPS formats:
- - git@github.com:owner/repo.git
- - git@gitlab.com:owner/repo.git
- - https://github.com/owner/repo
- - https://github.com/owner/repo.git
-
- Args:
- url: Git remote URL
-
- Returns:
- RepoIdentity if parsing succeeded, None otherwise
- """
- patterns = [
- # SSH format: git@host:owner/repo.git
- r"git@[^:]+:(?P[^/]+)/(?P[^/]+?)(?:\.git)?$",
- # HTTPS format: https://host/owner/repo.git
- r"https?://[^/]+/(?P[^/]+)/(?P[^/]+?)(?:\.git)?$",
- ]
-
- for pattern in patterns:
- match = re.match(pattern, url)
- if match:
- return RepoIdentity(
- owner=match.group("owner"),
- name=match.group("name"),
- )
- return None
diff --git a/src/specleft/license/status.py b/src/specleft/license/status.py
deleted file mode 100644
index a65209b..0000000
--- a/src/specleft/license/status.py
+++ /dev/null
@@ -1,117 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-"""License status and validation helpers."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from pathlib import Path
-
-import click
-import yaml
-
-from specleft.license.repo_identity import detect_repo_identity
-from specleft.specleft_signing.schema import SignedPolicy
-from specleft.specleft_signing.verify import VerifyFailure, VerifyResult, verify_policy
-
-DEFAULT_LICENSE_PATH = Path(".specleft/policies/policy.yml")
-LICENSE_DIR = Path(".specleft/policies")
-
-
-@dataclass
-class LicenseValidation:
- """Result of license file validation."""
-
- valid: bool
- policy: SignedPolicy | None
- verify_result: VerifyResult | None
- path: Path | None
- message: str | None = None
-
-
-def _load_policy(path: Path) -> SignedPolicy | None:
- try:
- content = yaml.safe_load(path.read_text())
- return SignedPolicy.model_validate(content)
- except FileNotFoundError:
- return None
- except yaml.YAMLError as exc:
- click.echo(f"Error: Invalid YAML in policy file: {exc}", err=True)
- return None
- except Exception as exc: # noqa: BLE001 - display any model error
- click.echo(f"Error: {exc}", err=True)
- return None
-
-
-def _verify_repo_binding(policy: SignedPolicy) -> VerifyResult:
- repo = detect_repo_identity()
- if repo is None:
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.REPO_MISMATCH,
- message="Cannot detect repository. Ensure git remote 'origin' exists.",
- )
- if not repo.matches(policy.license.licensed_to):
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.REPO_MISMATCH,
- message=(
- f"License for '{policy.license.licensed_to}', "
- f"current repo is '{repo.canonical}'"
- ),
- )
- return VerifyResult(valid=True)
-
-
-def _candidate_paths(preferred: Path | None) -> list[Path]:
- if preferred is not None:
- return [preferred]
-
- if DEFAULT_LICENSE_PATH.exists():
- return [DEFAULT_LICENSE_PATH]
-
- if not LICENSE_DIR.exists():
- return []
-
- candidates = sorted(LICENSE_DIR.glob("*.yml"))
- candidates.extend(sorted(LICENSE_DIR.glob("*.yaml")))
- return candidates
-
-
-def resolve_license(preferred: Path | None = None) -> LicenseValidation:
- """Resolve and validate the most appropriate license file."""
- candidates = _candidate_paths(preferred)
- last_result: VerifyResult | None = None
- last_policy: SignedPolicy | None = None
- last_path: Path | None = None
-
- for candidate in candidates:
- if not candidate.exists():
- continue
- policy = _load_policy(candidate)
- last_policy = policy
- last_path = candidate
- if policy is None:
- continue
- result = verify_policy(policy)
- if result.valid:
- repo_result = _verify_repo_binding(policy)
- if repo_result.valid:
- return LicenseValidation(
- valid=True,
- policy=policy,
- verify_result=repo_result,
- path=candidate,
- )
- result = repo_result
- last_result = result
-
- return LicenseValidation(
- valid=False,
- policy=last_policy,
- verify_result=last_result,
- path=last_path,
- message=last_result.message if last_result else None,
- )
diff --git a/src/specleft/pytest_plugin.py b/src/specleft/pytest_plugin.py
index d0162ca..2537a20 100644
--- a/src/specleft/pytest_plugin.py
+++ b/src/specleft/pytest_plugin.py
@@ -459,7 +459,5 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
print("\nView Report with 'specleft test report --open-browser'")
print()
print("SpecLeft currently runs in report-only mode.")
- print("")
- print("To enforce declared behaviour in CI, see:\nhttps://specleft.dev/enforce")
print(f"{line}\n")
diff --git a/src/specleft/specleft_signing/__init__.py b/src/specleft/specleft_signing/__init__.py
deleted file mode 100644
index 2170c3b..0000000
--- a/src/specleft/specleft_signing/__init__.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/__init__.py
-"""SpecLeft Signing - Cryptographic signing and verification for policy files."""
-
-from .canonical import canonical_payload
-from .exceptions import (
- InvalidPolicyError,
- InvalidSignatureError,
- SigningKeyError,
- SpecleftSigningError,
- UnknownKeyIdError,
-)
-from .keys import (
- generate_keypair,
- load_private_key_from_base64,
- load_private_key_from_env,
- load_private_key_from_file,
- load_public_key_from_base64,
- private_key_to_base64,
- public_key_to_base64,
-)
-from .schema import (
- CoverageRules,
- EvaluationPeriod,
- LicenseInfo,
- PolicyRules,
- PolicyType,
- PriorityRule,
- SignatureBlock,
- SignedPolicy,
- UnsignedPolicy,
-)
-from .sign import sign_payload_raw, sign_policy
-from .verify import (
- TRUSTED_PUBLIC_KEYS,
- VerifyFailure,
- VerifyResult,
- verify_policy,
- verify_signature,
- verify_signature_raw,
-)
-
-__version__ = "0.1.0"
-
-__all__ = [
- # Schema
- "PolicyType",
- "EvaluationPeriod",
- "CoverageRules",
- "PriorityRule",
- "PolicyRules",
- "LicenseInfo",
- "SignatureBlock",
- "SignedPolicy",
- "UnsignedPolicy",
- # Canonical
- "canonical_payload",
- # Sign
- "sign_policy",
- "sign_payload_raw",
- # Verify
- "verify_policy",
- "verify_signature",
- "verify_signature_raw",
- "VerifyResult",
- "VerifyFailure",
- "TRUSTED_PUBLIC_KEYS",
- # Keys
- "generate_keypair",
- "private_key_to_base64",
- "public_key_to_base64",
- "load_private_key_from_base64",
- "load_public_key_from_base64",
- "load_private_key_from_file",
- "load_private_key_from_env",
- # Exceptions
- "SpecleftSigningError",
- "InvalidSignatureError",
- "InvalidPolicyError",
- "SigningKeyError",
- "UnknownKeyIdError",
- # Version
- "__version__",
-]
diff --git a/src/specleft/specleft_signing/canonical.py b/src/specleft/specleft_signing/canonical.py
deleted file mode 100644
index 0edd6ad..0000000
--- a/src/specleft/specleft_signing/canonical.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/canonical.py
-"""Deterministic JSON payload generation for signing and verification.
-
-CRITICAL: This module must produce byte-identical output in all contexts.
-Any change here will break signature verification for existing policies.
-"""
-
-import json
-from datetime import date
-from typing import Any
-
-
-def _default_serializer(obj: Any) -> str:
- """JSON serializer for non-standard types."""
- if isinstance(obj, date):
- return obj.isoformat()
- raise TypeError(f"Cannot serialize {type(obj)}")
-
-
-def canonical_payload(
- policy_type: str, license_data: dict[str, Any], rules: dict[str, Any]
-) -> bytes:
- """
- Generate canonical JSON bytes for signing/verification.
-
- Args:
- policy_type: "core" or "enforce"
- license_data: License info dict (from LicenseInfo.model_dump())
- rules: Rules dict (from PolicyRules.model_dump())
-
- Returns:
- Deterministic JSON bytes suitable for signing.
-
- IMPORTANT:
- - Keys are sorted alphabetically at all levels
- - No whitespace (separators=(",", ":"))
- - Dates serialized as ISO format strings
- - Must match EXACTLY between signing and verification
- """
- # Build license block with sorted keys
- license_block: dict[str, Any] = {
- "expires_at": license_data["expires_at"],
- "issued_at": license_data["issued_at"],
- "license_id": license_data["license_id"],
- "licensed_to": license_data["licensed_to"],
- }
-
- # Add evaluation if present (Enforce only)
- if license_data.get("evaluation"):
- eval_data = license_data["evaluation"]
- license_block["evaluation"] = {
- "ends_at": eval_data["ends_at"],
- "starts_at": eval_data["starts_at"],
- }
-
- # Add derived_from if present (downgraded Core only)
- if license_data.get("derived_from"):
- license_block["derived_from"] = license_data["derived_from"]
-
- # Build rules block with sorted keys
- rules_block: dict[str, Any] = {"priorities": {}}
-
- for priority in sorted(rules.get("priorities", {}).keys()):
- rules_block["priorities"][priority] = {
- "must_be_implemented": rules["priorities"][priority]["must_be_implemented"]
- }
-
- # Add coverage if present (Enforce only)
- if policy_type == "enforce" and rules.get("coverage"):
- coverage = rules["coverage"]
- rules_block["coverage"] = {
- "fail_below": coverage["fail_below"],
- "threshold_percent": coverage["threshold_percent"],
- }
-
- # Build final payload
- payload = {
- "license": license_block,
- "policy_type": policy_type,
- "rules": rules_block,
- }
-
- return json.dumps(
- payload,
- sort_keys=True,
- separators=(",", ":"),
- default=_default_serializer,
- ).encode("utf-8")
diff --git a/src/specleft/specleft_signing/exceptions.py b/src/specleft/specleft_signing/exceptions.py
deleted file mode 100644
index 4fe7d07..0000000
--- a/src/specleft/specleft_signing/exceptions.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/exceptions.py
-"""Custom exceptions for specleft-signing."""
-
-
-class SpecleftSigningError(Exception):
- """Base exception for all signing errors."""
-
- pass
-
-
-class InvalidSignatureError(SpecleftSigningError):
- """Raised when signature verification fails."""
-
- pass
-
-
-class InvalidPolicyError(SpecleftSigningError):
- """Raised when policy structure is invalid."""
-
- pass
-
-
-class SigningKeyError(SpecleftSigningError):
- """Raised when key loading or parsing fails."""
-
- pass
-
-
-class UnknownKeyIdError(SpecleftSigningError):
- """Raised when key_id is not in trusted keys."""
-
- pass
diff --git a/src/specleft/specleft_signing/keys.py b/src/specleft/specleft_signing/keys.py
deleted file mode 100644
index 20da518..0000000
--- a/src/specleft/specleft_signing/keys.py
+++ /dev/null
@@ -1,144 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/keys.py
-"""Key generation, loading, and management utilities."""
-
-import base64
-from pathlib import Path
-
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric.ed25519 import (
- Ed25519PrivateKey,
- Ed25519PublicKey,
-)
-
-from .exceptions import SigningKeyError
-
-
-def generate_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
- """
- Generate a new Ed25519 keypair.
-
- Returns:
- Tuple of (private_key, public_key)
- """
- private_key = Ed25519PrivateKey.generate()
- public_key = private_key.public_key()
- return private_key, public_key
-
-
-def private_key_to_base64(private_key: Ed25519PrivateKey) -> str:
- """
- Serialize private key to base64 string.
-
- Args:
- private_key: Ed25519 private key
-
- Returns:
- Base64-encoded raw private key bytes (32 bytes)
- """
- raw_bytes = private_key.private_bytes(
- encoding=serialization.Encoding.Raw,
- format=serialization.PrivateFormat.Raw,
- encryption_algorithm=serialization.NoEncryption(),
- )
- return base64.b64encode(raw_bytes).decode("ascii")
-
-
-def public_key_to_base64(public_key: Ed25519PublicKey) -> str:
- """
- Serialize public key to base64 string.
-
- Args:
- public_key: Ed25519 public key
-
- Returns:
- Base64-encoded raw public key bytes (32 bytes)
- """
- raw_bytes = public_key.public_bytes(
- encoding=serialization.Encoding.Raw,
- format=serialization.PublicFormat.Raw,
- )
- return base64.b64encode(raw_bytes).decode("ascii")
-
-
-def load_private_key_from_base64(key_b64: str) -> Ed25519PrivateKey:
- """
- Load private key from base64 string.
-
- Args:
- key_b64: Base64-encoded raw private key bytes
-
- Returns:
- Ed25519 private key
-
- Raises:
- SigningKeyError: If key cannot be loaded
- """
- try:
- raw_bytes = base64.b64decode(key_b64)
- return Ed25519PrivateKey.from_private_bytes(raw_bytes)
- except Exception as e:
- raise SigningKeyError(f"Failed to load private key: {e}") from e
-
-
-def load_public_key_from_base64(key_b64: str) -> Ed25519PublicKey:
- """
- Load public key from base64 string.
-
- Args:
- key_b64: Base64-encoded raw public key bytes
-
- Returns:
- Ed25519 public key
-
- Raises:
- SigningKeyError: If key cannot be loaded
- """
- try:
- raw_bytes = base64.b64decode(key_b64)
- return Ed25519PublicKey.from_public_bytes(raw_bytes)
- except Exception as e:
- raise SigningKeyError(f"Failed to load public key: {e}") from e
-
-
-def load_private_key_from_file(path: Path) -> Ed25519PrivateKey:
- """
- Load private key from file (base64 content).
-
- Args:
- path: Path to file containing base64-encoded private key
-
- Returns:
- Ed25519 private key
-
- Raises:
- SigningKeyError: If file cannot be read or key cannot be loaded
- """
- try:
- key_b64 = path.read_text().strip()
- return load_private_key_from_base64(key_b64)
- except FileNotFoundError as e:
- raise SigningKeyError(f"Key file not found: {path}") from e
- except SigningKeyError:
- raise
- except Exception as e:
- raise SigningKeyError(f"Failed to load private key from {path}: {e}") from e
-
-
-def load_private_key_from_env(env_value: str) -> Ed25519PrivateKey:
- """
- Load private key from environment variable value.
-
- Args:
- env_value: Base64-encoded private key from environment
-
- Returns:
- Ed25519 private key
-
- Raises:
- SigningKeyError: If key cannot be loaded
- """
- return load_private_key_from_base64(env_value.strip())
diff --git a/src/specleft/specleft_signing/schema.py b/src/specleft/specleft_signing/schema.py
deleted file mode 100644
index ff4dd41..0000000
--- a/src/specleft/specleft_signing/schema.py
+++ /dev/null
@@ -1,114 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/schema.py
-"""Pydantic models for SpecLeft policy files."""
-
-from datetime import date
-from enum import Enum
-from typing import Literal
-
-from pydantic import BaseModel, Field, model_validator
-
-
-class PolicyType(str, Enum):
- """Policy tier types."""
-
- CORE = "core"
- ENFORCE = "enforce"
-
-
-class EvaluationPeriod(BaseModel):
- """Fixed evaluation window (set at purchase time)."""
-
- starts_at: date
- ends_at: date
-
-
-class CoverageRules(BaseModel):
- """Coverage enforcement rules (Enforce only)."""
-
- threshold_percent: int = Field(default=100, ge=0, le=100)
- fail_below: bool = True
-
-
-class PriorityRule(BaseModel):
- """Rule for a specific priority level."""
-
- must_be_implemented: bool = False
-
-
-class PolicyRules(BaseModel):
- """Enforcement rules."""
-
- priorities: dict[str, PriorityRule] = Field(default_factory=dict)
- coverage: CoverageRules | None = None # Enforce only
-
-
-class LicenseInfo(BaseModel):
- """License binding information."""
-
- license_id: str = Field(pattern=r"^lic_[a-zA-Z0-9]{8,}$")
- licensed_to: str # "owner/repo" or "owner/*"
- issued_at: date
- expires_at: date
- evaluation: EvaluationPeriod | None = None # Enforce only
- derived_from: str | None = None # Downgraded Core only
-
-
-class SignatureBlock(BaseModel):
- """Cryptographic signature."""
-
- algorithm: Literal["ed25519"] = "ed25519"
- key_id: str
- value: str # Base64-encoded signature
-
-
-class SignedPolicy(BaseModel):
- """Complete signed policy file."""
-
- policy_id: str
- policy_version: str = Field(pattern=r"^\d+\.\d+$")
- policy_type: PolicyType
- license: LicenseInfo
- rules: PolicyRules
- signature: SignatureBlock
-
- @model_validator(mode="after")
- def validate_type_rules(self) -> "SignedPolicy":
- """Ensure rules match policy type."""
- if self.policy_type == PolicyType.CORE:
- if self.rules.coverage is not None:
- raise ValueError("Core policies cannot have coverage rules")
- if self.license.evaluation is not None:
- raise ValueError("Core policies cannot have evaluation config")
-
- if self.policy_type == PolicyType.ENFORCE and self.rules.coverage is None:
- raise ValueError("Enforce policies must have coverage rules")
-
- return self
-
-
-class UnsignedPolicy(BaseModel):
- """Policy data before signing (no signature block)."""
-
- policy_id: str
- policy_version: str = Field(pattern=r"^\d+\.\d+$")
- policy_type: PolicyType
- license: LicenseInfo
- rules: PolicyRules
-
- @model_validator(mode="after")
- def validate_type_rules(self) -> "UnsignedPolicy":
- """Ensure rules match policy type."""
- if self.policy_type == PolicyType.CORE:
- if self.rules.coverage is not None:
- raise ValueError("Core policies cannot have coverage rules")
- if self.license.evaluation is not None:
- raise ValueError("Core policies cannot have evaluation config")
-
- if self.policy_type == PolicyType.ENFORCE and self.rules.coverage is None:
- raise ValueError("Enforce policies must have coverage rules")
-
- return self
diff --git a/src/specleft/specleft_signing/sign.py b/src/specleft/specleft_signing/sign.py
deleted file mode 100644
index 94a71cb..0000000
--- a/src/specleft/specleft_signing/sign.py
+++ /dev/null
@@ -1,76 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/sign.py
-"""Policy signing operations (requires private key)."""
-
-import base64
-
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from .canonical import canonical_payload
-from .schema import (
- SignatureBlock,
- SignedPolicy,
- UnsignedPolicy,
-)
-
-
-def sign_policy(
- policy: UnsignedPolicy,
- private_key: Ed25519PrivateKey,
- key_id: str,
-) -> SignedPolicy:
- """
- Sign an unsigned policy.
-
- Args:
- policy: Policy data to sign
- private_key: Ed25519 private key for signing
- key_id: Identifier for the signing key (e.g., "specleft-prod-2026")
-
- Returns:
- Complete signed policy with signature block
- """
- # Generate canonical payload
- payload = canonical_payload(
- policy_type=policy.policy_type.value,
- license_data=policy.license.model_dump(),
- rules=policy.rules.model_dump(),
- )
-
- # Sign the payload
- signature_bytes = private_key.sign(payload)
- signature_b64 = base64.b64encode(signature_bytes).decode("ascii")
-
- # Build signed policy
- return SignedPolicy(
- policy_id=policy.policy_id,
- policy_version=policy.policy_version,
- policy_type=policy.policy_type,
- license=policy.license,
- rules=policy.rules,
- signature=SignatureBlock(
- algorithm="ed25519",
- key_id=key_id,
- value=signature_b64,
- ),
- )
-
-
-def sign_payload_raw(
- payload: bytes,
- private_key: Ed25519PrivateKey,
-) -> bytes:
- """
- Sign raw bytes payload.
-
- Args:
- payload: Bytes to sign (typically from canonical_payload())
- private_key: Ed25519 private key
-
- Returns:
- Raw signature bytes (64 bytes)
- """
- return private_key.sign(payload)
diff --git a/src/specleft/specleft_signing/verify.py b/src/specleft/specleft_signing/verify.py
deleted file mode 100644
index 0fe8b4f..0000000
--- a/src/specleft/specleft_signing/verify.py
+++ /dev/null
@@ -1,187 +0,0 @@
-# NOTICE: Commercial License
-# See LICENSE-COMMERCIAL for details.
-# Copyright (c) 2026 SpecLeft.
-
-# src/specleft_signing/verify.py
-"""Policy signature verification (public key only)."""
-
-import base64
-from dataclasses import dataclass
-from datetime import date
-from enum import Enum
-
-from cryptography.exceptions import InvalidSignature
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-
-from .canonical import canonical_payload
-from .exceptions import InvalidSignatureError, UnknownKeyIdError
-from .keys import load_public_key_from_base64
-from .schema import SignedPolicy
-
-# =============================================================================
-# TRUSTED PUBLIC KEYS
-# =============================================================================
-TRUSTED_PUBLIC_KEYS: dict[str, str] = {
- "specleft-dev-2026": "OBN/ZLH6RUg3KWLCW37U3iGnXQVULJLB0sDF/MGw/v0="
-}
-
-
-class VerifyFailure(Enum):
- """Verification failure reasons."""
-
- INVALID_SIGNATURE = "invalid_signature"
- UNKNOWN_KEY_ID = "unknown_key_id"
- EXPIRED = "expired"
- EVALUATION_EXPIRED = "evaluation_expired"
- REPO_MISMATCH = "repo_mismatch"
-
-
-@dataclass
-class VerifyResult:
- """Result of policy verification."""
-
- valid: bool
- failure: VerifyFailure | None = None
- message: str | None = None
-
-
-def get_trusted_public_key(key_id: str) -> Ed25519PublicKey:
- """
- Get a trusted public key by ID.
-
- Args:
- key_id: Key identifier
-
- Returns:
- Ed25519 public key
-
- Raises:
- UnknownKeyIdError: If key_id is not in trusted keys
- """
- if key_id not in TRUSTED_PUBLIC_KEYS:
- raise UnknownKeyIdError(f"Unknown key_id: {key_id}")
-
- return load_public_key_from_base64(TRUSTED_PUBLIC_KEYS[key_id])
-
-
-def verify_signature(policy: SignedPolicy) -> bool:
- """
- Verify policy signature only (no expiry or date checks).
-
- Args:
- policy: Signed policy to verify
-
- Returns:
- True if signature is valid
-
- Raises:
- UnknownKeyIdError: If key_id is not trusted
- InvalidSignatureError: If signature verification fails
- """
- # Get public key
- public_key = get_trusted_public_key(policy.signature.key_id)
-
- # Reconstruct canonical payload
- payload = canonical_payload(
- policy_type=policy.policy_type.value,
- license_data=policy.license.model_dump(),
- rules=policy.rules.model_dump(),
- )
-
- # Decode signature
- try:
- signature = base64.b64decode(policy.signature.value)
- except Exception as e:
- raise InvalidSignatureError(f"Invalid signature encoding: {e}") from e
-
- # Verify
- try:
- public_key.verify(signature, payload)
- return True
- except InvalidSignature as e:
- raise InvalidSignatureError("Signature verification failed") from e
-
-
-def verify_policy(policy: SignedPolicy, check_dates: bool = True) -> VerifyResult:
- """
- Full policy verification including signature, expiry, and evaluation.
-
- Args:
- policy: Signed policy to verify
- check_dates: If True, verify expiration and evaluation dates
-
- Returns:
- VerifyResult with valid=True or valid=False with failure details
-
- Note:
- This does NOT check repository binding. That must be done by the
- caller (SpecLeft CLI) which has access to the current repository.
- """
- # 1. Check key_id is known
- if policy.signature.key_id not in TRUSTED_PUBLIC_KEYS:
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.UNKNOWN_KEY_ID,
- message=f"Unknown signing key: {policy.signature.key_id}",
- )
-
- # 2. Verify signature
- try:
- verify_signature(policy)
- except InvalidSignatureError as e:
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.INVALID_SIGNATURE,
- message=str(e),
- )
- except UnknownKeyIdError as e:
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.UNKNOWN_KEY_ID,
- message=str(e),
- )
-
- if not check_dates:
- return VerifyResult(valid=True)
-
- # 3. Check license expiration
- today = date.today()
- if policy.license.expires_at < today:
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.EXPIRED,
- message=f"License expired on {policy.license.expires_at}",
- )
-
- # 4. Check evaluation period (Enforce only)
- if policy.license.evaluation and today > policy.license.evaluation.ends_at:
- return VerifyResult(
- valid=False,
- failure=VerifyFailure.EVALUATION_EXPIRED,
- message=f"Evaluation period ended on {policy.license.evaluation.ends_at}",
- )
-
- return VerifyResult(valid=True)
-
-
-def verify_signature_raw(
- payload: bytes,
- signature: bytes,
- public_key: Ed25519PublicKey,
-) -> bool:
- """
- Verify raw signature bytes against payload.
-
- Args:
- payload: Original signed payload
- signature: Signature bytes to verify
- public_key: Ed25519 public key
-
- Returns:
- True if valid, False otherwise
- """
- try:
- public_key.verify(signature, payload)
- return True
- except InvalidSignature:
- return False
diff --git a/src/specleft/templates/report.html.jinja2 b/src/specleft/templates/report.html.jinja2
index 4b0a414..d827342 100644
--- a/src/specleft/templates/report.html.jinja2
+++ b/src/specleft/templates/report.html.jinja2
@@ -511,9 +511,8 @@
Generated by SpecLeft.
- Enforcement is optional.
- Learn how to enforce system behaviour in CI:
- specleft.dev/enforce
+ Learn more in the project docs:
+ SpecLeft documentation
diff --git a/src/specleft/templates/skill_template.py b/src/specleft/templates/skill_template.py
index 617851f..7be8e9d 100644
--- a/src/specleft/templates/skill_template.py
+++ b/src/specleft/templates/skill_template.py
@@ -120,20 +120,6 @@ def get_skill_content() -> str:
`specleft doctor --verify-skill --format json`
Adds skill integrity status to standard environment diagnostics.
- ## Enforcement
-
- ### Enforce policy
- `specleft enforce [POLICY_FILE] --format json [--dir PATH] [--tests PATH] [--ignore-feature-id ID]`
- Default policy: `.specleft/policies/policy.yml`.
- Exit codes: 0 = satisfied, 1 = violated, 2 = license issue.
-
- ## License
-
- ### License status
- `specleft license status [--file PATH]`
- Show license status and validated policy metadata.
- Default: `.specleft/policies/policy.yml`.
-
## Guide
### Show workflow guide
diff --git a/tests/acceptance/conftest.py b/tests/acceptance/conftest.py
index 82a313d..d01d471 100644
--- a/tests/acceptance/conftest.py
+++ b/tests/acceptance/conftest.py
@@ -37,19 +37,6 @@
feature_4_unimplemented,
)
-# Feature 5: Policy Enforcement
-from fixtures.feature_5 import (
- feature_5_invalid_signature,
- feature_5_policy_satisfied,
- feature_5_policy_violation,
-)
-
-# Feature 6: CI Experience & Messaging
-from fixtures.feature_6 import (
- feature_6_ci_failure,
- feature_6_doc_links,
-)
-
# Feature 7: Autonomous Agent Test Execution
from fixtures.feature_7 import (
feature_7_agent_implements,
@@ -85,13 +72,6 @@
"feature_4_unimplemented",
"feature_4_implemented",
"feature_4_multi_feature_filter",
- # Feature 5
- "feature_5_policy_violation",
- "feature_5_policy_satisfied",
- "feature_5_invalid_signature",
- # Feature 6
- "feature_6_ci_failure",
- "feature_6_doc_links",
# Feature 7
"feature_7_next_scenario",
"feature_7_skeleton",
diff --git a/tests/acceptance/fixtures/__init__.py b/tests/acceptance/fixtures/__init__.py
index 11de982..0510d77 100644
--- a/tests/acceptance/fixtures/__init__.py
+++ b/tests/acceptance/fixtures/__init__.py
@@ -23,15 +23,6 @@
feature_4_multi_feature_filter,
feature_4_unimplemented,
)
-from fixtures.feature_5 import (
- feature_5_invalid_signature,
- feature_5_policy_satisfied,
- feature_5_policy_violation,
-)
-from fixtures.feature_6 import (
- feature_6_ci_failure,
- feature_6_doc_links,
-)
from fixtures.feature_7 import (
feature_7_agent_implements,
feature_7_coverage,
@@ -64,13 +55,6 @@
"feature_4_unimplemented",
"feature_4_implemented",
"feature_4_multi_feature_filter",
- # Feature 5
- "feature_5_policy_violation",
- "feature_5_policy_satisfied",
- "feature_5_invalid_signature",
- # Feature 6
- "feature_6_ci_failure",
- "feature_6_doc_links",
# Feature 7
"feature_7_next_scenario",
"feature_7_skeleton",
diff --git a/tests/acceptance/fixtures/feature_5.py b/tests/acceptance/fixtures/feature_5.py
deleted file mode 100644
index c579ee8..0000000
--- a/tests/acceptance/fixtures/feature_5.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""Feature 5: Policy Enforcement fixtures."""
-
-from __future__ import annotations
-
-from collections.abc import Iterator
-from pathlib import Path
-
-import pytest
-from click.testing import CliRunner
-
-from fixtures.common import FeatureFiles
-
-_FEATURE_5_USER_AUTH = """\
-# Feature: User Authentication
-priority: high
-
-## Scenarios
-
-### Scenario: User login critical
-priority: critical
-
-- Given a registered user
-- When they submit valid credentials
-- Then they are authenticated
-
-### Scenario: User password reset
-priority: high
-
-- Given a user forgot password
-- When they request reset
-- Then email is sent
-
-### Scenario: User logout
-priority: medium
-
-- Given an authenticated user
-- When they click logout
-- Then session is terminated
-"""
-
-_TEST_5_MEDIUM_ONLY = '''\
-from specleft import specleft
-
-@specleft(feature_id="feature-user-authentication", scenario_id="user-logout")
-def test_user_logout():
- """Only medium priority implemented."""
- pass
-'''
-
-_FEATURE_5_PAYMENT = """\
-# Feature: Payment Processing
-priority: high
-
-## Scenarios
-
-### Scenario: Process payment
-priority: critical
-
-- Given a valid payment method
-- When payment is submitted
-- Then transaction succeeds
-
-### Scenario: Refund payment
-priority: high
-
-- Given a completed transaction
-- When refund is requested
-- Then amount is returned
-
-### Scenario: View payment history
-priority: low
-
-- Given a user account
-- When viewing history
-- Then transactions are listed
-"""
-
-_TEST_5_PAYMENT_FULL = '''\
-from specleft import specleft
-
-@specleft(feature_id="feature-payment-processing", scenario_id="process-payment")
-def test_process_payment():
- """Critical scenario implemented."""
- pass
-
-@specleft(feature_id="feature-payment-processing", scenario_id="refund-payment")
-def test_refund_payment():
- """High priority scenario implemented."""
- pass
-
-# Note: view-payment-history (low priority) intentionally not implemented
-'''
-
-_FEATURE_5_USER_MGMT = """\
-# Feature: User Management
-priority: high
-
-## Scenarios
-
-### Scenario: Create user
-priority: critical
-
-- Given an admin
-- When creating a user
-- Then user is created
-"""
-
-
-@pytest.fixture
-def feature_5_policy_violation(
- acceptance_workspace: tuple[CliRunner, Path],
-) -> Iterator[tuple[CliRunner, Path, FeatureFiles]]:
- """Feature with unimplemented critical/high scenarios for policy violation test."""
- runner, workspace = acceptance_workspace
-
- features_dir = workspace / ".specleft" / "specs"
- features_dir.mkdir(parents=True, exist_ok=True)
- tests_dir = workspace / "tests"
- tests_dir.mkdir(exist_ok=True)
- (tests_dir / "__init__.py").write_text("")
-
- feature_path = features_dir / "feature-user-authentication.md"
- feature_path.write_text(_FEATURE_5_USER_AUTH)
-
- test_path = tests_dir / "test_auth.py"
- test_path.write_text(_TEST_5_MEDIUM_ONLY)
-
- yield (
- runner,
- workspace,
- FeatureFiles(
- feature_path=feature_path,
- test_path=test_path,
- features_dir=features_dir,
- tests_dir=tests_dir,
- ),
- )
-
-
-@pytest.fixture
-def feature_5_policy_satisfied(
- acceptance_workspace: tuple[CliRunner, Path],
-) -> Iterator[tuple[CliRunner, Path, FeatureFiles]]:
- """Feature with all critical/high scenarios implemented for passing enforcement."""
- runner, workspace = acceptance_workspace
-
- features_dir = workspace / ".specleft" / "specs"
- features_dir.mkdir(parents=True, exist_ok=True)
- tests_dir = workspace / "tests"
- tests_dir.mkdir(exist_ok=True)
-
- feature_path = features_dir / "feature-payment-processing.md"
- feature_path.write_text(_FEATURE_5_PAYMENT)
-
- test_path = tests_dir / "test_payment.py"
- test_path.write_text(_TEST_5_PAYMENT_FULL)
-
- yield (
- runner,
- workspace,
- FeatureFiles(
- feature_path=feature_path,
- test_path=test_path,
- features_dir=features_dir,
- tests_dir=tests_dir,
- ),
- )
-
-
-@pytest.fixture
-def feature_5_invalid_signature(
- acceptance_workspace: tuple[CliRunner, Path],
-) -> Iterator[tuple[CliRunner, Path, FeatureFiles]]:
- """Feature for testing invalid policy signature rejection."""
- runner, workspace = acceptance_workspace
-
- features_dir = workspace / ".specleft" / "specs"
- features_dir.mkdir(parents=True, exist_ok=True)
- tests_dir = workspace / "tests"
- tests_dir.mkdir(exist_ok=True)
-
- feature_path = features_dir / "feature-user-management.md"
- feature_path.write_text(_FEATURE_5_USER_MGMT)
-
- test_path = tests_dir / "test_user_management.py"
- test_path.write_text("")
-
- yield (
- runner,
- workspace,
- FeatureFiles(
- feature_path=feature_path,
- test_path=test_path,
- features_dir=features_dir,
- tests_dir=tests_dir,
- ),
- )
diff --git a/tests/acceptance/fixtures/feature_6.py b/tests/acceptance/fixtures/feature_6.py
deleted file mode 100644
index c9d9920..0000000
--- a/tests/acceptance/fixtures/feature_6.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""Feature 6: CI Experience & Messaging fixtures."""
-
-from __future__ import annotations
-
-from collections.abc import Iterator
-from pathlib import Path
-
-import pytest
-from click.testing import CliRunner
-
-from fixtures.common import FeatureFiles
-
-_FEATURE_6_ORDER = """\
-# Feature: Order Processing
-priority: high
-
-## Scenarios
-
-### Scenario: Process critical order
-priority: critical
-
-- Given a pending order
-- When processing is triggered
-- Then order is fulfilled
-
-### Scenario: Archive old orders
-priority: low
-
-- Given orders older than 90 days
-- When archival runs
-- Then orders are archived
-"""
-
-_TEST_6_ORDER_LOW_ONLY = '''\
-from specleft import specleft
-
-@specleft(feature_id="feature-order-processing", scenario_id="archive-old-orders")
-def test_archive_old_orders():
- """Only low priority implemented - critical is missing."""
- pass
-'''
-
-_FEATURE_6_NOTIFICATION = """\
-# Feature: Notification Service
-priority: high
-
-## Scenarios
-
-### Scenario: Send critical alert
-priority: critical
-
-- Given a critical event
-- When alert is triggered
-- Then notification is sent
-
-### Scenario: Log notification history
-priority: medium
-
-- Given notifications sent
-- When history is queried
-- Then records are returned
-"""
-
-_TEST_6_NOTIFICATION_MEDIUM_ONLY = '''\
-from specleft import specleft
-
-@specleft(feature_id="feature-notification-service", scenario_id="log-notification-history")
-def test_log_notification_history():
- """Only medium priority implemented."""
- pass
-'''
-
-
-@pytest.fixture
-def feature_6_ci_failure(
- acceptance_workspace: tuple[CliRunner, Path],
-) -> Iterator[tuple[CliRunner, Path, FeatureFiles]]:
- """Feature with unimplemented critical scenario for CI failure messaging test."""
- runner, workspace = acceptance_workspace
-
- features_dir = workspace / ".specleft" / "specs"
- features_dir.mkdir(parents=True, exist_ok=True)
- tests_dir = workspace / "tests"
- tests_dir.mkdir(exist_ok=True)
- (tests_dir / "__init__.py").write_text("")
-
- feature_path = features_dir / "feature-order-processing.md"
- feature_path.write_text(_FEATURE_6_ORDER)
-
- test_path = tests_dir / "test_orders.py"
- test_path.write_text(_TEST_6_ORDER_LOW_ONLY)
-
- yield (
- runner,
- workspace,
- FeatureFiles(
- feature_path=feature_path,
- test_path=test_path,
- features_dir=features_dir,
- tests_dir=tests_dir,
- ),
- )
-
-
-@pytest.fixture
-def feature_6_doc_links(
- acceptance_workspace: tuple[CliRunner, Path],
-) -> Iterator[tuple[CliRunner, Path, FeatureFiles]]:
- """Feature for testing documentation/support link presence on CI failure."""
- runner, workspace = acceptance_workspace
-
- features_dir = workspace / ".specleft" / "specs"
- features_dir.mkdir(parents=True, exist_ok=True)
- tests_dir = workspace / "tests"
- tests_dir.mkdir(exist_ok=True)
- (tests_dir / "__init__.py").write_text("")
-
- feature_path = features_dir / "feature-notification-service.md"
- feature_path.write_text(_FEATURE_6_NOTIFICATION)
-
- test_path = tests_dir / "test_notifications.py"
- test_path.write_text(_TEST_6_NOTIFICATION_MEDIUM_ONLY)
-
- yield (
- runner,
- workspace,
- FeatureFiles(
- feature_path=feature_path,
- test_path=test_path,
- features_dir=features_dir,
- tests_dir=tests_dir,
- ),
- )
diff --git a/tests/acceptance/test_feature-5-policy-enforcement.py b/tests/acceptance/test_feature-5-policy-enforcement.py
deleted file mode 100644
index ac22880..0000000
--- a/tests/acceptance/test_feature-5-policy-enforcement.py
+++ /dev/null
@@ -1,296 +0,0 @@
-"""
-Acceptance tests for Feature 5: Policy Enforcement.
-
-These tests verify that teams can enforce declared critical intent in CI
-using a signed policy file.
-
-Generated by SpecLeft - https://github.com/SpecLeft/specleft
-"""
-
-from __future__ import annotations
-
-import json
-from datetime import date
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-import yaml
-from click.testing import CliRunner
-
-from specleft import specleft
-from specleft.cli.main import cli
-from specleft.license.repo_identity import RepoIdentity
-from conftest import FeatureFiles
-from tests.license.fixtures import (
- TEST_KEY_ID,
- TEST_PUBLIC_KEY_B64,
- add_trusted_key,
- create_core_policy_data,
- create_enforce_policy_data,
- remove_trusted_key,
-)
-
-# =============================================================================
-# Feature: Feature 5: Policy Enforcement
-# ID: feature-5-policy-enforcement
-# priority: critical
-# =============================================================================
-
-# Story: Default
-# ID: default
-
-
-@pytest.fixture(autouse=True)
-def setup_test_key():
- """Set up test key for each test."""
- add_trusted_key(TEST_KEY_ID, TEST_PUBLIC_KEY_B64)
- yield
- remove_trusted_key(TEST_KEY_ID)
-
-
-def write_policy_file(
- base_dir: Path, policy_data: dict, filename: str = "policy.yml"
-) -> Path:
- """Write a policy file to .specleft/policies directory."""
- licenses_dir = base_dir / ".specleft" / "policies"
- licenses_dir.mkdir(parents=True, exist_ok=True)
- policy_path = licenses_dir / filename
-
- # Convert dates to strings for YAML
- def convert_dates(obj):
- if isinstance(obj, dict):
- return {k: convert_dates(v) for k, v in obj.items()}
- elif isinstance(obj, list):
- return [convert_dates(item) for item in obj]
- elif isinstance(obj, date):
- return obj.isoformat()
- return obj
-
- policy_path.write_text(yaml.dump(convert_dates(policy_data)))
- return policy_path
-
-
-@specleft(
- feature_id="feature-5-policy-enforcement",
- scenario_id="enforce-critical-and-high-priority-scenarios",
-)
-def test_enforce_critical_and_high_priority_scenarios(
- feature_5_policy_violation: tuple[CliRunner, Path, FeatureFiles],
-) -> None:
- """Enforce critical and high priority scenarios
-
- Priority: critical
-
- Verifies that when a signed policy requires critical and high scenarios
- to be implemented, and one or more are unimplemented, the command exits
- with a non-zero status and explains which intent was violated.
- """
- runner, _workspace, _files = feature_5_policy_violation
-
- with specleft.step(
- "Given a signed policy requiring critical and high scenarios to be implemented"
- ):
- # Feature and test files are already created by fixture
- # Create a signed policy requiring critical and high priorities
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=1, # Low threshold
- coverage_fail_below=False, # Disable coverage enforcement for this test
- priorities={
- "critical": {"must_be_implemented": True},
- "high": {"must_be_implemented": True},
- },
- )
- write_policy_file(Path("."), policy_data)
-
- with specleft.step("And one or more such scenarios are unimplemented"):
- # Test file is already created by fixture with only medium priority implemented
- pass
-
- with (
- specleft.step("When specleft enforce is executed"),
- patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "json"],
- )
-
- with specleft.step("Then the command exits with a non-zero status"):
- assert result.exit_code != 0, (
- f"Expected non-zero exit code but got {result.exit_code}. "
- f"Output: {result.output}"
- )
-
- with specleft.step("And the failure message explains which intent was violated"):
- # Parse JSON output
- payload = json.loads(result.output)
-
- # Verify the response indicates failure
- assert payload["failed"] is True, "Expected 'failed' to be True"
-
- # Verify priority violations are reported
- violations = payload["priority_violations"]
- assert len(violations) >= 1, "Expected at least one priority violation"
-
- # Check that the violations include the unimplemented critical/high scenarios
- violation_scenario_ids = {v["scenario_id"] for v in violations}
-
- # At least the critical scenario should be reported as violated
- assert (
- "user-login-critical" in violation_scenario_ids
- ), f"Expected 'user-login-critical' in violations but got: {violation_scenario_ids}"
-
-
-@specleft(
- feature_id="feature-5-policy-enforcement",
- scenario_id="pass-enforcement-when-intent-is-satisfied",
-)
-def test_pass_enforcement_when_intent_is_satisfied(
- feature_5_policy_satisfied: tuple[CliRunner, Path, FeatureFiles],
-) -> None:
- """Pass enforcement when intent is satisfied
-
- Priority: high
-
- Verifies that when all critical and high priority scenarios are implemented,
- the enforcement command exits successfully.
- """
- runner, _workspace, _files = feature_5_policy_satisfied
-
- with specleft.step(
- "Given all critical and high priority scenarios are implemented"
- ):
- # Feature and test files are already created by fixture
- # Create a signed policy requiring critical and high priorities
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=1, # Low threshold
- coverage_fail_below=False, # Disable coverage enforcement for this test
- priorities={
- "critical": {"must_be_implemented": True},
- "high": {"must_be_implemented": True},
- },
- )
- write_policy_file(Path("."), policy_data)
-
- with (
- specleft.step("When enforcement is executed"),
- patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "json"],
- )
-
- with specleft.step("Then the command exits successfully"):
- # Parse the output to check for violations
- if result.output.strip().startswith("{"):
- payload = json.loads(result.output)
- assert payload["failed"] is False, (
- f"Expected 'failed' to be False but policy reported violations: "
- f"priority={payload.get('priority_violations', [])} "
- f"coverage={payload.get('coverage_violations', [])}"
- )
-
- assert result.exit_code == 0, (
- f"Expected exit code 0 but got {result.exit_code}. "
- f"Output: {result.output}"
- )
-
-
-@specleft(
- feature_id="feature-5-policy-enforcement",
- scenario_id="reject-invalid-or-unsigned-policies",
-)
-def test_reject_invalid_or_unsigned_policies(
- feature_5_invalid_signature: tuple[CliRunner, Path, FeatureFiles],
-) -> None:
- """Reject invalid or unsigned policies
-
- Priority: critical
-
- Verifies that when a policy file has an invalid or missing signature,
- enforcement fails with a clear error and no intent evaluation is performed.
- """
- runner, _workspace, _files = feature_5_invalid_signature
-
- with specleft.step("Given a policy file has an invalid or missing signature"):
- # Feature file is already created by fixture
- # Create a policy with tampered/invalid signature
- # Start with valid policy data, then tamper with the signature itself
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
-
- # Tamper with the signature value to make it invalid
- # This will cause signature verification to fail
- policy_data["signature"]["value"] = (
- "AAAA" + policy_data["signature"]["value"][4:]
- )
-
- # Write the tampered policy
- write_policy_file(Path("."), policy_data, filename="policy-invalid.yml")
-
- with (
- specleft.step("When enforcement is executed"),
- patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy-invalid.yml"],
- )
-
- with specleft.step("Then enforcement fails with a clear error"):
- # Should exit with code 2 (license/signature issue)
- assert result.exit_code == 2, (
- f"Expected exit code 2 (license error) but got {result.exit_code}. "
- f"Output: {result.output}"
- )
-
- # Should contain error about signature
- output_lower = result.output.lower()
- assert (
- "signature" in output_lower or "invalid" in output_lower
- ), f"Expected error message about invalid signature. Got: {result.output}"
-
- with specleft.step("And no intent evaluation is performed"):
- # If signature is invalid, the command should fail early
- # and NOT show any violation messages (no evaluation happened)
- output_lower = result.output.lower()
-
- # Should NOT see priority violation messages (those would indicate evaluation ran)
- assert (
- "priority violations" not in output_lower
- ), "Expected no intent evaluation but saw priority violation messages"
-
- # Should NOT see "all checks passed" (that would indicate evaluation ran)
- assert (
- "all checks passed" not in output_lower
- ), "Expected no intent evaluation but saw 'all checks passed'"
-
- with specleft.step("And Documentation link is shown"):
- # Per PRD, a documentation link should be shown for invalid policies
- # NOTE: Current implementation doesn't show a doc link for signature failures.
- # This is a known gap - the handle_verification_failure function only shows
- # links for EVALUATION_EXPIRED, EXPIRED, and REPO_MISMATCH failures.
- # For now, we verify that the error message is clear and actionable.
- # TODO: Implementation should add documentation link for signature failures
- output_lower = result.output.lower()
- assert (
- "signature" in output_lower
- or "https://" in result.output
- or "documentation" in output_lower
- or "docs" in output_lower
- ), f"Expected clear error message or documentation link. Got: {result.output}"
diff --git a/tests/acceptance/test_feature-6-ci-experience-messaging.py b/tests/acceptance/test_feature-6-ci-experience-messaging.py
deleted file mode 100644
index 2eb06d7..0000000
--- a/tests/acceptance/test_feature-6-ci-experience-messaging.py
+++ /dev/null
@@ -1,256 +0,0 @@
-"""
-Acceptance tests for Feature 6: CI Experience & Messaging.
-
-These tests verify that enforcement failures are calm, precise, and actionable
-for senior engineers - no marketing language, clear remediation options.
-
-Generated by SpecLeft - https://github.com/SpecLeft/specleft
-"""
-
-from __future__ import annotations
-
-from datetime import date
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-import yaml
-from click.testing import CliRunner
-
-from specleft import specleft
-from specleft.cli.main import cli
-from specleft.license.repo_identity import RepoIdentity
-from conftest import FeatureFiles
-from tests.license.fixtures import (
- TEST_KEY_ID,
- TEST_PUBLIC_KEY_B64,
- add_trusted_key,
- create_core_policy_data,
- create_enforce_policy_data,
- remove_trusted_key,
-)
-
-# =============================================================================
-# Feature: Feature 6: CI Experience & Messaging
-# ID: feature-6-ci-experience-messaging
-# priority: medium
-# =============================================================================
-
-# Story: Default
-# ID: default
-
-
-@pytest.fixture(autouse=True)
-def setup_test_key():
- """Set up test key for each test."""
- add_trusted_key(TEST_KEY_ID, TEST_PUBLIC_KEY_B64)
- yield
- remove_trusted_key(TEST_KEY_ID)
-
-
-def write_policy_file(
- base_dir: Path, policy_data: dict, filename: str = "policy.yml"
-) -> Path:
- """Write a policy file to .specleft/policies directory."""
- licenses_dir = base_dir / ".specleft" / "policies"
- licenses_dir.mkdir(parents=True, exist_ok=True)
- policy_path = licenses_dir / filename
-
- # Convert dates to strings for YAML
- def convert_dates(obj):
- if isinstance(obj, dict):
- return {k: convert_dates(v) for k, v in obj.items()}
- elif isinstance(obj, list):
- return [convert_dates(item) for item in obj]
- elif isinstance(obj, date):
- return obj.isoformat()
- return obj
-
- policy_path.write_text(yaml.dump(convert_dates(policy_data)))
- return policy_path
-
-
-@specleft(
- feature_id="feature-6-ci-experience-messaging",
- scenario_id="ci-failure-explains-intent-mismatch",
-)
-def test_ci_failure_explains_intent_mismatch(
- feature_6_ci_failure: tuple[CliRunner, Path, FeatureFiles],
-):
- """CI failure explains intent mismatch
-
- Priority: critical (per PRD)
-
- Verifies that when enforcement fails in CI, the output message:
- - Explains declared intent (what scenarios were required)
- - Shows current implementation state (what's NOT IMPLEMENTED)
- - Provides clear remediation options (documentation links)
- - Contains NO marketing or pricing language
- """
- runner, _workspace, _files = feature_6_ci_failure
-
- with specleft.step("Given enforcement fails in CI"):
- # Feature and test files are already created by fixture
- # Create a signed policy requiring critical scenarios
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=1,
- coverage_fail_below=False,
- priorities={
- "critical": {"must_be_implemented": True},
- },
- )
- write_policy_file(Path("."), policy_data)
-
- with specleft.step("When output is printed"):
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
-
- output = result.output
-
- with specleft.step("Then the message explains:"):
- # Check for declared intent - the output should show what priority was required
- # and which scenarios were violations
- assert (
- "Priority violations" in output or "priority" in output.lower()
- ), f"Expected output to mention priority requirements. Got: {output}"
-
- # Check that specific scenario is identified as NOT IMPLEMENTED
- assert (
- "NOT IMPLEMENTED" in output or "not implemented" in output.lower()
- ), f"Expected output to show implementation state. Got: {output}"
-
- # Check that the feature/scenario is identified
- assert (
- "feature-order-processing" in output or "process-critical-order" in output
- ), f"Expected output to identify the violated scenario. Got: {output}"
-
- with specleft.step("And no marketing or pricing language is included"):
- output_lower = output.lower()
-
- # Should NOT contain marketing/pricing terms in the violation output
- # Note: pricing links may appear in evaluation warnings, but not in
- # the core violation message
- marketing_terms = [
- "purchase",
- "buy now",
- "subscribe",
- "premium",
- "upgrade today",
- "limited time",
- "discount",
- "special offer",
- ]
-
- for term in marketing_terms:
- assert term not in output_lower, (
- f"Found marketing language '{term}' in output. "
- f"CI messages should be professional and actionable. Got: {output}"
- )
-
- # Verify the exit code indicates policy violation (not license issue)
- assert result.exit_code == 1, (
- f"Expected exit code 1 (policy violation) but got {result.exit_code}. "
- f"Output: {output}"
- )
-
-
-@specleft(
- feature_id="feature-6-ci-experience-messaging",
- scenario_id="documentation-and-support-links-on-ci-failure",
-)
-@pytest.mark.parametrize(
- "package,policy_filename,policy_creator",
- [
- ("Core+", "policy-core.yml", create_core_policy_data),
- ("Enforce", "policy.yml", create_enforce_policy_data),
- ],
-)
-def test_documentation_and_support_links_on_ci_failure(
- feature_6_doc_links: tuple[CliRunner, Path, FeatureFiles],
- package: str,
- policy_filename: str,
- policy_creator,
-):
- """Documentation and support links on CI failure
-
- Priority: high
-
- Verifies that when enforcement fails with policy violations,
- the output includes actionable documentation and support links.
- """
- runner, _workspace, _files = feature_6_doc_links
-
- with specleft.step(
- f"Given enforcement fails in CI with {package} policy violation"
- ):
- # Feature and test files are already created by fixture
- # Create the appropriate policy type
- policy_data = policy_creator(
- licensed_to="test-owner/test-repo",
- coverage_threshold=1,
- coverage_fail_below=False,
- priorities={
- "critical": {"must_be_implemented": True},
- },
- )
- write_policy_file(Path("."), policy_data, filename=policy_filename)
-
- with specleft.step(
- f"When output is printed from specleft enforce {policy_filename}"
- ):
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- [
- "enforce",
- f".specleft/policies/{policy_filename}",
- "--format",
- "table",
- ],
- )
-
- output = result.output
-
- with specleft.step('Then the message includes "Documentation: "'):
- assert "Documentation:" in output, (
- f"Expected 'Documentation:' link in output for {package} policy. "
- f"Got: {output}"
- )
-
- # Verify the documentation link is a valid URL
- assert (
- "https://" in output
- ), f"Expected documentation link to be a valid URL. Got: {output}"
-
- with specleft.step('And the message includes "Support: "'):
- assert "Support:" in output, (
- f"Expected 'Support:' link in output for {package} policy. "
- f"Got: {output}"
- )
-
- with specleft.step("And both links are actionable and relevant"):
- # Verify links point to specleft.dev domain (actionable and relevant)
- assert (
- "specleft.dev" in output
- ), f"Expected links to point to specleft.dev. Got: {output}"
-
- # Verify the enforcement failed (so we're testing failure path)
- assert result.exit_code == 1, (
- f"Expected exit code 1 (policy violation) but got {result.exit_code}. "
- f"Output: {output}"
- )
-
- # Verify that documentation link appears to be for enforcement docs
- assert (
- "docs" in output.lower() or "enforce" in output.lower()
- ), f"Expected documentation to be relevant to enforcement. Got: {output}"
diff --git a/tests/commands/test_enforce.py b/tests/commands/test_enforce.py
deleted file mode 100644
index fa99f2a..0000000
--- a/tests/commands/test_enforce.py
+++ /dev/null
@@ -1,376 +0,0 @@
-"""Tests for 'specleft enforce' command."""
-
-from __future__ import annotations
-
-import json
-from datetime import date, timedelta
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-import yaml
-from click.testing import CliRunner
-from specleft.cli.main import cli
-from specleft.license.repo_identity import RepoIdentity
-
-from tests.helpers.specs import create_feature_specs
-from tests.license.fixtures import (
- TEST_KEY_ID,
- TEST_PUBLIC_KEY_B64,
- add_trusted_key,
- create_core_policy_data,
- create_enforce_policy_data,
- remove_trusted_key,
-)
-
-
-@pytest.fixture(autouse=True)
-def setup_test_key():
- """Set up test key for each test."""
- add_trusted_key(TEST_KEY_ID, TEST_PUBLIC_KEY_B64)
- yield
- remove_trusted_key(TEST_KEY_ID)
-
-
-def write_policy_file(
- base_dir: Path, policy_data: dict, filename: str = "policy.yml"
-) -> Path:
- """Write a policy file to .specleft directory."""
- specleft_dir = base_dir / ".specleft" / "policies"
- specleft_dir.mkdir(parents=True, exist_ok=True)
- policy_path = specleft_dir / filename
-
- # Convert dates to strings for YAML
- def convert_dates(obj):
- if isinstance(obj, dict):
- return {k: convert_dates(v) for k, v in obj.items()}
- elif isinstance(obj, list):
- return [convert_dates(item) for item in obj]
- elif isinstance(obj, date):
- return obj.isoformat()
- return obj
-
- policy_path.write_text(yaml.dump(convert_dates(policy_data)))
- return policy_path
-
-
-class TestEnforceCommand:
- """Tests for 'specleft enforce' command."""
-
- def test_enforce_core_passes(self) -> None:
- """Valid Core policy, scenarios implemented."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- # Create feature specs
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- # Create implemented test
- tests_dir = Path("tests") / "auth"
- tests_dir.mkdir(parents=True)
- (tests_dir / "test_login.py").write_text("""
-from specleft import specleft
-
-@specleft(feature_id="auth", scenario_id="login-success")
-def test_login_success():
- pass
-""")
-
- # Create policy
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- # Mock repo detection by using repo override in verify
- runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml"],
- catch_exceptions=False,
- )
-
- # Will fail on repo detection in CI, but that's expected
- # The test validates the command structure works
-
- def test_enforce_core_fails(self) -> None:
- """Valid Core policy, missing scenarios - exit 1."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- # Create feature specs but NO implementation
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- runner.invoke(cli, ["enforce", ".specleft/policies/policy.yml"])
- # Should fail on repo detection or policy violations
-
- def test_enforce_core_rejects_ignore_flag(self) -> None:
- """--ignore-feature-id errors on Core policy."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- )
-
- policy_data = create_core_policy_data(licensed_to="test-owner/test-repo")
- write_policy_file(Path("."), policy_data)
-
- result = runner.invoke(
- cli,
- [
- "enforce",
- ".specleft/policies/policy.yml",
- "--ignore-feature-id",
- "auth",
- ],
- )
-
- # Should fail because Core doesn't support ignore
- assert result.exit_code == 1
- assert "--ignore-feature-id requires Enforce" in result.output
-
- def test_enforce_rejects_invalid_ignore_feature_id(self) -> None:
- """--ignore-feature-id validates IDs at Click parsing layer."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- result = runner.invoke(
- cli,
- ["enforce", "--ignore-feature-id", "Bad ID"],
- )
- assert result.exit_code == 2
- assert "Must be kebab-case alphanumeric" in result.output
-
- def test_enforce_invalid_signature_exit_2(self) -> None:
- """Tampered file exits with code 2."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- )
-
- policy_data = create_core_policy_data(licensed_to="test-owner/test-repo")
- # Tamper with license_id after signing
- policy_data["license"]["license_id"] = "lic_tampered1234"
- write_policy_file(Path("."), policy_data)
-
- result = runner.invoke(cli, ["enforce", ".specleft/policies/policy.yml"])
-
- assert result.exit_code == 2
- assert "Signature" in result.output or "signature" in result.output
-
- def test_enforce_expired_license_exit_2(self) -> None:
- """Old license exits with code 2."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- )
-
- yesterday = date.today() - timedelta(days=1)
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- issued_at=date.today() - timedelta(days=365),
- expires_at=yesterday,
- )
- write_policy_file(Path("."), policy_data)
-
- result = runner.invoke(cli, ["enforce", ".specleft/policies/policy.yml"])
-
- assert result.exit_code == 2
- assert "expired" in result.output.lower()
-
- def test_enforce_json_output_structure(self) -> None:
- """--format json has expected keys."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "json"],
- )
-
- # Will fail on repo detection, but if it gets to JSON output
- # it should have the right structure
- if result.exit_code in (0, 1) and result.output.strip().startswith("{"):
- data = json.loads(result.output)
- assert "failed" in data
- assert "priority_violations" in data
- assert "ignored_features" in data
-
- def test_enforce_missing_policy_file(self) -> None:
- """Clear error for missing file."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- result = runner.invoke(cli, ["enforce", "nonexistent.yml"])
-
- assert result.exit_code == 2
- assert "not found" in result.output.lower() or "Error" in result.output
-
- def test_enforce_default_policy_path(self) -> None:
- """Default policy path uses .specleft/policies/policy.yml."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(cli, ["enforce"])
-
- assert result.exit_code in (0, 1)
-
- def test_enforce_fallback_single_policy_file(self) -> None:
- """Falls back to the single policy in .specleft/policies."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data, filename="custom.yml")
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(cli, ["enforce"])
-
- assert result.exit_code in (0, 1)
-
-
-class TestEnforceEnforcePolicy:
- """Tests specific to Enforce tier policies."""
-
- def test_enforce_enforce_allows_ignore_flag(self) -> None:
- """--ignore-feature-id works with Enforce policy."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- )
-
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=100,
- )
- write_policy_file(Path("."), policy_data)
-
- result = runner.invoke(
- cli,
- [
- "enforce",
- ".specleft/policies/policy.yml",
- "--ignore-feature-id",
- "auth",
- ],
- )
-
- # Should not fail due to ignore flag rejection
- assert "--ignore-feature-id requires Enforce" not in result.output
-
- def test_enforce_evaluation_expired_exit_2(self) -> None:
- """Evaluation expired exits with code 2 and shows instructions."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- )
-
- today = date.today()
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- evaluation_starts=today - timedelta(days=40),
- evaluation_ends=today - timedelta(days=10),
- )
- write_policy_file(Path("."), policy_data)
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli, ["enforce", ".specleft/policies/policy.yml"]
- )
-
- assert result.exit_code == 2
- assert "Evaluation" in result.output and "ended" in result.output
-
-
-class TestEnforceHelpText:
- """Tests for command help and documentation."""
-
- def test_enforce_help_shows_options(self) -> None:
- """Help text shows all options."""
- runner = CliRunner()
- result = runner.invoke(cli, ["enforce", "--help"])
-
- assert result.exit_code == 0
- assert "--format" in result.output
- assert "--ignore-feature-id" in result.output
- assert "--dir" in result.output
- assert "POLICY_FILE" in result.output
diff --git a/tests/commands/test_enforce_integration.py b/tests/commands/test_enforce_integration.py
deleted file mode 100644
index 374ceb2..0000000
--- a/tests/commands/test_enforce_integration.py
+++ /dev/null
@@ -1,354 +0,0 @@
-"""Integration tests for 'specleft enforce' command.
-
-These tests verify complete workflows from feature specs through enforcement.
-"""
-
-from __future__ import annotations
-
-from datetime import date, timedelta
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-import yaml
-from click.testing import CliRunner
-from specleft.cli.main import cli
-from specleft.license.repo_identity import RepoIdentity
-
-from tests.helpers.specs import create_feature_specs
-from tests.license.fixtures import (
- TEST_KEY_ID,
- TEST_PUBLIC_KEY_B64,
- add_trusted_key,
- create_core_policy_data,
- create_enforce_policy_data,
- remove_trusted_key,
-)
-
-
-@pytest.fixture(autouse=True)
-def setup_test_key():
- """Set up test key for each test."""
- add_trusted_key(TEST_KEY_ID, TEST_PUBLIC_KEY_B64)
- yield
- remove_trusted_key(TEST_KEY_ID)
-
-
-def write_policy_file(
- base_dir: Path, policy_data: dict, filename: str = "policy.yml"
-) -> Path:
- """Write a policy file to .specleft directory."""
- specleft_dir = base_dir / ".specleft" / "policies"
- specleft_dir.mkdir(parents=True, exist_ok=True)
- policy_path = specleft_dir / filename
-
- def convert_dates(obj):
- if isinstance(obj, dict):
- return {k: convert_dates(v) for k, v in obj.items()}
- elif isinstance(obj, list):
- return [convert_dates(item) for item in obj]
- elif isinstance(obj, date):
- return obj.isoformat()
- return obj
-
- policy_path.write_text(yaml.dump(convert_dates(policy_data)))
- return policy_path
-
-
-@pytest.mark.integration
-class TestEnforceIntegrationWorkflows:
- """Integration tests for complete enforce workflows."""
-
- def test_workflow_core_pass(self) -> None:
- """features -> skeleton -> implement -> enforce passes."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- # 1. Create feature specs with critical scenario
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- # 2. Create implemented test
- tests_dir = Path("tests") / "auth"
- tests_dir.mkdir(parents=True)
- (tests_dir / "test_login.py").write_text("""
-from specleft import specleft
-
-@specleft(feature_id="auth", scenario_id="login-success")
-def test_login_success():
- pass
-""")
-
- # 3. Create Core policy
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- # 4. Mock repo detection and run enforce
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
-
- assert result.exit_code == 0
- assert "All checks passed" in result.output
-
- def test_workflow_core_fail(self) -> None:
- """features -> skeleton -> enforce fails (not implemented)."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- # 1. Create feature specs but NO implementation
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- # 2. Create Core policy requiring critical
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- # 3. Run enforce
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
-
- assert result.exit_code == 1
- assert "Priority violations" in result.output
- assert "login-success" in result.output
-
- def test_workflow_enforce_evaluation(self) -> None:
- """enforce during active evaluation."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- # Create implemented test
- tests_dir = Path("tests") / "auth"
- tests_dir.mkdir(parents=True)
- (tests_dir / "test_login.py").write_text("""
-from specleft import specleft
-
-@specleft(feature_id="auth", scenario_id="login-success")
-def test_login_success():
- pass
-""")
-
- # Create Enforce policy with active evaluation
- today = date.today()
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=100,
- evaluation_starts=today - timedelta(days=5),
- evaluation_ends=today + timedelta(days=25),
- )
- write_policy_file(Path("."), policy_data)
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
-
- assert result.exit_code == 0
- assert "ℹ Enforce policy running in evaluation mode" in result.output
- assert "days remaining" in result.output
-
- def test_workflow_enforce_purchased(self) -> None:
- """enforce with no evaluation block (purchased license)."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- tests_dir = Path("tests") / "auth"
- tests_dir.mkdir(parents=True)
- (tests_dir / "test_login.py").write_text("""
-from specleft import specleft
-
-@specleft(feature_id="auth", scenario_id="login-success")
-def test_login_success():
- pass
-""")
-
- # Create Enforce policy without evaluation (purchased)
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=100,
- )
- write_policy_file(Path("."), policy_data)
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
-
- assert result.exit_code == 0
- assert "Enforce Policy active" in result.output
-
- def test_workflow_downgrade_path(self) -> None:
- """eval expires -> switch to policy-core.yml -> works."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
-
- tests_dir = Path("tests") / "auth"
- tests_dir.mkdir(parents=True)
- (tests_dir / "test_login.py").write_text("""
-from specleft import specleft
-
-@specleft(feature_id="auth", scenario_id="login-success")
-def test_login_success():
- pass
-""")
-
- # Expired Enforce policy
- today = date.today()
- enforce_policy = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- evaluation_starts=today - timedelta(days=40),
- evaluation_ends=today - timedelta(days=10),
- )
- write_policy_file(Path("."), enforce_policy, "policy.yml")
-
- # Downgraded Core policy
- core_policy = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- derived_from="lic_test12345678",
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), core_policy, "policy-core.yml")
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- # Enforce policy should fail (expired evaluation)
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
- assert result.exit_code == 2
- assert "Evaluation" in result.output and "ended" in result.output
-
- # Core policy should work
- result = runner.invoke(
- cli,
- [
- "enforce",
- ".specleft/policies/policy-core.yml",
- "--format",
- "table",
- ],
- )
- assert result.exit_code == 0
- assert "Core Policy (downgraded from Enforce)" in result.output
-
- def test_workflow_ignore_feature(self) -> None:
- """enforce --ignore-feature-id excludes feature."""
- runner = CliRunner()
- with runner.isolated_filesystem():
- # Create two features
- create_feature_specs(
- Path("."),
- feature_id="auth",
- story_id="login",
- scenario_id="login-success",
- scenario_priority="critical",
- )
- create_feature_specs(
- Path("."),
- feature_id="legacy",
- story_id="old-api",
- scenario_id="legacy-endpoint",
- scenario_priority="critical",
- )
-
- # Only implement auth
- tests_dir = Path("tests") / "auth"
- tests_dir.mkdir(parents=True)
- (tests_dir / "test_login.py").write_text("""
-from specleft import specleft
-
-@specleft(feature_id="auth", scenario_id="login-success")
-def test_login_success():
- pass
-""")
-
- # Enforce policy requiring critical
- policy_data = create_enforce_policy_data(
- licensed_to="test-owner/test-repo",
- coverage_threshold=50, # Lower threshold to pass
- priorities={"critical": {"must_be_implemented": True}},
- )
- write_policy_file(Path("."), policy_data)
-
- with patch(
- "specleft.commands.enforce.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- # Without ignore - should fail (legacy not implemented)
- result = runner.invoke(
- cli,
- ["enforce", ".specleft/policies/policy.yml", "--format", "table"],
- )
- assert result.exit_code == 1
-
- # With ignore - should pass
- result = runner.invoke(
- cli,
- [
- "enforce",
- ".specleft/policies/policy.yml",
- "--ignore-feature-id",
- "legacy",
- "--format",
- "table",
- ],
- )
- assert result.exit_code == 0
- assert "Ignored features: legacy" in result.output
diff --git a/tests/commands/test_license.py b/tests/commands/test_license.py
deleted file mode 100644
index 78cc7f8..0000000
--- a/tests/commands/test_license.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""Tests for 'specleft license' command."""
-
-from __future__ import annotations
-
-from datetime import date, timedelta
-from pathlib import Path
-from unittest.mock import patch
-
-import pytest
-import yaml
-from click.testing import CliRunner
-from specleft.cli.main import cli
-from specleft.license.repo_identity import RepoIdentity
-
-from tests.license.fixtures import (
- TEST_KEY_ID,
- TEST_PUBLIC_KEY_B64,
- add_trusted_key,
- create_core_policy_data,
- remove_trusted_key,
-)
-
-
-@pytest.fixture(autouse=True)
-def setup_test_key():
- """Set up test key for each test."""
- add_trusted_key(TEST_KEY_ID, TEST_PUBLIC_KEY_B64)
- yield
- remove_trusted_key(TEST_KEY_ID)
-
-
-def write_policy_file(
- base_dir: Path, policy_data: dict, filename: str = "policy.yml"
-) -> Path:
- """Write a policy file to .specleft/policies/ directory."""
- license_dir = base_dir / ".specleft" / "policies"
- license_dir.mkdir(parents=True, exist_ok=True)
- policy_path = license_dir / filename
-
- def convert_dates(obj):
- if isinstance(obj, dict):
- return {k: convert_dates(v) for k, v in obj.items()}
- if isinstance(obj, list):
- return [convert_dates(item) for item in obj]
- if isinstance(obj, date):
- return obj.isoformat()
- return obj
-
- policy_path.write_text(yaml.dump(convert_dates(policy_data)))
- return policy_path
-
-
-class TestLicenseCommand:
- """Tests for 'specleft license' command."""
-
- def test_license_status_default_policy(self) -> None:
- runner = CliRunner()
- with runner.isolated_filesystem():
- policy_data = create_core_policy_data(licensed_to="test-owner/test-repo")
- write_policy_file(Path("."), policy_data)
-
- with patch(
- "specleft.license.status.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(cli, ["license", "status"])
-
- assert result.exit_code == 0
- assert "Core License: Apache 2.0" in result.output
- assert "Commercial License: Active" in result.output
- assert "License Type: Core" in result.output
- assert "License ID: lic_test12345678" in result.output
- assert "Validated File: .specleft/policies/policy.yml" in result.output
-
- def test_license_status_file_override(self) -> None:
- runner = CliRunner()
- with runner.isolated_filesystem():
- policy_data = create_core_policy_data(licensed_to="test-owner/test-repo")
- write_policy_file(Path("."), policy_data, filename="custom.yml")
-
- with patch(
- "specleft.license.status.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(
- cli,
- [
- "license",
- "status",
- "--file",
- ".specleft/policies/custom.yml",
- ],
- )
-
- assert result.exit_code == 0
- assert "Commercial License: Active" in result.output
- assert "Validated File: .specleft/policies/custom.yml" in result.output
-
- def test_license_status_multiple_files_negative(self) -> None:
- runner = CliRunner()
- with runner.isolated_filesystem():
- license_dir = Path(".specleft/policies")
- license_dir.mkdir(parents=True, exist_ok=True)
- (license_dir / "01-invalid.yml").write_text("not: [valid: yaml")
-
- expired = date.today() - timedelta(days=1)
- policy_data = create_core_policy_data(
- licensed_to="test-owner/test-repo",
- expires_at=expired,
- )
- write_policy_file(Path("."), policy_data, filename="02-expired.yml")
-
- with patch(
- "specleft.license.status.detect_repo_identity",
- return_value=RepoIdentity(owner="test-owner", name="test-repo"),
- ):
- result = runner.invoke(cli, ["license", "status"])
-
- assert result.exit_code == 0
- assert "Commercial License: Inactive" in result.output
- assert "Validated File: .specleft/policies/02-expired.yml" in result.output
- assert "Valid Until: N/A" in result.output
diff --git a/tests/license/__init__.py b/tests/license/__init__.py
deleted file mode 100644
index e920621..0000000
--- a/tests/license/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Test package for license module."""
diff --git a/tests/license/fixtures.py b/tests/license/fixtures.py
deleted file mode 100644
index 2a1f17e..0000000
--- a/tests/license/fixtures.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""Test fixtures for license verification tests.
-
-Contains test keypairs and helper functions for creating signed policies.
-"""
-
-from __future__ import annotations
-
-import base64
-from datetime import date, timedelta
-from typing import Any
-
-from cryptography.hazmat.primitives.asymmetric.ed25519 import (
- Ed25519PrivateKey,
- Ed25519PublicKey,
-)
-from specleft.specleft_signing.canonical import canonical_payload
-from specleft.specleft_signing.verify import TRUSTED_PUBLIC_KEYS
-
-# Test keypair - ONLY for testing, never use in production
-# Generated once and stored here for deterministic tests
-_TEST_PRIVATE_KEY_BYTES = bytes.fromhex(
- "4c0d87e7a4a7e8c6f0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7"
-)
-_TEST_PRIVATE_KEY = Ed25519PrivateKey.from_private_bytes(_TEST_PRIVATE_KEY_BYTES)
-_TEST_PUBLIC_KEY: Ed25519PublicKey = _TEST_PRIVATE_KEY.public_key()
-
-# Test key ID used in tests
-TEST_KEY_ID = "specleft-test-2026"
-
-# Base64 encoded public key for verification
-TEST_PUBLIC_KEY_B64 = base64.b64encode(_TEST_PUBLIC_KEY.public_bytes_raw()).decode(
- "utf-8"
-)
-
-
-def add_trusted_key(key_id: str, public_key_base64: str) -> None:
- """Add a trusted public key for verification.
-
- This is primarily used for testing with test keypairs.
- Registers the key with specleft_signing's trusted keys.
-
- Args:
- key_id: Key identifier
- public_key_base64: Base64-encoded Ed25519 public key bytes
- """
- TRUSTED_PUBLIC_KEYS[key_id] = public_key_base64
-
-
-def remove_trusted_key(key_id: str) -> None:
- """Remove a trusted public key.
-
- Args:
- key_id: Key identifier to remove
- """
- TRUSTED_PUBLIC_KEYS.pop(key_id, None)
-
-
-def sign_payload(payload: bytes) -> str:
- """Sign a payload with the test private key.
-
- Args:
- payload: Bytes to sign
-
- Returns:
- Base64-encoded signature
- """
- signature = _TEST_PRIVATE_KEY.sign(payload)
- return base64.b64encode(signature).decode("utf-8")
-
-
-def create_signed_policy_data(
- policy_type: str = "core",
- license_id: str = "lic_test12345678",
- licensed_to: str = "test-owner/test-repo",
- issued_at: date | None = None,
- expires_at: date | None = None,
- evaluation_starts: date | None = None,
- evaluation_ends: date | None = None,
- derived_from: str | None = None,
- priorities: dict[str, dict[str, bool]] | None = None,
- coverage_threshold: int | None = None,
- coverage_fail_below: bool = True,
-) -> dict[str, Any]:
- """Create a valid signed policy data structure for testing.
-
- Args:
- policy_type: "core" or "enforce"
- license_id: License identifier
- licensed_to: Repository pattern
- issued_at: License issue date (default: today)
- expires_at: License expiry date (default: 1 year from now)
- evaluation_starts: Evaluation start (Enforce only)
- evaluation_ends: Evaluation end (Enforce only)
- derived_from: Original license ID if downgraded
- priorities: Priority rules
- coverage_threshold: Coverage threshold percent (Enforce only)
- coverage_fail_below: Whether to fail below threshold
-
- Returns:
- Dictionary ready to be used as policy data
- """
- today = date.today()
- issued = issued_at or today
- expires = expires_at or (today + timedelta(days=365))
-
- license_data: dict[str, Any] = {
- "license_id": license_id,
- "licensed_to": licensed_to,
- "issued_at": issued,
- "expires_at": expires,
- }
-
- if evaluation_starts and evaluation_ends:
- license_data["evaluation"] = {
- "starts_at": evaluation_starts,
- "ends_at": evaluation_ends,
- }
-
- if derived_from:
- license_data["derived_from"] = derived_from
-
- rules_data: dict[str, Any] = {
- "priorities": priorities or {"critical": {"must_be_implemented": True}},
- }
-
- if policy_type == "enforce":
- rules_data["coverage"] = {
- "threshold_percent": coverage_threshold or 100,
- "fail_below": coverage_fail_below,
- }
-
- # Generate signature
- payload = canonical_payload(policy_type, license_data, rules_data)
- signature = sign_payload(payload)
-
- return {
- "policy_id": f"{policy_type}-test-v1",
- "policy_version": "1.0",
- "policy_type": policy_type,
- "license": license_data,
- "rules": rules_data,
- "signature": {
- "algorithm": "ed25519",
- "key_id": TEST_KEY_ID,
- "value": signature,
- },
- }
-
-
-def create_core_policy_data(
- licensed_to: str = "test-owner/test-repo",
- expires_at: date | None = None,
- **kwargs: Any,
-) -> dict[str, Any]:
- """Create a Core policy data structure.
-
- Args:
- licensed_to: Repository pattern
- expires_at: License expiry date
- **kwargs: Additional arguments passed to create_signed_policy_data
-
- Returns:
- Dictionary ready to be used as Core policy data
- """
- return create_signed_policy_data(
- policy_type="core",
- licensed_to=licensed_to,
- expires_at=expires_at,
- **kwargs,
- )
-
-
-def create_enforce_policy_data(
- licensed_to: str = "test-owner/test-repo",
- expires_at: date | None = None,
- coverage_threshold: int = 100,
- evaluation_starts: date | None = None,
- evaluation_ends: date | None = None,
- **kwargs: Any,
-) -> dict[str, Any]:
- """Create an Enforce policy data structure.
-
- Args:
- licensed_to: Repository pattern
- expires_at: License expiry date
- coverage_threshold: Required coverage percentage
- evaluation_starts: Evaluation period start
- evaluation_ends: Evaluation period end
- **kwargs: Additional arguments passed to create_signed_policy_data
-
- Returns:
- Dictionary ready to be used as Enforce policy data
- """
- return create_signed_policy_data(
- policy_type="enforce",
- licensed_to=licensed_to,
- expires_at=expires_at,
- coverage_threshold=coverage_threshold,
- evaluation_starts=evaluation_starts,
- evaluation_ends=evaluation_ends,
- **kwargs,
- )
diff --git a/tests/license/test_repo_identity.py b/tests/license/test_repo_identity.py
deleted file mode 100644
index cd40c46..0000000
--- a/tests/license/test_repo_identity.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""Tests for repository identity detection."""
-
-from __future__ import annotations
-
-from specleft.license.repo_identity import RepoIdentity, parse_remote_url
-
-
-class TestRepoIdentity:
- """Tests for RepoIdentity class."""
-
- def test_canonical_format(self) -> None:
- repo = RepoIdentity(owner="owner", name="repo")
- assert repo.canonical == "owner/repo"
-
- def test_matches_exact(self) -> None:
- """owner/repo matches owner/repo."""
- repo = RepoIdentity(owner="owner", name="repo")
- assert repo.matches("owner/repo") is True
-
- def test_matches_exact_case_insensitive(self) -> None:
- """Owner/Repo matches owner/repo."""
- repo = RepoIdentity(owner="Owner", name="Repo")
- assert repo.matches("owner/repo") is True
-
- repo2 = RepoIdentity(owner="owner", name="repo")
- assert repo2.matches("Owner/Repo") is True
-
- def test_matches_wildcard(self) -> None:
- """owner/* matches owner/anything."""
- repo = RepoIdentity(owner="owner", name="any-repo")
- assert repo.matches("owner/*") is True
-
- def test_matches_wildcard_case_insensitive(self) -> None:
- """Owner/* matches owner/repo."""
- repo = RepoIdentity(owner="owner", name="repo")
- assert repo.matches("Owner/*") is True
-
- repo2 = RepoIdentity(owner="Owner", name="repo")
- assert repo2.matches("owner/*") is True
-
- def test_no_match_different_owner(self) -> None:
- """owner/* does not match other/repo."""
- repo = RepoIdentity(owner="other", name="repo")
- assert repo.matches("owner/*") is False
-
- def test_no_match_different_repo(self) -> None:
- """owner/repo does not match owner/other."""
- repo = RepoIdentity(owner="owner", name="repo")
- assert repo.matches("owner/other") is False
-
-
-class TestParseRemoteUrl:
- """Tests for parse_remote_url function."""
-
- def test_parse_ssh_github(self) -> None:
- """git@github.com:owner/repo.git"""
- result = parse_remote_url("git@github.com:owner/repo.git")
- assert result is not None
- assert result.owner == "owner"
- assert result.name == "repo"
-
- def test_parse_ssh_gitlab(self) -> None:
- """git@gitlab.com:owner/repo.git"""
- result = parse_remote_url("git@gitlab.com:owner/repo.git")
- assert result is not None
- assert result.owner == "owner"
- assert result.name == "repo"
-
- def test_parse_https_github(self) -> None:
- """https://github.com/owner/repo"""
- result = parse_remote_url("https://github.com/owner/repo")
- assert result is not None
- assert result.owner == "owner"
- assert result.name == "repo"
-
- def test_parse_https_with_git(self) -> None:
- """https://github.com/owner/repo.git"""
- result = parse_remote_url("https://github.com/owner/repo.git")
- assert result is not None
- assert result.owner == "owner"
- assert result.name == "repo"
-
- def test_parse_no_git_suffix(self) -> None:
- """Handles missing .git suffix."""
- result = parse_remote_url("git@github.com:owner/repo")
- assert result is not None
- assert result.owner == "owner"
- assert result.name == "repo"
-
- def test_parse_invalid_url(self) -> None:
- """Returns None for malformed URLs."""
- assert parse_remote_url("not-a-valid-url") is None
- assert parse_remote_url("") is None
- assert parse_remote_url("ftp://example.com/repo") is None
-
- def test_parse_http_url(self) -> None:
- """http:// URLs are supported."""
- result = parse_remote_url("http://github.com/owner/repo")
- assert result is not None
- assert result.owner == "owner"
- assert result.name == "repo"