Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions autoform_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1066,22 +1066,31 @@ example](../skills/setup/assets/cabannes-thesis-project/mkdocs.yml).
## Validation

`autoform check` rejects cycles between authored articles, missing targets,
escaping paths, symbolic links anywhere under `roadmap/`, self-dependencies,
escaping paths, symbolic links under `roadmap/`, self-dependencies,
missing or multiple H1 titles, unsupported frontmatter keys, assertion values
it does not recognize, and assertions the Lean cannot back: `proof: formalized`
without `statement: formalized`, or either without a `lean:` name, even beside
`mathlib: true`. A `## Sources` link to a local path must name a file
`mathlib: true`. Hidden files and folders under `roadmap/`, those with a part
starting with `.`, are no part of the roadmap, since render omits them: they
hold no articles, need no chapter page, and may be symbolic links, such as an
editor's lock file. A dependency on a hidden page fails, naming the hidden
path, as does one that steps through a hidden folder and back out, and so does
an `.implementation-notes` folder under `roadmap/`, which nothing reads there.
A `## Sources` link to a local path must name a file
inside the blueprint, as `audit` also requires: a missing file, a directory, an
invalid path or URL, or a target outside the blueprint fails. The path must open
as written, so one through a missing folder or a symbolic link loop fails too,
even where its `..` steps would cancel that part as text (Windows itself applies
`..` as text). So does a target
whose path as written, once its `..` steps are applied, has a part below the
blueprint starting with `.`, unless the article citing it is hidden too,
because render omits hidden files and directories. Under `sources/`, such a
blueprint starting with `.`, because render omits hidden files and
directories. Under `sources/`, such a
target passes `check` with a warning instead, and `audit` still reports it:
only a repository link reaches the note, and render writes one when it links
the other source notes there, so the note must be committed. `audit` alone checks
the other source notes there, so the note must be committed. A `## Sources`
link to a view generated at the blueprint root, such as `dependencies.md` or
`structure.md`, fails as well, because render omits the vault's copy; `audit`
and render reject coverage evidence that names one. `audit` alone checks
fragments and fails links with other schemes or a network location. Such a link
passes `check` with a warning, because work and search leave it out; so does a
`mathlib_file` that is not a portable relative path, which search reports as
Expand Down
1 change: 1 addition & 0 deletions autoform_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ def _source_link_issues(graph: Graph) -> tuple[list[str], list[str]]:
"malformed-source-url": "is not a valid URL",
"malformed-source-link": "contains an invalid path",
"hidden-source-target": "names a hidden path, which render omits",
"generated-source-target": "names a generated view, which render omits",
}
issues, warnings = [], []
for node in graph.nodes.values():
Expand Down
11 changes: 8 additions & 3 deletions autoform_cli/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from .markdown import frontmatter_end as _frontmatter_end
from .markdown import has_substance as _has_substance
from .markdown import HEADING as _HEADING
from .markdown import _is_hidden_path
from .markdown import is_placeholder as _is_placeholder
from .markdown import local_target_issue as _local_target_issue
from .markdown import markdown_links as _markdown_links
Expand Down Expand Up @@ -406,7 +407,10 @@ def _coverage_findings(
)
)

coverage_files = sorted(path for path in coverage_root.rglob("*.md") if path.is_file())
# Render omits hidden files, so their links are never published.
coverage_files = sorted(
path for path in coverage_root.rglob("*.md") if path.is_file() and not _is_hidden_path(path, blueprint)
)
for path in coverage_files:
article_path = _relative_path(path, blueprint)
try:
Expand Down Expand Up @@ -692,8 +696,9 @@ def _stable_validation_reason(blueprint: Path, issue: str) -> str:


