elspais 0.9.1__py3-none-any.whl → 0.11.0__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/cli.py +123 -1
- elspais/commands/changed.py +160 -0
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +81 -18
- elspais/core/git.py +352 -0
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/METADATA +78 -26
- elspais-0.11.0.dist-info/RECORD +101 -0
- elspais-0.9.1.dist-info/RECORD +0 -38
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/licenses/LICENSE +0 -0
elspais/cli.py
CHANGED
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from typing import List, Optional
|
|
11
11
|
|
|
12
12
|
from elspais import __version__
|
|
13
|
-
from elspais.commands import analyze, config_cmd, edit, hash_cmd, index, init, rules_cmd, trace, validate
|
|
13
|
+
from elspais.commands import analyze, changed, config_cmd, edit, hash_cmd, index, init, rules_cmd, trace, validate, reformat_cmd
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def create_parser() -> argparse.ArgumentParser:
|
|
@@ -97,6 +97,12 @@ Examples:
|
|
|
97
97
|
action="store_true",
|
|
98
98
|
help="Skip test scanning",
|
|
99
99
|
)
|
|
100
|
+
validate_parser.add_argument(
|
|
101
|
+
"--mode",
|
|
102
|
+
choices=["core", "combined"],
|
|
103
|
+
default="combined",
|
|
104
|
+
help="core: skip sponsor repos, combined: include all (default: combined)",
|
|
105
|
+
)
|
|
100
106
|
|
|
101
107
|
# trace command
|
|
102
108
|
trace_parser = subparsers.add_parser(
|
|
@@ -115,6 +121,49 @@ Examples:
|
|
|
115
121
|
help="Output file path",
|
|
116
122
|
metavar="PATH",
|
|
117
123
|
)
|
|
124
|
+
# trace-view enhanced options (requires elspais[trace-view])
|
|
125
|
+
trace_parser.add_argument(
|
|
126
|
+
"--view",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help="Generate interactive HTML traceability view (requires trace-view extra)",
|
|
129
|
+
)
|
|
130
|
+
trace_parser.add_argument(
|
|
131
|
+
"--embed-content",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="Embed full requirement content in HTML output",
|
|
134
|
+
)
|
|
135
|
+
trace_parser.add_argument(
|
|
136
|
+
"--edit-mode",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Include edit mode UI in HTML output",
|
|
139
|
+
)
|
|
140
|
+
trace_parser.add_argument(
|
|
141
|
+
"--review-mode",
|
|
142
|
+
action="store_true",
|
|
143
|
+
help="Include review mode UI in HTML output",
|
|
144
|
+
)
|
|
145
|
+
trace_parser.add_argument(
|
|
146
|
+
"--server",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="Start review server (requires trace-review extra)",
|
|
149
|
+
)
|
|
150
|
+
trace_parser.add_argument(
|
|
151
|
+
"--port",
|
|
152
|
+
type=int,
|
|
153
|
+
default=8080,
|
|
154
|
+
help="Port for review server (default: 8080)",
|
|
155
|
+
)
|
|
156
|
+
trace_parser.add_argument(
|
|
157
|
+
"--mode",
|
|
158
|
+
choices=["core", "sponsor", "combined"],
|
|
159
|
+
default="core",
|
|
160
|
+
help="Report mode: core, sponsor, or combined (default: core)",
|
|
161
|
+
)
|
|
162
|
+
trace_parser.add_argument(
|
|
163
|
+
"--sponsor",
|
|
164
|
+
help="Sponsor name for sponsor-specific reports",
|
|
165
|
+
metavar="NAME",
|
|
166
|
+
)
|
|
118
167
|
|
|
119
168
|
# hash command
|
|
120
169
|
hash_parser = subparsers.add_parser(
|
|
@@ -179,6 +228,28 @@ Examples:
|
|
|
179
228
|
help="Implementation coverage report",
|
|
180
229
|
)
|
|
181
230
|
|
|
231
|
+
# changed command
|
|
232
|
+
changed_parser = subparsers.add_parser(
|
|
233
|
+
"changed",
|
|
234
|
+
help="Detect git changes to spec files",
|
|
235
|
+
)
|
|
236
|
+
changed_parser.add_argument(
|
|
237
|
+
"--base-branch",
|
|
238
|
+
default="main",
|
|
239
|
+
help="Base branch for comparison (default: main)",
|
|
240
|
+
metavar="BRANCH",
|
|
241
|
+
)
|
|
242
|
+
changed_parser.add_argument(
|
|
243
|
+
"-j", "--json",
|
|
244
|
+
action="store_true",
|
|
245
|
+
help="Output as JSON",
|
|
246
|
+
)
|
|
247
|
+
changed_parser.add_argument(
|
|
248
|
+
"-a", "--all",
|
|
249
|
+
action="store_true",
|
|
250
|
+
help="Include all changed files (not just spec)",
|
|
251
|
+
)
|
|
252
|
+
|
|
182
253
|
# version command
|
|
183
254
|
version_parser = subparsers.add_parser(
|
|
184
255
|
"version",
|
|
@@ -371,6 +442,53 @@ Examples:
|
|
|
371
442
|
help="Content rule file name (e.g., 'AI-AGENT.md')",
|
|
372
443
|
)
|
|
373
444
|
|
|
445
|
+
# reformat-with-claude command
|
|
446
|
+
reformat_parser = subparsers.add_parser(
|
|
447
|
+
"reformat-with-claude",
|
|
448
|
+
help="Reformat requirements using AI (Acceptance Criteria -> Assertions)",
|
|
449
|
+
)
|
|
450
|
+
reformat_parser.add_argument(
|
|
451
|
+
"--start-req",
|
|
452
|
+
help="Starting requirement ID (default: all PRD requirements)",
|
|
453
|
+
metavar="ID",
|
|
454
|
+
)
|
|
455
|
+
reformat_parser.add_argument(
|
|
456
|
+
"--depth",
|
|
457
|
+
type=int,
|
|
458
|
+
help="Maximum traversal depth (default: unlimited)",
|
|
459
|
+
)
|
|
460
|
+
reformat_parser.add_argument(
|
|
461
|
+
"--dry-run",
|
|
462
|
+
action="store_true",
|
|
463
|
+
help="Preview changes without applying",
|
|
464
|
+
)
|
|
465
|
+
reformat_parser.add_argument(
|
|
466
|
+
"--backup",
|
|
467
|
+
action="store_true",
|
|
468
|
+
help="Create .bak files before editing",
|
|
469
|
+
)
|
|
470
|
+
reformat_parser.add_argument(
|
|
471
|
+
"--force",
|
|
472
|
+
action="store_true",
|
|
473
|
+
help="Reformat even if already in new format",
|
|
474
|
+
)
|
|
475
|
+
reformat_parser.add_argument(
|
|
476
|
+
"--fix-line-breaks",
|
|
477
|
+
action="store_true",
|
|
478
|
+
help="Normalize line breaks (remove extra blank lines)",
|
|
479
|
+
)
|
|
480
|
+
reformat_parser.add_argument(
|
|
481
|
+
"--line-breaks-only",
|
|
482
|
+
action="store_true",
|
|
483
|
+
help="Only fix line breaks, skip AI-based reformatting",
|
|
484
|
+
)
|
|
485
|
+
reformat_parser.add_argument(
|
|
486
|
+
"--mode",
|
|
487
|
+
choices=["combined", "core-only", "local-only"],
|
|
488
|
+
default="combined",
|
|
489
|
+
help="Which repos to include in hierarchy (default: combined)",
|
|
490
|
+
)
|
|
491
|
+
|
|
374
492
|
# mcp command
|
|
375
493
|
mcp_parser = subparsers.add_parser(
|
|
376
494
|
"mcp",
|
|
@@ -455,6 +573,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
455
573
|
return index.run(args)
|
|
456
574
|
elif args.command == "analyze":
|
|
457
575
|
return analyze.run(args)
|
|
576
|
+
elif args.command == "changed":
|
|
577
|
+
return changed.run(args)
|
|
458
578
|
elif args.command == "version":
|
|
459
579
|
return version_command(args)
|
|
460
580
|
elif args.command == "init":
|
|
@@ -465,6 +585,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
465
585
|
return config_cmd.run(args)
|
|
466
586
|
elif args.command == "rules":
|
|
467
587
|
return rules_cmd.run(args)
|
|
588
|
+
elif args.command == "reformat-with-claude":
|
|
589
|
+
return reformat_cmd.run(args)
|
|
468
590
|
elif args.command == "mcp":
|
|
469
591
|
return mcp_command(args)
|
|
470
592
|
else:
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
elspais.commands.changed - Git-based change detection for requirements.
|
|
3
|
+
|
|
4
|
+
Detects changes to requirement files using git:
|
|
5
|
+
- Uncommitted changes (modified or new files)
|
|
6
|
+
- Changes vs main/master branch
|
|
7
|
+
- Moved requirements (comparing current location to committed state)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from elspais.config.defaults import DEFAULT_CONFIG
|
|
17
|
+
from elspais.config.loader import find_config_file, load_config
|
|
18
|
+
from elspais.core.git import (
|
|
19
|
+
GitChangeInfo,
|
|
20
|
+
MovedRequirement,
|
|
21
|
+
detect_moved_requirements,
|
|
22
|
+
filter_spec_files,
|
|
23
|
+
get_current_req_locations,
|
|
24
|
+
get_git_changes,
|
|
25
|
+
get_repo_root,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_configuration(args: argparse.Namespace) -> Optional[Dict]:
|
|
30
|
+
"""Load configuration from file or use defaults."""
|
|
31
|
+
config_path = getattr(args, "config", None)
|
|
32
|
+
if config_path:
|
|
33
|
+
pass # Use provided path
|
|
34
|
+
else:
|
|
35
|
+
config_path = find_config_file(Path.cwd())
|
|
36
|
+
|
|
37
|
+
if config_path and config_path.exists():
|
|
38
|
+
try:
|
|
39
|
+
return load_config(config_path)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(f"Error loading config: {e}", file=sys.stderr)
|
|
42
|
+
return None
|
|
43
|
+
else:
|
|
44
|
+
return DEFAULT_CONFIG
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run(args: argparse.Namespace) -> int:
|
|
48
|
+
"""Run the changed command."""
|
|
49
|
+
# Get repository root
|
|
50
|
+
repo_root = get_repo_root()
|
|
51
|
+
if repo_root is None:
|
|
52
|
+
print("Error: Not in a git repository")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
# Load config to get spec directory
|
|
56
|
+
config = load_configuration(args)
|
|
57
|
+
if config is None:
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
spec_dir = config.get("directories", {}).get("spec", "spec")
|
|
61
|
+
if isinstance(spec_dir, list):
|
|
62
|
+
spec_dir = spec_dir[0] if spec_dir else "spec"
|
|
63
|
+
|
|
64
|
+
base_branch = getattr(args, "base_branch", None) or "main"
|
|
65
|
+
json_output = getattr(args, "json", False)
|
|
66
|
+
show_all = getattr(args, "all", False)
|
|
67
|
+
quiet = getattr(args, "quiet", False)
|
|
68
|
+
|
|
69
|
+
# Get git change information
|
|
70
|
+
changes = get_git_changes(repo_root, spec_dir, base_branch)
|
|
71
|
+
|
|
72
|
+
# Filter to spec files only
|
|
73
|
+
spec_modified = filter_spec_files(changes.modified_files, spec_dir)
|
|
74
|
+
spec_untracked = filter_spec_files(changes.untracked_files, spec_dir)
|
|
75
|
+
spec_branch = filter_spec_files(changes.branch_changed_files, spec_dir)
|
|
76
|
+
|
|
77
|
+
# Detect moved requirements
|
|
78
|
+
current_locations = get_current_req_locations(repo_root, spec_dir)
|
|
79
|
+
moved = detect_moved_requirements(
|
|
80
|
+
changes.committed_req_locations, current_locations
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Build result
|
|
84
|
+
result = {
|
|
85
|
+
"repo_root": str(repo_root),
|
|
86
|
+
"spec_dir": spec_dir,
|
|
87
|
+
"base_branch": base_branch,
|
|
88
|
+
"uncommitted": {
|
|
89
|
+
"modified": sorted(spec_modified),
|
|
90
|
+
"untracked": sorted(spec_untracked),
|
|
91
|
+
"count": len(spec_modified) + len(spec_untracked),
|
|
92
|
+
},
|
|
93
|
+
"branch_changed": {
|
|
94
|
+
"files": sorted(spec_branch),
|
|
95
|
+
"count": len(spec_branch),
|
|
96
|
+
},
|
|
97
|
+
"moved_requirements": [
|
|
98
|
+
{
|
|
99
|
+
"req_id": m.req_id,
|
|
100
|
+
"old_path": m.old_path,
|
|
101
|
+
"new_path": m.new_path,
|
|
102
|
+
}
|
|
103
|
+
for m in moved
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Include all files if --all flag is set
|
|
108
|
+
if show_all:
|
|
109
|
+
result["all_modified"] = sorted(changes.modified_files)
|
|
110
|
+
result["all_untracked"] = sorted(changes.untracked_files)
|
|
111
|
+
result["all_branch_changed"] = sorted(changes.branch_changed_files)
|
|
112
|
+
|
|
113
|
+
if json_output:
|
|
114
|
+
print(json.dumps(result, indent=2))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
# Human-readable output
|
|
118
|
+
has_changes = False
|
|
119
|
+
|
|
120
|
+
if spec_modified or spec_untracked:
|
|
121
|
+
has_changes = True
|
|
122
|
+
if not quiet:
|
|
123
|
+
uncommitted_count = len(spec_modified) + len(spec_untracked)
|
|
124
|
+
print(f"Uncommitted spec changes: {uncommitted_count}")
|
|
125
|
+
|
|
126
|
+
if spec_modified:
|
|
127
|
+
print(f" Modified ({len(spec_modified)}):")
|
|
128
|
+
for f in sorted(spec_modified):
|
|
129
|
+
print(f" M {f}")
|
|
130
|
+
|
|
131
|
+
if spec_untracked:
|
|
132
|
+
print(f" New ({len(spec_untracked)}):")
|
|
133
|
+
for f in sorted(spec_untracked):
|
|
134
|
+
print(f" + {f}")
|
|
135
|
+
print()
|
|
136
|
+
|
|
137
|
+
if spec_branch:
|
|
138
|
+
has_changes = True
|
|
139
|
+
if not quiet:
|
|
140
|
+
print(f"Changed vs {base_branch}: {len(spec_branch)}")
|
|
141
|
+
for f in sorted(spec_branch):
|
|
142
|
+
print(f" {f}")
|
|
143
|
+
print()
|
|
144
|
+
|
|
145
|
+
if moved:
|
|
146
|
+
has_changes = True
|
|
147
|
+
if not quiet:
|
|
148
|
+
print(f"Moved requirements: {len(moved)}")
|
|
149
|
+
for m in moved:
|
|
150
|
+
print(f" REQ-{m.req_id}:")
|
|
151
|
+
print(f" from: {m.old_path}")
|
|
152
|
+
print(f" to: {m.new_path}")
|
|
153
|
+
print()
|
|
154
|
+
|
|
155
|
+
if not has_changes:
|
|
156
|
+
if not quiet:
|
|
157
|
+
print("No uncommitted changes to spec files")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
return 0
|
elspais/commands/hash_cmd.py
CHANGED
|
@@ -104,8 +104,16 @@ def run_update(args: argparse.Namespace) -> int:
|
|
|
104
104
|
else:
|
|
105
105
|
print(f"Updating {len(updates)} hashes...")
|
|
106
106
|
for req_id, req, new_hash in updates:
|
|
107
|
-
update_hash_in_file(req, new_hash)
|
|
108
|
-
|
|
107
|
+
result = update_hash_in_file(req, new_hash)
|
|
108
|
+
if result['updated']:
|
|
109
|
+
print(f" ✓ {req_id}")
|
|
110
|
+
old_hash = result['old_hash'] or "(none)"
|
|
111
|
+
print(f" [INFO] Hash: {old_hash} -> {result['new_hash']}")
|
|
112
|
+
if result['title_fixed']:
|
|
113
|
+
print(f" [INFO] Title fixed: \"{result['old_title']}\" -> \"{req.title}\"")
|
|
114
|
+
else:
|
|
115
|
+
print(f" ✗ {req_id}")
|
|
116
|
+
print(f" [WARN] Could not find End marker to update")
|
|
109
117
|
|
|
110
118
|
return 0
|
|
111
119
|
|
|
@@ -138,37 +146,75 @@ def load_requirements(args: argparse.Namespace) -> tuple:
|
|
|
138
146
|
return config, requirements
|
|
139
147
|
|
|
140
148
|
|
|
141
|
-
def update_hash_in_file(req: Requirement, new_hash: str) ->
|
|
149
|
+
def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
|
|
142
150
|
"""Update the hash in the requirement's source file.
|
|
143
151
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
Finds the End marker by the old hash value, then replaces the entire line
|
|
153
|
+
with the correct title and new hash. This handles cases where the End
|
|
154
|
+
marker title doesn't match the header title.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
req: Requirement object with file_path, title, and hash
|
|
158
|
+
new_hash: New hash value to write
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Dict with change info:
|
|
162
|
+
- 'updated': bool - whether file was modified
|
|
163
|
+
- 'old_hash': str - previous hash (or None)
|
|
164
|
+
- 'new_hash': str - new hash value
|
|
165
|
+
- 'title_fixed': bool - whether title was corrected
|
|
166
|
+
- 'old_title': str - previous title (if different)
|
|
147
167
|
"""
|
|
168
|
+
import re
|
|
169
|
+
|
|
170
|
+
result = {
|
|
171
|
+
'updated': False,
|
|
172
|
+
'old_hash': req.hash,
|
|
173
|
+
'new_hash': new_hash,
|
|
174
|
+
'title_fixed': False,
|
|
175
|
+
'old_title': None,
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
if not req.file_path:
|
|
149
|
-
return
|
|
179
|
+
return result
|
|
150
180
|
|
|
151
181
|
content = req.file_path.read_text(encoding="utf-8")
|
|
152
|
-
|
|
153
|
-
import re
|
|
182
|
+
new_end_line = f"*End* *{req.title}* | **Hash**: {new_hash}"
|
|
154
183
|
|
|
155
184
|
if req.hash:
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
content,
|
|
163
|
-
|
|
185
|
+
# Strategy: Try title first (most specific), then hash if title not found
|
|
186
|
+
# This handles both: (1) normal case, (2) mismatched title case
|
|
187
|
+
|
|
188
|
+
# First try: match by correct title (handles case where titles match)
|
|
189
|
+
pattern_by_title = rf"^\*End\*\s+\*{re.escape(req.title)}\*\s*\|\s*\*\*Hash\*\*:\s*[a-fA-F0-9]+\s*$"
|
|
190
|
+
if re.search(pattern_by_title, content, re.MULTILINE):
|
|
191
|
+
content, count = re.subn(pattern_by_title, new_end_line, content, flags=re.MULTILINE)
|
|
192
|
+
if count > 0:
|
|
193
|
+
result['updated'] = True
|
|
194
|
+
else:
|
|
195
|
+
# Second try: find by hash value (handles mismatched title)
|
|
196
|
+
# Pattern: *End* *AnyTitle* | **Hash**: oldhash
|
|
197
|
+
pattern_by_hash = rf"^\*End\*\s+\*([^*]+)\*\s*\|\s*\*\*Hash\*\*:\s*{re.escape(req.hash)}\s*$"
|
|
198
|
+
match = re.search(pattern_by_hash, content, re.MULTILINE)
|
|
199
|
+
|
|
200
|
+
if match:
|
|
201
|
+
old_title = match.group(1)
|
|
202
|
+
if old_title != req.title:
|
|
203
|
+
result['title_fixed'] = True
|
|
204
|
+
result['old_title'] = old_title
|
|
205
|
+
|
|
206
|
+
# Replace entire line (only first match to avoid affecting other reqs)
|
|
207
|
+
content = re.sub(pattern_by_hash, new_end_line, content, count=1, flags=re.MULTILINE)
|
|
208
|
+
result['updated'] = True
|
|
164
209
|
else:
|
|
165
|
-
# Add hash to end marker
|
|
210
|
+
# Add hash to end marker (no existing hash)
|
|
166
211
|
# Pattern: *End* *Title* (without hash)
|
|
167
|
-
|
|
168
|
-
content = re.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
212
|
+
pattern = rf"^(\*End\*\s+\*{re.escape(req.title)}\*)(?!\s*\|\s*\*\*Hash\*\*)\s*$"
|
|
213
|
+
content, count = re.subn(pattern, new_end_line, content, flags=re.MULTILINE)
|
|
214
|
+
if count > 0:
|
|
215
|
+
result['updated'] = True
|
|
216
|
+
|
|
217
|
+
if result['updated']:
|
|
218
|
+
req.file_path.write_text(content, encoding="utf-8")
|
|
219
|
+
|
|
220
|
+
return result
|