kctl-conform 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. kctl_conform-0.2.0/.gitignore +36 -0
  2. kctl_conform-0.2.0/PKG-INFO +8 -0
  3. kctl_conform-0.2.0/README.md +73 -0
  4. kctl_conform-0.2.0/pyproject.toml +31 -0
  5. kctl_conform-0.2.0/src/kctl_conform/__init__.py +1 -0
  6. kctl_conform-0.2.0/src/kctl_conform/baseline.py +31 -0
  7. kctl_conform-0.2.0/src/kctl_conform/cli.py +119 -0
  8. kctl_conform-0.2.0/src/kctl_conform/core/__init__.py +0 -0
  9. kctl_conform-0.2.0/src/kctl_conform/core/callbacks.py +18 -0
  10. kctl_conform-0.2.0/src/kctl_conform/core/config.py +18 -0
  11. kctl_conform-0.2.0/src/kctl_conform/core/exceptions.py +5 -0
  12. kctl_conform-0.2.0/src/kctl_conform/engine.py +66 -0
  13. kctl_conform-0.2.0/src/kctl_conform/introspect.py +65 -0
  14. kctl_conform-0.2.0/src/kctl_conform/model.py +42 -0
  15. kctl_conform-0.2.0/src/kctl_conform/registry.py +35 -0
  16. kctl_conform-0.2.0/src/kctl_conform/report.py +56 -0
  17. kctl_conform-0.2.0/src/kctl_conform/rules/__init__.py +3 -0
  18. kctl_conform-0.2.0/src/kctl_conform/rules/afford.py +92 -0
  19. kctl_conform-0.2.0/src/kctl_conform/rules/docs.py +58 -0
  20. kctl_conform-0.2.0/src/kctl_conform/rules/layout.py +117 -0
  21. kctl_conform-0.2.0/src/kctl_conform/rules/naming.py +47 -0
  22. kctl_conform-0.2.0/src/kctl_conform/rules/repo.py +153 -0
  23. kctl_conform-0.2.0/src/kctl_conform/rules/size.py +120 -0
  24. kctl_conform-0.2.0/src/kctl_conform/rules/testing.py +83 -0
  25. kctl_conform-0.2.0/src/kctl_conform/rules/wire.py +99 -0
  26. kctl_conform-0.2.0/src/kctl_conform/workspace.py +99 -0
  27. kctl_conform-0.2.0/tests/__init__.py +0 -0
  28. kctl_conform-0.2.0/tests/conftest.py +22 -0
  29. kctl_conform-0.2.0/tests/fixtures/broken/pyproject_root/packages/kctl-bad/src/kctl_bad/__init__.py +0 -0
  30. kctl_conform-0.2.0/tests/fixtures/broken/pyproject_root/pyproject.toml +2 -0
  31. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/justfile +11 -0
  32. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/README.md +100 -0
  33. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/skills/good-admin/SKILL.md +1 -0
  34. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/src/kctl_good/cli.py +2 -0
  35. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/src/kctl_good/commands/__init__.py +0 -0
  36. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/src/kctl_good/core/callbacks.py +2 -0
  37. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/src/kctl_good/core/client.py +0 -0
  38. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/src/kctl_good/core/config.py +0 -0
  39. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/src/kctl_good/core/exceptions.py +0 -0
  40. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/tests/conftest.py +0 -0
  41. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/packages/kctl-good/tests/test_standard.py +10 -0
  42. kctl_conform-0.2.0/tests/fixtures/conformant/pyproject_root/pyproject.toml +2 -0
  43. kctl_conform-0.2.0/tests/test_afford_testing_rules.py +33 -0
  44. kctl_conform-0.2.0/tests/test_baseline.py +31 -0
  45. kctl_conform-0.2.0/tests/test_cli_commands.py +50 -0
  46. kctl_conform-0.2.0/tests/test_engine.py +48 -0
  47. kctl_conform-0.2.0/tests/test_integration.py +22 -0
  48. kctl_conform-0.2.0/tests/test_introspect.py +43 -0
  49. kctl_conform-0.2.0/tests/test_layout_docs_rules.py +49 -0
  50. kctl_conform-0.2.0/tests/test_naming_rules.py +41 -0
  51. kctl_conform-0.2.0/tests/test_registry.py +22 -0
  52. kctl_conform-0.2.0/tests/test_repo_rules.py +47 -0
  53. kctl_conform-0.2.0/tests/test_report.py +25 -0
  54. kctl_conform-0.2.0/tests/test_size_rules.py +41 -0
  55. kctl_conform-0.2.0/tests/test_standard.py +18 -0
  56. kctl_conform-0.2.0/tests/test_wire_rules.py +38 -0
  57. kctl_conform-0.2.0/tests/test_workspace.py +45 -0
