merge-help-csv.py 7.6 KB

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