scriptoria 0.5.0__tar.gz → 0.5.2__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.2}/.gitignore +4 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/PKG-INFO +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.2}/pyproject.toml +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/__init__.py +1 -1
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/anchors.py +28 -28
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/facts.py +29 -17
- scriptoria-0.5.2/src/scrip/frontmatter.py +128 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/graph.py +28 -29
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/hashing.py +2 -5
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/similar.py +23 -22
- scriptoria-0.5.2/tests/test_frontmatter.py +127 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_graph_status.py +78 -1
- {scriptoria-0.5.0 → scriptoria-0.5.2}/uv.lock +1 -1
- scriptoria-0.5.0/src/scrip/frontmatter.py +0 -62
- {scriptoria-0.5.0 → scriptoria-0.5.2}/README.md +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/pyrightconfig.json +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/blocks.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/cli.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/embeddings.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/errors.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/ingest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/lock.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/manifest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/query.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/retrieval.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/conftest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_anchor_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_anchors.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_blocks.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_embeddings.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_fact_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_hashing.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_index_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_ingest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_json_shapes.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_lock.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_manifest.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_new_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_query.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_retrieval.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_similar_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_span_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_stamp.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_status_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_unlock_cmd.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_verify.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_version.py +0 -0
- {scriptoria-0.5.0 → scriptoria-0.5.2}/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.2
|
|
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.2"
|
|
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.2"
|
|
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] = []
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Minimal YAML frontmatter parsing — no third-party 'frontmatter' package.
|
|
2
|
+
|
|
3
|
+
A document is ``---\\n<yaml>\\n---\\n<body>``. We split on the fence lines and let
|
|
4
|
+
PyYAML parse the middle. Keeping this in-house (≈40 lines) is what lets the
|
|
5
|
+
whole CLI stay "a few hundred LOC" with only duckdb + pyyaml as real deps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .errors import DataError
|
|
16
|
+
|
|
17
|
+
FENCE = "---"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse(text: str) -> tuple[dict, str]:
|
|
21
|
+
"""Return ``(meta, body)``. If there is no frontmatter, ``meta`` is ``{}``
|
|
22
|
+
and ``body`` is the whole text."""
|
|
23
|
+
lines = text.splitlines(keepends=True)
|
|
24
|
+
if not lines or lines[0].strip() != FENCE:
|
|
25
|
+
return {}, text
|
|
26
|
+
for i in range(1, len(lines)):
|
|
27
|
+
if lines[i].strip() == FENCE:
|
|
28
|
+
fm_text = "".join(lines[1:i])
|
|
29
|
+
body = "".join(lines[i + 1 :])
|
|
30
|
+
try:
|
|
31
|
+
meta = yaml.safe_load(fm_text)
|
|
32
|
+
except yaml.YAMLError as e:
|
|
33
|
+
raise DataError(f"invalid YAML frontmatter: {e}") from e
|
|
34
|
+
if meta is None:
|
|
35
|
+
meta = {}
|
|
36
|
+
if not isinstance(meta, dict):
|
|
37
|
+
raise DataError("frontmatter must be a YAML mapping")
|
|
38
|
+
return meta, body
|
|
39
|
+
raise DataError("unterminated frontmatter (missing closing '---')")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_frontmatter(f) -> tuple[bool, dict]:
|
|
43
|
+
first_line = f.readline()
|
|
44
|
+
if not first_line or first_line.strip() != FENCE:
|
|
45
|
+
return False, {}
|
|
46
|
+
|
|
47
|
+
fm_lines = []
|
|
48
|
+
for line in f:
|
|
49
|
+
if line.strip() == FENCE:
|
|
50
|
+
break
|
|
51
|
+
fm_lines.append(line)
|
|
52
|
+
else:
|
|
53
|
+
raise DataError("unterminated frontmatter (missing closing '---')")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
meta = yaml.safe_load("".join(fm_lines))
|
|
57
|
+
except yaml.YAMLError as e:
|
|
58
|
+
raise DataError(f"invalid YAML frontmatter: {e}") from e
|
|
59
|
+
if meta is None:
|
|
60
|
+
meta = {}
|
|
61
|
+
if not isinstance(meta, dict):
|
|
62
|
+
raise DataError("frontmatter must be a YAML mapping")
|
|
63
|
+
return True, meta
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load(path: str | Path) -> tuple[dict, str]:
|
|
67
|
+
p = Path(path)
|
|
68
|
+
try:
|
|
69
|
+
with open(p, encoding="utf-8") as f:
|
|
70
|
+
found, meta = _read_frontmatter(f)
|
|
71
|
+
if not found:
|
|
72
|
+
f.seek(0)
|
|
73
|
+
return {}, f.read()
|
|
74
|
+
body = f.read()
|
|
75
|
+
return meta, body
|
|
76
|
+
except OSError as e:
|
|
77
|
+
raise DataError(f"cannot read {p}: {e}") from e
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_meta(path: str | Path) -> dict:
|
|
81
|
+
"""Read just the frontmatter from a file without loading the body.
|
|
82
|
+
Significantly faster for metadata-only operations like scanning the vault."""
|
|
83
|
+
p = Path(path)
|
|
84
|
+
try:
|
|
85
|
+
with open(p, encoding="utf-8") as f:
|
|
86
|
+
_, meta = _read_frontmatter(f)
|
|
87
|
+
return meta
|
|
88
|
+
except OSError as e:
|
|
89
|
+
raise DataError(f"cannot read {p}: {e}") from e
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def dump(meta: dict, body: str) -> str:
|
|
93
|
+
"""Serialize back to a frontmatter document. Insertion order is preserved
|
|
94
|
+
(``sort_keys=False``) so files diff cleanly."""
|
|
95
|
+
fm = yaml.safe_dump(meta, sort_keys=False, allow_unicode=True).rstrip("\n")
|
|
96
|
+
return f"{FENCE}\n{fm}\n{FENCE}\n{body}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def require(meta: dict, keys: Iterable[str], where: str = "frontmatter") -> None:
|
|
100
|
+
"""Raise :class:`DataError` if any required key is absent."""
|
|
101
|
+
missing = [k for k in keys if k not in meta]
|
|
102
|
+
if missing:
|
|
103
|
+
raise DataError(f"{where}: missing required key(s): {', '.join(missing)}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def as_str(meta: dict, key: str, where: str = "frontmatter") -> str | None:
|
|
107
|
+
"""Return ``meta[key]`` as a string, or ``None`` if absent/null. Raise
|
|
108
|
+
:class:`DataError` (naming ``where``) if present but not a string."""
|
|
109
|
+
value = meta.get(key)
|
|
110
|
+
if value is None:
|
|
111
|
+
return None
|
|
112
|
+
if not isinstance(value, str):
|
|
113
|
+
raise DataError(f"{where}: '{key}' must be a string, got {type(value).__name__}")
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def as_str_list(meta: dict, key: str, where: str = "frontmatter") -> list[str]:
|
|
118
|
+
"""Return ``meta[key]`` as a list of strings, or ``[]`` if absent/null. Raise
|
|
119
|
+
:class:`DataError` (naming ``where``) if present but not a list of strings.
|
|
120
|
+
|
|
121
|
+
This is what stops a hand-edited bare string (``derived-from: raw/x``) from
|
|
122
|
+
silently char-splitting into per-character dependency ids downstream."""
|
|
123
|
+
value = meta.get(key)
|
|
124
|
+
if value is None:
|
|
125
|
+
return []
|
|
126
|
+
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
|
127
|
+
raise DataError(f"{where}: '{key}' must be a list of strings")
|
|
128
|
+
return value
|
|
@@ -77,31 +77,38 @@ def scan_derived(root: Path) -> dict:
|
|
|
77
77
|
wd = wiki_dir(root)
|
|
78
78
|
if wd.is_dir():
|
|
79
79
|
for path in sorted(wd.rglob("*.md")):
|
|
80
|
-
meta
|
|
80
|
+
meta = frontmatter.load_meta(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
|
|
|
@@ -188,11 +195,7 @@ def compute_status(
|
|
|
188
195
|
else:
|
|
189
196
|
ok.append({"id": did, "path": d["path"]})
|
|
190
197
|
|
|
191
|
-
uncompiled = [
|
|
192
|
-
{"id": rid, "path": r["path"]}
|
|
193
|
-
for rid, r in raw.items()
|
|
194
|
-
if rid not in referenced
|
|
195
|
-
]
|
|
198
|
+
uncompiled = [{"id": rid, "path": r["path"]} for rid, r in raw.items() if rid not in referenced]
|
|
196
199
|
|
|
197
200
|
if rebuild:
|
|
198
201
|
manifest_mod.save(root, manifest_mod.build(raw, derived))
|
|
@@ -264,11 +267,7 @@ def print_status(result: dict) -> None:
|
|
|
264
267
|
if stale:
|
|
265
268
|
print(f"STALE ({len(stale)})")
|
|
266
269
|
for s in stale:
|
|
267
|
-
tag = (
|
|
268
|
-
f" [changed: {', '.join(s['changed_sources'])}]"
|
|
269
|
-
if s["changed_sources"]
|
|
270
|
-
else ""
|
|
271
|
-
)
|
|
270
|
+
tag = f" [changed: {', '.join(s['changed_sources'])}]" if s["changed_sources"] else ""
|
|
272
271
|
print(f" ✗ {s['id']} — {s['reason']}{tag}")
|
|
273
272
|
if unc:
|
|
274
273
|
print(f"UNCOMPILED ({len(unc)})")
|
|
@@ -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
|
|
|
@@ -114,7 +115,7 @@ def compute_similar(
|
|
|
114
115
|
if d.get("type") != want_type or cid in skip:
|
|
115
116
|
continue # other-kind pages and the facts.set row are dropped here
|
|
116
117
|
c_sources = _source_set(d["derived_from"])
|
|
117
|
-
meta
|
|
118
|
+
meta = frontmatter.load_meta(root / d["path"])
|
|
118
119
|
c_title = (meta.get("title") if meta else "") or ""
|
|
119
120
|
c_tags = _page_tags(c_sources, source_tags)
|
|
120
121
|
|
|
@@ -162,7 +163,7 @@ def print_similar(result: dict) -> None:
|
|
|
162
163
|
s = c["scores"]
|
|
163
164
|
print(f' {s["combined"]:.3f} {c["id"]} "{c["title"]}"')
|
|
164
165
|
print(
|
|
165
|
-
f
|
|
166
|
-
f
|
|
166
|
+
f" sources {s['sources']:.2f} tags {s['tags']:.2f} title {s['title']:.2f}"
|
|
167
|
+
f" shared sources: {len(c['shared']['sources'])}, tags: {len(c['shared']['tags'])}"
|
|
167
168
|
)
|
|
168
169
|
print(f"({len(cands)} candidate(s))")
|
|
@@ -0,0 +1,127 @@
|
|
|
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_load_no_frontmatter_returns_whole_text(tmp_path):
|
|
48
|
+
path = tmp_path / "plain.md"
|
|
49
|
+
path.write_text("just a body\n", encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
meta, body = frontmatter.load(path)
|
|
52
|
+
|
|
53
|
+
assert meta == {}
|
|
54
|
+
assert body == "just a body\n"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_load_empty_frontmatter_strips_fences(tmp_path):
|
|
58
|
+
path = tmp_path / "empty.md"
|
|
59
|
+
path.write_text("---\n---\nbody\n", encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
meta, body = frontmatter.load(path)
|
|
62
|
+
|
|
63
|
+
assert meta == {}
|
|
64
|
+
assert body == "body\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_load_comment_only_frontmatter_strips_fences(tmp_path):
|
|
68
|
+
path = tmp_path / "comment.md"
|
|
69
|
+
path.write_text("---\n# intentionally empty\n---\nbody\n", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
meta, body = frontmatter.load(path)
|
|
72
|
+
|
|
73
|
+
assert meta == {}
|
|
74
|
+
assert body == "body\n"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_dump_roundtrips_and_preserves_insertion_order():
|
|
78
|
+
meta = {"id": "concept/x", "type": "wiki.concept", "derived-from": ["raw/a"]}
|
|
79
|
+
text = frontmatter.dump(meta, "Body.\n")
|
|
80
|
+
meta2, body2 = frontmatter.parse(text)
|
|
81
|
+
assert meta2 == meta
|
|
82
|
+
assert body2 == "Body.\n"
|
|
83
|
+
assert text.index("id:") < text.index("type:") < text.index("derived-from:")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- require --------------------------------------------------------------
|
|
87
|
+
def test_require_raises_on_missing():
|
|
88
|
+
with pytest.raises(DataError):
|
|
89
|
+
frontmatter.require({"a": 1}, ["a", "b"], where="page")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_require_passes_when_present():
|
|
93
|
+
frontmatter.require({"a": 1, "b": 2}, ["a", "b"]) # no raise
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- typed accessors (new) ------------------------------------------------
|
|
97
|
+
def test_as_str_returns_value_or_none():
|
|
98
|
+
assert frontmatter.as_str({"k": "v"}, "k") == "v"
|
|
99
|
+
assert frontmatter.as_str({}, "k") is None
|
|
100
|
+
assert frontmatter.as_str({"k": None}, "k") is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_as_str_rejects_non_string():
|
|
104
|
+
with pytest.raises(DataError):
|
|
105
|
+
frontmatter.as_str({"k": 3}, "k")
|
|
106
|
+
with pytest.raises(DataError):
|
|
107
|
+
frontmatter.as_str({"k": ["a"]}, "k")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_as_str_list_returns_list_or_empty():
|
|
111
|
+
assert frontmatter.as_str_list({"k": ["a", "b"]}, "k") == ["a", "b"]
|
|
112
|
+
assert frontmatter.as_str_list({}, "k") == []
|
|
113
|
+
assert frontmatter.as_str_list({"k": None}, "k") == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_as_str_list_rejects_bare_string_and_non_string_items():
|
|
117
|
+
with pytest.raises(DataError):
|
|
118
|
+
frontmatter.as_str_list({"k": "raw/a"}, "k") # bare string would char-split
|
|
119
|
+
with pytest.raises(DataError):
|
|
120
|
+
frontmatter.as_str_list({"k": [1, 2]}, "k")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_accessor_error_names_the_location():
|
|
124
|
+
with pytest.raises(DataError) as ei:
|
|
125
|
+
frontmatter.as_str_list({"derived-from": "raw/a"}, "derived-from", where="wiki/x.md")
|
|
126
|
+
assert "wiki/x.md" in str(ei.value)
|
|
127
|
+
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):
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"""Minimal YAML frontmatter parsing — no third-party 'frontmatter' package.
|
|
2
|
-
|
|
3
|
-
A document is ``---\\n<yaml>\\n---\\n<body>``. We split on the fence lines and let
|
|
4
|
-
PyYAML parse the middle. Keeping this in-house (≈40 lines) is what lets the
|
|
5
|
-
whole CLI stay "a few hundred LOC" with only duckdb + pyyaml as real deps.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from collections.abc import Iterable
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
import yaml
|
|
14
|
-
|
|
15
|
-
from .errors import DataError
|
|
16
|
-
|
|
17
|
-
FENCE = "---"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def parse(text: str) -> tuple[dict, str]:
|
|
21
|
-
"""Return ``(meta, body)``. If there is no frontmatter, ``meta`` is ``{}``
|
|
22
|
-
and ``body`` is the whole text."""
|
|
23
|
-
lines = text.splitlines(keepends=True)
|
|
24
|
-
if not lines or lines[0].strip() != FENCE:
|
|
25
|
-
return {}, text
|
|
26
|
-
for i in range(1, len(lines)):
|
|
27
|
-
if lines[i].strip() == FENCE:
|
|
28
|
-
fm_text = "".join(lines[1:i])
|
|
29
|
-
body = "".join(lines[i + 1 :])
|
|
30
|
-
try:
|
|
31
|
-
meta = yaml.safe_load(fm_text)
|
|
32
|
-
except yaml.YAMLError as e:
|
|
33
|
-
raise DataError(f"invalid YAML frontmatter: {e}") from e
|
|
34
|
-
if meta is None:
|
|
35
|
-
meta = {}
|
|
36
|
-
if not isinstance(meta, dict):
|
|
37
|
-
raise DataError("frontmatter must be a YAML mapping")
|
|
38
|
-
return meta, body
|
|
39
|
-
raise DataError("unterminated frontmatter (missing closing '---')")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def load(path: str | Path) -> tuple[dict, str]:
|
|
43
|
-
p = Path(path)
|
|
44
|
-
try:
|
|
45
|
-
text = p.read_text(encoding="utf-8")
|
|
46
|
-
except OSError as e:
|
|
47
|
-
raise DataError(f"cannot read {p}: {e}") from e
|
|
48
|
-
return parse(text)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def dump(meta: dict, body: str) -> str:
|
|
52
|
-
"""Serialize back to a frontmatter document. Insertion order is preserved
|
|
53
|
-
(``sort_keys=False``) so files diff cleanly."""
|
|
54
|
-
fm = yaml.safe_dump(meta, sort_keys=False, allow_unicode=True).rstrip("\n")
|
|
55
|
-
return f"{FENCE}\n{fm}\n{FENCE}\n{body}"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def require(meta: dict, keys: Iterable[str], where: str = "frontmatter") -> None:
|
|
59
|
-
"""Raise :class:`DataError` if any required key is absent."""
|
|
60
|
-
missing = [k for k in keys if k not in meta]
|
|
61
|
-
if missing:
|
|
62
|
-
raise DataError(f"{where}: missing required key(s): {', '.join(missing)}")
|
|
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
|