-
-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy pathmerge-help-csv.py
More file actions
executable file
·246 lines (202 loc) · 7.58 KB
/
Copy pathmerge-help-csv.py
File metadata and controls
executable file
·246 lines (202 loc) · 7.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Merge module help entries into shared _bmad/module-help.csv.
Reads a source CSV with module help entries and merges them into a target CSV.
Uses an anti-zombie pattern: all existing rows matching the source module code
are removed before appending fresh rows.
Legacy cleanup: when --legacy-dir and --module-code are provided, deletes old
per-module module-help.csv files from {legacy-dir}/{module-code}/ and
{legacy-dir}/core/. Only the current module and core are touched.
Exit codes: 0=success, 1=validation error, 2=runtime error
"""
import argparse
import csv
import json
import sys
from io import StringIO
from pathlib import Path
# CSV header for module-help.csv
HEADER = [
"module",
"skill",
"display-name",
"menu-code",
"description",
"action",
"args",
"phase",
"after",
"before",
"required",
"output-location",
"outputs",
]
def parse_args():
parser = argparse.ArgumentParser(
description="Merge module help entries into shared _bmad/module-help.csv with anti-zombie pattern."
)
parser.add_argument(
"--target",
required=True,
help="Path to the target _bmad/module-help.csv file",
)
parser.add_argument(
"--source",
required=True,
help="Path to the source module-help.csv with entries to merge",
)
parser.add_argument(
"--legacy-dir",
help="Path to _bmad/ directory to check for legacy per-module CSV files.",
)
parser.add_argument(
"--module-code",
help="Module code (required with --legacy-dir for scoping cleanup).",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress to stderr",
)
return parser.parse_args()
def read_csv_rows(path: str) -> tuple[list[str], list[list[str]]]:
"""Read CSV file returning (header, data_rows).
Returns empty header and rows if file doesn't exist.
"""
file_path = Path(path)
if not file_path.exists():
return [], []
with open(file_path, "r", encoding="utf-8", newline="") as f:
content = f.read()
reader = csv.reader(StringIO(content))
rows = list(reader)
if not rows:
return [], []
return rows[0], rows[1:]
def extract_module_codes(rows: list[list[str]]) -> set[str]:
"""Extract unique module codes from data rows."""
codes = set()
for row in rows:
if row and row[0].strip():
codes.add(row[0].strip())
return codes
def filter_rows(rows: list[list[str]], module_code: str) -> list[list[str]]:
"""Remove all rows matching the given module code."""
return [row for row in rows if not row or row[0].strip() != module_code]
def write_csv(path: str, header: list[str], rows: list[list[str]], verbose: bool = False) -> None:
"""Write header + rows to CSV file, creating parent dirs as needed."""
file_path = Path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
if verbose:
print(f"Writing {len(rows)} data rows to {path}", file=sys.stderr)
with open(file_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(header)
for row in rows:
writer.writerow(row)
def cleanup_legacy_csvs(
legacy_dir: str, module_code: str, verbose: bool = False
) -> list:
"""Delete legacy per-module module-help.csv files for this module and core only.
Returns list of deleted file paths.
"""
deleted = []
for subdir in (module_code, "core"):
legacy_path = Path(legacy_dir) / subdir / "module-help.csv"
if legacy_path.exists():
if verbose:
print(f"Deleting legacy CSV: {legacy_path}", file=sys.stderr)
legacy_path.unlink()
deleted.append(str(legacy_path))
return deleted
def reject_unresolved_paths(named_paths: list[tuple[str, str]]) -> None:
"""Exit with a clear error if any path argument still contains the literal
``{project-root}`` token. That token is meaningful only inside config
values; filesystem path arguments must be resolved by the caller. Failing
loudly here prevents silently creating a junk ``{project-root}/`` directory.
"""
for name, value in named_paths:
if value and "{project-root}" in value:
print(
json.dumps(
{
"status": "error",
"error": (
f"Unresolved '{{project-root}}' token in {name} path: {value!r}. "
"Resolve '{project-root}' to the actual project root before running "
"this script — it is a filesystem path, not a config value."
),
},
indent=2,
)
)
sys.exit(1)
def main():
args = parse_args()
reject_unresolved_paths(
[("--target", args.target), ("--legacy-dir", args.legacy_dir)]
)
# Read source entries
source_header, source_rows = read_csv_rows(args.source)
if not source_rows:
print(f"Error: No data rows found in source {args.source}", file=sys.stderr)
sys.exit(1)
# Determine module codes being merged
source_codes = extract_module_codes(source_rows)
if not source_codes:
print("Error: Could not determine module code from source rows", file=sys.stderr)
sys.exit(1)
if args.verbose:
print(f"Source module codes: {source_codes}", file=sys.stderr)
print(f"Source rows: {len(source_rows)}", file=sys.stderr)
# Read existing target (may not exist)
target_header, target_rows = read_csv_rows(args.target)
target_existed = Path(args.target).exists()
if args.verbose:
print(f"Target exists: {target_existed}", file=sys.stderr)
if target_existed:
print(f"Existing target rows: {len(target_rows)}", file=sys.stderr)
# Use source header if target doesn't exist or has no header
header = target_header if target_header else (source_header if source_header else HEADER)
# Anti-zombie: remove all rows for each source module code
filtered_rows = target_rows
removed_count = 0
for code in source_codes:
before_count = len(filtered_rows)
filtered_rows = filter_rows(filtered_rows, code)
removed_count += before_count - len(filtered_rows)
if args.verbose and removed_count > 0:
print(f"Removed {removed_count} existing rows (anti-zombie)", file=sys.stderr)
# Append source rows
merged_rows = filtered_rows + source_rows
# Write result
write_csv(args.target, header, merged_rows, args.verbose)
# Legacy cleanup: delete old per-module CSV files
legacy_deleted = []
if args.legacy_dir:
if not args.module_code:
print(
"Error: --module-code is required when --legacy-dir is provided",
file=sys.stderr,
)
sys.exit(1)
legacy_deleted = cleanup_legacy_csvs(
args.legacy_dir, args.module_code, args.verbose
)
# Output result summary as JSON
result = {
"status": "success",
"target_path": str(Path(args.target).resolve()),
"target_existed": target_existed,
"module_codes": sorted(source_codes),
"rows_removed": removed_count,
"rows_added": len(source_rows),
"total_rows": len(merged_rows),
"legacy_csvs_deleted": legacy_deleted,
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()