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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,20 @@ print("compatible:", report.is_compatible)

```bash
tool-semantics --version
tool-semantics capture <manifest.json> [-o .tool-semantics/snapshot.json]
tool-semantics capture <manifest.json> [-o .tool-semantics/snapshot.json] [-v]
tool-semantics compare <baseline.json> <candidate.json> \
[--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

Expand Down
34 changes: 34 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
@@ -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
```
18 changes: 18 additions & 0 deletions examples/tool-semantics.toml
Original file line number Diff line number Diff line change
@@ -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",
]
60 changes: 58 additions & 2 deletions src/tool_semantics/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,7 @@
),
)
console = Console()
err_console = Console(stderr=True)


def version_callback(value: bool) -> None:
Expand All @@ -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[
Expand All @@ -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}"
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions src/tool_semantics/config.py
Original file line number Diff line number Diff line change
@@ -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,
)
62 changes: 62 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading