super-ux 0.43.0 → 0.45.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,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.45.0 — 2026-08-19
4
+
5
+ **The requirement layer could not see a requirement with no observable.** The contract said
6
+ one was unfinished without it; nothing read for one. `ux_lint.py` never opened a scenario or
7
+ user-story body — no rule looked for `Expected result`, `Acceptance criteria` or a success
8
+ metric — while the layer *below* it, screens, had carried that check since U055/U056.
9
+
10
+ The dogfood is the argument: this pack's own 15 scenarios all read `Status: implemented`,
11
+ **none cited any code**, no test touched `bin/super-ux.js`, and `npm test` exited 0 over all
12
+ of it. A chain that demands the layers before code could not tell that its own chain closed
13
+ on nothing.
14
+
15
+ ### U060–U065 — the requirement layer gets read
16
+
17
+ A scenario or story with no observable is refused; a scenario claiming `implemented` must
18
+ cite code that resolves. `U055/U056` and `U064/U065` now share one owner (`coverage_claim`),
19
+ so two layers cannot answer the same question differently. All 15 own scenarios now cite
20
+ implementing ranges in `bin/super-ux.js`, each read rather than recalled.
21
+
22
+ ### `Product:` — a shipped scenario stops counting as a validated one
23
+
24
+ `unobserved | observed | contradicted`, distinct from `Status`, with **no floor and no
25
+ target**: absence means `unobserved`, and neither `unobserved` nor `contradicted` fails
26
+ anything. Outcome evidence often cannot exist yet, and saying so is not a defect.
27
+
28
+ Four things stop an audit promoting it: `U068` refuses everything an audit produces as an
29
+ outcome signal — a `file:line`, a PASS/FAIL verdict, a path into `docs/ux/audits/`; `U067`
30
+ refuses `observed` with no signal; `U066` refuses an out-of-enum value outright, because an
31
+ unrecognised value read as *no state* is how an enum drifts; and both homes of the
32
+ after-a-run step carry **The audit never writes `Product:`**, with a gate that fails if
33
+ either loses the sentence.
34
+
35
+ **All 15 own scenarios now read `unobserved`.** Before this they read as fifteen validated
36
+ bets, because `implemented` was the only state the chain had.
37
+
38
+ ### Found while there
39
+
40
+ The screens status enum had **already** drifted: the contract declared five values and the
41
+ linter matched four, so a `blocked` screen read as having *no* status and `U021` silently
42
+ stopped applying. Fixed. Two further instances of the same class filed rather than hidden.
43
+
44
+ The long spelling (`**Expected result:**`) is canonical; the short forms are still read, and
45
+ `U069` warns rather than errors — failing a live project over a synonym is the false positive
46
+ that gets a whole family switched off.
47
+
48
+ ## 0.44.0 — 2026-08-17
49
+
50
+ **Three lint codes for the two layers whose claims nothing could check.** `U055`, `U056` and
51
+ `U057`; the linter goes 43 → **54** fixtures, each code carrying the defect it must catch and
52
+ the shapes it must **not**.
53
+
54
+ ### A `Coverage` claim is a claim about code (`U055`, `U056`) — closes #6
55
+
56
+ Measured in a real project: five screens carried `partial` in the index while their entries
57
+ named no file, and one said `none — no per-account memberships route exists` about a route a
58
+ task had built the day before. **Two fields of one record contradicted each other for a day,
59
+ and neither was checked against the other.**
60
+
61
+ - `U055` (warn) — a `Coverage` value other than `none` that names no file. A claim about code
62
+ citing no code cannot be checked by a script *or* by a reader, who has nowhere to go to
63
+ disagree.
64
+ - `U056` (error) — a cited path that does not exist. A stale citation is the same defect with
65
+ the means to notice.
66
+
67
+ **The path pattern is deliberately narrow and that was the design decision, not a detail.** It
68
+ requires a slash **and** an extension, so `src/routes/x.tsx:12` matches while *"partial —
69
+ client/server split"* and *"the route is built"* do not. A wider pattern was tried first and
70
+ flagged three correct prose entries — the false positive that gets a rule switched off inside a
71
+ day, taking the real check with it.
72
+
73
+ ### A flow's verdict must be measurable, not inherited (`U057`) — closes #7
74
+
75
+ The chain is foundation → flows → screens → scenarios, and audits in practice attach to the two
76
+ **ends**. Flows sit between and are the only layer with no artefact of their own: a flow is a
77
+ path across screens, so the cheap thing is to derive its verdict from theirs — and a derived
78
+ verdict presented as a measured one is how one project's `flows.md` carried **no code verdict
79
+ for 42 flows across three weeks**, its header delegating to an audit that had itself derived
80
+ them, and a later scenario walk refuting that audit on every clause without touching flows.
81
+
82
+ `U057` does not verdict a flow. It reports the flows for which **no verdict can be measured at
83
+ all** — the state that was invisible. A flow naming no screen stays `U010`'s subject, not this
84
+ one.
85
+
86
+ Run against this repository's own chain: clean.
87
+
3
88
  ## 0.43.0 — 2026-08-17
