task-pipeline-skill 1.88.1 → 1.89.1
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.
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier-product.md +3 -1
- package/plugins/task-pipeline/agents/verifier-visual.md +115 -0
- package/plugins/task-pipeline/agents/verifier.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +4 -4
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +22 -1
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +12 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +14 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +97 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +46 -5
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +28 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/conventions.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +15 -9
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +23 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +27 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +75 -13
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +45 -16
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +21 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/visual_gate.py +589 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +5 -2
- package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +223 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +10 -0
|
@@ -0,0 +1,589 @@
|
|
|
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
|
+
|
|
19
|
+
`<surface_class>` is the brief's stage-0 answer: flagship | product | internal | ad. It
|
|
20
|
+
selects what each check owes (`references/stages.md` → stage 0, *The surface class*).
|
|
21
|
+
|
|
22
|
+
**A check that could not run says NOT_RUN, and NOT_RUN is never PASS.** The director-record
|
|
23
|
+
validator and the project linter belong to sheleg-design; where that package is absent, or
|
|
24
|
+
older than the flag, the command says so in words. `record` still checks the required
|
|
25
|
+
headings itself — that floor needs nothing installed — and reports the validator NOT_RUN
|
|
26
|
+
beside its own verdict, so a reader can tell the floor from the full check.
|
|
27
|
+
|
|
28
|
+
Exit codes: 0 PASS · 1 FAIL · 2 usage, or an input that cannot be read · 3 NOT_RUN (the check
|
|
29
|
+
could not run, or every row it would judge is still NOT_RUN — never a pass).
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import itertools
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
|
|
43
|
+
SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
|
|
44
|
+
# Where the visual look is a GATE. `internal` gets the deterministic floor (the linter) and
|
|
45
|
+
# a recommended look; a sheet there is checked for honesty, its coverage only reported.
|
|
46
|
+
GATED = ("flagship", "product", "ad")
|
|
47
|
+
|
|
48
|
+
# The director record's fields — `## <Field>` headings — and which class owes which. The
|
|
49
|
+
# record's own contract lives with sheleg-design; this is the floor the pipeline can check
|
|
50
|
+
# without it. Absent for `internal`: no record is owed there.
|
|
51
|
+
FULL_RECORD = ("Brief", "Mode", "Taste", "References", "Cast", "Fork", "Rubric", "Critique",
|
|
52
|
+
"Markers", "Alignment", "Quality", "Signature", "Surfaces", "Haptics", "ADA",
|
|
53
|
+
"Open")
|
|
54
|
+
RECORD_FIELDS = {
|
|
55
|
+
"flagship": FULL_RECORD,
|
|
56
|
+
"product": ("Brief", "Mode", "References", "Markers", "Open"),
|
|
57
|
+
"ad": ("Brief", "Mode", "References", "Markers", "ADA", "Open"),
|
|
58
|
+
"internal": (),
|
|
59
|
+
}
|
|
60
|
+
MODES = ("new", "redesign", "update", "audit", "declined")
|
|
61
|
+
# A heading whose body is one of these was never filled in. `n/a` is a valid answer only
|
|
62
|
+
# where the contract allows a bare one (Haptics on a surface that is not native); anywhere
|
|
63
|
+
# else it owes its reason, and a reason makes the body longer than the bare token.
|
|
64
|
+
PLACEHOLDER = re.compile(r"^(?:tbd|tba|todo|\.\.\.|…|-|—|n/?a|none|\?|<[^>]*>)\.?$", re.I)
|
|
65
|
+
BARE_NA_OK = ("Haptics",)
|
|
66
|
+
|
|
67
|
+
DEFAULT_TOOL = "npx --no-install sheleg-design-skill"
|
|
68
|
+
|
|
69
|
+
AXES = ("viewport", "theme", "text", "locale")
|
|
70
|
+
CAPTURE = ("revision", "route", "motion", "captured_at", "source")
|
|
71
|
+
RUBRIC_TYPES = ("G", "J", "H")
|
|
72
|
+
RUBRIC_STATUS = ("PASS", "FAIL", "NOT_ASSESSED", "uncertain", "unresolved")
|
|
73
|
+
DIFF_STATUS = ("PASS", "FAIL", "NOT_RUN")
|
|
74
|
+
KINDS = ("look", "suite", "library")
|
|
75
|
+
STATUSES = ("PASS", "FAIL", "NOT_RUN", "BLOCKED")
|
|
76
|
+
DEFAULT_TEXT = ("default", "base", "normal", "100%", "1x", "m", "medium", "large-off")
|
|
77
|
+
RTL = re.compile(r"(?:^|[^a-z])(?:rtl|ar|he|fa|ur|yi)(?:$|[^a-z])", re.I)
|
|
78
|
+
NARROW_PX = 400
|
|
79
|
+
FIGMA_KEY = re.compile(r"figma\.com/(?:design|file|proto|board|make|slides)/([A-Za-z0-9]{10,})")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- browser claims (browser-claims/1) ----------------------------------------
|
|
83
|
+
# The rules `references/browser.md` states for every claim, look or not. Kept here so a host
|
|
84
|
+
# project can run them; this repository's `test/browser_claims_test.py` imports them.
|
|
85
|
+
|
|
86
|
+
def claim_problems(claim, artifact_root=None):
|
|
87
|
+
"""Every reason this claim must not be believed. Empty list = valid."""
|
|
88
|
+
out = []
|
|
89
|
+
cid = claim.get("id", "?")
|
|
90
|
+
for field in ("id", "req", "scenario", "state", "kind", "status"):
|
|
91
|
+
if not claim.get(field):
|
|
92
|
+
out.append(f"{cid}: {field} missing")
|
|
93
|
+
if claim.get("kind") not in KINDS:
|
|
94
|
+
out.append(f"{cid}: kind {claim.get('kind')!r} is not one of "
|
|
95
|
+
f"{sorted(KINDS)} — the look/suite/library split is the contract")
|
|
96
|
+
if claim.get("status") not in STATUSES:
|
|
97
|
+
out.append(f"{cid}: status {claim.get('status')!r} unknown")
|
|
98
|
+
if claim.get("status") == "NOT_RUN" and not claim.get("reason"):
|
|
99
|
+
out.append(f"{cid}: NOT_RUN carries its reason, always")
|
|
100
|
+
if claim.get("status") == "PASS" and claim.get("kind") == "look":
|
|
101
|
+
if not claim.get("artifact"):
|
|
102
|
+
out.append(f"{cid}: a visual PASS with no artifact is a functional "
|
|
103
|
+
"claim wearing a visual verdict")
|
|
104
|
+
else:
|
|
105
|
+
if artifact_root is not None and \
|
|
106
|
+
not os.path.isfile(os.path.join(artifact_root, claim["artifact"])):
|
|
107
|
+
out.append(f"{cid}: artifact {claim['artifact']} does not exist "
|
|
108
|
+
"— a named file that is not there proves nothing")
|
|
109
|
+
if claim.get("artifact_state") != claim.get("state"):
|
|
110
|
+
out.append(f"{cid}: artifact captured in state "
|
|
111
|
+
f"{claim.get('artifact_state')!r} cannot close a claim about "
|
|
112
|
+
f"{claim.get('state')!r} — the initial screenshot does not "
|
|
113
|
+
"close an opened/error state")
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def toggle_cycle_gaps(claims, component):
|
|
118
|
+
"""Which of the full cycle's states the component's PASSing look claims miss."""
|
|
119
|
+
need = {"initial", "opened", "closed-again"}
|
|
120
|
+
have = {c["state"] for c in claims
|
|
121
|
+
if c.get("component") == component and c.get("kind") == "look"
|
|
122
|
+
and c.get("status") == "PASS"}
|
|
123
|
+
return sorted(need - have)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def suite_pass_closes_no_look(claims):
|
|
127
|
+
"""Look claims that would be wrongly closed by a suite PASS: none may be."""
|
|
128
|
+
suite_green = any(c.get("kind") == "suite" and c.get("status") == "PASS"
|
|
129
|
+
for c in claims)
|
|
130
|
+
if not suite_green:
|
|
131
|
+
return []
|
|
132
|
+
return [c["id"] for c in claims
|
|
133
|
+
if c.get("kind") == "look" and c.get("status") == "PASS"
|
|
134
|
+
and not c.get("artifact")]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- the contact sheet (the visual look's rows) --------------------------------
|
|
138
|
+
|
|
139
|
+
def _filled(v):
|
|
140
|
+
return isinstance(v, str) and bool(v.strip())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _iso(v):
|
|
144
|
+
if not _filled(v):
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
datetime.fromisoformat(v.replace("Z", "+00:00"))
|
|
148
|
+
return True
|
|
149
|
+
except ValueError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_visual_row(c):
|
|
154
|
+
return c.get("kind") == "look" and "axes" in c
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _large_text(v):
|
|
158
|
+
return _filled(v) and v.strip().lower() not in DEFAULT_TEXT
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _narrow(v):
|
|
162
|
+
if not _filled(v):
|
|
163
|
+
return False
|
|
164
|
+
s = v.lower()
|
|
165
|
+
if any(w in s for w in ("narrow", "compact", " se", "iphone se", "small")):
|
|
166
|
+
return True
|
|
167
|
+
m = re.match(r"\s*(\d{2,5})\s*[x×]", s)
|
|
168
|
+
return bool(m) and int(m.group(1)) <= NARROW_PX
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def visual_row_problems(c, revision, approved):
|
|
172
|
+
"""Everything wrong with one state × axes row, beyond the rules every claim obeys."""
|
|
173
|
+
out = []
|
|
174
|
+
cid = c.get("id", "?")
|
|
175
|
+
axes = c.get("axes")
|
|
176
|
+
if not isinstance(axes, dict):
|
|
177
|
+
return [f"{cid}: `axes` must be an object of {', '.join(AXES)}"]
|
|
178
|
+
for a in AXES:
|
|
179
|
+
if not _filled(axes.get(a)):
|
|
180
|
+
out.append(f"{cid}: axes.{a} is missing — a frame whose {a} nobody recorded "
|
|
181
|
+
"cannot answer a claim about any one of them")
|
|
182
|
+
for k in ("figma_frame", "baseline"):
|
|
183
|
+
if c.get(k) is not None and not _filled(c.get(k)):
|
|
184
|
+
out.append(f"{cid}: `{k}` is a path or URL, or null")
|
|
185
|
+
diff = c.get("diff")
|
|
186
|
+
if diff is not None:
|
|
187
|
+
if not isinstance(diff, dict):
|
|
188
|
+
out.append(f"{cid}: `diff` is an object or null")
|
|
189
|
+
diff = None
|
|
190
|
+
else:
|
|
191
|
+
if diff.get("against") not in ("figma", "baseline"):
|
|
192
|
+
out.append(f"{cid}: diff.against is {diff.get('against')!r} — it compares "
|
|
193
|
+
"against `figma` or `baseline`")
|
|
194
|
+
if diff.get("status") not in DIFF_STATUS:
|
|
195
|
+
out.append(f"{cid}: diff.status {diff.get('status')!r} is not one of "
|
|
196
|
+
f"{', '.join(DIFF_STATUS)}")
|
|
197
|
+
if diff.get("status") == "NOT_RUN" and not _filled(diff.get("reason")):
|
|
198
|
+
out.append(f"{cid}: a diff that did not run carries its reason")
|
|
199
|
+
rubric = c.get("rubric", [])
|
|
200
|
+
if not isinstance(rubric, list):
|
|
201
|
+
out.append(f"{cid}: `rubric` must be a list")
|
|
202
|
+
rubric = []
|
|
203
|
+
g_fail = []
|
|
204
|
+
for i, r in enumerate(rubric):
|
|
205
|
+
if not isinstance(r, dict):
|
|
206
|
+
out.append(f"{cid}: rubric[{i}] is not an object")
|
|
207
|
+
continue
|
|
208
|
+
rid = r.get("id") or f"rubric[{i}]"
|
|
209
|
+
if not _filled(r.get("id")):
|
|
210
|
+
out.append(f"{cid}: rubric[{i}] has no id")
|
|
211
|
+
if r.get("type") not in RUBRIC_TYPES:
|
|
212
|
+
out.append(f"{cid}: {rid}.type {r.get('type')!r} is not G, J or H")
|
|
213
|
+
st = r.get("status")
|
|
214
|
+
if st not in RUBRIC_STATUS:
|
|
215
|
+
out.append(f"{cid}: {rid}.status {st!r} is not one of {', '.join(RUBRIC_STATUS)}")
|
|
216
|
+
if st in ("FAIL", "unresolved"):
|
|
217
|
+
t = r.get("triple")
|
|
218
|
+
if not (isinstance(t, dict) and all(_filled(t.get(k))
|
|
219
|
+
for k in ("region", "defect", "fix"))):
|
|
220
|
+
out.append(f"{cid}: {rid} is {st} without its triple — region, defect, fix. "
|
|
221
|
+
"A finding nobody can locate and act on is an impression")
|
|
222
|
+
if r.get("type") == "J" and st in ("PASS", "FAIL") and not _filled(r.get("calibration")):
|
|
223
|
+
out.append(f"{cid}: {rid} is a judge item reported {st} with no `calibration` — "
|
|
224
|
+
"until a labelled set exists and the judge's agreement with it is "
|
|
225
|
+
"measured, a J item is NOT_ASSESSED, not a verdict")
|
|
226
|
+
if r.get("type") == "H" and st == "PASS" and not approved:
|
|
227
|
+
out.append(f"{cid}: {rid} is a human item reported PASS on a sheet nobody "
|
|
228
|
+
"approved — only the person who reviewed the sheet can pass it")
|
|
229
|
+
if r.get("type") == "G" and st == "FAIL":
|
|
230
|
+
g_fail.append(rid)
|
|
231
|
+
if c.get("status") == "PASS":
|
|
232
|
+
cap = c.get("capture")
|
|
233
|
+
if not isinstance(cap, dict):
|
|
234
|
+
out.append(f"{cid}: a PASS frame carries its capture record "
|
|
235
|
+
f"({', '.join(CAPTURE)}) — a screenshot with no record is an image, "
|
|
236
|
+
"not evidence")
|
|
237
|
+
else:
|
|
238
|
+
for k in CAPTURE:
|
|
239
|
+
if not _filled(cap.get(k)):
|
|
240
|
+
out.append(f"{cid}: capture.{k} is missing")
|
|
241
|
+
if _filled(cap.get("captured_at")) and not _iso(cap["captured_at"]):
|
|
242
|
+
out.append(f"{cid}: capture.captured_at {cap['captured_at']!r} is not "
|
|
243
|
+
"ISO-8601, so its staleness cannot be computed")
|
|
244
|
+
if _filled(revision) and _filled(cap.get("revision")) and \
|
|
245
|
+
cap["revision"] != revision:
|
|
246
|
+
out.append(f"{cid}: captured at {cap['revision']!r} while the sheet is for "
|
|
247
|
+
f"{revision!r} — a stale frame proves the revision before")
|
|
248
|
+
if (c.get("figma_frame") or c.get("baseline")) and diff is None:
|
|
249
|
+
out.append(f"{cid}: a frame with a reference ({'figma_frame' if c.get('figma_frame') else 'baseline'}) "
|
|
250
|
+
"and no `diff` — the comparison is the point of having the reference")
|
|
251
|
+
if diff is not None and diff.get("status") == "FAIL":
|
|
252
|
+
out.append(f"{cid}: PASS over a failing diff against {diff.get('against')} — "
|
|
253
|
+
"either the frame drifted, or a person approves a new baseline; "
|
|
254
|
+
"neither is a PASS written by the run")
|
|
255
|
+
if g_fail:
|
|
256
|
+
out.append(f"{cid}: PASS while gate item(s) {', '.join(g_fail)} FAIL — a "
|
|
257
|
+
"deterministic gate item is a floor the judge never overrides")
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def coverage_gaps(rows, states):
|
|
262
|
+
"""Holes in the state × axes matrix. Pairwise over the axes, plus the mandatory pairs."""
|
|
263
|
+
gaps = []
|
|
264
|
+
seen_states = {c.get("state") for c in rows}
|
|
265
|
+
for s in states:
|
|
266
|
+
if s not in seen_states:
|
|
267
|
+
gaps.append(f"state {s!r} has no frame — a hole in the matrix")
|
|
268
|
+
ax = [c.get("axes") for c in rows if isinstance(c.get("axes"), dict)]
|
|
269
|
+
if not ax:
|
|
270
|
+
return gaps + ["no state × axes row at all"]
|
|
271
|
+
if not any(_large_text(a.get("text")) for a in ax):
|
|
272
|
+
gaps.append("the text axis never leaves its default — large text (200% / AX5) is a "
|
|
273
|
+
"mandatory column")
|
|
274
|
+
darks = [a for a in ax if "dark" in str(a.get("theme", "")).lower()]
|
|
275
|
+
if darks and any(_large_text(a.get("text")) for a in ax) and \
|
|
276
|
+
not any(_large_text(a.get("text")) for a in darks):
|
|
277
|
+
gaps.append("no frame is dark × large text — a mandatory pair")
|
|
278
|
+
rtls = [a for a in ax if RTL.search(str(a.get("locale", "")))]
|
|
279
|
+
if rtls and not any(_narrow(a.get("viewport")) for a in rtls):
|
|
280
|
+
gaps.append("no frame is RTL × narrow — a mandatory pair")
|
|
281
|
+
# Pairwise coverage: every value of one axis meets every value of every other axis in
|
|
282
|
+
# SOME frame. The full cross product is what makes a sheet unreadable; pairs are the
|
|
283
|
+
# smallest set that still catches a defect that appears only where one axis meets another.
|
|
284
|
+
values = {k: sorted({str(a.get(k)) for a in ax if _filled(a.get(k))}) for k in AXES}
|
|
285
|
+
for x, y in itertools.combinations(AXES, 2):
|
|
286
|
+
have = {(str(a.get(x)), str(a.get(y))) for a in ax}
|
|
287
|
+
for vx in values[x]:
|
|
288
|
+
for vy in values[y]:
|
|
289
|
+
if (vx, vy) not in have:
|
|
290
|
+
gaps.append(f"pairwise: no frame is {x}={vx} × {y}={vy}")
|
|
291
|
+
return gaps
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def sheet_report(doc, surface_class, artifact_root=None, states=(), require_approval=False):
|
|
295
|
+
"""(verdict, problems, notes) for a contact sheet at one surface class."""
|
|
296
|
+
problems, notes = [], []
|
|
297
|
+
if not isinstance(doc, dict) or doc.get("schema_version") != "browser-claims/1":
|
|
298
|
+
return "FAIL", ["not a browser-claims/1 document — the contact sheet extends that "
|
|
299
|
+
"file, there is no second schema"], notes
|
|
300
|
+
claims = doc.get("claims")
|
|
301
|
+
if not isinstance(claims, list):
|
|
302
|
+
return "FAIL", ["`claims` must be a list"], notes
|
|
303
|
+
for c in claims:
|
|
304
|
+
problems += claim_problems(c, artifact_root)
|
|
305
|
+
for cid in suite_pass_closes_no_look(claims):
|
|
306
|
+
problems.append(f"{cid}: a suite PASS cannot close a look claim with no artifact")
|
|
307
|
+
rows = [c for c in claims if is_visual_row(c)]
|
|
308
|
+
approved = _filled(doc.get("approved_by")) or _filled(doc.get("approved_at"))
|
|
309
|
+
if rows or surface_class in GATED:
|
|
310
|
+
for k in ("surface", "revision"):
|
|
311
|
+
if not _filled(doc.get(k)):
|
|
312
|
+
problems.append(f"the sheet names no `{k}` — a contact sheet is for one "
|
|
313
|
+
"surface at one revision")
|
|
314
|
+
rr = doc.get("review_rounds")
|
|
315
|
+
if not isinstance(rr, int) or isinstance(rr, bool) or rr < 0:
|
|
316
|
+
problems.append("`review_rounds` must be a whole number — it is how passes are "
|
|
317
|
+
"measured, and a sheet that cannot say how many returns it took "
|
|
318
|
+
"cannot show the count falling")
|
|
319
|
+
if approved and not (_filled(doc.get("approved_by")) and _iso(doc.get("approved_at"))):
|
|
320
|
+
problems.append("an approval names who (`approved_by`) and when (`approved_at`, "
|
|
321
|
+
"ISO-8601) — half an approval is not one")
|
|
322
|
+
if require_approval and not approved:
|
|
323
|
+
problems.append("the sheet is not approved — at acceptance the contact sheet is the "
|
|
324
|
+
"human's one pass, and an unapproved sheet is a pass that did not "
|
|
325
|
+
"happen")
|
|
326
|
+
if surface_class in GATED and not rows:
|
|
327
|
+
problems.append(f"a {surface_class} surface with no state × axes row — the visual "
|
|
328
|
+
"look did not run, and on this class it is a gate")
|
|
329
|
+
j_fail = set()
|
|
330
|
+
pending = []
|
|
331
|
+
for c in rows:
|
|
332
|
+
problems += visual_row_problems(c, doc.get("revision"), approved)
|
|
333
|
+
for r in c.get("rubric") or []:
|
|
334
|
+
if isinstance(r, dict):
|
|
335
|
+
if r.get("type") == "J" and r.get("status") == "FAIL":
|
|
336
|
+
j_fail.add(r.get("id"))
|
|
337
|
+
if r.get("status") in ("uncertain", "unresolved"):
|
|
338
|
+
pending.append(f"{c.get('id')}:{r.get('id')} {r.get('status')}")
|
|
339
|
+
if c.get("status") in ("NOT_RUN", "BLOCKED"):
|
|
340
|
+
pending.append(f"{c.get('id')} {c.get('status')}")
|
|
341
|
+
if surface_class == "flagship" and len(j_fail) > 2:
|
|
342
|
+
problems.append(f"{len(j_fail)} judge items FAIL ({', '.join(sorted(map(str, j_fail)))}) "
|
|
343
|
+
"— the flagship profile admits at most two, each with its triple")
|
|
344
|
+
if rows:
|
|
345
|
+
gaps = coverage_gaps(rows, states)
|
|
346
|
+
if surface_class == "flagship":
|
|
347
|
+
problems += gaps
|
|
348
|
+
elif surface_class in ("product", "ad"):
|
|
349
|
+
hard = [g for g in gaps if not g.startswith("pairwise:")]
|
|
350
|
+
problems += hard
|
|
351
|
+
notes += [g for g in gaps if g.startswith("pairwise:")]
|
|
352
|
+
else:
|
|
353
|
+
notes += gaps
|
|
354
|
+
if isinstance(doc.get("review_rounds"), int) and doc["review_rounds"] > 2 and not approved:
|
|
355
|
+
notes.append(f"review_rounds is {doc['review_rounds']}: past the budget of two — the "
|
|
356
|
+
"open items go to the person as `unresolved`, not into another round")
|
|
357
|
+
if pending:
|
|
358
|
+
notes.append("for the person on the sheet: " + "; ".join(pending))
|
|
359
|
+
if problems:
|
|
360
|
+
return "FAIL", problems, notes
|
|
361
|
+
# Honest on every class: a frame that did not run is NOT_RUN in the verdict as well as
|
|
362
|
+
# in its row. Whether that blocks the stage is the class's business — on `internal`
|
|
363
|
+
# the look is recommended, so the stage reads NOT_RUN and goes on, saying so.
|
|
364
|
+
if any(c.get("status") in ("NOT_RUN", "BLOCKED") for c in rows):
|
|
365
|
+
return "NOT_RUN", problems, notes
|
|
366
|
+
return "PASS", problems, notes
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- the director record --------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def _sections(text):
|
|
372
|
+
"""`## Heading` -> body, keyed by the heading's first word."""
|
|
373
|
+
out = {}
|
|
374
|
+
parts = re.split(r"^##[ \t]+(.+?)[ \t]*$", text, flags=re.M)
|
|
375
|
+
for i in range(1, len(parts), 2):
|
|
376
|
+
m = re.match(r"([A-Za-z]+)", parts[i].strip())
|
|
377
|
+
if m:
|
|
378
|
+
body = re.sub(r"<!--.*?-->", "", parts[i + 1], flags=re.S).strip()
|
|
379
|
+
out.setdefault(m.group(1).lower(), body)
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _declared_class(text):
|
|
384
|
+
head = text.split("\n## ", 1)[0]
|
|
385
|
+
m = re.search(r"surface_class\**\s*:\s*`?\s*([a-z]+)", head, re.I)
|
|
386
|
+
return m.group(1).lower() if m else None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def record_floor(text, surface_class):
|
|
390
|
+
"""(problems, declined) — the headings the class owes, present and filled."""
|
|
391
|
+
problems = []
|
|
392
|
+
sec = _sections(text)
|
|
393
|
+
declared = _declared_class(text)
|
|
394
|
+
if declared is None:
|
|
395
|
+
problems.append("the record declares no `surface_class:` in its header")
|
|
396
|
+
elif declared not in SURFACE_CLASSES:
|
|
397
|
+
problems.append(f"the record's surface_class {declared!r} is not one of "
|
|
398
|
+
f"{', '.join(SURFACE_CLASSES)}")
|
|
399
|
+
elif declared != surface_class:
|
|
400
|
+
problems.append(f"the record says surface_class {declared!r} and the brief says "
|
|
401
|
+
f"{surface_class!r} — one of them is stale, and the gate profile "
|
|
402
|
+
"depends on which")
|
|
403
|
+
mode_body = sec.get("mode", "")
|
|
404
|
+
mode = (re.match(r"[`*_\s]*([A-Za-z]+)", mode_body) or [None, ""])[1].lower()
|
|
405
|
+
if "mode" not in sec or not mode:
|
|
406
|
+
problems.append("## Mode is missing or empty")
|
|
407
|
+
return problems, False
|
|
408
|
+
if mode not in MODES:
|
|
409
|
+
problems.append(f"## Mode is {mode!r} — one of {', '.join(MODES)}")
|
|
410
|
+
if mode == "declined":
|
|
411
|
+
reason = re.sub(r"^[`*_\s]*declined[`*_\s:—-]*", "", mode_body, flags=re.I).strip()
|
|
412
|
+
if len(reason.split()) < 3:
|
|
413
|
+
problems.append("## Mode: declined carries no reason — a refusal passes the gate "
|
|
414
|
+
"only when it says why, in words a reader can disagree with")
|
|
415
|
+
return problems, True
|
|
416
|
+
for field in RECORD_FIELDS[surface_class]:
|
|
417
|
+
body = sec.get(field.lower())
|
|
418
|
+
if body is None:
|
|
419
|
+
problems.append(f"## {field} is missing — the {surface_class} profile owes it")
|
|
420
|
+
elif not body or (PLACEHOLDER.match(body) and
|
|
421
|
+
not (field in BARE_NA_OK and re.match(r"^n/?a\.?$", body, re.I))):
|
|
422
|
+
problems.append(f"## {field} is empty ({body!r}) — a heading with nothing under "
|
|
423
|
+
"it is the fact of the track, not its trace")
|
|
424
|
+
return problems, False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _run_tool(cmd, flag, args, timeout=180):
|
|
428
|
+
"""("PASS"|"FAIL"|"NOT_RUN", detail). The tool is sheleg-design's CLI."""
|
|
429
|
+
if cmd.strip().lower() == "none":
|
|
430
|
+
return "NOT_RUN", "disabled with `none`"
|
|
431
|
+
argv = shlex.split(cmd)
|
|
432
|
+
try:
|
|
433
|
+
h = subprocess.run(argv + ["--help"], capture_output=True, text=True, timeout=timeout)
|
|
434
|
+
except FileNotFoundError:
|
|
435
|
+
return "NOT_RUN", f"`{argv[0]}` is not installed"
|
|
436
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
437
|
+
return "NOT_RUN", f"`{cmd} --help` could not run ({type(e).__name__})"
|
|
438
|
+
if flag not in (h.stdout + h.stderr):
|
|
439
|
+
return "NOT_RUN", (f"`{cmd}` does not offer {flag} — sheleg-design is not installed, "
|
|
440
|
+
"or older than that flag")
|
|
441
|
+
try:
|
|
442
|
+
r = subprocess.run(argv + [flag] + args, capture_output=True, text=True,
|
|
443
|
+
timeout=timeout)
|
|
444
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
445
|
+
return "NOT_RUN", f"`{cmd} {flag}` could not run ({type(e).__name__})"
|
|
446
|
+
tail = (r.stdout + r.stderr).strip()[-1500:]
|
|
447
|
+
if r.returncode == 0:
|
|
448
|
+
return "PASS", tail
|
|
449
|
+
if r.returncode == 1:
|
|
450
|
+
return "FAIL", tail
|
|
451
|
+
return "NOT_RUN", f"`{cmd} {flag}` exited {r.returncode}: {tail[-300:]}"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_record(a):
|
|
455
|
+
if a.surface_class == "internal":
|
|
456
|
+
return _emit(a, "record", "PASS", [], ["no director record is owed by an internal "
|
|
457
|
+
"surface; the floor there is the linter"])
|
|
458
|
+
try:
|
|
459
|
+
text = open(a.path, encoding="utf-8").read()
|
|
460
|
+
except OSError:
|
|
461
|
+
return _emit(a, "record", "FAIL", [f"{a.path} does not exist — the VISUAL track "
|
|
462
|
+
"leaves a director record, or a recorded refusal"],
|
|
463
|
+
[])
|
|
464
|
+
problems, declined = record_floor(text, a.surface_class)
|
|
465
|
+
status, detail = _run_tool(a.validator, "--check-record", [a.path])
|
|
466
|
+
notes = [f"validator: {status}" + (f" — {detail}" if status != "PASS" else "")]
|
|
467
|
+
if declined and not problems:
|
|
468
|
+
notes.insert(0, "the visual track was declined, with its reason — a recorded "
|
|
469
|
+
"refusal passes")
|
|
470
|
+
floor = "FAIL" if problems else "PASS"
|
|
471
|
+
if status == "FAIL":
|
|
472
|
+
problems.append("the record validator refused it: " + detail)
|
|
473
|
+
verdict = "FAIL" if problems else "PASS"
|
|
474
|
+
return _emit(a, "record", verdict, problems, notes,
|
|
475
|
+
extra={"floor": floor, "validator": status})
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def cmd_sheet(a):
|
|
479
|
+
try:
|
|
480
|
+
doc = json.load(open(a.path, encoding="utf-8"))
|
|
481
|
+
except (OSError, ValueError) as e:
|
|
482
|
+
print(f"visual_gate: cannot read {a.path} ({type(e).__name__})", file=sys.stderr)
|
|
483
|
+
return 2
|
|
484
|
+
states = [s for s in (a.states or "").split(",") if s.strip()]
|
|
485
|
+
verdict, problems, notes = sheet_report(doc, a.surface_class, a.artifact_root, states,
|
|
486
|
+
a.require_approval)
|
|
487
|
+
return _emit(a, "sheet", verdict, problems, notes)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def cmd_lint(a):
|
|
491
|
+
status, detail = _run_tool(a.linter, "--lint", [a.dir, "--json"])
|
|
492
|
+
if status == "NOT_RUN":
|
|
493
|
+
return _emit(a, "lint", "NOT_RUN", [], [detail])
|
|
494
|
+
counts = {}
|
|
495
|
+
try:
|
|
496
|
+
for f in json.loads(detail[detail.index("["):]):
|
|
497
|
+
counts[f.get("severity", "?")] = counts.get(f.get("severity", "?"), 0) + 1
|
|
498
|
+
except (ValueError, AttributeError, TypeError):
|
|
499
|
+
pass
|
|
500
|
+
summary = ", ".join(f"{k} {v}" for k, v in sorted(counts.items())) or "no findings parsed"
|
|
501
|
+
if status == "FAIL":
|
|
502
|
+
return _emit(a, "lint", "FAIL", [f"the project linter exited 1 ({summary})"], [])
|
|
503
|
+
return _emit(a, "lint", "PASS", [], [f"findings by severity: {summary}"])
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _section(text, title):
|
|
507
|
+
m = re.search(r"^(#{1,6})[ \t]+[^\n]*" + re.escape(title) + r"[^\n]*$", text, re.M | re.I)
|
|
508
|
+
if not m:
|
|
509
|
+
return None
|
|
510
|
+
level = len(m.group(1))
|
|
511
|
+
rest = text[m.end():]
|
|
512
|
+
nxt = re.search(r"^#{1,%d}[ \t]" % level, rest, re.M)
|
|
513
|
+
return rest[:nxt.start()] if nxt else rest
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def cmd_filekeys(a):
|
|
517
|
+
try:
|
|
518
|
+
rec = open(a.record, encoding="utf-8").read()
|
|
519
|
+
scr = open(a.screens, encoding="utf-8").read()
|
|
520
|
+
except OSError as e:
|
|
521
|
+
print(f"visual_gate: cannot read an input ({e.filename})", file=sys.stderr)
|
|
522
|
+
return 2
|
|
523
|
+
body = _section(rec, "Design tooling")
|
|
524
|
+
recorded = sorted(set(FIGMA_KEY.findall(body if body is not None else rec)))
|
|
525
|
+
used = sorted(set(FIGMA_KEY.findall(scr)))
|
|
526
|
+
problems = []
|
|
527
|
+
if used and not recorded:
|
|
528
|
+
problems.append("frames link to Figma and the record names no file — the "
|
|
529
|
+
"destination was never decided, or was decided somewhere else")
|
|
530
|
+
for k in used:
|
|
531
|
+
if recorded and k not in recorded:
|
|
532
|
+
problems.append(f"frame file key {k} is not a recorded file — a second file "
|
|
533
|
+
"nobody will open, holding real work")
|
|
534
|
+
notes = [f"recorded: {', '.join(recorded) or 'none'}",
|
|
535
|
+
f"linked from screens: {', '.join(used) or 'none'}"]
|
|
536
|
+
return _emit(a, "filekeys", "FAIL" if problems else "PASS", problems, notes)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
EXIT = {"PASS": 0, "FAIL": 1, "NOT_RUN": 3}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _emit(a, check, verdict, problems, notes, extra=None):
|
|
543
|
+
if getattr(a, "json", False):
|
|
544
|
+
out = {"check": check, "verdict": verdict, "problems": problems, "notes": notes}
|
|
545
|
+
out.update(extra or {})
|
|
546
|
+
print(json.dumps(out, ensure_ascii=False))
|
|
547
|
+
else:
|
|
548
|
+
cls = getattr(a, "surface_class", None)
|
|
549
|
+
print(f"{check}: {verdict}" + (f" — class {cls}" if cls else ""))
|
|
550
|
+
for p in problems:
|
|
551
|
+
print(f" ✗ {p}")
|
|
552
|
+
for n in notes:
|
|
553
|
+
print(f" · {n}")
|
|
554
|
+
return EXIT[verdict]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def main(argv):
|
|
558
|
+
p = argparse.ArgumentParser(prog="visual_gate.py",
|
|
559
|
+
description="The visual half of the gates; NOT_RUN is never PASS.")
|
|
560
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
561
|
+
r = sub.add_parser("record", help="stage 3: the director record carries its fields")
|
|
562
|
+
r.add_argument("path")
|
|
563
|
+
r.add_argument("--class", dest="surface_class", required=True, choices=SURFACE_CLASSES)
|
|
564
|
+
r.add_argument("--validator", default=DEFAULT_TOOL,
|
|
565
|
+
help=f"the record validator's command (default `{DEFAULT_TOOL}`), or none")
|
|
566
|
+
r.add_argument("--json", action="store_true")
|
|
567
|
+
s = sub.add_parser("sheet", help="stages 6 and 10: the contact sheet")
|
|
568
|
+
s.add_argument("path")
|
|
569
|
+
s.add_argument("--class", dest="surface_class", required=True, choices=SURFACE_CLASSES)
|
|
570
|
+
s.add_argument("--artifact-root", help="where the frames live, so a named file is checked")
|
|
571
|
+
s.add_argument("--states", help="comma-separated SCR states the matrix must cover")
|
|
572
|
+
s.add_argument("--require-approval", action="store_true",
|
|
573
|
+
help="stage 10: the sheet carries the person's approval")
|
|
574
|
+
s.add_argument("--json", action="store_true")
|
|
575
|
+
li = sub.add_parser("lint", help="stages 5–6: sheleg-design's project linter")
|
|
576
|
+
li.add_argument("dir")
|
|
577
|
+
li.add_argument("--linter", default=DEFAULT_TOOL)
|
|
578
|
+
li.add_argument("--json", action="store_true")
|
|
579
|
+
f = sub.add_parser("filekeys", help="stage 3: frame links stay in the recorded files")
|
|
580
|
+
f.add_argument("--record", required=True)
|
|
581
|
+
f.add_argument("--screens", required=True)
|
|
582
|
+
f.add_argument("--json", action="store_true")
|
|
583
|
+
a = p.parse_args(argv)
|
|
584
|
+
return {"record": cmd_record, "sheet": cmd_sheet, "lint": cmd_lint,
|
|
585
|
+
"filekeys": cmd_filekeys}[a.cmd](a)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
if __name__ == "__main__":
|
|
589
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
- **Task (one line):** <what the operator asked for, restated>
|
|
9
9
|
- **UI verdict:** yes / no — does this touch a user-facing surface (web/mobile/CLI/TUI)?
|
|
10
10
|
If yes, the stage-3 super-ux UX track is armed.
|
|
11
|
+
- **surface_class:** flagship / product / internal / ad — UI tasks only. Selects the
|
|
12
|
+
visual gate profile: the director record's fields at stage 3, whether the visual half
|
|
13
|
+
of the look gates stage 6, and whether stage 10 needs the approved contact sheet.
|
|
11
14
|
|
|
12
15
|
## Contents
|
|
13
16
|
|
|
@@ -129,9 +132,9 @@ is not neutral — it is a scheduled interruption.
|
|
|
129
132
|
| 0 Docs regime | Where settled things live (register or ADR set — one home, never both); who may write it; lease mechanism present, or is this run `ungated`? Gate command + ratchet floors; may this run raise a floor? | … |
|
|
130
133
|
| 1 Docs | External libs/APIs/SDKs in play; any context7 can't resolve → where their docs live | … |
|
|
131
134
|
| 2 Decompose | Platform (several capabilities/surfaces) or one module? If platform — deploy cadence: per module, or once at the end | … |
|
|
132
|
-
| 2–3 Spec | UI verdict (arms super-ux)
|
|
135
|
+
| 2–3 Spec | UI verdict (arms super-ux) and `surface_class`; scenario-tracing waiver, if any | … |
|
|
133
136
|
| 3 Design surface | UI only: Figma on or text-only (check `docs/ux/foundation.md` → Design tooling first); Figma MCP connected? **If not — ship text-only, or stop and connect it?** | … (super-ux never blocks on a missing MCP, so an unanswered row here ships the feature without mockups) |
|
|
134
|
-
| 3 Design file | Figma on only: **which team/org + which file** — existing URL, or "create one in team `<name>`" **with creation authorized**. Canonical record: `docs/ux/foundation.md` → Design tooling | … (team: `<name>` ·
|
|
137
|
+
| 3 Design file | Figma on only: **which team/org + which file per surface** (App, Web, ASO) — existing URL, or "create one in team `<name>`" **with creation authorized**. Canonical record: `docs/ux/foundation.md` → Design tooling | … (team: `<name>` · App: `<url>` · Web: `<url>` · ASO: `<url>` \| `create in <team>, authorized` — never create when a recorded file resolves) |
|
|
135
138
|
| 4–5 Dev | Base branch; worktree/branch policy; is `main` off-limits; commit convention; task tracker | … |
|
|
136
139
|
| 5 Integration | How the branch lands — direct merge, PR (who approves), or "leave it, I'll merge"; is parallel fan-out (one worktree per implementer) wanted? | … |
|
|
137
140
|
| 6 Tests | Test command; what "green" means; known-red baseline; coverage expectation | … |
|