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.
- kctl_conform/__init__.py +1 -0
- kctl_conform/baseline.py +31 -0
- kctl_conform/cli.py +119 -0
- kctl_conform/core/__init__.py +0 -0
- kctl_conform/core/callbacks.py +18 -0
- kctl_conform/core/config.py +18 -0
- kctl_conform/core/exceptions.py +5 -0
- kctl_conform/engine.py +66 -0
- kctl_conform/introspect.py +65 -0
- kctl_conform/model.py +42 -0
- kctl_conform/registry.py +35 -0
- kctl_conform/report.py +56 -0
- kctl_conform/rules/__init__.py +3 -0
- kctl_conform/rules/afford.py +92 -0
- kctl_conform/rules/docs.py +58 -0
- kctl_conform/rules/layout.py +117 -0
- kctl_conform/rules/naming.py +47 -0
- kctl_conform/rules/repo.py +153 -0
- kctl_conform/rules/size.py +120 -0
- kctl_conform/rules/testing.py +83 -0
- kctl_conform/rules/wire.py +99 -0
- kctl_conform/workspace.py +99 -0
- kctl_conform-0.2.0.dist-info/METADATA +8 -0
- kctl_conform-0.2.0.dist-info/RECORD +26 -0
- kctl_conform-0.2.0.dist-info/WHEEL +4 -0
- kctl_conform-0.2.0.dist-info/entry_points.txt +2 -0
kctl_conform/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
kctl_conform/baseline.py
ADDED
|
@@ -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)")
|
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}"
|
kctl_conform/registry.py
ADDED
|
@@ -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,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
|