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.
Files changed (55) hide show
  1. context_engineering/__init__.py +3 -0
  2. context_engineering/__main__.py +2 -0
  3. context_engineering/analysis/__init__.py +1 -0
  4. context_engineering/analysis/backfill.py +1064 -0
  5. context_engineering/analysis/context_check.py +253 -0
  6. context_engineering/analysis/context_layout.py +111 -0
  7. context_engineering/analysis/context_review.py +224 -0
  8. context_engineering/analysis/cross_cutting/__init__.py +6 -0
  9. context_engineering/analysis/cross_cutting/authors.py +57 -0
  10. context_engineering/analysis/cross_cutting/buckets.py +40 -0
  11. context_engineering/analysis/cross_cutting/co_change.py +47 -0
  12. context_engineering/analysis/cross_cutting/discover.py +75 -0
  13. context_engineering/analysis/cross_cutting/imports.py +61 -0
  14. context_engineering/analysis/cross_cutting/pair.py +118 -0
  15. context_engineering/analysis/impact.py +77 -0
  16. context_engineering/analysis/sessions.py +27 -0
  17. context_engineering/analysis/staleness.py +179 -0
  18. context_engineering/analysis/tier.py +91 -0
  19. context_engineering/checks/__init__.py +1 -0
  20. context_engineering/checks/antipatterns/__init__.py +5 -0
  21. context_engineering/checks/antipatterns/context.py +23 -0
  22. context_engineering/checks/antipatterns/density.py +72 -0
  23. context_engineering/checks/antipatterns/line_limits.py +52 -0
  24. context_engineering/checks/antipatterns/runner.py +137 -0
  25. context_engineering/checks/antipatterns/splitting.py +97 -0
  26. context_engineering/checks/antipatterns/volatile.py +38 -0
  27. context_engineering/checks/antipatterns/watermark.py +113 -0
  28. context_engineering/checks/contracts.py +456 -0
  29. context_engineering/checks/depth.py +82 -0
  30. context_engineering/checks/frontmatter.py +125 -0
  31. context_engineering/checks/references.py +325 -0
  32. context_engineering/checks/skill_structure.py +124 -0
  33. context_engineering/cli/__init__.py +3 -0
  34. context_engineering/cli/dispatch.py +90 -0
  35. context_engineering/cli/registry.py +33 -0
  36. context_engineering/cli/render.py +92 -0
  37. context_engineering/cli/subcommands.py +587 -0
  38. context_engineering/domain/__init__.py +0 -0
  39. context_engineering/domain/commit.py +19 -0
  40. context_engineering/domain/evidence.py +57 -0
  41. context_engineering/domain/finding.py +37 -0
  42. context_engineering/domain/result.py +59 -0
  43. context_engineering/infra/__init__.py +13 -0
  44. context_engineering/infra/filesystem.py +22 -0
  45. context_engineering/infra/git.py +153 -0
  46. context_engineering/infra/git_evidence.py +357 -0
  47. context_engineering/infra/git_tree.py +139 -0
  48. context_engineering/infra/markdown.py +58 -0
  49. context_engineering/infra/yaml_frontmatter.py +70 -0
  50. context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
  51. context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
  52. context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
  53. context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
  54. context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
  55. 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,3 @@
1
+ from .dispatch import main
2
+
3
+ __all__ = ["main"]
@@ -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}")