feat: validator, bundle builder and release checks for 0.1.0
- Add tools/validate.py: validates packages, and with --correction runs proposed corrections through the gate against a package without writing anything (spec/11 §11.4). The gate now refuses a proposal that fails the correction schema, rule correction.schema. - tools/load.py validates every package before loading, and with --bundle --built-from builds a scienting-bundle/1 with the FTS5 search index and bundle_meta (spec/13 §13.5). Search rows are defined per unit (sentence, cell, equation outside a sentence, figure-text) with the parent kind as context. - Roster keys carry a role, and a key with the role owner may revoke any receipt (spec/08 §8.7, spec/20); roster schema, store table and the fixture roster updated. The checker signs a trial revocation to prove the rule both ways. - A qa: receipt raises the nodes it sampled except those in its errors (spec/08 §8.2); the example paper's fidelity counts become 27 F3, 13 F2, 4 F1 and 1 F0. - The destination chooses the keyed scheme, not the text policy: hmac-team between the team's machines, hkdf-pages outside the team (spec/05 §5.8, spec/12 §12.5). - A TextQuoteSelector must repeat the anchor's plain quote and is absent for hashed or keyed anchors (spec/18 §18.3). - Add fixtures/records/suspect.json, a scienting-suspect/1 record. - Oracle: hashes are kept incrementally and checked after every correction against a full recomputation; load and write refuse unsafe manifest paths; Ed25519, HKDF and schema validation come from the cryptography and jsonschema packages; canonical JSON uses the built-in encoder with an exact RFC 8785 fallback. - The tools need Python 3.14 with cryptography and jsonschema; the py310 CI environment is dropped and the workflow runs the default only. - Resolve every [CHECK] note in the docs, READMEs and context; add CONTRIBUTING.md; every tool answers --help and refuses unknown options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
89 files changed
+1141
-513
No files matched your search
+237
-49
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the whole repository. Exit status 0 means every check passed.
|
||||
|
||||
Non-normative tooling; standard library only (it uses `jsonschema` as a second validator when installed).
|
||||
Non-normative tooling; it needs Python 3.14 and the packages pixi installs (`jsonschema`, `cryptography`).
|
||||
Usage: python3 tools/check.py [--quick] (--quick skips the determinism rebuild)
|
||||
"""
|
||||
|
||||
@@ -13,6 +13,7 @@ import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
from fractions import Fraction
|
||||
|
||||
@@ -27,6 +28,8 @@ from tools.oracle import package as PK # noqa: E402
|
||||
from tools.oracle.model import Doc # noqa: E402
|
||||
from tools.oracle.notation import fold_math # noqa: E402
|
||||
from tools.oracle.schema import Store, validate as sv # noqa: E402
|
||||
from jsonschema import Draft202012Validator # noqa: E402
|
||||
from jsonschema.exceptions import SchemaError # noqa: E402
|
||||
from tools.oracle.text import cp_to_utf16, letter_first, mint_node_id, norm, record_id, sha256_hex # noqa: E402
|
||||
from tools.oracle.tree import Refusal # noqa: E402
|
||||
|
||||
@@ -69,35 +72,20 @@ def check_schemas():
|
||||
for f in SCHEMA_FILES:
|
||||
s = STORE.load(f)
|
||||
ok("schemas", s.get("$id", "").endswith("/" + f), "%s: $id must end with its file name" % f)
|
||||
try:
|
||||
Draft202012Validator.check_schema(s)
|
||||
meta = True
|
||||
except SchemaError:
|
||||
meta = False
|
||||
ok("schemas", meta, "%s is not a valid draft 2020-12 schema" % f)
|
||||
for f in js(p("registry", "formats.json"))["formats"]:
|
||||
ok("schemas", os.path.exists(p("schemas", f["schema"])) or os.path.exists(p(f["schema"])),
|
||||
"%s names a missing schema %s" % (f["format"], f["schema"]))
|
||||
try:
|
||||
import jsonschema # noqa: F401
|
||||
from referencing import Registry, Resource
|
||||
reg = Registry().with_resources(
|
||||
[(STORE.load(f)["$id"], Resource.from_contents(STORE.load(f))) for f in SCHEMA_FILES])
|
||||
COUNTS["second-validator"] = 0
|
||||
globals()["SECOND"] = (jsonschema, reg)
|
||||
except ImportError:
|
||||
globals()["SECOND"] = None
|
||||
|
||||
|
||||
def second(name, rec):
|
||||
if not SECOND:
|
||||
return
|
||||
jsonschema, reg = SECOND
|
||||
v = jsonschema.Draft202012Validator(STORE.load(name), registry=reg)
|
||||
errs = list(v.iter_errors(rec))
|
||||
COUNTS["second-validator"] += 1
|
||||
if errs:
|
||||
FAILS.append("second-validator: %s: %s" % (name, errs[0].message))
|
||||
|
||||
|
||||
def valid(section, name, rec, where):
|
||||
e = schema_errors(name, rec)
|
||||
ok(section, not e, "%s: %s" % (where, e[:3]))
|
||||
second(name, rec)
|
||||
|
||||
|
||||
def check_registry():
|
||||
@@ -106,6 +94,13 @@ def check_registry():
|
||||
for rule in js(p("registry", "stamps", "scienting-stamps-1.json"))["rules"]:
|
||||
re.compile(rule["pattern"])
|
||||
rules = {r["id"] for r in js(p("registry", "rules.json"))["rules"]}
|
||||
for f in sorted(os.listdir(p("tools", "oracle"))):
|
||||
if f.endswith(".py"):
|
||||
for rule in re.findall(r'(?:Refusal|rep\.add)\(\s*"([a-z][a-z0-9.-]+)"', open(p("tools", "oracle", f)).read()):
|
||||
if not rule.endswith("."):
|
||||
ok("registry", rule in rules, "tools/oracle/%s raises %s, which is not in rules.json" % (f, rule))
|
||||
for v in ("text-hash", "match-hash", "align-key", "own-hash", "node-hash"):
|
||||
ok("registry", "doc.hash." + v in rules, "doc.hash.%s is not in rules.json" % v)
|
||||
for d in sorted(os.listdir(p("fixtures", "invalid"))):
|
||||
ok("registry", js(p("fixtures", "invalid", d, "expect.json"))["rule"] in rules, "%s: rule is not in rules.json" % d)
|
||||
|
||||
@@ -133,13 +128,28 @@ def check_records():
|
||||
else:
|
||||
sch = FORMAT_SCHEMA[rec["format"]]
|
||||
valid("records", sch, rec, rel)
|
||||
if rec.get("format") == "scienting-suspect/1":
|
||||
ok("records", rec["id"] == record_id(sha256_hex(jcs.dumpb({k: v for k, v in rec.items() if k != "id"}))),
|
||||
"%s: id does not derive" % rel)
|
||||
if rec.get("signature"):
|
||||
ok("records", RC.verify_signature(rec, roster), "%s: signature does not verify" % rel)
|
||||
ok("records", rec["id"] == record_id(RC.record_hash(rec)), "%s: id does not derive" % rel)
|
||||
# A project's settings.stages holds the id of a stage file.
|
||||
stage_ids = {js(p("registry", "stages", f))["id"] for f in os.listdir(p("registry", "stages")) if f.endswith(".json")}
|
||||
stages = js(os.path.join(R, "project.json")).get("settings", {}).get("stages")
|
||||
ok("records", stages is None or stages in stage_ids, "project settings.stages names no stage file: %s" % stages)
|
||||
# Confirmed annotations are covered by the layer receipt at their content hash.
|
||||
lr = js(os.path.join(R, "layer", "receipt.json"))
|
||||
items = {(k, i): h for k, i, h in lr["records"]}
|
||||
for a in jl(os.path.join(R, "layer", "annotations.jsonl")):
|
||||
sel = a["target"].get("selector", [])
|
||||
anchors = [x["anchor"] for x in sel if x.get("type") == "AnchorSelector"]
|
||||
for x in sel:
|
||||
if x.get("type") == "TextQuoteSelector":
|
||||
q = anchors[0].get("quote", {}) if anchors else {}
|
||||
same = all(x.get(k, "") == q.get(k, "") for k in ("exact", "prefix", "suffix"))
|
||||
ok("records", "exact" in q and same and set(x) <= {"type", "exact", "prefix", "suffix"},
|
||||
"a TextQuoteSelector must repeat the anchor's plain quote: " + a["id"])
|
||||
body = {k: v for k, v in a.items() if k not in ("status", "receipt", "modified")}
|
||||
ok("records", a["id"].rsplit("/", 1)[1] == record_id(sha256_hex(jcs.dumpb(
|
||||
{k: v for k, v in body.items() if k != "id"}))), "annotation id does not derive: " + a["id"])
|
||||
@@ -150,12 +160,7 @@ def check_records():
|
||||
|
||||
|
||||
def state_of(pkg):
|
||||
nodes = jl(os.path.join(pkg, "nodes.jsonl"))
|
||||
key = js(os.path.join(pkg, "document.json"))["key"]
|
||||
ids = jl(os.path.join(pkg, "ids.jsonl")) if os.path.exists(os.path.join(pkg, "ids.jsonl")) else []
|
||||
cors = jl(os.path.join(pkg, "corrections.jsonl")) if os.path.exists(os.path.join(pkg, "corrections.jsonl")) else []
|
||||
strip = ("text_hash", "match_hash", "align_key", "own_hash", "node_hash")
|
||||
return CO.State(key, [{k: v for k, v in n.items() if k not in strip} for n in nodes], ids, cors)
|
||||
return PK.read_state(pkg)
|
||||
|
||||
|
||||
def check_packages():
|
||||
@@ -192,6 +197,142 @@ def check_packages():
|
||||
valid("packages", "mathml.schema.json", m, "derived/mathml.jsonl")
|
||||
|
||||
|
||||
def check_owner_revocation():
|
||||
"""A key the roster makes an owner may revoke anyone's receipt (spec/08 §8.7); any other key may not.
|
||||
|
||||
No fixture has such a revocation, so this signs one: Ben revokes a receipt of Ana's, once with the
|
||||
roster role owner and once as the editor he is.
|
||||
"""
|
||||
hp = p("fixtures", "packages", "hale2026harmonic")
|
||||
revoker = "ben"
|
||||
secret = bytes.fromhex(open(p("fixtures", "keys", revoker + ".ed25519.hex")).read().strip())
|
||||
roster_rec = js(os.path.join(hp, "roster.json"))
|
||||
as_owner = dict(roster_rec, keys=[dict(k, role="owner") if k["id"] == revoker else k for k in roster_rec["keys"]])
|
||||
owner_roster = {k["id"]: k for k in as_owner["keys"]}
|
||||
nodes = jl(os.path.join(hp, "nodes.jsonl"))
|
||||
vals = {n["id"]: n for n in nodes}
|
||||
document = js(os.path.join(hp, "document.json"))
|
||||
recs, fidelity = jl(os.path.join(hp, "receipts.jsonl")), None
|
||||
for target in recs:
|
||||
if target["format"] != "scienting-receipt/1" or target["by"] == revoker:
|
||||
continue
|
||||
rev = RC.seal({"format": "scienting-revocation/1", "receipt": target["id"], "reason": "Owner check.",
|
||||
"by": revoker, "at": "2026-10-13T00:00:00Z"}, secret, revoker)
|
||||
trial = sorted(recs + [rev], key=lambda r: (r["at"], r["id"]))
|
||||
fidelity = RC.distribution(RC.effective_levels(nodes, vals, trial, owner_roster))
|
||||
if fidelity != document["fidelity"]:
|
||||
recs = trial
|
||||
break
|
||||
ok("packages", fidelity != document["fidelity"], "no receipt of the hale package matters to its levels")
|
||||
document["fidelity"] = fidelity
|
||||
manifest = js(os.path.join(hp, "MANIFEST.json"))
|
||||
files, absent = {}, [f for f in manifest["files"] if f.get("present", True) is False]
|
||||
for f in manifest["files"]:
|
||||
if f.get("present", True):
|
||||
with open(os.path.join(hp, f["path"]), "rb") as fh:
|
||||
files[f["path"]] = fh.read()
|
||||
files["receipts.jsonl"] = PK.jsonl(recs)
|
||||
files["document.json"] = PK.line(document).encode("utf-8")
|
||||
for roster, passes in ((as_owner, True), (roster_rec, False)):
|
||||
files["roster.json"] = PK.line(roster).encode("utf-8")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
PK.write(tmp, files, absent)
|
||||
rules = {e["rule"] for e in PK.validate(tmp, STORE).errors}
|
||||
role = next(k["role"] for k in roster["keys"] if k["id"] == revoker)
|
||||
ok("packages", ("doc.fidelity.report" not in rules) == passes and rules <= {"doc.fidelity.report"},
|
||||
"a revocation by a key with the role %s: %s" % (role, sorted(rules)))
|
||||
|
||||
|
||||
def check_unsafe_paths():
|
||||
"""Loading and writing refuse manifest paths that leave the package, even without a prior validation."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pkg = os.path.join(tmp, "pkg")
|
||||
shutil.copytree(p("fixtures", "packages", "tiny2026example"), pkg)
|
||||
manifest = js(os.path.join(pkg, "MANIFEST.json"))
|
||||
for bad in ("../outside", "/etc/outside", "assets/../../outside"):
|
||||
m = dict(manifest, files=[{"path": bad, "sha256": "0" * 64, "size": 1}] + manifest["files"])
|
||||
with open(os.path.join(pkg, "MANIFEST.json"), "w", encoding="utf-8") as f:
|
||||
f.write(PK.line(m))
|
||||
conn = sqlite3.connect(":memory:")
|
||||
db.create(conn, p("sql"))
|
||||
try:
|
||||
db.load(conn, pkg, os.path.join(tmp, "assets"))
|
||||
rule = None
|
||||
except Refusal as e:
|
||||
rule = e.rule
|
||||
ok("packages", rule == "package.path.unsafe", "load accepted the manifest path %r" % bad)
|
||||
try:
|
||||
PK.write(os.path.join(tmp, "out"), {bad: b"x", "document.json": b"{}"})
|
||||
rule = None
|
||||
except Refusal as e:
|
||||
rule = e.rule
|
||||
ok("packages", rule == "package.path.unsafe", "write accepted the path %r" % bad)
|
||||
ok("packages", not os.path.exists(os.path.join(tmp, "outside")), "a file was written outside the package")
|
||||
|
||||
|
||||
def reference_values(doc, hasher=hashing.PLAIN):
|
||||
"""The five values the slow way: every node projected from its whole subtree, as spec/05 §5.1 reads.
|
||||
|
||||
The oracle's hashing.Index joins its children's cached projections and rehashes only what a correction
|
||||
changed; this independent computation must always agree with it.
|
||||
"""
|
||||
values = {}
|
||||
for nid in reversed(doc.preorder()):
|
||||
st, _ = hashing.strict(doc, nid)
|
||||
own = hasher(jcs.dumpb(hashing.own_content(doc, nid, lambda x: values[x]["text_hash"])))
|
||||
values[nid] = {"text_hash": hasher(norm(hashing.plain(doc, nid))), "match_hash": hasher(st),
|
||||
"align_key": hasher(hashing.lossy_from_strict(st)), "own_hash": own,
|
||||
"node_hash": hasher(jcs.dumpb({"own": own, "children": [
|
||||
values[c]["node_hash"] for c in doc.kids(nid)]}))}
|
||||
return values
|
||||
|
||||
|
||||
def check_incremental():
|
||||
"""After every correction of every fixture source, the incrementally kept values equal the slow ones."""
|
||||
from tools.build import Names, build_document
|
||||
team = hashing.Hasher(crypto.team_key(bytes.fromhex(
|
||||
open(p("fixtures", "keys", "smithlab-team-secret.hex")).read().strip())))
|
||||
Names.registry.clear()
|
||||
# Sorted, the main example comes first, so the draft's references to its sentences resolve.
|
||||
for f in sorted(os.listdir(p("fixtures", "src"))):
|
||||
if not f.endswith(".json"):
|
||||
continue
|
||||
_, _, _, _, states, _ = build_document(js(p("fixtures", "src", f)))
|
||||
keyed = None
|
||||
for rev in sorted(states):
|
||||
st = states[rev]
|
||||
ok("packages", st.index().values == reference_values(st.doc()),
|
||||
"%s rev %d: incremental values differ from the full computation" % (f, rev))
|
||||
# The keyed values, carried forward from revision 0 by updates alone.
|
||||
keyed = hashing.Index.build(st.doc(), team) if keyed is None else keyed.update(st.doc())
|
||||
ok("packages", keyed.values == reference_values(st.doc(), team),
|
||||
"%s rev %d: incremental keyed values differ from the full computation" % (f, rev))
|
||||
Names.registry.clear()
|
||||
|
||||
|
||||
def check_gate():
|
||||
"""The gate runs on a proposal against a package's current state and leaves that state unchanged."""
|
||||
st = state_of(p("fixtures", "packages", "hale2026harmonic"))
|
||||
before = json.dumps(st.nodes, sort_keys=True)
|
||||
target = next(n for n in st.nodes if n["kind"] == "sentence" and "Each bond" in hashing.plain(st.doc(), n["id"]))
|
||||
base = {"format": "scienting-correction/1", "evidence": [{"kind": "person", "by": "ana"}], "author": {"key": "ana"},
|
||||
"reason": "Gate check.", "at": "2026-10-14T09:00:00Z", "status": "proposed"}
|
||||
st1, rec = CO.gate(st, dict(base, op="replace", target=target["id"], find="Each bond", replace="Every bond"), STORE)
|
||||
ok("packages", rec["index"] == st.rev + 1 and st1.rev == st.rev + 1, "the gate's record has the next index")
|
||||
ok("packages", json.dumps(st.nodes, sort_keys=True) == before, "the gate changed the state it was given")
|
||||
valid("packages", "correction.schema.json", rec, "gate record")
|
||||
for bad, rule in (({"op": "replace", "target": target["id"]}, "correction.schema"),
|
||||
(dict(base, op="replace", target=target["id"], find="0.01", replace="0.02"), "correction.digits"),
|
||||
(dict(base, op="insert", target=target["id"], run=9, offset=0, text="x",
|
||||
evidence=[{"kind": "crop", "sha256": "0" * 64}]), "correction.insert")):
|
||||
try:
|
||||
CO.gate(st, bad, STORE)
|
||||
got = None
|
||||
except Refusal as e:
|
||||
got = e.rule
|
||||
ok("packages", got == rule, "the gate gave %s for a proposal that breaks %s" % (got, rule))
|
||||
|
||||
|
||||
def check_invalid():
|
||||
base = p("fixtures", "invalid")
|
||||
for d in sorted(os.listdir(base)):
|
||||
@@ -299,6 +440,8 @@ def check_vectors():
|
||||
try:
|
||||
st1, rec = CO.apply(st, c["correction"])
|
||||
v1 = hashing.compute(st1.doc())
|
||||
ok("vectors", st1.index().values == reference_values(st1.doc()),
|
||||
"correction %s: incremental values differ from the full computation" % c["id"])
|
||||
got = {"record": rec, "nodes": [dict(n, **v1[n["id"]]) for n in st1.nodes], "ids": st1.ids}
|
||||
except Refusal as e:
|
||||
got = {"refusal": e.rule}
|
||||
@@ -330,7 +473,8 @@ def check_determinism():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
subprocess.run([sys.executable, p("tools", "build.py"), "--out", tmp], check=True)
|
||||
for sub in ("fixtures", "vectors"):
|
||||
cmp = filecmp.dircmp(p(sub), os.path.join(tmp, sub))
|
||||
# Files the repository ignores, such as macOS's .DS_Store, are not part of the build.
|
||||
cmp = filecmp.dircmp(p(sub), os.path.join(tmp, sub), ignore=filecmp.DEFAULT_IGNORES + [".DS_Store"])
|
||||
diffs = []
|
||||
|
||||
def walk(c, prefix):
|
||||
@@ -384,8 +528,7 @@ def check_sql():
|
||||
ok("sql", "FOREIGN KEY" not in ddl and "REFERENCES" not in ddl, "no foreign keys")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
conn = sqlite3.connect(os.path.join(tmp, "bundle.db"))
|
||||
db.create(conn, p("sql"))
|
||||
conn.executescript(open(p("sql", "bundle-sqlite.sql"), encoding="utf-8").read())
|
||||
db.create_bundle(conn, p("sql"))
|
||||
assets = os.path.join(tmp, "assets")
|
||||
pk = p("fixtures", "packages")
|
||||
for d in sorted(os.listdir(pk)):
|
||||
@@ -393,29 +536,18 @@ def check_sql():
|
||||
if key is None:
|
||||
# A second load of the same key goes to its own database.
|
||||
c2 = sqlite3.connect(":memory:")
|
||||
db.create(c2, p("sql"))
|
||||
db.create_bundle(c2, p("sql"))
|
||||
key = db.load(c2, os.path.join(pk, d), assets)
|
||||
ok("sql", db.index(c2, key) == 0, "%s: withheld text was indexed" % d)
|
||||
h = db.unload(c2, key, os.path.join(tmp, "out", d), assets)
|
||||
else:
|
||||
db.index(conn, key)
|
||||
h = db.unload(conn, key, os.path.join(tmp, "out", d), assets)
|
||||
cmp = filecmp.dircmp(os.path.join(pk, d), os.path.join(tmp, "out", d))
|
||||
same = _same_tree(os.path.join(pk, d), os.path.join(tmp, "out", d))
|
||||
ok("sql", same, "%s: unload is not byte-identical" % d)
|
||||
with open(os.path.join(pk, d, "MANIFEST.json"), "rb") as f:
|
||||
ok("sql", sha256_hex(f.read()) == h, "%s: package hash differs after unload" % d)
|
||||
# The search index over sentences, captions, headings and cells.
|
||||
for doc_key, nid, kind, parent in conn.execute(
|
||||
"SELECT n.doc_key, n.id, n.kind, p.kind FROM nodes n JOIN nodes p ON p.doc_key = n.doc_key AND p.id = n.parent "
|
||||
"WHERE n.kind IN ('sentence', 'cell', 'equation') AND n.withheld IS NULL").fetchall():
|
||||
st = None
|
||||
rows = conn.execute("SELECT doc_key, id, kind, content FROM nodes WHERE kind IN ('sentence','cell')").fetchall()
|
||||
for doc_key, nid, kind, content in rows:
|
||||
runs = json.loads(content)
|
||||
text = "".join(r.get("text", "") if r["kind"] != "math" else "$" + r["source"] + "$" for r in runs
|
||||
if r["kind"] != "equation")
|
||||
conn.execute("INSERT INTO search VALUES (?,?,?,?,?)", (doc_key, nid, kind, "", norm(text)))
|
||||
hits = conn.execute("SELECT node FROM search WHERE search MATCH 'scanned'").fetchall()
|
||||
ok("sql", len(hits) == 1, "full-text search finds the merged sentence")
|
||||
check_bundle(conn)
|
||||
# Store records satisfy the store tables' CHECK constraints.
|
||||
R = p("fixtures", "records")
|
||||
lay = js(os.path.join(R, "layer", "layer.json"))
|
||||
@@ -433,8 +565,8 @@ def check_sql():
|
||||
for m in prj["members"]:
|
||||
conn.execute("INSERT INTO members VALUES (?,?,?,?)", (prj["key"], m["person"], m["role"], jcs.dumps(m["keys"])))
|
||||
for k in js(os.path.join(R, "roster.json"))["keys"]:
|
||||
conn.execute("INSERT INTO roster_keys VALUES (?,?,?,?,?,?,?,?)",
|
||||
("smithlab", k["id"], jcs.dumps(k["person"]), k["scheme"], k["public"],
|
||||
conn.execute("INSERT INTO roster_keys VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
("smithlab", k["id"], jcs.dumps(k["person"]), k["role"], k["scheme"], k["public"],
|
||||
jcs.dumps(k["purposes"]), k["registered_at"], k["revoked_at"]))
|
||||
dg = js(os.path.join(R, "digitization.json"))
|
||||
conn.execute("INSERT INTO digitizations VALUES (?,?,?,?,?,?,?,?)",
|
||||
@@ -444,6 +576,51 @@ def check_sql():
|
||||
ok("sql", True, "store records loaded")
|
||||
|
||||
|
||||
def check_bundle(conn):
|
||||
"""The bundle's search index and metadata (spec/13 §13.5), built by the oracle's db.index and db.write_meta."""
|
||||
units = dict(conn.execute("SELECT unit, COUNT(*) FROM search GROUP BY unit").fetchall())
|
||||
for u in ("sentence", "cell", "figure-text"):
|
||||
ok("sql", units.get(u, 0) > 0, "the search index has no %s rows" % u)
|
||||
# No fixture has an equation outside a sentence, so a small document shows both kinds of equation.
|
||||
nodes = [{"id": "d", "kind": "document", "parent": None, "ord": 0},
|
||||
{"id": "p", "kind": "paragraph", "parent": "d", "ord": 0},
|
||||
{"id": "s", "kind": "sentence", "parent": "p", "ord": 0,
|
||||
"content": [{"kind": "text", "text": "Then "}, {"kind": "equation", "node": "e1"}]},
|
||||
{"id": "e1", "kind": "equation", "parent": "s", "ord": 0, "content": {"notation": "tex", "source": "a=b"}},
|
||||
{"id": "e2", "kind": "equation", "parent": "p", "ord": 1, "content": {"notation": "tex", "source": "c=d"}}]
|
||||
got = [(n, u, c) for _, n, u, c, _ in db.search_rows("k", nodes)]
|
||||
ok("sql", got == [("s", "sentence", "paragraph"), ("e2", "equation", "paragraph")],
|
||||
"search rows for a sentence with an inline equation and a display equation: %s" % got)
|
||||
ok("sql", set(units) <= {"sentence", "cell", "equation", "figure-text"}, "unknown search units %s" % sorted(units))
|
||||
expect = conn.execute(
|
||||
"SELECT COUNT(*) FROM nodes n LEFT JOIN nodes q ON q.doc_key = n.doc_key AND q.id = n.parent "
|
||||
"WHERE n.withheld IS NULL AND (n.kind IN ('sentence', 'cell') OR (n.kind = 'equation' AND q.kind <> 'sentence'))"
|
||||
).fetchone()[0]
|
||||
ok("sql", sum(v for u, v in units.items() if u != "figure-text") == expect,
|
||||
"the search index does not have one row per sentence, cell and equation outside a sentence")
|
||||
inner = conn.execute(
|
||||
"SELECT COUNT(*) FROM search s JOIN nodes n ON n.doc_key = s.doc_key AND n.id = s.node "
|
||||
"JOIN nodes q ON q.doc_key = n.doc_key AND q.id = n.parent WHERE n.kind = 'equation' AND q.kind = 'sentence'"
|
||||
).fetchone()[0]
|
||||
ok("sql", inner == 0, "an equation inside a sentence is indexed apart from its sentence")
|
||||
bad = conn.execute(
|
||||
"SELECT COUNT(*) FROM search s JOIN nodes n ON n.doc_key = s.doc_key AND n.id = s.node "
|
||||
"LEFT JOIN nodes q ON q.doc_key = n.doc_key AND q.id = n.parent "
|
||||
"WHERE s.unit <> 'figure-text' AND s.context IS NOT q.kind").fetchone()[0]
|
||||
ok("sql", bad == 0, "a search row's context is not the kind of its node's parent")
|
||||
hits = conn.execute("SELECT node FROM search WHERE search MATCH 'scanned'").fetchall()
|
||||
ok("sql", len(hits) == 1, "full-text search finds the merged sentence")
|
||||
hits = conn.execute("SELECT context FROM search WHERE unit = 'sentence' AND search MATCH 'Methods'").fetchall()
|
||||
ok("sql", ("heading",) in hits, "a heading sentence is indexed with the context heading")
|
||||
stores = [r[0] for r in conn.execute("SELECT DISTINCT store FROM documents WHERE store IS NOT NULL")]
|
||||
db.write_meta(conn, stores[0] if len(stores) == 1 else "", "fixtures/packages")
|
||||
meta = dict(conn.execute("SELECT name, value FROM bundle_meta").fetchall())
|
||||
ok("sql", {"format", "store", "built_from", "spec"} <= set(meta), "bundle_meta lacks a required name")
|
||||
ok("sql", meta["format"] in FORMAT_SCHEMA, "bundle_meta names an unregistered format")
|
||||
top = re.search(r"^## (\S+)", open(p("CHANGELOG.md"), encoding="utf-8").read(), re.M).group(1)
|
||||
ok("sql", meta["spec"] == top, "bundle_meta spec %s is not the changelog's latest version %s" % (meta["spec"], top))
|
||||
|
||||
|
||||
def _same_tree(a, b):
|
||||
fa = sorted(os.path.relpath(os.path.join(d, f), a) for d, _, fs in os.walk(a) for f in fs)
|
||||
fb = sorted(os.path.relpath(os.path.join(d, f), b) for d, _, fs in os.walk(b) for f in fs)
|
||||
@@ -525,13 +702,24 @@ def check_links():
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Check the whole repository: schemas, records, packages, broken packages, vectors, a "
|
||||
"byte-for-byte rebuild, the SQL round trip and the bundle, prose and links. "
|
||||
"Exit status 0 means every check passed.")
|
||||
ap.add_argument("--quick", action="store_true", help="skip the byte-for-byte rebuild, the slowest check")
|
||||
args = ap.parse_args()
|
||||
check_schemas()
|
||||
check_registry()
|
||||
check_records()
|
||||
check_packages()
|
||||
check_owner_revocation()
|
||||
check_unsafe_paths()
|
||||
check_incremental()
|
||||
check_gate()
|
||||
check_invalid()
|
||||
check_vectors()
|
||||
if "--quick" not in sys.argv:
|
||||
if not args.quick:
|
||||
check_determinism()
|
||||
check_sql()
|
||||
check_prose()
|
||||
|
||||
Reference in new issue
Block a user