agentic-development-framework 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.
@@ -0,0 +1,129 @@
1
+ """Comparison and human-readable impact reporting for requirements versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ChangeSet:
11
+ """Added, modified, removed, and unchanged identifiers."""
12
+
13
+ added: tuple[str, ...]
14
+ modified: tuple[str, ...]
15
+ removed: tuple[str, ...]
16
+ unchanged: tuple[str, ...]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class VersionComparison:
21
+ """File-level and requirement-level changes between two versions."""
22
+
23
+ previous_version: str
24
+ current_version: str
25
+ files: ChangeSet
26
+ requirements: ChangeSet
27
+ conflicts: tuple[str, ...]
28
+
29
+
30
+ def _changes(previous: dict[str, str], current: dict[str, str]) -> ChangeSet:
31
+ previous_keys = set(previous)
32
+ current_keys = set(current)
33
+ shared = previous_keys & current_keys
34
+ return ChangeSet(
35
+ added=tuple(sorted(current_keys - previous_keys)),
36
+ modified=tuple(sorted(key for key in shared if previous[key] != current[key])),
37
+ removed=tuple(sorted(previous_keys - current_keys)),
38
+ unchanged=tuple(sorted(key for key in shared if previous[key] == current[key])),
39
+ )
40
+
41
+
42
+ def compare_versions(
43
+ previous_manifest: dict[str, Any],
44
+ current_manifest: dict[str, Any],
45
+ previous_requirements: dict[str, Any],
46
+ current_requirements: dict[str, Any],
47
+ ) -> VersionComparison:
48
+ """Compare source hashes and extracted requirement hashes."""
49
+
50
+ previous_files = {item["path"]: item["sha256"] for item in previous_manifest["files"]}
51
+ current_files = {item["path"]: item["sha256"] for item in current_manifest["files"]}
52
+ previous_ids = {
53
+ item["id"]: item["text_sha256"] for item in previous_requirements["requirements"]
54
+ }
55
+ current_ids = {
56
+ item["id"]: item["text_sha256"] for item in current_requirements["requirements"]
57
+ }
58
+ return VersionComparison(
59
+ previous_version=previous_manifest["version"],
60
+ current_version=current_manifest["version"],
61
+ files=_changes(previous_files, current_files),
62
+ requirements=_changes(previous_ids, current_ids),
63
+ conflicts=tuple(current_manifest.get("conflicts", [])),
64
+ )
65
+
66
+
67
+ def _section(title: str, values: tuple[str, ...]) -> list[str]:
68
+ lines = [f"### {title}", ""]
69
+ lines.extend(f"- `{value}`" for value in values)
70
+ if not values:
71
+ lines.append("- None")
72
+ lines.append("")
73
+ return lines
74
+
75
+
76
+ def render_impact_report(comparison: VersionComparison) -> str:
77
+ """Render an actionable Markdown impact report."""
78
+
79
+ changed_ids = tuple(
80
+ sorted(
81
+ set(comparison.requirements.added)
82
+ | set(comparison.requirements.modified)
83
+ | set(comparison.requirements.removed)
84
+ )
85
+ )
86
+ lines = [
87
+ f"# Requirements impact: {comparison.previous_version} to {comparison.current_version}",
88
+ "",
89
+ "## Summary",
90
+ "",
91
+ "| Scope | Added | Modified | Removed | Unchanged |",
92
+ "|---|---:|---:|---:|---:|",
93
+ (
94
+ f"| Source files | {len(comparison.files.added)} | {len(comparison.files.modified)} | "
95
+ f"{len(comparison.files.removed)} | {len(comparison.files.unchanged)} |"
96
+ ),
97
+ (
98
+ f"| Requirement IDs | {len(comparison.requirements.added)} | "
99
+ f"{len(comparison.requirements.modified)} | {len(comparison.requirements.removed)} | "
100
+ f"{len(comparison.requirements.unchanged)} |"
101
+ ),
102
+ "",
103
+ "## Source file changes",
104
+ "",
105
+ ]
106
+ lines.extend(_section("Added", comparison.files.added))
107
+ lines.extend(_section("Modified", comparison.files.modified))
108
+ lines.extend(_section("Removed", comparison.files.removed))
109
+ lines.extend(["## Requirement changes", ""])
110
+ lines.extend(_section("Added", comparison.requirements.added))
111
+ lines.extend(_section("Modified", comparison.requirements.modified))
112
+ lines.extend(_section("Removed", comparison.requirements.removed))
113
+ lines.extend(_section("Conflicts requiring resolution", comparison.conflicts))
114
+ lines.extend(
115
+ [
116
+ "## Impact review",
117
+ "",
118
+ f"Requirement IDs requiring review: {', '.join(changed_ids) or 'none identified'}.",
119
+ "",
120
+ "- Documentation: review the SRS, feature descriptions, user flows, and traceability.",
121
+ "- Architecture: review components, data models, APIs, and decisions linked to "
122
+ "changed IDs.",
123
+ "- Implementation: review planned and active tasks linked to changed IDs.",
124
+ "- Tests: add, update, or retire tests linked to changed IDs.",
125
+ "- Approval: do not change approved decisions until this impact is reviewed.",
126
+ "",
127
+ ]
128
+ )
129
+ return "\n".join(lines)
@@ -0,0 +1,43 @@
1
+ """Small, explicit text-extraction boundary for requirement sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+ _TEXT_EXTENSIONS = {".csv", ".json", ".md", ".rst", ".tsv", ".txt", ".yaml", ".yml"}
10
+
11
+
12
+ class ExtractionStatus(StrEnum):
13
+ """Result of trying to extract searchable text from a source file."""
14
+
15
+ EXTRACTED = "extracted"
16
+ UNSUPPORTED = "unsupported"
17
+ FAILED = "failed"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ExtractionResult:
22
+ """Text and diagnostics produced by an extractor."""
23
+
24
+ status: ExtractionStatus
25
+ text: str | None = None
26
+ message: str | None = None
27
+
28
+
29
+ def extract_text(path: Path) -> ExtractionResult:
30
+ """Extract UTF-8 text or report why extraction was not possible."""
31
+
32
+ extension = path.suffix.lower()
33
+ if extension not in _TEXT_EXTENSIONS:
34
+ label = extension or "files without an extension"
35
+ return ExtractionResult(
36
+ status=ExtractionStatus.UNSUPPORTED,
37
+ message=f"text extraction is not configured for {label}",
38
+ )
39
+ try:
40
+ text = path.read_text(encoding="utf-8-sig")
41
+ except (OSError, UnicodeError) as exc:
42
+ return ExtractionResult(status=ExtractionStatus.FAILED, message=str(exc))
43
+ return ExtractionResult(status=ExtractionStatus.EXTRACTED, text=text)
@@ -0,0 +1,367 @@
1
+ """Atomic requirements ingestion and immutable version publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import re
10
+ import shutil
11
+ import tempfile
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from agentic_framework.exceptions import RequirementsError
20
+ from agentic_framework.requirements_manager.comparison import (
21
+ VersionComparison,
22
+ compare_versions,
23
+ render_impact_report,
24
+ )
25
+ from agentic_framework.requirements_manager.extraction import ExtractionResult, extract_text
26
+ from agentic_framework.requirements_manager.traceability import (
27
+ RequirementRecord,
28
+ extract_requirement_records,
29
+ traceability_document,
30
+ )
31
+
32
+ _VERSION_NAME = re.compile(r"version-(\d+)$")
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class InventoryItem:
37
+ """One inbox source and its stable metadata."""
38
+
39
+ path: Path
40
+ relative_path: Path
41
+ sha256: str
42
+ size: int
43
+ media_type: str
44
+ extraction: ExtractionResult
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class IngestionResult:
49
+ """Outcome of an ingestion attempt."""
50
+
51
+ version: str
52
+ version_path: Path
53
+ file_count: int
54
+ requirement_count: int
55
+ duplicate: bool
56
+ previous_version: str | None = None
57
+ impact_report: Path | None = None
58
+ comparison: VersionComparison | None = None
59
+
60
+
61
+ def _sha256(path: Path) -> str:
62
+ digest = hashlib.sha256()
63
+ try:
64
+ with path.open("rb") as source:
65
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
66
+ digest.update(chunk)
67
+ except OSError as exc:
68
+ raise RequirementsError(f"cannot read requirement source {path}: {exc}") from exc
69
+ return digest.hexdigest()
70
+
71
+
72
+ def _read_json(path: Path) -> dict[str, Any]:
73
+ try:
74
+ value = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
76
+ raise RequirementsError(f"cannot read version metadata {path}: {exc}") from exc
77
+ if not isinstance(value, dict):
78
+ raise RequirementsError(f"version metadata must be a JSON object: {path}")
79
+ return value
80
+
81
+
82
+ def _write_json(path: Path, value: dict[str, Any]) -> None:
83
+ path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
84
+
85
+
86
+ @contextmanager
87
+ def _ingestion_lock(requirements_root: Path) -> Iterator[None]:
88
+ lock = requirements_root / ".ingestion.lock"
89
+ try:
90
+ descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
91
+ except FileExistsError as exc:
92
+ raise RequirementsError(
93
+ "requirements ingestion is already running; remove stale lock only after "
94
+ f"verification: {lock}"
95
+ ) from exc
96
+ try:
97
+ os.write(descriptor, f"pid={os.getpid()}\n".encode())
98
+ os.close(descriptor)
99
+ yield
100
+ finally:
101
+ try:
102
+ os.close(descriptor)
103
+ except OSError:
104
+ pass
105
+ lock.unlink(missing_ok=True)
106
+
107
+
108
+ class RequirementsManager:
109
+ """Version requirement sources without changing the inbox or prior versions."""
110
+
111
+ def __init__(self, project_root: Path) -> None:
112
+ self.project_root = project_root.expanduser().resolve()
113
+ self.requirements_root = self.project_root / "requirements"
114
+ self.inbox = self.requirements_root / "inbox"
115
+ self.versions = self.requirements_root / "versions"
116
+ self.reports = self.requirements_root / "reports"
117
+
118
+ def _prepare_directories(self) -> None:
119
+ self.inbox.mkdir(parents=True, exist_ok=True)
120
+ self.versions.mkdir(parents=True, exist_ok=True)
121
+ self.reports.mkdir(parents=True, exist_ok=True)
122
+
123
+ def inventory(self) -> tuple[InventoryItem, ...]:
124
+ """Hash and inspect every regular inbox file without modifying it."""
125
+
126
+ items: list[InventoryItem] = []
127
+ for path in sorted(self.inbox.rglob("*")):
128
+ if path.is_symlink():
129
+ raise RequirementsError(
130
+ f"symbolic links are not accepted in requirements inbox: {path}"
131
+ )
132
+ if not path.is_file():
133
+ continue
134
+ relative = path.relative_to(self.inbox)
135
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
136
+ items.append(
137
+ InventoryItem(
138
+ path=path,
139
+ relative_path=relative,
140
+ sha256=_sha256(path),
141
+ size=path.stat().st_size,
142
+ media_type=media_type,
143
+ extraction=extract_text(path),
144
+ )
145
+ )
146
+ if not items:
147
+ raise RequirementsError(f"no requirement files found in {self.inbox}")
148
+ return tuple(items)
149
+
150
+ def _published_versions(self) -> list[tuple[Path, dict[str, Any]]]:
151
+ published: list[tuple[Path, dict[str, Any]]] = []
152
+ directories = [
153
+ path
154
+ for path in self.versions.glob("version-*")
155
+ if path.is_dir() and _VERSION_NAME.fullmatch(path.name)
156
+ ]
157
+ directories.sort(key=lambda path: int(path.name.removeprefix("version-")))
158
+ for directory in directories:
159
+ manifest = directory / "manifest.json"
160
+ if manifest.is_file():
161
+ metadata = _read_json(manifest)
162
+ self._validate_published_version(directory, metadata)
163
+ published.append((directory, metadata))
164
+ elif any(directory.iterdir()):
165
+ raise RequirementsError(
166
+ f"incomplete requirements version must be repaired: {directory}"
167
+ )
168
+ return published
169
+
170
+ def latest_version(self) -> dict[str, Any] | None:
171
+ """Return validated metadata for the latest published version."""
172
+
173
+ self._prepare_directories()
174
+ published = self._published_versions()
175
+ return published[-1][1] if published else None
176
+
177
+ @staticmethod
178
+ def _validate_published_version(directory: Path, manifest: dict[str, Any]) -> None:
179
+ if manifest.get("version") != directory.name or manifest.get("immutable") is not True:
180
+ raise RequirementsError(f"invalid immutable version manifest: {directory}")
181
+ files = manifest.get("files")
182
+ if not isinstance(files, list):
183
+ raise RequirementsError(f"invalid file inventory in version manifest: {directory}")
184
+ source_root = (directory / "source-files").resolve()
185
+ for item in files:
186
+ if not isinstance(item, dict) or not isinstance(item.get("stored_path"), str):
187
+ raise RequirementsError(f"invalid source record in version manifest: {directory}")
188
+ stored = (directory / item["stored_path"]).resolve()
189
+ if not stored.is_relative_to(source_root) or not stored.is_file():
190
+ raise RequirementsError(f"missing or unsafe immutable source copy: {stored}")
191
+ if _sha256(stored) != item.get("sha256"):
192
+ raise RequirementsError(f"immutable source copy was modified: {stored}")
193
+ artifacts = manifest.get("artifacts")
194
+ if not isinstance(artifacts, dict):
195
+ raise RequirementsError(f"invalid artifact inventory in version manifest: {directory}")
196
+ for relative, expected_hash in artifacts.items():
197
+ artifact = (directory / relative).resolve()
198
+ if not artifact.is_relative_to(directory.resolve()) or not artifact.is_file():
199
+ raise RequirementsError(f"missing or unsafe immutable version artifact: {artifact}")
200
+ if _sha256(artifact) != expected_hash:
201
+ raise RequirementsError(f"immutable version artifact was modified: {artifact}")
202
+
203
+ @staticmethod
204
+ def _signature(items: tuple[InventoryItem, ...]) -> dict[str, str]:
205
+ return {item.relative_path.as_posix(): item.sha256 for item in items}
206
+
207
+ @staticmethod
208
+ def _manifest_signature(manifest: dict[str, Any]) -> dict[str, str]:
209
+ return {item["path"]: item["sha256"] for item in manifest["files"]}
210
+
211
+ def _next_target(self, published: list[tuple[Path, dict[str, Any]]]) -> tuple[str, Path]:
212
+ number = (
213
+ int(published[-1][0].name.removeprefix("version-")) + 1 if published else 1
214
+ )
215
+ version = f"version-{number:03d}"
216
+ target = self.versions / version
217
+ if target.exists() and any(target.iterdir()):
218
+ raise RequirementsError(f"requirements version target already contains data: {target}")
219
+ return version, target
220
+
221
+ @staticmethod
222
+ def _requirements_markdown(items: tuple[InventoryItem, ...]) -> str:
223
+ lines = ["# Consolidated requirements", ""]
224
+ for item in items:
225
+ relative = item.relative_path.as_posix()
226
+ lines.extend([f"## Source: `{relative}`", ""])
227
+ if item.extraction.text is not None:
228
+ lines.extend([item.extraction.text.rstrip(), ""])
229
+ else:
230
+ lines.extend(
231
+ [
232
+ f"Extraction status: **{item.extraction.status.value}**.",
233
+ "",
234
+ item.extraction.message or "No diagnostic was provided.",
235
+ "",
236
+ ]
237
+ )
238
+ return "\n".join(lines).rstrip() + "\n"
239
+
240
+ @staticmethod
241
+ def _requirements_index(records: dict[str, RequirementRecord]) -> dict[str, object]:
242
+ return {
243
+ "schema_version": 1,
244
+ "requirements": [
245
+ {
246
+ "id": record.requirement_id,
247
+ "source": record.source,
248
+ "text": record.text,
249
+ "text_sha256": record.sha256,
250
+ }
251
+ for record in sorted(records.values(), key=lambda item: item.requirement_id)
252
+ ],
253
+ }
254
+
255
+ def _publish(
256
+ self,
257
+ items: tuple[InventoryItem, ...],
258
+ version: str,
259
+ target: Path,
260
+ previous_version: str | None,
261
+ ) -> tuple[dict[str, Any], dict[str, object]]:
262
+ extracted = {
263
+ item.relative_path.as_posix(): item.extraction.text
264
+ for item in items
265
+ if item.extraction.text is not None
266
+ }
267
+ records, conflicts = extract_requirement_records(extracted)
268
+ manifest: dict[str, Any] = {
269
+ "schema_version": 1,
270
+ "version": version,
271
+ "created_at": datetime.now(UTC).isoformat(),
272
+ "previous_version": previous_version,
273
+ "immutable": True,
274
+ "files": [
275
+ {
276
+ "path": item.relative_path.as_posix(),
277
+ "stored_path": f"source-files/{item.relative_path.as_posix()}",
278
+ "sha256": item.sha256,
279
+ "size": item.size,
280
+ "media_type": item.media_type,
281
+ "extraction": {
282
+ "status": item.extraction.status.value,
283
+ "message": item.extraction.message,
284
+ },
285
+ }
286
+ for item in items
287
+ ],
288
+ "conflicts": list(conflicts),
289
+ }
290
+ requirements_index = self._requirements_index(records)
291
+ with tempfile.TemporaryDirectory(prefix=".adf-version-", dir=self.versions) as temporary:
292
+ staging = Path(temporary)
293
+ for item in items:
294
+ destination = staging / "source-files" / item.relative_path
295
+ destination.parent.mkdir(parents=True, exist_ok=True)
296
+ shutil.copyfile(item.path, destination)
297
+ if _sha256(destination) != item.sha256:
298
+ raise RequirementsError(f"copied source failed hash verification: {item.path}")
299
+ _write_json(staging / "requirements-index.json", requirements_index)
300
+ _write_json(staging / "traceability.json", traceability_document(records))
301
+ (staging / "requirements.md").write_text(
302
+ self._requirements_markdown(items), encoding="utf-8"
303
+ )
304
+ manifest["artifacts"] = {
305
+ name: _sha256(staging / name)
306
+ for name in (
307
+ "requirements-index.json",
308
+ "requirements.md",
309
+ "traceability.json",
310
+ )
311
+ }
312
+ _write_json(staging / "manifest.json", manifest)
313
+ if target.exists():
314
+ target.rmdir()
315
+ os.replace(staging, target)
316
+ return manifest, requirements_index
317
+
318
+ def _write_impact_report(self, comparison: VersionComparison) -> Path:
319
+ report = self.reports / (
320
+ f"{comparison.previous_version}-to-{comparison.current_version}-impact.md"
321
+ )
322
+ content = render_impact_report(comparison)
323
+ if report.exists():
324
+ if report.read_text(encoding="utf-8") != content:
325
+ raise RequirementsError(f"refusing to overwrite existing impact report: {report}")
326
+ return report
327
+ report.write_text(content, encoding="utf-8")
328
+ return report
329
+
330
+ def ingest(self) -> IngestionResult:
331
+ """Publish the inbox as the next immutable version or identify a duplicate."""
332
+
333
+ self._prepare_directories()
334
+ with _ingestion_lock(self.requirements_root):
335
+ items = self.inventory()
336
+ published = self._published_versions()
337
+ latest = published[-1] if published else None
338
+ if latest and self._manifest_signature(latest[1]) == self._signature(items):
339
+ index = _read_json(latest[0] / "requirements-index.json")
340
+ return IngestionResult(
341
+ version=latest[1]["version"],
342
+ version_path=latest[0],
343
+ file_count=len(items),
344
+ requirement_count=len(index["requirements"]),
345
+ duplicate=True,
346
+ previous_version=latest[1].get("previous_version"),
347
+ )
348
+
349
+ version, target = self._next_target(published)
350
+ previous_version = latest[1]["version"] if latest else None
351
+ manifest, index = self._publish(items, version, target, previous_version)
352
+ comparison = None
353
+ impact_report = None
354
+ if latest:
355
+ previous_index = _read_json(latest[0] / "requirements-index.json")
356
+ comparison = compare_versions(latest[1], manifest, previous_index, index)
357
+ impact_report = self._write_impact_report(comparison)
358
+ return IngestionResult(
359
+ version=version,
360
+ version_path=target,
361
+ file_count=len(items),
362
+ requirement_count=len(index["requirements"]),
363
+ duplicate=False,
364
+ previous_version=previous_version,
365
+ impact_report=impact_report,
366
+ comparison=comparison,
367
+ )
@@ -0,0 +1,65 @@
1
+ """Requirement-ID extraction and initial traceability records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ _REQUIREMENT_HEADING = re.compile(r"(?m)^\s*(REQ-\d+)\b[^\n]*")
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class RequirementRecord:
14
+ """One identified requirement and its source location."""
15
+
16
+ requirement_id: str
17
+ source: str
18
+ text: str
19
+
20
+ @property
21
+ def sha256(self) -> str:
22
+ return hashlib.sha256(self.text.encode("utf-8")).hexdigest()
23
+
24
+
25
+ def extract_requirement_records(
26
+ extracted_sources: dict[str, str],
27
+ ) -> tuple[dict[str, RequirementRecord], tuple[str, ...]]:
28
+ """Extract requirement blocks and report duplicate IDs with conflicting text."""
29
+
30
+ records: dict[str, RequirementRecord] = {}
31
+ conflicts: set[str] = set()
32
+ for source, text in sorted(extracted_sources.items()):
33
+ matches = list(_REQUIREMENT_HEADING.finditer(text))
34
+ for index, match in enumerate(matches):
35
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
36
+ block = text[match.start() : end].strip().splitlines()
37
+ normalized = "\n".join(line.rstrip() for line in block)
38
+ requirement_id = match.group(1).upper()
39
+ record = RequirementRecord(requirement_id, source, normalized)
40
+ existing = records.get(requirement_id)
41
+ if existing is None:
42
+ records[requirement_id] = record
43
+ elif existing.text != record.text:
44
+ conflicts.add(requirement_id)
45
+ return records, tuple(sorted(conflicts))
46
+
47
+
48
+ def traceability_document(records: dict[str, RequirementRecord]) -> dict[str, object]:
49
+ """Create the initial traceability shape extended by later workflow phases."""
50
+
51
+ return {
52
+ "schema_version": 1,
53
+ "requirements": [
54
+ {
55
+ "id": record.requirement_id,
56
+ "source": record.source,
57
+ "text_sha256": record.sha256,
58
+ "srs_sections": [],
59
+ "architecture_components": [],
60
+ "tasks": [],
61
+ "tests": [],
62
+ }
63
+ for record in sorted(records.values(), key=lambda item: item.requirement_id)
64
+ ],
65
+ }
@@ -0,0 +1,5 @@
1
+ """SQLite-backed runtime state and recovery."""
2
+
3
+ from agentic_framework.state.database import RuntimeStore
4
+
5
+ __all__ = ["RuntimeStore"]