codd-dev 1.9.2__tar.gz → 1.11.0__tar.gz

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 (55) hide show
  1. {codd_dev-1.9.2 → codd_dev-1.11.0}/PKG-INFO +141 -1
  2. {codd_dev-1.9.2 → codd_dev-1.11.0}/README.md +140 -0
  3. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/cli.py +113 -17
  4. codd_dev-1.11.0/codd/drift.py +157 -0
  5. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/extractor.py +41 -0
  6. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/implementer.py +135 -16
  7. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/parsing.py +254 -0
  8. codd_dev-1.11.0/codd/routes_extractor.py +120 -0
  9. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/scanner.py +55 -0
  10. {codd_dev-1.9.2 → codd_dev-1.11.0}/pyproject.toml +1 -1
  11. {codd_dev-1.9.2 → codd_dev-1.11.0}/.gitignore +0 -0
  12. {codd_dev-1.9.2 → codd_dev-1.11.0}/LICENSE +0 -0
  13. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/__init__.py +0 -0
  14. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/assembler.py +0 -0
  15. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/bridge.py +0 -0
  16. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/clustering.py +0 -0
  17. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/config.py +0 -0
  18. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/contracts.py +0 -0
  19. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/defaults.yaml +0 -0
  20. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/e2e_runner.py +0 -0
  21. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/env_refs.py +0 -0
  22. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/extract_ai.py +0 -0
  23. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/fixer.py +0 -0
  24. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/generator.py +0 -0
  25. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/graph.py +0 -0
  26. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/hooks/__init__.py +0 -0
  27. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/hooks/pre-commit +0 -0
  28. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/inheritance.py +0 -0
  29. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/mcp_server.py +0 -0
  30. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/measure.py +0 -0
  31. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/planner.py +0 -0
  32. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/policy.py +0 -0
  33. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/propagate.py +0 -0
  34. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/propagator.py +0 -0
  35. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/repair_slice.py +0 -0
  36. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/require.py +0 -0
  37. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/require_plugins.py +0 -0
  38. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/restore.py +0 -0
  39. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/schema_refs.py +0 -0
  40. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/synth.py +0 -0
  41. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/codd.yaml.tmpl +0 -0
  42. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/conventions.yaml.tmpl +0 -0
  43. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
  44. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/doc_links.yaml.tmpl +0 -0
  45. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
  46. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
  47. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
  48. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
  49. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/extracted/system-context.md.j2 +0 -0
  50. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/gitignore.tmpl +0 -0
  51. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/templates/overrides.yaml.tmpl +0 -0
  52. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/traceability.py +0 -0
  53. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/validator.py +0 -0
  54. {codd_dev-1.9.2 → codd_dev-1.11.0}/codd/wiring.py +0 -0
  55. {codd_dev-1.9.2 → codd_dev-1.11.0}/docs/requirements/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codd-dev
3
- Version: 1.9.2
3
+ Version: 1.11.0
4
4
  Summary: CoDD: Coherence-Driven Development — cross-artifact change impact analysis
5
5
  Project-URL: Homepage, https://github.com/yohey-w/codd-dev
6
6
  Project-URL: Repository, https://github.com/yohey-w/codd-dev
@@ -60,6 +60,19 @@ Description-Content-Type: text/markdown
60
60
  pip install codd-dev
61
61
  ```
62
62
 
63
+ ## 🆕 v1.11.0 — Filesystem-Routing Aware Drift Detection
64
+
65
+ CoDD now understands filesystem-routing frameworks (Next.js, SvelteKit, Nuxt, Astro, Remix) and can detect URL drift between your design docs and actual implementation.
66
+
67
+ - 📐 `FileSystemRouteExtractor` — endpoint nodes from directory structure
68
+ - 🔗 `DocumentUrlLinker` — auto-link design doc URLs to endpoints
69
+ - 🔍 `codd drift` — find URL gaps between design and implementation
70
+ - 🎨 `codd extract --layer routes` — reverse-engineer screen-flow diagrams
71
+
72
+ See [Filesystem Routing Adapter Recipes](#filesystem-routing-adapter-recipes) for setup.
73
+
74
+ ---
75
+
63
76
  **v1.9.0** — `codd implement` now supports **multi-AI engine** (Claude stdout + Codex file-writing) and **automatic parallel execution** within phases via git worktree isolation. Phase milestone format (`#### M1.1`) supported. AI command timeout extended to 1 hour for heavy reasoning models. SWE-bench Verified: **73/73 = 100%** resolved.
