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.
- {scriptoria-0.9.0 → scriptoria-0.11.0}/PKG-INFO +5 -3
- {scriptoria-0.9.0 → scriptoria-0.11.0}/README.md +3 -1
- {scriptoria-0.9.0 → scriptoria-0.11.0}/pyproject.toml +1 -1
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/__init__.py +1 -1
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/blocks.py +29 -27
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/cli.py +124 -11
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/facts.py +12 -12
- scriptoria-0.11.0/src/scrip/neighbors.py +166 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_fact_cmd.py +29 -15
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_facts.py +11 -0
- scriptoria-0.11.0/tests/test_init_cmd.py +102 -0
- scriptoria-0.11.0/tests/test_neighbors_cmd.py +139 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/uv.lock +1 -1
- {scriptoria-0.9.0 → scriptoria-0.11.0}/.gitignore +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/pyrightconfig.json +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/anchors.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/embeddings.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/errors.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/frontmatter.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/graph.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/hashing.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/ingest.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/lock.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/manifest.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/ontology.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/pageindex_adapter.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/query.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/retrieval.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/src/scrip/similar.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/conftest.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_anchor_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_anchors.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_blocks.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_embeddings.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_frontmatter.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_graph_status.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_hashing.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_index_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_ingest.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_json_shapes.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_lock.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_manifest.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_new_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_pageindex_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_query.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_retrieval.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_similar_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_span_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_stamp.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_status_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_unlock_cmd.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_verify.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_version.py +0 -0
- {scriptoria-0.9.0 → scriptoria-0.11.0}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: scriptoria
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
64
|
-
|
|
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
|
|
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
|
|
43
|
-
|
|
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
|
|
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/).
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
result = facts.add(root, args.table,
|
|
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(
|
|
95
|
-
"""Parse proposed records (one JSON object per line).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
604
|
-
|
|
605
|
-
_write_meta(root, meta,
|
|
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
|
|
51
|
-
|
|
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").
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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)"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|