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.
@@ -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