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 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
- 37 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
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, 2 errors.
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, 1 warnings, 2 errors. Clean means checkable, not good — the
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, 1 warnings, 2 errors. Clean means *checkable*, not *good* —
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, user stories) is the WHY
10
- layer scenarios trace to.
11
- - `docs/ux/screens.md` is the UI map: every screen + state with its wireframe
12
- and (Figma on) its frame link. A screen that changes in code changes here in
13
- the SAME change.
14
- - Run the linter after any UX change and before calling work done:
15
- `python3 docs/ux/lint.py`. It must passdrift must not merge; wire it
16
- into CI/pre-commit.
17
- - Run `python3 docs/ux/doctor.py` when picking this base up after a while.
18
- The linter checks the chain against itself and stays silent when the whole
19
- base is written to an old contract; the doctor is what notices. Fix a
20
- mixed or stale contract before designing on top of it.
21
- - Any change that touches user-facing behavior MUST update
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 mapstates, 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 (conflicts, overlaps, gaps), approved. An idea
28
- serving no job is challenged, not silently accepted.
29
- - Do NOT write interface code until the UX workflow is done first: the
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 one locked style pack, recorded once in `screens.md`
35
- Design system and used by every frame and every built screen — picked with
36
- the **sheleg-design** companion skill (`npx sheleg-design-skill`) when the
37
- project has no design system of its own. Recommended, never forced; a
38
- palette invented per screen is visual drift.
39
- - Workflows: `ux-foundation` rule (WHY), `ux-flows` rule (flows + Figma
40
- mockups + style pack), `ux-scenarios` rule (scenario base), `ux-audit` rule
41
- (evidence-backed audits).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "scripts": {
5
5
  "test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py"
6
6
  },
@@ -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, 1 warnings only, 2 any error.
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 _q, lit in LITERAL_RE.findall(body) if _looks_like_copy(lit)
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
- elif row["text"] not in body and row["text"].strip() not in body:
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 _quote, literal in LITERAL_RE.findall(_strip_comments(text, suffix)):
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-B032 -- every figure traces to a row, every row to a source."""
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 = " ".join(r["value"] for r in rows if r["public"].lower() != "no")
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.replace(" ", ""):
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
- return 1 if findings else 0
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:' entry ids, in order."""
57
- return re.findall(rf"^###\s+({prefix}-\d+):", text, re.MULTILINE)
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
- parts = re.split(rf"^###\s+({prefix}-\d+):", text, flags=re.MULTILINE)
96
- # parts = [pre, id1, body1, id2, body2, ...]
97
- for i in range(1, len(parts), 2):
98
- sid = parts[i]
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[sid] = body
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
- CITED_PATH = re.compile(r"\b([\w.-]+(?:/[\w.-]+)+\.[A-Za-z][\w]{0,4}(?::\d+)?)")
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, cited_paths_that_do_not_exist)`. One owner for two
122
- layers: `screens.md` and the requirement layer above it ask the same two
123
- questions of the same field, and an answer that differed between them would
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 = [rel for rel in cited if not (root / rel.split(":", 1)[0]).exists()]
128
- return (not cited, missing)
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) -> None:
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)
@@ -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 clean; wire it into CI or pre-commit alongside the
61
- UX linter so copy drift cannot merge.
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.