4
89
 
5
90
  **`ux-flows` has told agents to sweep real products before inventing a flow since 0.35.0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.43.0",
3
+ "version": "0.45.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
  },
@@ -102,11 +102,320 @@ def entry_blocks(text: str, prefix: str) -> dict[str, str]:
102
102
  return out
103
103
 
104
104
 
105
+ # A cited path, deliberately narrow: it must carry a slash AND an extension, so
106
+ # `src/routes/x.tsx:12` matches and prose like "partial — client/server split"
107
+ # or "the route is built" does not. Widening this to any slash-bearing token was
108
+ # tried and flagged three correct prose entries, which is the false positive that
109
+ # gets a rule switched off.
110
+ CITED_PATH = re.compile(r"\b([\w.-]+(?:/[\w.-]+)+\.[A-Za-z][\w]{0,4}(?::\d+)?)")
111
+
112
+
105
113
  def screen_blocks(text: str) -> dict[str, str]:
106
114
  """Map SCR-id -> its section body."""
107
115
  return entry_blocks(text, "SCR")
108
116
 
109
117
 
118
+ def coverage_claim(cov: str, root: Path) -> tuple[bool, list[str]]:
119
+ """A `Coverage:` value read as the claim about code that it is.
120
+
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.
125
+ """
126
+ 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)
129
+
130
+
131
+ # --- The observable a requirement is unfinished without --------------------
132
+ #
133
+ # `Expected result` on a scenario and `Acceptance criteria` on a story are the
134
+ # contract's names for the same thing: the thing a reader can watch happen and
135
+ # disagree about. Until this block existed, no rule in this file opened a
136
+ # scenario or a story body at all -- the contract asked for an observable and
137
+ # nothing read for one, so a scenario could reach `implemented` having never
138
+ # said what would be true if it worked.
139
+ #
140
+ # Field spelling is read tolerantly on purpose. `Expected:` and `Acceptance:`
141
+ # are the short forms in live use (this pack's own chain writes both), and the
142
+ # question these codes ask is whether an observable EXISTS. A rule that failed a
143
+ # scenario for spelling its field the short way would be a different rule
144
+ # wearing this one's number, and it would be the false positive that gets the
145
+ # whole family switched off.
146
+ SCENARIO_OBSERVABLE = re.compile(r"\*\*Expected(?:\s+result)?:\*\*[ \t]*(.*)")
147
+ STORY_OBSERVABLE = re.compile(r"\*\*Acceptance(?:\s+criteria)?:\*\*[ \t]*(.*)")
148
+ FIELD_START = re.compile(r"^\s*(?:[-*]\s+)?\*\*[^*]+:\*\*")
149
+ PLACEHOLDER = re.compile(r"^(?:[-—–]+|<[^>]*>|tbd|todo|n/?a|\?+)$", re.IGNORECASE)
150
+
151
+ # A scenario or story that declares itself unfinished is not a finding: it has
152
+ # already said what these codes would say. `retired` and `dropped` are gone, and
153
+ # `draft`/`proposed` are the states in which the observable is still being
154
+ # written. Every other value -- including an unstated one -- is a claim to be
155
+ # finished, and that claim is what gets checked.
156
+ SCENARIO_UNFINISHED = ("draft", "retired")
157
+ STORY_UNFINISHED = ("proposed", "dropped")
158
+
159
+
160
+ def field_body(body: str, pattern: re.Pattern) -> str | None:
161
+ """A `**Field:**` value: the rest of its line plus the lines beneath it.
162
+
163
+ `None` when the field is absent, `""` when it carries nothing. Reading the
164
+ lines beneath is what makes `Acceptance criteria` legible at all -- the
165
+ contract puts its Given/When/Then bullets under the label, not after it.
166
+ """
167
+ m = pattern.search(body)
168
+ if m is None:
169
+ return None
170
+ out = [m.group(1).strip()]
171
+ for line in body[m.end():].splitlines():
172
+ if not line.strip():
173
+ continue
174
+ if FIELD_START.match(line) or line.lstrip().startswith("#"):
175
+ break
176
+ out.append(line.strip())
177
+ return "\n".join(part for part in out if part).strip()
178
+
179
+
180
+ def stated(value: str | None) -> bool:
181
+ """Does this field say anything? A placeholder is not an answer."""
182
+ if value is None:
183
+ return False
184
+ for line in value.splitlines():
185
+ line = line.strip().lstrip("-*").strip()
186
+ if line and not PLACEHOLDER.match(line):
187
+ return True
188
+ return False
189
+
190
+
191
+ def declared_status(body: str) -> str | None:
192
+ m = re.search(r"\*\*Status:\*\*\s*([A-Za-z-]+)", body)
193
+ return m.group(1).lower() if m else None
194
+
195
+
196
+ def check_observables(scenarios: str, foundation: str, root: Path) -> None:
197
+ """A requirement with no observable is unfinished, and says so or is told.
198
+
199
+ The reason this is a gate and not advice: an observable added after the
200
+ implementation is read is not a test of the requirement, it is a
201
+ description of the code. By then the only honest thing left to measure is
202
+ whether the code does what the code does. So the observable is required at
203
+ the layer that DEFINES the requirement, where it is still cheap, and the
204
+ citation that connects it to code is required the moment the requirement
205
+ claims to be implemented.
206
+ """
207
+ for sid, body in sorted(entry_blocks(scenarios, "SCN").items()):
208
+ status = declared_status(body)
209
+ if status in SCENARIO_UNFINISHED:
210
+ continue
211
+ if not stated(field_body(body, SCENARIO_OBSERVABLE)):
212
+ err(f"[U060] scenarios.md: {sid} states no observable result — add "
213
+ f"**Expected result:**. A requirement with no observable cannot be "
214
+ f"connected to evidence later without inventing the test after "
215
+ f"reading the implementation")
216
+ cov_m = re.search(r"\*\*Coverage:\*\*\s*(.+)", body)
217
+ cov = cov_m.group(1).strip() if cov_m else ""
218
+ if status == "implemented" and (not cov or cov.lower().startswith("none")):
219
+ warn(f"[U063] scenarios.md: {sid} is 'implemented' and names no code — "
220
+ f"the status claims an audit passed and nothing says against what")
221
+ if cov and not cov.lower().startswith("none"):
222
+ unfalsifiable, missing = coverage_claim(cov, root)
223
+ if unfalsifiable:
224
+ warn(f"[U064] scenarios.md: {sid} claims Coverage '{cov}' and names no file")
225
+ for rel in missing:
226
+ err(f"[U065] scenarios.md: {sid} cites '{rel}', which does not exist")
227
+
228
+ for sid, body in sorted(entry_blocks(foundation, "ST").items()):
229
+ status = declared_status(body)
230
+ if status in STORY_UNFINISHED:
231
+ continue
232
+ # `or ""` so the second question is never asked of None: the linter's
233
+ # promise is that malformed markdown is reported, not raised, and the
234
+ # only way to watch the first branch fail is to disable it.
235
+ criteria = field_body(body, STORY_OBSERVABLE) or ""
236
+ if not stated(criteria):
237
+ err(f"[U061] foundation.md: {sid} states no acceptance criteria — a story "
238
+ f"whose delivery nobody can witness is unfinished, whatever its status")
239
+ elif not re.search(r"\bthen\b", criteria, re.IGNORECASE):
240
+ warn(f"[U062] foundation.md: {sid} acceptance criteria name no outcome "
241
+ f"(no 'then') — the contract's shape is Given/When/Then, and the "
242
+ f"'then' is the only half an audit can check")
243
+
244
+
245
+ # --- Delivery proof is not outcome proof -----------------------------------
246
+ #
247
+ # `Status` is the DELIVERY state: does the code do what the scenario said. An
248
+ # audit PASS moves it, and that is the whole of what an audit can know.
249
+ # `Product` is the OUTCOME state: did shipping it change anything for a user.
250
+ # Only a signal from the world moves it.
251
+ #
252
+ # Until this block existed the pack had one state and the word `unobserved`
253
+ # appeared in it nowhere, so a shipped scenario silently counted as a validated
254
+ # one: `implemented` was read as "we were right about this", which is a claim
255
+ # nothing in the chain could support. Manifesto M-21 names the state that was
256
+ # missing rather than the check -- *some outcome evidence cannot exist until
257
+ # after release, so `unobserved` is a legitimate product state; pretending
258
+ # delivery proof is outcome proof is not.*
259
+ #
260
+ # So this field has NO FLOOR AND NO TARGET. Its absence means `unobserved` and
261
+ # is never a finding; a scenario may hold `unobserved` for its whole life and
262
+ # nothing here will fail. `contradicted` is not a failing gate either -- it is
263
+ # the information the field exists to make recordable, and what to do about it is
264
+ # a product decision no linter makes. Two things are refused, and both are
265
+ # claims rather than states: an outcome claim that names no observation (U067),
266
+ # and delivery proof handed in wearing an outcome label (U068). The two artefacts
267
+ # an audit produces are a `file:line` and a verdict, and U068 refuses both AS A
268
+ # SIGNAL -- which is what makes an audit PASS unable to promote this field in
269
+ # code as well as in doctrine.
270
+ PRODUCT_STATES = ("unobserved", "observed", "contradicted")
271
+ # The two layers that carry a hypothesis: a scenario is the unit that ships and
272
+ # a story is the unit that bets. A screen has a delivery state and no bet of its
273
+ # own, so it carries no product state, and this tuple is what
274
+ # `validate_status_enums_match_contract` reads rather than guessing from
275
+ # `STATUS_ENUMS`.
276
+ PRODUCT_LAYERS = ("SCN", "ST")
277
+ PRODUCT_EVIDENCED = ("observed", "contradicted")
278
+ PRODUCT_FIELD = re.compile(r"\*\*Product:\*\*[ \t]*(.*)")
279
+ # What an audit hands back, in the two forms the contract gives it. The verdict
280
+ # tokens are upper case and nothing else in these documents is, so they are safe
281
+ # to key on: `PASS` is a verdict, `passed` is prose, and that sentence is the
282
+ # negative fixture. The second is the audit report's own home, which the contract
283
+ # fixes at `docs/ux/audits/` — a path into it is the audit speaking, whatever
284
+ # prose is wrapped around it, and prose around a citation is how the first form
285
+ # would otherwise be smuggled past. Kept as a tuple so deleting one pattern turns
286
+ # exactly one fixture red.
287
+ AUDIT_EVIDENCE = (
288
+ re.compile(r"\b(?:PASS|FAIL|PARTIAL)\b"),
289
+ re.compile(r"\bdocs/ux/audits/[\w.-]+"),
290
+ )
291
+
292
+ # A code citation as this layer actually writes one, RANGES INCLUDED. `CITED_PATH`
293
+ # stops at the first line number on purpose -- it resolves a path, and the range
294
+ # is B-004's open work -- so subtracting only what it matches left `-296` behind
295
+ # and read it as prose. The plant caught that: `observed — bin/super-ux.js:235-296`
296
+ # went clean on the first attempt, and the range form is exactly what this pack's
297
+ # own chain writes. Used for the residue test below and nothing else.
298
+ CITED_SPAN = re.compile(
299
+ r"`?\b[\w.-]+(?:/[\w.-]+)+\.[A-Za-z][\w]{0,4}(?::\d+(?:-\d+)?)?`?"
300
+ )
301
+
302
+ # Every enum this file matches on, in one table, because the drift it closes was
303
+ # exactly a table kept twice. `scenario-format.md` has declared `blocked` for a
304
+ # screen since the value was introduced -- with a paragraph of rules of its own
305
+ # -- and the matcher here listed four of the five values, so a `blocked` screen
306
+ # produced `status = None` and `U021` quietly stopped applying to it. An
307
+ # out-of-enum value must be an error, because the alternative is that it means
308
+ # nothing and nothing says so. `validate_status_enums_match_contract` compares
309
+ # this table against the contract's declaration and fails when either side moves
310
+ # alone.
311
+ STATUS_ENUMS = {
312
+ "SCN": ("draft", "validated", "implemented", "retired"),
313
+ "ST": ("proposed", "validated", "delivered", "dropped"),
314
+ "SCR": ("designed", "blocked", "built", "drifted", "retired"),
315
+ }
316
+
317
+ # The canonical spelling of a field, and the short form in live use beside it.
318
+ # `U060`/`U061` read both on purpose -- their question is whether an observable
319
+ # EXISTS -- which left the vocabulary itself ungated: a project could spell a
320
+ # required field any way it liked and no code said so. The long spelling is
321
+ # canonical because it is what the contract declares and what both shipped
322
+ # templates seed, so a fresh install already writes it and the migration cost
323
+ # falls on nobody who followed the template. A warning, not an error: the
324
+ # observable is present and unambiguous, and failing a project over a synonym is
325
+ # the false positive that gets a whole family switched off.
326
+ FIELD_ALIASES = (
327
+ ("SCN", "scenarios.md", "**Expected:**", "**Expected result:**"),
328
+ ("ST", "foundation.md", "**Acceptance:**", "**Acceptance criteria:**"),
329
+ )
330
+
331
+
332
+ def product_state(body: str) -> tuple[str | None, str]:
333
+ """The `Product:` value read as `(state, signal)`.
334
+
335
+ `(None, "")` when the field is absent, and absence is not a finding: it
336
+ means `unobserved`, the honest default. A field that is PRESENT and says
337
+ nothing is a different thing and is reported -- the same distinction
338
+ `field_body` draws between `None` and `""`.
339
+ """
340
+ m = PRODUCT_FIELD.search(body)
341
+ if m is None:
342
+ return (None, "")
343
+ m2 = re.match(r"\s*([A-Za-z][\w-]*)\s*[—–:-]?\s*(.*)$", m.group(1).strip(),
344
+ re.DOTALL)
345
+ if m2 is None:
346
+ return ("", "")
347
+ return (m2.group(1).lower(), m2.group(2).strip())
348
+
349
+
350
+ def check_product_state(scenarios: str, foundation: str) -> None:
351
+ """The outcome state, which nothing an audit can produce is allowed to move."""
352
+ layers = {"SCN": (scenarios, "scenarios.md"), "ST": (foundation, "foundation.md")}
353
+ for prefix in PRODUCT_LAYERS:
354
+ text, name = layers[prefix]
355
+ for sid, body in sorted(entry_blocks(text, prefix).items()):
356
+ state, signal = product_state(body)
357
+ if state is None:
358
+ continue # absent == `unobserved`; no floor asks for the field
359
+ if state not in PRODUCT_STATES:
360
+ err(f"[U066] {name}: {sid} declares Product "
361
+ f"'{state or '(nothing)'}', which is not one of "
362
+ f"{' | '.join(PRODUCT_STATES)} — an unrecognised value reads "
363
+ f"as no product state at all, which is how a shipped scenario "
364
+ f"silently counts as a validated one")
365
+ continue
366
+ if state not in PRODUCT_EVIDENCED:
367
+ continue
368
+ # Three disjoint guards rather than an elif chain: each fixture must
369
+ # be able to fire ONE of them, so that disabling one turns exactly
370
+ # its own case red (standing instruction #5).
371
+ if not stated(signal):
372
+ err(f"[U067] {name}: {sid} claims Product '{state}' and names no "
373
+ f"signal — an outcome state is a claim about the world, and it "
374
+ f"has to say which observation supports it")
375
+ # Punctuation is dropped before the residue is judged: `stated()`
376
+ # reads a lone comma as content, so two citations separated by one
377
+ # went clean until the plant said otherwise.
378
+ residue = re.sub(r"[^\w]+", " ", CITED_SPAN.sub(" ", signal))
379
+ if stated(signal) and not stated(residue):
380
+ err(f"[U068] {name}: {sid} offers '{signal}' as an outcome signal, "
381
+ f"and that is a code citation — delivery proof, which "
382
+ f"`Status` and `Coverage` already carry. Pretending delivery "
383
+ f"proof is outcome proof is the one thing this field exists "
384
+ f"to prevent")
385
+ if stated(signal) and any(p.search(signal) for p in AUDIT_EVIDENCE):
386
+ err(f"[U068] {name}: {sid} offers an audit's own output as an "
387
+ f"outcome signal — an audit reads code and cannot know whether "
388
+ f"shipping this changed anything for a user")
389
+
390
+
391
+ def check_field_vocabulary(scenarios: str, foundation: str) -> None:
392
+ """A required field is spelled the way the contract names it."""
393
+ layers = {"SCN": scenarios, "ST": foundation}
394
+ for prefix, name, alias, canonical in FIELD_ALIASES:
395
+ for sid, body in sorted(entry_blocks(layers[prefix], prefix).items()):
396
+ if alias in body:
397
+ warn(f"[U069] {name}: {sid} spells the field '{alias}'; the "
398
+ f"contract's name is '{canonical}'. The observable is read "
399
+ f"either way — this is the vocabulary, so a required field "
400
+ f"cannot be spelled any way a project likes with nothing "
401
+ f"saying so")
402
+
403
+
404
+ def check_status_enums(scenarios: str, foundation: str, screens: str) -> None:
405
+ """A status outside its layer's enum is refused, not read as no status."""
406
+ for text, prefix, name in ((scenarios, "SCN", "scenarios.md"),
407
+ (foundation, "ST", "foundation.md"),
408
+ (screens, "SCR", "screens.md")):
409
+ allowed = STATUS_ENUMS[prefix]
410
+ for sid, body in sorted(entry_blocks(text, prefix).items()):
411
+ status = declared_status(body)
412
+ if status is None or status in allowed:
413
+ continue
414
+ err(f"[U070] {name}: {sid} declares Status '{status}', which is not "
415
+ f"one of {' | '.join(allowed)} — an unrecognised status reads as "
416
+ f"no status, and every rule keyed on one silently stops applying")
417
+
418
+
110
419
  WEB_SURFACE_FIELDS = ("Route", "Answers", "Indexable", "Without JS", "Entity")
