defmodule OpenAgents.DocsCheck do
@moduledoc false
@root File.cwd!()
@markdown_files ["README.md", "INVARIANTS.md"] ++ Path.wildcard("docs/**/*.md")
# These records must preserve the terms they measure or prohibit. Current
# product narratives and closed migration records remain subject to the scan.
@lexical_exceptions MapSet.new([
"docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md",
"docs/2026-08-20-test-coverage-audit.md",
"docs/episode-triage.md",
"docs/decisions/0005-use-basecoat-and-one-component-system.md"
])
@banned_terms [
{~r/\bDaisyUI\b/i, "retired component library"},
{~r/\b(?:pro|api)\.openagents\.com\b/i, "retired private-service domain"},
{~r/OpenAgentsWeb\.SarahUI/, "retired generic component module"},
{~r/OpenAgents\.Sarah\.Supervisor/, "retired generic supervisor"},
{~r/style-sarah\.css|style-openagents\.css/, "retired style-pack path"},
{~r/priv\/openagents\//, "nonexistent artifact root"}
]
@developer_paths [
{~r{~/(?:work|code)(?:/|\b)}, "home-relative developer path"},
{~r{/Users/[A-Za-z0-9._-]+/(?:work|code)(?:/|\b)}, "macOS developer path"},
{~r{/home/[A-Za-z0-9._-]+/(?:work|code)(?:/|\b)}, "Linux developer path"}
]
# The glossary claims code exists. A term whose module, context, or file has
# been renamed or removed stops being a definition and becomes a wrong answer,
# so the same resolution the invariants get applies to `docs/taxonomy.md`.
@glossary_file "docs/taxonomy.md"
@evidence_path_prefixes ["assets/", "config/", "docs/", "lib/", "ops/", "priv/", "test/"]
@theme_contract_files ["AGENTS.md", "INVARIANTS.md", "docs/component-library.md"]
@retired_theme_claims [
{~r/\bdark-only\b/i, "retired dark-only theme claim"},
{~r/\bsingle dark theme\b/i, "retired single-theme claim"},
{~r/\bno theme selector enters the bundle\b/i, "retired selector-free theme claim"}
]
def run do
errors =
[]
|> check_markdown_links()
|> check_current_language()
|> check_theme_contract()
|> check_invariants()
|> check_glossary()
case Enum.reverse(errors) do
[] ->
IO.puts("Documentation check passed (#{length(@markdown_files)} Markdown files).")
failures ->
Enum.each(failures, &IO.puts(:stderr, "documentation check: #{&1}"))
System.halt(1)
end
end
defp check_markdown_links(errors) do
Enum.reduce(@markdown_files, errors, fn file, acc ->
content = File.read!(file)
Regex.scan(~r/\[[^\]]*\]\(([^)]+)\)/, content, capture: :all_but_first)
|> Enum.reduce(acc, fn [raw_target], link_errors ->
target =
raw_target
|> String.trim()
|> String.trim_leading("<")
|> String.trim_trailing(">")
|> String.split(~r/\s+"/, parts: 2)
|> hd()
|> String.split("#", parts: 2)
|> hd()
|> String.split("?", parts: 2)
|> hd()
if external_or_route?(target) do
link_errors
else
resolved = Path.expand(target, Path.dirname(Path.join(@root, file)))
if File.exists?(resolved) do
link_errors
else
["#{file} links to missing local target #{inspect(raw_target)}" | link_errors]
end
end
end)
end)
end
defp external_or_route?(target) do
target == "" or String.starts_with?(target, ["#", "/", "http://", "https://", "mailto:"])
end
defp check_current_language(errors) do
@markdown_files
|> Enum.reject(&MapSet.member?(@lexical_exceptions, &1))
|> Enum.reduce(errors, fn file, acc ->
content = File.read!(file)
acc
|> scan_terms(file, content, @banned_terms)
|> scan_terms(file, content, @developer_paths)
end)
end
defp scan_terms(errors, file, content, patterns) do
Enum.reduce(patterns, errors, fn {pattern, label}, acc ->
case Regex.run(pattern, content, return: :index) do
nil ->
acc
[{offset, _length} | _captures] ->
["#{file}:#{line_at(content, offset)} contains #{label}" | acc]
end
end)
end
defp check_theme_contract(errors) do
Enum.reduce(@theme_contract_files, errors, fn file, acc ->
scan_terms(acc, file, File.read!(file), @retired_theme_claims)
end)
end
defp check_invariants(errors) do
content = File.read!("INVARIANTS.md")
sections = invariant_sections(content)
ids = Enum.map(sections, & &1.id)
errors
|> check_duplicate_ids(ids)
|> check_statuses(sections)
|> check_proof_index(content, sections)
|> check_referenced_paths("INVARIANTS.md", content)
|> check_module_references("INVARIANTS.md", content)
end
defp check_glossary(errors) do
content = File.read!(@glossary_file)
errors
|> check_referenced_paths(@glossary_file, content)
|> check_module_references(@glossary_file, content)
end
defp invariant_sections(content) do
Regex.split(~r/(?=^### [A-Z][A-Z0-9-]+-\d{3} — )/m, content)
|> Enum.flat_map(fn section ->
case Regex.run(~r/^### ([A-Z][A-Z0-9-]+-\d{3}) — /, section, capture: :all_but_first) do
[id] -> [%{id: id, body: section}]
nil -> []
end
end)
end
defp check_duplicate_ids(errors, ids) do
ids
|> Enum.frequencies()
|> Enum.reduce(errors, fn
{id, count}, acc when count > 1 -> ["INVARIANTS.md contains duplicate ID #{id}" | acc]
{_id, _count}, acc -> acc
end)
end
defp check_statuses(errors, sections) do
Enum.reduce(sections, errors, fn section, acc ->
statuses =
Regex.scan(~r/^Status: (Current|Proposed)$/m, section.body, capture: :all_but_first)
case statuses do
[[_status]] -> acc
_other -> ["#{section.id} must declare exactly one Current or Proposed status" | acc]
end
end)
end
defp check_proof_index(errors, content, sections) do
rows =
Regex.scan(
~r/^\| ([A-Z][A-Z0-9-]+-\d{3}) \| (.+) \|$/m,
content,
capture: :all_but_first
)
proof_rows = Map.new(rows, fn [id, proof] -> {id, proof} end)
section_ids = MapSet.new(sections, & &1.id)
proof_ids = MapSet.new(Map.keys(proof_rows))
errors =
Enum.reduce(MapSet.difference(section_ids, proof_ids), errors, fn id, acc ->
["proof index is missing #{id}" | acc]
end)
errors =
Enum.reduce(MapSet.difference(proof_ids, section_ids), errors, fn id, acc ->
["proof index names unknown invariant #{id}" | acc]
end)
Enum.reduce(sections, errors, fn section, acc ->
current? = Regex.match?(~r/^Status: Current$/m, section.body)
proof = Map.get(proof_rows, section.id, "")
if current? and not Regex.match?(~r/`(?:test|assets\/test|ops)\/[^`]+`/, proof) do
["current invariant #{section.id} has no executable proof file" | acc]
else
acc
end
end)
end
# Fenced blocks must go before code spans are read. A fence is three
# backticks, so an odd number of blocks flips the pairing for every span
# after it and the scan silently returns nothing to check.
defp inline_code_tokens(content) do
content
|> String.replace(~r/^```.*?^```/ms, "")
|> then(&Regex.scan(~r/`([^`\n]+)`/, &1, capture: :all_but_first))
|> List.flatten()
end
defp check_referenced_paths(errors, file, content) do
content
|> inline_code_tokens()
|> Enum.filter(&local_evidence_path?/1)
|> Enum.uniq()
|> Enum.reduce(errors, fn path, acc ->
normalized = String.trim_trailing(path, "/")
if File.exists?(normalized) do
acc
else
["#{file} names missing evidence path #{path}" | acc]
end
end)
end
defp local_evidence_path?(path) do
String.starts_with?(path, @evidence_path_prefixes) or
path in [".dockerignore", ".gitignore", "AGENTS.md", "INVARIANTS.md"]
end
defp check_module_references(errors, file, content) do
module_files = Path.wildcard("{lib,test}/**/*.{ex,exs}")
declared =
module_files
|> Enum.flat_map(fn file ->
Regex.scan(
~r/defmodule\s+(OpenAgents(?:Web)?(?:\.[A-Z][A-Za-z0-9_]*)*)\s+do/,
File.read!(file), capture: :all_but_first)
|> List.flatten()
end)
|> MapSet.new()
content
|> inline_code_tokens()
|> Enum.reject(&String.contains?(&1, "*"))
|> Enum.flat_map(fn token ->
case Regex.run(
~r/^(OpenAgents(?:Web)?(?:\.[A-Z][A-Za-z0-9_]*)+)/,
token,
capture: :all_but_first
) do
[module] -> [module]
nil -> []
end
end)
|> Enum.uniq()
|> Enum.reduce(errors, fn module, acc ->
if MapSet.member?(declared, module) do
acc
else
["#{file} names missing module #{module}" | acc]
end
end)
end
defp line_at(content, offset) do
content
|> binary_part(0, offset)
|> String.split("\n")
|> length()
end
end
OpenAgents.DocsCheck.run()