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.
Files changed (48) hide show
  1. {scriptoria-0.5.0 → scriptoria-0.5.2}/.gitignore +4 -0
  2. {scriptoria-0.5.0 → scriptoria-0.5.2}/PKG-INFO +1 -1
  3. {scriptoria-0.5.0 → scriptoria-0.5.2}/pyproject.toml +1 -1
  4. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/__init__.py +1 -1
  5. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/anchors.py +28 -28
  6. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/facts.py +29 -17
  7. scriptoria-0.5.2/src/scrip/frontmatter.py +128 -0
  8. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/graph.py +28 -29
  9. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/hashing.py +2 -5
  10. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/similar.py +23 -22
  11. scriptoria-0.5.2/tests/test_frontmatter.py +127 -0
  12. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_graph_status.py +78 -1
  13. {scriptoria-0.5.0 → scriptoria-0.5.2}/uv.lock +1 -1
  14. scriptoria-0.5.0/src/scrip/frontmatter.py +0 -62
  15. {scriptoria-0.5.0 → scriptoria-0.5.2}/README.md +0 -0
  16. {scriptoria-0.5.0 → scriptoria-0.5.2}/pyrightconfig.json +0 -0
  17. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/blocks.py +0 -0
  18. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/cli.py +0 -0
  19. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/embeddings.py +0 -0
  20. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/errors.py +0 -0
  21. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/ingest.py +0 -0
  22. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/lock.py +0 -0
  23. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/manifest.py +0 -0
  24. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/query.py +0 -0
  25. {scriptoria-0.5.0 → scriptoria-0.5.2}/src/scrip/retrieval.py +0 -0
  26. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/conftest.py +0 -0
  27. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_anchor_cmd.py +0 -0
  28. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_anchors.py +0 -0
  29. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_blocks.py +0 -0
  30. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_embeddings.py +0 -0
  31. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_fact_cmd.py +0 -0
  32. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_hashing.py +0 -0
  33. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_index_cmd.py +0 -0
  34. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_ingest.py +0 -0
  35. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_json_shapes.py +0 -0
  36. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_lock.py +0 -0
  37. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_manifest.py +0 -0
  38. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_new_cmd.py +0 -0
  39. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_query.py +0 -0
  40. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_retrieval.py +0 -0
  41. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_similar_cmd.py +0 -0
  42. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_span_cmd.py +0 -0
  43. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_stamp.py +0 -0
  44. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_status_cmd.py +0 -0
  45. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_unlock_cmd.py +0 -0
  46. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_verify.py +0 -0
  47. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_version.py +0 -0
  48. {scriptoria-0.5.0 → scriptoria-0.5.2}/tests/test_watch.py +0 -0
@@ -29,3 +29,7 @@ build/
29
29
 
30
30
  # roborev snapshots
31
31
  /.roborev/
32
+
33
+ # Obsidian local state: created when you open vault/ in Obsidian (UI config,
34
+ # sideloaded plugin copies). Local-only; the vault is valid without it.
35
+ vault/.obsidian/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scriptoria
3
- Version: 0.5.0
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.0"
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.0"
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
- for line in path.read_text(encoding="utf-8").splitlines():
143
- m = _FOOTNOTE.match(line)
144
- if not m:
145
- continue
146
- target = m.group(2)
147
- if "#" not in target:
148
- continue
149
- source_id, anchor = target.split("#", 1)
150
- yield {"fn": m.group(1), "source_id": source_id, "anchor": anchor}
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
- for lineno, raw_line in enumerate(
183
- claims_path.read_text(encoding="utf-8").splitlines(), start=1
184
- ):
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"]))
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 _read_table(path: Path) -> tuple[list[dict], str]:
232
- """Read an NDJSON table, returning ``(records, raw_text)``. Malformed lines
233
- are a :class:`DataError` — the vault on disk violates the contract."""
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
- text = path.read_text(encoding="utf-8")
246
+ return [], True
247
+ ends_newline = _ends_with_newline(path)
237
248
  records: list[dict] = []
238
- for lineno, raw_line in enumerate(text.splitlines(), start=1):
239
- line = raw_line.strip()
240
- if not line:
241
- continue
242
- try:
243
- rec = json.loads(line)
244
- except json.JSONDecodeError as e:
245
- raise DataError(f"{path.name}:{lineno}: invalid JSON: {e}") from e
246
- records.append(rec)
247
- return records, text
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, existing_text = _read_table(path)
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 existing_text and not existing_text.endswith("\n"):
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, _ = frontmatter.load(path)
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
- did = meta.get("id") or str(path.relative_to(root))
83
+ where = str(path.relative_to(root))
84
+ did = frontmatter.as_str(meta, "id", where) or where
84
85
  out[did] = {
85
- "path": str(path.relative_to(root)),
86
- "type": meta.get("type", "wiki"),
87
- "derived_from": list(meta.get("derived-from") or []),
88
- "input_hash": meta.get("input-hash"),
89
- "last_compiled": meta.get("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")) or {}
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 isinstance(data, dict) and "derived-from" in data:
98
- did = data.get("id") or "facts/core"
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": str(fmeta.relative_to(root)),
101
- "type": data.get("type", "facts.set"),
102
- "derived_from": list(data.get("derived-from") or []),
103
- "input_hash": data.get("input-hash"),
104
- "last_compiled": data.get("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
- for b in r["blocks"]:
118
- if b["block_id"] == block_id:
119
- return b["hash"]
120
- return None
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
- t = _WS.sub(" ", t).strip()
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
- for lineno, raw_line in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
60
- line = raw_line.strip()
61
- if not line:
62
- continue
63
- try:
64
- rec = json.loads(line)
65
- except json.JSONDecodeError as e:
66
- raise DataError(f"claims.ndjson:{lineno}: invalid JSON: {e}") from e
67
- if not isinstance(rec, dict):
68
- raise DataError(f"claims.ndjson:{lineno}: expected a JSON object")
69
- sid = rec.get("source_id")
70
- if not isinstance(sid, str):
71
- raise DataError(f"claims.ndjson:{lineno}: 'source_id' must be a string")
72
- tags = rec.get("tags")
73
- if tags is None:
74
- continue
75
- if not isinstance(tags, list) or any(not isinstance(t, str) for t in tags):
76
- raise DataError(f"claims.ndjson:{lineno}: 'tags' must be a list of strings")
77
- out.setdefault(sid, set()).update(tags)
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, _ = frontmatter.load(root / d["path"])
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' sources {s["sources"]:.2f} tags {s["tags"]:.2f} title {s["title"]:.2f}'
166
- f' shared sources: {len(c["shared"]["sources"])}, tags: {len(c["shared"]["tags"])}'
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
- from scrip import cli, graph
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):
@@ -1141,7 +1141,7 @@ wheels = [
1141
1141
 
1142
1142
  [[package]]
1143
1143
  name = "scriptoria"
1144
- version = "0.5.0"
1144
+ version = "0.5.2"
1145
1145
  source = { editable = "." }
1146
1146
  dependencies = [
1147
1147
  { name = "duckdb" },
@@ -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