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.
- {codd_dev-0.4.0 → codd_dev-0.5.0}/PKG-INFO +1 -1
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/extractor.py +20 -0
- codd_dev-0.5.0/codd/risk.py +100 -0
- codd_dev-0.5.0/codd/schema_refs.py +122 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/synth.py +33 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/architecture-overview.md.j2 +10 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/module-detail.md.j2 +33 -0
- codd_dev-0.5.0/codd/traceability.py +67 -0
- codd_dev-0.5.0/codd/wiring.py +146 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/pyproject.toml +1 -1
- {codd_dev-0.4.0 → codd_dev-0.5.0}/.gitignore +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/LICENSE +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/README.md +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/__init__.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/cli.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/clustering.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/config.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/contracts.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/defaults.yaml +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/generator.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/graph.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/hooks.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/implementer.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/parsing.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/planner.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/propagate.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/scanner.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/codd.yaml.tmpl +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/conventions.yaml.tmpl +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/doc_links.yaml.tmpl +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/extracted/system-context.md.j2 +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/gitignore.tmpl +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/templates/overrides.yaml.tmpl +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/validator.py +0 -0
- {codd_dev-0.4.0 → codd_dev-0.5.0}/codd/verifier.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|