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,231 @@
1
+ """
2
+ Pipeline: The Orchestrator
3
+
4
+ The Pipeline orchestrates filter execution with hooks, taps, and state tracking.
5
+ Filters run in sequence; Valves provide conditional flow control;
6
+ Taps provide observation points; Hooks provide lifecycle integration.
7
+ """
8
+
9
+ import inspect
10
+ from typing import Any, AsyncIterator, Dict, List, Optional, Set, Tuple, TypeVar, Generic, Union
11
+ from .payload import Payload
12
+ from .filter import Filter
13
+ from .stream_filter import StreamFilter
14
+ from .tap import Tap
15
+ from .hook import Hook
16
+ from .state import State
17
+
18
+ __all__ = ["Pipeline"]
19
+
20
+ TInput = TypeVar('TInput')
21
+ TOutput = TypeVar('TOutput')
22
+
23
+
24
+ class Pipeline(Generic[TInput, TOutput]):
25
+ """
26
+ Orchestrator — runs filters in sequence with hooks, taps, and state tracking.
27
+
28
+ Build a pipeline by adding filters (.add_filter), taps (.add_tap),
29
+ and hooks (.use_hook). Run it with .run(payload).
30
+ After execution, inspect .state for execution metadata.
31
+ """
32
+
33
+ def __init__(self):
34
+ self._steps: List[Tuple[str, Union[Filter, Tap], str]] = [] # (name, step, type)
35
+ self._hooks: List[Hook] = []
36
+ self._state: State = State()
37
+
38
+ @property
39
+ def state(self) -> State:
40
+ """Access pipeline execution state after run()."""
41
+ return self._state
42
+
43
+ def add_filter(self, filter: Filter[TInput, TOutput], name: Optional[str] = None) -> None:
44
+ """Add a filter to the pipeline."""
45
+ filter_name = name or filter.__class__.__name__
46
+ self._steps.append((filter_name, filter, "filter"))
47
+
48
+ def add_tap(self, tap: Tap, name: Optional[str] = None) -> None:
49
+ """Add a tap (observation point) to the pipeline."""
50
+ tap_name = name or tap.__class__.__name__
51
+ self._steps.append((tap_name, tap, "tap"))
52
+
53
+ def use_hook(self, hook: Hook) -> None:
54
+ """Attach a lifecycle hook."""
55
+ self._hooks.append(hook)
56
+
57
+ @staticmethod
58
+ async def _invoke(fn, *args):
59
+ """Call fn(*args), awaiting the result only if it is a coroutine."""
60
+ result = fn(*args)
61
+ if inspect.isawaitable(result):
62
+ result = await result
63
+ return result
64
+
65
+ async def run(self, initial_payload: Payload[TInput]) -> Payload[TOutput]:
66
+ """Execute the pipeline — flow payload through all filters and taps."""
67
+ # Check for StreamFilters — .run() is 1→1, StreamFilters are 0..N
68
+ for name, step, step_type in self._steps:
69
+ if step_type == "filter" and self._is_stream_filter(step):
70
+ raise ValueError(
71
+ f"Pipeline contains StreamFilter '{name}'. "
72
+ f"Use pipeline.stream(source) with an async generator instead. "
73
+ f"Example: async for result in pipeline.stream(async_generator_of_payloads): ..."
74
+ )
75
+
76
+ self._state = State()
77
+ payload = initial_payload
78
+
79
+ # Hook: pipeline start
80
+ for hook in self._hooks:
81
+ await self._invoke(hook.before, None, payload)
82
+
83
+ try:
84
+ for name, step, step_type in self._steps:
85
+
86
+ if step_type == "tap":
87
+ await self._invoke(step.observe, payload) # type: ignore
88
+ self._state.mark_executed(name)
89
+ continue
90
+
91
+ # It's a filter (or valve — valves conform to Filter protocol)
92
+ for hook in self._hooks:
93
+ await self._invoke(hook.before, step, payload)
94
+
95
+ payload = await self._invoke(step.call, payload) # type: ignore
96
+
97
+ # Track valve skips via Valve's own tracking
98
+ if hasattr(step, '_last_skipped') and step._last_skipped:
99
+ self._state.mark_skipped(name)
100
+ else:
101
+ self._state.mark_executed(name)
102
+
103
+ for hook in self._hooks:
104
+ await self._invoke(hook.after, step, payload)
105
+
106
+ except Exception as e:
107
+ for hook in self._hooks:
108
+ await self._invoke(hook.on_error, None, e, payload)
109
+ raise
110
+
111
+ # Hook: pipeline end
112
+ for hook in self._hooks:
113
+ await self._invoke(hook.after, None, payload)
114
+
115
+ return payload # type: ignore
116
+
117
+ # ------------------------------------------------------------------
118
+ # Streaming
119
+ # ------------------------------------------------------------------
120
+
121
+ @staticmethod
122
+ def _is_stream_filter(step) -> bool:
123
+ """Check if a step implements the StreamFilter protocol (has .stream())."""
124
+ return hasattr(step, 'stream') and callable(getattr(step, 'stream'))
125
+
126
+ async def stream(
127
+ self,
128
+ source: AsyncIterator[Payload[TInput]],
129
+ ) -> AsyncIterator[Payload[TOutput]]:
130
+ """
131
+ Stream payloads through the pipeline, one chunk at a time.
132
+
133
+ source: An async iterable of Payload chunks.
134
+ Yields: Transformed Payload chunks as they flow out.
135
+
136
+ - Regular Filters are auto-adapted: 1 chunk in → 1 chunk out.
137
+ - StreamFilters can yield 0, 1, or N chunks per input.
138
+ - Valves gate per-chunk (predicate evaluated on each chunk).
139
+ - Taps observe each chunk.
140
+ - Hooks fire once per filter at stream-start and stream-end.
141
+ - State tracks chunks_processed per step.
142
+ """
143
+ self._state = State()
144
+
145
+ # Hook: pipeline start (payload=None-ish, use empty payload as sentinel)
146
+ sentinel = Payload()
147
+ for hook in self._hooks:
148
+ await self._invoke(hook.before, None, sentinel)
149
+
150
+ try:
151
+ # Build the processing chain as nested async generators
152
+ async def _source_gen():
153
+ async for chunk in source:
154
+ yield chunk
155
+
156
+ current = _source_gen()
157
+
158
+ for name, step, step_type in self._steps:
159
+ current = self._wrap_step(current, name, step, step_type)
160
+
161
+ # Drain the chain, yielding results to the caller
162
+ async for result in current:
163
+ yield result
164
+
165
+ except Exception as e:
166
+ for hook in self._hooks:
167
+ await self._invoke(hook.on_error, None, e, sentinel)
168
+ raise
169
+
170
+ # Hook: pipeline end
171
+ for hook in self._hooks:
172
+ await self._invoke(hook.after, None, sentinel)
173
+
174
+ async def _wrap_step(
175
+ self,
176
+ upstream: AsyncIterator[Payload],
177
+ name: str,
178
+ step,
179
+ step_type: str,
180
+ ) -> AsyncIterator[Payload]:
181
+ """Wrap a single step around an upstream async iterator."""
182
+
183
+ # --- Tap: observe each chunk, pass through unchanged ---
184
+ if step_type == "tap":
185
+ if name not in self._state.executed:
186
+ self._state.mark_executed(name)
187
+ async for chunk in upstream:
188
+ await self._invoke(step.observe, chunk) # type: ignore
189
+ self._state.increment_chunks(name)
190
+ yield chunk
191
+ return
192
+
193
+ # --- Filter or Valve ---
194
+ # Fire hook.before once at the start of this step's stream
195
+ for hook in self._hooks:
196
+ await self._invoke(hook.before, step, Payload())
197
+
198
+ is_valve = hasattr(step, '_predicate')
199
+ is_stream = self._is_stream_filter(step)
200
+
201
+ if name not in self._state.executed and name not in self._state.skipped:
202
+ self._state.mark_executed(name)
203
+
204
+ async for chunk in upstream:
205
+ # Valve gating — per-chunk predicate
206
+ if is_valve:
207
+ if not step._predicate(chunk):
208
+ self._state.increment_chunks(name) # counted but skipped
209
+ yield chunk
210
+ continue
211
+
212
+ if is_stream:
213
+ # StreamFilter: yield 0..N chunks per input
214
+ stream_result = step.stream(chunk)
215
+ if inspect.isasyncgen(stream_result):
216
+ async for out in stream_result:
217
+ self._state.increment_chunks(name)
218
+ yield out
219
+ else:
220
+ for out in stream_result:
221
+ self._state.increment_chunks(name)
222
+ yield out
223
+ else:
224
+ # Regular Filter: 1 chunk in → 1 chunk out
225
+ result = await self._invoke(step.call, chunk) # type: ignore
226
+ self._state.increment_chunks(name)
227
+ yield result
228
+
229
+ # Fire hook.after once at the end of this step's stream
230
+ for hook in self._hooks:
231
+ await self._invoke(hook.after, step, Payload())
@@ -0,0 +1,78 @@
1
+ """
2
+ State: Pipeline Execution Metadata
3
+
4
+ State tracks what happened during pipeline execution — which filters ran,
5
+ which were skipped, timing data, and errors encountered.
6
+ Access it after pipeline.run() via pipeline.state.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ __all__ = ["State"]
12
+
13
+
14
+ class State:
15
+ """
16
+ Pipeline execution state — tracks filter execution, timing, and errors.
17
+
18
+ Provides visibility into what happened during a pipeline run:
19
+ - Which filters executed and in what order
20
+ - Which filters were skipped (by valves)
21
+ - Errors encountered during execution
22
+ - Arbitrary metadata for custom tracking
23
+ """
24
+
25
+ def __init__(self):
26
+ self.executed: List[str] = []
27
+ self.skipped: List[str] = []
28
+ self.errors: List[Tuple[str, Exception]] = []
29
+ self.metadata: Dict[str, Any] = {}
30
+ self.chunks_processed: Dict[str, int] = {}
31
+
32
+ def mark_executed(self, name: str) -> None:
33
+ """Record that a filter executed."""
34
+ self.executed.append(name)
35
+
36
+ def mark_skipped(self, name: str) -> None:
37
+ """Record that a filter was skipped."""
38
+ self.skipped.append(name)
39
+
40
+ def increment_chunks(self, name: str, count: int = 1) -> None:
41
+ """Increment the chunk counter for a streaming step."""
42
+ self.chunks_processed[name] = self.chunks_processed.get(name, 0) + count
43
+
44
+ def record_error(self, name: str, error: Exception) -> None:
45
+ """Record an error from a filter."""
46
+ self.errors.append((name, error))
47
+
48
+ def set(self, key: str, value: Any) -> None:
49
+ """Store arbitrary metadata."""
50
+ self.metadata[key] = value
51
+
52
+ def get(self, key: str, default: Any = None) -> Any:
53
+ """Retrieve metadata."""
54
+ return self.metadata.get(key, default)
55
+
56
+ @property
57
+ def has_errors(self) -> bool:
58
+ """Whether any errors were recorded."""
59
+ return len(self.errors) > 0
60
+
61
+ @property
62
+ def last_error(self) -> Optional[Exception]:
63
+ """The most recent error, or None."""
64
+ return self.errors[-1][1] if self.errors else None
65
+
66
+ def reset(self) -> None:
67
+ """Reset state for a fresh run."""
68
+ self.executed.clear()
69
+ self.skipped.clear()
70
+ self.errors.clear()
71
+ self.metadata.clear()
72
+ self.chunks_processed.clear()
73
+
74
+ def __repr__(self) -> str:
75
+ return (
76
+ f"State(executed={self.executed}, skipped={self.skipped}, "
77
+ f"errors={len(self.errors)}, chunks={self.chunks_processed})"
78
+ )
@@ -0,0 +1,33 @@
1
+ """
2
+ StreamFilter Protocol: Chunk-at-a-Time Processing
3
+
4
+ A StreamFilter processes one Payload chunk and yields zero or more output chunks.
5
+ This enables filtering (drop), mapping (1→1), and fan-out (1→N) at constant memory.
6
+
7
+ Regular Filters are auto-adapted for streaming (1 chunk in → 1 chunk out).
8
+ StreamFilters opt in to the richer yield-based interface.
9
+ """
10
+
11
+ from typing import AsyncIterator, Protocol, TypeVar
12
+ from .payload import Payload
13
+
14
+ __all__ = ["StreamFilter"]
15
+
16
+ TInput = TypeVar('TInput')
17
+ TOutput = TypeVar('TOutput')
18
+
19
+
20
+ class StreamFilter(Protocol[TInput, TOutput]):
21
+ """
22
+ Streaming processing unit — receives one chunk, yields zero or more output chunks.
23
+
24
+ Use for:
25
+ - Filtering: yield nothing to drop a chunk
26
+ - Mapping: yield one transformed chunk (same as a regular Filter)
27
+ - Fan-out: yield multiple chunks from one input
28
+ - Batching/windowing: accumulate internally, yield when ready
29
+ """
30
+
31
+ async def stream(self, chunk: Payload[TInput]) -> AsyncIterator[Payload[TOutput]]:
32
+ """Process a single chunk and yield output chunks."""
33
+ ...
codeupipe/core/tap.py ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ Tap: Observation Point
3
+
4
+ A Tap is a non-modifying observation point in the pipeline.
5
+ It receives the payload for inspection (logging, metrics, debugging)
6
+ but never modifies it. Think of it as a pressure gauge on a pipe.
7
+ """
8
+
9
+ from typing import Protocol, TypeVar
10
+ from .payload import Payload
11
+
12
+ __all__ = ["Tap"]
13
+
14
+ T = TypeVar('T')
15
+
16
+
17
+ class Tap(Protocol[T]):
18
+ """
19
+ Non-modifying observation point — inspect the payload without changing it.
20
+
21
+ Use Taps for logging, metrics, debugging, and auditing.
22
+ The pipeline calls observe() and discards the return value.
23
+ """
24
+
25
+ async def observe(self, payload: Payload[T]) -> None:
26
+ """Observe the payload. Must not modify it."""
27
+ ...
@@ -0,0 +1,52 @@
1
+ """
2
+ Valve: Conditional Flow Control
3
+
4
+ A Valve wraps a Filter with a predicate — the inner filter only executes
5
+ when the predicate evaluates to True. Otherwise the payload passes through unchanged.
6
+ Valves conform to the Filter protocol, so they compose seamlessly into Pipelines.
7
+ """
8
+
9
+ import inspect
10
+ from typing import Callable, Generic, TypeVar
11
+ from .payload import Payload
12
+ from .filter import Filter
13
+
14
+ __all__ = ["Valve"]
15
+
16
+ TInput = TypeVar('TInput')
17
+ TOutput = TypeVar('TOutput')
18
+
19
+
20
+ class Valve(Generic[TInput, TOutput]):
21
+ """
22
+ Conditional flow control — gates a Filter with a predicate.
23
+
24
+ If the predicate returns True, the inner filter processes the payload.
25
+ If False, the payload passes through unchanged.
26
+
27
+ Conforms to the Filter protocol so it can be used anywhere a Filter is expected.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ name: str,
33
+ inner: Filter[TInput, TOutput],
34
+ predicate: Callable[[Payload[TInput]], bool],
35
+ ):
36
+ self.name = name
37
+ self._inner = inner
38
+ self._predicate = predicate
39
+
40
+ async def call(self, payload: Payload[TInput]) -> Payload[TOutput]:
41
+ """Execute the inner filter only if the predicate passes."""
42
+ if self._predicate(payload):
43
+ self._last_skipped = False
44
+ result = self._inner.call(payload)
45
+ if inspect.isawaitable(result):
46
+ result = await result
47
+ return result
48
+ self._last_skipped = True
49
+ return payload # type: ignore — pass through unchanged
50
+
51
+ def __repr__(self) -> str:
52
+ return f"Valve({self.name!r})"
@@ -0,0 +1,57 @@
1
+ """
2
+ Public API for linter package.
3
+
4
+ Auto-generated by: cup bundle
5
+ """
6
+
7
+ from .assemble_doc_report import AssembleDocReport
8
+ from .assemble_report import AssembleReport
9
+ from .check_bundle import CheckBundle
10
+ from .check_index import CheckIndex
11
+ from .check_naming import CheckNaming
12
+ from .check_protocols import CheckProtocols
13
+ from .check_structure import CheckStructure
14
+ from .check_symbols import CheckSymbols
15
+ from .check_tests import CheckTests
16
+ from .coverage_pipeline import build_coverage_pipeline
17
+ from .detect_drift import DetectDrift
18
+ from .detect_orphans import DetectOrphans
19
+ from .doc_check_pipeline import build_doc_check_pipeline
20
+ from .git_history import GitHistory
21
+ from .lint_pipeline import build_lint_pipeline
22
+ from .map_coverage import MapCoverage
23
+ from .report_gaps import ReportGaps
24
+ from .report_pipeline import build_report_pipeline
25
+ from .resolve_refs import ResolveRefs
26
+ from .scan_components import ScanComponents
27
+ from .scan_directory import ScanDirectory, analyze_file, classify_class
28
+ from .scan_docs import ScanDocs
29
+ from .scan_tests import ScanTests
30
+
31
+ __all__ = [
32
+ "AssembleDocReport",
33
+ "AssembleReport",
34
+ "CheckBundle",
35
+ "CheckIndex",
36
+ "CheckNaming",
37
+ "CheckProtocols",
38
+ "CheckStructure",
39
+ "CheckSymbols",
40
+ "CheckTests",
41
+ "DetectDrift",
42
+ "DetectOrphans",
43
+ "GitHistory",
44
+ "MapCoverage",
45
+ "ReportGaps",
46
+ "ResolveRefs",
47
+ "ScanComponents",
48
+ "ScanDirectory",
49
+ "ScanDocs",
50
+ "ScanTests",
51
+ "analyze_file",
52
+ "build_coverage_pipeline",
53
+ "build_doc_check_pipeline",
54
+ "build_lint_pipeline",
55
+ "build_report_pipeline",
56
+ "classify_class",
57
+ ]
@@ -0,0 +1,97 @@
1
+ """
2
+ AssembleDocReport: Merge all doc-check findings into a structured report.
3
+
4
+ Combines results from ScanDocs, ResolveRefs, CheckSymbols, DetectDrift,
5
+ and CheckIndex into a single structured report with summary counts and
6
+ detail items.
7
+ """
8
+
9
+ from codeupipe import Payload
10
+
11
+
12
+ class AssembleDocReport:
13
+ """
14
+ Filter (sync): Assemble the final doc-check report.
15
+
16
+ Input keys:
17
+ - doc_refs (list[dict]): from ScanDocs
18
+ - resolved_refs (list[dict]): from ResolveRefs
19
+ - drifted_refs (list[dict]): from DetectDrift
20
+ - symbol_issues (list[dict]): from CheckSymbols
21
+ - index_issues (list[dict]): from CheckIndex (optional)
22
+
23
+ Output keys (added):
24
+ - doc_report (dict): structured report with:
25
+ total_refs, drifted, missing_symbols, missing_files,
26
+ unmapped_files, status ("ok"|"stale"), details (list)
27
+ """
28
+
29
+ def call(self, payload: Payload) -> Payload:
30
+ doc_refs = payload.get("doc_refs", [])
31
+ resolved = payload.get("resolved_refs", [])
32
+ drifted = payload.get("drifted_refs", [])
33
+ symbol_issues = payload.get("symbol_issues", [])
34
+ index_issues = payload.get("index_issues", [])
35
+
36
+ missing_files = sum(1 for r in resolved if not r.get("exists", True))
37
+
38
+ details = []
39
+
40
+ for d in drifted:
41
+ details.append({
42
+ "type": "drift",
43
+ "file": d["file"],
44
+ "doc_path": d["doc_path"],
45
+ "line": d["line"],
46
+ "message": (
47
+ f"Hash drift: stored={d['stored_hash']}, "
48
+ f"current={d['current_hash']}"
49
+ ),
50
+ })
51
+
52
+ for s in symbol_issues:
53
+ details.append({
54
+ "type": "missing_symbol",
55
+ "file": s["file"],
56
+ "doc_path": s["doc_path"],
57
+ "line": s["line"],
58
+ "message": f"Symbol '{s['symbol']}' not found in {s['file']}",
59
+ })
60
+
61
+ for r in resolved:
62
+ if not r.get("exists", True):
63
+ details.append({
64
+ "type": "missing_file",
65
+ "file": r["file"],
66
+ "doc_path": r["doc_path"],
67
+ "line": r["line"],
68
+ "message": f"Referenced file '{r['file']}' does not exist",
69
+ })
70
+
71
+ for idx in index_issues:
72
+ details.append({
73
+ "type": "unmapped_file",
74
+ "file": idx["file"],
75
+ "doc_path": "INDEX.md",
76
+ "line": 0,
77
+ "message": idx["message"],
78
+ })
79
+
80
+ has_issues = (
81
+ len(drifted) > 0
82
+ or len(symbol_issues) > 0
83
+ or missing_files > 0
84
+ or len(index_issues) > 0
85
+ )
86
+
87
+ report = {
88
+ "total_refs": len(doc_refs),
89
+ "drifted": len(drifted),
90
+ "missing_symbols": len(symbol_issues),
91
+ "missing_files": missing_files,
92
+ "unmapped_files": len(index_issues),
93
+ "status": "stale" if has_issues else "ok",
94
+ "details": details,
95
+ }
96
+
97
+ return payload.insert("doc_report", report)