Skip to content

Commit 858f4ee

Browse files
authored
feat: drift checker additional checks and policy data (Phase 2 Session B) (#8)
Adds the three remaining checks from the Phase 2 design: broken-refs, required-refs, and stale-counts. Each is a full Check implementation per the Session A Protocol, even where the current ecosystem surface is minimal. Extends RepoSnapshot with meta_standards and meta_required_refs carry-alongs so checks can resolve against meta-repo policy without a back-channel. Ships supporting policy data: - standards/drift-checker.config.json (expanded: globals/types/repos) - standards/required-refs.json (new; mostly empty per Q2 audit) - standards/versioning.md (new section on MAJOR/MINOR/PATCH semantics for ecosystem standards) Adds report/gh_summary.py for GitHub Actions step-summary output (details folds per repo, emoji severity). Wired into cli.py as --format gh-summary. Session C will wire this into CI. Preflight audits the full ecosystem (9 repos, 13 agent-context files) and confirms: - Zero standards/*.md links exist today -> broken-refs and required-refs both silent. - Aggregate-count narrative exists in 8 of 9 repos, producing ~62 stale-counts warnings (warn severity, non-blocking). - Version-signal still reports the expected 261 MAJOR.MINOR drift errors (tool repos at 1.6.3, meta at 1.7.1) that Session D mechanically clears. Also fixes a Session A test coupling: CLI tests now pass a tmp meta-repo via --meta-repo instead of reading the real VERSION, so fixture values are stable across meta-repo bumps. VERSION: 1.7.0 -> 1.7.1. Called PATCH per handoff guidance, but flagged as defensibly MINOR: the new checks surface warnings on existing tool-repo content (aggregate narrative), which tool repos will have to reconcile during the Session D rollout. No contract-breaking changes to the Session A Protocol or CLI surface. See PR body for the discussion. See #1 Phase 2 Session B. Signed-off-by: 154358121+TMHSDigital@users.noreply.github.com Made-with: Cursor Signed-off-by: 154358121+TMHSDigital@users.noreply.github.com
1 parent a7c0294 commit 858f4ee

32 files changed

Lines changed: 1402 additions & 44 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.7.0
1+
1.7.1
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
"""Registered checks live here. Session A ships ``version_signal`` only.
1+
"""Registered checks live here.
22
3-
Session B will add ``broken_refs``, ``required_refs``, ``stale_counts``.
3+
Session A shipped ``version_signal``. Session B adds ``broken_refs``,
4+
``required_refs``, and ``stale_counts``. Phase 3 will add more via the
5+
same ``Check`` Protocol from ``types.py``.
46
"""
7+
from .broken_refs import BrokenRefsCheck
8+
from .required_refs import RequiredRefsCheck
9+
from .stale_counts import StaleCountsCheck
510
from .version_signal import VersionSignalCheck
611

7-
__all__ = ["VersionSignalCheck"]
12+
__all__ = [
13+
"VersionSignalCheck",
14+
"BrokenRefsCheck",
15+
"RequiredRefsCheck",
16+
"StaleCountsCheck",
17+
]
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Broken standards/*.md references check.
2+
3+
Scans every markdown file in the snapshot for links whose targets live
4+
under ``standards/``. Resolves each target against the meta-repo's
5+
``standards/`` directory at snapshot time. Missing target -> ``error``.
6+
7+
Zero surface today — the design-doc preflight found 0 ``standards/*.md``
8+
links across 13 agent-context files in 9 repos. The check is implemented
9+
anyway so the plumbing is in place the moment tool repos start linking
10+
to standards.
11+
12+
Link shapes we care about:
13+
14+
* ``[text](standards/foo.md)``
15+
* ``[text](standards/foo.md#anchor)`` — fragment checking deferred; we
16+
only verify the file exists for now
17+
* ``[text](../Developer-Tools-Directory/standards/foo.md)`` — tolerated
18+
by stripping to the trailing ``standards/foo.md``
19+
* reference-style ``[text][label]`` + ``[label]: standards/foo.md`` —
20+
the label-definition regex below catches these
21+
* bare URLs to GitHub-hosted standards are NOT checked; contribute
22+
a separate check for those when/if they appear
23+
24+
We do NOT follow non-``standards/`` links. That is out of scope for this
25+
check; it would duplicate a general markdown link checker.
26+
"""
27+
from __future__ import annotations
28+
29+
import re
30+
from pathlib import Path
31+
from typing import Iterable, List, Tuple
32+
33+
from ..types import Finding, RepoSnapshot
34+
35+
36+
NAME = "broken-refs"
37+
38+
# Inline link: ``[text](target)`` where target starts with or contains
39+
# ``standards/`` and ends with ``.md`` (with optional ``#fragment``).
40+
_INLINE_LINK_RE = re.compile(
41+
rb"\[[^\]]*\]\(\s*([^)\s]*standards/[^)\s#]+\.md(?:#[^)\s]*)?)\s*\)"
42+
)
43+
# Reference-style definition: ``[label]: standards/foo.md``
44+
_REF_DEF_RE = re.compile(
45+
rb"^\s*\[[^\]]+\]:\s*([^\s]*standards/[^\s#]+\.md(?:#[^\s]*)?)\s*$",
46+
re.MULTILINE,
47+
)
48+
49+
50+
def _iter_standards_links(content: bytes) -> Iterable[Tuple[str, int]]:
51+
"""Yield ``(target, line_number)`` for every standards link in the
52+
file. ``target`` is a decoded string; ``line_number`` is 1-indexed."""
53+
for m in _INLINE_LINK_RE.finditer(content):
54+
yield m.group(1).decode("utf-8", errors="replace"), content.count(b"\n", 0, m.start()) + 1
55+
for m in _REF_DEF_RE.finditer(content):
56+
yield m.group(1).decode("utf-8", errors="replace"), content.count(b"\n", 0, m.start()) + 1
57+
58+
59+
def _extract_standard_filename(target: str) -> str | None:
60+
"""Given a link target, return the standards file basename or None.
61+
62+
``standards/foo.md`` -> ``foo.md``
63+
``standards/foo.md#anchor`` -> ``foo.md``
64+
``../standards/foo.md`` -> ``foo.md``
65+
``https://github.com/.../standards/foo.md`` -> ``foo.md``
66+
"""
67+
target = target.split("#", 1)[0]
68+
marker = "standards/"
69+
idx = target.rfind(marker)
70+
if idx == -1:
71+
return None
72+
basename = target[idx + len(marker):].strip("/")
73+
if not basename or not basename.endswith(".md"):
74+
return None
75+
if "/" in basename:
76+
# Nested path under standards/. We only ship flat files in
77+
# ``standards/``, so a nested path is a broken reference by
78+
# construction.
79+
return basename
80+
return basename
81+
82+
83+
class BrokenRefsCheck:
84+
name: str = NAME
85+
86+
def run(self, snapshot: RepoSnapshot) -> Iterable[Finding]:
87+
if NAME in snapshot.config.skip_checks:
88+
return ()
89+
90+
out: List[Finding] = []
91+
meta_files = snapshot.meta_standards
92+
93+
for rel_path, file in snapshot.files.items():
94+
pragma = next(
95+
(p for p in file.pragmas if p.check_name == NAME), None
96+
)
97+
if pragma is not None:
98+
out.append(
99+
Finding(
100+
repo=snapshot.slug,
101+
file=rel_path,
102+
check=NAME,
103+
severity="info",
104+
message=(
105+
"skipped by drift-ignore pragma"
106+
+ (f" (reason: {pragma.reason})" if pragma.reason else "")
107+
),
108+
)
109+
)
110+
continue
111+
112+
for target, line in _iter_standards_links(file.content):
113+
basename = _extract_standard_filename(target)
114+
if basename is None:
115+
continue
116+
if basename not in meta_files:
117+
out.append(
118+
Finding(
119+
repo=snapshot.slug,
120+
file=rel_path,
121+
check=NAME,
122+
severity="error",
123+
message=(
124+
f"broken standards reference at line {line}: "
125+
f"{target!r} (standards/{basename} does not "
126+
f"exist in meta-repo)"
127+
),
128+
suggested_fix=(
129+
f"check the filename; available standards: "
130+
f"{', '.join(sorted(meta_files)) or '(none found)'}"
131+
),
132+
)
133+
)
134+
return out
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Required standards-references check (Q2 resolution: start permissive).
2+
3+
For each repo, look up the per-type requirements from
4+
``standards/required-refs.json``. For each ``(file, required_ref)`` pair:
5+
6+
* file missing in the repo -> ``error``
7+
* file present but lacks a link to the required standards doc -> ``error``
8+
* otherwise: silent
9+
10+
Today the requirements block is empty (zero tool repos link to
11+
``standards/*.md``), so this check is silent in practice. The plumbing is
12+
ready for the moment that changes — at that point, add entries to
13+
``required-refs.json`` and the check immediately starts enforcing them
14+
without any code change.
15+
16+
The loader is in this module rather than ``config.py`` to keep the data
17+
colocated with its consumer; it is only used here.
18+
"""
19+
from __future__ import annotations
20+
21+
import json
22+
import re
23+
from pathlib import Path
24+
from typing import Iterable, List, Mapping, Sequence
25+
26+
from ..types import Finding, RepoSnapshot
27+
28+
29+
NAME = "required-refs"
30+
31+
32+
class RequiredRefsError(Exception):
33+
"""Raised when required-refs.json is present but malformed."""
34+
35+
36+
def load_required_refs(path: Path | None) -> Mapping[str, Mapping[str, Sequence[str]]]:
37+
"""Load and validate ``standards/required-refs.json``.
38+
39+
Returns a mapping ``{repo_type: {filename: [required_ref, ...]}}``.
40+
Missing file -> empty mapping. Malformed JSON or wrong schema ->
41+
``RequiredRefsError``.
42+
"""
43+
if path is None or not path.is_file():
44+
return {}
45+
try:
46+
data = json.loads(path.read_text(encoding="utf-8"))
47+
except json.JSONDecodeError as exc:
48+
raise RequiredRefsError(f"malformed JSON in {path}: {exc}") from exc
49+
if not isinstance(data, dict):
50+
raise RequiredRefsError(f"{path}: expected object at root")
51+
reqs = data.get("requirements", {})
52+
if not isinstance(reqs, dict):
53+
raise RequiredRefsError(f"{path}: 'requirements' must be an object")
54+
55+
out: dict[str, dict[str, list[str]]] = {}
56+
for repo_type, file_map in reqs.items():
57+
if not isinstance(file_map, dict):
58+
raise RequiredRefsError(
59+
f"{path}: requirements[{repo_type!r}] must be an object"
60+
)
61+
out[repo_type] = {}
62+
for fname, refs in file_map.items():
63+
if not isinstance(refs, list):
64+
raise RequiredRefsError(
65+
f"{path}: requirements[{repo_type!r}][{fname!r}] must be a list"
66+
)
67+
out[repo_type][fname] = [str(r) for r in refs]
68+
return out
69+
70+
71+
def _file_links_to(content: bytes, required_ref: str) -> bool:
72+
"""Return True if ``content`` contains any markdown link whose target
73+
resolves to ``required_ref`` (matched by trailing basename)."""
74+
basename = required_ref.split("/")[-1]
75+
if not basename:
76+
return False
77+
# Match both inline and reference-style link targets that end with
78+
# the required basename (optionally followed by #fragment or whitespace).
79+
pattern = re.compile(
80+
rb"standards/" + re.escape(basename.encode("utf-8")) + rb"(?:#[^\s)]*)?",
81+
)
82+
return pattern.search(content) is not None
83+
84+
85+
class RequiredRefsCheck:
86+
name: str = NAME
87+
88+
def run(self, snapshot: RepoSnapshot) -> Iterable[Finding]:
89+
if NAME in snapshot.config.skip_checks:
90+
return ()
91+
92+
requirements = snapshot.meta_required_refs.get(snapshot.repo_type, {})
93+
if not requirements:
94+
return ()
95+
96+
out: List[Finding] = []
97+
for file_name, required_refs in requirements.items():
98+
if not required_refs:
99+
continue
100+
rel = Path(file_name)
101+
file = snapshot.files.get(rel)
102+
103+
pragma = None
104+
if file is not None:
105+
pragma = next(
106+
(p for p in file.pragmas if p.check_name == NAME), None
107+
)
108+
if pragma is not None:
109+
out.append(
110+
Finding(
111+
repo=snapshot.slug,
112+
file=rel,
113+
check=NAME,
114+
severity="info",
115+
message=(
116+
"skipped by drift-ignore pragma"
117+
+ (f" (reason: {pragma.reason})" if pragma.reason else "")
118+
),
119+
)
120+
)
121+
continue
122+
123+
if file is None:
124+
out.append(
125+
Finding(
126+
repo=snapshot.slug,
127+
file=rel,
128+
check=NAME,
129+
severity="error",
130+
message=(
131+
f"{file_name} is required for {snapshot.repo_type} "
132+
f"repos but is not present"
133+
),
134+
suggested_fix=(
135+
f"create {file_name} and link to "
136+
f"{', '.join(required_refs)}"
137+
),
138+
)
139+
)
140+
continue
141+
142+
for ref in required_refs:
143+
if not _file_links_to(file.content, ref):
144+
out.append(
145+
Finding(
146+
repo=snapshot.slug,
147+
file=rel,
148+
check=NAME,
149+
severity="error",
150+
message=(
151+
f"{file_name} must link to {ref} "
152+
f"(required for {snapshot.repo_type})"
153+
),
154+
suggested_fix=f"add a link to {ref} in {file_name}",
155+
)
156+
)
157+
return out

0 commit comments

Comments
 (0)