provenance-cli 0.1.0__py3-none-any.whl

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.
prov/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Provenance CLI — spec-driven development. Python 3.9+ stdlib only."""
2
+ from prov.cli import main
3
+
4
+ __all__ = ["main"]
prov/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ """CLI entry point — argument parsing and command dispatch."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from prov.commands import (
8
+ cmd_check_slug,
9
+ cmd_context,
10
+ cmd_diff,
11
+ cmd_domain,
12
+ cmd_find,
13
+ cmd_impact,
14
+ cmd_init,
15
+ cmd_orient,
16
+ cmd_rebuild,
17
+ cmd_reconcile,
18
+ cmd_scope,
19
+ cmd_sync,
20
+ cmd_validate,
21
+ cmd_write,
22
+ )
23
+ from prov.spec_io import get_repo_root, get_spec_dir
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ args = argv if argv is not None else sys.argv[1:]
28
+
29
+ # Resolve spec dir: entry script's parent dir (e.g. src/ or prov/)
30
+ entry = Path(sys.argv[0]).resolve()
31
+ entry_dir = entry.parent if entry.suffix else Path.cwd()
32
+ spec_dir = get_spec_dir(entry_dir)
33
+ repo_root = get_repo_root(spec_dir)
34
+
35
+ if not args:
36
+ print("Usage: prov <command> [args] (or: python prov/prov.py <command> [args])")
37
+ print(
38
+ "Commands: orient, scope <path>, context <slug>, impact <slug>, find <kw>, domain <name>,"
39
+ )
40
+ print(
41
+ " validate, check-slug <slug>, reconcile <path>, sync [path],"
42
+ )
43
+ print(
44
+ " sync mark-implemented <slug>, sync remove-ref <slug> <ref>,"
45
+ )
46
+ print(
47
+ " sync update-ref <slug> <old> <new>, sync remove-backlink <file> <line> <slug>,"
48
+ )
49
+ print(" write [--yes], diff [ref], rebuild, init")
50
+ return 0
51
+
52
+ cmd = args[0].lower()
53
+ rest = args[1:]
54
+
55
+ # Commands other than init require an existing spec directory
56
+ if cmd != "init" and not (spec_dir / "CONTEXT.md").exists():
57
+ print("No spec directory found.")
58
+ print(" Run 'prov init' to scaffold one, or cd into a project with prov/, spec/, or specs/.")
59
+ return 1
60
+
61
+ if cmd == "orient":
62
+ cmd_orient(spec_dir, repo_root)
63
+ elif cmd == "scope":
64
+ cmd_scope(spec_dir, repo_root, rest[0] if rest else ".")
65
+ elif cmd == "context":
66
+ if not rest:
67
+ print("Usage: prov context <slug>")
68
+ return 1
69
+ cmd_context(spec_dir, repo_root, rest[0])
70
+ elif cmd == "impact":
71
+ if not rest:
72
+ print("Usage: prov impact <slug>")
73
+ return 1
74
+ cmd_impact(spec_dir, repo_root, rest[0])
75
+ elif cmd == "find":
76
+ cmd_find(spec_dir, " ".join(rest) if rest else "")
77
+ elif cmd == "domain":
78
+ if not rest:
79
+ print("Usage: prov domain <name>")
80
+ return 1
81
+ cmd_domain(spec_dir, rest[0])
82
+ elif cmd == "validate":
83
+ return cmd_validate(spec_dir, repo_root)
84
+ elif cmd == "check-slug":
85
+ if not rest:
86
+ print("Usage: prov check-slug <slug>")
87
+ return 1
88
+ cmd_check_slug(spec_dir, rest[0])
89
+ elif cmd == "reconcile":
90
+ cmd_reconcile(spec_dir, repo_root, rest[0] if rest else ".")
91
+ elif cmd == "sync":
92
+ return cmd_sync(spec_dir, repo_root, rest)
93
+ elif cmd in ("rebuild", "rebuild-cache"):
94
+ cmd_rebuild(spec_dir)
95
+ elif cmd == "write":
96
+ return cmd_write(spec_dir, repo_root, rest)
97
+ elif cmd == "diff":
98
+ ref = rest[0] if rest else "HEAD"
99
+ cmd_diff(spec_dir, repo_root, ref)
100
+ elif cmd == "init":
101
+ cmd_init(spec_dir)
102
+ else:
103
+ print(f"Unknown command: {cmd}")
104
+ return 1
105
+ return 0
@@ -0,0 +1,32 @@
1
+ """CLI command implementations."""
2
+ from prov.commands.orient import cmd_orient
3
+ from prov.commands.scope import cmd_scope
4
+ from prov.commands.context import cmd_context
5
+ from prov.commands.impact import cmd_impact
6
+ from prov.commands.find import cmd_find
7
+ from prov.commands.domain import cmd_domain
8
+ from prov.commands.validate import cmd_validate
9
+ from prov.commands.check_slug import cmd_check_slug
10
+ from prov.commands.reconcile import cmd_reconcile
11
+ from prov.commands.sync import cmd_sync
12
+ from prov.commands.rebuild import cmd_rebuild
13
+ from prov.commands.write import cmd_write
14
+ from prov.commands.diff import cmd_diff
15
+ from prov.commands.init import cmd_init
16
+
17
+ __all__ = [
18
+ "cmd_orient",
19
+ "cmd_scope",
20
+ "cmd_context",
21
+ "cmd_impact",
22
+ "cmd_find",
23
+ "cmd_domain",
24
+ "cmd_validate",
25
+ "cmd_check_slug",
26
+ "cmd_reconcile",
27
+ "cmd_sync",
28
+ "cmd_rebuild",
29
+ "cmd_write",
30
+ "cmd_diff",
31
+ "cmd_init",
32
+ ]
@@ -0,0 +1,27 @@
1
+ """prov check-slug <slug> — is slug available."""
2
+ from pathlib import Path
3
+
4
+ from prov.indexing import nodes_by_slug
5
+ from prov.spec_io import load_backend
6
+
7
+
8
+ def cmd_check_slug(spec_dir: Path, slug: str) -> None:
9
+ nodes, _, _, _, _ = load_backend(spec_dir)
10
+ nodes_by_slug_map = nodes_by_slug(nodes)
11
+ full = (
12
+ slug
13
+ if slug in nodes_by_slug_map
14
+ else (slug if slug.startswith("C:") or slug.startswith("Q:") else slug)
15
+ )
16
+ if full in nodes_by_slug_map or slug in nodes_by_slug_map:
17
+ key = full if full in nodes_by_slug_map else slug
18
+ n = nodes_by_slug_map[key]
19
+ print(f"=== CHECK-SLUG: {slug} ===")
20
+ print()
21
+ print(f"TAKEN — {n.file}:")
22
+ print(f" {n.slug}: {n.statement[:60]}")
23
+ print()
24
+ print("Try: " + slug + "-alt, " + slug + "-v2")
25
+ else:
26
+ print(f"=== CHECK-SLUG: {slug} ===")
27
+ print("Available.")
@@ -0,0 +1,74 @@
1
+ """prov context <slug> — full entry details."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from prov.indexing import build_edges, nodes_by_slug
6
+ from prov.spec_io import load_backend
7
+
8
+
9
+ def cmd_context(spec_dir: Path, repo_root: Path, slug: str) -> None:
10
+ nodes, _, _, _, file_by_domain = load_backend(spec_dir)
11
+ nodes_by_slug_map = nodes_by_slug(nodes)
12
+ edges = build_edges(nodes)
13
+ n = nodes_by_slug_map.get(slug)
14
+ if not n:
15
+ for k in nodes_by_slug_map:
16
+ if k.replace("C:", "").replace("Q:", "") == slug or k.endswith(":" + slug):
17
+ n = nodes_by_slug_map[k]
18
+ break
19
+ if not n:
20
+ print(f"Entry not found: {slug}")
21
+ sys.exit(1)
22
+ domain_nodes = [x for x in nodes if x.domain == n.domain]
23
+ constraints = [x for x in domain_nodes if x.type == "constraint"]
24
+ deps = [nodes_by_slug_map[d] for d in n.depends_on if d in nodes_by_slug_map]
25
+ dep_by = [x for x in nodes if n.slug in x.depends_on]
26
+ blocked = [
27
+ nodes_by_slug_map[b] for b in getattr(n, "blocked_by", []) if b in nodes_by_slug_map
28
+ ]
29
+
30
+ print(f"=== CONTEXT: {n.slug} ===")
31
+ print()
32
+ status = "planned" if getattr(n, "planned", False) else "implemented"
33
+ print(f"{n.type} | domain: {n.domain} | {status}")
34
+ print(n.file)
35
+ print()
36
+ print("Statement:")
37
+ print(f" {n.statement}")
38
+ print()
39
+ print("Why this exists:")
40
+ print(f" > {n.provenance or '(none)'}")
41
+ if n.assumptions:
42
+ print()
43
+ print("Assumptions (unconfirmed):")
44
+ for a in n.assumptions:
45
+ print(f" ! {a}")
46
+ if n.code_refs and not getattr(n, "planned", True):
47
+ print()
48
+ print("Code:")
49
+ for ref in n.code_refs:
50
+ print(f" ~ {ref}")
51
+ if constraints:
52
+ print()
53
+ print("Constraints governing this domain:")
54
+ for c in constraints:
55
+ print(f" {c.slug} {c.statement}")
56
+ if deps:
57
+ print()
58
+ print("Depends on:")
59
+ for d in deps:
60
+ print(f" {d.slug} [{d.domain}] {d.statement[:60]}")
61
+ if dep_by:
62
+ print()
63
+ print("Depended on by:")
64
+ for d in dep_by[:10]:
65
+ print(f" {d.slug} [{d.domain}] {d.statement[:60]}")
66
+ if blocked:
67
+ print()
68
+ print("Blocked by:")
69
+ for b in blocked:
70
+ print(f" {b.slug} {b.statement[:60]}")
71
+ print()
72
+ print("─── prov impact <slug> before making changes")
73
+ print("─── prov context <dep-slug> explore a dependency")
74
+ print("─── prov domain <domain> load full domain context")
prov/commands/diff.py ADDED
@@ -0,0 +1,106 @@
1
+ """prov diff [ref] — semantic change manifest vs ref."""
2
+ from pathlib import Path
3
+
4
+ from prov.indexing import nodes_by_slug
5
+ from prov.model import Node
6
+ from prov.spec_io import load_backend, load_nodes_from_ref
7
+
8
+
9
+ def cmd_diff(spec_dir: Path, repo_root: Path, ref: str) -> None:
10
+ nodes, _, _, _, _ = load_backend(spec_dir)
11
+ old_nodes = load_nodes_from_ref(spec_dir, repo_root, ref)
12
+ old_by = nodes_by_slug(old_nodes)
13
+ cur_by = nodes_by_slug(nodes)
14
+
15
+ implemented: list[tuple[str, str]] = []
16
+ new_entries: list[Node] = []
17
+ modified: list[tuple[Node, Node]] = []
18
+ resolved_questions: list[str] = []
19
+ removed: list[Node] = []
20
+ constraints_changed: list[tuple[Node, Node]] = []
21
+ assumptions_to_confirm: list[tuple[str, str]] = []
22
+
23
+ for slug, n in cur_by.items():
24
+ if slug not in old_by:
25
+ new_entries.append(n)
26
+ for a in n.assumptions:
27
+ assumptions_to_confirm.append((n.slug, a))
28
+ continue
29
+ o = old_by[slug]
30
+ if n.type == "question":
31
+ continue
32
+ if n.type == "constraint":
33
+ if o.statement != n.statement or o.provenance != n.provenance:
34
+ constraints_changed.append((o, n))
35
+ continue
36
+ if o.planned and not n.planned and n.code_refs:
37
+ implemented.append((n.slug, n.domain))
38
+ elif (
39
+ o.statement != n.statement
40
+ or o.provenance != n.provenance
41
+ or set(o.depends_on) != set(n.depends_on)
42
+ or set(o.blocked_by) != set(n.blocked_by)
43
+ ):
44
+ modified.append((o, n))
45
+ for a in n.assumptions:
46
+ if (n.slug, a) not in assumptions_to_confirm:
47
+ assumptions_to_confirm.append((n.slug, a))
48
+
49
+ for slug, o in old_by.items():
50
+ if slug not in cur_by:
51
+ removed.append(o)
52
+ if o.type == "question":
53
+ resolved_questions.append(slug)
54
+
55
+ print("=== PROV DIFF ===")
56
+ print(f"vs {ref}")
57
+ print()
58
+ if implemented:
59
+ print("IMPLEMENTED (was [planned], now has ~ refs):")
60
+ for slug, dom in implemented:
61
+ print(f" {slug} ({dom})")
62
+ print()
63
+ if new_entries:
64
+ print("NEW ENTRIES:")
65
+ for n in new_entries:
66
+ print(f" {n.slug} ({n.domain}) {n.statement[:50]}...")
67
+ print()
68
+ if modified:
69
+ print("MODIFIED:")
70
+ for o, n in modified:
71
+ print(f" {o.slug}: statement/provenance/deps changed")
72
+ print()
73
+ if resolved_questions:
74
+ print("RESOLVED QUESTIONS:")
75
+ for s in resolved_questions:
76
+ print(f" {s}")
77
+ print()
78
+ if removed:
79
+ print("REMOVED ENTRIES:")
80
+ for n in removed:
81
+ print(f" {n.slug} ({n.domain})")
82
+ print()
83
+ if constraints_changed:
84
+ print("CONSTRAINTS CHANGED:")
85
+ for o, n in constraints_changed:
86
+ print(f" {n.slug}")
87
+ print()
88
+ if assumptions_to_confirm:
89
+ print("ASSUMPTIONS REQUIRING CONFIRMATION:")
90
+ for slug, a in assumptions_to_confirm[:15]:
91
+ print(f" {slug} ! {a[:50]}...")
92
+ if len(assumptions_to_confirm) > 15:
93
+ print(f" ... and {len(assumptions_to_confirm) - 15} more")
94
+ print()
95
+ if not any(
96
+ [
97
+ implemented,
98
+ new_entries,
99
+ modified,
100
+ resolved_questions,
101
+ removed,
102
+ constraints_changed,
103
+ assumptions_to_confirm,
104
+ ]
105
+ ):
106
+ print("No semantic changes.")
@@ -0,0 +1,57 @@
1
+ """prov domain <name> — full domain load."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from prov.spec_io import load_backend
6
+
7
+
8
+ def cmd_domain(spec_dir: Path, name: str) -> None:
9
+ nodes, _, summaries, refs_by_domain, file_by_domain = load_backend(spec_dir)
10
+ domain_nodes = [n for n in nodes if n.domain == name]
11
+ if not domain_nodes:
12
+ print(f"Domain not found: {name}")
13
+ sys.exit(1)
14
+ path = file_by_domain.get(name, "")
15
+ summary = summaries.get(name, "")
16
+
17
+ print(f"=== DOMAIN: {name} ===")
18
+ print()
19
+ print(summary)
20
+ print(f"File: {path}")
21
+ print()
22
+ constraints = [n for n in domain_nodes if n.type == "constraint"]
23
+ reqs = [n for n in domain_nodes if n.type == "requirement"]
24
+ planned = sum(1 for n in reqs if n.planned)
25
+ questions = [n for n in domain_nodes if n.type == "question"]
26
+ print(f"CONSTRAINTS ({len(constraints)}):")
27
+ for n in constraints:
28
+ print(f" {n.slug}: {n.statement}")
29
+ print(f" > {n.provenance}")
30
+ if n.code_refs:
31
+ print(f" ~ " + ", ".join(n.code_refs))
32
+ print()
33
+ print(f"REQUIREMENTS ({len(reqs) - planned} implemented, {planned} planned):")
34
+ for n in reqs:
35
+ suffix = " [planned]" if n.planned else ""
36
+ print(f" {n.slug}: {n.statement}{suffix}")
37
+ print(f" > {n.provenance}")
38
+ if n.assumptions:
39
+ for a in n.assumptions:
40
+ print(f" ! {a}")
41
+ if n.depends_on:
42
+ for d in n.depends_on:
43
+ print(f" @ {d}")
44
+ if n.code_refs:
45
+ for r in n.code_refs:
46
+ print(f" ~ {r}")
47
+ if n.blocked_by:
48
+ for b in n.blocked_by:
49
+ print(f" ? {b}")
50
+ print()
51
+ print(f"OPEN QUESTIONS ({len(questions)}):")
52
+ for n in questions:
53
+ print(f" {n.slug}: {n.statement}")
54
+ print(f" > blocks: {', '.join(n.blocked_by)}")
55
+ print()
56
+ print("OUT OF SCOPE:")
57
+ print(" (see domain file)")
prov/commands/find.py ADDED
@@ -0,0 +1,31 @@
1
+ """prov find <keywords> — search entries."""
2
+ from pathlib import Path
3
+
4
+ from prov.spec_io import load_backend
5
+
6
+
7
+ def cmd_find(spec_dir: Path, keywords: str) -> None:
8
+ nodes, _, _, _, _ = load_backend(spec_dir)
9
+ kws = keywords.lower().split()
10
+ matches = []
11
+ for n in nodes:
12
+ score = 0
13
+ text = f"{n.slug} {n.statement} {n.domain}".lower()
14
+ for kw in kws:
15
+ if kw in text:
16
+ score += 1
17
+ if score > 0:
18
+ matches.append((score, n))
19
+ matches.sort(key=lambda x: -x[0])
20
+
21
+ print(f'=== FIND: "{keywords}" ===')
22
+ print()
23
+ if not matches:
24
+ print("No entries match.")
25
+ print("Try: prov orient to browse all domains.")
26
+ return
27
+ for _, n in matches[:20]:
28
+ print(f"{n.slug} [{n.domain}] {n.type} {n.statement[:60]}")
29
+ print()
30
+ print(f"{len(matches)} results.")
31
+ print("─── prov context <slug> for full details")
@@ -0,0 +1,70 @@
1
+ """prov impact <slug> — blast radius before changing."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from prov.indexing import build_edges, nodes_by_slug
6
+ from prov.spec_io import load_backend
7
+
8
+
9
+ def cmd_impact(spec_dir: Path, repo_root: Path, slug: str) -> None:
10
+ nodes, _, _, _, _ = load_backend(spec_dir)
11
+ nodes_by_slug_map = nodes_by_slug(nodes)
12
+ build_edges(nodes)
13
+ if slug not in nodes_by_slug_map:
14
+ print(f"Entry not found: {slug}")
15
+ sys.exit(1)
16
+ direct = [x for x in nodes if slug in x.depends_on]
17
+ trans: set[str] = set(n.slug for n in direct)
18
+ worklist = list(trans)
19
+ while worklist:
20
+ s = worklist.pop()
21
+ for x in nodes:
22
+ if s in x.depends_on and x.slug not in trans:
23
+ trans.add(x.slug)
24
+ worklist.append(x.slug)
25
+ code_paths: list[str] = []
26
+ all_slugs = (trans | {slug}) if slug in nodes_by_slug_map else trans
27
+ for n in [nodes_by_slug_map[s] for s in all_slugs if s in nodes_by_slug_map]:
28
+ if n and n.code_refs:
29
+ code_paths.extend(n.code_refs)
30
+ code_paths.extend(nodes_by_slug_map[slug].code_refs if slug in nodes_by_slug_map else [])
31
+ assumptions: list[tuple[str, str]] = []
32
+ for s in trans | {slug}:
33
+ if s in nodes_by_slug_map:
34
+ for a in nodes_by_slug_map[s].assumptions:
35
+ assumptions.append((s, a))
36
+ planned = [
37
+ nodes_by_slug_map[s]
38
+ for s in trans
39
+ if s in nodes_by_slug_map and getattr(nodes_by_slug_map[s], "planned", False)
40
+ ]
41
+
42
+ print(f"=== IMPACT: {slug} ===")
43
+ print()
44
+ print(f"Direct dependents ({len(direct)}):")
45
+ for n in direct:
46
+ status = "planned" if getattr(n, "planned", False) else "implemented"
47
+ print(f" {n.slug} [{n.domain}] {n.type} {status}")
48
+ print()
49
+ print(f"Transitive dependents ({len(trans)} total):")
50
+ for s in list(trans)[:15]:
51
+ d = nodes_by_slug_map.get(s)
52
+ if d:
53
+ print(f" {s} [{d.domain}]")
54
+ if code_paths:
55
+ print()
56
+ print("All code in blast radius:")
57
+ for p in code_paths[:20]:
58
+ print(f" {p} <- {slug}")
59
+ if assumptions:
60
+ print()
61
+ print("Unconfirmed assumptions in blast radius:")
62
+ for s, a in assumptions[:10]:
63
+ print(f" {s} ! {a[:60]}")
64
+ if planned:
65
+ print()
66
+ print("Planned entries in blast radius:")
67
+ for n in planned[:10]:
68
+ print(f" {n.slug} [{n.domain}] [planned]")
69
+ print()
70
+ print("─── Proceed carefully. Verify all affected code after changes.")
prov/commands/init.py ADDED
@@ -0,0 +1,41 @@
1
+ """prov init — scaffold CONTEXT.md."""
2
+ from pathlib import Path
3
+
4
+
5
+ def cmd_init(spec_dir: Path) -> None:
6
+ spec_dir.mkdir(parents=True, exist_ok=True)
7
+ ctx_path = spec_dir / "CONTEXT.md"
8
+ if ctx_path.exists():
9
+ print("CONTEXT.md already exists.")
10
+ return
11
+ ctx_path.write_text(
12
+ """# <Project Name>
13
+
14
+ > <One sentence. What it does and who uses it.>
15
+
16
+ ## Purpose
17
+
18
+ <2-3 sentences. The problem being solved.>
19
+
20
+ ## User goals
21
+
22
+ 1. <Primary user goal>
23
+ 2. <Secondary goal>
24
+
25
+ ## Hard constraints
26
+
27
+ C:example: <Non-negotiable rule>
28
+
29
+ > <why>
30
+
31
+ ## Non-goals
32
+
33
+ - <Explicit out-of-scope items>
34
+
35
+ ## Domain map
36
+
37
+ <domain> prov/<domain>.md
38
+ """,
39
+ encoding="utf-8",
40
+ )
41
+ print("Created CONTEXT.md. Edit it with your project details.")
@@ -0,0 +1,62 @@
1
+ """prov orient — session start."""
2
+ from pathlib import Path
3
+
4
+ from prov.model import Node
5
+ from prov.spec_io import load_backend
6
+
7
+
8
+ def cmd_orient(spec_dir: Path, repo_root: Path) -> None:
9
+ nodes, ctx, summaries, refs_by_domain, _ = load_backend(spec_dir)
10
+ by_domain: dict[str, list[Node]] = {}
11
+ for n in nodes:
12
+ by_domain.setdefault(n.domain, []).append(n)
13
+
14
+ print("=== PROV ORIENT ===")
15
+ print()
16
+ print(f"Project: {ctx.title}")
17
+ print(ctx.purpose)
18
+ print()
19
+ print(
20
+ "Hard constraints:",
21
+ ", ".join(ctx.hard_constraints) if ctx.hard_constraints else "(none)",
22
+ )
23
+ print()
24
+ print("DOMAINS:")
25
+ for domain in sorted(by_domain.keys()):
26
+ reqs = [n for n in by_domain[domain] if n.type == "requirement"]
27
+ planned = sum(1 for n in reqs if n.planned)
28
+ questions = [n for n in by_domain[domain] if n.type == "question"]
29
+ assumptions = sum(len(n.assumptions) for n in by_domain[domain])
30
+ summ = (summaries.get(domain) or "")[:50]
31
+ print(
32
+ f" {domain:12} {summ:50} {len(reqs)} reqs {planned} planned {len(questions)} questions {assumptions} assumptions"
33
+ )
34
+
35
+ open_q = [n for n in nodes if n.type == "question"]
36
+ if open_q:
37
+ print()
38
+ print(f"OPEN QUESTIONS ({len(open_q)} total):")
39
+ for n in open_q[:20]:
40
+ stmt = (n.statement or "")[:60]
41
+ refs = [x for x in nodes if n.slug in getattr(x, "blocked_by", [])]
42
+ block_slugs = ", ".join(x.slug for x in refs) if refs else ""
43
+ print(f" {n.slug} {n.domain} {stmt}")
44
+ if block_slugs:
45
+ print(f" -> blocks: {block_slugs}")
46
+
47
+ unconf = [(n, a) for n in nodes for a in n.assumptions]
48
+ if unconf:
49
+ print()
50
+ print(f"UNCONFIRMED ASSUMPTIONS ({len(unconf)} total):")
51
+ for n, a in unconf[:15]:
52
+ print(f" {n.slug:20} {n.domain:10} ! {(a or '')[:50]}")
53
+
54
+ planned = [n for n in nodes if n.type == "requirement" and n.planned]
55
+ if planned:
56
+ print()
57
+ print(f"PLANNED ({len(planned)} total):")
58
+ for n in planned[:15]:
59
+ print(f" {n.slug:20} {n.domain:10} {(n.statement or '')[:50]}")
60
+
61
+ print()
62
+ print("─── Next: prov domain <name> | prov context <slug> | prov validate")
@@ -0,0 +1,57 @@
1
+ """prov rebuild — regenerate .spec/ cache."""
2
+ import json
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ from prov.indexing import build_edges
7
+ from prov.spec_io import load_backend
8
+
9
+
10
+ def cmd_rebuild(spec_dir: Path) -> None:
11
+ cache_dir = spec_dir / ".spec"
12
+ cache_dir.mkdir(exist_ok=True)
13
+ nodes, ctx, summaries, refs_by_domain, file_by_domain = load_backend(spec_dir)
14
+
15
+ data = {
16
+ "generated": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
17
+ "spec_files": list(file_by_domain.values()),
18
+ "nodes": {
19
+ n.slug: {
20
+ "type": n.type,
21
+ "domain": n.domain,
22
+ "file": n.file,
23
+ "statement": n.statement,
24
+ "planned": getattr(n, "planned", False),
25
+ "provenance": n.provenance,
26
+ "assumptions": n.assumptions,
27
+ "code_refs": n.code_refs,
28
+ "depends_on": n.depends_on,
29
+ "blocked_by": getattr(n, "blocked_by", []),
30
+ }
31
+ for n in nodes
32
+ },
33
+ "edges": [],
34
+ }
35
+ edges = build_edges(nodes)
36
+ for e in edges:
37
+ if "src/" in e.to_slug or "/" in e.to_slug:
38
+ data["edges"].append({"from": e.from_slug, "to": e.to_slug, "type": e.type})
39
+ else:
40
+ data["edges"].append(
41
+ {"from": e.from_slug, "to": e.to_slug, "type": "depends-on"}
42
+ )
43
+ by_file: dict[str, list[str]] = {}
44
+ for n in nodes:
45
+ for ref in n.code_refs:
46
+ fp = ref.split(":")[0]
47
+ by_file.setdefault(fp, []).append(n.slug)
48
+ code_index = {
49
+ "generated": data["generated"],
50
+ "by_file": by_file,
51
+ "by_dir": dict(refs_by_domain),
52
+ }
53
+ (cache_dir / "graph.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
54
+ (cache_dir / "code-index.json").write_text(
55
+ json.dumps(code_index, indent=2), encoding="utf-8"
56
+ )
57
+ print("Cache rebuilt.")