def _validation_article_path(blueprint: Path, issue: str) -> str:
issue_root = issue.split("/", 1)[0].split(":", 1)[0]
if issue_root.casefold() == IMPLEMENTATION_NOTES_DIR.casefold():
# The notes directory, or one misplaced under roadmap/, as the issue names it.
subject = issue.split(":", 1)[0].split("/")
if IMPLEMENTATION_NOTES_DIR.casefold() in {part.casefold() for part in subject}:
for marker in _IMPLEMENTATION_NOTE_ISSUE_MARKERS:
boundary = issue.rfind(marker)
if boundary >= 0:
Expand Down
46 changes: 36 additions & 10 deletions autoform_cli/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@
from urllib.parse import unquote, urlsplit

from .lean import declaration_names
from .markdown import directory_path, link_definitions, link_targets, mask_code_and_comments, resolved_path
from .markdown import (
_is_hidden_path,
directory_path,
link_definitions,
link_targets,
mask_code_and_comments,
resolved_path,
)


_HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$")
Expand Down Expand Up @@ -283,10 +290,20 @@ def _discover_nodes(blueprint: Path) -> tuple[list[_NodeSource], list[str]]:
def unlistable(exc: OSError) -> None:
issues.append(f"cannot list roadmap directory {exc.filename}: {exc.strerror}")

# Unlike a glob, a walk reports each directory it cannot list.
entries = sorted(
Path(directory, name) for directory, _, files in os.walk(roadmap_root, onerror=unlistable) for name in files
)
# Unlike a glob, a walk reports each directory it cannot list. Render omits
# hidden entries, so they are no part of the roadmap here either.
entries: list[Path] = []
for directory, directories, files in os.walk(roadmap_root, onerror=unlistable):
for name in directories:
if name.casefold() == IMPLEMENTATION_NOTES_DIR.casefold():
misplaced = (Path(directory) / name).relative_to(roadmap_root.parent).as_posix()
issues.append(
f"{misplaced}: implementation notes directory belongs at the blueprint root, "
f"as {IMPLEMENTATION_NOTES_DIR}; nothing reads it here"
)
directories[:] = [name for name in directories if not name.startswith(".")]
entries.extend(Path(directory, name) for name in files if not name.startswith("."))
entries.sort()
issues.sort() # a walk visits directories in arbitrary order
for path in entries:
if path.suffix != ".md" and path.name.casefold() != "readme.md":
Expand Down Expand Up @@ -323,7 +340,7 @@ def unlistable(exc: OSError) -> None:


def roadmap_symlink_issues(blueprint: Path) -> list[str]:
"""Report the symbolic links among ``roadmap/`` and every path beneath it.
"""Report the symbolic links among ``roadmap/`` and the paths beneath it that are not hidden.

Graph loading and runtime projection share this policy, so ``autoform check``
accepts exactly the roadmaps the runtime does.
Expand All @@ -332,7 +349,10 @@ def roadmap_symlink_issues(blueprint: Path) -> list[str]:
roadmap = blueprint / "roadmap"
# os.path.islink is False for a path it cannot stat, which discovery then reports; Path.is_symlink raises
# before 3.14.
paths = [roadmap] if roadmap.is_symlink() else [path for path in sorted(roadmap.rglob("*")) if os.path.islink(path)]
# Hidden entries, such as an editor's lock symlink, are no part of the roadmap.
paths = [roadmap] if roadmap.is_symlink() else [
path for path in sorted(roadmap.rglob("*")) if os.path.islink(path) and not _is_hidden_path(path, roadmap)
]
return [f"roadmap contains a symbolic link: {path.relative_to(blueprint).as_posix()}" for path in paths]


