super-ux 0.45.0 → 0.46.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.
- package/CHANGELOG.md +60 -0
- package/README.md +2 -2
- package/cursor/rules/brand-voice.mdc +1 -1
- package/cursor/rules/copywriting.mdc +1 -1
- package/cursor/rules/super-ux.mdc +52 -27
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +145 -8
- package/plugins/super-ux/scripts/ux_lint.py +181 -21
- package/templates/claude-rule.md +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.46.0 — 2026-08-20
|
|
4
|
+
|
|
5
|
+
**The check that decides whether a public number is real accepted any substring of the
|
|
6
|
+
others.** `B030` built its corpus as `" ".join(values)` and then asked whether the figure
|
|
7
|
+
was *in* that string with the spaces removed — so the eight facts became the character
|
|
8
|
+
sequence `7158215243770+`, and every substring of it was "sourced". Watched: appending
|
|
9
|
+
*"super-ux is used by 58 teams and ships 1582 checks."* to `README.md` — a declared public
|
|
10
|
+
surface — left `docs/brand/lint.py` printing `brand pack is clean`, exit 0. This is the one
|
|
11
|
+
check the brand hard rule exists to enforce. Values are normalised and compared one at a
|
|
12
|
+
time now, against a set.
|
|
13
|
+
|
|
14
|
+
Twelve more, each reproduced before it was fixed and each new guard watched failing:
|
|
15
|
+
|
|
16
|
+
- **`facts.md` had no duplicate-key rule**, and a second `| skills shipped | 99 |` row not
|
|
17
|
+
only stood beside the first but widened the sourced corpus, making `99` quotable in
|
|
18
|
+
public copy. `B033`.
|
|
19
|
+
- **"every row names a command that recomputes it" ran nothing.** Two rows named no
|
|
20
|
+
command, and `repo validator checks` read 3500 against a measured 3539. Each `Source` is
|
|
21
|
+
executed now; the self-referential row runs the validator as a count-neutral child, and
|
|
22
|
+
the row that cannot be recomputed here is marked and **disclosed** rather than skipped.
|
|
23
|
+
- **`Coverage:` line ranges were never resolved** — `bin/super-ux.js:99000-99999` in a
|
|
24
|
+
396-line file passed. `U071`/`U072`, and the fourteen live citations that had drifted are
|
|
25
|
+
rewritten.
|
|
26
|
+
- **The job layer was invisible to every rule.** `ids()` required `### PREFIX-NN: <name>`
|
|
27
|
+
and this repo's jobs carry no name, so two identical `JTBD-01` ids passed. Entries are
|
|
28
|
+
matched by id now (`U073`, `U074`), and the three jobs carry the five fields the contract
|
|
29
|
+
requires.
|
|
30
|
+
- **Nine `Status:` values sat on layers no enum covered**, and `voice.md` read `approved`
|
|
31
|
+
where the contract declares `draft|validated` — it worked only because the linter
|
|
32
|
+
compared `== "draft"`. Enums are declared per layer and parity-checked; the two layers
|
|
33
|
+
that legitimately have no status say so (`U075`, `B034`).
|
|
34
|
+
- **The Cursor umbrella shipped a three-release-old system**: four workflows named of eight
|
|
35
|
+
rules shipped, with `vision`, `brand-voice` and `copywriting` at zero mentions. It must
|
|
36
|
+
name every shipped skill now.
|
|
37
|
+
- **The `AT-` marker set had ids and no gate**: deleting a whole section while leaving its
|
|
38
|
+
table row kept the validator at `OK (3539 checks)`, exit 0. Covered both directions.
|
|
39
|
+
- **The board reused ids** — `B-011`–`B-013` in two tables with different content — and
|
|
40
|
+
nothing read the board at all.
|
|
41
|
+
- **The hard rule had four payload homes and `HARD_RULES` paired two** (320/2102 words and
|
|
42
|
+
chars against 349/2284 and 348/2204). Anchor parity now, derived from the template, with
|
|
43
|
+
the one legitimate exemption as data.
|
|
44
|
+
- **The front-matter budget measured the wrong thing**: the whole block against 1024
|
|
45
|
+
instead of `name` ≤ 64 and `description` ≤ 1024 separately. No live violation; both
|
|
46
|
+
directions planted.
|
|
47
|
+
- **One pack, two opposite exit policies.** `brand_lint.py` returned 1 on warnings-only
|
|
48
|
+
while `ux_lint.py` returned 0 unless `--strict`, so thirteen of thirty-seven codes turned
|
|
49
|
+
`npm test` red while printing `0 error(s)`. `--strict` added, warnings non-blocking by
|
|
50
|
+
default, and the three homes of that rule reworded in the same change.
|
|
51
|
+
- **The literal extractor could not cross a newline**, so `usage()` — the most-read UI
|
|
52
|
+
surface the pack has — was invisible to the string registry. Sixteen findings came out of
|
|
53
|
+
it on the first run.
|
|
54
|
+
|
|
55
|
+
Checks: `validate.py` 3539 → **3667**, `brand_lint_test.py` 77 → **89**, `ux_lint_test.py`
|
|
56
|
+
106 → **133**, with `test/floors.json` raised to the measured per-block delta.
|
|
57
|
+
|
|
58
|
+
Two findings came out of planting rather than reading: per-line template extraction
|
|
59
|
+
produced six mid-sentence fragments no registry row can hold, and a reworded README line
|
|
60
|
+
tripped the pack's own AI-tell check.
|
|
61
|
+
|
|
62
|
+
|
|
3
63
|
## 0.45.0 — 2026-08-19
|
|
4
64
|
|
|
5
65
|
**The requirement layer could not see a requirement with no observable.** The contract said
|
package/README.md
CHANGED
|
@@ -129,11 +129,11 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
|
|
|
129
129
|
python3 docs/brand/lint.py
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
39 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
|
|
133
133
|
with no sourced fact, a field over its limit with the locale coefficient
|
|
134
134
|
applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
|
|
135
135
|
a rhetorical dash, a title that ends in a full stop, a locale that lags
|
|
136
|
-
without saying so. Exit 0 clean, 1 warnings,
|
|
136
|
+
without saying so. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
|
|
137
137
|
|
|
138
138
|
Clean means *checkable*, not *good*: tone drift, unproven claims and a voice
|
|
139
139
|
that has overshot its own failure mode are judged by `/ux-audit copy`.
|
|
@@ -33,5 +33,5 @@ this layer removes.
|
|
|
33
33
|
python3 docs/brand/lint.py
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Exit 0 clean
|
|
36
|
+
Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`. Clean means checkable, not good — the
|
|
37
37
|
judgement half is `/ux-audit copy`.
|
|
@@ -60,6 +60,6 @@ lags the primary says so rather than pretending to be current.
|
|
|
60
60
|
python3 docs/brand/lint.py
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
Exit 0 clean
|
|
63
|
+
Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`. Clean means *checkable*, not *good* —
|
|
64
64
|
tone drift, unproven claims and a voice that overshot its declared failure
|
|
65
65
|
mode are judged by `/ux-audit copy`.
|
|
@@ -6,36 +6,61 @@ alwaysApply: true
|
|
|
6
6
|
# UX scenarios — hard rule (super-ux)
|
|
7
7
|
|
|
8
8
|
- `docs/ux/scenarios.md` is the source of truth for all user-facing behavior;
|
|
9
|
-
`docs/ux/foundation.md` (personas, JTBD, journeys,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`docs/ux/scenarios.md` in the same change (add/adjust scenarios, statuses,
|
|
23
|
-
coverage). New user-facing behavior with no scenario is a blocker, not a
|
|
24
|
-
warning.
|
|
9
|
+
`docs/ux/foundation.md` (personas, JTBD, journeys, stories) and
|
|
10
|
+
`docs/ux/flows.md` (user flows) are the WHY and HOW layers scenarios trace
|
|
11
|
+
to. Above all three sits `docs/ux/vision.md` — what the product is and
|
|
12
|
+
refuses to become — when the project has written one.
|
|
13
|
+
- Any change that touches user-facing behavior or interface MUST update, in
|
|
14
|
+
the same change: `docs/ux/scenarios.md`; affected flows; the affected
|
|
15
|
+
screens in `docs/ux/screens.md` (the UI map — states, elements, coverage);
|
|
16
|
+
and, when Figma is enabled, the Figma frame(s) plus their links in
|
|
17
|
+
`screens.md`. A screen whose code diverges from its record, or a stale
|
|
18
|
+
Figma link, is drift — the exact thing this system prevents.
|
|
19
|
+
- A `Coverage:` citation is a claim about code and is resolved as one: the
|
|
20
|
+
path must exist and the line range must be inside the file. Seven of this
|
|
21
|
+
pack's own citations were pre-shift ranges nothing re-resolved.
|
|
25
22
|
- Any new feature or project STARTS with the chain: which job does it serve,
|
|
26
23
|
which journey stage, which story — then flows and scenarios, validated
|
|
27
|
-
against the existing base
|
|
28
|
-
|
|
29
|
-
- Do NOT write interface code until the UX workflow is done first
|
|
24
|
+
against the existing base, approved. An idea serving no job is challenged,
|
|
25
|
+
not silently accepted.
|
|
26
|
+
- **Do NOT write interface code until the UX workflow is done first:** the
|
|
30
27
|
foundation → flows → screens → scenarios chain is designed and approved,
|
|
31
28
|
and — when Figma is enabled (default) — the UI is mocked up in Figma with
|
|
32
29
|
every screen linked to its frame. Building UI before this is the exact
|
|
33
30
|
mistake super-ux exists to prevent.
|
|
34
|
-
- Visual identity is
|
|
35
|
-
Design system and
|
|
36
|
-
the **sheleg-design** companion skill
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
- Visual identity is ONE locked style pack, recorded in `docs/ux/screens.md`
|
|
32
|
+
→ Design system and obeyed by every Figma frame and every built screen —
|
|
33
|
+
picked with the **sheleg-design** companion skill when the project has no
|
|
34
|
+
design system of its own (recommended, not required). Inventing a palette,
|
|
35
|
+
type pairing, or motion per screen is visual drift.
|
|
36
|
+
- After any UX change and before calling the work done, run the linter
|
|
37
|
+
`python3 docs/ux/lint.py` — it must pass (errors are drift/broken
|
|
38
|
+
structure; wire it into CI/pre-commit). Run `python3 docs/ux/doctor.py`
|
|
39
|
+
when picking this base up after a while: the linter checks the chain
|
|
40
|
+
against itself and stays silent when the whole base is written to an old
|
|
41
|
+
contract, and the doctor is what notices.
|
|
42
|
+
- Text a user reads is governed too. `docs/brand/` holds the voice, the
|
|
43
|
+
terminology, the string registry and `facts.md` — the only source of any
|
|
44
|
+
public figure — and `python3 docs/brand/lint.py` must pass beside the UX
|
|
45
|
+
linter. Never quote a number that has no row in `facts.md`.
|
|
46
|
+
|
|
47
|
+
### Which rule owns which question
|
|
48
|
+
|
|
49
|
+
Eight rules ship in `.cursor/rules/`, and this one routes to the other seven.
|
|
50
|
+
A rule nobody names is a rule nobody loads.
|
|
51
|
+
|
|
52
|
+
| Rule | Owns |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `vision` | what the product is and refuses to become — the layer above the chain |
|
|
55
|
+
| `ux-foundation` | the WHY layer: personas, jobs (`JTBD-NN`, each with a success metric), journeys, stories |
|
|
56
|
+
| `ux-flows` | flows, Figma mockups and the locked style pack |
|
|
57
|
+
| `ux-scenarios` | the scenario base and its maintenance |
|
|
58
|
+
| `ux-audit` | evidence-backed audits: `file:line` or it did not happen |
|
|
59
|
+
| `brand-voice` | how the product sounds — voice, terminology, facts, per-surface register |
|
|
60
|
+
| `copywriting` | the text itself: interface strings, landing, pricing, changelog, posts |
|
|
61
|
+
|
|
62
|
+
The chain runs `vision` → `ux-foundation` → `ux-flows` → `ux-scenarios`, with
|
|
63
|
+
`ux-audit` checking the result against the code. `brand-voice` and
|
|
64
|
+
`copywriting` read the same scenarios and do not wait on each other — but
|
|
65
|
+
where both land on one screen, compare them before shipping: a label the
|
|
66
|
+
layout has no room for is right in each and wrong on the screen.
|
package/package.json
CHANGED
|
@@ -18,8 +18,16 @@ Read-only by default. `--fix` applies only the changes that cannot be wrong.
|
|
|
18
18
|
python3 brand_lint.py [path] --fix # apply the safe subset
|
|
19
19
|
python3 brand_lint.py [path] --brief # one line, for sweeping projects
|
|
20
20
|
python3 brand_lint.py [path] --json # machine-readable findings
|
|
21
|
+
python3 brand_lint.py [path] --strict # warnings block too
|
|
21
22
|
|
|
22
|
-
Exit codes: 0 clean
|
|
23
|
+
Exit codes: 0 clean or warnings only, 1 warnings under `--strict`, 2 any error.
|
|
24
|
+
|
|
25
|
+
One pack, one policy: `ux_lint.py` has always blocked on errors and needed
|
|
26
|
+
`--strict` before a warning could fail a build, and this file returned 1 for
|
|
27
|
+
warnings alone -- so 13 of the 37 codes turned `npm test` red while printing
|
|
28
|
+
`0 error(s), 1 warning(s)`. A gate whose two halves disagree about what a
|
|
29
|
+
warning means gets the noisier half switched off, and the half switched off
|
|
30
|
+
here is the one that guards public figures.
|
|
23
31
|
"""
|
|
24
32
|
|
|
25
33
|
from __future__ import annotations
|
|
@@ -34,6 +42,20 @@ from pathlib import Path
|
|
|
34
42
|
CONTRACT = "brand-contract"
|
|
35
43
|
CONTRACT_VERSION = "v1"
|
|
36
44
|
|
|
45
|
+
# The one enum this file matches on, kept as a module literal so
|
|
46
|
+
# `validate_status_enums_match_contract` can read it out of the source and
|
|
47
|
+
# compare it against `brand-contract.md`'s declaration -- the mechanism the UX
|
|
48
|
+
# linter has carried since SU-02, applied to the layer that was left out of it.
|
|
49
|
+
#
|
|
50
|
+
# The defect it closes was live in this repository's own pack and worked by
|
|
51
|
+
# accident: `brand-contract.md` declares `draft | validated`, `docs/brand/voice.md`
|
|
52
|
+
# said `Status: approved`, and nothing caught it because every read here asked
|
|
53
|
+
# `== "draft"` or `!= "draft"`. So `approved` behaved like `validated` today and
|
|
54
|
+
# would have read as NOT validated the first time any check tested for the value
|
|
55
|
+
# instead of against `draft`. Same class as the screens enum SU-02 closed: an
|
|
56
|
+
# out-of-enum value that is neither refused nor accepted, and invisible.
|
|
57
|
+
VOICE_STATUSES = ("draft", "validated")
|
|
58
|
+
|
|
37
59
|
SEVERITY_ERROR = "error"
|
|
38
60
|
SEVERITY_WARN = "warn"
|
|
39
61
|
|
|
@@ -184,6 +206,18 @@ def check_contract(brand_dir: Path) -> list[Finding]:
|
|
|
184
206
|
|
|
185
207
|
voice = read(brand_dir / "voice.md") or ""
|
|
186
208
|
status = header_field(voice, "Status")
|
|
209
|
+
# B034 -- an out-of-enum `Status` is refused rather than read as no status.
|
|
210
|
+
if status is not None and not unfilled(status) \
|
|
211
|
+
and status not in VOICE_STATUSES:
|
|
212
|
+
findings.append(Finding(
|
|
213
|
+
"B034", SEVERITY_ERROR, "voice.md", 1,
|
|
214
|
+
f"`Status: {status}` is not one of "
|
|
215
|
+
f"{' | '.join(VOICE_STATUSES)} -- every read in this file asks "
|
|
216
|
+
f"whether the status is `draft`, so an unrecognised value behaves "
|
|
217
|
+
f"like `validated` today and reads as not-validated the moment a "
|
|
218
|
+
f"check tests for the value instead of against `draft`",
|
|
219
|
+
))
|
|
220
|
+
|
|
187
221
|
strings = read(brand_dir / "strings.md") or ""
|
|
188
222
|
agreed = [r for r in table_rows(strings) if r and r[-1] == "agreed"]
|
|
189
223
|
if status == "draft" and agreed:
|
|
@@ -363,6 +397,41 @@ WEAK_LABELS = {
|
|
|
363
397
|
|
|
364
398
|
LITERAL_RE = re.compile(r"""(["'`])((?:(?!\1)[^\n]){3,200})\1""")
|
|
365
399
|
|
|
400
|
+
# A `'` or `"` literal cannot cross a newline in JS or TS, so `[^\n]` above is
|
|
401
|
+
# right for those. A TEMPLATE literal can, and `usage()` in this pack's own
|
|
402
|
+
# installer is twenty lines of exactly one -- the most-read UI surface the pack
|
|
403
|
+
# has, invisible to the registry since B022 existed. `strings.md` recorded it as
|
|
404
|
+
# needing "a per-language extractor"; it needed a second pattern.
|
|
405
|
+
#
|
|
406
|
+
# A multi-line template is split into PARAGRAPHS, not lines and not left whole.
|
|
407
|
+
# Whole is useless: one 1800-character blob is a document, and nobody agrees or
|
|
408
|
+
# retires a document. Per line is worse than it looks -- it was tried first, and
|
|
409
|
+
# it turned wrapped prose into six findings no registry row could sensibly hold
|
|
410
|
+
# ("docs/ux skeleton, the docs/brand pack, and all three linters" is half a
|
|
411
|
+
# sentence). A blank-line-separated block is the unit a reader actually sees: a
|
|
412
|
+
# title, a usage list, an explanation. The 4000-char ceiling guards against an
|
|
413
|
+
# unbalanced backtick swallowing a file; it is not a claim about copy.
|
|
414
|
+
TEMPLATE_RE = re.compile(r"`((?:[^`\\]|\\.){3,4000})`", re.DOTALL)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def code_literals(text: str) -> list[str]:
|
|
418
|
+
"""Every string literal in source, one entry per unit a reader would see.
|
|
419
|
+
|
|
420
|
+
Comments must already be stripped by the caller -- `_strip_comments` leaves
|
|
421
|
+
literals untouched, which is what makes both patterns below safe to run.
|
|
422
|
+
"""
|
|
423
|
+
out: list[str] = [lit for _quote, lit in LITERAL_RE.findall(text)]
|
|
424
|
+
for block in TEMPLATE_RE.findall(text):
|
|
425
|
+
if "\n" not in block:
|
|
426
|
+
continue # single-line backticks are already in `out`
|
|
427
|
+
for para in re.split(r"\n\s*\n", block):
|
|
428
|
+
joined = " ".join(
|
|
429
|
+
line.strip() for line in para.splitlines() if line.strip()
|
|
430
|
+
)
|
|
431
|
+
if len(joined) >= 3:
|
|
432
|
+
out.append(joined)
|
|
433
|
+
return out
|
|
434
|
+
|
|
366
435
|
# A template literal's interpolations split it into pieces, and the pieces are
|
|
367
436
|
# not strings: `${a} : ${b}` yielded a literal " : ". Running this check over
|
|
368
437
|
# super-ux's own installer produced 598 such fragments and buried the four real
|
|
@@ -530,7 +599,8 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
530
599
|
|
|
531
600
|
body = read(target) or ""
|
|
532
601
|
literals = [
|
|
533
|
-
lit for
|
|
602
|
+
lit for lit in code_literals(_strip_comments(body, target.suffix))
|
|
603
|
+
if _looks_like_copy(lit)
|
|
534
604
|
]
|
|
535
605
|
if literals:
|
|
536
606
|
# The registry records what a reader sees, so the built page is the
|
|
@@ -544,7 +614,19 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
544
614
|
f"`{row['key']}` is \"{row['text']}\" in the registry, "
|
|
545
615
|
f"but that text is not on the rendered page",
|
|
546
616
|
))
|
|
547
|
-
|
|
617
|
+
# Byte-exact first, and that stays the invariant `strings.md`
|
|
618
|
+
# documents: escape sequences included, so a row holding `\n---`
|
|
619
|
+
# is compared as the three characters the source carries.
|
|
620
|
+
#
|
|
621
|
+
# The wrap-tolerant form is a FALLBACK, not a loosening, and it
|
|
622
|
+
# exists because without it a wrapped block is unregisterable by
|
|
623
|
+
# construction -- B022 would demand a row for `usage()` that B021
|
|
624
|
+
# would then refuse, which is a check with no passing answer. It
|
|
625
|
+
# compares the same wording across a newline the reader never sees,
|
|
626
|
+
# which is exactly what the rendered-page branch above already does.
|
|
627
|
+
elif row["text"] not in body \
|
|
628
|
+
and row["text"].strip() not in body \
|
|
629
|
+
and normalise(row["text"]) not in normalise(body):
|
|
548
630
|
findings.append(Finding(
|
|
549
631
|
"B021", SEVERITY_ERROR, location, 0,
|
|
550
632
|
f"`{row['key']}` is \"{row['text']}\" in the registry, "
|
|
@@ -737,7 +819,7 @@ def _strip_comments(text: str, suffix: str) -> str:
|
|
|
737
819
|
def _copy_in_code(text: str, suffix: str = ".ts") -> str:
|
|
738
820
|
"""The user-visible strings in a source file, joined as prose."""
|
|
739
821
|
out = []
|
|
740
|
-
for
|
|
822
|
+
for literal in code_literals(_strip_comments(text, suffix)):
|
|
741
823
|
candidate = re.sub(r"\s{2,}", " ", INTERPOLATION_RE.sub(" ", literal)).strip()
|
|
742
824
|
# A literal with no space is an import path, an identifier or a one-word
|
|
743
825
|
# label. None of them can carry a rhetorical dash or stuff a keyword, and
|
|
@@ -843,11 +925,59 @@ def _today() -> str:
|
|
|
843
925
|
return datetime.date.today().isoformat()
|
|
844
926
|
|
|
845
927
|
|
|
928
|
+
def _fact_figures(value: str) -> set[str]:
|
|
929
|
+
"""Every figure ONE fact's `Value` sources, normalised the way copy is read.
|
|
930
|
+
|
|
931
|
+
B030 compares a figure in public copy against this set exactly. Until
|
|
932
|
+
2026-08-20 it compared against every value joined into a single string and
|
|
933
|
+
asked `compact not in known.replace(" ", "")` -- so the corpus was one
|
|
934
|
+
character sequence and every SUBSTRING of it counted as sourced. With this
|
|
935
|
+
pack's own seven public rows the corpus was `7158215243770+`, which sourced
|
|
936
|
+
the invented `1582` in "super-ux ... ships 1582 checks": the linter printed
|
|
937
|
+
`brand pack is clean` and exited 0. An invented public number passing the
|
|
938
|
+
check whose entire purpose is to refuse one is the worst failure this file
|
|
939
|
+
can have, because it is indistinguishable from working.
|
|
940
|
+
|
|
941
|
+
Three forms are accepted, and each is the same claim written differently:
|
|
942
|
+
the value with its whitespace removed; the same with a bound marker stripped,
|
|
943
|
+
so a row of `500+` sources the `500` a sentence writes; and whatever the
|
|
944
|
+
figure regex reads INSIDE the value, so `$3.10` and a thousands separator are
|
|
945
|
+
matched in the form copy uses. Nothing else -- a figure not written in the
|
|
946
|
+
table is not in the table.
|
|
947
|
+
"""
|
|
948
|
+
compact = re.sub(r"\s+", "", value)
|
|
949
|
+
figures = {compact, compact.strip("+~><≈").strip()}
|
|
950
|
+
figures.update(m.replace(" ", "") for m in NUMBER_RE.findall(value))
|
|
951
|
+
return {f for f in figures if f}
|
|
952
|
+
|
|
953
|
+
|
|
846
954
|
def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
847
|
-
"""B030-
|
|
955
|
+
"""B030-B033 -- every figure traces to a row, every row to a source."""
|
|
848
956
|
findings: list[Finding] = []
|
|
849
957
|
rows = facts(brand_dir)
|
|
850
|
-
known
|
|
958
|
+
known: set[str] = set()
|
|
959
|
+
for row in rows:
|
|
960
|
+
if row["public"].lower() != "no":
|
|
961
|
+
known |= _fact_figures(row["value"])
|
|
962
|
+
|
|
963
|
+
# B033 -- `Fact` is the key a figure is cited by, and a table with two rows
|
|
964
|
+
# under one key has no answer to "what is that number". Watched: a second
|
|
965
|
+
# `| skills shipped | 99 |` row left the pack clean AND put `99` into the
|
|
966
|
+
# sourced corpus, so the duplicate did not merely go unreported -- it
|
|
967
|
+
# licensed a wrong figure in public copy. An error, not a warning: the two
|
|
968
|
+
# rows disagree by construction and no reader can tell which one is meant.
|
|
969
|
+
by_key: dict[str, int] = {}
|
|
970
|
+
for row in rows:
|
|
971
|
+
key = normalise(row["fact"]).lower()
|
|
972
|
+
by_key[key] = by_key.get(key, 0) + 1
|
|
973
|
+
for key, count in sorted(by_key.items()):
|
|
974
|
+
if count > 1:
|
|
975
|
+
findings.append(Finding(
|
|
976
|
+
"B033", SEVERITY_ERROR, "facts.md", 0,
|
|
977
|
+
f"`{key}` has {count} rows -- a fact is cited by its name, and "
|
|
978
|
+
f"two rows under one name make every figure quoting it "
|
|
979
|
+
f"ambiguous. Retire one or rename both",
|
|
980
|
+
))
|
|
851
981
|
|
|
852
982
|
for row in rows:
|
|
853
983
|
if unfilled(row["source"]):
|
|
@@ -868,7 +998,7 @@ def check_facts(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
868
998
|
compact = number.replace(" ", "")
|
|
869
999
|
if YEAR_RE.match(compact):
|
|
870
1000
|
continue
|
|
871
|
-
if compact not in known
|
|
1001
|
+
if compact not in known:
|
|
872
1002
|
findings.append(Finding(
|
|
873
1003
|
"B030", SEVERITY_ERROR, path, 0,
|
|
874
1004
|
f"`{number}` appears in public copy with no row in "
|
|
@@ -1539,6 +1669,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1539
1669
|
parser.add_argument("--fix", action="store_true")
|
|
1540
1670
|
parser.add_argument("--brief", action="store_true")
|
|
1541
1671
|
parser.add_argument("--json", action="store_true")
|
|
1672
|
+
parser.add_argument(
|
|
1673
|
+
"--strict", action="store_true",
|
|
1674
|
+
help="let warnings fail the run too (same meaning as ux_lint.py --strict)",
|
|
1675
|
+
)
|
|
1542
1676
|
args = parser.parse_args(argv)
|
|
1543
1677
|
|
|
1544
1678
|
brand_dir = Path(args.path)
|
|
@@ -1555,7 +1689,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1555
1689
|
|
|
1556
1690
|
if any(f.severity == SEVERITY_ERROR for f in findings):
|
|
1557
1691
|
return 2
|
|
1558
|
-
|
|
1692
|
+
# A warning is advice about a judgement a person still has to make -- an
|
|
1693
|
+
# unregistered string, a fact past review, a label naming no outcome. It
|
|
1694
|
+
# blocks only when the caller asks, which is what `--strict` is for.
|
|
1695
|
+
return 1 if (args.strict and findings) else 0
|
|
1559
1696
|
|
|
1560
1697
|
|
|
1561
1698
|
if __name__ == "__main__":
|
|
@@ -52,9 +52,32 @@ def find_ux_dir(arg: str | None) -> Path | None:
|
|
|
52
52
|
return None
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
# An entry header, matched by its ID and not by the shape of the rest of it.
|
|
56
|
+
#
|
|
57
|
+
# Requiring the colon is how a whole layer became invisible. This pack's own
|
|
58
|
+
# three jobs are written `### JTBD-01` with no `: <name>`, so `ids()` and
|
|
59
|
+
# `entry_blocks()` matched zero of them and NOT ONE rule in this file applied to
|
|
60
|
+
# the layer -- not id uniqueness, not the gap warning, not a required field.
|
|
61
|
+
# Watched: two identical `### JTBD-01` headers passed the whole gate, exit 0.
|
|
62
|
+
# The contract has asked for `### JTBD-NN: <short job name>` since the layer
|
|
63
|
+
# existed, and asking for it in the MATCHER meant a malformed entry was not
|
|
64
|
+
# refused but erased, which is the same class as an out-of-enum status reading
|
|
65
|
+
# as no status. So the header is matched loosely and its shape is then checked
|
|
66
|
+
# (`U073`) -- an entry nobody can see cannot be told it is malformed.
|
|
67
|
+
def _entry_header_re(prefix: str) -> re.Pattern:
|
|
68
|
+
return re.compile(rf"^###[ \t]+({prefix}-\d+)\b[ \t]*(:?)[ \t]*([^\n]*)$",
|
|
69
|
+
re.MULTILINE)
|
|
70
|
+
|
|
71
|
+
|
|
55
72
|
def ids(text: str, prefix: str) -> list[str]:
|
|
56
|
-
"""All '### PREFIX-NN
|
|
57
|
-
return
|
|
73
|
+
"""All '### PREFIX-NN' entry ids, in order, named or not."""
|
|
74
|
+
return [m.group(1) for m in _entry_header_re(prefix).finditer(text)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def entry_names(text: str, prefix: str) -> list[tuple[str, str, str]]:
|
|
78
|
+
"""(id, colon, name) per entry header — what `U073` reads to check the shape."""
|
|
79
|
+
return [(m.group(1), m.group(2), m.group(3).strip())
|
|
80
|
+
for m in _entry_header_re(prefix).finditer(text)]
|
|
58
81
|
|
|
59
82
|
|
|
60
83
|
def index_ids(text: str, prefix: str) -> set[str]:
|
|
@@ -92,13 +115,12 @@ def figma_enabled(foundation: str) -> bool | None:
|
|
|
92
115
|
def entry_blocks(text: str, prefix: str) -> dict[str, str]:
|
|
93
116
|
"""Map PREFIX-id -> its section body (from its header to the next ### / ##)."""
|
|
94
117
|
out: dict[str, str] = {}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
body = parts[i + 1] if i + 1 < len(parts) else ""
|
|
118
|
+
headers = list(_entry_header_re(prefix).finditer(text))
|
|
119
|
+
for i, match in enumerate(headers):
|
|
120
|
+
stop = headers[i + 1].start() if i + 1 < len(headers) else len(text)
|
|
121
|
+
body = text[match.end():stop]
|
|
100
122
|
body = re.split(r"^##\s", body, maxsplit=1, flags=re.MULTILINE)[0]
|
|
101
|
-
out[
|
|
123
|
+
out[match.group(1)] = body
|
|
102
124
|
return out
|
|
103
125
|
|
|
104
126
|
|
|
@@ -107,7 +129,11 @@ def entry_blocks(text: str, prefix: str) -> dict[str, str]:
|
|
|
107
129
|
# or "the route is built" does not. Widening this to any slash-bearing token was
|
|
108
130
|
# tried and flagged three correct prose entries, which is the false positive that
|
|
109
131
|
# gets a rule switched off.
|
|
110
|
-
|
|
132
|
+
# The line suffix now includes a RANGE, because a range is what this layer
|
|
133
|
+
# writes and stopping at the first number is what let one drift for a release.
|
|
134
|
+
CITED_PATH = re.compile(
|
|
135
|
+
r"\b([\w.-]+(?:/[\w.-]+)+\.[A-Za-z][\w]{0,4}(?::\d+(?:-\d+)?)?)"
|
|
136
|
+
)
|
|
111
137
|
|
|
112
138
|
|
|
113
139
|
def screen_blocks(text: str) -> dict[str, str]:
|
|
@@ -115,17 +141,44 @@ def screen_blocks(text: str) -> dict[str, str]:
|
|
|
115
141
|
return entry_blocks(text, "SCR")
|
|
116
142
|
|
|
117
143
|
|
|
118
|
-
def coverage_claim(cov: str, root: Path) -> tuple[bool, list[str]]:
|
|
144
|
+
def coverage_claim(cov: str, root: Path) -> tuple[bool, list[str], list[str]]:
|
|
119
145
|
"""A `Coverage:` value read as the claim about code that it is.
|
|
120
146
|
|
|
121
|
-
Returns `(names_no_file,
|
|
122
|
-
layers: `screens.md` and the requirement layer
|
|
123
|
-
questions of the same field, and an answer that
|
|
124
|
-
be a second contract wearing one field's name.
|
|
147
|
+
Returns `(names_no_file, paths_that_do_not_exist, spans_the_file_does_not
|
|
148
|
+
have)`. One owner for three layers: `screens.md` and the requirement layer
|
|
149
|
+
above it ask the same questions of the same field, and an answer that
|
|
150
|
+
differed between them would be a second contract wearing one field's name.
|
|
151
|
+
|
|
152
|
+
The third answer is the one B-004 was open for. Until 2026-08-20 a citation
|
|
153
|
+
was split on `:` and only its path was resolved, so a LINE NUMBER was
|
|
154
|
+
decoration: `bin/super-ux.js:99000-99999` passed against a 396-line file,
|
|
155
|
+
and seven live citations in this pack's own `screens.md` were pre-shift
|
|
156
|
+
ranges the gate could not see -- `SCR-01` pointed at 223-284 while
|
|
157
|
+
`selectInteractive` had moved to 235-296, and `scenarios.md` had the same
|
|
158
|
+
function right. A coverage claim whose numbers nobody resolves is a claim
|
|
159
|
+
about a file, not about code, and code is what it says it is about.
|
|
125
160
|
"""
|
|
126
161
|
cited = CITED_PATH.findall(cov)
|
|
127
|
-
missing = [
|
|
128
|
-
|
|
162
|
+
missing, beyond = [], []
|
|
163
|
+
for rel in cited:
|
|
164
|
+
path, _, span = rel.partition(":")
|
|
165
|
+
target = root / path
|
|
166
|
+
if not target.exists():
|
|
167
|
+
missing.append(rel)
|
|
168
|
+
continue
|
|
169
|
+
if not span:
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
total = len(target.read_text(encoding="utf-8").splitlines())
|
|
173
|
+
except (OSError, UnicodeDecodeError):
|
|
174
|
+
# A binary or unreadable target is not a citation defect. The path
|
|
175
|
+
# resolved; refusing it here would be a rule about file encodings.
|
|
176
|
+
continue
|
|
177
|
+
start, _, end = span.partition("-")
|
|
178
|
+
first, last = int(start), int(end or start)
|
|
179
|
+
if first < 1 or last < first or last > total:
|
|
180
|
+
beyond.append(f"{rel} ({path} has {total} lines)")
|
|
181
|
+
return (not cited, missing, beyond)
|
|
129
182
|
|
|
130
183
|
|
|
131
184
|
# --- The observable a requirement is unfinished without --------------------
|
|
@@ -219,11 +272,15 @@ def check_observables(scenarios: str, foundation: str, root: Path) -> None:
|
|
|
219
272
|
warn(f"[U063] scenarios.md: {sid} is 'implemented' and names no code — "
|
|
220
273
|
f"the status claims an audit passed and nothing says against what")
|
|
221
274
|
if cov and not cov.lower().startswith("none"):
|
|
222
|
-
unfalsifiable, missing = coverage_claim(cov, root)
|
|
275
|
+
unfalsifiable, missing, beyond = coverage_claim(cov, root)
|
|
223
276
|
if unfalsifiable:
|
|
224
277
|
warn(f"[U064] scenarios.md: {sid} claims Coverage '{cov}' and names no file")
|
|
225
278
|
for rel in missing:
|
|
226
279
|
err(f"[U065] scenarios.md: {sid} cites '{rel}', which does not exist")
|
|
280
|
+
for rel in beyond:
|
|
281
|
+
err(f"[U072] scenarios.md: {sid} cites '{rel}' — the file resolves "
|
|
282
|
+
f"and those lines do not, so the citation points at code that "
|
|
283
|
+
f"is not there")
|
|
227
284
|
|
|
228
285
|
for sid, body in sorted(entry_blocks(foundation, "ST").items()):
|
|
229
286
|
status = declared_status(body)
|
|
@@ -312,8 +369,36 @@ STATUS_ENUMS = {
|
|
|
312
369
|
"SCN": ("draft", "validated", "implemented", "retired"),
|
|
313
370
|
"ST": ("proposed", "validated", "delivered", "dropped"),
|
|
314
371
|
"SCR": ("designed", "blocked", "built", "drifted", "retired"),
|
|
372
|
+
# The two foundation layers that carry a state of their own. A persona and a
|
|
373
|
+
# job are either an assumption or something an observation has confirmed, and
|
|
374
|
+
# `proposed -> confirmed` is the only claim either layer can make about
|
|
375
|
+
# itself. Both were carrying `confirmed` in this pack's own foundation with
|
|
376
|
+
# no enum anywhere covering them: nine `**Status:**` values across three
|
|
377
|
+
# layers sat outside every table, unrefused and unaccepted.
|
|
378
|
+
"P": ("proposed", "confirmed", "retired"),
|
|
379
|
+
"JTBD": ("proposed", "confirmed", "retired"),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
# A layer whose state lives on the DOCUMENT rather than on an entry. `vision.md`
|
|
383
|
+
# has declared `draft | approved` since the layer shipped -- inside a fenced
|
|
384
|
+
# example, not in the enum home -- and `check_vision` matched the single word
|
|
385
|
+
# `approved` and nothing said what else was legal.
|
|
386
|
+
DOC_STATUS_ENUMS = {
|
|
387
|
+
"vision.md": ("draft", "approved"),
|
|
315
388
|
}
|
|
316
389
|
|
|
390
|
+
# Layers the contract gives NO status, checked so that saying nothing about a
|
|
391
|
+
# state is not the same as accepting any word for it. A flow's delivery state is
|
|
392
|
+
# MEASURED through the screens it traverses -- that is what `U057` exists for --
|
|
393
|
+
# so a declared `Status:` on a flow is the inherited verdict U057 refuses,
|
|
394
|
+
# written into the record. This pack's own four flows each carried one.
|
|
395
|
+
STATUSLESS_LAYERS = (
|
|
396
|
+
("FLW", "flows.md", "a flow's coverage is measured through its screens (U057), "
|
|
397
|
+
"never declared on the flow"),
|
|
398
|
+
("JRN", "foundation.md", "a journey is a map of what happens, and a map has "
|
|
399
|
+
"no delivery state of its own"),
|
|
400
|
+
)
|
|
401
|
+
|
|
317
402
|
# The canonical spelling of a field, and the short form in live use beside it.
|
|
318
403
|
# `U060`/`U061` read both on purpose -- their question is whether an observable
|
|
319
404
|
# EXISTS -- which left the vocabulary itself ungated: a project could spell a
|
|
@@ -401,11 +486,14 @@ def check_field_vocabulary(scenarios: str, foundation: str) -> None:
|
|
|
401
486
|
f"saying so")
|
|
402
487
|
|
|
403
488
|
|
|
404
|
-
def check_status_enums(scenarios: str, foundation: str, screens: str
|
|
489
|
+
def check_status_enums(scenarios: str, foundation: str, screens: str,
|
|
490
|
+
flows: str = "", vision: str = "") -> None:
|
|
405
491
|
"""A status outside its layer's enum is refused, not read as no status."""
|
|
406
492
|
for text, prefix, name in ((scenarios, "SCN", "scenarios.md"),
|
|
407
493
|
(foundation, "ST", "foundation.md"),
|
|
408
|
-
(screens, "SCR", "screens.md")
|
|
494
|
+
(screens, "SCR", "screens.md"),
|
|
495
|
+
(foundation, "P", "foundation.md"),
|
|
496
|
+
(foundation, "JTBD", "foundation.md")):
|
|
409
497
|
allowed = STATUS_ENUMS[prefix]
|
|
410
498
|
for sid, body in sorted(entry_blocks(text, prefix).items()):
|
|
411
499
|
status = declared_status(body)
|
|
@@ -415,6 +503,73 @@ def check_status_enums(scenarios: str, foundation: str, screens: str) -> None:
|
|
|
415
503
|
f"one of {' | '.join(allowed)} — an unrecognised status reads as "
|
|
416
504
|
f"no status, and every rule keyed on one silently stops applying")
|
|
417
505
|
|
|
506
|
+
# A layer the contract gives no status must not carry one. Silence about a
|
|
507
|
+
# state is not permission to invent a vocabulary for it: an undeclared value
|
|
508
|
+
# is outside every table, so no rule can key on it and no reader can be wrong
|
|
509
|
+
# about it out loud.
|
|
510
|
+
layers = {"FLW": flows, "JRN": foundation}
|
|
511
|
+
for prefix, name, why in STATUSLESS_LAYERS:
|
|
512
|
+
for sid, body in sorted(entry_blocks(layers[prefix], prefix).items()):
|
|
513
|
+
status = declared_status(body)
|
|
514
|
+
if status is None:
|
|
515
|
+
continue
|
|
516
|
+
err(f"[U075] {name}: {sid} declares Status '{status}' on a layer the "
|
|
517
|
+
f"contract gives no status — {why}")
|
|
518
|
+
|
|
519
|
+
for filename, allowed in sorted(DOC_STATUS_ENUMS.items()):
|
|
520
|
+
text = vision if filename == "vision.md" else ""
|
|
521
|
+
if not text.strip():
|
|
522
|
+
continue
|
|
523
|
+
status = declared_status(text)
|
|
524
|
+
if status is None or status in allowed:
|
|
525
|
+
continue
|
|
526
|
+
err(f"[U070] {filename}: declares Status '{status}', which is not one of "
|
|
527
|
+
f"{' | '.join(allowed)} — an unrecognised status reads as no status")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# What the contract requires of a job, in the contract's own field names. The
|
|
531
|
+
# `Success metric` is the M-17 observable one layer above a story: without it a
|
|
532
|
+
# job is a sentence about a feeling, and no later evidence can be connected to it
|
|
533
|
+
# without inventing the measure after seeing what shipped. None of this pack's
|
|
534
|
+
# own three jobs carried one, and no rule could have said so -- the layer was
|
|
535
|
+
# invisible (see `_entry_header_re`), which is why two defects hid each other.
|
|
536
|
+
JOB_FIELDS = ("Statement", "Personas", "Type", "Forces", "Success metric")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def check_jobs(foundation: str, scenarios: str = "", flows: str = "",
|
|
540
|
+
screens: str = "") -> None:
|
|
541
|
+
"""The job layer: a named header, and the fields the contract asks for.
|
|
542
|
+
|
|
543
|
+
The header check covers EVERY entry layer, not only the one it was written
|
|
544
|
+
for. The invisibility was never specific to jobs -- any layer whose entries
|
|
545
|
+
drop the `: <name>` disappears from `ids()` and `entry_blocks()` the same
|
|
546
|
+
way, and it happened to be the job layer that did.
|
|
547
|
+
"""
|
|
548
|
+
layers = (("P", "foundation.md", "persona", foundation),
|
|
549
|
+
("JTBD", "foundation.md", "job", foundation),
|
|
550
|
+
("JRN", "foundation.md", "journey", foundation),
|
|
551
|
+
("ST", "foundation.md", "story", foundation),
|
|
552
|
+
("SCN", "scenarios.md", "scenario", scenarios),
|
|
553
|
+
("FLW", "flows.md", "flow", flows),
|
|
554
|
+
("SCR", "screens.md", "screen", screens))
|
|
555
|
+
for prefix, name, label, text in layers:
|
|
556
|
+
for sid, colon, title in entry_names(text, prefix):
|
|
557
|
+
if colon and title:
|
|
558
|
+
continue
|
|
559
|
+
err(f"[U073] {name}: `### {sid}` carries no name — the contract's "
|
|
560
|
+
f"header is `### {sid}: <short {label} name>`, and an entry with "
|
|
561
|
+
f"no name is how this layer stayed invisible to every rule in "
|
|
562
|
+
f"this file for three releases")
|
|
563
|
+
|
|
564
|
+
for sid, body in sorted(entry_blocks(foundation, "JTBD").items()):
|
|
565
|
+
if declared_status(body) == "retired":
|
|
566
|
+
continue
|
|
567
|
+
for field in JOB_FIELDS:
|
|
568
|
+
if not re.search(rf"\*\*{re.escape(field)}:\*\*", body):
|
|
569
|
+
err(f"[U074] foundation.md: {sid} is missing **{field}:** — the "
|
|
570
|
+
f"contract asks a job for all of "
|
|
571
|
+
f"{', '.join(JOB_FIELDS)}")
|
|
572
|
+
|
|
418
573
|
|
|
419
574
|
WEB_SURFACE_FIELDS = ("Route", "Answers", "Indexable", "Without JS", "Entity")
|
|
420
575
|
|
|
@@ -689,16 +844,21 @@ def main() -> int:
|
|
|
689
844
|
if cov and not cov.lower().startswith("none"):
|
|
690
845
|
# The line suffix is part of a citation, not of the path --
|
|
691
846
|
# `coverage_claim` owns that, for this layer and the one above.
|
|
692
|
-
unfalsifiable, missing = coverage_claim(cov, screens_root)
|
|
847
|
+
unfalsifiable, missing, beyond = coverage_claim(cov, screens_root)
|
|
693
848
|
if unfalsifiable:
|
|
694
849
|
warn(f"[U055] screens.md: {sid} claims Coverage '{cov}' and names no file")
|
|
695
850
|
for rel in missing:
|
|
696
851
|
err(f"[U056] screens.md: {sid} cites '{rel}', which does not exist")
|
|
852
|
+
for rel in beyond:
|
|
853
|
+
err(f"[U071] screens.md: {sid} cites '{rel}' — the file resolves "
|
|
854
|
+
f"and those lines do not, so the citation points at code "
|
|
855
|
+
f"that is not there")
|
|
697
856
|
|
|
698
857
|
check_observables(scenarios, foundation, project_root)
|
|
699
858
|
check_product_state(scenarios, foundation)
|
|
700
859
|
check_field_vocabulary(scenarios, foundation)
|
|
701
|
-
check_status_enums(scenarios, foundation, screens)
|
|
860
|
+
check_status_enums(scenarios, foundation, screens, flows, vision)
|
|
861
|
+
check_jobs(foundation, scenarios, flows, screens)
|
|
702
862
|
check_vision(ux, vision)
|
|
703
863
|
check_web_surface(screens, flows)
|
|
704
864
|
check_links(ux)
|
package/templates/claude-rule.md
CHANGED
|
@@ -57,5 +57,7 @@
|
|
|
57
57
|
relationships and find-and-replace picks the wrong one. `B062` and `B063`
|
|
58
58
|
catch what a machine can prove; the rest is in the skill's `ai-tells.md`.
|
|
59
59
|
- Run `python3 docs/brand/lint.py` after any text change and before calling
|
|
60
|
-
work done. It must exit
|
|
61
|
-
UX linter
|
|
60
|
+
work done. It must exit 0 — an error blocks and a warning is advice, which
|
|
61
|
+
is the same policy the UX linter has; `--strict` makes warnings block too.
|
|
62
|
+
Wire it into CI or pre-commit alongside the UX linter so copy drift cannot
|
|
63
|
+
merge.
|