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
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())