context-engineering-cli 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. context_engineering/__init__.py +3 -0
  2. context_engineering/__main__.py +2 -0
  3. context_engineering/analysis/__init__.py +1 -0
  4. context_engineering/analysis/backfill.py +1064 -0
  5. context_engineering/analysis/context_check.py +253 -0
  6. context_engineering/analysis/context_layout.py +111 -0
  7. context_engineering/analysis/context_review.py +224 -0
  8. context_engineering/analysis/cross_cutting/__init__.py +6 -0
  9. context_engineering/analysis/cross_cutting/authors.py +57 -0
  10. context_engineering/analysis/cross_cutting/buckets.py +40 -0
  11. context_engineering/analysis/cross_cutting/co_change.py +47 -0
  12. context_engineering/analysis/cross_cutting/discover.py +75 -0
  13. context_engineering/analysis/cross_cutting/imports.py +61 -0
  14. context_engineering/analysis/cross_cutting/pair.py +118 -0
  15. context_engineering/analysis/impact.py +77 -0
  16. context_engineering/analysis/sessions.py +27 -0
  17. context_engineering/analysis/staleness.py +179 -0
  18. context_engineering/analysis/tier.py +91 -0
  19. context_engineering/checks/__init__.py +1 -0
  20. context_engineering/checks/antipatterns/__init__.py +5 -0
  21. context_engineering/checks/antipatterns/context.py +23 -0
  22. context_engineering/checks/antipatterns/density.py +72 -0
  23. context_engineering/checks/antipatterns/line_limits.py +52 -0
  24. context_engineering/checks/antipatterns/runner.py +137 -0
  25. context_engineering/checks/antipatterns/splitting.py +97 -0
  26. context_engineering/checks/antipatterns/volatile.py +38 -0
  27. context_engineering/checks/antipatterns/watermark.py +113 -0
  28. context_engineering/checks/contracts.py +456 -0
  29. context_engineering/checks/depth.py +82 -0
  30. context_engineering/checks/frontmatter.py +125 -0
  31. context_engineering/checks/references.py +325 -0
  32. context_engineering/checks/skill_structure.py +124 -0
  33. context_engineering/cli/__init__.py +3 -0
  34. context_engineering/cli/dispatch.py +90 -0
  35. context_engineering/cli/registry.py +33 -0
  36. context_engineering/cli/render.py +92 -0
  37. context_engineering/cli/subcommands.py +587 -0
  38. context_engineering/domain/__init__.py +0 -0
  39. context_engineering/domain/commit.py +19 -0
  40. context_engineering/domain/evidence.py +57 -0
  41. context_engineering/domain/finding.py +37 -0
  42. context_engineering/domain/result.py +59 -0
  43. context_engineering/infra/__init__.py +13 -0
  44. context_engineering/infra/filesystem.py +22 -0
  45. context_engineering/infra/git.py +153 -0
  46. context_engineering/infra/git_evidence.py +357 -0
  47. context_engineering/infra/git_tree.py +139 -0
  48. context_engineering/infra/markdown.py +58 -0
  49. context_engineering/infra/yaml_frontmatter.py +70 -0
  50. context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
  51. context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
  52. context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
  53. context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
  54. context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
  55. provenance.json +1 -0
@@ -0,0 +1,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)