64
77
 
65
78
  ---
@@ -325,6 +338,133 @@ codd init --config-dir .codd --project-name "my-project" --language "python"
325
338
 
326
339
  All other commands (`scan`, `impact`, `generate`, etc.) automatically discover whichever config directory exists — `codd/` first, then `.codd/`. No extra flags needed.
327
340
 
341
+ ## Filesystem Routing Adapter Recipes
342
+
343
+ CoDD detects URL drift between your design documents and implementation
344
+ using framework conventions declared in `codd.yaml`.
345
+ These recipes cover the five major filesystem-routing frameworks.
346
+
347
+ | Framework | Base dir | Page glob | API glob | Dynamic segment |
348
+ |------------------------|---------------|------------------|---------------------------|-----------------------|
349
+ | Next.js (App Router) | `app/` | `page.{tsx,jsx}` | `route.{ts,js}` | `[param]` → `:param` |
350
+ | Next.js (Pages Router) | `pages/` | `*.{tsx,jsx}` | `api/**/*.{ts,js}` | `[param]` → `:param` |
351
+ | SvelteKit | `src/routes/` | `+page.svelte` | `+server.{ts,js}` | `[param]` → `:param` |
352
+ | Nuxt 3 | `pages/` | `*.vue` | `server/api/**/*.{ts,js}` | `[param]` → `:param` |
353
+ | Astro | `src/pages/` | `*.astro` | `*.{ts,js}` (in pages) | `[...slug]` → `:slug` |
354
+ | Remix | `app/routes/` | `*.{tsx,jsx}` | `*.{ts,js}` | `$param` → `:param` |
355
+
356
+ ### Next.js (App Router)
357
+
358
+ ```yaml
359
+ filesystem_routes:
360
+ - base_dir: app/
361
+ page_pattern: "page.{tsx,jsx,ts,js}"
362
+ api_pattern: "route.{ts,js}"
363
+ url_template: "/{relative_dir}"
364
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
365
+ ignore_segment: ["\\(.*\\)", "@.*"]
366
+ base_url: ""
367
+ ```
368
+
369
+ ### Next.js (Pages Router)
370
+
371
+ ```yaml
372
+ filesystem_routes:
373
+ - base_dir: pages/
374
+ page_pattern: "*.{tsx,jsx,ts,js}"
375
+ api_pattern: ""
376
+ url_template: "/{relative_dir}/{stem}"
377
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
378
+ ignore_segment: ["^_.*"]
379
+ base_url: ""
380
+ - base_dir: pages/api/
381
+ page_pattern: ""
382
+ api_pattern: "*.{ts,js}"
383
+ url_template: "/api/{relative_dir}/{stem}"
384
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
385
+ ignore_segment: []
386
+ base_url: ""
387
+ ```
388
+
389
+ ### SvelteKit
390
+
391
+ ```yaml
392
+ filesystem_routes:
393
+ - base_dir: src/routes/
394
+ page_pattern: "+page.svelte"
395
+ api_pattern: "+server.{ts,js}"
396
+ url_template: "/{relative_dir}"
397
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
398
+ ignore_segment: ["\\(.*\\)"]
399
+ base_url: ""
400
+ ```
401
+
402
+ ### Nuxt 3
403
+
404
+ ```yaml
405
+ filesystem_routes:
406
+ - base_dir: pages/
407
+ page_pattern: "*.vue"
408
+ api_pattern: ""
409
+ url_template: "/{relative_dir}/{stem}"
410
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
411
+ ignore_segment: []
412
+ base_url: ""
413
+ - base_dir: server/api/
414
+ page_pattern: ""
415
+ api_pattern: "*.{ts,js}"
416
+ url_template: "/api/{relative_dir}/{stem}"
417
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
418
+ ignore_segment: []
419
+ base_url: ""
420
+ ```
421
+
422
+ ### Astro
423
+
424
+ ```yaml
425
+ filesystem_routes:
426
+ - base_dir: src/pages/
427
+ page_pattern: "*.astro"
428
+ api_pattern: "*.{ts,js}"
429
+ url_template: "/{relative_dir}/{stem}"
430
+ dynamic_segment: { from: "\\[\\.\\.\\.(\\w+)\\]", to: ":$1" }
431
+ ignore_segment: []
432
+ base_url: ""
433
+ ```
434
+
435
+ ### Remix
436
+
437
+ ```yaml
438
+ filesystem_routes:
439
+ - base_dir: app/routes/
440
+ page_pattern: "*.{tsx,jsx}"
441
+ api_pattern: "*.{ts,js}"
442
+ url_template: "/{segments}"
443
+ dynamic_segment: { from: "\\$(\\w+)", to: ":$1" }
444
+ ignore_segment: ["^_.*"]
445
+ base_url: ""
446
+ ```
447
+
448
+ ### Enable URL Drift Detection
449
+
450
+ Add to `codd.yaml` to automatically link design doc URLs to endpoints:
451
+
452
+ ```yaml
453
+ document_url_linking:
454
+ enabled: true
455
+ applies_to: [design, requirement]
456
+ url_pattern: "(?:^|[\\s`(\\[])(/[a-z0-9][a-z0-9/\\-:_\\[\\]]*)"
457
+ edge_type: references
458
+ ```
459
+
460
+ Then run:
461
+
462
+ ```bash
463
+ codd drift # detect URL drift between design docs and implementation
464
+ codd drift --format json # machine-readable output
465
+ codd extract --layer routes --format mermaid # generate screen-flow diagram
466
+ ```
467
+
328
468
  ## Brownfield? Start Here
329
469
 
330
470
  Already have a codebase? CoDD provides a full brownfield workflow — from code extraction to design doc reconstruction.
@@ -22,6 +22,19 @@
22
22
  pip install codd-dev
23
23
  ```
