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,47 @@
1
+ """Git co-change signal — count file pairs touched in the same commit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ from ...domain.commit import Commit
10
+ from ...infra.git import git_log
11
+ from .buckets import infer, module_for_path
12
+
13
+
14
+ def pairs_between(commits: Iterable[Commit], module_roots: list[str]) -> Counter:
15
+ """Count how often each pair of `module_roots` co-changes."""
16
+ pairs: Counter = Counter()
17
+ for commit in commits:
18
+ touched = {
19
+ m for f in commit.files if (m := module_for_path(f, module_roots))
20
+ }
21
+ ordered = sorted(touched)
22
+ for i in range(len(ordered)):
23
+ for j in range(i + 1, len(ordered)):
24
+ pairs[(ordered[i], ordered[j])] += 1
25
+ return pairs
26
+
27
+
28
+ def discover_partners(
29
+ repo_root: Path, target_module: str, lookback_days: int
30
+ ) -> tuple[Counter, int]:
31
+ """(partners, total_commits_touching_target) — `discover` mode counterpart."""
32
+ partners: Counter = Counter()
33
+ total = 0
34
+ for commit in git_log(repo_root, since_days=lookback_days):
35
+ if not commit.touches(target_module):
36
+ continue
37
+ total += 1
38
+ others: set[str] = set()
39
+ for f in commit.files:
40
+ if f == target_module or f.startswith(target_module + "/"):
41
+ continue
42
+ bucket = infer(f)
43
+ if bucket and bucket != target_module:
44
+ others.add(bucket)
45
+ for bucket in others:
46
+ partners[bucket] += 1
47
+ return partners, total
@@ -0,0 +1,75 @@
1
+ """Discover mode — auto-find partners of a single target module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from pathlib import Path
7
+
8
+ from ...domain.result import AnalysisResult
9
+ from ...infra.git import git_root
10
+ from . import authors, co_change, imports
11
+
12
+
13
+ def _rank(counts: Counter, *, top_n: int, min_edge: int = 0) -> list[tuple[str, int]]:
14
+ """Stable ranking by (-count, name) so tie-breaking is deterministic."""
15
+ return sorted(
16
+ ((k, v) for k, v in counts.items() if v >= min_edge),
17
+ key=lambda x: (-x[1], x[0]),
18
+ )[:top_n]
19
+
20
+
21
+ def discover(
22
+ target_path: Path,
23
+ *,
24
+ lookback_days: int = 90,
25
+ min_edge: int = 3,
26
+ top_n: int = 10,
27
+ signals: frozenset[str] = frozenset({"imports", "co-change", "authors"}),
28
+ ) -> AnalysisResult:
29
+ repo = git_root(target_path)
30
+ if repo is None:
31
+ return AnalysisResult(
32
+ target=str(target_path),
33
+ data={"error": "not in a git repo", "mode": "discover"},
34
+ )
35
+
36
+ try:
37
+ target_rel = str(target_path.resolve().relative_to(repo.resolve()))
38
+ except ValueError:
39
+ target_rel = str(target_path)
40
+
41
+ data: dict = {
42
+ "repo_root": str(repo),
43
+ "modules": [target_rel],
44
+ "lookback_days": lookback_days,
45
+ "mode": "discover",
46
+ }
47
+
48
+ if "imports" in signals:
49
+ import_counts = imports.discover_targets(target_path, repo)
50
+ data["discovered_imports"] = [
51
+ {"module": m, "count": c}
52
+ for m, c in _rank(import_counts, top_n=top_n, min_edge=min_edge)
53
+ ]
54
+
55
+ if "co-change" in signals:
56
+ partners, total = co_change.discover_partners(repo, target_rel, lookback_days)
57
+ data["discovered_co_change"] = [
58
+ {"module": m, "shared_commits": c}
59
+ for m, c in _rank(partners, top_n=top_n, min_edge=min_edge)
60
+ ]
61
+ data["co_change_total_commits_touching_target"] = total
62
+
63
+ if "authors" in signals:
64
+ author_partners = authors.discover_partner_modules(repo, target_rel, lookback_days)
65
+ module_to_authors: Counter = Counter()
66
+ for counts in author_partners.values():
67
+ for m in counts:
68
+ module_to_authors[m] += 1
69
+ data["discovered_by_authors"] = [
70
+ {"module": m, "shared_authors": c}
71
+ for m, c in _rank(module_to_authors, top_n=top_n, min_edge=min_edge)
72
+ ]
73
+ data["target_author_count"] = len(author_partners)
74
+
75
+ return AnalysisResult(target=target_rel, data=data)
@@ -0,0 +1,61 @@
1
+ """Static-import signal — collect Python imports, count cross-module edges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ from .buckets import infer, module_for_path
10
+
11
+ _IMPORT_RE = re.compile(
12
+ r"^\s*(?:from\s+([a-zA-Z0-9_.]+)\s+import\b|import\s+([a-zA-Z0-9_.]+))",
13
+ re.MULTILINE,
14
+ )
15
+
16
+
17
+ def collect(module_path: Path) -> set[str]:
18
+ """Every dotted import path referenced from Python files under `module_path`."""
19
+ imports: set[str] = set()
20
+ for py in module_path.rglob("*.py"):
21
+ try:
22
+ content = py.read_text(encoding="utf-8", errors="ignore")
23
+ except OSError:
24
+ continue
25
+ for match in _IMPORT_RE.finditer(content):
26
+ name = match.group(1) or match.group(2)
27
+ if name:
28
+ imports.add(name)
29
+ return imports
30
+
31
+
32
+ def edges_between(
33
+ module_paths: list[Path], module_roots: list[str]
34
+ ) -> dict[str, Counter]:
35
+ """For each source module, count imports that resolve into a *different* input module."""
36
+ result: dict[str, Counter] = {root: Counter() for root in module_roots}
37
+ for src_path, src_root in zip(module_paths, module_roots, strict=True):
38
+ for imp in collect(src_path):
39
+ imp_path = imp.replace(".", "/")
40
+ target = module_for_path(imp_path, module_roots)
41
+ if target and target != src_root:
42
+ result[src_root][target] += 1
43
+ return result
44
+
45
+
46
+ def discover_targets(module_path: Path, repo_root: Path) -> Counter:
47
+ """Count buckets the input imports from (for discover mode)."""
48
+ try:
49
+ src_bucket = infer(str(module_path.relative_to(repo_root)))
50
+ except ValueError:
51
+ src_bucket = None
52
+
53
+ counts: Counter = Counter()
54
+ for imp in collect(module_path):
55
+ bucket = infer(imp.replace(".", "/"))
56
+ if bucket is None or bucket == src_bucket:
57
+ continue
58
+ if not (repo_root / bucket).exists():
59
+ continue
60
+ counts[bucket] += 1
61
+ return counts
@@ -0,0 +1,118 @@
1
+ """Pair mode — coupling between N specified modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ...domain.finding import Finding, Severity
8
+ from ...domain.result import AnalysisResult
9
+ from ...infra.git import git_log, git_root
10
+ from . import authors, co_change, imports
11
+
12
+
13
+ def _resolve_modules(
14
+ module_paths: list[Path], repo: Path
15
+ ) -> tuple[list[Path], list[str], list[Finding]]:
16
+ """Drop paths outside `repo`, returning a warning Finding for each drop.
17
+
18
+ Both sides are resolved (symlinks followed) so a repo root reported by
19
+ `git rev-parse` with a symlink component matches the same physical path
20
+ the user supplied — matching what discover mode does.
21
+ """
22
+ repo_resolved = repo.resolve()
23
+ inside_paths: list[Path] = []
24
+ inside_roots: list[str] = []
25
+ warnings: list[Finding] = []
26
+ for mp in module_paths:
27
+ try:
28
+ inside_roots.append(str(mp.resolve().relative_to(repo_resolved)))
29
+ inside_paths.append(mp)
30
+ except ValueError:
31
+ warnings.append(
32
+ Finding(
33
+ file=str(mp),
34
+ line=0,
35
+ severity=Severity.WARNING,
36
+ code="cross-cutting-path-outside-repo",
37
+ message=f"skipping {mp}: not under repo root {repo}",
38
+ )
39
+ )
40
+ return inside_paths, inside_roots, warnings
41
+
42
+
43
+ def _repo_for_any_path(module_paths: list[Path]) -> Path | None:
44
+ """Return the git root for the first path that is inside a repository."""
45
+ for module_path in module_paths:
46
+ repo = git_root(module_path)
47
+ if repo is not None:
48
+ return repo
49
+ return None
50
+
51
+
52
+ def pair(
53
+ module_paths: list[Path],
54
+ *,
55
+ lookback_days: int = 90,
56
+ min_edge: int = 3,
57
+ signals: frozenset[str] = frozenset({"imports", "co-change", "authors"}),
58
+ ) -> AnalysisResult:
59
+ if not module_paths:
60
+ return AnalysisResult(
61
+ target="<not-a-repo>", data={"error": "no module paths provided", "mode": "pair"}
62
+ )
63
+
64
+ repo = _repo_for_any_path(module_paths)
65
+ if repo is None:
66
+ return AnalysisResult(
67
+ target="<not-a-repo>", data={"error": "not in a git repo", "mode": "pair"}
68
+ )
69
+
70
+ resolved_paths, module_roots, findings = _resolve_modules(module_paths, repo)
71
+
72
+ data: dict = {
73
+ "repo_root": str(repo),
74
+ "modules": module_roots,
75
+ "lookback_days": lookback_days,
76
+ "mode": "pair",
77
+ }
78
+
79
+ if "imports" in signals:
80
+ edges = imports.edges_between(resolved_paths, module_roots)
81
+ data["import_edges"] = {
82
+ src: [
83
+ {"target": t, "count": c}
84
+ # -count primary, name secondary → stable ordering.
85
+ # Apply min_edge consistently — discover mode filters all
86
+ # three signals, and pair's co-change does too.
87
+ for t, c in sorted(cs.items(), key=lambda x: (-x[1], x[0]))
88
+ if c >= min_edge
89
+ ]
90
+ for src, cs in edges.items()
91
+ }
92
+
93
+ # Share the history walk: co-change wants commits that touch a module
94
+ # root; authors wants the full stream. Pull once, slice twice.
95
+ if {"co-change", "authors"} & signals:
96
+ all_commits = list(git_log(repo, since_days=lookback_days))
97
+
98
+ if "co-change" in signals:
99
+ touching = [
100
+ c for c in all_commits
101
+ if any(c.touches(r) for r in module_roots)
102
+ ]
103
+ pairs = co_change.pairs_between(touching, module_roots)
104
+ data["co_change_pairs"] = [
105
+ {"modules": list(p), "commits": c}
106
+ for p, c in sorted(pairs.items(), key=lambda x: (-x[1], x[0]))
107
+ if c >= min_edge
108
+ ]
109
+ data["co_change_total_commits"] = len(touching)
110
+
111
+ if "authors" in signals:
112
+ activity = authors.activity_by_author(all_commits, module_roots)
113
+ data["cross_cutting_authors"] = [
114
+ {"author": a, "modules": m}
115
+ for a, m in authors.cross_cutting_authors(activity, min_modules=2)
116
+ ]
117
+
118
+ return AnalysisResult(target=str(module_paths[0]), data=data, findings=findings)
@@ -0,0 +1,77 @@
1
+ """Classify changed files by impact (HIGH / MEDIUM / LOW).
2
+
3
+ The patterns live here — staleness.py just calls classify() so there's one
4
+ place to add a new high-impact filename.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+
13
+
14
+ class Impact(StrEnum):
15
+ HIGH = "high"
16
+ MEDIUM = "medium"
17
+ LOW = "low"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ImpactClassification:
22
+ impact: Impact
23
+ reason: str
24
+
25
+
26
+ def _rule(pattern: str, reason: str) -> tuple[re.Pattern[str], str]:
27
+ return re.compile(pattern), reason
28
+
29
+
30
+ _HIGH = (
31
+ _rule(r"(^|/)package\.json$", "dependency manifest"),
32
+ _rule(r"(^|/)pyproject\.toml$", "Python project manifest"),
33
+ _rule(r"(^|/)Cargo\.toml$", "Rust project manifest"),
34
+ _rule(r"(^|/)go\.(mod|sum)$", "Go dependency manifest"),
35
+ _rule(r"(^|/)pom\.xml$", "Maven project manifest"),
36
+ _rule(r"(^|/)build\.gradle", "Gradle build configuration"),
37
+ _rule(r"(^|/)Makefile$", "Make build entrypoint"),
38
+ _rule(r"(^|/)Taskfile", "Task runner configuration"),
39
+ _rule(r"(^|/)justfile$", "Just task runner configuration"),
40
+ _rule(r"(^|/)\.mise\.toml$", "mise task and tool configuration"),
41
+ _rule(r"(^|/)\.github/workflows/", "GitHub Actions workflow"),
42
+ _rule(r"(^|/)\.gitlab-ci", "GitLab CI configuration"),
43
+ _rule(r"(^|/)Jenkinsfile", "Jenkins pipeline"),
44
+ _rule(r"(^|/)Dockerfile", "container build definition"),
45
+ _rule(r"(^|/)docker-compose", "container composition configuration"),
46
+ _rule(r"(^|/)\.pre-commit-config\.yaml$", "pre-commit configuration"),
47
+ )
48
+
49
+ _MEDIUM = (
50
+ _rule(r"(^|/)README", "README documentation"),
51
+ _rule(r"(^|/)CHANGELOG", "changelog"),
52
+ _rule(r"(^|/)setup\.(py|cfg)$", "Python package configuration"),
53
+ _rule(r"(^|/)requirements.*\.txt$", "Python dependency list"),
54
+ )
55
+
56
+
57
+ def classify_with_reason(filepath: str) -> ImpactClassification:
58
+ for pattern, reason in _HIGH:
59
+ if pattern.search(filepath):
60
+ return ImpactClassification(Impact.HIGH, f"matched high-impact rule: {reason}")
61
+ for pattern, reason in _MEDIUM:
62
+ if pattern.search(filepath):
63
+ return ImpactClassification(Impact.MEDIUM, f"matched medium-impact rule: {reason}")
64
+ # Shallow paths (top-level changes) default to MEDIUM; deep paths are LOW
65
+ if len(filepath.split("/")) <= 2:
66
+ return ImpactClassification(
67
+ Impact.MEDIUM,
68
+ "no named rule matched; paths at depth 2 or less default to medium impact",
69
+ )
70
+ return ImpactClassification(
71
+ Impact.LOW,
72
+ "no named rule matched; paths deeper than 2 components default to low impact",
73
+ )
74
+
75
+
76
+ def classify(filepath: str) -> Impact:
77
+ return classify_with_reason(filepath).impact
@@ -0,0 +1,27 @@
1
+ """Optional session-artifact discovery with no assumed agent or home-directory layout."""
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+ from ..domain.result import AnalysisResult
5
+
6
+ def analyze(project_path: str, max_results: int = 20, sessions_dir: str | None = None) -> AnalysisResult:
7
+ """Return explicit unavailable evidence unless the caller configures a directory.
8
+
9
+ This portable fallback deliberately never searches a personal agent-history path.
10
+ A caller may supply exported JSONL artifacts through a configured directory.
11
+ """
12
+ result=AnalysisResult(target=project_path)
13
+ root=Path(sessions_dir).expanduser() if sessions_dir else None
14
+ if root is None:
15
+ result.data={"state":"unavailable", "reason":"No session artifact directory was configured; continue without session evidence."}
16
+ return result
17
+ if not root.is_dir():
18
+ result.data={"state":"unavailable", "reason":"Configured session artifact directory does not exist."}
19
+ return result
20
+ target=str(Path(project_path).resolve())
21
+ hits=[]
22
+ for artifact in root.rglob("*.jsonl"):
23
+ try: count=artifact.read_text(encoding="utf-8").count(target)
24
+ except OSError: continue
25
+ if count: hits.append((count,artifact))
26
+ result.data={"state":"available", "sessions":[{"path":str(artifact),"mentions":count} for count, artifact in sorted(hits, reverse=True)[:max_results]]}
27
+ return result
@@ -0,0 +1,179 @@
1
+ """Recommend whether an AGENTS.md needs refreshing based on recent git activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from pathlib import Path
7
+
8
+ from ..domain.result import AnalysisResult
9
+ from ..infra.git import git_log, git_root
10
+ from .impact import Impact, classify_with_reason
11
+
12
+
13
+ def _last_update(agents_path: Path) -> tuple[str, str]:
14
+ """(sha, iso_date) of the most recent commit that touched `agents_path`."""
15
+ repo = git_root(agents_path.parent)
16
+ if repo is None:
17
+ return "", ""
18
+ # `git log -- <path>` pathspecs are repo-relative, and git_log runs from
19
+ # the repo root. Pass the full repo-relative path so nested AGENTS.md
20
+ # files (e.g. services/payments/AGENTS.md) actually match their history.
21
+ try:
22
+ rel = str(agents_path.resolve().relative_to(repo.resolve()))
23
+ except ValueError:
24
+ return "", ""
25
+ commit = next(git_log(repo, paths=[rel], max_count=1), None)
26
+ if commit is None:
27
+ return "", ""
28
+ return commit.sha, commit.date.isoformat()
29
+
30
+
31
+ def _days_since(iso_date: str) -> int:
32
+ if not iso_date:
33
+ return 0
34
+ try:
35
+ dt = datetime.datetime.fromisoformat(iso_date)
36
+ except ValueError:
37
+ return -1
38
+ return (datetime.datetime.now(datetime.UTC) - dt).days
39
+
40
+
41
+ def recommend(*, commits_since: int, high: int, medium: int, days_since: int) -> str:
42
+ if commits_since == 0:
43
+ return "skip"
44
+ if days_since > 30 or commits_since > 50:
45
+ return "full-rebuild"
46
+ if high > 0:
47
+ return "full-update"
48
+ if medium > 0:
49
+ return "partial-update"
50
+ return "skip"
51
+
52
+
53
+ def _recommendation_reasons(
54
+ *, commits_since: int, high: int, medium: int, days_since: int
55
+ ) -> list[str]:
56
+ if commits_since == 0:
57
+ return ["no commits touched the scoped module since the AGENTS.md update"]
58
+ if days_since > 30 or commits_since > 50:
59
+ reasons = []
60
+ if days_since > 30:
61
+ reasons.append(f"{days_since} days exceeds the 30-day rebuild threshold")
62
+ if commits_since > 50:
63
+ reasons.append(
64
+ f"{commits_since} commits exceeds the 50-commit rebuild threshold"
65
+ )
66
+ return reasons
67
+ if high > 0:
68
+ return [f"{high} changed paths matched high-impact rules"]
69
+ if medium > 0:
70
+ return [f"{medium} changed paths classified medium impact"]
71
+ return ["all changed paths defaulted to low impact"]
72
+
73
+
74
+ def analyze(agents_path: Path) -> AnalysisResult:
75
+ agents_path = agents_path.resolve()
76
+
77
+ if not agents_path.exists():
78
+ return AnalysisResult(
79
+ target=str(agents_path),
80
+ data={
81
+ "recommendation": "full-rebuild",
82
+ "recommendation_advisory": True,
83
+ "recommendation_reasons": ["the target AGENTS.md does not exist"],
84
+ "changed_files": [],
85
+ "error": f"{agents_path} not found",
86
+ },
87
+ )
88
+
89
+ repo = git_root(agents_path)
90
+ if repo is None:
91
+ return AnalysisResult(
92
+ target=str(agents_path),
93
+ data={
94
+ "recommendation": "full-rebuild",
95
+ "recommendation_advisory": True,
96
+ "recommendation_reasons": ["the target is not in a git repository"],
97
+ "changed_files": [],
98
+ "error": "Not a git repository",
99
+ },
100
+ )
101
+
102
+ last_sha, last_date = _last_update(agents_path)
103
+
104
+ # Scope the scan to the module directory — for a nested AGENTS.md like
105
+ # `services/payments/AGENTS.md`, we only care about commits that touched
106
+ # `services/payments/`. Walking the entire repo inflates commit counts and
107
+ # counts unrelated Dockerfiles/CI configs as HIGH-impact, which made
108
+ # the recommendation default to "full-rebuild" for every non-root
109
+ # AGENTS.md in a busy monorepo.
110
+ try:
111
+ scope = str(agents_path.parent.resolve().relative_to(repo.resolve()))
112
+ except ValueError:
113
+ scope = "."
114
+ scope_paths = [scope] if scope and scope != "." else None
115
+
116
+ commits_iter = (
117
+ git_log(repo, since_ref=last_sha, paths=scope_paths)
118
+ if last_sha
119
+ else git_log(repo, paths=scope_paths)
120
+ )
121
+ commits = 0
122
+ files: set[str] = set()
123
+ for commit in commits_iter:
124
+ commits += 1
125
+ files.update(commit.files)
126
+
127
+ changed_files: list[dict[str, str]] = []
128
+ impact_counts = {impact: 0 for impact in Impact}
129
+ for f in sorted(files):
130
+ classification = classify_with_reason(f)
131
+ impact_counts[classification.impact] += 1
132
+ changed_files.append(
133
+ {
134
+ "path": f,
135
+ "impact": classification.impact.value,
136
+ "classification_reason": classification.reason,
137
+ }
138
+ )
139
+
140
+ days_since = _days_since(last_date)
141
+ # A never-committed AGENTS.md (`last_sha == ""`) is not "fresh" — it's
142
+ # untracked. Force full-rebuild instead of letting days_since=0 trick the
143
+ # heuristic into "skip".
144
+ if not last_sha:
145
+ rec = "full-rebuild"
146
+ else:
147
+ rec = recommend(
148
+ commits_since=commits,
149
+ high=impact_counts[Impact.HIGH],
150
+ medium=impact_counts[Impact.MEDIUM],
151
+ days_since=days_since,
152
+ )
153
+
154
+ if not last_sha:
155
+ recommendation_reasons = ["the AGENTS.md has no committed update"]
156
+ else:
157
+ recommendation_reasons = _recommendation_reasons(
158
+ commits_since=commits,
159
+ high=impact_counts[Impact.HIGH],
160
+ medium=impact_counts[Impact.MEDIUM],
161
+ days_since=days_since,
162
+ )
163
+
164
+ return AnalysisResult(
165
+ target=str(agents_path),
166
+ data={
167
+ "agents_file": str(agents_path),
168
+ "last_updated": {"commit": last_sha, "date": last_date},
169
+ "commits_since": commits,
170
+ "days_since": days_since,
171
+ "changed_files": changed_files,
172
+ "impact_summary": {
173
+ impact.value: impact_counts[impact] for impact in Impact
174
+ },
175
+ "recommendation": rec,
176
+ "recommendation_advisory": True,
177
+ "recommendation_reasons": recommendation_reasons,
178
+ },
179
+ )
@@ -0,0 +1,91 @@
1
+ """Classify a module's opinion-density tier from its recent unique authors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..domain.result import AnalysisResult
8
+ from ..infra.git import git_log, git_root
9
+
10
+ _TIERS: tuple[tuple[int, str], ...] = (
11
+ (0, "unknown"),
12
+ (1, "1"),
13
+ (5, "2-5"),
14
+ (20, "6-20"),
15
+ )
16
+
17
+
18
+ def classify(unique_authors: int) -> str:
19
+ for threshold, label in _TIERS:
20
+ if unique_authors <= threshold:
21
+ return label
22
+ return "21+"
23
+
24
+
25
+ _GUIDANCE: dict[str, dict[str, str]] = {
26
+ "unknown": {
27
+ "multi_path_commands": (
28
+ "No history in lookback window — default to listing options neutrally; "
29
+ "be conservative"
30
+ ),
31
+ "how_to_work_here": (
32
+ "No tier signal — skip unless code structure makes a canonical flow obvious"
33
+ ),
34
+ "gotcha_budget_default": "5-7",
35
+ },
36
+ "1": {
37
+ "multi_path_commands": "Pick the canonical path; one contributor owns it",
38
+ "how_to_work_here": "Opinionated step-by-step is appropriate",
39
+ "gotcha_budget_default": "7",
40
+ },
41
+ "2-5": {
42
+ "multi_path_commands": "Pick a default with a one-line rationale",
43
+ "how_to_work_here": "Opinionated, validated by the small team",
44
+ "gotcha_budget_default": "7",
45
+ },
46
+ "6-20": {
47
+ "multi_path_commands": "List options with tradeoff notes; don't prescribe a default",
48
+ "how_to_work_here": "Brief pointer to canonical flow",
49
+ "gotcha_budget_default": "7",
50
+ },
51
+ "21+": {
52
+ "multi_path_commands": "List options neutrally or defer to --help / nested AGENTS.md",
53
+ "how_to_work_here": "Drop; candidates go to SKIPPED_FOR_REVIEW.md",
54
+ "gotcha_budget_default": "5-7",
55
+ },
56
+ }
57
+
58
+
59
+ def guidance(tier: str) -> dict[str, str]:
60
+ return dict(_GUIDANCE[tier])
61
+
62
+
63
+ def analyze(module_path: Path, *, lookback_days: int = 90) -> AnalysisResult:
64
+ repo = git_root(module_path)
65
+ try:
66
+ target = (
67
+ str(module_path.resolve().relative_to(repo.resolve()))
68
+ if repo
69
+ else str(module_path)
70
+ )
71
+ except ValueError:
72
+ target = str(module_path)
73
+
74
+ authors: set[str] = set()
75
+ total = 0
76
+ for commit in git_log(module_path, since_days=lookback_days, paths=[target]):
77
+ authors.add(commit.author)
78
+ total += 1
79
+
80
+ tier = classify(len(authors))
81
+ return AnalysisResult(
82
+ target=target,
83
+ data={
84
+ "path": target,
85
+ "lookback_days": lookback_days,
86
+ "unique_authors": len(authors),
87
+ "total_commits": total,
88
+ "tier": tier,
89
+ "guidance": guidance(tier),
90
+ },
91
+ )
@@ -0,0 +1 @@
1
+ """Linters — each produces Iterator[Finding]."""
@@ -0,0 +1,5 @@
1
+ """Antipattern checkers — each a pure function yielding Findings."""
2
+
3
+ from .runner import lint
4
+
5
+ __all__ = ["lint"]