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,1304 @@
|
|
|
1
|
+
"""Persistent discovery and clarification for specification requests.
|
|
2
|
+
|
|
3
|
+
Discovery is deliberately separate from specification. It records the
|
|
4
|
+
questions that change scope, the answers supplied by a human, and the
|
|
5
|
+
technical assumptions proposed by DevFlow. A specification may only be
|
|
6
|
+
generated when every blocking question is answered and every proposed
|
|
7
|
+
assumption has been explicitly accepted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import unicodedata
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from devflow.core.architecture import ArchitectureManifest
|
|
21
|
+
from devflow.core.artifacts.store import ArtifactStore
|
|
22
|
+
from devflow.core.clock import Clock, SystemClock, format_timestamp
|
|
23
|
+
from devflow.models.project import PROJECT_MODES, Project
|
|
24
|
+
|
|
25
|
+
DISCOVERY_FILENAME = "discovery.yaml"
|
|
26
|
+
MAX_DISCOVERY_BYTES = 512_000
|
|
27
|
+
MAX_QUESTIONS = 100
|
|
28
|
+
MAX_ASSUMPTIONS = 100
|
|
29
|
+
MAX_TEXT_LENGTH = 4_000
|
|
30
|
+
MAX_OPTIONS = 10
|
|
31
|
+
|
|
32
|
+
DISCOVERY_PENDING = "pending"
|
|
33
|
+
DISCOVERY_AWAITING = "awaiting_clarification"
|
|
34
|
+
DISCOVERY_COMPLETE = "complete"
|
|
35
|
+
DISCOVERY_STATUSES = (
|
|
36
|
+
DISCOVERY_PENDING,
|
|
37
|
+
DISCOVERY_AWAITING,
|
|
38
|
+
DISCOVERY_COMPLETE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
QUESTION_PENDING = "pending"
|
|
42
|
+
QUESTION_ANSWERED = "answered"
|
|
43
|
+
QUESTION_STATUSES = (QUESTION_PENDING, QUESTION_ANSWERED)
|
|
44
|
+
|
|
45
|
+
QUESTION_CATEGORIES = (
|
|
46
|
+
"functional_scope",
|
|
47
|
+
"user_behavior",
|
|
48
|
+
"data_persistence",
|
|
49
|
+
"security_authentication",
|
|
50
|
+
"external_integration",
|
|
51
|
+
"architecture_boundary",
|
|
52
|
+
"dependencies",
|
|
53
|
+
"deployment",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
ASSUMPTION_PENDING = "pending_acceptance"
|
|
57
|
+
ASSUMPTION_ACCEPTED = "accepted"
|
|
58
|
+
ASSUMPTION_STATUSES = (ASSUMPTION_PENDING, ASSUMPTION_ACCEPTED)
|
|
59
|
+
ASSUMPTION_SOURCES = ("agent_default", "human", "existing_convention")
|
|
60
|
+
|
|
61
|
+
_QUESTION_ID = re.compile(r"^Q\d{1,3}$")
|
|
62
|
+
_ASSUMPTION_ID = re.compile(r"^A\d{1,3}$")
|
|
63
|
+
_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
64
|
+
|
|
65
|
+
_STOPWORDS = {
|
|
66
|
+
"a",
|
|
67
|
+
"al",
|
|
68
|
+
"algo",
|
|
69
|
+
"and",
|
|
70
|
+
"con",
|
|
71
|
+
"de",
|
|
72
|
+
"del",
|
|
73
|
+
"el",
|
|
74
|
+
"en",
|
|
75
|
+
"for",
|
|
76
|
+
"from",
|
|
77
|
+
"in",
|
|
78
|
+
"la",
|
|
79
|
+
"las",
|
|
80
|
+
"lo",
|
|
81
|
+
"los",
|
|
82
|
+
"my",
|
|
83
|
+
"of",
|
|
84
|
+
"on",
|
|
85
|
+
"para",
|
|
86
|
+
"por",
|
|
87
|
+
"que",
|
|
88
|
+
"se",
|
|
89
|
+
"the",
|
|
90
|
+
"to",
|
|
91
|
+
"un",
|
|
92
|
+
"una",
|
|
93
|
+
"with",
|
|
94
|
+
"y",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_VAGUE_TERMS = {
|
|
98
|
+
"better",
|
|
99
|
+
"improve",
|
|
100
|
+
"improvement",
|
|
101
|
+
"mejor",
|
|
102
|
+
"mejora",
|
|
103
|
+
"mejorar",
|
|
104
|
+
"optimize",
|
|
105
|
+
"optimizacion",
|
|
106
|
+
"optimizar",
|
|
107
|
+
"optimization",
|
|
108
|
+
"robust",
|
|
109
|
+
"robusto",
|
|
110
|
+
"scalable",
|
|
111
|
+
"escalable",
|
|
112
|
+
"support",
|
|
113
|
+
"soportar",
|
|
114
|
+
"soporte",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_CONTACT_WORDS = {"contact", "contacto", "contactenos"}
|
|
118
|
+
_FORM_WORDS = {"form", "formulario"}
|
|
119
|
+
_PAGE_WORDS = {
|
|
120
|
+
"page",
|
|
121
|
+
"pagina",
|
|
122
|
+
"template",
|
|
123
|
+
"plantilla",
|
|
124
|
+
"view",
|
|
125
|
+
"vista",
|
|
126
|
+
}
|
|
127
|
+
_SUBMISSION_DECISIONS = {
|
|
128
|
+
"store",
|
|
129
|
+
"stored",
|
|
130
|
+
"save",
|
|
131
|
+
"saved",
|
|
132
|
+
"persist",
|
|
133
|
+
"persisted",
|
|
134
|
+
"database",
|
|
135
|
+
"db",
|
|
136
|
+
"email",
|
|
137
|
+
"correo",
|
|
138
|
+
"smtp",
|
|
139
|
+
"send_mail",
|
|
140
|
+
"confirmation",
|
|
141
|
+
"confirmacion",
|
|
142
|
+
"flash",
|
|
143
|
+
"almacenar",
|
|
144
|
+
"guardar",
|
|
145
|
+
}
|
|
146
|
+
_SUBMISSION_PHRASES = {
|
|
147
|
+
"no persistence",
|
|
148
|
+
"sin persistencia",
|
|
149
|
+
"only show",
|
|
150
|
+
"solo mostrar",
|
|
151
|
+
"confirmation message",
|
|
152
|
+
"mensaje de confirmacion",
|
|
153
|
+
}
|
|
154
|
+
_NO_PERSISTENCE_PHRASES = {
|
|
155
|
+
"no persistence",
|
|
156
|
+
"sin persistencia",
|
|
157
|
+
"do not persist",
|
|
158
|
+
"no guardar",
|
|
159
|
+
"only show",
|
|
160
|
+
"solo mostrar",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_AUTH_WORDS = {
|
|
164
|
+
"auth",
|
|
165
|
+
"authentication",
|
|
166
|
+
"autenticacion",
|
|
167
|
+
"authorization",
|
|
168
|
+
"autorizacion",
|
|
169
|
+
"contrasena",
|
|
170
|
+
"jwt",
|
|
171
|
+
"login",
|
|
172
|
+
"oauth",
|
|
173
|
+
"password",
|
|
174
|
+
"permission",
|
|
175
|
+
"permiso",
|
|
176
|
+
"session",
|
|
177
|
+
"sesion",
|
|
178
|
+
"token",
|
|
179
|
+
}
|
|
180
|
+
_AUTH_INTEGRATION_WORDS = {
|
|
181
|
+
"api",
|
|
182
|
+
"bearer",
|
|
183
|
+
"cookie",
|
|
184
|
+
"endpoint",
|
|
185
|
+
"html",
|
|
186
|
+
"rest",
|
|
187
|
+
"session",
|
|
188
|
+
"sesion",
|
|
189
|
+
"view",
|
|
190
|
+
"vista",
|
|
191
|
+
}
|
|
192
|
+
_DATA_WORDS = {
|
|
193
|
+
"campo",
|
|
194
|
+
"column",
|
|
195
|
+
"columna",
|
|
196
|
+
"data",
|
|
197
|
+
"database",
|
|
198
|
+
"datos",
|
|
199
|
+
"db",
|
|
200
|
+
"field",
|
|
201
|
+
"migration",
|
|
202
|
+
"migracion",
|
|
203
|
+
"model",
|
|
204
|
+
"modelo",
|
|
205
|
+
"persistencia",
|
|
206
|
+
"persistence",
|
|
207
|
+
"schema",
|
|
208
|
+
"tabla",
|
|
209
|
+
"table",
|
|
210
|
+
}
|
|
211
|
+
_PERSISTENCE_DECISIONS = {
|
|
212
|
+
"archivo",
|
|
213
|
+
"database",
|
|
214
|
+
"db",
|
|
215
|
+
"file",
|
|
216
|
+
"mongodb",
|
|
217
|
+
"mysql",
|
|
218
|
+
"postgres",
|
|
219
|
+
"postgresql",
|
|
220
|
+
"redis",
|
|
221
|
+
"sqlite",
|
|
222
|
+
}
|
|
223
|
+
_INTEGRATION_PHRASES = {
|
|
224
|
+
"send email",
|
|
225
|
+
"enviar correo",
|
|
226
|
+
"sendgrid",
|
|
227
|
+
"mailgun",
|
|
228
|
+
"smtp",
|
|
229
|
+
"stripe",
|
|
230
|
+
"webhook",
|
|
231
|
+
"third party",
|
|
232
|
+
"integrate with",
|
|
233
|
+
"integration with",
|
|
234
|
+
"integrar con",
|
|
235
|
+
"payment provider",
|
|
236
|
+
"pasarela de pago",
|
|
237
|
+
}
|
|
238
|
+
_PROVIDER_WORDS = {
|
|
239
|
+
"aws",
|
|
240
|
+
"gcp",
|
|
241
|
+
"github",
|
|
242
|
+
"google",
|
|
243
|
+
"mailgun",
|
|
244
|
+
"oauth",
|
|
245
|
+
"sendgrid",
|
|
246
|
+
"slack",
|
|
247
|
+
"smtp",
|
|
248
|
+
"stripe",
|
|
249
|
+
"twilio",
|
|
250
|
+
}
|
|
251
|
+
_DEPLOYMENT_WORDS = {
|
|
252
|
+
"aws",
|
|
253
|
+
"azure",
|
|
254
|
+
"cd",
|
|
255
|
+
"ci",
|
|
256
|
+
"deploy",
|
|
257
|
+
"deployment",
|
|
258
|
+
"desplegar",
|
|
259
|
+
"despliegue",
|
|
260
|
+
"docker",
|
|
261
|
+
"gcp",
|
|
262
|
+
"heroku",
|
|
263
|
+
"kubernetes",
|
|
264
|
+
"produccion",
|
|
265
|
+
"production",
|
|
266
|
+
"vercel",
|
|
267
|
+
}
|
|
268
|
+
_DEPLOYMENT_TARGETS = {
|
|
269
|
+
"aws",
|
|
270
|
+
"azure",
|
|
271
|
+
"docker",
|
|
272
|
+
"gcp",
|
|
273
|
+
"heroku",
|
|
274
|
+
"kubernetes",
|
|
275
|
+
"vercel",
|
|
276
|
+
}
|
|
277
|
+
_ARCHITECTURE_WORDS = {
|
|
278
|
+
"architecture",
|
|
279
|
+
"arquitectura",
|
|
280
|
+
"boundaries",
|
|
281
|
+
"boundary",
|
|
282
|
+
"capa",
|
|
283
|
+
"layer",
|
|
284
|
+
"microservice",
|
|
285
|
+
"microservicio",
|
|
286
|
+
"modulo",
|
|
287
|
+
"module",
|
|
288
|
+
"refactor",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class DiscoveryError(Exception):
|
|
293
|
+
"""Raised when discovery data is missing or malformed."""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class DiscoveryIncompleteError(DiscoveryError):
|
|
297
|
+
"""Raised when a workflow requires a complete discovery."""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True)
|
|
301
|
+
class DiscoveryQuestion:
|
|
302
|
+
"""One decision that may block specification generation."""
|
|
303
|
+
|
|
304
|
+
id: str
|
|
305
|
+
category: str
|
|
306
|
+
question: str
|
|
307
|
+
reason: str
|
|
308
|
+
blocking: bool
|
|
309
|
+
status: str = QUESTION_PENDING
|
|
310
|
+
options: tuple[str, ...] = ()
|
|
311
|
+
answer: str | None = None
|
|
312
|
+
answered_by: str | None = None
|
|
313
|
+
answered_at: str | None = None
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def is_answered(self) -> bool:
|
|
317
|
+
return self.status == QUESTION_ANSWERED and bool(self.answer)
|
|
318
|
+
|
|
319
|
+
def to_dict(self) -> dict[str, Any]:
|
|
320
|
+
return {
|
|
321
|
+
"id": self.id,
|
|
322
|
+
"category": self.category,
|
|
323
|
+
"question": self.question,
|
|
324
|
+
"reason": self.reason,
|
|
325
|
+
"blocking": self.blocking,
|
|
326
|
+
"status": self.status,
|
|
327
|
+
"options": list(self.options),
|
|
328
|
+
"answer": self.answer,
|
|
329
|
+
"answered_by": self.answered_by,
|
|
330
|
+
"answered_at": self.answered_at,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass(frozen=True)
|
|
335
|
+
class DiscoveryAssumption:
|
|
336
|
+
"""A technical assumption that needs explicit human acceptance."""
|
|
337
|
+
|
|
338
|
+
id: str
|
|
339
|
+
value: str
|
|
340
|
+
reason: str
|
|
341
|
+
source: str = "agent_default"
|
|
342
|
+
status: str = ASSUMPTION_PENDING
|
|
343
|
+
question_id: str | None = None
|
|
344
|
+
accepted_by: str | None = None
|
|
345
|
+
accepted_at: str | None = None
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def is_accepted(self) -> bool:
|
|
349
|
+
return self.status == ASSUMPTION_ACCEPTED
|
|
350
|
+
|
|
351
|
+
def to_dict(self) -> dict[str, Any]:
|
|
352
|
+
return {
|
|
353
|
+
"id": self.id,
|
|
354
|
+
"question_id": self.question_id,
|
|
355
|
+
"value": self.value,
|
|
356
|
+
"reason": self.reason,
|
|
357
|
+
"source": self.source,
|
|
358
|
+
"status": self.status,
|
|
359
|
+
"accepted_by": self.accepted_by,
|
|
360
|
+
"accepted_at": self.accepted_at,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True)
|
|
365
|
+
class DiscoveryDocument:
|
|
366
|
+
"""Persistent discovery record for one change."""
|
|
367
|
+
|
|
368
|
+
version: int
|
|
369
|
+
spec_id: str
|
|
370
|
+
change: str
|
|
371
|
+
request: str
|
|
372
|
+
project_mode: str
|
|
373
|
+
status: str
|
|
374
|
+
created_at: str
|
|
375
|
+
updated_at: str
|
|
376
|
+
questions: tuple[DiscoveryQuestion, ...] = ()
|
|
377
|
+
assumptions: tuple[DiscoveryAssumption, ...] = ()
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def pending_blocking(self) -> tuple[DiscoveryQuestion, ...]:
|
|
381
|
+
return tuple(
|
|
382
|
+
question
|
|
383
|
+
for question in self.questions
|
|
384
|
+
if question.blocking and not question.is_answered
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def pending_assumptions(
|
|
389
|
+
self,
|
|
390
|
+
) -> tuple[DiscoveryAssumption, ...]:
|
|
391
|
+
return tuple(
|
|
392
|
+
assumption
|
|
393
|
+
for assumption in self.assumptions
|
|
394
|
+
if not assumption.is_accepted
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def derived_status(self) -> str:
|
|
399
|
+
if self.pending_blocking or self.pending_assumptions:
|
|
400
|
+
return DISCOVERY_AWAITING
|
|
401
|
+
if self.status == DISCOVERY_PENDING:
|
|
402
|
+
return DISCOVERY_PENDING
|
|
403
|
+
return DISCOVERY_COMPLETE
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def is_complete(self) -> bool:
|
|
407
|
+
return self.derived_status == DISCOVERY_COMPLETE
|
|
408
|
+
|
|
409
|
+
def blockers(self) -> tuple[str, ...]:
|
|
410
|
+
messages: list[str] = []
|
|
411
|
+
for question in self.pending_blocking:
|
|
412
|
+
messages.append(f"{question.id}: {question.question}")
|
|
413
|
+
for assumption in self.pending_assumptions:
|
|
414
|
+
messages.append(
|
|
415
|
+
f"{assumption.id}: proposed assumption requires acceptance: "
|
|
416
|
+
f"{assumption.value}"
|
|
417
|
+
)
|
|
418
|
+
if not messages and self.status == DISCOVERY_PENDING:
|
|
419
|
+
messages.append("Discovery has not been completed.")
|
|
420
|
+
return tuple(messages)
|
|
421
|
+
|
|
422
|
+
def decisions(self) -> tuple[str, ...]:
|
|
423
|
+
decisions: list[str] = []
|
|
424
|
+
for question in self.questions:
|
|
425
|
+
if question.is_answered:
|
|
426
|
+
decisions.append(
|
|
427
|
+
f"{question.id} ({question.category}): "
|
|
428
|
+
f"{question.question} -> {question.answer}"
|
|
429
|
+
)
|
|
430
|
+
for assumption in self.assumptions:
|
|
431
|
+
if assumption.is_accepted:
|
|
432
|
+
decisions.append(
|
|
433
|
+
f"{assumption.id} (accepted by "
|
|
434
|
+
f"{assumption.accepted_by}): {assumption.value}"
|
|
435
|
+
)
|
|
436
|
+
return tuple(decisions)
|
|
437
|
+
|
|
438
|
+
def to_dict(self) -> dict[str, Any]:
|
|
439
|
+
return {
|
|
440
|
+
"version": self.version,
|
|
441
|
+
"spec_id": self.spec_id,
|
|
442
|
+
"change": self.change,
|
|
443
|
+
"request": self.request,
|
|
444
|
+
"project_mode": self.project_mode,
|
|
445
|
+
"status": self.status,
|
|
446
|
+
"created_at": self.created_at,
|
|
447
|
+
"updated_at": self.updated_at,
|
|
448
|
+
"questions": [
|
|
449
|
+
question.to_dict() for question in self.questions
|
|
450
|
+
],
|
|
451
|
+
"assumptions": [
|
|
452
|
+
assumption.to_dict() for assumption in self.assumptions
|
|
453
|
+
],
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class DiscoveryManager:
|
|
458
|
+
"""Read, validate and write discovery records safely."""
|
|
459
|
+
|
|
460
|
+
def __init__(
|
|
461
|
+
self,
|
|
462
|
+
root: Path,
|
|
463
|
+
*,
|
|
464
|
+
clock: Clock | None = None,
|
|
465
|
+
) -> None:
|
|
466
|
+
self.root = root.resolve()
|
|
467
|
+
self.store = ArtifactStore(self.root)
|
|
468
|
+
self.clock = clock or SystemClock()
|
|
469
|
+
|
|
470
|
+
def path(self, directory: Path) -> Path:
|
|
471
|
+
"""Return the discovery path for a change directory."""
|
|
472
|
+
return directory / DISCOVERY_FILENAME
|
|
473
|
+
|
|
474
|
+
def exists(self, directory: Path) -> bool:
|
|
475
|
+
"""Return whether a discovery file exists for a change."""
|
|
476
|
+
path = self.path(directory)
|
|
477
|
+
return path.exists() or path.is_symlink()
|
|
478
|
+
|
|
479
|
+
def load(
|
|
480
|
+
self,
|
|
481
|
+
directory: Path,
|
|
482
|
+
*,
|
|
483
|
+
required: bool = False,
|
|
484
|
+
) -> DiscoveryDocument | None:
|
|
485
|
+
"""Load and validate discovery data for a change."""
|
|
486
|
+
path = self.path(directory)
|
|
487
|
+
if path.is_symlink():
|
|
488
|
+
msg = (
|
|
489
|
+
"Discovery file must not be a symbolic link: "
|
|
490
|
+
f"{path}"
|
|
491
|
+
)
|
|
492
|
+
raise DiscoveryError(msg)
|
|
493
|
+
if not path.is_file():
|
|
494
|
+
if required:
|
|
495
|
+
msg = f"Discovery file does not exist: {path}"
|
|
496
|
+
raise DiscoveryError(msg)
|
|
497
|
+
return None
|
|
498
|
+
try:
|
|
499
|
+
size = path.stat().st_size
|
|
500
|
+
except OSError as error:
|
|
501
|
+
msg = f"Could not inspect discovery file: {path}"
|
|
502
|
+
raise DiscoveryError(msg) from error
|
|
503
|
+
if size > MAX_DISCOVERY_BYTES:
|
|
504
|
+
msg = (
|
|
505
|
+
"Discovery file is too large: "
|
|
506
|
+
f"{size} bytes (max {MAX_DISCOVERY_BYTES})."
|
|
507
|
+
)
|
|
508
|
+
raise DiscoveryError(msg)
|
|
509
|
+
try:
|
|
510
|
+
content = path.read_text(encoding="utf-8")
|
|
511
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
512
|
+
msg = f"Could not read discovery file: {path}"
|
|
513
|
+
raise DiscoveryError(msg) from error
|
|
514
|
+
try:
|
|
515
|
+
data = yaml.safe_load(content)
|
|
516
|
+
except yaml.YAMLError as error:
|
|
517
|
+
msg = f"Invalid YAML in discovery file: {path}"
|
|
518
|
+
raise DiscoveryError(msg) from error
|
|
519
|
+
document = self._parse(data, directory)
|
|
520
|
+
if (
|
|
521
|
+
document.status == DISCOVERY_COMPLETE
|
|
522
|
+
and (
|
|
523
|
+
document.pending_blocking
|
|
524
|
+
or document.pending_assumptions
|
|
525
|
+
)
|
|
526
|
+
):
|
|
527
|
+
msg = (
|
|
528
|
+
"Discovery is marked complete but still has pending "
|
|
529
|
+
"blocking questions or assumptions."
|
|
530
|
+
)
|
|
531
|
+
raise DiscoveryError(msg)
|
|
532
|
+
return replace(document, status=document.derived_status)
|
|
533
|
+
|
|
534
|
+
def write(
|
|
535
|
+
self,
|
|
536
|
+
directory: Path,
|
|
537
|
+
document: DiscoveryDocument,
|
|
538
|
+
) -> tuple[Path, str]:
|
|
539
|
+
"""Write discovery data atomically and return its digest."""
|
|
540
|
+
now = format_timestamp(self.clock.now())
|
|
541
|
+
normalized = replace(
|
|
542
|
+
document,
|
|
543
|
+
status=document.derived_status,
|
|
544
|
+
updated_at=now,
|
|
545
|
+
)
|
|
546
|
+
path = self.path(directory)
|
|
547
|
+
if path.is_symlink():
|
|
548
|
+
msg = (
|
|
549
|
+
"Discovery file must not be a symbolic link: "
|
|
550
|
+
f"{path}"
|
|
551
|
+
)
|
|
552
|
+
raise DiscoveryError(msg)
|
|
553
|
+
self.store.write(path, self._dump(normalized))
|
|
554
|
+
return path, self.store.digest_file(path)
|
|
555
|
+
|
|
556
|
+
def answer(
|
|
557
|
+
self,
|
|
558
|
+
document: DiscoveryDocument,
|
|
559
|
+
question_id: str,
|
|
560
|
+
answer: str,
|
|
561
|
+
*,
|
|
562
|
+
by: str = "user",
|
|
563
|
+
at: str | None = None,
|
|
564
|
+
) -> DiscoveryDocument:
|
|
565
|
+
"""Return discovery with one question answered explicitly."""
|
|
566
|
+
value = answer.strip()
|
|
567
|
+
if not value:
|
|
568
|
+
msg = "A discovery answer cannot be empty."
|
|
569
|
+
raise DiscoveryError(msg)
|
|
570
|
+
timestamp = at or format_timestamp(self.clock.now())
|
|
571
|
+
found = False
|
|
572
|
+
questions: list[DiscoveryQuestion] = []
|
|
573
|
+
for question in document.questions:
|
|
574
|
+
if question.id != question_id:
|
|
575
|
+
questions.append(question)
|
|
576
|
+
continue
|
|
577
|
+
found = True
|
|
578
|
+
questions.append(
|
|
579
|
+
replace(
|
|
580
|
+
question,
|
|
581
|
+
status=QUESTION_ANSWERED,
|
|
582
|
+
answer=value,
|
|
583
|
+
answered_by=by,
|
|
584
|
+
answered_at=timestamp,
|
|
585
|
+
)
|
|
586
|
+
)
|
|
587
|
+
if not found:
|
|
588
|
+
msg = f"Unknown discovery question: {question_id}"
|
|
589
|
+
raise DiscoveryError(msg)
|
|
590
|
+
updated = replace(
|
|
591
|
+
document,
|
|
592
|
+
questions=tuple(questions),
|
|
593
|
+
updated_at=timestamp,
|
|
594
|
+
)
|
|
595
|
+
return replace(updated, status=updated.derived_status)
|
|
596
|
+
|
|
597
|
+
def accept(
|
|
598
|
+
self,
|
|
599
|
+
document: DiscoveryDocument,
|
|
600
|
+
assumption_id: str,
|
|
601
|
+
*,
|
|
602
|
+
by: str = "user",
|
|
603
|
+
at: str | None = None,
|
|
604
|
+
) -> DiscoveryDocument:
|
|
605
|
+
"""Return discovery with one assumption explicitly accepted."""
|
|
606
|
+
timestamp = at or format_timestamp(self.clock.now())
|
|
607
|
+
found = False
|
|
608
|
+
assumptions: list[DiscoveryAssumption] = []
|
|
609
|
+
for assumption in document.assumptions:
|
|
610
|
+
if assumption.id != assumption_id:
|
|
611
|
+
assumptions.append(assumption)
|
|
612
|
+
continue
|
|
613
|
+
found = True
|
|
614
|
+
assumptions.append(
|
|
615
|
+
replace(
|
|
616
|
+
assumption,
|
|
617
|
+
status=ASSUMPTION_ACCEPTED,
|
|
618
|
+
accepted_by=by,
|
|
619
|
+
accepted_at=timestamp,
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
if not found:
|
|
623
|
+
msg = f"Unknown discovery assumption: {assumption_id}"
|
|
624
|
+
raise DiscoveryError(msg)
|
|
625
|
+
updated = replace(
|
|
626
|
+
document,
|
|
627
|
+
assumptions=tuple(assumptions),
|
|
628
|
+
updated_at=timestamp,
|
|
629
|
+
)
|
|
630
|
+
return replace(updated, status=updated.derived_status)
|
|
631
|
+
|
|
632
|
+
def _dump(self, document: DiscoveryDocument) -> str:
|
|
633
|
+
return yaml.safe_dump(
|
|
634
|
+
document.to_dict(),
|
|
635
|
+
sort_keys=False,
|
|
636
|
+
allow_unicode=True,
|
|
637
|
+
width=1000,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
def _parse(
|
|
641
|
+
self,
|
|
642
|
+
data: object,
|
|
643
|
+
directory: Path,
|
|
644
|
+
) -> DiscoveryDocument:
|
|
645
|
+
mapping = _mapping(data, "discovery")
|
|
646
|
+
version = _integer(mapping.get("version"), "version")
|
|
647
|
+
if version != 1:
|
|
648
|
+
msg = "Only discovery version 1 is supported."
|
|
649
|
+
raise DiscoveryError(msg)
|
|
650
|
+
spec_id = _text(mapping.get("spec_id"), "spec_id")
|
|
651
|
+
change = _text(mapping.get("change"), "change")
|
|
652
|
+
request = _text(mapping.get("request"), "request")
|
|
653
|
+
project_mode = _text(
|
|
654
|
+
mapping.get("project_mode"),
|
|
655
|
+
"project_mode",
|
|
656
|
+
).lower()
|
|
657
|
+
if project_mode not in PROJECT_MODES:
|
|
658
|
+
msg = (
|
|
659
|
+
"project_mode must be one of: "
|
|
660
|
+
+ ", ".join(PROJECT_MODES)
|
|
661
|
+
+ "."
|
|
662
|
+
)
|
|
663
|
+
raise DiscoveryError(msg)
|
|
664
|
+
status = _text(mapping.get("status"), "status")
|
|
665
|
+
if status not in DISCOVERY_STATUSES:
|
|
666
|
+
msg = (
|
|
667
|
+
"status must be one of: "
|
|
668
|
+
+ ", ".join(DISCOVERY_STATUSES)
|
|
669
|
+
+ "."
|
|
670
|
+
)
|
|
671
|
+
raise DiscoveryError(msg)
|
|
672
|
+
created_at = _text(mapping.get("created_at"), "created_at")
|
|
673
|
+
updated_at = _text(mapping.get("updated_at"), "updated_at")
|
|
674
|
+
|
|
675
|
+
if change != directory.name:
|
|
676
|
+
msg = (
|
|
677
|
+
f"Discovery change '{change}' does not match directory "
|
|
678
|
+
f"'{directory.name}'."
|
|
679
|
+
)
|
|
680
|
+
raise DiscoveryError(msg)
|
|
681
|
+
if not change.startswith(f"{spec_id}-"):
|
|
682
|
+
msg = (
|
|
683
|
+
f"Discovery spec_id '{spec_id}' does not match change "
|
|
684
|
+
f"'{change}'."
|
|
685
|
+
)
|
|
686
|
+
raise DiscoveryError(msg)
|
|
687
|
+
|
|
688
|
+
questions_data = _list(mapping.get("questions"), "questions")
|
|
689
|
+
assumptions_data = _list(
|
|
690
|
+
mapping.get("assumptions"),
|
|
691
|
+
"assumptions",
|
|
692
|
+
)
|
|
693
|
+
if len(questions_data) > MAX_QUESTIONS:
|
|
694
|
+
msg = f"At most {MAX_QUESTIONS} discovery questions are allowed."
|
|
695
|
+
raise DiscoveryError(msg)
|
|
696
|
+
if len(assumptions_data) > MAX_ASSUMPTIONS:
|
|
697
|
+
msg = (
|
|
698
|
+
f"At most {MAX_ASSUMPTIONS} discovery assumptions are allowed."
|
|
699
|
+
)
|
|
700
|
+
raise DiscoveryError(msg)
|
|
701
|
+
|
|
702
|
+
questions = tuple(
|
|
703
|
+
self._question(item, index)
|
|
704
|
+
for index, item in enumerate(questions_data, start=1)
|
|
705
|
+
)
|
|
706
|
+
question_ids = {question.id for question in questions}
|
|
707
|
+
if len(question_ids) != len(questions):
|
|
708
|
+
msg = "Discovery question ids must be unique."
|
|
709
|
+
raise DiscoveryError(msg)
|
|
710
|
+
|
|
711
|
+
assumptions = tuple(
|
|
712
|
+
self._assumption(item, index, question_ids)
|
|
713
|
+
for index, item in enumerate(assumptions_data, start=1)
|
|
714
|
+
)
|
|
715
|
+
assumption_ids = {
|
|
716
|
+
assumption.id for assumption in assumptions
|
|
717
|
+
}
|
|
718
|
+
if len(assumption_ids) != len(assumptions):
|
|
719
|
+
msg = "Discovery assumption ids must be unique."
|
|
720
|
+
raise DiscoveryError(msg)
|
|
721
|
+
|
|
722
|
+
document = DiscoveryDocument(
|
|
723
|
+
version=version,
|
|
724
|
+
spec_id=spec_id,
|
|
725
|
+
change=change,
|
|
726
|
+
request=request,
|
|
727
|
+
project_mode=project_mode,
|
|
728
|
+
status=status,
|
|
729
|
+
created_at=created_at,
|
|
730
|
+
updated_at=updated_at,
|
|
731
|
+
questions=questions,
|
|
732
|
+
assumptions=assumptions,
|
|
733
|
+
)
|
|
734
|
+
if (
|
|
735
|
+
document.status == DISCOVERY_COMPLETE
|
|
736
|
+
and (
|
|
737
|
+
document.pending_blocking
|
|
738
|
+
or document.pending_assumptions
|
|
739
|
+
)
|
|
740
|
+
):
|
|
741
|
+
msg = (
|
|
742
|
+
"Discovery is marked complete but still has pending "
|
|
743
|
+
"blocking questions or assumptions."
|
|
744
|
+
)
|
|
745
|
+
raise DiscoveryError(msg)
|
|
746
|
+
return document
|
|
747
|
+
|
|
748
|
+
def _question(
|
|
749
|
+
self,
|
|
750
|
+
data: object,
|
|
751
|
+
index: int,
|
|
752
|
+
) -> DiscoveryQuestion:
|
|
753
|
+
mapping = _mapping(data, f"questions[{index}]")
|
|
754
|
+
identifier = _text(mapping.get("id"), "question id")
|
|
755
|
+
if not _QUESTION_ID.fullmatch(identifier):
|
|
756
|
+
msg = f"Invalid discovery question id: {identifier!r}"
|
|
757
|
+
raise DiscoveryError(msg)
|
|
758
|
+
category = _text(mapping.get("category"), "question category")
|
|
759
|
+
if category not in QUESTION_CATEGORIES:
|
|
760
|
+
msg = (
|
|
761
|
+
f"Invalid discovery question category: {category!r}"
|
|
762
|
+
)
|
|
763
|
+
raise DiscoveryError(msg)
|
|
764
|
+
question = _limited_text(
|
|
765
|
+
mapping.get("question"),
|
|
766
|
+
f"{identifier}.question",
|
|
767
|
+
)
|
|
768
|
+
reason = _limited_text(
|
|
769
|
+
mapping.get("reason"),
|
|
770
|
+
f"{identifier}.reason",
|
|
771
|
+
)
|
|
772
|
+
blocking = _boolean(mapping.get("blocking"), f"{identifier}.blocking")
|
|
773
|
+
status = _text(mapping.get("status"), f"{identifier}.status")
|
|
774
|
+
if status not in QUESTION_STATUSES:
|
|
775
|
+
msg = (
|
|
776
|
+
f"Invalid status for {identifier}: {status!r}. "
|
|
777
|
+
f"Expected one of: {', '.join(QUESTION_STATUSES)}."
|
|
778
|
+
)
|
|
779
|
+
raise DiscoveryError(msg)
|
|
780
|
+
options = tuple(
|
|
781
|
+
_limited_text(item, f"{identifier}.options")
|
|
782
|
+
for item in _list(
|
|
783
|
+
mapping.get("options", []),
|
|
784
|
+
f"{identifier}.options",
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
if len(options) > MAX_OPTIONS:
|
|
788
|
+
msg = f"{identifier} has too many options."
|
|
789
|
+
raise DiscoveryError(msg)
|
|
790
|
+
answer = _optional_text(mapping.get("answer"), f"{identifier}.answer")
|
|
791
|
+
answered_by = _optional_text(
|
|
792
|
+
mapping.get("answered_by"),
|
|
793
|
+
f"{identifier}.answered_by",
|
|
794
|
+
)
|
|
795
|
+
answered_at = _optional_text(
|
|
796
|
+
mapping.get("answered_at"),
|
|
797
|
+
f"{identifier}.answered_at",
|
|
798
|
+
)
|
|
799
|
+
if status == QUESTION_ANSWERED:
|
|
800
|
+
if not answer:
|
|
801
|
+
raise DiscoveryError(
|
|
802
|
+
f"{identifier} is answered but has no answer."
|
|
803
|
+
)
|
|
804
|
+
if not answered_by:
|
|
805
|
+
raise DiscoveryError(
|
|
806
|
+
f"{identifier} is answered but has no answered_by."
|
|
807
|
+
)
|
|
808
|
+
if not answered_at:
|
|
809
|
+
raise DiscoveryError(
|
|
810
|
+
f"{identifier} is answered but has no answered_at."
|
|
811
|
+
)
|
|
812
|
+
elif answer or answered_by or answered_at:
|
|
813
|
+
raise DiscoveryError(
|
|
814
|
+
f"{identifier} is pending but contains answer metadata."
|
|
815
|
+
)
|
|
816
|
+
return DiscoveryQuestion(
|
|
817
|
+
id=identifier,
|
|
818
|
+
category=category,
|
|
819
|
+
question=question,
|
|
820
|
+
reason=reason,
|
|
821
|
+
blocking=blocking,
|
|
822
|
+
status=status,
|
|
823
|
+
options=options,
|
|
824
|
+
answer=answer,
|
|
825
|
+
answered_by=answered_by,
|
|
826
|
+
answered_at=answered_at,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def _assumption(
|
|
830
|
+
self,
|
|
831
|
+
data: object,
|
|
832
|
+
index: int,
|
|
833
|
+
question_ids: set[str],
|
|
834
|
+
) -> DiscoveryAssumption:
|
|
835
|
+
mapping = _mapping(data, f"assumptions[{index}]")
|
|
836
|
+
identifier = _text(mapping.get("id"), "assumption id")
|
|
837
|
+
if not _ASSUMPTION_ID.fullmatch(identifier):
|
|
838
|
+
msg = f"Invalid discovery assumption id: {identifier!r}"
|
|
839
|
+
raise DiscoveryError(msg)
|
|
840
|
+
question_id = _optional_text(
|
|
841
|
+
mapping.get("question_id"),
|
|
842
|
+
f"{identifier}.question_id",
|
|
843
|
+
)
|
|
844
|
+
if question_id is not None and question_id not in question_ids:
|
|
845
|
+
msg = (
|
|
846
|
+
f"{identifier} references unknown question "
|
|
847
|
+
f"{question_id!r}."
|
|
848
|
+
)
|
|
849
|
+
raise DiscoveryError(msg)
|
|
850
|
+
value = _limited_text(
|
|
851
|
+
mapping.get("value"),
|
|
852
|
+
f"{identifier}.value",
|
|
853
|
+
)
|
|
854
|
+
reason = _limited_text(
|
|
855
|
+
mapping.get("reason"),
|
|
856
|
+
f"{identifier}.reason",
|
|
857
|
+
)
|
|
858
|
+
source = _text(mapping.get("source"), f"{identifier}.source")
|
|
859
|
+
if source not in ASSUMPTION_SOURCES:
|
|
860
|
+
msg = (
|
|
861
|
+
f"Invalid source for {identifier}: {source!r}. "
|
|
862
|
+
f"Expected one of: {', '.join(ASSUMPTION_SOURCES)}."
|
|
863
|
+
)
|
|
864
|
+
raise DiscoveryError(msg)
|
|
865
|
+
status = _text(mapping.get("status"), f"{identifier}.status")
|
|
866
|
+
if status not in ASSUMPTION_STATUSES:
|
|
867
|
+
msg = (
|
|
868
|
+
f"Invalid status for {identifier}: {status!r}. "
|
|
869
|
+
f"Expected one of: {', '.join(ASSUMPTION_STATUSES)}."
|
|
870
|
+
)
|
|
871
|
+
raise DiscoveryError(msg)
|
|
872
|
+
accepted_by = _optional_text(
|
|
873
|
+
mapping.get("accepted_by"),
|
|
874
|
+
f"{identifier}.accepted_by",
|
|
875
|
+
)
|
|
876
|
+
accepted_at = _optional_text(
|
|
877
|
+
mapping.get("accepted_at"),
|
|
878
|
+
f"{identifier}.accepted_at",
|
|
879
|
+
)
|
|
880
|
+
if status == ASSUMPTION_ACCEPTED:
|
|
881
|
+
if not accepted_by or not accepted_at:
|
|
882
|
+
raise DiscoveryError(
|
|
883
|
+
f"{identifier} is accepted but has no acceptance metadata."
|
|
884
|
+
)
|
|
885
|
+
elif accepted_by or accepted_at:
|
|
886
|
+
raise DiscoveryError(
|
|
887
|
+
f"{identifier} is pending but contains acceptance metadata."
|
|
888
|
+
)
|
|
889
|
+
return DiscoveryAssumption(
|
|
890
|
+
id=identifier,
|
|
891
|
+
question_id=question_id,
|
|
892
|
+
value=value,
|
|
893
|
+
reason=reason,
|
|
894
|
+
source=source,
|
|
895
|
+
status=status,
|
|
896
|
+
accepted_by=accepted_by,
|
|
897
|
+
accepted_at=accepted_at,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
class DiscoveryPlanner:
|
|
902
|
+
"""Generate deterministic discovery questions from a request."""
|
|
903
|
+
|
|
904
|
+
def build(
|
|
905
|
+
self,
|
|
906
|
+
*,
|
|
907
|
+
request: str,
|
|
908
|
+
spec_id: str,
|
|
909
|
+
change: str,
|
|
910
|
+
project_mode: str,
|
|
911
|
+
project: Project,
|
|
912
|
+
profiles: tuple[str, ...],
|
|
913
|
+
reference_paths: tuple[str, ...],
|
|
914
|
+
category_paths: dict[str, tuple[str, ...]],
|
|
915
|
+
has_tests: bool,
|
|
916
|
+
architecture: ArchitectureManifest | None,
|
|
917
|
+
now: str,
|
|
918
|
+
) -> DiscoveryDocument:
|
|
919
|
+
"""Return a deterministic discovery record for a request."""
|
|
920
|
+
text = _normalize(request)
|
|
921
|
+
words = _words(text)
|
|
922
|
+
questions: list[DiscoveryQuestion] = []
|
|
923
|
+
assumptions: list[DiscoveryAssumption] = []
|
|
924
|
+
|
|
925
|
+
def add_question(
|
|
926
|
+
category: str,
|
|
927
|
+
question: str,
|
|
928
|
+
reason: str,
|
|
929
|
+
options: tuple[str, ...] = (),
|
|
930
|
+
) -> str | None:
|
|
931
|
+
if any(
|
|
932
|
+
item.question.casefold() == question.casefold()
|
|
933
|
+
for item in questions
|
|
934
|
+
):
|
|
935
|
+
return None
|
|
936
|
+
identifier = f"Q{len(questions) + 1}"
|
|
937
|
+
questions.append(
|
|
938
|
+
DiscoveryQuestion(
|
|
939
|
+
id=identifier,
|
|
940
|
+
category=category,
|
|
941
|
+
question=question,
|
|
942
|
+
reason=reason,
|
|
943
|
+
blocking=True,
|
|
944
|
+
options=options,
|
|
945
|
+
)
|
|
946
|
+
)
|
|
947
|
+
return identifier
|
|
948
|
+
|
|
949
|
+
keywords = [
|
|
950
|
+
word
|
|
951
|
+
for word in words
|
|
952
|
+
if len(word) >= 3 and word not in _STOPWORDS
|
|
953
|
+
]
|
|
954
|
+
if len(keywords) < 2:
|
|
955
|
+
add_question(
|
|
956
|
+
"functional_scope",
|
|
957
|
+
"What exact behavior should change, and how will it be "
|
|
958
|
+
"verified?",
|
|
959
|
+
"The request is too short to define a testable scope.",
|
|
960
|
+
(
|
|
961
|
+
"Describe the user-visible behavior.",
|
|
962
|
+
"Describe the expected technical outcome.",
|
|
963
|
+
),
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
vague = sorted(words & _VAGUE_TERMS)
|
|
967
|
+
if vague:
|
|
968
|
+
target = vague[0]
|
|
969
|
+
add_question(
|
|
970
|
+
"functional_scope",
|
|
971
|
+
f"What measurable outcome defines '{target}' for this change?",
|
|
972
|
+
"A vague goal changes the functional scope and the acceptance "
|
|
973
|
+
"criteria.",
|
|
974
|
+
(
|
|
975
|
+
"Define the expected observable behavior.",
|
|
976
|
+
"Define a measurable target.",
|
|
977
|
+
"Describe the current pain point.",
|
|
978
|
+
),
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
contact_question: str | None = None
|
|
982
|
+
is_contact_page = (
|
|
983
|
+
bool(words & _CONTACT_WORDS)
|
|
984
|
+
and bool(words & (_FORM_WORDS | _PAGE_WORDS))
|
|
985
|
+
)
|
|
986
|
+
if is_contact_page and not _has_submission_decision(text, words):
|
|
987
|
+
contact_question = add_question(
|
|
988
|
+
"functional_scope",
|
|
989
|
+
"What should happen when the contact form is submitted?",
|
|
990
|
+
"Persistence, confirmation and email delivery change the data "
|
|
991
|
+
"model, dependencies and user-visible behavior.",
|
|
992
|
+
(
|
|
993
|
+
"Only show a confirmation message.",
|
|
994
|
+
"Store messages in the database.",
|
|
995
|
+
"Send an email notification.",
|
|
996
|
+
"Store messages and send an email.",
|
|
997
|
+
),
|
|
998
|
+
)
|
|
999
|
+
if contact_question and not _has_contact_names(text, words):
|
|
1000
|
+
assumptions.append(
|
|
1001
|
+
DiscoveryAssumption(
|
|
1002
|
+
id="A1",
|
|
1003
|
+
question_id=contact_question,
|
|
1004
|
+
value=(
|
|
1005
|
+
"Use `contacto.html` as the template and `/contacto/` as "
|
|
1006
|
+
"the URL path."
|
|
1007
|
+
),
|
|
1008
|
+
reason=(
|
|
1009
|
+
"Internal naming and routing can follow existing project "
|
|
1010
|
+
"conventions, but the choice must be visible."
|
|
1011
|
+
),
|
|
1012
|
+
source="agent_default",
|
|
1013
|
+
)
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
auth_question: str | None = None
|
|
1017
|
+
is_auth_request = bool(words & _AUTH_WORDS)
|
|
1018
|
+
if is_auth_request and not (words & _AUTH_INTEGRATION_WORDS):
|
|
1019
|
+
auth_question = add_question(
|
|
1020
|
+
"security_authentication",
|
|
1021
|
+
"How should authentication be integrated into the existing "
|
|
1022
|
+
"application?",
|
|
1023
|
+
"API tokens, browser cookies and session authentication change "
|
|
1024
|
+
"security boundaries, dependencies and user-visible behavior.",
|
|
1025
|
+
(
|
|
1026
|
+
"New JSON API with Bearer tokens.",
|
|
1027
|
+
"JWT in an HttpOnly cookie for HTML views.",
|
|
1028
|
+
"Both the JSON API and the HTML views.",
|
|
1029
|
+
"Other (describe).",
|
|
1030
|
+
),
|
|
1031
|
+
)
|
|
1032
|
+
if auth_question and (words & {"jwt", "oauth", "token"}):
|
|
1033
|
+
if not (words & {
|
|
1034
|
+
"django",
|
|
1035
|
+
"djangorestframework",
|
|
1036
|
+
"framework",
|
|
1037
|
+
"pyjwt",
|
|
1038
|
+
"rest",
|
|
1039
|
+
"simplejwt",
|
|
1040
|
+
}):
|
|
1041
|
+
assumptions.append(
|
|
1042
|
+
DiscoveryAssumption(
|
|
1043
|
+
id=f"A{len(assumptions) + 1}",
|
|
1044
|
+
question_id=auth_question,
|
|
1045
|
+
value=(
|
|
1046
|
+
"Use the existing user identity source and a maintained "
|
|
1047
|
+
"JWT library instead of custom cryptographic code."
|
|
1048
|
+
),
|
|
1049
|
+
reason=(
|
|
1050
|
+
"The library and identity source change dependencies and "
|
|
1051
|
+
"security behavior, so the assumption must be accepted."
|
|
1052
|
+
),
|
|
1053
|
+
source="agent_default",
|
|
1054
|
+
)
|
|
1055
|
+
)
|
|
1056
|
+
if not (words & {
|
|
1057
|
+
"day",
|
|
1058
|
+
"days",
|
|
1059
|
+
"expiracion",
|
|
1060
|
+
"hour",
|
|
1061
|
+
"hours",
|
|
1062
|
+
"lifetime",
|
|
1063
|
+
"minute",
|
|
1064
|
+
"minutes",
|
|
1065
|
+
}):
|
|
1066
|
+
assumptions.append(
|
|
1067
|
+
DiscoveryAssumption(
|
|
1068
|
+
id=f"A{len(assumptions) + 1}",
|
|
1069
|
+
question_id=auth_question,
|
|
1070
|
+
value=(
|
|
1071
|
+
"Use short-lived access tokens with refresh tokens and "
|
|
1072
|
+
"define both lifetimes in project settings."
|
|
1073
|
+
),
|
|
1074
|
+
reason=(
|
|
1075
|
+
"Token lifetimes are a security decision and must not be "
|
|
1076
|
+
"invented silently."
|
|
1077
|
+
),
|
|
1078
|
+
source="agent_default",
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
if (
|
|
1083
|
+
words & _DATA_WORDS
|
|
1084
|
+
and not project.databases
|
|
1085
|
+
and not _has_persistence_decision(text, words)
|
|
1086
|
+
and "Database migrations" not in category_paths
|
|
1087
|
+
):
|
|
1088
|
+
add_question(
|
|
1089
|
+
"data_persistence",
|
|
1090
|
+
"Where should the new data be stored and how should it be "
|
|
1091
|
+
"persisted?",
|
|
1092
|
+
"Persistence changes the data model, migrations, tests and "
|
|
1093
|
+
"deployment requirements.",
|
|
1094
|
+
(
|
|
1095
|
+
"Use the existing database.",
|
|
1096
|
+
"Create a new database or service.",
|
|
1097
|
+
"No persistence is required.",
|
|
1098
|
+
),
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
if (
|
|
1102
|
+
contact_question is None
|
|
1103
|
+
and _has_external_integration(text, words)
|
|
1104
|
+
and not (words & _PROVIDER_WORDS)
|
|
1105
|
+
):
|
|
1106
|
+
add_question(
|
|
1107
|
+
"external_integration",
|
|
1108
|
+
"Which external service or integration should be used?",
|
|
1109
|
+
"The provider, credentials and failure behavior change the "
|
|
1110
|
+
"implementation and operations.",
|
|
1111
|
+
(
|
|
1112
|
+
"Describe the provider and its configuration.",
|
|
1113
|
+
"Reuse an existing integration.",
|
|
1114
|
+
"No external integration is required.",
|
|
1115
|
+
),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
if (
|
|
1119
|
+
words & _DEPLOYMENT_WORDS
|
|
1120
|
+
and not (words & _DEPLOYMENT_TARGETS)
|
|
1121
|
+
):
|
|
1122
|
+
add_question(
|
|
1123
|
+
"deployment",
|
|
1124
|
+
"Which deployment target and environment should the change "
|
|
1125
|
+
"support?",
|
|
1126
|
+
"Deployment constraints change configuration, secrets and "
|
|
1127
|
+
"operational requirements.",
|
|
1128
|
+
(
|
|
1129
|
+
"Use the existing deployment target.",
|
|
1130
|
+
"Add a new target (describe it).",
|
|
1131
|
+
"Local development only.",
|
|
1132
|
+
),
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
if (
|
|
1136
|
+
project_mode == "existing"
|
|
1137
|
+
and architecture is None
|
|
1138
|
+
and words & _ARCHITECTURE_WORDS
|
|
1139
|
+
):
|
|
1140
|
+
add_question(
|
|
1141
|
+
"architecture_boundary",
|
|
1142
|
+
"Which module boundaries and dependency direction should the "
|
|
1143
|
+
"new behavior follow?",
|
|
1144
|
+
"Without a declared architecture manifest, ownership and "
|
|
1145
|
+
"dependency boundaries are ambiguous.",
|
|
1146
|
+
(
|
|
1147
|
+
"Follow the existing project structure.",
|
|
1148
|
+
"Create a new module (describe it).",
|
|
1149
|
+
"Other (describe).",
|
|
1150
|
+
),
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
if project_mode == "greenfield" and architecture is not None:
|
|
1154
|
+
if not architecture.stack:
|
|
1155
|
+
add_question(
|
|
1156
|
+
"dependencies",
|
|
1157
|
+
"Which technology stack should the new system use?",
|
|
1158
|
+
"The declared architecture manifest does not define the "
|
|
1159
|
+
"languages, frameworks or database.",
|
|
1160
|
+
(
|
|
1161
|
+
"Complete the architecture manifest.",
|
|
1162
|
+
"Describe the stack for this change.",
|
|
1163
|
+
),
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
questions = questions[:4]
|
|
1167
|
+
question_ids = {question.id for question in questions}
|
|
1168
|
+
assumptions = [
|
|
1169
|
+
assumption
|
|
1170
|
+
for assumption in assumptions[:3]
|
|
1171
|
+
if assumption.question_id is None
|
|
1172
|
+
or assumption.question_id in question_ids
|
|
1173
|
+
]
|
|
1174
|
+
# Keep assumption ids contiguous after filtering.
|
|
1175
|
+
assumptions = [
|
|
1176
|
+
replace(assumption, id=f"A{index}")
|
|
1177
|
+
for index, assumption in enumerate(assumptions, start=1)
|
|
1178
|
+
]
|
|
1179
|
+
|
|
1180
|
+
document = DiscoveryDocument(
|
|
1181
|
+
version=1,
|
|
1182
|
+
spec_id=spec_id,
|
|
1183
|
+
change=change,
|
|
1184
|
+
request=request.strip(),
|
|
1185
|
+
project_mode=project_mode,
|
|
1186
|
+
status=(
|
|
1187
|
+
DISCOVERY_COMPLETE
|
|
1188
|
+
if not questions and not assumptions
|
|
1189
|
+
else DISCOVERY_PENDING
|
|
1190
|
+
),
|
|
1191
|
+
created_at=now,
|
|
1192
|
+
updated_at=now,
|
|
1193
|
+
questions=tuple(questions),
|
|
1194
|
+
assumptions=tuple(assumptions),
|
|
1195
|
+
)
|
|
1196
|
+
return replace(document, status=document.derived_status)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _normalize(value: str) -> str:
|
|
1200
|
+
normalized = unicodedata.normalize("NFKD", value)
|
|
1201
|
+
without_accents = "".join(
|
|
1202
|
+
character
|
|
1203
|
+
for character in normalized
|
|
1204
|
+
if not unicodedata.combining(character)
|
|
1205
|
+
)
|
|
1206
|
+
return without_accents.lower()
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _words(text: str) -> set[str]:
|
|
1210
|
+
return set(re.findall(r"[a-z0-9_]+", text))
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _has_submission_decision(text: str, words: set[str]) -> bool:
|
|
1214
|
+
if words & _SUBMISSION_DECISIONS:
|
|
1215
|
+
return True
|
|
1216
|
+
return _has_phrase(text, _SUBMISSION_PHRASES)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _has_contact_names(text: str, words: set[str]) -> bool:
|
|
1220
|
+
return (
|
|
1221
|
+
"contacto.html" in text
|
|
1222
|
+
or "/contacto/" in text
|
|
1223
|
+
or "contact.html" in text
|
|
1224
|
+
or "/contact/" in text
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _has_persistence_decision(text: str, words: set[str]) -> bool:
|
|
1229
|
+
if words & _PERSISTENCE_DECISIONS:
|
|
1230
|
+
return True
|
|
1231
|
+
return _has_phrase(text, _NO_PERSISTENCE_PHRASES)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _has_external_integration(text: str, words: set[str]) -> bool:
|
|
1235
|
+
return _has_phrase(text, _INTEGRATION_PHRASES) or (
|
|
1236
|
+
bool(words & {"webhook", "webhooks"})
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _has_phrase(text: str, phrases: set[str]) -> bool:
|
|
1241
|
+
return any(phrase in text for phrase in phrases)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _mapping(value: object, field: str) -> dict[str, Any]:
|
|
1245
|
+
if not isinstance(value, dict):
|
|
1246
|
+
msg = f"{field} must be a mapping."
|
|
1247
|
+
raise DiscoveryError(msg)
|
|
1248
|
+
return value
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _list(value: object, field: str) -> list[Any]:
|
|
1252
|
+
if value is None:
|
|
1253
|
+
return []
|
|
1254
|
+
if not isinstance(value, list):
|
|
1255
|
+
msg = f"{field} must be a list."
|
|
1256
|
+
raise DiscoveryError(msg)
|
|
1257
|
+
return value
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _text(value: object, field: str) -> str:
|
|
1261
|
+
if not isinstance(value, str) or not value.strip():
|
|
1262
|
+
msg = f"{field} must be a non-empty string."
|
|
1263
|
+
raise DiscoveryError(msg)
|
|
1264
|
+
return value.strip()
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _optional_text(value: object, field: str) -> str | None:
|
|
1268
|
+
if value is None:
|
|
1269
|
+
return None
|
|
1270
|
+
if not isinstance(value, str):
|
|
1271
|
+
msg = f"{field} must be a string or null."
|
|
1272
|
+
raise DiscoveryError(msg)
|
|
1273
|
+
stripped = value.strip()
|
|
1274
|
+
return stripped or None
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _limited_text(value: object, field: str) -> str:
|
|
1278
|
+
text = _text(value, field)
|
|
1279
|
+
if len(text) > MAX_TEXT_LENGTH:
|
|
1280
|
+
msg = (
|
|
1281
|
+
f"{field} is too long ({len(text)} characters, "
|
|
1282
|
+
f"max {MAX_TEXT_LENGTH})."
|
|
1283
|
+
)
|
|
1284
|
+
raise DiscoveryError(msg)
|
|
1285
|
+
return text
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _boolean(value: object, field: str) -> bool:
|
|
1289
|
+
if not isinstance(value, bool):
|
|
1290
|
+
msg = f"{field} must be true or false."
|
|
1291
|
+
raise DiscoveryError(msg)
|
|
1292
|
+
return value
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _integer(value: object, field: str) -> int:
|
|
1296
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1297
|
+
msg = f"{field} must be an integer."
|
|
1298
|
+
raise DiscoveryError(msg)
|
|
1299
|
+
return value
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def is_valid_digest(value: str) -> bool:
|
|
1303
|
+
"""Return whether a value is a DevFlow SHA-256 digest."""
|
|
1304
|
+
return bool(_SHA256.fullmatch(value))
|