ops-wiki-agent-kit 0.1.0 → 0.1.2

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.
Files changed (49) hide show
  1. package/.github/agents/source-code-to-spec-documenter.agent.md +1 -0
  2. package/.github/agents/source-code-to-system-ops-overview.agent.md +1 -0
  3. package/.github/prompts/00-discover-target-baseline.prompt.md +37 -0
  4. package/.github/prompts/01-generate-target-foundation-spec.prompt.md +13 -9
  5. package/.github/prompts/02-generate-target-architecture-spec.prompt.md +11 -7
  6. package/.github/prompts/03-generate-target-ops-spec.prompt.md +11 -7
  7. package/.github/prompts/generate-system-ops-overview.prompt.md +3 -2
  8. package/.github/skills/source-code-to-ops-spec-guidelines/SKILL.md +3 -25
  9. package/.github/skills/source-code-to-ops-spec-guidelines/references/01-system-overview-and-business-scenarios-guideline.md +0 -8
  10. package/.github/skills/source-code-to-ops-spec-guidelines/references/02-core-architecture-flow-data-logic-guideline.md +230 -171
  11. package/.github/skills/source-code-to-ops-spec-guidelines/references/03-error-ops-scenario-coverage-guideline.md +20 -2
  12. package/.github/skills/source-code-to-ops-spec-guidelines/references/supporting-output-format-diagram-and-example-reference.md +145 -143
  13. package/.github/skills/source-code-to-spec-documenter/SKILL.md +35 -6
  14. package/.github/skills/source-code-to-spec-documenter/references/generation-handoff-contract.md +43 -132
  15. package/.github/skills/source-code-to-spec-documenter/references/generation-workflow.md +100 -48
  16. package/.github/skills/source-code-to-spec-documenter/references/handoff-initial-template.json +76 -0
  17. package/.github/skills/source-code-to-spec-documenter/references/source-tracing-rules.md +61 -15
  18. package/.github/skills/source-code-to-spec-documenter/references/target-queue-contract.md +24 -7
  19. package/.github/skills/source-code-to-spec-documenter/scripts/handoff_update.py +310 -0
  20. package/.github/skills/source-code-to-spec-documenter/scripts/spec_queue.py +31 -2
  21. package/.github/skills/source-code-to-spec-tools/SKILL.md +11 -0
  22. package/.github/skills/source-code-to-spec-tools/references/repository-artifact-contract.md +61 -51
  23. package/.github/skills/source-code-to-spec-tools/references/target-queue-schema-contract.md +13 -1
  24. package/.github/skills/source-code-to-spec-tools/references/terminology-contract.md +2 -2
  25. package/.github/skills/source-code-to-spec-tools/scripts/catalog_query.py +43 -2
  26. package/.github/skills/source-code-to-spec-tools/scripts/queue_contract.py +3 -0
  27. package/.github/skills/source-code-to-spec-tools/scripts/target_query.py +3 -0
  28. package/.github/skills/source-code-to-system-ops-overview/SKILL.md +2 -1
  29. package/.github/skills/source-code-to-system-ops-overview/references/system-operations-overview-guideline.md +36 -5
  30. package/package.json +1 -1
  31. package/.github/agents/docs-target-catalog.agent.md +0 -52
  32. package/.github/agents/docs-target-queue-from-catalog.agent.md +0 -34
  33. package/.github/agents/source-code-to-spec-reviewer.agent.md +0 -51
  34. package/.github/prompts/00-generate-target-all-spec.prompt.md +0 -35
  35. package/.github/prompts/04-review-target-spec.prompt.md +0 -24
  36. package/.github/prompts/docs-target-catalog.prompt.md +0 -32
  37. package/.github/prompts/docs-target-queue-from-catalog.prompt.md +0 -28
  38. package/.github/skills/database-query/SKILL.md +0 -140
  39. package/.github/skills/database-query/references/client-commands.md +0 -189
  40. package/.github/skills/database-query/references/query-safety.md +0 -109
  41. package/.github/skills/database-query/scripts/find_db_config.py +0 -273
  42. package/.github/skills/docs-target-catalog/SKILL.md +0 -194
  43. package/.github/skills/docs-target-catalog/references/docs-target-queue-conversion.md +0 -164
  44. package/.github/skills/docs-target-catalog/references/entrypoint-source-patterns.md +0 -83
  45. package/.github/skills/docs-target-catalog/references/output-templates.md +0 -168
  46. package/.github/skills/docs-target-queue-from-catalog/SKILL.md +0 -255
  47. package/.github/skills/docs-target-queue-from-catalog/references/docs-target-queue-contract.md +0 -125
  48. package/.github/skills/docs-target-queue-from-catalog/references/metadata-acquisition-patterns.md +0 -149
  49. package/.github/skills/docs-target-queue-from-catalog/scripts/write_documentation_target_queue.py +0 -527
