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,23 @@
|
|
|
1
|
+
"""Context passed to each checker — depth, modified flag, shared git metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CheckContext:
|
|
11
|
+
root: Path
|
|
12
|
+
git_root: Path | None
|
|
13
|
+
modified_only: bool
|
|
14
|
+
|
|
15
|
+
def depth(self, path: Path) -> int:
|
|
16
|
+
"""Depth of `path.parent` relative to git root. Falls back to 2 (leaf default)."""
|
|
17
|
+
if self.git_root is None:
|
|
18
|
+
return 2
|
|
19
|
+
try:
|
|
20
|
+
rel = path.parent.resolve().relative_to(self.git_root.resolve())
|
|
21
|
+
return len([p for p in rel.parts if p != "."])
|
|
22
|
+
except ValueError:
|
|
23
|
+
return 2
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""P2: section density — long prose with no file refs or DO/NOT/BECAUSE bullets.
|
|
2
|
+
|
|
3
|
+
Heuristic: an H2 section with 5+ prose lines and no file:line references and no
|
|
4
|
+
DO/NOT/BECAUSE markers is drifting architecture prose that belongs in docs/.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ...domain.finding import Finding, Severity
|
|
14
|
+
from ...infra.filesystem import read_text_safe
|
|
15
|
+
from .context import CheckContext
|
|
16
|
+
|
|
17
|
+
_FILE_REF_RE = re.compile(r"`[^`]+:\d+`|`[^`]+\.[a-z]+`")
|
|
18
|
+
_MARKER_RE = re.compile(r"\*\*DO\*\*|\*\*NOT\*\*|\*\*BECAUSE\*\*")
|
|
19
|
+
_MIN_PROSE = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_prose(line: str) -> bool:
|
|
23
|
+
s = line.strip()
|
|
24
|
+
return bool(s) and not s.startswith(("#", "-", "*", "|", ">", "```"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _evaluate_section(
|
|
28
|
+
path: Path, section: str, start_line: int, body: list[str]
|
|
29
|
+
) -> Iterator[Finding]:
|
|
30
|
+
prose = [ln for ln in body if _is_prose(ln)]
|
|
31
|
+
if len(prose) <= _MIN_PROSE:
|
|
32
|
+
return
|
|
33
|
+
if any(_FILE_REF_RE.search(ln) for ln in body):
|
|
34
|
+
return
|
|
35
|
+
if any(_MARKER_RE.search(ln) for ln in body):
|
|
36
|
+
return
|
|
37
|
+
yield Finding(
|
|
38
|
+
file=str(path),
|
|
39
|
+
line=start_line,
|
|
40
|
+
severity=Severity.INFO,
|
|
41
|
+
code="P2-low-density",
|
|
42
|
+
message=(
|
|
43
|
+
f"Section '{section}' has {len(prose)} prose lines with no "
|
|
44
|
+
"file:line references or DO/NOT/BECAUSE bullets"
|
|
45
|
+
),
|
|
46
|
+
hint="Consider cutting, adding specific references, or moving to docs/",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check(path: Path, _ctx: CheckContext) -> Iterator[Finding]:
|
|
51
|
+
if path.name not in ("AGENTS.md", "CLAUDE.md"):
|
|
52
|
+
return
|
|
53
|
+
content = read_text_safe(path)
|
|
54
|
+
if content is None:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
current: str | None = None
|
|
58
|
+
start = 0
|
|
59
|
+
body: list[str] = []
|
|
60
|
+
|
|
61
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
62
|
+
if line.startswith("## "):
|
|
63
|
+
if current is not None:
|
|
64
|
+
yield from _evaluate_section(path, current, start, body)
|
|
65
|
+
current = line.lstrip("# ").strip()
|
|
66
|
+
start = i
|
|
67
|
+
body = []
|
|
68
|
+
elif current is not None:
|
|
69
|
+
body.append(line)
|
|
70
|
+
|
|
71
|
+
if current is not None:
|
|
72
|
+
yield from _evaluate_section(path, current, start, body)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""P11: depth-tiered line limits for AGENTS.md and docs/ files."""
|
|
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 ...infra.filesystem import read_text_safe
|
|
10
|
+
from .context import CheckContext
|
|
11
|
+
|
|
12
|
+
LIMITS: dict[str, tuple[int, int]] = {
|
|
13
|
+
"depth_0_agents": (80, 120),
|
|
14
|
+
"depth_1_agents": (150, 200),
|
|
15
|
+
"depth_2plus_agents": (250, 300),
|
|
16
|
+
"docs": (300, 500),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _limits_for(path: Path, depth: int) -> tuple[tuple[int, int], str]:
|
|
21
|
+
is_docs = "docs" in path.parts and path.name not in ("AGENTS.md", "README.md")
|
|
22
|
+
if is_docs:
|
|
23
|
+
return LIMITS["docs"], "docs/ file"
|
|
24
|
+
if depth == 0:
|
|
25
|
+
return LIMITS["depth_0_agents"], "root AGENTS.md (depth 0)"
|
|
26
|
+
if depth == 1:
|
|
27
|
+
return LIMITS["depth_1_agents"], f"module AGENTS.md (depth {depth})"
|
|
28
|
+
return LIMITS["depth_2plus_agents"], f"leaf AGENTS.md (depth {depth})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check(path: Path, ctx: CheckContext) -> Iterator[Finding]:
|
|
32
|
+
content = read_text_safe(path)
|
|
33
|
+
if content is None:
|
|
34
|
+
return
|
|
35
|
+
line_count = len(content.splitlines())
|
|
36
|
+
depth = ctx.depth(path)
|
|
37
|
+
(target, hard_stop), label = _limits_for(path, depth)
|
|
38
|
+
|
|
39
|
+
if line_count > hard_stop:
|
|
40
|
+
yield Finding(
|
|
41
|
+
file=str(path), line=line_count, severity=Severity.WARNING,
|
|
42
|
+
code="P11-size-hard-stop",
|
|
43
|
+
message=f"{label} exceeds hard stop: {line_count}L > {hard_stop}L",
|
|
44
|
+
hint=f"target: {target}, hard stop: {hard_stop}",
|
|
45
|
+
)
|
|
46
|
+
elif line_count > target:
|
|
47
|
+
yield Finding(
|
|
48
|
+
file=str(path), line=line_count, severity=Severity.INFO,
|
|
49
|
+
code="P11-size-warning",
|
|
50
|
+
message=f"{label} exceeds target: {line_count}L > {target}L (hard stop: {hard_stop})",
|
|
51
|
+
hint=f"target: {target}, hard stop: {hard_stop}",
|
|
52
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Compose the 5 antipattern checkers over a discovered file set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...domain.finding import Finding
|
|
10
|
+
from ...domain.result import LintResult
|
|
11
|
+
from ...infra.filesystem import EXCLUDED_DIRS
|
|
12
|
+
from ...infra.git import git_root
|
|
13
|
+
from . import density, line_limits, splitting, volatile, watermark
|
|
14
|
+
from .context import CheckContext
|
|
15
|
+
|
|
16
|
+
# Checker signature: (path, ctx) -> Iterator[Finding]
|
|
17
|
+
Checker = Callable[[Path, CheckContext], Iterator[Finding]]
|
|
18
|
+
|
|
19
|
+
# Universal checkers — run on every candidate file.
|
|
20
|
+
UNIVERSAL: tuple[Checker, ...] = (
|
|
21
|
+
volatile.check,
|
|
22
|
+
watermark.check_presence,
|
|
23
|
+
watermark.check_staleness,
|
|
24
|
+
density.check,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Checkers that apply only to AGENTS.md / CLAUDE.md
|
|
28
|
+
AGENTS_ONLY: tuple[Checker, ...] = (
|
|
29
|
+
line_limits.check,
|
|
30
|
+
splitting.check,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Checkers that apply only to docs/*.md (non-AGENTS, non-README)
|
|
34
|
+
DOCS_ONLY: tuple[Checker, ...] = (
|
|
35
|
+
line_limits.check,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_candidate(path: Path) -> bool:
|
|
40
|
+
if any(p in EXCLUDED_DIRS for p in path.parts):
|
|
41
|
+
return False
|
|
42
|
+
return (
|
|
43
|
+
path.name == "AGENTS.md"
|
|
44
|
+
or path.name == "CLAUDE.md"
|
|
45
|
+
or "docs" in path.parts
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _git_tracked_md(root: Path) -> list[Path]:
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard", "*.md"],
|
|
53
|
+
capture_output=True, text=True, cwd=root,
|
|
54
|
+
)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
return []
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
return []
|
|
59
|
+
return [root / p for p in result.stdout.strip().splitlines() if p]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _git_modified_md(root: Path, gr: Path) -> list[Path]:
|
|
63
|
+
try:
|
|
64
|
+
result = subprocess.run(
|
|
65
|
+
["git", "status", "--porcelain", "-u"],
|
|
66
|
+
capture_output=True, text=True, cwd=root,
|
|
67
|
+
)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return []
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
return []
|
|
72
|
+
files: list[Path] = []
|
|
73
|
+
for line in result.stdout.strip().splitlines():
|
|
74
|
+
if len(line) < 4:
|
|
75
|
+
continue
|
|
76
|
+
path_str = line[3:].strip()
|
|
77
|
+
if " -> " in path_str:
|
|
78
|
+
path_str = path_str.split(" -> ")[-1]
|
|
79
|
+
if not path_str.endswith(".md"):
|
|
80
|
+
continue
|
|
81
|
+
full = (gr / path_str).resolve()
|
|
82
|
+
try:
|
|
83
|
+
full.relative_to(root.resolve())
|
|
84
|
+
files.append(full)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
return files
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _filesystem_fallback(root: Path) -> Iterable[Path]:
|
|
91
|
+
"""Used outside git — targeted walk, not full rglob."""
|
|
92
|
+
if (root / "AGENTS.md").exists():
|
|
93
|
+
yield root / "AGENTS.md"
|
|
94
|
+
docs = root / "docs"
|
|
95
|
+
if docs.is_dir():
|
|
96
|
+
yield from sorted(docs.rglob("*.md"))
|
|
97
|
+
for child in sorted(root.iterdir()):
|
|
98
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
99
|
+
nested = child / "AGENTS.md"
|
|
100
|
+
if nested.exists():
|
|
101
|
+
yield nested
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _discover(root: Path, gr: Path | None, modified_only: bool) -> list[Path]:
|
|
105
|
+
if modified_only and gr is not None:
|
|
106
|
+
return _git_modified_md(root, gr)
|
|
107
|
+
candidates = _git_tracked_md(root) if gr is not None else []
|
|
108
|
+
if not candidates and not modified_only:
|
|
109
|
+
candidates = list(_filesystem_fallback(root))
|
|
110
|
+
return candidates
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _checkers_for(path: Path) -> Iterable[Checker]:
|
|
114
|
+
yield from UNIVERSAL
|
|
115
|
+
if path.name in ("AGENTS.md", "CLAUDE.md"):
|
|
116
|
+
yield from AGENTS_ONLY
|
|
117
|
+
elif "docs" in path.parts and path.name != "README.md":
|
|
118
|
+
yield from DOCS_ONLY
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def lint(root: Path, *, modified_only: bool = False) -> LintResult:
|
|
122
|
+
gr = git_root(root)
|
|
123
|
+
ctx = CheckContext(root=root, git_root=gr, modified_only=modified_only)
|
|
124
|
+
|
|
125
|
+
seen: set[Path] = set()
|
|
126
|
+
findings: list[Finding] = []
|
|
127
|
+
for path in _discover(root, gr, modified_only):
|
|
128
|
+
if not path.is_file():
|
|
129
|
+
continue
|
|
130
|
+
resolved = path.resolve()
|
|
131
|
+
if resolved in seen or not _is_candidate(path):
|
|
132
|
+
continue
|
|
133
|
+
seen.add(resolved)
|
|
134
|
+
for checker in _checkers_for(path):
|
|
135
|
+
findings.extend(checker(path, ctx))
|
|
136
|
+
|
|
137
|
+
return LintResult(target=str(root), findings=findings)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""P3: splitting-candidate — 3+ gotcha bullets clustering under a common path.
|
|
2
|
+
|
|
3
|
+
Signal that the AGENTS.md wants to be split — promote a nested
|
|
4
|
+
<subdir>/AGENTS.md rather than pack more into the current one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ...domain.finding import Finding, Severity
|
|
15
|
+
from ...infra.filesystem import read_text_safe
|
|
16
|
+
from .context import CheckContext
|
|
17
|
+
|
|
18
|
+
_FILEREF_RE = re.compile(r"`([a-zA-Z0-9_./\-]+\.[a-zA-Z0-9_]+)(?::\d+(?:-\d+)?)?`")
|
|
19
|
+
_MIN_BULLETS = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_bullets(content: str) -> list[list[str]]:
|
|
23
|
+
"""Return one list of referenced paths per DO bullet."""
|
|
24
|
+
bullets: list[list[str]] = []
|
|
25
|
+
current: list[str] = []
|
|
26
|
+
in_bullet = False
|
|
27
|
+
|
|
28
|
+
for line in content.splitlines():
|
|
29
|
+
stripped = line.lstrip()
|
|
30
|
+
if stripped.startswith("- **DO**"):
|
|
31
|
+
if in_bullet and current:
|
|
32
|
+
bullets.append(current)
|
|
33
|
+
current = []
|
|
34
|
+
in_bullet = True
|
|
35
|
+
elif not stripped and in_bullet:
|
|
36
|
+
if current:
|
|
37
|
+
bullets.append(current)
|
|
38
|
+
current = []
|
|
39
|
+
in_bullet = False
|
|
40
|
+
|
|
41
|
+
if in_bullet:
|
|
42
|
+
for m in _FILEREF_RE.finditer(line):
|
|
43
|
+
ref = m.group(1)
|
|
44
|
+
if "/" in ref:
|
|
45
|
+
current.append(ref)
|
|
46
|
+
|
|
47
|
+
if in_bullet and current:
|
|
48
|
+
bullets.append(current)
|
|
49
|
+
return bullets
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _count_prefixes(bullets: list[list[str]]) -> tuple[Counter, dict[str, list[str]]]:
|
|
53
|
+
prefix_bullets: Counter = Counter()
|
|
54
|
+
samples: dict[str, list[str]] = {}
|
|
55
|
+
for paths in bullets:
|
|
56
|
+
prefixes: set[str] = set()
|
|
57
|
+
for p in paths:
|
|
58
|
+
parts = p.split("/")
|
|
59
|
+
for n in (1, 2, 3):
|
|
60
|
+
if len(parts) > n:
|
|
61
|
+
prefixes.add("/".join(parts[:n]))
|
|
62
|
+
for prefix in prefixes:
|
|
63
|
+
prefix_bullets[prefix] += 1
|
|
64
|
+
samples.setdefault(prefix, []).extend(paths)
|
|
65
|
+
return prefix_bullets, samples
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check(path: Path, _ctx: CheckContext) -> Iterator[Finding]:
|
|
69
|
+
if path.name not in ("AGENTS.md", "CLAUDE.md"):
|
|
70
|
+
return
|
|
71
|
+
content = read_text_safe(path)
|
|
72
|
+
if content is None:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
bullets = _parse_bullets(content)
|
|
76
|
+
prefix_bullets, samples = _count_prefixes(bullets)
|
|
77
|
+
|
|
78
|
+
for prefix, count in prefix_bullets.most_common():
|
|
79
|
+
if count < _MIN_BULLETS:
|
|
80
|
+
break
|
|
81
|
+
# Exact-segment match: `chat_bot` as file parent should NOT suppress
|
|
82
|
+
# a warning about prefix `bot` — compare leaf exactly, not endswith.
|
|
83
|
+
prefix_leaf = prefix.rsplit("/", 1)[-1]
|
|
84
|
+
if path.parent.name and prefix_leaf == path.parent.name:
|
|
85
|
+
continue
|
|
86
|
+
sample_refs = list(dict.fromkeys(samples[prefix]))[:3]
|
|
87
|
+
yield Finding(
|
|
88
|
+
file=str(path),
|
|
89
|
+
line=0,
|
|
90
|
+
severity=Severity.INFO,
|
|
91
|
+
code="splitting-candidate",
|
|
92
|
+
message=(
|
|
93
|
+
f"{count} gotcha bullets reference paths under `{prefix}/` — "
|
|
94
|
+
f"consider promoting to `{prefix}/AGENTS.md`"
|
|
95
|
+
),
|
|
96
|
+
hint=f"sample refs: {', '.join(sample_refs)}",
|
|
97
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""P4: flag content that goes stale quickly (counts, timestamps, versions)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...domain.finding import Finding, Severity
|
|
10
|
+
from ...infra.filesystem import read_text_safe
|
|
11
|
+
from .context import CheckContext
|
|
12
|
+
|
|
13
|
+
_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
|
|
14
|
+
(re.compile(
|
|
15
|
+
r"~?\d{2,}\s+(files?|dirs?|directories|modules?|models?|services?|"
|
|
16
|
+
r"tables?|tests?|endpoints?|routes?|components?|packages?)"
|
|
17
|
+
), "volatile count"),
|
|
18
|
+
(re.compile(r"[Aa]ll\s+\d+\s+\w+"), "volatile count with 'all'"),
|
|
19
|
+
(re.compile(r"Updated:\s*\d{4}-\d{2}-\d{2}"), "diagram timestamp (stale quickly)"),
|
|
20
|
+
(re.compile(r"v\d+\.\d+\.\d+"), "version number (may go stale)"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check(path: Path, _ctx: CheckContext) -> Iterator[Finding]:
|
|
25
|
+
content = read_text_safe(path)
|
|
26
|
+
if content is None:
|
|
27
|
+
return
|
|
28
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
29
|
+
for pattern, message in _PATTERNS:
|
|
30
|
+
if pattern.search(line):
|
|
31
|
+
yield Finding(
|
|
32
|
+
file=str(path),
|
|
33
|
+
line=i,
|
|
34
|
+
severity=Severity.WARNING,
|
|
35
|
+
code="P4-volatile",
|
|
36
|
+
message=message,
|
|
37
|
+
hint=line.strip()[:100],
|
|
38
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Watermark presence + staleness checks.
|
|
2
|
+
|
|
3
|
+
Presence: AGENTS.md needs an HTML-comment watermark; docs/*.md need both
|
|
4
|
+
`generated-by` and `last-updated` frontmatter keys together.
|
|
5
|
+
|
|
6
|
+
Staleness: a modified file whose `last-updated` date matches HEAD was
|
|
7
|
+
"edited without updating the watermark" — a common oversight.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ...domain.finding import Finding, Severity
|
|
18
|
+
from ...infra.filesystem import read_text_safe
|
|
19
|
+
from .context import CheckContext
|
|
20
|
+
|
|
21
|
+
_DATE_RE = re.compile(r"last-updated:\s*(\d{4}-\d{2}-\d{2})")
|
|
22
|
+
_HTML_WATERMARK_RE = re.compile(r"<!--\s*generated-by:")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _last_updated(content: str) -> str | None:
|
|
26
|
+
m = _DATE_RE.search(content)
|
|
27
|
+
return m.group(1) if m else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _head_content(path: Path, git_root: Path | None) -> str | None:
|
|
31
|
+
if git_root is None:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
rel = path.resolve().relative_to(git_root.resolve())
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["git", "show", f"HEAD:{rel}"], capture_output=True, text=True, cwd=git_root,
|
|
40
|
+
)
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return None
|
|
43
|
+
return result.stdout if result.returncode == 0 else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_presence(path: Path, _ctx: CheckContext) -> Iterator[Finding]:
|
|
47
|
+
content = read_text_safe(path)
|
|
48
|
+
if content is None:
|
|
49
|
+
return
|
|
50
|
+
lines = content.splitlines()
|
|
51
|
+
|
|
52
|
+
if path.name == "AGENTS.md" and content.startswith("---\n"):
|
|
53
|
+
yield Finding(
|
|
54
|
+
file=str(path), line=1, severity=Severity.WARNING,
|
|
55
|
+
code="structural", message="AGENTS.md should not have YAML frontmatter",
|
|
56
|
+
hint="---",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
has_generated_by = any("generated-by:" in ln for ln in lines)
|
|
60
|
+
has_last_updated = any("last-updated:" in ln for ln in lines)
|
|
61
|
+
|
|
62
|
+
if path.name == "AGENTS.md":
|
|
63
|
+
if not _HTML_WATERMARK_RE.search(content):
|
|
64
|
+
yield Finding(
|
|
65
|
+
file=str(path), line=max(1, len(lines)), severity=Severity.WARNING,
|
|
66
|
+
code="watermark-missing",
|
|
67
|
+
message="AGENTS.md missing generated-by HTML comment watermark",
|
|
68
|
+
hint="<!-- generated-by: plugin@version | last-updated: YYYY-MM-DD -->",
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if path.name == "README.md" or path.suffix != ".md":
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if has_generated_by and not has_last_updated:
|
|
76
|
+
yield Finding(
|
|
77
|
+
file=str(path), line=1, severity=Severity.WARNING,
|
|
78
|
+
code="watermark-incomplete",
|
|
79
|
+
message="has generated-by but missing last-updated",
|
|
80
|
+
)
|
|
81
|
+
elif has_last_updated and not has_generated_by:
|
|
82
|
+
yield Finding(
|
|
83
|
+
file=str(path), line=1, severity=Severity.WARNING,
|
|
84
|
+
code="watermark-incomplete",
|
|
85
|
+
message="has last-updated but missing generated-by",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_staleness(path: Path, ctx: CheckContext) -> Iterator[Finding]:
|
|
90
|
+
if not ctx.modified_only:
|
|
91
|
+
return
|
|
92
|
+
content = read_text_safe(path)
|
|
93
|
+
if content is None:
|
|
94
|
+
return
|
|
95
|
+
current = _last_updated(content)
|
|
96
|
+
if current is None:
|
|
97
|
+
return
|
|
98
|
+
head = _head_content(path, ctx.git_root)
|
|
99
|
+
if head is None:
|
|
100
|
+
return
|
|
101
|
+
head_date = _last_updated(head)
|
|
102
|
+
if head_date is None or head_date != current:
|
|
103
|
+
return
|
|
104
|
+
line_num = next(
|
|
105
|
+
(i for i, ln in enumerate(content.splitlines(), 1) if "last-updated:" in ln),
|
|
106
|
+
1,
|
|
107
|
+
)
|
|
108
|
+
yield Finding(
|
|
109
|
+
file=str(path), line=line_num, severity=Severity.WARNING,
|
|
110
|
+
code="watermark-stale",
|
|
111
|
+
message=f"file was modified but last-updated date unchanged ({current})",
|
|
112
|
+
hint=f"Update last-updated to today's date (currently: {current})",
|
|
113
|
+
)
|