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,456 @@
|
|
|
1
|
+
"""Discover and validate portable SPEC.md and ADR contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..analysis.context_layout import discover
|
|
10
|
+
from ..domain.finding import Finding, Severity
|
|
11
|
+
from ..domain.result import AnalysisResult
|
|
12
|
+
from ..infra.markdown import mask_invisible_markdown
|
|
13
|
+
from ..infra.yaml_frontmatter import parse_frontmatter
|
|
14
|
+
|
|
15
|
+
SPEC_SECTIONS = (
|
|
16
|
+
"Summary",
|
|
17
|
+
"Goals / Non-Goals",
|
|
18
|
+
"Requirements",
|
|
19
|
+
"Interfaces & Contracts",
|
|
20
|
+
"Invariants",
|
|
21
|
+
"Acceptance",
|
|
22
|
+
)
|
|
23
|
+
LEGACY_SPEC_SECTIONS = {"purpose", "scope", "invariants", "interfaces", "validation"}
|
|
24
|
+
ADR_STATUSES = {"proposed", "accepted", "deprecated", "superseded", "rejected"}
|
|
25
|
+
|
|
26
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$", re.MULTILINE)
|
|
27
|
+
_LINK_RE = re.compile(r"\[[^]]+\]\(([^)]+\.md(?:#[^)]+)?)\)", re.IGNORECASE)
|
|
28
|
+
_NORMATIVE_STATEMENT_RE = re.compile(
|
|
29
|
+
r"(?m)^\s*(?:[-*+]\s+|\d+[.)]\s+)?[^\n]*\S\s+(?:MUST|SHOULD|MAY)\s+\S+"
|
|
30
|
+
)
|
|
31
|
+
_LEGACY_MIGRATION_HINT = (
|
|
32
|
+
"Migrate Purpose to Summary; split Scope into Goals / Non-Goals; rewrite "
|
|
33
|
+
"observable obligations under Requirements with MUST, SHOULD, or MAY; "
|
|
34
|
+
"rename Interfaces to Interfaces & Contracts; keep Invariants; and replace "
|
|
35
|
+
"Validation with Acceptance."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _finding(path: Path, root: Path, code: str, message: str, hint: str | None = None) -> Finding:
|
|
40
|
+
return Finding(
|
|
41
|
+
file=path.relative_to(root).as_posix() if path != root else ".",
|
|
42
|
+
line=1,
|
|
43
|
+
severity=Severity.ERROR,
|
|
44
|
+
code=code,
|
|
45
|
+
message=message,
|
|
46
|
+
hint=hint,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _spec_sections(content: str) -> list[str]:
|
|
51
|
+
return [
|
|
52
|
+
match.group(2).strip().casefold()
|
|
53
|
+
for match in _HEADING_RE.finditer(content)
|
|
54
|
+
if match.group(1) == "##"
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _section_body(content: str, section: str) -> str | None:
|
|
59
|
+
matches = [match for match in _HEADING_RE.finditer(content) if match.group(1) == "##"]
|
|
60
|
+
for index, match in enumerate(matches):
|
|
61
|
+
if match.group(2).strip().casefold() != section.casefold():
|
|
62
|
+
continue
|
|
63
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(content)
|
|
64
|
+
return content[match.end() : end]
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read(path: Path, root: Path) -> tuple[str | None, list[Finding]]:
|
|
69
|
+
try:
|
|
70
|
+
return path.read_text(encoding="utf-8"), []
|
|
71
|
+
except (OSError, UnicodeError) as error:
|
|
72
|
+
return None, [
|
|
73
|
+
_finding(
|
|
74
|
+
path,
|
|
75
|
+
root,
|
|
76
|
+
"contract-read-failed",
|
|
77
|
+
f"Contract file could not be read as UTF-8: {error}",
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _metadata(content: str) -> tuple[dict[str, str], str, set[str]]:
|
|
83
|
+
frontmatter, body = parse_frontmatter(content)
|
|
84
|
+
metadata: dict[str, str] = {}
|
|
85
|
+
duplicates: set[str] = set()
|
|
86
|
+
if frontmatter:
|
|
87
|
+
metadata.update(
|
|
88
|
+
{
|
|
89
|
+
key.casefold().replace("-", "_"): str(value).strip()
|
|
90
|
+
for key, value in frontmatter.items()
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
visible = mask_invisible_markdown(body)
|
|
94
|
+
second_level = re.search(r"^##\s+", visible, re.MULTILINE)
|
|
95
|
+
preamble = visible[: second_level.start()] if second_level else visible
|
|
96
|
+
for line in preamble.splitlines():
|
|
97
|
+
match = re.match(r"^(Status|Date|Superseded[ _-]by):\s*(.+?)\s*$", line, re.IGNORECASE)
|
|
98
|
+
if match:
|
|
99
|
+
key = match.group(1).casefold().replace(" ", "_").replace("-", "_")
|
|
100
|
+
if key in metadata:
|
|
101
|
+
duplicates.add(key)
|
|
102
|
+
metadata[key] = match.group(2)
|
|
103
|
+
return metadata, body, duplicates
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _section_code(section: str) -> str:
|
|
107
|
+
return re.sub(r"[^a-z0-9]+", "-", section.casefold()).strip("-")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _has_substantive_content(body: str) -> bool:
|
|
111
|
+
visible = re.sub(r"[\s#>*_`~\[\](){}.+\-]+", " ", body).strip()
|
|
112
|
+
return bool(re.search(r"[A-Za-z0-9]", visible))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_spec(spec: Path, root: Path) -> list[Finding]:
|
|
116
|
+
content, findings = _read(spec, root)
|
|
117
|
+
if content is None:
|
|
118
|
+
return findings
|
|
119
|
+
_frontmatter, body = parse_frontmatter(content)
|
|
120
|
+
visible_content = mask_invisible_markdown(body)
|
|
121
|
+
headings = list(_HEADING_RE.finditer(visible_content))
|
|
122
|
+
titles = [match for match in headings if match.group(1) == "#"]
|
|
123
|
+
sections = _spec_sections(visible_content)
|
|
124
|
+
section_set = set(sections)
|
|
125
|
+
canonical_sections = [section.casefold() for section in SPEC_SECTIONS]
|
|
126
|
+
if not titles:
|
|
127
|
+
findings.append(
|
|
128
|
+
_finding(
|
|
129
|
+
spec,
|
|
130
|
+
root,
|
|
131
|
+
"contract-spec-missing-title",
|
|
132
|
+
"SPEC.md needs one level-one title",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
elif len(titles) > 1:
|
|
136
|
+
findings.append(
|
|
137
|
+
_finding(
|
|
138
|
+
spec,
|
|
139
|
+
root,
|
|
140
|
+
"contract-spec-multiple-titles",
|
|
141
|
+
"SPEC.md needs exactly one level-one title",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
if LEGACY_SPEC_SECTIONS.issubset(section_set):
|
|
145
|
+
findings.append(
|
|
146
|
+
_finding(
|
|
147
|
+
spec,
|
|
148
|
+
root,
|
|
149
|
+
"contract-spec-legacy-profile",
|
|
150
|
+
"SPEC.md uses the retired Purpose/Scope/Interfaces/Validation profile",
|
|
151
|
+
_LEGACY_MIGRATION_HINT,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
for section in SPEC_SECTIONS:
|
|
155
|
+
if section.casefold() not in section_set:
|
|
156
|
+
findings.append(
|
|
157
|
+
_finding(
|
|
158
|
+
spec,
|
|
159
|
+
root,
|
|
160
|
+
f"contract-spec-missing-{section.casefold().replace(' ', '-')}",
|
|
161
|
+
f"SPEC.md is missing the required '{section}' section",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
noncanonical = [section for section in sections if section not in canonical_sections]
|
|
165
|
+
duplicate_sections = sorted(
|
|
166
|
+
section for section in canonical_sections if sections.count(section) > 1
|
|
167
|
+
)
|
|
168
|
+
if duplicate_sections:
|
|
169
|
+
findings.append(
|
|
170
|
+
_finding(
|
|
171
|
+
spec,
|
|
172
|
+
root,
|
|
173
|
+
"contract-spec-duplicate-section",
|
|
174
|
+
"SPEC.md repeats a canonical second-level section: "
|
|
175
|
+
+ ", ".join(duplicate_sections),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
if noncanonical:
|
|
179
|
+
findings.append(
|
|
180
|
+
_finding(
|
|
181
|
+
spec,
|
|
182
|
+
root,
|
|
183
|
+
"contract-spec-noncanonical-section",
|
|
184
|
+
"SPEC.md contains a noncanonical second-level section: " + ", ".join(noncanonical),
|
|
185
|
+
"Keep only Summary; Goals / Non-Goals; Requirements; "
|
|
186
|
+
"Interfaces & Contracts; Invariants; and Acceptance as second-level sections.",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
if (
|
|
190
|
+
not noncanonical
|
|
191
|
+
and not duplicate_sections
|
|
192
|
+
and section_set == set(canonical_sections)
|
|
193
|
+
and sections != canonical_sections
|
|
194
|
+
):
|
|
195
|
+
findings.append(
|
|
196
|
+
_finding(
|
|
197
|
+
spec,
|
|
198
|
+
root,
|
|
199
|
+
"contract-spec-section-order",
|
|
200
|
+
"SPEC.md sections are not in the canonical order",
|
|
201
|
+
"Order sections as Summary; Goals / Non-Goals; Requirements; "
|
|
202
|
+
"Interfaces & Contracts; Invariants; and Acceptance.",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
requirements = _section_body(visible_content, "Requirements")
|
|
206
|
+
for section in SPEC_SECTIONS:
|
|
207
|
+
section_body = _section_body(visible_content, section)
|
|
208
|
+
if section_body is not None and not _has_substantive_content(section_body):
|
|
209
|
+
findings.append(
|
|
210
|
+
_finding(
|
|
211
|
+
spec,
|
|
212
|
+
root,
|
|
213
|
+
f"contract-spec-empty-{_section_code(section)}",
|
|
214
|
+
f"SPEC.md '{section}' section has no substantive visible content",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
if requirements is not None and not _NORMATIVE_STATEMENT_RE.search(requirements):
|
|
218
|
+
findings.append(
|
|
219
|
+
_finding(
|
|
220
|
+
spec,
|
|
221
|
+
root,
|
|
222
|
+
"contract-spec-requirements-not-normative",
|
|
223
|
+
"SPEC.md Requirements do not contain a normative obligation",
|
|
224
|
+
"State each obligation with MUST, SHOULD, or MAY.",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return findings
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _adr_files(directory: Path) -> list[Path]:
|
|
231
|
+
return sorted(
|
|
232
|
+
path
|
|
233
|
+
for path in directory.glob("*.md")
|
|
234
|
+
if path.name.casefold() not in {"readme.md", "index.md"}
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _target_is_within_root(target: Path, root: Path) -> bool:
|
|
239
|
+
try:
|
|
240
|
+
target.relative_to(root.resolve())
|
|
241
|
+
except ValueError:
|
|
242
|
+
return False
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _check_supersession(adr: Path, root: Path, metadata: dict[str, str]) -> list[Finding]:
|
|
247
|
+
status = metadata.get("status", "").casefold()
|
|
248
|
+
superseded_by = metadata.get("superseded_by")
|
|
249
|
+
if status == "superseded" and not superseded_by:
|
|
250
|
+
return [
|
|
251
|
+
_finding(
|
|
252
|
+
adr,
|
|
253
|
+
root,
|
|
254
|
+
"contract-adr-supersession-target-missing",
|
|
255
|
+
"A superseded ADR must name its replacement with 'Superseded by:'",
|
|
256
|
+
)
|
|
257
|
+
]
|
|
258
|
+
if superseded_by:
|
|
259
|
+
target = (adr.parent / superseded_by.split("#", 1)[0]).resolve()
|
|
260
|
+
if not _target_is_within_root(target, root):
|
|
261
|
+
return [
|
|
262
|
+
_finding(
|
|
263
|
+
adr,
|
|
264
|
+
root,
|
|
265
|
+
"contract-adr-supersession-target-outside-root",
|
|
266
|
+
f"Supersession target leaves the validated root: {superseded_by}",
|
|
267
|
+
)
|
|
268
|
+
]
|
|
269
|
+
if not target.is_file():
|
|
270
|
+
return [
|
|
271
|
+
_finding(
|
|
272
|
+
adr,
|
|
273
|
+
root,
|
|
274
|
+
"contract-adr-supersession-target-missing",
|
|
275
|
+
f"Supersession target does not exist: {superseded_by}",
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _check_adr_links(adr: Path, root: Path, content: str) -> list[Finding]:
|
|
282
|
+
findings: list[Finding] = []
|
|
283
|
+
for raw_target in _LINK_RE.findall(content):
|
|
284
|
+
target_text = raw_target.split("#", 1)[0]
|
|
285
|
+
if "://" in target_text:
|
|
286
|
+
continue
|
|
287
|
+
target = (adr.parent / target_text).resolve()
|
|
288
|
+
if not _target_is_within_root(target, root):
|
|
289
|
+
findings.append(
|
|
290
|
+
_finding(
|
|
291
|
+
adr,
|
|
292
|
+
root,
|
|
293
|
+
"contract-adr-link-outside-root",
|
|
294
|
+
f"Markdown link leaves the validated root: {raw_target}",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
elif not target.is_file():
|
|
298
|
+
findings.append(
|
|
299
|
+
_finding(
|
|
300
|
+
adr,
|
|
301
|
+
root,
|
|
302
|
+
"contract-adr-link-missing",
|
|
303
|
+
f"Markdown link does not resolve: {raw_target}",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
return findings
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _check_adr(adr: Path, root: Path) -> list[Finding]:
|
|
310
|
+
content, findings = _read(adr, root)
|
|
311
|
+
if content is None:
|
|
312
|
+
return findings
|
|
313
|
+
metadata, body, duplicate_metadata = _metadata(content)
|
|
314
|
+
visible = mask_invisible_markdown(body)
|
|
315
|
+
titles = [line for line in visible.splitlines() if line.startswith("# ")]
|
|
316
|
+
if not titles:
|
|
317
|
+
findings.append(
|
|
318
|
+
_finding(
|
|
319
|
+
adr,
|
|
320
|
+
root,
|
|
321
|
+
"contract-adr-missing-title",
|
|
322
|
+
"ADR needs one level-one title",
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
elif len(titles) > 1:
|
|
326
|
+
findings.append(
|
|
327
|
+
_finding(
|
|
328
|
+
adr,
|
|
329
|
+
root,
|
|
330
|
+
"contract-adr-multiple-titles",
|
|
331
|
+
"ADR needs exactly one visible level-one title",
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
if duplicate_metadata:
|
|
335
|
+
findings.append(
|
|
336
|
+
_finding(
|
|
337
|
+
adr,
|
|
338
|
+
root,
|
|
339
|
+
"contract-adr-duplicate-metadata",
|
|
340
|
+
"ADR repeats metadata fields: " + ", ".join(sorted(duplicate_metadata)),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
status = metadata.get("status", "").casefold()
|
|
344
|
+
if not status:
|
|
345
|
+
findings.append(
|
|
346
|
+
_finding(
|
|
347
|
+
adr,
|
|
348
|
+
root,
|
|
349
|
+
"contract-adr-missing-status",
|
|
350
|
+
"ADR needs Status metadata",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
elif status not in ADR_STATUSES:
|
|
354
|
+
findings.append(
|
|
355
|
+
_finding(
|
|
356
|
+
adr,
|
|
357
|
+
root,
|
|
358
|
+
"contract-adr-invalid-status",
|
|
359
|
+
f"ADR status '{metadata['status']}' is not one of "
|
|
360
|
+
f"{', '.join(sorted(ADR_STATUSES))}",
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
date_value = metadata.get("date")
|
|
364
|
+
if not date_value:
|
|
365
|
+
findings.append(_finding(adr, root, "contract-adr-missing-date", "ADR needs Date metadata"))
|
|
366
|
+
else:
|
|
367
|
+
try:
|
|
368
|
+
parsed_date = date.fromisoformat(date_value)
|
|
369
|
+
except ValueError:
|
|
370
|
+
parsed_date = None
|
|
371
|
+
if parsed_date is None or parsed_date.isoformat() != date_value:
|
|
372
|
+
findings.append(
|
|
373
|
+
_finding(
|
|
374
|
+
adr,
|
|
375
|
+
root,
|
|
376
|
+
"contract-adr-invalid-date",
|
|
377
|
+
"ADR Date metadata must be an ISO calendar date (YYYY-MM-DD)",
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
findings.extend(_check_supersession(adr, root, metadata))
|
|
381
|
+
findings.extend(_check_adr_links(adr, root, visible))
|
|
382
|
+
return findings
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _check_index(directory: Path, adrs: list[Path], root: Path) -> list[Finding]:
|
|
386
|
+
candidates = (directory / "README.md", directory / "index.md")
|
|
387
|
+
index = next((path for path in candidates if path.is_file()), None)
|
|
388
|
+
if index is None:
|
|
389
|
+
return []
|
|
390
|
+
content, findings = _read(index, root)
|
|
391
|
+
if content is None:
|
|
392
|
+
return findings
|
|
393
|
+
linked = {
|
|
394
|
+
(index.parent / target.split("#", 1)[0]).resolve()
|
|
395
|
+
for target in _LINK_RE.findall(content)
|
|
396
|
+
if "://" not in target
|
|
397
|
+
}
|
|
398
|
+
return findings + [
|
|
399
|
+
_finding(
|
|
400
|
+
index,
|
|
401
|
+
root,
|
|
402
|
+
"contract-adr-index-missing-entry",
|
|
403
|
+
f"ADR index does not link to {adr.name}",
|
|
404
|
+
)
|
|
405
|
+
for adr in adrs
|
|
406
|
+
if adr.resolve() not in linked
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def analyze(
|
|
411
|
+
root: Path,
|
|
412
|
+
*,
|
|
413
|
+
require_spec: bool = False,
|
|
414
|
+
require_adrs: bool = False,
|
|
415
|
+
) -> AnalysisResult:
|
|
416
|
+
"""Return deterministic discovery data and validation findings for ``root``."""
|
|
417
|
+
root = root.resolve()
|
|
418
|
+
layout = discover(root)
|
|
419
|
+
specs = list(layout.specs)
|
|
420
|
+
directories = [tree.path for tree in layout.adr_trees]
|
|
421
|
+
findings: list[Finding] = []
|
|
422
|
+
for spec in specs:
|
|
423
|
+
findings.extend(_check_spec(spec, root))
|
|
424
|
+
if not specs and require_spec:
|
|
425
|
+
findings.append(
|
|
426
|
+
_finding(root, root, "contract-spec-required", "SPEC.md is required but was not found")
|
|
427
|
+
)
|
|
428
|
+
if require_adrs and not any(_adr_files(directory) for directory in directories):
|
|
429
|
+
findings.append(
|
|
430
|
+
_finding(
|
|
431
|
+
root,
|
|
432
|
+
root,
|
|
433
|
+
"contract-adrs-required",
|
|
434
|
+
"At least one ADR record is required but was not found",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
adr_count = 0
|
|
438
|
+
for directory in directories:
|
|
439
|
+
adrs = _adr_files(directory)
|
|
440
|
+
adr_count += len(adrs)
|
|
441
|
+
for adr in adrs:
|
|
442
|
+
findings.extend(_check_adr(adr, root))
|
|
443
|
+
findings.extend(_check_index(directory, adrs, root))
|
|
444
|
+
return AnalysisResult(
|
|
445
|
+
target=str(root),
|
|
446
|
+
data={
|
|
447
|
+
"spec": "SPEC.md" if (root / "SPEC.md") in specs else None,
|
|
448
|
+
"specs": [spec.relative_to(root).as_posix() for spec in specs],
|
|
449
|
+
"adr_directories": [
|
|
450
|
+
directory.relative_to(root).as_posix() for directory in directories
|
|
451
|
+
],
|
|
452
|
+
"adr_count": adr_count,
|
|
453
|
+
"requirements": {"spec": require_spec, "adrs": require_adrs},
|
|
454
|
+
},
|
|
455
|
+
findings=sorted(findings),
|
|
456
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Depth-appropriate content checks (P3).
|
|
2
|
+
|
|
3
|
+
At the repo root, Commands sections are out of place — they should live in
|
|
4
|
+
module-level AGENTS.md files. Near the root, deep submodule path references
|
|
5
|
+
are a signal that content should have been written at a deeper level.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..domain.finding import Finding, Severity
|
|
15
|
+
from ..domain.result import LintResult
|
|
16
|
+
from ..infra.filesystem import read_text_safe
|
|
17
|
+
from ..infra.git import git_root
|
|
18
|
+
|
|
19
|
+
_COMMANDS_SECTIONS = {"commands", "dev commands", "development commands"}
|
|
20
|
+
_DEEP_PATH_RE = re.compile(r"`([a-zA-Z0-9_\-]+/[a-zA-Z0-9_\-]+/[a-zA-Z0-9_\-./]+)`")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_depth(agents_path: Path, repo_root: Path) -> int:
|
|
24
|
+
"""Depth of AGENTS.md's directory relative to the repo root. -1 if outside."""
|
|
25
|
+
try:
|
|
26
|
+
rel = agents_path.parent.resolve().relative_to(repo_root.resolve())
|
|
27
|
+
except ValueError:
|
|
28
|
+
return -1
|
|
29
|
+
return len([p for p in rel.parts if p != "."])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check(path: Path, depth: int) -> Iterator[Finding]:
|
|
33
|
+
content = read_text_safe(path)
|
|
34
|
+
if content is None:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
current_section: str | None = None
|
|
38
|
+
|
|
39
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
40
|
+
if line.startswith("## "):
|
|
41
|
+
current_section = line.lstrip("# ").strip().lower()
|
|
42
|
+
|
|
43
|
+
if depth == 0 and current_section in _COMMANDS_SECTIONS:
|
|
44
|
+
yield Finding(
|
|
45
|
+
file=str(path),
|
|
46
|
+
line=i,
|
|
47
|
+
severity=Severity.WARNING,
|
|
48
|
+
code="P3-depth0-commands",
|
|
49
|
+
message="Commands section at repo root (depth 0)",
|
|
50
|
+
hint="Defer dev commands to module-level AGENTS.md files",
|
|
51
|
+
)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if depth <= 1:
|
|
55
|
+
for ref in _DEEP_PATH_RE.findall(line):
|
|
56
|
+
if ref.startswith("http") or ".." in ref:
|
|
57
|
+
continue
|
|
58
|
+
yield Finding(
|
|
59
|
+
file=str(path),
|
|
60
|
+
line=i,
|
|
61
|
+
severity=Severity.WARNING,
|
|
62
|
+
code="P3-too-specific",
|
|
63
|
+
message=f"Reference to `{ref}` may be too specific for depth {depth}",
|
|
64
|
+
hint="Consider moving to a deeper AGENTS.md",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lint(target: Path) -> LintResult:
|
|
69
|
+
"""Lint an AGENTS.md.
|
|
70
|
+
|
|
71
|
+
Accepts either a file path or a directory. For a directory, we lint
|
|
72
|
+
`<dir>/AGENTS.md` if it exists (quick-start mode when AGENTS.md doesn't
|
|
73
|
+
exist yet returns empty findings — depth is implicit in the target).
|
|
74
|
+
"""
|
|
75
|
+
repo = git_root(target)
|
|
76
|
+
agents = target / "AGENTS.md" if target.is_dir() else target
|
|
77
|
+
depth = compute_depth(agents, repo) if repo else -1
|
|
78
|
+
|
|
79
|
+
if depth < 0 or not agents.is_file():
|
|
80
|
+
return LintResult(target=str(agents), findings=[])
|
|
81
|
+
|
|
82
|
+
return LintResult(target=str(agents), findings=list(_check(agents, depth)))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Validate YAML frontmatter on docs/*.md content files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from ..analysis.context_layout import discover
|
|
11
|
+
from ..domain.finding import Finding, Severity
|
|
12
|
+
from ..domain.result import LintResult
|
|
13
|
+
from ..infra.filesystem import EXCLUDED_DIRS, read_text_safe
|
|
14
|
+
from ..infra.markdown import mask_invisible_markdown
|
|
15
|
+
from ..infra.yaml_frontmatter import parse_frontmatter
|
|
16
|
+
|
|
17
|
+
_REQUIRED_FIELDS = ("id", "title", "description")
|
|
18
|
+
_HEADING_RE = re.compile(r"^##\s+(.+?)(?:\s*\{.*\})?\s*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _slugify(heading: str) -> str:
|
|
22
|
+
slug = re.sub(r"[^\w\s-]", "", heading.lower())
|
|
23
|
+
return re.sub(r"[\s_]+", "-", slug).strip("-")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _h2_slugs(body: str) -> list[str]:
|
|
27
|
+
body = mask_invisible_markdown(body)
|
|
28
|
+
return [
|
|
29
|
+
_slugify(m.group(1).strip()) for line in body.splitlines() if (m := _HEADING_RE.match(line))
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _err(file: Path, code: str, message: str) -> Finding:
|
|
34
|
+
return Finding(file=str(file), line=1, severity=Severity.ERROR, code=code, message=message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lint_file(path: Path, *, seen_ids: dict[str, Path]) -> Iterator[Finding]:
|
|
38
|
+
"""Validate a single file. Mutates `seen_ids` for cross-file duplicate detection."""
|
|
39
|
+
content = read_text_safe(path)
|
|
40
|
+
if content is None:
|
|
41
|
+
yield _err(path, "frontmatter-unreadable", "could not read file")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
fm, body = parse_frontmatter(content)
|
|
45
|
+
if fm is None:
|
|
46
|
+
yield _err(path, "frontmatter-missing", "missing YAML frontmatter")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
for field in _REQUIRED_FIELDS:
|
|
50
|
+
if field not in fm:
|
|
51
|
+
yield _err(path, "frontmatter-missing-field", f"missing required field '{field}'")
|
|
52
|
+
|
|
53
|
+
if "index" not in fm:
|
|
54
|
+
yield _err(path, "frontmatter-missing-field", "missing 'index' array")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
index = fm["index"]
|
|
58
|
+
if not isinstance(index, list):
|
|
59
|
+
yield _err(path, "frontmatter-invalid-index", "'index' must be an array")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
file_id = fm.get("id", "")
|
|
63
|
+
if file_id:
|
|
64
|
+
if file_id in seen_ids:
|
|
65
|
+
yield _err(
|
|
66
|
+
path,
|
|
67
|
+
"frontmatter-duplicate-id",
|
|
68
|
+
f"duplicate id '{file_id}' (also in {seen_ids[file_id]})",
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
seen_ids[file_id] = path
|
|
72
|
+
|
|
73
|
+
slugs = _h2_slugs(body)
|
|
74
|
+
for i, entry in enumerate(index):
|
|
75
|
+
if not isinstance(entry, dict):
|
|
76
|
+
yield _err(path, "frontmatter-invalid-index-entry", f"index[{i}] is not a mapping")
|
|
77
|
+
continue
|
|
78
|
+
entry_map = cast(dict[str, object], entry)
|
|
79
|
+
if "id" not in entry_map:
|
|
80
|
+
yield _err(path, "frontmatter-index-missing-id", f"index[{i}] missing 'id'")
|
|
81
|
+
continue
|
|
82
|
+
entry_id = entry_map["id"]
|
|
83
|
+
if entry_id and entry_id not in slugs:
|
|
84
|
+
yield _err(
|
|
85
|
+
path,
|
|
86
|
+
"frontmatter-index-heading-missing",
|
|
87
|
+
f"index[{i}].id '{entry_id}' has no matching ## heading",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _find_docs_trees(root: Path) -> Iterator[tuple[Path, list[Path]]]:
|
|
92
|
+
for tree in discover(root).docs_trees:
|
|
93
|
+
docs_dir = tree.path
|
|
94
|
+
files = []
|
|
95
|
+
for md in sorted(docs_dir.rglob("*.md")):
|
|
96
|
+
relative_to_tree = md.relative_to(docs_dir)
|
|
97
|
+
if md.name in ("AGENTS.md", "README.md"):
|
|
98
|
+
continue
|
|
99
|
+
if "docs" in relative_to_tree.parts[:-1]:
|
|
100
|
+
continue
|
|
101
|
+
if any(p in EXCLUDED_DIRS for p in md.relative_to(root).parts):
|
|
102
|
+
continue
|
|
103
|
+
files.append(md)
|
|
104
|
+
yield docs_dir, files
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def lint(root: Path) -> LintResult:
|
|
108
|
+
root = root.resolve()
|
|
109
|
+
findings: list[Finding] = []
|
|
110
|
+
for _docs_dir, files in _find_docs_trees(root):
|
|
111
|
+
seen_ids: dict[str, Path] = {}
|
|
112
|
+
contents = {path: read_text_safe(path) for path in files}
|
|
113
|
+
for path, content in contents.items():
|
|
114
|
+
if content is None:
|
|
115
|
+
findings.append(_err(path, "frontmatter-unreadable", "could not read file"))
|
|
116
|
+
convention_exists = any(
|
|
117
|
+
content is not None and bool(parse_frontmatter(content)[0])
|
|
118
|
+
for content in contents.values()
|
|
119
|
+
)
|
|
120
|
+
if not convention_exists:
|
|
121
|
+
continue
|
|
122
|
+
for file in files:
|
|
123
|
+
if contents[file] is not None:
|
|
124
|
+
findings.extend(lint_file(file, seen_ids=seen_ids))
|
|
125
|
+
return LintResult(target=str(root), findings=findings)
|