111
420
 
112
421
 
@@ -253,6 +562,10 @@ def main() -> int:
253
562
  screens = read(ux / "screens.md")
254
563
  scenarios = read(ux / "scenarios.md")
255
564
 
565
+ # Cited paths are project-relative, so they resolve against the tree the
566
+ # ux directory sits in — the same derivation `check_links` already uses.
567
+ project_root = ux.parent.parent if ux.name == "ux" else ux.parent
568
+
256
569
  has_flows = bool(ids(flows, "FLW"))
257
570
  has_screens = bool(ids(screens, "SCR"))
258
571
  has_stories = bool(ids(foundation, "ST"))
@@ -289,6 +602,33 @@ def main() -> int:
289
602
  for orphan in sorted(screen_ids - used):
290
603
  warn(f"[U011] screens.md: {orphan} is used by no flow (orphan)")
291
604
 
605
+ # --- A flow's verdict must be measurable, not inherited -------------
606
+ #
607
+ # The layer order is foundation → flows → screens → scenarios, and audits
608
+ # in practice attach to the two ENDS. Flows sit between and are the only
609
+ # layer with no artefact of their own to measure: a flow is a path across
610
+ # screens, so the cheap thing is to derive its verdict from theirs — and a
611
+ # derived verdict presented as a measured one is what let one project's
612
+ # `flows.md` carry no code verdict for 42 flows across three weeks, its
613
+ # header delegating to an audit that had itself derived them.
614
+ #
615
+ # This does not verdict a flow. It reports the flows for which no verdict
616
+ # can be measured at all, which is the state that was invisible.
617
+ for fid, fbody in sorted(entry_blocks(flows, "FLW").items()):
618
+ mine = [b for b in screen_blocks(screens).values()
619
+ if re.search(r"\*\*Used by:\*\*[^\n]*\b" + re.escape(fid) + r"\b", b)]
620
+ if not mine:
621
+ continue # a flow naming no screen is U010's subject, not this one
622
+ cited_anywhere = False
623
+ for b in mine:
624
+ m = re.search(r"\*\*Coverage:\*\*\s*(.+)", b)
625
+ if m and CITED_PATH.search(m.group(1)):
626
+ cited_anywhere = True
627
+ break
628
+ if not cited_anywhere:
629
+ warn(f"[U057] flows.md: {fid} has no screen naming an implementing "
630
+ f"file, so its coverage cannot be measured — only inherited")
631
+
292
632
  # --- Scenario traces resolve ---
