codeupipe 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.
- codeupipe/__init__.py +39 -0
- codeupipe/cli.py +1502 -0
- codeupipe/converter/__init__.py +9 -0
- codeupipe/converter/config.py +119 -0
- codeupipe/converter/filters/__init__.py +21 -0
- codeupipe/converter/filters/analyze.py +60 -0
- codeupipe/converter/filters/classify.py +52 -0
- codeupipe/converter/filters/classify_files.py +61 -0
- codeupipe/converter/filters/generate_export.py +187 -0
- codeupipe/converter/filters/generate_import.py +229 -0
- codeupipe/converter/filters/parse_config.py +26 -0
- codeupipe/converter/filters/scan_project.py +52 -0
- codeupipe/converter/pipelines/__init__.py +8 -0
- codeupipe/converter/pipelines/export_pipeline.py +40 -0
- codeupipe/converter/pipelines/import_pipeline.py +40 -0
- codeupipe/converter/taps/__init__.py +7 -0
- codeupipe/converter/taps/conversion_log.py +46 -0
- codeupipe/core/__init__.py +20 -0
- codeupipe/core/filter.py +27 -0
- codeupipe/core/hook.py +34 -0
- codeupipe/core/payload.py +94 -0
- codeupipe/core/pipeline.py +231 -0
- codeupipe/core/state.py +78 -0
- codeupipe/core/stream_filter.py +33 -0
- codeupipe/core/tap.py +27 -0
- codeupipe/core/valve.py +52 -0
- codeupipe/linter/__init__.py +57 -0
- codeupipe/linter/assemble_doc_report.py +97 -0
- codeupipe/linter/assemble_report.py +140 -0
- codeupipe/linter/check_bundle.py +47 -0
- codeupipe/linter/check_index.py +88 -0
- codeupipe/linter/check_naming.py +47 -0
- codeupipe/linter/check_protocols.py +73 -0
- codeupipe/linter/check_structure.py +41 -0
- codeupipe/linter/check_symbols.py +116 -0
- codeupipe/linter/check_tests.py +48 -0
- codeupipe/linter/coverage_pipeline.py +39 -0
- codeupipe/linter/detect_drift.py +44 -0
- codeupipe/linter/detect_orphans.py +106 -0
- codeupipe/linter/doc_check_pipeline.py +28 -0
- codeupipe/linter/git_history.py +130 -0
- codeupipe/linter/lint_pipeline.py +51 -0
- codeupipe/linter/map_coverage.py +84 -0
- codeupipe/linter/report_gaps.py +68 -0
- codeupipe/linter/report_pipeline.py +49 -0
- codeupipe/linter/resolve_refs.py +48 -0
- codeupipe/linter/scan_components.py +95 -0
- codeupipe/linter/scan_directory.py +123 -0
- codeupipe/linter/scan_docs.py +62 -0
- codeupipe/linter/scan_tests.py +104 -0
- codeupipe/py.typed +1 -0
- codeupipe/testing.py +344 -0
- codeupipe/utils/__init__.py +10 -0
- codeupipe/utils/error_handling.py +68 -0
- codeupipe-0.1.0.dist-info/METADATA +216 -0
- codeupipe-0.1.0.dist-info/RECORD +60 -0
- codeupipe-0.1.0.dist-info/WHEEL +5 -0
- codeupipe-0.1.0.dist-info/entry_points.txt +2 -0
- codeupipe-0.1.0.dist-info/licenses/LICENSE +190 -0
- codeupipe-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AssembleReport: Merge all analysis data into a unified health report.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from codeupipe import Payload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
STALE_THRESHOLD_DAYS = 90
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _compute_health_score(coverage_pct: float, orphan_count: int,
|
|
14
|
+
stale_count: int, total_components: int) -> str:
|
|
15
|
+
"""Compute a letter grade health score.
|
|
16
|
+
|
|
17
|
+
Scoring:
|
|
18
|
+
Start at 100 points.
|
|
19
|
+
- Deduct (100 - coverage_pct) * 0.6 (coverage is 60% of score)
|
|
20
|
+
- Deduct orphan_ratio * 20 (orphans are 20% of score)
|
|
21
|
+
- Deduct stale_ratio * 20 (staleness is 20% of score)
|
|
22
|
+
|
|
23
|
+
Grades: A >= 90, B >= 80, C >= 70, D >= 60, F < 60
|
|
24
|
+
"""
|
|
25
|
+
if total_components == 0:
|
|
26
|
+
return "A"
|
|
27
|
+
|
|
28
|
+
score = 100.0
|
|
29
|
+
score -= (100.0 - coverage_pct) * 0.6
|
|
30
|
+
orphan_ratio = orphan_count / total_components if total_components else 0
|
|
31
|
+
score -= orphan_ratio * 20
|
|
32
|
+
stale_ratio = stale_count / total_components if total_components else 0
|
|
33
|
+
score -= stale_ratio * 20
|
|
34
|
+
|
|
35
|
+
score = max(0.0, min(100.0, score))
|
|
36
|
+
|
|
37
|
+
if score >= 90:
|
|
38
|
+
return "A"
|
|
39
|
+
elif score >= 80:
|
|
40
|
+
return "B"
|
|
41
|
+
elif score >= 70:
|
|
42
|
+
return "C"
|
|
43
|
+
elif score >= 60:
|
|
44
|
+
return "D"
|
|
45
|
+
else:
|
|
46
|
+
return "F"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AssembleReport:
|
|
50
|
+
"""
|
|
51
|
+
Filter (sync): Merge coverage, orphan, and git data into a unified report.
|
|
52
|
+
|
|
53
|
+
Input keys:
|
|
54
|
+
- coverage (list[dict]): from MapCoverage
|
|
55
|
+
- summary (dict): from ReportGaps
|
|
56
|
+
- gaps (list[dict]): from ReportGaps
|
|
57
|
+
- orphaned_components (list[dict]): from DetectOrphans
|
|
58
|
+
- orphaned_tests (list[dict]): from DetectOrphans
|
|
59
|
+
- import_map (dict): from DetectOrphans
|
|
60
|
+
- git_info (dict): from GitHistory
|
|
61
|
+
- directory (str): component directory
|
|
62
|
+
|
|
63
|
+
Output keys (added):
|
|
64
|
+
- report (dict): unified report structure
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def call(self, payload: Payload) -> Payload:
|
|
68
|
+
coverage = payload.get("coverage", [])
|
|
69
|
+
summary = payload.get("summary", {})
|
|
70
|
+
gaps = payload.get("gaps", [])
|
|
71
|
+
orphaned_components = payload.get("orphaned_components", [])
|
|
72
|
+
orphaned_tests = payload.get("orphaned_tests", [])
|
|
73
|
+
import_map = payload.get("import_map", {})
|
|
74
|
+
git_info = payload.get("git_info", {})
|
|
75
|
+
directory = payload.get("directory", "")
|
|
76
|
+
|
|
77
|
+
orphaned_names = {o["name"] for o in orphaned_components}
|
|
78
|
+
|
|
79
|
+
# Build enriched component list
|
|
80
|
+
components = []
|
|
81
|
+
for cov in coverage:
|
|
82
|
+
file_path = cov["file"]
|
|
83
|
+
git = git_info.get(file_path, {
|
|
84
|
+
"last_modified": None,
|
|
85
|
+
"last_author": None,
|
|
86
|
+
"commit_count": 0,
|
|
87
|
+
"days_since_change": None,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
components.append({
|
|
91
|
+
"name": cov["name"],
|
|
92
|
+
"kind": cov["kind"],
|
|
93
|
+
"file": file_path,
|
|
94
|
+
"methods": cov["methods"],
|
|
95
|
+
"coverage_pct": cov["coverage_pct"],
|
|
96
|
+
"test_count": cov["test_count"],
|
|
97
|
+
"untested_methods": cov["untested_methods"],
|
|
98
|
+
"orphaned": cov["name"] in orphaned_names,
|
|
99
|
+
"imported_by": import_map.get(cov["name"], []),
|
|
100
|
+
"git": git,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
# Detect stale files
|
|
104
|
+
stale_files = []
|
|
105
|
+
for file_path, info in git_info.items():
|
|
106
|
+
days = info.get("days_since_change")
|
|
107
|
+
if days is not None and days > STALE_THRESHOLD_DAYS:
|
|
108
|
+
stale_files.append({
|
|
109
|
+
"file": file_path,
|
|
110
|
+
"days_since_change": days,
|
|
111
|
+
"last_modified": info.get("last_modified"),
|
|
112
|
+
"last_author": info.get("last_author"),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
# Compute health score
|
|
116
|
+
total = summary.get("total_components", 0)
|
|
117
|
+
health_score = _compute_health_score(
|
|
118
|
+
coverage_pct=summary.get("overall_pct", 100.0),
|
|
119
|
+
orphan_count=len(orphaned_components),
|
|
120
|
+
stale_count=len(stale_files),
|
|
121
|
+
total_components=total,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
report = {
|
|
125
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
126
|
+
"directory": directory,
|
|
127
|
+
"components": components,
|
|
128
|
+
"orphaned_components": orphaned_components,
|
|
129
|
+
"orphaned_tests": orphaned_tests,
|
|
130
|
+
"stale_files": stale_files,
|
|
131
|
+
"summary": {
|
|
132
|
+
**summary,
|
|
133
|
+
"orphaned_count": len(orphaned_components),
|
|
134
|
+
"orphaned_test_count": len(orphaned_tests),
|
|
135
|
+
"stale_count": len(stale_files),
|
|
136
|
+
"health_score": health_score,
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return payload.insert("report", report)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckBundle: CUP008 — stale __init__.py bundle detection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codeupipe import Payload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CheckBundle:
|
|
11
|
+
"""
|
|
12
|
+
Filter (sync): Detect modules missing from a cup-bundle generated __init__.py.
|
|
13
|
+
|
|
14
|
+
Input keys:
|
|
15
|
+
- directory (str): path to the scanned directory
|
|
16
|
+
- files (list[dict]): file analyses from ScanDirectory
|
|
17
|
+
- issues (list): accumulated lint issues
|
|
18
|
+
|
|
19
|
+
Output keys (modified):
|
|
20
|
+
- issues (list): with CUP008 violations appended
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def call(self, payload: Payload) -> Payload:
|
|
24
|
+
directory = payload.get("directory")
|
|
25
|
+
files = payload.get("files", [])
|
|
26
|
+
issues = list(payload.get("issues", []))
|
|
27
|
+
|
|
28
|
+
dir_path = Path(directory)
|
|
29
|
+
init_file = dir_path / "__init__.py"
|
|
30
|
+
|
|
31
|
+
if not init_file.exists():
|
|
32
|
+
return payload.insert("issues", issues)
|
|
33
|
+
|
|
34
|
+
init_content = init_file.read_text()
|
|
35
|
+
if "Auto-generated by: cup bundle" not in init_content:
|
|
36
|
+
return payload.insert("issues", issues)
|
|
37
|
+
|
|
38
|
+
for info in files:
|
|
39
|
+
module = info["stem"]
|
|
40
|
+
if f"from .{module} import" not in init_content:
|
|
41
|
+
issues.append((
|
|
42
|
+
"CUP008", "warning", str(init_file),
|
|
43
|
+
f"Bundle is stale: module '{module}' not exported. "
|
|
44
|
+
f"Run: cup bundle {directory}"
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
return payload.insert("issues", issues)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckIndex: Verify INDEX.md covers the project's key source files.
|
|
3
|
+
|
|
4
|
+
Scans the project for Python source files under the package directory
|
|
5
|
+
and checks that each is referenced (directly or via its parent __init__)
|
|
6
|
+
in cup:ref markers within INDEX.md. Reports unmapped files as issues.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from codeupipe import Payload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Files/patterns that don't need explicit index coverage
|
|
15
|
+
_IGNORE_PATTERNS = {
|
|
16
|
+
"__pycache__",
|
|
17
|
+
".pyc",
|
|
18
|
+
"py.typed",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CheckIndex:
|
|
23
|
+
"""
|
|
24
|
+
Filter (sync): Verify INDEX.md maps the project structure.
|
|
25
|
+
|
|
26
|
+
Input keys:
|
|
27
|
+
- directory (str): root directory to scan
|
|
28
|
+
- doc_refs (list[dict]): from ScanDocs (all cup:ref markers)
|
|
29
|
+
|
|
30
|
+
Output keys (added):
|
|
31
|
+
- index_issues (list[dict]): unmapped files, each with:
|
|
32
|
+
file (str), message (str)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def call(self, payload: Payload) -> Payload:
|
|
36
|
+
directory = Path(payload.get("directory", "."))
|
|
37
|
+
doc_refs = payload.get("doc_refs", [])
|
|
38
|
+
|
|
39
|
+
# Collect all file paths referenced in cup:ref markers across all docs
|
|
40
|
+
referenced = set()
|
|
41
|
+
for ref in doc_refs:
|
|
42
|
+
referenced.add(ref["file"])
|
|
43
|
+
|
|
44
|
+
# Discover key source files: __init__.py is the structural anchor,
|
|
45
|
+
# plus any .py files that define public exports (non-__init__)
|
|
46
|
+
package_dir = directory / "codeupipe"
|
|
47
|
+
if not package_dir.is_dir():
|
|
48
|
+
return payload.insert("index_issues", [])
|
|
49
|
+
|
|
50
|
+
source_files = set()
|
|
51
|
+
for py_file in sorted(package_dir.rglob("*.py")):
|
|
52
|
+
rel = str(py_file.relative_to(directory))
|
|
53
|
+
|
|
54
|
+
# Skip __pycache__ and other noise
|
|
55
|
+
if any(p in rel for p in _IGNORE_PATTERNS):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Skip individual filter files inside linter/ and converter/
|
|
59
|
+
# (the __init__.py for those packages is sufficient coverage)
|
|
60
|
+
parts = py_file.parts
|
|
61
|
+
in_subpackage = False
|
|
62
|
+
for sub in ("linter", "converter"):
|
|
63
|
+
if sub in parts:
|
|
64
|
+
sub_idx = parts.index(sub)
|
|
65
|
+
# If it's deeper than the direct children, always skip
|
|
66
|
+
if len(parts) > sub_idx + 2:
|
|
67
|
+
in_subpackage = True
|
|
68
|
+
# Direct children that aren't __init__.py or *_pipeline.py
|
|
69
|
+
elif (len(parts) == sub_idx + 2
|
|
70
|
+
and py_file.name != "__init__.py"
|
|
71
|
+
and not py_file.name.endswith("_pipeline.py")):
|
|
72
|
+
in_subpackage = True
|
|
73
|
+
|
|
74
|
+
if in_subpackage:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
source_files.add(rel)
|
|
78
|
+
|
|
79
|
+
# Check which source files are not referenced
|
|
80
|
+
issues = []
|
|
81
|
+
for src in sorted(source_files):
|
|
82
|
+
if src not in referenced:
|
|
83
|
+
issues.append({
|
|
84
|
+
"file": src,
|
|
85
|
+
"message": f"Source file '{src}' not referenced in any cup:ref marker",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return payload.insert("index_issues", issues)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckNaming: CUP007 — snake_case file name enforcement.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from codeupipe import Payload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_SNAKE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_snake(name: str) -> str:
|
|
14
|
+
"""Convert PascalCase or mixed to snake_case."""
|
|
15
|
+
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
16
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
17
|
+
return re.sub(r"[\s\-]+", "_", s).lower()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CheckNaming:
|
|
21
|
+
"""
|
|
22
|
+
Filter (sync): Check that file names follow snake_case convention.
|
|
23
|
+
|
|
24
|
+
Input keys:
|
|
25
|
+
- files (list[dict]): file analyses from ScanDirectory
|
|
26
|
+
- issues (list): accumulated lint issues
|
|
27
|
+
|
|
28
|
+
Output keys (modified):
|
|
29
|
+
- issues (list): with CUP007 violations appended
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def call(self, payload: Payload) -> Payload:
|
|
33
|
+
files = payload.get("files", [])
|
|
34
|
+
issues = list(payload.get("issues", []))
|
|
35
|
+
|
|
36
|
+
for info in files:
|
|
37
|
+
stem = info["stem"]
|
|
38
|
+
rel = info["path"]
|
|
39
|
+
if not _SNAKE_RE.match(stem):
|
|
40
|
+
expected = _to_snake(stem)
|
|
41
|
+
issues.append((
|
|
42
|
+
"CUP007", "warning", rel,
|
|
43
|
+
f"File name '{stem}.py' is not snake_case. "
|
|
44
|
+
f"Expected: '{expected}.py'"
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
return payload.insert("issues", issues)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckProtocols: CUP003–CUP006 — protocol compliance enforcement.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from codeupipe import Payload
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_HOOK_METHODS = {"before", "after", "on_error"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CheckProtocols:
|
|
12
|
+
"""
|
|
13
|
+
Filter (sync): Verify each component has its required protocol methods.
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
CUP003: Filter missing call()
|
|
17
|
+
CUP004: Tap missing observe()
|
|
18
|
+
CUP005: StreamFilter missing stream()
|
|
19
|
+
CUP006: Hook missing lifecycle methods (before, after, on_error)
|
|
20
|
+
|
|
21
|
+
Input keys:
|
|
22
|
+
- files (list[dict]): file analyses from ScanDirectory
|
|
23
|
+
- issues (list): accumulated lint issues
|
|
24
|
+
|
|
25
|
+
Output keys (modified):
|
|
26
|
+
- issues (list): with CUP003–CUP006 violations appended
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def call(self, payload: Payload) -> Payload:
|
|
30
|
+
files = payload.get("files", [])
|
|
31
|
+
issues = list(payload.get("issues", []))
|
|
32
|
+
|
|
33
|
+
for info in files:
|
|
34
|
+
if info["error"]:
|
|
35
|
+
issues.append((
|
|
36
|
+
"CUP000", "error", info["path"],
|
|
37
|
+
f"Syntax error: {info['error']}"
|
|
38
|
+
))
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
for class_name, ctype, methods in info["classes"]:
|
|
42
|
+
if ctype is None:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if ctype == "filter" and "call" not in methods:
|
|
46
|
+
issues.append((
|
|
47
|
+
"CUP003", "error", info["path"],
|
|
48
|
+
f"Filter '{class_name}' is missing call() method."
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
if ctype == "tap" and "observe" not in methods:
|
|
52
|
+
issues.append((
|
|
53
|
+
"CUP004", "error", info["path"],
|
|
54
|
+
f"Tap '{class_name}' is missing observe() method."
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
if ctype == "stream-filter" and "stream" not in methods:
|
|
58
|
+
issues.append((
|
|
59
|
+
"CUP005", "error", info["path"],
|
|
60
|
+
f"StreamFilter '{class_name}' is missing stream() method."
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
if ctype == "hook":
|
|
64
|
+
missing = _HOOK_METHODS - methods
|
|
65
|
+
if missing:
|
|
66
|
+
issues.append((
|
|
67
|
+
"CUP006", "error", info["path"],
|
|
68
|
+
f"Hook '{class_name}' is missing: "
|
|
69
|
+
f"{', '.join(sorted(missing))}. "
|
|
70
|
+
f"Hooks need before(), after(), and on_error()."
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
return payload.insert("issues", issues)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckStructure: CUP001 — one component per file enforcement.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from codeupipe import Payload
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CheckStructure:
|
|
9
|
+
"""
|
|
10
|
+
Filter (sync): Flag files containing multiple CUP components.
|
|
11
|
+
|
|
12
|
+
Input keys:
|
|
13
|
+
- files (list[dict]): file analyses from ScanDirectory
|
|
14
|
+
- issues (list): accumulated lint issues
|
|
15
|
+
|
|
16
|
+
Output keys (modified):
|
|
17
|
+
- issues (list): with CUP001 violations appended
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def call(self, payload: Payload) -> Payload:
|
|
21
|
+
files = payload.get("files", [])
|
|
22
|
+
issues = list(payload.get("issues", []))
|
|
23
|
+
|
|
24
|
+
for info in files:
|
|
25
|
+
if info["error"]:
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
component_classes = [
|
|
29
|
+
(name, ctype)
|
|
30
|
+
for name, ctype, _ in info["classes"]
|
|
31
|
+
if ctype is not None
|
|
32
|
+
]
|
|
33
|
+
if len(component_classes) > 1:
|
|
34
|
+
names = [f"{n} ({t})" for n, t in component_classes]
|
|
35
|
+
issues.append((
|
|
36
|
+
"CUP001", "error", info["path"],
|
|
37
|
+
f"Multiple components in one file: {', '.join(names)}. "
|
|
38
|
+
f"Use one component per file."
|
|
39
|
+
))
|
|
40
|
+
|
|
41
|
+
return payload.insert("issues", issues)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckSymbols: AST-verify that referenced symbols exist in source files.
|
|
3
|
+
|
|
4
|
+
Parses each referenced source file and checks that the symbols mentioned
|
|
5
|
+
in cup:ref markers actually exist as top-level classes, functions, or
|
|
6
|
+
class attributes/methods.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional, Set
|
|
12
|
+
|
|
13
|
+
from codeupipe import Payload
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _collect_top_level_names(tree: ast.Module) -> Set[str]:
|
|
17
|
+
"""Collect top-level class and function names from an AST."""
|
|
18
|
+
names = set()
|
|
19
|
+
for node in ast.iter_child_nodes(tree):
|
|
20
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
21
|
+
names.add(node.name)
|
|
22
|
+
elif isinstance(node, ast.Assign):
|
|
23
|
+
for target in node.targets:
|
|
24
|
+
if isinstance(target, ast.Name):
|
|
25
|
+
names.add(target.id)
|
|
26
|
+
return names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _collect_class_members(tree: ast.Module, class_name: str) -> Set[str]:
|
|
30
|
+
"""Collect method and assignment attribute names within a class."""
|
|
31
|
+
members = set()
|
|
32
|
+
for node in ast.iter_child_nodes(tree):
|
|
33
|
+
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
|
34
|
+
for child in ast.iter_child_nodes(node):
|
|
35
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
36
|
+
members.add(child.name)
|
|
37
|
+
elif isinstance(child, ast.Assign):
|
|
38
|
+
for target in child.targets:
|
|
39
|
+
if isinstance(target, ast.Name):
|
|
40
|
+
members.add(target.id)
|
|
41
|
+
# Also check __init__ for self.attr assignments
|
|
42
|
+
for child in ast.walk(node):
|
|
43
|
+
if isinstance(child, ast.Assign):
|
|
44
|
+
for target in child.targets:
|
|
45
|
+
if (isinstance(target, ast.Attribute)
|
|
46
|
+
and isinstance(target.value, ast.Name)
|
|
47
|
+
and target.value.id == "self"):
|
|
48
|
+
members.add(target.attr)
|
|
49
|
+
return members
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CheckSymbols:
|
|
53
|
+
"""
|
|
54
|
+
Filter (sync): Verify referenced symbols exist in source files.
|
|
55
|
+
|
|
56
|
+
Input keys:
|
|
57
|
+
- directory (str): root directory
|
|
58
|
+
- resolved_refs (list[dict]): from ResolveRefs
|
|
59
|
+
|
|
60
|
+
Output keys (added):
|
|
61
|
+
- symbol_issues (list[dict]): symbols not found, each with:
|
|
62
|
+
symbol, file, doc_path, line
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def call(self, payload: Payload) -> Payload:
|
|
66
|
+
resolved = payload.get("resolved_refs", [])
|
|
67
|
+
issues: List[dict] = []
|
|
68
|
+
|
|
69
|
+
for ref in resolved:
|
|
70
|
+
if not ref.get("exists", False):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
symbols = ref.get("symbols", [])
|
|
74
|
+
if not symbols:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
abs_path = ref["abs_path"]
|
|
78
|
+
try:
|
|
79
|
+
source = Path(abs_path).read_text(encoding="utf-8", errors="replace")
|
|
80
|
+
tree = ast.parse(source)
|
|
81
|
+
except (SyntaxError, OSError):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
top_names = _collect_top_level_names(tree)
|
|
85
|
+
|
|
86
|
+
for symbol in symbols:
|
|
87
|
+
if "." in symbol:
|
|
88
|
+
# Dotted: Class.member
|
|
89
|
+
parts = symbol.split(".", 1)
|
|
90
|
+
class_name, member_name = parts[0], parts[1]
|
|
91
|
+
if class_name not in top_names:
|
|
92
|
+
issues.append({
|
|
93
|
+
"symbol": symbol,
|
|
94
|
+
"file": ref["file"],
|
|
95
|
+
"doc_path": ref["doc_path"],
|
|
96
|
+
"line": ref["line"],
|
|
97
|
+
})
|
|
98
|
+
else:
|
|
99
|
+
members = _collect_class_members(tree, class_name)
|
|
100
|
+
if member_name not in members:
|
|
101
|
+
issues.append({
|
|
102
|
+
"symbol": symbol,
|
|
103
|
+
"file": ref["file"],
|
|
104
|
+
"doc_path": ref["doc_path"],
|
|
105
|
+
"line": ref["line"],
|
|
106
|
+
})
|
|
107
|
+
else:
|
|
108
|
+
if symbol not in top_names:
|
|
109
|
+
issues.append({
|
|
110
|
+
"symbol": symbol,
|
|
111
|
+
"file": ref["file"],
|
|
112
|
+
"doc_path": ref["doc_path"],
|
|
113
|
+
"line": ref["line"],
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return payload.insert("symbol_issues", issues)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CheckTests: CUP002 — missing test file detection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codeupipe import Payload
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CheckTests:
|
|
11
|
+
"""
|
|
12
|
+
Filter (sync): Flag components that have no corresponding test file.
|
|
13
|
+
|
|
14
|
+
Input keys:
|
|
15
|
+
- files (list[dict]): file analyses from ScanDirectory
|
|
16
|
+
- issues (list): accumulated lint issues
|
|
17
|
+
- tests_dir (str, optional): path to tests directory (default: "tests")
|
|
18
|
+
|
|
19
|
+
Output keys (modified):
|
|
20
|
+
- issues (list): with CUP002 violations appended
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def call(self, payload: Payload) -> Payload:
|
|
24
|
+
files = payload.get("files", [])
|
|
25
|
+
issues = list(payload.get("issues", []))
|
|
26
|
+
tests_dir = payload.get("tests_dir", "tests")
|
|
27
|
+
|
|
28
|
+
for info in files:
|
|
29
|
+
if info["error"]:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
stem = info["stem"]
|
|
33
|
+
test_file = Path(tests_dir) / f"test_{stem}.py"
|
|
34
|
+
|
|
35
|
+
has_components = any(
|
|
36
|
+
ctype is not None for _, ctype, _ in info["classes"]
|
|
37
|
+
)
|
|
38
|
+
has_builders = any(
|
|
39
|
+
f.startswith("build_") for f in info["functions"]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (has_components or has_builders) and not test_file.exists():
|
|
43
|
+
issues.append((
|
|
44
|
+
"CUP002", "warning", info["path"],
|
|
45
|
+
f"No test file found. Expected: {test_file}"
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
return payload.insert("issues", issues)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CoveragePipeline: AST-based component coverage mapping — built with CUP.
|
|
3
|
+
|
|
4
|
+
Scans a component directory and its tests to produce:
|
|
5
|
+
- Per-component method coverage map
|
|
6
|
+
- Untested method gaps
|
|
7
|
+
- Aggregate coverage summary
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from codeupipe import Pipeline, Payload
|
|
11
|
+
|
|
12
|
+
# TODO: update import paths to match your project layout
|
|
13
|
+
from .scan_components import ScanComponents
|
|
14
|
+
from .scan_tests import ScanTests
|
|
15
|
+
from .map_coverage import MapCoverage
|
|
16
|
+
from .report_gaps import ReportGaps
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_coverage_pipeline() -> Pipeline:
|
|
20
|
+
"""
|
|
21
|
+
Construct the CoveragePipeline pipeline.
|
|
22
|
+
|
|
23
|
+
Steps:
|
|
24
|
+
1. ScanComponents (Filter)
|
|
25
|
+
2. ScanTests (Filter)
|
|
26
|
+
3. MapCoverage (Filter)
|
|
27
|
+
4. ReportGaps (Filter)
|
|
28
|
+
|
|
29
|
+
Use pipeline.run(payload) for single-payload execution.
|
|
30
|
+
Use pipeline.stream(source) for streaming execution.
|
|
31
|
+
"""
|
|
32
|
+
pipeline = Pipeline()
|
|
33
|
+
|
|
34
|
+
pipeline.add_filter(ScanComponents(), "scan_components")
|
|
35
|
+
pipeline.add_filter(ScanTests(), "scan_tests")
|
|
36
|
+
pipeline.add_filter(MapCoverage(), "map_coverage")
|
|
37
|
+
pipeline.add_filter(ReportGaps(), "report_gaps")
|
|
38
|
+
|
|
39
|
+
return pipeline
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DetectDrift: Compare stored hashes with current file content hashes.
|
|
3
|
+
|
|
4
|
+
Flags doc references where the stored hash no longer matches the current
|
|
5
|
+
file contents, indicating the source has changed since the doc was written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from codeupipe import Payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DetectDrift:
|
|
12
|
+
"""
|
|
13
|
+
Filter (sync): Detect hash drift between docs and source files.
|
|
14
|
+
|
|
15
|
+
Input keys:
|
|
16
|
+
- resolved_refs (list[dict]): from ResolveRefs
|
|
17
|
+
|
|
18
|
+
Output keys (added):
|
|
19
|
+
- drifted_refs (list[dict]): refs with hash mismatches, each with:
|
|
20
|
+
file, stored_hash, current_hash, doc_path, line
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def call(self, payload: Payload) -> Payload:
|
|
24
|
+
resolved = payload.get("resolved_refs", [])
|
|
25
|
+
drifted = []
|
|
26
|
+
|
|
27
|
+
for ref in resolved:
|
|
28
|
+
stored_hash = ref.get("hash")
|
|
29
|
+
if stored_hash is None:
|
|
30
|
+
# No stored hash — symbol-only mode, skip drift check
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
current_hash = ref.get("current_hash")
|
|
34
|
+
|
|
35
|
+
if current_hash != stored_hash:
|
|
36
|
+
drifted.append({
|
|
37
|
+
"file": ref["file"],
|
|
38
|
+
"stored_hash": stored_hash,
|
|
39
|
+
"current_hash": current_hash,
|
|
40
|
+
"doc_path": ref["doc_path"],
|
|
41
|
+
"line": ref["line"],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return payload.insert("drifted_refs", drifted)
|