scriptoria 0.5.0__tar.gz → 0.5.1__tar.gz
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.
- {scriptoria-0.5.0 → scriptoria-0.5.1}/.gitignore +4 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/PKG-INFO +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.1}/pyproject.toml +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/__init__.py +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/anchors.py +28 -28
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/facts.py +29 -17
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/frontmatter.py +27 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/graph.py +25 -18
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/hashing.py +2 -5
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/similar.py +20 -19
- scriptoria-0.5.1/tests/test_frontmatter.py +97 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_graph_status.py +78 -1
- {scriptoria-0.5.0 → scriptoria-0.5.1}/uv.lock +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.1}/README.md +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/pyrightconfig.json +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/blocks.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/cli.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/embeddings.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/errors.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/ingest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/lock.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/manifest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/query.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/src/scrip/retrieval.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/conftest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_anchor_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_anchors.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_blocks.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_embeddings.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_fact_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_hashing.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_index_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_ingest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_json_shapes.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_lock.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_manifest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_new_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_query.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_retrieval.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_similar_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_span_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_stamp.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_status_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_unlock_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_verify.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_version.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.1}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptoria
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base
|
|
5
5
|
Project-URL: Homepage, https://github.com/coredipper/scriptorium
|
|
6
6
|
Project-URL: Changelog, https://github.com/coredipper/scriptorium/blob/main/CHANGELOG.md
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Distribution name on PyPI is `scriptoria` (scrip/scriptorium were taken); the
|
|
3
3
|
# CLI command and the import package both remain `scrip`.
|
|
4
4
|
name = "scriptoria"
|
|
5
|
-
version = "0.5.
|
|
5
|
+
version = "0.5.1"
|
|
6
6
|
description = "Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.10"
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
__version__ = "0.5.
|
|
16
|
+
__version__ = "0.5.1"
|
|
17
17
|
|
|
18
18
|
# --- canonical vault layout ------------------------------------------------
|
|
19
19
|
# ``root`` is the repo/instance root: the directory containing ``vault/``.
|
|
@@ -139,15 +139,16 @@ def source_text(root: Path, source_id: str) -> str:
|
|
|
139
139
|
|
|
140
140
|
|
|
141
141
|
def _iter_footnote_anchors(path: Path) -> Iterator[dict]:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
142
|
+
with path.open(encoding="utf-8") as f:
|
|
143
|
+
for line in f:
|
|
144
|
+
m = _FOOTNOTE.match(line)
|
|
145
|
+
if not m:
|
|
146
|
+
continue
|
|
147
|
+
target = m.group(2)
|
|
148
|
+
if "#" not in target:
|
|
149
|
+
continue
|
|
150
|
+
source_id, anchor = target.split("#", 1)
|
|
151
|
+
yield {"fn": m.group(1), "source_id": source_id, "anchor": anchor}
|
|
151
152
|
|
|
152
153
|
|
|
153
154
|
def verify_vault(root: Path) -> dict:
|
|
@@ -179,25 +180,24 @@ def verify_vault(root: Path) -> dict:
|
|
|
179
180
|
claims_path = facts_dir(root) / "claims.ndjson"
|
|
180
181
|
if claims_path.exists():
|
|
181
182
|
seen: set[str] = set()
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
record(f"claim:{cid}", rec, resolve(text, rec["anchor"]))
|
|
183
|
+
with claims_path.open(encoding="utf-8") as f:
|
|
184
|
+
for lineno, raw_line in enumerate(f, start=1):
|
|
185
|
+
line = raw_line.strip()
|
|
186
|
+
if not line:
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
rec = json.loads(line)
|
|
190
|
+
except json.JSONDecodeError as e:
|
|
191
|
+
raise DataError(f"claims.ndjson:{lineno}: invalid JSON: {e}") from e
|
|
192
|
+
for key in ("claim_id", "source_id", "anchor"):
|
|
193
|
+
if key not in rec:
|
|
194
|
+
raise DataError(f"claims.ndjson:{lineno}: missing '{key}'")
|
|
195
|
+
cid = rec["claim_id"]
|
|
196
|
+
if cid in seen:
|
|
197
|
+
raise DataError(f"duplicate claim_id: {cid}")
|
|
198
|
+
seen.add(cid)
|
|
199
|
+
text = _source_text(root, rec["source_id"], src_cache)
|
|
200
|
+
record(f"claim:{cid}", rec, resolve(text, rec["anchor"]))
|
|
201
201
|
|
|
202
202
|
wd = wiki_dir(root)
|
|
203
203
|
if wd.is_dir():
|
|
@@ -228,23 +228,35 @@ def _resolve_claim(
|
|
|
228
228
|
# --------------------------------------------------------------------------- #
|
|
229
229
|
# Existing-table reads & keys
|
|
230
230
|
# --------------------------------------------------------------------------- #
|
|
231
|
-
def
|
|
232
|
-
"""
|
|
233
|
-
|
|
231
|
+
def _ends_with_newline(path: Path) -> bool:
|
|
232
|
+
"""Whether the file's final byte is a newline (an empty/absent file counts as
|
|
233
|
+
terminated, so no separator is prepended before the next append)."""
|
|
234
|
+
if not path.exists() or path.stat().st_size == 0:
|
|
235
|
+
return True
|
|
236
|
+
with path.open("rb") as f:
|
|
237
|
+
f.seek(-1, 2)
|
|
238
|
+
return f.read(1) == b"\n"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_table(path: Path) -> tuple[list[dict], bool]:
|
|
242
|
+
"""Read an NDJSON table, returning ``(records, ends_with_newline)``. Streams the
|
|
243
|
+
file line-by-line rather than loading it whole. Malformed lines are a
|
|
244
|
+
:class:`DataError` — the vault on disk violates the contract."""
|
|
234
245
|
if not path.exists():
|
|
235
|
-
return [],
|
|
236
|
-
|
|
246
|
+
return [], True
|
|
247
|
+
ends_newline = _ends_with_newline(path)
|
|
237
248
|
records: list[dict] = []
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
249
|
+
with path.open(encoding="utf-8") as f:
|
|
250
|
+
for lineno, raw_line in enumerate(f, start=1):
|
|
251
|
+
line = raw_line.strip()
|
|
252
|
+
if not line:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
rec = json.loads(line)
|
|
256
|
+
except json.JSONDecodeError as e:
|
|
257
|
+
raise DataError(f"{path.name}:{lineno}: invalid JSON: {e}") from e
|
|
258
|
+
records.append(rec)
|
|
259
|
+
return records, ends_newline
|
|
248
260
|
|
|
249
261
|
|
|
250
262
|
def claim_source_anchor(root: Path, claim_id: str) -> tuple[str, str]:
|
|
@@ -384,7 +396,7 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
384
396
|
if failures:
|
|
385
397
|
return {"table": table, "appended": [], "skipped": [], "failures": failures}
|
|
386
398
|
|
|
387
|
-
existing,
|
|
399
|
+
existing, ends_with_newline = _read_table(path)
|
|
388
400
|
meta = _load_meta(root) # parse before appending: fail whole, not half
|
|
389
401
|
appended: list[dict] = []
|
|
390
402
|
skipped: list[dict] = []
|
|
@@ -503,7 +515,7 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
503
515
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
504
516
|
payload = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in appended)
|
|
505
517
|
with open(path, "a", encoding="utf-8") as f:
|
|
506
|
-
if
|
|
518
|
+
if not ends_with_newline:
|
|
507
519
|
f.write("\n")
|
|
508
520
|
f.write(payload)
|
|
509
521
|
new_sources: list[str] = []
|
|
@@ -60,3 +60,30 @@ def require(meta: dict, keys: Iterable[str], where: str = "frontmatter") -> None
|
|
|
60
60
|
missing = [k for k in keys if k not in meta]
|
|
61
61
|
if missing:
|
|
62
62
|
raise DataError(f"{where}: missing required key(s): {', '.join(missing)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def as_str(meta: dict, key: str, where: str = "frontmatter") -> str | None:
|
|
66
|
+
"""Return ``meta[key]`` as a string, or ``None`` if absent/null. Raise
|
|
67
|
+
:class:`DataError` (naming ``where``) if present but not a string."""
|
|
68
|
+
value = meta.get(key)
|
|
69
|
+
if value is None:
|
|
70
|
+
return None
|
|
71
|
+
if not isinstance(value, str):
|
|
72
|
+
raise DataError(
|
|
73
|
+
f"{where}: '{key}' must be a string, got {type(value).__name__}"
|
|
74
|
+
)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def as_str_list(meta: dict, key: str, where: str = "frontmatter") -> list[str]:
|
|
79
|
+
"""Return ``meta[key]`` as a list of strings, or ``[]`` if absent/null. Raise
|
|
80
|
+
:class:`DataError` (naming ``where``) if present but not a list of strings.
|
|
81
|
+
|
|
82
|
+
This is what stops a hand-edited bare string (``derived-from: raw/x``) from
|
|
83
|
+
silently char-splitting into per-character dependency ids downstream."""
|
|
84
|
+
value = meta.get(key)
|
|
85
|
+
if value is None:
|
|
86
|
+
return []
|
|
87
|
+
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
|
88
|
+
raise DataError(f"{where}: '{key}' must be a list of strings")
|
|
89
|
+
return value
|
|
@@ -80,28 +80,35 @@ def scan_derived(root: Path) -> dict:
|
|
|
80
80
|
meta, _ = frontmatter.load(path)
|
|
81
81
|
if not meta or "derived-from" not in meta:
|
|
82
82
|
continue # index.md, log.md, hand notes: not tracked artifacts
|
|
83
|
-
|
|
83
|
+
where = str(path.relative_to(root))
|
|
84
|
+
did = frontmatter.as_str(meta, "id", where) or where
|
|
84
85
|
out[did] = {
|
|
85
|
-
"path":
|
|
86
|
-
"type":
|
|
87
|
-
"derived_from":
|
|
88
|
-
"input_hash":
|
|
89
|
-
"last_compiled":
|
|
86
|
+
"path": where,
|
|
87
|
+
"type": frontmatter.as_str(meta, "type", where) or "wiki",
|
|
88
|
+
"derived_from": frontmatter.as_str_list(meta, "derived-from", where),
|
|
89
|
+
"input_hash": frontmatter.as_str(meta, "input-hash", where),
|
|
90
|
+
"last_compiled": frontmatter.as_str(meta, "last-compiled", where),
|
|
90
91
|
}
|
|
91
92
|
fmeta = facts_dir(root) / "_meta.yaml"
|
|
92
93
|
if fmeta.exists():
|
|
93
94
|
try:
|
|
94
|
-
data = yaml.safe_load(fmeta.read_text(encoding="utf-8"))
|
|
95
|
+
data = yaml.safe_load(fmeta.read_text(encoding="utf-8"))
|
|
95
96
|
except yaml.YAMLError as e:
|
|
96
97
|
raise DataError(f"invalid facts/_meta.yaml: {e}") from e
|
|
97
|
-
if
|
|
98
|
-
|
|
98
|
+
if data is None:
|
|
99
|
+
data = {} # empty / null file: legitimately "no facts set"
|
|
100
|
+
elif not isinstance(data, dict):
|
|
101
|
+
# don't let `or {}` mask falsey non-mappings ([], false, 0, '')
|
|
102
|
+
raise DataError("facts/_meta.yaml must be a YAML mapping")
|
|
103
|
+
if "derived-from" in data:
|
|
104
|
+
where = str(fmeta.relative_to(root))
|
|
105
|
+
did = frontmatter.as_str(data, "id", where) or "facts/core"
|
|
99
106
|
out[did] = {
|
|
100
|
-
"path":
|
|
101
|
-
"type":
|
|
102
|
-
"derived_from":
|
|
103
|
-
"input_hash":
|
|
104
|
-
"last_compiled":
|
|
107
|
+
"path": where,
|
|
108
|
+
"type": frontmatter.as_str(data, "type", where) or "facts.set",
|
|
109
|
+
"derived_from": frontmatter.as_str_list(data, "derived-from", where),
|
|
110
|
+
"input_hash": frontmatter.as_str(data, "input-hash", where),
|
|
111
|
+
"last_compiled": frontmatter.as_str(data, "last-compiled", where),
|
|
105
112
|
}
|
|
106
113
|
return out
|
|
107
114
|
|
|
@@ -114,10 +121,10 @@ def _dep_hash(dep_id: str, raw: dict) -> str | None:
|
|
|
114
121
|
r = raw.get(base)
|
|
115
122
|
if not r:
|
|
116
123
|
return None
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
124
|
+
# ⚡ Bolt: Replace O(n) loop with O(1) hash map lookup for blocks
|
|
125
|
+
if "_blocks_map" not in r:
|
|
126
|
+
r["_blocks_map"] = {b["block_id"]: b["hash"] for b in r["blocks"]}
|
|
127
|
+
return r["_blocks_map"].get(block_id)
|
|
121
128
|
r = raw.get(dep_id)
|
|
122
129
|
return r["content_hash"] if r else None
|
|
123
130
|
|
|
@@ -7,13 +7,10 @@ self-describing in the manifest and frontmatter.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import hashlib
|
|
10
|
-
import re
|
|
11
10
|
import unicodedata
|
|
12
11
|
from collections.abc import Mapping
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
|
|
15
|
-
_WS = re.compile(r"\s+")
|
|
16
|
-
|
|
17
14
|
|
|
18
15
|
def normalize(text: str) -> str:
|
|
19
16
|
"""Canonical text normalization shared by provenance anchors and block ids.
|
|
@@ -23,8 +20,8 @@ def normalize(text: str) -> str:
|
|
|
23
20
|
``blocks`` import) means anchor identity and block identity can never drift.
|
|
24
21
|
"""
|
|
25
22
|
t = unicodedata.normalize("NFC", text)
|
|
26
|
-
|
|
27
|
-
return t.lower()
|
|
23
|
+
# Using split() and join() is significantly faster than regex for whitespace normalization
|
|
24
|
+
return " ".join(t.split()).lower()
|
|
28
25
|
|
|
29
26
|
|
|
30
27
|
def _digest(data: bytes) -> str:
|
|
@@ -56,25 +56,26 @@ def _source_tags(root: Path) -> dict[str, set[str]]:
|
|
|
56
56
|
p = facts_dir(root) / "claims.ndjson"
|
|
57
57
|
if not p.exists():
|
|
58
58
|
return out
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
59
|
+
with p.open(encoding="utf-8") as f:
|
|
60
|
+
for lineno, raw_line in enumerate(f, start=1):
|
|
61
|
+
line = raw_line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
rec = json.loads(line)
|
|
66
|
+
except json.JSONDecodeError as e:
|
|
67
|
+
raise DataError(f"claims.ndjson:{lineno}: invalid JSON: {e}") from e
|
|
68
|
+
if not isinstance(rec, dict):
|
|
69
|
+
raise DataError(f"claims.ndjson:{lineno}: expected a JSON object")
|
|
70
|
+
sid = rec.get("source_id")
|
|
71
|
+
if not isinstance(sid, str):
|
|
72
|
+
raise DataError(f"claims.ndjson:{lineno}: 'source_id' must be a string")
|
|
73
|
+
tags = rec.get("tags")
|
|
74
|
+
if tags is None:
|
|
75
|
+
continue
|
|
76
|
+
if not isinstance(tags, list) or any(not isinstance(t, str) for t in tags):
|
|
77
|
+
raise DataError(f"claims.ndjson:{lineno}: 'tags' must be a list of strings")
|
|
78
|
+
out.setdefault(sid, set()).update(tags)
|
|
78
79
|
return out
|
|
79
80
|
|
|
80
81
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from scrip.errors import DataError
|
|
3
|
+
|
|
4
|
+
from scrip import frontmatter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# --- parse ----------------------------------------------------------------
|
|
8
|
+
def test_parse_no_frontmatter_returns_whole_text():
|
|
9
|
+
meta, body = frontmatter.parse("just a body\n")
|
|
10
|
+
assert meta == {}
|
|
11
|
+
assert body == "just a body\n"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_parse_valid_mapping():
|
|
15
|
+
meta, body = frontmatter.parse("---\nid: concept/x\nn: 3\n---\nBody.\n")
|
|
16
|
+
assert meta == {"id": "concept/x", "n": 3}
|
|
17
|
+
assert body == "Body.\n"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parse_empty_frontmatter_is_empty_dict():
|
|
21
|
+
meta, body = frontmatter.parse("---\n---\nbody\n")
|
|
22
|
+
assert meta == {}
|
|
23
|
+
assert body == "body\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_parse_invalid_yaml_raises():
|
|
27
|
+
with pytest.raises(DataError):
|
|
28
|
+
frontmatter.parse("---\nfoo: [unclosed\n---\nbody\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_parse_non_mapping_raises():
|
|
32
|
+
with pytest.raises(DataError):
|
|
33
|
+
frontmatter.parse("---\n- a\n- b\n---\nbody\n") # a list, not a mapping
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_parse_unterminated_raises():
|
|
37
|
+
with pytest.raises(DataError):
|
|
38
|
+
frontmatter.parse("---\nid: x\nno closing fence here\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- load / dump ----------------------------------------------------------
|
|
42
|
+
def test_load_missing_file_raises(tmp_path):
|
|
43
|
+
with pytest.raises(DataError):
|
|
44
|
+
frontmatter.load(tmp_path / "nope.md")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_dump_roundtrips_and_preserves_insertion_order():
|
|
48
|
+
meta = {"id": "concept/x", "type": "wiki.concept", "derived-from": ["raw/a"]}
|
|
49
|
+
text = frontmatter.dump(meta, "Body.\n")
|
|
50
|
+
meta2, body2 = frontmatter.parse(text)
|
|
51
|
+
assert meta2 == meta
|
|
52
|
+
assert body2 == "Body.\n"
|
|
53
|
+
assert text.index("id:") < text.index("type:") < text.index("derived-from:")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- require --------------------------------------------------------------
|
|
57
|
+
def test_require_raises_on_missing():
|
|
58
|
+
with pytest.raises(DataError):
|
|
59
|
+
frontmatter.require({"a": 1}, ["a", "b"], where="page")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_require_passes_when_present():
|
|
63
|
+
frontmatter.require({"a": 1, "b": 2}, ["a", "b"]) # no raise
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- typed accessors (new) ------------------------------------------------
|
|
67
|
+
def test_as_str_returns_value_or_none():
|
|
68
|
+
assert frontmatter.as_str({"k": "v"}, "k") == "v"
|
|
69
|
+
assert frontmatter.as_str({}, "k") is None
|
|
70
|
+
assert frontmatter.as_str({"k": None}, "k") is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_as_str_rejects_non_string():
|
|
74
|
+
with pytest.raises(DataError):
|
|
75
|
+
frontmatter.as_str({"k": 3}, "k")
|
|
76
|
+
with pytest.raises(DataError):
|
|
77
|
+
frontmatter.as_str({"k": ["a"]}, "k")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_as_str_list_returns_list_or_empty():
|
|
81
|
+
assert frontmatter.as_str_list({"k": ["a", "b"]}, "k") == ["a", "b"]
|
|
82
|
+
assert frontmatter.as_str_list({}, "k") == []
|
|
83
|
+
assert frontmatter.as_str_list({"k": None}, "k") == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_as_str_list_rejects_bare_string_and_non_string_items():
|
|
87
|
+
with pytest.raises(DataError):
|
|
88
|
+
frontmatter.as_str_list({"k": "raw/a"}, "k") # bare string would char-split
|
|
89
|
+
with pytest.raises(DataError):
|
|
90
|
+
frontmatter.as_str_list({"k": [1, 2]}, "k")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_accessor_error_names_the_location():
|
|
94
|
+
with pytest.raises(DataError) as ei:
|
|
95
|
+
frontmatter.as_str_list({"derived-from": "raw/a"}, "derived-from", where="wiki/x.md")
|
|
96
|
+
assert "wiki/x.md" in str(ei.value)
|
|
97
|
+
assert "derived-from" in str(ei.value)
|
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
import os
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import pytest
|
|
4
|
+
from scrip.errors import DataError
|
|
5
|
+
|
|
6
|
+
from scrip import cli, frontmatter, graph
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _write_wiki(kb, slug, meta, body="Body.\n"):
|
|
10
|
+
"""Write a wiki page with arbitrary (possibly malformed) frontmatter."""
|
|
11
|
+
p = kb.root / "vault" / "wiki" / "concepts" / f"{slug}.md"
|
|
12
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
p.write_text(frontmatter.dump(meta, body), encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_derived_from_as_string_raises_not_silent_stale(kb):
|
|
17
|
+
"""A bare string derived-from would char-split into per-character dep ids and
|
|
18
|
+
silently mark the page STALE; it must raise a clear, file-named error."""
|
|
19
|
+
kb.add_raw("a", "# A\n\nAlpha.\n")
|
|
20
|
+
_write_wiki(kb, "x", {
|
|
21
|
+
"id": "concept/x",
|
|
22
|
+
"type": "wiki.concept",
|
|
23
|
+
"derived-from": "raw/a", # STRING, not a list
|
|
24
|
+
"input-hash": "sha256:dead",
|
|
25
|
+
"last-compiled": "2026-01-01T00:00:00Z",
|
|
26
|
+
})
|
|
27
|
+
with pytest.raises(DataError) as ei:
|
|
28
|
+
graph.compute_status(kb.root, use_cache=False)
|
|
29
|
+
assert "x.md" in str(ei.value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_wrong_typed_id_raises(kb):
|
|
33
|
+
kb.add_raw("a", "# A\n\nAlpha.\n")
|
|
34
|
+
_write_wiki(kb, "x", {
|
|
35
|
+
"id": ["concept/x"], # non-string id
|
|
36
|
+
"derived-from": ["raw/a"],
|
|
37
|
+
"input-hash": "sha256:dead",
|
|
38
|
+
"last-compiled": "2026-01-01T00:00:00Z",
|
|
39
|
+
})
|
|
40
|
+
with pytest.raises(DataError):
|
|
41
|
+
graph.compute_status(kb.root, use_cache=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_facts_meta_derived_from_as_string_raises(kb):
|
|
45
|
+
kb.add_raw("a", "# A\n\nAlpha.\n")
|
|
46
|
+
(kb.root / "vault" / "facts" / "_meta.yaml").write_text(
|
|
47
|
+
"id: facts/core\nderived-from: raw/a\n", encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
with pytest.raises(DataError):
|
|
50
|
+
graph.compute_status(kb.root, use_cache=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_facts_meta_non_mapping_raises(kb):
|
|
54
|
+
"""A facts/_meta.yaml that parses to a non-mapping must fail loudly, not
|
|
55
|
+
silently drop the facts set from the graph (roborev 1251)."""
|
|
56
|
+
kb.add_raw("a", "# A\n\nAlpha.\n")
|
|
57
|
+
(kb.root / "vault" / "facts" / "_meta.yaml").write_text(
|
|
58
|
+
"- not\n- a\n- mapping\n", encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
with pytest.raises(DataError):
|
|
61
|
+
graph.compute_status(kb.root, use_cache=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_facts_meta_falsey_non_mapping_raises(kb):
|
|
65
|
+
"""`[]`/`false`/`''` are falsey, so `safe_load(...) or {}` used to mask them as
|
|
66
|
+
an empty mapping and silently drop the facts set (roborev 1253)."""
|
|
67
|
+
kb.add_raw("a", "# A\n\nAlpha.\n")
|
|
68
|
+
(kb.root / "vault" / "facts" / "_meta.yaml").write_text("[]\n", encoding="utf-8")
|
|
69
|
+
with pytest.raises(DataError):
|
|
70
|
+
graph.compute_status(kb.root, use_cache=False)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_facts_meta_empty_is_not_an_error(kb):
|
|
74
|
+
"""An empty / null facts/_meta.yaml is legitimately 'no facts set' — None must
|
|
75
|
+
still map to {} and not raise (guards against over-correcting)."""
|
|
76
|
+
kb.add_raw("a", "# A\n\nAlpha.\n")
|
|
77
|
+
kb.add_wiki("x", ["raw/a"])
|
|
78
|
+
(kb.root / "vault" / "facts" / "_meta.yaml").write_text("", encoding="utf-8")
|
|
79
|
+
res = graph.compute_status(kb.root, use_cache=False)
|
|
80
|
+
assert res["stale"] == []
|
|
4
81
|
|
|
5
82
|
|
|
6
83
|
def test_fresh_vault_all_ok(kb):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|