elspais 0.11.2__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 (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  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 +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/core/git.py DELETED
@@ -1,346 +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(repo_root: Path, base_branch: str = "main") -> Set[str]:
127
- """Get set of files changed between current branch and base branch.
128
-
129
- Args:
130
- repo_root: Path to repository root
131
- base_branch: Name of base branch (default: 'main')
132
-
133
- Returns:
134
- Set of file paths changed vs base branch
135
- """
136
- # Try local branch first, then remote
137
- for branch_ref in [base_branch, f"origin/{base_branch}"]:
138
- try:
139
- result = subprocess.run(
140
- ["git", "diff", "--name-only", f"{branch_ref}...HEAD"],
141
- cwd=repo_root,
142
- capture_output=True,
143
- text=True,
144
- check=True,
145
- )
146
- changed_files: Set[str] = set()
147
- for line in result.stdout.split("\n"):
148
- if line.strip():
149
- changed_files.add(line.strip())
150
- return changed_files
151
- except subprocess.CalledProcessError:
152
- continue
153
- except FileNotFoundError:
154
- return set()
155
-
156
- return set()
157
-
158
-
159
- def get_committed_req_locations(
160
- repo_root: Path,
161
- spec_dir: str = "spec",
162
- exclude_files: Optional[List[str]] = None,
163
- ) -> Dict[str, str]:
164
- """Get REQ ID -> file path mapping from committed state (HEAD).
165
-
166
- This allows detection of moved requirements by comparing current location
167
- to where the REQ was in the last commit.
168
-
169
- Args:
170
- repo_root: Path to repository root
171
- spec_dir: Spec directory relative to repo root
172
- exclude_files: Files to exclude (default: INDEX.md, README.md)
173
-
174
- Returns:
175
- Dict mapping REQ ID (e.g., 'd00001') to relative file path
176
- """
177
- if exclude_files is None:
178
- exclude_files = ["INDEX.md", "README.md", "requirements-format.md"]
179
-
180
- req_locations: Dict[str, str] = {}
181
- # Pattern matches REQ headers with optional associated prefix
182
- req_pattern = re.compile(r"^#{1,6}\s+REQ-(?:[A-Z]{2,4}-)?([pod]\d{5}):", re.MULTILINE)
183
-
184
- try:
185
- # Get list of spec files in committed state
186
- result = subprocess.run(
187
- ["git", "ls-tree", "-r", "--name-only", "HEAD", f"{spec_dir}/"],
188
- cwd=repo_root,
189
- capture_output=True,
190
- text=True,
191
- check=True,
192
- )
193
-
194
- for file_path in result.stdout.strip().split("\n"):
195
- if not file_path.endswith(".md"):
196
- continue
197
- if any(skip in file_path for skip in exclude_files):
198
- continue
199
-
200
- # Get file content from committed state
201
- try:
202
- content_result = subprocess.run(
203
- ["git", "show", f"HEAD:{file_path}"],
204
- cwd=repo_root,
205
- capture_output=True,
206
- text=True,
207
- check=True,
208
- )
209
- content = content_result.stdout
210
-
211
- # Find all REQ IDs in this file
212
- for match in req_pattern.finditer(content):
213
- req_id = match.group(1)
214
- req_locations[req_id] = file_path
215
-
216
- except subprocess.CalledProcessError:
217
- # File might not exist in HEAD (new file)
218
- continue
219
-
220
- except (subprocess.CalledProcessError, FileNotFoundError):
221
- pass
222
-
223
- return req_locations
224
-
225
-
226
- def get_current_req_locations(
227
- repo_root: Path,
228
- spec_dir: str = "spec",
229
- exclude_files: Optional[List[str]] = None,
230
- ) -> Dict[str, str]:
231
- """Get REQ ID -> file path mapping from current working directory.
232
-
233
- Args:
234
- repo_root: Path to repository root
235
- spec_dir: Spec directory relative to repo root
236
- exclude_files: Files to exclude (default: INDEX.md, README.md)
237
-
238
- Returns:
239
- Dict mapping REQ ID (e.g., 'd00001') to relative file path
240
- """
241
- if exclude_files is None:
242
- exclude_files = ["INDEX.md", "README.md", "requirements-format.md"]
243
-
244
- req_locations: Dict[str, str] = {}
245
- req_pattern = re.compile(r"^#{1,6}\s+REQ-(?:[A-Z]{2,4}-)?([pod]\d{5}):", re.MULTILINE)
246
-
247
- spec_path = repo_root / spec_dir
248
- if not spec_path.exists():
249
- return req_locations
250
-
251
- for md_file in spec_path.rglob("*.md"):
252
- if any(skip in md_file.name for skip in exclude_files):
253
- continue
254
-
255
- try:
256
- content = md_file.read_text(encoding="utf-8")
257
- rel_path = str(md_file.relative_to(repo_root))
258
-
259
- for match in req_pattern.finditer(content):
260
- req_id = match.group(1)
261
- req_locations[req_id] = rel_path
262
-
263
- except (OSError, UnicodeDecodeError):
264
- continue
265
-
266
- return req_locations
267
-
268
-
269
- def detect_moved_requirements(
270
- committed_locations: Dict[str, str],
271
- current_locations: Dict[str, str],
272
- ) -> List[MovedRequirement]:
273
- """Detect requirements that have been moved between files.
274
-
275
- Args:
276
- committed_locations: REQ ID -> path mapping from committed state
277
- current_locations: REQ ID -> path mapping from current state
278
-
279
- Returns:
280
- List of MovedRequirement objects for requirements whose location changed
281
- """
282
- moved = []
283
- for req_id, old_path in committed_locations.items():
284
- if req_id in current_locations:
285
- new_path = current_locations[req_id]
286
- if old_path != new_path:
287
- moved.append(
288
- MovedRequirement(
289
- req_id=req_id,
290
- old_path=old_path,
291
- new_path=new_path,
292
- )
293
- )
294
- return moved
295
-
296
-
297
- def get_git_changes(
298
- repo_root: Optional[Path] = None,
299
- spec_dir: str = "spec",
300
- base_branch: str = "main",
301
- ) -> GitChangeInfo:
302
- """Get comprehensive git change information for requirement files.
303
-
304
- This is the main entry point for git change detection. It gathers:
305
- - Modified files (uncommitted changes to tracked files)
306
- - Untracked files (new files not yet in git)
307
- - Branch changed files (files changed vs main/master)
308
- - Committed REQ locations (for move detection)
309
-
310
- Args:
311
- repo_root: Path to repository root (auto-detected if None)
312
- spec_dir: Spec directory relative to repo root
313
- base_branch: Base branch for comparison (default: 'main')
314
-
315
- Returns:
316
- GitChangeInfo with all change information
317
- """
318
- if repo_root is None:
319
- repo_root = get_repo_root()
320
- if repo_root is None:
321
- return GitChangeInfo()
322
-
323
- modified, untracked = get_modified_files(repo_root)
324
- branch_changed = get_changed_vs_branch(repo_root, base_branch)
325
- committed_locations = get_committed_req_locations(repo_root, spec_dir)
326
-
327
- return GitChangeInfo(
328
- modified_files=modified,
329
- untracked_files=untracked,
330
- branch_changed_files=branch_changed,
331
- committed_req_locations=committed_locations,
332
- )
333
-
334
-
335
- def filter_spec_files(files: Set[str], spec_dir: str = "spec") -> Set[str]:
336
- """Filter a set of files to only include spec directory files.
337
-
338
- Args:
339
- files: Set of file paths
340
- spec_dir: Spec directory prefix
341
-
342
- Returns:
343
- Set of files that are in the spec directory
344
- """
345
- prefix = f"{spec_dir}/"
346
- 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)