context-engineering-cli 2.6.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.
- context_engineering/__init__.py +3 -0
- context_engineering/__main__.py +2 -0
- context_engineering/analysis/__init__.py +1 -0
- context_engineering/analysis/backfill.py +1064 -0
- context_engineering/analysis/context_check.py +253 -0
- context_engineering/analysis/context_layout.py +111 -0
- context_engineering/analysis/context_review.py +224 -0
- context_engineering/analysis/cross_cutting/__init__.py +6 -0
- context_engineering/analysis/cross_cutting/authors.py +57 -0
- context_engineering/analysis/cross_cutting/buckets.py +40 -0
- context_engineering/analysis/cross_cutting/co_change.py +47 -0
- context_engineering/analysis/cross_cutting/discover.py +75 -0
- context_engineering/analysis/cross_cutting/imports.py +61 -0
- context_engineering/analysis/cross_cutting/pair.py +118 -0
- context_engineering/analysis/impact.py +77 -0
- context_engineering/analysis/sessions.py +27 -0
- context_engineering/analysis/staleness.py +179 -0
- context_engineering/analysis/tier.py +91 -0
- context_engineering/checks/__init__.py +1 -0
- context_engineering/checks/antipatterns/__init__.py +5 -0
- context_engineering/checks/antipatterns/context.py +23 -0
- context_engineering/checks/antipatterns/density.py +72 -0
- context_engineering/checks/antipatterns/line_limits.py +52 -0
- context_engineering/checks/antipatterns/runner.py +137 -0
- context_engineering/checks/antipatterns/splitting.py +97 -0
- context_engineering/checks/antipatterns/volatile.py +38 -0
- context_engineering/checks/antipatterns/watermark.py +113 -0
- context_engineering/checks/contracts.py +456 -0
- context_engineering/checks/depth.py +82 -0
- context_engineering/checks/frontmatter.py +125 -0
- context_engineering/checks/references.py +325 -0
- context_engineering/checks/skill_structure.py +124 -0
- context_engineering/cli/__init__.py +3 -0
- context_engineering/cli/dispatch.py +90 -0
- context_engineering/cli/registry.py +33 -0
- context_engineering/cli/render.py +92 -0
- context_engineering/cli/subcommands.py +587 -0
- context_engineering/domain/__init__.py +0 -0
- context_engineering/domain/commit.py +19 -0
- context_engineering/domain/evidence.py +57 -0
- context_engineering/domain/finding.py +37 -0
- context_engineering/domain/result.py +59 -0
- context_engineering/infra/__init__.py +13 -0
- context_engineering/infra/filesystem.py +22 -0
- context_engineering/infra/git.py +153 -0
- context_engineering/infra/git_evidence.py +357 -0
- context_engineering/infra/git_tree.py +139 -0
- context_engineering/infra/markdown.py +58 -0
- context_engineering/infra/yaml_frontmatter.py +70 -0
- context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
- context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
- context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
- context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
- context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
- provenance.json +1 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Verify that paths referenced in AGENTS.md / docs/*.md files resolve.
|
|
2
|
+
|
|
3
|
+
`is_likely_path` is a series of small named predicates — each rules out a
|
|
4
|
+
specific kind of non-path that happens to appear in backticks. Adding a new
|
|
5
|
+
exclusion means adding a one-liner, not extending a 15-branch function.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..analysis.context_layout import RepositoryContextLayout, discover
|
|
15
|
+
from ..domain.finding import Finding, Severity
|
|
16
|
+
from ..domain.result import LintResult
|
|
17
|
+
from ..infra.filesystem import EXCLUDED_DIRS, read_text_safe
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Path-likeness heuristic — factored into named predicates so adding
|
|
21
|
+
# exclusions doesn't mean touching a 30-branch function.
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
_URL_RE = re.compile(r"^(https?|git\+https?|gs|gcr\.io|docker\.io)[:/]")
|
|
25
|
+
_ENV_OR_FLAG_RE = re.compile(r"^(--|\$|[A-Z_]+=|@|#|\*|>)")
|
|
26
|
+
_KEYBINDING_RE = re.compile(r"^(Alt|Ctrl|Shift|Meta|Cmd|Super)\+")
|
|
27
|
+
_VIM_KEY_RE = re.compile(r"^<(leader|prefix|space|cr|esc|tab|bs|del|up|down|left|right)")
|
|
28
|
+
_DOMAIN_RE = re.compile(r"^\w+\.\w+\.\w+/")
|
|
29
|
+
_CONTAINER_TAG_RE = re.compile(r":\w[\w.-]*$")
|
|
30
|
+
_DOTTED_PROP_RE = re.compile(r"\w+(\.\w+)+")
|
|
31
|
+
# Deliberately narrow: only match bare org/repo when no file extension would
|
|
32
|
+
# distinguish it. Two-segment repo paths like `services/payments` match the
|
|
33
|
+
# filesystem-resolution fallback, so we accept them as path candidates and let
|
|
34
|
+
# the resolver decide. Matching `foo/bar` as "github repo" caused broken
|
|
35
|
+
# references to pass silently.
|
|
36
|
+
_TOOL_OUTPUT_DIR_RE = re.compile(r"^\.[a-zA-Z][\w-]*(/|$)")
|
|
37
|
+
_UPPERCASE_PROPERTY_RE = re.compile(r"\.[a-z]*[A-Z]\w*$") # .sourceDir, .chezmoi
|
|
38
|
+
_DOTTED_METHOD_NAMES = {".parent", ".name", ".stem", ".suffix", ".resolve"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _looks_like_prose(text: str) -> bool:
|
|
42
|
+
"""Strings that can't possibly be file paths."""
|
|
43
|
+
return (
|
|
44
|
+
not text
|
|
45
|
+
or len(text) < 3
|
|
46
|
+
or " " in text
|
|
47
|
+
or any(c in text for c in "()=;{}<>\"'")
|
|
48
|
+
or "{{" in text
|
|
49
|
+
or "*" in text
|
|
50
|
+
or "?" in text
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_homebrew_tap(text: str) -> bool:
|
|
55
|
+
return text.startswith(("brew ", "tap ", "homebrew/"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_home_or_absolute(text: str) -> bool:
|
|
59
|
+
return text.startswith(("/", "~/", "~"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_protocol_uri(text: str) -> bool:
|
|
63
|
+
return bool(_URL_RE.match(text)) or text.startswith(
|
|
64
|
+
("collection://", "discussion://", "notion://")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_flag_or_cli(text: str) -> bool:
|
|
69
|
+
return bool(_ENV_OR_FLAG_RE.match(text))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_shortcut(text: str) -> bool:
|
|
73
|
+
return bool(_KEYBINDING_RE.match(text) or _VIM_KEY_RE.match(text))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_domain_path(text: str) -> bool:
|
|
77
|
+
return bool(_DOMAIN_RE.match(text))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_tool_output_dir(text: str) -> bool:
|
|
81
|
+
return bool(_TOOL_OUTPUT_DIR_RE.match(text))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_bare_extension(text: str) -> bool:
|
|
85
|
+
return "/" not in text and text.startswith(".") and bool(re.fullmatch(r"\.\w+", text))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_dotted_config_key(text: str) -> bool:
|
|
89
|
+
return "/" not in text and bool(_DOTTED_PROP_RE.fullmatch(text))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_uppercase_property(text: str) -> bool:
|
|
93
|
+
return "/" not in text and bool(_UPPERCASE_PROPERTY_RE.search(text))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_dotted_method(text: str) -> bool:
|
|
97
|
+
return "/" not in text and any(text.endswith(m) for m in _DOTTED_METHOD_NAMES)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_container_image(text: str) -> bool:
|
|
101
|
+
return ":" in text and bool(_CONTAINER_TAG_RE.search(text))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _looks_pathish(text: str) -> bool:
|
|
105
|
+
has_slash = "/" in text
|
|
106
|
+
has_extension = bool(re.search(r"\.\w{1,10}$", text))
|
|
107
|
+
return has_slash or has_extension
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_NON_PATH_PREDICATES = (
|
|
111
|
+
_is_homebrew_tap,
|
|
112
|
+
_is_home_or_absolute,
|
|
113
|
+
_is_protocol_uri,
|
|
114
|
+
_is_flag_or_cli,
|
|
115
|
+
_is_shortcut,
|
|
116
|
+
_is_domain_path,
|
|
117
|
+
_is_tool_output_dir,
|
|
118
|
+
_is_bare_extension,
|
|
119
|
+
_is_dotted_config_key,
|
|
120
|
+
_is_uppercase_property,
|
|
121
|
+
_is_dotted_method,
|
|
122
|
+
_is_container_image,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_likely_path(text: str) -> bool:
|
|
127
|
+
"""Conservative: exclude everything we know isn't a path, accept the rest."""
|
|
128
|
+
text = text.strip()
|
|
129
|
+
if _looks_like_prose(text):
|
|
130
|
+
return False
|
|
131
|
+
if any(pred(text) for pred in _NON_PATH_PREDICATES):
|
|
132
|
+
return False
|
|
133
|
+
return _looks_pathish(text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Extraction
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def strip_code_fences(content: str) -> str:
|
|
142
|
+
return re.sub(r"```[\s\S]*?```", "", content)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def extract_backtick_paths(content: str) -> list[str]:
|
|
146
|
+
stripped = strip_code_fences(content)
|
|
147
|
+
return [
|
|
148
|
+
m.group(1).strip().rstrip(",;:")
|
|
149
|
+
for m in re.finditer(r"`([^`\n]+)`", stripped)
|
|
150
|
+
if is_likely_path(m.group(1))
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_markdown_links(content: str) -> list[str]:
|
|
155
|
+
out: list[str] = []
|
|
156
|
+
for match in re.finditer(r"\[([^\]]*)\]\(([^)]+)\)", content):
|
|
157
|
+
target = match.group(2).strip()
|
|
158
|
+
if target.startswith(("http://", "https://", "#", "mailto:")):
|
|
159
|
+
continue
|
|
160
|
+
out.append(target.split("#")[0])
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Lint
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _under_reference_dir(path: Path, root: Path) -> bool:
|
|
170
|
+
try:
|
|
171
|
+
rel = path.relative_to(root).parts
|
|
172
|
+
except ValueError:
|
|
173
|
+
return False
|
|
174
|
+
return any(part in ("references", "resources", "skills") for part in rel)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_ref(ref: str, file_dir: Path, root: Path, git_root_path: Path | None) -> bool:
|
|
178
|
+
clean = re.sub(r":\d+(-\d+)?$", "", ref)
|
|
179
|
+
for base in (file_dir, root, git_root_path):
|
|
180
|
+
if base and (base / clean).exists():
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def lint_file(path: Path, root: Path, *, git_root_path: Path | None = None) -> Iterator[Finding]:
|
|
186
|
+
content = read_text_safe(path)
|
|
187
|
+
if content is None:
|
|
188
|
+
return
|
|
189
|
+
# extract_backtick_paths already strips code fences — don't double-strip.
|
|
190
|
+
paths = dict.fromkeys(extract_backtick_paths(content))
|
|
191
|
+
in_ref_dir = _under_reference_dir(path, root)
|
|
192
|
+
|
|
193
|
+
for ref in paths:
|
|
194
|
+
if _resolve_ref(ref, path.parent, root, git_root_path):
|
|
195
|
+
continue
|
|
196
|
+
if "{{" in ref or "{%" in ref or "*" in ref:
|
|
197
|
+
continue
|
|
198
|
+
if in_ref_dir and ref.startswith("docs/"):
|
|
199
|
+
continue
|
|
200
|
+
yield Finding(
|
|
201
|
+
file=str(path),
|
|
202
|
+
line=0,
|
|
203
|
+
severity=Severity.ERROR,
|
|
204
|
+
code="broken-reference",
|
|
205
|
+
message=f"broken reference `{ref}`",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _lint_symlinks(root: Path, layout: RepositoryContextLayout) -> Iterator[Finding]:
|
|
210
|
+
for agents in layout.agents_files:
|
|
211
|
+
claude = agents.parent / "CLAUDE.md"
|
|
212
|
+
if claude.is_symlink():
|
|
213
|
+
if not claude.exists():
|
|
214
|
+
yield Finding(
|
|
215
|
+
file=str(claude),
|
|
216
|
+
line=0,
|
|
217
|
+
severity=Severity.ERROR,
|
|
218
|
+
code="claude-md-broken-symlink",
|
|
219
|
+
message="broken symlink (target does not exist)",
|
|
220
|
+
)
|
|
221
|
+
elif claude.resolve() != agents.resolve():
|
|
222
|
+
yield Finding(
|
|
223
|
+
file=str(claude),
|
|
224
|
+
line=0,
|
|
225
|
+
severity=Severity.ERROR,
|
|
226
|
+
code="claude-md-wrong-target",
|
|
227
|
+
message=f"symlink points to {claude.resolve()}, expected {agents}",
|
|
228
|
+
)
|
|
229
|
+
elif claude.exists():
|
|
230
|
+
yield Finding(
|
|
231
|
+
file=str(claude),
|
|
232
|
+
line=0,
|
|
233
|
+
severity=Severity.ERROR,
|
|
234
|
+
code="claude-md-not-symlink",
|
|
235
|
+
message="is a regular file, must be a symlink to AGENTS.md",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _lint_docs_indexes(root: Path, layout: RepositoryContextLayout) -> Iterator[Finding]:
|
|
240
|
+
docs_paths = {tree.path for tree in layout.docs_trees}
|
|
241
|
+
for agents in layout.agents_files:
|
|
242
|
+
if agents.parent not in docs_paths:
|
|
243
|
+
continue
|
|
244
|
+
content = read_text_safe(agents) or ""
|
|
245
|
+
referenced = {m.group(1).strip() for m in re.finditer(r"`([^`\n]+)`", content)}
|
|
246
|
+
for md in agents.parent.rglob("*.md"):
|
|
247
|
+
if md.name in ("README.md", "AGENTS.md"):
|
|
248
|
+
continue
|
|
249
|
+
relative_to_docs = md.relative_to(agents.parent)
|
|
250
|
+
if "docs" in relative_to_docs.parts[:-1]:
|
|
251
|
+
continue
|
|
252
|
+
if any(p in EXCLUDED_DIRS for p in md.relative_to(root).parts):
|
|
253
|
+
continue
|
|
254
|
+
rel_to_docs = str(relative_to_docs)
|
|
255
|
+
rel_to_root = str(md.relative_to(root))
|
|
256
|
+
if rel_to_docs in referenced or rel_to_root in referenced:
|
|
257
|
+
continue
|
|
258
|
+
yield Finding(
|
|
259
|
+
file=str(agents),
|
|
260
|
+
line=0,
|
|
261
|
+
severity=Severity.ERROR,
|
|
262
|
+
code="docs-index-incomplete",
|
|
263
|
+
message=f"docs index missing entry for `{rel_to_docs}`",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _lint_docs_readme_links(root: Path, layout: RepositoryContextLayout) -> Iterator[Finding]:
|
|
268
|
+
for tree in layout.docs_trees:
|
|
269
|
+
readme = tree.path / "README.md"
|
|
270
|
+
if not readme.is_file():
|
|
271
|
+
continue
|
|
272
|
+
content = read_text_safe(readme) or ""
|
|
273
|
+
for link in _extract_markdown_links(content):
|
|
274
|
+
if (readme.parent / link).exists() or (root / link).exists():
|
|
275
|
+
continue
|
|
276
|
+
yield Finding(
|
|
277
|
+
file=str(readme),
|
|
278
|
+
line=0,
|
|
279
|
+
severity=Severity.ERROR,
|
|
280
|
+
code="readme-broken-link",
|
|
281
|
+
message=f"broken link `{link}`",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _find_context_files(root: Path, layout: RepositoryContextLayout) -> Iterator[Path]:
|
|
286
|
+
yield from layout.agents_files
|
|
287
|
+
for tree in layout.docs_trees:
|
|
288
|
+
docs_dir = tree.path
|
|
289
|
+
for md in sorted(docs_dir.rglob("*.md")):
|
|
290
|
+
relative = md.relative_to(docs_dir)
|
|
291
|
+
if "docs" in relative.parts[:-1]:
|
|
292
|
+
continue
|
|
293
|
+
if any(part in EXCLUDED_DIRS for part in relative.parts):
|
|
294
|
+
continue
|
|
295
|
+
yield md
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _context_root_for(path: Path, layout: RepositoryContextLayout) -> Path:
|
|
299
|
+
"""Return the nearest contract root that owns a context file."""
|
|
300
|
+
matches = [
|
|
301
|
+
root
|
|
302
|
+
for root in layout.contract_roots
|
|
303
|
+
if path == root or root in path.parents
|
|
304
|
+
]
|
|
305
|
+
return max(matches, key=lambda root: len(root.parts), default=layout.root)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def lint(root: Path) -> LintResult:
|
|
309
|
+
from ..infra.git import git_root
|
|
310
|
+
|
|
311
|
+
root = root.resolve()
|
|
312
|
+
# Materialized commit trees intentionally have no .git directory. In that
|
|
313
|
+
# case the requested validation root is still the repository-scope fallback
|
|
314
|
+
# for references written from the checkout root.
|
|
315
|
+
git_root_path = git_root(root) or root
|
|
316
|
+
|
|
317
|
+
layout = discover(root)
|
|
318
|
+
findings: list[Finding] = []
|
|
319
|
+
for path in _find_context_files(root, layout):
|
|
320
|
+
context_root = _context_root_for(path, layout)
|
|
321
|
+
findings.extend(lint_file(path, context_root, git_root_path=git_root_path))
|
|
322
|
+
findings.extend(_lint_symlinks(root, layout))
|
|
323
|
+
findings.extend(_lint_docs_indexes(root, layout))
|
|
324
|
+
findings.extend(_lint_docs_readme_links(root, layout))
|
|
325
|
+
return LintResult(target=str(root), findings=findings)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Validate skill directory layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..domain.finding import Finding, Severity
|
|
9
|
+
from ..domain.result import LintResult
|
|
10
|
+
from ..infra.filesystem import read_text_safe
|
|
11
|
+
from ..infra.yaml_frontmatter import parse_frontmatter
|
|
12
|
+
|
|
13
|
+
_EXPECTED_SUBDIRS = frozenset({"scripts", "references", "resources", "assets"})
|
|
14
|
+
_ALLOWED_TOP_LEVEL = frozenset({"SKILL.md", "SYNC.md", "README.md"})
|
|
15
|
+
_SOFT_LINE_LIMIT = 500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _finding(path: Path, code: str, message: str, severity: Severity = Severity.ERROR) -> Finding:
|
|
19
|
+
return Finding(file=str(path), line=0, severity=severity, code=code, message=message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def lint_skill(skill_dir: Path) -> Iterator[Finding]:
|
|
23
|
+
if not skill_dir.is_dir():
|
|
24
|
+
yield _finding(skill_dir, "skill-not-a-directory", f"{skill_dir} is not a directory")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
skill_md = skill_dir / "SKILL.md"
|
|
28
|
+
if not skill_md.exists():
|
|
29
|
+
yield _finding(skill_dir, "skill-missing-skill-md", "SKILL.md not found")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
content = read_text_safe(skill_md) or ""
|
|
33
|
+
fm, _ = parse_frontmatter(content)
|
|
34
|
+
|
|
35
|
+
if fm is None:
|
|
36
|
+
yield _finding(skill_md, "skill-no-frontmatter", "SKILL.md has no YAML frontmatter")
|
|
37
|
+
else:
|
|
38
|
+
if "name" not in fm:
|
|
39
|
+
yield _finding(skill_md, "skill-missing-name", "frontmatter missing 'name'")
|
|
40
|
+
if "description" not in fm:
|
|
41
|
+
yield _finding(
|
|
42
|
+
skill_md,
|
|
43
|
+
"skill-missing-description",
|
|
44
|
+
"frontmatter missing 'description'",
|
|
45
|
+
)
|
|
46
|
+
if "name" in fm and fm["name"] != skill_dir.name:
|
|
47
|
+
yield _finding(
|
|
48
|
+
skill_md,
|
|
49
|
+
"skill-name-mismatch",
|
|
50
|
+
f"frontmatter name '{fm['name']}' differs from directory '{skill_dir.name}'",
|
|
51
|
+
Severity.WARNING,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
line_count = len(content.splitlines())
|
|
55
|
+
if line_count > _SOFT_LINE_LIMIT:
|
|
56
|
+
yield _finding(
|
|
57
|
+
skill_md,
|
|
58
|
+
"skill-oversized",
|
|
59
|
+
f"SKILL.md is {line_count} lines (target <{_SOFT_LINE_LIMIT})",
|
|
60
|
+
Severity.WARNING,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
yield from _lint_layout(skill_dir)
|
|
64
|
+
yield from _lint_scripts(skill_dir)
|
|
65
|
+
yield from _lint_references(skill_dir)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _lint_layout(skill_dir: Path) -> Iterator[Finding]:
|
|
69
|
+
for item in sorted(skill_dir.iterdir()):
|
|
70
|
+
if item.name.startswith("."):
|
|
71
|
+
continue
|
|
72
|
+
if item.is_dir() and item.name not in _EXPECTED_SUBDIRS:
|
|
73
|
+
yield _finding(
|
|
74
|
+
item,
|
|
75
|
+
"skill-unexpected-subdir",
|
|
76
|
+
f"unexpected subdirectory: {item.name}/",
|
|
77
|
+
Severity.WARNING,
|
|
78
|
+
)
|
|
79
|
+
elif item.is_file() and item.name not in _ALLOWED_TOP_LEVEL:
|
|
80
|
+
yield _finding(
|
|
81
|
+
item,
|
|
82
|
+
"skill-unexpected-top-level-file",
|
|
83
|
+
f"unexpected top-level file: {item.name}",
|
|
84
|
+
Severity.WARNING,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _lint_scripts(skill_dir: Path) -> Iterator[Finding]:
|
|
89
|
+
scripts = skill_dir / "scripts"
|
|
90
|
+
if not scripts.is_dir():
|
|
91
|
+
return
|
|
92
|
+
for py in sorted(scripts.glob("*.py")):
|
|
93
|
+
source = read_text_safe(py)
|
|
94
|
+
if source is None:
|
|
95
|
+
yield _finding(py, "skill-unreadable-script", "could not read script")
|
|
96
|
+
continue
|
|
97
|
+
# `compile()` does a pure syntax/parse check — no __pycache__ side
|
|
98
|
+
# effects like py_compile. A lint should be read-only.
|
|
99
|
+
try:
|
|
100
|
+
compile(source, str(py), "exec")
|
|
101
|
+
except SyntaxError as e:
|
|
102
|
+
yield _finding(py, "skill-script-compile-error", f"compile error: {e}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _lint_references(skill_dir: Path) -> Iterator[Finding]:
|
|
106
|
+
for sub in ("references", "resources"):
|
|
107
|
+
subdir = skill_dir / sub
|
|
108
|
+
if not subdir.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
for md in sorted(subdir.glob("*.md")):
|
|
111
|
+
if read_text_safe(md) is None:
|
|
112
|
+
yield _finding(md, "skill-unreadable-file", f"unreadable {sub} file")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def lint(target: Path, *, recurse: bool = False) -> LintResult:
|
|
116
|
+
"""If `recurse`, treat `target` as a parent of skills; otherwise target is one skill."""
|
|
117
|
+
if not recurse:
|
|
118
|
+
return LintResult(target=str(target), findings=list(lint_skill(target)))
|
|
119
|
+
|
|
120
|
+
findings: list[Finding] = []
|
|
121
|
+
for skill in sorted(target.iterdir()):
|
|
122
|
+
if skill.is_dir() and (skill / "SKILL.md").exists():
|
|
123
|
+
findings.extend(lint_skill(skill))
|
|
124
|
+
return LintResult(target=str(target), findings=findings)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""One dispatcher. Build the parser from the registry, run, render, exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ..domain.result import AnalysisResult, LintResult
|
|
9
|
+
from .registry import Subcommand
|
|
10
|
+
from .render import emit_json, render_analysis, render_lint
|
|
11
|
+
from .subcommands import ALL
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _error(message: str, *, exit_code: int = 1) -> int:
|
|
15
|
+
print(f"error: {message}", file=sys.stderr)
|
|
16
|
+
return exit_code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser(subcommands: tuple[Subcommand, ...] = ALL) -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="context-engineering",
|
|
22
|
+
description=(
|
|
23
|
+
"Read-only analyzer CLI for context-engineering. "
|
|
24
|
+
"Inspect repository paths, contracts, history, and diffs."
|
|
25
|
+
),
|
|
26
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
27
|
+
epilog=(
|
|
28
|
+
"Examples:\n"
|
|
29
|
+
" context-engineering tier services/payments\n"
|
|
30
|
+
" context-engineering antipatterns modules/help-desk --json\n"
|
|
31
|
+
" context-engineering cross-cutting services/payments --discover\n"
|
|
32
|
+
" context-engineering skills ./skills --all\n"
|
|
33
|
+
" context-engineering contracts packages/api --json\n"
|
|
34
|
+
" context-engineering review . --base origin/main\n"
|
|
35
|
+
" context-engineering check . --base origin/main\n"
|
|
36
|
+
" context-engineering check . --all\n"
|
|
37
|
+
" context-engineering backfill . --cutoff HEAD --json\n"
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
sub = parser.add_subparsers(dest="subcommand", metavar="<subcommand>")
|
|
41
|
+
|
|
42
|
+
for cmd in subcommands:
|
|
43
|
+
sp = sub.add_parser(
|
|
44
|
+
cmd.name,
|
|
45
|
+
help=cmd.help,
|
|
46
|
+
description=cmd.description,
|
|
47
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
48
|
+
epilog=cmd.epilog(),
|
|
49
|
+
)
|
|
50
|
+
cmd.configure(sp)
|
|
51
|
+
sp.add_argument("--json", action="store_true", help="emit JSON to stdout")
|
|
52
|
+
sp.set_defaults(_handler=cmd)
|
|
53
|
+
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _render(result, *, json_mode: bool) -> int:
|
|
58
|
+
if json_mode:
|
|
59
|
+
emit_json(result)
|
|
60
|
+
if isinstance(result, (LintResult, AnalysisResult)):
|
|
61
|
+
return 1 if result.has_errors else 0
|
|
62
|
+
return 0
|
|
63
|
+
if isinstance(result, LintResult):
|
|
64
|
+
return render_lint(result)
|
|
65
|
+
render_analysis(result)
|
|
66
|
+
return 1 if result.has_errors else 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = build_parser()
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
if not getattr(args, "subcommand", None):
|
|
74
|
+
parser.print_help()
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
cmd: Subcommand = args._handler
|
|
78
|
+
result = cmd.run(args)
|
|
79
|
+
|
|
80
|
+
if result is None:
|
|
81
|
+
hint = cmd.examples[0] if cmd.examples else f"context-engineering {cmd.name} <path>"
|
|
82
|
+
return _error(
|
|
83
|
+
f"{cmd.name}: invalid target path. Try: {hint}",
|
|
84
|
+
exit_code=2,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if not isinstance(result, (LintResult, AnalysisResult)):
|
|
88
|
+
return _error(f"internal: {cmd.name} returned {type(result).__name__}")
|
|
89
|
+
|
|
90
|
+
return _render(result, json_mode=args.json)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Subcommand registry — one dataclass per subcommand, zero dispatcher boilerplate.
|
|
2
|
+
|
|
3
|
+
Each subcommand declares:
|
|
4
|
+
- its name, help text, and Examples block
|
|
5
|
+
- a `configure(parser)` callback to add arguments
|
|
6
|
+
- a `run(args) -> Result` callback — the actual handler
|
|
7
|
+
|
|
8
|
+
`dispatch.main` walks the registry to build the parser, pick a handler, and
|
|
9
|
+
render the result uniformly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
Configure = Callable[[argparse.ArgumentParser], None]
|
|
20
|
+
Run = Callable[[argparse.Namespace], Any] # Returns LintResult | AnalysisResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Subcommand:
|
|
25
|
+
name: str
|
|
26
|
+
help: str
|
|
27
|
+
description: str
|
|
28
|
+
examples: tuple[str, ...]
|
|
29
|
+
configure: Configure
|
|
30
|
+
run: Run
|
|
31
|
+
|
|
32
|
+
def epilog(self) -> str:
|
|
33
|
+
return "Examples:\n" + "\n".join(f" {e}" for e in self.examples) + "\n"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Render LintResult / AnalysisResult as JSON or human-readable text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..domain.finding import Finding, Severity
|
|
10
|
+
from ..domain.result import AnalysisResult, LintResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def emit_json(result: Any) -> None:
|
|
14
|
+
json.dump(result.to_dict(), sys.stdout, indent=2, default=str, sort_keys=False)
|
|
15
|
+
sys.stdout.write("\n")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_lint(result: LintResult) -> int:
|
|
19
|
+
"""Print a LintResult in human-readable form. Return suggested exit code."""
|
|
20
|
+
if not result.findings:
|
|
21
|
+
print(f"No findings in {result.target}")
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
by_severity: dict[Severity, list[Finding]] = {
|
|
25
|
+
Severity.ERROR: [],
|
|
26
|
+
Severity.WARNING: [],
|
|
27
|
+
Severity.INFO: [],
|
|
28
|
+
}
|
|
29
|
+
for finding in result.findings:
|
|
30
|
+
by_severity[finding.severity].append(finding)
|
|
31
|
+
|
|
32
|
+
summary = (
|
|
33
|
+
f"{result.error_count} error(s), "
|
|
34
|
+
f"{result.warning_count} warning(s), "
|
|
35
|
+
f"{result.info_count} info"
|
|
36
|
+
)
|
|
37
|
+
print(f"{summary} in {result.target}\n")
|
|
38
|
+
|
|
39
|
+
for severity in (Severity.ERROR, Severity.WARNING, Severity.INFO):
|
|
40
|
+
for f in by_severity[severity]:
|
|
41
|
+
where = f"{f.file}:{f.line}" if f.line > 0 else f.file
|
|
42
|
+
print(f" [{f.severity.value}] {where} [{f.code}] {f.message}")
|
|
43
|
+
if f.hint:
|
|
44
|
+
print(f" hint: {f.hint}")
|
|
45
|
+
if by_severity[severity]:
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
return 1 if result.has_errors else 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_analysis(result: AnalysisResult) -> int:
|
|
52
|
+
"""Print an AnalysisResult. Key-value or tabular depending on shape."""
|
|
53
|
+
data = result.data
|
|
54
|
+
print(f"Target: {result.target}")
|
|
55
|
+
for key, value in data.items():
|
|
56
|
+
if isinstance(value, (dict, list)):
|
|
57
|
+
print(f"{key}:")
|
|
58
|
+
_print_nested(value, indent=2)
|
|
59
|
+
else:
|
|
60
|
+
print(f"{key}: {value}")
|
|
61
|
+
|
|
62
|
+
# Surface any warnings/errors that came along with the analysis (e.g.,
|
|
63
|
+
# cross-cutting's "skipping outside-repo path" warnings). Previously these
|
|
64
|
+
# showed up in --json but not in the human-readable output.
|
|
65
|
+
if result.findings:
|
|
66
|
+
print()
|
|
67
|
+
print(f"Findings ({len(result.findings)}):")
|
|
68
|
+
for f in result.findings:
|
|
69
|
+
where = f"{f.file}:{f.line}" if f.line > 0 else f.file
|
|
70
|
+
print(f" [{f.severity.value}] {where} [{f.code}] {f.message}")
|
|
71
|
+
if f.hint:
|
|
72
|
+
print(f" hint: {f.hint}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _print_nested(value, indent: int) -> None:
|
|
77
|
+
pad = " " * indent
|
|
78
|
+
if isinstance(value, list):
|
|
79
|
+
for item in value[:20]:
|
|
80
|
+
if isinstance(item, dict):
|
|
81
|
+
print(pad + "- " + ", ".join(f"{k}={v}" for k, v in item.items()))
|
|
82
|
+
else:
|
|
83
|
+
print(f"{pad}- {item}")
|
|
84
|
+
if len(value) > 20:
|
|
85
|
+
print(f"{pad}... {len(value) - 20} more items; rerun with --json for all")
|
|
86
|
+
elif isinstance(value, dict):
|
|
87
|
+
for k, v in value.items():
|
|
88
|
+
if isinstance(v, (dict, list)):
|
|
89
|
+
print(f"{pad}{k}:")
|
|
90
|
+
_print_nested(v, indent + 2)
|
|
91
|
+
else:
|
|
92
|
+
print(f"{pad}{k}: {v}")
|