24
24
 
25
+ ## 🆕 v1.11.0 — Filesystem-Routing Aware Drift Detection
26
+
27
+ CoDD now understands filesystem-routing frameworks (Next.js, SvelteKit, Nuxt, Astro, Remix) and can detect URL drift between your design docs and actual implementation.
28
+
29
+ - 📐 `FileSystemRouteExtractor` — endpoint nodes from directory structure
30
+ - 🔗 `DocumentUrlLinker` — auto-link design doc URLs to endpoints
31
+ - 🔍 `codd drift` — find URL gaps between design and implementation
32
+ - 🎨 `codd extract --layer routes` — reverse-engineer screen-flow diagrams
33
+
34
+ See [Filesystem Routing Adapter Recipes](#filesystem-routing-adapter-recipes) for setup.
35
+
36
+ ---
37
+
25
38
  **v1.9.0** — `codd implement` now supports **multi-AI engine** (Claude stdout + Codex file-writing) and **automatic parallel execution** within phases via git worktree isolation. Phase milestone format (`#### M1.1`) supported. AI command timeout extended to 1 hour for heavy reasoning models. SWE-bench Verified: **73/73 = 100%** resolved.
26
39
 
27
40
  ---
@@ -287,6 +300,133 @@ codd init --config-dir .codd --project-name "my-project" --language "python"
287
300
 
288
301
  All other commands (`scan`, `impact`, `generate`, etc.) automatically discover whichever config directory exists — `codd/` first, then `.codd/`. No extra flags needed.
289
302
 
303
+ ## Filesystem Routing Adapter Recipes
304
+
305
+ CoDD detects URL drift between your design documents and implementation
306
+ using framework conventions declared in `codd.yaml`.
307
+ These recipes cover the five major filesystem-routing frameworks.
308
+
309
+ | Framework | Base dir | Page glob | API glob | Dynamic segment |
310
+ |------------------------|---------------|------------------|---------------------------|-----------------------|
311
+ | Next.js (App Router) | `app/` | `page.{tsx,jsx}` | `route.{ts,js}` | `[param]` → `:param` |
312
+ | Next.js (Pages Router) | `pages/` | `*.{tsx,jsx}` | `api/**/*.{ts,js}` | `[param]` → `:param` |
313
+ | SvelteKit | `src/routes/` | `+page.svelte` | `+server.{ts,js}` | `[param]` → `:param` |
314
+ | Nuxt 3 | `pages/` | `*.vue` | `server/api/**/*.{ts,js}` | `[param]` → `:param` |
315
+ | Astro | `src/pages/` | `*.astro` | `*.{ts,js}` (in pages) | `[...slug]` → `:slug` |
316
+ | Remix | `app/routes/` | `*.{tsx,jsx}` | `*.{ts,js}` | `$param` → `:param` |
317
+
318
+ ### Next.js (App Router)
319
+
320
+ ```yaml
321
+ filesystem_routes:
322
+ - base_dir: app/
323
+ page_pattern: "page.{tsx,jsx,ts,js}"
324
+ api_pattern: "route.{ts,js}"
325
+ url_template: "/{relative_dir}"
326
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
327
+ ignore_segment: ["\\(.*\\)", "@.*"]
328
+ base_url: ""
329
+ ```
330
+
331
+ ### Next.js (Pages Router)
332
+
333
+ ```yaml
334
+ filesystem_routes:
335
+ - base_dir: pages/
336
+ page_pattern: "*.{tsx,jsx,ts,js}"
337
+ api_pattern: ""
338
+ url_template: "/{relative_dir}/{stem}"
339
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
340
+ ignore_segment: ["^_.*"]
341
+ base_url: ""
342
+ - base_dir: pages/api/
343
+ page_pattern: ""
344
+ api_pattern: "*.{ts,js}"
345
+ url_template: "/api/{relative_dir}/{stem}"
346
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
347
+ ignore_segment: []
348
+ base_url: ""
349
+ ```
350
+
351
+ ### SvelteKit
352
+
353
+ ```yaml
354
+ filesystem_routes:
355
+ - base_dir: src/routes/
356
+ page_pattern: "+page.svelte"
357
+ api_pattern: "+server.{ts,js}"
358
+ url_template: "/{relative_dir}"
359
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
360
+ ignore_segment: ["\\(.*\\)"]
361
+ base_url: ""
362
+ ```
363
+
364
+ ### Nuxt 3
365
+
366
+ ```yaml
367
+ filesystem_routes:
368
+ - base_dir: pages/
369
+ page_pattern: "*.vue"
370
+ api_pattern: ""
371
+ url_template: "/{relative_dir}/{stem}"
372
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
373
+ ignore_segment: []
374
+ base_url: ""
375
+ - base_dir: server/api/
376
+ page_pattern: ""
377
+ api_pattern: "*.{ts,js}"
378
+ url_template: "/api/{relative_dir}/{stem}"
379
+ dynamic_segment: { from: "\\[(.+)\\]", to: ":$1" }
380
+ ignore_segment: []
381
+ base_url: ""
382
+ ```
383
+
384
+ ### Astro
385
+
386
+ ```yaml
387
+ filesystem_routes:
388
+ - base_dir: src/pages/
389
+ page_pattern: "*.astro"
390
+ api_pattern: "*.{ts,js}"
391
+ url_template: "/{relative_dir}/{stem}"
392
+ dynamic_segment: { from: "\\[\\.\\.\\.(\\w+)\\]", to: ":$1" }
393
+ ignore_segment: []
394
+ base_url: ""
395
+ ```
396
+
397
+ ### Remix
398
+
399
+ ```yaml
400
+ filesystem_routes:
401
+ - base_dir: app/routes/
402
+ page_pattern: "*.{tsx,jsx}"
403
+ api_pattern: "*.{ts,js}"
404
+ url_template: "/{segments}"
405
+ dynamic_segment: { from: "\\$(\\w+)", to: ":$1" }
406
+ ignore_segment: ["^_.*"]
407
+ base_url: ""
408
+ ```
409
+
410
+ ### Enable URL Drift Detection
411
+
412
+ Add to `codd.yaml` to automatically link design doc URLs to endpoints:
413
+
414
+ ```yaml
415
+ document_url_linking:
416
+ enabled: true
417
+ applies_to: [design, requirement]
418
+ url_pattern: "(?:^|[\\s`(\\[])(/[a-z0-9][a-z0-9/\\-:_\\[\\]]*)"
419
+ edge_type: references
420
+ ```
421
+
422
+ Then run:
423
+
424
+ ```bash
425
+ codd drift # detect URL drift between design docs and implementation
426
+ codd drift --format json # machine-readable output
427
+ codd extract --layer routes --format mermaid # generate screen-flow diagram
428
+ ```
429
+
290
430
  ## Brownfield? Start Here
291
431
 
292
432
  Already have a codebase? CoDD provides a full brownfield workflow — from code extraction to design doc reconstruction.
@@ -589,13 +589,38 @@ def verify(path: str, sprint: int | None, e2e: bool, deploy: bool, base_url: str
589
589
  default=None,
590
590
  help="Override AI CLI command (default: codd.yaml ai_command or claude --print)",
591
591
  )
592
- @click.option(
593
- "--prompt-file",
594
- default=None,
595
- type=click.Path(exists=True),
596
- help="Custom extraction prompt file (overrides built-in baseline preset)",
597
- )
598
- def extract(path: str, language: str | None, source_dirs: str | None, output: str | None, ai: bool, ai_cmd: str | None, prompt_file: str | None):
592
+ @click.option(
593
+ "--prompt-file",
594
+ default=None,
595
+ type=click.Path(exists=True),
596
+ help="Custom extraction prompt file (overrides built-in baseline preset)",
597
+ )
598
+ @click.option(
599
+ "--layer",
600
+ default=None,
601
+ type=click.Choice(["routes"]),
602
+ help="Extract specific layer (routes: filesystem routes as Mermaid diagram)",
603
+ )
604
+ @click.option(
605
+ "--format",
606
+ "output_format",
607
+ default="mermaid",
608
+ type=click.Choice(["mermaid"]),
609
+ help="Output format for --layer extraction",
610
+ )
611
+ @click.option("--output-file", default=None, help="Output file for --layer routes (default: stdout)")
612
+ def extract(
613
+ path: str,
614
+ language: str | None,
615
+ source_dirs: str | None,
616
+ output: str | None,
617
+ ai: bool,
618
+ ai_cmd: str | None,
619
+ prompt_file: str | None,
620
+ layer: str | None,
621
+ output_format: str,
622
+ output_file: str | None,
623
+ ):
599
624
  """Extract design documents from existing codebase (brownfield bootstrap).
600
625
 
601
626
  Default mode: static analysis (no AI, pure structural facts).
@@ -604,16 +629,33 @@ def extract(path: str, language: str | None, source_dirs: str | None, output: st
604
629
  Output goes to the discovered CoDD config dir as draft documents
605
630
  (`codd/extracted/` or `.codd/extracted/`). Review and promote
606
631
  confirmed docs when ready.
607
- """
608
- project_root = Path(path).resolve()
609
- bootstrap_codd_dir = _resolve_bootstrap_codd_dir(project_root)
610
- dirs = [d.strip() for d in source_dirs.split(",") if d.strip()] if source_dirs else None
611
- output_path = Path(output) if output else bootstrap_codd_dir / "extracted"
612
-
613
- if ai:
614
- from codd.extract_ai import run_extract_ai
615
- from codd.extractor import extract_facts
616
- from codd.config import load_project_config
632
+ """
633
+ project_root = Path(path).resolve()
634
+ bootstrap_codd_dir = _resolve_bootstrap_codd_dir(project_root)
635
+ dirs = [d.strip() for d in source_dirs.split(",") if d.strip()] if source_dirs else None
636
+ output_path = Path(output) if output else bootstrap_codd_dir / "extracted"
637
+
638
+ if layer == "routes":
639
+ from codd.config import load_project_config
640
+ from codd.routes_extractor import generate_mermaid_screen_flow
641
+
642
+ config = load_project_config(project_root)
643
+ route_configs = config.get("filesystem_routes", [])
644
+ result = generate_mermaid_screen_flow(project_root, route_configs)
645
+ content = f"```{output_format}\n{result.mermaid}\n```\n"
646
+ if output_file:
647
+ destination = Path(output_file)
648
+ destination.parent.mkdir(parents=True, exist_ok=True)
649
+ destination.write_text(content, encoding="utf-8")
650
+ click.echo(f"Extracted {result.route_count} routes -> {output_file}")
651
+ else:
652
+ click.echo(content)
653
+ return
654
+
655
+ if ai:
656
+ from codd.extract_ai import run_extract_ai
657
+ from codd.extractor import extract_facts
658
+ from codd.config import load_project_config
617
659
 
618
660
  # Resolve AI command
619
661
  if ai_cmd is None:
@@ -909,6 +951,60 @@ def policy(path: str):
909
951
  raise SystemExit(0 if result.pass_ else 1)
910
952
 
911
953
 
954
+ @main.command("drift")
955
+ @click.option("--path", default=".", help="Project root directory")
956
+ @click.option(
957
+ "--format",
958
+ "output_format",
959
+ default="text",
960
+ type=click.Choice(["text", "json"]),
961
+ help="Output format",
962
+ )
963
+ def drift(path: str, output_format: str):
964
+ """Detect drift between design-referenced URLs and implementation endpoints.
965
+
966
+ Exit code 0 = no drift. Exit code 1 = drift detected (use in CI).
967
+ """
968
+ from codd.drift import run_drift
969
+
970
+ project_root = Path(path).resolve()
971
+ codd_dir = _require_codd_dir(project_root)
972
+ result = run_drift(project_root, codd_dir)
973
+
974
+ if output_format == "json":
975
+ click.echo(
976
+ json.dumps(
977
+ {
978
+ "design_urls": result.design_urls,
979
+ "impl_urls": result.impl_urls,
980
+ "drift": [
981
+ {
982
+ "kind": entry.kind,
983
+ "url": entry.url,
984
+ "source": entry.source,
985
+ "closest_match": entry.closest_match,
986
+ }
987
+ for entry in result.drift
988
+ ],
989
+ },
990
+ ensure_ascii=False,
991
+ indent=2,
992
+ )
993
+ )
994
+ else:
995
+ for entry in result.drift:
996
+ label = f"[drift {entry.kind}]"
997
+ closest = f" (closest: {entry.closest_match})" if entry.closest_match else ""
998
+ source = f" in {entry.source}" if entry.source else ""
999
+ click.echo(f"{label} {entry.url}{source}{closest}")
1000
+ if not result.drift:
1001
+ click.echo("No drift detected.")
1002
+ else:
1003
+ click.echo(f"\n{len(result.drift)} drift(s) found.")
1004
+
1005
+ raise SystemExit(result.exit_code)
1006
+
1007
+
912
1008
  @main.command()
913
1009
  @click.option("--path", default=".", help="Project root directory")
914
1010
  @click.option("--json", "as_json", is_flag=True, help="Output plan as JSON")
@@ -0,0 +1,157 @@
1
+ """codd drift - Detect design-to-implementation URL drift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Sequence
8
+
9
+
10
+ @dataclass
11
+ class DriftEntry:
12
+ kind: str
13
+ url: str
14
+ source: str
15
+ closest_match: str
16
+
17
+
18
+ @dataclass
19
+ class DriftResult:
20
+ design_urls: list[str]
21
+ impl_urls: list[str]
22
+ drift: list[DriftEntry] = field(default_factory=list)
23
+ exit_code: int = 0
24
+
25
+
26
+ def compute_drift(
27
+ design_urls: Sequence[str],
28
+ impl_urls: Sequence[str],
29
+ design_sources: dict[str, str] | None = None,
30
+ ) -> DriftResult:
31
+ """Compute drift between design-referenced URLs and implementation endpoints."""
32
+ design_sources = design_sources or {}
33
+ normalized_design_urls = _unique_urls(design_urls)
34
+ normalized_impl_urls = _unique_urls(impl_urls)
35
+ design_set = set(normalized_design_urls)
36
+ impl_set = set(normalized_impl_urls)
37
+
38
+ drift: list[DriftEntry] = []
39
+ for url in normalized_design_urls:
40
+ if url not in impl_set:
41
+ drift.append(
42
+ DriftEntry(
43
+ kind="design-only",
44
+ url=url,
45
+ source=design_sources.get(url, ""),
46
+ closest_match=_find_closest(url, normalized_impl_urls),
47
+ )
48
+ )
49
+
50
+ for url in normalized_impl_urls:
51
+ if url not in design_set:
52
+ drift.append(
53
+ DriftEntry(
54
+ kind="impl-only",
55
+ url=url,
56
+ source="implementation",
57
+ closest_match=_find_closest(url, normalized_design_urls),
58
+ )
59
+ )
60
+
61
+ return DriftResult(
62
+ design_urls=normalized_design_urls,
63
+ impl_urls=normalized_impl_urls,
64
+ drift=drift,
65
+ exit_code=1 if drift else 0,
66
+ )
67
+
68
+
69
+ def _find_closest(url: str, candidates: list[str]) -> str:
70
+ """Find closest URL from candidates using common-prefix heuristic."""
71
+ if not candidates:
72
+ return ""
73
+
74
+ def score(candidate: str) -> int:
75
+ prefix = 0
76
+ for left, right in zip(url, candidate):
77
+ if left != right:
78
+ break
79
+ prefix += 1
80
+ return prefix
81
+
82
+ return max(candidates, key=score)
83
+
84
+
85
+ def run_drift(project_root: Path, codd_dir: Path) -> DriftResult:
86
+ """Full drift run: read codd.yaml, extract URLs, and compute drift."""
87
+ import yaml
88
+
89
+ config_path = codd_dir / "codd.yaml"
90
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
91
+
92
+ impl_urls = _extract_impl_urls(project_root, config)
93
+ design_urls, design_sources = _extract_design_urls(project_root, config)
94
+
95
+ return compute_drift(design_urls, impl_urls, design_sources)
96
+
97
+
98
+ def _extract_impl_urls(project_root: Path, config: dict[str, Any]) -> list[str]:
99
+ fs_route_configs = config.get("filesystem_routes", [])
100
+ if not fs_route_configs:
101
+ return []
102
+
103
+ try:
104
+ from codd.parsing import FileSystemRouteExtractor
105
+ except ImportError:
106
+ return []
107
+
108
+ extractor = FileSystemRouteExtractor()
109
+ route_info = extractor.extract_routes(project_root, fs_route_configs)
110
+ return [_route_url(route) for route in getattr(route_info, "routes", []) if _route_url(route)]
111
+
112
+
113
+ def _extract_design_urls(project_root: Path, config: dict[str, Any]) -> tuple[list[str], dict[str, str]]:
114
+ doc_link_config = config.get("document_url_linking", {})
115
+ if not doc_link_config.get("enabled", False):
116
+ return [], {}
117
+
118
+ try:
119
+ from codd.extractor import DocumentUrlLinker
120
+ except ImportError:
121
+ return [], {}
122
+
123
+ linker = DocumentUrlLinker(doc_link_config)
124
+ design_urls: list[str] = []
125
+ design_sources: dict[str, str] = {}
126
+ doc_dirs = config.get("scan", {}).get("doc_dirs", [])
127
+
128
+ for doc_dir in doc_dirs:
129
+ full_dir = project_root / doc_dir
130
+ if not full_dir.exists():
131
+ continue
132
+ for md_file in full_dir.rglob("*.md"):
133
+ text = md_file.read_text(encoding="utf-8", errors="ignore")
134
+ rel_path = md_file.relative_to(project_root).as_posix()
135
+ result = linker.extract_urls(text, rel_path)
136
+ for url in getattr(result, "urls", []):
137
+ design_urls.append(url)
138
+ design_sources.setdefault(url, getattr(result, "node_id", rel_path))
139
+
140
+ return design_urls, design_sources
141
+
142
+
143
+ def _unique_urls(urls: Sequence[str]) -> list[str]:
144
+ seen: set[str] = set()
145
+ unique: list[str] = []
146
+ for url in urls:
147
+ if url in seen:
148
+ continue
149
+ seen.add(url)
150
+ unique.append(url)
151
+ return unique
152
+
153
+
154
+ def _route_url(route: Any) -> str:
155
+ if isinstance(route, dict):
156
+ return str(route.get("url", ""))
157
+ return str(getattr(route, "url", ""))
@@ -1018,3 +1018,44 @@ def run_extract(project_root: Path, language: str | None = None,
1018
1018
  def _match_glob(path: str, pattern: str) -> bool:
1019
1019
  import fnmatch
1020
1020
  return fnmatch.fnmatch(path, pattern)
1021
+
1022
+
1023
+ @dataclass
1024
+ class DocumentUrlLinkInfo:
1025
+ """URLs extracted from a document node."""
1026
+ node_id: str
1027
+ urls: list[str]
1028
+
1029
+
1030
+ class DocumentUrlLinker:
1031
+ """Scan design/requirement document text for URL patterns.
1032
+
1033
+ Extracts URL strings referenced in documents (Mermaid diagrams, prose,
1034
+ code blocks) and returns them for downstream drift analysis.
1035
+ FW-agnostic: pattern driven by codd.yaml document_url_linking config.
1036
+ """
1037
+
1038
+ DEFAULT_URL_PATTERN = r"(?:^|[\s`(\[])(/(?:[a-z0-9][a-z0-9/\-:_\[\]]*)?)"
1039
+ DEFAULT_EDGE_TYPE = "references"
1040
+
1041
+ def __init__(self, config: dict | None = None):
1042
+ cfg = config or {}
1043
+ self._pattern = re.compile(
1044
+ cfg.get("url_pattern", self.DEFAULT_URL_PATTERN),
1045
+ re.MULTILINE,
1046
+ )
1047
+ self.edge_type = cfg.get("edge_type", self.DEFAULT_EDGE_TYPE)
1048
+
1049
+ def extract_urls(self, text: str, node_id: str = "") -> DocumentUrlLinkInfo:
1050
+ """Extract and normalize URLs from document text."""
1051
+ raw = self._pattern.findall(text)
1052
+ normalized = sorted(set(self._normalize_url(url) for url in raw if url))
1053
+ return DocumentUrlLinkInfo(node_id=node_id, urls=normalized)
1054
+
1055
+ def _normalize_url(self, url: str) -> str:
1056
+ url = url.strip()
1057
+ while url.endswith("]") and url.count("]") > url.count("["):
1058
+ url = url[:-1]
1059
+ if url != "/" and url.endswith("/"):
1060
+ url = url.rstrip("/")
1061
+ return url