scriptoria 0.9.0__tar.gz → 0.11.0__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 (54) hide show
  1. {scriptoria-0.9.0 → scriptoria-0.11.0}/PKG-INFO +5 -3
  2. {scriptoria-0.9.0 → scriptoria-0.11.0}/README.md +3 -1
  3. {scriptoria-0.9.0 → scriptoria-0.11.0}/pyproject.toml +1 -1
  4. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/__init__.py +1 -1
  5. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/blocks.py +29 -27
  6. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/cli.py +124 -11
  7. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/facts.py +12 -12
  8. scriptoria-0.11.0/src/scrip/neighbors.py +166 -0
  9. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_fact_cmd.py +29 -15
  10. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_facts.py +11 -0
  11. scriptoria-0.11.0/tests/test_init_cmd.py +102 -0
  12. scriptoria-0.11.0/tests/test_neighbors_cmd.py +139 -0
  13. {scriptoria-0.9.0 → scriptoria-0.11.0}/uv.lock +1 -1
  14. {scriptoria-0.9.0 → scriptoria-0.11.0}/.gitignore +0 -0
  15. {scriptoria-0.9.0 → scriptoria-0.11.0}/pyrightconfig.json +0 -0
  16. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/anchors.py +0 -0
  17. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/embeddings.py +0 -0
  18. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/errors.py +0 -0
  19. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/frontmatter.py +0 -0
  20. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/graph.py +0 -0
  21. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/hashing.py +0 -0
  22. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/ingest.py +0 -0
  23. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/lock.py +0 -0
  24. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/manifest.py +0 -0
  25. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/ontology.py +0 -0
  26. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/pageindex_adapter.py +0 -0
  27. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/query.py +0 -0
  28. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/retrieval.py +0 -0
  29. {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/similar.py +0 -0
  30. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/conftest.py +0 -0
  31. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_anchor_cmd.py +0 -0
  32. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_anchors.py +0 -0
  33. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_blocks.py +0 -0
  34. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_embeddings.py +0 -0
  35. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_frontmatter.py +0 -0
  36. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_graph_status.py +0 -0
  37. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_hashing.py +0 -0
  38. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_index_cmd.py +0 -0
  39. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_ingest.py +0 -0
  40. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_json_shapes.py +0 -0
  41. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_lock.py +0 -0
  42. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_manifest.py +0 -0
  43. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_new_cmd.py +0 -0
  44. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_pageindex_cmd.py +0 -0
  45. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_query.py +0 -0
  46. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_retrieval.py +0 -0
  47. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_similar_cmd.py +0 -0
  48. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_span_cmd.py +0 -0
  49. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_stamp.py +0 -0
  50. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_status_cmd.py +0 -0
  51. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_unlock_cmd.py +0 -0
  52. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_verify.py +0 -0
  53. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_version.py +0 -0
  54. {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: scriptoria
3
- Version: 0.9.0
3
+ Version: 0.11.0
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
@@ -51,6 +51,7 @@ uv run --project scrip scrip --help
51
51
 
52
52
  | Command | What it does |
53
53
  |---|---|
54
+ | `scrip init [DIR]` | Create the root skeleton — `vault/{raw,facts,wiki}/` plus the `.kb/` cache and its manifest — in `DIR` (default: the current directory). Idempotent in an existing root; refuses to nest a new instance inside one. |
54
55
  | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
55
56
  | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
56
57
  | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
@@ -67,7 +68,8 @@ uv run --project scrip scrip --help
67
68
  | `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
68
69
  | `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
69
70
 
70
- Every command accepts `--root DIR` and `--json`.
71
+ Every command accepts `--root DIR` and `--json` (`init` takes its target as a
72
+ positional path instead of `--root`).
71
73
 
72
74
  The optional `scrip-harness` package adds the model-bearing commands, including
73
75
  `scrip-harness answer "<question>"`, which gathers evidence with these primitives
@@ -20,6 +20,7 @@ uv run --project scrip scrip --help
20
20
 
21
21
  | Command | What it does |
22
22
  |---|---|
23
+ | `scrip init [DIR]` | Create the root skeleton — `vault/{raw,facts,wiki}/` plus the `.kb/` cache and its manifest — in `DIR` (default: the current directory). Idempotent in an existing root; refuses to nest a new instance inside one. |
23
24
  | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
24
25
  | `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
25
26
  | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
@@ -36,7 +37,8 @@ uv run --project scrip scrip --help
36
37
  | `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
37
38
  | `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
38
39
 
39
- Every command accepts `--root DIR` and `--json`.
40
+ Every command accepts `--root DIR` and `--json` (`init` takes its target as a
41
+ positional path instead of `--root`).
40
42
 
41
43
  The optional `scrip-harness` package adds the model-bearing commands, including
42
44
  `scrip-harness answer "<question>"`, which gathers evidence with these primitives
@@ -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.9.0"
5
+ version = "0.11.0"
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.9.0"
16
+ __version__ = "0.11.0"
17
17
 
18
18
  # --- canonical vault layout ------------------------------------------------
19
19
  # ``root`` is the repo/instance root: the directory containing ``vault/``.
@@ -38,43 +38,45 @@ def split_blocks(text: str) -> list[dict]:
38
38
  # Use splitlines(keepends=True): its boundary set (form feed, NEL, U+2028/9,
39
39
  # …) is part of the deterministic segmentation contract — io.StringIO only
40
40
  # splits on \r/\n and would re-hash existing blocks that contain those chars.
41
- ranges: list[list[int]] = [] # [start, end] per block
42
- cur: list[int] | None = None
41
+ # Emit blocks in a single pass: a running [cur_start, cur_end] paragraph is
42
+ # flushed at each boundary, so no intermediate `ranges` list is allocated.
43
+ out: list[dict] = []
44
+ seen: dict[str, int] = {}
45
+
46
+ def emit(s: int, e: int) -> None:
47
+ slice_text = text[s:e]
48
+ out.append(
49
+ {
50
+ "block_id": _block_id(slice_text, seen),
51
+ "span": [s, e],
52
+ "hash": hashing.sha256_text(slice_text),
53
+ }
54
+ )
55
+
56
+ cur_start: int | None = None
57
+ cur_end = 0
43
58
  start = 0
44
59
 
45
60
  for line in text.splitlines(keepends=True):
46
61
  end = start + len(line)
47
62
  if _is_blank(line):
48
- if cur is not None:
49
- ranges.append(cur)
50
- cur = None
63
+ if cur_start is not None:
64
+ emit(cur_start, cur_end)
65
+ cur_start = None
51
66
  elif _is_heading(line):
52
- if cur is not None:
53
- ranges.append(cur)
54
- cur = None
55
- ranges.append([start, end]) # a heading is its own block
67
+ if cur_start is not None:
68
+ emit(cur_start, cur_end)
69
+ cur_start = None
70
+ emit(start, end) # a heading is its own block
56
71
  else:
57
- if cur is None:
58
- cur = [start, end]
59
- else:
60
- cur[1] = end
72
+ if cur_start is None:
73
+ cur_start = start
74
+ cur_end = end
61
75
  start = end
62
76
 
63
- if cur is not None:
64
- ranges.append(cur)
77
+ if cur_start is not None:
78
+ emit(cur_start, cur_end)
65
79
 
66
- out: list[dict] = []
67
- seen: dict[str, int] = {}
68
- for s, e in ranges:
69
- slice_text = text[s:e]
70
- block_id = _block_id(slice_text, seen)
71
- out.append(
72
- {
73
- "block_id": block_id,
74
- "span": [s, e],
75
- "hash": hashing.sha256_text(slice_text),
76
- }
77
- )
78
80
  return out
79
81
 
80
82
 
@@ -1,7 +1,7 @@
1
1
  """Command-line entry point for ``scrip``.
2
2
 
3
- Dispatches to the four subcommands. Logic modules (``graph``, ``anchors``,
4
- ``query``) are imported lazily inside handlers so ``scrip --help`` works even
3
+ Dispatches to the subcommands. Logic modules (``graph``, ``anchors``,
4
+ ``query``, ``neighbors``) are imported lazily inside handlers so ``scrip --help`` works even
5
5
  before those modules exist, and so importing the CLI never pulls in DuckDB
6
6
  unless a query is actually run.
7
7
  """
@@ -35,12 +35,20 @@ def _safe_slug(slug: str, what: str = "slug") -> str:
35
35
  # --------------------------------------------------------------------------- #
36
36
  # Root resolution
37
37
  # --------------------------------------------------------------------------- #
38
+ def _looks_like_root(cand: Path) -> bool:
39
+ """True if ``cand`` looks like a scriptorium instance root: a directory
40
+ containing ``vault/`` plus either ``SPEC.md`` or ``.kb/``."""
41
+ return (cand / "vault").is_dir() and (
42
+ (cand / "SPEC.md").exists() or (cand / ".kb").is_dir()
43
+ )
44
+
45
+
38
46
  def resolve_root(root_arg: str | None) -> Path:
39
47
  """Return the scriptorium root.
40
48
 
41
49
  With ``--root`` the path is used verbatim (must be a directory). Otherwise we
42
- walk up from the cwd for the nearest ancestor that looks like an instance: a
43
- directory containing ``vault/`` plus either ``SPEC.md`` or ``.kb/``.
50
+ walk up from the cwd for the nearest ancestor that looks like an instance
51
+ (see :func:`_looks_like_root`).
44
52
  """
45
53
  if root_arg:
46
54
  root = Path(root_arg).expanduser().resolve()
@@ -50,13 +58,12 @@ def resolve_root(root_arg: str | None) -> Path:
50
58
 
51
59
  cur = Path.cwd().resolve()
52
60
  for cand in (cur, *cur.parents):
53
- if (cand / "vault").is_dir() and (
54
- (cand / "SPEC.md").exists() or (cand / ".kb").is_dir()
55
- ):
61
+ if _looks_like_root(cand):
56
62
  return cand
57
63
  raise errors.UsageError(
58
64
  "could not locate a scriptorium root (looked for a parent with vault/ and "
59
- "SPEC.md or .kb/). Pass --root explicitly."
65
+ "SPEC.md or .kb/). Run `scrip init` to start one here, or pass --root "
66
+ "explicitly."
60
67
  )
61
68
 
62
69
 
@@ -68,6 +75,58 @@ def _emit(payload: dict) -> None:
68
75
  # --------------------------------------------------------------------------- #
69
76
  # Subcommand handlers -> return an int exit code
70
77
  # --------------------------------------------------------------------------- #
78
+ def cmd_init(args: argparse.Namespace) -> int:
79
+ from . import graph, manifest_path
80
+
81
+ target = Path(args.path or ".").expanduser().resolve()
82
+ if target.exists() and not target.is_dir():
83
+ raise errors.UsageError(f"init target is not a directory: {target}")
84
+ if _looks_like_root(target):
85
+ if args.json:
86
+ _emit({"root": str(target), "status": "exists", "created": []})
87
+ else:
88
+ print(f"already a scriptorium root: {target} (nothing to do)")
89
+ return 0
90
+ # Refuse to create a nested instance: an inner vault/ would shadow the outer
91
+ # root for every command run beneath it (resolve_root stops at the nearest
92
+ # ancestor).
93
+ for cand in target.parents:
94
+ if _looks_like_root(cand):
95
+ raise errors.UsageError(
96
+ f"refusing to create a nested scriptorium: {target} is inside the "
97
+ f"root at {cand}. Run `scrip init` at that root, or pick a "
98
+ f"directory outside it."
99
+ )
100
+
101
+ created: list[str] = []
102
+ for rel in ("vault/raw", "vault/facts", "vault/wiki", ".kb"):
103
+ d = target / rel
104
+ if d.is_dir():
105
+ continue
106
+ if d.exists():
107
+ raise errors.UsageError(
108
+ f"cannot create {rel}/: a file already exists at {d}"
109
+ )
110
+ d.mkdir(parents=True, exist_ok=True)
111
+ created.append(rel + "/")
112
+ # Same as `scrip status --rebuild-manifest`: scan the (empty) vault and write
113
+ # the .kb/manifest.json cache, so the instance starts warm and detectable.
114
+ graph.compute_status(target, rebuild=True)
115
+ created.append(str(manifest_path(target).relative_to(target)))
116
+
117
+ if args.json:
118
+ _emit({"root": str(target), "status": "initialized", "created": created})
119
+ else:
120
+ print(f"initialized scriptorium root at {target}")
121
+ for c in created:
122
+ print(f" + {c}")
123
+ print(
124
+ " next: add a source (`scrip ingest <url|file>`), compile a page "
125
+ "(`scrip new` + `scrip anchor` + `scrip stamp`), then `scrip verify`"
126
+ )
127
+ return 0
128
+
129
+
71
130
  def cmd_status(args: argparse.Namespace) -> int:
72
131
  from . import graph
73
132
 
@@ -197,6 +256,25 @@ def cmd_query(args: argparse.Namespace) -> int:
197
256
  return 0
198
257
 
199
258
 
259
+ def cmd_neighbors(args: argparse.Namespace) -> int:
260
+ from . import neighbors
261
+
262
+ root = resolve_root(args.root)
263
+ result = neighbors.neighborhood(
264
+ root,
265
+ args.node,
266
+ depth=args.depth,
267
+ kinds=args.kind,
268
+ direction=args.direction,
269
+ limit=args.limit,
270
+ )
271
+ if args.json:
272
+ _emit(result)
273
+ else:
274
+ neighbors.print_neighborhood(result)
275
+ return 0
276
+
277
+
200
278
  def cmd_index(args: argparse.Namespace) -> int:
201
279
  from . import embeddings
202
280
 
@@ -460,12 +538,13 @@ def cmd_fact_add(args: argparse.Namespace) -> int:
460
538
  root = resolve_root(args.root)
461
539
  if args.file:
462
540
  try:
463
- text = Path(args.file).read_text(encoding="utf-8")
541
+ with Path(args.file).open(encoding="utf-8", newline="\n") as f:
542
+ proposals = facts.parse_ndjson(f)
464
543
  except OSError as e:
465
544
  raise errors.UsageError(f"cannot read --file: {e}") from e
466
545
  else:
467
- text = sys.stdin.read()
468
- result = facts.add(root, args.table, facts.parse_ndjson(text))
546
+ proposals = facts.parse_ndjson(sys.stdin)
547
+ result = facts.add(root, args.table, proposals)
469
548
  if args.json:
470
549
  _emit(result)
471
550
  else:
@@ -555,6 +634,21 @@ def build_parser() -> argparse.ArgumentParser:
555
634
  p.add_argument("--version", action="version", version=f"scrip {__version__}")
556
635
  sub = p.add_subparsers(dest="command", required=True, metavar="<command>")
557
636
 
637
+ pinit = sub.add_parser(
638
+ "init",
639
+ help="create the root skeleton (vault/{raw,facts,wiki}/ + .kb/ cache) and its manifest",
640
+ )
641
+ pinit.add_argument(
642
+ "path",
643
+ nargs="?",
644
+ metavar="DIR",
645
+ help="directory to initialize (default: current directory; created if absent)",
646
+ )
647
+ pinit.add_argument(
648
+ "--json", action="store_true", help="machine-readable JSON output"
649
+ )
650
+ pinit.set_defaults(func=cmd_init)
651
+
558
652
  ps = sub.add_parser(
559
653
  "status",
560
654
  parents=[common],
@@ -631,6 +725,25 @@ def build_parser() -> argparse.ArgumentParser:
631
725
  pq.add_argument("--limit", type=int, help="row limit for a named query")
632
726
  pq.set_defaults(func=cmd_query)
633
727
 
728
+ pn = sub.add_parser(
729
+ "neighbors",
730
+ parents=[common],
731
+ help="show one node's edges within N hops of facts/graph.ndjson",
732
+ )
733
+ pn.add_argument("node", help="an entity id (entity/<slug>) or any edge src/dst id")
734
+ pn.add_argument("--depth", type=int, default=1, help="hops to walk, 1-3 (default 1)")
735
+ pn.add_argument(
736
+ "--kind", action="append", help="only follow edges of this kind (repeatable)"
737
+ )
738
+ pn.add_argument(
739
+ "--direction",
740
+ choices=["both", "out", "in"],
741
+ default="both",
742
+ help="follow outgoing, incoming, or both (default both)",
743
+ )
744
+ pn.add_argument("--limit", type=int, default=50, help="max edges shown (default 50)")
745
+ pn.set_defaults(func=cmd_neighbors)
746
+
634
747
  pi = sub.add_parser(
635
748
  "index",
636
749
  parents=[common],
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import json
23
23
  import re
24
- from collections.abc import Iterator
24
+ from collections.abc import Iterable, Iterator
25
25
  from datetime import datetime, timezone
26
26
  from pathlib import Path
27
27
 
@@ -91,15 +91,18 @@ def _iter_lf_lines(text: str) -> Iterator[str]:
91
91
  start = end + 1
92
92
 
93
93
 
94
- def parse_ndjson(text: str) -> list[dict]:
95
- """Parse proposed records (one JSON object per line). Malformed input is a
94
+ def parse_ndjson(source: str | Iterable[str]) -> list[dict]:
95
+ """Parse proposed records (one JSON object per line). ``source`` is the whole
96
+ input as a string, or an iterable of lines such as a file opened with
97
+ ``newline="\n"`` (streamed, never read whole). Malformed input is a
96
98
  :class:`DataError` with its line number; an empty input is a usage error."""
97
99
  records: list[dict] = []
98
100
  # Split on "\n" only, not str.splitlines(): NDJSON records are newline-
99
101
  # delimited and may legally contain U+2028/U+2029/NEL inside a JSON string,
100
102
  # which splitlines() would wrongly treat as record breaks. trailing \r (from
101
103
  # \r\n) and the trailing empty element from a final newline are dropped below.
102
- for lineno, raw_line in enumerate(_iter_lf_lines(text), start=1):
104
+ lines = _iter_lf_lines(source) if isinstance(source, str) else source
105
+ for lineno, raw_line in enumerate(lines, start=1):
103
106
  line = raw_line.strip()
104
107
  if not line:
105
108
  continue
@@ -412,10 +415,7 @@ def _load_meta(root: Path) -> dict:
412
415
 
413
416
  def _write_meta(root: Path, data: dict, new_sources: list[str]) -> None:
414
417
  derived = list(data.get("derived-from") or [])
415
- for sid in new_sources:
416
- if sid not in derived:
417
- derived.append(sid)
418
- data["derived-from"] = derived
418
+ data["derived-from"] = list(dict.fromkeys(derived + new_sources))
419
419
  # Drop the stamp on EVERY append: with an unchanged derived-from the
420
420
  # recomputed input-hash would still match the stored one, and status would
421
421
  # report OK over facts nobody has blessed. Removing input-hash forces STALE
@@ -596,12 +596,12 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
596
596
  if not ends_with_newline:
597
597
  f.write("\n")
598
598
  f.write(payload)
599
- new_sources: list[str] = []
599
+ new_sources_dict: dict[str, None] = {}
600
600
  if table in ("claims", "edges"):
601
601
  for rec in appended:
602
602
  sid = rec.get("source_id") # cited edges carry one; bare edges don't
603
- if sid and sid not in new_sources:
604
- new_sources.append(sid)
605
- _write_meta(root, meta, new_sources)
603
+ if sid:
604
+ new_sources_dict[sid] = None
605
+ _write_meta(root, meta, list(new_sources_dict.keys()))
606
606
 
607
607
  return {"table": table, "appended": appended, "skipped": skipped, "failures": []}
@@ -0,0 +1,166 @@
1
+ """Focused neighborhood views over ``facts/graph.ndjson``.
2
+
3
+ ``scrip query edges`` dumps the whole edge list; past a few dozen edges that is
4
+ the usual unreadable hairball. This module answers the narrower question "what
5
+ is connected to this one node, within N hops?" — deterministic, read-only, and
6
+ read through the same DuckDB lens as ``scrip query`` so the two cannot drift.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import deque
12
+ from pathlib import Path
13
+
14
+ from . import facts_dir
15
+ from . import query as query_mod
16
+ from .errors import DataError, UsageError
17
+
18
+ MAX_DEPTH = 3
19
+ DIRECTIONS = ("both", "out", "in")
20
+
21
+
22
+ def _has_rows(path: Path) -> bool:
23
+ if not path.exists():
24
+ return False
25
+ with path.open(encoding="utf-8") as f:
26
+ return any(line.strip() for line in f)
27
+
28
+
29
+ def _load(root: Path, name: str, fname: str) -> list[dict]:
30
+ # An absent or empty table is simply no rows: DuckDB has no view for it.
31
+ if not _has_rows(facts_dir(root) / fname):
32
+ return []
33
+ _, rows = query_mod.run(root, name=name)
34
+ return rows
35
+
36
+
37
+ def _node_summary(node: str, entity: dict | None) -> dict:
38
+ out: dict = {"id": node}
39
+ if entity:
40
+ out["name"] = entity.get("name")
41
+ out["kind"] = entity.get("kind")
42
+ return out
43
+
44
+
45
+ def neighborhood(
46
+ root: Path,
47
+ node: str,
48
+ *,
49
+ depth: int = 1,
50
+ kinds: list[str] | None = None,
51
+ direction: str = "both",
52
+ limit: int = 50,
53
+ ) -> dict:
54
+ """Return the edges within ``depth`` hops of ``node`` and the nodes they touch
55
+ (entity name/kind when the node is an entity, else id only).
56
+
57
+ Walks breadth-first; each edge is reported once, at the hop it is first
58
+ reached. Order is deterministic: by hop, then ``src``, ``dst``, ``kind``.
59
+ ``truncated`` is true when ``limit`` cut edges off."""
60
+ if not 1 <= depth <= MAX_DEPTH:
61
+ raise UsageError(f"--depth must be between 1 and {MAX_DEPTH}")
62
+ if direction not in DIRECTIONS:
63
+ raise UsageError(f"--direction must be one of: {', '.join(DIRECTIONS)}")
64
+ if limit < 1:
65
+ raise UsageError("--limit must be at least 1")
66
+
67
+ entities = _load(root, "entities", "entities.ndjson")
68
+ edges = _load(root, "edges", "graph.ndjson")
69
+ entity_by_id = {
70
+ str(e["entity_id"]): e for e in entities if isinstance(e.get("entity_id"), str)
71
+ }
72
+ wanted = set(kinds) if kinds else None
73
+
74
+ out_adj: dict[str, list[int]] = {}
75
+ in_adj: dict[str, list[int]] = {}
76
+ known = set(entity_by_id)
77
+ for i, e in enumerate(edges):
78
+ src, dst = str(e.get("src") or ""), str(e.get("dst") or "")
79
+ known.update((src, dst))
80
+ if wanted is not None and e.get("kind") not in wanted:
81
+ continue
82
+ out_adj.setdefault(src, []).append(i)
83
+ in_adj.setdefault(dst, []).append(i)
84
+ if node not in known:
85
+ raise DataError(f"no such node: {node}")
86
+
87
+ seen_edges: set[int] = set()
88
+ found: list[tuple[int, int]] = [] # (hop, edge index)
89
+ visited = {node}
90
+ frontier: deque[tuple[str, int]] = deque([(node, 0)])
91
+ while frontier:
92
+ cur, hop = frontier.popleft()
93
+ if hop == depth:
94
+ continue
95
+ steps: list[tuple[int, str]] = []
96
+ if direction in ("both", "out"):
97
+ steps += [(i, str(edges[i].get("dst") or "")) for i in out_adj.get(cur, [])]
98
+ if direction in ("both", "in"):
99
+ steps += [(i, str(edges[i].get("src") or "")) for i in in_adj.get(cur, [])]
100
+ for i, other in steps:
101
+ if i not in seen_edges:
102
+ seen_edges.add(i)
103
+ found.append((hop + 1, i))
104
+ if other not in visited:
105
+ visited.add(other)
106
+ frontier.append((other, hop + 1))
107
+
108
+ def _key(item: tuple[int, int]) -> tuple:
109
+ hop, i = item
110
+ e = edges[i]
111
+ return (hop, str(e.get("src") or ""), str(e.get("dst") or ""), str(e.get("kind") or ""))
112
+
113
+ found.sort(key=_key)
114
+ truncated = len(found) > limit
115
+ found = found[:limit]
116
+
117
+ out_edges: list[dict] = []
118
+ touched = {node}
119
+ for hop, i in found:
120
+ e = edges[i]
121
+ rec = {"src": e.get("src"), "dst": e.get("dst"), "kind": e.get("kind"), "hop": hop}
122
+ for key in ("source_id", "anchor"):
123
+ if e.get(key):
124
+ rec[key] = e[key]
125
+ out_edges.append(rec)
126
+ touched.update((str(rec["src"]), str(rec["dst"])))
127
+
128
+ return {
129
+ "root": node,
130
+ "depth": depth,
131
+ "nodes": [_node_summary(n, entity_by_id.get(n)) for n in sorted(touched)],
132
+ "edges": out_edges,
133
+ "truncated": truncated,
134
+ }
135
+
136
+
137
+ def print_neighborhood(result: dict) -> None:
138
+ names = {n["id"]: n for n in result["nodes"]}
139
+ root = result["root"]
140
+
141
+ def _label(node: str) -> str:
142
+ ent = names.get(node, {})
143
+ bits = [ent.get("name") or "", f"({ent['kind']})" if ent.get("kind") else ""]
144
+ return " ".join(b for b in bits if b)
145
+
146
+ print(f"{root} {_label(root)}".rstrip())
147
+ rows = []
148
+ for e in result["edges"]:
149
+ src, dst = str(e["src"]), str(e["dst"])
150
+ if src == root:
151
+ arrow, other = "->", dst
152
+ elif dst == root:
153
+ arrow, other = "<-", src
154
+ else:
155
+ arrow, other = f"{src} ->", dst
156
+ cite = f" [cited {e['source_id']}#{e['anchor']}]" if e.get("anchor") else ""
157
+ indent = " " * e["hop"]
158
+ rows.append(f"{indent}{arrow} {e['kind']} {other} {_label(other)}".rstrip() + cite)
159
+ for line in rows:
160
+ print(line)
161
+ n_edges, n_nodes = len(result["edges"]), len(result["nodes"])
162
+ more = " (truncated; raise --limit)" if result["truncated"] else ""
163
+ print(
164
+ f"{n_edges} edge{'s' if n_edges != 1 else ''}, "
165
+ f"{n_nodes} node{'s' if n_nodes != 1 else ''} (depth {result['depth']}){more}"
166
+ )
@@ -47,32 +47,32 @@ def _run_add(kb, text, *extra):
47
47
  return cli.main(["fact", "add", "--file", str(p), "--root", str(kb.root), *extra])
48
48
 
49
49
 
50
- def _claims_lines(kb):
51
- p = kb.root / "vault" / "facts" / "claims.ndjson"
50
+ def _read_ndjson(p):
51
+ # Split on "\n" only: splitlines() also breaks on U+2028/U+2029/NEL, which
52
+ # are legal inside JSON strings and would split one record into fragments.
52
53
  if not p.exists():
53
54
  return []
54
- return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
55
+ return [json.loads(s) for s in p.read_text(encoding="utf-8").split("\n") if s.strip()]
56
+
57
+
58
+ def _claims_lines(kb):
59
+ p = kb.root / "vault" / "facts" / "claims.ndjson"
60
+ return _read_ndjson(p)
55
61
 
56
62
 
57
63
  def _recs_lines(kb):
58
64
  p = kb.root / "vault" / "facts" / "reconciliations.ndjson"
59
- if not p.exists():
60
- return []
61
- return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
65
+ return _read_ndjson(p)
62
66
 
63
67
 
64
68
  def _graph_lines(kb):
65
69
  p = kb.root / "vault" / "facts" / "graph.ndjson"
66
- if not p.exists():
67
- return []
68
- return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
70
+ return _read_ndjson(p)
69
71
 
70
72
 
71
73
  def _entities_lines(kb):
72
74
  p = kb.root / "vault" / "facts" / "entities.ndjson"
73
- if not p.exists():
74
- return []
75
- return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
75
+ return _read_ndjson(p)
76
76
 
77
77
 
78
78
  def _write_ontology(kb, text):
@@ -240,6 +240,20 @@ def test_fact_add_reads_stdin(kb, monkeypatch):
240
240
  assert len(_claims_lines(kb)) == 1
241
241
 
242
242
 
243
+ def test_fact_add_file_streams_crlf_and_u2028(kb):
244
+ # --file is streamed with newline="\n": CRLF endings are stripped and a raw
245
+ # U+2028 inside a JSON string does not split the record.
246
+ kb.add_raw("s", SRC)
247
+ text = _ndjson(
248
+ _claim("The quick brown fox jumps over the lazy dog.", object="a\u2028b"),
249
+ _claim("Caching answers beats recomputing them."),
250
+ ).replace("\n", "\r\n")
251
+ assert "\u2028" in text
252
+ rc = _run_add(kb, text)
253
+ assert rc == 0
254
+ assert [c["object"] for c in _claims_lines(kb)] == ["a\u2028b", "o"]
255
+
256
+
243
257
  def test_fact_add_json_reports_appended_records(kb, capsys):
244
258
  kb.add_raw("s", SRC)
245
259
  rc = _run_add(
@@ -513,7 +527,7 @@ def test_fact_add_entities_appends_and_conflicts(kb, capsys):
513
527
  rc = _run_add(kb, _ndjson(ent), "--table", "entities")
514
528
  assert rc == 0
515
529
  lines = (kb.root / "vault" / "facts" / "entities.ndjson").read_text(encoding="utf-8")
516
- assert json.loads(lines.splitlines()[0])["entity_id"] == "entity/duckdb"
530
+ assert json.loads(lines.split("\n")[0])["entity_id"] == "entity/duckdb"
517
531
 
518
532
  # identical record is skipped; a conflicting one (same id, new name) fails
519
533
  assert _run_add(kb, _ndjson(ent), "--table", "entities") == 0
@@ -523,7 +537,7 @@ def test_fact_add_entities_appends_and_conflicts(kb, capsys):
523
537
  assert rc == 1
524
538
  [failure] = json.loads(capsys.readouterr().out)["failures"]
525
539
  assert failure["status"] == "ID_CONFLICT"
526
- assert len(lines.splitlines()) == 1 # nothing else was appended
540
+ assert lines.count("\n") == 1 # nothing else was appended
527
541
 
528
542
 
529
543
  def test_fact_add_entities_accept_optional_uri_metadata(kb):
@@ -618,7 +632,7 @@ def test_fact_add_edges_appends_and_skips_duplicates(kb):
618
632
  assert _run_add(kb, _ndjson(edge), "--table", "edges") == 0
619
633
  assert _run_add(kb, _ndjson(edge), "--table", "edges") == 0
620
634
  lines = (kb.root / "vault" / "facts" / "graph.ndjson").read_text(encoding="utf-8")
621
- assert len(lines.splitlines()) == 1
635
+ assert lines.count("\n") == 1
622
636
 
623
637
 
624
638
  # --------------------------------------------------------------------------- #
@@ -1,3 +1,5 @@
1
+ import io
2
+
1
3
  import pytest
2
4
  from scrip.errors import DataError, UsageError
3
5
 
@@ -46,3 +48,12 @@ def test_parse_ndjson_reports_line_number_on_bad_json():
46
48
  def test_parse_ndjson_empty_is_usage_error():
47
49
  with pytest.raises(UsageError):
48
50
  facts.parse_ndjson(" \n \n")
51
+
52
+
53
+ def test_parse_ndjson_accepts_line_iterable():
54
+ # A file opened with newline="\n" yields lines split on "\n" only, so
55
+ # U+2028 inside a string and \r\n endings parse the same as the str path.
56
+ text = '{"a": "x\u2028y"}\r\n\r\n{"b": 2}\n'
57
+ expected = facts.parse_ndjson(text)
58
+ assert facts.parse_ndjson(io.StringIO(text, newline="\n")) == expected
59
+ assert expected == [{"a": "x\u2028y"}, {"b": 2}]
@@ -0,0 +1,102 @@
1
+ """`scrip init` — create the root skeleton (vault/ layers + .kb/ cache) so a new
2
+ instance starts detectable and green. Idempotent in an existing root; refuses to
3
+ nest a new instance inside an existing one."""
4
+
5
+ import json
6
+
7
+ from scrip import cli, manifest_path
8
+
9
+
10
+ def test_init_creates_skeleton_and_manifest(tmp_path, capsys):
11
+ root = tmp_path / "kb"
12
+ assert cli.main(["init", str(root)]) == 0
13
+ for rel in ("vault/raw", "vault/facts", "vault/wiki", ".kb"):
14
+ assert (root / rel).is_dir()
15
+ # same effect as `scrip status --rebuild-manifest`: the cache exists
16
+ assert manifest_path(root).exists()
17
+ out = capsys.readouterr().out
18
+ assert f"initialized scriptorium root at {root}" in out
19
+ assert "scrip ingest" in out # next-steps hint: ingest -> compile -> verify
20
+ assert "scrip verify" in out
21
+
22
+
23
+ def test_init_defaults_to_cwd(tmp_path, monkeypatch):
24
+ monkeypatch.chdir(tmp_path)
25
+ assert cli.main(["init"]) == 0
26
+ assert (tmp_path / "vault" / "raw").is_dir()
27
+ assert (tmp_path / ".kb").is_dir()
28
+
29
+
30
+ def test_init_then_commands_resolve_root_without_flags(tmp_path, monkeypatch):
31
+ """The dogfooding gap this command closes: after init, `scrip status` and
32
+ `scrip verify` run green from inside the new root with no --root."""
33
+ root = tmp_path / "kb"
34
+ assert cli.main(["init", str(root)]) == 0
35
+ monkeypatch.chdir(root)
36
+ assert cli.main(["status"]) == 0
37
+ assert cli.main(["verify"]) == 0
38
+
39
+
40
+ def test_init_existing_root_is_noop(tmp_path, capsys):
41
+ root = tmp_path / "kb"
42
+ assert cli.main(["init", str(root)]) == 0
43
+ manifest_before = manifest_path(root).read_text(encoding="utf-8")
44
+ capsys.readouterr()
45
+ assert cli.main(["init", str(root)]) == 0
46
+ out = capsys.readouterr().out
47
+ assert f"already a scriptorium root: {root}" in out
48
+ # a true no-op: the manifest was not rewritten
49
+ assert manifest_path(root).read_text(encoding="utf-8") == manifest_before
50
+
51
+
52
+ def test_init_detects_spec_marker_root_as_existing(kb, capsys):
53
+ """A root marked by SPEC.md (no .kb/ needed) counts as already initialized."""
54
+ assert cli.main(["init", str(kb.root)]) == 0
55
+ assert "already a scriptorium root" in capsys.readouterr().out
56
+
57
+
58
+ def test_init_inside_existing_root_refused_exit_2(tmp_path):
59
+ root = tmp_path / "kb"
60
+ assert cli.main(["init", str(root)]) == 0
61
+ nested = root / "vault" / "raw" / "sub"
62
+ assert cli.main(["init", str(nested)]) == 2
63
+ # nothing was created for the refused nested instance
64
+ assert not nested.exists()
65
+
66
+
67
+ def test_init_target_is_a_file_exit_2(tmp_path):
68
+ f = tmp_path / "notes.md"
69
+ f.write_text("hi\n", encoding="utf-8")
70
+ assert cli.main(["init", str(f)]) == 2
71
+
72
+
73
+ def test_init_fills_in_missing_layers(tmp_path):
74
+ """A partial layout (vault/ alone is not a detectable root) is completed
75
+ rather than refused."""
76
+ root = tmp_path / "kb"
77
+ (root / "vault" / "raw").mkdir(parents=True)
78
+ assert cli.main(["init", str(root)]) == 0
79
+ for rel in ("vault/facts", "vault/wiki", ".kb"):
80
+ assert (root / rel).is_dir()
81
+
82
+
83
+ def test_init_layer_path_is_a_file_refused_exit_2(tmp_path):
84
+ """A file squatting a layer path is refused cleanly (exit 2), not an
85
+ uncaught FileExistsError mapped to the internal-error exit code."""
86
+ root = tmp_path / "kb"
87
+ (root / "vault").mkdir(parents=True)
88
+ (root / "vault" / "facts").write_text("oops\n", encoding="utf-8")
89
+ assert cli.main(["init", str(root)]) == 2
90
+
91
+
92
+ def test_init_json_shape(tmp_path, capsys):
93
+ root = tmp_path / "kb"
94
+ assert cli.main(["init", "--json", str(root)]) == 0
95
+ data = json.loads(capsys.readouterr().out)
96
+ assert set(data) == {"root", "status", "created"}
97
+ assert data["status"] == "initialized"
98
+ assert ".kb/manifest.json" in data["created"]
99
+ assert cli.main(["init", "--json", str(root)]) == 0
100
+ data = json.loads(capsys.readouterr().out)
101
+ assert data["status"] == "exists"
102
+ assert data["created"] == []
@@ -0,0 +1,139 @@
1
+ """`scrip neighbors` — a focused, read-only view of one node's edges within N hops
2
+ of facts/graph.ndjson, instead of the whole-graph dump of `scrip query edges`."""
3
+
4
+ import json
5
+
6
+ from scrip import cli, neighbors
7
+
8
+
9
+ def _write(kb, fname, rows):
10
+ p = kb.root / "vault" / "facts" / fname
11
+ p.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows), encoding="utf-8")
12
+
13
+
14
+ def _ent(slug, name=None, kind="system"):
15
+ return {"entity_id": f"entity/{slug}", "name": name or slug.title(), "kind": kind}
16
+
17
+
18
+ def _edge(src, dst, kind="depends-on", **kw):
19
+ return {"src": f"entity/{src}", "dst": f"entity/{dst}", "kind": kind, **kw}
20
+
21
+
22
+ def _graph(kb):
23
+ # a -> b -> c -> d, plus x -> a (incoming), a -alt-> e, and a cycle c -> a.
24
+ _write(kb, "entities.ndjson", [_ent(s) for s in "abcdex"])
25
+ _write(
26
+ kb,
27
+ "graph.ndjson",
28
+ [
29
+ _edge("a", "b"),
30
+ _edge("b", "c"),
31
+ _edge("c", "d"),
32
+ _edge("x", "a", "part-of"),
33
+ _edge("a", "e", "alternative-to"),
34
+ _edge("c", "a"),
35
+ ],
36
+ )
37
+
38
+
39
+ def _pairs(result):
40
+ return [(e["src"], e["dst"], e["kind"]) for e in result["edges"]]
41
+
42
+
43
+ def test_depth_one_both_directions(kb):
44
+ _graph(kb)
45
+ r = neighbors.neighborhood(kb.root, "entity/a")
46
+ assert r["root"] == "entity/a" and r["depth"] == 1 and r["truncated"] is False
47
+ assert _pairs(r) == [
48
+ ("entity/a", "entity/b", "depends-on"),
49
+ ("entity/a", "entity/e", "alternative-to"),
50
+ ("entity/c", "entity/a", "depends-on"),
51
+ ("entity/x", "entity/a", "part-of"),
52
+ ]
53
+ assert all(e["hop"] == 1 for e in r["edges"])
54
+ assert [n["id"] for n in r["nodes"]] == [
55
+ "entity/a", "entity/b", "entity/c", "entity/e", "entity/x",
56
+ ]
57
+
58
+
59
+ def test_depth_two_reaches_second_hop_and_cycle_terminates(kb):
60
+ _graph(kb)
61
+ r = neighbors.neighborhood(kb.root, "entity/a", depth=2, direction="out")
62
+ assert _pairs(r) == [
63
+ ("entity/a", "entity/b", "depends-on"),
64
+ ("entity/a", "entity/e", "alternative-to"),
65
+ ("entity/b", "entity/c", "depends-on"),
66
+ ]
67
+ assert [e["hop"] for e in r["edges"]] == [1, 1, 2]
68
+ # depth 3 closes the c -> a cycle and reaches d, each edge once
69
+ r3 = neighbors.neighborhood(kb.root, "entity/a", depth=3, direction="out")
70
+ assert len(_pairs(r3)) == len(set(_pairs(r3))) == 5
71
+
72
+
73
+ def test_kind_filter_and_direction_in(kb):
74
+ _graph(kb)
75
+ r = neighbors.neighborhood(kb.root, "entity/a", kinds=["part-of"])
76
+ assert _pairs(r) == [("entity/x", "entity/a", "part-of")]
77
+ r_in = neighbors.neighborhood(kb.root, "entity/a", direction="in")
78
+ assert {e["dst"] for e in r_in["edges"]} == {"entity/a"}
79
+
80
+
81
+ def test_limit_sets_truncated(kb):
82
+ _graph(kb)
83
+ r = neighbors.neighborhood(kb.root, "entity/a", limit=2)
84
+ assert len(r["edges"]) == 2 and r["truncated"] is True
85
+
86
+
87
+ def test_cited_edge_and_non_entity_node(kb):
88
+ _write(kb, "entities.ndjson", [_ent("a")])
89
+ _write(
90
+ kb,
91
+ "graph.ndjson",
92
+ [{"src": "entity/a", "dst": "raw/paper", "kind": "cites",
93
+ "source_id": "raw/paper", "anchor": "qh:ab|loc:0|len:5"}],
94
+ )
95
+ r = neighbors.neighborhood(kb.root, "raw/paper")
96
+ [edge] = r["edges"]
97
+ assert edge["anchor"] == "qh:ab|loc:0|len:5"
98
+ assert {"id": "raw/paper"} in r["nodes"] # not an entity: id only
99
+
100
+
101
+ def test_entity_without_edges(kb):
102
+ _write(kb, "entities.ndjson", [_ent("lonely")])
103
+ r = neighbors.neighborhood(kb.root, "entity/lonely")
104
+ assert r["edges"] == [] and [n["id"] for n in r["nodes"]] == ["entity/lonely"]
105
+
106
+
107
+ def test_cli_unknown_node_is_data_error(kb, capsys):
108
+ _graph(kb)
109
+ assert cli.main(["neighbors", "entity/nope", "--root", str(kb.root)]) == 3
110
+ assert "no such node: entity/nope" in capsys.readouterr().err
111
+
112
+
113
+ def test_cli_empty_facts_is_data_error(kb):
114
+ assert cli.main(["neighbors", "entity/a", "--root", str(kb.root)]) == 3
115
+
116
+
117
+ def test_cli_depth_out_of_range_is_usage_error(kb):
118
+ _graph(kb)
119
+ assert cli.main(["neighbors", "entity/a", "--depth", "4", "--root", str(kb.root)]) == 2
120
+
121
+
122
+ def test_cli_json_is_stable(kb, capsys):
123
+ _graph(kb)
124
+ args = ["neighbors", "entity/a", "--depth", "2", "--json", "--root", str(kb.root)]
125
+ assert cli.main(args) == 0
126
+ first = json.loads(capsys.readouterr().out)
127
+ assert cli.main(args) == 0
128
+ assert json.loads(capsys.readouterr().out) == first
129
+ assert set(first) == {"root", "depth", "nodes", "edges", "truncated"}
130
+
131
+
132
+ def test_cli_text_output(kb, capsys):
133
+ _graph(kb)
134
+ assert cli.main(["neighbors", "entity/a", "--root", str(kb.root)]) == 0
135
+ out = capsys.readouterr().out.splitlines()
136
+ assert out[0] == "entity/a A (system)"
137
+ assert " -> depends-on entity/b B (system)" in out
138
+ assert " <- part-of entity/x X (system)" in out
139
+ assert out[-1] == "4 edges, 5 nodes (depth 1)"
@@ -1141,7 +1141,7 @@ wheels = [
1141
1141
 
1142
1142
  [[package]]
1143
1143
  name = "scriptoria"
1144
- version = "0.9.0"
1144
+ version = "0.11.0"
1145
1145
  source = { editable = "." }
1146
1146
  dependencies = [
1147
1147
  { name = "duckdb" },
File without changes