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.
Files changed (60) hide show
  1. codeupipe/__init__.py +39 -0
  2. codeupipe/cli.py +1502 -0
  3. codeupipe/converter/__init__.py +9 -0
  4. codeupipe/converter/config.py +119 -0
  5. codeupipe/converter/filters/__init__.py +21 -0
  6. codeupipe/converter/filters/analyze.py +60 -0
  7. codeupipe/converter/filters/classify.py +52 -0
  8. codeupipe/converter/filters/classify_files.py +61 -0
  9. codeupipe/converter/filters/generate_export.py +187 -0
  10. codeupipe/converter/filters/generate_import.py +229 -0
  11. codeupipe/converter/filters/parse_config.py +26 -0
  12. codeupipe/converter/filters/scan_project.py +52 -0
  13. codeupipe/converter/pipelines/__init__.py +8 -0
  14. codeupipe/converter/pipelines/export_pipeline.py +40 -0
  15. codeupipe/converter/pipelines/import_pipeline.py +40 -0
  16. codeupipe/converter/taps/__init__.py +7 -0
  17. codeupipe/converter/taps/conversion_log.py +46 -0
  18. codeupipe/core/__init__.py +20 -0
  19. codeupipe/core/filter.py +27 -0
  20. codeupipe/core/hook.py +34 -0
  21. codeupipe/core/payload.py +94 -0
  22. codeupipe/core/pipeline.py +231 -0
  23. codeupipe/core/state.py +78 -0
  24. codeupipe/core/stream_filter.py +33 -0
  25. codeupipe/core/tap.py +27 -0
  26. codeupipe/core/valve.py +52 -0
  27. codeupipe/linter/__init__.py +57 -0
  28. codeupipe/linter/assemble_doc_report.py +97 -0
  29. codeupipe/linter/assemble_report.py +140 -0
  30. codeupipe/linter/check_bundle.py +47 -0
  31. codeupipe/linter/check_index.py +88 -0
  32. codeupipe/linter/check_naming.py +47 -0
  33. codeupipe/linter/check_protocols.py +73 -0
  34. codeupipe/linter/check_structure.py +41 -0
  35. codeupipe/linter/check_symbols.py +116 -0
  36. codeupipe/linter/check_tests.py +48 -0
  37. codeupipe/linter/coverage_pipeline.py +39 -0
  38. codeupipe/linter/detect_drift.py +44 -0
  39. codeupipe/linter/detect_orphans.py +106 -0
  40. codeupipe/linter/doc_check_pipeline.py +28 -0
  41. codeupipe/linter/git_history.py +130 -0
  42. codeupipe/linter/lint_pipeline.py +51 -0
  43. codeupipe/linter/map_coverage.py +84 -0
  44. codeupipe/linter/report_gaps.py +68 -0
  45. codeupipe/linter/report_pipeline.py +49 -0
  46. codeupipe/linter/resolve_refs.py +48 -0
  47. codeupipe/linter/scan_components.py +95 -0
  48. codeupipe/linter/scan_directory.py +123 -0
  49. codeupipe/linter/scan_docs.py +62 -0
  50. codeupipe/linter/scan_tests.py +104 -0
  51. codeupipe/py.typed +1 -0
  52. codeupipe/testing.py +344 -0
  53. codeupipe/utils/__init__.py +10 -0
  54. codeupipe/utils/error_handling.py +68 -0
  55. codeupipe-0.1.0.dist-info/METADATA +216 -0
  56. codeupipe-0.1.0.dist-info/RECORD +60 -0
  57. codeupipe-0.1.0.dist-info/WHEEL +5 -0
  58. codeupipe-0.1.0.dist-info/entry_points.txt +2 -0
  59. codeupipe-0.1.0.dist-info/licenses/LICENSE +190 -0
  60. 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)