293
633
  if ids(scenarios, "SCN"):
294
634
  story_ids = set(ids(foundation, "ST"))
@@ -317,9 +657,13 @@ def main() -> int:
317
657
  # --- Screen-level: Figma frames, coverage, drift status ---
318
658
  if has_screens:
319
659
  fig = figma_enabled(foundation)
660
+ screens_root = project_root
320
661
  for sid, body in screen_blocks(screens).items():
321
- status_m = re.search(r"\*\*Status:\*\*\s*(designed|built|drifted|retired)", body)
322
- status = status_m.group(1) if status_m else None
662
+ # Read by value, not matched against a copy of the enum: the copy
663
+ # was one value short of the contract for as long as `blocked`
664
+ # existed, and an unmatched status silently became no status.
665
+ # `check_status_enums` owns the enum for all three layers now.
666
+ status = declared_status(body)
323
667
  if status == "retired":
324
668
  continue
325
669
  # every state row present in the States table
@@ -335,7 +679,26 @@ def main() -> int:
335
679
  cov = cov_m.group(1).strip() if cov_m else ""
336
680
  if status == "built" and (not cov or cov.lower().startswith("none")):
337
681
  warn(f"[U021] screens.md: {sid} is 'built' but has no Coverage")
338
-
682
+ # A Coverage value other than `none` is a CLAIM ABOUT CODE, and a claim
683
+ # about code that names no code is unfalsifiable — not by a script and
684
+ # not by a reader, who has nowhere to go to disagree. Measured in a real
685
+ # project: five screens carried `partial` in the index while their
686
+ # entries named no file, and one of them said `none — no route exists`
687
+ # about a route a task had built the day before. Two fields of one
688
+ # record contradicting each other, neither checked against the other.
689
+ if cov and not cov.lower().startswith("none"):
690
+ # The line suffix is part of a citation, not of the path --
691
+ # `coverage_claim` owns that, for this layer and the one above.
692
+ unfalsifiable, missing = coverage_claim(cov, screens_root)
693
+ if unfalsifiable:
694
+ warn(f"[U055] screens.md: {sid} claims Coverage '{cov}' and names no file")
695
+ for rel in missing:
696
+ err(f"[U056] screens.md: {sid} cites '{rel}', which does not exist")
697
+
698
+ check_observables(scenarios, foundation, project_root)
699
+ check_product_state(scenarios, foundation)
700
+ check_field_vocabulary(scenarios, foundation)
701
+ check_status_enums(scenarios, foundation, screens)
339
702
  check_vision(ux, vision)
340
703
  check_web_surface(screens, flows)
341
704
  check_links(ux)
@@ -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
  -->