@@ -1,527 +0,0 @@
1
- #!/usr/bin/env python3
2
- import sys
3
-
4
- sys.dont_write_bytecode = True
5
-
6
- import argparse
7
- import csv
8
- import json
9
- import re
10
- from collections import OrderedDict, defaultdict
11
- from pathlib import Path
12
-
13
- for parent in Path(__file__).resolve().parents:
14
- shared_scripts = parent / "source-code-to-spec-tools" / "scripts"
15
- if (shared_scripts / "queue_contract.py").exists():
16
- sys.path.insert(0, str(shared_scripts))
17
- break
18
- else:
19
- raise SystemExit("Cannot locate source-code-to-spec-tools/scripts/queue_contract.py")
20
-
21
- from queue_contract import (
22
- DOC_STATUSES as VALID_DOC_STATUSES,
23
- COVERAGE_COLUMNS,
24
- DEFAULT_PATH_PATTERNS,
25
- DEFAULT_PREFIXES,
26
- MAIN_COLUMNS,
27
- REVIEW_STATUSES as VALID_REVIEW_STATUSES,
28
- SUMMARY_COLUMNS,
29
- normalize_completed_flag,
30
- normalize_doc_profile,
31
- normalize_status,
32
- normalize_text,
33
- )
34
-
35
-
36
- def normalize_path_segment(value):
37
- value = normalize_text(value)
38
- if not value:
39
- return "Unclassified"
40
- value = re.sub(r'[<>:"/\\|?*]', " ", value)
41
- value = re.sub(r"\s+", " ", value).strip()
42
- return value or "Unclassified"
43
-
44
-
45
- def markdown_escape(value):
46
- return normalize_text(value).replace("|", "\\|")
47
-
48
-
49
- def detect_delimiter(path):
50
- suffix = path.suffix.lower()
51
- if suffix == ".tsv":
52
- return "\t"
53
- return ","
54
-
55
-
56
- def read_rows(path):
57
- if path is None:
58
- return []
59
- path = Path(path)
60
- if not path.exists():
61
- raise SystemExit(f"Input file not found: {path}")
62
- suffix = path.suffix.lower()
63
- if suffix == ".jsonl":
64
- rows = []
65
- with path.open("r", encoding="utf-8-sig", newline="") as handle:
66
- for line_no, line in enumerate(handle, 1):
67
- line = line.strip()
68
- if not line:
69
- continue
70
- try:
71
- value = json.loads(line)
72
- except json.JSONDecodeError as exc:
73
- raise SystemExit(f"{path}:{line_no}: invalid JSONL: {exc}") from exc
74
- if not isinstance(value, dict):
75
- raise SystemExit(f"{path}:{line_no}: JSONL row must be an object")
76
- rows.append(value)
77
- return rows
78
- if suffix == ".json":
79
- with path.open("r", encoding="utf-8-sig") as handle:
80
- value = json.load(handle)
81
- if isinstance(value, dict):
82
- for key in ("rows", "targets", "items"):
83
- if key in value:
84
- value = value[key]
85
- break
86
- if not isinstance(value, list) or any(not isinstance(row, dict) for row in value):
87
- raise SystemExit(f"{path}: JSON input must be an array of objects or an object with rows")
88
- return value
89
- delimiter = detect_delimiter(path)
90
- with path.open("r", encoding="utf-8-sig", newline="") as handle:
91
- return list(csv.DictReader(handle, delimiter=delimiter))
92
-
93
-
94
- def write_csv(path, rows, columns):
95
- path = Path(path)
96
- path.parent.mkdir(parents=True, exist_ok=True)
97
- with path.open("w", encoding="utf-8", newline="") as handle:
98
- writer = csv.DictWriter(handle, fieldnames=columns)
99
- writer.writeheader()
100
- for row in rows:
101
- writer.writerow({column: row.get(column, "") for column in columns})
102
-
103
-
104
- def parse_prefix_args(values):
105
- prefixes = dict(DEFAULT_PREFIXES)
106
- for value in values or []:
107
- if "=" not in value:
108
- raise SystemExit(f"Invalid --prefix value: {value}. Expected source_type=PREFIX.")
109
- source_type, prefix = value.split("=", 1)
110
- source_type = normalize_text(source_type)
111
- prefix = normalize_text(prefix).upper()
112
- if not source_type or not re.fullmatch(r"[A-Z][A-Z0-9]*", prefix):
113
- raise SystemExit(f"Invalid --prefix value: {value}.")
114
- prefixes[source_type] = prefix
115
- return prefixes
116
-
117
-
118
- def row_get(row, *names):
119
- lowered = {str(key).lower(): key for key in row.keys()}
120
- for name in names:
121
- key = lowered.get(name.lower())
122
- if key is not None:
123
- return row.get(key)
124
- return ""
125
-
126
-
127
- def normalize_targets(rows, prefixes):
128
- normalized = []
129
- for index, row in enumerate(rows, 1):
130
- source_type = normalize_text(row_get(row, "source_type", "type"))
131
- group = normalize_text(row_get(row, "group", "group_name", "module", "category")) or "Unclassified"
132
- name = normalize_text(row_get(row, "name", "display_name", "label", "title"))
133
- entrypoint = normalize_text(row_get(row, "entrypoint", "activation_target", "route", "path", "command"))
134
- keyword = normalize_text(row_get(row, "keyword", "stable_key", "key", "code", "id"))
135
- document_path = normalize_text(row_get(row, "document_path", "doc_path"))
136
- foundation_doc_status = normalize_status(
137
- row_get(row, "foundation_doc_status"),
138
- VALID_DOC_STATUSES,
139
- "pending",
140
- )
141
- architecture_doc_status = normalize_status(
142
- row_get(row, "architecture_doc_status"),
143
- VALID_DOC_STATUSES,
144
- "pending",
145
- )
146
- ops_doc_status = normalize_status(
147
- row_get(row, "ops_doc_status"),
148
- VALID_DOC_STATUSES,
149
- "pending",
150
- )
151
- review_status = normalize_status(
152
- row_get(row, "review_status"), VALID_REVIEW_STATUSES, "not_started"
153
- )
154
- completed_flag = normalize_completed_flag(
155
- row_get(row, "document_completed_flag(Y/N)")
156
- )
157
- if not source_type:
158
- raise SystemExit(f"Target row {index}: missing source_type")
159
- if source_type not in prefixes:
160
- raise SystemExit(
161
- f"Target row {index}: source_type '{source_type}' has no prefix. "
162
- "Register the activation surface in Source Type Registry or pass "
163
- "--prefix source_type=PREFIX for this converter run."
164
- )
165
- if not name:
166
- raise SystemExit(f"Target row {index}: missing name")
167
- if not keyword:
168
- keyword = entrypoint or name
169
- if not entrypoint:
170
- entrypoint = keyword
171
- if not document_path:
172
- pattern = DEFAULT_PATH_PATTERNS.get(source_type, "docs/{source_type}/{name}")
173
- document_path = pattern.format(
174
- source_type=normalize_path_segment(source_type),
175
- group=normalize_path_segment(group),
176
- name=normalize_path_segment(name),
177
- )
178
- doc_profile = normalize_doc_profile(row_get(row, "doc_profile"), source_type)
179
- normalized.append(
180
- {
181
- "id": normalize_text(row_get(row, "id")),
182
- "source_type": source_type,
183
- "original_id": normalize_text(row_get(row, "original_id")),
184
- "group": group,
185
- "name": name,
186
- "entrypoint": entrypoint,
187
- "document_path": document_path,
188
- "keyword": keyword,
189
- "doc_profile": doc_profile,
190
- "foundation_doc_status": foundation_doc_status,
191
- "architecture_doc_status": architecture_doc_status,
192
- "ops_doc_status": ops_doc_status,
193
- "review_status": review_status,
194
- "document_completed_flag(Y/N)": completed_flag,
195
- "last_handoff": normalize_text(row_get(row, "last_handoff")),
196
- "notes": normalize_text(row_get(row, "notes")),
197
- }
198
- )
199
- return dedupe_targets(normalized)
200
-
201
-
202
- def dedupe_targets(rows):
203
- deduped = OrderedDict()
204
- for row in rows:
205
- key = (
206
- row["source_type"].casefold(),
207
- row["keyword"].casefold(),
208
- row["entrypoint"].casefold(),
209
- )
210
- if key not in deduped:
211
- deduped[key] = row
212
- continue
213
- current = deduped[key]
214
- for column in MAIN_COLUMNS:
215
- if not current.get(column) and row.get(column):
216
- current[column] = row[column]
217
- return list(deduped.values())
218
-
219
-
220
- def sort_targets_by_source_type(rows):
221
- return sorted(rows, key=lambda row: normalize_text(row.get("source_type", "")).casefold())
222
-
223
-
224
- def parse_existing_ids(path):
225
- if path is None or not Path(path).exists():
226
- return {}, defaultdict(set)
227
- rows = parse_markdown_main_table(Path(path))
228
- mapping = {}
229
- used = defaultdict(set)
230
- for row in rows:
231
- source_type = row.get("source_type", "")
232
- original_id = row.get("original_id", "")
233
- if source_type and original_id.isdigit():
234
- used[source_type].add(int(original_id))
235
- for key in target_keys(row):
236
- mapping[key] = {
237
- "id": row.get("id", ""),
238
- "original_id": row.get("original_id", ""),
239
- }
240
- return mapping, used
241
-
242
-
243
- def parse_markdown_main_table(path):
244
- rows = []
245
- lines = path.read_text(encoding="utf-8-sig").splitlines()
246
- for index, line in enumerate(lines):
247
- cells = split_markdown_row(line)
248
- if cells == MAIN_COLUMNS:
249
- columns = cells
250
- if index + 1 >= len(lines):
251
- return rows
252
- for data_line in lines[index + 2 :]:
253
- if not data_line.strip().startswith("|"):
254
- break
255
- values = split_markdown_row(data_line)
256
- if len(values) != len(columns):
257
- break
258
- row = dict(zip(columns, values))
259
- rows.append(normalize_existing_row(row))
260
- break
261
- return rows
262
-
263
-
264
- def normalize_existing_row(row):
265
- normalized = {column: row.get(column, "") for column in MAIN_COLUMNS}
266
- normalized["doc_profile"] = normalize_doc_profile(
267
- normalized.get("doc_profile", ""), normalized.get("source_type", "")
268
- )
269
- return normalized
270
-
271
-
272
- def split_markdown_row(line):
273
- line = line.strip()
274
- if not line.startswith("|") or not line.endswith("|"):
275
- return []
276
- cells = []
277
- current = []
278
- escaped = False
279
- for char in line[1:-1]:
280
- if escaped:
281
- current.append(char)
282
- escaped = False
283
- elif char == "\\":
284
- escaped = True
285
- elif char == "|":
286
- cells.append("".join(current).strip())
287
- current = []
288
- else:
289
- current.append(char)
290
- cells.append("".join(current).strip())
291
- return cells
292
-
293
-
294
- def target_keys(row):
295
- source_type = normalize_text(row.get("source_type", "")).casefold()
296
- keyword = normalize_text(row.get("keyword", "")).casefold()
297
- entrypoint = normalize_text(row.get("entrypoint", "")).casefold()
298
- name = normalize_text(row.get("name", "")).casefold()
299
- keys = []
300
- if source_type and keyword:
301
- keys.append((source_type, "keyword", keyword))
302
- if source_type and entrypoint and name:
303
- keys.append((source_type, "entrypoint-name", entrypoint, name))
304
- return keys
305
-
306
-
307
- def assign_ids(rows, prefixes, existing_mapping, used_original_ids):
308
- used_ids = set()
309
- next_ids = {}
310
- for source_type, values in used_original_ids.items():
311
- next_ids[source_type] = max(values) + 1 if values else 1
312
-
313
- for row in rows:
314
- preserved = None
315
- for key in target_keys(row):
316
- preserved = existing_mapping.get(key)
317
- if preserved:
318
- break
319
- source_type = row["source_type"]
320
- prefix = prefixes[source_type]
321
- if preserved and preserved.get("id") and preserved["id"] not in used_ids:
322
- row["id"] = preserved["id"]
323
- row["original_id"] = preserved.get("original_id", row.get("original_id", ""))
324
- elif row.get("id") and row["id"] not in used_ids:
325
- row["original_id"] = row.get("original_id") or strip_prefix(row["id"], prefix)
326
- else:
327
- next_value = next_ids.get(source_type, 1)
328
- while next_value in used_original_ids[source_type]:
329
- next_value += 1
330
- row["original_id"] = str(next_value)
331
- row["id"] = f"{prefix}{next_value}"
332
- used_original_ids[source_type].add(next_value)
333
- next_ids[source_type] = next_value + 1
334
- if not row["original_id"].isdigit():
335
- raise SystemExit(f"Row '{row['name']}' has non-numeric original_id: {row['original_id']}")
336
- expected_prefix = prefixes[source_type]
337
- expected_id = f"{expected_prefix}{row['original_id']}"
338
- if row["id"] != expected_id:
339
- raise SystemExit(
340
- f"Row '{row['name']}' id does not match source_type prefix and original_id: "
341
- f"{row['id']} != {expected_id}"
342
- )
343
- if row["id"] in used_ids:
344
- raise SystemExit(f"Duplicate id after assignment: {row['id']}")
345
- used_ids.add(row["id"])
346
- return rows
347
-
348
-
349
- def strip_prefix(value, prefix):
350
- value = normalize_text(value)
351
- if value.startswith(prefix):
352
- return value[len(prefix) :]
353
- return ""
354
-
355
-
356
- def normalize_summary(rows):
357
- normalized = []
358
- for index, row in enumerate(rows, 1):
359
- item = {column: normalize_text(row_get(row, column)) for column in SUMMARY_COLUMNS}
360
- for column in ("raw_count", "eligible_count", "excluded_count", "gap_count"):
361
- if item[column] == "":
362
- raise SystemExit(f"Summary row {index}: missing {column}")
363
- if not item[column].isdigit():
364
- raise SystemExit(f"Summary row {index}: {column} must be a non-negative integer")
365
- raw = int(item["raw_count"])
366
- eligible = int(item["eligible_count"])
367
- excluded = int(item["excluded_count"])
368
- gap = int(item["gap_count"])
369
- if raw != eligible + excluded + gap:
370
- raise SystemExit(
371
- f"Summary row {index}: raw_count must equal eligible_count + excluded_count + gap_count"
372
- )
373
- normalized.append(item)
374
- return normalized
375
-
376
-
377
- def normalize_coverage(rows):
378
- normalized = []
379
- for row in rows:
380
- item = {column: normalize_text(row_get(row, column)) for column in COVERAGE_COLUMNS}
381
- if any(item.values()):
382
- normalized.append(item)
383
- return normalized
384
-
385
-
386
- def validate_main_counts(rows, summary_rows):
387
- main_count = len(rows)
388
- eligible_count = sum(int(row["eligible_count"]) for row in summary_rows)
389
- if eligible_count != main_count:
390
- raise SystemExit(
391
- f"Eligible count mismatch: summary eligible_count total is {eligible_count}, "
392
- f"but main target rows are {main_count}."
393
- )
394
-
395
-
396
- def is_canonical_queue_output(output_path):
397
- return (
398
- output_path.name == "docs-target-queue.md"
399
- and output_path.parent.name.casefold() == "docs"
400
- )
401
-
402
-
403
- def is_target_staging_output(output_path):
404
- return (
405
- output_path.suffix.lower() == ".md"
406
- and "target" in {part.casefold() for part in output_path.parts}
407
- )
408
-
409
-
410
- def validate_output_path(output_path, partial_output):
411
- if is_canonical_queue_output(output_path):
412
- return
413
- if partial_output and is_target_staging_output(output_path):
414
- return
415
- if partial_output:
416
- raise SystemExit(
417
- "--partial-output only permits staging Markdown output under target/**. "
418
- "Final queue output must be docs/docs-target-queue.md."
419
- )
420
- raise SystemExit(
421
- "Final queue output must be docs/docs-target-queue.md. "
422
- "For partial candidates, pass --partial-output and write under "
423
- "target/docs-target-queue-from-catalog/."
424
- )
425
-
426
-
427
- def build_obsidian_links(output_path):
428
- links = []
429
- if output_path.name == "docs-target-queue.md":
430
- catalog_path = output_path.with_name("docs-target-catalog.md")
431
- if catalog_path.exists():
432
- links.append("- [[docs-target-catalog]]")
433
- return links
434
-
435
-
436
- def build_markdown(rows, summary_rows, coverage_rows, source_note, output_path):
437
- lines = []
438
- lines.extend(["# Documentation Target Queue", ""])
439
- lines.append(f"Source note: {source_note}")
440
- obsidian_links = build_obsidian_links(output_path)
441
- if obsidian_links:
442
- lines.extend(["", "## Obsidian Links", ""])
443
- lines.extend(obsidian_links)
444
- lines.extend(["", "## Source Acquisition Summary", ""])
445
- append_table(lines, SUMMARY_COLUMNS, summary_rows)
446
- lines.extend(["", "## Main Target Table", ""])
447
- append_table(lines, MAIN_COLUMNS, rows)
448
- lines.extend(["", "## Source-Type Counts", ""])
449
- count_rows = []
450
- counts = defaultdict(int)
451
- for row in rows:
452
- counts[row["source_type"]] += 1
453
- for source_type in sorted(counts):
454
- count_rows.append({"source_type": source_type, "count": str(counts[source_type])})
455
- count_rows.append({"source_type": "Total", "count": str(len(rows))})
456
- append_table(lines, ["source_type", "count"], count_rows)
457
- lines.extend(["", "## Coverage Review", ""])
458
- append_table(lines, COVERAGE_COLUMNS, coverage_rows)
459
- lines.append("")
460
- return "\n".join(lines)
461
-
462
-
463
- def append_table(lines, columns, rows):
464
- lines.append("| " + " | ".join(columns) + " |")
465
- lines.append("| " + " | ".join("---" for _ in columns) + " |")
466
- for row in rows:
467
- values = [markdown_escape(row.get(column, "")) for column in columns]
468
- lines.append("| " + " | ".join(values) + " |")
469
-
470
-
471
- def main():
472
- parser = argparse.ArgumentParser(
473
- description="Generate docs/docs-target-queue.md from normalized target, summary, and coverage inputs."
474
- )
475
- parser.add_argument("--targets", required=True, help="CSV/TSV/JSON/JSONL normalized target rows.")
476
- parser.add_argument("--summary", required=True, help="CSV/TSV/JSON/JSONL source acquisition summary rows.")
477
- parser.add_argument("--coverage", help="CSV/TSV/JSON/JSONL coverage review rows.")
478
- parser.add_argument("--existing", help="Existing docs/docs-target-queue.md used to preserve IDs.")
479
- parser.add_argument("--output", required=True, help="Output docs/docs-target-queue.md path.")
480
- parser.add_argument("--source-note", required=True, help="Short source note for the generated Markdown.")
481
- parser.add_argument(
482
- "--partial-output",
483
- action="store_true",
484
- help=(
485
- "Allow writing a partial/staging Markdown output under target/**. "
486
- "Final output remains docs/docs-target-queue.md."
487
- ),
488
- )
489
- parser.add_argument(
490
- "--prefix",
491
- action="append",
492
- default=[],
493
- help=(
494
- "Registration extension for a source_type prefix in source_type=PREFIX form. "
495
- "Use for activation surfaces not yet in the shared registry. May be repeated."
496
- ),
497
- )
498
- parser.add_argument(
499
- "--write-normalized-targets",
500
- help="Optional CSV path for the post-validation target rows with assigned IDs.",
501
- )
502
- args = parser.parse_args()
503
-
504
- output = Path(args.output)
505
- validate_output_path(output, args.partial_output)
506
-
507
- prefixes = parse_prefix_args(args.prefix)
508
- targets = normalize_targets(read_rows(args.targets), prefixes)
509
- summary = normalize_summary(read_rows(args.summary))
510
- coverage = normalize_coverage(read_rows(args.coverage) if args.coverage else [])
511
- existing_mapping, used_original_ids = parse_existing_ids(args.existing)
512
- targets = assign_ids(targets, prefixes, existing_mapping, used_original_ids)
513
- targets = sort_targets_by_source_type(targets)
514
- validate_main_counts(targets, summary)
515
-
516
- output.parent.mkdir(parents=True, exist_ok=True)
517
- output.write_text(build_markdown(targets, summary, coverage, args.source_note, output), encoding="utf-8", newline="\n")
518
- if args.write_normalized_targets:
519
- write_csv(args.write_normalized_targets, targets, MAIN_COLUMNS)
520
- print(f"Wrote {output} with {len(targets)} target rows.")
521
-
522
-
523
- if __name__ == "__main__":
524
- try:
525
- main()
526
- except BrokenPipeError:
527
- sys.exit(1)