From f3dbe9f0e06d37600b2656a3ea3962e7a650198e Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Thu, 26 Feb 2026 07:18:50 +0000 Subject: [PATCH 1/6] Place migrations in future-features --- AI_AGENTS.md | 25 +- CLAUDE.md | 23 +- GET_STARTED.md | 1 - LICENSE | 20 +- LICENSE-COMMERCIAL | 22 - NOTICE.md | 14 +- README.md | 18 +- docs/SKILL.md | 14 - docs/cli-reference.md | 77 ---- features/feature-5-policy-enforcement.md | 22 - features/feature-6-ci-experience-messaging.md | 28 -- pyproject.toml | 6 +- src/specleft/cli/main.py | 4 - src/specleft/commands/__init__.py | 4 - src/specleft/commands/coverage.py | 4 +- src/specleft/commands/enforce.py | 375 ----------------- src/specleft/commands/features.py | 4 +- src/specleft/commands/init.py | 10 +- src/specleft/commands/license.py | 79 ---- src/specleft/commands/plan.py | 10 - src/specleft/commands/status.py | 2 +- src/specleft/enforcement/__init__.py | 13 - src/specleft/enforcement/engine.py | 149 ------- src/specleft/license/__init__.py | 3 - src/specleft/license/repo_identity.py | 97 ----- src/specleft/license/status.py | 117 ------ src/specleft/pytest_plugin.py | 2 - src/specleft/specleft_signing/__init__.py | 87 ---- src/specleft/specleft_signing/canonical.py | 92 ----- src/specleft/specleft_signing/exceptions.py | 36 -- src/specleft/specleft_signing/keys.py | 144 ------- src/specleft/specleft_signing/schema.py | 114 ------ src/specleft/specleft_signing/sign.py | 76 ---- src/specleft/specleft_signing/verify.py | 187 --------- src/specleft/templates/report.html.jinja2 | 5 +- src/specleft/templates/skill_template.py | 14 - tests/acceptance/conftest.py | 20 - tests/acceptance/fixtures/__init__.py | 16 - tests/acceptance/fixtures/feature_5.py | 197 --------- tests/acceptance/fixtures/feature_6.py | 133 ------- .../test_feature-5-policy-enforcement.py | 296 -------------- .../test_feature-6-ci-experience-messaging.py | 256 ------------ tests/commands/test_enforce.py | 376 ------------------ tests/commands/test_enforce_integration.py | 354 ----------------- tests/commands/test_license.py | 122 ------ tests/license/__init__.py | 1 - tests/license/fixtures.py | 202 ---------- tests/license/test_repo_identity.py | 101 ----- 48 files changed, 32 insertions(+), 3940 deletions(-) delete mode 100644 LICENSE-COMMERCIAL delete mode 100644 features/feature-5-policy-enforcement.md delete mode 100644 features/feature-6-ci-experience-messaging.md delete mode 100644 src/specleft/commands/enforce.py delete mode 100644 src/specleft/commands/license.py delete mode 100644 src/specleft/enforcement/__init__.py delete mode 100644 src/specleft/enforcement/engine.py delete mode 100644 src/specleft/license/__init__.py delete mode 100644 src/specleft/license/repo_identity.py delete mode 100644 src/specleft/license/status.py delete mode 100644 src/specleft/specleft_signing/__init__.py delete mode 100644 src/specleft/specleft_signing/canonical.py delete mode 100644 src/specleft/specleft_signing/exceptions.py delete mode 100644 src/specleft/specleft_signing/keys.py delete mode 100644 src/specleft/specleft_signing/schema.py delete mode 100644 src/specleft/specleft_signing/sign.py delete mode 100644 src/specleft/specleft_signing/verify.py delete mode 100644 tests/acceptance/fixtures/feature_5.py delete mode 100644 tests/acceptance/fixtures/feature_6.py delete mode 100644 tests/acceptance/test_feature-5-policy-enforcement.py delete mode 100644 tests/acceptance/test_feature-6-ci-experience-messaging.py delete mode 100644 tests/commands/test_enforce.py delete mode 100644 tests/commands/test_enforce_integration.py delete mode 100644 tests/commands/test_license.py delete mode 100644 tests/license/__init__.py delete mode 100644 tests/license/fixtures.py delete mode 100644 tests/license/test_repo_identity.py 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/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/NOTICE.md b/NOTICE.md index b45f579..c0b2268 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,15 +1,5 @@ # NOTICE -SpecLeft is dual-licensed. +SpecLeft is licensed under the Apache License, Version 2.0. -## 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. +Copyright (c) 2026 SpecLeft Contributors. diff --git a/README.md b/README.md index 4363c0e..402ce89 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,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 +137,5 @@ 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). +See [NOTICE.md](https://github.com/SpecLeft/specleft/blob/main/NOTICE.md) for attribution details. 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..c067757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/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" From 5a9da9543562a8c052c562a364165bab9ff50b78 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Thu, 26 Feb 2026 07:41:17 +0000 Subject: [PATCH 2/6] Update roadmap --- LICENSE-OPEN | 17 ----------------- NOTICE.md | 5 ----- README.md | 14 ++++++++++---- ROADMAP.md | 20 +++++--------------- 4 files changed, 15 insertions(+), 41 deletions(-) delete mode 100644 LICENSE-OPEN delete mode 100644 NOTICE.md 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 c0b2268..0000000 --- a/NOTICE.md +++ /dev/null @@ -1,5 +0,0 @@ -# NOTICE - -SpecLeft is licensed under the Apache License, Version 2.0. - -Copyright (c) 2026 SpecLeft Contributors. diff --git a/README.md b/README.md index 402ce89..e190af0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -138,4 +145,3 @@ For MCP end-to-end smoke testing and CI workflow details, see [docs/mcp-testing. ## License SpecLeft is licensed under [Apache License 2.0](https://github.com/SpecLeft/specleft/blob/main/LICENSE). -See [NOTICE.md](https://github.com/SpecLeft/specleft/blob/main/NOTICE.md) for attribution details. diff --git a/ROADMAP.md b/ROADMAP.md index ca251a9..f63014d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SpecLeft Roadmap -## Current (v0.2.0 - Foundation) +## Current (v0.3.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. +- 🎚️ **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! From d2540d0aaaccfd904b99fbb6ada255d7766e6229 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Thu, 26 Feb 2026 07:52:13 +0000 Subject: [PATCH 3/6] Update readme header --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e190af0..2aaf7a7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![SpecLeft social preview](.github/assets/specleft-social-preview.png) -# SpecLeft: Planning-First Workflow for pytest +# SpecLeft: Spec Driven Workflow for Agents ![Spec coverage](.github/assets/spec-coverage-badge.svg) [![MCP Registry](https://img.shields.io/badge/MCP-Registry-blue)](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) From 43b9bdf28bf9ed6b68a31f6b2dba3dd21b7b1359 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Thu, 26 Feb 2026 20:49:31 +0000 Subject: [PATCH 4/6] Contributing amendments --- CONTRIBUTING.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c02782d..6183c00 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? From cc8dc551da0c0b0966f62d8714fb44a703802edc Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Thu, 26 Feb 2026 20:51:58 +0000 Subject: [PATCH 5/6] Upgrade to v0.4.0 --- pyproject.toml | 2 +- server.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c067757..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" 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" From e4f7f76bcadc60a615f2df058da6bd9518b96187 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Thu, 26 Feb 2026 20:56:54 +0000 Subject: [PATCH 6/6] Remove 0.3.0 wording --- CONTRIBUTING.md | 2 +- ROADMAP.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6183c00..f03b2ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -261,7 +261,7 @@ Examples: ## Testing Guidelines ### Manual Testing -- For new features, we strongly encourage manually testing the functionality to ensure it works as intended. +- 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. diff --git a/ROADMAP.md b/ROADMAP.md index f63014d..6f104ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SpecLeft Roadmap -## Current (v0.3.0 - Foundation) +## Current (v0.4.0 - Foundation) - ✅ Spec-defined test decoration - ✅ Step-by-step test tracing - ✅ Skeleton test generation from JSON specs @@ -16,7 +16,7 @@ - ✍️ **Agent Skills** - Integrated agent skills for more autonomous planning and test generation. -## Future (v0.4.0 and beyond) +## 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.