PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
Loading...
Searching...
No Matches
audit_ingress.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Static ingress audit for PETSc option parsing in setup/io.
4
5This script scans the source files named by the manifest for PetscOptionsGet*/HasName
6calls, extracts option flags, and compares them against a maintained manifest.
7
8Two kinds of option name reach C. Most are string literals. A variable-arity
9configuration, such as the field-statistics window list, must construct its names
10instead, and a constructed name is invisible to a literal scan. Rather than let that
11become a hole, this audit also collects the format strings those names are built
12from, requires each to be declared as a family, and fails on any constructed name it
13cannot trace back to one. The invariant is that every option name reaching C is
14either a declared literal or a declared family.
15
16Reads that pass a private PetscOptions object rather than NULL are deliberately out
17of scope: they read checkpoint state, not user configuration.
18"""
19
20from __future__ import annotations
21
22import argparse
23import json
24import pathlib
25import re
26import sys
27from typing import Iterable, Set
28
29
30_ACCESSOR = r'PetscOptions(?:Get(?:Int|Real|Bool|String|IntArray|RealArray)|HasName)'
31
32OPTION_RE = re.compile(_ACCESSOR + r'\s*\‍(\s*NULL\s*,\s*NULL\s*,\s*"(-[^"]+)"')
33
34#: A read whose name comes from a variable rather than a literal.
35CONSTRUCTED_RE = re.compile(
36 _ACCESSOR + r'\s*\‍(\s*NULL\s*,\s*NULL\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*,'
37)
38
39#: The start of a name-building call; the format argument is parsed separately
40#: because it may be several literals spliced around a width macro.
41SNPRINTF_RE = re.compile(r'PetscSNPrintf\s*\‍(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,')
42
43LITERAL_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
44
45
46def canonical_family(fragments: Iterable[str]) -> str:
47 """!
48 @brief Join the literal fragments of a constructed name into one stable pattern.
49 @details Each substituted value collapses to a bare `%`, so a name spliced around
50 `PetscInt_FMT` and one written with `%d` produce the same family.
51 @param[in] fragments Literal chunks of the format expression, in source order.
52 @return Value returned by `canonical_family()`.
53 """
54 joined = "".join(fragments)
55 return re.sub(r"%[-+ #0-9.]*[a-zA-Z]?", "%", joined)
56
57
58def extract_families(text: str) -> "dict[str, Set[str]]":
59 """!
60 @brief Map each name variable built by PetscSNPrintf to every family it carries.
61 @details One buffer is normally reused for many names, so a variable maps to a
62 set rather than a single pattern; collapsing it to the last assignment
63 would hide every family but one.
64 @param[in] text Source text of one translation unit.
65 @return Value returned by `extract_families()`.
66 """
67 families: "dict[str, Set[str]]" = {}
68 for match in SNPRINTF_RE.finditer(text):
69 variable = match.group(1)
70 depth = 1
71 index = match.end()
72 while index < len(text) and depth:
73 if text[index] == "(":
74 depth += 1
75 elif text[index] == ")":
76 depth -= 1
77 index += 1
78 fragments = LITERAL_RE.findall(text[match.end():index])
79 if not fragments or not fragments[0].startswith("-"):
80 continue
81 families.setdefault(variable, set()).add(canonical_family(fragments))
82 return families
83
84
85def scan_option_families(paths: Iterable[pathlib.Path]) -> "tuple[Set[str], list[str]]":
86 """!
87 @brief Collect declared option families and any constructed name that lacks one.
88 @param[in] paths Source files to scan.
89 @return Value returned by `scan_option_families()`.
90 """
91 families: Set[str] = set()
92 untraceable: "list[str]" = []
93 for path in paths:
94 text = path.read_text(encoding="utf-8")
95 built = extract_families(text)
96 for match in CONSTRUCTED_RE.finditer(text):
97 variable = match.group(1)
98 if variable in built:
99 families.update(built[variable])
100 else:
101 untraceable.append(f"{path.name}: '{variable}'")
102 return families, untraceable
103
104
105def scan_petsc_options(paths: Iterable[pathlib.Path]) -> Set[str]:
106 """!
107 @brief Perform scan petsc options.
108 @param[in] paths Argument passed to `scan_petsc_options()`.
109 @return Value returned by `scan_petsc_options()`.
110 """
111 flags: Set[str] = set()
112 for path in paths:
113 text = path.read_text(encoding="utf-8")
114 for match in OPTION_RE.finditer(text):
115 flags.add(match.group(1))
116 return flags
117
118
119def load_manifest(path: pathlib.Path) -> dict:
120 """!
121 @brief Read and validate the option names, families, and sources in the manifest.
122 @param[in] path Filesystem path argument passed to `load_manifest()`.
123 @return Value returned by `load_manifest()`.
124 """
125 data = json.loads(path.read_text(encoding="utf-8"))
126 result = {}
127 for key in ("known_petsc_options", "known_petsc_option_families"):
128 entries = data.get(key, [])
129 if not isinstance(entries, list):
130 raise ValueError(f"Manifest key '{key}' must be a list.")
131 bad = [opt for opt in entries if not isinstance(opt, str) or not opt.startswith("-")]
132 if bad:
133 raise ValueError(f"Manifest key '{key}' has invalid entries: {bad}")
134 result[key] = set(entries)
135 sources = data.get("sources")
136 if not isinstance(sources, list) or not all(isinstance(src, str) for src in sources):
137 raise ValueError("Manifest key 'sources' must be a list of paths.")
138 result["sources"] = sources
139 return result
140
141
142def main() -> int:
143 """!
144 @brief Entry point for this script.
145 @return Value returned by `main()`.
146 """
147 parser = argparse.ArgumentParser(
148 description=(
149 "Scan PETSc option ingress in src/setup.c and src/io.c, then compare "
150 "against tests/tooling/audit_ingress_manifest.json."
151 ),
152 formatter_class=argparse.RawDescriptionHelpFormatter,
153 epilog=(
154 "Examples:\n"
155 " python3 tests/tooling/audit_ingress.py\n"
156 " python3 tests/tooling/audit_ingress.py --show-scanned\n"
157 " python3 tests/tooling/audit_ingress.py --manifest tests/tooling/audit_ingress_manifest.json\n"
158 ),
159 )
160 parser.add_argument(
161 "--manifest",
162 default="tests/tooling/audit_ingress_manifest.json",
163 help=(
164 "Manifest JSON path, relative to repository root unless absolute "
165 "(default: tests/tooling/audit_ingress_manifest.json)."
166 ),
167 )
168 parser.add_argument(
169 "--show-scanned",
170 action="store_true",
171 help="Print discovered PETSc options before drift comparison.",
172 )
173 args = parser.parse_args()
174
175 repo_root = pathlib.Path(__file__).resolve().parents[2]
176 manifest_path = (repo_root / args.manifest).resolve()
177
178 if not manifest_path.exists():
179 print(f"[ERROR] Manifest not found: {manifest_path}", file=sys.stderr)
180 return 2
181
182 manifest = load_manifest(manifest_path)
183 #: Scan paths come from the manifest so a new parse site cannot be added
184 #: without also being declared to the audit.
185 scan_paths = [repo_root / src for src in manifest["sources"]]
186 absent = [str(path) for path in scan_paths if not path.exists()]
187 if absent:
188 print(f"[ERROR] Manifest names sources that do not exist: {absent}", file=sys.stderr)
189 return 2
190
191 scanned = scan_petsc_options(scan_paths)
192 expected = manifest["known_petsc_options"]
193 scanned_families, untraceable = scan_option_families(scan_paths)
194 expected_families = manifest["known_petsc_option_families"]
195
196 missing_in_manifest = sorted(scanned - expected)
197 stale_in_manifest = sorted(expected - scanned)
198 missing_families = sorted(scanned_families - expected_families)
199 stale_families = sorted(expected_families - scanned_families)
200
201 if args.show_scanned:
202 print("[INFO] Scanned PETSc options:")
203 for flag in sorted(scanned):
204 print(flag)
205 print("")
206
207 print(f"[INFO] Scanned options: {len(scanned)}")
208 print(f"[INFO] Manifest options: {len(expected)}")
209 print(f"[INFO] Scanned option families: {len(scanned_families)}")
210 print(f"[INFO] Manifest option families: {len(expected_families)}")
211
212 if missing_in_manifest:
213 print("[ERROR] New PETSc ingress options missing in manifest:")
214 for flag in missing_in_manifest:
215 print(f" - {flag}")
216
217 if stale_in_manifest:
218 print("[ERROR] Manifest options no longer present in setup/io scan:")
219 for flag in stale_in_manifest:
220 print(f" - {flag}")
221
222 if missing_families:
223 print("[ERROR] Constructed option families missing in manifest:")
224 for family in missing_families:
225 print(f" - {family}")
226
227 if stale_families:
228 print("[ERROR] Manifest option families no longer present in the scan:")
229 for family in stale_families:
230 print(f" - {family}")
231
232 if untraceable:
233 print("[ERROR] Option names built from a variable this audit cannot trace to a family:")
234 for entry in untraceable:
235 print(f" - {entry}")
236 print(
237 " Build the name with PetscSNPrintf from a literal format beginning with '-', "
238 "so the audit can see it."
239 )
240
241 if missing_in_manifest or stale_in_manifest or missing_families or stale_families or untraceable:
242 print(
243 "[FAIL] Ingress drift detected. Update tests/tooling/audit_ingress_manifest.json and docs mapping.",
244 file=sys.stderr,
245 )
246 return 1
247
248 print("[OK] Ingress manifest matches the PETSc option scan of its declared sources.")
249 return 0
250
251
252if __name__ == "__main__":
253 raise SystemExit(main())
Set[str] scan_petsc_options(Iterable[pathlib.Path] paths)
Perform scan petsc options.
"dict[str, Set[str]]" extract_families(str text)
Map each name variable built by PetscSNPrintf to every family it carries.
"tuple[Set[str], list[str]]" scan_option_families(Iterable[pathlib.Path] paths)
Collect declared option families and any constructed name that lacks one.
int main()
Entry point for this script.
str canonical_family(Iterable[str] fragments)
Join the literal fragments of a constructed name into one stable pattern.
dict load_manifest(pathlib.Path path)
Read and validate the option names, families, and sources in the manifest.