PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
audit_path_literals.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Reject unmanaged run-path literals so a layout change cannot leave prose stale."""
3
4from __future__ import annotations
5
6import json
7import re
8import subprocess
9import sys
10from pathlib import Path
11
12sys.path.insert(0, str(Path(__file__).resolve().parent))
13from repo_files import enumerate_repository_files # noqa: E402
14
15
16REPO_ROOT = Path(__file__).resolve().parents[2]
17TOPOLOGY = REPO_ROOT / "tests" / "tooling" / "artifact_topology.json"
18SKIP_DIRS = {".git", "docs_build", "obj", "bin", "stubs", "runs", "studies", "__pycache__", ".pytest_cache"}
19
20# Historical records describe layouts as they were; rewriting them would falsify history.
21HISTORICAL = {"docs/CHANGELOG.md"}
22
23# Directories a run owns. Their names are now fixed rather than configurable, but a
24# bare `logs/...` in prose is still wrong for a different reason: three different
25# owners ship a directory by that kind of name - the repository's own build logs and
26# `config/` library, a workspace's editable `config/` and `inputs/`, and a run's
27# generated tree. A bare prefix does not say which one is meant, and a reader
28# following it lands in the wrong place. Logical identities name the owner as well as
29# the path, and artifact_topology.json is where the mapping is enumerated, so a layout
30# change is a contract change rather than a prose hunt.
31RUN_OWNED = "config|logs|output|scheduler|visualization|checkpoints"
32
33# Concrete run-relative directories that a layout change would invalidate.
34MANAGED = re.compile(
35 r"(?<![\w./<])runs/(?:<[^>]+>|\$\{[^}]+\}|[A-Za-z0-9_.*-]+)/(" + RUN_OWNED + r")\b")
36
37# A run-owned directory named against an unresolved run root - `<run_dir>/logs/...`.
38# The `<run_dir>` placeholder says "somewhere in the run" but still hardcodes which
39# subdirectory, so it drifts exactly like the concrete form.
40PLACEHOLDER_ROOT = re.compile(
41 r"<(?:run_dir|run|RUN_DIR)>/(" + RUN_OWNED + r")\b")
42
43# A bare run-owned prefix at the start of a path - `logs/Runtime_Memory.log`. This is
44# the form the audit used to miss entirely, and the one pages reach for most often.
45# `<repo>/logs/...` is the escape hatch for the repository's own build and test logs,
46# which are a different directory that no run configuration moves.
47# The trailing part is optional: `logs/` on its own is exactly as wrong as
48# `logs/Runtime_Memory.log`, and it is the form pages reach for when naming a
49# directory rather than a file. Requiring a character after the slash let every
50# terminal directory reference through.
51BARE_PREFIX = re.compile(
52 r"(?<![\w./<>-])(" + RUN_OWNED + r")/(?![A-Za-z0-9_./*-]*[<>])[A-Za-z0-9_./*-]*")
53
54# A repository-owned path, written with an explicit `<repo>/` prefix. That prefix is
55# what distinguishes the repository's build log from a run's runtime logs, and its
56# shipped `config/` library from a run's config directory - all of which otherwise
57# read identically in prose while naming directories with different owners.
58REPO_OWNED = re.compile(r"<repo>/[A-Za-z0-9_][A-Za-z0-9_./*<>-]*")
59
60# A workspace-owned path, written with an explicit `<workspace>/` prefix. An
61# initialized workspace owns `config/`, `inputs/`, and `assets/` directories whose
62# names match a run's, so the prefix is what tells a reader which of the two a
63# sentence means.
64WORKSPACE_OWNED = re.compile(r"<workspace>/[A-Za-z0-9_][A-Za-z0-9_./*<>-]*")
65
66# Bare prefixes that are not run-owned paths at all: repository directories that
67# happen to share a name. `config/` is a real top-level source directory.
68# The repository ships a top-level `config/` library of reusable profiles. Its
69# subdirectories are source, not run output, and are named from their real paths.
70REPO_CONFIG_SUBDIRS = ("guide.md", "build", "grids", "initial_conditions", "monitors",
71 "postprocessors", "profiles", "runtime", "schedulers", "solvers",
72 "studies")
73NOT_RUN_OWNED_PREFIXES = re.compile(
74 r"(?<![\w./<>-])config/(?:" + "|".join(
75 name.replace(".", r"\.") for name in REPO_CONFIG_SUBDIRS) + r")\b")
76
77# Occurrences that legitimately show a concrete path.
78ALLOWED_CONTEXTS = (
79 "```", # runnable command examples
80 "@verbinclude", # embedded executable templates
81 "@code",
82)
83
84
85def tracked_markdown() -> list:
86 """!
87 @brief Markdown files the current commit carries.
88 @return Sorted Markdown paths.
89 """
90 found = enumerate_repository_files(REPO_ROOT, ".md", frozenset(SKIP_DIRS))
91 if found is None:
92 return sorted(path for path in REPO_ROOT.rglob("*.md") if path.is_file())
93 return found
94
95
96def in_code_block(lines: list, index: int) -> bool:
97 """!
98 @brief Whether a line sits inside a fenced code block.
99 @param[in] lines All lines of the file.
100 @param[in] index Zero-based line index.
101 @return True when the line is inside a fence.
102 """
103 fences = sum(1 for line in lines[:index] if line.lstrip().startswith("```"))
104 return fences % 2 == 1
105
106
107def main() -> int:
108 """!
109 @brief Fail when narrative prose hardcodes a run-relative path.
110 @return Process status code.
111 """
112 contract = json.loads(TOPOLOGY.read_text(encoding="utf-8"))
113 identities = [a["id"] for a in contract["artifacts"]]
114 violations: list = []
115 scanned = 0
116 for path in tracked_markdown():
117 if str(path.relative_to(REPO_ROOT)) in HISTORICAL:
118 continue
119 text = path.read_text(encoding="utf-8", errors="replace")
120 lines = text.splitlines()
121 scanned += 1
122 for number, line in enumerate(lines):
123 if in_code_block(lines, number) or any(token in line for token in ALLOWED_CONTEXTS):
124 continue
125 probe = NOT_RUN_OWNED_PREFIXES.sub("", line)
126 # `<repo>/logs/...` is the repository's own build and test log directory,
127 # which no run configuration moves. Remove the whole reference before
128 # looking for bare prefixes, so the distinct notation actually buys the
129 # author something rather than leaving a bare `logs/` behind.
130 probe = REPO_OWNED.sub("", probe)
131 probe = WORKSPACE_OWNED.sub("", probe)
132 if MANAGED.search(line):
133 found, advice = "unmanaged run-path literal", (
134 "Use logical notation such as `<run.config>`, or move the concrete "
135 "path into a runnable command block.")
136 elif PLACEHOLDER_ROOT.search(line):
137 found, advice = "run-owned directory named under an unresolved run root", (
138 "`<run_dir>/logs/...` names a subdirectory without naming the "
139 "contract that fixes it. Use the logical identity - "
140 "`<run.runtime_logs>/...` - which artifact_topology.json maps.")
141 elif BARE_PREFIX.search(probe):
142 match = BARE_PREFIX.search(probe)
143 found, advice = f"bare run-owned prefix `{match.group(0)}`", (
144 "A bare `logs/...` or `config/...` does not say which owner is "
145 "meant. Use the run's logical identity - `<run.runtime_logs>/...`, "
146 "`<run.solver_output>/...` - or name the other owner explicitly "
147 "with `<repo>/logs/...` or `<workspace>/config/...`.")
148 else:
149 continue
150 violations.append(
151 f"{path.relative_to(REPO_ROOT)}:{number + 1}: {found}\n"
152 f" {line.strip()[:96]}\n"
153 f" {advice}"
154 )
155 if violations:
156 print("Unmanaged run-path literals in narrative prose:", file=sys.stderr)
157 for violation in violations:
158 print(f" {violation}", file=sys.stderr)
159 print(
160 f"\nLogical identities are declared in tests/tooling/artifact_topology.json "
161 f"({len(identities)} identities). Narrative pages should refer to those, so a layout "
162 f"change is a contract change rather than a prose hunt.",
163 file=sys.stderr,
164 )
165 return 1
166 print(f"Path-literal audit passed: {scanned} Markdown files, no unmanaged run-path literals in prose.")
167 return 0
168
169
170if __name__ == "__main__":
171 raise SystemExit(main())
int main()
Fail when narrative prose hardcodes a run-relative path.
list tracked_markdown()
Markdown files the current commit carries.
bool in_code_block(list lines, int index)
Whether a line sits inside a fenced code block.