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.
- {codd_dev-1.2.1 → codd_dev-1.3.0}/PKG-INFO +17 -1
- {codd_dev-1.2.1 → codd_dev-1.3.0}/README.md +16 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/__init__.py +1 -1
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/cli.py +65 -12
- codd_dev-1.3.0/codd/require.py +417 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/pyproject.toml +1 -1
- {codd_dev-1.2.1 → codd_dev-1.3.0}/.gitignore +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/LICENSE +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/assembler.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/clustering.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/config.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/contracts.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/defaults.yaml +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/env_refs.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/extractor.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/generator.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/graph.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/hooks/__init__.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/hooks/pre-commit +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/implementer.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/inheritance.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/parsing.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/planner.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/propagate.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/propagator.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/restore.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/reviewer.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/risk.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/scanner.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/schema_refs.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/synth.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/codd.yaml.tmpl +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/conventions.yaml.tmpl +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/data_dependencies.yaml.tmpl +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/doc_links.yaml.tmpl +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/api-contract.md.j2 +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/architecture-overview.md.j2 +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/module-detail.md.j2 +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/schema-design.md.j2 +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/extracted/system-context.md.j2 +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/gitignore.tmpl +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/templates/overrides.yaml.tmpl +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/traceability.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/validator.py +0 -0
- {codd_dev-1.2.1 → codd_dev-1.3.0}/codd/verifier.py +0 -0
- {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.
|
|
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,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("--
|
|
215
|
-
@click.option("--
|
|
216
|
-
@click.option("--
|
|
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
|
|
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
|
|
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
|