kctl-conform 0.2.0__py3-none-any.whl

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.
@@ -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
kctl_conform/cli.py ADDED
@@ -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)."""
kctl_conform/engine.py ADDED
@@ -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()
kctl_conform/model.py ADDED
@@ -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]
kctl_conform/report.py ADDED
@@ -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"]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from ..introspect import command_tree, global_options, group_names, load_app
4
+ from ..model import How, Rule, Scope, Severity, Violation
5
+ from ..registry import package_rule
6
+ from ..workspace import PackageCtx
7
+
8
+
9
+ @package_rule(
10
+ Rule(
11
+ "AFFORD-001",
12
+ "AFFORD",
13
+ Severity.ERROR,
14
+ Scope.PACKAGE,
15
+ "--json and --profile resolve",
16
+ "Uniform global options for agents.",
17
+ How.INTROSPECT,
18
+ )
19
+ )
20
+ def check_global_options(ctx: PackageCtx) -> list[Violation]:
21
+ app = load_app(ctx.module)
22
+ if app is None:
23
+ return [Violation("AFFORD-001", ctx.module, "could not import app for introspection")]
24
+ opts = global_options(app)
25
+ out: list[Violation] = []
26
+ if "--json" not in opts:
27
+ out.append(Violation("AFFORD-001", ctx.module, "no --json global option"))
28
+ if "--profile" not in opts and "-p" not in opts:
29
+ out.append(Violation("AFFORD-001", ctx.module, "no --profile/-p global option"))
30
+ return out
31
+
32
+
33
+ @package_rule(
34
+ Rule(
35
+ "AFFORD-002",
36
+ "AFFORD",
37
+ Severity.ERROR,
38
+ Scope.PACKAGE,
39
+ "commands tree/list registered",
40
+ "One-call machine-readable discovery.",
41
+ How.INTROSPECT,
42
+ )
43
+ )
44
+ def check_introspection_commands(ctx: PackageCtx) -> list[Violation]:
45
+ app = load_app(ctx.module)
46
+ if app is None:
47
+ return [Violation("AFFORD-002", ctx.module, "could not import app")]
48
+ if "commands" not in group_names(app):
49
+ return [Violation("AFFORD-002", ctx.module, "no 'commands' group (introspection)")]
50
+ return []
51
+
52
+
53
+ @package_rule(
54
+ Rule(
55
+ "AFFORD-004",
56
+ "AFFORD",
57
+ Severity.WARN,
58
+ Scope.PACKAGE,
59
+ "doctor ai-summary registered",
60
+ "One-call diagnostic summary for agents.",
61
+ How.INTROSPECT,
62
+ )
63
+ )
64
+ def check_doctor_ai_summary(ctx: PackageCtx) -> list[Violation]:
65
+ app = load_app(ctx.module)
66
+ if app is None:
67
+ return []
68
+ paths = {ci.path for ci in command_tree(app)}
69
+ if not any(p == "doctor ai-summary" for p in paths):
70
+ return [Violation("AFFORD-004", ctx.module, "no 'doctor ai-summary' command")]
71
+ return []
72
+
73
+
74
+ @package_rule(
75
+ Rule(
76
+ "AFFORD-006",
77
+ "AFFORD",
78
+ Severity.WARN,
79
+ Scope.PACKAGE,
80
+ "skill generate registered",
81
+ "Auto-generate SKILL.md from introspection.",
82
+ How.INTROSPECT,
83
+ )
84
+ )
85
+ def check_skill_generate(ctx: PackageCtx) -> list[Violation]:
86
+ app = load_app(ctx.module)
87
+ if app is None:
88
+ return []
89
+ paths = {ci.path for ci in command_tree(app)}
90
+ if not any(p == "skill generate" for p in paths):
91
+ return [Violation("AFFORD-006", ctx.module, "no 'skill generate' command")]
92
+ return []
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from ..model import How, Rule, Scope, Severity, Violation
6
+ from ..registry import package_rule
7
+ from ..workspace import PackageCtx
8
+
9
+ _README_MIN = 60
10
+ _DESC_MAX = 200
11
+
12
+
13
+ @package_rule(
14
+ Rule(
15
+ "DOC-001",
16
+ "DOC",
17
+ Severity.WARN,
18
+ Scope.PACKAGE,
19
+ "README >= 60 lines",
20
+ "Docs scale with the CLI surface.",
21
+ How.STATIC,
22
+ )
23
+ )
24
+ def check_readme_length(ctx: PackageCtx) -> list[Violation]:
25
+ n = PackageCtx.line_count(ctx.readme())
26
+ if n >= _README_MIN:
27
+ return []
28
+ return [Violation("DOC-001", str(ctx.readme()), f"{n} lines (min {_README_MIN})")]
29
+
30
+
31
+ @package_rule(
32
+ Rule(
33
+ "DOC-002",
34
+ "DOC",
35
+ Severity.WARN,
36
+ Scope.PACKAGE,
37
+ "SKILL.md description <= 200 chars",
38
+ "Slim picker payloads; keywords go in the body.",
39
+ How.STATIC,
40
+ )
41
+ )
42
+ def check_skill_description(ctx: PackageCtx) -> list[Violation]:
43
+ skills = ctx.path / "skills"
44
+ if not skills.is_dir():
45
+ return []
46
+ out: list[Violation] = []
47
+ for skill in skills.rglob("SKILL.md"):
48
+ text = skill.read_text(encoding="utf-8", errors="replace")
49
+ m = re.search(r"(?m)^description:\s*(.+)$", text)
50
+ if m and len(m.group(1).strip()) > _DESC_MAX:
51
+ out.append(
52
+ Violation(
53
+ "DOC-002",
54
+ str(skill),
55
+ f"description {len(m.group(1).strip())} chars (max {_DESC_MAX})",
56
+ )
57
+ )
58
+ return out