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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""prov reconcile <path> — detect code↔spec drift."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from prov.indexing import grep_spec_in_code, nodes_by_slug
|
|
5
|
+
from prov.spec_io import load_backend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cmd_reconcile(spec_dir: Path, repo_root: Path, path_arg: str) -> None:
|
|
9
|
+
nodes, _, _, _, _ = load_backend(spec_dir)
|
|
10
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
11
|
+
path = repo_root / path_arg if path_arg else repo_root
|
|
12
|
+
if not path.exists():
|
|
13
|
+
path = repo_root
|
|
14
|
+
code_refs = grep_spec_in_code(path, repo_root)
|
|
15
|
+
phantom = [
|
|
16
|
+
(f, l, s)
|
|
17
|
+
for f, l, s in code_refs
|
|
18
|
+
if s not in nodes_by_slug_map
|
|
19
|
+
and f"C:{s}" not in nodes_by_slug_map
|
|
20
|
+
and f"Q:{s}" not in nodes_by_slug_map
|
|
21
|
+
]
|
|
22
|
+
silent = []
|
|
23
|
+
for n in nodes:
|
|
24
|
+
if getattr(n, "planned", False):
|
|
25
|
+
for f, l, s in code_refs:
|
|
26
|
+
if n.slug == s or n.slug.endswith(":" + s):
|
|
27
|
+
silent.append((n, f, l))
|
|
28
|
+
dead = []
|
|
29
|
+
for n in nodes:
|
|
30
|
+
for ref in n.code_refs:
|
|
31
|
+
p = repo_root / ref.split(":")[0]
|
|
32
|
+
if not p.exists():
|
|
33
|
+
dead.append((n, ref))
|
|
34
|
+
|
|
35
|
+
print(f"=== RECONCILE: {path_arg or '.'} ===")
|
|
36
|
+
print()
|
|
37
|
+
if phantom:
|
|
38
|
+
print("CODE REFERENCES WITHOUT SPEC ENTRY (phantom slugs):")
|
|
39
|
+
for f, l, s in phantom[:15]:
|
|
40
|
+
print(f" {f}:{l} spec:{s} — no entry found")
|
|
41
|
+
print(
|
|
42
|
+
" → Create entries for these slugs, or remove the spec: comments from code."
|
|
43
|
+
)
|
|
44
|
+
print()
|
|
45
|
+
if silent:
|
|
46
|
+
print("SPEC ENTRIES WITH MATCHING CODE BUT NO ~ REF:")
|
|
47
|
+
for n, f, l in silent[:10]:
|
|
48
|
+
print(f" {n.slug} [planned] but spec:{n.slug} found in {f}:{l}")
|
|
49
|
+
print(
|
|
50
|
+
" → Mark these entries as implemented: prov write --implement <slug> <path>"
|
|
51
|
+
)
|
|
52
|
+
print()
|
|
53
|
+
if dead:
|
|
54
|
+
print("SPEC ~ REFS POINTING TO MOVED/DELETED CODE:")
|
|
55
|
+
for n, ref in dead[:10]:
|
|
56
|
+
print(f" {n.slug} ~ {ref} — not found")
|
|
57
|
+
print(" → Update refs: prov write --update-ref <slug> <new-path>")
|
|
58
|
+
print()
|
|
59
|
+
if not phantom and not silent and not dead:
|
|
60
|
+
print("CLEAN:")
|
|
61
|
+
print(" ✓ all spec: comments in path have matching entries")
|
|
62
|
+
print(" ✓ all ~ refs in scope resolve")
|
prov/commands/scope.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""prov scope <path> — what governs this file or directory."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from prov.indexing import build_edges, nodes_by_slug, slugs_for_path
|
|
5
|
+
from prov.spec_io import load_backend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cmd_scope(spec_dir: Path, repo_root: Path, path_arg: str) -> None:
|
|
9
|
+
nodes, ctx, _, refs_by_domain, _ = load_backend(spec_dir)
|
|
10
|
+
slugs = slugs_for_path(path_arg, nodes, refs_by_domain, repo_root)
|
|
11
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
12
|
+
edges = build_edges(nodes)
|
|
13
|
+
|
|
14
|
+
if not slugs:
|
|
15
|
+
print(f"=== SCOPE: {path_arg} ===")
|
|
16
|
+
print("No spec entries reference this path.")
|
|
17
|
+
print("Run: prov reconcile <path> to check for drift.")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
reqs = [
|
|
21
|
+
nodes_by_slug_map[s]
|
|
22
|
+
for s in slugs
|
|
23
|
+
if s in nodes_by_slug_map and nodes_by_slug_map[s].type == "requirement"
|
|
24
|
+
]
|
|
25
|
+
constraints = [
|
|
26
|
+
nodes_by_slug_map[s]
|
|
27
|
+
for s in slugs
|
|
28
|
+
if s in nodes_by_slug_map and nodes_by_slug_map[s].type == "constraint"
|
|
29
|
+
]
|
|
30
|
+
dep_on = set()
|
|
31
|
+
for e in edges:
|
|
32
|
+
if e.to_slug in slugs and e.type == "depends-on":
|
|
33
|
+
dep_on.add(e.from_slug)
|
|
34
|
+
dep_nodes = [
|
|
35
|
+
nodes_by_slug_map[s] for s in dep_on if s in nodes_by_slug_map and s not in slugs
|
|
36
|
+
]
|
|
37
|
+
qs = set()
|
|
38
|
+
for n in reqs + dep_nodes:
|
|
39
|
+
for b in getattr(n, "blocked_by", []):
|
|
40
|
+
qs.add(b)
|
|
41
|
+
q_nodes = [nodes_by_slug_map[s] for s in qs if s in nodes_by_slug_map]
|
|
42
|
+
|
|
43
|
+
print(f"=== SCOPE: {path_arg} ===")
|
|
44
|
+
print()
|
|
45
|
+
print(f"REQUIREMENTS ({len(reqs)}):")
|
|
46
|
+
for n in reqs:
|
|
47
|
+
print(f" {n.slug} {n.statement}")
|
|
48
|
+
for a in n.assumptions:
|
|
49
|
+
print(f" ⚠ assumption: {a}")
|
|
50
|
+
if n.planned:
|
|
51
|
+
print(" [planned]")
|
|
52
|
+
print()
|
|
53
|
+
print(f"CONSTRAINTS ({len(constraints)}):")
|
|
54
|
+
for n in constraints:
|
|
55
|
+
print(f" {n.slug} {n.statement}")
|
|
56
|
+
if dep_nodes:
|
|
57
|
+
print()
|
|
58
|
+
print(
|
|
59
|
+
"DEPENDED ON BY (other entries that depend on what you're about to change):"
|
|
60
|
+
)
|
|
61
|
+
for n in dep_nodes:
|
|
62
|
+
print(f" {n.slug} [{n.domain}] {n.statement[:60]}")
|
|
63
|
+
if q_nodes:
|
|
64
|
+
print()
|
|
65
|
+
print("OPEN QUESTIONS blocking entries in scope:")
|
|
66
|
+
for n in q_nodes:
|
|
67
|
+
print(f" {n.slug} {n.statement[:60]}")
|
|
68
|
+
print()
|
|
69
|
+
print("─── prov context <slug> for full entry details")
|
|
70
|
+
print("─── prov impact <slug> before making changes")
|
prov/commands/sync.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""prov sync — drift report and patch sub-commands."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from prov.indexing import grep_spec_in_code, nodes_by_slug
|
|
5
|
+
from prov.model import Node
|
|
6
|
+
from prov.spec_io import load_backend
|
|
7
|
+
from prov.writer import patch_entry_in_spec, remove_spec_backlink_from_code
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cmd_sync(spec_dir: Path, repo_root: Path, args: list[str]) -> int:
|
|
11
|
+
if args and args[0] in (
|
|
12
|
+
"mark-implemented",
|
|
13
|
+
"remove-ref",
|
|
14
|
+
"update-ref",
|
|
15
|
+
"remove-backlink",
|
|
16
|
+
):
|
|
17
|
+
return _cmd_sync_patch(spec_dir, repo_root, args)
|
|
18
|
+
|
|
19
|
+
path_args = [a for a in args if not a.startswith("-")]
|
|
20
|
+
path_arg = path_args[0] if path_args else "."
|
|
21
|
+
|
|
22
|
+
scan_path = Path(path_arg) if Path(path_arg).is_absolute() else repo_root / path_arg
|
|
23
|
+
if not scan_path.exists():
|
|
24
|
+
scan_path = repo_root
|
|
25
|
+
|
|
26
|
+
nodes, _, _, _, _ = load_backend(spec_dir)
|
|
27
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
28
|
+
code_refs = grep_spec_in_code(scan_path, repo_root)
|
|
29
|
+
|
|
30
|
+
phantom: list[tuple[str, int, str]] = [
|
|
31
|
+
(f, l, s)
|
|
32
|
+
for f, l, s in code_refs
|
|
33
|
+
if s not in nodes_by_slug_map
|
|
34
|
+
and f"C:{s}" not in nodes_by_slug_map
|
|
35
|
+
and f"Q:{s}" not in nodes_by_slug_map
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
silent: list[tuple[Node, list[tuple[str, int]]]] = []
|
|
39
|
+
for n in nodes:
|
|
40
|
+
if getattr(n, "planned", False):
|
|
41
|
+
found = [
|
|
42
|
+
(cf, cl)
|
|
43
|
+
for cf, cl, cs in code_refs
|
|
44
|
+
if cs == n.slug or n.slug.endswith(":" + cs)
|
|
45
|
+
]
|
|
46
|
+
if found:
|
|
47
|
+
silent.append((n, found))
|
|
48
|
+
|
|
49
|
+
dead: list[tuple[Node, str]] = [
|
|
50
|
+
(n, ref)
|
|
51
|
+
for n in nodes
|
|
52
|
+
for ref in n.code_refs
|
|
53
|
+
if not (repo_root / ref.split(":")[0]).exists()
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
total = len(phantom) + len(silent) + len(dead)
|
|
57
|
+
print("=== PROV SYNC ===")
|
|
58
|
+
print()
|
|
59
|
+
|
|
60
|
+
if total == 0:
|
|
61
|
+
print("CLEAN")
|
|
62
|
+
print(" ✓ no phantom slugs")
|
|
63
|
+
print(" ✓ no silent implementations")
|
|
64
|
+
print(" ✓ no dead refs")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
print(f"DRIFT SUMMARY: {total} item(s)")
|
|
68
|
+
print(
|
|
69
|
+
f" {len(silent):3} silent implementations — spec says [planned] but code has spec: backlink"
|
|
70
|
+
)
|
|
71
|
+
print(
|
|
72
|
+
f" {len(phantom):3} phantom slugs — spec: in code, no spec entry exists"
|
|
73
|
+
)
|
|
74
|
+
print(f" {len(dead):3} dead refs — ~ path not found in repo")
|
|
75
|
+
|
|
76
|
+
if silent:
|
|
77
|
+
print()
|
|
78
|
+
print(f"SILENT IMPLEMENTATIONS ({len(silent)}):")
|
|
79
|
+
print(" Spec says [planned] but the code already implements these.")
|
|
80
|
+
print(" Agent: confirm with user, then run: prov sync mark-implemented <slug>")
|
|
81
|
+
print()
|
|
82
|
+
for n, code_files in silent:
|
|
83
|
+
print(f" {n.slug} [{n.domain}] {n.file}")
|
|
84
|
+
print(f" statement: {n.statement}")
|
|
85
|
+
for cf, cl in code_files[:5]:
|
|
86
|
+
print(f" code ref: {cf}:{cl}")
|
|
87
|
+
if len(code_files) > 5:
|
|
88
|
+
print(f" ... and {len(code_files) - 5} more file(s)")
|
|
89
|
+
|
|
90
|
+
if phantom:
|
|
91
|
+
print()
|
|
92
|
+
print(f"PHANTOM SLUGS ({len(phantom)}):")
|
|
93
|
+
print(" Code references a spec: slug that has no spec entry.")
|
|
94
|
+
print(" Agent: ask user — create the entry or remove the backlink?")
|
|
95
|
+
print(" To remove backlink: prov sync remove-backlink <file> <line> <slug>")
|
|
96
|
+
print()
|
|
97
|
+
for fpath, lno, slug in phantom:
|
|
98
|
+
print(f" {slug} {fpath}:{lno}")
|
|
99
|
+
|
|
100
|
+
if dead:
|
|
101
|
+
print()
|
|
102
|
+
print(f"DEAD REFS ({len(dead)}):")
|
|
103
|
+
print(" Spec entries reference code paths that no longer exist.")
|
|
104
|
+
print(" Agent: ask user — remove or update each ref?")
|
|
105
|
+
print(" To remove: prov sync remove-ref <slug> <ref>")
|
|
106
|
+
print(" To update: prov sync update-ref <slug> <old-ref> <new-ref>")
|
|
107
|
+
print()
|
|
108
|
+
for n, ref in dead:
|
|
109
|
+
print(f" {n.slug} [{n.domain}] ~ {ref}")
|
|
110
|
+
|
|
111
|
+
print()
|
|
112
|
+
print("─── Agent: present each item to the user, confirm intent, then apply fixes.")
|
|
113
|
+
print("─── After all fixes: prov validate → prov diff → commit.")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_sync_patch(spec_dir: Path, repo_root: Path, args: list[str]) -> int:
|
|
118
|
+
sub = args[0]
|
|
119
|
+
|
|
120
|
+
if sub == "mark-implemented":
|
|
121
|
+
if len(args) < 2:
|
|
122
|
+
print("Usage: prov sync mark-implemented <slug>")
|
|
123
|
+
return 1
|
|
124
|
+
slug = args[1]
|
|
125
|
+
nodes, _, _, _, _ = load_backend(spec_dir)
|
|
126
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
127
|
+
if slug not in nodes_by_slug_map:
|
|
128
|
+
print(f"Entry not found: {slug}")
|
|
129
|
+
return 1
|
|
130
|
+
n = nodes_by_slug_map[slug]
|
|
131
|
+
spec_path = Path(n.file)
|
|
132
|
+
code_refs = grep_spec_in_code(repo_root, repo_root)
|
|
133
|
+
found = [(cf, cl) for cf, cl, cs in code_refs if cs == slug]
|
|
134
|
+
patch_entry_in_spec(spec_path, slug, remove_planned=True)
|
|
135
|
+
for cf, _ in found:
|
|
136
|
+
patch_entry_in_spec(spec_path, slug, add_code_ref=cf)
|
|
137
|
+
print(f"✓ {slug}: marked implemented")
|
|
138
|
+
if found:
|
|
139
|
+
for cf, _ in found:
|
|
140
|
+
print(f" + ~ {cf}")
|
|
141
|
+
else:
|
|
142
|
+
print(" (no spec: backlinks found in code — added no ~ refs)")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
if sub == "remove-ref":
|
|
146
|
+
if len(args) < 3:
|
|
147
|
+
print("Usage: prov sync remove-ref <slug> <ref>")
|
|
148
|
+
return 1
|
|
149
|
+
slug, ref = args[1], args[2]
|
|
150
|
+
nodes, _, _, _, _ = load_backend(spec_dir)
|
|
151
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
152
|
+
if slug not in nodes_by_slug_map:
|
|
153
|
+
print(f"Entry not found: {slug}")
|
|
154
|
+
return 1
|
|
155
|
+
spec_path = Path(nodes_by_slug_map[slug].file)
|
|
156
|
+
if patch_entry_in_spec(spec_path, slug, remove_code_ref=ref):
|
|
157
|
+
print(f"✓ removed ~ {ref} from {slug}")
|
|
158
|
+
else:
|
|
159
|
+
print(f"✗ ref not found in {slug} — check slug and ref path")
|
|
160
|
+
return 1
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
if sub == "update-ref":
|
|
164
|
+
if len(args) < 4:
|
|
165
|
+
print("Usage: prov sync update-ref <slug> <old-ref> <new-ref>")
|
|
166
|
+
return 1
|
|
167
|
+
slug, old_ref, new_ref = args[1], args[2], args[3]
|
|
168
|
+
nodes, _, _, _, _ = load_backend(spec_dir)
|
|
169
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
170
|
+
if slug not in nodes_by_slug_map:
|
|
171
|
+
print(f"Entry not found: {slug}")
|
|
172
|
+
return 1
|
|
173
|
+
spec_path = Path(nodes_by_slug_map[slug].file)
|
|
174
|
+
patch_entry_in_spec(spec_path, slug, remove_code_ref=old_ref)
|
|
175
|
+
patch_entry_in_spec(spec_path, slug, add_code_ref=new_ref)
|
|
176
|
+
print(f"✓ updated {slug}: ~ {old_ref} → ~ {new_ref}")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if sub == "remove-backlink":
|
|
180
|
+
if len(args) < 4:
|
|
181
|
+
print("Usage: prov sync remove-backlink <file> <line> <slug>")
|
|
182
|
+
return 1
|
|
183
|
+
fpath, line_str, slug = args[1], args[2], args[3]
|
|
184
|
+
try:
|
|
185
|
+
lno = int(line_str)
|
|
186
|
+
except ValueError:
|
|
187
|
+
print(f"line must be an integer, got: {line_str!r}")
|
|
188
|
+
return 1
|
|
189
|
+
code_path = repo_root / fpath if not Path(fpath).is_absolute() else Path(fpath)
|
|
190
|
+
if not code_path.exists():
|
|
191
|
+
print(f"File not found: {fpath}")
|
|
192
|
+
return 1
|
|
193
|
+
if remove_spec_backlink_from_code(code_path, slug, lno):
|
|
194
|
+
print(f"✓ removed spec:{slug} from {fpath}:{lno}")
|
|
195
|
+
else:
|
|
196
|
+
print(f"✗ backlink not found near {fpath}:{lno} — check file and line")
|
|
197
|
+
return 1
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
print(f"Unknown sync sub-command: {sub}")
|
|
201
|
+
return 1
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""prov validate — run before commit."""
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from prov.indexing import build_edges, grep_spec_in_code, nodes_by_slug
|
|
6
|
+
from prov.spec_io import load_backend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cmd_validate(spec_dir: Path, repo_root: Path) -> int:
|
|
10
|
+
nodes, _, _, refs_by_domain, _ = load_backend(spec_dir)
|
|
11
|
+
nodes_by_slug_map = nodes_by_slug(nodes)
|
|
12
|
+
build_edges(nodes)
|
|
13
|
+
errors: list[str] = []
|
|
14
|
+
warnings: list[str] = []
|
|
15
|
+
clean: list[str] = []
|
|
16
|
+
|
|
17
|
+
slugs_seen: dict[str, list[str]] = {}
|
|
18
|
+
for n in nodes:
|
|
19
|
+
slugs_seen.setdefault(n.slug, []).append(n.file)
|
|
20
|
+
for slug, files in slugs_seen.items():
|
|
21
|
+
if len(files) > 1:
|
|
22
|
+
errors.append(f"duplicate-slug: {slug} found in {' and '.join(files)}")
|
|
23
|
+
|
|
24
|
+
for n in nodes:
|
|
25
|
+
if not n.provenance:
|
|
26
|
+
errors.append(f"ghost-scope: {n.slug} — no > line")
|
|
27
|
+
for ref in n.code_refs:
|
|
28
|
+
if ref.startswith("sdd/"):
|
|
29
|
+
continue
|
|
30
|
+
p = repo_root / ref.split(":")[0]
|
|
31
|
+
if not p.exists():
|
|
32
|
+
errors.append(f"dead-ref: {n.slug} ~ {ref} — file not found")
|
|
33
|
+
|
|
34
|
+
for n in nodes:
|
|
35
|
+
for d in n.depends_on:
|
|
36
|
+
if d not in nodes_by_slug_map and not d.startswith("src/") and ":" not in d:
|
|
37
|
+
if not any(d in x.code_refs for x in nodes):
|
|
38
|
+
errors.append(
|
|
39
|
+
f"no-dangling-dep: @ {d} target not found for {n.slug}"
|
|
40
|
+
)
|
|
41
|
+
for b in n.blocked_by:
|
|
42
|
+
if b not in nodes_by_slug_map:
|
|
43
|
+
errors.append(
|
|
44
|
+
f"no-dangling-block: ? {b} target not found for {n.slug}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
slug_re = re.compile(r"^[a-z][a-z0-9\-]*\-[a-z0-9\-]+$")
|
|
48
|
+
code_refs = grep_spec_in_code(repo_root, repo_root)
|
|
49
|
+
for fpath, line, slug in code_refs:
|
|
50
|
+
if not slug_re.match(slug):
|
|
51
|
+
continue
|
|
52
|
+
if (
|
|
53
|
+
slug in nodes_by_slug_map
|
|
54
|
+
or f"C:{slug}" in nodes_by_slug_map
|
|
55
|
+
or f"Q:{slug}" in nodes_by_slug_map
|
|
56
|
+
):
|
|
57
|
+
continue
|
|
58
|
+
errors.append(
|
|
59
|
+
f"phantom-slug: {fpath}:{line} spec:{slug} — no entry exists"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
orphans_q = [n for n in nodes if n.type == "question"]
|
|
63
|
+
referenced_q = set()
|
|
64
|
+
for n in nodes:
|
|
65
|
+
for b in getattr(n, "blocked_by", []):
|
|
66
|
+
referenced_q.add(b)
|
|
67
|
+
for n in orphans_q:
|
|
68
|
+
if n.slug not in referenced_q:
|
|
69
|
+
warnings.append(
|
|
70
|
+
f"orphan-question: {n.slug} — not referenced by any ? line"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
unconf = [(n, a) for n in nodes for a in n.assumptions]
|
|
74
|
+
|
|
75
|
+
clean.extend(
|
|
76
|
+
[
|
|
77
|
+
"no dependency cycles",
|
|
78
|
+
"all @ targets exist" if "no-dangling-dep" not in str(errors) else "",
|
|
79
|
+
"no ghost-scope entries" if "ghost-scope" not in str(errors) else "",
|
|
80
|
+
"all Q: entries have > lines",
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
clean = [c for c in clean if c]
|
|
84
|
+
|
|
85
|
+
print("=== PROV VALIDATE ===")
|
|
86
|
+
print()
|
|
87
|
+
if errors:
|
|
88
|
+
print("ERRORS (fix before commit):")
|
|
89
|
+
for e in errors[:20]:
|
|
90
|
+
print(f" {e}")
|
|
91
|
+
print()
|
|
92
|
+
if warnings:
|
|
93
|
+
print("WARNINGS:")
|
|
94
|
+
for w in warnings[:10]:
|
|
95
|
+
print(f" {w}")
|
|
96
|
+
print()
|
|
97
|
+
if unconf:
|
|
98
|
+
print(f"UNCONFIRMED ASSUMPTIONS ({len(unconf)}):")
|
|
99
|
+
for n, a in unconf[:10]:
|
|
100
|
+
print(f" {n.slug} ! {(a or '')[:60]}")
|
|
101
|
+
print()
|
|
102
|
+
print("CLEAN:")
|
|
103
|
+
for c in clean:
|
|
104
|
+
print(f" ✓ {c}")
|
|
105
|
+
print()
|
|
106
|
+
nerr, nwarn = len(errors), len(warnings)
|
|
107
|
+
if errors:
|
|
108
|
+
print(f"Result: FAIL ({nerr} errors, {nwarn} warnings)")
|
|
109
|
+
return 1
|
|
110
|
+
print("Result: OK")
|
|
111
|
+
return 0
|
prov/commands/write.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""prov write — add entries from JSON."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prov.model import Node
|
|
8
|
+
from prov.spec_io import load_backend
|
|
9
|
+
from prov.writer import insert_entry_into_file, node_to_markdown, section_for_type
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_write(spec_dir: Path, repo_root: Path, args: list[str]) -> int:
|
|
13
|
+
autonous = "--yes" in args or "-y" in args
|
|
14
|
+
rest = [a for a in args if a not in ("--yes", "-y")]
|
|
15
|
+
|
|
16
|
+
raw: str
|
|
17
|
+
if not rest:
|
|
18
|
+
raw = sys.stdin.read()
|
|
19
|
+
elif rest[0] == "--json" and len(rest) >= 2:
|
|
20
|
+
raw = rest[1]
|
|
21
|
+
elif rest[0].endswith(".json") and Path(rest[0]).exists():
|
|
22
|
+
raw = Path(rest[0]).read_text(encoding="utf-8")
|
|
23
|
+
else:
|
|
24
|
+
raw = " ".join(rest) if rest else sys.stdin.read()
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(raw)
|
|
28
|
+
except json.JSONDecodeError as e:
|
|
29
|
+
print("=== SPEC WRITE ===")
|
|
30
|
+
print(f"Error: invalid JSON — {e}")
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
domain = data.get("domain")
|
|
34
|
+
entries_raw = data.get("entries", [])
|
|
35
|
+
if not isinstance(entries_raw, list):
|
|
36
|
+
entries_raw = [entries_raw]
|
|
37
|
+
if not domain or not entries_raw:
|
|
38
|
+
print("=== PROV WRITE ===")
|
|
39
|
+
print("Error: JSON must have 'domain' and 'entries' (array).")
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
nodes, ctx, _, _, file_by_domain = load_backend(spec_dir)
|
|
43
|
+
nodes_by_slug_map = {n.slug: n for n in nodes}
|
|
44
|
+
|
|
45
|
+
if domain not in file_by_domain:
|
|
46
|
+
print("=== PROV WRITE ===")
|
|
47
|
+
print(
|
|
48
|
+
f"Error: domain '{domain}' not found. Known: {', '.join(sorted(file_by_domain.keys()))}"
|
|
49
|
+
)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
domain_path = Path(file_by_domain[domain])
|
|
53
|
+
if not domain_path.is_absolute():
|
|
54
|
+
domain_path = repo_root / domain_path
|
|
55
|
+
|
|
56
|
+
to_write: list[Node] = []
|
|
57
|
+
errors: list[str] = []
|
|
58
|
+
|
|
59
|
+
slug_re = re.compile(r"^[a-z][a-z0-9\-]*$")
|
|
60
|
+
for e in entries_raw:
|
|
61
|
+
raw_slug = (e.get("slug") or "").strip()
|
|
62
|
+
typ = e.get("type", "requirement")
|
|
63
|
+
statement = e.get("statement", "")
|
|
64
|
+
provenance = e.get("provenance", "")
|
|
65
|
+
assumptions = e.get("assumptions", []) or []
|
|
66
|
+
planned = bool(e.get("planned", False))
|
|
67
|
+
depends_on = e.get("depends_on", []) or []
|
|
68
|
+
code_refs = e.get("code_refs", []) or []
|
|
69
|
+
blocked_by = e.get("blocked_by", []) or []
|
|
70
|
+
|
|
71
|
+
base = raw_slug.lstrip("C:").lstrip("Q:")
|
|
72
|
+
if not base or not slug_re.match(base):
|
|
73
|
+
errors.append(f"invalid slug: {raw_slug!r} (need kebab-case)")
|
|
74
|
+
continue
|
|
75
|
+
if typ == "constraint":
|
|
76
|
+
slug = f"C:{base}"
|
|
77
|
+
elif typ == "question":
|
|
78
|
+
slug = f"Q:{base}"
|
|
79
|
+
else:
|
|
80
|
+
slug = base
|
|
81
|
+
|
|
82
|
+
if slug in nodes_by_slug_map:
|
|
83
|
+
errors.append(f"slug taken: {slug}")
|
|
84
|
+
continue
|
|
85
|
+
for dep in depends_on:
|
|
86
|
+
if dep not in nodes_by_slug_map and dep not in {n.slug for n in to_write}:
|
|
87
|
+
errors.append(f"depends-on @ {dep} not found for {slug}")
|
|
88
|
+
if not provenance:
|
|
89
|
+
errors.append(f"provenance required for {slug}")
|
|
90
|
+
if typ == "constraint" and planned:
|
|
91
|
+
planned = False
|
|
92
|
+
|
|
93
|
+
to_write.append(
|
|
94
|
+
Node(
|
|
95
|
+
slug=slug,
|
|
96
|
+
type=typ,
|
|
97
|
+
domain=domain,
|
|
98
|
+
file=str(domain_path),
|
|
99
|
+
statement=statement,
|
|
100
|
+
planned=planned,
|
|
101
|
+
provenance=provenance,
|
|
102
|
+
assumptions=assumptions,
|
|
103
|
+
code_refs=code_refs,
|
|
104
|
+
depends_on=depends_on,
|
|
105
|
+
blocked_by=blocked_by,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if errors:
|
|
110
|
+
print("=== PROV WRITE ===")
|
|
111
|
+
print()
|
|
112
|
+
print("VALIDATION ERRORS:")
|
|
113
|
+
for err in errors:
|
|
114
|
+
print(f" {err}")
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
print("=== PROV WRITE ===")
|
|
118
|
+
print()
|
|
119
|
+
print("WOULD WRITE:")
|
|
120
|
+
for n in to_write:
|
|
121
|
+
print(node_to_markdown(n))
|
|
122
|
+
print(
|
|
123
|
+
f"→ {len(to_write)} entries to {domain_path.relative_to(repo_root) if domain_path.is_relative_to(repo_root) else domain_path}"
|
|
124
|
+
)
|
|
125
|
+
if not autonous:
|
|
126
|
+
print()
|
|
127
|
+
print("Run with --yes to write.")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
for n in to_write:
|
|
131
|
+
section = section_for_type(n.type)
|
|
132
|
+
insert_entry_into_file(domain_path, section, node_to_markdown(n))
|
|
133
|
+
print()
|
|
134
|
+
print("Wrote.")
|
|
135
|
+
return 0
|
prov/indexing.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Indexing — nodes by slug, edges, slugs for path, grep spec in code."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prov.model import Edge, Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def slug_to_full(slug: str, nodes_by_slug: dict[str, Node]) -> str:
|
|
11
|
+
if slug in nodes_by_slug:
|
|
12
|
+
return slug
|
|
13
|
+
if slug.startswith("C:") and slug in nodes_by_slug:
|
|
14
|
+
return slug
|
|
15
|
+
if slug.startswith("Q:") and slug in nodes_by_slug:
|
|
16
|
+
return slug
|
|
17
|
+
return slug
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_edges(nodes: list[Node]) -> list[Edge]:
|
|
21
|
+
edges: list[Edge] = []
|
|
22
|
+
for n in nodes:
|
|
23
|
+
for dep in n.depends_on:
|
|
24
|
+
edges.append(Edge(from_slug=n.slug, to_slug=dep, type="depends-on"))
|
|
25
|
+
for ref in n.code_refs:
|
|
26
|
+
edges.append(Edge(from_slug=n.slug, to_slug=ref, type="implements"))
|
|
27
|
+
return edges
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def nodes_by_slug(nodes: list[Node]) -> dict[str, Node]:
|
|
31
|
+
return {n.slug: n for n in nodes}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def slugs_for_path(
|
|
35
|
+
path_str: str,
|
|
36
|
+
nodes: list[Node],
|
|
37
|
+
refs_by_domain: dict[str, list[str]],
|
|
38
|
+
repo_root: Path,
|
|
39
|
+
) -> set[str]:
|
|
40
|
+
"""Find slugs governing a code path (direct ~ refs or dir ownership in ## Refs)."""
|
|
41
|
+
slugs: set[str] = set()
|
|
42
|
+
path = Path(path_str)
|
|
43
|
+
if not path.is_absolute():
|
|
44
|
+
path = repo_root / path_str
|
|
45
|
+
try:
|
|
46
|
+
path_str_norm = str(path.relative_to(repo_root)).replace("\\", "/")
|
|
47
|
+
except ValueError:
|
|
48
|
+
path_str_norm = str(path).replace("\\", "/")
|
|
49
|
+
|
|
50
|
+
for n in nodes:
|
|
51
|
+
for ref in n.code_refs:
|
|
52
|
+
ref_file = ref.split(":")[0].strip()
|
|
53
|
+
if not ref_file:
|
|
54
|
+
continue
|
|
55
|
+
if path_str_norm == ref_file or path_str_norm.startswith(
|
|
56
|
+
ref_file.rstrip("/") + "/"
|
|
57
|
+
):
|
|
58
|
+
slugs.add(n.slug)
|
|
59
|
+
elif ref_file.endswith("/") and path_str_norm.startswith(
|
|
60
|
+
ref_file.rstrip("/")
|
|
61
|
+
):
|
|
62
|
+
slugs.add(n.slug)
|
|
63
|
+
|
|
64
|
+
for domain, refs in refs_by_domain.items():
|
|
65
|
+
for r in refs:
|
|
66
|
+
rp = r.strip().rstrip("/")
|
|
67
|
+
if not rp:
|
|
68
|
+
continue
|
|
69
|
+
if path_str_norm == rp or path_str_norm.startswith(rp + "/"):
|
|
70
|
+
for n in nodes:
|
|
71
|
+
if n.domain == domain:
|
|
72
|
+
slugs.add(n.slug)
|
|
73
|
+
|
|
74
|
+
return slugs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def grep_spec_in_code(
|
|
78
|
+
path_or_dir: Path, repo_root: Path
|
|
79
|
+
) -> list[tuple[str, int, str]]:
|
|
80
|
+
"""Returns (file, line_no, slug) for spec: backlink comments in code. Excludes .md files."""
|
|
81
|
+
results: list[tuple[str, int, str]] = []
|
|
82
|
+
spec_re = re.compile(r"(?:^|[#/*\s])spec:\s*([a-z][a-z0-9\-,\s]*)", re.I)
|
|
83
|
+
slug_re = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$")
|
|
84
|
+
_skip_dirs = {
|
|
85
|
+
".git",
|
|
86
|
+
".spec",
|
|
87
|
+
"__pycache__",
|
|
88
|
+
"node_modules",
|
|
89
|
+
".venv",
|
|
90
|
+
"venv",
|
|
91
|
+
"prompts",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if path_or_dir.is_file():
|
|
95
|
+
files = [path_or_dir] if path_or_dir.suffix.lower() != ".md" else []
|
|
96
|
+
else:
|
|
97
|
+
files = []
|
|
98
|
+
for f in path_or_dir.rglob("*"):
|
|
99
|
+
if not f.is_file():
|
|
100
|
+
continue
|
|
101
|
+
if f.suffix.lower() == ".md":
|
|
102
|
+
continue
|
|
103
|
+
if any(part in _skip_dirs for part in f.parts):
|
|
104
|
+
continue
|
|
105
|
+
files.append(f)
|
|
106
|
+
|
|
107
|
+
for f in files:
|
|
108
|
+
try:
|
|
109
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
110
|
+
except Exception:
|
|
111
|
+
continue
|
|
112
|
+
rel = str(f.relative_to(repo_root)) if f.is_relative_to(repo_root) else str(f)
|
|
113
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
114
|
+
for m in spec_re.finditer(line):
|
|
115
|
+
for part in m.group(1).replace(",", " ").split():
|
|
116
|
+
slug = part.strip().rstrip(".")
|
|
117
|
+
if slug and slug_re.match(slug):
|
|
118
|
+
results.append((rel, i, slug))
|
|
119
|
+
return results
|