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
codeupipe/cli.py
ADDED
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
"""
|
|
2
|
+
codeupipe CLI — scaffold components with zero boilerplate.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
cup new <component> <name> [path]
|
|
6
|
+
cup new pipeline <name> [path] --steps step1 step2:type ...
|
|
7
|
+
cup bundle <path>
|
|
8
|
+
cup lint <path>
|
|
9
|
+
cup coverage <path> [--tests-dir DIR]
|
|
10
|
+
cup report <path> [--tests-dir DIR] [--json] [--detail] [--verbose]
|
|
11
|
+
cup doc-check [path] [--json]
|
|
12
|
+
|
|
13
|
+
Components:
|
|
14
|
+
filter Filter (sync def call) — Pipeline handles awaiting
|
|
15
|
+
async-filter Filter (async def call) — native coroutine
|
|
16
|
+
stream-filter StreamFilter (async def stream → yields 0..N chunks)
|
|
17
|
+
tap Tap (sync def observe) — Pipeline handles awaiting
|
|
18
|
+
async-tap Tap (async def observe) — native coroutine
|
|
19
|
+
hook Lifecycle hook (before/after/on_error)
|
|
20
|
+
valve Conditional flow control (filter + predicate)
|
|
21
|
+
pipeline Pipeline orchestrator
|
|
22
|
+
retry-filter RetryFilter wrapper
|
|
23
|
+
|
|
24
|
+
Step Types (for --steps):
|
|
25
|
+
name Defaults to 'filter'
|
|
26
|
+
name:filter Explicit filter
|
|
27
|
+
name:tap Observation point
|
|
28
|
+
name:hook Lifecycle hook
|
|
29
|
+
name:valve Conditional gate
|
|
30
|
+
name:stream-filter Streaming (0..N output)
|
|
31
|
+
|
|
32
|
+
Bundle:
|
|
33
|
+
Scans a directory for codeupipe components and generates
|
|
34
|
+
__init__.py with re-exports for clean imports.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
cup new filter validate_email
|
|
38
|
+
cup new filter validate_email src/filters
|
|
39
|
+
cup new pipeline checkout_flow src/pipelines
|
|
40
|
+
cup new pipeline checkout_flow src/pipelines --steps validate_cart calc_total charge_payment
|
|
41
|
+
cup new pipeline data_etl src/pipelines --steps parse:filter fan_out:stream-filter audit:tap
|
|
42
|
+
cup new hook audit_logger src/hooks
|
|
43
|
+
cup new stream-filter log_parser src/streams
|
|
44
|
+
cup bundle src/signup
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import ast
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Optional
|
|
54
|
+
|
|
55
|
+
from codeupipe import Payload
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Name Utilities ──────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def _to_snake(name: str) -> str:
|
|
61
|
+
"""Convert any casing to snake_case."""
|
|
62
|
+
# Insert _ before uppercase runs: 'ValidateEmail' → 'Validate_Email'
|
|
63
|
+
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
64
|
+
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
65
|
+
# Replace hyphens/spaces with underscores
|
|
66
|
+
s = re.sub(r"[-\s]+", "_", s)
|
|
67
|
+
return s.lower()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_pascal(snake: str) -> str:
|
|
71
|
+
"""Convert snake_case to PascalCase."""
|
|
72
|
+
return "".join(word.capitalize() for word in snake.split("_"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Templates ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
_TEMPLATES = {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _register(component_type: str, file_template: str, test_template: str):
|
|
81
|
+
_TEMPLATES[component_type] = (file_template, test_template)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Filter (sync) ──
|
|
85
|
+
|
|
86
|
+
_register("filter", file_template='''\
|
|
87
|
+
"""
|
|
88
|
+
{class_name}: [describe what this filter does]
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
from codeupipe import Payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class {class_name}:
|
|
95
|
+
"""
|
|
96
|
+
Filter (sync): [one-line purpose]
|
|
97
|
+
|
|
98
|
+
Pipeline._invoke() transparently awaits sync returns,
|
|
99
|
+
so a plain def call() works seamlessly.
|
|
100
|
+
For async I/O (db, http, etc.) use: async def call(...)
|
|
101
|
+
|
|
102
|
+
Input keys:
|
|
103
|
+
- [key]: [description]
|
|
104
|
+
|
|
105
|
+
Output keys (added):
|
|
106
|
+
- [key]: [description]
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def call(self, payload: Payload) -> Payload:
|
|
110
|
+
# TODO: implement transformation logic
|
|
111
|
+
return payload
|
|
112
|
+
''', test_template='''\
|
|
113
|
+
"""Tests for {class_name}."""
|
|
114
|
+
|
|
115
|
+
import asyncio
|
|
116
|
+
|
|
117
|
+
import pytest
|
|
118
|
+
|
|
119
|
+
from codeupipe import Payload, Pipeline
|
|
120
|
+
from {import_path} import {class_name}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def run(coro):
|
|
124
|
+
return asyncio.run(coro)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Test{class_name}:
|
|
128
|
+
"""Unit tests for {class_name}."""
|
|
129
|
+
|
|
130
|
+
def test_happy_path(self):
|
|
131
|
+
result = run(_run_filter({class_name}(), {{}}))
|
|
132
|
+
# TODO: assert expected output keys
|
|
133
|
+
|
|
134
|
+
def test_missing_input_key(self):
|
|
135
|
+
result = run(_run_filter({class_name}(), {{}}))
|
|
136
|
+
# TODO: define expected behavior for missing keys
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _run_filter(f, data):
|
|
140
|
+
p = Pipeline()
|
|
141
|
+
p.add_filter(f, "{snake_name}")
|
|
142
|
+
return await p.run(Payload(data))
|
|
143
|
+
''')
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Filter (async) ──
|
|
147
|
+
|
|
148
|
+
_register("async-filter", file_template='''\
|
|
149
|
+
"""
|
|
150
|
+
{class_name}: [describe what this async filter does]
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
from codeupipe import Payload
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class {class_name}:
|
|
157
|
+
"""
|
|
158
|
+
Filter (async): [one-line purpose]
|
|
159
|
+
|
|
160
|
+
Native coroutine — use when call() needs await
|
|
161
|
+
(database queries, HTTP calls, file I/O, etc.).
|
|
162
|
+
For pure computation use: def call(...) (sync)
|
|
163
|
+
|
|
164
|
+
Input keys:
|
|
165
|
+
- [key]: [description]
|
|
166
|
+
|
|
167
|
+
Output keys (added):
|
|
168
|
+
- [key]: [description]
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
async def call(self, payload: Payload) -> Payload:
|
|
172
|
+
# TODO: implement async transformation logic
|
|
173
|
+
return payload
|
|
174
|
+
''', test_template='''\
|
|
175
|
+
"""Tests for {class_name}."""
|
|
176
|
+
|
|
177
|
+
import asyncio
|
|
178
|
+
|
|
179
|
+
import pytest
|
|
180
|
+
|
|
181
|
+
from codeupipe import Payload, Pipeline
|
|
182
|
+
from {import_path} import {class_name}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def run(coro):
|
|
186
|
+
return asyncio.run(coro)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Test{class_name}:
|
|
190
|
+
"""Unit tests for {class_name}."""
|
|
191
|
+
|
|
192
|
+
def test_happy_path(self):
|
|
193
|
+
f = {class_name}()
|
|
194
|
+
result = run(_run_filter(f, {{}}))
|
|
195
|
+
# TODO: assert expected output keys
|
|
196
|
+
|
|
197
|
+
def test_missing_input_key(self):
|
|
198
|
+
f = {class_name}()
|
|
199
|
+
# TODO: define expected behavior for missing keys
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _run_filter(f, data):
|
|
203
|
+
p = Pipeline()
|
|
204
|
+
p.add_filter(f, "{snake_name}")
|
|
205
|
+
return await p.run(Payload(data))
|
|
206
|
+
''')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── StreamFilter ──
|
|
210
|
+
|
|
211
|
+
_register("stream-filter", file_template='''\
|
|
212
|
+
"""
|
|
213
|
+
{class_name}: [describe what this stream filter does]
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
from typing import AsyncIterator
|
|
217
|
+
|
|
218
|
+
from codeupipe import Payload
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class {class_name}:
|
|
222
|
+
"""
|
|
223
|
+
StreamFilter (async generator): [one-line purpose]
|
|
224
|
+
|
|
225
|
+
Yields 0, 1, or N output chunks per input chunk.
|
|
226
|
+
Always async — streaming requires async generators.
|
|
227
|
+
Used with Pipeline.stream() instead of Pipeline.run().
|
|
228
|
+
|
|
229
|
+
Input keys:
|
|
230
|
+
- [key]: [description]
|
|
231
|
+
|
|
232
|
+
Output keys (yielded):
|
|
233
|
+
- [key]: [description]
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
async def stream(self, chunk: Payload) -> AsyncIterator[Payload]:
|
|
237
|
+
# TODO: implement streaming logic
|
|
238
|
+
# yield chunk # pass-through (1→1)
|
|
239
|
+
# yield nothing # drop (1→0)
|
|
240
|
+
# yield chunk1; yield chunk2 # fan-out (1→N)
|
|
241
|
+
yield chunk
|
|
242
|
+
''', test_template='''\
|
|
243
|
+
"""Tests for {class_name}."""
|
|
244
|
+
|
|
245
|
+
import asyncio
|
|
246
|
+
|
|
247
|
+
import pytest
|
|
248
|
+
|
|
249
|
+
from codeupipe import Payload, Pipeline
|
|
250
|
+
from {import_path} import {class_name}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run(coro):
|
|
254
|
+
return asyncio.run(coro)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def collect(aiter):
|
|
258
|
+
results = []
|
|
259
|
+
async for item in aiter:
|
|
260
|
+
results.append(item)
|
|
261
|
+
return results
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def make_source(*dicts):
|
|
265
|
+
for d in dicts:
|
|
266
|
+
yield Payload(d)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Test{class_name}:
|
|
270
|
+
"""Unit tests for {class_name}."""
|
|
271
|
+
|
|
272
|
+
def test_pass_through(self):
|
|
273
|
+
pipeline = Pipeline()
|
|
274
|
+
pipeline.add_filter({class_name}(), "{snake_name}")
|
|
275
|
+
|
|
276
|
+
async def go():
|
|
277
|
+
return await collect(pipeline.stream(make_source({{"key": "value"}})))
|
|
278
|
+
|
|
279
|
+
results = run(go())
|
|
280
|
+
assert len(results) == 1
|
|
281
|
+
# TODO: assert output chunk contents
|
|
282
|
+
|
|
283
|
+
def test_empty_source(self):
|
|
284
|
+
pipeline = Pipeline()
|
|
285
|
+
pipeline.add_filter({class_name}(), "{snake_name}")
|
|
286
|
+
|
|
287
|
+
async def go():
|
|
288
|
+
return await collect(pipeline.stream(make_source()))
|
|
289
|
+
|
|
290
|
+
assert run(go()) == []
|
|
291
|
+
''')
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ── Tap (sync) ──
|
|
295
|
+
|
|
296
|
+
_register("tap", file_template='''\
|
|
297
|
+
"""
|
|
298
|
+
{class_name}: [describe what this tap observes]
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
from codeupipe import Payload
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class {class_name}:
|
|
305
|
+
"""
|
|
306
|
+
Tap (sync): [one-line purpose]
|
|
307
|
+
|
|
308
|
+
Observes the payload without modifying it.
|
|
309
|
+
Pipeline._invoke() transparently handles sync returns.
|
|
310
|
+
For async I/O (external metrics, HTTP logging) use: async def observe(...)
|
|
311
|
+
|
|
312
|
+
Use for logging, metrics, auditing, debugging.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self):
|
|
316
|
+
self.observations = []
|
|
317
|
+
|
|
318
|
+
def observe(self, payload: Payload) -> None:
|
|
319
|
+
# TODO: implement observation logic
|
|
320
|
+
self.observations.append(payload.to_dict())
|
|
321
|
+
''', test_template='''\
|
|
322
|
+
"""Tests for {class_name}."""
|
|
323
|
+
|
|
324
|
+
import asyncio
|
|
325
|
+
|
|
326
|
+
import pytest
|
|
327
|
+
|
|
328
|
+
from codeupipe import Payload, Pipeline
|
|
329
|
+
from {import_path} import {class_name}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def run(coro):
|
|
333
|
+
return asyncio.run(coro)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class Test{class_name}:
|
|
337
|
+
"""Unit tests for {class_name}."""
|
|
338
|
+
|
|
339
|
+
def test_captures_observation(self):
|
|
340
|
+
tap = {class_name}()
|
|
341
|
+
pipeline = Pipeline()
|
|
342
|
+
pipeline.add_tap(tap, "{snake_name}")
|
|
343
|
+
|
|
344
|
+
run(pipeline.run(Payload({{"key": "value"}})))
|
|
345
|
+
assert len(tap.observations) == 1
|
|
346
|
+
assert tap.observations[0]["key"] == "value"
|
|
347
|
+
|
|
348
|
+
def test_does_not_modify_payload(self):
|
|
349
|
+
tap = {class_name}()
|
|
350
|
+
pipeline = Pipeline()
|
|
351
|
+
pipeline.add_tap(tap, "{snake_name}")
|
|
352
|
+
|
|
353
|
+
result = run(pipeline.run(Payload({{"x": 1}})))
|
|
354
|
+
assert result.get("x") == 1
|
|
355
|
+
''')
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── Tap (async) ──
|
|
359
|
+
|
|
360
|
+
_register("async-tap", file_template='''\
|
|
361
|
+
"""
|
|
362
|
+
{class_name}: [describe what this async tap observes]
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
from codeupipe import Payload
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class {class_name}:
|
|
369
|
+
"""
|
|
370
|
+
Tap (async): [one-line purpose]
|
|
371
|
+
|
|
372
|
+
Native coroutine — use when observe() needs await
|
|
373
|
+
(external metrics APIs, async logging, etc.).
|
|
374
|
+
For pure in-memory observation use: def observe(...) (sync)
|
|
375
|
+
|
|
376
|
+
Observes the payload without modifying it.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(self):
|
|
380
|
+
self.observations = []
|
|
381
|
+
|
|
382
|
+
async def observe(self, payload: Payload) -> None:
|
|
383
|
+
# TODO: implement async observation logic
|
|
384
|
+
self.observations.append(payload.to_dict())
|
|
385
|
+
''', test_template='''\
|
|
386
|
+
"""Tests for {class_name}."""
|
|
387
|
+
|
|
388
|
+
import asyncio
|
|
389
|
+
|
|
390
|
+
import pytest
|
|
391
|
+
|
|
392
|
+
from codeupipe import Payload, Pipeline
|
|
393
|
+
from {import_path} import {class_name}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def run(coro):
|
|
397
|
+
return asyncio.run(coro)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class Test{class_name}:
|
|
401
|
+
"""Unit tests for {class_name}."""
|
|
402
|
+
|
|
403
|
+
def test_captures_observation(self):
|
|
404
|
+
tap = {class_name}()
|
|
405
|
+
pipeline = Pipeline()
|
|
406
|
+
pipeline.add_tap(tap, "{snake_name}")
|
|
407
|
+
|
|
408
|
+
run(pipeline.run(Payload({{"key": "value"}})))
|
|
409
|
+
assert len(tap.observations) == 1
|
|
410
|
+
|
|
411
|
+
def test_does_not_modify_payload(self):
|
|
412
|
+
tap = {class_name}()
|
|
413
|
+
pipeline = Pipeline()
|
|
414
|
+
pipeline.add_tap(tap, "{snake_name}")
|
|
415
|
+
|
|
416
|
+
result = run(pipeline.run(Payload({{"x": 1}})))
|
|
417
|
+
assert result.get("x") == 1
|
|
418
|
+
''')
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ── Hook ──
|
|
422
|
+
|
|
423
|
+
_register("hook", file_template='''\
|
|
424
|
+
"""
|
|
425
|
+
{class_name}: [describe what this hook does]
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
from typing import Optional
|
|
429
|
+
|
|
430
|
+
from codeupipe import Hook, Payload
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class {class_name}(Hook):
|
|
434
|
+
"""
|
|
435
|
+
Lifecycle Hook: [one-line purpose]
|
|
436
|
+
|
|
437
|
+
Override any combination of before(), after(), on_error().
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
async def before(self, filter, payload: Payload) -> None:
|
|
441
|
+
# Called before each filter (filter=None for pipeline start)
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
async def after(self, filter, payload: Payload) -> None:
|
|
445
|
+
# Called after each filter (filter=None for pipeline end)
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
async def on_error(self, filter, error: Exception, payload: Payload) -> None:
|
|
449
|
+
# Called when a filter raises an exception
|
|
450
|
+
pass
|
|
451
|
+
''', test_template='''\
|
|
452
|
+
"""Tests for {class_name}."""
|
|
453
|
+
|
|
454
|
+
import asyncio
|
|
455
|
+
|
|
456
|
+
import pytest
|
|
457
|
+
|
|
458
|
+
from codeupipe import Hook, Payload, Pipeline
|
|
459
|
+
from {import_path} import {class_name}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def run(coro):
|
|
463
|
+
return asyncio.run(coro)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class Test{class_name}:
|
|
467
|
+
"""Unit tests for {class_name}."""
|
|
468
|
+
|
|
469
|
+
def test_before_fires(self):
|
|
470
|
+
hook = {class_name}()
|
|
471
|
+
pipeline = Pipeline()
|
|
472
|
+
pipeline.use_hook(hook)
|
|
473
|
+
pipeline.add_filter(
|
|
474
|
+
type("Noop", (), {{"call": lambda self, p: p}})(),
|
|
475
|
+
"noop",
|
|
476
|
+
)
|
|
477
|
+
run(pipeline.run(Payload({{}})))
|
|
478
|
+
# TODO: assert hook.before was called
|
|
479
|
+
|
|
480
|
+
def test_on_error_fires(self):
|
|
481
|
+
hook = {class_name}()
|
|
482
|
+
pipeline = Pipeline()
|
|
483
|
+
pipeline.use_hook(hook)
|
|
484
|
+
pipeline.add_filter(
|
|
485
|
+
type("Bomb", (), {{"call": lambda self, p: (_ for _ in ()).throw(RuntimeError("boom"))}})(),
|
|
486
|
+
"bomb",
|
|
487
|
+
)
|
|
488
|
+
with pytest.raises(RuntimeError):
|
|
489
|
+
run(pipeline.run(Payload({{}})))
|
|
490
|
+
# TODO: assert hook.on_error was called
|
|
491
|
+
''')
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ── Valve ──
|
|
495
|
+
|
|
496
|
+
_register("valve", file_template='''\
|
|
497
|
+
"""
|
|
498
|
+
{class_name}: [describe what this valve gates]
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
from codeupipe import Payload, Valve
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class {inner_class_name}:
|
|
505
|
+
"""Inner filter that runs when the valve predicate is True.
|
|
506
|
+
|
|
507
|
+
Can be sync (def call) or async (async def call) —
|
|
508
|
+
Valve uses Pipeline._invoke() which handles both.
|
|
509
|
+
"""
|
|
510
|
+
|
|
511
|
+
def call(self, payload: Payload) -> Payload:
|
|
512
|
+
# TODO: implement gated logic
|
|
513
|
+
# For async I/O, change to: async def call(...)
|
|
514
|
+
return payload
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_{snake_name}() -> Valve:
|
|
518
|
+
"""
|
|
519
|
+
Construct the {class_name} valve.
|
|
520
|
+
|
|
521
|
+
Returns a Valve that gates {inner_class_name} behind a predicate.
|
|
522
|
+
"""
|
|
523
|
+
return Valve(
|
|
524
|
+
name="{snake_name}",
|
|
525
|
+
inner={inner_class_name}(),
|
|
526
|
+
predicate=lambda p: True, # TODO: define your gate condition
|
|
527
|
+
)
|
|
528
|
+
''', test_template='''\
|
|
529
|
+
"""Tests for {class_name} valve."""
|
|
530
|
+
|
|
531
|
+
import asyncio
|
|
532
|
+
|
|
533
|
+
import pytest
|
|
534
|
+
|
|
535
|
+
from codeupipe import Payload, Pipeline
|
|
536
|
+
from {import_path} import build_{snake_name}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def run(coro):
|
|
540
|
+
return asyncio.run(coro)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class Test{class_name}:
|
|
544
|
+
"""Unit tests for {class_name} valve."""
|
|
545
|
+
|
|
546
|
+
def test_predicate_true_runs_inner(self):
|
|
547
|
+
pipeline = Pipeline()
|
|
548
|
+
pipeline.add_filter(build_{snake_name}(), "{snake_name}")
|
|
549
|
+
result = run(pipeline.run(Payload({{}})))
|
|
550
|
+
# TODO: assert inner filter effect
|
|
551
|
+
|
|
552
|
+
def test_predicate_false_skips(self):
|
|
553
|
+
# TODO: build a valve with a predicate that returns False
|
|
554
|
+
# and verify the inner filter was skipped
|
|
555
|
+
pass
|
|
556
|
+
''')
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ── Pipeline ──
|
|
560
|
+
|
|
561
|
+
_register("pipeline", file_template='''\
|
|
562
|
+
"""
|
|
563
|
+
{class_name}: [describe what this pipeline does]
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
from codeupipe import Pipeline, Payload
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def build_{snake_name}() -> Pipeline:
|
|
570
|
+
"""
|
|
571
|
+
Construct the {class_name} pipeline.
|
|
572
|
+
|
|
573
|
+
Steps:
|
|
574
|
+
1. [step description]
|
|
575
|
+
2. [step description]
|
|
576
|
+
|
|
577
|
+
Returns a configured Pipeline ready for .run() or .stream().
|
|
578
|
+
"""
|
|
579
|
+
pipeline = Pipeline()
|
|
580
|
+
|
|
581
|
+
# TODO: add your filters, taps, hooks
|
|
582
|
+
# pipeline.add_filter(MyFilter(), "my_filter")
|
|
583
|
+
# pipeline.add_tap(MyTap(), "my_tap")
|
|
584
|
+
# pipeline.use_hook(MyHook())
|
|
585
|
+
|
|
586
|
+
return pipeline
|
|
587
|
+
''', test_template='''\
|
|
588
|
+
"""Tests for {class_name} pipeline."""
|
|
589
|
+
|
|
590
|
+
import asyncio
|
|
591
|
+
|
|
592
|
+
import pytest
|
|
593
|
+
|
|
594
|
+
from codeupipe import Payload
|
|
595
|
+
from {import_path} import build_{snake_name}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def run(coro):
|
|
599
|
+
return asyncio.run(coro)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class Test{class_name}:
|
|
603
|
+
"""Integration tests for {class_name} pipeline."""
|
|
604
|
+
|
|
605
|
+
def test_happy_path(self):
|
|
606
|
+
pipeline = build_{snake_name}()
|
|
607
|
+
result = run(pipeline.run(Payload({{}})))
|
|
608
|
+
# TODO: assert final output
|
|
609
|
+
|
|
610
|
+
def test_state_tracks_all_steps(self):
|
|
611
|
+
pipeline = build_{snake_name}()
|
|
612
|
+
run(pipeline.run(Payload({{}})))
|
|
613
|
+
# TODO: assert pipeline.state.executed contains expected steps
|
|
614
|
+
''')
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ── RetryFilter ──
|
|
618
|
+
|
|
619
|
+
_register("retry-filter", file_template='''\
|
|
620
|
+
"""
|
|
621
|
+
{class_name}: [describe what this retry filter wraps]
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
from codeupipe import Payload, RetryFilter
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class {inner_class_name}:
|
|
628
|
+
"""Inner filter that may fail transiently."""
|
|
629
|
+
|
|
630
|
+
async def call(self, payload: Payload) -> Payload:
|
|
631
|
+
# TODO: implement logic that might fail
|
|
632
|
+
return payload
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def build_{snake_name}(max_retries: int = 3) -> RetryFilter:
|
|
636
|
+
"""
|
|
637
|
+
Construct {class_name} with retry logic.
|
|
638
|
+
|
|
639
|
+
Wraps {inner_class_name} with up to max_retries attempts.
|
|
640
|
+
"""
|
|
641
|
+
return RetryFilter({inner_class_name}(), max_retries=max_retries)
|
|
642
|
+
''', test_template='''\
|
|
643
|
+
"""Tests for {class_name} retry filter."""
|
|
644
|
+
|
|
645
|
+
import asyncio
|
|
646
|
+
|
|
647
|
+
import pytest
|
|
648
|
+
|
|
649
|
+
from codeupipe import Payload, Pipeline
|
|
650
|
+
from {import_path} import build_{snake_name}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def run(coro):
|
|
654
|
+
return asyncio.run(coro)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class Test{class_name}:
|
|
658
|
+
"""Unit tests for {class_name} retry filter."""
|
|
659
|
+
|
|
660
|
+
def test_succeeds_on_first_try(self):
|
|
661
|
+
pipeline = Pipeline()
|
|
662
|
+
pipeline.add_filter(build_{snake_name}(), "{snake_name}")
|
|
663
|
+
result = run(pipeline.run(Payload({{}})))
|
|
664
|
+
assert result.get("error") is None
|
|
665
|
+
|
|
666
|
+
def test_retries_on_failure(self):
|
|
667
|
+
# TODO: mock inner to fail N times then succeed
|
|
668
|
+
pass
|
|
669
|
+
''')
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# ── Composed Pipeline Builder ───────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
# Maps step types to the Pipeline wiring method
|
|
675
|
+
_STEP_WIRING = {
|
|
676
|
+
"filter": "add_filter",
|
|
677
|
+
"async-filter": "add_filter",
|
|
678
|
+
"stream-filter": "add_filter",
|
|
679
|
+
"valve": "add_filter",
|
|
680
|
+
"retry-filter": "add_filter",
|
|
681
|
+
"tap": "add_tap",
|
|
682
|
+
"async-tap": "add_tap",
|
|
683
|
+
"hook": "use_hook",
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
_VALID_STEP_TYPES = set(_STEP_WIRING.keys())
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _parse_steps(raw_steps):
|
|
690
|
+
"""Parse step specs like 'validate_cart' or 'audit_log:tap'.
|
|
691
|
+
|
|
692
|
+
Returns list of (snake_name, pascal_name, step_type) tuples.
|
|
693
|
+
Defaults to 'filter' when no type is specified.
|
|
694
|
+
"""
|
|
695
|
+
parsed = []
|
|
696
|
+
for spec in raw_steps:
|
|
697
|
+
if ":" in spec:
|
|
698
|
+
name, stype = spec.rsplit(":", 1)
|
|
699
|
+
if stype not in _VALID_STEP_TYPES:
|
|
700
|
+
raise ValueError(
|
|
701
|
+
f"Unknown step type '{stype}' in '{spec}'. "
|
|
702
|
+
f"Choose from: {', '.join(sorted(_VALID_STEP_TYPES))}"
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
name = spec
|
|
706
|
+
stype = "filter"
|
|
707
|
+
snake = _to_snake(name)
|
|
708
|
+
pascal = _to_pascal(snake)
|
|
709
|
+
parsed.append((snake, pascal, stype))
|
|
710
|
+
return parsed
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _build_composed_pipeline(pipeline_snake, pipeline_pascal, steps, import_path_prefix):
|
|
714
|
+
"""Build a composed pipeline file from a list of step specs."""
|
|
715
|
+
has_stream = any(st == "stream-filter" for _, _, st in steps)
|
|
716
|
+
|
|
717
|
+
# ── Imports ──
|
|
718
|
+
imports = ["from codeupipe import Pipeline, Payload"]
|
|
719
|
+
if any(st == "hook" for _, _, st in steps):
|
|
720
|
+
imports[0] = "from codeupipe import Hook, Pipeline, Payload"
|
|
721
|
+
if any(st == "valve" for _, _, st in steps):
|
|
722
|
+
imports[0] = imports[0].replace("Pipeline,", "Pipeline, Valve,")
|
|
723
|
+
|
|
724
|
+
import_lines = []
|
|
725
|
+
for snake, pascal, stype in steps:
|
|
726
|
+
if stype in ("valve", "retry-filter"):
|
|
727
|
+
import_lines.append(f"from .{snake} import build_{snake}")
|
|
728
|
+
else:
|
|
729
|
+
import_lines.append(f"from .{snake} import {pascal}")
|
|
730
|
+
|
|
731
|
+
# ── Pipeline build function body ──
|
|
732
|
+
wiring_lines = []
|
|
733
|
+
for snake, pascal, stype in steps:
|
|
734
|
+
method = _STEP_WIRING[stype]
|
|
735
|
+
if stype in ("valve", "retry-filter"):
|
|
736
|
+
inst = f"build_{snake}()"
|
|
737
|
+
else:
|
|
738
|
+
inst = f"{pascal}()"
|
|
739
|
+
|
|
740
|
+
if stype == "hook":
|
|
741
|
+
wiring_lines.append(f" pipeline.{method}({inst})")
|
|
742
|
+
else:
|
|
743
|
+
wiring_lines.append(f' pipeline.{method}({inst}, "{snake}")')
|
|
744
|
+
|
|
745
|
+
# ── Step descriptions ──
|
|
746
|
+
step_descs = []
|
|
747
|
+
for i, (snake, pascal, stype) in enumerate(steps, 1):
|
|
748
|
+
label = stype.replace("-", " ").title()
|
|
749
|
+
step_descs.append(f" {i}. {pascal} ({label})")
|
|
750
|
+
|
|
751
|
+
# ── Run hint ──
|
|
752
|
+
if has_stream:
|
|
753
|
+
run_hint = (
|
|
754
|
+
" Use pipeline.stream(source) — this pipeline contains StreamFilter(s).\n"
|
|
755
|
+
" Example:\n"
|
|
756
|
+
" async for result in pipeline.stream(async_generator):\n"
|
|
757
|
+
" process(result)"
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
run_hint = (
|
|
761
|
+
" Use pipeline.run(payload) for single-payload execution.\n"
|
|
762
|
+
" Use pipeline.stream(source) for streaming execution."
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
file_content = f'''\
|
|
766
|
+
"""
|
|
767
|
+
{pipeline_pascal}: [describe what this pipeline does]
|
|
768
|
+
"""
|
|
769
|
+
|
|
770
|
+
{imports[0]}
|
|
771
|
+
|
|
772
|
+
# TODO: update import paths to match your project layout
|
|
773
|
+
{chr(10).join(import_lines)}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def build_{pipeline_snake}() -> Pipeline:
|
|
777
|
+
"""
|
|
778
|
+
Construct the {pipeline_pascal} pipeline.
|
|
779
|
+
|
|
780
|
+
Steps:
|
|
781
|
+
{chr(10).join(step_descs)}
|
|
782
|
+
|
|
783
|
+
{run_hint}
|
|
784
|
+
"""
|
|
785
|
+
pipeline = Pipeline()
|
|
786
|
+
|
|
787
|
+
{chr(10).join(wiring_lines)}
|
|
788
|
+
|
|
789
|
+
return pipeline
|
|
790
|
+
'''
|
|
791
|
+
return file_content
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _build_composed_test(pipeline_snake, pipeline_pascal, steps, import_path):
|
|
795
|
+
"""Build a test file for a composed pipeline."""
|
|
796
|
+
has_stream = any(st == "stream-filter" for _, _, st in steps)
|
|
797
|
+
|
|
798
|
+
# Collect expected step names (non-hook steps tracked in state)
|
|
799
|
+
tracked = [snake for snake, _, stype in steps if stype != "hook"]
|
|
800
|
+
|
|
801
|
+
if has_stream:
|
|
802
|
+
stream_helpers = '''\
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
async def collect(aiter):
|
|
806
|
+
results = []
|
|
807
|
+
async for item in aiter:
|
|
808
|
+
results.append(item)
|
|
809
|
+
return results
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
async def make_source(*dicts):
|
|
813
|
+
for d in dicts:
|
|
814
|
+
yield Payload(d)'''
|
|
815
|
+
|
|
816
|
+
happy_path_body = '''\
|
|
817
|
+
pipeline = build_{snake}()
|
|
818
|
+
|
|
819
|
+
async def go():
|
|
820
|
+
return await collect(pipeline.stream(make_source({{"input": "test"}})))
|
|
821
|
+
|
|
822
|
+
results = run(go())
|
|
823
|
+
assert len(results) >= 1
|
|
824
|
+
# TODO: assert output content'''.format(snake=pipeline_snake)
|
|
825
|
+
|
|
826
|
+
state_body = '''\
|
|
827
|
+
pipeline = build_{snake}()
|
|
828
|
+
|
|
829
|
+
async def go():
|
|
830
|
+
results = await collect(pipeline.stream(make_source({{"input": "test"}})))
|
|
831
|
+
return pipeline
|
|
832
|
+
|
|
833
|
+
pipeline = run(go())
|
|
834
|
+
executed = pipeline.state.executed'''.format(snake=pipeline_snake)
|
|
835
|
+
else:
|
|
836
|
+
stream_helpers = ''
|
|
837
|
+
happy_path_body = '''\
|
|
838
|
+
pipeline = build_{snake}()
|
|
839
|
+
result = run(pipeline.run(Payload({{"input": "test"}})))
|
|
840
|
+
# TODO: assert final output'''.format(snake=pipeline_snake)
|
|
841
|
+
|
|
842
|
+
state_body = '''\
|
|
843
|
+
pipeline = build_{snake}()
|
|
844
|
+
run(pipeline.run(Payload({{"input": "test"}})))
|
|
845
|
+
executed = pipeline.state.executed'''.format(snake=pipeline_snake)
|
|
846
|
+
|
|
847
|
+
# State assertions for tracked steps
|
|
848
|
+
state_asserts = "\n".join(
|
|
849
|
+
f' assert "{s}" in executed' for s in tracked
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
test_content = f'''\
|
|
853
|
+
"""Tests for {pipeline_pascal} pipeline."""
|
|
854
|
+
|
|
855
|
+
import asyncio
|
|
856
|
+
|
|
857
|
+
import pytest
|
|
858
|
+
|
|
859
|
+
from codeupipe import Payload
|
|
860
|
+
from {import_path} import build_{pipeline_snake}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def run(coro):
|
|
864
|
+
return asyncio.run(coro)
|
|
865
|
+
{stream_helpers}
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
class Test{pipeline_pascal}:
|
|
869
|
+
"""Integration tests for {pipeline_pascal} pipeline."""
|
|
870
|
+
|
|
871
|
+
def test_happy_path(self):
|
|
872
|
+
{happy_path_body}
|
|
873
|
+
|
|
874
|
+
def test_state_tracks_all_steps(self):
|
|
875
|
+
{state_body}
|
|
876
|
+
{state_asserts}
|
|
877
|
+
'''
|
|
878
|
+
return test_content
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# ── Scaffolding Engine ──────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
COMPONENT_TYPES = list(_TEMPLATES.keys())
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def scaffold(component_type: str, name: str, path: str, steps=None) -> dict:
|
|
887
|
+
"""
|
|
888
|
+
Generate component and test files.
|
|
889
|
+
|
|
890
|
+
Returns dict with 'component_file' and 'test_file' paths created.
|
|
891
|
+
"""
|
|
892
|
+
if component_type not in _TEMPLATES:
|
|
893
|
+
raise ValueError(
|
|
894
|
+
f"Unknown component type '{component_type}'. "
|
|
895
|
+
f"Choose from: {', '.join(COMPONENT_TYPES)}"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
snake = _to_snake(name)
|
|
899
|
+
pascal = _to_pascal(snake)
|
|
900
|
+
|
|
901
|
+
# Resolve paths
|
|
902
|
+
component_dir = Path(path)
|
|
903
|
+
component_file = component_dir / f"{snake}.py"
|
|
904
|
+
|
|
905
|
+
# Build import path from component file (relative to cwd)
|
|
906
|
+
try:
|
|
907
|
+
rel = component_file.relative_to(Path.cwd())
|
|
908
|
+
except ValueError:
|
|
909
|
+
rel = component_file
|
|
910
|
+
import_path = str(rel.with_suffix("")).replace(os.sep, ".")
|
|
911
|
+
|
|
912
|
+
# Test file: mirror structure under tests/
|
|
913
|
+
test_dir = Path("tests")
|
|
914
|
+
test_file = test_dir / f"test_{snake}.py"
|
|
915
|
+
|
|
916
|
+
# ── Composed pipeline (with --steps) ──
|
|
917
|
+
if component_type == "pipeline" and steps:
|
|
918
|
+
parsed_steps = _parse_steps(steps)
|
|
919
|
+
# Import prefix for step imports (sibling modules in same dir)
|
|
920
|
+
component_content = _build_composed_pipeline(
|
|
921
|
+
snake, pascal, parsed_steps, import_path
|
|
922
|
+
)
|
|
923
|
+
test_content = _build_composed_test(
|
|
924
|
+
snake, pascal, parsed_steps, import_path
|
|
925
|
+
)
|
|
926
|
+
else:
|
|
927
|
+
# ── Standard template ──
|
|
928
|
+
file_tpl, test_tpl = _TEMPLATES[component_type]
|
|
929
|
+
|
|
930
|
+
# Determine inner class name for valve/retry-filter
|
|
931
|
+
inner_pascal = pascal + "Inner"
|
|
932
|
+
|
|
933
|
+
fmt = {
|
|
934
|
+
"class_name": pascal,
|
|
935
|
+
"snake_name": snake,
|
|
936
|
+
"import_path": import_path,
|
|
937
|
+
"inner_class_name": inner_pascal,
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
component_content = file_tpl.format(**fmt)
|
|
941
|
+
test_content = test_tpl.format(**fmt)
|
|
942
|
+
|
|
943
|
+
# Create directories
|
|
944
|
+
component_dir.mkdir(parents=True, exist_ok=True)
|
|
945
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
946
|
+
|
|
947
|
+
# Write files (never overwrite)
|
|
948
|
+
if component_file.exists():
|
|
949
|
+
raise FileExistsError(f"File already exists: {component_file}")
|
|
950
|
+
if test_file.exists():
|
|
951
|
+
raise FileExistsError(f"Test file already exists: {test_file}")
|
|
952
|
+
|
|
953
|
+
component_file.write_text(component_content)
|
|
954
|
+
test_file.write_text(test_content)
|
|
955
|
+
|
|
956
|
+
# Ensure __init__.py exists in component directory
|
|
957
|
+
init_file = component_dir / "__init__.py"
|
|
958
|
+
if not init_file.exists():
|
|
959
|
+
init_file.write_text("")
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
"component_file": str(component_file),
|
|
963
|
+
"test_file": str(test_file),
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# ── Bundle Engine ────────────────────────────────────────────────────
|
|
968
|
+
|
|
969
|
+
def _extract_exports(filepath: Path) -> list:
|
|
970
|
+
"""Extract public classes and builder functions from a Python file using AST.
|
|
971
|
+
|
|
972
|
+
Returns list of (symbol_name, kind) tuples where kind is 'class' or 'function'.
|
|
973
|
+
"""
|
|
974
|
+
try:
|
|
975
|
+
source = filepath.read_text()
|
|
976
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
977
|
+
except (SyntaxError, OSError):
|
|
978
|
+
return []
|
|
979
|
+
|
|
980
|
+
exports = []
|
|
981
|
+
for node in ast.iter_child_nodes(tree):
|
|
982
|
+
if isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
|
|
983
|
+
exports.append((node.name, "class"))
|
|
984
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
985
|
+
if not node.name.startswith("_"):
|
|
986
|
+
exports.append((node.name, "function"))
|
|
987
|
+
return exports
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def bundle(directory: str) -> dict:
|
|
991
|
+
"""Scan a directory and generate __init__.py with re-exports.
|
|
992
|
+
|
|
993
|
+
Returns dict with 'init_file' path and 'exports' list.
|
|
994
|
+
"""
|
|
995
|
+
dir_path = Path(directory)
|
|
996
|
+
if not dir_path.is_dir():
|
|
997
|
+
raise FileNotFoundError(f"Directory not found: {directory}")
|
|
998
|
+
|
|
999
|
+
# Collect exports from all .py files (skip __init__.py)
|
|
1000
|
+
all_exports = [] # (module_name, symbol, kind)
|
|
1001
|
+
for py_file in sorted(dir_path.glob("*.py")):
|
|
1002
|
+
if py_file.name == "__init__.py":
|
|
1003
|
+
continue
|
|
1004
|
+
module = py_file.stem
|
|
1005
|
+
symbols = _extract_exports(py_file)
|
|
1006
|
+
for symbol, kind in symbols:
|
|
1007
|
+
all_exports.append((module, symbol, kind))
|
|
1008
|
+
|
|
1009
|
+
if not all_exports:
|
|
1010
|
+
raise ValueError(f"No exportable symbols found in {directory}")
|
|
1011
|
+
|
|
1012
|
+
# Group by module for clean import lines
|
|
1013
|
+
modules = {}
|
|
1014
|
+
for module, symbol, kind in all_exports:
|
|
1015
|
+
modules.setdefault(module, []).append(symbol)
|
|
1016
|
+
|
|
1017
|
+
# Build __init__.py content
|
|
1018
|
+
lines = ['"""', f"Public API for {dir_path.name} package.", "", "Auto-generated by: cup bundle", '"""', ""]
|
|
1019
|
+
for module in sorted(modules.keys()):
|
|
1020
|
+
symbols = sorted(modules[module])
|
|
1021
|
+
symbols_str = ", ".join(symbols)
|
|
1022
|
+
lines.append(f"from .{module} import {symbols_str}")
|
|
1023
|
+
|
|
1024
|
+
# __all__ for explicit public API
|
|
1025
|
+
all_symbols = sorted(sym for _, sym, _ in all_exports)
|
|
1026
|
+
lines.append("")
|
|
1027
|
+
lines.append("__all__ = [")
|
|
1028
|
+
for sym in all_symbols:
|
|
1029
|
+
lines.append(f' "{sym}",')
|
|
1030
|
+
lines.append("]")
|
|
1031
|
+
lines.append("")
|
|
1032
|
+
|
|
1033
|
+
init_content = "\n".join(lines)
|
|
1034
|
+
|
|
1035
|
+
# Write __init__.py
|
|
1036
|
+
init_file = dir_path / "__init__.py"
|
|
1037
|
+
init_file.write_text(init_content)
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
"init_file": str(init_file),
|
|
1041
|
+
"exports": [(m, s) for m, s, _ in all_exports],
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
# ── Linter Engine ───────────────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
# Component detection heuristics (AST-based)
|
|
1048
|
+
def lint(directory: str) -> list:
|
|
1049
|
+
"""Lint a codeupipe component directory for standards violations.
|
|
1050
|
+
|
|
1051
|
+
Returns list of (rule_id, severity, filepath, message) tuples.
|
|
1052
|
+
|
|
1053
|
+
Internally delegates to the CUP linter pipeline (dogfooding).
|
|
1054
|
+
"""
|
|
1055
|
+
import asyncio
|
|
1056
|
+
from .linter import build_lint_pipeline
|
|
1057
|
+
|
|
1058
|
+
pipeline = build_lint_pipeline()
|
|
1059
|
+
payload = Payload({"directory": directory})
|
|
1060
|
+
result = asyncio.run(pipeline.run(payload))
|
|
1061
|
+
return result.get("issues", [])
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ── Coverage Engine ─────────────────────────────────────────────────
|
|
1065
|
+
|
|
1066
|
+
def coverage(directory: str, tests_dir: str = "tests") -> dict:
|
|
1067
|
+
"""Map test coverage for a codeupipe component directory.
|
|
1068
|
+
|
|
1069
|
+
Returns dict with 'coverage', 'summary', and 'gaps' keys.
|
|
1070
|
+
|
|
1071
|
+
Internally delegates to the CUP coverage pipeline (dogfooding).
|
|
1072
|
+
"""
|
|
1073
|
+
import asyncio
|
|
1074
|
+
from .linter.coverage_pipeline import build_coverage_pipeline
|
|
1075
|
+
|
|
1076
|
+
pipeline = build_coverage_pipeline()
|
|
1077
|
+
payload = Payload({"directory": directory, "tests_dir": tests_dir})
|
|
1078
|
+
result = asyncio.run(pipeline.run(payload))
|
|
1079
|
+
return {
|
|
1080
|
+
"coverage": result.get("coverage", []),
|
|
1081
|
+
"summary": result.get("summary", {}),
|
|
1082
|
+
"gaps": result.get("gaps", []),
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# ── Report Engine ───────────────────────────────────────────────────
|
|
1087
|
+
|
|
1088
|
+
def report(directory: str, tests_dir: str = "tests") -> dict:
|
|
1089
|
+
"""Generate a full codebase health report.
|
|
1090
|
+
|
|
1091
|
+
Returns the report dict with components, orphans, git history,
|
|
1092
|
+
stale files, and health score.
|
|
1093
|
+
|
|
1094
|
+
Internally delegates to the CUP report pipeline (dogfooding).
|
|
1095
|
+
"""
|
|
1096
|
+
import asyncio
|
|
1097
|
+
from .linter.report_pipeline import build_report_pipeline
|
|
1098
|
+
|
|
1099
|
+
pipeline = build_report_pipeline()
|
|
1100
|
+
payload = Payload({"directory": directory, "tests_dir": tests_dir})
|
|
1101
|
+
result = asyncio.run(pipeline.run(payload))
|
|
1102
|
+
return result.get("report", {})
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# ── Doc-Check Engine ────────────────────────────────────────────────
|
|
1106
|
+
|
|
1107
|
+
def doc_check(directory: str) -> dict:
|
|
1108
|
+
"""Check documentation freshness against source code.
|
|
1109
|
+
|
|
1110
|
+
Scans markdown files for cup:ref markers, verifies referenced
|
|
1111
|
+
source files exist, checks symbol presence via AST, and detects
|
|
1112
|
+
content drift via SHA256 hashes.
|
|
1113
|
+
|
|
1114
|
+
Returns dict with 'total_refs', 'drifted', 'missing_symbols',
|
|
1115
|
+
'missing_files', 'status', and 'details' keys.
|
|
1116
|
+
|
|
1117
|
+
Internally delegates to the CUP doc-check pipeline (dogfooding).
|
|
1118
|
+
"""
|
|
1119
|
+
import asyncio
|
|
1120
|
+
from .linter.doc_check_pipeline import build_doc_check_pipeline
|
|
1121
|
+
|
|
1122
|
+
pipeline = build_doc_check_pipeline()
|
|
1123
|
+
payload = Payload({"directory": directory})
|
|
1124
|
+
result = asyncio.run(pipeline.run(payload))
|
|
1125
|
+
return result.get("doc_report", {})
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
# ── CLI Entry Point ─────────────────────────────────────────────────
|
|
1129
|
+
|
|
1130
|
+
def main(argv=None):
|
|
1131
|
+
parser = argparse.ArgumentParser(
|
|
1132
|
+
prog="cup",
|
|
1133
|
+
description="codeupipe CLI — scaffold pipeline components instantly.",
|
|
1134
|
+
)
|
|
1135
|
+
sub = parser.add_subparsers(dest="command")
|
|
1136
|
+
|
|
1137
|
+
# cup new <component> <name> [path]
|
|
1138
|
+
new_parser = sub.add_parser("new", help="Scaffold a new component")
|
|
1139
|
+
new_parser.add_argument(
|
|
1140
|
+
"component",
|
|
1141
|
+
choices=COMPONENT_TYPES,
|
|
1142
|
+
help="Component type to scaffold",
|
|
1143
|
+
)
|
|
1144
|
+
new_parser.add_argument(
|
|
1145
|
+
"name",
|
|
1146
|
+
help="Component name (snake_case or PascalCase)",
|
|
1147
|
+
)
|
|
1148
|
+
new_parser.add_argument(
|
|
1149
|
+
"path",
|
|
1150
|
+
nargs="?",
|
|
1151
|
+
default=".",
|
|
1152
|
+
help="Directory to create the component in (default: current dir)",
|
|
1153
|
+
)
|
|
1154
|
+
new_parser.add_argument(
|
|
1155
|
+
"--steps",
|
|
1156
|
+
nargs="+",
|
|
1157
|
+
metavar="NAME[:TYPE]",
|
|
1158
|
+
help=(
|
|
1159
|
+
"Compose a pipeline from steps (pipeline only). "
|
|
1160
|
+
"Format: name or name:type. Default type is 'filter'. "
|
|
1161
|
+
"Types: filter, async-filter, stream-filter, tap, async-tap, "
|
|
1162
|
+
"hook, valve, retry-filter. "
|
|
1163
|
+
"Example: --steps validate_cart calc_total audit_log:tap"
|
|
1164
|
+
),
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
# cup list
|
|
1168
|
+
list_parser = sub.add_parser("list", help="List available component types")
|
|
1169
|
+
|
|
1170
|
+
# cup bundle <path>
|
|
1171
|
+
bundle_parser = sub.add_parser(
|
|
1172
|
+
"bundle",
|
|
1173
|
+
help="Generate __init__.py re-exports for a component directory",
|
|
1174
|
+
)
|
|
1175
|
+
bundle_parser.add_argument(
|
|
1176
|
+
"path",
|
|
1177
|
+
help="Directory to scan and bundle",
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
# cup lint <path>
|
|
1181
|
+
lint_parser = sub.add_parser(
|
|
1182
|
+
"lint",
|
|
1183
|
+
help="Check a component directory for codeupipe standards violations",
|
|
1184
|
+
)
|
|
1185
|
+
lint_parser.add_argument(
|
|
1186
|
+
"path",
|
|
1187
|
+
help="Directory to lint",
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
# cup coverage <path> [--tests-dir]
|
|
1191
|
+
cov_parser = sub.add_parser(
|
|
1192
|
+
"coverage",
|
|
1193
|
+
help="Map test coverage for a component directory",
|
|
1194
|
+
)
|
|
1195
|
+
cov_parser.add_argument(
|
|
1196
|
+
"path",
|
|
1197
|
+
help="Directory to analyze",
|
|
1198
|
+
)
|
|
1199
|
+
cov_parser.add_argument(
|
|
1200
|
+
"--tests-dir",
|
|
1201
|
+
default="tests",
|
|
1202
|
+
help="Path to tests directory (default: tests)",
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
# cup report <path> [--tests-dir] [--json] [--detail] [--verbose]
|
|
1206
|
+
report_parser = sub.add_parser(
|
|
1207
|
+
"report",
|
|
1208
|
+
help="Generate a full codebase health report",
|
|
1209
|
+
)
|
|
1210
|
+
report_parser.add_argument(
|
|
1211
|
+
"path",
|
|
1212
|
+
help="Directory to analyze",
|
|
1213
|
+
)
|
|
1214
|
+
report_parser.add_argument(
|
|
1215
|
+
"--tests-dir",
|
|
1216
|
+
default="tests",
|
|
1217
|
+
help="Path to tests directory (default: tests)",
|
|
1218
|
+
)
|
|
1219
|
+
report_parser.add_argument(
|
|
1220
|
+
"--json",
|
|
1221
|
+
action="store_true",
|
|
1222
|
+
dest="json_output",
|
|
1223
|
+
help="Output raw JSON for piping to web/CI",
|
|
1224
|
+
)
|
|
1225
|
+
report_parser.add_argument(
|
|
1226
|
+
"--detail",
|
|
1227
|
+
action="store_true",
|
|
1228
|
+
help="Show per-component detail table",
|
|
1229
|
+
)
|
|
1230
|
+
report_parser.add_argument(
|
|
1231
|
+
"--verbose",
|
|
1232
|
+
action="store_true",
|
|
1233
|
+
help="Show full detail with source info for flagged items",
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
# cup doc-check [path] [--json]
|
|
1237
|
+
doc_check_parser = sub.add_parser(
|
|
1238
|
+
"doc-check",
|
|
1239
|
+
help="Check documentation freshness against source code",
|
|
1240
|
+
)
|
|
1241
|
+
doc_check_parser.add_argument(
|
|
1242
|
+
"path",
|
|
1243
|
+
nargs="?",
|
|
1244
|
+
default=".",
|
|
1245
|
+
help="Directory to scan for markdown files (default: current dir)",
|
|
1246
|
+
)
|
|
1247
|
+
doc_check_parser.add_argument(
|
|
1248
|
+
"--json",
|
|
1249
|
+
action="store_true",
|
|
1250
|
+
dest="json_output",
|
|
1251
|
+
help="Output raw JSON for piping to CI",
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
args = parser.parse_args(argv)
|
|
1255
|
+
|
|
1256
|
+
if args.command == "list":
|
|
1257
|
+
print("Available component types:")
|
|
1258
|
+
for ct in COMPONENT_TYPES:
|
|
1259
|
+
print(f" {ct}")
|
|
1260
|
+
return 0
|
|
1261
|
+
|
|
1262
|
+
if args.command == "new":
|
|
1263
|
+
try:
|
|
1264
|
+
steps = getattr(args, "steps", None)
|
|
1265
|
+
if steps and args.component != "pipeline":
|
|
1266
|
+
print(
|
|
1267
|
+
"Error: --steps can only be used with 'pipeline' component type.",
|
|
1268
|
+
file=sys.stderr,
|
|
1269
|
+
)
|
|
1270
|
+
return 1
|
|
1271
|
+
result = scaffold(args.component, args.name, args.path, steps=steps)
|
|
1272
|
+
print(f"Created {args.component}:")
|
|
1273
|
+
print(f" {result['component_file']}")
|
|
1274
|
+
print(f" {result['test_file']}")
|
|
1275
|
+
return 0
|
|
1276
|
+
except FileExistsError as e:
|
|
1277
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1278
|
+
return 1
|
|
1279
|
+
except Exception as e:
|
|
1280
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1281
|
+
return 1
|
|
1282
|
+
|
|
1283
|
+
if args.command == "bundle":
|
|
1284
|
+
try:
|
|
1285
|
+
result = bundle(args.path)
|
|
1286
|
+
print(f"Bundled {result['init_file']}:")
|
|
1287
|
+
for module, symbol in result["exports"]:
|
|
1288
|
+
print(f" {module} → {symbol}")
|
|
1289
|
+
return 0
|
|
1290
|
+
except (FileNotFoundError, ValueError) as e:
|
|
1291
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1292
|
+
return 1
|
|
1293
|
+
except Exception as e:
|
|
1294
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1295
|
+
return 1
|
|
1296
|
+
|
|
1297
|
+
if args.command == "lint":
|
|
1298
|
+
try:
|
|
1299
|
+
issues = lint(args.path)
|
|
1300
|
+
if not issues:
|
|
1301
|
+
print(f"✓ {args.path}: all checks passed")
|
|
1302
|
+
return 0
|
|
1303
|
+
|
|
1304
|
+
errors = [i for i in issues if i[1] == "error"]
|
|
1305
|
+
warnings = [i for i in issues if i[1] == "warning"]
|
|
1306
|
+
|
|
1307
|
+
for rule_id, severity, filepath, message in issues:
|
|
1308
|
+
marker = "✗" if severity == "error" else "!"
|
|
1309
|
+
print(f" {marker} {rule_id} [{severity}] {filepath}: {message}")
|
|
1310
|
+
|
|
1311
|
+
print()
|
|
1312
|
+
summary_parts = []
|
|
1313
|
+
if errors:
|
|
1314
|
+
summary_parts.append(f"{len(errors)} error(s)")
|
|
1315
|
+
if warnings:
|
|
1316
|
+
summary_parts.append(f"{len(warnings)} warning(s)")
|
|
1317
|
+
print(f" {', '.join(summary_parts)}")
|
|
1318
|
+
|
|
1319
|
+
return 1 if errors else 0
|
|
1320
|
+
except FileNotFoundError as e:
|
|
1321
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1322
|
+
return 1
|
|
1323
|
+
except Exception as e:
|
|
1324
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1325
|
+
return 1
|
|
1326
|
+
|
|
1327
|
+
if args.command == "coverage":
|
|
1328
|
+
try:
|
|
1329
|
+
tests_dir = getattr(args, "tests_dir", "tests")
|
|
1330
|
+
result = coverage(args.path, tests_dir=tests_dir)
|
|
1331
|
+
summary = result["summary"]
|
|
1332
|
+
gaps = result["gaps"]
|
|
1333
|
+
cov_list = result["coverage"]
|
|
1334
|
+
|
|
1335
|
+
if not cov_list:
|
|
1336
|
+
print(f"✓ {args.path}: no components found")
|
|
1337
|
+
return 0
|
|
1338
|
+
|
|
1339
|
+
# Per-component table
|
|
1340
|
+
for entry in cov_list:
|
|
1341
|
+
pct = entry["coverage_pct"]
|
|
1342
|
+
icon = "✓" if pct == 100.0 else ("!" if pct > 0 else "✗")
|
|
1343
|
+
test_tag = f"{entry['test_count']} tests" if entry["has_test_file"] else "no tests"
|
|
1344
|
+
print(f" {icon} {entry['name']} ({entry['kind']}) — {pct}% [{test_tag}]")
|
|
1345
|
+
if entry["untested_methods"]:
|
|
1346
|
+
for m in entry["untested_methods"]:
|
|
1347
|
+
print(f" missing: {m}()")
|
|
1348
|
+
|
|
1349
|
+
# Summary
|
|
1350
|
+
print()
|
|
1351
|
+
print(
|
|
1352
|
+
f" {summary['overall_pct']}% method coverage "
|
|
1353
|
+
f"({summary['tested_methods']}/{summary['total_methods']} methods, "
|
|
1354
|
+
f"{summary['tested_components']}/{summary['total_components']} components tested)"
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
if gaps:
|
|
1358
|
+
print(f" {len(gaps)} component(s) with gaps")
|
|
1359
|
+
|
|
1360
|
+
return 0
|
|
1361
|
+
except FileNotFoundError as e:
|
|
1362
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1363
|
+
return 1
|
|
1364
|
+
except Exception as e:
|
|
1365
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1366
|
+
return 1
|
|
1367
|
+
|
|
1368
|
+
if args.command == "report":
|
|
1369
|
+
try:
|
|
1370
|
+
import json as json_mod
|
|
1371
|
+
|
|
1372
|
+
tests_dir = getattr(args, "tests_dir", "tests")
|
|
1373
|
+
rpt = report(args.path, tests_dir=tests_dir)
|
|
1374
|
+
|
|
1375
|
+
# JSON mode — dump and exit
|
|
1376
|
+
if getattr(args, "json_output", False):
|
|
1377
|
+
print(json_mod.dumps(rpt, indent=2))
|
|
1378
|
+
return 0
|
|
1379
|
+
|
|
1380
|
+
summary = rpt.get("summary", {})
|
|
1381
|
+
components = rpt.get("components", [])
|
|
1382
|
+
orphaned_comps = rpt.get("orphaned_components", [])
|
|
1383
|
+
orphaned_tests = rpt.get("orphaned_tests", [])
|
|
1384
|
+
stale_files = rpt.get("stale_files", [])
|
|
1385
|
+
show_detail = getattr(args, "detail", False) or getattr(args, "verbose", False)
|
|
1386
|
+
show_verbose = getattr(args, "verbose", False)
|
|
1387
|
+
|
|
1388
|
+
# Header
|
|
1389
|
+
score = summary.get("health_score", "?")
|
|
1390
|
+
score_icon = {"A": "✓", "B": "✓", "C": "!", "D": "✗", "F": "✗"}.get(score, "?")
|
|
1391
|
+
print(f"\n {score_icon} Health Score: {score}")
|
|
1392
|
+
print(f" generated: {rpt.get('generated_at', 'unknown')}")
|
|
1393
|
+
print(f" directory: {rpt.get('directory', '')}")
|
|
1394
|
+
print()
|
|
1395
|
+
|
|
1396
|
+
# Summary line
|
|
1397
|
+
cov_pct = summary.get("overall_pct", 0)
|
|
1398
|
+
total = summary.get("total_components", 0)
|
|
1399
|
+
tested = summary.get("tested_components", 0)
|
|
1400
|
+
print(f" Coverage: {cov_pct}% ({tested}/{total} components)")
|
|
1401
|
+
print(f" Orphans: {len(orphaned_comps)} component(s), {len(orphaned_tests)} test(s)")
|
|
1402
|
+
print(f" Stale: {len(stale_files)} file(s) (>90d)")
|
|
1403
|
+
|
|
1404
|
+
if show_detail:
|
|
1405
|
+
print()
|
|
1406
|
+
print(" Components:")
|
|
1407
|
+
for comp in components:
|
|
1408
|
+
pct = comp["coverage_pct"]
|
|
1409
|
+
icon = "✓" if pct == 100.0 else ("!" if pct > 0 else "✗")
|
|
1410
|
+
orphan_tag = " [ORPHAN]" if comp.get("orphaned") else ""
|
|
1411
|
+
git = comp.get("git", {})
|
|
1412
|
+
age = git.get("days_since_change")
|
|
1413
|
+
age_tag = f" ({age}d ago)" if age is not None else ""
|
|
1414
|
+
author = git.get("last_author", "")
|
|
1415
|
+
author_tag = f" by {author}" if author else ""
|
|
1416
|
+
print(f" {icon} {comp['name']} ({comp['kind']}) — {pct}%{orphan_tag}{age_tag}{author_tag}")
|
|
1417
|
+
if show_verbose and comp.get("untested_methods"):
|
|
1418
|
+
for m in comp["untested_methods"]:
|
|
1419
|
+
print(f" missing: {m}()")
|
|
1420
|
+
if show_verbose and comp.get("imported_by"):
|
|
1421
|
+
print(f" imported by: {', '.join(comp['imported_by'])}")
|
|
1422
|
+
|
|
1423
|
+
if orphaned_comps:
|
|
1424
|
+
print()
|
|
1425
|
+
print(" Orphaned Components:")
|
|
1426
|
+
for o in orphaned_comps:
|
|
1427
|
+
print(f" ✗ {o['name']} ({o['kind']}) — {o['file']}")
|
|
1428
|
+
|
|
1429
|
+
if orphaned_tests:
|
|
1430
|
+
print()
|
|
1431
|
+
print(" Orphaned Tests:")
|
|
1432
|
+
for o in orphaned_tests:
|
|
1433
|
+
print(f" ✗ {o['file']}")
|
|
1434
|
+
|
|
1435
|
+
if stale_files:
|
|
1436
|
+
print()
|
|
1437
|
+
print(" Stale Files:")
|
|
1438
|
+
for s in stale_files:
|
|
1439
|
+
print(f" ! {s['file']} — {s['days_since_change']}d since change")
|
|
1440
|
+
|
|
1441
|
+
print()
|
|
1442
|
+
return 0
|
|
1443
|
+
except FileNotFoundError as e:
|
|
1444
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1445
|
+
return 1
|
|
1446
|
+
except Exception as e:
|
|
1447
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1448
|
+
return 1
|
|
1449
|
+
|
|
1450
|
+
if args.command == "doc-check":
|
|
1451
|
+
try:
|
|
1452
|
+
import json as json_mod
|
|
1453
|
+
|
|
1454
|
+
rpt = doc_check(args.path)
|
|
1455
|
+
|
|
1456
|
+
if getattr(args, "json_output", False):
|
|
1457
|
+
print(json_mod.dumps(rpt, indent=2))
|
|
1458
|
+
return 0 if rpt.get("status") == "ok" else 1
|
|
1459
|
+
|
|
1460
|
+
total = rpt.get("total_refs", 0)
|
|
1461
|
+
drifted = rpt.get("drifted", 0)
|
|
1462
|
+
missing_sym = rpt.get("missing_symbols", 0)
|
|
1463
|
+
missing_files = rpt.get("missing_files", 0)
|
|
1464
|
+
status = rpt.get("status", "ok")
|
|
1465
|
+
details = rpt.get("details", [])
|
|
1466
|
+
|
|
1467
|
+
if status == "ok":
|
|
1468
|
+
print(f"✓ docs: {total} ref(s) checked, all current")
|
|
1469
|
+
return 0
|
|
1470
|
+
|
|
1471
|
+
print(f"✗ docs: {total} ref(s) checked, issues found")
|
|
1472
|
+
print()
|
|
1473
|
+
|
|
1474
|
+
if drifted:
|
|
1475
|
+
print(f" Drifted: {drifted} ref(s)")
|
|
1476
|
+
if missing_sym:
|
|
1477
|
+
print(f" Missing symbols: {missing_sym}")
|
|
1478
|
+
if missing_files:
|
|
1479
|
+
print(f" Missing files: {missing_files}")
|
|
1480
|
+
|
|
1481
|
+
if details:
|
|
1482
|
+
print()
|
|
1483
|
+
for d in details:
|
|
1484
|
+
kind = d.get("type", "unknown")
|
|
1485
|
+
icon = "!" if kind == "drift" else "✗"
|
|
1486
|
+
doc = d.get("doc_file", "?")
|
|
1487
|
+
src = d.get("source_file", d.get("file", "?"))
|
|
1488
|
+
msg = d.get("message", d.get("symbol", ""))
|
|
1489
|
+
print(f" {icon} {doc} → {src}: {msg}")
|
|
1490
|
+
|
|
1491
|
+
print()
|
|
1492
|
+
return 1
|
|
1493
|
+
except Exception as e:
|
|
1494
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1495
|
+
return 1
|
|
1496
|
+
|
|
1497
|
+
parser.print_help()
|
|
1498
|
+
return 1
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
if __name__ == "__main__":
|
|
1502
|
+
sys.exit(main())
|