Expand Down Expand Up @@ -364,12 +384,14 @@ def _chapter_issues(roadmap_root: Path) -> list[str]:
"""

try:
chapters = sorted(path for path in roadmap_root.iterdir() if path.is_dir())
chapters = sorted(path for path in roadmap_root.iterdir() if path.is_dir() and not path.name.startswith("."))
except OSError:
return []
issues = []
for chapter in chapters:
articles = [path for path in chapter.rglob("*.md") if os.path.isfile(path)]
articles = [
path for path in chapter.rglob("*.md") if os.path.isfile(path) and not _is_hidden_path(path, chapter)
]
if not articles:
continue
if os.path.isfile(chapter / "README.md"):
Expand Down Expand Up @@ -684,8 +706,12 @@ def _resolve_target(
return None, f"{node.id}: dependency target must be a relative .md file: {target!r}"

# A path that leaves the blueprint as spelled escapes it; nothing outside is looked at.
if not _is_within(Path(os.path.normpath(node.path.parent / relative)), blueprint):
spelled = Path(os.path.normpath(node.path.parent / relative))
if not _is_within(spelled, blueprint):
return None, f"{node.id}: dependency target escapes the blueprint directory: {target!r}"
# Render's copy has no hidden folder, so it cannot step through one and back out either.
if any(part.startswith(".") and part != ".." for part in relative.parts):
return None, f"{node.id}: dependency target names a hidden path, which render omits: {target!r}"
resolved = resolved_path(node.path.parent / relative)
if resolved is None:
# The kernel reaches a file behind a link only `readlink` refuses.
Expand Down
21 changes: 19 additions & 2 deletions autoform_cli/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,20 @@ def _anchors_from_body(body: str) -> frozenset[str]:
return frozenset(html.unescape(found) for found in _HEADING_ID.findall(rendered))


#: Views generated at the blueprint root. Render omits the vault's copies: it
#: writes the current views itself and drops stale copies of the retired ones.
GENERATED_VIEWS = frozenset(
{
"dependencies.md",
"dependencies.html",
"graph.html",
"progress.md",
# The vault keeps its own Obsidian-readable copy; the site builds one.
"structure.md",
}
)


def resolved_path(path: Path) -> Path | None:
"""``path`` with its symbolic links resolved, or None when the system cannot open it.

Expand Down Expand Up @@ -775,7 +789,7 @@ def local_target_issue(
*,
label: str,
) -> tuple[str, str] | None:
"""Return ``(code, reason)`` when a local link does not resolve, or names a hidden path.
"""Return ``(code, reason)`` when a local link does not resolve, or names a path render omits.

