papercheck 0.3.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.
- papercheck/__init__.py +3 -0
- papercheck/cli/__init__.py +1 -0
- papercheck/cli/main.py +324 -0
- papercheck/core/__init__.py +1 -0
- papercheck/core/_resources.py +38 -0
- papercheck/core/adjudicate.py +228 -0
- papercheck/core/compare.py +258 -0
- papercheck/core/domainpack.py +148 -0
- papercheck/core/gate.py +178 -0
- papercheck/core/html_report.py +295 -0
- papercheck/core/issues.py +86 -0
- papercheck/core/ledger.py +157 -0
- papercheck/core/paths.py +50 -0
- papercheck/core/profiles.py +51 -0
- papercheck/core/render.py +211 -0
- papercheck/core/schemas.py +45 -0
- papercheck/core/segments.py +198 -0
- papercheck/core/state.py +168 -0
- papercheck/core/texscan.py +801 -0
- papercheck/core/verify.py +66 -0
- papercheck/core/webserve.py +612 -0
- papercheck/domain_packs/README.md +47 -0
- papercheck/domain_packs/general.yaml +18 -0
- papercheck/domain_packs/machine_learning.yaml +18 -0
- papercheck/domain_packs/numerical_analysis.yaml +17 -0
- papercheck/domain_packs/optimization.yaml +18 -0
- papercheck/domain_packs/pde.yaml +18 -0
- papercheck/domain_packs/stochastic_analysis.yaml +17 -0
- papercheck/mcp_server/__init__.py +1 -0
- papercheck/mcp_server/handlers.py +346 -0
- papercheck/mcp_server/server.py +216 -0
- papercheck/profiles/profiles.json +74 -0
- papercheck/prompts/00_bootstrap_orchestrator.md +18 -0
- papercheck/prompts/01_repository_inspector.md +23 -0
- papercheck/prompts/02_build_and_source_hygiene.md +22 -0
- papercheck/prompts/03_segmenter_and_budgeter.md +36 -0
- papercheck/prompts/04_theorem_inventory.md +20 -0
- papercheck/prompts/05_assumption_dependency_auditor.md +20 -0
- papercheck/prompts/06_equation_indexer.md +16 -0
- papercheck/prompts/07_formalist_proof_auditor.md +30 -0
- papercheck/prompts/08_domain_specialist_auditor.md +13 -0
- papercheck/prompts/09_main_theorem_chain_auditor.md +30 -0
- papercheck/prompts/10_numerical_experiments_auditor.md +25 -0
- papercheck/prompts/11_related_work_and_novelty_auditor.md +18 -0
- papercheck/prompts/12_notation_consistency_auditor.md +21 -0
- papercheck/prompts/13_source_hygiene_auditor.md +29 -0
- papercheck/prompts/14_global_synthesis.md +25 -0
- papercheck/prompts/15_issue_adjudicator.md +38 -0
- papercheck/prompts/16_patch_planner.md +22 -0
- papercheck/prompts/17_patcher.md +20 -0
- papercheck/prompts/18_regression_auditor.md +23 -0
- papercheck/prompts/19_final_acceptance_gate.md +28 -0
- papercheck/prompts/20_version_comparison_auditor.md +19 -0
- papercheck/schemas/.gitkeep +0 -0
- papercheck/schemas/domain_pack.schema.json +28 -0
- papercheck/schemas/issue.schema.json +121 -0
- papercheck/schemas/manual_check.schema.json +29 -0
- papercheck/schemas/patch.schema.json +52 -0
- papercheck/schemas/segment.schema.json +55 -0
- papercheck/schemas/state.schema.json +65 -0
- papercheck/templates/assumption_record.md +13 -0
- papercheck/templates/equation_record.md +11 -0
- papercheck/templates/final_gate.md +11 -0
- papercheck/templates/issue.md +23 -0
- papercheck/templates/patch_record.md +13 -0
- papercheck/templates/report_header.md +11 -0
- papercheck/templates/segment_record.md +12 -0
- papercheck/templates/theorem_record.md +18 -0
- papercheck-0.3.0.dist-info/METADATA +177 -0
- papercheck-0.3.0.dist-info/RECORD +73 -0
- papercheck-0.3.0.dist-info/WHEEL +4 -0
- papercheck-0.3.0.dist-info/entry_points.txt +3 -0
- papercheck-0.3.0.dist-info/licenses/LICENSE +21 -0
papercheck/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer command-line interface for papercheck."""
|
papercheck/cli/main.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""papercheck command-line interface.
|
|
2
|
+
|
|
3
|
+
Defines the Typer ``app`` and command stubs. Command bodies are filled in later
|
|
4
|
+
phases; for now they echo a placeholder so ``papercheck --help`` lists them all.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from papercheck.core import paths, texscan
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="papercheck — audit harness for mathematical LaTeX papers")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def init(
|
|
21
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize a paper's audit workspace."""
|
|
24
|
+
import datetime
|
|
25
|
+
|
|
26
|
+
from papercheck.mcp_server import handlers
|
|
27
|
+
|
|
28
|
+
run_id = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%SZ")
|
|
29
|
+
state = handlers.init_audit(paper_root, run_id=run_id)
|
|
30
|
+
typer.echo(f"Initialized audit at {paths.audit_dir(Path(paper_root))}")
|
|
31
|
+
typer.echo(f" run_id: {state['run_id']}")
|
|
32
|
+
typer.echo(f" stage: {state['stage']}")
|
|
33
|
+
raise typer.Exit(0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def scan(
|
|
38
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
39
|
+
json_out: bool = typer.Option(
|
|
40
|
+
False, "--json", help="Print the full structure JSON instead of a summary."
|
|
41
|
+
),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Scan a paper's LaTeX sources into a structured representation."""
|
|
44
|
+
root = Path(paper_root)
|
|
45
|
+
result = texscan.scan(root)
|
|
46
|
+
|
|
47
|
+
if json_out:
|
|
48
|
+
typer.echo(json.dumps(result, indent=2))
|
|
49
|
+
raise typer.Exit(0)
|
|
50
|
+
|
|
51
|
+
typer.echo(f"Scanned {root}")
|
|
52
|
+
typer.echo(f" tex files: {len(result['files']['tex'])}")
|
|
53
|
+
typer.echo(f" theorem envs: {len(result['theorem_envs'])}")
|
|
54
|
+
typer.echo(f" duplicate labels: {len(result['duplicate_labels'])}")
|
|
55
|
+
typer.echo(f" unresolved refs: {len(result['unresolved_refs'])}")
|
|
56
|
+
typer.echo(f" unresolved citations: {len(result['unresolved_citations'])}")
|
|
57
|
+
typer.echo(f" draft markers: {len(result['draft_markers'])}")
|
|
58
|
+
typer.echo(f"structure.json -> {paths.structure_file(root)}")
|
|
59
|
+
raise typer.Exit(0)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def segments(
|
|
64
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Propose audit segments for a paper."""
|
|
67
|
+
from papercheck.core import segments as segments_mod
|
|
68
|
+
|
|
69
|
+
root = Path(paper_root)
|
|
70
|
+
struct_path = paths.structure_file(root)
|
|
71
|
+
if struct_path.exists():
|
|
72
|
+
structure = json.loads(struct_path.read_text(encoding="utf-8"))
|
|
73
|
+
else:
|
|
74
|
+
structure = texscan.scan(root)
|
|
75
|
+
|
|
76
|
+
records = segments_mod.write_segments(root, structure)
|
|
77
|
+
|
|
78
|
+
budget_counts: dict[str, int] = {}
|
|
79
|
+
for rec in records:
|
|
80
|
+
budget_counts[rec["budget"]] = budget_counts.get(rec["budget"], 0) + 1
|
|
81
|
+
summary = ", ".join(
|
|
82
|
+
f"{level}={budget_counts.get(level, 0)}" for level in ("HIGH", "MEDIUM", "LOW")
|
|
83
|
+
)
|
|
84
|
+
typer.echo(f"Proposed {len(records)} segment(s): {summary}")
|
|
85
|
+
typer.echo(f"segments.json -> {paths.segments_file(root)}")
|
|
86
|
+
raise typer.Exit(0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def gate(
|
|
91
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
92
|
+
mechanical_only: bool = typer.Option(
|
|
93
|
+
False, "--mechanical-only", help="Run only the mechanical gate signals."
|
|
94
|
+
),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Run the final gate checks for a paper."""
|
|
97
|
+
from papercheck.core.gate import run_gate
|
|
98
|
+
|
|
99
|
+
root = Path(paper_root)
|
|
100
|
+
result = run_gate(root, mechanical_only=mechanical_only)
|
|
101
|
+
|
|
102
|
+
typer.echo("")
|
|
103
|
+
typer.echo(f"==== {result['verdict']} ====")
|
|
104
|
+
blockers = result.get("blockers", [])
|
|
105
|
+
if blockers:
|
|
106
|
+
typer.echo("Blockers:")
|
|
107
|
+
for b in blockers:
|
|
108
|
+
typer.echo(f" - {b}")
|
|
109
|
+
else:
|
|
110
|
+
typer.echo("No blockers.")
|
|
111
|
+
|
|
112
|
+
ready = result["verdict"] in {"READY", "READY AFTER MECHANICAL FIXES"}
|
|
113
|
+
raise typer.Exit(0 if ready else 1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def render(
|
|
118
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Render audit reports for a paper."""
|
|
121
|
+
from papercheck.core.render import render_all
|
|
122
|
+
|
|
123
|
+
root = Path(paper_root)
|
|
124
|
+
render_all(root)
|
|
125
|
+
|
|
126
|
+
out_dir = paths.audit_dir(root)
|
|
127
|
+
candidates = [
|
|
128
|
+
"03_segment_map.md",
|
|
129
|
+
"07_issue_ledger.proposed.md",
|
|
130
|
+
"08_issue_ledger.adjudicated.md",
|
|
131
|
+
"10_final_acceptance_gate.md",
|
|
132
|
+
"manual_check_queue.md",
|
|
133
|
+
]
|
|
134
|
+
written = [name for name in candidates if (out_dir / name).exists()]
|
|
135
|
+
if written:
|
|
136
|
+
typer.echo("Wrote:")
|
|
137
|
+
for name in written:
|
|
138
|
+
typer.echo(f" - {out_dir / name}")
|
|
139
|
+
else:
|
|
140
|
+
typer.echo("No reports written (no source artifacts found).")
|
|
141
|
+
raise typer.Exit(0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command("verify-quote")
|
|
145
|
+
def verify_quote(
|
|
146
|
+
file: Path = typer.Argument(..., help="Path to the source file to search."),
|
|
147
|
+
quote: str = typer.Argument(..., help="The exact quote to look for."),
|
|
148
|
+
line_start: int | None = typer.Option(None, "--line-start", help="1-based start line."),
|
|
149
|
+
line_end: int | None = typer.Option(None, "--line-end", help="1-based end line."),
|
|
150
|
+
slack: int = typer.Option(0, "--slack", help="Lines of slack around the window."),
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Verify that a quote appears in a paper's source."""
|
|
153
|
+
from papercheck.core.verify import verify_quote as _verify_quote
|
|
154
|
+
|
|
155
|
+
found = _verify_quote(file, quote, line_start, line_end, slack)
|
|
156
|
+
typer.echo("QUOTE FOUND" if found else "QUOTE NOT FOUND")
|
|
157
|
+
raise typer.Exit(code=0 if found else 1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def prompts(
|
|
162
|
+
action: str = typer.Argument("list", help="Either 'list' or 'show'."),
|
|
163
|
+
name: str | None = typer.Argument(None, help="Prompt name (for 'show')."),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Show the audit prompt pack ('list' names, or 'show <name>')."""
|
|
166
|
+
from papercheck.mcp_server import handlers
|
|
167
|
+
|
|
168
|
+
if action == "list":
|
|
169
|
+
typer.echo(handlers.list_prompts())
|
|
170
|
+
raise typer.Exit(0)
|
|
171
|
+
if action == "show":
|
|
172
|
+
if not name:
|
|
173
|
+
typer.echo("prompts show requires a NAME argument")
|
|
174
|
+
raise typer.Exit(2)
|
|
175
|
+
try:
|
|
176
|
+
typer.echo(handlers.get_prompt(name))
|
|
177
|
+
except KeyError:
|
|
178
|
+
typer.echo(f"No such prompt: {name}")
|
|
179
|
+
raise typer.Exit(1) from None
|
|
180
|
+
raise typer.Exit(0)
|
|
181
|
+
typer.echo(f"Unknown action {action!r}; expected 'list' or 'show'")
|
|
182
|
+
raise typer.Exit(2)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
def report(
|
|
187
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Render a self-contained HTML audit report from the Paper_Audit artifacts."""
|
|
190
|
+
from papercheck.core.html_report import render_html
|
|
191
|
+
|
|
192
|
+
out = render_html(Path(paper_root))
|
|
193
|
+
typer.echo(f"HTML report -> {out}")
|
|
194
|
+
raise typer.Exit(0)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def compare(
|
|
199
|
+
old_root: str = typer.Argument(..., help="Path to the OLD version's source root."),
|
|
200
|
+
new_root: str = typer.Argument(..., help="Path to the NEW version's source root."),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Compare two versions of a paper and write a structural diff report."""
|
|
203
|
+
from papercheck.core.compare import write_compare_report
|
|
204
|
+
|
|
205
|
+
out = write_compare_report(Path(old_root), Path(new_root))
|
|
206
|
+
typer.echo(f"Version comparison -> {out}")
|
|
207
|
+
raise typer.Exit(0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def profile(
|
|
212
|
+
action: str = typer.Argument("list", help="Either 'list' or 'show'."),
|
|
213
|
+
name: str | None = typer.Argument(None, help="Profile name (for 'show')."),
|
|
214
|
+
) -> None:
|
|
215
|
+
"""List advisory audit profiles, or show one profile's recommended steps."""
|
|
216
|
+
from papercheck.core import profiles as profiles_mod
|
|
217
|
+
|
|
218
|
+
if action == "list":
|
|
219
|
+
for pname in profiles_mod.list_profiles():
|
|
220
|
+
prof = profiles_mod.get_profile(pname)
|
|
221
|
+
typer.echo(f"{pname}: {prof.get('description', '')}")
|
|
222
|
+
raise typer.Exit(0)
|
|
223
|
+
if action == "show":
|
|
224
|
+
if not name:
|
|
225
|
+
typer.echo("profile show requires a NAME argument")
|
|
226
|
+
raise typer.Exit(2)
|
|
227
|
+
try:
|
|
228
|
+
prof = profiles_mod.get_profile(name)
|
|
229
|
+
except KeyError:
|
|
230
|
+
typer.echo(f"No such profile: {name}")
|
|
231
|
+
raise typer.Exit(1) from None
|
|
232
|
+
typer.echo(f"{name}: {prof.get('description', '')}")
|
|
233
|
+
typer.echo(f" mechanical_only: {prof.get('mechanical_only')}")
|
|
234
|
+
typer.echo(" steps:")
|
|
235
|
+
for step in prof.get("steps", []):
|
|
236
|
+
typer.echo(f" - {step}")
|
|
237
|
+
raise typer.Exit(0)
|
|
238
|
+
typer.echo(f"Unknown action {action!r}; expected 'list' or 'show'")
|
|
239
|
+
raise typer.Exit(2)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def packs(
|
|
244
|
+
action: str = typer.Argument("list", help="One of 'list', 'show', 'scaffold', 'create'."),
|
|
245
|
+
name: str | None = typer.Argument(None, help="Pack name (for 'show')."),
|
|
246
|
+
paper_root: str | None = typer.Option(
|
|
247
|
+
None, "--paper-root", help="Paper root, for paper-local scaffold/create/generated packs."
|
|
248
|
+
),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Manage domain packs: list/show shipped packs, or scaffold/create one from a paper."""
|
|
251
|
+
from papercheck.core import domainpack
|
|
252
|
+
|
|
253
|
+
root = Path(paper_root) if paper_root else None
|
|
254
|
+
|
|
255
|
+
if action == "list":
|
|
256
|
+
for pname in domainpack.list_packs(root):
|
|
257
|
+
typer.echo(pname)
|
|
258
|
+
raise typer.Exit(0)
|
|
259
|
+
if action == "show":
|
|
260
|
+
if not name:
|
|
261
|
+
typer.echo("packs show requires a NAME argument")
|
|
262
|
+
raise typer.Exit(2)
|
|
263
|
+
try:
|
|
264
|
+
typer.echo(json.dumps(domainpack.load_pack(name, root), indent=2))
|
|
265
|
+
except KeyError:
|
|
266
|
+
typer.echo(f"No such domain pack: {name}")
|
|
267
|
+
raise typer.Exit(1) from None
|
|
268
|
+
raise typer.Exit(0)
|
|
269
|
+
if action == "scaffold":
|
|
270
|
+
if root is None:
|
|
271
|
+
typer.echo("packs scaffold requires --paper-root")
|
|
272
|
+
raise typer.Exit(2)
|
|
273
|
+
struct_path = paths.structure_file(root)
|
|
274
|
+
if struct_path.exists():
|
|
275
|
+
structure = json.loads(struct_path.read_text(encoding="utf-8"))
|
|
276
|
+
else:
|
|
277
|
+
structure = texscan.scan(root)
|
|
278
|
+
typer.echo(json.dumps(domainpack.scaffold_pack(structure), indent=2))
|
|
279
|
+
typer.echo("")
|
|
280
|
+
typer.echo(
|
|
281
|
+
"# Draft only. Refine the fields, save to a JSON file, "
|
|
282
|
+
"then: papercheck packs create --paper-root <root> <file.json>"
|
|
283
|
+
)
|
|
284
|
+
raise typer.Exit(0)
|
|
285
|
+
if action == "create":
|
|
286
|
+
if root is None:
|
|
287
|
+
typer.echo("packs create requires --paper-root")
|
|
288
|
+
raise typer.Exit(2)
|
|
289
|
+
if not name:
|
|
290
|
+
typer.echo("packs create requires a PATH to a pack JSON file as NAME argument")
|
|
291
|
+
raise typer.Exit(2)
|
|
292
|
+
pack = json.loads(Path(name).read_text(encoding="utf-8"))
|
|
293
|
+
out = domainpack.create_pack(root, pack)
|
|
294
|
+
typer.echo(f"Domain pack -> {out}")
|
|
295
|
+
raise typer.Exit(0)
|
|
296
|
+
typer.echo(f"Unknown action {action!r}; expected list/show/scaffold/create")
|
|
297
|
+
raise typer.Exit(2)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@app.command()
|
|
301
|
+
def serve(
|
|
302
|
+
paper_root: str = typer.Argument(..., help="Path to the paper's source root."),
|
|
303
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind (localhost)."),
|
|
304
|
+
port: int = typer.Option(8765, "--port", help="Port to bind."),
|
|
305
|
+
open_browser: bool = typer.Option(
|
|
306
|
+
False, "--open", help="Open the UI in a web browser after starting."
|
|
307
|
+
),
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Serve an interactive local web UI for a paper's audit state (blocking)."""
|
|
310
|
+
from papercheck.core.webserve import serve as _serve
|
|
311
|
+
|
|
312
|
+
_serve(Path(paper_root), host=host, port=port, open_browser=open_browser)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command()
|
|
316
|
+
def mcp() -> None:
|
|
317
|
+
"""Run the papercheck MCP server (blocking stdio transport)."""
|
|
318
|
+
from papercheck.mcp_server.server import main as _server_main
|
|
319
|
+
|
|
320
|
+
_server_main()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic core of the papercheck audit harness."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Locate bundled data directories in both installed and source layouts.
|
|
2
|
+
|
|
3
|
+
The four data directories (``schemas``, ``prompts``, ``templates``,
|
|
4
|
+
``domain_packs``) live at the repo root during development but are copied
|
|
5
|
+
*inside* the package (via hatchling ``force-include``) when a wheel is built.
|
|
6
|
+
This module resolves either layout so runtime code works identically whether
|
|
7
|
+
``papercheck`` is an editable checkout or an installed wheel.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resource_dir(name: str) -> Path:
|
|
16
|
+
"""Return the directory for a bundled data resource.
|
|
17
|
+
|
|
18
|
+
Tries, in order:
|
|
19
|
+
|
|
20
|
+
(a) package-relative — ``site-packages/papercheck/<name>`` in an installed
|
|
21
|
+
wheel (``.parent.parent`` of this file is ``papercheck/``);
|
|
22
|
+
(b) repo-root fallback — ``<repo>/<name>`` in an editable/source layout
|
|
23
|
+
(``parents[3]`` of this file is the repo root).
|
|
24
|
+
|
|
25
|
+
Raises :class:`FileNotFoundError` if neither exists.
|
|
26
|
+
"""
|
|
27
|
+
packaged = Path(__file__).resolve().parent.parent / name
|
|
28
|
+
if packaged.is_dir():
|
|
29
|
+
return packaged
|
|
30
|
+
repo_root = Path(__file__).resolve().parents[3] / name
|
|
31
|
+
if repo_root.is_dir():
|
|
32
|
+
return repo_root
|
|
33
|
+
raise FileNotFoundError(name)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resource_file(name: str, filename: str) -> Path:
|
|
37
|
+
"""Return the path to ``filename`` inside the named resource directory."""
|
|
38
|
+
return resource_dir(name) / filename
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Adjudication, patch planning, and regression helpers.
|
|
2
|
+
|
|
3
|
+
These functions operate on the file-based ledgers to move issues through their
|
|
4
|
+
post-audit lifecycle: an adjudicator accepts/rejects/defers each proposed
|
|
5
|
+
issue, accepted issues are folded into planned then applied patches, and
|
|
6
|
+
regression results are recorded. Manual checks are created and resolved here
|
|
7
|
+
too.
|
|
8
|
+
|
|
9
|
+
All functions are pure with respect to wall-clock time (no timestamps are
|
|
10
|
+
generated) so they remain deterministic and testable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from papercheck.core import ledger, schemas
|
|
19
|
+
from papercheck.core.paths import audit_dir, manual_checks_dir
|
|
20
|
+
|
|
21
|
+
# Decisions accepted by :func:`adjudicate_issue` and the status each maps to.
|
|
22
|
+
_DECISION_TO_STATUS: dict[str, str] = {
|
|
23
|
+
"ACCEPT": "ACCEPTED",
|
|
24
|
+
"REJECT": "REJECTED",
|
|
25
|
+
"NEEDS_MANUAL_CHECK": "NEEDS_MANUAL_CHECK",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Issue statuses that count as belonging to the "accepted family": an issue in
|
|
29
|
+
# any of these has passed adjudication and is eligible for patch planning.
|
|
30
|
+
_ACCEPTED_FAMILY: frozenset[str] = frozenset(
|
|
31
|
+
{"ACCEPTED", "PATCH_PLANNED", "PATCHED", "REGRESSION_PASSED", "CLOSED"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Regression outcomes accepted by :func:`record_regression_result`.
|
|
35
|
+
_REGRESSION_RESULTS: frozenset[str] = frozenset(
|
|
36
|
+
{"FIXED", "PARTIALLY_FIXED", "NOT_FIXED", "NEW_PROBLEM"}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _regression_file(paper_root: Path) -> Path:
|
|
41
|
+
return audit_dir(Path(paper_root)) / "regression.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def adjudicate_issue(
|
|
45
|
+
paper_root: Path,
|
|
46
|
+
issue_id: str,
|
|
47
|
+
decision: str,
|
|
48
|
+
rationale: str,
|
|
49
|
+
adjudicator: str,
|
|
50
|
+
severity_final: str | None = None,
|
|
51
|
+
) -> dict:
|
|
52
|
+
"""Record an adjudication decision on an issue and re-route its file.
|
|
53
|
+
|
|
54
|
+
``decision`` must be one of ACCEPT / REJECT / NEEDS_MANUAL_CHECK. The issue
|
|
55
|
+
is loaded from whichever status folder holds it, annotated with the
|
|
56
|
+
decision, optionally given a final severity, and re-saved into the folder
|
|
57
|
+
implied by its new status.
|
|
58
|
+
|
|
59
|
+
Raises :class:`KeyError` when the issue is absent and :class:`ValueError`
|
|
60
|
+
for an unknown decision.
|
|
61
|
+
"""
|
|
62
|
+
paper_root = Path(paper_root)
|
|
63
|
+
|
|
64
|
+
if decision not in _DECISION_TO_STATUS:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"Unknown decision {decision!r}; expected one of "
|
|
67
|
+
f"{sorted(_DECISION_TO_STATUS)}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# KeyError propagates when the issue does not exist.
|
|
71
|
+
issue = ledger.load_issue(paper_root, issue_id)
|
|
72
|
+
|
|
73
|
+
issue["adjudication"] = {
|
|
74
|
+
"decision": decision,
|
|
75
|
+
"rationale": rationale,
|
|
76
|
+
"adjudicator": adjudicator,
|
|
77
|
+
}
|
|
78
|
+
if severity_final is not None:
|
|
79
|
+
issue["severity_final"] = severity_final
|
|
80
|
+
issue["status"] = _DECISION_TO_STATUS[decision]
|
|
81
|
+
|
|
82
|
+
# Remove the old file (it may live in a different folder than the new
|
|
83
|
+
# status routes to) before saving the mutated issue.
|
|
84
|
+
old_path = ledger._find_issue_path(paper_root, issue_id)
|
|
85
|
+
if old_path is not None:
|
|
86
|
+
old_path.unlink()
|
|
87
|
+
ledger.save_issue(paper_root, issue)
|
|
88
|
+
return issue
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plan_patch(paper_root: Path, patch: dict) -> dict:
|
|
92
|
+
"""Validate and record a *planned* patch over accepted issues.
|
|
93
|
+
|
|
94
|
+
Refuses (raises :class:`ValueError`) unless every id in
|
|
95
|
+
``patch["accepted_issue_ids"]`` refers to an issue currently in an
|
|
96
|
+
accepted-family status. On success the patch is saved and each referenced
|
|
97
|
+
issue is bumped to ``PATCH_PLANNED``.
|
|
98
|
+
"""
|
|
99
|
+
paper_root = Path(paper_root)
|
|
100
|
+
schemas.validate(patch, "patch")
|
|
101
|
+
|
|
102
|
+
for issue_id in patch["accepted_issue_ids"]:
|
|
103
|
+
try:
|
|
104
|
+
issue = ledger.load_issue(paper_root, issue_id)
|
|
105
|
+
except KeyError as exc:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Patch references unknown issue {issue_id!r}"
|
|
108
|
+
) from exc
|
|
109
|
+
if issue.get("status") not in _ACCEPTED_FAMILY:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Patch references issue {issue_id!r} with non-accepted status "
|
|
112
|
+
f"{issue.get('status')!r}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
ledger.save_patch(paper_root, patch)
|
|
116
|
+
for issue_id in patch["accepted_issue_ids"]:
|
|
117
|
+
ledger.move_issue(paper_root, issue_id, "PATCH_PLANNED")
|
|
118
|
+
return patch
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def record_patch(paper_root: Path, patch: dict) -> dict:
|
|
122
|
+
"""Validate and record an *applied* patch, moving issues to ``PATCHED``.
|
|
123
|
+
|
|
124
|
+
Like :func:`plan_patch` but represents a patch that has actually been
|
|
125
|
+
applied; referenced issues are moved to ``PATCHED``.
|
|
126
|
+
"""
|
|
127
|
+
paper_root = Path(paper_root)
|
|
128
|
+
schemas.validate(patch, "patch")
|
|
129
|
+
|
|
130
|
+
for issue_id in patch["accepted_issue_ids"]:
|
|
131
|
+
try:
|
|
132
|
+
issue = ledger.load_issue(paper_root, issue_id)
|
|
133
|
+
except KeyError as exc:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"Patch references unknown issue {issue_id!r}"
|
|
136
|
+
) from exc
|
|
137
|
+
if issue.get("status") not in _ACCEPTED_FAMILY:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Patch references issue {issue_id!r} with non-accepted status "
|
|
140
|
+
f"{issue.get('status')!r}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
ledger.save_patch(paper_root, patch)
|
|
144
|
+
for issue_id in patch["accepted_issue_ids"]:
|
|
145
|
+
ledger.move_issue(paper_root, issue_id, "PATCHED")
|
|
146
|
+
return patch
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record_regression_result(paper_root: Path, issue_id: str, result: str) -> dict:
|
|
150
|
+
"""Append a regression outcome for an issue; promote it if ``FIXED``.
|
|
151
|
+
|
|
152
|
+
Results are appended to ``Paper_Audit/regression.json`` (a JSON list). A
|
|
153
|
+
``FIXED`` result also moves the issue to ``REGRESSION_PASSED``.
|
|
154
|
+
|
|
155
|
+
Raises :class:`ValueError` for an unknown result.
|
|
156
|
+
"""
|
|
157
|
+
paper_root = Path(paper_root)
|
|
158
|
+
if result not in _REGRESSION_RESULTS:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Unknown regression result {result!r}; expected one of "
|
|
161
|
+
f"{sorted(_REGRESSION_RESULTS)}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
path = _regression_file(paper_root)
|
|
165
|
+
if path.exists():
|
|
166
|
+
records = json.loads(path.read_text(encoding="utf-8"))
|
|
167
|
+
else:
|
|
168
|
+
records = []
|
|
169
|
+
entry = {"issue_id": issue_id, "result": result}
|
|
170
|
+
records.append(entry)
|
|
171
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
path.write_text(json.dumps(records, indent=2) + "\n", encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
if result == "FIXED":
|
|
175
|
+
ledger.move_issue(paper_root, issue_id, "REGRESSION_PASSED")
|
|
176
|
+
|
|
177
|
+
return dict(entry)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def next_check_id(paper_root: Path) -> str:
|
|
181
|
+
"""Return the next unused manual-check id (e.g. ``MANUAL-3``)."""
|
|
182
|
+
max_n = 0
|
|
183
|
+
for check in ledger.list_manual_checks(Path(paper_root)):
|
|
184
|
+
check_id = check.get("check_id", "")
|
|
185
|
+
prefix, sep, suffix = check_id.rpartition("-")
|
|
186
|
+
if sep and prefix == "MANUAL" and suffix.isdigit():
|
|
187
|
+
max_n = max(max_n, int(suffix))
|
|
188
|
+
return f"MANUAL-{max_n + 1}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def add_manual_check(
|
|
192
|
+
paper_root: Path,
|
|
193
|
+
question: str,
|
|
194
|
+
needed_source: str | None = None,
|
|
195
|
+
blocking: bool = True,
|
|
196
|
+
owner: str = "human",
|
|
197
|
+
) -> dict:
|
|
198
|
+
"""Create, validate, and persist a new manual check. Return it."""
|
|
199
|
+
paper_root = Path(paper_root)
|
|
200
|
+
check = {
|
|
201
|
+
"check_id": next_check_id(paper_root),
|
|
202
|
+
"question": question,
|
|
203
|
+
"needed_source": needed_source,
|
|
204
|
+
"blocking": blocking,
|
|
205
|
+
"owner": owner,
|
|
206
|
+
"resolution": None,
|
|
207
|
+
"resolved": False,
|
|
208
|
+
}
|
|
209
|
+
schemas.validate(check, "manual_check")
|
|
210
|
+
ledger.save_manual_check(paper_root, check)
|
|
211
|
+
return check
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def resolve_manual_check(paper_root: Path, check_id: str, resolution: str) -> dict:
|
|
215
|
+
"""Mark a manual check resolved with the given resolution text. Return it.
|
|
216
|
+
|
|
217
|
+
Raises :class:`KeyError` if the check does not exist.
|
|
218
|
+
"""
|
|
219
|
+
paper_root = Path(paper_root)
|
|
220
|
+
path = manual_checks_dir(paper_root) / f"{check_id}.json"
|
|
221
|
+
if not path.is_file():
|
|
222
|
+
raise KeyError(f"Manual check {check_id!r} not found")
|
|
223
|
+
check = json.loads(path.read_text(encoding="utf-8"))
|
|
224
|
+
check["resolved"] = True
|
|
225
|
+
check["resolution"] = resolution
|
|
226
|
+
schemas.validate(check, "manual_check")
|
|
227
|
+
ledger.save_manual_check(paper_root, check)
|
|
228
|
+
return check
|