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 +85 -0
- package/package.json +1 -1
- package/plugins/super-ux/scripts/ux_lint.py +366 -3
- package/templates/foundation.md +1 -0
- package/templates/scenarios.md +1 -0
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
|
@@ -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
|
-
|
|
322
|
-
|
|
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)
|
package/templates/foundation.md
CHANGED