machinechecked 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.
Files changed (58) hide show
  1. machinechecked-0.1.0.dist-info/METADATA +108 -0
  2. machinechecked-0.1.0.dist-info/RECORD +58 -0
  3. machinechecked-0.1.0.dist-info/WHEEL +5 -0
  4. machinechecked-0.1.0.dist-info/entry_points.txt +3 -0
  5. machinechecked-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. machinechecked-0.1.0.dist-info/top_level.txt +1 -0
  7. prooflens/__init__.py +1 -0
  8. prooflens/__main__.py +3 -0
  9. prooflens/assess.py +504 -0
  10. prooflens/audit.py +115 -0
  11. prooflens/bundle.py +70 -0
  12. prooflens/cli.py +542 -0
  13. prooflens/config.py +145 -0
  14. prooflens/data/ProofLensProbe.lean +469 -0
  15. prooflens/data/prooflens.sty +75 -0
  16. prooflens/data/schema/evidence.schema.json +24 -0
  17. prooflens/data/schema/inventory.schema.json +55 -0
  18. prooflens/data/schema/manifest-claim.schema.json +126 -0
  19. prooflens/data/schema/manifest-index.schema.json +128 -0
  20. prooflens/evidence.py +60 -0
  21. prooflens/export.py +227 -0
  22. prooflens/formalization.py +106 -0
  23. prooflens/manifest.py +296 -0
  24. prooflens/pdfclaims.py +129 -0
  25. prooflens/pipeline.py +331 -0
  26. prooflens/probe.py +126 -0
  27. prooflens/promote.py +204 -0
  28. prooflens/review_queue.py +138 -0
  29. prooflens/reviews.py +105 -0
  30. prooflens/schemas.py +19 -0
  31. prooflens/server.py +63 -0
  32. prooflens/simple.py +628 -0
  33. prooflens/sources.py +164 -0
  34. prooflens/tex.py +408 -0
  35. prooflens/util.py +66 -0
  36. prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  37. prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  38. prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  39. prooflens/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  40. prooflens/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  41. prooflens/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  42. prooflens/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  43. prooflens/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  44. prooflens/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  45. prooflens/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  46. prooflens/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  47. prooflens/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. prooflens/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  49. prooflens/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  50. prooflens/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  51. prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  54. prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  55. prooflens/web/assets/index-B2GDDbsW.css +1 -0
  56. prooflens/web/assets/index-DN7uLe7p.js +285 -0
  57. prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  58. prooflens/web/index.html +14 -0
