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,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]."""
|