devflow-toolkit 0.3.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.
- devflow/__init__.py +0 -0
- devflow/adapters/__init__.py +47 -0
- devflow/adapters/agents.py +49 -0
- devflow/adapters/base.py +81 -0
- devflow/adapters/claude_code.py +120 -0
- devflow/adapters/codex.py +111 -0
- devflow/adapters/detector.py +118 -0
- devflow/adapters/instructions.py +263 -0
- devflow/adapters/kiro.py +211 -0
- devflow/adapters/opencode.py +237 -0
- devflow/adapters/registry.py +41 -0
- devflow/cli.py +39 -0
- devflow/commands/__init__.py +6 -0
- devflow/commands/approve.py +98 -0
- devflow/commands/harness.py +98 -0
- devflow/commands/init.py +544 -0
- devflow/commands/spec.py +514 -0
- devflow/commands/status.py +156 -0
- devflow/core/__init__.py +15 -0
- devflow/core/architecture.py +469 -0
- devflow/core/artifacts/__init__.py +29 -0
- devflow/core/artifacts/errors.py +19 -0
- devflow/core/artifacts/models.py +82 -0
- devflow/core/artifacts/store.py +130 -0
- devflow/core/clock.py +67 -0
- devflow/core/config.py +706 -0
- devflow/core/constitution.py +166 -0
- devflow/core/discovery.py +1304 -0
- devflow/core/ports.py +159 -0
- devflow/core/profile_manager.py +311 -0
- devflow/core/project.py +533 -0
- devflow/core/spec_workflow.py +932 -0
- devflow/core/specification.py +2443 -0
- devflow/core/state/__init__.py +75 -0
- devflow/core/state/errors.py +39 -0
- devflow/core/state/events.py +725 -0
- devflow/core/state/locator.py +116 -0
- devflow/core/state/lock.py +69 -0
- devflow/core/state/manager.py +960 -0
- devflow/core/state/models.py +164 -0
- devflow/core/state/transitions.py +114 -0
- devflow/models/__init__.py +6 -0
- devflow/models/profile.py +63 -0
- devflow/models/project.py +47 -0
- devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
- devflow/profiles/ai-rag/profile.yaml +28 -0
- devflow/profiles/base/constitution/architecture.md +58 -0
- devflow/profiles/base/constitution/code-standards.md +85 -0
- devflow/profiles/base/constitution/constitution.md +85 -0
- devflow/profiles/base/profile.yaml +15 -0
- devflow/profiles/django-backend/constitution/django-standards.md +64 -0
- devflow/profiles/django-backend/profile.yaml +24 -0
- devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
- devflow/profiles/fastapi-backend/profile.yaml +23 -0
- devflow/prompts/__init__.py +200 -0
- devflow/prompts/discovery.md +119 -0
- devflow/prompts/spec.greenfield.md +149 -0
- devflow/prompts/spec.md +116 -0
- devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
- devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
- devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
- devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
- devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Declared architecture manifests for Greenfield specifications.
|
|
2
|
+
|
|
3
|
+
DevFlow validates the structure and internal consistency of a manifest.
|
|
4
|
+
It does not certify that the declared architecture is appropriate.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from devflow.core.artifacts.errors import ArtifactError
|
|
16
|
+
from devflow.core.artifacts.store import ArtifactStore
|
|
17
|
+
|
|
18
|
+
ARCHITECTURE_RELATIVE_PATH = Path(".devflow") / "architecture.yaml"
|
|
19
|
+
SUPPORTED_MANIFEST_VERSIONS = (1,)
|
|
20
|
+
MAX_MANIFEST_BYTES = 256_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ArchitectureError(Exception):
|
|
24
|
+
"""Raised when an architecture manifest is missing or invalid."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ARCHITECTURE_TEMPLATE = """\
|
|
28
|
+
# DevFlow declared architecture manifest.
|
|
29
|
+
# Edit this file before running a Greenfield specification.
|
|
30
|
+
# DevFlow validates structure and internal consistency, not quality.
|
|
31
|
+
version: 1
|
|
32
|
+
style: ""
|
|
33
|
+
summary: ""
|
|
34
|
+
modules: []
|
|
35
|
+
layers: []
|
|
36
|
+
stack: {}
|
|
37
|
+
constraints: {}
|
|
38
|
+
quality_attributes: []
|
|
39
|
+
fitness_functions: []
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ArchitectureModule:
|
|
45
|
+
"""A declared module or bounded context."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
responsibility: str
|
|
49
|
+
depends_on: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ArchitectureLayer:
|
|
54
|
+
"""A declared architectural layer."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
may_depend_on: tuple[str, ...] = ()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ArchitectureManifest:
|
|
62
|
+
"""A validated, reviewable declared architecture."""
|
|
63
|
+
|
|
64
|
+
path: Path
|
|
65
|
+
content: str
|
|
66
|
+
digest: str
|
|
67
|
+
version: int
|
|
68
|
+
style: str
|
|
69
|
+
summary: str
|
|
70
|
+
modules: tuple[ArchitectureModule, ...]
|
|
71
|
+
layers: tuple[ArchitectureLayer, ...]
|
|
72
|
+
stack: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
73
|
+
constraints: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
|
74
|
+
quality_attributes: tuple[str, ...] = ()
|
|
75
|
+
fitness_functions: tuple[str, ...] = ()
|
|
76
|
+
warnings: tuple[str, ...] = ()
|
|
77
|
+
|
|
78
|
+
def relative_path(self, root: Path) -> str:
|
|
79
|
+
"""Return the manifest path relative to the project root."""
|
|
80
|
+
try:
|
|
81
|
+
return self.path.relative_to(root.resolve()).as_posix()
|
|
82
|
+
except ValueError:
|
|
83
|
+
return self.path.as_posix()
|
|
84
|
+
|
|
85
|
+
def render_appendix(self, root: Path) -> str:
|
|
86
|
+
"""Render the declared manifest as a specification appendix."""
|
|
87
|
+
lines = [
|
|
88
|
+
"## Appendix A: Declared Architecture Manifest",
|
|
89
|
+
"",
|
|
90
|
+
"| Field | Value |",
|
|
91
|
+
"| --- | --- |",
|
|
92
|
+
f"| Manifest | `{self.relative_path(root)}` |",
|
|
93
|
+
f"| Digest | `{self.digest}` |",
|
|
94
|
+
f"| Version | `{self.version}` |",
|
|
95
|
+
f"| Style | {self.style} |",
|
|
96
|
+
"",
|
|
97
|
+
"The digest above identifies the exact manifest used to prepare "
|
|
98
|
+
"this specification. If the manifest changes, generate and approve "
|
|
99
|
+
"the specification again.",
|
|
100
|
+
"",
|
|
101
|
+
"```yaml",
|
|
102
|
+
self.content.rstrip(),
|
|
103
|
+
"```",
|
|
104
|
+
"",
|
|
105
|
+
]
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ArchitectureManifestLoader:
|
|
110
|
+
"""Load and structurally validate `.devflow/architecture.yaml`."""
|
|
111
|
+
|
|
112
|
+
def load(
|
|
113
|
+
self,
|
|
114
|
+
root: Path,
|
|
115
|
+
*,
|
|
116
|
+
required: bool,
|
|
117
|
+
) -> ArchitectureManifest | None:
|
|
118
|
+
"""Load the manifest if present, or fail when it is required."""
|
|
119
|
+
project_root = root.resolve()
|
|
120
|
+
path = project_root / ARCHITECTURE_RELATIVE_PATH
|
|
121
|
+
|
|
122
|
+
if path.is_symlink():
|
|
123
|
+
msg = (
|
|
124
|
+
"Architecture manifest must not be a symbolic link: "
|
|
125
|
+
f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}"
|
|
126
|
+
)
|
|
127
|
+
raise ArchitectureError(msg)
|
|
128
|
+
if not path.is_file():
|
|
129
|
+
if required:
|
|
130
|
+
msg = (
|
|
131
|
+
"Greenfield mode requires a declared architecture manifest at "
|
|
132
|
+
f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}. Run 'devflow init "
|
|
133
|
+
"--project-mode greenfield' to create a template, then edit it."
|
|
134
|
+
)
|
|
135
|
+
raise ArchitectureError(msg)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
store = ArtifactStore(project_root)
|
|
139
|
+
try:
|
|
140
|
+
content = store.read(path)
|
|
141
|
+
digest = store.digest_text(content)
|
|
142
|
+
except ArtifactError as error:
|
|
143
|
+
raise ArchitectureError(str(error)) from error
|
|
144
|
+
|
|
145
|
+
if len(content.encode("utf-8")) > MAX_MANIFEST_BYTES:
|
|
146
|
+
msg = (
|
|
147
|
+
"Architecture manifest is too large. Keep it below "
|
|
148
|
+
f"{MAX_MANIFEST_BYTES} bytes."
|
|
149
|
+
)
|
|
150
|
+
raise ArchitectureError(msg)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
data = yaml.safe_load(content)
|
|
154
|
+
except yaml.YAMLError as error:
|
|
155
|
+
msg = (
|
|
156
|
+
"Invalid YAML in architecture manifest "
|
|
157
|
+
f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}: {error}"
|
|
158
|
+
)
|
|
159
|
+
raise ArchitectureError(msg) from error
|
|
160
|
+
|
|
161
|
+
if not isinstance(data, dict):
|
|
162
|
+
msg = "Architecture manifest must contain a mapping."
|
|
163
|
+
raise ArchitectureError(msg)
|
|
164
|
+
|
|
165
|
+
version = data.get("version")
|
|
166
|
+
if (
|
|
167
|
+
not isinstance(version, int)
|
|
168
|
+
or isinstance(version, bool)
|
|
169
|
+
or version not in SUPPORTED_MANIFEST_VERSIONS
|
|
170
|
+
):
|
|
171
|
+
supported = ", ".join(
|
|
172
|
+
str(item) for item in SUPPORTED_MANIFEST_VERSIONS
|
|
173
|
+
)
|
|
174
|
+
msg = (
|
|
175
|
+
"architecture.version must be one of: "
|
|
176
|
+
f"{supported}. Found: {version!r}."
|
|
177
|
+
)
|
|
178
|
+
raise ArchitectureError(msg)
|
|
179
|
+
|
|
180
|
+
style = self._required_text(data.get("style"), "style")
|
|
181
|
+
summary = self._required_text(data.get("summary"), "summary")
|
|
182
|
+
modules = self._modules(data.get("modules"))
|
|
183
|
+
layers = self._layers(data.get("layers"))
|
|
184
|
+
stack = self._values_mapping(data.get("stack"), "stack")
|
|
185
|
+
constraints = self._values_mapping(
|
|
186
|
+
data.get("constraints"),
|
|
187
|
+
"constraints",
|
|
188
|
+
)
|
|
189
|
+
quality_attributes = self._text_list(
|
|
190
|
+
data.get("quality_attributes"),
|
|
191
|
+
"quality_attributes",
|
|
192
|
+
)
|
|
193
|
+
fitness_functions = self._text_list(
|
|
194
|
+
data.get("fitness_functions"),
|
|
195
|
+
"fitness_functions",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
self._check_references(modules, layers)
|
|
199
|
+
warnings = self._cycles(modules, layers)
|
|
200
|
+
|
|
201
|
+
return ArchitectureManifest(
|
|
202
|
+
path=path,
|
|
203
|
+
content=content,
|
|
204
|
+
digest=digest,
|
|
205
|
+
version=version,
|
|
206
|
+
style=style,
|
|
207
|
+
summary=summary,
|
|
208
|
+
modules=modules,
|
|
209
|
+
layers=layers,
|
|
210
|
+
stack=stack,
|
|
211
|
+
constraints=constraints,
|
|
212
|
+
quality_attributes=quality_attributes,
|
|
213
|
+
fitness_functions=fitness_functions,
|
|
214
|
+
warnings=warnings,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _required_text(self, value: Any, field_name: str) -> str:
|
|
218
|
+
if not isinstance(value, str) or not value.strip():
|
|
219
|
+
msg = f"architecture.{field_name} must be a non-empty string."
|
|
220
|
+
raise ArchitectureError(msg)
|
|
221
|
+
return value.strip()
|
|
222
|
+
|
|
223
|
+
def _text_list(self, value: Any, field_name: str) -> tuple[str, ...]:
|
|
224
|
+
if value is None:
|
|
225
|
+
return ()
|
|
226
|
+
if not isinstance(value, list):
|
|
227
|
+
msg = f"architecture.{field_name} must be a list of strings."
|
|
228
|
+
raise ArchitectureError(msg)
|
|
229
|
+
items: list[str] = []
|
|
230
|
+
for item in value:
|
|
231
|
+
if not isinstance(item, str) or not item.strip():
|
|
232
|
+
msg = (
|
|
233
|
+
f"architecture.{field_name} entries must be non-empty "
|
|
234
|
+
"strings."
|
|
235
|
+
)
|
|
236
|
+
raise ArchitectureError(msg)
|
|
237
|
+
items.append(item.strip())
|
|
238
|
+
return tuple(items)
|
|
239
|
+
|
|
240
|
+
def _values_mapping(
|
|
241
|
+
self,
|
|
242
|
+
value: Any,
|
|
243
|
+
field_name: str,
|
|
244
|
+
) -> dict[str, tuple[str, ...]]:
|
|
245
|
+
if value is None:
|
|
246
|
+
return {}
|
|
247
|
+
if not isinstance(value, dict):
|
|
248
|
+
msg = f"architecture.{field_name} must be a mapping."
|
|
249
|
+
raise ArchitectureError(msg)
|
|
250
|
+
|
|
251
|
+
result: dict[str, tuple[str, ...]] = {}
|
|
252
|
+
for key, item in value.items():
|
|
253
|
+
if not isinstance(key, str) or not key.strip():
|
|
254
|
+
msg = (
|
|
255
|
+
f"architecture.{field_name} keys must be non-empty strings."
|
|
256
|
+
)
|
|
257
|
+
raise ArchitectureError(msg)
|
|
258
|
+
result[key.strip()] = self._scalar_values(
|
|
259
|
+
item,
|
|
260
|
+
f"{field_name}.{key}",
|
|
261
|
+
)
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
def _scalar_values(self, value: Any, field_name: str) -> tuple[str, ...]:
|
|
265
|
+
if isinstance(value, list):
|
|
266
|
+
values = value
|
|
267
|
+
else:
|
|
268
|
+
values = [value]
|
|
269
|
+
if not values:
|
|
270
|
+
msg = f"architecture.{field_name} must not be empty."
|
|
271
|
+
raise ArchitectureError(msg)
|
|
272
|
+
|
|
273
|
+
result: list[str] = []
|
|
274
|
+
for item in values:
|
|
275
|
+
if isinstance(item, bool) or not isinstance(
|
|
276
|
+
item,
|
|
277
|
+
(str, int, float),
|
|
278
|
+
):
|
|
279
|
+
msg = (
|
|
280
|
+
f"architecture.{field_name} values must be strings or "
|
|
281
|
+
"numbers."
|
|
282
|
+
)
|
|
283
|
+
raise ArchitectureError(msg)
|
|
284
|
+
text = str(item).strip()
|
|
285
|
+
if not text:
|
|
286
|
+
msg = (
|
|
287
|
+
f"architecture.{field_name} values must not be empty."
|
|
288
|
+
)
|
|
289
|
+
raise ArchitectureError(msg)
|
|
290
|
+
result.append(text)
|
|
291
|
+
return tuple(result)
|
|
292
|
+
|
|
293
|
+
def _modules(self, value: Any) -> tuple[ArchitectureModule, ...]:
|
|
294
|
+
if value is None:
|
|
295
|
+
msg = "architecture.modules must declare at least one module."
|
|
296
|
+
raise ArchitectureError(msg)
|
|
297
|
+
if not isinstance(value, list) or not value:
|
|
298
|
+
msg = "architecture.modules must be a non-empty list."
|
|
299
|
+
raise ArchitectureError(msg)
|
|
300
|
+
|
|
301
|
+
modules: list[ArchitectureModule] = []
|
|
302
|
+
seen: set[str] = set()
|
|
303
|
+
for index, item in enumerate(value, start=1):
|
|
304
|
+
if not isinstance(item, dict):
|
|
305
|
+
msg = f"architecture.modules[{index}] must be a mapping."
|
|
306
|
+
raise ArchitectureError(msg)
|
|
307
|
+
name = self._required_text(
|
|
308
|
+
item.get("name"),
|
|
309
|
+
f"modules[{index}].name",
|
|
310
|
+
)
|
|
311
|
+
if name in seen:
|
|
312
|
+
msg = f"architecture.modules contains duplicate name: {name}."
|
|
313
|
+
raise ArchitectureError(msg)
|
|
314
|
+
seen.add(name)
|
|
315
|
+
responsibility = self._required_text(
|
|
316
|
+
item.get("responsibility"),
|
|
317
|
+
f"modules[{index}].responsibility",
|
|
318
|
+
)
|
|
319
|
+
depends_on = self._text_list(
|
|
320
|
+
item.get("depends_on"),
|
|
321
|
+
f"modules[{index}].depends_on",
|
|
322
|
+
)
|
|
323
|
+
modules.append(
|
|
324
|
+
ArchitectureModule(
|
|
325
|
+
name=name,
|
|
326
|
+
responsibility=responsibility,
|
|
327
|
+
depends_on=depends_on,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
return tuple(modules)
|
|
331
|
+
|
|
332
|
+
def _layers(self, value: Any) -> tuple[ArchitectureLayer, ...]:
|
|
333
|
+
if value is None:
|
|
334
|
+
return ()
|
|
335
|
+
if not isinstance(value, list):
|
|
336
|
+
msg = "architecture.layers must be a list."
|
|
337
|
+
raise ArchitectureError(msg)
|
|
338
|
+
|
|
339
|
+
layers: list[ArchitectureLayer] = []
|
|
340
|
+
seen: set[str] = set()
|
|
341
|
+
for index, item in enumerate(value, start=1):
|
|
342
|
+
if not isinstance(item, dict):
|
|
343
|
+
msg = f"architecture.layers[{index}] must be a mapping."
|
|
344
|
+
raise ArchitectureError(msg)
|
|
345
|
+
name = self._required_text(
|
|
346
|
+
item.get("name"),
|
|
347
|
+
f"layers[{index}].name",
|
|
348
|
+
)
|
|
349
|
+
if name in seen:
|
|
350
|
+
msg = f"architecture.layers contains duplicate name: {name}."
|
|
351
|
+
raise ArchitectureError(msg)
|
|
352
|
+
seen.add(name)
|
|
353
|
+
may_depend_on = self._text_list(
|
|
354
|
+
item.get("may_depend_on"),
|
|
355
|
+
f"layers[{index}].may_depend_on",
|
|
356
|
+
)
|
|
357
|
+
layers.append(
|
|
358
|
+
ArchitectureLayer(
|
|
359
|
+
name=name,
|
|
360
|
+
may_depend_on=may_depend_on,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
return tuple(layers)
|
|
364
|
+
|
|
365
|
+
def _check_references(
|
|
366
|
+
self,
|
|
367
|
+
modules: tuple[ArchitectureModule, ...],
|
|
368
|
+
layers: tuple[ArchitectureLayer, ...],
|
|
369
|
+
) -> None:
|
|
370
|
+
module_names = {module.name for module in modules}
|
|
371
|
+
for module in modules:
|
|
372
|
+
unknown = sorted(set(module.depends_on) - module_names)
|
|
373
|
+
if unknown:
|
|
374
|
+
msg = (
|
|
375
|
+
f"architecture module '{module.name}' depends on unknown "
|
|
376
|
+
"module(s): " + ", ".join(unknown) + "."
|
|
377
|
+
)
|
|
378
|
+
raise ArchitectureError(msg)
|
|
379
|
+
|
|
380
|
+
layer_names = {layer.name for layer in layers}
|
|
381
|
+
for layer in layers:
|
|
382
|
+
unknown = sorted(set(layer.may_depend_on) - layer_names)
|
|
383
|
+
if unknown:
|
|
384
|
+
msg = (
|
|
385
|
+
f"architecture layer '{layer.name}' depends on unknown "
|
|
386
|
+
"layer(s): " + ", ".join(unknown) + "."
|
|
387
|
+
)
|
|
388
|
+
raise ArchitectureError(msg)
|
|
389
|
+
|
|
390
|
+
def _cycles(
|
|
391
|
+
self,
|
|
392
|
+
modules: tuple[ArchitectureModule, ...],
|
|
393
|
+
layers: tuple[ArchitectureLayer, ...],
|
|
394
|
+
) -> tuple[str, ...]:
|
|
395
|
+
warnings: list[str] = []
|
|
396
|
+
module_cycles = _find_cycles(
|
|
397
|
+
{module.name: module.depends_on for module in modules}
|
|
398
|
+
)
|
|
399
|
+
for cycle in module_cycles:
|
|
400
|
+
warnings.append(
|
|
401
|
+
"architecture modules contain a dependency cycle: "
|
|
402
|
+
+ " -> ".join(cycle)
|
|
403
|
+
+ "."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
layer_cycles = _find_cycles(
|
|
407
|
+
{layer.name: layer.may_depend_on for layer in layers}
|
|
408
|
+
)
|
|
409
|
+
for cycle in layer_cycles:
|
|
410
|
+
warnings.append(
|
|
411
|
+
"architecture layers contain a dependency cycle: "
|
|
412
|
+
+ " -> ".join(cycle)
|
|
413
|
+
+ "."
|
|
414
|
+
)
|
|
415
|
+
return tuple(warnings)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def create_architecture_template(root: Path) -> Path | None:
|
|
419
|
+
"""Create the Greenfield manifest template without overwriting files."""
|
|
420
|
+
project_root = root.resolve()
|
|
421
|
+
path = project_root / ARCHITECTURE_RELATIVE_PATH
|
|
422
|
+
if path.is_symlink():
|
|
423
|
+
msg = (
|
|
424
|
+
"Architecture manifest must not be a symbolic link: "
|
|
425
|
+
f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}"
|
|
426
|
+
)
|
|
427
|
+
raise ArchitectureError(msg)
|
|
428
|
+
if path.exists():
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
ArtifactStore(project_root).write(path, ARCHITECTURE_TEMPLATE)
|
|
433
|
+
except ArtifactError as error:
|
|
434
|
+
raise ArchitectureError(str(error)) from error
|
|
435
|
+
return path
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _find_cycles(graph: dict[str, tuple[str, ...]]) -> list[tuple[str, ...]]:
|
|
439
|
+
"""Return one representative path per strongly connected cycle."""
|
|
440
|
+
visited: set[str] = set()
|
|
441
|
+
active: list[str] = []
|
|
442
|
+
active_set: set[str] = set()
|
|
443
|
+
cycles: list[tuple[str, ...]] = []
|
|
444
|
+
reported: set[frozenset[str]] = set()
|
|
445
|
+
|
|
446
|
+
def visit(node: str) -> None:
|
|
447
|
+
if node in active_set:
|
|
448
|
+
start = active.index(node)
|
|
449
|
+
cycle = tuple(active[start:] + [node])
|
|
450
|
+
key = frozenset(cycle[:-1])
|
|
451
|
+
if key and key not in reported:
|
|
452
|
+
reported.add(key)
|
|
453
|
+
cycles.append(cycle)
|
|
454
|
+
return
|
|
455
|
+
if node in visited:
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
visited.add(node)
|
|
459
|
+
active.append(node)
|
|
460
|
+
active_set.add(node)
|
|
461
|
+
for dependency in graph.get(node, ()):
|
|
462
|
+
if dependency in graph:
|
|
463
|
+
visit(dependency)
|
|
464
|
+
active.pop()
|
|
465
|
+
active_set.remove(node)
|
|
466
|
+
|
|
467
|
+
for node in graph:
|
|
468
|
+
visit(node)
|
|
469
|
+
return cycles
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Artifact storage and lifecycle metadata."""
|
|
2
|
+
|
|
3
|
+
from devflow.core.artifacts.errors import (
|
|
4
|
+
ArtifactError,
|
|
5
|
+
ArtifactNotFoundError,
|
|
6
|
+
ArtifactOutsideProjectError,
|
|
7
|
+
ArtifactWriteError,
|
|
8
|
+
)
|
|
9
|
+
from devflow.core.artifacts.models import (
|
|
10
|
+
ARTIFACT_SOURCES,
|
|
11
|
+
ARTIFACT_STATUSES,
|
|
12
|
+
ArtifactRecord,
|
|
13
|
+
ArtifactSource,
|
|
14
|
+
ArtifactStatus,
|
|
15
|
+
)
|
|
16
|
+
from devflow.core.artifacts.store import ArtifactStore
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ARTIFACT_SOURCES",
|
|
20
|
+
"ARTIFACT_STATUSES",
|
|
21
|
+
"ArtifactError",
|
|
22
|
+
"ArtifactNotFoundError",
|
|
23
|
+
"ArtifactOutsideProjectError",
|
|
24
|
+
"ArtifactRecord",
|
|
25
|
+
"ArtifactSource",
|
|
26
|
+
"ArtifactStatus",
|
|
27
|
+
"ArtifactStore",
|
|
28
|
+
"ArtifactWriteError",
|
|
29
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Artifact-related errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ArtifactError(Exception):
|
|
7
|
+
"""Raised when an artifact cannot be read, written or verified."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ArtifactNotFoundError(ArtifactError):
|
|
11
|
+
"""Raised when an expected artifact does not exist."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ArtifactOutsideProjectError(ArtifactError):
|
|
15
|
+
"""Raised when an artifact path escapes the project root."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ArtifactWriteError(ArtifactError):
|
|
19
|
+
"""Raised when an artifact cannot be written atomically."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Artifact data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ArtifactStatus(str, Enum):
|
|
11
|
+
"""Lifecycle state of a generated artifact."""
|
|
12
|
+
|
|
13
|
+
GENERATED = "generated"
|
|
14
|
+
VALIDATED = "validated"
|
|
15
|
+
APPROVED = "approved"
|
|
16
|
+
REJECTED = "rejected"
|
|
17
|
+
STALE = "stale"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArtifactSource(str, Enum):
|
|
21
|
+
"""Origin of an artifact."""
|
|
22
|
+
|
|
23
|
+
AGENT = "agent"
|
|
24
|
+
DETERMINISTIC = "deterministic"
|
|
25
|
+
EXISTING = "existing"
|
|
26
|
+
HUMAN = "human"
|
|
27
|
+
SYSTEM = "system"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ARTIFACT_STATUSES = tuple(status.value for status in ArtifactStatus)
|
|
31
|
+
ARTIFACT_SOURCES = tuple(source.value for source in ArtifactSource)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ArtifactRecord:
|
|
36
|
+
"""Metadata for one stored artifact."""
|
|
37
|
+
|
|
38
|
+
kind: str
|
|
39
|
+
path: str
|
|
40
|
+
digest: str
|
|
41
|
+
status: str
|
|
42
|
+
source: str
|
|
43
|
+
version: int = 1
|
|
44
|
+
updated_at: str = ""
|
|
45
|
+
validated_at: str = ""
|
|
46
|
+
approved_at: str = ""
|
|
47
|
+
stale_at: str = ""
|
|
48
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"kind": self.kind,
|
|
53
|
+
"path": self.path,
|
|
54
|
+
"digest": self.digest,
|
|
55
|
+
"status": self.status,
|
|
56
|
+
"source": self.source,
|
|
57
|
+
"version": self.version,
|
|
58
|
+
"updated_at": self.updated_at,
|
|
59
|
+
"validated_at": self.validated_at,
|
|
60
|
+
"approved_at": self.approved_at,
|
|
61
|
+
"stale_at": self.stale_at,
|
|
62
|
+
"metadata": dict(self.metadata),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, data: dict[str, Any]) -> ArtifactRecord:
|
|
67
|
+
metadata = data.get("metadata") or {}
|
|
68
|
+
if not isinstance(metadata, dict):
|
|
69
|
+
metadata = {}
|
|
70
|
+
return cls(
|
|
71
|
+
kind=str(data["kind"]),
|
|
72
|
+
path=str(data["path"]),
|
|
73
|
+
digest=str(data["digest"]),
|
|
74
|
+
status=str(data["status"]),
|
|
75
|
+
source=str(data["source"]),
|
|
76
|
+
version=int(data.get("version", 1)),
|
|
77
|
+
updated_at=str(data.get("updated_at", "")),
|
|
78
|
+
validated_at=str(data.get("validated_at", "")),
|
|
79
|
+
approved_at=str(data.get("approved_at", "")),
|
|
80
|
+
stale_at=str(data.get("stale_at", "")),
|
|
81
|
+
metadata=metadata,
|
|
82
|
+
)
|