codd-dev 0.2.0a1__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.
codd/propagate.py ADDED
@@ -0,0 +1,308 @@
1
+ """Change propagation engine — impact analysis from git diff."""
2
+
3
+ import subprocess
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from codd.graph import CEG
10
+ from codd.scanner import _extract_frontmatter
11
+
12
+
13
+ def run_impact(project_root: Path, codd_dir: Path, diff_target: str,
14
+ output_path: str = None):
15
+ """Run impact analysis from git diff."""
16
+ config = yaml.safe_load((codd_dir / "codd.yaml").read_text())
17
+ scan_dir = codd_dir / "scan"
18
+ ceg = CEG(scan_dir)
19
+
20
+ # Get changed files from git diff
21
+ changed_files = _get_changed_files(project_root, diff_target)
22
+ if not changed_files:
23
+ print("No changed files detected.")
24
+ ceg.close()
25
+ return
26
+
27
+ print(f"Changed files: {len(changed_files)}")
28
+ for f in changed_files:
29
+ print(f" - {f}")
30
+
31
+ # Load band thresholds from config
32
+ bands = config.get("bands", {})
33
+ green_conf = bands.get("green", {}).get("min_confidence", 0.90)
34
+ green_evidence = bands.get("green", {}).get("min_evidence_count", 2)
35
+ amber_conf = bands.get("amber", {}).get("min_confidence", 0.50)
36
+ max_depth = config.get("propagation", {}).get("max_depth", 10)
37
+
38
+ # Resolve changed files to graph node IDs
39
+ start_nodes = _resolve_start_nodes(ceg, project_root, changed_files)
40
+ print(f"Resolved to {len(start_nodes)} graph nodes:")
41
+ for node_id, source in start_nodes:
42
+ print(f" - {node_id} (from {source})")
43
+
44
+ # Propagate impact from all start nodes
45
+ all_impacts = {}
46
+ for node_id, source_file in start_nodes:
47
+ impacts = ceg.propagate_impact(node_id, max_depth=max_depth)
48
+ for target_id, info in impacts.items():
49
+ if target_id not in all_impacts or info["depth"] < all_impacts[target_id]["depth"]:
50
+ all_impacts[target_id] = {
51
+ **info,
52
+ "source": source_file,
53
+ }
54
+
55
+ # Check conventions from graph (must_review edges)
56
+ convention_impacts = _check_conventions_from_graph(ceg, start_nodes)
57
+
58
+ # Generate report
59
+ report = _generate_report(
60
+ ceg, changed_files, start_nodes, all_impacts, convention_impacts,
61
+ green_conf, green_evidence, amber_conf
62
+ )
63
+
64
+ if output_path:
65
+ Path(output_path).write_text(report)
66
+ print(f"Report written to {output_path}")
67
+ else:
68
+ print("\n" + report)
69
+
70
+ ceg.close()
71
+
72
+
73
+ def _get_changed_files(project_root: Path, diff_target: str) -> list:
74
+ """Get list of changed files from git diff."""
75
+ try:
76
+ result = subprocess.run(
77
+ ["git", "diff", "--name-only", diff_target],
78
+ capture_output=True, text=True, cwd=str(project_root)
79
+ )
80
+ if result.returncode != 0:
81
+ print(f"Warning: git diff failed: {result.stderr.strip()}")
82
+ return []
83
+ return [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
84
+ except FileNotFoundError:
85
+ print("Warning: git not found.")
86
+ return []
87
+
88
+
89
+ def _resolve_start_nodes(ceg: CEG, project_root: Path, changed_files: list) -> list:
90
+ """Resolve changed files to graph node IDs.
91
+
92
+ A changed .md file with CoDD frontmatter resolves to its node_id (e.g. design:system-design).
93
+ A changed source file resolves to file:path.
94
+ Returns list of (node_id, source_file) tuples.
95
+ """
96
+ start_nodes = []
97
+ seen = set()
98
+
99
+ for changed_file in changed_files:
100
+ full_path = project_root / changed_file
101
+
102
+ # Check if it's a document with CoDD frontmatter
103
+ if changed_file.endswith(".md") and full_path.exists():
104
+ codd_data = _extract_frontmatter(full_path)
105
+ if codd_data and "node_id" in codd_data:
106
+ node_id = codd_data["node_id"]
107
+ if node_id not in seen and ceg.get_node(node_id):
108
+ start_nodes.append((node_id, changed_file))
109
+ seen.add(node_id)
110
+ continue
111
+
112
+ # Check file:path node
113
+ file_node_id = f"file:{changed_file}"
114
+ if file_node_id not in seen and ceg.get_node(file_node_id):
115
+ start_nodes.append((file_node_id, changed_file))
116
+ seen.add(file_node_id)
117
+
118
+ # Also check if any node has this path
119
+ path_nodes = ceg.find_nodes_by_path(changed_file)
120
+ for node in path_nodes:
121
+ if node["id"] not in seen:
122
+ start_nodes.append((node["id"], changed_file))
123
+ seen.add(node["id"])
124
+
125
+ return start_nodes
126
+
127
+
128
+ def _check_conventions_from_graph(ceg: CEG, start_nodes: list) -> list:
129
+ """Check if changed nodes trigger convention (must_review) edges in the graph.
130
+
131
+ Walks the graph looking for must_review edges reachable from start nodes.
132
+ """
133
+ triggered = []
134
+ checked = set()
135
+
136
+ for node_id, source_file in start_nodes:
137
+ # Direct must_review edges from the changed node
138
+ conv_edges = ceg.get_convention_edges(node_id)
139
+ for edge in conv_edges:
140
+ key = (node_id, edge["target_id"])
141
+ if key in checked:
142
+ continue
143
+ checked.add(key)
144
+
145
+ # Get the evidence detail (reason) for this convention
146
+ reason = ""
147
+ for ev in edge.get("evidence", []):
148
+ if ev.get("detail"):
149
+ reason = ev["detail"]
150
+ break
151
+
152
+ triggered.append({
153
+ "source_node": node_id,
154
+ "target_id": edge["target_id"],
155
+ "target_name": edge["target_name"],
156
+ "target_type": edge["target_type"],
157
+ "reason": reason,
158
+ "confidence": edge["confidence"],
159
+ "triggered_by": source_file,
160
+ })
161
+
162
+ # Also check must_review edges from nodes that depend on the changed node
163
+ incoming = ceg.get_incoming_edges(node_id)
164
+ for inc_edge in incoming:
165
+ parent_id = inc_edge["source_id"]
166
+ parent_convs = ceg.get_convention_edges(parent_id)
167
+ for edge in parent_convs:
168
+ key = (parent_id, edge["target_id"])
169
+ if key in checked:
170
+ continue
171
+ checked.add(key)
172
+
173
+ reason = ""
174
+ for ev in edge.get("evidence", []):
175
+ if ev.get("detail"):
176
+ reason = ev["detail"]
177
+ break
178
+
179
+ triggered.append({
180
+ "source_node": parent_id,
181
+ "target_id": edge["target_id"],
182
+ "target_name": edge["target_name"],
183
+ "target_type": edge["target_type"],
184
+ "reason": reason,
185
+ "confidence": edge["confidence"],
186
+ "triggered_by": source_file,
187
+ })
188
+
189
+ return triggered
190
+
191
+
192
+ def _generate_report(ceg: CEG, changed_files: list, start_nodes: list,
193
+ graph_impacts: dict, convention_impacts: list,
194
+ green_conf: float, green_evidence: int,
195
+ amber_conf: float) -> str:
196
+ """Generate a Markdown impact report."""
197
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
198
+ lines = [
199
+ f"# CoDD Impact Report",
200
+ f"",
201
+ f"**Generated**: {now}",
202
+ f"**Changed files**: {len(changed_files)}",
203
+ f"**Resolved nodes**: {len(start_nodes)}",
204
+ f"",
205
+ ]
206
+
207
+ # Changed files → resolved nodes
208
+ lines.append("## Changed Files")
209
+ lines.append("")
210
+ for f in changed_files:
211
+ # Find matching start node
212
+ matched = [n for n, s in start_nodes if s == f]
213
+ if matched:
214
+ lines.append(f"- `{f}` → `{matched[0]}`")
215
+ else:
216
+ lines.append(f"- `{f}` (not in graph)")
217
+ lines.append("")
218
+
219
+ # Convention alerts (highest priority)
220
+ if convention_impacts:
221
+ lines.append("## Convention Alerts")
222
+ lines.append("")
223
+ for ci in convention_impacts:
224
+ lines.append(f"### must_review: `{ci['target_name']}`")
225
+ lines.append(f"")
226
+ lines.append(f"- **Source**: `{ci['source_node']}`")
227
+ lines.append(f"- **Target**: `{ci['target_id']}` ({ci['target_type']})")
228
+ lines.append(f"- **Reason**: {ci['reason']}")
229
+ lines.append(f"- **Confidence**: {ci['confidence']:.2f}")
230
+ lines.append(f"- **Triggered by**: `{ci['triggered_by']}`")
231
+ lines.append("")
232
+
233
+ # Graph-based impacts
234
+ if graph_impacts:
235
+ # Classify into bands
236
+ green_items = []
237
+ amber_items = []
238
+ gray_items = []
239
+
240
+ for target_id, info in graph_impacts.items():
241
+ node = ceg.get_node(target_id)
242
+ edges = ceg.get_incoming_edges(target_id)
243
+ evidence_count = sum(1 for _ in edges)
244
+ max_conf = max((e["confidence"] for e in edges), default=0.0)
245
+ band = ceg.classify_band(max_conf, evidence_count, green_conf, green_evidence, amber_conf)
246
+
247
+ item = {
248
+ "id": target_id,
249
+ "name": node["name"] if node else target_id,
250
+ "type": node["type"] if node else "unknown",
251
+ "depth": info["depth"],
252
+ "confidence": max_conf,
253
+ "source": info.get("source", "?"),
254
+ }
255
+
256
+ if band == "green":
257
+ green_items.append(item)
258
+ elif band == "amber":
259
+ amber_items.append(item)
260
+ else:
261
+ gray_items.append(item)
262
+
263
+ lines.append("## Impact Propagation")
264
+ lines.append("")
265
+
266
+ if green_items:
267
+ lines.append("### Green Band (high confidence, auto-propagate)")
268
+ lines.append("")
269
+ lines.append("| Target | Type | Depth | Confidence | Source |")
270
+ lines.append("|--------|------|-------|------------|--------|")
271
+ for item in sorted(green_items, key=lambda x: x["depth"]):
272
+ lines.append(f"| `{item['name']}` | {item['type']} | {item['depth']} | {item['confidence']:.2f} | `{item['source']}` |")
273
+ lines.append("")
274
+
275
+ if amber_items:
276
+ lines.append("### Amber Band (must review)")
277
+ lines.append("")
278
+ lines.append("| Target | Type | Depth | Confidence | Source |")
279
+ lines.append("|--------|------|-------|------------|--------|")
280
+ for item in sorted(amber_items, key=lambda x: x["depth"]):
281
+ lines.append(f"| `{item['name']}` | {item['type']} | {item['depth']} | {item['confidence']:.2f} | `{item['source']}` |")
282
+ lines.append("")
283
+
284
+ if gray_items:
285
+ lines.append("### Gray Band (informational)")
286
+ lines.append("")
287
+ lines.append("| Target | Type | Depth | Confidence | Source |")
288
+ lines.append("|--------|------|-------|------------|--------|")
289
+ for item in sorted(gray_items, key=lambda x: x["depth"]):
290
+ lines.append(f"| `{item['name']}` | {item['type']} | {item['depth']} | {item['confidence']:.2f} | `{item['source']}` |")
291
+ lines.append("")
292
+
293
+ if not graph_impacts and not convention_impacts:
294
+ lines.append("## Result")
295
+ lines.append("")
296
+ lines.append("No impacts detected. Changed files have no tracked dependencies in the graph.")
297
+ lines.append("")
298
+
299
+ # Stats
300
+ stats = ceg.stats()
301
+ lines.append("## Graph Stats")
302
+ lines.append("")
303
+ lines.append(f"- Nodes: {stats['nodes']}")
304
+ lines.append(f"- Edges: {stats['edges']}")
305
+ lines.append(f"- Evidence records: {stats['evidence']}")
306
+ lines.append("")
307
+
308
+ return "\n".join(lines)