elspais 0.11.1__py3-none-any.whl → 0.43.5__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 (148) hide show
  1. elspais/__init__.py +2 -11
  2. elspais/{sponsors/__init__.py → associates.py} +102 -58
  3. elspais/cli.py +395 -79
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +121 -173
  6. elspais/commands/changed.py +15 -30
  7. elspais/commands/config_cmd.py +13 -16
  8. elspais/commands/edit.py +60 -44
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +167 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -114
  13. elspais/commands/init.py +103 -26
  14. elspais/commands/reformat_cmd.py +41 -444
  15. elspais/commands/rules_cmd.py +7 -3
  16. elspais/commands/trace.py +444 -321
  17. elspais/commands/validate.py +195 -415
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -3
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +47 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +2016 -247
  61. elspais/testing/__init__.py +4 -4
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/result_parser.py +25 -21
  65. elspais/testing/scanner.py +301 -12
  66. elspais/utilities/__init__.py +1 -0
  67. elspais/utilities/docs_loader.py +115 -0
  68. elspais/utilities/git.py +607 -0
  69. elspais/{core → utilities}/hasher.py +8 -22
  70. elspais/utilities/md_renderer.py +189 -0
  71. elspais/{core → utilities}/patterns.py +58 -57
  72. elspais/utilities/reference_config.py +626 -0
  73. elspais/validation/__init__.py +19 -0
  74. elspais/validation/format.py +264 -0
  75. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  76. elspais-0.43.5.dist-info/RECORD +80 -0
  77. elspais/config/defaults.py +0 -173
  78. elspais/config/loader.py +0 -494
  79. elspais/core/__init__.py +0 -21
  80. elspais/core/git.py +0 -352
  81. elspais/core/models.py +0 -320
  82. elspais/core/parser.py +0 -640
  83. elspais/core/rules.py +0 -514
  84. elspais/mcp/context.py +0 -171
  85. elspais/mcp/serializers.py +0 -112
  86. elspais/reformat/__init__.py +0 -50
  87. elspais/reformat/detector.py +0 -119
  88. elspais/reformat/hierarchy.py +0 -246
  89. elspais/reformat/line_breaks.py +0 -220
  90. elspais/reformat/prompts.py +0 -123
  91. elspais/reformat/transformer.py +0 -264
  92. elspais/trace_view/__init__.py +0 -54
  93. elspais/trace_view/coverage.py +0 -183
  94. elspais/trace_view/generators/__init__.py +0 -12
  95. elspais/trace_view/generators/base.py +0 -329
  96. elspais/trace_view/generators/csv.py +0 -122
  97. elspais/trace_view/generators/markdown.py +0 -175
  98. elspais/trace_view/html/__init__.py +0 -31
  99. elspais/trace_view/html/generator.py +0 -1006
  100. elspais/trace_view/html/templates/base.html +0 -283
  101. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  102. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  103. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  104. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  105. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  106. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  107. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  108. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  109. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  110. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  111. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  112. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  113. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  114. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  115. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  116. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  117. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  118. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  119. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  120. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  121. elspais/trace_view/models.py +0 -353
  122. elspais/trace_view/review/__init__.py +0 -60
  123. elspais/trace_view/review/branches.py +0 -1149
  124. elspais/trace_view/review/models.py +0 -1205
  125. elspais/trace_view/review/position.py +0 -609
  126. elspais/trace_view/review/server.py +0 -1056
  127. elspais/trace_view/review/status.py +0 -470
  128. elspais/trace_view/review/storage.py +0 -1367
  129. elspais/trace_view/scanning.py +0 -213
  130. elspais/trace_view/specs/README.md +0 -84
  131. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  132. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  133. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  134. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  135. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  136. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  137. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  138. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  139. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  140. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  141. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  142. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  143. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  144. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  145. elspais-0.11.1.dist-info/RECORD +0 -101
  146. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  147. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  148. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/core/git.py DELETED
