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.
- elspais/__init__.py +1 -10
- elspais/{sponsors/__init__.py → associates.py} +102 -56
- elspais/cli.py +366 -69
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +118 -169
- elspais/commands/changed.py +12 -23
- elspais/commands/config_cmd.py +10 -13
- elspais/commands/edit.py +33 -13
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +161 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -115
- elspais/commands/init.py +99 -22
- elspais/commands/reformat_cmd.py +41 -433
- elspais/commands/rules_cmd.py +2 -2
- elspais/commands/trace.py +443 -324
- elspais/commands/validate.py +193 -411
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -2
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +45 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +1998 -244
- elspais/testing/__init__.py +3 -3
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +56 -51
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -179
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -346
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -639
- elspais/core/rules.py +0 -509
- elspais/mcp/context.py +0 -172
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -112
- elspais/reformat/hierarchy.py +0 -247
- elspais/reformat/line_breaks.py +0 -218
- elspais/reformat/prompts.py +0 -133
- elspais/reformat/transformer.py +0 -266
- elspais/trace_view/__init__.py +0 -55
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -334
- elspais/trace_view/generators/csv.py +0 -118
- elspais/trace_view/generators/markdown.py +0 -170
- elspais/trace_view/html/__init__.py +0 -33
- elspais/trace_view/html/generator.py +0 -1140
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -378
- elspais/trace_view/review/__init__.py +0 -63
- elspais/trace_view/review/branches.py +0 -1142
- elspais/trace_view/review/models.py +0 -1200
- elspais/trace_view/review/position.py +0 -591
- elspais/trace_view/review/server.py +0 -1032
- elspais/trace_view/review/status.py +0 -455
- elspais/trace_view/review/storage.py +0 -1343
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.2.dist-info/RECORD +0 -101
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {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)
|