super-ux 0.50.0 → 0.52.1
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 +131 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +201 -16
- package/plugins/super-ux/scripts/ux_lint.py +124 -1
- package/templates/brand/voice.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.52.1 — a ledger row that machine-read as something else
|
|
4
|
+
|
|
5
|
+
- **`B-029`'s board row carried an unescaped `|` inside a backticked span**
|
|
6
|
+
(`` `Kind: copy \| layout` ``), and markdown splits a table row on the pipe before any
|
|
7
|
+
inline parsing happens, so the code fence does not protect it. Every column after the
|
|
8
|
+
break shifted by one and `Status` read as whatever landed in its place: a `resolved` row
|
|
9
|
+
that a machine reads as something else, in one of the two files this pipeline treats as
|
|
10
|
+
its record. Escaped.
|
|
11
|
+
- **Found by the family umbrella's validator on this repository's own v0.52.0 tag**, while
|
|
12
|
+
bumping the pin — not by anything here. `validate_ledger_table_shape` now asks the same
|
|
13
|
+
question of both ledgers, and it is escape-aware because the first version was not: it
|
|
14
|
+
counted `\|` as a separator and reported fourteen broken rows in files that had one,
|
|
15
|
+
which is a detector nobody would have kept. Watched on the exact defect, and its limit
|
|
16
|
+
measured and written down — `backlog.md` carries its header twice, so the guard catches
|
|
17
|
+
a header that vanished entirely while a partial loss falls to the ratchet, which moved
|
|
18
|
+
4488 to 4475 on that plant.
|
|
19
|
+
|
|
20
|
+
## 0.52.0 — the dash rule stops checking the glyph, humanization stops being a mode, and the pack learns to assemble a product
|
|
21
|
+
|
|
22
|
+
- **`B062` judges the dash's role rather than its codepoint.** `DASH` was the single
|
|
23
|
+
character `—`, so a find-and-replace swapping it for `–` or for a hyphen with a space
|
|
24
|
+
each side cleared every finding and left the habit untouched. Measured in the wild on
|
|
25
|
+
trycomp.ai, 2026-08-30: twenty rhetorical dashes on one page and not one em dash among
|
|
26
|
+
them. `normalise_dash_spelling` now reduces every spelling to the canonical mark before
|
|
27
|
+
the three existing branches judge it, so the conjunction rule, the paired-dash rule,
|
|
28
|
+
the locale allowances and the range and direct-speech exemptions all apply unchanged to
|
|
29
|
+
all three spellings. The substitutions are length-preserving by construction and the
|
|
30
|
+
finding quotes the raw text, so a report shows the characters the author actually typed
|
|
31
|
+
rather than a mark they never used.
|
|
32
|
+
- **A dash alone in a table cell is an empty string, and now the code agrees.** `AT-06`
|
|
33
|
+
has listed the no-value cell dash among the grammatical exemptions since it was
|
|
34
|
+
written, and nothing implemented it: in any strict locale `| landing | — |` was
|
|
35
|
+
reported. `TABLE_CELL_DASH_RE` blanks it before judgement. The doctrine is corrected in
|
|
36
|
+
the same change, because it opened "The em dash is banned where it is rhetorical" while
|
|
37
|
+
the rule it states is about the mark's role.
|
|
38
|
+
- **`landing-pages.md`, a new reference in `copywriting`: how a landing page is
|
|
39
|
+
assembled, not how its sentences are edited.** Twenty rules with ids `LP-01..LP-20`
|
|
40
|
+
across five layers — the offer, awareness and the shape it demands, the proof ladder,
|
|
41
|
+
the action and the risk beside it, and the page as a machine — each carrying a verbatim
|
|
42
|
+
example from a page that shipped. It closes six gaps the pack had: no offer
|
|
43
|
+
architecture, no awareness-to-structure map, no proof ladder, no CTA or risk mechanics,
|
|
44
|
+
no answer for a category nobody searches for yet, and no readiness criterion. The file
|
|
45
|
+
ends in a runnable readiness check and says which of its rules no command can decide.
|
|
46
|
+
- **`validate_landing_coverage` is the answer to "what would notice if this fell
|
|
47
|
+
behind?"** Table rows against sections in both directions, duplicate ids, a gap in the
|
|
48
|
+
sequence, an id the readiness check names that no section defines, and the
|
|
49
|
+
`copywriting/SKILL.md` link without which the reference ships to nobody. Four defects
|
|
50
|
+
planted, each caught by its own message and reverted. The validator grows 4174 → 4197
|
|
51
|
+
checks, `brand_lint_test.py` 89 → 93, and `test/floors.json` ratchets with both.
|
|
52
|
+
- **The evidence is in the repository, not in a summary of it.** `docs/research/landings/`
|
|
53
|
+
carries the three teardowns the rules were extracted from — crowdreply.io, trycomp.ai
|
|
54
|
+
and zerorank.ai, read 2026-08-30 as raw markup, rendered text and in a browser. Three
|
|
55
|
+
independent pages committed the same four defects, and those four are the ones stated
|
|
56
|
+
as classes: answers absent from the markup, one action under several labels, numbers
|
|
57
|
+
that disagree with themselves, and the strongest proof filed one click behind the claim
|
|
58
|
+
it proves.
|
|
59
|
+
|
|
3
60
|
## 0.50.0 — the templates ship where the texts say they are
|
|
4
61
|
|
|
5
62
|
- **`templates/` now travels with everything that names it** (SUX-01, family audit
|
|
@@ -34,6 +91,80 @@
|
|
|
34
91
|
umbrella routes on remains a literal substring of its description — verified with the
|
|
35
92
|
umbrella's own `advertised_check.js` (43/43, and watched failing against a dropped
|
|
36
93
|
«мокап»).
|
|
94
|
+
- **Humanization runs by default, in every mode that produces text.** It was a mode you
|
|
95
|
+
had to know to ask for, so the common path produced unswept drafts, and the one field
|
|
96
|
+
that touched the question -- `Humanization pass:` -- existed **only in the template**:
|
|
97
|
+
absent from `brand-contract.md`, absent from this pack's own `voice.md`, and read by no
|
|
98
|
+
code anywhere. Two fields now answer the two different questions the old one conflated.
|
|
99
|
+
`Humanization: on | off` is whether the pass runs, defaulting to `on` when absent;
|
|
100
|
+
`Humanization pass:` names which implementation, defaulting to `own`. Write, Edit and
|
|
101
|
+
Adapt each end in the sweep, positioned where it cannot be wasted: after the seven
|
|
102
|
+
sweeps in Edit, per surface in Adapt. `Humanize` survives as the standalone mode for
|
|
103
|
+
auditing text nobody is writing.
|
|
104
|
+
- **The state is visible in four places, because a pass that runs invisibly is
|
|
105
|
+
indistinguishable from one that did not.** `voice.md` records it, every delivery of copy
|
|
106
|
+
prints a status line naming the pass and what it changed, `/ux` and `/brand` report it
|
|
107
|
+
in their status blocks, and `B064` refuses the three states that are defects: an absent
|
|
108
|
+
field warns that the default applies unrecorded, an out-of-enum value errors, and `off`
|
|
109
|
+
with no `Humanization declined:` reason errors. The enum joins
|
|
110
|
+
`validate_status_enums_match_contract` from the day it was written rather than after it
|
|
111
|
+
drifted, which required generalising `DOC_ENUM_DECL_RE` past the hardcoded `**Status**`
|
|
112
|
+
so the next document-level enum is compared too.
|
|
113
|
+
- **`header_field` stops swallowing an aligned comment.** Standing instruction #3 fired on
|
|
114
|
+
the first enum field the templates seed with a literal value beside a comment: the
|
|
115
|
+
freshly seeded pack errored because the whole line, `# on | off; on is the default`
|
|
116
|
+
included, was the value. Two spaces or more before a `#` now ends the value; one space
|
|
117
|
+
does not, so a `Humanization declined: per ticket #431` keeps its reason.
|
|
118
|
+
- **Three references give the pack the assembly layer above its 241 tactics.**
|
|
119
|
+
`onboarding.md` (`ON-01..ON-18`, in `ux-flows`) orders the path to the first value and
|
|
120
|
+
rests on that value being defined first. `internal-screens.md` (`IS-01..IS-18`, in
|
|
121
|
+
`ux-flows`) covers the screens nobody A/B tests, where the four states are one design
|
|
122
|
+
and a list is a working surface rather than a directory of links.
|
|
123
|
+
`product-frameworks.md` (`PF-01..PF-12`, in `ux-foundation`) carries the named decision
|
|
124
|
+
models the pack had **none** of -- measured: Hook, Fogg, Kano, opportunity solution
|
|
125
|
+
tree, AARRR, north star, time to value, value proposition canvas, forces of progress,
|
|
126
|
+
switch interview, service blueprint and double diamond all returned zero occurrences --
|
|
127
|
+
each with the failure mode that makes it worth knowing rather than worth quoting.
|
|
128
|
+
- **One coverage gate over four id sets, not four copies of one.**
|
|
129
|
+
`validate_landing_coverage` became `validate_doctrine_set_coverage`, parameterised over
|
|
130
|
+
`LP`, `ON`, `IS` and `PF`: rows against sections both ways, duplicates, sequence gaps,
|
|
131
|
+
an id the readiness check names that no section defines, and the `SKILL.md` link without
|
|
132
|
+
which a reference ships to nobody. A fourth hand-written copy of one comparison is the
|
|
133
|
+
drift the gate exists to refuse.
|
|
134
|
+
- **The board went to zero.** All eleven open rows closed with a mechanism and a watched
|
|
135
|
+
plant each, not with a status change. `B-029` got the decision it asked for rather than a
|
|
136
|
+
filter: `strings.md` gains `Kind: copy | layout`, so an aligned option table is registered
|
|
137
|
+
and exempt from the rules that would be judging typesetting, and `docs/brand/lint.py` now
|
|
138
|
+
prints `brand pack is clean` where it printed a permanent warning. `B-028`: a `Coverage:`
|
|
139
|
+
citation may name its subject (`path:start-end symbolName`) and `U078` resolves it, catching
|
|
140
|
+
the drift a range cannot report about itself. `B-023`: `B005` asks `git log -L` about the
|
|
141
|
+
cited entries rather than the whole file, so a jobs edit stops warning about personas.
|
|
142
|
+
`B-005`: `U076` says a vision is still the seeded template instead of passing until someone
|
|
143
|
+
self-declares `approved`. `B-001`: the seeded linter carries `VISION_RULE_TEXT`, `U077` warns
|
|
144
|
+
when a target project's installed rule differs, and `validate_vision_rule_embed` keeps that
|
|
145
|
+
third copy honest. `B-030` and `B-031` get contract-parity gates; `B-032` gets `test/evals/`
|
|
146
|
+
with four cases whose anchors must still resolve.
|
|
147
|
+
- **Two of the eleven turned out not to be what the board said.** `B-021` asked for a
|
|
148
|
+
reachability arrow that had existed inside `validate_bp_index` since before the row was
|
|
149
|
+
filed; a duplicate check was written, caught by planting `BP-242`, and deleted rather than
|
|
150
|
+
shipped. What was genuinely missing ran the other way: a routing row pointing at a `BP-NNN`
|
|
151
|
+
the catalog does not define, invisible because every check in that function starts from the
|
|
152
|
+
catalog. `B-019` did not reproduce in four probes; it had been fixed by an uncredited
|
|
153
|
+
refactor, and the class is now mechanical — the report prints each distinct message once and
|
|
154
|
+
names any duplicate emission.
|
|
155
|
+
- **The refreshed code graph asserted a number nobody computed.** `B-022`'s refresh ran
|
|
156
|
+
unattended (1149 nodes, 1801 edges, built from `6348b641`), and 58 label fields said
|
|
157
|
+
`82 tags, 206 practices` about a catalog of 241 and an index that states no counts at all —
|
|
158
|
+
cached across refreshes, and read with the authority of a machine. Corrected, and gated by
|
|
159
|
+
`validate_graph_claims`, narrowed to `label`/`norm_label` because a node summarising a past
|
|
160
|
+
defect legitimately quotes an old number, and one of them does.
|
|
161
|
+
- **The bytecode cache could defeat a planted defect, which is this project's unit of
|
|
162
|
+
evidence.** CPython invalidates on `(mtime, size)`, so swapping `"B064"` for `"B999"` --
|
|
163
|
+
identical length -- and reverting inside the same second left a `.pyc` the interpreter
|
|
164
|
+
considered current: the revert ran the plant and the transcript reported a defect no
|
|
165
|
+
longer in the file. Observed live during this run's own plants. Both harnesses now set
|
|
166
|
+
`sys.dont_write_bytecode`, verified by plant-then-revert-in-second going red then green
|
|
167
|
+
with no cache clear.
|
|
37
168
|
|
|
38
169
|
## 0.49.1 — the skills handoff refuses the shadow it used to create
|
|
39
170
|
|
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
|
|
|
157
157
|
python3 docs/brand/lint.py
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
41 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
|
|
161
161
|
with no sourced fact, a field over its limit with the locale coefficient
|
|
162
162
|
applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
|
|
163
163
|
a rhetorical dash, a title that ends in a full stop, a locale that lags
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.1",
|
|
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 && node test/installer_test.js"
|
|
6
6
|
},
|
|
@@ -56,6 +56,24 @@ CONTRACT_VERSION = "v1"
|
|
|
56
56
|
# out-of-enum value that is neither refused nor accepted, and invisible.
|
|
57
57
|
VOICE_STATUSES = ("draft", "validated")
|
|
58
58
|
|
|
59
|
+
# Whether the humanization pass runs at all, which is a different question from
|
|
60
|
+
# `Humanization pass:`, which names WHICH implementation runs. `on` is the
|
|
61
|
+
# pack's default and it is a default rather than a preference: an unswept draft
|
|
62
|
+
# carries the markers `ai-tells.md` grades, and a reader registers them before
|
|
63
|
+
# they can name why. Turning it off is a legitimate decision and it IS a
|
|
64
|
+
# decision, so it leaves its reason in the file instead of a silence that reads
|
|
65
|
+
# identically to never having been asked.
|
|
66
|
+
HUMANIZATION_MODES = ("on", "off")
|
|
67
|
+
|
|
68
|
+
# What a registry row IS, which decides which rules may judge it. `copy` is
|
|
69
|
+
# language and every language rule applies. `layout` is a column-aligned table,
|
|
70
|
+
# an ASCII frame, a banner: a string whose shape carries the meaning, where a
|
|
71
|
+
# capitalisation or dash rule would be judging typesetting. It is registered
|
|
72
|
+
# rather than exempted from registration, so `B022` still knows it exists and
|
|
73
|
+
# nobody can hide a real string by calling it furniture. Absent, a row is
|
|
74
|
+
# `copy`, because the safe default is to check.
|
|
75
|
+
STRING_KINDS = ("copy", "layout")
|
|
76
|
+
|
|
59
77
|
SEVERITY_ERROR = "error"
|
|
60
78
|
SEVERITY_WARN = "warn"
|
|
61
79
|
|
|
@@ -122,10 +140,74 @@ def content_date(path: Path) -> str | None:
|
|
|
122
140
|
return None
|
|
123
141
|
|
|
124
142
|
|
|
143
|
+
# A trailing comment, aligned away from the value the way the seeded templates
|
|
144
|
+
# write one. Two spaces or more, because one space is prose: a `Humanization
|
|
145
|
+
# declined:` reason may legitimately say "per ticket #431", and truncating it
|
|
146
|
+
# would read as no reason at all. Found by standing instruction #3 on the first
|
|
147
|
+
# enum field the templates seeded with a literal value beside a comment.
|
|
148
|
+
HEADER_COMMENT_RE = re.compile(r"[ \t]{2,}#.*$")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def entry_line_span(text: str, ident: str) -> tuple[int, int] | None:
|
|
152
|
+
"""The 1-based line span of `### <ident>:` up to the next entry or section."""
|
|
153
|
+
lines = text.split("\n")
|
|
154
|
+
start = next((i for i, l in enumerate(lines, 1)
|
|
155
|
+
if l.startswith(f"### {ident}:")), None)
|
|
156
|
+
if start is None:
|
|
157
|
+
return None
|
|
158
|
+
for j in range(start, len(lines)):
|
|
159
|
+
if lines[j].startswith("### ") or lines[j].startswith("## "):
|
|
160
|
+
return start, j
|
|
161
|
+
return start, len(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cited_entries_date(path: Path, idents: list[str]) -> tuple[str | None, bool]:
|
|
165
|
+
"""When the CITED entries last changed, not when the file did.
|
|
166
|
+
|
|
167
|
+
`B-023`: `B005` asked whether `foundation.md` had moved since the voice was
|
|
168
|
+
calibrated, and every entry in it shares one file date, so editing the
|
|
169
|
+
monetization section raised a warning about personas nobody had touched.
|
|
170
|
+
Measured on SU-02: seven story entries changed, zero changed lines
|
|
171
|
+
mentioned any cited id, and the gate went red with a re-stamp as the only
|
|
172
|
+
way out. A warning that fires when nothing it protects moved is one people
|
|
173
|
+
learn to re-stamp past.
|
|
174
|
+
|
|
175
|
+
Returns `(date, exact)`. `exact` is False when the per-entry question could
|
|
176
|
+
not be answered -- no git, an untracked file, a renamed entry -- and the
|
|
177
|
+
whole-file date was used instead, which is the old behaviour. The caller
|
|
178
|
+
says which it got, because a narrowed check that silently widens is worse
|
|
179
|
+
than one that never narrowed.
|
|
180
|
+
"""
|
|
181
|
+
text = read(path) or ""
|
|
182
|
+
spans = [entry_line_span(text, i) for i in idents]
|
|
183
|
+
if not spans or any(sp is None for sp in spans):
|
|
184
|
+
return content_date(path), False
|
|
185
|
+
import subprocess
|
|
186
|
+
|
|
187
|
+
dates: list[str] = []
|
|
188
|
+
for span in spans:
|
|
189
|
+
assert span is not None
|
|
190
|
+
try:
|
|
191
|
+
out = subprocess.run(
|
|
192
|
+
["git", "log", "-L", f"{span[0]},{span[1]}:{path.name}",
|
|
193
|
+
"--format=%cs", "-s", "-1"],
|
|
194
|
+
cwd=path.parent, capture_output=True, text=True, timeout=15,
|
|
195
|
+
)
|
|
196
|
+
except (OSError, subprocess.SubprocessError):
|
|
197
|
+
return content_date(path), False
|
|
198
|
+
stamp = out.stdout.strip().split("\n")[0].strip()
|
|
199
|
+
if out.returncode != 0 or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", stamp):
|
|
200
|
+
return content_date(path), False
|
|
201
|
+
dates.append(stamp)
|
|
202
|
+
return (max(dates) if dates else None), True
|
|
203
|
+
|
|
204
|
+
|
|
125
205
|
def header_field(text: str, key: str) -> str | None:
|
|
126
|
-
"""A `Key: value` line from a file's header block."""
|
|
206
|
+
"""A `Key: value` line from a file's header block, comment stripped."""
|
|
127
207
|
match = re.search(rf"^{re.escape(key)}:\s*(.+?)\s*$", text, re.M)
|
|
128
|
-
|
|
208
|
+
if not match:
|
|
209
|
+
return None
|
|
210
|
+
return HEADER_COMMENT_RE.sub("", match.group(1)).strip() or None
|
|
129
211
|
|
|
130
212
|
|
|
131
213
|
def table_rows(text: str) -> list[list[str]]:
|
|
@@ -218,8 +300,39 @@ def check_contract(brand_dir: Path) -> list[Finding]:
|
|
|
218
300
|
f"check tests for the value instead of against `draft`",
|
|
219
301
|
))
|
|
220
302
|
|
|
303
|
+
# B064 -- the humanization pass: does it run, and was that decided.
|
|
304
|
+
# Three branches, one code, and each is reachable only in the state the
|
|
305
|
+
# others are not: absent, present-and-illegal, present-legal-and-off. A
|
|
306
|
+
# fixture for one therefore cannot be satisfied by another firing.
|
|
307
|
+
humanization = header_field(voice, "Humanization")
|
|
308
|
+
if humanization is None:
|
|
309
|
+
findings.append(Finding(
|
|
310
|
+
"B064", SEVERITY_WARN, "voice.md", 1,
|
|
311
|
+
f"no `Humanization:` field, so the pack default `on` applies and "
|
|
312
|
+
f"nobody has recorded whether that was chosen -- write "
|
|
313
|
+
f"`Humanization: on` to make the state readable, here and in every "
|
|
314
|
+
f"status this pack prints",
|
|
315
|
+
))
|
|
316
|
+
elif not unfilled(humanization):
|
|
317
|
+
if humanization not in HUMANIZATION_MODES:
|
|
318
|
+
findings.append(Finding(
|
|
319
|
+
"B064", SEVERITY_ERROR, "voice.md", 1,
|
|
320
|
+
f"`Humanization: {humanization}` is not one of "
|
|
321
|
+
f"{' | '.join(HUMANIZATION_MODES)} -- an unrecognised value "
|
|
322
|
+
f"leaves the pass in neither state, and the copy modes read "
|
|
323
|
+
f"this field to decide whether to run",
|
|
324
|
+
))
|
|
325
|
+
elif humanization == "off" and not header_field(voice, "Humanization declined"):
|
|
326
|
+
findings.append(Finding(
|
|
327
|
+
"B064", SEVERITY_ERROR, "voice.md", 1,
|
|
328
|
+
f"`Humanization: off` with no `Humanization declined:` line -- "
|
|
329
|
+
f"switching the pass off is a decision and it outlives whoever "
|
|
330
|
+
f"made it, so it carries the reason and the date rather than "
|
|
331
|
+
f"reading, later, as an oversight nobody can safely reverse",
|
|
332
|
+
))
|
|
333
|
+
|
|
221
334
|
strings = read(brand_dir / "strings.md") or ""
|
|
222
|
-
agreed = [r for r in
|
|
335
|
+
agreed = [r for r in registry(brand_dir) if r["status"] == "agreed"]
|
|
223
336
|
if status == "draft" and agreed:
|
|
224
337
|
findings.append(Finding(
|
|
225
338
|
"B003", SEVERITY_WARN, "voice.md", 1,
|
|
@@ -243,12 +356,19 @@ def check_contract(brand_dir: Path) -> list[Finding]:
|
|
|
243
356
|
calibrated = header_field(voice, "Last calibrated")
|
|
244
357
|
if foundation is not None and calibrated:
|
|
245
358
|
try:
|
|
246
|
-
changed =
|
|
359
|
+
changed, exact = cited_entries_date(
|
|
360
|
+
brand_dir.parent / "ux" / "foundation.md", ids
|
|
361
|
+
)
|
|
247
362
|
if changed and changed > calibrated:
|
|
363
|
+
what = (f"the entries this voice cites ({', '.join(ids)}) "
|
|
364
|
+
f"changed" if exact else
|
|
365
|
+
"foundation.md changed, and the per-entry question "
|
|
366
|
+
"could not be answered here so the whole file was "
|
|
367
|
+
"used")
|
|
248
368
|
findings.append(Finding(
|
|
249
369
|
"B005", SEVERITY_WARN, "voice.md", 1,
|
|
250
|
-
f"
|
|
251
|
-
f"
|
|
370
|
+
f"{what} on {changed}, after the voice was last "
|
|
371
|
+
f"calibrated on {calibrated}",
|
|
252
372
|
))
|
|
253
373
|
except OSError:
|
|
254
374
|
pass
|
|
@@ -303,6 +423,10 @@ def registry(brand_dir: Path) -> list[dict]:
|
|
|
303
423
|
rows.append({
|
|
304
424
|
"key": cells[0], "text": cells[1], "location": cells[2],
|
|
305
425
|
"scenario": cells[3], "status": cells[4],
|
|
426
|
+
# Sixth column, appended so a five-column registry written before
|
|
427
|
+
# this existed keeps every index it had. Absent means `copy`.
|
|
428
|
+
"kind": (cells[5].strip() if len(cells) > 5 and cells[5].strip()
|
|
429
|
+
else "copy"),
|
|
306
430
|
})
|
|
307
431
|
return rows
|
|
308
432
|
|
|
@@ -358,6 +482,16 @@ def check_terminology(brand_dir: Path) -> list[Finding]:
|
|
|
358
482
|
findings: list[Finding] = []
|
|
359
483
|
banned, terms, entities = dictionary(brand_dir)
|
|
360
484
|
for row in registry(brand_dir):
|
|
485
|
+
if row["kind"] not in STRING_KINDS:
|
|
486
|
+
findings.append(Finding(
|
|
487
|
+
"B065", SEVERITY_ERROR, "strings.md", 0,
|
|
488
|
+
f"`{row['key']}` has `Kind: {row['kind']}`, which is not one of "
|
|
489
|
+
f"{' | '.join(STRING_KINDS)} -- an unrecognised kind is treated "
|
|
490
|
+
f"as `copy` and judged by every language rule, so the row is "
|
|
491
|
+
f"neither exempt nor knowingly checked",
|
|
492
|
+
))
|
|
493
|
+
if row["kind"] == "layout":
|
|
494
|
+
continue
|
|
361
495
|
text = row["text"]
|
|
362
496
|
for word in banned:
|
|
363
497
|
if _mentions(word, text):
|
|
@@ -575,6 +709,8 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
575
709
|
for row in rows:
|
|
576
710
|
if not LABEL_KEY_RE.match(row["key"]):
|
|
577
711
|
continue
|
|
712
|
+
if row["kind"] == "layout":
|
|
713
|
+
continue # a terminal full stop inside a frame is not a title
|
|
578
714
|
text = row["text"].rstrip()
|
|
579
715
|
if not text.endswith(".") or text.endswith("..") or text.endswith("…"):
|
|
580
716
|
continue
|
|
@@ -649,6 +785,8 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
649
785
|
for name in entity_words:
|
|
650
786
|
proper.update(name.split())
|
|
651
787
|
for row in rows:
|
|
788
|
+
if row["kind"] == "layout":
|
|
789
|
+
continue # casing in a column-aligned table is typesetting
|
|
652
790
|
# Escape sequences are not words: "\\n--- Skills for ..." begins with a
|
|
653
791
|
# token whose only letter is the n of \\n.
|
|
654
792
|
readable = re.sub(r"\\[nrt]|\\x[0-9a-fA-F]{2}\[[0-9;]*[A-Za-z]", " ",
|
|
@@ -1153,6 +1291,26 @@ EMOJI_RE = re.compile(
|
|
|
1153
1291
|
# can actually be established without parsing the sentence.
|
|
1154
1292
|
DASH = "—"
|
|
1155
1293
|
|
|
1294
|
+
# Every spelling of the same mark. The tell is the ROLE the dash plays, not
|
|
1295
|
+
# the codepoint it is written with: swapping "—" for "–", or for a hyphen
|
|
1296
|
+
# with a space each side, leaves the habit exactly where it was, and a check
|
|
1297
|
+
# bound to one codepoint cannot see that it happened. Measured on
|
|
1298
|
+
# trycomp.ai, 2026-08-30: twenty rhetorical dashes, zero em dashes, every
|
|
1299
|
+
# one of them written " - ". See `docs/research/landings/trycomp.md`.
|
|
1300
|
+
DASH_ALIASES = "–‒―"
|
|
1301
|
+
|
|
1302
|
+
# A dash alone in a table cell stands for "no value": a glyph doing the job
|
|
1303
|
+
# of an empty string, not punctuation joining two clauses. AT-06 has said so
|
|
1304
|
+
# since it was written, and until the spellings were normalised nothing
|
|
1305
|
+
# implemented it, so a bare cell dash was reported in every strict locale.
|
|
1306
|
+
TABLE_CELL_DASH_RE = re.compile(rf"(?<=\|)([ \t]*)[{DASH}{DASH_ALIASES}-]([ \t]*)(?=\|)")
|
|
1307
|
+
|
|
1308
|
+
# A hyphen with whitespace on both sides, mid-line. The lookbehind is what
|
|
1309
|
+
# keeps a markdown bullet out of it: a list item's hyphen opens its line, so
|
|
1310
|
+
# nothing non-space precedes the indent in front of it.
|
|
1311
|
+
DASH_HYPHEN_RE = re.compile(r"(?<=\S)([ \t]+)-([ \t]+)(?=\S)")
|
|
1312
|
+
DASH_ALIAS_RE = re.compile(rf"[{DASH_ALIASES}]")
|
|
1313
|
+
|
|
1156
1314
|
# Never a finding. A range is arithmetic and direct speech is a convention.
|
|
1157
1315
|
DASH_RANGE_RE = re.compile(rf"\d\s*{DASH}\s*\d")
|
|
1158
1316
|
DASH_SPEECH_RE = re.compile(rf"^\s*{DASH}\s")
|
|
@@ -1185,6 +1343,19 @@ def prose_only(text: str) -> str:
|
|
|
1185
1343
|
return text
|
|
1186
1344
|
|
|
1187
1345
|
|
|
1346
|
+
def normalise_dash_spelling(text: str) -> str:
|
|
1347
|
+
"""Reduce every spelling of the rhetorical mark to the canonical dash.
|
|
1348
|
+
|
|
1349
|
+
Length-preserving by construction -- every substitution swaps a single
|
|
1350
|
+
character for a single character -- so a finding can quote the author's
|
|
1351
|
+
own characters while the branches below judge the normalised ones. A
|
|
1352
|
+
quote showing a dash the author never typed sends them grepping for it.
|
|
1353
|
+
"""
|
|
1354
|
+
text = TABLE_CELL_DASH_RE.sub(r"\1 \2", text)
|
|
1355
|
+
text = DASH_HYPHEN_RE.sub(rf"\1{DASH}\2", text)
|
|
1356
|
+
return DASH_ALIAS_RE.sub(DASH, text)
|
|
1357
|
+
|
|
1358
|
+
|
|
1188
1359
|
def sentences(text: str) -> list[str]:
|
|
1189
1360
|
"""Crude split, sufficient to count dashes inside one sentence."""
|
|
1190
1361
|
return [p for p in re.split(r"(?<=[.!?])\s+|\n\s*\n", text) if p.strip()]
|
|
@@ -1203,14 +1374,22 @@ def grammatical_dash_language(text: str, primary: str | None) -> bool:
|
|
|
1203
1374
|
return bool(CYRILLIC_RE.search(text))
|
|
1204
1375
|
|
|
1205
1376
|
|
|
1206
|
-
def _around(sentence: str, width: int = 34) -> str:
|
|
1207
|
-
"""The dash with enough either side to find it and decide the fix.
|
|
1208
|
-
|
|
1209
|
-
|
|
1377
|
+
def _around(sentence: str, original: str | None = None, width: int = 34) -> str:
|
|
1378
|
+
"""The dash with enough either side to find it and decide the fix.
|
|
1379
|
+
|
|
1380
|
+
`sentence` is normalised and `original` is what the author wrote. The
|
|
1381
|
+
position comes from the first and the characters from the second, which
|
|
1382
|
+
is why the substitutions above are length-preserving: a quote is only
|
|
1383
|
+
useful if it can be found in the file it came from.
|
|
1384
|
+
"""
|
|
1385
|
+
source = sentence if original is None else original
|
|
1386
|
+
at = sentence.find(DASH)
|
|
1210
1387
|
if at < 0:
|
|
1211
|
-
return
|
|
1212
|
-
start, end = max(0, at - width), min(len(
|
|
1213
|
-
return ("…" if start else "")
|
|
1388
|
+
return " ".join(source.split())[:width * 2]
|
|
1389
|
+
start, end = max(0, at - width), min(len(source), at + width)
|
|
1390
|
+
return (("…" if start else "")
|
|
1391
|
+
+ " ".join(source[start:end].split())
|
|
1392
|
+
+ ("…" if end < len(source) else ""))
|
|
1214
1393
|
|
|
1215
1394
|
|
|
1216
1395
|
def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding]:
|
|
@@ -1222,7 +1401,8 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
|
|
|
1222
1401
|
off rather than obeyed.
|
|
1223
1402
|
"""
|
|
1224
1403
|
findings: list[Finding] = []
|
|
1225
|
-
|
|
1404
|
+
raw = prose_only(text)
|
|
1405
|
+
body = normalise_dash_spelling(raw)
|
|
1226
1406
|
if DASH not in body:
|
|
1227
1407
|
return findings
|
|
1228
1408
|
|
|
@@ -1246,14 +1426,17 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
|
|
|
1246
1426
|
break
|
|
1247
1427
|
return best
|
|
1248
1428
|
|
|
1249
|
-
|
|
1429
|
+
# `raw` and `body` are the same length and split at the same points --
|
|
1430
|
+
# normalisation touches neither terminal punctuation nor newlines -- so
|
|
1431
|
+
# the two sentence lists stay aligned and the quote comes from the raw.
|
|
1432
|
+
for sentence, original in zip(sentences(body), sentences(raw)):
|
|
1250
1433
|
if DASH not in sentence:
|
|
1251
1434
|
continue
|
|
1252
1435
|
if DASH_SPEECH_RE.match(sentence):
|
|
1253
1436
|
continue
|
|
1254
1437
|
|
|
1255
1438
|
line = line_of(sentence)
|
|
1256
|
-
quoted = _around(sentence)
|
|
1439
|
+
quoted = _around(sentence, original)
|
|
1257
1440
|
|
|
1258
1441
|
conj = DASH_CONJ_RE.search(sentence)
|
|
1259
1442
|
if conj:
|
|
@@ -1429,6 +1612,8 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1429
1612
|
findings.extend(dash_findings("B062", path, body, strict))
|
|
1430
1613
|
|
|
1431
1614
|
for row in registry(brand_dir):
|
|
1615
|
+
if row["kind"] == "layout":
|
|
1616
|
+
continue
|
|
1432
1617
|
strict = not grammatical_dash_language(row["text"], primary)
|
|
1433
1618
|
findings.extend(dash_findings("B062", row["location"], row["text"], strict))
|
|
1434
1619
|
|
|
@@ -141,6 +141,55 @@ def screen_blocks(text: str) -> dict[str, str]:
|
|
|
141
141
|
return entry_blocks(text, "SCR")
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
def coverage_subjects(cov: str, root: Path) -> list[tuple[str, str]]:
|
|
145
|
+
"""Citations that name a subject, and whether the subject is still there.
|
|
146
|
+
|
|
147
|
+
`B-028`: a range proves its bounds and nothing else. `U071` resolves whether
|
|
148
|
+
the cited lines exist, which caught `bin/super-ux.js:99000-99999` in a
|
|
149
|
+
396-line file, and cannot catch `223-284` once `selectInteractive` has moved
|
|
150
|
+
to `235-296` -- the exact drift that was live in this pack's own
|
|
151
|
+
`screens.md` for a release. Bounds are all a range can prove about itself.
|
|
152
|
+
|
|
153
|
+
So a citation may name what it is about: `path:start-end symbolName`, the
|
|
154
|
+
symbol separated by a space inside the same backticks. When it does, this
|
|
155
|
+
resolves the symbol in the file and answers whether it is inside the span.
|
|
156
|
+
|
|
157
|
+
It proves the subject is WITHIN the range, not that the range is exactly the
|
|
158
|
+
subject, and that is the honest limit: matching a definition's full extent
|
|
159
|
+
needs a parser per language, and a check that claimed to do it would be
|
|
160
|
+
asserting what it has not measured. Returns `(citation, symbol)` pairs that
|
|
161
|
+
failed.
|
|
162
|
+
"""
|
|
163
|
+
bad: list[tuple[str, str]] = []
|
|
164
|
+
for token in re.findall(r"`([^`]+)`", cov):
|
|
165
|
+
parts = token.split()
|
|
166
|
+
if len(parts) != 2:
|
|
167
|
+
continue
|
|
168
|
+
citation, symbol = parts
|
|
169
|
+
if not re.fullmatch(r"[A-Za-z_$][\w$]*", symbol):
|
|
170
|
+
continue
|
|
171
|
+
path, _, span = citation.partition(":")
|
|
172
|
+
match = re.fullmatch(r"(\d+)(?:-(\d+))?", span)
|
|
173
|
+
target = root / path
|
|
174
|
+
if not match or not target.exists():
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
lines = target.read_text(encoding="utf-8").splitlines()
|
|
178
|
+
except (OSError, UnicodeDecodeError):
|
|
179
|
+
continue
|
|
180
|
+
start = int(match.group(1))
|
|
181
|
+
stop = int(match.group(2)) if match.group(2) else start
|
|
182
|
+
word = re.compile(rf"\b{re.escape(symbol)}\b")
|
|
183
|
+
here = [i for i, l in enumerate(lines, 1)
|
|
184
|
+
if start <= i <= stop and word.search(l)]
|
|
185
|
+
if here:
|
|
186
|
+
continue
|
|
187
|
+
elsewhere = [i for i, l in enumerate(lines, 1) if word.search(l)]
|
|
188
|
+
bad.append((citation, symbol if not elsewhere
|
|
189
|
+
else f"{symbol} (found at line {elsewhere[0]})"))
|
|
190
|
+
return bad
|
|
191
|
+
|
|
192
|
+
|
|
144
193
|
def coverage_claim(cov: str, root: Path) -> tuple[bool, list[str], list[str]]:
|
|
145
194
|
"""A `Coverage:` value read as the claim about code that it is.
|
|
146
195
|
|
|
@@ -650,6 +699,33 @@ VISION_SECTIONS = [
|
|
|
650
699
|
]
|
|
651
700
|
|
|
652
701
|
VISION_RULE_HEADING = "## Vision alignment — hard rule (super-ux)"
|
|
702
|
+
|
|
703
|
+
# `B-001`: `validate_hard_rule_copies` compares the template against the copy
|
|
704
|
+
# embedded in the `vision` skill, and both live in THIS repository. A target
|
|
705
|
+
# project's `CLAUDE.md` carries a third copy, installed once and never compared
|
|
706
|
+
# to anything again, so a rule softened by hand or left behind by an upgrade
|
|
707
|
+
# reads exactly like a rule being obeyed. This linter is seeded into that
|
|
708
|
+
# project, so it is the only thing there that can hold the canonical text.
|
|
709
|
+
# `validate.py` gates this constant against `templates/vision-rule.md`, which
|
|
710
|
+
# keeps the number of sources at one.
|
|
711
|
+
VISION_RULE_TEXT = """\
|
|
712
|
+
## Vision alignment — hard rule (super-ux)
|
|
713
|
+
|
|
714
|
+
Before planning any new feature, capability or significant change, check it
|
|
715
|
+
against `docs/ux/vision.md` — specifically the **anti-vision** and the
|
|
716
|
+
**alignment test**.
|
|
717
|
+
|
|
718
|
+
**Aligned** → proceed, and say in one line which part of the vision it serves.
|
|
719
|
+
|
|
720
|
+
**Misaligned** → stop and say so before writing code:
|
|
721
|
+
1. Name the conflict — which layer it contradicts, quoting that layer.
|
|
722
|
+
2. Offer two paths: (a) reshape the feature to fit, with the specific change;
|
|
723
|
+
(b) amend the vision, saying which layer changes and what that costs.
|
|
724
|
+
3. Wait for the decision. Do not pick one silently.
|
|
725
|
+
|
|
726
|
+
**Do NOT trigger for:** bug fixes, refactors, dependency work, tests,
|
|
727
|
+
documentation, or anything with no user-facing surface. A vision check on a
|
|
728
|
+
typo fix is how a team learns to skip the check that matters."""
|
|
653
729
|
INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
|
|
654
730
|
|
|
655
731
|
|
|
@@ -679,16 +755,59 @@ def check_vision(ux: Path, vision: str) -> None:
|
|
|
679
755
|
err(f"[U031] vision.md: approved but '## {section}' is empty — "
|
|
680
756
|
f"the section that settles arguments cannot be blank")
|
|
681
757
|
|
|
758
|
+
# `B-005`: the seeded template is nine headings above HTML comments, and
|
|
759
|
+
# `read()` strips comments, so a project that seeds it and writes nothing
|
|
760
|
+
# passes every check here until somebody self-declares `approved`. Silence
|
|
761
|
+
# then reads as a vision, and the alignment rule the `vision` skill
|
|
762
|
+
# installs starts arbitrating against a blank document. A warning rather
|
|
763
|
+
# than an error, because a new project legitimately starts here: the defect
|
|
764
|
+
# is not that it is empty, it is that nothing said so.
|
|
765
|
+
def _authored(section: str) -> bool:
|
|
766
|
+
parts = re.split(rf"^##\s+{re.escape(section)}\s*$", vision, maxsplit=1,
|
|
767
|
+
flags=re.MULTILINE)
|
|
768
|
+
if len(parts) != 2:
|
|
769
|
+
return False
|
|
770
|
+
tail = re.split(r"^##\s", parts[1], maxsplit=1, flags=re.MULTILINE)[0]
|
|
771
|
+
# A `<placeholder>` is the template asking a question, not an answer,
|
|
772
|
+
# and `read()` strips only HTML comments. Section 9 ships three of them
|
|
773
|
+
# as a numbered list, which is why "is anything written here" cannot be
|
|
774
|
+
# answered by `.strip()` alone -- found by watching this check stay
|
|
775
|
+
# silent on the pristine seed it was written for.
|
|
776
|
+
tail = re.sub(r"<[^<>\n]{0,120}>", "", tail)
|
|
777
|
+
tail = re.sub(r"^\s*(?:[-*+]|\d+\.)\s*$", "", tail, flags=re.MULTILINE)
|
|
778
|
+
return bool(tail.strip())
|
|
779
|
+
|
|
780
|
+
written = [s for s in VISION_SECTIONS if _authored(s)]
|
|
781
|
+
if not approved and not written:
|
|
782
|
+
warn(f"[U076] vision.md is still the seeded template — all "
|
|
783
|
+
f"{len(VISION_SECTIONS)} sections are headings with nothing under "
|
|
784
|
+
f"them, so the alignment rule is arbitrating against a blank "
|
|
785
|
+
f"document. Write it, or delete the file until you do")
|
|
786
|
+
|
|
682
787
|
root = ux.parent.parent if ux.name == "ux" else ux.parent
|
|
683
788
|
present = [root / n for n in INSTRUCTION_FILES if (root / n).is_file()]
|
|
684
789
|
if not present:
|
|
685
790
|
warn("[U032] vision.md exists but the project has no CLAUDE.md / AGENTS.md / "
|
|
686
791
|
"GEMINI.md — the alignment rule has nowhere to live")
|
|
687
792
|
return
|
|
688
|
-
|
|
793
|
+
carrying = [p for p in present if VISION_RULE_HEADING in read(p)]
|
|
794
|
+
if not carrying:
|
|
689
795
|
warn(f"[U033] vision.md exists but no '{VISION_RULE_HEADING}' block in "
|
|
690
796
|
f"{', '.join(p.name for p in present)} — nothing ever reads the vision "
|
|
691
797
|
f"(run the `vision` skill's step 4)")
|
|
798
|
+
return
|
|
799
|
+
for path in carrying:
|
|
800
|
+
text = read(path)
|
|
801
|
+
start = text.index(VISION_RULE_HEADING)
|
|
802
|
+
tail = text[start + len(VISION_RULE_HEADING):]
|
|
803
|
+
stop = re.search(r"^##\s", tail, re.MULTILINE)
|
|
804
|
+
installed = (VISION_RULE_HEADING
|
|
805
|
+
+ (tail[:stop.start()] if stop else tail)).strip()
|
|
806
|
+
if installed != VISION_RULE_TEXT.strip():
|
|
807
|
+
warn(f"[U077] {path.name}: the installed vision rule differs from the "
|
|
808
|
+
f"one this version ships — a rule edited by hand or left behind "
|
|
809
|
+
f"by an upgrade reads exactly like a rule being obeyed. Re-run "
|
|
810
|
+
f"the `vision` skill's step 4, or keep the edit deliberately")
|
|
692
811
|
|
|
693
812
|
|
|
694
813
|
def check_links(ux: Path) -> None:
|
|
@@ -849,6 +968,10 @@ def main() -> int:
|
|
|
849
968
|
warn(f"[U055] screens.md: {sid} claims Coverage '{cov}' and names no file")
|
|
850
969
|
for rel in missing:
|
|
851
970
|
err(f"[U056] screens.md: {sid} cites '{rel}', which does not exist")
|
|
971
|
+
for citation, symbol in coverage_subjects(cov, screens_root):
|
|
972
|
+
warn(f"[U078] screens.md: {sid} cites '{citation}' as covering "
|
|
973
|
+
f"`{symbol}`, which is not inside those lines — the range "
|
|
974
|
+
f"proves its bounds and the subject is what it is about")
|
|
852
975
|
for rel in beyond:
|
|
853
976
|
err(f"[U071] screens.md: {sid} cites '{rel}' — the file resolves "
|
|
854
977
|
f"and those lines do not, so the citation points at code "
|
package/templates/brand/voice.md
CHANGED
|
@@ -5,7 +5,8 @@ Locale parity threshold: 80%
|
|
|
5
5
|
Derived-from: <P-NN, JTBD-NN from docs/ux/foundation.md — or `inferred`>
|
|
6
6
|
Status: draft
|
|
7
7
|
Last calibrated: <YYYY-MM-DD>
|
|
8
|
-
Humanization
|
|
8
|
+
Humanization: on # on | off; on is the default, off needs a reason below
|
|
9
|
+
Humanization pass: <own | humanizer | avoid-ai-writing> # optional; absent = own
|
|
9
10
|
|
|
10
11
|
# Voice
|
|
11
12
|
|