``source_path`` is the file containing the link, ``boundary`` the directory
the link may not escape. External schemes are the reader's problem and are
Expand Down Expand Up @@ -803,6 +817,8 @@ def local_target_issue(
spelled = Path(os.path.normpath(source_path.parent.resolve() / raw_path))
if Path(raw_path).is_absolute() or not _is_within(spelled, boundary):
return f"{label}-escapes-blueprint", f"{label} link escapes the blueprint: {target!r}"
if spelled.parent == boundary and spelled.name in GENERATED_VIEWS:
return f"generated-{label}-target", f"{label} link names a generated view, which render omits: {target!r}"
candidate = resolved_path(source_path.parent / raw_path if raw_path else source_path)
if candidate is None:
return f"{label}-not-found", f"{label} link does not resolve to a file: {target!r}"
Expand All @@ -814,7 +830,7 @@ def local_target_issue(
# repository link reaches a hidden note under `sources/`, and render writes one
# when it links the other source notes there; that link resolves only once the
# note is committed, so it is reported too, and `check` only warns about it.
if _is_hidden_path(spelled, boundary) and not _is_hidden_path(source_path.resolve(), boundary):
if _is_hidden_path(spelled, boundary):
return f"hidden-{label}-target", f"{label} link names a hidden path, which render omits: {target!r}"
# Before Python 3.14, `Path.is_file` raises under an unreadable directory.
if not os.path.isfile(candidate):
Expand Down Expand Up @@ -986,6 +1002,7 @@ def _is_hidden_path(path: Path, boundary: Path) -> bool:
"EXTERNAL_SCHEMES",
"FENCE",
"FENCE_CLOSE",
"GENERATED_VIEWS",
"HEADING",
"INLINE_CODE",
"SITE_EXTENSIONS",
Expand Down
14 changes: 2 additions & 12 deletions autoform_cli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
snapshot_project_sources,
verify_repository_snapshot,
)
from .markdown import article_parts, blueprint_root, directory_path, site_converter
from .markdown import GENERATED_VIEWS, article_parts, blueprint_root, directory_path, site_converter
from .markdown import mask_fences_and_comments
from .status import is_definition

Expand Down Expand Up @@ -104,17 +104,7 @@
#: How long a render waits for another render committing into the same parent.
_PUBLICATION_LOCK_WAIT_SECONDS = 30.0
#: Derived views this command rewrites; stale copies must not leak into the site.
_GENERATED_FILES = frozenset(
{
"dependencies.md",
"dependencies.html",
"graph.html",
"progress.md",
# The vault keeps its own Obsidian-readable copy; the site builds one.
"structure.md",
PUBLICATION_MANIFEST,
}
)
_GENERATED_FILES = GENERATED_VIEWS | {PUBLICATION_MANIFEST}
_LOCAL_ONLY_NAMES = frozenset(
{
".autoform",
Expand Down
19 changes: 15 additions & 4 deletions tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,18 +400,29 @@ def test_audit_reports_invalid_note_at_its_blueprint_path(
]


def test_audit_reports_a_misplaced_notes_directory_where_it_is(tmp_path: Path) -> None:
def test_audit_skips_a_hidden_coverage_file_as_render_does(tmp_path: Path) -> None:
blueprint = tmp_path / "blueprint"
_coverage(blueprint)
_article(blueprint, "result.md", declaration="theorem")
(blueprint / "coverage" / ".draft.md").write_text("[Gone](../roadmap/gone.md)\n", encoding="utf-8")

assert "coverage/.draft.md" not in {finding.article_path for finding in audit_blueprint(blueprint).findings}


@pytest.mark.parametrize("folder", ["roadmap", "roadmap/chapter"])
def test_audit_reports_a_misplaced_notes_directory_where_it_is(tmp_path: Path, folder: str) -> None:
blueprint = tmp_path / "blueprint"
_coverage(blueprint)
article_id = "af_0123456789abcdef01234567"
_article(blueprint, "result.md", article_id=article_id, declaration="theorem")
misplaced = blueprint / "roadmap" / ".implementation-notes"
misplaced.mkdir()
misplaced = blueprint / folder / ".implementation-notes"
misplaced.mkdir(parents=True)
(misplaced / f"{article_id}.md").write_text("Mathlib gap.\n", encoding="utf-8")

paths = {finding.article_path for finding in audit_blueprint(blueprint).findings}

assert f"roadmap/.implementation-notes/{article_id}.md" in paths
# Hidden entries are no part of the roadmap, as for render, so the directory itself is named.
assert f"{folder}/.implementation-notes" in paths
assert ".implementation-notes" not in paths


Expand Down
62 changes: 62 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,55 @@ def test_check_warns_about_a_hidden_note_under_sources(tmp_path: Path, capsys: p
]


def test_check_ignores_hidden_roadmap_entries_as_render_does(tmp_path: Path) -> None:
"""Issue #206: render omits hidden entries, so check must count the same articles."""
blueprint = tmp_path / "blueprint"
_node(blueprint, "base.md", "# Base\n")
_node(blueprint, ".helper.md", "# Helper\n")
# A hidden folder needs no chapter page, since nothing in it is published.
_roadmap_page(blueprint, ".drafts/draft.md", _node_text("# Draft\n"))
# Nor does a folder whose only page is hidden.
_roadmap_page(blueprint, "drafts/.wip.md", _node_text("# Work in progress\n"))
try:
# Emacs leaves this lock symlink beside a page with unsaved edits.
(blueprint / "roadmap" / ".#base.md").symlink_to("user@host.1234:1700000000")
except OSError:
pytest.skip("symlinks are unavailable")

assert list(load_graph(blueprint).nodes) == ["base"]


def test_a_dependency_on_a_hidden_page_names_the_hidden_path(tmp_path: Path) -> None:
blueprint = tmp_path / "blueprint"
_node(blueprint, ".draft.md", "# Draft\n")
_node(blueprint, "cited.md", "# Cited\n")
# Render's copy has no .x to step through, though the vault has one.
(blueprint / "roadmap" / ".x").mkdir()
_node(blueprint, "base.md", "# Base\n## Depends on\n[Draft](.draft.md)\n[Cited](.x/../cited.md)\n")

with pytest.raises(GraphValidationError) as caught:
load_graph(blueprint)

assert caught.value.issues == (
"base: dependency target names a hidden path, which render omits: '.draft.md'",
"base: dependency target names a hidden path, which render omits: '.x/../cited.md'",
)


def test_check_rejects_a_sources_link_to_a_generated_view(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
# Render writes its own dependencies.md and omits the vault's copy, so the link cannot be checked against it.
blueprint = tmp_path / "blueprint"
_node(blueprint, "result.md", "# Result\n\n## Sources\n\n- [Graph](../dependencies.md)\n")
(blueprint / "dependencies.md").write_text("# Dependencies\n", encoding="utf-8")

assert main(["check", str(blueprint)]) == 1
assert capsys.readouterr().out == (
"error: result: source target names a generated view, which render omits: '../dependencies.md'\n"
)


def test_check_warns_about_a_mathlib_file_search_reports_as_null(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down Expand Up @@ -1235,6 +1284,19 @@ def test_hidden_entries_beside_implementation_notes_are_ignored(tmp_path: Path)
assert list(load_graph(blueprint).nodes) == ["result"]


def test_an_implementation_note_for_a_hidden_page_names_no_article(tmp_path: Path) -> None:
# Render leaves the hidden page out, and check agrees.
blueprint = tmp_path / "blueprint"
article_id = "af_0123456789abcdef01234567"
_node(blueprint, "base.md", "# Base\n")
_node(blueprint, ".parked.md", "# Parked\n", article_id=article_id)
(blueprint / ".implementation-notes").mkdir()
(blueprint / ".implementation-notes" / f"{article_id}.md").write_text("route\n", encoding="utf-8")

with pytest.raises(GraphValidationError, match="implementation note names no roadmap article"):
load_graph(blueprint)


def test_implementation_notes_directory_must_not_be_a_symlink(tmp_path: Path) -> None:
blueprint = tmp_path / "blueprint"
roadmap = blueprint / "roadmap"
Expand Down
8 changes: 6 additions & 2 deletions tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,14 +444,18 @@ def issue(target: str) -> str | None:
assert issue("../roadmap/node.md") is None
assert issue("../roadmap/node.md#results") is None
assert issue("../.drafts/../roadmap/node.md") is None
# Render omits the vault's copies of the root views, whether or not one exists.
(tmp_path / "structure.md").write_text("# Structure\n", encoding="utf-8")
assert issue("../structure.md") == issue("../dependencies.md") == "generated-coverage-target"
(tmp_path / "sources").mkdir()
(tmp_path / "sources" / "structure.md").write_text("# Structure notes\n", encoding="utf-8")
assert issue("../sources/structure.md") is None
assert issue("../.x/../roadmap/node.md") == "coverage-not-found"
assert issue("https://example.invalid/page") is None
assert issue("../roadmap/node.md#absent") == "coverage-anchor-not-found"
assert issue("../roadmap/absent.md") == "coverage-not-found"
assert issue("../../outside.md") == "coverage-escapes-blueprint"
assert issue("../.drafts/notes.md") == "hidden-coverage-target"
# Render publishes neither a hidden page nor its links.
assert local_target_issue(hidden, "#notes", tmp_path, label="coverage") is None
assert issue("mailto:someone@example.invalid") == "unsupported-coverage-link"
assert issue("//example.invalid/page") == "unsupported-coverage-link"

Expand Down
Loading