codd-dev 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (39) hide show
  1. {codd_dev-0.4.0 → codd_dev-0.5.0}/PKG-INFO +1 -1
  2. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/extractor.py +20 -0
  3. codd_dev-0.5.0/codd/risk.py +100 -0
  4. codd_dev-0.5.0/codd/schema_refs.py +122 -0
  5. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/synth.py +33 -0
  6. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/architecture-overview.md.j2 +10 -0
  7. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/module-detail.md.j2 +33 -0
  8. codd_dev-0.5.0/codd/traceability.py +67 -0
  9. codd_dev-0.5.0/codd/wiring.py +146 -0
  10. {codd_dev-0.4.0 → codd_dev-0.5.0}/pyproject.toml +1 -1
  11. {codd_dev-0.4.0 → codd_dev-0.5.0}/.gitignore +0 -0
  12. {codd_dev-0.4.0 → codd_dev-0.5.0}/LICENSE +0 -0
  13. {codd_dev-0.4.0 → codd_dev-0.5.0}/README.md +0 -0
  14. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/__init__.py +0 -0
  15. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/cli.py +0 -0
  16. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/clustering.py +0 -0
  17. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/config.py +0 -0
  18. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/contracts.py +0 -0
  19. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/defaults.yaml +0 -0
  20. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/generator.py +0 -0
  21. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/graph.py +0 -0
  22. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/hooks.py +0 -0
  23. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/implementer.py +0 -0
  24. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/parsing.py +0 -0
  25. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/planner.py +0 -0
  26. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/propagate.py +0 -0
  27. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/scanner.py +0 -0
  28. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/codd.yaml.tmpl +0 -0
  29. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/conventions.yaml.tmpl +0 -0
  30. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
  31. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/doc_links.yaml.tmpl +0 -0
  32. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
  33. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
  34. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/system-context.md.j2 +0 -0
  35. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/gitignore.tmpl +0 -0
  36. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/overrides.yaml.tmpl +0 -0
  37. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/validator.py +0 -0
  38. {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/verifier.py +0 -0
  39. {codd_dev-0.4.0 → codd_dev-0.5.0}/hooks/pre-commit +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codd-dev
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CoDD: Coherence-Driven Development — cross-artifact change impact analysis
5
5
  Project-URL: Homepage, https://github.com/yohey-w/codd-dev
6
6
  Project-URL: Repository, https://github.com/yohey-w/codd-dev
@@ -76,6 +76,9 @@ class ModuleInfo:
76
76
  patterns: dict[str, str] = field(default_factory=dict) # pattern_type -> detail
77
77
  call_edges: list[CallEdge] = field(default_factory=list)
78
78
  interface_contract: Any = None # InterfaceContract from contracts.py
79
+ test_coverage: Any = None # TestCoverage from traceability.py
80
+ schema_refs: list[Any] = field(default_factory=list) # SchemaRef from schema_refs.py
81
+ runtime_wires: list[Any] = field(default_factory=list) # RuntimeWire from wiring.py
79
82
 
80
83
 
81
84
  @dataclass
@@ -104,6 +107,7 @@ class ProjectFacts:
104
107
  infra_config: dict[str, ConfigInfo] = field(default_factory=dict)
105
108
  build_deps: BuildDepsInfo | None = None
106
109
  feature_clusters: list[FeatureCluster] = field(default_factory=list)
110
+ change_risks: list[Any] = field(default_factory=list) # ChangeRisk from risk.py
107
111
 
108
112
 
109
113
  @dataclass
@@ -184,6 +188,22 @@ def extract_facts(project_root: Path, language: str | None = None,
184
188
  from codd.clustering import build_feature_clusters
185
189
  build_feature_clusters(facts)
186
190
 
191
+ # R5.1: Test traceability
192
+ from codd.traceability import build_test_traceability
193
+ build_test_traceability(facts, project_root)
194
+
195
+ # R5.2: Schema-code dependency
196
+ from codd.schema_refs import build_schema_refs
197
+ build_schema_refs(facts, project_root)
198
+
199
+ # R5.3: Runtime wiring detection
200
+ from codd.wiring import build_runtime_wires
201
+ build_runtime_wires(facts, project_root)
202
+
203
+ # R5.4: Change risk scoring (depends on R4.3, R5.1)
204
+ from codd.risk import build_change_risks
205
+ build_change_risks(facts)
206
+
187
207
  return facts
188
208
 
189
209
 
@@ -0,0 +1,100 @@
1
+ """R5.4 — Change risk scoring for codd extract.
2
+
3
+ Computes per-module risk score based on:
4
+ - Number of dependents (import + call + runtime)
5
+ - Test coverage ratio (R5.1)
6
+ - API surface ratio (R4.3)
7
+ - Encapsulation violations (R4.3)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from codd.extractor import ProjectFacts
17
+
18
+
19
+ @dataclass
20
+ class ChangeRisk:
21
+ """Per-module change risk assessment."""
22
+ module: str
23
+ score: float = 0.0
24
+ factors: dict[str, float] = field(default_factory=dict)
25
+
26
+
27
+ def build_change_risks(facts: ProjectFacts) -> None:
28
+ """Populate ``facts.change_risks`` with per-module risk scores."""
29
+ # Step 1: Count inbound dependents per module (import + call + runtime)
30
+ dependents: dict[str, int] = {name: 0 for name in facts.modules}
31
+
32
+ for mod in facts.modules.values():
33
+ # Import dependents
34
+ for dep_name in mod.internal_imports:
35
+ if dep_name in dependents:
36
+ dependents[dep_name] += 1
37
+ # Call dependents
38
+ for edge in mod.call_edges:
39
+ target = edge.callee.split(".")[0]
40
+ if target in dependents and target != mod.name:
41
+ dependents[target] += 1
42
+ # Runtime wire dependents
43
+ for wire in getattr(mod, "runtime_wires", []):
44
+ target = wire.target.split(".")[0]
45
+ if target in dependents and target != mod.name:
46
+ dependents[target] += 1
47
+
48
+ max_dep = max(dependents.values()) if dependents else 1
49
+ if max_dep == 0:
50
+ max_dep = 1
51
+
52
+ # Step 2: Collect max violations
53
+ violations: dict[str, int] = {}
54
+ for mod in facts.modules.values():
55
+ ic = mod.interface_contract
56
+ violations[mod.name] = len(ic.encapsulation_violations) if ic else 0
57
+ max_viol = max(violations.values()) if violations else 1
58
+ if max_viol == 0:
59
+ max_viol = 1
60
+
61
+ # Step 3: Compute risk score per module
62
+ risks: list[ChangeRisk] = []
63
+ for mod in facts.modules.values():
64
+ # Coverage ratio (from R5.1 test traceability)
65
+ tc = getattr(mod, "test_coverage", None)
66
+ coverage_ratio = tc.coverage_ratio if tc else 0.0
67
+
68
+ # API surface ratio (from R4.3 interface contracts)
69
+ ic = mod.interface_contract
70
+ api_ratio = ic.api_surface_ratio if ic else 1.0
71
+
72
+ # Violation count
73
+ viol_count = violations.get(mod.name, 0)
74
+
75
+ # Dependent count
76
+ dep_count = dependents.get(mod.name, 0)
77
+
78
+ # Formula
79
+ dep_factor = dep_count / max_dep
80
+ cov_factor = 1.0 - coverage_ratio
81
+ api_factor = api_ratio
82
+ viol_factor = viol_count / max_viol
83
+
84
+ score = (0.3 * dep_factor
85
+ + 0.3 * cov_factor
86
+ + 0.2 * api_factor
87
+ + 0.2 * viol_factor)
88
+
89
+ risks.append(ChangeRisk(
90
+ module=mod.name,
91
+ score=round(score, 2),
92
+ factors={
93
+ "dependents": round(dep_factor, 2),
94
+ "uncovered": round(cov_factor, 2),
95
+ "api_surface": round(api_factor, 2),
96
+ "violations": round(viol_factor, 2),
97
+ },
98
+ ))
99
+
100
+ facts.change_risks = sorted(risks, key=lambda r: -r.score)
@@ -0,0 +1,122 @@
1
+ """R5.2 — Schema-code dependency detection for codd extract.
2
+
3
+ Detects ORM model definitions (SQLAlchemy, Django, Prisma) and raw SQL
4
+ references in source code. Links source modules to schema tables.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from codd.extractor import ProjectFacts
16
+
17
+
18
+ @dataclass
19
+ class SchemaRef:
20
+ """A reference from source code to a database table or model."""
21
+ table_or_model: str
22
+ kind: str # "sqlalchemy" | "django" | "prisma" | "raw_sql"
23
+ file: str
24
+ line: int
25
+
26
+
27
+ # ── Detection patterns ──────────────────────────────────
28
+
29
+ # SQLAlchemy: __tablename__ = 'users'
30
+ _SQLA_TABLENAME_RE = re.compile(
31
+ r"""__tablename__\s*=\s*['"](\w+)['"]""",
32
+ )
33
+
34
+ # Django: class User(models.Model)
35
+ _DJANGO_MODEL_RE = re.compile(
36
+ r"""class\s+(\w+)\s*\(\s*(?:models\.Model|AbstractUser|AbstractBaseUser)""",
37
+ )
38
+
39
+ # Prisma client: prisma.user.find_many() etc
40
+ _PRISMA_CLIENT_RE = re.compile(
41
+ r"""prisma\.(\w+)\.\s*(?:find_many|find_first|find_unique|create|update|delete|count|aggregate|group_by)""",
42
+ )
43
+
44
+ # Raw SQL: SELECT/INSERT/UPDATE/DELETE ... FROM/INTO/TABLE tablename
45
+ _RAW_SQL_RE = re.compile(
46
+ r"""(?:SELECT\s+.*?\s+FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|CREATE\s+TABLE|ALTER\s+TABLE|DROP\s+TABLE)\s+[`"']?(\w+)[`"']?""",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+
51
+ def detect_schema_refs(content: str, file_path: str) -> list[SchemaRef]:
52
+ """Detect schema/model references in source code."""
53
+ refs: list[SchemaRef] = []
54
+ lines = content.splitlines()
55
+
56
+ for line_no, line in enumerate(lines, 1):
57
+ # SQLAlchemy
58
+ m = _SQLA_TABLENAME_RE.search(line)
59
+ if m:
60
+ refs.append(SchemaRef(
61
+ table_or_model=m.group(1),
62
+ kind="sqlalchemy",
63
+ file=file_path,
64
+ line=line_no,
65
+ ))
66
+
67
+ # Django
68
+ m = _DJANGO_MODEL_RE.search(line)
69
+ if m:
70
+ refs.append(SchemaRef(
71
+ table_or_model=m.group(1),
72
+ kind="django",
73
+ file=file_path,
74
+ line=line_no,
75
+ ))
76
+
77
+ # Prisma client
78
+ m = _PRISMA_CLIENT_RE.search(line)
79
+ if m:
80
+ refs.append(SchemaRef(
81
+ table_or_model=m.group(1),
82
+ kind="prisma",
83
+ file=file_path,
84
+ line=line_no,
85
+ ))
86
+
87
+ # Raw SQL in string literals
88
+ # Only match inside quotes to avoid false positives
89
+ if any(kw in line.upper() for kw in ("SELECT", "INSERT", "UPDATE", "DELETE", "CREATE TABLE", "ALTER TABLE")):
90
+ for m2 in _RAW_SQL_RE.finditer(line):
91
+ table = m2.group(1)
92
+ # Filter out SQL keywords that got captured as table names
93
+ if table.upper() not in {"SET", "INTO", "FROM", "WHERE", "AND", "OR",
94
+ "TABLE", "INDEX", "VIEW", "VALUES", "NULL",
95
+ "NOT", "EXISTS", "AS", "ON", "JOIN", "LEFT",
96
+ "RIGHT", "INNER", "OUTER", "GROUP", "ORDER",
97
+ "BY", "HAVING", "LIMIT", "OFFSET", "UNION",
98
+ "ALL", "DISTINCT", "CASE", "WHEN", "THEN",
99
+ "ELSE", "END", "IF", "BEGIN", "COMMIT"}:
100
+ refs.append(SchemaRef(
101
+ table_or_model=table,
102
+ kind="raw_sql",
103
+ file=file_path,
104
+ line=line_no,
105
+ ))
106
+
107
+ return refs
108
+
109
+
110
+ def build_schema_refs(facts: ProjectFacts, project_root: Path) -> None:
111
+ """Populate ``schema_refs`` on every module in *facts*."""
112
+ for mod in facts.modules.values():
113
+ all_refs: list[SchemaRef] = []
114
+ for rel_file in mod.files:
115
+ full = project_root / rel_file
116
+ try:
117
+ content = full.read_text(errors="ignore")
118
+ except Exception:
119
+ continue
120
+ refs = detect_schema_refs(content, rel_file)
121
+ all_refs.extend(refs)
122
+ mod.schema_refs = all_refs
@@ -167,6 +167,7 @@ def synth_architecture(
167
167
  external_dependencies=_all_external_dependencies(facts),
168
168
  build_deps=_build_deps_context(facts.build_deps),
169
169
  deployment_hints=_deployment_hints(facts),
170
+ change_risks=facts.change_risks,
170
171
  )
171
172
  architecture_path.write_text(content, encoding="utf-8")
172
173
  return architecture_path
@@ -266,6 +267,9 @@ def _render_module_detail(env: Environment, facts: ProjectFacts, module: ModuleI
266
267
  tests=_tests_context(module),
267
268
  call_edges=module.call_edges,
268
269
  interface_contract=module.interface_contract,
270
+ test_coverage=module.test_coverage,
271
+ schema_refs=module.schema_refs,
272
+ runtime_wires=module.runtime_wires,
269
273
  )
270
274
  return content
271
275
 
@@ -367,6 +371,35 @@ def _module_depends_on(facts: ProjectFacts, module: ModuleInfo) -> list[dict[str
367
371
  )
368
372
  seen_ids.add(nid)
369
373
 
374
+ # R5.2: schema_uses edges
375
+ for ref in getattr(module, "schema_refs", []):
376
+ # Link to schema doc if one exists for this table
377
+ for schema_path in facts.schemas:
378
+ schema_obj = facts.schemas[schema_path]
379
+ tables = [t.get("name", "") for t in getattr(schema_obj, "tables", [])]
380
+ models = [m.get("name", "") for m in getattr(schema_obj, "models", [])]
381
+ if ref.table_or_model in tables or ref.table_or_model in models:
382
+ nid = _schema_node_id(schema_path)
383
+ if nid not in seen_ids:
384
+ depends_on.append(
385
+ {"id": nid, "relation": "schema_uses", "semantic": "technical"}
386
+ )
387
+ seen_ids.add(nid)
388
+
389
+ # R5.3: runtime_wires edges
390
+ wire_targets: set[str] = set()
391
+ for wire in getattr(module, "runtime_wires", []):
392
+ target = wire.target.split(".")[0]
393
+ if target in facts.modules and target != module.name:
394
+ wire_targets.add(target)
395
+ for target in sorted(wire_targets):
396
+ nid = _module_node_id(target)
397
+ if nid not in seen_ids:
398
+ depends_on.append(
399
+ {"id": nid, "relation": "runtime_wires", "semantic": "technical"}
400
+ )
401
+ seen_ids.add(nid)
402
+
370
403
  return depends_on
371
404
 
372
405
 
@@ -58,6 +58,16 @@ Evidence: {{ cluster.evidence | join("; ") }}
58
58
  {% endfor %}
59
59
  {% endif %}
60
60
 
61
+ {% if change_risks %}
62
+ ## Change Risk Summary
63
+
64
+ | Module | Risk | Dependents | Uncovered | API Surface | Violations |
65
+ |--------|------|------------|-----------|-------------|------------|
66
+ {% for risk in change_risks -%}
67
+ | `{{ risk.module }}` | {{ risk.score }} | {{ risk.factors.dependents }} | {{ risk.factors.uncovered }} | {{ risk.factors.api_surface }} | {{ risk.factors.violations }} |
68
+ {% endfor %}
69
+ {% endif %}
70
+
61
71
  ## Layer Violations
62
72
 
63
73
  {% if violations %}
@@ -98,6 +98,39 @@
98
98
  {% endfor %}
99
99
  {% endif %}
100
100
 
101
+ {% if test_coverage %}
102
+ ## Test Coverage
103
+
104
+ **Coverage**: {{ test_coverage.coverage_ratio }} ({{ test_coverage.covered_symbols | length }} / {{ test_coverage.covered_symbols | length + test_coverage.uncovered_symbols | length }})
105
+ {% if test_coverage.covering_tests %}
106
+ Tests: {{ test_coverage.covering_tests | join(", ") }}
107
+ {% endif %}
108
+ {% if test_coverage.uncovered_symbols %}
109
+
110
+ **Uncovered symbols**: {% for s in test_coverage.uncovered_symbols %}`{{ s }}`{% if not loop.last %}, {% endif %}{% endfor %}
111
+ {% endif %}
112
+ {% endif %}
113
+
114
+ {% if schema_refs %}
115
+ ## Schema Dependencies
116
+
117
+ | Table/Model | Kind | Location |
118
+ |-------------|------|----------|
119
+ {% for ref in schema_refs -%}
120
+ | `{{ ref.table_or_model }}` | {{ ref.kind }} | `{{ ref.file }}:{{ ref.line }}` |
121
+ {% endfor %}
122
+ {% endif %}
123
+
124
+ {% if runtime_wires %}
125
+ ## Runtime Wiring
126
+
127
+ | Kind | Target | Framework | Location |
128
+ |------|--------|-----------|----------|
129
+ {% for wire in runtime_wires -%}
130
+ | {{ wire.kind }} | `{{ wire.target }}` | {{ wire.framework }} | `{{ wire.source }}` |
131
+ {% endfor %}
132
+ {% endif %}
133
+
101
134
  {% if internal_dependencies %}
102
135
  ## Import Dependencies
103
136
 
@@ -0,0 +1,67 @@
1
+ """R5.1 — Test traceability for codd extract.
2
+
3
+ Maps test files to the source symbols they exercise, via import analysis
4
+ of test code. Enables 'which tests to run' and 'untested symbols' queries.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from codd.extractor import ProjectFacts
15
+
16
+
17
+ @dataclass
18
+ class TestCoverage:
19
+ """Per-module test coverage summary."""
20
+ module: str
21
+ covered_symbols: list[str] = field(default_factory=list)
22
+ uncovered_symbols: list[str] = field(default_factory=list)
23
+ coverage_ratio: float = 0.0
24
+ covering_tests: list[str] = field(default_factory=list)
25
+
26
+
27
+ def build_test_traceability(facts: ProjectFacts, project_root: Path) -> None:
28
+ """Populate ``test_coverage`` on every module in *facts*.
29
+
30
+ Strategy: For each module's test files, scan import lines and test
31
+ function call patterns to identify which source symbols are exercised.
32
+ """
33
+ for mod in facts.modules.values():
34
+ all_symbols = {s.name for s in mod.symbols}
35
+ if not all_symbols:
36
+ continue
37
+
38
+ covered: set[str] = set()
39
+ covering_tests: list[str] = []
40
+
41
+ for test_detail in mod.test_details:
42
+ covering_tests.append(test_detail.file_path)
43
+ # Read test file and scan for symbol references
44
+ test_path = project_root / test_detail.file_path
45
+ try:
46
+ test_content = test_path.read_text(errors="ignore")
47
+ except Exception:
48
+ continue
49
+ # Any source symbol name that appears in the test file = covered
50
+ for sym_name in all_symbols:
51
+ if sym_name in test_content:
52
+ covered.add(sym_name)
53
+
54
+ # Deduplicate covering_tests
55
+ covering_tests = sorted(set(covering_tests))
56
+ covered_list = sorted(covered & all_symbols)
57
+ uncovered_list = sorted(all_symbols - covered)
58
+ total = len(all_symbols)
59
+ ratio = len(covered_list) / total if total else 0.0
60
+
61
+ mod.test_coverage = TestCoverage(
62
+ module=mod.name,
63
+ covered_symbols=covered_list,
64
+ uncovered_symbols=uncovered_list,
65
+ coverage_ratio=round(ratio, 2),
66
+ covering_tests=covering_tests,
67
+ )
@@ -0,0 +1,146 @@
1
+ """R5.3 — Runtime wiring detection for codd extract.
2
+
3
+ Detects framework-specific implicit dependencies that don't appear
4
+ in import graphs or call graphs: DI injection, middleware chains,
5
+ signal handlers, and decorator-based routing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from codd.extractor import ProjectFacts
17
+
18
+
19
+ @dataclass
20
+ class RuntimeWire:
21
+ """An implicit runtime dependency detected from framework patterns."""
22
+ kind: str # "depends" | "middleware" | "signal" | "decorator" | "task"
23
+ source: str # file:line
24
+ target: str # the function/class/module wired
25
+ framework: str # "fastapi" | "django" | "flask" | "celery" | "generic"
26
+
27
+
28
+ # ── Detection patterns ──────────────────────────────────
29
+
30
+ # FastAPI Depends()
31
+ _FASTAPI_DEPENDS_RE = re.compile(
32
+ r"""Depends\(\s*(\w[\w.]*)\s*\)""",
33
+ )
34
+
35
+ # Django MIDDLEWARE list
36
+ _DJANGO_MIDDLEWARE_RE = re.compile(
37
+ r"""MIDDLEWARE\s*=\s*\[([^\]]*)\]""",
38
+ re.DOTALL,
39
+ )
40
+
41
+ # Django signals: signal.connect(handler)
42
+ _DJANGO_SIGNAL_RE = re.compile(
43
+ r"""(\w+)\s*\.\s*connect\(\s*(\w[\w.]*)\s*""",
44
+ )
45
+
46
+ # Flask before/after request
47
+ _FLASK_HOOK_RE = re.compile(
48
+ r"""@\s*(?:\w+\.)\s*(before_request|after_request|before_first_request|teardown_request|teardown_appcontext)\b""",
49
+ )
50
+
51
+ # Celery task
52
+ _CELERY_TASK_RE = re.compile(
53
+ r"""@\s*(?:\w+\.)\s*task\b""",
54
+ )
55
+
56
+ # Generic event handler registration: on_event, add_event_handler, register
57
+ _GENERIC_HANDLER_RE = re.compile(
58
+ r"""(?:on_event|add_event_handler|register_handler|subscribe)\(\s*['"](\w+)['"]\s*,\s*(\w[\w.]*)""",
59
+ )
60
+
61
+
62
+ def detect_runtime_wires(content: str, file_path: str) -> list[RuntimeWire]:
63
+ """Detect runtime wiring patterns in source code."""
64
+ wires: list[RuntimeWire] = []
65
+ lines = content.splitlines()
66
+
67
+ for line_no, line in enumerate(lines, 1):
68
+ source = f"{file_path}:{line_no}"
69
+
70
+ # FastAPI Depends()
71
+ for m in _FASTAPI_DEPENDS_RE.finditer(line):
72
+ wires.append(RuntimeWire(
73
+ kind="depends",
74
+ source=source,
75
+ target=m.group(1),
76
+ framework="fastapi",
77
+ ))
78
+
79
+ # Django signals
80
+ m = _DJANGO_SIGNAL_RE.search(line)
81
+ if m and m.group(1) in ("post_save", "pre_save", "post_delete",
82
+ "pre_delete", "m2m_changed", "post_init",
83
+ "pre_init", "request_started", "request_finished"):
84
+ wires.append(RuntimeWire(
85
+ kind="signal",
86
+ source=source,
87
+ target=m.group(2),
88
+ framework="django",
89
+ ))
90
+
91
+ # Flask hooks
92
+ m = _FLASK_HOOK_RE.search(line)
93
+ if m:
94
+ wires.append(RuntimeWire(
95
+ kind="decorator",
96
+ source=source,
97
+ target=m.group(1),
98
+ framework="flask",
99
+ ))
100
+
101
+ # Celery task
102
+ if _CELERY_TASK_RE.search(line):
103
+ wires.append(RuntimeWire(
104
+ kind="task",
105
+ source=source,
106
+ target="celery_task",
107
+ framework="celery",
108
+ ))
109
+
110
+ # Generic event handlers
111
+ m = _GENERIC_HANDLER_RE.search(line)
112
+ if m:
113
+ wires.append(RuntimeWire(
114
+ kind="signal",
115
+ source=source,
116
+ target=m.group(2),
117
+ framework="generic",
118
+ ))
119
+
120
+ # Django MIDDLEWARE (multiline)
121
+ for m in _DJANGO_MIDDLEWARE_RE.finditer(content):
122
+ raw = m.group(1)
123
+ for mw in re.findall(r"""['"]([^'"]+)['"]""", raw):
124
+ wires.append(RuntimeWire(
125
+ kind="middleware",
126
+ source=f"{file_path}:{content[:m.start()].count(chr(10)) + 1}",
127
+ target=mw,
128
+ framework="django",
129
+ ))
130
+
131
+ return wires
132
+
133
+
134
+ def build_runtime_wires(facts: ProjectFacts, project_root: Path) -> None:
135
+ """Populate ``runtime_wires`` on every module in *facts*."""
136
+ for mod in facts.modules.values():
137
+ all_wires: list[RuntimeWire] = []
138
+ for rel_file in mod.files:
139
+ full = project_root / rel_file
140
+ try:
141
+ content = full.read_text(errors="ignore")
142
+ except Exception:
143
+ continue
144
+ wires = detect_runtime_wires(content, rel_file)
145
+ all_wires.extend(wires)
146
+ mod.runtime_wires = all_wires
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codd-dev"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "CoDD: Coherence-Driven Development — cross-artifact change impact analysis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes