specfact-cli 0.6.3__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.
Files changed (97) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +24 -0
  3. specfact_cli/agents/analyze_agent.py +391 -0
  4. specfact_cli/agents/base.py +95 -0
  5. specfact_cli/agents/plan_agent.py +202 -0
  6. specfact_cli/agents/registry.py +176 -0
  7. specfact_cli/agents/sync_agent.py +133 -0
  8. specfact_cli/analyzers/__init__.py +12 -0
  9. specfact_cli/analyzers/ambiguity_scanner.py +592 -0
  10. specfact_cli/analyzers/code_analyzer.py +1228 -0
  11. specfact_cli/analyzers/contract_extractor.py +419 -0
  12. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  13. specfact_cli/analyzers/requirement_extractor.py +337 -0
  14. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  15. specfact_cli/cli.py +264 -0
  16. specfact_cli/commands/__init__.py +7 -0
  17. specfact_cli/commands/constitution.py +261 -0
  18. specfact_cli/commands/enforce.py +96 -0
  19. specfact_cli/commands/import_cmd.py +694 -0
  20. specfact_cli/commands/init.py +143 -0
  21. specfact_cli/commands/plan.py +2398 -0
  22. specfact_cli/commands/repro.py +214 -0
  23. specfact_cli/commands/sync.py +744 -0
  24. specfact_cli/common/__init__.py +25 -0
  25. specfact_cli/common/logger_setup.py +654 -0
  26. specfact_cli/common/logging_utils.py +41 -0
  27. specfact_cli/common/text_utils.py +52 -0
  28. specfact_cli/common/utils.py +48 -0
  29. specfact_cli/comparators/__init__.py +11 -0
  30. specfact_cli/comparators/plan_comparator.py +391 -0
  31. specfact_cli/enrichers/constitution_enricher.py +765 -0
  32. specfact_cli/enrichers/plan_enricher.py +268 -0
  33. specfact_cli/generators/__init__.py +14 -0
  34. specfact_cli/generators/plan_generator.py +105 -0
  35. specfact_cli/generators/protocol_generator.py +115 -0
  36. specfact_cli/generators/report_generator.py +200 -0
  37. specfact_cli/generators/workflow_generator.py +120 -0
  38. specfact_cli/importers/__init__.py +7 -0
  39. specfact_cli/importers/speckit_converter.py +1051 -0
  40. specfact_cli/importers/speckit_scanner.py +776 -0
  41. specfact_cli/models/__init__.py +33 -0
  42. specfact_cli/models/deviation.py +105 -0
  43. specfact_cli/models/enforcement.py +150 -0
  44. specfact_cli/models/plan.py +139 -0
  45. specfact_cli/models/protocol.py +28 -0
  46. specfact_cli/modes/__init__.py +19 -0
  47. specfact_cli/modes/detector.py +126 -0
  48. specfact_cli/modes/router.py +153 -0
  49. specfact_cli/resources/mappings/node-async.yaml +49 -0
  50. specfact_cli/resources/mappings/python-async.yaml +47 -0
  51. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  52. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  53. specfact_cli/resources/prompts/specfact-import-from-code.md +597 -0
  54. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  55. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  56. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  57. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  58. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  59. specfact_cli/resources/prompts/specfact-plan-review.md +869 -0
  60. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  61. specfact_cli/resources/prompts/specfact-plan-update-feature.md +234 -0
  62. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  63. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  64. specfact_cli/resources/prompts/specfact-sync.md +457 -0
  65. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  66. specfact_cli/resources/schemas/plan.schema.json +204 -0
  67. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  68. specfact_cli/resources/semgrep/async.yml +285 -0
  69. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  70. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  71. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  72. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  73. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  74. specfact_cli/sync/__init__.py +21 -0
  75. specfact_cli/sync/repository_sync.py +279 -0
  76. specfact_cli/sync/speckit_sync.py +388 -0
  77. specfact_cli/sync/watcher.py +268 -0
  78. specfact_cli/telemetry.py +440 -0
  79. specfact_cli/utils/__init__.py +58 -0
  80. specfact_cli/utils/console.py +70 -0
  81. specfact_cli/utils/enrichment_parser.py +445 -0
  82. specfact_cli/utils/feature_keys.py +212 -0
  83. specfact_cli/utils/git.py +241 -0
  84. specfact_cli/utils/github_annotations.py +399 -0
  85. specfact_cli/utils/ide_setup.py +389 -0
  86. specfact_cli/utils/prompts.py +180 -0
  87. specfact_cli/utils/structure.py +674 -0
  88. specfact_cli/utils/yaml_utils.py +200 -0
  89. specfact_cli/validators/__init__.py +20 -0
  90. specfact_cli/validators/fsm.py +262 -0
  91. specfact_cli/validators/repro_checker.py +780 -0
  92. specfact_cli/validators/schema.py +196 -0
  93. specfact_cli-0.6.3.dist-info/METADATA +456 -0
  94. specfact_cli-0.6.3.dist-info/RECORD +97 -0
  95. specfact_cli-0.6.3.dist-info/WHEEL +4 -0
  96. specfact_cli-0.6.3.dist-info/entry_points.txt +2 -0
  97. specfact_cli-0.6.3.dist-info/licenses/LICENSE.md +202 -0
