osaudit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- osaudit/__init__.py +3 -0
- osaudit/__main__.py +7 -0
- osaudit/_cli.py +114 -0
- osaudit/_config.py +283 -0
- osaudit/_findings.py +23 -0
- osaudit/_git.py +45 -0
- osaudit/_number.py +63 -0
- osaudit/_numbers.py +122 -0
- osaudit/_pdf.py +55 -0
- osaudit/_provenance.py +150 -0
- osaudit/_quotes.py +228 -0
- osaudit/_results.py +124 -0
- osaudit/_results_check.py +145 -0
- osaudit/_tex.py +105 -0
- osaudit-0.1.0.dist-info/METADATA +128 -0
- osaudit-0.1.0.dist-info/RECORD +19 -0
- osaudit-0.1.0.dist-info/WHEEL +4 -0
- osaudit-0.1.0.dist-info/entry_points.txt +2 -0
- osaudit-0.1.0.dist-info/licenses/LICENSE +21 -0
osaudit/__init__.py
ADDED
osaudit/__main__.py
ADDED
osaudit/_cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The `osaudit` command line: silent when the paper is clean, one line per finding otherwise.
|
|
2
|
+
|
|
3
|
+
Exit status: 0 no finding, 1 findings, 2 the audit could not run (configuration, input).
|
|
4
|
+
`osaudit run` writes a results file with its provenance; it prints nothing on success.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__, _numbers, _provenance, _quotes, _results_check
|
|
16
|
+
from ._config import Config, ConfigError, instruments, load
|
|
17
|
+
from ._findings import Finding
|
|
18
|
+
from ._git import GitError
|
|
19
|
+
from ._pdf import PdfError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _results(cfg: Config, args: argparse.Namespace) -> list[Finding]:
|
|
23
|
+
return _results_check.check(cfg, inputs=getattr(args, "inputs", False),
|
|
24
|
+
rerun=getattr(args, "rerun", False),
|
|
25
|
+
timeout=getattr(args, "timeout", None))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CHECKS: dict[str, tuple[Callable[[Config, argparse.Namespace], list[Finding]], str]] = {
|
|
29
|
+
"numbers": (lambda cfg, _: _numbers.check(cfg),
|
|
30
|
+
"every number of the paper traced to results, derivation or allow"),
|
|
31
|
+
"quotes": (lambda cfg, _: _quotes.check(cfg),
|
|
32
|
+
"every cited verbatim quote in a verified [[quotes]] entry, with its page"),
|
|
33
|
+
"results": (_results,"every results file has a provenance that still holds "
|
|
34
|
+
"(static; --inputs, --rerun to go further)"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parser() -> argparse.ArgumentParser:
|
|
39
|
+
p = argparse.ArgumentParser(
|
|
40
|
+
prog="osaudit",
|
|
41
|
+
description="Audit a paper repository: report every claim not traced to its evidence. "
|
|
42
|
+
"Silent when clean; `file:line: rule: message` per finding. "
|
|
43
|
+
"Conventions: CONVENTIONS.md. Without a check name, runs every check "
|
|
44
|
+
"(static parts only).",
|
|
45
|
+
epilog="exit status: 0 clean, 1 findings, 2 the audit could not run",
|
|
46
|
+
)
|
|
47
|
+
p.add_argument("--version", action="version", version=f"osaudit {__version__}")
|
|
48
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
49
|
+
common.add_argument("--root", type=Path, default=Path("."),
|
|
50
|
+
help="paper repository (default: current directory)")
|
|
51
|
+
common.add_argument("--config", type=Path, help="audit.toml (default: <root>/audit.toml)")
|
|
52
|
+
common.add_argument("--json", action="store_true",
|
|
53
|
+
help="print findings as a JSON array ([] when clean)")
|
|
54
|
+
sub = p.add_subparsers(dest="check", metavar="CHECK")
|
|
55
|
+
for name, (_, help_) in CHECKS.items():
|
|
56
|
+
sp = sub.add_parser(name, parents=[common], help=help_, description=help_)
|
|
57
|
+
if name == "results":
|
|
58
|
+
sp.add_argument("--inputs", action="store_true",
|
|
59
|
+
help="rehash every recorded input (needs the inputs)")
|
|
60
|
+
sp.add_argument("--rerun", action="store_true",
|
|
61
|
+
help="rerun every recorded command at its time anchor and compare")
|
|
62
|
+
sp.add_argument("--timeout", type=float, help="seconds per rerun")
|
|
63
|
+
sub.add_parser("all", parents=[common], help="run every check (the default)")
|
|
64
|
+
run = sub.add_parser(
|
|
65
|
+
"run", help="run an instrument, write its JSON results with their provenance",
|
|
66
|
+
description="Run CMD (its stdout must be a JSON object) with the time anchor in "
|
|
67
|
+
f"${_provenance.ENV_NOW}, and write the object to OUTPUT with a provenance "
|
|
68
|
+
"block: command, inputs and their sha256, git commit, time anchor. "
|
|
69
|
+
"Refuses to run on a tree with uncommitted changes.",
|
|
70
|
+
epilog="example: osaudit run --input data/answers.csv -o results/survey.json -- "
|
|
71
|
+
"python study/survey.py data/answers.csv")
|
|
72
|
+
run.add_argument("--root", type=Path, default=Path("."), help="repository of the instruments")
|
|
73
|
+
run.add_argument("--input", type=Path, action="append", default=[], dest="input_paths",
|
|
74
|
+
help="an input file or directory (repeat for each)")
|
|
75
|
+
run.add_argument("-o", "--output", type=Path, required=True, help="results file to write")
|
|
76
|
+
run.add_argument("cmd", nargs=argparse.REMAINDER, help="-- CMD [ARG ...]")
|
|
77
|
+
return p
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run(args: argparse.Namespace) -> int:
|
|
81
|
+
cmd = args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd
|
|
82
|
+
if not cmd:
|
|
83
|
+
print("osaudit run: no command (put it after --)", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
root = args.root.resolve()
|
|
86
|
+
try:
|
|
87
|
+
output = args.output.resolve()
|
|
88
|
+
out_rel = str(output.relative_to(root)) if output.is_relative_to(root) else str(output)
|
|
89
|
+
_provenance.run(root, cmd, args.input_paths, args.output, instruments(root, out_rel))
|
|
90
|
+
except (_provenance.RunError, ConfigError, GitError, OSError) as e:
|
|
91
|
+
print(f"osaudit run: {e}", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
97
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
98
|
+
if not (argv and argv[0] in (*CHECKS, "all", "run", "-h", "--help", "--version")):
|
|
99
|
+
argv = ["all", *argv]
|
|
100
|
+
args = parser().parse_args(argv)
|
|
101
|
+
if args.check == "run":
|
|
102
|
+
return _run(args)
|
|
103
|
+
names = list(CHECKS) if args.check == "all" else [args.check]
|
|
104
|
+
try:
|
|
105
|
+
cfg = load(args.root.resolve(), args.config.resolve() if args.config else None)
|
|
106
|
+
findings = [f for n in names for f in CHECKS[n][0](cfg, args)]
|
|
107
|
+
except (ConfigError, PdfError, GitError, OSError, ValueError) as e:
|
|
108
|
+
print(f"osaudit: {e}", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
if args.json:
|
|
111
|
+
print(json.dumps([f.as_json() for f in findings], indent=1))
|
|
112
|
+
else:
|
|
113
|
+
sys.stdout.write("".join(f"{f}\n" for f in findings))
|
|
114
|
+
return 1 if findings else 0
|
osaudit/_config.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""`audit.toml`: where the paper, the results and the instruments are, and how they are audited.
|
|
2
|
+
|
|
3
|
+
[paper]
|
|
4
|
+
tex = ["paper/sections/*.tex"] # LaTeX sources, audited when present
|
|
5
|
+
pdf = "paper/main.pdf" # audited when no tex is declared
|
|
6
|
+
|
|
7
|
+
[results]
|
|
8
|
+
files = ["study/results/*.json"]
|
|
9
|
+
instruments = ["study/*.py"] # the code that produces the results (staleness, clock)
|
|
10
|
+
|
|
11
|
+
[[results.depends]] # optional: the instruments one results file depends on
|
|
12
|
+
file = "study/results/survey.json" # (default: every instrument)
|
|
13
|
+
instruments = ["study/survey.py", "study/_common.py"]
|
|
14
|
+
|
|
15
|
+
[[results.manual]] # a results file made by a person, not an instrument
|
|
16
|
+
file = "study/results/labels_check.json"
|
|
17
|
+
by = "A. Author"
|
|
18
|
+
date = "2026-10-17"
|
|
19
|
+
reason = "the author checked 20 labels of the classifier"
|
|
20
|
+
|
|
21
|
+
[[numbers.derive]]
|
|
22
|
+
value = "1,204"
|
|
23
|
+
expr = "survey.responses - survey.partial"
|
|
24
|
+
file = "paper/results.tex" # optional: only there
|
|
25
|
+
|
|
26
|
+
[[numbers.allow]]
|
|
27
|
+
value = "2,000,000"
|
|
28
|
+
reason = "population size published by the cited census"
|
|
29
|
+
cite = "census2020" # optional
|
|
30
|
+
file = "paper/method.tex" # optional
|
|
31
|
+
|
|
32
|
+
[[quotes]] # a quote as read in its source
|
|
33
|
+
key = "doe2001theory" # BibTeX key
|
|
34
|
+
text = "The full sentence, verbatim, of which the paper may quote a part."
|
|
35
|
+
version = "publisher" # publisher|author-pdf|preprint|reprint|abstract|secondary
|
|
36
|
+
url = "https://doi.org/10.1000/xyz"
|
|
37
|
+
page = "107" # omitted for an abstract
|
|
38
|
+
date = "2026-10-17" # read on
|
|
39
|
+
status = "verified" # verified|unverified
|
|
40
|
+
claim = "what the citation supports" # optional
|
|
41
|
+
|
|
42
|
+
`[results]` is optional: a paper without a study audits its quotes and references only.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import re
|
|
48
|
+
import tomllib
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from ._number import Num, parse
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConfigError(Exception):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Entry:
|
|
62
|
+
"""A `[[numbers.derive]]` or `[[numbers.allow]]` entry; `line` is its header in audit.toml."""
|
|
63
|
+
|
|
64
|
+
kind: str
|
|
65
|
+
num: Num
|
|
66
|
+
line: int
|
|
67
|
+
file: str | None
|
|
68
|
+
expr: str | None = None
|
|
69
|
+
reason: str | None = None
|
|
70
|
+
cite: str | None = None
|
|
71
|
+
|
|
72
|
+
def covers(self, n: Num) -> bool:
|
|
73
|
+
return n.key == self.num.key and (self.file is None or self.file == n.file)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Manual:
|
|
78
|
+
"""A `[[results.manual]]` entry: a results file made by a person."""
|
|
79
|
+
|
|
80
|
+
file: str
|
|
81
|
+
by: str
|
|
82
|
+
date: str
|
|
83
|
+
reason: str
|
|
84
|
+
line: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Depends:
|
|
89
|
+
"""A `[[results.depends]]` entry: the instruments one results file depends on."""
|
|
90
|
+
|
|
91
|
+
file: str
|
|
92
|
+
instruments: tuple[Path, ...]
|
|
93
|
+
line: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
VERSIONS = ("publisher", "author-pdf", "preprint", "reprint", "abstract", "secondary")
|
|
97
|
+
STATUSES = ("verified", "unverified")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Quote:
|
|
102
|
+
"""A `[[quotes]]` entry: a quote as read in a source."""
|
|
103
|
+
|
|
104
|
+
key: str
|
|
105
|
+
text: str
|
|
106
|
+
version: str
|
|
107
|
+
url: str
|
|
108
|
+
page: str | None
|
|
109
|
+
date: str
|
|
110
|
+
status: str
|
|
111
|
+
claim: str | None
|
|
112
|
+
line: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Config:
|
|
117
|
+
root: Path
|
|
118
|
+
path: Path
|
|
119
|
+
tex: tuple[Path, ...]
|
|
120
|
+
pdf: Path | None
|
|
121
|
+
results: tuple[Path, ...]
|
|
122
|
+
entries: tuple[Entry, ...]
|
|
123
|
+
instruments: tuple[Path, ...] = ()
|
|
124
|
+
manual: tuple[Manual, ...] = ()
|
|
125
|
+
depends: tuple[Depends, ...] = ()
|
|
126
|
+
quotes: tuple[Quote, ...] = ()
|
|
127
|
+
|
|
128
|
+
def rel(self, p: Path) -> str:
|
|
129
|
+
try:
|
|
130
|
+
return str(p.relative_to(self.root))
|
|
131
|
+
except ValueError:
|
|
132
|
+
return str(p)
|
|
133
|
+
|
|
134
|
+
def instruments_of(self, results_file: str) -> tuple[Path, ...]:
|
|
135
|
+
"""The instruments `results_file` depends on: its `[[results.depends]]`, or all."""
|
|
136
|
+
for d in self.depends:
|
|
137
|
+
if d.file == results_file:
|
|
138
|
+
return d.instruments
|
|
139
|
+
return self.instruments
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _glob(root: Path, patterns: Any, what: str) -> tuple[Path, ...]:
|
|
143
|
+
if isinstance(patterns, str):
|
|
144
|
+
patterns = [patterns]
|
|
145
|
+
if not isinstance(patterns, list) or not all(isinstance(p, str) for p in patterns):
|
|
146
|
+
raise ConfigError(f"{what} must be a string or a list of strings")
|
|
147
|
+
out: list[Path] = []
|
|
148
|
+
for p in patterns:
|
|
149
|
+
hits = sorted(root.glob(p))
|
|
150
|
+
if not hits:
|
|
151
|
+
raise ConfigError(f"{what}: {p!r} matches no file")
|
|
152
|
+
out += hits
|
|
153
|
+
return tuple(out)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _header_lines(text: str, table: str) -> list[int]:
|
|
157
|
+
pat = re.compile(rf"^[ \t]*\[\[[ \t]*{re.escape(table)}[ \t]*\]\]", re.MULTILINE)
|
|
158
|
+
return [text.count("\n", 0, m.start()) + 1 for m in pat.finditer(text)]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _entries(text: str, numbers: dict[str, Any], kind: str) -> list[Entry]:
|
|
162
|
+
raw = numbers.get(kind, [])
|
|
163
|
+
if not isinstance(raw, list):
|
|
164
|
+
raise ConfigError(f"numbers.{kind} must be an array of tables ([[numbers.{kind}]])")
|
|
165
|
+
lines = _header_lines(text, f"numbers.{kind}")
|
|
166
|
+
out = []
|
|
167
|
+
for i, e in enumerate(raw):
|
|
168
|
+
line = lines[i] if i < len(lines) else 0
|
|
169
|
+
where = f"audit.toml:{line}: numbers.{kind}"
|
|
170
|
+
value = e.get("value")
|
|
171
|
+
num = parse(value) if isinstance(value, str) else None
|
|
172
|
+
if num is None:
|
|
173
|
+
raise ConfigError(f"{where}: value must be a string, the number as printed")
|
|
174
|
+
if kind == "derive" and not isinstance(e.get("expr"), str):
|
|
175
|
+
raise ConfigError(f"{where}: expr is mandatory")
|
|
176
|
+
if kind == "allow" and not str(e.get("reason", "")).strip():
|
|
177
|
+
raise ConfigError(f"{where}: reason is mandatory")
|
|
178
|
+
out.append(Entry(kind, num, line, e.get("file"), e.get("expr"), e.get("reason"),
|
|
179
|
+
e.get("cite")))
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load(root: Path, path: Path | None = None) -> Config:
|
|
184
|
+
path = path or root / "audit.toml"
|
|
185
|
+
if not path.is_file():
|
|
186
|
+
raise ConfigError(f"{path}: no audit.toml (see CONVENTIONS.md)")
|
|
187
|
+
text = path.read_text()
|
|
188
|
+
try:
|
|
189
|
+
data = tomllib.loads(text)
|
|
190
|
+
except tomllib.TOMLDecodeError as e:
|
|
191
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
192
|
+
paper = data.get("paper", {})
|
|
193
|
+
tex = _glob(root, paper["tex"], "paper.tex") if "tex" in paper else ()
|
|
194
|
+
pdf = None
|
|
195
|
+
if not tex:
|
|
196
|
+
if "pdf" not in paper:
|
|
197
|
+
raise ConfigError(f"{path}: [paper] declares neither tex nor pdf")
|
|
198
|
+
pdf = root / paper["pdf"]
|
|
199
|
+
if not pdf.is_file():
|
|
200
|
+
raise ConfigError(f"paper.pdf: {paper['pdf']!r} is not a file")
|
|
201
|
+
res = data.get("results", {})
|
|
202
|
+
results = _glob(root, res["files"], "results.files") if "files" in res else ()
|
|
203
|
+
numbers = data.get("numbers", {})
|
|
204
|
+
entries = _entries(text, numbers, "derive") + _entries(text, numbers, "allow")
|
|
205
|
+
instruments = res.get("instruments", [])
|
|
206
|
+
return Config(root, path, tex, pdf, results, tuple(entries),
|
|
207
|
+
_glob(root, instruments, "results.instruments") if instruments else (),
|
|
208
|
+
tuple(_manual(text, res.get("manual", []))),
|
|
209
|
+
tuple(_depends(root, text, res.get("depends", []))),
|
|
210
|
+
tuple(_quotes(text, data.get("quotes", []))))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _quotes(text: str, raw: Any) -> list[Quote]:
|
|
214
|
+
if not isinstance(raw, list):
|
|
215
|
+
raise ConfigError("quotes must be an array of tables ([[quotes]])")
|
|
216
|
+
lines = _header_lines(text, "quotes")
|
|
217
|
+
out = []
|
|
218
|
+
for i, e in enumerate(raw):
|
|
219
|
+
line = lines[i] if i < len(lines) else 0
|
|
220
|
+
where = f"audit.toml:{line}: quotes"
|
|
221
|
+
missing = [k for k in ("key", "text", "version", "url", "date", "status")
|
|
222
|
+
if not str(e.get(k, "")).strip()]
|
|
223
|
+
if missing:
|
|
224
|
+
raise ConfigError(f"{where}: {', '.join(missing)} mandatory")
|
|
225
|
+
if e["version"] not in VERSIONS:
|
|
226
|
+
raise ConfigError(f"{where}: version must be one of {', '.join(VERSIONS)}")
|
|
227
|
+
if e["status"] not in STATUSES:
|
|
228
|
+
raise ConfigError(f"{where}: status must be one of {', '.join(STATUSES)}")
|
|
229
|
+
page = e.get("page")
|
|
230
|
+
out.append(Quote(e["key"], e["text"], e["version"], e["url"],
|
|
231
|
+
None if page is None else str(page), str(e["date"]), e["status"],
|
|
232
|
+
e.get("claim"), line))
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def instruments(root: Path, output: str) -> list[str] | None:
|
|
237
|
+
"""The instruments `output` depends on, per `<root>/audit.toml`, relative to root; None if
|
|
238
|
+
undeclared.
|
|
239
|
+
|
|
240
|
+
For `osaudit run`, which may create the first results file, so `load` would refuse.
|
|
241
|
+
"""
|
|
242
|
+
path = root / "audit.toml"
|
|
243
|
+
if not path.is_file():
|
|
244
|
+
return None
|
|
245
|
+
text = path.read_text()
|
|
246
|
+
try:
|
|
247
|
+
results = tomllib.loads(text).get("results", {})
|
|
248
|
+
except tomllib.TOMLDecodeError as e:
|
|
249
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
250
|
+
for d in _depends(root, text, results.get("depends", [])):
|
|
251
|
+
if d.file == output:
|
|
252
|
+
return [str(p.relative_to(root)) for p in d.instruments]
|
|
253
|
+
if not results.get("instruments"):
|
|
254
|
+
return None
|
|
255
|
+
return [str(p.relative_to(root))
|
|
256
|
+
for p in _glob(root, results["instruments"], "results.instruments")]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _depends(root: Path, text: str, raw: Any) -> list[Depends]:
|
|
260
|
+
if not isinstance(raw, list):
|
|
261
|
+
raise ConfigError("results.depends must be an array of tables ([[results.depends]])")
|
|
262
|
+
lines = _header_lines(text, "results.depends")
|
|
263
|
+
out = []
|
|
264
|
+
for i, e in enumerate(raw):
|
|
265
|
+
line = lines[i] if i < len(lines) else 0
|
|
266
|
+
if not isinstance(e.get("file"), str) or "instruments" not in e:
|
|
267
|
+
raise ConfigError(f"audit.toml:{line}: results.depends: file, instruments mandatory")
|
|
268
|
+
out.append(Depends(e["file"], _glob(root, e["instruments"], "results.depends"), line))
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _manual(text: str, raw: Any) -> list[Manual]:
|
|
273
|
+
if not isinstance(raw, list):
|
|
274
|
+
raise ConfigError("results.manual must be an array of tables ([[results.manual]])")
|
|
275
|
+
lines = _header_lines(text, "results.manual")
|
|
276
|
+
out = []
|
|
277
|
+
for i, e in enumerate(raw):
|
|
278
|
+
line = lines[i] if i < len(lines) else 0
|
|
279
|
+
missing = [k for k in ("file", "by", "date", "reason") if not str(e.get(k, "")).strip()]
|
|
280
|
+
if missing:
|
|
281
|
+
raise ConfigError(f"audit.toml:{line}: results.manual: {', '.join(missing)} mandatory")
|
|
282
|
+
out.append(Manual(e["file"], e["by"], str(e["date"]), e["reason"], line))
|
|
283
|
+
return out
|
osaudit/_findings.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""A finding: one line `file:line: rule: message`, or one JSON object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Finding:
|
|
11
|
+
file: str
|
|
12
|
+
line: int
|
|
13
|
+
rule: str
|
|
14
|
+
message: str
|
|
15
|
+
candidates: tuple[str, ...] = field(default=())
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"{self.file}:{self.line}: {self.rule}: {self.message}"
|
|
19
|
+
|
|
20
|
+
def as_json(self) -> dict[str, Any]:
|
|
21
|
+
d = asdict(self)
|
|
22
|
+
d["candidates"] = list(self.candidates)
|
|
23
|
+
return d
|
osaudit/_git.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The few git queries the audit needs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _git(root: Path, *args: str) -> str:
|
|
14
|
+
try:
|
|
15
|
+
r = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True)
|
|
16
|
+
except OSError as e:
|
|
17
|
+
raise GitError(f"git: {e}") from e
|
|
18
|
+
if r.returncode != 0:
|
|
19
|
+
raise GitError(f"git {' '.join(args)}: {r.stderr.strip()}")
|
|
20
|
+
return r.stdout
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def head(root: Path) -> str:
|
|
24
|
+
return _git(root, "rev-parse", "HEAD").strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dirty(root: Path) -> list[str]:
|
|
28
|
+
"""Tracked files modified, staged or deleted, relative to `root` (untracked do not count)."""
|
|
29
|
+
return _git(root, "diff", "--name-only", "--relative", "HEAD").splitlines()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def has_commit(root: Path, commit: str) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
_git(root, "cat-file", "-e", f"{commit}^{{commit}}")
|
|
35
|
+
except GitError:
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def changed_since(root: Path, commit: str, paths: list[str]) -> list[str]:
|
|
41
|
+
"""Of `paths`, those that differ between `commit` and the working tree."""
|
|
42
|
+
if not paths:
|
|
43
|
+
return []
|
|
44
|
+
out = _git(root, "diff", "--name-only", "--relative", commit, "--", *paths)
|
|
45
|
+
return out.splitlines()
|
osaudit/_number.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""A number as printed in a paper, and when it matches a value."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
# A number not glued to a word, a path or a command: `123d0d04`, `3.0cm`, `RQ1` and `\\2` are not
|
|
10
|
+
# numbers. The lookahead refuses a trailing `.x`/`,x`, so backtracking cannot cut `3.0cm` to `3`.
|
|
11
|
+
NUMBER = r"(?<![\w.\\/])(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?(?![.,]?\w)"
|
|
12
|
+
# A number that names a part of a document, not a quantity: "Section 4.3", "Table~1", "p. 107".
|
|
13
|
+
PART = re.compile(
|
|
14
|
+
r"\b(?:sections?|sec\.|tables?|tab\.|figures?|fig\.|definitions?|equations?|eq\.|appendix"
|
|
15
|
+
r"|chapters?|ch\.|pages?|pp?\.)(?:\s|~)*\d+(?:\.\d+)*(?:\s*[-–]\s*\d+)?",
|
|
16
|
+
re.IGNORECASE)
|
|
17
|
+
PRINTED = re.compile(r"\s*(\d{1,3}(?:,\d{3})+|\d+)(\.\d+)?\s*(\\?%)?\s*")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Num:
|
|
22
|
+
"""A printed number: `text` as in the paper, `value` without separators, d `decimals`."""
|
|
23
|
+
|
|
24
|
+
file: str
|
|
25
|
+
line: int
|
|
26
|
+
text: str
|
|
27
|
+
value: Decimal
|
|
28
|
+
decimals: int
|
|
29
|
+
percent: bool
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def key(self) -> tuple[Decimal, int, bool]:
|
|
33
|
+
"""What makes two printed numbers the same number (`1,204` and `1204`)."""
|
|
34
|
+
return (self.value, self.decimals, self.percent)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make(file: str, line: int, text: str, integer: str, frac: str | None, percent: bool) -> Num:
|
|
38
|
+
digits = integer.replace(",", "") + (frac or "")
|
|
39
|
+
return Num(file, line, text, Decimal(digits), len(frac) - 1 if frac else 0, percent)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse(text: str) -> Num | None:
|
|
43
|
+
"""Parse a value written in `audit.toml` (`"1,204"`, `"80.3%"`, `"80.3\\%"`)."""
|
|
44
|
+
m = PRINTED.fullmatch(text)
|
|
45
|
+
if not m:
|
|
46
|
+
return None
|
|
47
|
+
return make("", 0, text.strip(), m.group(1), m.group(2), m.group(3) is not None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def close(num: Num, v: Decimal) -> bool:
|
|
51
|
+
"""`num` is `v` printed with its decimals: equal within half a unit of its last digit.
|
|
52
|
+
|
|
53
|
+
A plain integer matches only an integral value: `7` is not a rounding of 7.2. A percent
|
|
54
|
+
matches v as a share (100·v), or as a value already in percent when v is not a count (a
|
|
55
|
+
JSON integer): `57.0%` is not the count 57.
|
|
56
|
+
"""
|
|
57
|
+
half = Decimal(5) * Decimal(10) ** -(num.decimals + 1)
|
|
58
|
+
if num.percent:
|
|
59
|
+
in_percent = v.as_tuple().exponent != 0 and abs(num.value - v) <= half
|
|
60
|
+
return abs(num.value - 100 * v) <= half or in_percent
|
|
61
|
+
if num.decimals == 0:
|
|
62
|
+
return v == v.to_integral_value() and v == num.value
|
|
63
|
+
return abs(num.value - v) <= half
|
osaudit/_numbers.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`osaudit numbers`: every number of the paper is a results value, a derivation, or allowed.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
numbers/untraced a printed number matches no results value, derivation or allow entry
|
|
5
|
+
numbers/derive-mismatch a derivation evaluates to another value than the one it declares
|
|
6
|
+
numbers/derive-unknown a derivation names a results value that does not exist
|
|
7
|
+
numbers/unused a derivation or allow entry matches no number of the paper
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterable, Iterator
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
|
|
17
|
+
from . import _pdf, _tex
|
|
18
|
+
from ._config import Config
|
|
19
|
+
from ._findings import Finding
|
|
20
|
+
from ._number import Num, close
|
|
21
|
+
from ._results import Results
|
|
22
|
+
|
|
23
|
+
MAX_CANDIDATES = 3
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def paper_numbers(cfg: Config) -> Iterator[Num]:
|
|
27
|
+
for f in cfg.tex:
|
|
28
|
+
yield from _tex.numbers(cfg.rel(f), f.read_text())
|
|
29
|
+
if cfg.pdf is not None:
|
|
30
|
+
yield from _pdf.numbers(cfg.rel(cfg.pdf), _pdf.text_of(cfg.pdf))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _printed(v: Decimal) -> str:
|
|
34
|
+
return format(v.normalize(), "f") if v == v.to_integral_value() else format(v, ".6g")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _shown(n: Num, v: Decimal) -> str:
|
|
38
|
+
"""`v` rounded as `n` is printed, for messages."""
|
|
39
|
+
x = 100 * v if n.percent else v
|
|
40
|
+
q = x.quantize(Decimal(10) ** -n.decimals)
|
|
41
|
+
return f"{q:,}" + ("%" if n.percent else "")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def candidates(n: Num, values: dict[str, Decimal]) -> list[str]:
|
|
45
|
+
"""Up to MAX_CANDIDATES single-operation expressions over results values that print as `n`.
|
|
46
|
+
|
|
47
|
+
Operands are counts (JSON integers): a sum or difference of counts, or a share of two counts.
|
|
48
|
+
Expressions within one results file come first, then those whose operands are closest in
|
|
49
|
+
the results tree (`survey.by_age.young.complete / survey.total`), then the shortest.
|
|
50
|
+
"""
|
|
51
|
+
named = sorted((k, v) for k, v in values.items()
|
|
52
|
+
if not k.endswith(" (key)") and v.as_tuple().exponent == 0)
|
|
53
|
+
by_value: dict[Decimal, list[str]] = {}
|
|
54
|
+
for k, v in named:
|
|
55
|
+
by_value.setdefault(v, []).append(k)
|
|
56
|
+
out: list[str] = []
|
|
57
|
+
if not n.percent and n.decimals == 0:
|
|
58
|
+
for a, va in named:
|
|
59
|
+
for b in by_value.get(va - n.value, []):
|
|
60
|
+
out.append(f"{a} - {b}")
|
|
61
|
+
for b in by_value.get(n.value - va, []):
|
|
62
|
+
if a < b:
|
|
63
|
+
out.append(f"{a} + {b}")
|
|
64
|
+
else:
|
|
65
|
+
for a, va in named:
|
|
66
|
+
for b, vb in named:
|
|
67
|
+
if a != b and vb != 0 and close(n, va / vb):
|
|
68
|
+
out.append(f"{a} / {b}")
|
|
69
|
+
def rank(e: str) -> tuple[int, int, int, str]:
|
|
70
|
+
a, b = (re.split(r"[.\[]", x) for x in e.split(" ", 2)[::2])
|
|
71
|
+
shared = len(os.path.commonprefix([a, b]))
|
|
72
|
+
return (a[0] != b[0], len(a) + len(b) - 2 * shared, len(e), e)
|
|
73
|
+
|
|
74
|
+
return sorted(out, key=rank)[:MAX_CANDIDATES]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check(cfg: Config) -> list[Finding]:
|
|
78
|
+
results = Results.load(list(cfg.results))
|
|
79
|
+
values = list(results.values.values())
|
|
80
|
+
toml = cfg.rel(cfg.path)
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
|
|
83
|
+
broken: set[int] = set()
|
|
84
|
+
for i, e in enumerate(cfg.entries):
|
|
85
|
+
if e.kind != "derive":
|
|
86
|
+
continue
|
|
87
|
+
assert e.expr is not None
|
|
88
|
+
try:
|
|
89
|
+
v = results.evaluate(e.expr)
|
|
90
|
+
except ValueError as err:
|
|
91
|
+
findings.append(Finding(toml, e.line, "numbers/derive-unknown", str(err)))
|
|
92
|
+
broken.add(i)
|
|
93
|
+
continue
|
|
94
|
+
if not close(e.num, v):
|
|
95
|
+
findings.append(Finding(
|
|
96
|
+
toml, e.line, "numbers/derive-mismatch",
|
|
97
|
+
f"{e.num.text} declared, but {e.expr} = {_printed(v)}, printed {_shown(e.num, v)}"))
|
|
98
|
+
broken.add(i)
|
|
99
|
+
|
|
100
|
+
# A broken entry still covers its number: the defect is reported once, on the entry.
|
|
101
|
+
used: set[int] = set()
|
|
102
|
+
for n in paper_numbers(cfg):
|
|
103
|
+
hits = [i for i, e in enumerate(cfg.entries) if e.covers(n)]
|
|
104
|
+
used.update(hits)
|
|
105
|
+
if hits or any(close(n, v) for v in values):
|
|
106
|
+
continue
|
|
107
|
+
cands = candidates(n, results.values)
|
|
108
|
+
msg = f"{n.text} matches no results value, derivation or allow entry"
|
|
109
|
+
if cands:
|
|
110
|
+
msg += "; candidate expr: " + " | ".join(cands)
|
|
111
|
+
findings.append(Finding(n.file, n.line, "numbers/untraced", msg, tuple(cands)))
|
|
112
|
+
|
|
113
|
+
for i, e in enumerate(cfg.entries):
|
|
114
|
+
if i not in used and i not in broken:
|
|
115
|
+
where = f" in {e.file}" if e.file else ""
|
|
116
|
+
findings.append(Finding(toml, e.line, "numbers/unused",
|
|
117
|
+
f"{e.kind} {e.num.text} matches no number of the paper{where}"))
|
|
118
|
+
return sorted(findings, key=lambda f: (f.file, f.line, f.rule, f.message))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render(findings: Iterable[Finding]) -> str:
|
|
122
|
+
return "".join(f"{f}\n" for f in findings)
|