@@ -1,352 +0,0 @@
1
- """
2
- Git state management for elspais.
3
-
4
- Provides functions to query git status and detect changes to requirement files,
5
- enabling detection of:
6
- - Uncommitted changes to spec files
7
- - New (untracked) requirement files
8
- - Files changed vs main/master branch
9
- - Moved requirements (comparing current location to committed state)
10
- """
11
-
12
- import re
13
- import subprocess
14
- from dataclasses import dataclass, field
15
- from pathlib import Path
16
- from typing import Dict, List, Optional, Set, Tuple
17
-
18
-
19
- @dataclass
20
- class GitChangeInfo:
21
- """Information about git changes to requirement files."""
22
-
23
- modified_files: Set[str] = field(default_factory=set)
24
- """Files with uncommitted modifications (staged or unstaged)."""
25
-
26
- untracked_files: Set[str] = field(default_factory=set)
27
- """New files not yet tracked by git."""
28
-
29
- branch_changed_files: Set[str] = field(default_factory=set)
30
- """Files changed between current branch and main/master."""
31
-
32
- committed_req_locations: Dict[str, str] = field(default_factory=dict)
33
- """REQ ID -> file path mapping from committed state (HEAD)."""
34
-
35
- @property
36
- def all_changed_files(self) -> Set[str]:
37
- """Get all files with any kind of change."""
38
- return self.modified_files | self.untracked_files | self.branch_changed_files
39
-
40
- @property
41
- def uncommitted_files(self) -> Set[str]:
42
- """Get all files with uncommitted changes (modified or untracked)."""
43
- return self.modified_files | self.untracked_files
44
-
45
-
46
- @dataclass
47
- class MovedRequirement:
48
- """Information about a requirement that was moved between files."""
49
-
50
- req_id: str
51
- """The requirement ID (e.g., 'd00001')."""
52
-
53
- old_path: str
54
- """Path in the committed state."""
55
-
56
- new_path: str
57
- """Path in the current working directory."""
58
-
59
-
60
- def get_repo_root(start_path: Optional[Path] = None) -> Optional[Path]:
61
- """Find the git repository root.
62
-
63
- Args:
64
- start_path: Path to start searching from (default: current directory)
65
-
66
- Returns:
67
- Path to repository root, or None if not in a git repository
68
- """
69
- try:
70
- result = subprocess.run(
71
- ["git", "rev-parse", "--show-toplevel"],
72
- cwd=start_path or Path.cwd(),
73
- capture_output=True,
74
- text=True,
75
- check=True,
76
- )
77
- return Path(result.stdout.strip())
78
- except (subprocess.CalledProcessError, FileNotFoundError):
79
- return None
80
-
81
-
82
- def get_modified_files(repo_root: Path) -> Tuple[Set[str], Set[str]]:
83
- """Get sets of modified and untracked files according to git status.
84
-
85
- Args:
86
- repo_root: Path to repository root
87
-
88
- Returns:
89
- Tuple of (modified_files, untracked_files):
90
- - modified_files: Tracked files with changes (M, A, R, etc.)
91
- - untracked_files: New files not yet tracked (??)
92
- """
93
- try:
94
- result = subprocess.run(
95
- ["git", "status", "--porcelain", "--untracked-files=all"],
96
- cwd=repo_root,
97
- capture_output=True,
98
- text=True,
99
- check=True,
100
- )
101
- modified_files: Set[str] = set()
102
- untracked_files: Set[str] = set()
103
-
104
- for line in result.stdout.split("\n"):
105
- if line and len(line) >= 3:
106
- # Format: "XY filename" or "XY orig -> renamed"
107
- # XY = two-letter status (e.g., " M", "??", "A ", "R ")
108
- status_code = line[:2]
109
- file_path = line[3:].strip()
110
-
111
- # Handle renames: "orig -> new"
112
- if " -> " in file_path:
113
- file_path = file_path.split(" -> ")[1]
114
-
115
- if file_path:
116
- if status_code == "??":
117
- untracked_files.add(file_path)
118
- else:
119
- modified_files.add(file_path)
120
-
121
- return modified_files, untracked_files
122
- except (subprocess.CalledProcessError, FileNotFoundError):
123
- return set(), set()
124
-
125
-
126
- def get_changed_vs_branch(
127
- repo_root: Path, base_branch: str = "main"
128
- ) -> Set[str]:
129
- """Get set of files changed between current branch and base branch.
130
-
131
- Args:
132
- repo_root: Path to repository root
133
- base_branch: Name of base branch (default: 'main')
134
-
135
- Returns:
136
- Set of file paths changed vs base branch
137
- """
138
- # Try local branch first, then remote
139
- for branch_ref in [base_branch, f"origin/{base_branch}"]:
140
- try:
141
- result = subprocess.run(
142
- ["git", "diff", "--name-only", f"{branch_ref}...HEAD"],
143
- cwd=repo_root,
144
- capture_output=True,
145
- text=True,
146
- check=True,
147
- )
148
- changed_files: Set[str] = set()
149
- for line in result.stdout.split("\n"):
150
- if line.strip():
151
- changed_files.add(line.strip())
152
- return changed_files
153
- except subprocess.CalledProcessError:
154
- continue
155
- except FileNotFoundError:
156
- return set()
157
-
158
- return set()
159
-
160
-
161
- def get_committed_req_locations(
162
- repo_root: Path,
163
- spec_dir: str = "spec",
164
- exclude_files: Optional[List[str]] = None,
165
- ) -> Dict[str, str]:
166
- """Get REQ ID -> file path mapping from committed state (HEAD).
167
-
168
- This allows detection of moved requirements by comparing current location
169
- to where the REQ was in the last commit.
170
-
171
- Args:
172
- repo_root: Path to repository root
173
- spec_dir: Spec directory relative to repo root
174
- exclude_files: Files to exclude (default: INDEX.md, README.md)
175
-
176
- Returns:
177
- Dict mapping REQ ID (e.g., 'd00001') to relative file path
178
- """
179
- if exclude_files is None:
180
- exclude_files = ["INDEX.md", "README.md", "requirements-format.md"]
181
-
182
- req_locations: Dict[str, str] = {}
183
- # Pattern matches REQ headers with optional associated prefix
184
- req_pattern = re.compile(
185
- r"^#{1,6}\s+REQ-(?:[A-Z]{2,4}-)?([pod]\d{5}):", re.MULTILINE
186
- )
187
-
188
- try:
189
- # Get list of spec files in committed state
190
- result = subprocess.run(
191
- ["git", "ls-tree", "-r", "--name-only", "HEAD", f"{spec_dir}/"],
192
- cwd=repo_root,
193
- capture_output=True,
194
- text=True,
195
- check=True,
196
- )
197
-
198
- for file_path in result.stdout.strip().split("\n"):
199
- if not file_path.endswith(".md"):
200
- continue
201
- if any(skip in file_path for skip in exclude_files):
202
- continue
203
-
204
- # Get file content from committed state
205
- try:
206
- content_result = subprocess.run(
207
- ["git", "show", f"HEAD:{file_path}"],
208
- cwd=repo_root,
209
- capture_output=True,
210
- text=True,
211
- check=True,
212
- )
213
- content = content_result.stdout
214
-
215
- # Find all REQ IDs in this file
216
- for match in req_pattern.finditer(content):
217
- req_id = match.group(1)
218
- req_locations[req_id] = file_path
219
-
220
- except subprocess.CalledProcessError:
221
- # File might not exist in HEAD (new file)
222
- continue
223
-
224
- except (subprocess.CalledProcessError, FileNotFoundError):
225
- pass
226
-
227
- return req_locations
228
-
229
-
230
- def get_current_req_locations(
231
- repo_root: Path,
232
- spec_dir: str = "spec",
233
- exclude_files: Optional[List[str]] = None,
234
- ) -> Dict[str, str]:
235
- """Get REQ ID -> file path mapping from current working directory.
236
-
237
- Args:
238
- repo_root: Path to repository root
239
- spec_dir: Spec directory relative to repo root
240
- exclude_files: Files to exclude (default: INDEX.md, README.md)
241
-
242
- Returns:
243
- Dict mapping REQ ID (e.g., 'd00001') to relative file path
244
- """
245
- if exclude_files is None:
246
- exclude_files = ["INDEX.md", "README.md", "requirements-format.md"]
247
-
248
- req_locations: Dict[str, str] = {}
249
- req_pattern = re.compile(
250
- r"^#{1,6}\s+REQ-(?:[A-Z]{2,4}-)?([pod]\d{5}):", re.MULTILINE
251
- )
252
-
253
- spec_path = repo_root / spec_dir
254
- if not spec_path.exists():
255
- return req_locations
256
-
257
- for md_file in spec_path.rglob("*.md"):
258
- if any(skip in md_file.name for skip in exclude_files):
259
- continue
260
-
261
- try:
262
- content = md_file.read_text(encoding="utf-8")
263
- rel_path = str(md_file.relative_to(repo_root))
264
-
265
- for match in req_pattern.finditer(content):
266
- req_id = match.group(1)
267
- req_locations[req_id] = rel_path
268
-
269
- except (IOError, UnicodeDecodeError):
270
- continue
271
-
272
- return req_locations
273
-
274
-
275
- def detect_moved_requirements(
276
- committed_locations: Dict[str, str],
277
- current_locations: Dict[str, str],
278
- ) -> List[MovedRequirement]:
279
- """Detect requirements that have been moved between files.
280
-
281
- Args:
282
- committed_locations: REQ ID -> path mapping from committed state
283
- current_locations: REQ ID -> path mapping from current state
284
-
285
- Returns:
286
- List of MovedRequirement objects for requirements whose location changed
287
- """
288
- moved = []
289
- for req_id, old_path in committed_locations.items():
290
- if req_id in current_locations:
291
- new_path = current_locations[req_id]
292
- if old_path != new_path:
293
- moved.append(
294
- MovedRequirement(
295
- req_id=req_id,
296
- old_path=old_path,
297
- new_path=new_path,
298
- )
299
- )
300
- return moved
301
-
302
-
303
- def get_git_changes(
304
- repo_root: Optional[Path] = None,
305
- spec_dir: str = "spec",
306
- base_branch: str = "main",
307
- ) -> GitChangeInfo:
308
- """Get comprehensive git change information for requirement files.
309
-
310
- This is the main entry point for git change detection. It gathers:
311
- - Modified files (uncommitted changes to tracked files)
312
- - Untracked files (new files not yet in git)
313
- - Branch changed files (files changed vs main/master)
314
- - Committed REQ locations (for move detection)
315
-
316
- Args:
317
- repo_root: Path to repository root (auto-detected if None)
318
- spec_dir: Spec directory relative to repo root
319
- base_branch: Base branch for comparison (default: 'main')
320
-
321
- Returns:
322
- GitChangeInfo with all change information
323
- """
324
- if repo_root is None:
325
- repo_root = get_repo_root()
326
- if repo_root is None:
327
- return GitChangeInfo()
328
-
329
- modified, untracked = get_modified_files(repo_root)
330
- branch_changed = get_changed_vs_branch(repo_root, base_branch)
331
- committed_locations = get_committed_req_locations(repo_root, spec_dir)
332
-
333
- return GitChangeInfo(
334
- modified_files=modified,
335
- untracked_files=untracked,
336
- branch_changed_files=branch_changed,
337
- committed_req_locations=committed_locations,
338
- )
339
-
340
-
341
- def filter_spec_files(files: Set[str], spec_dir: str = "spec") -> Set[str]:
342
- """Filter a set of files to only include spec directory files.
343
-
344
- Args:
345
- files: Set of file paths
346
- spec_dir: Spec directory prefix
347
-
348
- Returns:
349
- Set of files that are in the spec directory
350
- """
351
- prefix = f"{spec_dir}/"
352
- return {f for f in files if f.startswith(prefix) and f.endswith(".md")}
elspais/core/models.py DELETED
@@ -1,320 +0,0 @@
1
- """
2
- elspais.core.models - Core data models for requirements.
3
-
4
- Provides dataclasses for representing requirements, parsed IDs,
5
- and requirement types.
6
- """
7
-
8
- import re
9
- from dataclasses import dataclass, field
10
- from pathlib import Path
11
- from typing import Dict, List, Optional
12
-
13
-
14
- @dataclass
15
- class RequirementType:
16
- """
17
- Represents a requirement type (PRD, OPS, DEV, etc.).
18
-
19
- Attributes:
20
- id: The type identifier used in requirement IDs (e.g., "p", "PRD")
21
- name: Human-readable name (e.g., "Product Requirement")
22
- level: Hierarchy level (1=highest/parent, higher numbers=children)
23
- """
24
-
25
- id: str
26
- name: str = ""
27
- level: int = 1
28
-
29
-
30
- @dataclass
31
- class Assertion:
32
- """
33
- Represents a single assertion within a requirement.
34
-
35
- Assertions are the unit of verification - each defines one testable
36
- obligation using SHALL/SHALL NOT language.
37
-
38
- Attributes:
39
- label: The assertion label (e.g., "A", "B", "01", "0A")
40
- text: The assertion text (e.g., "The system SHALL...")
41
- is_placeholder: True if text indicates removed/deprecated assertion
42
- """
43
-
44
- label: str
45
- text: str
46
- is_placeholder: bool = False
47
-
48
- @property
49
- def full_id(self) -> str:
50
- """Return the assertion ID suffix (e.g., "-A")."""
51
- return f"-{self.label}"
52
-
53
- def __str__(self) -> str:
54
- return f"{self.label}. {self.text}"
55
-
56
-
57
- @dataclass
58
- class ParsedRequirement:
59
- """
60
- Represents a parsed requirement ID broken into components.
61
-
62
- Attributes:
63
- full_id: The complete requirement ID (e.g., "REQ-CAL-p00001" or "REQ-p00001-A")
64
- prefix: The ID prefix (e.g., "REQ")
65
- associated: Optional associated repo namespace (e.g., "CAL")
66
- type_code: The requirement type code (e.g., "p")
67
- number: The ID number or name (e.g., "00001")
68
- assertion: Optional assertion label (e.g., "A", "01")
69
- """
70
-
71
- full_id: str
72
- prefix: str
73
- associated: Optional[str]
74
- type_code: str
75
- number: str
76
- assertion: Optional[str] = None
77
-
78
- @property
79
- def base_id(self) -> str:
80
- """Return the requirement ID without assertion suffix."""
81
- if self.assertion:
82
- return self.full_id.rsplit("-", 1)[0]
83
- return self.full_id
84
-
85
-
86
- @dataclass
87
- class Requirement:
88
- """
89
- Represents a complete requirement specification.
90
-
91
- Attributes:
92
- id: Unique requirement identifier (e.g., "REQ-p00001")
93
- title: Requirement title
94
- level: Requirement level/type name (e.g., "PRD", "DEV")
95
- status: Current status (e.g., "Active", "Draft")
96
- body: Main requirement text
97
- implements: List of requirement IDs this requirement implements
98
- acceptance_criteria: List of acceptance criteria (legacy format)
99
- assertions: List of Assertion objects (new format)
100
- rationale: Optional rationale text
101
- hash: Content hash for change detection
102
- file_path: Source file path
103
- line_number: Line number in source file
104
- tags: Optional list of tags
105
- """
106
-
107
- id: str
108
- title: str
109
- level: str
110
- status: str
111
- body: str
112
- implements: List[str] = field(default_factory=list)
113
- acceptance_criteria: List[str] = field(default_factory=list)
114
- assertions: List["Assertion"] = field(default_factory=list)
115
- rationale: Optional[str] = None
116
- hash: Optional[str] = None
117
- file_path: Optional[Path] = None
118
- line_number: Optional[int] = None
119
- tags: List[str] = field(default_factory=list)
120
- subdir: str = "" # Subdirectory within spec/, e.g., "roadmap", "archive", ""
121
- is_conflict: bool = False # True if this is a conflicting duplicate entry
122
- conflict_with: str = "" # ID of the original requirement this conflicts with
123
-
124
- @property
125
- def type_code(self) -> str:
126
- """
127
- Extract the type code from the requirement ID.
128
-
129
- For REQ-p00001, returns "p".
130
- For REQ-CAL-d00001, returns "d".
131
- For PRD-00001, returns "PRD".
132
- """
133
- # Try to extract type code from ID
134
- # Pattern: after last separator, before numbers
135
- match = re.search(r"-([a-zA-Z]+)\d", self.id)
136
- if match:
137
- return match.group(1)
138
-
139
- # Pattern: type at start (e.g., PRD-00001)
140
- match = re.match(r"([A-Z]+)-\d", self.id)
141
- if match:
142
- return match.group(1)
143
-
144
- return ""
145
-
146
- @property
147
- def number(self) -> int:
148
- """
149
- Extract the numeric ID from the requirement ID.
150
-
151
- For REQ-p00001, returns 1.
152
- For REQ-d00042, returns 42.
153
- """
154
- match = re.search(r"(\d+)$", self.id)
155
- if match:
156
- return int(match.group(1))
157
- return 0
158
-
159
- @property
160
- def associated(self) -> Optional[str]:
161
- """
162
- Extract the associated repo code from the requirement ID.
163
-
164
- For REQ-CAL-d00001, returns "CAL".
165
- For REQ-p00001, returns None.
166
- """
167
- # Pattern: REQ-XXX- where XXX is 2-4 uppercase letters
168
- match = re.search(r"^[A-Z]+-([A-Z]{2,4})-", self.id)
169
- if match:
170
- return match.group(1)
171
- return None
172
-
173
- @property
174
- def is_roadmap(self) -> bool:
175
- """
176
- Check if this requirement is from the roadmap subdirectory.
177
-
178
- Returns True if subdir is "roadmap", False otherwise.
179
- This is a convenience property for backward compatibility.
180
- """
181
- return self.subdir == "roadmap"
182
-
183
- @property
184
- def spec_path(self) -> str:
185
- """
186
- Return the spec-relative file path as a string.
187
-
188
- For requirements in spec/prd-core.md, returns "spec/prd-core.md".
189
- For requirements in spec/roadmap/prd-future.md, returns "spec/roadmap/prd-future.md".
190
- """
191
- if self.file_path:
192
- return str(self.file_path)
193
- return ""
194
-
195
- def location(self) -> str:
196
- """Return file:line location string."""
197
- if self.file_path and self.line_number:
198
- return f"{self.file_path}:{self.line_number}"
199
- elif self.file_path:
200
- return str(self.file_path)
201
- return "unknown"
202
-
203
- def get_assertion(self, label: str) -> Optional["Assertion"]:
204
- """Get an assertion by its label."""
205
- for assertion in self.assertions:
206
- if assertion.label == label:
207
- return assertion
208
- return None
209
-
210
- def assertion_id(self, label: str) -> str:
211
- """Return the full assertion ID (e.g., 'REQ-p00001-A')."""
212
- return f"{self.id}-{label}"
213
-
214
- def __str__(self) -> str:
215
- return f"{self.id}: {self.title}"
216
-
217
- def __repr__(self) -> str:
218
- return f"Requirement(id={self.id!r}, title={self.title!r}, level={self.level!r})"
219
-
220
-
221
- @dataclass
222
- class ContentRule:
223
- """
224
- Represents a content rule file for semantic validation guidance.
225
-
226
- Content rules are markdown files that provide guidance to AI agents
227
- and humans when authoring requirements. They can include YAML frontmatter
228
- for metadata.
229
-
230
- Attributes:
231
- file_path: Path to the content rule file
232
- title: Human-readable title (from frontmatter or filename)
233
- content: Full markdown content (excluding frontmatter)
234
- type: Rule type - "guidance", "specification", or "template"
235
- applies_to: List of what this rule applies to (e.g., ["requirements", "assertions"])
236
- """
237
-
238
- file_path: Path
239
- title: str
240
- content: str
241
- type: str = "guidance"
242
- applies_to: List[str] = field(default_factory=list)
243
-
244
-
245
- @dataclass
246
- class ParseWarning:
247
- """
248
- Parser-level warning about a requirement.
249
-
250
- Warnings indicate issues found during parsing that don't prevent
251
- the requirement from being parsed, but may indicate problems.
252
-
253
- Attributes:
254
- requirement_id: The requirement ID this warning relates to
255
- message: Human-readable warning message
256
- file_path: Source file path (optional)
257
- line_number: Line number in source file (optional)
258
- """
259
-
260
- requirement_id: str
261
- message: str
262
- file_path: Optional[Path] = None
263
- line_number: Optional[int] = None
264
-
265
- def __str__(self) -> str:
266
- location = ""
267
- if self.file_path:
268
- location = f" at {self.file_path}"
269
- if self.line_number:
270
- location = f" at {self.file_path}:{self.line_number}"
271
- return f"[{self.requirement_id}] {self.message}{location}"
272
-
273
-
274
- @dataclass
275
- class ParseResult:
276
- """
277
- Result of parsing requirements from text or files.
278
-
279
- Contains both the successfully parsed requirements and any
280
- warnings generated during parsing.
281
-
282
- Attributes:
283
- requirements: Dictionary of requirement ID to Requirement
284
- warnings: List of parser warnings
285
- """
286
-
287
- requirements: Dict[str, "Requirement"]
288
- warnings: List[ParseWarning] = field(default_factory=list)
289
-
290
- def __getitem__(self, key: str) -> "Requirement":
291
- """Get a requirement by ID."""
292
- return self.requirements[key]
293
-
294
- def __contains__(self, key: str) -> bool:
295
- """Check if a requirement ID exists."""
296
- return key in self.requirements
297
-
298
- def __len__(self) -> int:
299
- """Return the number of requirements."""
300
- return len(self.requirements)
301
-
302
- def __iter__(self):
303
- """Iterate over requirement IDs."""
304
- return iter(self.requirements)
305
-
306
- def items(self):
307
- """Return items like a dict."""
308
- return self.requirements.items()
309
-
310
- def keys(self):
311
- """Return keys like a dict."""
312
- return self.requirements.keys()
313
-
314
- def values(self):
315
- """Return values like a dict."""
316
- return self.requirements.values()
317
-
318
- def get(self, key: str, default=None) -> Optional["Requirement"]:
319
- """Get a requirement by ID with default."""
320
- return self.requirements.get(key, default)