@antoneeo/mkt-agentic-sdlc-skill 0.2.0

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.
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env python3
2
+ """mkt_check.py -- mechanical validator for mkt_docs/ (Marketing Agentic SDLC).
3
+
4
+ Commands:
5
+ check [--root R] [--strict] run validate + ledger + budget + funnel + trace
6
+ validate [--root R] [--strict] frontmatter + index freshness on canonical docs
7
+ ledger [--root R] evidence ledger integrity + [EV-nn] reference resolution
8
+ budget [--root R] [FILE] budget allocation sums to Total budget (±1%)
9
+ funnel [--root R] [FILE] funnel model rows recompute (±5%)
10
+ trace [--root R] objective -> tactic -> KPI chain
11
+ index [--root R] (re)generate mkt_docs/INDEX.md
12
+
13
+ Exit codes: 0 clean (warnings allowed unless --strict), 1 errors (or warnings
14
+ under --strict), 2 usage / structural problem (no mkt_docs).
15
+
16
+ Pure stdlib. ASCII output only (Windows-console safe).
17
+ """
18
+
19
+ import argparse
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ CANONICAL_DIRS = ("vision", "strategy", "tactics", "deliverables")
25
+ VALID_STATUS = ("CURRENT", "SUPERSEDED", "DRAFT", "DEPRECATED")
26
+ LEDGER_CLASSES = ("FACT", "BENCHMARK", "ASSUMPTION")
27
+ CONFIDENCE_VALUES = ("HIGH", "MED", "MEDIUM", "LOW")
28
+ EV_REF = re.compile(r"\[(EV-\d+)\]")
29
+ OBJECTIVE_HEADING = re.compile(r"^###\s+(O\d+)\b", re.MULTILINE)
30
+ OBJECTIVE_TOKEN = re.compile(r"\bO\d+\b")
31
+ EXAMPLE_MARKER = "(example)"
32
+
33
+ BUDGET_TOLERANCE = 0.01 # ±1% on allocation sum
34
+ FUNNEL_TOLERANCE = 0.05 # ±5% on recomputed funnel cells
35
+
36
+
37
+ class Report:
38
+ def __init__(self):
39
+ self.errors = []
40
+ self.warnings = []
41
+
42
+ def error(self, msg):
43
+ self.errors.append(msg)
44
+
45
+ def warn(self, msg):
46
+ self.warnings.append(msg)
47
+
48
+ def merge(self, other):
49
+ self.errors.extend(other.errors)
50
+ self.warnings.extend(other.warnings)
51
+
52
+ def print(self, title):
53
+ for w in self.warnings:
54
+ print(f"[WARN] {title}: {w}")
55
+ for e in self.errors:
56
+ print(f"[ERROR] {title}: {e}")
57
+
58
+ def exit_code(self, strict=False):
59
+ if self.errors:
60
+ return 1
61
+ if strict and self.warnings:
62
+ return 1
63
+ return 0
64
+
65
+
66
+ # ---------------------------------------------------------------- helpers
67
+
68
+ def find_root(root_arg):
69
+ """Resolve the project root: --root wins, else walk up from cwd."""
70
+ if root_arg:
71
+ root = Path(root_arg).resolve()
72
+ return root if (root / "mkt_docs").is_dir() else None
73
+ cur = Path.cwd().resolve()
74
+ for candidate in (cur, *cur.parents):
75
+ if (candidate / "mkt_docs").is_dir():
76
+ return candidate
77
+ return None
78
+
79
+
80
+ def read_text(path):
81
+ return path.read_text(encoding="utf-8", errors="replace")
82
+
83
+
84
+ def load_frontmatter(text):
85
+ """Return (dict, body). Tolerant: missing frontmatter -> ({}, text)."""
86
+ if not text.startswith("---"):
87
+ return {}, text
88
+ lines = text.split("\n")
89
+ fm = {}
90
+ for i, line in enumerate(lines[1:], start=1):
91
+ if line.strip() == "---":
92
+ return fm, "\n".join(lines[i + 1:])
93
+ m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
94
+ if m:
95
+ fm[m.group(1).strip()] = m.group(2).strip()
96
+ return {}, text # unterminated frontmatter: treat as body
97
+
98
+
99
+ def parse_num(cell):
100
+ """Parse a table-cell number: strips currency symbols, spaces, commas,
101
+ percent signs and [EV-nn] refs; supports k/M suffix. Returns float or None."""
102
+ if cell is None:
103
+ return None
104
+ s = EV_REF.sub("", cell)
105
+ s = re.sub(r"[^\dkKmM.,%-]", "", s.strip())
106
+ s = s.replace(",", "").replace("%", "")
107
+ if not s:
108
+ return None
109
+ mult = 1.0
110
+ if s and s[-1] in "kK":
111
+ mult, s = 1_000.0, s[:-1]
112
+ elif s and s[-1] in "mM":
113
+ mult, s = 1_000_000.0, s[:-1]
114
+ try:
115
+ return float(s) * mult
116
+ except ValueError:
117
+ return None
118
+
119
+
120
+ def parse_tables(text):
121
+ """Return every markdown table as (header_cells_lower, rows) where each
122
+ row is a dict {header_lower: cell}."""
123
+ tables = []
124
+ lines = text.split("\n")
125
+ i = 0
126
+ while i < len(lines):
127
+ line = lines[i].strip()
128
+ if line.startswith("|") and i + 1 < len(lines) \
129
+ and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1].strip()):
130
+ headers = [h.strip().lower() for h in line.strip("|").split("|")]
131
+ rows = []
132
+ j = i + 2
133
+ while j < len(lines) and lines[j].strip().startswith("|"):
134
+ cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
135
+ if len(cells) >= len(headers) - 1: # tolerate ragged tail
136
+ row = {}
137
+ for k, h in enumerate(headers):
138
+ row[h] = cells[k] if k < len(cells) else ""
139
+ rows.append(row)
140
+ j += 1
141
+ tables.append((headers, rows))
142
+ i = j
143
+ else:
144
+ i += 1
145
+ return tables
146
+
147
+
148
+ def find_table(text, required_cols):
149
+ """First table whose header contains every required column (contains-match)."""
150
+ req = [c.lower() for c in required_cols]
151
+ for headers, rows in parse_tables(text):
152
+ if all(any(r in h for h in headers) for r in req):
153
+ return headers, rows
154
+ # exact-name pass for short names like 'kpi'
155
+ if all(r in headers for r in req):
156
+ return headers, rows
157
+ return None, None
158
+
159
+
160
+ def col(row, name):
161
+ """Fetch a cell by contains-match on the header name."""
162
+ name = name.lower()
163
+ if name in row:
164
+ return row[name]
165
+ for h, v in row.items():
166
+ if name in h:
167
+ return v
168
+ return None
169
+
170
+
171
+ def is_example_row(row):
172
+ return any(EXAMPLE_MARKER in (v or "") for v in row.values())
173
+
174
+
175
+ def close_enough(stated, computed, tol):
176
+ if stated is None or computed is None:
177
+ return True # missing cells reported separately
178
+ if computed == 0:
179
+ return abs(stated) < 1e-9
180
+ return abs(stated - computed) / abs(computed) <= tol
181
+
182
+
183
+ def canonical_files(root):
184
+ docs = root / "mkt_docs"
185
+ for d in CANONICAL_DIRS:
186
+ base = docs / d
187
+ if base.is_dir():
188
+ for p in sorted(base.rglob("*.md")):
189
+ yield p
190
+
191
+
192
+ def all_md_files(root):
193
+ docs = root / "mkt_docs"
194
+ for p in sorted(docs.rglob("*.md")):
195
+ yield p
196
+
197
+
198
+ # ---------------------------------------------------------------- index
199
+
200
+ def build_index(root):
201
+ docs = root / "mkt_docs"
202
+ lines = [
203
+ "# mkt_docs INDEX (generated by mkt_check.py index -- do not edit by hand)",
204
+ "",
205
+ "| Path | Description | Status |",
206
+ "|---|---|---|",
207
+ ]
208
+ for p in canonical_files(root):
209
+ if p.name == "INDEX.md":
210
+ continue
211
+ fm, body = load_frontmatter(read_text(p))
212
+ desc = fm.get("description", "")
213
+ status = fm.get("status", "")
214
+ if not desc:
215
+ h1 = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
216
+ desc = h1.group(1).strip() if h1 else ""
217
+ rel = p.relative_to(docs).as_posix()
218
+ lines.append(f"| {rel} | {desc} | {status} |")
219
+ return "\n".join(lines) + "\n"
220
+
221
+
222
+ def cmd_index(root):
223
+ content = build_index(root)
224
+ out = root / "mkt_docs" / "INDEX.md"
225
+ out.write_text(content, encoding="utf-8")
226
+ print(f"[OK] wrote {out.relative_to(root).as_posix()}")
227
+ return 0
228
+
229
+
230
+ # ---------------------------------------------------------------- validate
231
+
232
+ def run_validate(root):
233
+ rep = Report()
234
+ docs = root / "mkt_docs"
235
+ known = {p.name for p in docs.rglob("*.md")}
236
+ for p in canonical_files(root):
237
+ if p.name == "INDEX.md":
238
+ continue
239
+ rel = p.relative_to(docs).as_posix()
240
+ fm, _ = load_frontmatter(read_text(p))
241
+ status = fm.get("status", "")
242
+ if not status:
243
+ rep.warn(f"{rel}: missing frontmatter 'status'")
244
+ elif status.upper() not in VALID_STATUS:
245
+ rep.warn(f"{rel}: invalid status '{status}' (expected {'|'.join(VALID_STATUS)})")
246
+ sup = fm.get("supersedes", "")
247
+ if sup and Path(sup).name not in known:
248
+ rep.warn(f"{rel}: supersedes '{sup}' which does not exist under mkt_docs/")
249
+ index_path = docs / "INDEX.md"
250
+ if not index_path.exists():
251
+ rep.warn("mkt_docs/INDEX.md missing -- generate with 'mkt_check.py index'")
252
+ elif read_text(index_path) != build_index(root):
253
+ rep.warn("mkt_docs/INDEX.md stale -- regenerate with 'mkt_check.py index'")
254
+ return rep
255
+
256
+
257
+ # ---------------------------------------------------------------- ledger
258
+
259
+ def load_ledger(root):
260
+ """Return (rows, report). Rows: list of dicts with id/claim/class/... keys."""
261
+ rep = Report()
262
+ path = root / "mkt_docs" / "research" / "evidence_ledger.md"
263
+ if not path.exists():
264
+ rep.error("research/evidence_ledger.md missing")
265
+ return [], rep
266
+ headers, rows = find_table(read_text(path), ["id", "claim", "class", "source"])
267
+ if rows is None:
268
+ rep.error("evidence_ledger.md: ledger table not found "
269
+ "(need columns ID | Claim | Class | ... | Source | ...)")
270
+ return [], rep
271
+ entries = []
272
+ seen = set()
273
+ for row in rows:
274
+ rid = (col(row, "id") or "").strip()
275
+ if not rid:
276
+ continue
277
+ if is_example_row(row):
278
+ continue
279
+ if not re.fullmatch(r"EV-\d+", rid):
280
+ rep.error(f"ledger: bad ID '{rid}' (expected EV-<n>)")
281
+ continue
282
+ if rid in seen:
283
+ rep.error(f"ledger: duplicate ID {rid}")
284
+ seen.add(rid)
285
+ entries.append(row)
286
+ return entries, rep
287
+
288
+
289
+ def run_ledger(root):
290
+ entries, rep = load_ledger(root)
291
+ ids = set()
292
+ for row in entries:
293
+ rid = col(row, "id").strip()
294
+ ids.add(rid)
295
+ cls = (col(row, "class") or "").strip().upper()
296
+ source = (col(row, "source") or "").strip()
297
+ date = (col(row, "date") or "").strip()
298
+ confidence = (col(row, "confidence") or "").strip().upper()
299
+ if cls not in LEDGER_CLASSES:
300
+ rep.error(f"ledger {rid}: class '{cls}' invalid (FACT|BENCHMARK|ASSUMPTION)")
301
+ continue
302
+ if cls == "BENCHMARK":
303
+ if "http" not in source.lower():
304
+ rep.error(f"ledger {rid}: BENCHMARK without a source URL")
305
+ if not date:
306
+ rep.error(f"ledger {rid}: BENCHMARK without a date")
307
+ if cls == "ASSUMPTION":
308
+ if confidence not in CONFIDENCE_VALUES:
309
+ rep.error(f"ledger {rid}: ASSUMPTION without confidence (HIGH|MED|LOW)")
310
+ value = (col(row, "value") or "").strip()
311
+ if value and not re.search(r"\d\s*[-–—]\s*\d|\bto\b", value):
312
+ rep.warn(f"ledger {rid}: ASSUMPTION value looks like a point estimate, expected a range")
313
+ # reference resolution across every md file (ledger file itself excluded)
314
+ referenced = set()
315
+ ledger_path = root / "mkt_docs" / "research" / "evidence_ledger.md"
316
+ for p in all_md_files(root):
317
+ if p == ledger_path:
318
+ continue
319
+ text = read_text(p)
320
+ for line in text.split("\n"):
321
+ if EXAMPLE_MARKER in line:
322
+ continue
323
+ for m in EV_REF.finditer(line):
324
+ referenced.add(m.group(1))
325
+ if m.group(1) not in ids:
326
+ rel = p.relative_to(root / "mkt_docs").as_posix()
327
+ rep.error(f"{rel}: reference [{m.group(1)}] not found in the ledger")
328
+ for rid in sorted(ids - referenced, key=lambda x: int(x.split("-")[1])):
329
+ rep.warn(f"ledger {rid}: never referenced by any document")
330
+ return rep
331
+
332
+
333
+ # ---------------------------------------------------------------- budget
334
+
335
+ def default_tactical_files(root, file_arg):
336
+ if file_arg:
337
+ p = Path(file_arg)
338
+ if not p.is_absolute():
339
+ p = root / file_arg
340
+ return [p] if p.exists() else []
341
+ tactics = root / "mkt_docs" / "tactics"
342
+ files = []
343
+ if (tactics / "TACTICAL_PLAN.md").exists():
344
+ files.append(tactics / "TACTICAL_PLAN.md")
345
+ if tactics.is_dir():
346
+ files.extend(sorted(tactics.glob("CAMPAIGN_*.md")))
347
+ return files
348
+
349
+
350
+ def run_budget(root, file_arg=None):
351
+ rep = Report()
352
+ files = default_tactical_files(root, file_arg)
353
+ if not files:
354
+ rep.warn("no tactical plan found (tactics/TACTICAL_PLAN.md) -- budget check skipped")
355
+ return rep
356
+ for path in files:
357
+ rel = path.name
358
+ text = read_text(path)
359
+ m = re.search(r"^Total budget:\s*(.+)$", text, re.MULTILINE | re.IGNORECASE)
360
+ if not m:
361
+ rep.error(f"{rel}: 'Total budget:' line missing")
362
+ continue
363
+ total = parse_num(m.group(1))
364
+ if total is None or total <= 0:
365
+ rep.error(f"{rel}: cannot parse Total budget '{m.group(1).strip()}'")
366
+ continue
367
+ headers, rows = find_table(text, ["channel", "budget", "share"])
368
+ if rows is None:
369
+ rep.error(f"{rel}: Budget Allocation table not found (Channel | Budget | Share)")
370
+ continue
371
+ rows = [r for r in rows if not is_example_row(r)]
372
+ if not rows:
373
+ rep.error(f"{rel}: Budget Allocation table has no rows")
374
+ continue
375
+ alloc = 0.0
376
+ for r in rows:
377
+ v = parse_num(col(r, "budget"))
378
+ if v is None:
379
+ rep.error(f"{rel}: unparseable budget cell '{col(r, 'budget')}' "
380
+ f"for channel '{col(r, 'channel')}'")
381
+ else:
382
+ alloc += v
383
+ if not close_enough(alloc, total, BUDGET_TOLERANCE):
384
+ rep.error(f"{rel}: allocations sum to {alloc:g}, Total budget is {total:g} "
385
+ f"(tolerance 1%)")
386
+ return rep
387
+
388
+
389
+ # ---------------------------------------------------------------- funnel
390
+
391
+ FUNNEL_CHAIN = (
392
+ # (stated_col, compute, label)
393
+ ("clicks", lambda b, cpc, cvr, close, r: b / cpc if cpc else None, "Budget/CPC"),
394
+ ("leads", lambda b, cpc, cvr, close, r: (r["clicks"] * cvr / 100.0)
395
+ if (r["clicks"] is not None and cvr is not None) else None, "Clicks*CVR%"),
396
+ ("customers", lambda b, cpc, cvr, close, r: (r["leads"] * close / 100.0)
397
+ if (r["leads"] is not None and close is not None) else None, "Leads*Close%"),
398
+ ("cac", lambda b, cpc, cvr, close, r: (b / r["customers"])
399
+ if (r["customers"] not in (None, 0) and b is not None) else None, "Budget/Customers"),
400
+ )
401
+
402
+
403
+ def run_funnel(root, file_arg=None):
404
+ rep = Report()
405
+ files = default_tactical_files(root, file_arg)
406
+ if not files:
407
+ rep.warn("no tactical plan found -- funnel check skipped")
408
+ return rep
409
+ for path in files:
410
+ rel = path.name
411
+ text = read_text(path)
412
+ headers, rows = find_table(text, ["channel", "budget", "cpc", "cvr", "customers", "cac"])
413
+ if rows is None:
414
+ rep.error(f"{rel}: Funnel Model table not found "
415
+ "(Channel | Budget | CPC | Clicks | CVR % | Leads | Close % | Customers | CAC)")
416
+ continue
417
+ rows = [r for r in rows if not is_example_row(r)]
418
+ if not rows:
419
+ rep.error(f"{rel}: Funnel Model table has no rows")
420
+ continue
421
+ for r in rows:
422
+ channel = col(r, "channel") or "?"
423
+ budget = parse_num(col(r, "budget"))
424
+ cpc = parse_num(col(r, "cpc"))
425
+ cvr = parse_num(col(r, "cvr"))
426
+ close = parse_num(col(r, "close"))
427
+ stated = {
428
+ "clicks": parse_num(col(r, "clicks")),
429
+ "leads": parse_num(col(r, "leads")),
430
+ "customers": parse_num(col(r, "customers")),
431
+ "cac": parse_num(col(r, "cac")),
432
+ }
433
+ if budget is None or cpc is None:
434
+ rep.error(f"{rel} [{channel}]: Budget/CPC missing or unparseable")
435
+ continue
436
+ # verify each stated cell against computation from STATED upstream
437
+ # cells (so a single wrong cell does not cascade into four errors)
438
+ chain_inputs = dict(stated)
439
+ for name, compute, label in FUNNEL_CHAIN:
440
+ computed = compute(budget, cpc, cvr, close, chain_inputs)
441
+ if stated[name] is None:
442
+ rep.error(f"{rel} [{channel}]: '{name}' cell missing")
443
+ continue
444
+ if computed is None:
445
+ continue # upstream missing, already reported
446
+ if not close_enough(stated[name], computed, FUNNEL_TOLERANCE):
447
+ rep.error(f"{rel} [{channel}]: {name}={stated[name]:g} but {label} "
448
+ f"= {computed:g} (tolerance 5%)")
449
+ return rep
450
+
451
+
452
+ # ---------------------------------------------------------------- trace
453
+
454
+ def run_trace(root):
455
+ rep = Report()
456
+ docs = root / "mkt_docs"
457
+ obj_path = docs / "strategy" / "OBJECTIVES.md"
458
+ if not obj_path.exists():
459
+ rep.warn("strategy/OBJECTIVES.md missing -- trace check skipped")
460
+ return rep
461
+ objectives = set(OBJECTIVE_HEADING.findall(read_text(obj_path)))
462
+ if not objectives:
463
+ rep.error("OBJECTIVES.md: no '### O<n> --...' headings found")
464
+ return rep
465
+
466
+ tactical = docs / "tactics" / "TACTICAL_PLAN.md"
467
+ served = set()
468
+ if tactical.exists():
469
+ headers, rows = find_table(read_text(tactical), ["channel", "objective", "kpi"])
470
+ if rows is None:
471
+ rep.error("TACTICAL_PLAN.md: Channel Plan table not found "
472
+ "(Channel | Objective | KPI | Budget | Owner)")
473
+ else:
474
+ for r in rows:
475
+ if is_example_row(r):
476
+ continue
477
+ tokens = set(OBJECTIVE_TOKEN.findall(col(r, "objective") or ""))
478
+ if not tokens:
479
+ rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: no objective (O<n>) named")
480
+ for t in tokens:
481
+ if t not in objectives:
482
+ rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: objective {t} "
483
+ "not defined in OBJECTIVES.md")
484
+ served |= tokens & objectives
485
+ if not (col(r, "kpi") or "").strip():
486
+ rep.error(f"TACTICAL_PLAN.md [{col(r, 'channel')}]: KPI cell empty")
487
+ for o in sorted(objectives - served):
488
+ rep.warn(f"objective {o}: no tactic serves it in TACTICAL_PLAN.md")
489
+ else:
490
+ rep.warn("tactics/TACTICAL_PLAN.md missing -- tactic trace skipped")
491
+
492
+ measure = docs / "tactics" / "MEASUREMENT_PLAN.md"
493
+ if measure.exists():
494
+ headers, rows = find_table(read_text(measure), ["objective", "kpi", "target"])
495
+ if rows is None:
496
+ rep.error("MEASUREMENT_PLAN.md: KPI table not found (Objective | KPI | Target | ...)")
497
+ else:
498
+ measured = set()
499
+ for r in rows:
500
+ if is_example_row(r):
501
+ continue
502
+ measured |= set(OBJECTIVE_TOKEN.findall(col(r, "objective") or ""))
503
+ for o in sorted(objectives - measured):
504
+ rep.error(f"objective {o}: no KPI row in MEASUREMENT_PLAN.md")
505
+ else:
506
+ rep.warn("tactics/MEASUREMENT_PLAN.md missing -- KPI trace skipped")
507
+ return rep
508
+
509
+
510
+ # ---------------------------------------------------------------- check
511
+
512
+ def run_check(root):
513
+ rep = Report()
514
+ for title, fn in (("validate", run_validate), ("ledger", run_ledger),
515
+ ("budget", run_budget), ("funnel", run_funnel),
516
+ ("trace", run_trace)):
517
+ sub = fn(root)
518
+ sub.print(title)
519
+ rep.merge(sub)
520
+ return rep
521
+
522
+
523
+ # ---------------------------------------------------------------- main
524
+
525
+ def main(argv=None):
526
+ parser = argparse.ArgumentParser(prog="mkt_check.py", description=__doc__)
527
+ parser.add_argument("command", choices=["check", "validate", "ledger",
528
+ "budget", "funnel", "trace", "index"])
529
+ parser.add_argument("file", nargs="?", default=None,
530
+ help="optional target file for budget/funnel")
531
+ parser.add_argument("--root", default=None, help="project root (contains mkt_docs/)")
532
+ parser.add_argument("--strict", action="store_true",
533
+ help="warnings also fail the exit code")
534
+ args = parser.parse_args(argv)
535
+
536
+ root = find_root(args.root)
537
+ if root is None:
538
+ print("[ERROR] no mkt_docs/ directory found (use --root or run mkt-sdlc-init)")
539
+ return 2
540
+
541
+ if args.command == "index":
542
+ return cmd_index(root)
543
+
544
+ runners = {
545
+ "validate": lambda: run_validate(root),
546
+ "ledger": lambda: run_ledger(root),
547
+ "budget": lambda: run_budget(root, args.file),
548
+ "funnel": lambda: run_funnel(root, args.file),
549
+ "trace": lambda: run_trace(root),
550
+ }
551
+ if args.command == "check":
552
+ rep = run_check(root)
553
+ else:
554
+ rep = runners[args.command]()
555
+ rep.print(args.command)
556
+
557
+ code = rep.exit_code(strict=args.strict)
558
+ if code == 0:
559
+ print(f"[OK] {args.command} clean "
560
+ f"({len(rep.warnings)} warning(s))" if rep.warnings else f"[OK] {args.command} clean")
561
+ else:
562
+ print(f"[FAIL] {args.command}: {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)")
563
+ return code
564
+
565
+
566
+ if __name__ == "__main__":
567
+ sys.exit(main())