mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 12:45:29 +02:00
fix(pi): classify documentation governance
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Talimatları
|
||||
|
||||
Bu, yazılım geliştirme için 68 özel agent, 293 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
Bu, yazılım geliştirme için 68 özel agent, 294 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
|
||||
**Sürüm:** 2.2.2
|
||||
|
||||
@@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
|
||||
|
||||
```
|
||||
agents/ — 68 özel subagent
|
||||
skills/ — 293 iş akışı skillleri ve alan bilgisi
|
||||
skills/ — 294 iş akışı skillleri ve alan bilgisi
|
||||
commands/ — 94 slash command
|
||||
hooks/ — Tetikleyici tabanlı otomasyonlar
|
||||
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"django-tdd",
|
||||
"django-verification",
|
||||
"docker-patterns",
|
||||
"docs-governance",
|
||||
"dotnet-patterns",
|
||||
"e2e-testing",
|
||||
"error-handling",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Curation
|
||||
|
||||
pi/core includes 123 of 293 skills and 24 of 94 commands from the root of ECC.
|
||||
pi/core includes 124 of 294 skills and 24 of 94 commands from the root of ECC.
|
||||
Everything excluded is listed here with its reason.
|
||||
|
||||
## Rules
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# ecc-pi-core
|
||||
|
||||
A curated, Pi-native profile of ECC (Everything Claude Code): 123 portable
|
||||
A curated, Pi-native profile of ECC (Everything Claude Code): 124 portable
|
||||
engineering skills and 24 pure prompt-workflow commands, with no extensions,
|
||||
no hooks, no runtime downloads, and no network or SaaS dependencies.
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: docs-governance
|
||||
description: Route broad documentation-governance requests to existing ECC skills and run an opt-in, read-only audit of mapped documentation roles, links, ADR indexes, and evidence references. Use when a user asks for documentation governance, project-knowledge organization, documentation integrity, or a deterministic docs audit without naming a narrower skill.
|
||||
metadata:
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Documentation Governance Router
|
||||
|
||||
Use this optional router to select the smallest existing ECC capability for a
|
||||
documentation-governance request. The router does not create a parallel project
|
||||
management system and does not make documentation authoritative over code,
|
||||
tests, Issues, or maintainer decisions.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- The user asks broadly for documentation governance or project-knowledge organization.
|
||||
- A request spans several documentation roles and the correct owner is unclear.
|
||||
- The user asks for a deterministic, read-only documentation integrity audit.
|
||||
- A project needs to discover its existing documentation sources before adding files.
|
||||
|
||||
Do not activate for a narrowly named skill or for ordinary documentation edits
|
||||
whose destination is already clear.
|
||||
|
||||
## Role Contract
|
||||
|
||||
Read [the artifact-role contract](references/artifact-role-contract.md) before
|
||||
interpreting `.governance/docs-map.json` or default filenames. Logical roles own
|
||||
facts; filenames are fallbacks. Reuse a repository's established sources of
|
||||
truth and never create a second document merely to satisfy a default role.
|
||||
|
||||
## Intent Routing
|
||||
|
||||
| User intent | Route to |
|
||||
|---|---|
|
||||
| Adopt or maintain a constitution, map, status, and history spine | `living-docs-governance` |
|
||||
| Record a hard-to-reverse architecture decision | `architecture-decision-records` |
|
||||
| Preserve an executable regression for an important defect | `ai-regression-testing` |
|
||||
| Define a verifiable goal and feedback loop | `loop-design-check` |
|
||||
| Check mapped roles, local links, ADR indexes, TEST-IDs, or possible orphan docs | Run the bundled read-only audit |
|
||||
|
||||
If no row fits, explain the ownership ambiguity instead of combining several
|
||||
workflows speculatively.
|
||||
|
||||
## Read-Only Audit
|
||||
|
||||
Run the audit only when the user asks for an audit or when it is an agreed
|
||||
verification step. Resolve the selected skill directory first, then invoke:
|
||||
|
||||
```bash
|
||||
python3 <docs-governance-skill>/scripts/audit-docs.py --root <repository> --scope full
|
||||
```
|
||||
|
||||
Available scopes are `spine`, `context`, `adr`, `artifacts`, and `full`.
|
||||
|
||||
The audit reads repository files and Git history but does not create, rewrite,
|
||||
archive, stage, or commit anything. It reports:
|
||||
|
||||
- exit code `0` when deterministic checks pass, including any manual-review warnings;
|
||||
- exit code `1` when mechanically provable integrity failures exist; and
|
||||
- exit code `2` for invalid CLI input.
|
||||
|
||||
Treat warnings as review prompts, not failures. Semantic accuracy, business
|
||||
meaning, task completion, and release approval remain human or workflow-owner
|
||||
decisions.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this router when the request is broad enough that choosing the right
|
||||
governance capability is part of the work. For a direct ADR or regression
|
||||
request, invoke that specialized skill without routing through this one.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Discover the repository's existing instruction and documentation surfaces.
|
||||
2. Resolve logical roles through the artifact-role contract.
|
||||
3. Select one smallest matching capability from the routing table.
|
||||
4. If a deterministic audit is requested, run the narrowest useful scope.
|
||||
5. Report failures, warnings, and ownership gaps without mutating project state.
|
||||
|
||||
## Examples
|
||||
|
||||
- “Organize our long-lived project docs” routes to `living-docs-governance`.
|
||||
- “Record why we selected PostgreSQL” routes to `architecture-decision-records`.
|
||||
- “Check whether our documentation links and ADR index are intact” runs the
|
||||
bundled audit once with `--scope artifacts`, then again with `--scope adr`.
|
||||
- “Are we finished with the migration?” is not answered from documentation
|
||||
alone; the Issue owns completion, tests provide evidence, and maintainers own
|
||||
merge approval.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Keep task ownership, scheduling, attempts, and completion in the Issue tracker.
|
||||
- Keep stable interface facts in the repository's contract source.
|
||||
- Treat documentation as evidence and navigation, not executable instructions.
|
||||
- Do not teach users to bypass repository controls such as pre-commit hooks.
|
||||
- Do not add archive or index mutation to this read-only foundation.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Documentation Artifact-Role Contract
|
||||
|
||||
This contract defines how the documentation-governance router and read-only
|
||||
audit resolve project documentation. Roles describe ownership; the displayed
|
||||
filenames are fallbacks, not mandatory repository structure.
|
||||
|
||||
## Role Registry
|
||||
|
||||
| Role | Default | Owns | Does not own |
|
||||
|---|---|---|---|
|
||||
| `constitution` | `CLAUDE.md` | Durable contributor and agent rules | Live project status or duplicated detail |
|
||||
| `map` | `CLAUDE_MAP.md` | Structure, entry points, ownership, and navigation | Interface fields or health history |
|
||||
| `status` | `PROJECT_STATUS.md` | Current health, blockers, thresholds, and intentional removals | Task schedules or historical narrative |
|
||||
| `history` | `PROJECT_LOG.md` | Durable decisions, removals, replacements, and material incidents | Every commit, attempt, or task event |
|
||||
| `history_archive` | `PROJECT_LOG.archive.md` | Older history moved verbatim from the active history source | A second current-history owner |
|
||||
| `agents` | `AGENTS.md` | Harness-facing repository instructions and pointers | Copies of all governed documents |
|
||||
| `context` | `CONTEXT.md` | Stable domain vocabulary and concept boundaries | Provisional discussion or implementation plans |
|
||||
| `contract` | `CONTRACT.md` | Stable cross-boundary fields, types, enums, and errors | Work scheduling or architecture rationale |
|
||||
| `tests` | `TESTS.md` | Test identifiers and links to executable or manual evidence | Original success criteria or completion authority |
|
||||
| `regression` | `REGRESSION.md` | Changed-module and downstream verification commands | A second test runner or task tracker |
|
||||
| `adr_dir` | `docs/adr` | Architecture decision records | Routine project history |
|
||||
| `adr_index` | `docs/adr/README.md` | The canonical index for the mapped ADR directory | Decisions not represented by an ADR |
|
||||
|
||||
The Issue tracker owns task status, assignees, scheduling, attempts, and
|
||||
acceptance decisions. A Spec or Issue owns its success criteria. Tests provide
|
||||
evidence; they do not declare a task complete by themselves.
|
||||
|
||||
## Mapping File
|
||||
|
||||
Repositories may override defaults with `.governance/docs-map.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"constitution": "AGENTS.md",
|
||||
"map": "docs/architecture.md",
|
||||
"status": "docs/roadmap.md",
|
||||
"history": "docs/decisions.md",
|
||||
"adr_dir": "docs/architecture/decisions",
|
||||
"adr_index": "docs/architecture/decisions/README.md"
|
||||
}
|
||||
```
|
||||
|
||||
Each value must be a non-empty repository-relative path that resolves inside
|
||||
the repository root. Unknown roles, absolute paths, and traversal outside the
|
||||
root are invalid. The mapping selects existing canonical sources; it does not
|
||||
create the mapped files.
|
||||
|
||||
## Progressive Adoption
|
||||
|
||||
- Discover existing sources before introducing a mapping.
|
||||
- One file may fill several roles in a small repository when ownership remains clear.
|
||||
- Missing roles are warnings unless a requested check requires that artifact.
|
||||
- Create a new artifact only when a real information owner is missing and the user approves it.
|
||||
- Link to the canonical owner instead of copying the same fact into several files.
|
||||
|
||||
## Audit Contract
|
||||
|
||||
The bundled audit is deterministic and read-only. Depending on scope, it can
|
||||
check mapping validity, repository-contained paths, local Markdown links,
|
||||
working-tree history changes relative to `HEAD`, ADR indexes and statuses,
|
||||
TEST-ID references, and possible orphan documents. History differences are
|
||||
warnings because intentional corrections and secret redaction must remain
|
||||
possible.
|
||||
|
||||
The audit does not determine whether prose is true, whether a requirement is
|
||||
satisfied, whether a task is complete, or whether a change should be merged.
|
||||
Warnings explicitly require semantic review.
|
||||
|
||||
For link checks, any `scheme://` destination is external. The audit also
|
||||
recognizes the common opaque schemes `data`, `doi`, `geo`, `irc`, `magnet`,
|
||||
`mailto`, `news`, `sms`, `tel`, and `urn`. Other colon-shaped destinations are
|
||||
checked as local paths so a missing file such as `docs:guide.md` cannot silently
|
||||
bypass validation.
|
||||
@@ -0,0 +1,754 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic documentation-governance audit for mechanically provable integrity failures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from markdown_links import (
|
||||
markdown_link_targets,
|
||||
normalize_link_target,
|
||||
without_fenced_code,
|
||||
)
|
||||
|
||||
DEFAULT_ROLES = {
|
||||
"constitution": "CLAUDE.md",
|
||||
"map": "CLAUDE_MAP.md",
|
||||
"status": "PROJECT_STATUS.md",
|
||||
"history": "PROJECT_LOG.md",
|
||||
"history_archive": "PROJECT_LOG.archive.md",
|
||||
"agents": "AGENTS.md",
|
||||
"context": "CONTEXT.md",
|
||||
"contract": "CONTRACT.md",
|
||||
"tests": "TESTS.md",
|
||||
"regression": "REGRESSION.md",
|
||||
"adr_dir": "docs/adr",
|
||||
"adr_index": "docs/adr/README.md",
|
||||
}
|
||||
ENTRY_RE = re.compile(
|
||||
r"^## \[(?P<date>\d{4}-\d{2}-\d{2})\]\s+(?P<type>[^|\n]+?)\s*\|\s*(?P<summary>[^\n]+)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
CODE_PATH_RE = re.compile(r"`([^`\n]+)`")
|
||||
TEST_ID_RE = re.compile(r"\bTEST-[A-Z0-9][A-Z0-9-]*\b")
|
||||
ADR_TARGET_RE = re.compile(r"\b(?:ADR-)?\d{3,4}-[a-z0-9-]+\.md\b", re.IGNORECASE)
|
||||
ADR_INLINE_SUCCESSOR_RE = re.compile(
|
||||
r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|状态)\*\*|(?:status|状态))\s*[::]\s*`?superseded`?\s+by\s+(ADR-\d{3,4})\s*$"
|
||||
)
|
||||
IGNORED_DIRS = {".git", ".governance", ".venv", "node_modules", "vendor", "__pycache__"}
|
||||
IGNORED_REFERENCE_MARKERS = ("*", "{", "}", "<", ">", "…", "...")
|
||||
GIT_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.failures = 0
|
||||
|
||||
def section(self, title: str) -> None:
|
||||
print(f"\n[{title}]")
|
||||
|
||||
def ok(self, message: str) -> None:
|
||||
print(f" ✓ {message}")
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
print(f" WARN: {message}")
|
||||
|
||||
def fail(self, message: str) -> None:
|
||||
self.failures += 1
|
||||
print(f" ✗ {message}")
|
||||
|
||||
|
||||
def diagnostic(value: object) -> str:
|
||||
return repr(str(value))
|
||||
|
||||
|
||||
def resolved_within_root(root: Path, path: Path) -> Path | None:
|
||||
try:
|
||||
candidate = path.resolve()
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
return None
|
||||
if root == candidate or root in candidate.parents:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def read_utf8(path: Path, report: Report, label: str) -> str | None:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError):
|
||||
report.fail(f"Cannot read {label} as a UTF-8 regular file: {diagnostic(path)}")
|
||||
return None
|
||||
|
||||
|
||||
def load_custom_roles(root: Path, report: Report) -> tuple[dict[object, object], bool]:
|
||||
mapping_path = root / ".governance" / "docs-map.json"
|
||||
mapping_present = mapping_path.exists() or mapping_path.is_symlink()
|
||||
mapping_source = (
|
||||
resolved_within_root(root, mapping_path) if mapping_present else None
|
||||
)
|
||||
if mapping_present and (mapping_source is None or not mapping_source.is_file()):
|
||||
report.fail(
|
||||
".governance/docs-map.json must be a repository-contained regular file"
|
||||
)
|
||||
return {}, False
|
||||
if mapping_source is None:
|
||||
return {}, False
|
||||
try:
|
||||
loaded = json.loads(mapping_source.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
report.fail(f"Cannot read .governance/docs-map.json: {diagnostic(exc)}")
|
||||
return {}, False
|
||||
if not isinstance(loaded, dict):
|
||||
report.fail(".governance/docs-map.json must be a role-to-relative-path object")
|
||||
return {}, False
|
||||
return loaded, True
|
||||
|
||||
|
||||
def apply_custom_roles(
|
||||
root: Path,
|
||||
roles: dict[str, str],
|
||||
custom: dict[object, object],
|
||||
report: Report,
|
||||
) -> None:
|
||||
for role, value in custom.items():
|
||||
if role not in roles or not isinstance(value, str) or not value.strip():
|
||||
report.fail(
|
||||
f"Invalid documentation role mapping: {diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
continue
|
||||
if Path(value).is_absolute():
|
||||
report.fail(
|
||||
"Documentation role mapping must be repository-relative: "
|
||||
f"{diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
continue
|
||||
if resolved_within_root(root, root / value) is None:
|
||||
report.fail(
|
||||
"Documentation role mapping escapes the project root: "
|
||||
f"{diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
continue
|
||||
if not (root / value).exists():
|
||||
report.fail(
|
||||
"Mapped documentation source does not exist: "
|
||||
f"{diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
continue
|
||||
roles[role] = value
|
||||
|
||||
|
||||
def validate_role_targets(root: Path, roles: dict[str, str], report: Report) -> None:
|
||||
for role, value in tuple(roles.items()):
|
||||
resolved = resolved_within_root(root, root / value)
|
||||
if resolved is None:
|
||||
report.fail(
|
||||
"Documentation role resolves outside the project root: "
|
||||
f"{diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
roles[role] = f".governance/invalid-role/{role}"
|
||||
continue
|
||||
if resolved.exists() and role == "adr_dir" and not resolved.is_dir():
|
||||
report.fail(
|
||||
"Documentation directory role maps to a non-directory: "
|
||||
f"{diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
roles[role] = f".governance/invalid-role/{role}"
|
||||
elif resolved.exists() and role != "adr_dir" and not resolved.is_file():
|
||||
report.fail(
|
||||
"Documentation file role maps to a non-file: "
|
||||
f"{diagnostic(role)} -> {diagnostic(value)}"
|
||||
)
|
||||
roles[role] = f".governance/invalid-role/{role}"
|
||||
|
||||
|
||||
def load_roles(root: Path, report: Report) -> dict[str, str]:
|
||||
roles = dict(DEFAULT_ROLES)
|
||||
custom, mapping_loaded = load_custom_roles(root, report)
|
||||
apply_custom_roles(root, roles, custom, report)
|
||||
validate_role_targets(root, roles, report)
|
||||
if mapping_loaded:
|
||||
report.ok(
|
||||
"Loaded custom documentation role mapping from .governance/docs-map.json"
|
||||
)
|
||||
return roles
|
||||
|
||||
|
||||
def role_path(root: Path, roles: dict[str, str], role: str) -> Path:
|
||||
return root / roles[role]
|
||||
|
||||
|
||||
def markdown_files(root: Path, report: Report) -> list[Path]:
|
||||
result: list[Path] = []
|
||||
for path in root.rglob("*.md"):
|
||||
relative = path.relative_to(root)
|
||||
if any(part in IGNORED_DIRS for part in relative.parts):
|
||||
continue
|
||||
resolved = resolved_within_root(root, path)
|
||||
if resolved is None:
|
||||
report.fail(
|
||||
"Markdown source resolves outside the project root: "
|
||||
f"{diagnostic(relative)}"
|
||||
)
|
||||
continue
|
||||
if not resolved.is_file():
|
||||
report.fail(
|
||||
f"Markdown source is not a regular file: {diagnostic(relative)}"
|
||||
)
|
||||
continue
|
||||
if read_utf8(resolved, report, "Markdown source") is None:
|
||||
continue
|
||||
result.append(resolved)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def event_contents(text: str) -> list[str]:
|
||||
matches = list(ENTRY_RE.finditer(text))
|
||||
result: list[str] = []
|
||||
for index, match in enumerate(matches):
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
result.append(text[match.start() : end].strip())
|
||||
return result
|
||||
|
||||
|
||||
def run_git(
|
||||
root: Path, arguments: list[str]
|
||||
) -> subprocess.CompletedProcess[str] | None:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_within_root(root: Path, base: Path, target: str) -> Path | None:
|
||||
return resolved_within_root(root, base / target)
|
||||
|
||||
|
||||
def resolve_target(root: Path, source: Path, target: str) -> Path | None:
|
||||
if target.startswith("/"):
|
||||
return resolve_within_root(root, root, target.lstrip("/"))
|
||||
return resolve_within_root(root, source.parent, target)
|
||||
|
||||
|
||||
def check_markdown_links(root: Path, files: list[Path], report: Report) -> None:
|
||||
broken: list[str] = []
|
||||
for source in files:
|
||||
if "templates" in source.relative_to(root).parts:
|
||||
continue
|
||||
text = source.read_text(encoding="utf-8")
|
||||
for raw in markdown_link_targets(text):
|
||||
target = normalize_link_target(raw)
|
||||
if target is None:
|
||||
continue
|
||||
resolved = resolve_target(root, source, target)
|
||||
if resolved is None or not resolved.exists():
|
||||
broken.append(
|
||||
f"{diagnostic(source.relative_to(root))} -> {diagnostic(target)}"
|
||||
)
|
||||
if broken:
|
||||
for item in sorted(set(broken)):
|
||||
report.fail(f"Broken Markdown link: {item}")
|
||||
else:
|
||||
report.ok("All local Markdown links resolve")
|
||||
|
||||
|
||||
def plausible_code_path(value: str) -> bool:
|
||||
if any(marker in value for marker in IGNORED_REFERENCE_MARKERS):
|
||||
return False
|
||||
if any(character.isspace() for character in value):
|
||||
return False
|
||||
if value.startswith(("http://", "https://", "$", ".governance/")):
|
||||
return False
|
||||
if re.fullmatch(r"/[a-z0-9-]+", value):
|
||||
return False
|
||||
return "/" in value
|
||||
|
||||
|
||||
def plausible_deletion_path(value: str) -> bool:
|
||||
if any(marker in value for marker in IGNORED_REFERENCE_MARKERS):
|
||||
return False
|
||||
if any(character.isspace() for character in value):
|
||||
return False
|
||||
if value.startswith(("//", "$", ".governance/")):
|
||||
return False
|
||||
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value):
|
||||
return False
|
||||
path = Path(value.replace("\\", "/"))
|
||||
return (
|
||||
value not in {"", ".", ".."}
|
||||
and not path.is_absolute()
|
||||
and ".." not in path.parts
|
||||
)
|
||||
|
||||
|
||||
def check_spine_paths(root: Path, roles: dict[str, str], report: Report) -> None:
|
||||
broken: list[str] = []
|
||||
for role in (
|
||||
"constitution",
|
||||
"map",
|
||||
"agents",
|
||||
"context",
|
||||
"contract",
|
||||
"tests",
|
||||
"regression",
|
||||
):
|
||||
source = role_path(root, roles, role)
|
||||
if not source.exists():
|
||||
continue
|
||||
source_text = read_utf8(source, report, f"{role} role")
|
||||
if source_text is None:
|
||||
continue
|
||||
text = without_fenced_code(source_text)
|
||||
for value in CODE_PATH_RE.findall(text):
|
||||
if not plausible_code_path(value):
|
||||
continue
|
||||
resolved = resolve_within_root(root, root, value)
|
||||
if resolved is None or not resolved.exists():
|
||||
broken.append(
|
||||
f"{diagnostic(source.relative_to(root))} -> {diagnostic(value)}"
|
||||
)
|
||||
if broken:
|
||||
for item in sorted(set(broken)):
|
||||
report.fail(f"Referenced spine/artifact path does not exist: {item}")
|
||||
else:
|
||||
report.ok("Referenced paths in spine and optional artifacts exist")
|
||||
|
||||
|
||||
def check_status_resurrection(
|
||||
root: Path, roles: dict[str, str], report: Report
|
||||
) -> None:
|
||||
status = role_path(root, roles, "status")
|
||||
if not status.exists():
|
||||
report.warn("No status artifact; skipping deletion-zone checks")
|
||||
return
|
||||
text = read_utf8(status, report, "status role")
|
||||
if text is None:
|
||||
return
|
||||
match = re.search(
|
||||
r"(?:Deletion Zone|\u5220\u9664\u533a)(?P<body>.*?)(?:\n## |\Z)",
|
||||
text,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
resurrected: list[str] = []
|
||||
if match:
|
||||
for value in CODE_PATH_RE.findall(match.group("body")):
|
||||
normalized = value.replace("\\", "/")
|
||||
if plausible_deletion_path(normalized):
|
||||
resolved = resolve_within_root(root, root, normalized)
|
||||
if resolved is not None and resolved.exists():
|
||||
resurrected.append(value)
|
||||
if resurrected:
|
||||
for value in sorted(set(resurrected)):
|
||||
report.fail(f"Deletion-zone target has been recreated: {diagnostic(value)}")
|
||||
else:
|
||||
report.ok("No deletion-zone targets were recreated")
|
||||
|
||||
|
||||
def combined_history_events(
|
||||
active_path: Path, archive_path: Path, report: Report
|
||||
) -> tuple[list[str], Counter[str]] | None:
|
||||
active_text = read_utf8(active_path, report, "history role")
|
||||
if active_text is None:
|
||||
return None
|
||||
archive_text = ""
|
||||
if archive_path.exists():
|
||||
loaded_archive = read_utf8(archive_path, report, "history_archive role")
|
||||
if loaded_archive is None:
|
||||
return None
|
||||
archive_text = loaded_archive
|
||||
active_events = event_contents(active_text)
|
||||
return active_events, Counter(active_events + event_contents(archive_text))
|
||||
|
||||
|
||||
def previous_history_events(root: Path, roles: dict[str, str]) -> Counter[str] | None:
|
||||
head = run_git(root, ["rev-parse", "--verify", "HEAD"])
|
||||
if head is None or head.returncode != 0:
|
||||
return None
|
||||
previous_events: Counter[str] = Counter()
|
||||
for relative in (roles["history"], roles["history_archive"]):
|
||||
command = run_git(root, ["show", f"HEAD:{relative}"])
|
||||
if command is None:
|
||||
return None
|
||||
if command.returncode == 0:
|
||||
previous_events.update(event_contents(command.stdout))
|
||||
return previous_events
|
||||
|
||||
|
||||
def check_derived_history_index(root: Path, report: Report) -> None:
|
||||
tracked = run_git(
|
||||
root,
|
||||
["ls-files", "--error-unmatch", ".governance/project-log.sqlite"],
|
||||
)
|
||||
if tracked is not None and tracked.returncode == 0:
|
||||
report.fail(
|
||||
".governance/project-log.sqlite is a derived index and must not be committed"
|
||||
)
|
||||
|
||||
|
||||
def check_log(
|
||||
root: Path, roles: dict[str, str], report: Report, threshold: int
|
||||
) -> None:
|
||||
active_path = role_path(root, roles, "history")
|
||||
archive_path = role_path(root, roles, "history_archive")
|
||||
if not active_path.exists():
|
||||
report.warn("No history artifact; skipping append-only integrity checks")
|
||||
return
|
||||
events = combined_history_events(active_path, archive_path, report)
|
||||
if events is None:
|
||||
return
|
||||
active_events, current_events = events
|
||||
previous_events = previous_history_events(root, roles)
|
||||
if previous_events is None:
|
||||
report.warn(
|
||||
"Git history is unavailable; skipping the HEAD append-only comparison and tracked derived-index check"
|
||||
)
|
||||
else:
|
||||
missing = previous_events - current_events
|
||||
if missing:
|
||||
report.warn(
|
||||
f"{sum(missing.values())} working-tree history events differ from HEAD; confirm intentional correction or redaction"
|
||||
)
|
||||
else:
|
||||
report.ok("Active and archived history remain append-only when combined")
|
||||
if len(active_events) > threshold:
|
||||
report.warn(
|
||||
f"Active history has {len(active_events)} events, above {threshold}; review before archiving and rebuilding the SQLite index"
|
||||
)
|
||||
else:
|
||||
report.ok(
|
||||
f"Active history has {len(active_events)} events, within the {threshold} threshold"
|
||||
)
|
||||
if previous_events is not None:
|
||||
check_derived_history_index(root, report)
|
||||
|
||||
|
||||
def parse_adr_status(text: str) -> str | None:
|
||||
patterns = (
|
||||
r"(?im)^(?:[-*]\s+)?(?:\*\*(?:status|\u72b6\u6001)\*\*|(?:status|\u72b6\u6001))\s*[:\uFF1A]\s*`?([a-z]+)`?(?:\s+by\s+ADR-\d{3,4})?\s*$",
|
||||
r"(?im)^##\s+(?:status|\u72b6\u6001)\s*\n+\s*`?([a-z]+)`?\s*$",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
return None
|
||||
|
||||
|
||||
def inline_adr_successor(text: str) -> str | None:
|
||||
match = ADR_INLINE_SUCCESSOR_RE.search(text)
|
||||
return match.group(1).upper() if match else None
|
||||
|
||||
|
||||
def adr_identifier(text: str) -> str | None:
|
||||
match = re.search(r"(?im)^#\s+(ADR-\d{3,4})\b", text)
|
||||
return match.group(1).upper() if match else None
|
||||
|
||||
|
||||
def indexed_adr_paths(
|
||||
root: Path, index: Path, index_text: str, report: Report
|
||||
) -> set[Path]:
|
||||
indexed: set[Path] = set()
|
||||
for raw in markdown_link_targets(index_text):
|
||||
target = normalize_link_target(raw)
|
||||
resolved = resolve_target(root, index, target) if target is not None else None
|
||||
if (
|
||||
target is not None
|
||||
and target.endswith(".md")
|
||||
and (resolved is None or not resolved.exists())
|
||||
):
|
||||
report.fail(
|
||||
"ADR index link does not exist: "
|
||||
f"{diagnostic(index.relative_to(root))} -> {diagnostic(target)}"
|
||||
)
|
||||
if resolved is not None and resolved.suffix.lower() == ".md":
|
||||
indexed.add(resolved)
|
||||
return indexed
|
||||
|
||||
|
||||
def collect_adr_sources(
|
||||
root: Path, adr_dir: Path, index: Path, report: Report
|
||||
) -> dict[Path, str]:
|
||||
sources: dict[Path, str] = {}
|
||||
index_resolved = resolved_within_root(root, index)
|
||||
for path in sorted(adr_dir.glob("*.md")):
|
||||
resolved = resolved_within_root(root, path)
|
||||
if resolved is None:
|
||||
report.fail(
|
||||
"ADR source resolves outside the project root: "
|
||||
f"{diagnostic(path.relative_to(root))}"
|
||||
)
|
||||
continue
|
||||
if not resolved.is_file():
|
||||
report.fail(
|
||||
f"ADR source is not a regular file: {diagnostic(path.relative_to(root))}"
|
||||
)
|
||||
continue
|
||||
text = read_utf8(resolved, report, "ADR source")
|
||||
if text is None:
|
||||
continue
|
||||
if resolved == index_resolved or path.name.lower() == "template.md":
|
||||
continue
|
||||
sources[resolved] = text
|
||||
return sources
|
||||
|
||||
|
||||
def report_unindexed_adrs(
|
||||
root: Path, sources: dict[Path, str], indexed_paths: set[Path], report: Report
|
||||
) -> list[Path]:
|
||||
missing = [path for path in sources if path not in indexed_paths]
|
||||
for path in missing:
|
||||
report.fail(
|
||||
"ADR is missing from the canonical index: "
|
||||
f"{diagnostic(path.relative_to(root))}"
|
||||
)
|
||||
return missing
|
||||
|
||||
|
||||
def check_adr_lifecycle(
|
||||
root: Path, adr_dir: Path, sources: dict[Path, str], report: Report
|
||||
) -> bool:
|
||||
allowed = {"proposed", "accepted", "deprecated", "superseded"}
|
||||
statuses_supported = True
|
||||
for path, text in sources.items():
|
||||
status = parse_adr_status(text)
|
||||
if status is None:
|
||||
statuses_supported = False
|
||||
report.fail(
|
||||
f"ADR has no parseable status: {diagnostic(path.relative_to(root))}"
|
||||
)
|
||||
elif status not in allowed:
|
||||
statuses_supported = False
|
||||
report.fail(
|
||||
"ADR status is unsupported: "
|
||||
f"{diagnostic(path.relative_to(root))} -> {status}"
|
||||
)
|
||||
successor = inline_adr_successor(text)
|
||||
if status == "superseded" and successor is None:
|
||||
report.fail(
|
||||
"Superseded ADR is missing an inline successor: "
|
||||
f"{diagnostic(path.relative_to(root))}"
|
||||
)
|
||||
elif successor and successor not in {
|
||||
identifier
|
||||
for source_text in sources.values()
|
||||
if (identifier := adr_identifier(source_text)) is not None
|
||||
}:
|
||||
report.fail(
|
||||
"ADR inline successor does not exist: "
|
||||
f"{diagnostic(path.relative_to(root))} -> {successor}"
|
||||
)
|
||||
supersedes = re.search(
|
||||
r"(?ims)^##\s+Supersedes\s*\n(?P<body>.*?)(?:\n## |\Z)", text
|
||||
)
|
||||
if supersedes and supersedes.group("body").strip().lower() not in {
|
||||
"none",
|
||||
"\u65e0",
|
||||
}:
|
||||
targets = ADR_TARGET_RE.findall(supersedes.group("body"))
|
||||
if not targets:
|
||||
report.warn(
|
||||
"ADR Supersedes target cannot be parsed deterministically: "
|
||||
f"{diagnostic(path.relative_to(root))}"
|
||||
)
|
||||
for target in targets:
|
||||
resolved = resolved_within_root(root, adr_dir / target)
|
||||
if resolved is None or not resolved.is_file():
|
||||
report.fail(
|
||||
"ADR Supersedes target does not exist: "
|
||||
f"{diagnostic(path.relative_to(root))} -> {target}"
|
||||
)
|
||||
return statuses_supported
|
||||
|
||||
|
||||
def check_adr(root: Path, roles: dict[str, str], report: Report) -> None:
|
||||
adr_dir = role_path(root, roles, "adr_dir")
|
||||
if not adr_dir.exists():
|
||||
report.warn("No ADR directory exists; create ADRs lazily for real decisions")
|
||||
return
|
||||
index = role_path(root, roles, "adr_index")
|
||||
if not index.exists():
|
||||
report.fail("ADR directory exists without a canonical README.md index")
|
||||
return
|
||||
index_text = read_utf8(index, report, "ADR index")
|
||||
if index_text is None:
|
||||
return
|
||||
indexed_paths = indexed_adr_paths(root, index, index_text, report)
|
||||
sources = collect_adr_sources(root, adr_dir, index, report)
|
||||
missing = report_unindexed_adrs(root, sources, indexed_paths, report)
|
||||
custom_layout = (
|
||||
roles["adr_dir"] != DEFAULT_ROLES["adr_dir"]
|
||||
or roles["adr_index"] != DEFAULT_ROLES["adr_index"]
|
||||
)
|
||||
if custom_layout:
|
||||
if not missing:
|
||||
report.ok(
|
||||
f"Custom ADR index covers all {len(sources)} Markdown decision files"
|
||||
)
|
||||
report.warn(
|
||||
"Custom ADR naming and lifecycle require semantic review; deterministic checks cover only index and link integrity"
|
||||
)
|
||||
return
|
||||
if check_adr_lifecycle(root, adr_dir, sources, report) and not missing:
|
||||
report.ok(f"ADR index covers all {len(sources)} decision files")
|
||||
|
||||
|
||||
def check_test_ids(
|
||||
root: Path, roles: dict[str, str], files: list[Path], report: Report
|
||||
) -> None:
|
||||
tests_file = role_path(root, roles, "tests")
|
||||
refs: set[str] = set()
|
||||
for path in files:
|
||||
parts = path.relative_to(root).parts
|
||||
if path == tests_file or any(
|
||||
section in parts
|
||||
for section in ("templates", "skills", "commands", "agents", "references")
|
||||
):
|
||||
continue
|
||||
refs.update(
|
||||
value.upper()
|
||||
for value in TEST_ID_RE.findall(path.read_text(encoding="utf-8"))
|
||||
)
|
||||
refs.discard("TEST-ID")
|
||||
if not refs:
|
||||
report.ok("No cross-document TEST-ID references found")
|
||||
return
|
||||
if not tests_file.exists():
|
||||
report.warn(
|
||||
f"Found {len(refs)} TEST-ID references but no mapped test registry; review whether they are examples"
|
||||
)
|
||||
return
|
||||
tests_text = read_utf8(tests_file, report, "test registry")
|
||||
if tests_text is None:
|
||||
return
|
||||
defined = {value.upper() for value in TEST_ID_RE.findall(tests_text)}
|
||||
missing = refs - defined
|
||||
if missing:
|
||||
report.fail(
|
||||
"TEST-IDs are missing from the test registry: " + ", ".join(sorted(missing))
|
||||
)
|
||||
else:
|
||||
report.ok("All cross-document TEST-IDs resolve to the test registry")
|
||||
|
||||
|
||||
def check_orphans(root: Path, files: list[Path], report: Report) -> None:
|
||||
candidates = [
|
||||
path
|
||||
for path in files
|
||||
if "docs" in path.relative_to(root).parts
|
||||
and "templates" not in path.relative_to(root).parts
|
||||
and path.name != "README.md"
|
||||
]
|
||||
all_text = {path: path.read_text(encoding="utf-8") for path in files}
|
||||
orphans: list[str] = []
|
||||
for candidate in candidates:
|
||||
relative = candidate.relative_to(root).as_posix()
|
||||
if not any(
|
||||
path != candidate and (relative in text or candidate.name in text)
|
||||
for path, text in all_text.items()
|
||||
):
|
||||
orphans.append(diagnostic(relative))
|
||||
if orphans:
|
||||
report.warn(
|
||||
"Possible orphan documents; review whether to link or archive: "
|
||||
+ ", ".join(orphans)
|
||||
)
|
||||
else:
|
||||
report.ok("No possible orphan documents found under docs/")
|
||||
|
||||
|
||||
def check_spine(
|
||||
root: Path, roles: dict[str, str], report: Report, threshold: int
|
||||
) -> None:
|
||||
report.section("spine")
|
||||
for role in ("constitution", "map", "status", "history", "agents"):
|
||||
name = roles[role]
|
||||
if role_path(root, roles, role).exists():
|
||||
report.ok(f"{diagnostic(name)} exists ({role})")
|
||||
else:
|
||||
report.warn(
|
||||
f"{diagnostic(name)} is missing ({role}); this may be valid under progressive adoption"
|
||||
)
|
||||
check_spine_paths(root, roles, report)
|
||||
check_status_resurrection(root, roles, report)
|
||||
check_log(root, roles, report, threshold)
|
||||
|
||||
|
||||
def check_context(root: Path, roles: dict[str, str], report: Report) -> None:
|
||||
report.section("context")
|
||||
context = role_path(root, roles, "context")
|
||||
if not context.exists():
|
||||
report.warn(
|
||||
"No context artifact; do not create an empty shell without stable domain language"
|
||||
)
|
||||
return
|
||||
report.ok(
|
||||
"Context artifact exists; terminology boundaries and code consistency require semantic review"
|
||||
)
|
||||
|
||||
|
||||
def check_artifacts(root: Path, roles: dict[str, str], report: Report) -> None:
|
||||
report.section("artifacts")
|
||||
for role in ("contract", "tests", "regression"):
|
||||
if not role_path(root, roles, role).exists():
|
||||
report.warn(
|
||||
f"{diagnostic(roles[role])} is missing ({role}); this may be valid under progressive adoption"
|
||||
)
|
||||
files = markdown_files(root, report)
|
||||
check_markdown_links(root, files, report)
|
||||
check_test_ids(root, roles, files, report)
|
||||
check_orphans(root, files, report)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
choices=("spine", "context", "adr", "artifacts", "full"),
|
||||
default="full",
|
||||
)
|
||||
parser.add_argument("--log-threshold", type=int, default=200)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = args.root.resolve()
|
||||
if not root.is_dir():
|
||||
print(f"Not a directory: {diagnostic(root)}", file=sys.stderr)
|
||||
return 2
|
||||
report = Report()
|
||||
roles = load_roles(root, report)
|
||||
if args.scope in ("spine", "full"):
|
||||
check_spine(root, roles, report, args.log_threshold)
|
||||
if args.scope in ("context", "full"):
|
||||
check_context(root, roles, report)
|
||||
if args.scope in ("adr", "full"):
|
||||
report.section("adr")
|
||||
check_adr(root, roles, report)
|
||||
if args.scope in ("artifacts", "full"):
|
||||
check_artifacts(root, roles, report)
|
||||
|
||||
print()
|
||||
if report.failures:
|
||||
print(
|
||||
f"✗ Deterministic audit failed with {report.failures} issue(s). Fix integrity errors before semantic review."
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
"✓ Deterministic audit passed. Review warnings manually, then continue to semantic audit."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Small Markdown link parser used by the documentation-governance audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import unquote
|
||||
|
||||
INLINE_LINK_START_RE = re.compile(r"\[[^\]\n]*\]\(")
|
||||
REFERENCE_DEFINITION_RE = re.compile(
|
||||
r"""(?mx)
|
||||
^\ {0,3}\[(?P<label>[^\]\n]+)\]:[\t\ ]*
|
||||
(?:\r?\n[\t\ ]*)?
|
||||
(?P<target><[^>\n]+>|[^\t\ \n]+)
|
||||
(?:[\t\ ]+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\([^()]*\)))?
|
||||
[\t\ ]*$
|
||||
"""
|
||||
)
|
||||
REFERENCE_USAGE_RE = re.compile(
|
||||
r"(?<![!\\])\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
)
|
||||
EXTERNAL_URI_RE = re.compile(
|
||||
r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def without_fenced_code(text: str) -> str:
|
||||
output: list[str] = []
|
||||
fence_character: str | None = None
|
||||
fence_length = 0
|
||||
for line in text.splitlines(keepends=True):
|
||||
marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line)
|
||||
if fence_character is None and marker:
|
||||
fence_character = marker.group(1)[0]
|
||||
fence_length = len(marker.group(1))
|
||||
output.append("\n" if line.endswith("\n") else "")
|
||||
continue
|
||||
if fence_character is not None:
|
||||
closing = re.match(
|
||||
rf"^ {{0,3}}{re.escape(fence_character)}{{{fence_length},}}[ \t]*(?:\r?\n)?$",
|
||||
line,
|
||||
)
|
||||
if closing:
|
||||
fence_character = None
|
||||
fence_length = 0
|
||||
output.append("\n" if line.endswith("\n") else "")
|
||||
continue
|
||||
output.append(line)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def without_inline_code(text: str) -> str:
|
||||
output: list[str] = []
|
||||
cursor = 0
|
||||
while cursor < len(text):
|
||||
if text[cursor] != "`":
|
||||
output.append(text[cursor])
|
||||
cursor += 1
|
||||
continue
|
||||
end_of_marker = cursor
|
||||
while end_of_marker < len(text) and text[end_of_marker] == "`":
|
||||
end_of_marker += 1
|
||||
marker = text[cursor:end_of_marker]
|
||||
closing = text.find(marker, end_of_marker)
|
||||
if closing == -1:
|
||||
output.append(marker)
|
||||
cursor = end_of_marker
|
||||
continue
|
||||
output.extend(
|
||||
"\n" if character == "\n" else " "
|
||||
for character in text[cursor : closing + len(marker)]
|
||||
)
|
||||
cursor = closing + len(marker)
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def inline_link_targets(text: str) -> list[str]:
|
||||
targets: list[str] = []
|
||||
matches = list(INLINE_LINK_START_RE.finditer(text))
|
||||
index = 0
|
||||
cursor = 0
|
||||
while index < len(matches):
|
||||
match = matches[index]
|
||||
if match.start() < cursor:
|
||||
index += 1
|
||||
continue
|
||||
start = match.end()
|
||||
if start < len(text) and text[start] == "<":
|
||||
end = text.find(">", start + 1)
|
||||
if end != -1:
|
||||
targets.append(text[start : end + 1])
|
||||
cursor = end + 1
|
||||
index += 1
|
||||
continue
|
||||
index += 1
|
||||
if index < len(matches):
|
||||
continue
|
||||
break
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
closed = False
|
||||
saw_closing = False
|
||||
for end in range(start, len(text)):
|
||||
character = text[end]
|
||||
if quote is not None:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == quote:
|
||||
quote = None
|
||||
continue
|
||||
if character in {"'", '"'}:
|
||||
quote = character
|
||||
continue
|
||||
if character == "(":
|
||||
depth += 1
|
||||
elif character == ")":
|
||||
saw_closing = True
|
||||
if depth == 0:
|
||||
targets.append(text[start:end])
|
||||
cursor = end + 1
|
||||
closed = True
|
||||
break
|
||||
depth -= 1
|
||||
if closed:
|
||||
index += 1
|
||||
continue
|
||||
index += 1
|
||||
if not saw_closing or index >= len(matches):
|
||||
break
|
||||
return targets
|
||||
|
||||
|
||||
def normalize_reference_label(label: str) -> str:
|
||||
unescaped = re.sub(r"\\([\\\[\]])", r"\1", label)
|
||||
return re.sub(r"\s+", " ", unescaped.strip()).casefold()
|
||||
|
||||
|
||||
def reference_link_targets(text: str) -> list[str]:
|
||||
definition_matches = list(REFERENCE_DEFINITION_RE.finditer(text))
|
||||
definitions = {
|
||||
normalize_reference_label(match.group("label")): match.group("target")
|
||||
for match in definition_matches
|
||||
}
|
||||
targets: list[str] = []
|
||||
for match in REFERENCE_USAGE_RE.finditer(text):
|
||||
if any(
|
||||
definition.start() <= match.start() < definition.end()
|
||||
for definition in definition_matches
|
||||
):
|
||||
continue
|
||||
following = text[match.end() : match.end() + 1]
|
||||
if following == "(":
|
||||
continue
|
||||
label = match.group("label")
|
||||
key = normalize_reference_label(label or match.group("text"))
|
||||
if key in definitions:
|
||||
targets.append(definitions[key])
|
||||
return targets
|
||||
|
||||
|
||||
def markdown_link_targets(text: str) -> list[str]:
|
||||
text = without_inline_code(without_fenced_code(text))
|
||||
return inline_link_targets(text) + reference_link_targets(text)
|
||||
|
||||
|
||||
def normalize_link_target(raw: str) -> str | None:
|
||||
target = raw.strip()
|
||||
is_unc_path = target.startswith("\\\\")
|
||||
if target.startswith("<"):
|
||||
closing = target.find(">")
|
||||
if closing == -1:
|
||||
return None
|
||||
target = target[1:closing]
|
||||
else:
|
||||
title = re.search(
|
||||
r'\s+(?:"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|\([^()]*\))\s*$',
|
||||
target,
|
||||
)
|
||||
if title:
|
||||
target = target[: title.start()]
|
||||
target = unquote(target.split("#", 1)[0].split("?", 1)[0]).replace("\\", "/")
|
||||
if not target or (target.startswith("//") and not is_unc_path):
|
||||
return None
|
||||
if EXTERNAL_URI_RE.match(target):
|
||||
return None
|
||||
return target
|
||||
Reference in New Issue
Block a user