diff --git a/README.md b/README.md index 17e5ece..4bc683b 100644 --- a/README.md +++ b/README.md @@ -134,14 +134,20 @@ print("compatible:", report.is_compatible) ```bash tool-semantics --version -tool-semantics capture [-o .tool-semantics/snapshot.json] +tool-semantics capture [-o .tool-semantics/snapshot.json] [-v] tool-semantics compare \ [--json-output report.json] \ - [--markdown-output report.md] + [--markdown-output report.md] \ + [--config .tool-semantics.toml] \ + [-v] ``` +- `--verbose` / `-v` logs paths, tool counts, and change totals to **stderr** (default Rich UX unchanged). +- `--config` loads ignore rules; if omitted, `.tool-semantics.toml` in the cwd is used when present. + JSON reports include `changes`, `is_compatible`, and `counts` by severity. -Change-code catalog: [docs/change-codes.md](docs/change-codes.md). +Change-code catalog: [docs/change-codes.md](docs/change-codes.md). +Ignore-config schema: [docs/config.md](docs/config.md). ## Project layout diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..6592cb2 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,34 @@ +# Config + +Tool-Semantics reads optional project config from `.tool-semantics.toml` in the +current working directory, or from an explicit `--config` path. + +## Schema + +```toml +[ignore] +codes = ["tool.description_changed", "parameter.default_changed"] +subjects = ["experimental_*", "*.debug"] +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `ignore.codes` | string array | Exact change codes to ignore (see [change-codes.md](change-codes.md)) | +| `ignore.subjects` | string array | `fnmatch` patterns against change subjects (`tool` or `tool.param`) | + +## Severity model + +Matched **warning / breaking / critical** changes are **downgraded to `info`** and +prefixed with `[ignored]` in the message. They remain visible in Markdown/JSON +reports and the CLI table, but no longer make `is_compatible` false or force +`compare` to exit `1`. + +This lets teams adopt strict gates incrementally without hiding history. + +## CLI + +```bash +tool-semantics compare baseline.json candidate.json --config .tool-semantics.toml +# or rely on cwd discovery: +tool-semantics compare baseline.json candidate.json +``` diff --git a/examples/tool-semantics.toml b/examples/tool-semantics.toml new file mode 100644 index 0000000..ab8a9ca --- /dev/null +++ b/examples/tool-semantics.toml @@ -0,0 +1,18 @@ +# Example Tool-Semantics config — copy to `.tool-semantics.toml` in your project root. +# +# Ignored changes are still listed in reports as severity `info` with an `[ignored]` +# prefix, but they do not fail CI (`compare` exit code stays 0 when only ignored +# breaks remain). + +[ignore] +# Stable change codes from docs/change-codes.md +codes = [ + # "tool.description_changed", + # "parameter.default_changed", +] + +# fnmatch patterns matched against change subjects (tool name or tool.param) +subjects = [ + # "experimental_*", + # "*.internal", +] diff --git a/src/tool_semantics/cli.py b/src/tool_semantics/cli.py index 3fa4d67..e8c9510 100644 --- a/src/tool_semantics/cli.py +++ b/src/tool_semantics/cli.py @@ -9,6 +9,7 @@ from rich.table import Table from tool_semantics import __version__ +from tool_semantics.config import apply_ignore_rules, load_config from tool_semantics.diff import compare_snapshots from tool_semantics.report import render_markdown, severity_style from tool_semantics.scanner import ManifestError, capture_manifest, read_snapshot, write_snapshot @@ -21,6 +22,7 @@ ), ) console = Console() +err_console = Console(stderr=True) def version_callback(value: bool) -> None: @@ -38,6 +40,11 @@ def main( """Tool-Semantics command-line interface.""" +def _log_verbose(verbose: bool, message: str) -> None: + if verbose: + err_console.print(f"[dim]{message}[/dim]") + + @app.command() def capture( manifest: Annotated[ @@ -56,14 +63,28 @@ def capture( help="Where to write the normalized snapshot JSON.", ), ] = Path(".tool-semantics/snapshot.json"), + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Log capture steps to stderr (paths and tool counts).", + ), + ] = False, ) -> None: """Normalize a JSON tool manifest into a Tool-Semantics snapshot.""" + _log_verbose(verbose, f"Reading manifest {manifest.resolve()}") try: snapshot = capture_manifest(manifest) write_snapshot(snapshot, output) except ManifestError as exc: console.print(f"[red]Capture failed:[/red] {exc}") raise typer.Exit(code=2) from exc + _log_verbose( + verbose, + f"Wrote snapshot {output.resolve()} with {len(snapshot.tools)} tools " + f"(server={snapshot.server_name})", + ) console.print( f"[green]Captured[/green] {len(snapshot.tools)} tools from " f"[bold]{snapshot.server_name}[/bold] into {output}" @@ -105,16 +126,51 @@ def compare( help="Write a GitHub-friendly Markdown report.", ), ] = None, + config: Annotated[ + Path | None, + typer.Option( + "--config", + "-c", + help="Path to `.tool-semantics.toml` (default: look in cwd).", + ), + ] = None, + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Log compare steps to stderr (paths, tool counts, change totals).", + ), + ] = False, ) -> None: """Compare two Tool-Semantics snapshots (exit 1 on breaking/critical).""" _require_snapshot_file(baseline, "Baseline") _require_snapshot_file(candidate, "Candidate") + _log_verbose(verbose, f"Loading baseline {baseline.resolve()}") + _log_verbose(verbose, f"Loading candidate {candidate.resolve()}") try: - report = compare_snapshots(read_snapshot(baseline), read_snapshot(candidate)) - except ManifestError as exc: + config_data = load_config(config) + baseline_snap = read_snapshot(baseline) + candidate_snap = read_snapshot(candidate) + _log_verbose( + verbose, + f"Tools: baseline={len(baseline_snap.tools)} candidate={len(candidate_snap.tools)}", + ) + report = apply_ignore_rules( + compare_snapshots(baseline_snap, candidate_snap), + config_data, + ) + except (ManifestError, FileNotFoundError, ValueError) as exc: console.print(f"[red]Comparison failed:[/red] {exc}") raise typer.Exit(code=2) from exc + counts = report.counts_by_severity() + _log_verbose( + verbose, + f"Changes={len(report.changes)} counts={counts} " + f"compatible={report.is_compatible}", + ) + table = Table(title=f"Tool-Semantics: {report.baseline} → {report.candidate}") for heading in ("Severity", "Code", "Subject", "Change"): table.add_column(heading) diff --git a/src/tool_semantics/config.py b/src/tool_semantics/config.py new file mode 100644 index 0000000..cb82ec8 --- /dev/null +++ b/src/tool_semantics/config.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import fnmatch +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from tool_semantics.diff import Change, CompatibilityReport, Severity + +DEFAULT_CONFIG_NAME = ".tool-semantics.toml" + + +@dataclass(frozen=True) +class IgnoreRules: + codes: tuple[str, ...] = () + subjects: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ToolSemanticsConfig: + ignore: IgnoreRules = field(default_factory=IgnoreRules) + + +def _as_str_tuple(value: Any, field_name: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ValueError(f"Config field '{field_name}' must be an array of strings") + return tuple(value) + + +def load_config(path: Path | None = None) -> ToolSemanticsConfig: + """Load `.tool-semantics.toml` (or an explicit path). Missing file → empty config.""" + config_path = path + if config_path is None: + candidate = Path.cwd() / DEFAULT_CONFIG_NAME + if not candidate.is_file(): + return ToolSemanticsConfig() + config_path = candidate + elif not config_path.is_file(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + raw = tomllib.loads(config_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("Config root must be a table") + ignore_raw = raw.get("ignore", {}) + if ignore_raw is None: + ignore_raw = {} + if not isinstance(ignore_raw, dict): + raise ValueError("Config field 'ignore' must be a table") + return ToolSemanticsConfig( + ignore=IgnoreRules( + codes=_as_str_tuple(ignore_raw.get("codes"), "ignore.codes"), + subjects=_as_str_tuple(ignore_raw.get("subjects"), "ignore.subjects"), + ) + ) + + +def _is_ignored(change: Change, rules: IgnoreRules) -> bool: + if change.code in rules.codes: + return True + return any(fnmatch.fnmatchcase(change.subject, pattern) for pattern in rules.subjects) + + +def apply_ignore_rules( + report: CompatibilityReport, config: ToolSemanticsConfig +) -> CompatibilityReport: + """Downgrade ignored changes to info so they stay visible but do not fail CI.""" + if not config.ignore.codes and not config.ignore.subjects: + return report + adjusted: list[Change] = [] + for change in report.changes: + if _is_ignored(change, config.ignore) and change.severity in { + Severity.WARNING, + Severity.BREAKING, + Severity.CRITICAL, + }: + adjusted.append( + Change( + severity=Severity.INFO, + code=change.code, + subject=change.subject, + message=f"[ignored] {change.message}", + ) + ) + else: + adjusted.append(change) + return CompatibilityReport( + baseline=report.baseline, + candidate=report.candidate, + changes=adjusted, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..16eacde --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from typer.testing import CliRunner + +from tool_semantics.cli import app + +runner = CliRunner() + + +def test_capture_verbose_writes_stderr(tmp_path: Path) -> None: + output = tmp_path / "snap.json" + result = runner.invoke( + app, + [ + "capture", + "examples/github_server_v1.json", + "-o", + str(output), + "--verbose", + ], + ) + assert result.exit_code == 0 + assert "Reading manifest" in result.stderr + assert output.is_file() + + +def test_compare_verbose_and_config_ignore(tmp_path: Path) -> None: + baseline = tmp_path / "v1.json" + candidate = tmp_path / "v2.json" + config = tmp_path / "rules.toml" + assert ( + runner.invoke( + app, + ["capture", "examples/github_server_v1.json", "-o", str(baseline)], + ).exit_code + == 0 + ) + assert ( + runner.invoke( + app, + ["capture", "examples/github_server_v2.json", "-o", str(candidate)], + ).exit_code + == 0 + ) + config.write_text( + '[ignore]\ncodes = ["tool.removed", "parameter.added_required"]\n', + encoding="utf-8", + ) + result = runner.invoke( + app, + [ + "compare", + str(baseline), + str(candidate), + "--config", + str(config), + "--verbose", + ], + ) + assert "Changes=" in result.stderr + assert result.exit_code == 0 + assert "compatible" in result.stdout.lower() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..77bce6d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,77 @@ +from pathlib import Path + +from tool_semantics.config import ( + IgnoreRules, + ToolSemanticsConfig, + apply_ignore_rules, + load_config, +) +from tool_semantics.diff import Change, CompatibilityReport, Severity, compare_snapshots +from tool_semantics.scanner import capture_manifest + + +def test_load_config_missing_returns_empty(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + config = load_config() + assert config.ignore.codes == () + assert config.ignore.subjects == () + + +def test_load_config_from_path(tmp_path: Path) -> None: + path = tmp_path / "rules.toml" + path.write_text( + ( + "[ignore]\n" + 'codes = ["tool.description_changed"]\n' + 'subjects = ["experimental_*", "*.debug"]\n' + ), + encoding="utf-8", + ) + config = load_config(path) + assert config.ignore.codes == ("tool.description_changed",) + assert config.ignore.subjects == ("experimental_*", "*.debug") + + +def test_ignore_by_code_downgrades_breaking() -> None: + baseline = capture_manifest(Path("examples/github_server_v1.json")) + candidate = capture_manifest(Path("examples/github_server_v2.json")) + report = compare_snapshots(baseline, candidate) + assert not report.is_compatible + filtered = apply_ignore_rules( + report, + ToolSemanticsConfig(ignore=IgnoreRules(codes=("tool.removed", "parameter.added_required"))), + ) + # Other breaks may remain; ensure ignored codes are info and prefixed. + for change in filtered.changes: + if change.code in {"tool.removed", "parameter.added_required"}: + assert change.severity == Severity.INFO + assert change.message.startswith("[ignored]") + + +def test_ignore_by_subject_glob() -> None: + report = CompatibilityReport( + baseline="1", + candidate="2", + changes=[ + Change( + severity=Severity.BREAKING, + code="tool.removed", + subject="experimental_search", + message="gone", + ), + Change( + severity=Severity.BREAKING, + code="tool.removed", + subject="search_issues", + message="gone", + ), + ], + ) + filtered = apply_ignore_rules( + report, + ToolSemanticsConfig(ignore=IgnoreRules(subjects=("experimental_*",))), + ) + by_subject = {change.subject: change for change in filtered.changes} + assert by_subject["experimental_search"].severity == Severity.INFO + assert by_subject["search_issues"].severity == Severity.BREAKING + assert not filtered.is_compatible