prooflens/promote.py ADDED
@@ -0,0 +1,204 @@
1
+ """Promotion of review reports: from a reviewer's report to the project's review markers.
2
+
3
+ A report (exported from the review queue) records what a reviewer read, compared and decided.
4
+ Promotion is the maintainer's step. It refuses a report that does not apply to the project as
5
+ it now is, or that the acceptance rules cannot count as an independent review:
6
+
7
+ * the evidence must be current (the Lean inputs unchanged since the last build);
8
+ * the unit's fingerprints (and the 32-digit legacy hash the markers record) and the manuscript
9
+ revisions must be those the report was made at;
10
+ * the verdict must be faithful, faithful with representation choices (listed), or broader (with
11
+ the instantiation that gives the printed claim);
12
+ * the reading must have been locked before the verdict, and the verdict recorded before the
13
+ author's review was shown;
14
+ * the reviewer must not be an author of the statements, and a model of the same family as the
15
+ author (or of unknown family) is refused unless the maintainer accepts it explicitly.
16
+
17
+ For each report it accepts, it archives the report (JSON and the protocol's text block) and
18
+ writes `<!-- spec-review: NAME HASH by=REVIEWER -->` (or `def-review`) after the declaration's
19
+ last marker, in the exact form the acceptance checker reads, then assesses the stored evidence
20
+ again. A promoted marker records the reviewer the report names; who they are is as the report
21
+ states it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ from pathlib import Path
29
+
30
+ from . import evidence as ev
31
+ from .config import Config
32
+ from .reviews import history, load_reviews, review_files
33
+ from .util import read_json
34
+
35
+ ACCEPTABLE = {"faithful", "faithful with representation choices", "broader"}
36
+ NEEDS_DETAIL = {"faithful with representation choices", "broader"}
37
+ HEX32 = re.compile(r"^[0-9a-f]{32}$")
38
+
39
+
40
+ class PromoteError(RuntimeError):
41
+ pass
42
+
43
+
44
+ def latest_record(cfg: Config) -> dict:
45
+ rels = sorted((cfg.out_dir / "releases").glob("*.json"), key=lambda p: p.stat().st_mtime)
46
+ if not rels:
47
+ raise PromoteError("no release: run `prooflens build` first")
48
+ return ev.load(cfg.out_dir, read_json(rels[-1])["evidence"])
49
+
50
+
51
+ def load_reports(paths: list[Path]) -> list[dict]:
52
+ out = []
53
+ for p in paths:
54
+ d = json.loads(Path(p).read_text(encoding="utf-8"))
55
+ out += d["reports"] if "reports" in d else [d]
56
+ return out
57
+
58
+
59
+ def check_report(r: dict, body: dict, cfg: Config, accept_same_family: bool) -> list[str]:
60
+ """Why `r` cannot be promoted against the evidence `body` (empty if it can)."""
61
+ why = []
62
+ if r.get("schema") != "prooflens/review-report@1":
63
+ return [f"not a review report (schema {r.get('schema')!r})"]
64
+ u, pins, rev = r["unit"], r["pins"], r["reviewer"]
65
+ lean = body.get("lean") or {}
66
+ if u["kind"] == "spec":
67
+ cur = lean.get("specs", {}).get(u["name"])
68
+ elif u["kind"] == "def":
69
+ cur = lean.get("decls", {}).get(u["name"])
70
+ else:
71
+ return [f"a {u['kind']} unit has no review marker in this project"]
72
+ if cur is None:
73
+ return [f"{u['name']} is not in the current evidence"]
74
+ if pins.get("fingerprint") != cur.get("fingerprint"):
75
+ why.append("the declaration changed since the review (fingerprint differs)")
76
+ if pins.get("legacy_hash") != cur.get("legacy_hash") or not HEX32.match(
77
+ cur.get("legacy_hash") or ""):
78
+ why.append("the legacy hash differs from the current one")
79
+ papers = body["provenance"]["papers"]
80
+ for pid, revn in (pins.get("manuscripts") or {}).items():
81
+ if pid in papers and papers[pid].get("revision") != revn:
82
+ why.append(f"paper {pid} is at another revision than the one reviewed")
83
+ v = r["verdict"]["value"]
84
+ if v not in ACCEPTABLE:
85
+ why.append(f"verdict {v!r} is not one that is recorded as a review")
86
+ elif v in NEEDS_DETAIL and not r["verdict"].get("detail", "").strip():
87
+ why.append(f"verdict {v!r} needs its detail (representation choices or instantiation)")
88
+ if not r["reading"].get("text", "").strip():
89
+ why.append("the report has no reading of the paper")
90
+ t = [r["reading"].get("locked_at"), r["comparison"].get("decided_at"),
91
+ r["author_review"].get("shown_at"), r.get("submitted_at")]
92
+ if None in t or t != sorted(t):
93
+ why.append("the steps are not recorded in order (reading, verdict, author's review)")
94
+ rid = rev.get("id", "")
95
+ if not rid or rid in cfg.statement_authors or rev.get("name") in cfg.statement_authors:
96
+ why.append(f"reviewer {rid!r} is an author of the statements, or unnamed")
97
+ if rev.get("kind") == "model" and rev.get("same_family_as_author") != "no" \
98
+ and not accept_same_family:
99
+ why.append("a model reviewer of the same family as the author, or of unknown family "
100
+ "(pass --accept-same-family to accept it)")
101
+ if not re.fullmatch(r"\S+", rid):
102
+ why.append(f"reviewer id {rid!r} has spaces")
103
+ return why
104
+
105
+
106
+ def marker(r: dict, cur: dict) -> str:
107
+ return (f"<!-- {r['unit']['kind']}-review: {r['unit']['name']} {cur['legacy_hash']} "
108
+ f"by={r['reviewer']['id']} -->")
109
+
110
+
111
+ def report_text(r: dict, cur: dict) -> str:
112
+ """The report as the protocol's text block."""
113
+ u, p, rv = r["unit"], r["pins"], r["reviewer"]
114
+ ms = "; ".join(f"{k} {v}" for k, v in (p.get("manuscripts") or {}).items()) or "—"
115
+ return "\n".join([
116
+ f"### {', '.join(u['claims']) or u['id']}: {u['name']}",
117
+ f"Pins: manuscript {ms}; project {p.get('project_commit')}; "
118
+ f"hash {cur['legacy_hash']} ({p.get('fingerprint')})",
119
+ f"Reviewer: {rv['id']} ({rv['kind']}{', ' + rv['model'] if rv.get('model') else ''}, "
120
+ f"self-declared); {r['submitted_at']}; same model family as the author: "
121
+ f"{rv.get('same_family_as_author')}",
122
+ f"Phase 1 specification (locked {r['reading']['locked_at']}):", r["reading"]["text"],
123
+ f"Comparison (verdict recorded {r['comparison']['decided_at']}):",
124
+ r["comparison"]["text"],
125
+ f"Witness: {r['comparison'].get('witness') or '—'}",
126
+ f"Verdict: {r['verdict']['value']}"
127
+ + (f" — {r['verdict']['detail']}" if r['verdict'].get('detail') else ""),
128
+ f"Disagreements with the author's review (shown {r['author_review']['shown_at']}): "
129
+ f"{r['author_review'].get('disagreements') or 'none'}", ""])
130
+
131
+
132
+ def target_file(cfg: Config, r: dict, reviews) -> tuple[Path, int | None]:
133
+ """Where the marker goes: after the declaration's last marker, else at the end of the
134
+ paper's review file (specs) or of the definitions' review file."""
135
+ u = r["unit"]
136
+ hist = history(reviews, u["kind"], u["name"])
137
+ if hist:
138
+ return (cfg.root / hist[-1].file).resolve(), hist[-1].line
139
+ files = review_files(cfg.reviews)
140
+ if not files:
141
+ raise PromoteError("the configuration names no review file")
142
+ want = "definitions.md" if u["kind"] == "def" else f"{(u.get('papers') or ['?'])[0]}.md"
143
+ return next((f for f in files if f.name == want), files[-1]), None
144
+
145
+
146
+ def insert_after(path: Path, line: int | None, lines: list[str]):
147
+ txt = path.read_text(encoding="utf-8").split("\n")
148
+ if line is None:
149
+ while txt and not txt[-1].strip():
150
+ txt.pop()
151
+ txt += [""] + lines + [""]
152
+ else:
153
+ indent = re.match(r"\s*", txt[line - 1]).group(0)
154
+ txt[line:line] = [indent + l for l in lines]
155
+ path.write_text("\n".join(txt), encoding="utf-8")
156
+
157
+
158
+ def reports_dir(cfg: Config) -> Path:
159
+ first = next((p for p in cfg.reviews if p.is_dir()), cfg.reviews[0].parent if cfg.reviews
160
+ else cfg.root)
161
+ return first / "reports"
162
+
163
+
164
+ def plan(cfg: Config, reports: list[dict], accept_same_family: bool = False):
165
+ """For each report: (report, current record, reasons it is refused)."""
166
+ body = latest_record(cfg)["body"]
167
+ from .pipeline import lean_inputs_digest
168
+ if body["provenance"].get("lean_inputs_sha256") != lean_inputs_digest(cfg):
169
+ raise PromoteError("the Lean project changed since the last build: run `prooflens build` "
170
+ "first, so the reports are checked against the current declarations")
171
+ lean = body.get("lean") or {}
172
+ out = []
173
+ for r in reports:
174
+ u = r.get("unit", {})
175
+ cur = (lean.get("specs" if u.get("kind") == "spec" else "decls", {}) or {}).get(
176
+ u.get("name"))
177
+ out.append((r, cur, check_report(r, body, cfg, accept_same_family)))
178
+ return out
179
+
180
+
181
+ def apply(cfg: Config, accepted: list[tuple[dict, dict]]) -> list[dict]:
182
+ """Archive each report and write its marker."""
183
+ reviews, _ = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
184
+ rdir = reports_dir(cfg)
185
+ rdir.mkdir(parents=True, exist_ok=True)
186
+ done = []
187
+ for r, cur in accepted:
188
+ stem = re.sub(r"[^\w.-]+", "_", f"{r['unit']['name']}--{r['reviewer']['id']}--"
189
+ f"{r['submitted_at']}")
190
+ (rdir / f"{stem}.json").write_text(json.dumps(r, indent=1, ensure_ascii=False) + "\n",
191
+ encoding="utf-8")
192
+ (rdir / f"{stem}.md").write_text(report_text(r, cur), encoding="utf-8")
193
+ path, line = target_file(cfg, r, reviews)
194
+ rel = Path(rdir / f"{stem}.md").resolve()
195
+ try:
196
+ rel = rel.relative_to(path.parent.resolve())
197
+ except ValueError:
198
+ pass
199
+ mk = marker(r, cur)
200
+ insert_after(path, line, [f"Review by {r['reviewer']['id']}: {r['verdict']['value']} "
201
+ f"(report `{rel.as_posix()}`).", mk])
202
+ reviews, _ = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
203
+ done.append({"name": r["unit"]["name"], "file": str(path), "marker": mk})
204
+ return done
@@ -0,0 +1,138 @@
1
+ """The review queue: what a reviewer can review, in a suggested order.
2
+
3
+ A unit of review is a locked proposition (`spec`), a definition its meaning depends on (`def`), or,
4
+ in the zero-configuration view, a confirmed link between a claim and a declaration (`link`). Each
5
+ unit carries the fingerprints it would be reviewed at, its current review state, the author's
6
+ review (shown to the reviewer only after their own verdict) and why it sits where it does in the
7
+ order. A unit is reviewed once per fingerprint, however many claims depend on it.
8
+
9
+ The order is a suggestion: less effort first, with a preference for units that many claims depend
10
+ on. Reviewing a definition settles that definition only; no claim is approved by it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import re
17
+
18
+ SCHEMA = "prooflens/review-queue@1"
19
+
20
+
21
+ def section_of(number: str, location: str | None = None) -> str:
22
+ """The section a claim sits in, from its printed number: `Theorem 3.1`, `(4.2)`, `§8.1.3` and
23
+ `App A.3` give `3`, `4`, `8` and `A`."""
24
+ m = re.search(r"(?:App(?:endix)?\.?\s*|§\s*|\(|\b)([A-Z]|\d+)(?=[.\d)])", number or "")
25
+ if m:
26
+ return m.group(1)
27
+ m = re.search(r"\b([A-Z]|\d+)\b", number or "")
28
+ return m.group(1) if m else "—"
29
+
30
+
31
+ def _text(seg) -> str:
32
+ return (seg or {}).get("text", "") if isinstance(seg, dict) else str(seg or "")
33
+
34
+
35
+ def _effort_spec(st: dict, unreviewed: int) -> float:
36
+ binders = len(st.get("binders") or [])
37
+ size = len(_text(st.get("conclusion"))) + sum(len(_text(b.get("type")))
38
+ for b in st.get("binders") or [])
39
+ return binders + size / 200 + unreviewed / 4
40
+
41
+
42
+ def _effort_def(rec: dict) -> float:
43
+ return 1 + len(_text(rec.get("signature"))) / 150 + (2 if rec.get("value") else 0)
44
+
45
+
46
+ def _size(effort: float) -> str:
47
+ return "Short" if effort < 4 else "Medium" if effort < 10 else "Long"
48
+
49
+
50
+ def _author_text(review: dict | None) -> list[dict]:
51
+ return [{"by": r.get("by"), "text": r.get("text", ""), "file": r.get("file"),
52
+ "line": r.get("line")} for r in (review or {}).get("current", [])]
53
+
54
+
55
+ def assurance_queue(index: dict, chunks: dict, store: dict, authors: list[str]) -> dict:
56
+ """The queue of a full assessment: every locked proposition with a statement, and every
57
+ definition in their closures."""
58
+ paper_of = {c["id"]: c["paper"] for c in index["claims"]}
59
+ by_name = {r["name"]: r for r in store.values()}
60
+ keys = {r["name"]: k for k, r in store.items()}
61
+ used_by: dict[str, set] = {}
62
+ specs: dict[str, dict] = {}
63
+ unspecified = []
64
+ for c in index["claims"]:
65
+ ch = chunks[c["file"]]
66
+ if c["class"] == "unspecified":
67
+ unspecified.append({"id": c["id"], "paper": c["paper"], "number": c["number"]})
68
+ for n in ch.get("decls", {}):
69
+ used_by.setdefault(n, set()).add(c["id"])
70
+ for cl in ch.get("clauses", []):
71
+ st = cl.get("statement")
72
+ if not cl.get("spec") or not st:
73
+ continue
74
+ u = specs.setdefault(cl["spec"], {
75
+ "id": "spec:" + cl["spec"], "kind": "spec", "name": cl["spec"],
76
+ "paper": c["paper"], "papers": [c["paper"]],
77
+ "section": section_of(c["number"]), "claims": [], "clauses": [],
78
+ "legacy_hash": st.get("legacy_hash"), "fingerprint": st.get("fingerprint"),
79
+ "state": (cl.get("spec_review") or {}).get("state", "pending"),
80
+ "author_review": _author_text(cl.get("spec_review")),
81
+ "witness": cl.get("witness"), "refute": cl.get("refute"),
82
+ "unreviewed": sum(1 for r in (cl.get("def_reviews") or {}).values()
83
+ if r["state"] != "reviewed"),
84
+ "effort": 0.0})
85
+ u["claims"].append({"id": c["id"], "number": c["number"], "title": c["title"],
86
+ "file": c["file"], "status": c["status"]})
87
+ u["clauses"].append(cl["name"])
88
+ u["effort"] = round(_effort_spec(st, u["unreviewed"]), 2)
89
+ units = list(specs.values())
90
+ for u in units:
91
+ u["reason"] = (f"{_size(u['effort'])} statement"
92
+ + (f"; its meaning uses {u['unreviewed']} definition(s) not yet "
93
+ "independently reviewed" if u["unreviewed"] else ""))
94
+ for name, rec in sorted(by_name.items()):
95
+ if rec.get("review") is None:
96
+ continue
97
+ users = sorted(used_by.get(name, ()))
98
+ effort = round(_effort_def(rec), 2)
99
+ units.append({
100
+ "id": "def:" + name, "kind": "def", "name": name, "key": keys.get(name),
101
+ "paper": None, "papers": sorted({paper_of[i] for i in users}),
102
+ "section": rec.get("module", "").rsplit(".", 1)[0] or "—",
103
+ "claims": [{"id": i} for i in users], "legacy_hash": rec.get("legacy_hash"),
104
+ "fingerprint": rec.get("fingerprint"), "state": rec["review"]["state"],
105
+ "author_review": _author_text(rec["review"]), "effort": effort,
106
+ "reason": f"{_size(effort)} definition; used by {len(users)} claim(s)"})
107
+ for u in units:
108
+ u["order"] = round(u["effort"] - math.log2(1 + len(u["claims"])), 3)
109
+ units.sort(key=lambda u: (u["state"] == "reviewed", u["order"], u["id"]))
110
+ return {"schema": SCHEMA, "release": index["release"], "mode": "assurance",
111
+ "authors": sorted(authors), "units": units, "needs_statement": unspecified}
112
+
113
+
114
+ def simple_queue(index: dict, chunks: dict) -> dict:
115
+ """The queue of the zero-configuration view: every claim with links, reviewed as one unit
116
+ (do these declarations say what the claim says?)."""
117
+ units = []
118
+ for c in index["claims"]:
119
+ ch = chunks[c["file"]]
120
+ links = ch.get("links") or []
121
+ if not links:
122
+ continue
123
+ size = sum(len(_text((l.get("statement") or {}).get("conclusion"))) +
124
+ sum(len(_text(b.get("type"))) for b in (l.get("statement") or {})
125
+ .get("binders", [])) for l in links)
126
+ effort = round(1 + size / 200, 2)
127
+ units.append({
128
+ "id": "link:" + ch["key"], "kind": "link", "key": ch["key"],
129
+ "name": ", ".join(l["name"] for l in links), "lean": [l["name"] for l in links],
130
+ "fingerprints": {l["name"]: l.get("fingerprint") for l in links},
131
+ "paper": c["paper"], "papers": [c["paper"]], "section": section_of(c["number"]),
132
+ "claims": [{"id": c["id"], "number": c["number"], "title": c["title"],
133
+ "file": c["file"], "status": c["status"]}],
134
+ "state": ch.get("review_state") or "pending", "author_review": [],
135
+ "effort": effort, "order": effort, "reason": f"{_size(effort)} statement"})
136
+ units.sort(key=lambda u: (u["state"] == "reviewed", u["order"], u["id"]))
137
+ return {"schema": SCHEMA, "release": index["release"], "mode": "simple", "authors": [],
138
+ "units": units, "needs_statement": []}
prooflens/reviews.py ADDED
@@ -0,0 +1,105 @@
1
+ """Review records: markers in Markdown files, and dismissals of advisory findings.
2
+
3
+ A marker `<!-- spec-review: <name> <hash> by=<reviewer> [kind=human|ai] -->` records a review of
4
+ the proposition `<name>` at `<hash>`; `def-review` does the same for a definition. The hash is
5
+ either the legacy 128-bit structural hash or a `pl-fp-v1:` fingerprint. The last marker of a
6
+ declaration (files in order, then position) is its record; it counts only while its hash is
7
+ current. The text of the review is the section
8
+ the marker closes.
9
+
10
+ A marker is a self-declared record: nothing in the file authenticates who wrote it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fnmatch
16
+ import os
17
+ import json
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .util import sha256_file
23
+
24
+ MARKER = re.compile(r"<!--\s*(spec|def)-review:\s*(\S+)\s+(\S+)((?:\s+[\w-]+=\S+)*)\s*-->")
25
+ HEADING = re.compile(r"^#{1,6} ", re.M)
26
+
27
+
28
+ @dataclass
29
+ class Review:
30
+ target: str # "spec" | "def"
31
+ name: str
32
+ hash: str
33
+ by: str
34
+ kind: str # "human" | "ai" | "unknown"
35
+ provenance: str # "self-declared"
36
+ file: str
37
+ line: int
38
+ text: str
39
+
40
+ def to_json(self) -> dict:
41
+ return dict(self.__dict__)
42
+
43
+
44
+ def reviewer_kind(by: str, explicit: str | None, ai_patterns: list[str]) -> str:
45
+ if explicit in ("human", "ai"):
46
+ return explicit
47
+ if any(fnmatch.fnmatch(by, p) for p in ai_patterns):
48
+ return "ai"
49
+ return "unknown"
50
+
51
+
52
+ def parse_file(path: Path, ai_patterns: list[str], rel_to: Path) -> list[Review]:
53
+ txt = path.read_text(encoding="utf-8")
54
+ out, prev = [], 0
55
+ for m in MARKER.finditer(txt):
56
+ attrs = dict(a.split("=", 1) for a in m.group(4).split())
57
+ start = prev
58
+ heads = [h.start() for h in HEADING.finditer(txt, prev, m.start())]
59
+ if heads:
60
+ start = heads[-1]
61
+ body = MARKER.sub("", txt[start:m.start()]).strip()
62
+ body = re.sub(r"\A#{1,6} [^\n]*\n", "", body).strip()
63
+ by = attrs.get("by", "unknown")
64
+ out.append(Review(
65
+ target=m.group(1), name=m.group(2), hash=m.group(3), by=by,
66
+ kind=reviewer_kind(by, attrs.get("kind"), ai_patterns), provenance="self-declared",
67
+ file=os.path.relpath(path, rel_to),
68
+ line=txt.count("\n", 0, m.start()) + 1, text=body))
69
+ prev = m.end()
70
+ return out
71
+
72
+
73
+ def review_files(entries: list[Path]) -> list[Path]:
74
+ """The review files named by the configuration: a file, or every `.md` file of a directory
75
+ in name order."""
76
+ out = []
77
+ for e in entries:
78
+ if e.is_dir():
79
+ out += sorted(p for p in e.iterdir() if p.suffix == ".md")
80
+ elif e.exists():
81
+ out.append(e)
82
+ return out
83
+
84
+
85
+ def load_reviews(entries: list[Path], ai_patterns: list[str],
86
+ root: Path) -> tuple[list[Review], dict]:
87
+ """Every marker, in file order and then position."""
88
+ reviews, hashes = [], {}
89
+ for f in review_files(entries):
90
+ reviews += parse_file(f, ai_patterns, root)
91
+ hashes[os.path.relpath(f, root)] = sha256_file(f)
92
+ return reviews, hashes
93
+
94
+
95
+ def history(reviews: list[Review], target: str, name: str) -> list[Review]:
96
+ """The markers of a declaration; the last one is its current record."""
97
+ return [r for r in reviews if r.target == target and r.name == name]
98
+
99
+
100
+ def load_dismissals(path: Path | None) -> list[dict]:
101
+ """Dismissals of advisory findings: `[{code, subject, fingerprint, rationale, by}]`. A
102
+ dismissal holds only while `fingerprint` is the subject's current fingerprint."""
103
+ if path is None or not path.exists():
104
+ return []
105
+ return json.loads(path.read_text(encoding="utf-8"))
prooflens/schemas.py ADDED
@@ -0,0 +1,19 @@
1
+ """JSON Schemas of the inventory, the evidence record and the presentation manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from functools import cache
7
+ from importlib import resources
8
+
9
+ import jsonschema
10
+
11
+
12
+ @cache
13
+ def schema(name: str) -> dict:
14
+ return json.loads(resources.files("prooflens").joinpath(f"data/schema/{name}.schema.json")
15
+ .read_text())
16
+
17
+
18
+ def validate(obj, name: str) -> None:
19
+ jsonschema.validate(obj, schema(name))
prooflens/server.py ADDED
@@ -0,0 +1,63 @@
1
+ """The local server of `prooflens view`: the site, and saving links into the repository.
2
+
3
+ It listens on 127.0.0.1 only. `GET /api/status` tells the reader it may edit; `POST /api/link`
4
+ with `{"key", "lean": [names] | null, "reject": [names]}` records a link (or removes it, or
5
+ rejects suggestions) in `prooflens.links.json` and rebuilds the view.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import http.server
12
+ import json
13
+ import threading
14
+ from pathlib import Path
15
+
16
+
17
+ def serve(site: Path, port: int, on_link=None, open_browser: bool = True):
18
+ lock = threading.Lock()
19
+
20
+ class Handler(http.server.SimpleHTTPRequestHandler):
21
+ def log_message(self, *a):
22
+ pass
23
+
24
+ def _json(self, code: int, obj):
25
+ b = json.dumps(obj).encode()
26
+ self.send_response(code)
27
+ self.send_header("Content-Type", "application/json")
28
+ self.send_header("Content-Length", str(len(b)))
29
+ self.end_headers()
30
+ self.wfile.write(b)
31
+
32
+ def do_GET(self):
33
+ if self.path == "/api/status":
34
+ return self._json(200, {"editable": on_link is not None})
35
+ return super().do_GET()
36
+
37
+ def do_POST(self):
38
+ if self.path != "/api/link" or on_link is None:
39
+ return self._json(404, {"error": "not found"})
40
+ try:
41
+ n = int(self.headers.get("Content-Length", "0"))
42
+ req = json.loads(self.rfile.read(n) or b"{}")
43
+ with lock:
44
+ on_link(req)
45
+ return self._json(200, {"ok": True})
46
+ except Exception as e: # the reader shows the message
47
+ return self._json(400, {"error": str(e)})
48
+
49
+ handler = functools.partial(Handler, directory=str(site))
50
+ httpd = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
51
+ url = f"http://127.0.0.1:{httpd.server_address[1]}/"
52
+ print(f"ProofLens: {url} (Ctrl-C to stop)")
53
+ if open_browser:
54
+ try:
55
+ import webbrowser
56
+ webbrowser.open(url)
57
+ except Exception:
58
+ pass
59
+ try:
60
+ httpd.serve_forever()
61
+ except KeyboardInterrupt:
62
+ pass
63
+ return url