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 +4 -0
- prov/cli.py +105 -0
- prov/commands/__init__.py +32 -0
- prov/commands/check_slug.py +27 -0
- prov/commands/context.py +74 -0
- prov/commands/diff.py +106 -0
- prov/commands/domain.py +57 -0
- prov/commands/find.py +31 -0
- prov/commands/impact.py +70 -0
- prov/commands/init.py +41 -0
- prov/commands/orient.py +62 -0
- prov/commands/rebuild.py +57 -0
- prov/commands/reconcile.py +62 -0
- prov/commands/scope.py +70 -0
- prov/commands/sync.py +201 -0
- prov/commands/validate.py +111 -0
- prov/commands/write.py +135 -0
- prov/indexing.py +119 -0
- prov/model.py +55 -0
- prov/spec_io.py +401 -0
- prov/writer.py +180 -0
- provenance_cli-0.1.0.dist-info/METADATA +423 -0
- provenance_cli-0.1.0.dist-info/RECORD +27 -0
- provenance_cli-0.1.0.dist-info/WHEEL +5 -0
- provenance_cli-0.1.0.dist-info/entry_points.txt +2 -0
- provenance_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- provenance_cli-0.1.0.dist-info/top_level.txt +1 -0
prov/__init__.py
ADDED
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.")
|
prov/commands/context.py
ADDED
|
@@ -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.")
|
prov/commands/domain.py
ADDED
|
@@ -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")
|
prov/commands/impact.py
ADDED
|
@@ -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.")
|
prov/commands/orient.py
ADDED
|
@@ -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")
|
prov/commands/rebuild.py
ADDED
|
@@ -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.")
|