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.
- agentic_development_framework-0.1.0.dist-info/METADATA +175 -0
- agentic_development_framework-0.1.0.dist-info/RECORD +27 -0
- agentic_development_framework-0.1.0.dist-info/WHEEL +4 -0
- agentic_development_framework-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_development_framework-0.1.0.dist-info/licenses/LICENSE +22 -0
- agentic_framework/__init__.py +6 -0
- agentic_framework/__main__.py +7 -0
- agentic_framework/adapters/__init__.py +5 -0
- agentic_framework/adapters/generator.py +139 -0
- agentic_framework/cli.py +264 -0
- agentic_framework/core/__init__.py +2 -0
- agentic_framework/core/command_registry.py +16 -0
- agentic_framework/core/config.py +146 -0
- agentic_framework/core/initialization.py +241 -0
- agentic_framework/core/project.py +93 -0
- agentic_framework/core/workflow.py +94 -0
- agentic_framework/exceptions.py +22 -0
- agentic_framework/logging.py +70 -0
- agentic_framework/requirements_manager/__init__.py +8 -0
- agentic_framework/requirements_manager/comparison.py +129 -0
- agentic_framework/requirements_manager/extraction.py +43 -0
- agentic_framework/requirements_manager/ingestion.py +367 -0
- agentic_framework/requirements_manager/traceability.py +65 -0
- agentic_framework/state/__init__.py +5 -0
- agentic_framework/state/database.py +231 -0
- agentic_framework/templates/__init__.py +1 -0
- agentic_framework/templates/skills/agentic-commands/SKILL.md +22 -0
|
@@ -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
|
+
}
|