@@ -0,0 +1,776 @@
1
+ """
2
+ Spec-Kit scanner for importing Spec-Kit projects.
3
+
4
+ This module provides functionality to scan Spec-Kit repositories,
5
+ parse their structure, and extract features, stories, and requirements
6
+ from markdown artifacts generated by Spec-Kit commands.
7
+
8
+ Spec-Kit uses slash commands (/speckit.specify, /speckit.plan, etc.) to
9
+ generate markdown artifacts in specs/ and .specify/ directories.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from beartype import beartype
19
+ from icontract import ensure, require
20
+
21
+
22
+ class SpecKitScanner:
23
+ """
24
+ Scanner for Spec-Kit repositories.
25
+
26
+ Scans Spec-Kit directory structure, parses markdown files (spec.md, plan.md, tasks.md),
27
+ and extracts features, user stories, requirements, and tasks.
28
+ """
29
+
30
+ # Spec-Kit directory structure
31
+ SPECIFY_DIR = ".specify"
32
+ SPECIFY_MEMORY_DIR = ".specify/memory"
33
+ SPECS_DIR = "specs"
34
+
35
+ @beartype
36
+ def __init__(self, repo_path: Path) -> None:
37
+ """
38
+ Initialize Spec-Kit scanner.
39
+
40
+ Args:
41
+ repo_path: Path to Spec-Kit repository root
42
+ """
43
+ self.repo_path = Path(repo_path)
44
+
45
+ @beartype
46
+ @ensure(lambda result: isinstance(result, bool), "Must return boolean")
47
+ def is_speckit_repo(self) -> bool:
48
+ """
49
+ Check if repository is a Spec-Kit project.
50
+
51
+ Returns:
52
+ True if Spec-Kit structure detected, False otherwise
53
+ """
54
+ # Check for Spec-Kit format (.specify directory)
55
+ specify_dir = self.repo_path / self.SPECIFY_DIR
56
+ return specify_dir.exists() and specify_dir.is_dir()
57
+
58
+ @beartype
59
+ @ensure(lambda result: isinstance(result, tuple), "Must return tuple")
60
+ @ensure(lambda result: len(result) == 2, "Must return (bool, str) tuple")
61
+ def has_constitution(self) -> tuple[bool, str]:
62
+ """
63
+ Check if constitution.md exists and is not empty.
64
+
65
+ Returns:
66
+ Tuple of (exists_and_valid, error_message)
67
+ - exists_and_valid: True if constitution exists and has content
68
+ - error_message: Empty string if valid, otherwise error description
69
+ """
70
+ memory_dir = self.repo_path / self.SPECIFY_MEMORY_DIR
71
+ constitution_file = memory_dir / "constitution.md"
72
+
73
+ if not memory_dir.exists():
74
+ return (
75
+ False,
76
+ f"Spec-Kit memory directory not found: {memory_dir}\n"
77
+ "The constitution must be created before syncing.\n"
78
+ "Run '/speckit.constitution' command first to create the project constitution.",
79
+ )
80
+
81
+ if not constitution_file.exists():
82
+ return (
83
+ False,
84
+ f"Constitution file not found: {constitution_file}\n"
85
+ "The constitution is required before syncing Spec-Kit artifacts.\n"
86
+ "Run '/speckit.constitution' command first to create the project constitution.",
87
+ )
88
+
89
+ # Check if file is empty or only contains whitespace/placeholders
90
+ try:
91
+ content = constitution_file.read_text(encoding="utf-8").strip()
92
+ if not content:
93
+ return (
94
+ False,
95
+ f"Constitution file is empty: {constitution_file}\n"
96
+ "The constitution must be populated before syncing.\n"
97
+ "Run '/speckit.constitution' command to fill in the constitution template.",
98
+ )
99
+
100
+ # Check if file only contains template placeholders (no actual content)
101
+ # Look for patterns like [PROJECT_NAME], [PRINCIPLE_1_NAME], etc.
102
+ placeholder_pattern = r"\[[A-Z_0-9]+\]"
103
+ placeholder_count = len(re.findall(placeholder_pattern, content))
104
+ # If more than 50% of lines contain placeholders, consider it a template
105
+ lines = [line.strip() for line in content.split("\n") if line.strip()]
106
+ if lines and placeholder_count > len(lines) * 0.5:
107
+ return (
108
+ False,
109
+ f"Constitution file contains only template placeholders: {constitution_file}\n"
110
+ "The constitution must be filled in before syncing.\n"
111
+ "Run '/speckit.constitution' command to replace placeholders with actual project principles.",
112
+ )
113
+
114
+ return (True, "")
115
+ except Exception as e:
116
+ return (
117
+ False,
118
+ f"Error reading constitution file: {constitution_file}\n"
119
+ f"Error: {e!s}\n"
120
+ "Please ensure the constitution file is readable and try again.",
121
+ )
122
+
123
+ @beartype
124
+ @ensure(lambda result: isinstance(result, dict), "Must return dictionary")
125
+ @ensure(lambda result: "is_speckit" in result, "Must include is_speckit key")
126
+ def scan_structure(self) -> dict[str, Any]:
127
+ """
128
+ Scan Spec-Kit directory structure.
129
+
130
+ Returns:
131
+ Dictionary with detected structure information
132
+ """
133
+ structure: dict[str, Any] = {
134
+ "is_speckit": False,
135
+ "specify_dir": None,
136
+ "specify_memory_dir": None,
137
+ "specs_dir": None,
138
+ "spec_files": [],
139
+ "feature_dirs": [],
140
+ "memory_files": [],
141
+ }
142
+ # Explicitly type the list values for type checker
143
+ spec_files: list[str] = []
144
+ feature_dirs: list[str] = []
145
+ memory_files: list[str] = []
146
+ structure["spec_files"] = spec_files
147
+ structure["feature_dirs"] = feature_dirs
148
+ structure["memory_files"] = memory_files
149
+
150
+ if not self.is_speckit_repo():
151
+ return structure
152
+
153
+ structure["is_speckit"] = True
154
+
155
+ # Check for .specify directory
156
+ specify_dir = self.repo_path / self.SPECIFY_DIR
157
+ if specify_dir.exists() and specify_dir.is_dir():
158
+ structure["specify_dir"] = str(specify_dir)
159
+
160
+ # Check for .specify/memory directory
161
+ specify_memory_dir = self.repo_path / self.SPECIFY_MEMORY_DIR
162
+ if specify_memory_dir.exists():
163
+ structure["specify_memory_dir"] = str(specify_memory_dir)
164
+ structure["memory_files"] = [str(f) for f in specify_memory_dir.glob("*.md")]
165
+
166
+ # Check for specs directory
167
+ specs_dir = self.repo_path / self.SPECS_DIR
168
+ if specs_dir.exists():
169
+ structure["specs_dir"] = str(specs_dir)
170
+ # Find all feature directories (specs/*/)
171
+ for spec_dir in specs_dir.iterdir():
172
+ if spec_dir.is_dir():
173
+ feature_dirs.append(str(spec_dir))
174
+ # Find all markdown files in each feature directory
175
+ for md_file in spec_dir.glob("*.md"):
176
+ spec_files.append(str(md_file))
177
+ # Also check for contracts/*.yaml
178
+ contracts_dir = spec_dir / "contracts"
179
+ if contracts_dir.exists():
180
+ for yaml_file in contracts_dir.glob("*.yaml"):
181
+ spec_files.append(str(yaml_file))
182
+
183
+ return structure
184
+
185
+ @beartype
186
+ @ensure(lambda result: isinstance(result, list), "Must return list")
187
+ @ensure(lambda result: all(isinstance(f, dict) for f in result), "All items must be dictionaries")
188
+ def discover_features(self) -> list[dict[str, Any]]:
189
+ """
190
+ Discover all features from specs directory.
191
+
192
+ Returns:
193
+ List of feature dictionaries with parsed data from spec.md, plan.md, tasks.md
194
+ """
195
+ features: list[dict[str, Any]] = []
196
+ structure = self.scan_structure()
197
+
198
+ if not structure["is_speckit"] or not structure["feature_dirs"]:
199
+ return features
200
+
201
+ for feature_dir_path in structure["feature_dirs"]:
202
+ feature_dir = Path(feature_dir_path)
203
+ spec_file = feature_dir / "spec.md"
204
+
205
+ if spec_file.exists():
206
+ spec_data = self.parse_spec_markdown(spec_file)
207
+ if spec_data:
208
+ # Parse plan.md if it exists
209
+ plan_file = feature_dir / "plan.md"
210
+ if plan_file.exists():
211
+ plan_data = self.parse_plan_markdown(plan_file)
212
+ spec_data["plan"] = plan_data
213
+
214
+ # Parse tasks.md if it exists
215
+ tasks_file = feature_dir / "tasks.md"
216
+ if tasks_file.exists():
217
+ tasks_data = self.parse_tasks_markdown(tasks_file)
218
+ spec_data["tasks"] = tasks_data
219
+
220
+ features.append(spec_data)
221
+
222
+ return features
223
+
224
+ @beartype
225
+ @require(lambda spec_file: spec_file is not None, "Spec file path must not be None")
226
+ @require(lambda spec_file: spec_file.suffix == ".md", "Spec file must be markdown")
227
+ @ensure(
228
+ lambda result, spec_file: result is None or (isinstance(result, dict) and "feature_key" in result),
229
+ "Must return None or dict with feature_key",
230
+ )
231
+ def parse_spec_markdown(self, spec_file: Path) -> dict[str, Any] | None:
232
+ """
233
+ Parse a Spec-Kit spec.md file to extract features, stories, requirements, and success criteria.
234
+
235
+ Args:
236
+ spec_file: Path to spec.md file
237
+
238
+ Returns:
239
+ Dictionary with extracted feature and story information, or None if file doesn't exist
240
+ """
241
+ if not spec_file.exists():
242
+ return None
243
+
244
+ try:
245
+ content = spec_file.read_text(encoding="utf-8")
246
+ spec_data: dict[str, Any] = {
247
+ "feature_key": None,
248
+ "feature_title": None,
249
+ "feature_branch": None,
250
+ "created_date": None,
251
+ "status": None,
252
+ "stories": [],
253
+ "requirements": [],
254
+ "success_criteria": [],
255
+ "edge_cases": [],
256
+ }
257
+
258
+ # Extract frontmatter (if present)
259
+ frontmatter_match = re.search(r"^---\n(.*?)\n---", content, re.MULTILINE | re.DOTALL)
260
+ if frontmatter_match:
261
+ frontmatter = frontmatter_match.group(1)
262
+ # Extract Feature Branch
263
+ branch_match = re.search(r"\*\*Feature Branch\*\*:\s*`(.+?)`", frontmatter)
264
+ if branch_match:
265
+ spec_data["feature_branch"] = branch_match.group(1).strip()
266
+ # Extract Created date
267
+ created_match = re.search(r"\*\*Created\*\*:\s*(\d{4}-\d{2}-\d{2})", frontmatter)
268
+ if created_match:
269
+ spec_data["created_date"] = created_match.group(1).strip()
270
+ # Extract Status
271
+ status_match = re.search(r"\*\*Status\*\*:\s*(.+?)(?:\n|$)", frontmatter)
272
+ if status_match:
273
+ spec_data["status"] = status_match.group(1).strip()
274
+
275
+ # Extract feature key from directory name (specs/001-feature-name/spec.md)
276
+ spec_dir = spec_file.parent
277
+ if spec_dir.name:
278
+ spec_data["feature_key"] = spec_dir.name.upper().replace("-", "_")
279
+ # If feature_branch not found in frontmatter, use directory name
280
+ if not spec_data["feature_branch"]:
281
+ spec_data["feature_branch"] = spec_dir.name
282
+
283
+ # Extract feature title from spec.md header
284
+ title_match = re.search(r"^#\s+Feature Specification:\s*(.+)$", content, re.MULTILINE)
285
+ if title_match:
286
+ spec_data["feature_title"] = title_match.group(1).strip()
287
+
288
+ # Extract user stories with full context
289
+ story_pattern = r"###\s+User Story\s+(\d+)\s*-\s*(.+?)\s*\(Priority:\s*(P\d+)\)"
290
+ stories = re.finditer(story_pattern, content, re.MULTILINE | re.DOTALL)
291
+
292
+ story_counter = 1
293
+ for story_match in stories:
294
+ story_number = story_match.group(1)
295
+ story_title = story_match.group(2).strip()
296
+ priority = story_match.group(3)
297
+
298
+ # Find story content (between this story and next story or end of section)
299
+ story_start = story_match.end()
300
+ next_story_match = re.search(r"###\s+User Story\s+\d+", content[story_start:], re.MULTILINE)
301
+ story_end = story_start + next_story_match.start() if next_story_match else len(content)
302
+ story_content = content[story_start:story_end]
303
+
304
+ # Extract "As a..." description
305
+ as_a_match = re.search(
306
+ r"As a (.+?), I want (.+?) so that (.+?)(?=\n\n|\*\*Why|\*\*Independent|\*\*Acceptance)",
307
+ story_content,
308
+ re.DOTALL,
309
+ )
310
+ as_a_text = ""
311
+ if as_a_match:
312
+ as_a_text = f"As a {as_a_match.group(1)}, I want {as_a_match.group(2)}, so that {as_a_match.group(3)}".strip()
313
+
314
+ # Extract "Why this priority" text
315
+ why_priority_match = re.search(
316
+ r"\*\*Why this priority\*\*:\s*(.+?)(?=\n\n|\*\*Independent|$)", story_content, re.DOTALL
317
+ )
318
+ why_priority = why_priority_match.group(1).strip() if why_priority_match else ""
319
+
320
+ # Extract INVSEST criteria
321
+ invsest_criteria: dict[str, str | None] = {
322
+ "independent": None,
323
+ "negotiable": None,
324
+ "valuable": None,
325
+ "estimable": None,
326
+ "small": None,
327
+ "testable": None,
328
+ }
329
+ for criterion in ["Independent", "Negotiable", "Valuable", "Estimable", "Small", "Testable"]:
330
+ criterion_match = re.search(rf"\*\*{criterion}\*\*:\s*(YES|NO)", story_content, re.IGNORECASE)
331
+ if criterion_match:
332
+ invsest_criteria[criterion.lower()] = criterion_match.group(1).upper()
333
+
334
+ # Extract acceptance scenarios
335
+ acceptance_pattern = r"(\d+)\.\s+\*\*Given\*\*\s+(.+?),\s+\*\*When\*\*\s+(.+?),\s+\*\*Then\*\*\s+(.+?)(?=\n\n|\n\d+\.|\n###|$)"
336
+ acceptances = re.finditer(acceptance_pattern, story_content, re.DOTALL)
337
+
338
+ acceptance_criteria: list[str] = []
339
+ for acc_match in acceptances:
340
+ given = acc_match.group(2).strip()
341
+ when = acc_match.group(3).strip()
342
+ then = acc_match.group(4).strip()
343
+ acceptance_criteria.append(f"Given {given}, When {when}, Then {then}")
344
+
345
+ # Extract scenarios (Primary, Alternate, Exception, Recovery)
346
+ scenarios: dict[str, list[str]] = {
347
+ "primary": [],
348
+ "alternate": [],
349
+ "exception": [],
350
+ "recovery": [],
351
+ }
352
+ scenarios_section = re.search(r"\*\*Scenarios:\*\*\s*\n(.*?)(?=\n\n|\*\*|$)", story_content, re.DOTALL)
353
+ if scenarios_section:
354
+ scenarios_text = scenarios_section.group(1)
355
+ # Extract Primary scenarios
356
+ primary_matches = re.finditer(
357
+ r"- \*\*Primary Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
358
+ )
359
+ for match in primary_matches:
360
+ scenarios["primary"].append(match.group(1).strip())
361
+ # Extract Alternate scenarios
362
+ alternate_matches = re.finditer(
363
+ r"- \*\*Alternate Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
364
+ )
365
+ for match in alternate_matches:
366
+ scenarios["alternate"].append(match.group(1).strip())
367
+ # Extract Exception scenarios
368
+ exception_matches = re.finditer(
369
+ r"- \*\*Exception Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
370
+ )
371
+ for match in exception_matches:
372
+ scenarios["exception"].append(match.group(1).strip())
373
+ # Extract Recovery scenarios
374
+ recovery_matches = re.finditer(
375
+ r"- \*\*Recovery Scenario\*\*:\s*(.+?)(?=\n-|\n|$)", scenarios_text, re.DOTALL
376
+ )
377
+ for match in recovery_matches:
378
+ scenarios["recovery"].append(match.group(1).strip())
379
+
380
+ story_key = f"STORY-{story_counter:03d}"
381
+ spec_data["stories"].append(
382
+ {
383
+ "key": story_key,
384
+ "number": story_number,
385
+ "title": story_title,
386
+ "priority": priority,
387
+ "as_a": as_a_text,
388
+ "why_priority": why_priority,
389
+ "invsest": invsest_criteria,
390
+ "acceptance": acceptance_criteria,
391
+ "scenarios": scenarios,
392
+ }
393
+ )
394
+ story_counter += 1
395
+
396
+ # Extract functional requirements (FR-XXX)
397
+ req_pattern = r"-?\s*\*\*FR-(\d+)\*\*:\s*System MUST\s+(.+?)(?=\n-|\n\*|\n\n|\*\*FR-|$)"
398
+ requirements = re.finditer(req_pattern, content, re.MULTILINE | re.DOTALL)
399
+
400
+ for req_match in requirements:
401
+ req_id = req_match.group(1)
402
+ req_text = req_match.group(2).strip()
403
+ spec_data["requirements"].append(
404
+ {
405
+ "id": f"FR-{req_id}",
406
+ "text": req_text,
407
+ }
408
+ )
409
+
410
+ # Extract success criteria (SC-XXX)
411
+ sc_pattern = r"-?\s*\*\*SC-(\d+)\*\*:\s*(.+?)(?=\n-|\n\*|\n\n|\*\*SC-|$)"
412
+ success_criteria = re.finditer(sc_pattern, content, re.MULTILINE | re.DOTALL)
413
+
414
+ for sc_match in success_criteria:
415
+ sc_id = sc_match.group(1)
416
+ sc_text = sc_match.group(2).strip()
417
+ spec_data["success_criteria"].append(
418
+ {
419
+ "id": f"SC-{sc_id}",
420
+ "text": sc_text,
421
+ }
422
+ )
423
+
424
+ # Extract edge cases section
425
+ edge_case_section = re.search(r"### Edge Cases\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
426
+ if edge_case_section:
427
+ edge_case_text = edge_case_section.group(1)
428
+ # Extract individual edge cases (lines starting with -)
429
+ edge_case_pattern = r"- (.+?)(?=\n-|\n|$)"
430
+ edge_cases = re.finditer(edge_case_pattern, edge_case_text, re.MULTILINE)
431
+ for ec_match in edge_cases:
432
+ ec_text = ec_match.group(1).strip()
433
+ if ec_text:
434
+ spec_data["edge_cases"].append(ec_text)
435
+
436
+ return spec_data
437
+
438
+ except Exception as e:
439
+ raise ValueError(f"Failed to parse spec.md: {e}") from e
440
+
441
+ @beartype
442
+ @require(lambda plan_file: plan_file is not None, "Plan file path must not be None")
443
+ @require(lambda plan_file: plan_file.suffix == ".md", "Plan file must be markdown")
444
+ @ensure(
445
+ lambda result: result is None or (isinstance(result, dict) and "dependencies" in result),
446
+ "Must return None or dict with dependencies",
447
+ )
448
+ def parse_plan_markdown(self, plan_file: Path) -> dict[str, Any] | None:
449
+ """
450
+ Parse a Spec-Kit plan.md file to extract technical context and architecture.
451
+
452
+ Args:
453
+ plan_file: Path to plan.md file
454
+
455
+ Returns:
456
+ Dictionary with extracted plan information, or None if file doesn't exist
457
+ """
458
+ if not plan_file.exists():
459
+ return None
460
+
461
+ try:
462
+ content = plan_file.read_text(encoding="utf-8")
463
+ plan_data: dict[str, Any] = {
464
+ "summary": None,
465
+ "language_version": None,
466
+ "dependencies": [],
467
+ "technology_stack": [],
468
+ "constraints": [],
469
+ "unknowns": [],
470
+ "constitution_check": {},
471
+ "phases": [],
472
+ "architecture": {},
473
+ }
474
+
475
+ # Extract summary
476
+ summary_match = re.search(r"^## Summary\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
477
+ if summary_match:
478
+ plan_data["summary"] = summary_match.group(1).strip()
479
+
480
+ # Extract technical context
481
+ tech_context_match = re.search(r"^## Technical Context\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
482
+ if tech_context_match:
483
+ tech_context = tech_context_match.group(1)
484
+ # Extract language/version
485
+ lang_match = re.search(r"\*\*Language/Version\*\*:\s*(.+?)(?=\n|$)", tech_context, re.MULTILINE)
486
+ if lang_match:
487
+ plan_data["language_version"] = lang_match.group(1).strip()
488
+
489
+ # Extract dependencies
490
+ deps_match = re.search(
491
+ r"\*\*Primary Dependencies\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
492
+ )
493
+ if deps_match:
494
+ deps_text = deps_match.group(1)
495
+ # Extract list items
496
+ dep_items = re.finditer(r"- `(.+?)`\s*-?\s*(.+?)(?=\n-|\n|$)", deps_text, re.MULTILINE)
497
+ for dep_match in dep_items:
498
+ dep_name = dep_match.group(1).strip()
499
+ dep_desc = dep_match.group(2).strip() if dep_match.group(2) else ""
500
+ plan_data["dependencies"].append({"name": dep_name, "description": dep_desc})
501
+
502
+ # Extract Technology Stack
503
+ stack_match = re.search(
504
+ r"\*\*Technology Stack\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
505
+ )
506
+ if stack_match:
507
+ stack_text = stack_match.group(1)
508
+ stack_items = re.finditer(r"- (.+?)(?=\n-|\n|$)", stack_text, re.MULTILINE)
509
+ for item_match in stack_items:
510
+ plan_data["technology_stack"].append(item_match.group(1).strip())
511
+
512
+ # Extract Constraints
513
+ constraints_match = re.search(
514
+ r"\*\*Constraints\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
515
+ )
516
+ if constraints_match:
517
+ constraints_text = constraints_match.group(1)
518
+ constraint_items = re.finditer(r"- (.+?)(?=\n-|\n|$)", constraints_text, re.MULTILINE)
519
+ for item_match in constraint_items:
520
+ plan_data["constraints"].append(item_match.group(1).strip())
521
+
522
+ # Extract Unknowns
523
+ unknowns_match = re.search(
524
+ r"\*\*Unknowns\*\*:\s*\n(.*?)(?=\n\*\*|$)", tech_context, re.MULTILINE | re.DOTALL
525
+ )
526
+ if unknowns_match:
527
+ unknowns_text = unknowns_match.group(1)
528
+ unknown_items = re.finditer(r"- (.+?)(?=\n-|\n|$)", unknowns_text, re.MULTILINE)
529
+ for item_match in unknown_items:
530
+ plan_data["unknowns"].append(item_match.group(1).strip())
531
+
532
+ # Extract Constitution Check section (CRITICAL for /speckit.analyze)
533
+ constitution_match = re.search(
534
+ r"^## Constitution Check\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL
535
+ )
536
+ if constitution_match:
537
+ constitution_text = constitution_match.group(1)
538
+ plan_data["constitution_check"] = {
539
+ "article_vii": {},
540
+ "article_viii": {},
541
+ "article_ix": {},
542
+ "status": None,
543
+ }
544
+ # Extract Article VII (Simplicity)
545
+ article_vii_match = re.search(
546
+ r"\*\*Article VII \(Simplicity\)\*\*:\s*\n(.*?)(?=\n\*\*|$)",
547
+ constitution_text,
548
+ re.MULTILINE | re.DOTALL,
549
+ )
550
+ if article_vii_match:
551
+ article_vii_text = article_vii_match.group(1)
552
+ plan_data["constitution_check"]["article_vii"] = {
553
+ "using_3_projects": re.search(r"- \[([ x])\]", article_vii_text) is not None,
554
+ "no_future_proofing": re.search(r"- \[([ x])\]", article_vii_text) is not None,
555
+ }
556
+ # Extract Article VIII (Anti-Abstraction)
557
+ article_viii_match = re.search(
558
+ r"\*\*Article VIII \(Anti-Abstraction\)\*\*:\s*\n(.*?)(?=\n\*\*|$)",
559
+ constitution_text,
560
+ re.MULTILINE | re.DOTALL,
561
+ )
562
+ if article_viii_match:
563
+ article_viii_text = article_viii_match.group(1)
564
+ plan_data["constitution_check"]["article_viii"] = {
565
+ "using_framework_directly": re.search(r"- \[([ x])\]", article_viii_text) is not None,
566
+ "single_model_representation": re.search(r"- \[([ x])\]", article_viii_text) is not None,
567
+ }
568
+ # Extract Article IX (Integration-First)
569
+ article_ix_match = re.search(
570
+ r"\*\*Article IX \(Integration-First\)\*\*:\s*\n(.*?)(?=\n\*\*|$)",
571
+ constitution_text,
572
+ re.MULTILINE | re.DOTALL,
573
+ )
574
+ if article_ix_match:
575
+ article_ix_text = article_ix_match.group(1)
576
+ plan_data["constitution_check"]["article_ix"] = {
577
+ "contracts_defined": re.search(r"- \[([ x])\]", article_ix_text) is not None,
578
+ "contract_tests_written": re.search(r"- \[([ x])\]", article_ix_text) is not None,
579
+ }
580
+ # Extract Status
581
+ status_match = re.search(r"\*\*Status\*\*:\s*(PASS|FAIL)", constitution_text, re.IGNORECASE)
582
+ if status_match:
583
+ plan_data["constitution_check"]["status"] = status_match.group(1).upper()
584
+
585
+ # Extract Phases
586
+ phase_pattern = r"^## Phase (-?\d+):\s*(.+?)\n(.*?)(?=\n## Phase|$)"
587
+ phases = re.finditer(phase_pattern, content, re.MULTILINE | re.DOTALL)
588
+ for phase_match in phases:
589
+ phase_num = phase_match.group(1)
590
+ phase_name = phase_match.group(2).strip()
591
+ phase_content = phase_match.group(3).strip()
592
+ plan_data["phases"].append(
593
+ {
594
+ "number": phase_num,
595
+ "name": phase_name,
596
+ "content": phase_content,
597
+ }
598
+ )
599
+
600
+ return plan_data
601
+
602
+ except Exception as e:
603
+ raise ValueError(f"Failed to parse plan.md: {e}") from e
604
+
605
+ @beartype
606
+ @require(lambda tasks_file: tasks_file is not None, "Tasks file path must not be None")
607
+ @require(lambda tasks_file: tasks_file.suffix == ".md", "Tasks file must be markdown")
608
+ @ensure(
609
+ lambda result: result is None or (isinstance(result, dict) and "tasks" in result),
610
+ "Must return None or dict with tasks",
611
+ )
612
+ def parse_tasks_markdown(self, tasks_file: Path) -> dict[str, Any] | None:
613
+ """
614
+ Parse a Spec-Kit tasks.md file to extract tasks with IDs, story mappings, and dependencies.
615
+
616
+ Args:
617
+ tasks_file: Path to tasks.md file
618
+
619
+ Returns:
620
+ Dictionary with extracted task information, or None if file doesn't exist
621
+ """
622
+ if not tasks_file.exists():
623
+ return None
624
+
625
+ try:
626
+ content = tasks_file.read_text(encoding="utf-8")
627
+ tasks_data: dict[str, Any] = {
628
+ "tasks": [],
629
+ "phases": [],
630
+ }
631
+
632
+ # Extract tasks (format: - [ ] [TaskID] [P?] [Story?] Description)
633
+ task_pattern = r"- \[([ x])\] \[?([T\d]+)\]?\s*\[?([P])?\]?\s*\[?([US\d]+)?\]?\s*(.+?)(?=\n-|\n##|$)"
634
+ tasks = re.finditer(task_pattern, content, re.MULTILINE | re.DOTALL)
635
+
636
+ for task_match in tasks:
637
+ checked = task_match.group(1) == "x"
638
+ task_id = task_match.group(2)
639
+ is_parallel = task_match.group(3) == "P"
640
+ story_ref = task_match.group(4)
641
+ description = task_match.group(5).strip()
642
+
643
+ tasks_data["tasks"].append(
644
+ {
645
+ "id": task_id,
646
+ "description": description,
647
+ "checked": checked,
648
+ "parallel": is_parallel,
649
+ "story_ref": story_ref,
650
+ }
651
+ )
652
+
653
+ # Extract phase sections and map tasks to phases
654
+ phase_pattern = r"^## Phase (\d+): (.+?)\n(.*?)(?=\n## Phase|$)"
655
+ phases = re.finditer(phase_pattern, content, re.MULTILINE | re.DOTALL)
656
+
657
+ for phase_match in phases:
658
+ phase_num = phase_match.group(1)
659
+ phase_name = phase_match.group(2).strip()
660
+ phase_content = phase_match.group(3)
661
+
662
+ # Find tasks in this phase
663
+ phase_tasks: list[dict[str, Any]] = []
664
+ phase_task_pattern = (
665
+ r"- \[([ x])\] \[?([T\d]+)\]?\s*\[?([P])?\]?\s*\[?([US\d]+)?\]?\s*(.+?)(?=\n-|\n##|$)"
666
+ )
667
+ phase_task_matches = re.finditer(phase_task_pattern, phase_content, re.MULTILINE | re.DOTALL)
668
+
669
+ for task_match in phase_task_matches:
670
+ checked = task_match.group(1) == "x"
671
+ task_id = task_match.group(2)
672
+ is_parallel = task_match.group(3) == "P"
673
+ story_ref = task_match.group(4)
674
+ description = task_match.group(5).strip()
675
+
676
+ phase_tasks.append(
677
+ {
678
+ "id": task_id,
679
+ "description": description,
680
+ "checked": checked,
681
+ "parallel": is_parallel,
682
+ "story_ref": story_ref,
683
+ "phase": phase_num,
684
+ "phase_name": phase_name,
685
+ }
686
+ )
687
+
688
+ tasks_data["phases"].append(
689
+ {
690
+ "number": phase_num,
691
+ "name": phase_name,
692
+ "content": phase_content,
693
+ "tasks": phase_tasks,
694
+ }
695
+ )
696
+
697
+ return tasks_data
698
+
699
+ except Exception as e:
700
+ raise ValueError(f"Failed to parse tasks.md: {e}") from e
701
+
702
+ def parse_memory_files(self, memory_dir: Path) -> dict[str, Any]:
703
+ """
704
+ Parse Spec-Kit memory files (constitution.md, etc.).
705
+
706
+ Args:
707
+ memory_dir: Path to memory directory
708
+
709
+ Returns:
710
+ Dictionary with extracted memory information
711
+ """
712
+ memory_data: dict[str, Any] = {
713
+ "constitution": None,
714
+ "principles": [],
715
+ "constraints": [],
716
+ "version": None,
717
+ }
718
+
719
+ if not memory_dir.exists():
720
+ return memory_data
721
+
722
+ # Parse constitution.md
723
+ constitution_file = memory_dir / "constitution.md"
724
+ if constitution_file.exists():
725
+ try:
726
+ content = constitution_file.read_text(encoding="utf-8")
727
+ memory_data["constitution"] = content
728
+
729
+ # Extract version
730
+ version_match = re.search(r"\*\*Version\*\*:\s*(\d+\.\d+\.\d+)", content, re.MULTILINE)
731
+ if version_match:
732
+ memory_data["version"] = version_match.group(1)
733
+
734
+ # Extract principles (from "### I. Principle Name" or "### Principle Name" sections)
735
+ principle_pattern = r"###\s+(?:[IVX]+\.\s*)?(.+?)(?:\s*\(NON-NEGOTIABLE\))?\n\n(.*?)(?=\n###|\n##|$)"
736
+ principles = re.finditer(principle_pattern, content, re.MULTILINE | re.DOTALL)
737
+
738
+ for prin_match in principles:
739
+ principle_name = prin_match.group(1).strip()
740
+ principle_content = prin_match.group(2).strip() if prin_match.group(2) else ""
741
+ # Skip placeholder principles
742
+ if not principle_name.startswith("["):
743
+ # Extract rationale if present
744
+ rationale_match = re.search(
745
+ r"\*\*Rationale\*\*:\s*(.+?)(?=\n\n|\n###|\n##|$)", principle_content, re.DOTALL
746
+ )
747
+ rationale = rationale_match.group(1).strip() if rationale_match else ""
748
+
749
+ memory_data["principles"].append(
750
+ {
751
+ "name": principle_name,
752
+ "description": (
753
+ principle_content.split("**Rationale**")[0].strip()
754
+ if "**Rationale**" in principle_content
755
+ else principle_content
756
+ ),
757
+ "rationale": rationale,
758
+ }
759
+ )
760
+
761
+ # Extract constraints from Governance section
762
+ governance_section = re.search(r"## Governance\n(.*?)(?=\n##|$)", content, re.MULTILINE | re.DOTALL)
763
+ if governance_section:
764
+ # Look for constraint patterns
765
+ constraint_pattern = r"- (.+?)(?=\n-|\n|$)"
766
+ constraints = re.finditer(constraint_pattern, governance_section.group(1), re.MULTILINE)
767
+ for const_match in constraints:
768
+ const_text = const_match.group(1).strip()
769
+ if const_text and not const_text.startswith("["):
770
+ memory_data["constraints"].append(const_text)
771
+
772
+ except Exception:
773
+ # Non-fatal error - log but continue
774
+ pass
775
+
776
+ return memory_data