@@ -0,0 +1,36 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .idea/
16
+ .vscode/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
34
+
35
+ # Agent memory (claude-mem regenerates AGENTS.md locally; not a committed guide)
36
+ AGENTS.md
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: kctl-conform
3
+ Version: 0.2.0
4
+ Summary: Conformance checker for the kctl-* CLI architecture standard
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: kctl-lib>=0.4.0
7
+ Requires-Dist: rich>=13.0
8
+ Requires-Dist: typer>=0.9.0
@@ -0,0 +1,73 @@
1
+ # kctl-conform
2
+
3
+ Conformance checker for the **kctl CLI architecture standard**. It scores every
4
+ `packages/kctl-*` package and the workspace against a written set of rules, and
5
+ gates CI with a baseline/ratchet so existing debt is grandfathered while new
6
+ violations are blocked.
7
+
8
+ See the standard: `docs/standards/kctl-cli-architecture.md`.
9
+
10
+ ## Install / run
11
+
12
+ ```bash
13
+ uv sync --all-extras --all-packages
14
+ uv run kctl-conform check
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ | Command | Purpose |
20
+ |---------|---------|
21
+ | `kctl-conform check [--package <name>] [--json] [--ci]` | Evaluate rules across the workspace. `--ci` exits non-zero on violations not in the baseline. |
22
+ | `kctl-conform report [--format markdown\|json]` | Fleet dashboard / refactor backlog. |
23
+ | `kctl-conform explain <RULE-ID>` | Explain a rule: title, rationale, severity, fix hint. |
24
+ | `kctl-conform baseline [--verify]` | Write (or verify) the ratchet baseline of current violations. |
25
+
26
+ ## Rule categories
27
+
28
+ | Category | Concern |
29
+ |----------|---------|
30
+ | `LAYOUT` | Required files/dirs per package (cli.py, core/, commands/, tests/, SKILL.md). |
31
+ | `SIZE` | File-size caps for cli.py and command modules; registration extraction. |
32
+ | `WIRE` | Required kctl-lib wiring (cli_entrypoint, introspection, AppContext). |
33
+ | `AFFORD` | AI affordances (--json/--profile, commands tree/list, doctor ai-summary, skill generate). |
34
+ | `TEST` | conftest.py, test_standard.py with the 4 standard classes, test-to-command ratio. |
35
+ | `DOC` | README length, SKILL.md description length. |
36
+ | `REPO` | Workspace hygiene (no tracked build artifacts, orphan tests, co-located skills, justfile). |
37
+
38
+ ## Severities
39
+
40
+ - `error` — a clear breakage; weighted 3x in scoring.
41
+ - `warn` — drift worth fixing; weighted 1x.
42
+
43
+ ## Package classification
44
+
45
+ A package may opt out of CLI rules via its own `pyproject.toml`:
46
+
47
+ ```toml
48
+ [tool.kctl-conform]
49
+ kind = "library" # skipped entirely (e.g. kctl-lib)
50
+ # or
51
+ kind = "meta"
52
+ exempt = ["AFFORD-*"] # rule-id globs dropped for this package
53
+ ```
54
+
55
+ - `kind = "library"` — not a CLI; excluded from all package rules.
56
+ - `exempt = [...]` — rule-id glob patterns whose violations are dropped for
57
+ this package (used by meta/dev tools where a rule is genuinely N/A).
58
+
59
+ ## Baseline / ratchet
60
+
61
+ ```bash
62
+ uv run kctl-conform baseline # snapshot current violations
63
+ uv run kctl-conform check --ci # fail only on NEW violations
64
+ ```
65
+
66
+ The committed `conformance-baseline.json` records accepted existing debt;
67
+ re-run `baseline` after fixing violations to ratchet it down.
68
+
69
+ ## Tests
70
+
71
+ ```bash
72
+ uv run pytest packages/kctl-conform/tests/ -v
73
+ ```
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-conform"
7
+ version = "0.2.0"
8
+ description = "Conformance checker for the kctl-* CLI architecture standard"
9
+ requires-python = ">=3.12"
10
+ dependencies = ["kctl-lib>=0.4.0", "typer>=0.9.0", "rich>=13.0"]
11
+
12
+ [project.scripts]
13
+ kctl-conform = "kctl_conform.cli:app"
14
+
15
+ [tool.uv.sources]
16
+ kctl-lib = { workspace = true }
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/kctl_conform"]
20
+
21
+ [tool.kctl-conform]
22
+ kind = "meta"
23
+ # Rules genuinely N/A for a profile-less local analysis tool (not a service CLI):
24
+ # AFFORD-001 = no global --json/--profile options
25
+ # AFFORD-004 = no `doctor ai-summary` (no external service to probe)
26
+ # AFFORD-006 = no `skill generate` (no agent-facing SKILL.md)
27
+ # LAYOUT-006 = no core/client.py (reads the local filesystem, no API client)
28
+ # LAYOUT-003 = commands live in cli.py; no commands/ package
29
+ # LAYOUT-005 = no skills/<x>-admin/SKILL.md (developer tool, not an agent endpoint)
30
+ # TEST-002 = test_standard.py has Version/Help only; no config/completions groups to test
31
+ exempt = ["AFFORD-001", "AFFORD-004", "AFFORD-006", "LAYOUT-003", "LAYOUT-005", "LAYOUT-006", "TEST-002"]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .model import Violation
7
+
8
+
9
+ def save_baseline(path: Path, violations: list[Violation]) -> None:
10
+ payload = {
11
+ "version": 1,
12
+ "violations": sorted({v.key() for v in violations}),
13
+ }
14
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
15
+
16
+
17
+ def load_baseline(path: Path) -> set[str]:
18
+ if not path.is_file():
19
+ return set()
20
+ data = json.loads(path.read_text(encoding="utf-8"))
21
+ return set(data.get("violations", []))
22
+
23
+
24
+ def new_violations(current: list[Violation], baseline: set[str]) -> list[Violation]:
25
+ return [v for v in current if v.key() not in baseline]
26
+
27
+
28
+ def stale_baseline_keys(current: list[Violation], baseline: set[str]) -> set[str]:
29
+ """Baselined keys that are no longer violated (ratchet has slack to tighten)."""
30
+ current_keys = {v.key() for v in current}
31
+ return baseline - current_keys
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+ from kctl_lib import cli_entrypoint, register_introspection_commands
5
+
6
+ import kctl_conform.rules # noqa: F401 (registers all rules)
7
+
8
+ from . import __version__
9
+ from .baseline import load_baseline, new_violations, save_baseline, stale_baseline_keys
10
+ from .engine import run_all
11
+ from .registry import all_rules
12
+ from .report import render_json, render_markdown
13
+ from .workspace import WorkspaceInfo
14
+
15
+ app = typer.Typer(
16
+ name="kctl-conform",
17
+ help="Score kctl-* packages and the workspace against the architecture standard.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ BASELINE_NAME = "conformance-baseline.json"
22
+
23
+
24
+ def _version_callback(value: bool) -> None:
25
+ if value:
26
+ typer.echo(f"kctl-conform {__version__}")
27
+ raise typer.Exit()
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ version: bool = typer.Option(
33
+ False,
34
+ "--version",
35
+ "-V",
36
+ callback=_version_callback,
37
+ is_eager=True,
38
+ help="Show version and exit.",
39
+ ),
40
+ ) -> None:
41
+ """kctl-conform CLI."""
42
+
43
+
44
+ @app.command()
45
+ def check(
46
+ package: str = typer.Option(None, "--package", help="Limit to one package."),
47
+ json_out: bool = typer.Option(False, "--json", help="Emit JSON."),
48
+ ci: bool = typer.Option(False, "--ci", help="Exit non-zero on violations absent from baseline."),
49
+ ) -> None:
50
+ """Evaluate the standard across the workspace."""
51
+ ws = WorkspaceInfo.discover()
52
+ if package:
53
+ ws.packages = [p for p in ws.packages if p.name == package]
54
+ report = run_all(ws)
55
+ typer.echo(render_json(report) if json_out else render_markdown(report))
56
+ if ci:
57
+ baseline = load_baseline(ws.root / BASELINE_NAME)
58
+ fresh = new_violations(report.all_violations(), baseline)
59
+ if fresh:
60
+ typer.echo(f"\n{len(fresh)} new violation(s) not in baseline:", err=True)
61
+ for v in fresh:
62
+ typer.echo(f" {v.rule_id} {v.location} — {v.detail}", err=True)
63
+ raise typer.Exit(code=1)
64
+
65
+
66
+ @app.command()
67
+ def report(
68
+ fmt: str = typer.Option("markdown", "--format", "-f", help="markdown|json"),
69
+ ) -> None:
70
+ """Render the fleet dashboard / refactor backlog."""
71
+ ws = WorkspaceInfo.discover()
72
+ rep = run_all(ws)
73
+ typer.echo(render_json(rep) if fmt == "json" else render_markdown(rep))
74
+
75
+
76
+ @app.command()
77
+ def explain(rule_id: str = typer.Argument(..., help="e.g. SIZE-004")) -> None:
78
+ """Explain a rule: title, rationale, severity, autofix hint."""
79
+ idx = {r.id: r for r in all_rules()}
80
+ rule = idx.get(rule_id.upper())
81
+ if rule is None:
82
+ typer.echo(f"unknown rule: {rule_id}", err=True)
83
+ raise typer.Exit(code=2)
84
+ typer.echo(f"{rule.id} [{rule.severity.value}] ({rule.category}, {rule.scope.value})")
85
+ typer.echo(f" {rule.title}")
86
+ typer.echo(f" why: {rule.rationale}")
87
+ typer.echo(f" checked: {rule.how.value}")
88
+ if rule.autofix_hint:
89
+ typer.echo(f" fix: {rule.autofix_hint}")
90
+
91
+
92
+ @app.command()
93
+ def baseline(
94
+ verify: bool = typer.Option(False, "--verify", help="Report drift instead of writing."),
95
+ ) -> None:
96
+ """Write (or verify) the ratchet baseline of current violations."""
97
+ ws = WorkspaceInfo.discover()
98
+ rep = run_all(ws)
99
+ current = rep.all_violations()
100
+ path = ws.root / BASELINE_NAME
101
+ if verify:
102
+ existing = load_baseline(path)
103
+ fresh = new_violations(current, existing)
104
+ stale = stale_baseline_keys(current, existing)
105
+ if fresh or stale:
106
+ for v in fresh:
107
+ typer.echo(f"NEW {v.rule_id} {v.location}", err=True)
108
+ for k in sorted(stale):
109
+ typer.echo(f"STALE {k}", err=True)
110
+ raise typer.Exit(code=1)
111
+ typer.echo("baseline is current")
112
+ return
113
+ save_baseline(path, current)
114
+ typer.echo(f"wrote {len(set(v.key() for v in current))} entries to {path}")
115
+
116
+
117
+ # Wrap app so KctlError subclasses surface as clean user-facing messages.
118
+ app = cli_entrypoint(app)
119
+ register_introspection_commands(app)
File without changes
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from kctl_lib.callbacks import AppContextBase
7
+
8
+
9
+ @dataclass
10
+ class AppContext(AppContextBase):
11
+ """Standard kctl context scaffold (satisfies the AppContextBase convention).
12
+
13
+ kctl-conform is a workspace analysis tool with no service client or profile;
14
+ commands resolve the workspace via WorkspaceInfo.discover() directly, so this
15
+ context is intentionally minimal.
16
+ """
17
+
18
+ root_override: Path | None = None
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from .exceptions import ConformError
7
+
8
+
9
+ def find_workspace_root(start: Path | None = None) -> Path:
10
+ """Walk upward until a pyproject.toml declaring a uv workspace is found."""
11
+ cur = (start or Path.cwd()).resolve()
12
+ for candidate in [cur, *cur.parents]:
13
+ pp = candidate / "pyproject.toml"
14
+ if pp.is_file():
15
+ data = tomllib.loads(pp.read_text(encoding="utf-8"))
16
+ if "workspace" in data.get("tool", {}).get("uv", {}):
17
+ return candidate
18
+ raise ConformError("could not locate uv workspace root (no [tool.uv.workspace] found)")
@@ -0,0 +1,5 @@
1
+ from kctl_lib.exceptions import KctlError
2
+
3
+
4
+ class ConformError(KctlError):
5
+ """Raised when the conformance checker cannot run (e.g. workspace not found)."""
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from dataclasses import dataclass, field
5
+
6
+ from .model import Rule, Severity, Violation
7
+ from .registry import PACKAGE_RULES, REPO_RULES, all_rules
8
+ from .workspace import WorkspaceInfo
9
+
10
+ WEIGHT = {Severity.ERROR: 3, Severity.WARN: 1}
11
+
12
+
13
+ @dataclass
14
+ class Report:
15
+ by_package: dict[str, list[Violation]] = field(default_factory=dict)
16
+ repo: list[Violation] = field(default_factory=list)
17
+
18
+ def all_violations(self) -> list[Violation]:
19
+ out = list(self.repo)
20
+ for vs in self.by_package.values():
21
+ out.extend(vs)
22
+ return out
23
+
24
+
25
+ def _dedup(violations: list[Violation]) -> list[Violation]:
26
+ seen: dict[str, Violation] = {}
27
+ for v in violations:
28
+ seen.setdefault(v.key(), v)
29
+ return list(seen.values())
30
+
31
+
32
+ def _is_exempt(rule_id: str, patterns: list[str]) -> bool:
33
+ return any(fnmatch.fnmatch(rule_id, p) for p in patterns)
34
+
35
+
36
+ def run_all(ws: WorkspaceInfo) -> Report:
37
+ report = Report()
38
+ for pkg in ws.packages:
39
+ if pkg.kind == "library":
40
+ continue
41
+ collected: list[Violation] = []
42
+ for _rule, check in PACKAGE_RULES:
43
+ collected.extend(check(pkg))
44
+ deduped = _dedup(collected)
45
+ report.by_package[pkg.name] = [v for v in deduped if not _is_exempt(v.rule_id, pkg.exempt)]
46
+ repo_ctx = ws.repo_ctx()
47
+ repo_collected: list[Violation] = []
48
+ for _rule, repo_check in REPO_RULES:
49
+ repo_collected.extend(repo_check(repo_ctx))
50
+ report.repo = _dedup(repo_collected)
51
+ return report
52
+
53
+
54
+ def _rule_index() -> dict[str, Rule]:
55
+ return {r.id: r for r in all_rules()}
56
+
57
+
58
+ def package_score(report: Report, pkg_name: str) -> float:
59
+ rules = [r for r, _ in PACKAGE_RULES]
60
+ total = sum(WEIGHT[r.severity] for r in rules)
61
+ if total == 0:
62
+ return 100.0
63
+ failed_ids = {v.rule_id for v in report.by_package.get(pkg_name, [])}
64
+ idx = _rule_index()
65
+ lost = sum(WEIGHT[idx[rid].severity] for rid in failed_ids if rid in idx)
66
+ return round(100.0 * (1 - lost / total), 1)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from dataclasses import dataclass, field
5
+
6
+ import click
7
+ import typer
8
+
9
+
10
+ @dataclass
11
+ class CommandInfo:
12
+ path: str # "things list"
13
+ options: set[str] = field(default_factory=set)
14
+
15
+
16
+ def _load_command(app: typer.Typer) -> click.Command:
17
+ return typer.main.get_command(app)
18
+
19
+
20
+ def command_tree(app: typer.Typer) -> list[CommandInfo]:
21
+ """Flatten a Typer app into leaf commands with their option names."""
22
+ root = _load_command(app)
23
+ leaves: list[CommandInfo] = []
24
+
25
+ def walk(cmd: click.Command, prefix: list[str]) -> None:
26
+ if isinstance(cmd, click.Group):
27
+ for name, sub in cmd.commands.items():
28
+ walk(sub, prefix + [name])
29
+ else:
30
+ opts: set[str] = set()
31
+ for param in cmd.params:
32
+ if isinstance(param, click.Option):
33
+ opts.update(param.opts)
34
+ leaves.append(CommandInfo(path=" ".join(prefix), options=opts))
35
+
36
+ walk(root, [])
37
+ return [ci for ci in leaves if ci.path]
38
+
39
+
40
+ def load_app(module: str) -> typer.Typer | None:
41
+ """Import `<module>.cli` and return its `app`, or None on failure."""
42
+ try:
43
+ mod = importlib.import_module(f"{module}.cli")
44
+ except Exception:
45
+ return None
46
+ app = getattr(mod, "app", None)
47
+ return app if isinstance(app, typer.Typer) else None
48
+
49
+
50
+ def global_options(app: typer.Typer) -> set[str]:
51
+ """Option names declared on the root group (the CLI's global options)."""
52
+ root = _load_command(app)
53
+ opts: set[str] = set()
54
+ for param in root.params:
55
+ if isinstance(param, click.Option):
56
+ opts.update(param.opts)
57
+ return opts
58
+
59
+
60
+ def group_names(app: typer.Typer) -> set[str]:
61
+ """Top-level command/group names (e.g. {'commands', 'doctor', 'skill'})."""
62
+ root = _load_command(app)
63
+ if isinstance(root, click.Group):
64
+ return set(root.commands.keys())
65
+ return set()
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+
7
+ class Severity(StrEnum):
8
+ ERROR = "error"
9
+ WARN = "warn"
10
+
11
+
12
+ class Scope(StrEnum):
13
+ PACKAGE = "package"
14
+ REPO = "repo"
15
+
16
+
17
+ class How(StrEnum):
18
+ STATIC = "static"
19
+ INTROSPECT = "introspect"
20
+ RUNTIME = "runtime"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Rule:
25
+ id: str
26
+ category: str
27
+ severity: Severity
28
+ scope: Scope
29
+ title: str
30
+ rationale: str
31
+ how: How
32
+ autofix_hint: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class Violation:
37
+ rule_id: str
38
+ location: str
39
+ detail: str
40
+
41
+ def key(self) -> str:
42
+ return f"{self.rule_id}::{self.location}"
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .model import Rule, Violation
7
+
8
+ if TYPE_CHECKING:
9
+ from .workspace import PackageCtx, RepoCtx
10
+
11
+ PackageCheck = Callable[["PackageCtx"], list[Violation]]
12
+ RepoCheck = Callable[["RepoCtx"], list[Violation]]
13
+
14
+ PACKAGE_RULES: list[tuple[Rule, PackageCheck]] = []
15
+ REPO_RULES: list[tuple[Rule, RepoCheck]] = []
16
+
17
+
18
+ def package_rule(rule: Rule) -> Callable[[PackageCheck], PackageCheck]:
19
+ def deco(fn: PackageCheck) -> PackageCheck:
20
+ PACKAGE_RULES.append((rule, fn))
21
+ return fn
22
+
23
+ return deco
24
+
25
+
26
+ def repo_rule(rule: Rule) -> Callable[[RepoCheck], RepoCheck]:
27
+ def deco(fn: RepoCheck) -> RepoCheck:
28
+ REPO_RULES.append((rule, fn))
29
+ return fn
30
+
31
+ return deco
32
+
33
+
34
+ def all_rules() -> list[Rule]:
35
+ return [r for r, _ in PACKAGE_RULES] + [r for r, _ in REPO_RULES]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .engine import Report, package_score
6
+ from .model import Violation
7
+ from .registry import all_rules
8
+
9
+
10
+ def _severity_map() -> dict[str, str]:
11
+ return {r.id: r.severity.value for r in all_rules()}
12
+
13
+
14
+ def _count(violations: list[Violation], severity: str, sev_map: dict[str, str]) -> int:
15
+ return sum(1 for v in violations if sev_map.get(v.rule_id) == severity)
16
+
17
+
18
+ def render_json(report: Report) -> str:
19
+ payload = {
20
+ "packages": {
21
+ name: [{"rule_id": v.rule_id, "location": v.location, "detail": v.detail} for v in vs]
22
+ for name, vs in report.by_package.items()
23
+ },
24
+ "repo": [{"rule_id": v.rule_id, "location": v.location, "detail": v.detail} for v in report.repo],
25
+ "scores": {name: package_score(report, name) for name in report.by_package},
26
+ }
27
+ return json.dumps(payload, indent=2)
28
+
29
+
30
+ def render_markdown(report: Report) -> str:
31
+ sev = _severity_map()
32
+ ranked = sorted(report.by_package.items(), key=lambda kv: len(kv[1]), reverse=True)
33
+ lines = [
34
+ "# kctl-conform report",
35
+ "",
36
+ "## Packages (ranked by violations)",
37
+ "",
38
+ "| Package | Score | Errors | Warns |",
39
+ "|---------|-------|--------|-------|",
40
+ ]
41
+ for name, vs in ranked:
42
+ lines.append(
43
+ f"| {name} | {package_score(report, name)} | {_count(vs, 'error', sev)} | {_count(vs, 'warn', sev)} |"
44
+ )
45
+ lines += ["", "## Violations", ""]
46
+ for name, vs in ranked:
47
+ if not vs:
48
+ continue
49
+ lines.append(f"### {name}")
50
+ lines += [f"- `{v.rule_id}` {v.location} — {v.detail}" for v in sorted(vs, key=lambda v: v.rule_id)]
51
+ lines.append("")
52
+ if report.repo:
53
+ lines.append("### repo")
54
+ lines += [f"- `{v.rule_id}` {v.location} — {v.detail}" for v in sorted(report.repo, key=lambda v: v.rule_id)]
55
+ lines.append("")
56
+ return "\n".join(lines)
@@ -0,0 +1,3 @@
1
+ from . import afford, docs, layout, naming, repo, size, testing, wire # noqa: F401
2
+
3
+ __all__ = ["afford", "docs", "layout", "naming", "repo", "size", "testing", "wire"]