elspais 0.11.0__py3-none-any.whl → 0.11.2__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 -1
- elspais/cli.py +75 -23
- elspais/commands/analyze.py +5 -6
- elspais/commands/changed.py +2 -6
- elspais/commands/config_cmd.py +4 -4
- elspais/commands/edit.py +32 -36
- elspais/commands/hash_cmd.py +24 -18
- elspais/commands/index.py +8 -7
- elspais/commands/init.py +4 -4
- elspais/commands/reformat_cmd.py +32 -43
- elspais/commands/rules_cmd.py +6 -2
- elspais/commands/trace.py +23 -19
- elspais/commands/validate.py +8 -10
- elspais/config/defaults.py +7 -1
- elspais/core/content_rules.py +0 -1
- elspais/core/git.py +4 -10
- elspais/core/parser.py +55 -56
- elspais/core/patterns.py +2 -6
- elspais/core/rules.py +10 -15
- elspais/mcp/__init__.py +2 -0
- elspais/mcp/context.py +1 -0
- elspais/mcp/serializers.py +1 -1
- elspais/mcp/server.py +54 -39
- elspais/reformat/__init__.py +13 -13
- elspais/reformat/detector.py +9 -16
- elspais/reformat/hierarchy.py +8 -7
- elspais/reformat/line_breaks.py +36 -38
- elspais/reformat/prompts.py +22 -12
- elspais/reformat/transformer.py +43 -41
- elspais/sponsors/__init__.py +0 -2
- elspais/testing/__init__.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/trace_view/__init__.py +4 -3
- elspais/trace_view/coverage.py +5 -5
- elspais/trace_view/generators/__init__.py +1 -1
- elspais/trace_view/generators/base.py +17 -12
- elspais/trace_view/generators/csv.py +2 -6
- elspais/trace_view/generators/markdown.py +3 -8
- elspais/trace_view/html/__init__.py +4 -2
- elspais/trace_view/html/generator.py +423 -289
- elspais/trace_view/models.py +25 -0
- elspais/trace_view/review/__init__.py +21 -18
- elspais/trace_view/review/branches.py +114 -121
- elspais/trace_view/review/models.py +232 -237
- elspais/trace_view/review/position.py +53 -71
- elspais/trace_view/review/server.py +264 -288
- elspais/trace_view/review/status.py +43 -58
- elspais/trace_view/review/storage.py +48 -72
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/METADATA +12 -9
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
- {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
elspais/__init__.py
CHANGED
|
@@ -10,7 +10,7 @@ and supports multi-repository requirement management with configurable
|
|
|
10
10
|
ID patterns and validation rules.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from importlib.metadata import
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
14
|
|
|
15
15
|
try:
|
|
16
16
|
__version__ = version("elspais")
|
elspais/cli.py
CHANGED
|
@@ -10,7 +10,19 @@ from pathlib import Path
|
|
|
10
10
|
from typing import List, Optional
|
|
11
11
|
|
|
12
12
|
from elspais import __version__
|
|
13
|
-
from elspais.commands import
|
|
13
|
+
from elspais.commands import (
|
|
14
|
+
analyze,
|
|
15
|
+
changed,
|
|
16
|
+
config_cmd,
|
|
17
|
+
edit,
|
|
18
|
+
hash_cmd,
|
|
19
|
+
index,
|
|
20
|
+
init,
|
|
21
|
+
reformat_cmd,
|
|
22
|
+
rules_cmd,
|
|
23
|
+
trace,
|
|
24
|
+
validate,
|
|
25
|
+
)
|
|
14
26
|
|
|
15
27
|
|
|
16
28
|
def create_parser() -> argparse.ArgumentParser:
|
|
@@ -22,9 +34,16 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
22
34
|
epilog="""
|
|
23
35
|
Examples:
|
|
24
36
|
elspais validate # Validate all requirements
|
|
37
|
+
elspais validate --fix # Auto-fix fixable issues
|
|
25
38
|
elspais trace --format html # Generate HTML traceability matrix
|
|
39
|
+
elspais trace --view # Interactive HTML view
|
|
26
40
|
elspais hash update # Update all requirement hashes
|
|
41
|
+
elspais changed # Show uncommitted spec changes
|
|
42
|
+
elspais analyze hierarchy # Show requirement hierarchy tree
|
|
43
|
+
elspais config show # View current configuration
|
|
27
44
|
elspais init # Create .elspais.toml configuration
|
|
45
|
+
|
|
46
|
+
For detailed command help: elspais <command> --help
|
|
28
47
|
""",
|
|
29
48
|
)
|
|
30
49
|
|
|
@@ -47,12 +66,14 @@ Examples:
|
|
|
47
66
|
metavar="PATH",
|
|
48
67
|
)
|
|
49
68
|
parser.add_argument(
|
|
50
|
-
"-v",
|
|
69
|
+
"-v",
|
|
70
|
+
"--verbose",
|
|
51
71
|
action="store_true",
|
|
52
72
|
help="Verbose output",
|
|
53
73
|
)
|
|
54
74
|
parser.add_argument(
|
|
55
|
-
"-q",
|
|
75
|
+
"-q",
|
|
76
|
+
"--quiet",
|
|
56
77
|
action="store_true",
|
|
57
78
|
help="Suppress non-error output",
|
|
58
79
|
)
|
|
@@ -64,6 +85,21 @@ Examples:
|
|
|
64
85
|
validate_parser = subparsers.add_parser(
|
|
65
86
|
"validate",
|
|
66
87
|
help="Validate requirements format, links, and hashes",
|
|
88
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
89
|
+
epilog="""
|
|
90
|
+
Examples:
|
|
91
|
+
elspais validate # Validate all requirements
|
|
92
|
+
elspais validate --fix # Auto-fix hashes and formatting
|
|
93
|
+
elspais validate --skip-rule hash.* # Skip all hash rules
|
|
94
|
+
elspais validate -j # Output JSON for tooling
|
|
95
|
+
elspais validate --mode core # Exclude associated repo specs
|
|
96
|
+
|
|
97
|
+
Common rules to skip:
|
|
98
|
+
hash.missing Hash footer is missing
|
|
99
|
+
hash.mismatch Hash doesn't match content
|
|
100
|
+
hierarchy.* All hierarchy rules
|
|
101
|
+
format.* All format rules
|
|
102
|
+
""",
|
|
67
103
|
)
|
|
68
104
|
validate_parser.add_argument(
|
|
69
105
|
"--fix",
|
|
@@ -79,11 +115,12 @@ Examples:
|
|
|
79
115
|
validate_parser.add_argument(
|
|
80
116
|
"--skip-rule",
|
|
81
117
|
action="append",
|
|
82
|
-
help="Skip
|
|
118
|
+
help="Skip validation rules (can be repeated, e.g., hash.*, format.*)",
|
|
83
119
|
metavar="RULE",
|
|
84
120
|
)
|
|
85
121
|
validate_parser.add_argument(
|
|
86
|
-
"-j",
|
|
122
|
+
"-j",
|
|
123
|
+
"--json",
|
|
87
124
|
action="store_true",
|
|
88
125
|
help="Output requirements as JSON (hht_diary compatible format)",
|
|
89
126
|
)
|
|
@@ -101,7 +138,7 @@ Examples:
|
|
|
101
138
|
"--mode",
|
|
102
139
|
choices=["core", "combined"],
|
|
103
140
|
default="combined",
|
|
104
|
-
help="
|
|
141
|
+
help="Scope: core (this repo only), combined (include sponsor repos)",
|
|
105
142
|
)
|
|
106
143
|
|
|
107
144
|
# trace command
|
|
@@ -113,7 +150,7 @@ Examples:
|
|
|
113
150
|
"--format",
|
|
114
151
|
choices=["markdown", "html", "csv", "both"],
|
|
115
152
|
default="both",
|
|
116
|
-
help="Output format (
|
|
153
|
+
help="Output format: markdown, html, csv, or both (markdown + csv)",
|
|
117
154
|
)
|
|
118
155
|
trace_parser.add_argument(
|
|
119
156
|
"--output",
|
|
@@ -130,17 +167,17 @@ Examples:
|
|
|
130
167
|
trace_parser.add_argument(
|
|
131
168
|
"--embed-content",
|
|
132
169
|
action="store_true",
|
|
133
|
-
help="Embed full requirement
|
|
170
|
+
help="Embed full requirement markdown in HTML for offline viewing",
|
|
134
171
|
)
|
|
135
172
|
trace_parser.add_argument(
|
|
136
173
|
"--edit-mode",
|
|
137
174
|
action="store_true",
|
|
138
|
-
help="
|
|
175
|
+
help="Enable in-browser editing of implements and status fields",
|
|
139
176
|
)
|
|
140
177
|
trace_parser.add_argument(
|
|
141
178
|
"--review-mode",
|
|
142
179
|
action="store_true",
|
|
143
|
-
help="
|
|
180
|
+
help="Enable collaborative review with comments and flags",
|
|
144
181
|
)
|
|
145
182
|
trace_parser.add_argument(
|
|
146
183
|
"--server",
|
|
@@ -168,7 +205,7 @@ Examples:
|
|
|
168
205
|
# hash command
|
|
169
206
|
hash_parser = subparsers.add_parser(
|
|
170
207
|
"hash",
|
|
171
|
-
help="Manage requirement hashes",
|
|
208
|
+
help="Manage requirement hashes (verify, update)",
|
|
172
209
|
)
|
|
173
210
|
hash_subparsers = hash_parser.add_subparsers(dest="hash_action")
|
|
174
211
|
|
|
@@ -195,7 +232,7 @@ Examples:
|
|
|
195
232
|
# index command
|
|
196
233
|
index_parser = subparsers.add_parser(
|
|
197
234
|
"index",
|
|
198
|
-
help="Manage INDEX.md file",
|
|
235
|
+
help="Manage INDEX.md file (validate, regenerate)",
|
|
199
236
|
)
|
|
200
237
|
index_subparsers = index_parser.add_subparsers(dest="index_action")
|
|
201
238
|
|
|
@@ -211,7 +248,7 @@ Examples:
|
|
|
211
248
|
# analyze command
|
|
212
249
|
analyze_parser = subparsers.add_parser(
|
|
213
250
|
"analyze",
|
|
214
|
-
help="Analyze requirement hierarchy",
|
|
251
|
+
help="Analyze requirement hierarchy (hierarchy, orphans, coverage)",
|
|
215
252
|
)
|
|
216
253
|
analyze_subparsers = analyze_parser.add_subparsers(dest="analyze_action")
|
|
217
254
|
|
|
@@ -221,7 +258,7 @@ Examples:
|
|
|
221
258
|
)
|
|
222
259
|
analyze_subparsers.add_parser(
|
|
223
260
|
"orphans",
|
|
224
|
-
help="Find
|
|
261
|
+
help="Find requirements with no parent (missing or invalid Implements)",
|
|
225
262
|
)
|
|
226
263
|
analyze_subparsers.add_parser(
|
|
227
264
|
"coverage",
|
|
@@ -240,12 +277,14 @@ Examples:
|
|
|
240
277
|
metavar="BRANCH",
|
|
241
278
|
)
|
|
242
279
|
changed_parser.add_argument(
|
|
243
|
-
"-j",
|
|
280
|
+
"-j",
|
|
281
|
+
"--json",
|
|
244
282
|
action="store_true",
|
|
245
283
|
help="Output as JSON",
|
|
246
284
|
)
|
|
247
285
|
changed_parser.add_argument(
|
|
248
|
-
"-a",
|
|
286
|
+
"-a",
|
|
287
|
+
"--all",
|
|
249
288
|
action="store_true",
|
|
250
289
|
help="Include all changed files (not just spec)",
|
|
251
290
|
)
|
|
@@ -258,7 +297,7 @@ Examples:
|
|
|
258
297
|
version_parser.add_argument(
|
|
259
298
|
"check",
|
|
260
299
|
nargs="?",
|
|
261
|
-
help="Check for updates",
|
|
300
|
+
help="Check for updates (not yet implemented)",
|
|
262
301
|
)
|
|
263
302
|
|
|
264
303
|
# init command
|
|
@@ -286,6 +325,17 @@ Examples:
|
|
|
286
325
|
edit_parser = subparsers.add_parser(
|
|
287
326
|
"edit",
|
|
288
327
|
help="Edit requirements in-place (implements, status, move)",
|
|
328
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
329
|
+
epilog="""
|
|
330
|
+
Examples:
|
|
331
|
+
elspais edit --req-id REQ-d00001 --status Draft
|
|
332
|
+
elspais edit --req-id REQ-d00001 --implements REQ-p00001,REQ-p00002
|
|
333
|
+
elspais edit --req-id REQ-d00001 --move-to roadmap/future.md
|
|
334
|
+
elspais edit --from-json edits.json
|
|
335
|
+
|
|
336
|
+
JSON batch format:
|
|
337
|
+
{"edits": [{"req_id": "...", "status": "...", "implements": [...]}]}
|
|
338
|
+
""",
|
|
289
339
|
)
|
|
290
340
|
edit_parser.add_argument(
|
|
291
341
|
"--req-id",
|
|
@@ -326,7 +376,7 @@ Examples:
|
|
|
326
376
|
# config command
|
|
327
377
|
config_parser = subparsers.add_parser(
|
|
328
378
|
"config",
|
|
329
|
-
help="View and modify configuration",
|
|
379
|
+
help="View and modify configuration (show, get, set, ...)",
|
|
330
380
|
)
|
|
331
381
|
config_subparsers = config_parser.add_subparsers(dest="config_action")
|
|
332
382
|
|
|
@@ -341,7 +391,8 @@ Examples:
|
|
|
341
391
|
metavar="SECTION",
|
|
342
392
|
)
|
|
343
393
|
config_show.add_argument(
|
|
344
|
-
"-j",
|
|
394
|
+
"-j",
|
|
395
|
+
"--json",
|
|
345
396
|
action="store_true",
|
|
346
397
|
help="Output as JSON",
|
|
347
398
|
)
|
|
@@ -356,7 +407,8 @@ Examples:
|
|
|
356
407
|
help="Configuration key (dot-notation, e.g., 'patterns.prefix')",
|
|
357
408
|
)
|
|
358
409
|
config_get.add_argument(
|
|
359
|
-
"-j",
|
|
410
|
+
"-j",
|
|
411
|
+
"--json",
|
|
360
412
|
action="store_true",
|
|
361
413
|
help="Output as JSON",
|
|
362
414
|
)
|
|
@@ -372,7 +424,7 @@ Examples:
|
|
|
372
424
|
)
|
|
373
425
|
config_set.add_argument(
|
|
374
426
|
"value",
|
|
375
|
-
help="Value to set (
|
|
427
|
+
help="Value to set (auto-detected: bool, number, JSON array/object, string)",
|
|
376
428
|
)
|
|
377
429
|
|
|
378
430
|
# config unset
|
|
@@ -422,7 +474,7 @@ Examples:
|
|
|
422
474
|
# rules command
|
|
423
475
|
rules_parser = subparsers.add_parser(
|
|
424
476
|
"rules",
|
|
425
|
-
help="View and manage content rules",
|
|
477
|
+
help="View and manage content rules (list, show)",
|
|
426
478
|
)
|
|
427
479
|
rules_subparsers = rules_parser.add_subparsers(dest="rules_action")
|
|
428
480
|
|
|
@@ -629,7 +681,7 @@ def mcp_command(args: argparse.Namespace) -> int:
|
|
|
629
681
|
if hasattr(args, "spec_dir") and args.spec_dir:
|
|
630
682
|
working_dir = args.spec_dir.parent
|
|
631
683
|
|
|
632
|
-
print(
|
|
684
|
+
print("Starting elspais MCP server...")
|
|
633
685
|
print(f"Working directory: {working_dir}")
|
|
634
686
|
print(f"Transport: {args.transport}")
|
|
635
687
|
|
elspais/commands/analyze.py
CHANGED
|
@@ -41,16 +41,14 @@ def run_hierarchy(args: argparse.Namespace) -> int:
|
|
|
41
41
|
|
|
42
42
|
# Find root requirements (PRD with no implements)
|
|
43
43
|
roots = [
|
|
44
|
-
req
|
|
44
|
+
req
|
|
45
|
+
for req in requirements.values()
|
|
45
46
|
if req.level.upper() in ["PRD", "PRODUCT"] and not req.implements
|
|
46
47
|
]
|
|
47
48
|
|
|
48
49
|
if not roots:
|
|
49
50
|
# Fall back to all PRD requirements
|
|
50
|
-
roots = [
|
|
51
|
-
req for req in requirements.values()
|
|
52
|
-
if req.level.upper() in ["PRD", "PRODUCT"]
|
|
53
|
-
]
|
|
51
|
+
roots = [req for req in requirements.values() if req.level.upper() in ["PRD", "PRODUCT"]]
|
|
54
52
|
|
|
55
53
|
printed = set()
|
|
56
54
|
|
|
@@ -153,7 +151,8 @@ def run_coverage(args: argparse.Namespace) -> int:
|
|
|
153
151
|
|
|
154
152
|
# List unimplemented PRD
|
|
155
153
|
unimplemented = [
|
|
156
|
-
req
|
|
154
|
+
req
|
|
155
|
+
for req in requirements.values()
|
|
157
156
|
if req.level.upper() in ["PRD", "PRODUCT"] and req.id not in implemented_prd
|
|
158
157
|
]
|
|
159
158
|
|
elspais/commands/changed.py
CHANGED
|
@@ -11,13 +11,11 @@ import argparse
|
|
|
11
11
|
import json
|
|
12
12
|
import sys
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Dict,
|
|
14
|
+
from typing import Dict, Optional
|
|
15
15
|
|
|
16
16
|
from elspais.config.defaults import DEFAULT_CONFIG
|
|
17
17
|
from elspais.config.loader import find_config_file, load_config
|
|
18
18
|
from elspais.core.git import (
|
|
19
|
-
GitChangeInfo,
|
|
20
|
-
MovedRequirement,
|
|
21
19
|
detect_moved_requirements,
|
|
22
20
|
filter_spec_files,
|
|
23
21
|
get_current_req_locations,
|
|
@@ -76,9 +74,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
76
74
|
|
|
77
75
|
# Detect moved requirements
|
|
78
76
|
current_locations = get_current_req_locations(repo_root, spec_dir)
|
|
79
|
-
moved = detect_moved_requirements(
|
|
80
|
-
changes.committed_req_locations, current_locations
|
|
81
|
-
)
|
|
77
|
+
moved = detect_moved_requirements(changes.committed_req_locations, current_locations)
|
|
82
78
|
|
|
83
79
|
# Build result
|
|
84
80
|
result = {
|
elspais/commands/config_cmd.py
CHANGED
|
@@ -8,15 +8,14 @@ import argparse
|
|
|
8
8
|
import json
|
|
9
9
|
import sys
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
12
|
|
|
13
|
+
from elspais.config.defaults import DEFAULT_CONFIG
|
|
13
14
|
from elspais.config.loader import (
|
|
14
15
|
find_config_file,
|
|
15
16
|
load_config,
|
|
16
|
-
merge_configs,
|
|
17
17
|
parse_toml,
|
|
18
18
|
)
|
|
19
|
-
from elspais.config.defaults import DEFAULT_CONFIG
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
def run(args: argparse.Namespace) -> int:
|
|
@@ -255,6 +254,7 @@ def cmd_path(args: argparse.Namespace) -> int:
|
|
|
255
254
|
|
|
256
255
|
# Helper functions
|
|
257
256
|
|
|
257
|
+
|
|
258
258
|
def _get_config_path(args: argparse.Namespace) -> Optional[Path]:
|
|
259
259
|
"""Get configuration file path from args or by discovery."""
|
|
260
260
|
if hasattr(args, "config") and args.config:
|
|
@@ -363,7 +363,7 @@ def _print_value(value: Any, prefix: str = "") -> None:
|
|
|
363
363
|
if prefix:
|
|
364
364
|
print(f"{prefix} = {'true' if value else 'false'}")
|
|
365
365
|
else:
|
|
366
|
-
print(
|
|
366
|
+
print("true" if value else "false")
|
|
367
367
|
elif isinstance(value, str):
|
|
368
368
|
if prefix:
|
|
369
369
|
print(f'{prefix} = "{value}"')
|
elspais/commands/edit.py
CHANGED
|
@@ -22,7 +22,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
22
22
|
from elspais.config.loader import find_config_file, get_spec_directories, load_config
|
|
23
23
|
|
|
24
24
|
# Load configuration
|
|
25
|
-
config_path = args.config if hasattr(args,
|
|
25
|
+
config_path = args.config if hasattr(args, "config") else None
|
|
26
26
|
if config_path is None:
|
|
27
27
|
config_path = find_config_file(Path.cwd())
|
|
28
28
|
if config_path and config_path.exists():
|
|
@@ -31,7 +31,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
31
31
|
config = DEFAULT_CONFIG
|
|
32
32
|
|
|
33
33
|
# Get spec directories
|
|
34
|
-
spec_dir = args.spec_dir if hasattr(args,
|
|
34
|
+
spec_dir = args.spec_dir if hasattr(args, "spec_dir") and args.spec_dir else None
|
|
35
35
|
spec_dirs = get_spec_directories(spec_dir, config)
|
|
36
36
|
if not spec_dirs:
|
|
37
37
|
print("Error: No spec directories found", file=sys.stderr)
|
|
@@ -40,16 +40,16 @@ def run(args: argparse.Namespace) -> int:
|
|
|
40
40
|
# Use first spec dir as base
|
|
41
41
|
base_spec_dir = spec_dirs[0]
|
|
42
42
|
|
|
43
|
-
dry_run = getattr(args,
|
|
43
|
+
dry_run = getattr(args, "dry_run", False)
|
|
44
44
|
|
|
45
|
-
validate_refs = getattr(args,
|
|
45
|
+
validate_refs = getattr(args, "validate_refs", False)
|
|
46
46
|
|
|
47
47
|
# Handle batch mode
|
|
48
|
-
if hasattr(args,
|
|
48
|
+
if hasattr(args, "from_json") and args.from_json:
|
|
49
49
|
return run_batch_edit(args.from_json, base_spec_dir, dry_run, validate_refs)
|
|
50
50
|
|
|
51
51
|
# Handle single edit mode
|
|
52
|
-
if hasattr(args,
|
|
52
|
+
if hasattr(args, "req_id") and args.req_id:
|
|
53
53
|
return run_single_edit(args, base_spec_dir, dry_run)
|
|
54
54
|
|
|
55
55
|
print("Error: Must specify --req-id or --from-json", file=sys.stderr)
|
|
@@ -109,18 +109,18 @@ def run_single_edit(args: argparse.Namespace, spec_dir: Path, dry_run: bool) ->
|
|
|
109
109
|
results = []
|
|
110
110
|
|
|
111
111
|
# Apply implements change
|
|
112
|
-
if hasattr(args,
|
|
112
|
+
if hasattr(args, "implements") and args.implements is not None:
|
|
113
113
|
impl_list = [i.strip() for i in args.implements.split(",")]
|
|
114
114
|
result = modify_implements(file_path, req_id, impl_list, dry_run=dry_run)
|
|
115
115
|
results.append(("implements", result))
|
|
116
116
|
|
|
117
117
|
# Apply status change
|
|
118
|
-
if hasattr(args,
|
|
118
|
+
if hasattr(args, "status") and args.status:
|
|
119
119
|
result = modify_status(file_path, req_id, args.status, dry_run=dry_run)
|
|
120
120
|
results.append(("status", result))
|
|
121
121
|
|
|
122
122
|
# Apply move
|
|
123
|
-
if hasattr(args,
|
|
123
|
+
if hasattr(args, "move_to") and args.move_to:
|
|
124
124
|
dest_path = spec_dir / args.move_to
|
|
125
125
|
result = move_requirement(file_path, dest_path, req_id, dry_run=dry_run)
|
|
126
126
|
results.append(("move", result))
|
|
@@ -157,14 +157,14 @@ def find_requirement_in_files(
|
|
|
157
157
|
Dict with file_path, req_id, line_number, or None if not found
|
|
158
158
|
"""
|
|
159
159
|
# Pattern to match requirement header
|
|
160
|
-
pattern = re.compile(rf
|
|
160
|
+
pattern = re.compile(rf"^#\s*{re.escape(req_id)}:", re.MULTILINE)
|
|
161
161
|
|
|
162
162
|
for md_file in spec_dir.rglob("*.md"):
|
|
163
163
|
content = md_file.read_text()
|
|
164
164
|
match = pattern.search(content)
|
|
165
165
|
if match:
|
|
166
166
|
# Count line number
|
|
167
|
-
line_number = content[:match.start()].count(
|
|
167
|
+
line_number = content[: match.start()].count("\n") + 1
|
|
168
168
|
return {
|
|
169
169
|
"file_path": md_file,
|
|
170
170
|
"req_id": req_id,
|
|
@@ -195,7 +195,7 @@ def modify_implements(
|
|
|
195
195
|
content = file_path.read_text()
|
|
196
196
|
|
|
197
197
|
# Find the requirement header
|
|
198
|
-
req_pattern = re.compile(rf
|
|
198
|
+
req_pattern = re.compile(rf"^(#\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
|
|
199
199
|
req_match = req_pattern.search(content)
|
|
200
200
|
|
|
201
201
|
if not req_match:
|
|
@@ -203,9 +203,9 @@ def modify_implements(
|
|
|
203
203
|
|
|
204
204
|
# Find the **Implements**: field after the header
|
|
205
205
|
start_pos = req_match.end()
|
|
206
|
-
search_region = content[start_pos:start_pos + 500]
|
|
206
|
+
search_region = content[start_pos : start_pos + 500]
|
|
207
207
|
|
|
208
|
-
impl_pattern = re.compile(r
|
|
208
|
+
impl_pattern = re.compile(r"(\*\*Implements\*\*:\s*)([^|\n]+)")
|
|
209
209
|
impl_match = impl_pattern.search(search_region)
|
|
210
210
|
|
|
211
211
|
if not impl_match:
|
|
@@ -272,7 +272,7 @@ def modify_status(
|
|
|
272
272
|
content = file_path.read_text()
|
|
273
273
|
|
|
274
274
|
# Find the requirement header
|
|
275
|
-
req_pattern = re.compile(rf
|
|
275
|
+
req_pattern = re.compile(rf"^(#\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
|
|
276
276
|
req_match = req_pattern.search(content)
|
|
277
277
|
|
|
278
278
|
if not req_match:
|
|
@@ -280,9 +280,9 @@ def modify_status(
|
|
|
280
280
|
|
|
281
281
|
# Find the **Status**: field after the header
|
|
282
282
|
start_pos = req_match.end()
|
|
283
|
-
search_region = content[start_pos:start_pos + 500]
|
|
283
|
+
search_region = content[start_pos : start_pos + 500]
|
|
284
284
|
|
|
285
|
-
status_pattern = re.compile(r
|
|
285
|
+
status_pattern = re.compile(r"(\*\*Status\*\*:\s*)(\w+)")
|
|
286
286
|
status_match = status_pattern.search(search_region)
|
|
287
287
|
|
|
288
288
|
if not status_match:
|
|
@@ -342,11 +342,8 @@ def move_requirement(
|
|
|
342
342
|
# Find the requirement block
|
|
343
343
|
# Pattern: # REQ-xxx: title ... *End* *title* | **Hash**: xxx\n---
|
|
344
344
|
req_pattern = re.compile(
|
|
345
|
-
rf
|
|
346
|
-
|
|
347
|
-
rf'\*End\*[^\n]*\n'
|
|
348
|
-
rf'(?:---\n)?)',
|
|
349
|
-
re.MULTILINE | re.DOTALL
|
|
345
|
+
rf"(^#\s*{re.escape(req_id)}:[^\n]*\n" rf".*?" rf"\*End\*[^\n]*\n" rf"(?:---\n)?)",
|
|
346
|
+
re.MULTILINE | re.DOTALL,
|
|
350
347
|
)
|
|
351
348
|
|
|
352
349
|
req_match = req_pattern.search(source_content)
|
|
@@ -361,9 +358,9 @@ def move_requirement(
|
|
|
361
358
|
req_block = req_block.rstrip() + "\n---\n"
|
|
362
359
|
|
|
363
360
|
# Remove from source
|
|
364
|
-
new_source_content = source_content[:req_match.start()] + source_content[req_match.end():]
|
|
361
|
+
new_source_content = source_content[: req_match.start()] + source_content[req_match.end() :]
|
|
365
362
|
# Clean up extra blank lines
|
|
366
|
-
new_source_content = re.sub(r
|
|
363
|
+
new_source_content = re.sub(r"\n{3,}", "\n\n", new_source_content)
|
|
367
364
|
|
|
368
365
|
# Add to destination
|
|
369
366
|
dest_content = dest_file.read_text() if dest_file.exists() else ""
|
|
@@ -400,8 +397,9 @@ def collect_all_req_ids(spec_dir: Path) -> set:
|
|
|
400
397
|
Set of requirement IDs found (short form, e.g., "p00001")
|
|
401
398
|
"""
|
|
402
399
|
import re
|
|
400
|
+
|
|
403
401
|
req_ids = set()
|
|
404
|
-
pattern = re.compile(r
|
|
402
|
+
pattern = re.compile(r"^#\s*(REQ-[A-Za-z0-9-]+):", re.MULTILINE)
|
|
405
403
|
|
|
406
404
|
for md_file in spec_dir.rglob("*.md"):
|
|
407
405
|
content = md_file.read_text()
|
|
@@ -453,11 +451,13 @@ def batch_edit(
|
|
|
453
451
|
# Find the requirement
|
|
454
452
|
location = find_requirement_in_files(spec_dir, req_id)
|
|
455
453
|
if not location:
|
|
456
|
-
results.append(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
454
|
+
results.append(
|
|
455
|
+
{
|
|
456
|
+
"success": False,
|
|
457
|
+
"req_id": req_id,
|
|
458
|
+
"error": f"Requirement {req_id} not found",
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
461
|
continue
|
|
462
462
|
|
|
463
463
|
file_path = location["file_path"]
|
|
@@ -493,9 +493,7 @@ def batch_edit(
|
|
|
493
493
|
|
|
494
494
|
# Apply status change
|
|
495
495
|
if "status" in change:
|
|
496
|
-
status_result = modify_status(
|
|
497
|
-
file_path, req_id, change["status"], dry_run=dry_run
|
|
498
|
-
)
|
|
496
|
+
status_result = modify_status(file_path, req_id, change["status"], dry_run=dry_run)
|
|
499
497
|
if not status_result["success"]:
|
|
500
498
|
result = status_result
|
|
501
499
|
result["req_id"] = req_id
|
|
@@ -506,9 +504,7 @@ def batch_edit(
|
|
|
506
504
|
# Apply move (must be last since it changes file location)
|
|
507
505
|
if "move_to" in change:
|
|
508
506
|
dest_path = spec_dir / change["move_to"]
|
|
509
|
-
move_result = move_requirement(
|
|
510
|
-
file_path, dest_path, req_id, dry_run=dry_run
|
|
511
|
-
)
|
|
507
|
+
move_result = move_requirement(file_path, dest_path, req_id, dry_run=dry_run)
|
|
512
508
|
if not move_result["success"]:
|
|
513
509
|
result = move_result
|
|
514
510
|
result["req_id"] = req_id
|
elspais/commands/hash_cmd.py
CHANGED
|
@@ -105,15 +105,15 @@ def run_update(args: argparse.Namespace) -> int:
|
|
|
105
105
|
print(f"Updating {len(updates)} hashes...")
|
|
106
106
|
for req_id, req, new_hash in updates:
|
|
107
107
|
result = update_hash_in_file(req, new_hash)
|
|
108
|
-
if result[
|
|
108
|
+
if result["updated"]:
|
|
109
109
|
print(f" ✓ {req_id}")
|
|
110
|
-
old_hash = result[
|
|
110
|
+
old_hash = result["old_hash"] or "(none)"
|
|
111
111
|
print(f" [INFO] Hash: {old_hash} -> {result['new_hash']}")
|
|
112
|
-
if result[
|
|
112
|
+
if result["title_fixed"]:
|
|
113
113
|
print(f" [INFO] Title fixed: \"{result['old_title']}\" -> \"{req.title}\"")
|
|
114
114
|
else:
|
|
115
115
|
print(f" ✗ {req_id}")
|
|
116
|
-
print(
|
|
116
|
+
print(" [WARN] Could not find End marker to update")
|
|
117
117
|
|
|
118
118
|
return 0
|
|
119
119
|
|
|
@@ -168,11 +168,11 @@ def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
|
|
|
168
168
|
import re
|
|
169
169
|
|
|
170
170
|
result = {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
"updated": False,
|
|
172
|
+
"old_hash": req.hash,
|
|
173
|
+
"new_hash": new_hash,
|
|
174
|
+
"title_fixed": False,
|
|
175
|
+
"old_title": None,
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
if not req.file_path:
|
|
@@ -186,35 +186,41 @@ def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
|
|
|
186
186
|
# This handles both: (1) normal case, (2) mismatched title case
|
|
187
187
|
|
|
188
188
|
# First try: match by correct title (handles case where titles match)
|
|
189
|
-
pattern_by_title =
|
|
189
|
+
pattern_by_title = (
|
|
190
|
+
rf"^\*End\*\s+\*{re.escape(req.title)}\*\s*\|\s*\*\*Hash\*\*:\s*[a-fA-F0-9]+\s*$"
|
|
191
|
+
)
|
|
190
192
|
if re.search(pattern_by_title, content, re.MULTILINE):
|
|
191
193
|
content, count = re.subn(pattern_by_title, new_end_line, content, flags=re.MULTILINE)
|
|
192
194
|
if count > 0:
|
|
193
|
-
result[
|
|
195
|
+
result["updated"] = True
|
|
194
196
|
else:
|
|
195
197
|
# Second try: find by hash value (handles mismatched title)
|
|
196
198
|
# Pattern: *End* *AnyTitle* | **Hash**: oldhash
|
|
197
|
-
pattern_by_hash =
|
|
199
|
+
pattern_by_hash = (
|
|
200
|
+
rf"^\*End\*\s+\*([^*]+)\*\s*\|\s*\*\*Hash\*\*:\s*{re.escape(req.hash)}\s*$"
|
|
201
|
+
)
|
|
198
202
|
match = re.search(pattern_by_hash, content, re.MULTILINE)
|
|
199
203
|
|
|
200
204
|
if match:
|
|
201
205
|
old_title = match.group(1)
|
|
202
206
|
if old_title != req.title:
|
|
203
|
-
result[
|
|
204
|
-
result[
|
|
207
|
+
result["title_fixed"] = True
|
|
208
|
+
result["old_title"] = old_title
|
|
205
209
|
|
|
206
210
|
# Replace entire line (only first match to avoid affecting other reqs)
|
|
207
|
-
content = re.sub(
|
|
208
|
-
|
|
211
|
+
content = re.sub(
|
|
212
|
+
pattern_by_hash, new_end_line, content, count=1, flags=re.MULTILINE
|
|
213
|
+
)
|
|
214
|
+
result["updated"] = True
|
|
209
215
|
else:
|
|
210
216
|
# Add hash to end marker (no existing hash)
|
|
211
217
|
# Pattern: *End* *Title* (without hash)
|
|
212
218
|
pattern = rf"^(\*End\*\s+\*{re.escape(req.title)}\*)(?!\s*\|\s*\*\*Hash\*\*)\s*$"
|
|
213
219
|
content, count = re.subn(pattern, new_end_line, content, flags=re.MULTILINE)
|
|
214
220
|
if count > 0:
|
|
215
|
-
result[
|
|
221
|
+
result["updated"] = True
|
|
216
222
|
|
|
217
|
-
if result[
|
|
223
|
+
if result["updated"]:
|
|
218
224
|
req.file_path.write_text(content, encoding="utf-8")
|
|
219
225
|
|
|
220
226
|
return result
|
elspais/commands/index.py
CHANGED
|
@@ -58,6 +58,7 @@ def run_validate(args: argparse.Namespace) -> int:
|
|
|
58
58
|
indexed_ids = set()
|
|
59
59
|
|
|
60
60
|
import re
|
|
61
|
+
|
|
61
62
|
for match in re.finditer(r"\|\s*([A-Z]+-(?:[A-Z]+-)?[a-zA-Z]?\d+)\s*\|", index_content):
|
|
62
63
|
indexed_ids.add(match.group(1))
|
|
63
64
|
|
|
@@ -155,12 +156,12 @@ def generate_index(requirements: dict, config: dict) -> str:
|
|
|
155
156
|
|
|
156
157
|
lines.append("")
|
|
157
158
|
|
|
158
|
-
lines.extend(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
lines.extend(
|
|
160
|
+
[
|
|
161
|
+
"---",
|
|
162
|
+
"",
|
|
163
|
+
"*Generated by elspais*",
|
|
164
|
+
]
|
|
165
|
+
)
|
|
163
166
|
|
|
164
167
|
return "\n".join(lines)
|
|
165
|
-
|
|
166
|
-
|