task-pipeline-skill 1.99.1 → 1.101.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.
Files changed (22) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +2 -1
  3. package/SKILL-CARD.md +1 -1
  4. package/package.json +3 -3
  5. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  6. package/plugins/task-pipeline/agents/design-tester.md +93 -0
  7. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +73 -1
  8. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +28 -0
  9. package/plugins/task-pipeline/skills/task-pipeline/references/design-review.md +76 -0
  10. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  11. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +9 -3
  12. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
  13. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  14. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +5 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +17 -2
  16. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +2 -3
  17. package/plugins/task-pipeline/skills/task-pipeline/scripts/design_gate.py +424 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
  19. package/plugins/task-pipeline/skills/task-pipeline/scripts/resource_guard.py +338 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +46 -3
  21. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +51 -4
  22. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +6 -4
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """The design-review gate: the test panel and the design-tester's report, as commands
3
+ with an exit code. Stdlib only.
4
+
5
+ design_gate.py panel <docs/ux/test-panel.md> [--min 10] [--json]
6
+ the test panel the design-tester walks: ten portraits (or --min), each with its
7
+ basis, situation, goals, features, composite scenarios, activation funnel,
8
+ retention loop and needs. A portrait without a basis line is refused: a panel is a
9
+ hypothesis instrument, and saying so is part of the portrait. Each retention line
10
+ opens with `local` or `tourist`, and the panel holds at least two of each (1.101.0).
11
+ design_gate.py report <design-review.json> --class <surface_class>
12
+ [--panel <test-panel.md>] [--scope SCR-01,SCR-02] [--require build]
13
+ [--max-passes 1] [--subscription] [--json]
14
+ stage 3 (`--require design`) and stage 6 (`--require build`): the report covers
15
+ every scope scenario × every panel persona that uses it, every step carries the
16
+ full checklist, no `breaks` finding is open, each round holds at most --max-passes
17
+ passes of each kind, and the resource receipt shows every process it started closed.
18
+ `--subscription` (or a `subscription` block in the report): the surface sells, renews
19
+ or cancels a subscription, so cancelling takes no more steps than subscribing and the
20
+ paywall makes one clear offer (1.101.0).
21
+
22
+ design_gate.py browser <pipeline.json> [--repo DIR] [--json]
23
+ the recorded test browser: `browser.primary` is set (NOT_RUN when the block is
24
+ absent: stage 0 asks the operator once), the fallback names known drivers, and
25
+ `browser.storageState`, a credential, sits outside the repository.
26
+
27
+ `<surface_class>` is the brief's stage-0 answer: flagship | product | internal | ad. On
28
+ flagship, product and ad the review is a GATE; on internal the report is checked for honesty
29
+ and its coverage only reported (`references/design-review.md`).
30
+
31
+ **A check that could not run says NOT_RUN, and NOT_RUN is never PASS.** No report on a gated
32
+ class is NOT_RUN; a step whose capture could not be taken is NOT_RUN with its reason.
33
+
34
+ Exit codes: 0 PASS · 1 FAIL · 2 usage, or an input that cannot be read · 3 NOT_RUN.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import re
42
+ import sys
43
+
44
+ SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
45
+ GATED = ("flagship", "product", "ad")
46
+ PASSES = ("design", "build")
47
+ # The fixed checklist every step is judged on (spec DR-05). A person can read it in a row.
48
+ CHECKS = ("frame", "primary_action", "controls", "focus", "states", "text_fit", "outcome")
49
+ CHECK_VALUES = ("pass", "fail", "na", "NOT_RUN")
50
+ SEVERITIES = ("breaks", "risk", "nit")
51
+ STATUSES = ("open", "fixed") # absent means open; anything else is refused, never read as closed
52
+ FIELDS = ("basis", "situation", "goals", "features", "scenarios", "activation", "retention", "needs")
53
+ PORTRAIT = re.compile(r"^##\s+(P-\d{2})\b(.*)$")
54
+ ALIASES = {"composite scenarios": "scenarios", "activation funnel": "activation",
55
+ "retention loop": "retention"}
56
+ FIELD = re.compile(r"^\s*[-*]\s+\*\*(%s)\s*:?\*\*\s*[:—–-]?\s*(.+?)\s*$"
57
+ % "|".join(sorted(list(FIELDS) + list(ALIASES), key=len, reverse=True)), re.I)
58
+ SCR = re.compile(r"\bSCR-\d+\b")
59
+ SEGMENT = re.compile(r"^\s*(local|tourist)\b\s*:?", re.I)
60
+
61
+
62
+ def read_text(path):
63
+ try:
64
+ with open(path, encoding="utf-8") as fh:
65
+ return fh.read()
66
+ except OSError as exc:
67
+ raise SystemExit(_usage("cannot read %s (%s)" % (path, exc.strerror or exc)))
68
+
69
+
70
+ def _usage(msg):
71
+ print("design_gate: %s" % msg, file=sys.stderr)
72
+ return 2
73
+
74
+
75
+ def parse_panel(text):
76
+ """{persona id: {field: value}} from the panel's `## P-NN` sections. A repeated id is
77
+ kept under `__duplicates__` so the check can name it rather than count it once."""
78
+ panel, cur, dups = {}, None, []
79
+ for line in text.splitlines():
80
+ m = PORTRAIT.match(line)
81
+ if m:
82
+ cur = m.group(1)
83
+ if cur in panel:
84
+ dups.append(cur)
85
+ panel[cur] = {"title": m.group(2).strip(" —-:")}
86
+ continue
87
+ if line.startswith("## "):
88
+ cur = None
89
+ continue
90
+ if cur:
91
+ f = FIELD.match(line)
92
+ if f:
93
+ key = f.group(1).lower()
94
+ panel[cur][ALIASES.get(key, key)] = f.group(2).strip()
95
+ if dups:
96
+ panel["__duplicates__"] = dups
97
+ return panel
98
+
99
+
100
+ def portraits(panel):
101
+ return {k: v for k, v in panel.items() if not k.startswith("__")}
102
+
103
+
104
+ def panel_problems(panel, minimum):
105
+ out = ["%s appears twice; every portrait has its own id" % d for d in panel.get("__duplicates__", [])]
106
+ panel = portraits(panel)
107
+ if len(panel) < minimum:
108
+ out.append("the panel holds %d portrait(s); it owes %d" % (len(panel), minimum))
109
+ for pid, p in sorted(panel.items()):
110
+ for f in FIELDS:
111
+ if not p.get(f):
112
+ out.append("%s: no **%s** line" % (pid, f))
113
+ basis = p.get("basis", "")
114
+ if basis and not re.match(r"^(evidence\s+RE-\d+|hypothesis)\b", basis, re.I):
115
+ out.append("%s: basis is `evidence RE-NN` or `hypothesis`, not %r" % (pid, basis[:40]))
116
+ if p.get("scenarios") and not SCR.search(p["scenarios"]):
117
+ out.append("%s: composite scenarios cite no SCR- id" % pid)
118
+ # M2 R3 (1.101.0): lifetime value rests on locals and churn on tourists, so the panel
119
+ # walks both. The retention line opens with the segment (super-ux templates/test-panel.md).
120
+ seg = {pid: SEGMENT.match(p.get("retention", "")) for pid, p in panel.items()}
121
+ for pid, m in sorted(seg.items()):
122
+ if panel[pid].get("retention") and not m:
123
+ out.append("%s: the retention line opens with `local` or `tourist`" % pid)
124
+ locals_ = sum(1 for m in seg.values() if m and m.group(1).lower() == "local")
125
+ tourists = sum(1 for m in seg.values() if m and m.group(1).lower() == "tourist")
126
+ if panel and (locals_ < 2 or tourists < 2):
127
+ out.append("the panel carries %d local%s and %d tourist%s; it owes at least two of each"
128
+ % (locals_, "" if locals_ == 1 else "s", tourists, "" if tourists == 1 else "s"))
129
+ return out
130
+
131
+
132
+ def personas_using(panel, scenario):
133
+ return sorted(pid for pid, p in portraits(panel).items() if scenario in SCR.findall(p.get("scenarios", "")))
134
+
135
+
136
+ def _posint(v):
137
+ """A round or step as a positive int: 1, "1" and "01" are 1; "1.0", "first", 0 are None."""
138
+ if isinstance(v, bool):
139
+ return None
140
+ if isinstance(v, int):
141
+ return v if v > 0 else None
142
+ if isinstance(v, str) and v.strip().isascii() and v.strip().isdigit() and int(v) > 0:
143
+ return int(v)
144
+ return None
145
+
146
+
147
+ def row_problems(r, i):
148
+ out, rid = [], "row %d (%s %s/%s %s)" % (i, r.get("scenario"), r.get("step"), r.get("persona"), r.get("pass"))
149
+ for k in ("scenario", "step", "persona", "pass", "round"):
150
+ if r.get(k) in (None, ""):
151
+ out.append("%s: no `%s`" % (rid, k))
152
+ if r.get("round") not in (None, "") and _posint(r.get("round")) is None:
153
+ out.append("%s: round is a positive integer, not %r" % (rid, r.get("round")))
154
+ if r.get("pass") not in PASSES:
155
+ out.append("%s: pass is design or build" % rid)
156
+ checks = r.get("checks") or {}
157
+ if not isinstance(checks, dict):
158
+ return out + ["%s: `checks` is an object of the seven checklist items" % rid]
159
+ if not isinstance(r.get("findings") or [], list):
160
+ return out + ["%s: `findings` is a list" % rid]
161
+ for c in CHECKS:
162
+ v = checks.get(c)
163
+ if v not in CHECK_VALUES:
164
+ out.append("%s: checklist item `%s` is %r, not one of %s" % (rid, c, v, "/".join(CHECK_VALUES)))
165
+ for c in ("frame", "outcome"):
166
+ if checks.get(c) == "na":
167
+ out.append("%s: `%s` is never na: every step has a screen and an outcome" % (rid, c))
168
+ if any(v == "NOT_RUN" for v in checks.values()) and not r.get("reason"):
169
+ out.append("%s: a NOT_RUN item carries its reason" % rid)
170
+ if r.get("pass") == "design" and not r.get("frame"):
171
+ out.append("%s: a design step names its Figma node or mockup (`frame`)" % rid)
172
+ if r.get("pass") == "build":
173
+ if not r.get("capture") and checks.get("frame") != "NOT_RUN":
174
+ out.append("%s: a build step carries its `capture`" % rid)
175
+ if not r.get("engine"):
176
+ out.append("%s: a build step names its `engine`" % rid)
177
+ failed = any(checks.get(c) == "fail" for c in CHECKS)
178
+ for f in r.get("findings") or []:
179
+ if not isinstance(f, dict):
180
+ out.append("%s: a finding is an object with severity, what and status" % rid)
181
+ continue
182
+ if f.get("status", "open") not in STATUSES:
183
+ out.append("%s: finding status is open or fixed, not %r" % (rid, f.get("status")))
184
+ if f.get("severity") not in SEVERITIES:
185
+ out.append("%s: finding severity is breaks, risk or nit" % rid)
186
+ elif f.get("severity") == "breaks" and not failed:
187
+ out.append("%s: a `breaks` finding names the checklist item that failed" % rid)
188
+ if any(v == "fail" for v in checks.values()) and not (r.get("findings") or []):
189
+ out.append("%s: a failed checklist item names its finding" % rid)
190
+ return out
191
+
192
+
193
+ PARITY = ("pass", "fail", "NOT_RUN")
194
+ PAUSE = ("offered", "absent", "na")
195
+
196
+
197
+ def subscription_problems(sub):
198
+ """M2 G8/R2: cancelling is as easy as subscribing, and the paywall makes one clear offer.
199
+ Returns (problems, notes, not_run)."""
200
+ if sub is None:
201
+ return (["a subscription surface carries its cancel parity check (`subscription` block: "
202
+ "cancel_parity, subscribe_steps, cancel_steps, single_offer, pause)"], [], False)
203
+ if not isinstance(sub, dict):
204
+ return ["`subscription` is an object, not %r" % (sub,)], [], False
205
+ out, notes = [], []
206
+ for k in ("cancel_parity", "single_offer"):
207
+ if sub.get(k) not in PARITY:
208
+ out.append("subscription.%s is %r, not one of %s" % (k, sub.get(k), "/".join(PARITY)))
209
+ steps = {k: _posint(sub.get(k)) for k in ("subscribe_steps", "cancel_steps")}
210
+ for k, v in steps.items():
211
+ if v is None and sub.get("cancel_parity") != "NOT_RUN":
212
+ out.append("subscription.%s is a positive integer, not %r" % (k, sub.get(k)))
213
+ s, c = steps["subscribe_steps"], steps["cancel_steps"]
214
+ if sub.get("cancel_parity") == "fail":
215
+ out.append("cancelling takes %s steps against %s to subscribe: cancel parity fails" % (c, s))
216
+ elif sub.get("cancel_parity") == "pass" and s and c and c > s:
217
+ out.append("cancel_parity `pass` contradicts %d cancel steps against %d to subscribe" % (c, s))
218
+ if sub.get("single_offer") == "fail":
219
+ out.append("the paywall makes no single clear offer")
220
+ if sub.get("pause") not in PAUSE:
221
+ out.append("subscription.pause is offered, absent or na, not %r" % (sub.get("pause"),))
222
+ elif sub.get("pause") == "absent":
223
+ notes.append("no pause offered on the cancel path (a save offer, not gated)")
224
+ idle = [k for k in ("cancel_parity", "single_offer") if sub.get(k) == "NOT_RUN"]
225
+ if idle and not sub.get("reason"):
226
+ out.append("a NOT_RUN %s carries its reason" % " and ".join(idle))
227
+ return out, notes, bool(idle)
228
+
229
+
230
+ def report_check(doc, surface_class, panel=None, scope=(), require=None, max_passes=1, subscription=False):
231
+ problems, notes = [], []
232
+ if not isinstance(doc, dict) or doc.get("schema") != "design-review/1":
233
+ return "FAIL", ["the report is not design-review/1"], notes
234
+ sub_idle = False
235
+ if subscription or "subscription" in doc:
236
+ sp, sn, sub_idle = subscription_problems(doc.get("subscription"))
237
+ problems += sp
238
+ notes += sn
239
+ rows = doc.get("rows") or []
240
+ if not isinstance(rows, list) or any(not isinstance(r, dict) for r in rows):
241
+ return "FAIL", ["`rows` is a list of objects"], notes
242
+ if not rows:
243
+ return "NOT_RUN", ["the report holds no rows"], notes
244
+ for i, r in enumerate(rows):
245
+ problems += row_problems(r, i)
246
+ open_breaks = [(r.get("scenario"), r.get("step"), r.get("persona")) for r in rows
247
+ for f in (r.get("findings") if isinstance(r.get("findings"), list) else [])
248
+ if isinstance(f, dict) and f.get("severity") == "breaks" and f.get("status", "open") != "fixed"]
249
+ for s in open_breaks:
250
+ problems.append("open `breaks` finding at %s step %s for %s" % s)
251
+ # DR-10: one pass of each kind per surface per round. A second pass shows as the same
252
+ # step walked again in the same round; the round is read as a number.
253
+ walks = {}
254
+ for r in rows:
255
+ rnd = _posint(r.get("round"))
256
+ if rnd is None:
257
+ continue
258
+ step = _posint(r.get("step"))
259
+ key = (rnd, r.get("pass"), r.get("scenario"), step if step is not None else str(r.get("step")), r.get("persona"))
260
+ walks[key] = walks.get(key, 0) + 1
261
+ over = {}
262
+ for (rnd, kind, *_), n in walks.items():
263
+ if n > max_passes:
264
+ over[(rnd, kind)] = max(over.get((rnd, kind), 0), n)
265
+ for (rnd, kind), n in sorted(over.items()):
266
+ problems.append("round %d walks a %s step %d times; the budget is %d pass(es) per round" % (rnd, kind, n, max_passes))
267
+ # Coverage: each scope scenario × each persona that uses it, for each required pass.
268
+ kinds = PASSES if require == "build" else ("design",) if require == "design" else ()
269
+ no_panel = panel is None or not portraits(panel)
270
+ coverage_gap = None
271
+ if kinds and surface_class in GATED and (no_panel or not scope):
272
+ missing = ("a test panel with portraits" if no_panel else "") + (" and " if no_panel and not scope else "") + \
273
+ ("the scope (--scope SCR ids)" if not scope else "")
274
+ coverage_gap = "coverage cannot be computed without %s; the gate does not pass on it" % missing
275
+ if panel and portraits(panel) and scope:
276
+ seen = {(r.get("scenario"), r.get("persona"), r.get("pass")) for r in rows}
277
+ for s in scope:
278
+ users = personas_using(panel, s)
279
+ if not users:
280
+ problems.append("%s: no panel persona uses it; the panel does not cover the change" % s)
281
+ for p in users:
282
+ for k in kinds:
283
+ if (s, p, k) not in seen:
284
+ problems.append("%s × %s: no %s pass" % (s, p, k))
285
+ elif kinds:
286
+ notes.append("coverage not computed: --panel and --scope are both needed")
287
+ # DR-08: the resource receipt.
288
+ res = doc.get("resources")
289
+ if not isinstance(res, dict):
290
+ problems.append("no resource receipt (`resources`): what the review started, and that it closed")
291
+ else:
292
+ if res.get("live"):
293
+ problems.append("processes the review started are still running: %s" % ", ".join(map(str, res["live"])))
294
+ if res.get("closed") is not True:
295
+ problems.append("the resource receipt does not say `closed: true`")
296
+ if surface_class not in GATED:
297
+ notes.append("class %s: the review is recommended; problems are reported, not gated" % surface_class)
298
+ return "PASS", problems, notes
299
+ if problems:
300
+ return "FAIL", problems + ([coverage_gap] if coverage_gap else []), notes
301
+ if coverage_gap:
302
+ return "NOT_RUN", [coverage_gap], notes
303
+ # NOT_RUN is judged per required pass: a stage-6 gate whose build steps were never driven
304
+ # is NOT_RUN, whatever the design rows say. Partial NOT_RUN (a sign-in wall) is listed.
305
+ def idle(r):
306
+ return all(v == "NOT_RUN" for v in (r.get("checks") or {}).values())
307
+ for k in kinds or PASSES:
308
+ mine = [r for r in rows if r.get("pass") == k]
309
+ if mine and all(idle(r) for r in mine):
310
+ return "NOT_RUN", ["every %s step is NOT_RUN: the %s pass did not run" % (k, k)], notes
311
+ if k in kinds and not mine:
312
+ return "NOT_RUN", ["no %s step in the report" % k], notes
313
+ if sub_idle:
314
+ return "NOT_RUN", ["the subscription checks were not walked: %s" % doc["subscription"].get("reason")], notes
315
+ partial = ["%s step %s for %s (%s)" % (r.get("scenario"), r.get("step"), r.get("persona"), r.get("reason", "no reason"))
316
+ for r in rows if any(v == "NOT_RUN" for v in (r.get("checks") or {}).values())]
317
+ if partial:
318
+ notes.append("NOT_RUN steps: " + "; ".join(partial))
319
+ return "PASS", problems, notes
320
+
321
+
322
+ DRIVERS = ("safari", "playwright-webkit", "chrome-dev", "playwright-chromium")
323
+
324
+
325
+ def browser_check(cfg, repo):
326
+ b = cfg.get("browser") if isinstance(cfg, dict) else None
327
+ if not isinstance(b, dict):
328
+ return "NOT_RUN", ["no `browser` block: stage 0 asks the operator which browser the tests use, once"], []
329
+ probs = []
330
+ if b.get("primary") not in DRIVERS:
331
+ probs.append("browser.primary is one of %s, not %r" % (", ".join(DRIVERS), b.get("primary")))
332
+ fb = b.get("fallback") or []
333
+ if not isinstance(fb, list):
334
+ probs.append("browser.fallback is a list of drivers, not %r" % (fb,))
335
+ fb = []
336
+ for f in fb:
337
+ if f not in DRIVERS:
338
+ probs.append("browser.fallback names an unknown driver %r" % f)
339
+ st = b.get("storageState")
340
+ if st is not None and not isinstance(st, str):
341
+ probs.append("browser.storageState is a path string")
342
+ st = None
343
+ if st:
344
+ exp = os.path.expanduser(st)
345
+ full = os.path.normcase(os.path.realpath(exp if os.path.isabs(exp) else os.path.join(repo, exp)))
346
+ root = os.path.normcase(os.path.realpath(repo))
347
+ if sys.platform == "darwin": # APFS is case-insensitive by default; normcase is not
348
+ full, root = full.lower(), root.lower()
349
+ if full == root or full.startswith(root + os.sep):
350
+ probs.append("browser.storageState %s is inside the repository; a signed-in state is a credential" % st)
351
+ notes = ["primary %s, fallback %s" % (b.get("primary"), ", ".join(map(str, fb)) or "none")]
352
+ return ("FAIL" if probs else "PASS"), probs, notes
353
+
354
+
355
+ def emit(verdict, problems, notes, as_json, extra=None):
356
+ if as_json:
357
+ print(json.dumps(dict(verdict=verdict, problems=problems, notes=notes, **(extra or {})), indent=2))
358
+ else:
359
+ print("design_gate: %s" % verdict)
360
+ for p in problems:
361
+ print(" - %s" % p)
362
+ for n in notes:
363
+ print(" note: %s" % n)
364
+ return {"PASS": 0, "FAIL": 1, "NOT_RUN": 3}[verdict]
365
+
366
+
367
+ def main(argv=None):
368
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
369
+ sub = ap.add_subparsers(dest="cmd", required=True)
370
+ p1 = sub.add_parser("panel")
371
+ p1.add_argument("path")
372
+ p1.add_argument("--min", type=int, default=10)
373
+ p1.add_argument("--json", action="store_true")
374
+ p3 = sub.add_parser("browser")
375
+ p3.add_argument("path")
376
+ p3.add_argument("--repo", help="the repository root; default: the git top level of the pipeline.json's folder")
377
+ p3.add_argument("--json", action="store_true")
378
+ p2 = sub.add_parser("report")
379
+ p2.add_argument("path")
380
+ p2.add_argument("--class", dest="cls", required=True, choices=SURFACE_CLASSES)
381
+ p2.add_argument("--panel")
382
+ p2.add_argument("--scope", default="")
383
+ p2.add_argument("--require", choices=PASSES)
384
+ p2.add_argument("--max-passes", type=int, default=1)
385
+ p2.add_argument("--subscription", action="store_true",
386
+ help="the surface sells, renews or cancels a subscription: cancel parity and one clear offer are owed")
387
+ p2.add_argument("--json", action="store_true")
388
+ a = ap.parse_args(argv)
389
+ if a.cmd == "panel":
390
+ if not os.path.exists(a.path):
391
+ return emit("NOT_RUN", ["no test panel at %s: super-ux `ux-foundation` builds it" % a.path], [], a.json)
392
+ panel = parse_panel(read_text(a.path))
393
+ probs = panel_problems(panel, a.min)
394
+ return emit("FAIL" if probs else "PASS", probs, ["%d portrait(s)" % len(portraits(panel))], a.json)
395
+ if a.cmd == "browser":
396
+ try:
397
+ cfg = json.loads(read_text(a.path))
398
+ except ValueError as exc:
399
+ print("design_gate: %s is not JSON (%s)" % (a.path, exc), file=sys.stderr)
400
+ return 2
401
+ repo = a.repo
402
+ if not repo:
403
+ import subprocess
404
+ here = os.path.dirname(os.path.abspath(a.path))
405
+ r = subprocess.run(["git", "-C", here, "rev-parse", "--show-toplevel"], capture_output=True, text=True)
406
+ repo = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else here
407
+ v, probs, notes = browser_check(cfg, repo)
408
+ return emit(v, probs, notes, a.json)
409
+ if not os.path.exists(a.path):
410
+ v = "NOT_RUN" if a.cls in GATED else "PASS"
411
+ return emit(v, ["no design review at %s" % a.path], [], a.json)
412
+ try:
413
+ doc = json.loads(read_text(a.path))
414
+ except ValueError as exc:
415
+ print("design_gate: %s is not JSON (%s)" % (a.path, exc), file=sys.stderr)
416
+ return 2
417
+ panel = parse_panel(read_text(a.panel)) if a.panel and os.path.exists(a.panel) else None
418
+ scope = [s.strip() for s in a.scope.split(",") if s.strip()]
419
+ verdict, probs, notes = report_check(doc, a.cls, panel, scope, a.require, a.max_passes, a.subscription)
420
+ return emit(verdict, probs, notes, a.json)
421
+
422
+
423
+ if __name__ == "__main__":
424
+ sys.exit(main())
@@ -1597,7 +1597,7 @@ def cmd_producer(graph, args):
1597
1597
  def cmd_doctrine(graph, args):
1598
1598
  """Which doctrine this run actually read — B-061.
1599
1599
 
1600
- The bundle is 53 reference files. A run reads some subset and nothing recorded which,
1600
+ The bundle is 54 reference files. A run reads some subset and nothing recorded which,
1601
1601
  so **a skipped file and a read one were indistinguishable** — the class every guard in
1602
1602
  this repository exists to catch, left standing over the doctrine itself.
1603
1603