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 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
- 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.44.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,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
- # Cited paths are project-relative, so they resolve against the tree the
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
- status_m = re.search(r"\*\*Status:\*\*\s*(designed|built|drifted|retired)", body)
360
- status = status_m.group(1) if status_m else None
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
- cited = CITED_PATH.findall(cov)
385
- if not cited:
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 cited:
388
- # The line suffix is part of a citation, not of the path.
389
- target = (screens_root / rel.split(":", 1)[0])
390
- if not target.exists():
391
- err(f"[U056] screens.md: {sid} cites '{rel}', which does not exist")
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)
@@ -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.
@@ -67,4 +67,5 @@ this section just records the on/off choice and the file location.
67
67
  - **Priority:** must | should | could
68
68
  - **Kill criteria:** <metric below threshold by date -> drop | iterate, or omit>
69
69
  - **Status:** proposed
70
+ - **Product:** unobserved
70
71
  -->
@@ -35,4 +35,5 @@ action per step paired with the observable system response:
35
35
  - **Telemetry:** <`object_action` event + params, or omit if none>
36
36
  - **Status:** draft
37
37
  - **Coverage:** none yet
38
+ - **Product:** unobserved
38
39
  -->