intendant 4.0.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.
- intendant/__init__.py +3 -0
- intendant/__main__.py +6 -0
- intendant/adapters/__init__.py +0 -0
- intendant/adapters/claude_skill/__init__.py +26 -0
- intendant/adapters/claude_skill/ci.py +34 -0
- intendant/adapters/claude_skill/inspectors.py +62 -0
- intendant/adapters/claude_skill/sk.py +287 -0
- intendant/adapters/go/__init__.py +18 -0
- intendant/adapters/go/ci.py +43 -0
- intendant/adapters/go/inspectors.py +58 -0
- intendant/adapters/go/pk.py +65 -0
- intendant/adapters/go/qu.py +30 -0
- intendant/adapters/go/sa.py +33 -0
- intendant/adapters/go/ts.py +31 -0
- intendant/adapters/node/__init__.py +19 -0
- intendant/adapters/node/ci.py +44 -0
- intendant/adapters/node/inspectors.py +37 -0
- intendant/adapters/node/pk.py +63 -0
- intendant/adapters/node/qu.py +60 -0
- intendant/adapters/node/sa.py +33 -0
- intendant/adapters/node/ts.py +46 -0
- intendant/adapters/python/__init__.py +36 -0
- intendant/adapters/python/ci.py +43 -0
- intendant/adapters/python/inspectors.py +29 -0
- intendant/adapters/python/lo.py +47 -0
- intendant/adapters/python/pk.py +178 -0
- intendant/adapters/python/qu.py +167 -0
- intendant/adapters/python/sa.py +33 -0
- intendant/adapters/python/ts.py +68 -0
- intendant/adapters/rust/__init__.py +18 -0
- intendant/adapters/rust/ci.py +41 -0
- intendant/adapters/rust/inspectors.py +38 -0
- intendant/adapters/rust/pk.py +76 -0
- intendant/adapters/rust/qu.py +23 -0
- intendant/adapters/rust/sa.py +33 -0
- intendant/adapters/rust/ts.py +31 -0
- intendant/adapters/swift/__init__.py +18 -0
- intendant/adapters/swift/ci.py +42 -0
- intendant/adapters/swift/inspectors.py +75 -0
- intendant/adapters/swift/pk.py +69 -0
- intendant/adapters/swift/qu.py +29 -0
- intendant/adapters/swift/sa.py +33 -0
- intendant/adapters/swift/ts.py +34 -0
- intendant/audit/__init__.py +0 -0
- intendant/audit/diff.py +115 -0
- intendant/audit/discovery.py +23 -0
- intendant/audit/fix.py +53 -0
- intendant/audit/output/__init__.py +0 -0
- intendant/audit/output/_html_assets.py +273 -0
- intendant/audit/output/human.py +84 -0
- intendant/audit/output/json_format.py +71 -0
- intendant/audit/output/md.py +79 -0
- intendant/audit/output/report_diff_html.py +144 -0
- intendant/audit/output/report_diff_human.py +79 -0
- intendant/audit/output/report_diff_json.py +31 -0
- intendant/audit/output/report_html.py +208 -0
- intendant/audit/output/report_human.py +110 -0
- intendant/audit/output/report_json.py +83 -0
- intendant/audit/registry.py +97 -0
- intendant/audit/runner.py +197 -0
- intendant/audit/snapshot.py +109 -0
- intendant/checks/__init__.py +52 -0
- intendant/checks/ci.py +88 -0
- intendant/checks/dg.py +185 -0
- intendant/checks/lo.py +19 -0
- intendant/checks/rl.py +254 -0
- intendant/checks/rl005.py +123 -0
- intendant/checks/sa.py +250 -0
- intendant/checks/ts.py +33 -0
- intendant/cli.py +59 -0
- intendant/commands/__init__.py +0 -0
- intendant/commands/audit.py +98 -0
- intendant/commands/doctor.py +28 -0
- intendant/commands/explain.py +123 -0
- intendant/commands/init.py +123 -0
- intendant/commands/mcp.py +27 -0
- intendant/commands/new.py +152 -0
- intendant/commands/report.py +241 -0
- intendant/core/__init__.py +0 -0
- intendant/core/config.py +174 -0
- intendant/core/handbook.py +115 -0
- intendant/core/patch.py +56 -0
- intendant/core/paths.py +38 -0
- intendant/core/repo.py +67 -0
- intendant/core/report.py +73 -0
- intendant/core/rule.py +46 -0
- intendant/core/subproject.py +14 -0
- intendant/mcp_server/__init__.py +6 -0
- intendant/mcp_server/handlers.py +158 -0
- intendant/mcp_server/server.py +59 -0
- intendant/scaffold/__init__.py +0 -0
- intendant/scaffold/engine.py +290 -0
- intendant/scaffold/substitutions.py +86 -0
- intendant-4.0.0.dist-info/METADATA +259 -0
- intendant-4.0.0.dist-info/RECORD +98 -0
- intendant-4.0.0.dist-info/WHEEL +4 -0
- intendant-4.0.0.dist-info/entry_points.txt +2 -0
- intendant-4.0.0.dist-info/licenses/LICENSE +21 -0
intendant/__init__.py
ADDED
intendant/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Skill adapter — Claude Skill repository rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intendant.adapters.claude_skill.ci import CLAUDE_SKILL_CI001MinimumSteps
|
|
6
|
+
from intendant.adapters.claude_skill.sk import (
|
|
7
|
+
SK001SkillMdExists,
|
|
8
|
+
SK002FrontmatterValid,
|
|
9
|
+
SK003DescriptionQuality,
|
|
10
|
+
SK004NameMatchesDir,
|
|
11
|
+
SK005EvalsNonEmpty,
|
|
12
|
+
SK006ReferencedDirsExist,
|
|
13
|
+
SK007ReadmeInstallPath,
|
|
14
|
+
)
|
|
15
|
+
from intendant.core.rule import Rule
|
|
16
|
+
|
|
17
|
+
RULES: list[Rule] = [
|
|
18
|
+
SK001SkillMdExists(),
|
|
19
|
+
SK002FrontmatterValid(),
|
|
20
|
+
SK003DescriptionQuality(),
|
|
21
|
+
SK004NameMatchesDir(),
|
|
22
|
+
SK005EvalsNonEmpty(),
|
|
23
|
+
SK006ReferencedDirsExist(),
|
|
24
|
+
SK007ReadmeInstallPath(),
|
|
25
|
+
CLAUDE_SKILL_CI001MinimumSteps(),
|
|
26
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Claude Skill adapter CI rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intendant.core.repo import Repo
|
|
6
|
+
from intendant.core.rule import CheckResult, Rule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CLAUDE_SKILL_CI001MinimumSteps(Rule): # noqa: N801
|
|
10
|
+
id = "CLAUDE_SKILL_CI001"
|
|
11
|
+
title = "CI workflow runs intendant audit"
|
|
12
|
+
severity = "required"
|
|
13
|
+
stacks = ("claude-skill",)
|
|
14
|
+
handbook_ref = "docs/handbook/03-ci.md#claude_skill_ci001"
|
|
15
|
+
|
|
16
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
17
|
+
wf_dir = repo.path / ".github" / "workflows"
|
|
18
|
+
if not wf_dir.is_dir():
|
|
19
|
+
return CheckResult(
|
|
20
|
+
passing=True,
|
|
21
|
+
skipped=True,
|
|
22
|
+
evidence="no .github/workflows/ directory (covered by CI001)",
|
|
23
|
+
)
|
|
24
|
+
contents = "\n".join(p.read_text(errors="replace") for p in wf_dir.glob("*.yml"))
|
|
25
|
+
contents += "\n".join(p.read_text(errors="replace") for p in wf_dir.glob("*.yaml"))
|
|
26
|
+
if "intendant audit" not in contents:
|
|
27
|
+
return CheckResult(
|
|
28
|
+
passing=False,
|
|
29
|
+
evidence="no `intendant audit` step found in CI workflows",
|
|
30
|
+
)
|
|
31
|
+
return CheckResult(
|
|
32
|
+
passing=True,
|
|
33
|
+
evidence="`intendant audit` step found in CI",
|
|
34
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Helpers shared by skill rule check/fix methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
_EXCLUDED_PARTS = frozenset(
|
|
11
|
+
{
|
|
12
|
+
".git",
|
|
13
|
+
"node_modules",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".venv",
|
|
16
|
+
".tox",
|
|
17
|
+
"dist",
|
|
18
|
+
"build",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(.*?\n)---\n", re.DOTALL)
|
|
23
|
+
_BOM = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_skill_md(repo_path: Path) -> Path | None:
|
|
27
|
+
"""Locate a single SKILL.md at depth 1 or 2 below ``repo_path``.
|
|
28
|
+
|
|
29
|
+
Returns the alphabetically-first match for determinism. Depth-1
|
|
30
|
+
matches take precedence over depth-2 matches. Excludes paths that
|
|
31
|
+
pass through any directory in ``_EXCLUDED_PARTS``. Returns ``None``
|
|
32
|
+
if no SKILL.md is found.
|
|
33
|
+
"""
|
|
34
|
+
for depth in (1, 2):
|
|
35
|
+
pattern = "*/" * (depth - 1) + "SKILL.md"
|
|
36
|
+
candidates = sorted(
|
|
37
|
+
p
|
|
38
|
+
for p in repo_path.glob(pattern)
|
|
39
|
+
if not (set(p.relative_to(repo_path).parts) & _EXCLUDED_PARTS)
|
|
40
|
+
)
|
|
41
|
+
if candidates:
|
|
42
|
+
return candidates[0]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_frontmatter(skill_md_path: Path) -> dict | None:
|
|
47
|
+
"""Extract and parse YAML frontmatter from a SKILL.md file.
|
|
48
|
+
|
|
49
|
+
Returns the parsed dict, or ``None`` if frontmatter is absent or malformed.
|
|
50
|
+
Treats UTF-8 BOM at file start as if it were not there.
|
|
51
|
+
"""
|
|
52
|
+
text = skill_md_path.read_text(encoding="utf-8", errors="replace")
|
|
53
|
+
if text.startswith(_BOM):
|
|
54
|
+
text = text[len(_BOM) :]
|
|
55
|
+
match = _FRONTMATTER_RE.match(text)
|
|
56
|
+
if not match:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
data = yaml.safe_load(match.group(1))
|
|
60
|
+
except yaml.YAMLError:
|
|
61
|
+
return None
|
|
62
|
+
return data if isinstance(data, dict) else None
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Skill adapter SK rules (SK001-SK007)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from intendant.adapters.claude_skill.inspectors import find_skill_md, parse_frontmatter
|
|
10
|
+
from intendant.core.patch import Patch
|
|
11
|
+
from intendant.core.repo import Repo
|
|
12
|
+
from intendant.core.rule import CheckResult, Rule
|
|
13
|
+
|
|
14
|
+
# Used by SK002 (raw-text frontmatter detection) and SK006 (strip frontmatter from body).
|
|
15
|
+
_FRONTMATTER_BLOCK_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SK001SkillMdExists(Rule):
|
|
19
|
+
id = "CLAUDE_SKILL_SK001"
|
|
20
|
+
title = "SKILL.md exists at depth <= 2"
|
|
21
|
+
severity = "required"
|
|
22
|
+
stacks = ("claude-skill",)
|
|
23
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk001"
|
|
24
|
+
|
|
25
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
26
|
+
skill_md = find_skill_md(repo.path)
|
|
27
|
+
if skill_md is None:
|
|
28
|
+
return CheckResult(
|
|
29
|
+
passing=False,
|
|
30
|
+
evidence=(
|
|
31
|
+
"no SKILL.md found at depth <= 2 (excluding .git, node_modules, "
|
|
32
|
+
"__pycache__, .venv, .tox, dist, build)"
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
rel = skill_md.relative_to(repo.path)
|
|
36
|
+
return CheckResult(passing=True, evidence=f"skill detected at {rel}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SK002FrontmatterValid(Rule):
|
|
40
|
+
id = "CLAUDE_SKILL_SK002"
|
|
41
|
+
title = "SKILL.md frontmatter valid (name + description present and non-empty)"
|
|
42
|
+
severity = "required"
|
|
43
|
+
stacks = ("claude-skill",)
|
|
44
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk002"
|
|
45
|
+
|
|
46
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
47
|
+
skill_md = find_skill_md(repo.path)
|
|
48
|
+
if skill_md is None:
|
|
49
|
+
return CheckResult(passing=False, evidence="no SKILL.md found")
|
|
50
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
51
|
+
if text.startswith(""):
|
|
52
|
+
text = text[1:]
|
|
53
|
+
match = _FRONTMATTER_BLOCK_RE.match(text)
|
|
54
|
+
if not match:
|
|
55
|
+
return CheckResult(passing=False, evidence="no frontmatter block found")
|
|
56
|
+
try:
|
|
57
|
+
data = yaml.safe_load(match.group(1))
|
|
58
|
+
except yaml.YAMLError as exc:
|
|
59
|
+
return CheckResult(passing=False, evidence=f"YAML parse error: {exc}")
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
return CheckResult(
|
|
62
|
+
passing=False,
|
|
63
|
+
evidence="frontmatter root is not a YAML mapping",
|
|
64
|
+
)
|
|
65
|
+
for field in ("name", "description"):
|
|
66
|
+
if field not in data:
|
|
67
|
+
return CheckResult(
|
|
68
|
+
passing=False,
|
|
69
|
+
evidence=f"missing required field: {field}",
|
|
70
|
+
)
|
|
71
|
+
value = data[field]
|
|
72
|
+
if value is None or (isinstance(value, str) and value.strip() == ""):
|
|
73
|
+
return CheckResult(
|
|
74
|
+
passing=False,
|
|
75
|
+
evidence=f"field {field!r} is empty",
|
|
76
|
+
)
|
|
77
|
+
name = data["name"]
|
|
78
|
+
desc = data["description"]
|
|
79
|
+
return CheckResult(
|
|
80
|
+
passing=True,
|
|
81
|
+
evidence=f"frontmatter valid: name={name!r}, description='{len(str(desc))} chars'",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SK003DescriptionQuality(Rule):
|
|
86
|
+
id = "CLAUDE_SKILL_SK003"
|
|
87
|
+
title = "SKILL.md description length within 10-1024 chars"
|
|
88
|
+
severity = "recommended"
|
|
89
|
+
stacks = ("claude-skill",)
|
|
90
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk003"
|
|
91
|
+
|
|
92
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
93
|
+
skill_md = find_skill_md(repo.path)
|
|
94
|
+
if skill_md is None:
|
|
95
|
+
return CheckResult(
|
|
96
|
+
passing=True,
|
|
97
|
+
skipped=True,
|
|
98
|
+
evidence="no SKILL.md found (covered by SK001)",
|
|
99
|
+
)
|
|
100
|
+
data = parse_frontmatter(skill_md)
|
|
101
|
+
if data is None or "description" not in data:
|
|
102
|
+
return CheckResult(
|
|
103
|
+
passing=True,
|
|
104
|
+
skipped=True,
|
|
105
|
+
evidence="frontmatter or description absent (covered by SK002)",
|
|
106
|
+
)
|
|
107
|
+
desc = str(data["description"]).strip()
|
|
108
|
+
n = len(desc)
|
|
109
|
+
if n < 10:
|
|
110
|
+
return CheckResult(
|
|
111
|
+
passing=False,
|
|
112
|
+
evidence=f"description too short: {n} chars (min 10)",
|
|
113
|
+
)
|
|
114
|
+
if n > 1024:
|
|
115
|
+
return CheckResult(
|
|
116
|
+
passing=False,
|
|
117
|
+
evidence=f"description too long: {n} chars (max 1024)",
|
|
118
|
+
)
|
|
119
|
+
return CheckResult(passing=True, evidence=f"description length: {n} chars")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SK004NameMatchesDir(Rule):
|
|
123
|
+
id = "CLAUDE_SKILL_SK004"
|
|
124
|
+
title = "frontmatter name matches parent directory name"
|
|
125
|
+
severity = "recommended"
|
|
126
|
+
stacks = ("claude-skill",)
|
|
127
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk004"
|
|
128
|
+
|
|
129
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
130
|
+
skill_md = find_skill_md(repo.path)
|
|
131
|
+
if skill_md is None:
|
|
132
|
+
return CheckResult(
|
|
133
|
+
passing=True,
|
|
134
|
+
skipped=True,
|
|
135
|
+
evidence="no SKILL.md found (covered by SK001)",
|
|
136
|
+
)
|
|
137
|
+
data = parse_frontmatter(skill_md)
|
|
138
|
+
if data is None or "name" not in data:
|
|
139
|
+
return CheckResult(
|
|
140
|
+
passing=True,
|
|
141
|
+
skipped=True,
|
|
142
|
+
evidence="frontmatter or name absent (covered by SK002)",
|
|
143
|
+
)
|
|
144
|
+
frontmatter_name = str(data["name"])
|
|
145
|
+
dir_name = skill_md.parent.name
|
|
146
|
+
if frontmatter_name == dir_name:
|
|
147
|
+
return CheckResult(passing=True, evidence=f"name matches directory: {dir_name!r}")
|
|
148
|
+
return CheckResult(
|
|
149
|
+
passing=False,
|
|
150
|
+
evidence=f"name {frontmatter_name!r} does not match directory {dir_name!r}",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_EVAL_EXTENSIONS = frozenset({".md", ".json", ".yaml", ".yml", ".txt"})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SK005EvalsNonEmpty(Rule):
|
|
158
|
+
id = "CLAUDE_SKILL_SK005"
|
|
159
|
+
title = "evals/ directory present and non-empty"
|
|
160
|
+
severity = "recommended"
|
|
161
|
+
stacks = ("claude-skill",)
|
|
162
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk005"
|
|
163
|
+
|
|
164
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
165
|
+
skill_md = find_skill_md(repo.path)
|
|
166
|
+
if skill_md is None:
|
|
167
|
+
return CheckResult(
|
|
168
|
+
passing=True,
|
|
169
|
+
skipped=True,
|
|
170
|
+
evidence="no SKILL.md found (covered by SK001)",
|
|
171
|
+
)
|
|
172
|
+
evals_dir = skill_md.parent / "evals"
|
|
173
|
+
if not evals_dir.is_dir():
|
|
174
|
+
return CheckResult(passing=False, evidence="evals/ directory missing")
|
|
175
|
+
files = [p for p in evals_dir.iterdir() if p.is_file() and p.suffix in _EVAL_EXTENSIONS]
|
|
176
|
+
if not files:
|
|
177
|
+
return CheckResult(
|
|
178
|
+
passing=False,
|
|
179
|
+
evidence="evals/ directory exists but empty (no .md/.json/.yaml/.txt files)",
|
|
180
|
+
)
|
|
181
|
+
return CheckResult(passing=True, evidence=f"evals/ present with {len(files)} file(s)")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_DIR_REF_RE = re.compile(r"(?<!\S)(references|scripts)/[\w/.-]+")
|
|
185
|
+
_FENCED_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _strip_frontmatter_and_codeblocks(text: str) -> str:
|
|
189
|
+
"""Return the SKILL.md body with frontmatter and fenced code blocks removed."""
|
|
190
|
+
body = _FRONTMATTER_BLOCK_RE.sub("", text, count=1)
|
|
191
|
+
body = _FENCED_BLOCK_RE.sub("", body)
|
|
192
|
+
return body
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SK006ReferencedDirsExist(Rule):
|
|
196
|
+
id = "CLAUDE_SKILL_SK006"
|
|
197
|
+
title = "referenced top-level dirs (references/, scripts/) exist when mentioned"
|
|
198
|
+
severity = "recommended"
|
|
199
|
+
stacks = ("claude-skill",)
|
|
200
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk006"
|
|
201
|
+
|
|
202
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
203
|
+
skill_md = find_skill_md(repo.path)
|
|
204
|
+
if skill_md is None:
|
|
205
|
+
return CheckResult(
|
|
206
|
+
passing=True,
|
|
207
|
+
skipped=True,
|
|
208
|
+
evidence="no SKILL.md found (covered by SK001)",
|
|
209
|
+
)
|
|
210
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
211
|
+
if text.startswith(""):
|
|
212
|
+
text = text[1:]
|
|
213
|
+
body = _strip_frontmatter_and_codeblocks(text)
|
|
214
|
+
mentioned_top_levels = {m.group(1) for m in _DIR_REF_RE.finditer(body)}
|
|
215
|
+
if not mentioned_top_levels:
|
|
216
|
+
return CheckResult(
|
|
217
|
+
passing=True,
|
|
218
|
+
evidence="no references/ or scripts/ mentioned",
|
|
219
|
+
)
|
|
220
|
+
skill_dir = skill_md.parent
|
|
221
|
+
missing = sorted(d for d in mentioned_top_levels if not (skill_dir / d).is_dir())
|
|
222
|
+
if missing:
|
|
223
|
+
return CheckResult(
|
|
224
|
+
passing=False,
|
|
225
|
+
evidence=f"SKILL.md references missing dirs: {missing}",
|
|
226
|
+
)
|
|
227
|
+
return CheckResult(passing=True, evidence="all referenced dirs present")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_INSTALL_BLOCK_TEMPLATE = """
|
|
231
|
+
|
|
232
|
+
## Installation
|
|
233
|
+
|
|
234
|
+
Clone into your local Claude skills directory:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
git clone <repo-url> ~/.claude/skills/{skill_name}
|
|
238
|
+
```
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class SK007ReadmeInstallPath(Rule):
|
|
243
|
+
id = "CLAUDE_SKILL_SK007"
|
|
244
|
+
title = "README mentions skill install path (~/.claude/skills/ or claude/plugins/)"
|
|
245
|
+
severity = "recommended"
|
|
246
|
+
stacks = ("claude-skill",)
|
|
247
|
+
handbook_ref = "docs/handbook/09-claude-skill.md#claude_skill_sk007"
|
|
248
|
+
|
|
249
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
250
|
+
readme = repo.path / "README.md"
|
|
251
|
+
if not readme.is_file():
|
|
252
|
+
return CheckResult(
|
|
253
|
+
passing=True,
|
|
254
|
+
skipped=True,
|
|
255
|
+
evidence="README.md not found at repo root (covered by DG003)",
|
|
256
|
+
)
|
|
257
|
+
text = readme.read_text(encoding="utf-8", errors="replace")
|
|
258
|
+
if "~/.claude/skills/" in text or "claude/plugins/" in text:
|
|
259
|
+
return CheckResult(passing=True, evidence="install path documented in README.md")
|
|
260
|
+
return CheckResult(
|
|
261
|
+
passing=False,
|
|
262
|
+
evidence="README.md does not mention skill install path "
|
|
263
|
+
"(~/.claude/skills/ or claude/plugins/)",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def fix(self, repo: Repo, result: CheckResult) -> Patch | None:
|
|
267
|
+
# Re-check before patching: never patch when skipped (no README) or already passing.
|
|
268
|
+
if result.skipped or result.passing:
|
|
269
|
+
return None
|
|
270
|
+
readme = repo.path / "README.md"
|
|
271
|
+
if not readme.is_file():
|
|
272
|
+
return None
|
|
273
|
+
text = readme.read_text(encoding="utf-8", errors="replace")
|
|
274
|
+
# Idempotency: if path is now present (race or stale result), no-op.
|
|
275
|
+
if "~/.claude/skills/" in text or "claude/plugins/" in text:
|
|
276
|
+
return None
|
|
277
|
+
skill_md = find_skill_md(repo.path)
|
|
278
|
+
skill_name = skill_md.parent.name if skill_md else "skill-name"
|
|
279
|
+
addition = _INSTALL_BLOCK_TEMPLATE.format(skill_name=skill_name)
|
|
280
|
+
new_content = text.rstrip() + addition
|
|
281
|
+
return Patch(
|
|
282
|
+
target_path=readme,
|
|
283
|
+
kind="overwrite",
|
|
284
|
+
content=new_content,
|
|
285
|
+
diff=(f"--- a/README.md\n+++ b/README.md\n@@ +N @@\n{addition}"),
|
|
286
|
+
safe=True,
|
|
287
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Go adapter — language-specific rules."""
|
|
2
|
+
|
|
3
|
+
from intendant.adapters.go.ci import GO_CI001MinimumSteps
|
|
4
|
+
from intendant.adapters.go.pk import GoMod, GoSum, GoVersion
|
|
5
|
+
from intendant.adapters.go.qu import GoLinter
|
|
6
|
+
from intendant.adapters.go.sa import GO_SA001GitignoreBaseline
|
|
7
|
+
from intendant.adapters.go.ts import GoTestFiles
|
|
8
|
+
from intendant.core.rule import Rule
|
|
9
|
+
|
|
10
|
+
RULES: list[Rule] = [
|
|
11
|
+
GoMod(),
|
|
12
|
+
GoSum(),
|
|
13
|
+
GoVersion(),
|
|
14
|
+
GoLinter(),
|
|
15
|
+
GoTestFiles(),
|
|
16
|
+
GO_CI001MinimumSteps(),
|
|
17
|
+
GO_SA001GitignoreBaseline(),
|
|
18
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Go adapter GO_CI rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intendant.core.repo import Repo
|
|
6
|
+
from intendant.core.rule import CheckResult, Rule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GO_CI001MinimumSteps(Rule): # noqa: N801
|
|
10
|
+
id = "GO_CI001"
|
|
11
|
+
title = "CI workflow runs go vet/build, go test, and a linter"
|
|
12
|
+
severity = "required"
|
|
13
|
+
stacks = ("go",)
|
|
14
|
+
handbook_ref = "docs/handbook/03-ci.md#go_ci001"
|
|
15
|
+
|
|
16
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
17
|
+
wf_dir = repo.path / ".github" / "workflows"
|
|
18
|
+
if not wf_dir.is_dir():
|
|
19
|
+
return CheckResult(
|
|
20
|
+
passing=True,
|
|
21
|
+
skipped=True,
|
|
22
|
+
evidence="no .github/workflows/ directory (covered by CI001)",
|
|
23
|
+
)
|
|
24
|
+
contents = "\n".join(p.read_text(errors="replace") for p in wf_dir.glob("*.yml"))
|
|
25
|
+
contents += "\n".join(p.read_text(errors="replace") for p in wf_dir.glob("*.yaml"))
|
|
26
|
+
missing: list[str] = []
|
|
27
|
+
build_markers = ("go vet", "go build")
|
|
28
|
+
if not any(m in contents for m in build_markers):
|
|
29
|
+
missing.append("build (go vet / go build)")
|
|
30
|
+
if "go test" not in contents:
|
|
31
|
+
missing.append("test (go test)")
|
|
32
|
+
lint_markers = ("golangci-lint", "gofmt", "staticcheck", "revive")
|
|
33
|
+
if not any(m in contents for m in lint_markers):
|
|
34
|
+
missing.append("lint (golangci-lint / gofmt / staticcheck / revive)")
|
|
35
|
+
if missing:
|
|
36
|
+
return CheckResult(
|
|
37
|
+
passing=False,
|
|
38
|
+
evidence=f"CI workflow(s) missing Go steps: {missing}",
|
|
39
|
+
)
|
|
40
|
+
return CheckResult(
|
|
41
|
+
passing=True,
|
|
42
|
+
evidence="CI workflow(s) include Go build+test+lint",
|
|
43
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Helpers shared by Go rule check methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_MODULE_RE = re.compile(r"^module\s+(\S+)\s*$", re.MULTILINE)
|
|
10
|
+
_GO_DIRECTIVE_RE = re.compile(r"^go\s+(\d+\.\d+(?:\.\d+)?)\s*$", re.MULTILINE)
|
|
11
|
+
_TEST_FUNC_RE = re.compile(r"^func\s+Test[A-Z_]\w*\s*\(", re.MULTILINE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class GoMod:
|
|
16
|
+
module: str | None
|
|
17
|
+
go_version: str | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def has_go_mod(repo_path: Path) -> bool:
|
|
21
|
+
return (repo_path / "go.mod").is_file()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_go_mod(repo_path: Path) -> GoMod | None:
|
|
25
|
+
"""Parse go.mod for the `module` and `go` directives. None if missing/unreadable."""
|
|
26
|
+
path = repo_path / "go.mod"
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
text = path.read_text(encoding="utf-8")
|
|
31
|
+
except UnicodeDecodeError:
|
|
32
|
+
return None
|
|
33
|
+
module_match = _MODULE_RE.search(text)
|
|
34
|
+
go_match = _GO_DIRECTIVE_RE.search(text)
|
|
35
|
+
return GoMod(
|
|
36
|
+
module=module_match.group(1) if module_match else None,
|
|
37
|
+
go_version=go_match.group(1) if go_match else None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_test_files(repo_path: Path) -> list[Path]:
|
|
42
|
+
"""Return *_test.go files containing a `func Test*(` declaration.
|
|
43
|
+
|
|
44
|
+
Walks the repo, skipping `vendor/`, `node_modules/`, `.git/`, and any
|
|
45
|
+
hidden directories at the top level.
|
|
46
|
+
"""
|
|
47
|
+
hits: list[Path] = []
|
|
48
|
+
skip_dirs = {"vendor", "node_modules", ".git", "target", "dist"}
|
|
49
|
+
for test_file in repo_path.rglob("*_test.go"):
|
|
50
|
+
rel_parts = test_file.relative_to(repo_path).parts[:-1]
|
|
51
|
+
if any(p in skip_dirs or p.startswith(".") for p in rel_parts):
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
if _TEST_FUNC_RE.search(test_file.read_text(errors="replace")):
|
|
55
|
+
hits.append(test_file)
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
return hits
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Go adapter GO_PK (packaging) rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intendant.adapters.go.inspectors import has_go_mod, load_go_mod
|
|
6
|
+
from intendant.core.repo import Repo
|
|
7
|
+
from intendant.core.rule import CheckResult, Rule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GoMod(Rule):
|
|
11
|
+
id = "GO_PK001"
|
|
12
|
+
title = "go.mod present at repo root with module declaration"
|
|
13
|
+
severity = "required"
|
|
14
|
+
stacks = ("go",)
|
|
15
|
+
handbook_ref = "docs/handbook/12-go.md#go_pk001"
|
|
16
|
+
|
|
17
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
18
|
+
if not has_go_mod(repo.path):
|
|
19
|
+
return CheckResult(passing=False, evidence="go.mod not found at repo root")
|
|
20
|
+
mod = load_go_mod(repo.path)
|
|
21
|
+
if mod is None or mod.module is None:
|
|
22
|
+
return CheckResult(
|
|
23
|
+
passing=False,
|
|
24
|
+
evidence="go.mod missing `module <path>` directive",
|
|
25
|
+
)
|
|
26
|
+
return CheckResult(passing=True, evidence=f"module declared: {mod.module}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GoSum(Rule):
|
|
30
|
+
id = "GO_PK002"
|
|
31
|
+
title = "go.sum present at repo root"
|
|
32
|
+
severity = "required"
|
|
33
|
+
stacks = ("go",)
|
|
34
|
+
handbook_ref = "docs/handbook/12-go.md#go_pk002"
|
|
35
|
+
|
|
36
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
37
|
+
if (repo.path / "go.sum").is_file():
|
|
38
|
+
return CheckResult(passing=True, evidence="go.sum present")
|
|
39
|
+
return CheckResult(
|
|
40
|
+
passing=False,
|
|
41
|
+
evidence="go.sum not found (run `go mod tidy` then commit it)",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GoVersion(Rule):
|
|
46
|
+
id = "GO_PK003"
|
|
47
|
+
title = "go directive pinned in go.mod"
|
|
48
|
+
severity = "recommended"
|
|
49
|
+
stacks = ("go",)
|
|
50
|
+
handbook_ref = "docs/handbook/12-go.md#go_pk003"
|
|
51
|
+
|
|
52
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
53
|
+
mod = load_go_mod(repo.path)
|
|
54
|
+
if mod is None:
|
|
55
|
+
return CheckResult(
|
|
56
|
+
passing=True,
|
|
57
|
+
skipped=True,
|
|
58
|
+
evidence="go.mod missing or unreadable (covered by GO_PK001)",
|
|
59
|
+
)
|
|
60
|
+
if mod.go_version:
|
|
61
|
+
return CheckResult(passing=True, evidence=f"go version pinned: {mod.go_version}")
|
|
62
|
+
return CheckResult(
|
|
63
|
+
passing=False,
|
|
64
|
+
evidence="no `go <version>` directive in go.mod",
|
|
65
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Go adapter GO_QU (quality) rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intendant.core.repo import Repo
|
|
6
|
+
from intendant.core.rule import CheckResult, Rule
|
|
7
|
+
|
|
8
|
+
_GOLANGCI_CONFIGS = (
|
|
9
|
+
".golangci.yml",
|
|
10
|
+
".golangci.yaml",
|
|
11
|
+
".golangci.toml",
|
|
12
|
+
".golangci.json",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GoLinter(Rule):
|
|
17
|
+
id = "GO_QU001"
|
|
18
|
+
title = "golangci-lint config present"
|
|
19
|
+
severity = "recommended"
|
|
20
|
+
stacks = ("go",)
|
|
21
|
+
handbook_ref = "docs/handbook/12-go.md#go_qu001"
|
|
22
|
+
|
|
23
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
24
|
+
present = [name for name in _GOLANGCI_CONFIGS if (repo.path / name).is_file()]
|
|
25
|
+
if present:
|
|
26
|
+
return CheckResult(passing=True, evidence=f"linter config present: {present}")
|
|
27
|
+
return CheckResult(
|
|
28
|
+
passing=False,
|
|
29
|
+
evidence=f"no golangci-lint config (looked for {list(_GOLANGCI_CONFIGS)})",
|
|
30
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Go adapter GO_SA rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intendant.core.repo import Repo
|
|
6
|
+
from intendant.core.rule import CheckResult, Rule
|
|
7
|
+
|
|
8
|
+
_GO_GITIGNORE_BASELINE = ("*.test",)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GO_SA001GitignoreBaseline(Rule): # noqa: N801
|
|
12
|
+
id = "GO_SA001"
|
|
13
|
+
title = "Go .gitignore baseline (*.test)"
|
|
14
|
+
severity = "required"
|
|
15
|
+
stacks = ("go",)
|
|
16
|
+
handbook_ref = "docs/handbook/06-sanitizing.md#go_sa001"
|
|
17
|
+
|
|
18
|
+
def check(self, repo: Repo) -> CheckResult:
|
|
19
|
+
path = repo.path / ".gitignore"
|
|
20
|
+
if not path.is_file():
|
|
21
|
+
return CheckResult(
|
|
22
|
+
passing=True,
|
|
23
|
+
skipped=True,
|
|
24
|
+
evidence=".gitignore not found (covered by SA004)",
|
|
25
|
+
)
|
|
26
|
+
text = path.read_text()
|
|
27
|
+
missing = [p for p in _GO_GITIGNORE_BASELINE if p not in text]
|
|
28
|
+
if missing:
|
|
29
|
+
return CheckResult(
|
|
30
|
+
passing=False,
|
|
31
|
+
evidence=f"missing Go baseline patterns in .gitignore: {missing}",
|
|
32
|
+
)
|
|
33
|
+
return CheckResult(passing=True, evidence="Go baseline patterns present")
|