codd-dev 1.2.1__tar.gz → 1.3.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 (46) hide show
  1. {codd_dev-1.2.1 → codd_dev-1.3.0}/PKG-INFO +17 -1
  2. {codd_dev-1.2.1 → codd_dev-1.3.0}/README.md +16 -0
  3. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/__init__.py +1 -1
  4. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/cli.py +65 -12
  5. codd_dev-1.3.0/codd/require.py +417 -0
  6. {codd_dev-1.2.1 → codd_dev-1.3.0}/pyproject.toml +1 -1
  7. {codd_dev-1.2.1 → codd_dev-1.3.0}/.gitignore +0 -0
  8. {codd_dev-1.2.1 → codd_dev-1.3.0}/LICENSE +0 -0
  9. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/assembler.py +0 -0
  10. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/clustering.py +0 -0
  11. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/config.py +0 -0
  12. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/contracts.py +0 -0
  13. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/defaults.yaml +0 -0
  14. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/env_refs.py +0 -0
  15. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/extractor.py +0 -0
  16. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/generator.py +0 -0
  17. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/graph.py +0 -0
  18. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/hooks/__init__.py +0 -0
  19. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/hooks/pre-commit +0 -0
  20. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/implementer.py +0 -0
  21. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/inheritance.py +0 -0
  22. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/parsing.py +0 -0
  23. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/planner.py +0 -0
  24. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/propagate.py +0 -0
  25. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/propagator.py +0 -0
  26. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/restore.py +0 -0
  27. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/reviewer.py +0 -0
  28. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/risk.py +0 -0
  29. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/scanner.py +0 -0
  30. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/schema_refs.py +0 -0
  31. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/synth.py +0 -0
  32. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/codd.yaml.tmpl +0 -0
  33. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/conventions.yaml.tmpl +0 -0
  34. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
  35. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/doc_links.yaml.tmpl +0 -0
  36. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
  37. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
  38. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
  39. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
  40. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/system-context.md.j2 +0 -0
  41. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/gitignore.tmpl +0 -0
  42. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/overrides.yaml.tmpl +0 -0
  43. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/traceability.py +0 -0
  44. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/validator.py +0 -0
  45. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/verifier.py +0 -0
  46. {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/wiring.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codd-dev
3
- Version: 1.2.1
3
+ Version: 1.3.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
@@ -351,6 +351,22 @@ ai_commands:
351
351
 
352
352
  **Resolution priority**: CLI `--ai-cmd` flag > `ai_commands.{command}` > `ai_command` > built-in default (Opus).
353
353
 
354
+ ### Claude Code Context Interference
355
+
356
+ When `claude --print` runs inside a project directory, it auto-discovers `CLAUDE.md` and loads project-level system prompts. These instructions can conflict with CoDD's generation prompts, causing format validation failures like:
357
+
358
+ ```
359
+ Error: AI command returned unstructured summary for 'ADR: ...'; missing section headings
360
+ ```
361
+
362
+ **Fix**: Use `--system-prompt` to override project context with a focused instruction:
363
+
364
+ ```yaml
365
+ ai_command: "claude --print --model claude-opus-4-6 --system-prompt 'You are a technical document generator. Output only the requested Markdown document. Follow section heading instructions exactly.'"
366
+ ```
367
+
368
+ > **Note**: `--bare` strips all context but also disables OAuth authentication. Use `--system-prompt` instead — it overrides `CLAUDE.md` while preserving auth.
369
+
354
370
  ## Config Directory Discovery
355
371
 
356
372
  By default, `codd init` creates a `codd/` directory. If your project already has a `codd/` directory (e.g., it's your source code package), use `--config-dir`:
@@ -314,6 +314,22 @@ ai_commands:
314
314
 
315
315
  **Resolution priority**: CLI `--ai-cmd` flag > `ai_commands.{command}` > `ai_command` > built-in default (Opus).
316
316
 
317
+ ### Claude Code Context Interference
318
+
319
+ When `claude --print` runs inside a project directory, it auto-discovers `CLAUDE.md` and loads project-level system prompts. These instructions can conflict with CoDD's generation prompts, causing format validation failures like:
320
+
321
+ ```
322
+ Error: AI command returned unstructured summary for 'ADR: ...'; missing section headings
323
+ ```
324
+
325
+ **Fix**: Use `--system-prompt` to override project context with a focused instruction:
326
+
327
+ ```yaml
328
+ ai_command: "claude --print --model claude-opus-4-6 --system-prompt 'You are a technical document generator. Output only the requested Markdown document. Follow section heading instructions exactly.'"
329
+ ```
330
+
331
+ > **Note**: `--bare` strips all context but also disables OAuth authentication. Use `--system-prompt` instead — it overrides `CLAUDE.md` while preserving auth.
332
+
317
333
  ## Config Directory Discovery
318
334
 
319
335
  By default, `codd init` creates a `codd/` directory. If your project already has a `codd/` directory (e.g., it's your source code package), use `--config-dir`:
@@ -1,3 +1,3 @@
1
1
  """CoDD — Coherence-Driven Development."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "1.3.0"
@@ -1,4 +1,4 @@
1
- """CoDD CLI — codd init / scan / impact / plan."""
1
+ """CoDD CLI — codd init / scan / impact / require / plan."""
2
2
 
3
3
  import click
4
4
  import json
@@ -175,8 +175,8 @@ def generate(wave: int, path: str, force: bool, ai_cmd: str | None, feedback: st
175
175
  help="Override AI CLI command (defaults to codd.yaml ai_command or 'claude --print')",
176
176
  )
177
177
  @click.option("--feedback", default=None, help="Review feedback to address in this restoration (from codd review)")
178
- def restore(wave: int, path: str, force: bool, ai_cmd: str | None, feedback: str | None):
179
- """Restore design documents from extracted codebase facts (brownfield).
178
+ def restore(wave: int, path: str, force: bool, ai_cmd: str | None, feedback: str | None):
179
+ """Restore design documents from extracted codebase facts (brownfield).
180
180
 
181
181
  Unlike 'generate' which creates design docs from requirements (greenfield),
182
182
  'restore' reconstructs design documents from extracted code analysis.
@@ -206,15 +206,68 @@ def restore(wave: int, path: str, force: bool, ai_cmd: str | None, feedback: str
206
206
  restored += 1
207
207
  else:
208
208
  skipped += 1
209
-
210
- click.echo(f"Wave {wave}: {restored} restored, {skipped} skipped")
211
-
212
-
213
- @main.command()
214
- @click.option("--diff", default="HEAD", help="Git diff target (default: HEAD, shows uncommitted changes)")
215
- @click.option("--path", default=".", help="Project root directory")
216
- @click.option("--update", is_flag=True, help="Actually update affected design docs via AI")
217
- @click.option(
209
+
210
+ click.echo(f"Wave {wave}: {restored} restored, {skipped} skipped")
211
+
212
+
213
+ @main.command()
214
+ @click.option("--path", default=".", help="Project root directory")
215
+ @click.option("--output", default="docs/requirements/", help="Output directory for generated requirements")
216
+ @click.option("--scope", default=None, help="Limit to a specific service boundary")
217
+ @click.option(
218
+ "--ai-cmd",
219
+ default=None,
220
+ help="Override AI CLI command (defaults to codd.yaml ai_command or merged CoDD defaults)",
221
+ )
222
+ @click.option("--force", is_flag=True, help="Overwrite existing files")
223
+ @click.option("--feedback", default=None, help="Review feedback from previous generation")
224
+ def require(path: str, output: str, scope: str | None, ai_cmd: str | None, force: bool, feedback: str | None):
225
+ """Infer requirements from extracted codebase facts (brownfield).
226
+
227
+ Unlike 'restore' which reconstructs design docs from extracted facts,
228
+ 'require' reverse-engineers requirements documents from the same
229
+ extracted code analysis. Run 'codd extract' first.
230
+ """
231
+ from codd.require import run_require
232
+
233
+ project_root = Path(path).resolve()
234
+ _require_codd_dir(project_root)
235
+
236
+ try:
237
+ results = run_require(
238
+ project_root,
239
+ output_dir=output,
240
+ scope=scope,
241
+ ai_command=ai_cmd,
242
+ force=force,
243
+ feedback=feedback,
244
+ )
245
+ except (FileNotFoundError, ValueError) as exc:
246
+ click.echo(f"Error: {exc}")
247
+ raise SystemExit(1)
248
+
249
+ generated = 0
250
+ skipped = 0
251
+
252
+ for result in results:
253
+ try:
254
+ rel_path = result.path.relative_to(project_root).as_posix()
255
+ except ValueError:
256
+ rel_path = result.path.as_posix()
257
+ click.echo(f"{result.status.capitalize()}: {rel_path} ({result.node_id})")
258
+ if result.status == "generated":
259
+ generated += 1
260
+ else:
261
+ skipped += 1
262
+
263
+ click.echo(f"Requirements: {generated} generated, {skipped} skipped")
264
+
265
+
266
+ @main.command()
267
+ @click.option("--diff", default="HEAD", help="Git diff target (default: HEAD, shows uncommitted changes)")
268
+ @click.option("--path", default=".", help="Project root directory")
269
+ @click.option("--update", is_flag=True, help="Actually update affected design docs via AI")
270
+ @click.option(
218
271
  "--ai-cmd",
219
272
  default=None,
220
273
  help="Override AI CLI command (defaults to codd.yaml ai_command)",
@@ -0,0 +1,417 @@
1
+ """CoDD require — infer requirement documents from extracted facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+ import re
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from codd.config import load_project_config
13
+ from codd.generator import _invoke_ai_command, _resolve_ai_command, _sanitize_generated_body
14
+ from codd.planner import ExtractedDocument, _load_extracted_documents
15
+ from codd.restore import INFERRED_REQUIREMENT_SECTIONS
16
+
17
+
18
+ CROSS_CUTTING_CLUSTER = "cross-cutting"
19
+ _CROSS_CUTTING_MARKERS = ("system-context", "architecture-overview")
20
+ _GENERIC_PATH_TOKENS = {
21
+ "app",
22
+ "apps",
23
+ "codd",
24
+ "doc",
25
+ "docs",
26
+ "extracted",
27
+ "lib",
28
+ "libs",
29
+ "module",
30
+ "modules",
31
+ "package",
32
+ "packages",
33
+ "service",
34
+ "services",
35
+ "source",
36
+ "sources",
37
+ "src",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class RequireResult:
43
+ """Result of generating one inferred requirements document."""
44
+
45
+ node_id: str
46
+ path: Path
47
+ status: str
48
+
49
+
50
+ def cluster_extracted_docs(
51
+ docs: list[ExtractedDocument],
52
+ config: dict[str, Any],
53
+ ) -> dict[str, list[ExtractedDocument]]:
54
+ """Group extracted docs into per-boundary clusters plus cross-cutting context."""
55
+ clusters: dict[str, list[ExtractedDocument]] = {}
56
+ cross_cutting_docs = [doc for doc in docs if _is_cross_cutting_doc(doc)]
57
+ module_docs = [doc for doc in docs if not _is_cross_cutting_doc(doc)]
58
+
59
+ service_boundaries = _normalize_service_boundaries(config.get("service_boundaries"))
60
+ assigned_doc_keys: set[str] = set()
61
+
62
+ for boundary_name, boundary_tokens in service_boundaries.items():
63
+ matched = [
64
+ doc for doc in module_docs
65
+ if _doc_key(doc) not in assigned_doc_keys and _doc_matches_boundary(doc, boundary_tokens)
66
+ ]
67
+ if matched:
68
+ clusters[boundary_name] = sorted(matched, key=_doc_key)
69
+ assigned_doc_keys.update(_doc_key(doc) for doc in matched)
70
+
71
+ for doc in module_docs:
72
+ if _doc_key(doc) in assigned_doc_keys:
73
+ continue
74
+ cluster_name = _infer_doc_cluster(doc)
75
+ clusters.setdefault(cluster_name, []).append(doc)
76
+
77
+ for cluster_name, cluster_docs in list(clusters.items()):
78
+ clusters[cluster_name] = sorted(cluster_docs, key=_doc_key)
79
+
80
+ clusters[CROSS_CUTTING_CLUSTER] = sorted(cross_cutting_docs, key=_doc_key)
81
+ return clusters
82
+
83
+
84
+ def _build_require_header(cluster_name: str) -> list[str]:
85
+ """Build the brownfield requirement-inference prompt header."""
86
+ scope_name = "system-wide cross-cutting behavior" if cluster_name == CROSS_CUTTING_CLUSTER else cluster_name
87
+ return [
88
+ "You are INFERRING REQUIREMENTS from an existing codebase (brownfield project).",
89
+ "The extracted documents below describe the actual code — modules, symbols, dependencies, patterns, and architecture.",
90
+ "",
91
+ "Your task is to REVERSE-ENGINEER what the original requirements were, based on what the code does.",
92
+ "These are INFERRED requirements — they describe what WAS built, not what SHOULD be built.",
93
+ "The code is ground truth. Derive the requirements from the structural facts.",
94
+ "",
95
+ "Important distinctions:",
96
+ "- You CANNOT know features that were planned but never implemented.",
97
+ "- You CANNOT distinguish bugs from intentional behavior — describe observed behavior.",
98
+ "- You CANNOT know business context that isn't reflected in code (stakeholder decisions, trade-off reasoning).",
99
+ "- Mark any non-obvious inference with [inferred] or [speculative] so humans can verify later.",
100
+ "",
101
+ "Inference scope:",
102
+ f" Cluster: {scope_name}",
103
+ ]
104
+
105
+
106
+ def build_require_prompt(
107
+ cluster_name: str,
108
+ cluster_docs: list[ExtractedDocument],
109
+ cross_cutting_docs: list[ExtractedDocument],
110
+ feedback: str | None = None,
111
+ ) -> str:
112
+ """Build the AI prompt for one requirements cluster."""
113
+ required_headings = [
114
+ f"## {index}. {section_name}"
115
+ for index, section_name in enumerate(INFERRED_REQUIREMENT_SECTIONS, start=1)
116
+ ]
117
+ unique_cluster_docs = _unique_docs(cluster_docs)
118
+ context_docs = [
119
+ doc for doc in _unique_docs(cross_cutting_docs)
120
+ if _doc_key(doc) not in {_doc_key(cluster_doc) for cluster_doc in unique_cluster_docs}
121
+ ]
122
+
123
+ lines = _build_require_header(cluster_name)
124
+ lines.extend(
125
+ [
126
+ "",
127
+ "ABSOLUTE PROHIBITION: Do not emit YAML frontmatter, TODO placeholders, or meta-commentary about the writing process.",
128
+ "Start directly with the document content.",
129
+ "",
130
+ "Inference guidelines:",
131
+ "- Tag each inferred requirement with one of [observed], [inferred], or [speculative].",
132
+ "- Cite concrete evidence for every requirement using extracted file paths, symbols, routes, schemas, services, or document references.",
133
+ "- Functional Requirements: derive capabilities from modules, APIs, classes, functions, schemas, and integrations.",
134
+ "- Non-Functional Requirements: infer quality attributes from patterns such as auth, retries, caching, async execution, observability, and deployment setup.",
135
+ "- Constraints: capture concrete frameworks, data stores, protocols, architectural boundaries, and technology choices that the code imposes.",
136
+ "- Open Questions: call out ambiguities that need human confirmation.",
137
+ "- Do not invent features that are not evidenced in the extracted facts.",
138
+ "- Do not assume standard features exist unless the extracted facts show them.",
139
+ "- Do not write aspirational requirements or recommendations.",
140
+ "- Include explicit review-needed notes for [speculative] or weakly supported items.",
141
+ "",
142
+ "Output contract:",
143
+ "- Write the finished Markdown requirements document body now.",
144
+ "- The first content line after the title must be the first required section heading below.",
145
+ "- Use these section headings exactly once and in this order:",
146
+ ]
147
+ )
148
+ lines.extend(required_headings)
149
+ lines.extend(
150
+ [
151
+ "",
152
+ "Primary extracted documents for this cluster:",
153
+ ]
154
+ )
155
+
156
+ for doc in unique_cluster_docs:
157
+ lines.extend(
158
+ [
159
+ f"--- BEGIN CLUSTER DOC {doc.path} ({doc.node_id}) ---",
160
+ doc.content.rstrip(),
161
+ f"--- END CLUSTER DOC {doc.path} ---",
162
+ "",
163
+ ]
164
+ )
165
+
166
+ if context_docs:
167
+ lines.append("Cross-cutting context documents:")
168
+ for doc in context_docs:
169
+ lines.extend(
170
+ [
171
+ f"--- BEGIN CONTEXT DOC {doc.path} ({doc.node_id}) ---",
172
+ doc.content.rstrip(),
173
+ f"--- END CONTEXT DOC {doc.path} ---",
174
+ "",
175
+ ]
176
+ )
177
+
178
+ if feedback:
179
+ lines.extend(
180
+ [
181
+ "--- REVIEW FEEDBACK (from previous generation attempt) ---",
182
+ "A reviewer found issues with a previous version of this requirements document.",
183
+ "You MUST address ALL of the following feedback in this generation:",
184
+ feedback.rstrip(),
185
+ "--- END REVIEW FEEDBACK ---",
186
+ "",
187
+ ]
188
+ )
189
+
190
+ lines.append(
191
+ "Final instruction: infer the requirements from the extracted facts above. "
192
+ "These are INFERRED requirements describing what was built, not hidden intent. "
193
+ "Output the Markdown body now."
194
+ )
195
+
196
+ return "\n".join(lines).rstrip() + "\n"
197
+
198
+
199
+ def _build_frontmatter(cluster_name: str) -> str:
200
+ """Build the generated requirements frontmatter."""
201
+ node_id = _cluster_node_id(cluster_name)
202
+ payload = {
203
+ "codd": {
204
+ "node_id": node_id,
205
+ "type": "requirement",
206
+ "depends_on": [],
207
+ "confidence": 0.65,
208
+ "source": "codd-require",
209
+ }
210
+ }
211
+ return f"---\n{yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)}---\n\n"
212
+
213
+
214
+ def run_require(
215
+ project_root: Path,
216
+ output_dir: str = "docs/requirements/",
217
+ scope: str | None = None,
218
+ ai_command: str | None = None,
219
+ force: bool = False,
220
+ feedback: str | None = None,
221
+ ) -> list[RequireResult]:
222
+ """Infer requirements documents from extracted code facts."""
223
+ project_root = project_root.resolve()
224
+ config = load_project_config(project_root)
225
+ extracted_documents = _load_extracted_documents(project_root, config)
226
+ if not extracted_documents:
227
+ raise ValueError("Run 'codd extract' first")
228
+
229
+ clusters = cluster_extracted_docs(extracted_documents, config)
230
+ cross_cutting_docs = clusters.get(CROSS_CUTTING_CLUSTER, [])
231
+ target_clusters = _select_clusters(clusters, scope)
232
+ resolved_ai_command = _resolve_ai_command(config, ai_command, command_name="require")
233
+ base_output_dir = Path(output_dir)
234
+ if not base_output_dir.is_absolute():
235
+ base_output_dir = project_root / base_output_dir
236
+
237
+ results: list[RequireResult] = []
238
+ for cluster_name in target_clusters:
239
+ output_path = base_output_dir / _cluster_output_name(cluster_name)
240
+ node_id = _cluster_node_id(cluster_name)
241
+ if output_path.exists() and not force:
242
+ results.append(RequireResult(node_id=node_id, path=output_path, status="skipped"))
243
+ continue
244
+
245
+ output_path.parent.mkdir(parents=True, exist_ok=True)
246
+ prompt = build_require_prompt(
247
+ cluster_name,
248
+ clusters.get(cluster_name, []),
249
+ cross_cutting_docs,
250
+ feedback=feedback,
251
+ )
252
+ title = _cluster_title(cluster_name)
253
+ raw_body = _invoke_ai_command(resolved_ai_command, prompt)
254
+ body = _sanitize_generated_body(title, raw_body, output_path=output_path.as_posix())
255
+ output_path.write_text(_build_frontmatter(cluster_name) + body.rstrip() + "\n", encoding="utf-8")
256
+ results.append(RequireResult(node_id=node_id, path=output_path, status="generated"))
257
+
258
+ return results
259
+
260
+
261
+ def _select_clusters(clusters: dict[str, list[ExtractedDocument]], scope: str | None) -> list[str]:
262
+ if scope:
263
+ requested = scope.strip().lower()
264
+ for cluster_name in clusters:
265
+ if cluster_name.lower() == requested:
266
+ return [cluster_name]
267
+ available = ", ".join(sorted(name for name in clusters if clusters[name]))
268
+ raise ValueError(f"unknown scope {scope!r}. Available scopes: {available or '(none)'}")
269
+
270
+ cluster_names = [
271
+ cluster_name
272
+ for cluster_name, cluster_docs in clusters.items()
273
+ if cluster_docs
274
+ ]
275
+ if CROSS_CUTTING_CLUSTER in cluster_names:
276
+ cluster_names.remove(CROSS_CUTTING_CLUSTER)
277
+ return [CROSS_CUTTING_CLUSTER, *sorted(cluster_names)]
278
+ return sorted(cluster_names)
279
+
280
+
281
+ def _normalize_service_boundaries(boundaries: Any) -> dict[str, set[str]]:
282
+ if not isinstance(boundaries, list):
283
+ return {}
284
+
285
+ normalized: dict[str, set[str]] = {}
286
+ for entry in boundaries:
287
+ if not isinstance(entry, dict):
288
+ continue
289
+ name = entry.get("name")
290
+ if not isinstance(name, str) or not name.strip():
291
+ continue
292
+
293
+ tokens = {_normalize_token(name)}
294
+ modules = entry.get("modules")
295
+ if isinstance(modules, list):
296
+ for module in modules:
297
+ if isinstance(module, str):
298
+ tokens.update(_extract_module_tokens(module))
299
+
300
+ normalized[name.strip()] = {token for token in tokens if token}
301
+ return normalized
302
+
303
+
304
+ def _extract_module_tokens(module_spec: str) -> set[str]:
305
+ raw = module_spec.strip().replace("\\", "/")
306
+ if not raw:
307
+ return set()
308
+
309
+ path = PurePosixPath(raw)
310
+ parts = [part for part in path.parts if part not in {".", ".."}]
311
+ candidates = [
312
+ raw,
313
+ path.name,
314
+ path.stem,
315
+ path.parent.name,
316
+ *parts,
317
+ ]
318
+
319
+ tokens: set[str] = set()
320
+ for candidate in candidates:
321
+ for piece in re.split(r"[^a-zA-Z0-9]+", candidate):
322
+ token = _normalize_token(piece)
323
+ if token and token not in _GENERIC_PATH_TOKENS:
324
+ tokens.add(token)
325
+ return tokens
326
+
327
+
328
+ def _doc_matches_boundary(doc: ExtractedDocument, boundary_tokens: set[str]) -> bool:
329
+ return bool(_extract_doc_tokens(doc) & boundary_tokens)
330
+
331
+
332
+ def _extract_doc_tokens(doc: ExtractedDocument) -> set[str]:
333
+ path = PurePosixPath(doc.path.replace("\\", "/"))
334
+ pieces = [
335
+ _infer_doc_cluster(doc),
336
+ doc.node_id,
337
+ path.name,
338
+ path.stem,
339
+ path.parent.name,
340
+ *path.parts,
341
+ ]
342
+ tokens: set[str] = set()
343
+ for piece in pieces:
344
+ for chunk in re.split(r"[^a-zA-Z0-9]+", piece):
345
+ token = _normalize_token(chunk)
346
+ if token and token not in _GENERIC_PATH_TOKENS:
347
+ tokens.add(token)
348
+ return tokens
349
+
350
+
351
+ def _infer_doc_cluster(doc: ExtractedDocument) -> str:
352
+ if _is_cross_cutting_doc(doc):
353
+ return CROSS_CUTTING_CLUSTER
354
+
355
+ path = PurePosixPath(doc.path.replace("\\", "/"))
356
+ if path.parent.name == "modules":
357
+ return path.stem
358
+
359
+ node_suffix = doc.node_id.split(":")[-1].strip()
360
+ if node_suffix:
361
+ return node_suffix
362
+
363
+ return path.stem
364
+
365
+
366
+ def _is_cross_cutting_doc(doc: ExtractedDocument) -> bool:
367
+ lowered_path = doc.path.lower()
368
+ lowered_node_id = doc.node_id.lower()
369
+ return any(marker in lowered_path or marker in lowered_node_id for marker in _CROSS_CUTTING_MARKERS)
370
+
371
+
372
+ def _cluster_output_name(cluster_name: str) -> str:
373
+ if cluster_name == CROSS_CUTTING_CLUSTER:
374
+ return "system-requirements.md"
375
+ return f"{_cluster_slug(cluster_name)}-requirements.md"
376
+
377
+
378
+ def _cluster_node_id(cluster_name: str) -> str:
379
+ if cluster_name == CROSS_CUTTING_CLUSTER:
380
+ return "req:system"
381
+ return f"req:{_cluster_slug(cluster_name)}"
382
+
383
+
384
+ def _cluster_title(cluster_name: str) -> str:
385
+ if cluster_name == CROSS_CUTTING_CLUSTER:
386
+ return "System Requirements"
387
+
388
+ words = [
389
+ word for word in re.split(r"[-_]+", cluster_name)
390
+ if word
391
+ ]
392
+ pretty_words = [word.upper() if len(word) <= 3 else word.capitalize() for word in words]
393
+ return f"{' '.join(pretty_words)} Requirements"
394
+
395
+
396
+ def _cluster_slug(cluster_name: str) -> str:
397
+ return _normalize_token(cluster_name) or "requirements"
398
+
399
+
400
+ def _normalize_token(value: str) -> str:
401
+ return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
402
+
403
+
404
+ def _doc_key(doc: ExtractedDocument) -> str:
405
+ return f"{doc.path}::{doc.node_id}"
406
+
407
+
408
+ def _unique_docs(docs: list[ExtractedDocument]) -> list[ExtractedDocument]:
409
+ seen: set[str] = set()
410
+ unique_docs: list[ExtractedDocument] = []
411
+ for doc in docs:
412
+ key = _doc_key(doc)
413
+ if key in seen:
414
+ continue
415
+ seen.add(key)
416
+ unique_docs.append(doc)
417
+ return unique_docs
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codd-dev"
7
- version = "1.2.1"
7
+ version = "1.3.0"
8
8
  description = "CoDD: Coherence-Driven Development — cross-artifact change impact analysis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes