task-pipeline-skill 1.88.1 → 1.90.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 (31) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +2 -2
  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/verifier-product.md +3 -1
  7. package/plugins/task-pipeline/agents/verifier-visual.md +115 -0
  8. package/plugins/task-pipeline/agents/verifier.md +2 -1
  9. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +5 -5
  10. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +22 -1
  11. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -4
  12. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +12 -0
  13. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +14 -8
  14. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +107 -3
  15. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +41 -2
  16. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +46 -5
  17. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +29 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/references/conventions.md +3 -3
  19. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +15 -9
  21. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +23 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -1
  23. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +27 -3
  24. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +83 -13
  25. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +2 -2
  26. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +45 -16
  27. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +21 -1
  28. package/plugins/task-pipeline/skills/task-pipeline/scripts/visual_gate.py +728 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +5 -2
  30. package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +223 -1
  31. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +10 -0
@@ -0,0 +1,728 @@
1
+ #!/usr/bin/env python3
2
+ """The visual half of the pipeline's gates, as commands with an exit code. Stdlib only.
3
+
4
+ visual_gate.py record <director-record.md> --class <surface_class>
5
+ [--validator CMD | --validator none] [--json]
6
+ stage 3: the VISUAL track left a director record, and the record carries the
7
+ fields its surface class owes — not merely "the track ran"
8
+ visual_gate.py sheet <contact-sheet.json> --class <surface_class>
9
+ [--artifact-root DIR] [--states a,b,…] [--require-approval] [--json]
10
+ stage 6 (and stage 10 with --require-approval): the contact sheet, a filled copy of
11
+ `templates/browser-claims.json` (browser-claims/1) whose look rows carry the
12
+ state × axes matrix, the capture record, the diff and the rubric
13
+ visual_gate.py lint <dir> [--linter CMD | --linter none] [--json]
14
+ stages 5–6: the project linter of sheleg-design (`--lint`), run where it exists
15
+ visual_gate.py filekeys --record <foundation.md or brief> --screens <screens.md> [--json]
16
+ stage 3 with Figma on: every frame link's file key is one of the files the project
17
+ recorded — one per surface (App, Web, ASO), never a file nobody recorded
18
+ visual_gate.py tokens --figma <variables.json> --css <tokens.css> [--json]
19
+ stages 5–6 with Figma on, the token drift probe: every variable name exported from
20
+ the file (`get_variable_defs`, or the REST export) has its CSS custom property in
21
+ the pack's token file and the reverse, and WEB code syntax, where set, names the
22
+ property the file actually declares. No export is NOT_RUN (exit 3), never PASS
23
+
24
+ `<surface_class>` is the brief's stage-0 answer: flagship | product | internal | ad. It
25
+ selects what each check owes (`references/stages.md` → stage 0, *The surface class*).
26
+
27
+ **A check that could not run says NOT_RUN, and NOT_RUN is never PASS.** The director-record
28
+ validator and the project linter belong to sheleg-design; where that package is absent, or
29
+ older than the flag, the command says so in words. `record` still checks the required
30
+ headings itself — that floor needs nothing installed — and reports the validator NOT_RUN
31
+ beside its own verdict, so a reader can tell the floor from the full check.
32
+
33
+ Exit codes: 0 PASS · 1 FAIL · 2 usage, or an input that cannot be read · 3 NOT_RUN (the check
34
+ could not run, or every row it would judge is still NOT_RUN — never a pass).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import itertools
40
+ import json
41
+ import os
42
+ import re
43
+ import shlex
44
+ import subprocess
45
+ import sys
46
+ from datetime import datetime
47
+
48
+ SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
49
+ # Where the visual look is a GATE. `internal` gets the deterministic floor (the linter) and
50
+ # a recommended look; a sheet there is checked for honesty, its coverage only reported.
51
+ GATED = ("flagship", "product", "ad")
52
+
53
+ # The director record's fields — `## <Field>` headings — and which class owes which. The
54
+ # record's own contract lives with sheleg-design; this is the floor the pipeline can check
55
+ # without it. Absent for `internal`: no record is owed there.
56
+ FULL_RECORD = ("Brief", "Mode", "Taste", "References", "Cast", "Fork", "Rubric", "Critique",
57
+ "Markers", "Alignment", "Quality", "Signature", "Surfaces", "Haptics", "ADA",
58
+ "Open")
59
+ RECORD_FIELDS = {
60
+ "flagship": FULL_RECORD,
61
+ "product": ("Brief", "Mode", "References", "Markers", "Open"),
62
+ "ad": ("Brief", "Mode", "References", "Markers", "ADA", "Open"),
63
+ "internal": (),
64
+ }
65
+ MODES = ("new", "redesign", "update", "audit", "declined")
66
+ # A heading whose body is one of these was never filled in. `n/a` is a valid answer only
67
+ # where the contract allows a bare one (Haptics on a surface that is not native); anywhere
68
+ # else it owes its reason, and a reason makes the body longer than the bare token.
69
+ PLACEHOLDER = re.compile(r"^(?:tbd|tba|todo|\.\.\.|…|-|—|n/?a|none|\?|<[^>]*>)\.?$", re.I)
70
+ BARE_NA_OK = ("Haptics",)
71
+
72
+ DEFAULT_TOOL = "npx --no-install sheleg-design-skill"
73
+
74
+ AXES = ("viewport", "theme", "text", "locale")
75
+ CAPTURE = ("revision", "route", "motion", "captured_at", "source")
76
+ RUBRIC_TYPES = ("G", "J", "H")
77
+ RUBRIC_STATUS = ("PASS", "FAIL", "NOT_ASSESSED", "uncertain", "unresolved")
78
+ DIFF_STATUS = ("PASS", "FAIL", "NOT_RUN")
79
+ KINDS = ("look", "suite", "library")
80
+ STATUSES = ("PASS", "FAIL", "NOT_RUN", "BLOCKED")
81
+ DEFAULT_TEXT = ("default", "base", "normal", "100%", "1x", "m", "medium", "large-off")
82
+ RTL = re.compile(r"(?:^|[^a-z])(?:rtl|ar|he|fa|ur|yi)(?:$|[^a-z])", re.I)
83
+ NARROW_PX = 400
84
+ FIGMA_KEY = re.compile(r"figma\.com/(?:design|file|proto|board|make|slides)/([A-Za-z0-9]{10,})")
85
+
86
+
87
+ # --- browser claims (browser-claims/1) ----------------------------------------
88
+ # The rules `references/browser.md` states for every claim, look or not. Kept here so a host
89
+ # project can run them; this repository's `test/browser_claims_test.py` imports them.
90
+
91
+ def claim_problems(claim, artifact_root=None):
92
+ """Every reason this claim must not be believed. Empty list = valid."""
93
+ out = []
94
+ cid = claim.get("id", "?")
95
+ for field in ("id", "req", "scenario", "state", "kind", "status"):
96
+ if not claim.get(field):
97
+ out.append(f"{cid}: {field} missing")
98
+ if claim.get("kind") not in KINDS:
99
+ out.append(f"{cid}: kind {claim.get('kind')!r} is not one of "
100
+ f"{sorted(KINDS)} — the look/suite/library split is the contract")
101
+ if claim.get("status") not in STATUSES:
102
+ out.append(f"{cid}: status {claim.get('status')!r} unknown")
103
+ if claim.get("status") == "NOT_RUN" and not claim.get("reason"):
104
+ out.append(f"{cid}: NOT_RUN carries its reason, always")
105
+ if claim.get("status") == "PASS" and claim.get("kind") == "look":
106
+ if not claim.get("artifact"):
107
+ out.append(f"{cid}: a visual PASS with no artifact is a functional "
108
+ "claim wearing a visual verdict")
109
+ else:
110
+ if artifact_root is not None and \
111
+ not os.path.isfile(os.path.join(artifact_root, claim["artifact"])):
112
+ out.append(f"{cid}: artifact {claim['artifact']} does not exist "
113
+ "— a named file that is not there proves nothing")
114
+ if claim.get("artifact_state") != claim.get("state"):
115
+ out.append(f"{cid}: artifact captured in state "
116
+ f"{claim.get('artifact_state')!r} cannot close a claim about "
117
+ f"{claim.get('state')!r} — the initial screenshot does not "
118
+ "close an opened/error state")
119
+ return out
120
+
121
+
122
+ def toggle_cycle_gaps(claims, component):
123
+ """Which of the full cycle's states the component's PASSing look claims miss."""
124
+ need = {"initial", "opened", "closed-again"}
125
+ have = {c["state"] for c in claims
126
+ if c.get("component") == component and c.get("kind") == "look"
127
+ and c.get("status") == "PASS"}
128
+ return sorted(need - have)
129
+
130
+
131
+ def suite_pass_closes_no_look(claims):
132
+ """Look claims that would be wrongly closed by a suite PASS: none may be."""
133
+ suite_green = any(c.get("kind") == "suite" and c.get("status") == "PASS"
134
+ for c in claims)
135
+ if not suite_green:
136
+ return []
137
+ return [c["id"] for c in claims
138
+ if c.get("kind") == "look" and c.get("status") == "PASS"
139
+ and not c.get("artifact")]
140
+
141
+
142
+ # --- the contact sheet (the visual look's rows) --------------------------------
143
+
144
+ def _filled(v):
145
+ return isinstance(v, str) and bool(v.strip())
146
+
147
+
148
+ def _iso(v):
149
+ if not _filled(v):
150
+ return False
151
+ try:
152
+ datetime.fromisoformat(v.replace("Z", "+00:00"))
153
+ return True
154
+ except ValueError:
155
+ return False
156
+
157
+
158
+ def is_visual_row(c):
159
+ return c.get("kind") == "look" and "axes" in c
160
+
161
+
162
+ def _large_text(v):
163
+ return _filled(v) and v.strip().lower() not in DEFAULT_TEXT
164
+
165
+
166
+ def _narrow(v):
167
+ if not _filled(v):
168
+ return False
169
+ s = v.lower()
170
+ if any(w in s for w in ("narrow", "compact", " se", "iphone se", "small")):
171
+ return True
172
+ m = re.match(r"\s*(\d{2,5})\s*[x×]", s)
173
+ return bool(m) and int(m.group(1)) <= NARROW_PX
174
+
175
+
176
+ def visual_row_problems(c, revision, approved):
177
+ """Everything wrong with one state × axes row, beyond the rules every claim obeys."""
178
+ out = []
179
+ cid = c.get("id", "?")
180
+ axes = c.get("axes")
181
+ if not isinstance(axes, dict):
182
+ return [f"{cid}: `axes` must be an object of {', '.join(AXES)}"]
183
+ for a in AXES:
184
+ if not _filled(axes.get(a)):
185
+ out.append(f"{cid}: axes.{a} is missing — a frame whose {a} nobody recorded "
186
+ "cannot answer a claim about any one of them")
187
+ for k in ("figma_frame", "baseline"):
188
+ if c.get(k) is not None and not _filled(c.get(k)):
189
+ out.append(f"{cid}: `{k}` is a path or URL, or null")
190
+ diff = c.get("diff")
191
+ if diff is not None:
192
+ if not isinstance(diff, dict):
193
+ out.append(f"{cid}: `diff` is an object or null")
194
+ diff = None
195
+ else:
196
+ if diff.get("against") not in ("figma", "baseline"):
197
+ out.append(f"{cid}: diff.against is {diff.get('against')!r} — it compares "
198
+ "against `figma` or `baseline`")
199
+ if diff.get("status") not in DIFF_STATUS:
200
+ out.append(f"{cid}: diff.status {diff.get('status')!r} is not one of "
201
+ f"{', '.join(DIFF_STATUS)}")
202
+ if diff.get("status") == "NOT_RUN" and not _filled(diff.get("reason")):
203
+ out.append(f"{cid}: a diff that did not run carries its reason")
204
+ rubric = c.get("rubric", [])
205
+ if not isinstance(rubric, list):
206
+ out.append(f"{cid}: `rubric` must be a list")
207
+ rubric = []
208
+ g_fail = []
209
+ for i, r in enumerate(rubric):
210
+ if not isinstance(r, dict):
211
+ out.append(f"{cid}: rubric[{i}] is not an object")
212
+ continue
213
+ rid = r.get("id") or f"rubric[{i}]"
214
+ if not _filled(r.get("id")):
215
+ out.append(f"{cid}: rubric[{i}] has no id")
216
+ if r.get("type") not in RUBRIC_TYPES:
217
+ out.append(f"{cid}: {rid}.type {r.get('type')!r} is not G, J or H")
218
+ st = r.get("status")
219
+ if st not in RUBRIC_STATUS:
220
+ out.append(f"{cid}: {rid}.status {st!r} is not one of {', '.join(RUBRIC_STATUS)}")
221
+ if st in ("FAIL", "unresolved"):
222
+ t = r.get("triple")
223
+ if not (isinstance(t, dict) and all(_filled(t.get(k))
224
+ for k in ("region", "defect", "fix"))):
225
+ out.append(f"{cid}: {rid} is {st} without its triple — region, defect, fix. "
226
+ "A finding nobody can locate and act on is an impression")
227
+ if r.get("type") == "J" and st in ("PASS", "FAIL") and not _filled(r.get("calibration")):
228
+ out.append(f"{cid}: {rid} is a judge item reported {st} with no `calibration` — "
229
+ "until a labelled set exists and the judge's agreement with it is "
230
+ "measured, a J item is NOT_ASSESSED, not a verdict")
231
+ if r.get("type") == "H" and st == "PASS" and not approved:
232
+ out.append(f"{cid}: {rid} is a human item reported PASS on a sheet nobody "
233
+ "approved — only the person who reviewed the sheet can pass it")
234
+ if r.get("type") == "G" and st == "FAIL":
235
+ g_fail.append(rid)
236
+ if c.get("status") == "PASS":
237
+ cap = c.get("capture")
238
+ if not isinstance(cap, dict):
239
+ out.append(f"{cid}: a PASS frame carries its capture record "
240
+ f"({', '.join(CAPTURE)}) — a screenshot with no record is an image, "
241
+ "not evidence")
242
+ else:
243
+ for k in CAPTURE:
244
+ if not _filled(cap.get(k)):
245
+ out.append(f"{cid}: capture.{k} is missing")
246
+ if _filled(cap.get("captured_at")) and not _iso(cap["captured_at"]):
247
+ out.append(f"{cid}: capture.captured_at {cap['captured_at']!r} is not "
248
+ "ISO-8601, so its staleness cannot be computed")
249
+ if _filled(revision) and _filled(cap.get("revision")) and \
250
+ cap["revision"] != revision:
251
+ out.append(f"{cid}: captured at {cap['revision']!r} while the sheet is for "
252
+ f"{revision!r} — a stale frame proves the revision before")
253
+ if (c.get("figma_frame") or c.get("baseline")) and diff is None:
254
+ out.append(f"{cid}: a frame with a reference ({'figma_frame' if c.get('figma_frame') else 'baseline'}) "
255
+ "and no `diff` — the comparison is the point of having the reference")
256
+ if diff is not None and diff.get("status") == "FAIL":
257
+ out.append(f"{cid}: PASS over a failing diff against {diff.get('against')} — "
258
+ "either the frame drifted, or a person approves a new baseline; "
259
+ "neither is a PASS written by the run")
260
+ if g_fail:
261
+ out.append(f"{cid}: PASS while gate item(s) {', '.join(g_fail)} FAIL — a "
262
+ "deterministic gate item is a floor the judge never overrides")
263
+ return out
264
+
265
+
266
+ def coverage_gaps(rows, states):
267
+ """Holes in the state × axes matrix. Pairwise over the axes, plus the mandatory pairs."""
268
+ gaps = []
269
+ seen_states = {c.get("state") for c in rows}
270
+ for s in states:
271
+ if s not in seen_states:
272
+ gaps.append(f"state {s!r} has no frame — a hole in the matrix")
273
+ ax = [c.get("axes") for c in rows if isinstance(c.get("axes"), dict)]
274
+ if not ax:
275
+ return gaps + ["no state × axes row at all"]
276
+ if not any(_large_text(a.get("text")) for a in ax):
277
+ gaps.append("the text axis never leaves its default — large text (200% / AX5) is a "
278
+ "mandatory column")
279
+ darks = [a for a in ax if "dark" in str(a.get("theme", "")).lower()]
280
+ if darks and any(_large_text(a.get("text")) for a in ax) and \
281
+ not any(_large_text(a.get("text")) for a in darks):
282
+ gaps.append("no frame is dark × large text — a mandatory pair")
283
+ rtls = [a for a in ax if RTL.search(str(a.get("locale", "")))]
284
+ if rtls and not any(_narrow(a.get("viewport")) for a in rtls):
285
+ gaps.append("no frame is RTL × narrow — a mandatory pair")
286
+ # Pairwise coverage: every value of one axis meets every value of every other axis in
287
+ # SOME frame. The full cross product is what makes a sheet unreadable; pairs are the
288
+ # smallest set that still catches a defect that appears only where one axis meets another.
289
+ values = {k: sorted({str(a.get(k)) for a in ax if _filled(a.get(k))}) for k in AXES}
290
+ for x, y in itertools.combinations(AXES, 2):
291
+ have = {(str(a.get(x)), str(a.get(y))) for a in ax}
292
+ for vx in values[x]:
293
+ for vy in values[y]:
294
+ if (vx, vy) not in have:
295
+ gaps.append(f"pairwise: no frame is {x}={vx} × {y}={vy}")
296
+ return gaps
297
+
298
+
299
+ def sheet_report(doc, surface_class, artifact_root=None, states=(), require_approval=False):
300
+ """(verdict, problems, notes) for a contact sheet at one surface class."""
301
+ problems, notes = [], []
302
+ if not isinstance(doc, dict) or doc.get("schema_version") != "browser-claims/1":
303
+ return "FAIL", ["not a browser-claims/1 document — the contact sheet extends that "
304
+ "file, there is no second schema"], notes
305
+ claims = doc.get("claims")
306
+ if not isinstance(claims, list):
307
+ return "FAIL", ["`claims` must be a list"], notes
308
+ for c in claims:
309
+ problems += claim_problems(c, artifact_root)
310
+ for cid in suite_pass_closes_no_look(claims):
311
+ problems.append(f"{cid}: a suite PASS cannot close a look claim with no artifact")
312
+ rows = [c for c in claims if is_visual_row(c)]
313
+ approved = _filled(doc.get("approved_by")) or _filled(doc.get("approved_at"))
314
+ if rows or surface_class in GATED:
315
+ for k in ("surface", "revision"):
316
+ if not _filled(doc.get(k)):
317
+ problems.append(f"the sheet names no `{k}` — a contact sheet is for one "
318
+ "surface at one revision")
319
+ rr = doc.get("review_rounds")
320
+ if not isinstance(rr, int) or isinstance(rr, bool) or rr < 0:
321
+ problems.append("`review_rounds` must be a whole number — it is how passes are "
322
+ "measured, and a sheet that cannot say how many returns it took "
323
+ "cannot show the count falling")
324
+ if approved and not (_filled(doc.get("approved_by")) and _iso(doc.get("approved_at"))):
325
+ problems.append("an approval names who (`approved_by`) and when (`approved_at`, "
326
+ "ISO-8601) — half an approval is not one")
327
+ if require_approval and not approved:
328
+ problems.append("the sheet is not approved — at acceptance the contact sheet is the "
329
+ "human's one pass, and an unapproved sheet is a pass that did not "
330
+ "happen")
331
+ if surface_class in GATED and not rows:
332
+ problems.append(f"a {surface_class} surface with no state × axes row — the visual "
333
+ "look did not run, and on this class it is a gate")
334
+ j_fail = set()
335
+ pending = []
336
+ for c in rows:
337
+ problems += visual_row_problems(c, doc.get("revision"), approved)
338
+ for r in c.get("rubric") or []:
339
+ if isinstance(r, dict):
340
+ if r.get("type") == "J" and r.get("status") == "FAIL":
341
+ j_fail.add(r.get("id"))
342
+ if r.get("status") in ("uncertain", "unresolved"):
343
+ pending.append(f"{c.get('id')}:{r.get('id')} {r.get('status')}")
344
+ if c.get("status") in ("NOT_RUN", "BLOCKED"):
345
+ pending.append(f"{c.get('id')} {c.get('status')}")
346
+ if surface_class == "flagship" and len(j_fail) > 2:
347
+ problems.append(f"{len(j_fail)} judge items FAIL ({', '.join(sorted(map(str, j_fail)))}) "
348
+ "— the flagship profile admits at most two, each with its triple")
349
+ if rows:
350
+ gaps = coverage_gaps(rows, states)
351
+ if surface_class == "flagship":
352
+ problems += gaps
353
+ elif surface_class in ("product", "ad"):
354
+ hard = [g for g in gaps if not g.startswith("pairwise:")]
355
+ problems += hard
356
+ notes += [g for g in gaps if g.startswith("pairwise:")]
357
+ else:
358
+ notes += gaps
359
+ if isinstance(doc.get("review_rounds"), int) and doc["review_rounds"] > 2 and not approved:
360
+ notes.append(f"review_rounds is {doc['review_rounds']}: past the budget of two — the "
361
+ "open items go to the person as `unresolved`, not into another round")
362
+ if pending:
363
+ notes.append("for the person on the sheet: " + "; ".join(pending))
364
+ if problems:
365
+ return "FAIL", problems, notes
366
+ # Honest on every class: a frame that did not run is NOT_RUN in the verdict as well as
367
+ # in its row. Whether that blocks the stage is the class's business — on `internal`
368
+ # the look is recommended, so the stage reads NOT_RUN and goes on, saying so.
369
+ if any(c.get("status") in ("NOT_RUN", "BLOCKED") for c in rows):
370
+ return "NOT_RUN", problems, notes
371
+ return "PASS", problems, notes
372
+
373
+
374
+ # --- the director record --------------------------------------------------------
375
+
376
+ def _sections(text):
377
+ """`## Heading` -> body, keyed by the heading's first word."""
378
+ out = {}
379
+ parts = re.split(r"^##[ \t]+(.+?)[ \t]*$", text, flags=re.M)
380
+ for i in range(1, len(parts), 2):
381
+ m = re.match(r"([A-Za-z]+)", parts[i].strip())
382
+ if m:
383
+ body = re.sub(r"<!--.*?-->", "", parts[i + 1], flags=re.S).strip()
384
+ out.setdefault(m.group(1).lower(), body)
385
+ return out
386
+
387
+
388
+ def _declared_class(text):
389
+ head = text.split("\n## ", 1)[0]
390
+ m = re.search(r"surface_class\**\s*:\s*`?\s*([a-z]+)", head, re.I)
391
+ return m.group(1).lower() if m else None
392
+
393
+
394
+ def record_floor(text, surface_class):
395
+ """(problems, declined) — the headings the class owes, present and filled."""
396
+ problems = []
397
+ sec = _sections(text)
398
+ declared = _declared_class(text)
399
+ if declared is None:
400
+ problems.append("the record declares no `surface_class:` in its header")
401
+ elif declared not in SURFACE_CLASSES:
402
+ problems.append(f"the record's surface_class {declared!r} is not one of "
403
+ f"{', '.join(SURFACE_CLASSES)}")
404
+ elif declared != surface_class:
405
+ problems.append(f"the record says surface_class {declared!r} and the brief says "
406
+ f"{surface_class!r} — one of them is stale, and the gate profile "
407
+ "depends on which")
408
+ mode_body = sec.get("mode", "")
409
+ mode = (re.match(r"[`*_\s]*([A-Za-z]+)", mode_body) or [None, ""])[1].lower()
410
+ if "mode" not in sec or not mode:
411
+ problems.append("## Mode is missing or empty")
412
+ return problems, False
413
+ if mode not in MODES:
414
+ problems.append(f"## Mode is {mode!r} — one of {', '.join(MODES)}")
415
+ if mode == "declined":
416
+ reason = re.sub(r"^[`*_\s]*declined[`*_\s:—-]*", "", mode_body, flags=re.I).strip()
417
+ if len(reason.split()) < 3:
418
+ problems.append("## Mode: declined carries no reason — a refusal passes the gate "
419
+ "only when it says why, in words a reader can disagree with")
420
+ return problems, True
421
+ for field in RECORD_FIELDS[surface_class]:
422
+ body = sec.get(field.lower())
423
+ if body is None:
424
+ problems.append(f"## {field} is missing — the {surface_class} profile owes it")
425
+ elif not body or (PLACEHOLDER.match(body) and
426
+ not (field in BARE_NA_OK and re.match(r"^n/?a\.?$", body, re.I))):
427
+ problems.append(f"## {field} is empty ({body!r}) — a heading with nothing under "
428
+ "it is the fact of the track, not its trace")
429
+ return problems, False
430
+
431
+
432
+ def _run_tool(cmd, flag, args, timeout=180):
433
+ """("PASS"|"FAIL"|"NOT_RUN", detail). The tool is sheleg-design's CLI."""
434
+ if cmd.strip().lower() == "none":
435
+ return "NOT_RUN", "disabled with `none`"
436
+ argv = shlex.split(cmd)
437
+ try:
438
+ h = subprocess.run(argv + ["--help"], capture_output=True, text=True, timeout=timeout)
439
+ except FileNotFoundError:
440
+ return "NOT_RUN", f"`{argv[0]}` is not installed"
441
+ except (OSError, subprocess.SubprocessError) as e:
442
+ return "NOT_RUN", f"`{cmd} --help` could not run ({type(e).__name__})"
443
+ if flag not in (h.stdout + h.stderr):
444
+ return "NOT_RUN", (f"`{cmd}` does not offer {flag} — sheleg-design is not installed, "
445
+ "or older than that flag")
446
+ try:
447
+ r = subprocess.run(argv + [flag] + args, capture_output=True, text=True,
448
+ timeout=timeout)
449
+ except (OSError, subprocess.SubprocessError) as e:
450
+ return "NOT_RUN", f"`{cmd} {flag}` could not run ({type(e).__name__})"
451
+ tail = (r.stdout + r.stderr).strip()[-1500:]
452
+ if r.returncode == 0:
453
+ return "PASS", tail
454
+ if r.returncode == 1:
455
+ return "FAIL", tail
456
+ return "NOT_RUN", f"`{cmd} {flag}` exited {r.returncode}: {tail[-300:]}"
457
+
458
+
459
+ def cmd_record(a):
460
+ if a.surface_class == "internal":
461
+ return _emit(a, "record", "PASS", [], ["no director record is owed by an internal "
462
+ "surface; the floor there is the linter"])
463
+ try:
464
+ text = open(a.path, encoding="utf-8").read()
465
+ except OSError:
466
+ return _emit(a, "record", "FAIL", [f"{a.path} does not exist — the VISUAL track "
467
+ "leaves a director record, or a recorded refusal"],
468
+ [])
469
+ problems, declined = record_floor(text, a.surface_class)
470
+ status, detail = _run_tool(a.validator, "--check-record", [a.path])
471
+ notes = [f"validator: {status}" + (f" — {detail}" if status != "PASS" else "")]
472
+ if declined and not problems:
473
+ notes.insert(0, "the visual track was declined, with its reason — a recorded "
474
+ "refusal passes")
475
+ floor = "FAIL" if problems else "PASS"
476
+ if status == "FAIL":
477
+ problems.append("the record validator refused it: " + detail)
478
+ verdict = "FAIL" if problems else "PASS"
479
+ return _emit(a, "record", verdict, problems, notes,
480
+ extra={"floor": floor, "validator": status})
481
+
482
+
483
+ def cmd_sheet(a):
484
+ try:
485
+ doc = json.load(open(a.path, encoding="utf-8"))
486
+ except (OSError, ValueError) as e:
487
+ print(f"visual_gate: cannot read {a.path} ({type(e).__name__})", file=sys.stderr)
488
+ return 2
489
+ states = [s for s in (a.states or "").split(",") if s.strip()]
490
+ verdict, problems, notes = sheet_report(doc, a.surface_class, a.artifact_root, states,
491
+ a.require_approval)
492
+ return _emit(a, "sheet", verdict, problems, notes)
493
+
494
+
495
+ def cmd_lint(a):
496
+ status, detail = _run_tool(a.linter, "--lint", [a.dir, "--json"])
497
+ if status == "NOT_RUN":
498
+ return _emit(a, "lint", "NOT_RUN", [], [detail])
499
+ counts = {}
500
+ try:
501
+ for f in json.loads(detail[detail.index("["):]):
502
+ counts[f.get("severity", "?")] = counts.get(f.get("severity", "?"), 0) + 1
503
+ except (ValueError, AttributeError, TypeError):
504
+ pass
505
+ summary = ", ".join(f"{k} {v}" for k, v in sorted(counts.items())) or "no findings parsed"
506
+ if status == "FAIL":
507
+ return _emit(a, "lint", "FAIL", [f"the project linter exited 1 ({summary})"], [])
508
+ return _emit(a, "lint", "PASS", [], [f"findings by severity: {summary}"])
509
+
510
+
511
+ def _section(text, title):
512
+ m = re.search(r"^(#{1,6})[ \t]+[^\n]*" + re.escape(title) + r"[^\n]*$", text, re.M | re.I)
513
+ if not m:
514
+ return None
515
+ level = len(m.group(1))
516
+ rest = text[m.end():]
517
+ nxt = re.search(r"^#{1,%d}[ \t]" % level, rest, re.M)
518
+ return rest[:nxt.start()] if nxt else rest
519
+
520
+
521
+ def cmd_filekeys(a):
522
+ try:
523
+ rec = open(a.record, encoding="utf-8").read()
524
+ scr = open(a.screens, encoding="utf-8").read()
525
+ except OSError as e:
526
+ print(f"visual_gate: cannot read an input ({e.filename})", file=sys.stderr)
527
+ return 2
528
+ body = _section(rec, "Design tooling")
529
+ recorded = sorted(set(FIGMA_KEY.findall(body if body is not None else rec)))
530
+ used = sorted(set(FIGMA_KEY.findall(scr)))
531
+ problems = []
532
+ if used and not recorded:
533
+ problems.append("frames link to Figma and the record names no file — the "
534
+ "destination was never decided, or was decided somewhere else")
535
+ for k in used:
536
+ if recorded and k not in recorded:
537
+ problems.append(f"frame file key {k} is not a recorded file — a second file "
538
+ "nobody will open, holding real work")
539
+ notes = [f"recorded: {', '.join(recorded) or 'none'}",
540
+ f"linked from screens: {', '.join(used) or 'none'}"]
541
+ return _emit(a, "filekeys", "FAIL" if problems else "PASS", problems, notes)
542
+
543
+
544
+ # --- token-name drift: Figma variables against the pack's CSS custom properties ---
545
+ # A token that has one name in the file and another in code has quietly split in two, and
546
+ # nothing downstream notices: `get_design_context` hands the agent the Figma name, the agent
547
+ # writes a raw value because no property answers to it, and the screen still looks right.
548
+
549
+ CSS_DECL = re.compile(r"(?<![\w-])(--[A-Za-z0-9_-]+)\s*:")
550
+ CS_PROP = re.compile(r"\s*(?:var\(\s*)?(--[A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)?\s*")
551
+ # Keys a variable's own record can carry. A dict value with none of them is a token GROUP
552
+ # (a nested design-token tree), not a variable, and reading its key as a name would compare
553
+ # the code against the group names.
554
+ VAR_KEYS = ("codeSyntax", "value", "$value", "resolvedType", "type", "valuesByMode")
555
+
556
+
557
+ def css_property(name):
558
+ """The CSS custom property a Figma variable name maps to when it carries no code syntax:
559
+ `Color/Text Muted` → `--color-text-muted`, `fontSize/bodyLarge` → `--font-size-body-large`.
560
+ A convention, not a law — WEB code syntax, where the file sets it, overrides it."""
561
+ s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
562
+ return "--" + re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-").lower()
563
+
564
+
565
+ def figma_variables(doc):
566
+ """[(name, web_code_syntax or None)] from a variable export. Accepts the map
567
+ `get_variable_defs` returns (name → value), the REST export (`meta.variables`, id →
568
+ variable) and a `variables` list. Raises ValueError on any other shape."""
569
+ if isinstance(doc, dict) and isinstance(doc.get("meta"), dict) and "variables" in doc["meta"]:
570
+ doc = doc["meta"]["variables"]
571
+ elif isinstance(doc, dict) and isinstance(doc.get("variables"), (list, dict)):
572
+ doc = doc["variables"]
573
+
574
+ def one(v):
575
+ cs = v.get("codeSyntax")
576
+ return v["name"], (cs.get("WEB") if isinstance(cs, dict) else None)
577
+
578
+ if isinstance(doc, list):
579
+ if not all(isinstance(v, dict) and isinstance(v.get("name"), str) for v in doc):
580
+ raise ValueError("a `variables` list whose items are not {name, …} objects")
581
+ return [one(v) for v in doc]
582
+ if not isinstance(doc, dict):
583
+ raise ValueError(f"a {type(doc).__name__}, not a variable map")
584
+ if doc and all(isinstance(v, dict) and isinstance(v.get("name"), str) for v in doc.values()):
585
+ return [one(v) for v in doc.values()]
586
+ out = []
587
+ for k, v in doc.items():
588
+ if isinstance(v, dict):
589
+ if not any(key in v for key in VAR_KEYS):
590
+ raise ValueError(f"{k!r} holds a group, not a variable — a nested token tree")
591
+ cs = v.get("codeSyntax")
592
+ out.append((k, cs.get("WEB") if isinstance(cs, dict) else None))
593
+ elif isinstance(v, list):
594
+ raise ValueError(f"{k!r} holds a list, not a variable's value")
595
+ else:
596
+ out.append((k, None))
597
+ return out
598
+
599
+
600
+ def css_properties(text):
601
+ """Every custom property the token file DECLARES. Comments are stripped first, and a
602
+ `var(--x)` use is not a declaration."""
603
+ return set(CSS_DECL.findall(re.sub(r"/\*.*?\*/", "", text, flags=re.S)))
604
+
605
+
606
+ def token_drift(variables, props):
607
+ """(problems, report) — names in one side and not the other, and code syntax that
608
+ disagrees with the property the token file actually has."""
609
+ problems, figma_only, syntax, used, owner = [], [], [], set(), {}
610
+ for name, cs in variables:
611
+ derived = css_property(name)
612
+ target = derived
613
+ if cs is not None:
614
+ m = CS_PROP.fullmatch(cs)
615
+ if not m:
616
+ syntax.append(f"{name}: WEB code syntax {cs!r} is not a CSS custom property")
617
+ continue
618
+ target = m.group(1)
619
+ if target in owner:
620
+ problems.append(f"{owner[target]!r} and {name!r} both map to {target} — one "
621
+ "property cannot hold both")
622
+ continue
623
+ owner[target] = name
624
+ if target in props:
625
+ used.add(target)
626
+ elif cs is not None and derived in props:
627
+ used.add(derived)
628
+ syntax.append(f"{name}: code syntax names {target}, and the token file calls it "
629
+ f"{derived}")
630
+ else:
631
+ figma_only.append(f"{name} → {target}")
632
+ css_only = sorted(props - used)
633
+ problems += [f"in Figma, not in the token file: {x}" for x in figma_only]
634
+ problems += [f"in the token file, not in Figma: {x}" for x in css_only]
635
+ problems += [f"code syntax: {x}" for x in syntax]
636
+ return problems, {"figma_only": figma_only, "css_only": css_only, "code_syntax": syntax,
637
+ "matched": len(used)}
638
+
639
+
640
+ def cmd_tokens(a):
641
+ try:
642
+ css = open(a.css, encoding="utf-8").read()
643
+ except OSError as e:
644
+ print(f"visual_gate: cannot read the token file {a.css} ({type(e).__name__})",
645
+ file=sys.stderr)
646
+ return 2
647
+ if not os.path.isfile(a.figma):
648
+ return _emit(a, "tokens", "NOT_RUN", [], [
649
+ f"no Figma variable export at {a.figma} — export it (`get_variable_defs`, or the "
650
+ "REST variables endpoint) and run again; until then the drift is unmeasured"])
651
+ try:
652
+ doc = json.load(open(a.figma, encoding="utf-8"))
653
+ except (OSError, ValueError) as e: # JSONDecodeError and UnicodeDecodeError included
654
+ print(f"visual_gate: cannot read {a.figma} ({type(e).__name__})", file=sys.stderr)
655
+ return 2
656
+ try:
657
+ variables = figma_variables(doc)
658
+ except ValueError as e:
659
+ print(f"visual_gate: {a.figma} is not a variable export — {e}", file=sys.stderr)
660
+ return 2
661
+ if not variables:
662
+ return _emit(a, "tokens", "NOT_RUN", [], [
663
+ f"{a.figma} holds no variables — there is nothing to compare, and nothing "
664
+ "compared is not a pass"])
665
+ props = css_properties(css)
666
+ problems, rep = token_drift(variables, props)
667
+ notes = [f"{len(variables)} Figma variable(s), {len(props)} CSS custom propert"
668
+ f"{'y' if len(props) == 1 else 'ies'}, {rep['matched']} matched"]
669
+ return _emit(a, "tokens", "FAIL" if problems else "PASS", problems, notes, extra=rep)
670
+
671
+
672
+ EXIT = {"PASS": 0, "FAIL": 1, "NOT_RUN": 3}
673
+
674
+
675
+ def _emit(a, check, verdict, problems, notes, extra=None):
676
+ if getattr(a, "json", False):
677
+ out = {"check": check, "verdict": verdict, "problems": problems, "notes": notes}
678
+ out.update(extra or {})
679
+ print(json.dumps(out, ensure_ascii=False))
680
+ else:
681
+ cls = getattr(a, "surface_class", None)
682
+ print(f"{check}: {verdict}" + (f" — class {cls}" if cls else ""))
683
+ for p in problems:
684
+ print(f" ✗ {p}")
685
+ for n in notes:
686
+ print(f" · {n}")
687
+ return EXIT[verdict]
688
+
689
+
690
+ def main(argv):
691
+ p = argparse.ArgumentParser(prog="visual_gate.py",
692
+ description="The visual half of the gates; NOT_RUN is never PASS.")
693
+ sub = p.add_subparsers(dest="cmd", required=True)
694
+ r = sub.add_parser("record", help="stage 3: the director record carries its fields")
695
+ r.add_argument("path")
696
+ r.add_argument("--class", dest="surface_class", required=True, choices=SURFACE_CLASSES)
697
+ r.add_argument("--validator", default=DEFAULT_TOOL,
698
+ help=f"the record validator's command (default `{DEFAULT_TOOL}`), or none")
699
+ r.add_argument("--json", action="store_true")
700
+ s = sub.add_parser("sheet", help="stages 6 and 10: the contact sheet")
701
+ s.add_argument("path")
702
+ s.add_argument("--class", dest="surface_class", required=True, choices=SURFACE_CLASSES)
703
+ s.add_argument("--artifact-root", help="where the frames live, so a named file is checked")
704
+ s.add_argument("--states", help="comma-separated SCR states the matrix must cover")
705
+ s.add_argument("--require-approval", action="store_true",
706
+ help="stage 10: the sheet carries the person's approval")
707
+ s.add_argument("--json", action="store_true")
708
+ li = sub.add_parser("lint", help="stages 5–6: sheleg-design's project linter")
709
+ li.add_argument("dir")
710
+ li.add_argument("--linter", default=DEFAULT_TOOL)
711
+ li.add_argument("--json", action="store_true")
712
+ f = sub.add_parser("filekeys", help="stage 3: frame links stay in the recorded files")
713
+ f.add_argument("--record", required=True)
714
+ f.add_argument("--screens", required=True)
715
+ f.add_argument("--json", action="store_true")
716
+ t = sub.add_parser("tokens", help="stages 5–6: Figma variable names against the CSS "
717
+ "custom properties of the pack's token file")
718
+ t.add_argument("--figma", required=True,
719
+ help="the variable export (get_variable_defs JSON, or the REST export)")
720
+ t.add_argument("--css", required=True, help="the pack's token file")
721
+ t.add_argument("--json", action="store_true")
722
+ a = p.parse_args(argv)
723
+ return {"record": cmd_record, "sheet": cmd_sheet, "lint": cmd_lint,
724
+ "filekeys": cmd_filekeys, "tokens": cmd_tokens}[a.cmd](a)
725
+
726
+
727
+ if __name__ == "__main__":
728
+ sys.exit(main(sys.argv[1:]))