super-ux 0.44.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 +105 -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 +491 -22
- package/templates/claude-rule.md +4 -2
- package/templates/foundation.md +1 -0
- package/templates/scenarios.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
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
|
+
|
|
63
|
+
## 0.45.0 — 2026-08-19
|
|
64
|
+
|
|
65
|
+
**The requirement layer could not see a requirement with no observable.** The contract said
|
|
66
|
+
one was unfinished without it; nothing read for one. `ux_lint.py` never opened a scenario or
|
|
67
|
+
user-story body — no rule looked for `Expected result`, `Acceptance criteria` or a success
|
|
68
|
+
metric — while the layer *below* it, screens, had carried that check since U055/U056.
|
|
69
|
+
|
|
70
|
+
The dogfood is the argument: this pack's own 15 scenarios all read `Status: implemented`,
|
|
71
|
+
**none cited any code**, no test touched `bin/super-ux.js`, and `npm test` exited 0 over all
|
|
72
|
+
of it. A chain that demands the layers before code could not tell that its own chain closed
|
|
73
|
+
on nothing.
|
|
74
|
+
|
|
75
|
+
### U060–U065 — the requirement layer gets read
|
|
76
|
+
|
|
77
|
+
A scenario or story with no observable is refused; a scenario claiming `implemented` must
|
|
78
|
+
cite code that resolves. `U055/U056` and `U064/U065` now share one owner (`coverage_claim`),
|
|
79
|
+
so two layers cannot answer the same question differently. All 15 own scenarios now cite
|
|
80
|
+
implementing ranges in `bin/super-ux.js`, each read rather than recalled.
|
|
81
|
+
|
|
82
|
+
### `Product:` — a shipped scenario stops counting as a validated one
|
|
83
|
+
|
|
84
|
+
`unobserved | observed | contradicted`, distinct from `Status`, with **no floor and no
|
|
85
|
+
target**: absence means `unobserved`, and neither `unobserved` nor `contradicted` fails
|
|
86
|
+
anything. Outcome evidence often cannot exist yet, and saying so is not a defect.
|
|
87
|
+
|
|
88
|
+
Four things stop an audit promoting it: `U068` refuses everything an audit produces as an
|
|
89
|
+
outcome signal — a `file:line`, a PASS/FAIL verdict, a path into `docs/ux/audits/`; `U067`
|
|
90
|
+
refuses `observed` with no signal; `U066` refuses an out-of-enum value outright, because an
|
|
91
|
+
unrecognised value read as *no state* is how an enum drifts; and both homes of the
|
|
92
|
+
after-a-run step carry **The audit never writes `Product:`**, with a gate that fails if
|
|
93
|
+
either loses the sentence.
|
|
94
|
+
|
|
95
|
+
**All 15 own scenarios now read `unobserved`.** Before this they read as fifteen validated
|
|
96
|
+
bets, because `implemented` was the only state the chain had.
|
|
97
|
+
|
|
98
|
+
### Found while there
|
|
99
|
+
|
|
100
|
+
The screens status enum had **already** drifted: the contract declared five values and the
|
|
101
|
+
linter matched four, so a `blocked` screen read as having *no* status and `U021` silently
|
|
102
|
+
stopped applying. Fixed. Two further instances of the same class filed rather than hidden.
|
|
103
|
+
|
|
104
|
+
The long spelling (`**Expected result:**`) is canonical; the short forms are still read, and
|
|
105
|
+
`U069` warns rather than errors — failing a live project over a synonym is the false positive
|
|
106
|
+
that gets a whole family switched off.
|
|
107
|
+
|
|
3
108
|
## 0.44.0 — 2026-08-17
|
|
4
109
|
|
|
5
110
|
**Three lint codes for the two layers whose claims nothing could check.** `U055`, `U056` and
|
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,6 +141,436 @@ def screen_blocks(text: str) -> dict[str, str]:
|
|
|
115
141
|
return entry_blocks(text, "SCR")
|
|
116
142
|
|
|
117
143
|
|
|
144
|
+
def coverage_claim(cov: str, root: Path) -> tuple[bool, list[str], list[str]]:
|
|
145
|
+
"""A `Coverage:` value read as the claim about code that it is.
|
|
146
|
+
|
|
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.
|
|
160
|
+
"""
|
|
161
|
+
cited = CITED_PATH.findall(cov)
|
|
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)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- The observable a requirement is unfinished without --------------------
|
|
185
|
+
#
|
|
186
|
+
# `Expected result` on a scenario and `Acceptance criteria` on a story are the
|
|
187
|
+
# contract's names for the same thing: the thing a reader can watch happen and
|
|
188
|
+
# disagree about. Until this block existed, no rule in this file opened a
|
|
189
|
+
# scenario or a story body at all -- the contract asked for an observable and
|
|
190
|
+
# nothing read for one, so a scenario could reach `implemented` having never
|
|
191
|
+
# said what would be true if it worked.
|
|
192
|
+
#
|
|
193
|
+
# Field spelling is read tolerantly on purpose. `Expected:` and `Acceptance:`
|
|
194
|
+
# are the short forms in live use (this pack's own chain writes both), and the
|
|
195
|
+
# question these codes ask is whether an observable EXISTS. A rule that failed a
|
|
196
|
+
# scenario for spelling its field the short way would be a different rule
|
|
197
|
+
# wearing this one's number, and it would be the false positive that gets the
|
|
198
|
+
# whole family switched off.
|
|
199
|
+
SCENARIO_OBSERVABLE = re.compile(r"\*\*Expected(?:\s+result)?:\*\*[ \t]*(.*)")
|
|
200
|
+
STORY_OBSERVABLE = re.compile(r"\*\*Acceptance(?:\s+criteria)?:\*\*[ \t]*(.*)")
|
|
201
|
+
FIELD_START = re.compile(r"^\s*(?:[-*]\s+)?\*\*[^*]+:\*\*")
|
|
202
|
+
PLACEHOLDER = re.compile(r"^(?:[-—–]+|<[^>]*>|tbd|todo|n/?a|\?+)$", re.IGNORECASE)
|
|
203
|
+
|
|
204
|
+
# A scenario or story that declares itself unfinished is not a finding: it has
|
|
205
|
+
# already said what these codes would say. `retired` and `dropped` are gone, and
|
|
206
|
+
# `draft`/`proposed` are the states in which the observable is still being
|
|
207
|
+
# written. Every other value -- including an unstated one -- is a claim to be
|
|
208
|
+
# finished, and that claim is what gets checked.
|
|
209
|
+
SCENARIO_UNFINISHED = ("draft", "retired")
|
|
210
|
+
STORY_UNFINISHED = ("proposed", "dropped")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def field_body(body: str, pattern: re.Pattern) -> str | None:
|
|
214
|
+
"""A `**Field:**` value: the rest of its line plus the lines beneath it.
|
|
215
|
+
|
|
216
|
+
`None` when the field is absent, `""` when it carries nothing. Reading the
|
|
217
|
+
lines beneath is what makes `Acceptance criteria` legible at all -- the
|
|
218
|
+
contract puts its Given/When/Then bullets under the label, not after it.
|
|
219
|
+
"""
|
|
220
|
+
m = pattern.search(body)
|
|
221
|
+
if m is None:
|
|
222
|
+
return None
|
|
223
|
+
out = [m.group(1).strip()]
|
|
224
|
+
for line in body[m.end():].splitlines():
|
|
225
|
+
if not line.strip():
|
|
226
|
+
continue
|
|
227
|
+
if FIELD_START.match(line) or line.lstrip().startswith("#"):
|
|
228
|
+
break
|
|
229
|
+
out.append(line.strip())
|
|
230
|
+
return "\n".join(part for part in out if part).strip()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def stated(value: str | None) -> bool:
|
|
234
|
+
"""Does this field say anything? A placeholder is not an answer."""
|
|
235
|
+
if value is None:
|
|
236
|
+
return False
|
|
237
|
+
for line in value.splitlines():
|
|
238
|
+
line = line.strip().lstrip("-*").strip()
|
|
239
|
+
if line and not PLACEHOLDER.match(line):
|
|
240
|
+
return True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def declared_status(body: str) -> str | None:
|
|
245
|
+
m = re.search(r"\*\*Status:\*\*\s*([A-Za-z-]+)", body)
|
|
246
|
+
return m.group(1).lower() if m else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def check_observables(scenarios: str, foundation: str, root: Path) -> None:
|
|
250
|
+
"""A requirement with no observable is unfinished, and says so or is told.
|
|
251
|
+
|
|
252
|
+
The reason this is a gate and not advice: an observable added after the
|
|
253
|
+
implementation is read is not a test of the requirement, it is a
|
|
254
|
+
description of the code. By then the only honest thing left to measure is
|
|
255
|
+
whether the code does what the code does. So the observable is required at
|
|
256
|
+
the layer that DEFINES the requirement, where it is still cheap, and the
|
|
257
|
+
citation that connects it to code is required the moment the requirement
|
|
258
|
+
claims to be implemented.
|
|
259
|
+
"""
|
|
260
|
+
for sid, body in sorted(entry_blocks(scenarios, "SCN").items()):
|
|
261
|
+
status = declared_status(body)
|
|
262
|
+
if status in SCENARIO_UNFINISHED:
|
|
263
|
+
continue
|
|
264
|
+
if not stated(field_body(body, SCENARIO_OBSERVABLE)):
|
|
265
|
+
err(f"[U060] scenarios.md: {sid} states no observable result — add "
|
|
266
|
+
f"**Expected result:**. A requirement with no observable cannot be "
|
|
267
|
+
f"connected to evidence later without inventing the test after "
|
|
268
|
+
f"reading the implementation")
|
|
269
|
+
cov_m = re.search(r"\*\*Coverage:\*\*\s*(.+)", body)
|
|
270
|
+
cov = cov_m.group(1).strip() if cov_m else ""
|
|
271
|
+
if status == "implemented" and (not cov or cov.lower().startswith("none")):
|
|
272
|
+
warn(f"[U063] scenarios.md: {sid} is 'implemented' and names no code — "
|
|
273
|
+
f"the status claims an audit passed and nothing says against what")
|
|
274
|
+
if cov and not cov.lower().startswith("none"):
|
|
275
|
+
unfalsifiable, missing, beyond = coverage_claim(cov, root)
|
|
276
|
+
if unfalsifiable:
|
|
277
|
+
warn(f"[U064] scenarios.md: {sid} claims Coverage '{cov}' and names no file")
|
|
278
|
+
for rel in missing:
|
|
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")
|
|
284
|
+
|
|
285
|
+
for sid, body in sorted(entry_blocks(foundation, "ST").items()):
|
|
286
|
+
status = declared_status(body)
|
|
287
|
+
if status in STORY_UNFINISHED:
|
|
288
|
+
continue
|
|
289
|
+
# `or ""` so the second question is never asked of None: the linter's
|
|
290
|
+
# promise is that malformed markdown is reported, not raised, and the
|
|
291
|
+
# only way to watch the first branch fail is to disable it.
|
|
292
|
+
criteria = field_body(body, STORY_OBSERVABLE) or ""
|
|
293
|
+
if not stated(criteria):
|
|
294
|
+
err(f"[U061] foundation.md: {sid} states no acceptance criteria — a story "
|
|
295
|
+
f"whose delivery nobody can witness is unfinished, whatever its status")
|
|
296
|
+
elif not re.search(r"\bthen\b", criteria, re.IGNORECASE):
|
|
297
|
+
warn(f"[U062] foundation.md: {sid} acceptance criteria name no outcome "
|
|
298
|
+
f"(no 'then') — the contract's shape is Given/When/Then, and the "
|
|
299
|
+
f"'then' is the only half an audit can check")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --- Delivery proof is not outcome proof -----------------------------------
|
|
303
|
+
#
|
|
304
|
+
# `Status` is the DELIVERY state: does the code do what the scenario said. An
|
|
305
|
+
# audit PASS moves it, and that is the whole of what an audit can know.
|
|
306
|
+
# `Product` is the OUTCOME state: did shipping it change anything for a user.
|
|
307
|
+
# Only a signal from the world moves it.
|
|
308
|
+
#
|
|
309
|
+
# Until this block existed the pack had one state and the word `unobserved`
|
|
310
|
+
# appeared in it nowhere, so a shipped scenario silently counted as a validated
|
|
311
|
+
# one: `implemented` was read as "we were right about this", which is a claim
|
|
312
|
+
# nothing in the chain could support. Manifesto M-21 names the state that was
|
|
313
|
+
# missing rather than the check -- *some outcome evidence cannot exist until
|
|
314
|
+
# after release, so `unobserved` is a legitimate product state; pretending
|
|
315
|
+
# delivery proof is outcome proof is not.*
|
|
316
|
+
#
|
|
317
|
+
# So this field has NO FLOOR AND NO TARGET. Its absence means `unobserved` and
|
|
318
|
+
# is never a finding; a scenario may hold `unobserved` for its whole life and
|
|
319
|
+
# nothing here will fail. `contradicted` is not a failing gate either -- it is
|
|
320
|
+
# the information the field exists to make recordable, and what to do about it is
|
|
321
|
+
# a product decision no linter makes. Two things are refused, and both are
|
|
322
|
+
# claims rather than states: an outcome claim that names no observation (U067),
|
|
323
|
+
# and delivery proof handed in wearing an outcome label (U068). The two artefacts
|
|
324
|
+
# an audit produces are a `file:line` and a verdict, and U068 refuses both AS A
|
|
325
|
+
# SIGNAL -- which is what makes an audit PASS unable to promote this field in
|
|
326
|
+
# code as well as in doctrine.
|
|
327
|
+
PRODUCT_STATES = ("unobserved", "observed", "contradicted")
|
|
328
|
+
# The two layers that carry a hypothesis: a scenario is the unit that ships and
|
|
329
|
+
# a story is the unit that bets. A screen has a delivery state and no bet of its
|
|
330
|
+
# own, so it carries no product state, and this tuple is what
|
|
331
|
+
# `validate_status_enums_match_contract` reads rather than guessing from
|
|
332
|
+
# `STATUS_ENUMS`.
|
|
333
|
+
PRODUCT_LAYERS = ("SCN", "ST")
|
|
334
|
+
PRODUCT_EVIDENCED = ("observed", "contradicted")
|
|
335
|
+
PRODUCT_FIELD = re.compile(r"\*\*Product:\*\*[ \t]*(.*)")
|
|
336
|
+
# What an audit hands back, in the two forms the contract gives it. The verdict
|
|
337
|
+
# tokens are upper case and nothing else in these documents is, so they are safe
|
|
338
|
+
# to key on: `PASS` is a verdict, `passed` is prose, and that sentence is the
|
|
339
|
+
# negative fixture. The second is the audit report's own home, which the contract
|
|
340
|
+
# fixes at `docs/ux/audits/` — a path into it is the audit speaking, whatever
|
|
341
|
+
# prose is wrapped around it, and prose around a citation is how the first form
|
|
342
|
+
# would otherwise be smuggled past. Kept as a tuple so deleting one pattern turns
|
|
343
|
+
# exactly one fixture red.
|
|
344
|
+
AUDIT_EVIDENCE = (
|
|
345
|
+
re.compile(r"\b(?:PASS|FAIL|PARTIAL)\b"),
|
|
346
|
+
re.compile(r"\bdocs/ux/audits/[\w.-]+"),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# A code citation as this layer actually writes one, RANGES INCLUDED. `CITED_PATH`
|
|
350
|
+
# stops at the first line number on purpose -- it resolves a path, and the range
|
|
351
|
+
# is B-004's open work -- so subtracting only what it matches left `-296` behind
|
|
352
|
+
# and read it as prose. The plant caught that: `observed — bin/super-ux.js:235-296`
|
|
353
|
+
# went clean on the first attempt, and the range form is exactly what this pack's
|
|
354
|
+
# own chain writes. Used for the residue test below and nothing else.
|
|
355
|
+
CITED_SPAN = re.compile(
|
|
356
|
+
r"`?\b[\w.-]+(?:/[\w.-]+)+\.[A-Za-z][\w]{0,4}(?::\d+(?:-\d+)?)?`?"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Every enum this file matches on, in one table, because the drift it closes was
|
|
360
|
+
# exactly a table kept twice. `scenario-format.md` has declared `blocked` for a
|
|
361
|
+
# screen since the value was introduced -- with a paragraph of rules of its own
|
|
362
|
+
# -- and the matcher here listed four of the five values, so a `blocked` screen
|
|
363
|
+
# produced `status = None` and `U021` quietly stopped applying to it. An
|
|
364
|
+
# out-of-enum value must be an error, because the alternative is that it means
|
|
365
|
+
# nothing and nothing says so. `validate_status_enums_match_contract` compares
|
|
366
|
+
# this table against the contract's declaration and fails when either side moves
|
|
367
|
+
# alone.
|
|
368
|
+
STATUS_ENUMS = {
|
|
369
|
+
"SCN": ("draft", "validated", "implemented", "retired"),
|
|
370
|
+
"ST": ("proposed", "validated", "delivered", "dropped"),
|
|
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"),
|
|
388
|
+
}
|
|
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
|
+
|
|
402
|
+
# The canonical spelling of a field, and the short form in live use beside it.
|
|
403
|
+
# `U060`/`U061` read both on purpose -- their question is whether an observable
|
|
404
|
+
# EXISTS -- which left the vocabulary itself ungated: a project could spell a
|
|
405
|
+
# required field any way it liked and no code said so. The long spelling is
|
|
406
|
+
# canonical because it is what the contract declares and what both shipped
|
|
407
|
+
# templates seed, so a fresh install already writes it and the migration cost
|
|
408
|
+
# falls on nobody who followed the template. A warning, not an error: the
|
|
409
|
+
# observable is present and unambiguous, and failing a project over a synonym is
|
|
410
|
+
# the false positive that gets a whole family switched off.
|
|
411
|
+
FIELD_ALIASES = (
|
|
412
|
+
("SCN", "scenarios.md", "**Expected:**", "**Expected result:**"),
|
|
413
|
+
("ST", "foundation.md", "**Acceptance:**", "**Acceptance criteria:**"),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def product_state(body: str) -> tuple[str | None, str]:
|
|
418
|
+
"""The `Product:` value read as `(state, signal)`.
|
|
419
|
+
|
|
420
|
+
`(None, "")` when the field is absent, and absence is not a finding: it
|
|
421
|
+
means `unobserved`, the honest default. A field that is PRESENT and says
|
|
422
|
+
nothing is a different thing and is reported -- the same distinction
|
|
423
|
+
`field_body` draws between `None` and `""`.
|
|
424
|
+
"""
|
|
425
|
+
m = PRODUCT_FIELD.search(body)
|
|
426
|
+
if m is None:
|
|
427
|
+
return (None, "")
|
|
428
|
+
m2 = re.match(r"\s*([A-Za-z][\w-]*)\s*[—–:-]?\s*(.*)$", m.group(1).strip(),
|
|
429
|
+
re.DOTALL)
|
|
430
|
+
if m2 is None:
|
|
431
|
+
return ("", "")
|
|
432
|
+
return (m2.group(1).lower(), m2.group(2).strip())
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def check_product_state(scenarios: str, foundation: str) -> None:
|
|
436
|
+
"""The outcome state, which nothing an audit can produce is allowed to move."""
|
|
437
|
+
layers = {"SCN": (scenarios, "scenarios.md"), "ST": (foundation, "foundation.md")}
|
|
438
|
+
for prefix in PRODUCT_LAYERS:
|
|
439
|
+
text, name = layers[prefix]
|
|
440
|
+
for sid, body in sorted(entry_blocks(text, prefix).items()):
|
|
441
|
+
state, signal = product_state(body)
|
|
442
|
+
if state is None:
|
|
443
|
+
continue # absent == `unobserved`; no floor asks for the field
|
|
444
|
+
if state not in PRODUCT_STATES:
|
|
445
|
+
err(f"[U066] {name}: {sid} declares Product "
|
|
446
|
+
f"'{state or '(nothing)'}', which is not one of "
|
|
447
|
+
f"{' | '.join(PRODUCT_STATES)} — an unrecognised value reads "
|
|
448
|
+
f"as no product state at all, which is how a shipped scenario "
|
|
449
|
+
f"silently counts as a validated one")
|
|
450
|
+
continue
|
|
451
|
+
if state not in PRODUCT_EVIDENCED:
|
|
452
|
+
continue
|
|
453
|
+
# Three disjoint guards rather than an elif chain: each fixture must
|
|
454
|
+
# be able to fire ONE of them, so that disabling one turns exactly
|
|
455
|
+
# its own case red (standing instruction #5).
|
|
456
|
+
if not stated(signal):
|
|
457
|
+
err(f"[U067] {name}: {sid} claims Product '{state}' and names no "
|
|
458
|
+
f"signal — an outcome state is a claim about the world, and it "
|
|
459
|
+
f"has to say which observation supports it")
|
|
460
|
+
# Punctuation is dropped before the residue is judged: `stated()`
|
|
461
|
+
# reads a lone comma as content, so two citations separated by one
|
|
462
|
+
# went clean until the plant said otherwise.
|
|
463
|
+
residue = re.sub(r"[^\w]+", " ", CITED_SPAN.sub(" ", signal))
|
|
464
|
+
if stated(signal) and not stated(residue):
|
|
465
|
+
err(f"[U068] {name}: {sid} offers '{signal}' as an outcome signal, "
|
|
466
|
+
f"and that is a code citation — delivery proof, which "
|
|
467
|
+
f"`Status` and `Coverage` already carry. Pretending delivery "
|
|
468
|
+
f"proof is outcome proof is the one thing this field exists "
|
|
469
|
+
f"to prevent")
|
|
470
|
+
if stated(signal) and any(p.search(signal) for p in AUDIT_EVIDENCE):
|
|
471
|
+
err(f"[U068] {name}: {sid} offers an audit's own output as an "
|
|
472
|
+
f"outcome signal — an audit reads code and cannot know whether "
|
|
473
|
+
f"shipping this changed anything for a user")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def check_field_vocabulary(scenarios: str, foundation: str) -> None:
|
|
477
|
+
"""A required field is spelled the way the contract names it."""
|
|
478
|
+
layers = {"SCN": scenarios, "ST": foundation}
|
|
479
|
+
for prefix, name, alias, canonical in FIELD_ALIASES:
|
|
480
|
+
for sid, body in sorted(entry_blocks(layers[prefix], prefix).items()):
|
|
481
|
+
if alias in body:
|
|
482
|
+
warn(f"[U069] {name}: {sid} spells the field '{alias}'; the "
|
|
483
|
+
f"contract's name is '{canonical}'. The observable is read "
|
|
484
|
+
f"either way — this is the vocabulary, so a required field "
|
|
485
|
+
f"cannot be spelled any way a project likes with nothing "
|
|
486
|
+
f"saying so")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def check_status_enums(scenarios: str, foundation: str, screens: str,
|
|
490
|
+
flows: str = "", vision: str = "") -> None:
|
|
491
|
+
"""A status outside its layer's enum is refused, not read as no status."""
|
|
492
|
+
for text, prefix, name in ((scenarios, "SCN", "scenarios.md"),
|
|
493
|
+
(foundation, "ST", "foundation.md"),
|
|
494
|
+
(screens, "SCR", "screens.md"),
|
|
495
|
+
(foundation, "P", "foundation.md"),
|
|
496
|
+
(foundation, "JTBD", "foundation.md")):
|
|
497
|
+
allowed = STATUS_ENUMS[prefix]
|
|
498
|
+
for sid, body in sorted(entry_blocks(text, prefix).items()):
|
|
499
|
+
status = declared_status(body)
|
|
500
|
+
if status is None or status in allowed:
|
|
501
|
+
continue
|
|
502
|
+
err(f"[U070] {name}: {sid} declares Status '{status}', which is not "
|
|
503
|
+
f"one of {' | '.join(allowed)} — an unrecognised status reads as "
|
|
504
|
+
f"no status, and every rule keyed on one silently stops applying")
|
|
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
|
+
|
|
573
|
+
|
|
118
574
|
WEB_SURFACE_FIELDS = ("Route", "Answers", "Indexable", "Without JS", "Entity")
|
|
119
575
|
|
|
120
576
|
|
|
@@ -261,6 +717,10 @@ def main() -> int:
|
|
|
261
717
|
screens = read(ux / "screens.md")
|
|
262
718
|
scenarios = read(ux / "scenarios.md")
|
|
263
719
|
|
|
720
|
+
# Cited paths are project-relative, so they resolve against the tree the
|
|
721
|
+
# ux directory sits in — the same derivation `check_links` already uses.
|
|
722
|
+
project_root = ux.parent.parent if ux.name == "ux" else ux.parent
|
|
723
|
+
|
|
264
724
|
has_flows = bool(ids(flows, "FLW"))
|
|
265
725
|
has_screens = bool(ids(screens, "SCR"))
|
|
266
726
|
has_stories = bool(ids(foundation, "ST"))
|
|
@@ -352,12 +812,13 @@ def main() -> int:
|
|
|
352
812
|
# --- Screen-level: Figma frames, coverage, drift status ---
|
|
353
813
|
if has_screens:
|
|
354
814
|
fig = figma_enabled(foundation)
|
|
355
|
-
|
|
356
|
-
# ux directory sits in — the same derivation `check_links` already uses.
|
|
357
|
-
screens_root = ux.parent.parent if ux.name == "ux" else ux.parent
|
|
815
|
+
screens_root = project_root
|
|
358
816
|
for sid, body in screen_blocks(screens).items():
|
|
359
|
-
|
|
360
|
-
|
|
817
|
+
# Read by value, not matched against a copy of the enum: the copy
|
|
818
|
+
# was one value short of the contract for as long as `blocked`
|
|
819
|
+
# existed, and an unmatched status silently became no status.
|
|
820
|
+
# `check_status_enums` owns the enum for all three layers now.
|
|
821
|
+
status = declared_status(body)
|
|
361
822
|
if status == "retired":
|
|
362
823
|
continue
|
|
363
824
|
# every state row present in the States table
|
|
@@ -381,15 +842,23 @@ def main() -> int:
|
|
|
381
842
|
# about a route a task had built the day before. Two fields of one
|
|
382
843
|
# record contradicting each other, neither checked against the other.
|
|
383
844
|
if cov and not cov.lower().startswith("none"):
|
|
384
|
-
|
|
385
|
-
|
|
845
|
+
# The line suffix is part of a citation, not of the path --
|
|
846
|
+
# `coverage_claim` owns that, for this layer and the one above.
|
|
847
|
+
unfalsifiable, missing, beyond = coverage_claim(cov, screens_root)
|
|
848
|
+
if unfalsifiable:
|
|
386
849
|
warn(f"[U055] screens.md: {sid} claims Coverage '{cov}' and names no file")
|
|
387
|
-
for rel in
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
850
|
+
for rel in missing:
|
|
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")
|
|
856
|
+
|
|
857
|
+
check_observables(scenarios, foundation, project_root)
|
|
858
|
+
check_product_state(scenarios, foundation)
|
|
859
|
+
check_field_vocabulary(scenarios, foundation)
|
|
860
|
+
check_status_enums(scenarios, foundation, screens, flows, vision)
|
|
861
|
+
check_jobs(foundation, scenarios, flows, screens)
|
|
393
862
|
check_vision(ux, vision)
|
|
394
863
|
check_web_surface(screens, flows)
|
|
395
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.
|
package/templates/foundation.md
CHANGED