super-ux 0.60.0 → 0.62.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,3 +1,65 @@
1
+ ## 0.62.0 (2026-10-19): retrieval crawlers, the audit's brief, landing promises, Copylot sweep
2
+
3
+ - **`B050` judges retrieval agents only.** The AI-search contradiction is a block
4
+ of `OAI-SearchBot`, `ChatGPT-User`, `Claude-SearchBot`, `Claude-User`,
5
+ `PerplexityBot` or `Perplexity-User` while `channels.md` declares AI search a
6
+ target. Training and dataset tokens (`GPTBot`, `ClaudeBot`, `CCBot`,
7
+ `Google-Extended`) are a separate decision and are never reported; before
8
+ this release a `GPTBot` block was an error and an `OAI-SearchBot` block was
9
+ invisible. robots.txt is read in RFC 9309 groups: shared `User-agent` lines,
10
+ the `*` fallback for an agent with no group of its own, case-insensitive
11
+ tokens, and an equal-length `Allow: /` beating `Disallow: /`. Each purpose is
12
+ cited from the vendor's own crawler page in the code and the contract.
13
+ Fifteen new fixtures, each defect planted in the linter and watched red.
14
+ - **`seo-aeo-safety.md`:** a "Crawler policy: two decisions" section with the
15
+ vendor table; the technical floor replaced by a pointer to seo-aeo-audit's
16
+ brief mode (`references/build-time.md`, ships with seo-aeo-audit 0.28); no
17
+ `FAQPage`/`HowTo` markup for rich results (Google's changelog: HowTo removed
18
+ in 2023, FAQ rich results gone from 7 May 2026); the keyword-stuffing text now
19
+ states what `B051` enforces (pages of 40 or more words, a word repeated 5 or
20
+ more times above a 4% share, registered terms exempt, advisory) instead of
21
+ the retired 1% rule; Claude's row in the engine table reflects web search.
22
+ - **`landing-pages.md` LP-21..LP-24:** an offer condition next to its claim and
23
+ the same in the ad; lead with the problem and name AI only where it changes
24
+ the outcome; a "works with X" claim waits for a measured acceptance test in
25
+ `facts.md`; one job per page and creatives judged by the qualified-step rate,
26
+ not CTR. Readiness-check commands for LP-21, LP-23 and LP-24; LP-22 stays a
27
+ reading. Each rule cites the regulator or platform policy it rests on.
28
+ - **`copywriting`:** runs `copylot.check` beside the brand linter when the tool
29
+ is in the session (target `register: web`, `format: web-page`; until
30
+ Copylot's schema lists them, `marketing` and no format, said out loud) and
31
+ prints a fallback line when it is absent.
32
+ - **Synthetic respondents are hypotheses:** `research-evidence.md`,
33
+ `ux-foundation` and `ux-scenarios` say a synthetic persona or LLM "digital
34
+ twin" is never evidence for a persona or scenario decision (cited: Nielsen
35
+ Norman Group, 2024). `audit-depth.md` adds the quarterly fresh-user walk:
36
+ clean install or new account, from the ad or landing page to paid use with a
37
+ real payment method, on the smallest supported screen, findings recorded
38
+ against scenarios.
39
+ - Contract copies synchronized with `python3 test/sync_references.py`; seeded
40
+ `docs/brand/lint.py` re-copied. Discovery descriptions unchanged.
41
+
42
+ ## 0.61.0 (2026-10-18): no em dash in new text
43
+
44
+ Owner decision, 2026-10-18: text an agent writes carries no em dash (U+2014), in
45
+ any language, and no substitute for one. The family hub carries the same rule as
46
+ an always-on line for every host; this release makes product text agree with it.
47
+
48
+ - `ai-tells.md` AT-06 is now a hard ban, not a frequency target. The dash rule has
49
+ two layers: the glyph (no em dash in newly authored text) and the role (the
50
+ rhetorical dash is banned in every spelling; the grammatical Russian dash is
51
+ allowed only where no rewrite reads naturally). Ranges take an en dash.
52
+ - New lint code `B067` (`brand_lint.py`, seeded `docs/brand/lint.py`): an em dash
53
+ joining a range errors; an em dash opening a line where the locale has no
54
+ dialogue dash errors; a lone dash in a locale with a grammatical one warns, so
55
+ the writer decides whether the grammar needs it. Never a second finding beside
56
+ `B062`. Five fixtures, each watched failing with `B067` stubbed out.
57
+ - `copywriting`: the em dash is a Non-negotiable and the one exception to the
58
+ advisory humanization pass; the status-line examples no longer use a dash.
59
+ `brand-voice` seeds the invariant into `voice.md`; the brand hard rule
60
+ (`templates/claude-rule.md` and its embedded copies) and the Cursor rule say so.
61
+ - Contract copies synchronized with `python3 test/sync_references.py`.
62
+
1
63
  ## 0.60.0 — a rendered screen is checked in WebKit (Safari) first
2
64
 
3
65
  - New shared contract `references/browser-engine.md`: Safari through
package/README.md CHANGED
@@ -161,10 +161,10 @@ Commands: `/brand` (status → one recommended action), `/brand-init`,
161
161
  python3 docs/brand/lint.py
162
162
  ```
163
163
 
164
- 48 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
164
+ 49 deterministic checks (`B001`..`B073`): banned words, one action under two names, a figure
165
165
  with no sourced fact, a field over its limit with the locale coefficient
166
166
  applied, blocked AI crawlers, keyword stuffing, humor on a billing screen,
167
- a rhetorical dash, a title that ends in a full stop, a locale that lags
167
+ a rhetorical dash or an em dash in new text, a title that ends in a full stop, a locale that lags
168
168
  without saying so, a short-video hook the filter calls weak, a caption that
169
169
  spends the feed window or asks twice, an invisible character pasted in. Exit 0 clean **or warnings only**, 1 warnings under `--strict`, 2 any error. That is the policy `docs/ux/lint.py` has always had, and one pack cannot hold two opposite meanings for a warning: this linter returned 1 on warnings alone until 2026-08-20, so 13 of its 39 codes turned a build red while printing `0 error(s), 1 warning(s)`.
170
170
 
@@ -54,9 +54,14 @@ Could this sentence sit unchanged on a competitor's page? Then it says
54
54
  nothing. Rewrite it until it could not.
55
55
 
56
56
  Machine-made tells to strip: tricolons that add nothing, "seamless /
57
- effortless / unlock / elevate / robust", em-dash cadence in every paragraph,
58
- "it's not just X, it's Y", hedges stacked two deep, and a closing sentence
59
- that summarizes what was just said.
57
+ effortless / unlock / elevate / robust", "it's not just X, it's Y", hedges
58
+ stacked two deep, and a closing sentence that summarizes what was just said.
59
+
60
+ **No em dash (U+2014) in text you write, in any language** (owner decision,
61
+ 2026-10-18). A hard ban, not a count: no substitute either (`--`, an en dash
62
+ or a spaced hyphen doing a dash's job). Use a full stop, a comma, a colon or
63
+ brackets, or rewrite. The rare exception is a dash Russian grammar needs where
64
+ no rewrite reads naturally; quotations, code, commands and paths keep theirs.
60
65
 
61
66
  ## Short video
62
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "scripts": {
5
5
  "test": "python3 test/validate.py && python3 test/brand_lint_test.py && python3 test/display_copy_test.py && python3 test/ux_lint_test.py && python3 docs/ux/lint.py && python3 docs/brand/lint.py && node test/installer_test.js && npm run test:audit",
6
6
  "test:audit": "for t in test/audit_regressions/*.py; do python3 \"$t\" || exit 1; done"
@@ -1794,7 +1794,79 @@ def _ios_keywords(path: str, fields: dict, raw: str) -> list[Finding]:
1794
1794
  return findings
1795
1795
 
1796
1796
 
1797
- AI_AGENTS = ("GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended")
1797
+ # B050 judges RETRIEVAL agents only: the ones that fetch a page to show, cite
1798
+ # or answer from it. Blocking one while AI search is a declared target is the
1799
+ # contradiction. Each purpose is the vendor's own wording, read 2026-10-19:
1800
+ # OAI-SearchBot "surface websites in search results in ChatGPT's search
1801
+ # features"; ChatGPT-User user-initiated fetches
1802
+ # (https://developers.openai.com/api/docs/bots)
1803
+ # Claude-SearchBot indexes content for search; Claude-User retrieves a
1804
+ # page in response to a user query
1805
+ # (https://support.claude.com/en/articles/8896518)
1806
+ # PerplexityBot "surface and link websites in search results on
1807
+ # Perplexity"; Perplexity-User fetches for a user's question
1808
+ # (https://docs.perplexity.ai/guides/bots)
1809
+ RETRIEVAL_AGENTS = (
1810
+ "OAI-SearchBot", "ChatGPT-User", "Claude-SearchBot", "Claude-User",
1811
+ "PerplexityBot", "Perplexity-User",
1812
+ )
1813
+ # TRAINING and dataset tokens: blocking them is a separate decision with no
1814
+ # effect on AI search, so B050 never reports them. GPTBot and ClaudeBot are
1815
+ # the vendors' training crawlers (same pages); Google-Extended controls Gemini
1816
+ # training and grounding and "does not impact a site's inclusion in Google
1817
+ # Search" (https://developers.google.com/crawling/docs/crawlers-fetchers/google-common-crawlers);
1818
+ # CCBot builds Common Crawl's open archive (https://commoncrawl.org/ccbot).
1819
+ TRAINING_AGENTS = ("GPTBot", "ClaudeBot", "CCBot", "Google-Extended")
1820
+ # Whole-site paths and their rule length. Google's matcher ignores a trailing
1821
+ # `*` (so `/*` and a bare `*` weigh what `/` weighs) and reads `$` as the end
1822
+ # of the path; the longer rule wins and Allow wins a tie (RFC 9309 2.2.2).
1823
+ # So `Disallow: /*` against `Allow: /` is a tie Allow wins, while
1824
+ # `Disallow: /*$` (3 octets) outranks `Allow: /`.
1825
+ FULL_PATHS = {"/": 1, "/*": 1, "*": 1, "/*$": 3}
1826
+
1827
+
1828
+ def robots_groups(text: str) -> list[tuple[list[str], list[tuple[str, str]]]]:
1829
+ """RFC 9309 groups: consecutive `User-agent` lines share the rules after
1830
+ them, and a `User-agent` line after a rule opens a new group. Tokens are
1831
+ lower-cased, because product tokens match case-insensitively, and cut to
1832
+ their leading [a-z_-] run, as Google's matcher does, so `OAI-SearchBot/1.0`
1833
+ still names OAI-SearchBot. A UTF-8 BOM before the first line is dropped."""
1834
+ text = text.lstrip("\ufeff")
1835
+ groups: list[tuple[list[str], list[tuple[str, str]]]] = []
1836
+ agents: list[str] = []
1837
+ rules: list[tuple[str, str]] = []
1838
+ for raw in text.splitlines():
1839
+ line = raw.split("#", 1)[0].strip()
1840
+ m = re.match(r"^(user-agent|allow|disallow)\s*:\s*(.*?)\s*$", line, re.I)
1841
+ if not m:
1842
+ continue
1843
+ key, value = m.group(1).lower(), m.group(2)
1844
+ if key == "user-agent":
1845
+ if rules:
1846
+ groups.append((agents, rules))
1847
+ agents, rules = [], []
1848
+ token = re.match(r"\*(?=\s|$)|[a-z_-]*", value.lower()).group(0)
1849
+ agents.append(token or value.lower())
1850
+ elif agents:
1851
+ rules.append((key, value))
1852
+ if agents:
1853
+ groups.append((agents, rules))
1854
+ return groups
1855
+
1856
+
1857
+ def robots_blocks(text: str, token: str) -> bool:
1858
+ """True when robots.txt shuts `token` out of the whole site. The agent
1859
+ obeys the groups naming it, or the `*` groups when none does; an
1860
+ equal-length `Allow` beats `Disallow` (RFC 9309 section 2.2.2)."""
1861
+ groups = robots_groups(text)
1862
+ own = [r for a, r in groups if token.lower() in a]
1863
+ rules = [rule for rs in (own or [r for a, r in groups if "*" in a])
1864
+ for rule in rs]
1865
+ def longest(kind: str) -> int:
1866
+ return max((FULL_PATHS[v] for k, v in rules
1867
+ if k == kind and v in FULL_PATHS), default=0)
1868
+ disallow, allow = longest("disallow"), longest("allow")
1869
+ return disallow > 0 and disallow > allow
1798
1870
 
1799
1871
  FILLER_OPENERS = (
1800
1872
  "in today's digital landscape", "in the ever-evolving world",
@@ -2037,6 +2109,75 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
2037
2109
  return findings
2038
2110
 
2039
2111
 
2112
+ # AT-06's hard layer, owner-decided 2026-10-18: newly authored text carries no
2113
+ # em dash (U+2014) in any language. B062 above judges the dash's ROLE; this
2114
+ # judges the glyph B062 leaves alone. It never reports a sentence B062
2115
+ # already reports, so one dash is one finding.
2116
+ EM_DASH = "—"
2117
+ EM_DASH_RANGE_RE = re.compile(rf"\d\s*{EM_DASH}\s*\d")
2118
+
2119
+
2120
+ def em_dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding]:
2121
+ """The em dash the rhetorical check cannot see, and the Russian dash it allows.
2122
+
2123
+ Three cases, each one the B062 branches pass over in silence:
2124
+
2125
+ - a range written with an em dash, in any language: an error, because a
2126
+ range takes an en dash (U+2013) and a rewrite never costs anything;
2127
+ - an em dash opening a line where the locale has no dialogue dash: an
2128
+ error, because there it is decoration, not the speech convention;
2129
+ - a lone dash, in any spelling, where the locale has a grammatical dash:
2130
+ a warning. The copula (AT-06-E1) is allowed only where no rewrite reads
2131
+ naturally, and only the writer can judge that, so the linter asks and
2132
+ never blocks on grammar it cannot parse.
2133
+ """
2134
+ findings: list[Finding] = []
2135
+ raw = prose_only(text)
2136
+ # Length-preserving like the B062 normaliser, so quotes come from `raw`.
2137
+ cells = TABLE_CELL_DASH_RE.sub(r"\1 \2", raw) # AT-06-E4
2138
+ body = normalise_dash_spelling(raw)
2139
+ if DASH not in body:
2140
+ return findings
2141
+
2142
+ def line_of(sentence: str) -> int:
2143
+ at = body.find(sentence[:40])
2144
+ return body.count("\n", 0, at) + 1 if at >= 0 else 0
2145
+
2146
+ for sentence, cell, original in zip(sentences(body), sentences(cells), sentences(raw)):
2147
+ if DASH not in sentence:
2148
+ continue
2149
+ line = line_of(sentence)
2150
+ if EM_DASH_RANGE_RE.search(cell):
2151
+ findings.append(Finding(
2152
+ code, SEVERITY_ERROR, path, line,
2153
+ f"an em dash joins a range; write the range with an en dash "
2154
+ f'(U+2013) or the word "to": "{_around(sentence, original)}"',
2155
+ ))
2156
+ continue
2157
+ speech = DASH_SPEECH_RE.match(sentence)
2158
+ if strict:
2159
+ # B062 reports every other bare dash in this locale.
2160
+ if speech and cell.lstrip().startswith(EM_DASH):
2161
+ findings.append(Finding(
2162
+ code, SEVERITY_ERROR, path, line,
2163
+ f"an em dash opens the line, and this locale has no dialogue "
2164
+ f'dash; drop it or use a list: "{_around(sentence, original)}"',
2165
+ ))
2166
+ continue
2167
+ if speech: # AT-06-E3
2168
+ continue
2169
+ bare = sentence.count(DASH) - len(DASH_RANGE_RE.findall(sentence))
2170
+ if bare <= 0 or bare >= 2 or DASH_CONJ_RE.search(sentence):
2171
+ continue # a range, or B062's own error
2172
+ findings.append(Finding(
2173
+ code, SEVERITY_WARN, path, line,
2174
+ f"a dash in new text is allowed only where the grammar needs it "
2175
+ f"(AT-06-E1) and no rewrite reads naturally; otherwise use a full "
2176
+ f'stop, a comma, a colon or brackets: "{_around(sentence, original)}"',
2177
+ ))
2178
+ return findings
2179
+
2180
+
2040
2181
  def markdown_title(text: str) -> str:
2041
2182
  """Read common inline heading markup as the displayed label."""
2042
2183
  text = re.sub(r"!?\[([^\]]+)\]\([^)]*\)", r"\1", text)
@@ -2090,21 +2231,15 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
2090
2231
  (p, {}, "") for p in sources["robots"]
2091
2232
  ]:
2092
2233
  robots = read(brand_dir.parent.parent / path) or ""
2093
- blocks = []
2094
- agent = None
2095
- for line in robots.splitlines():
2096
- head = re.match(r"^User-agent:\s*(.+?)\s*$", line, re.I)
2097
- if head:
2098
- agent = head.group(1).strip()
2099
- elif re.match(r"^Disallow:\s*/\s*$", line, re.I) and agent:
2100
- if agent in AI_AGENTS:
2101
- blocks.append(agent)
2234
+ blocks = [t for t in RETRIEVAL_AGENTS if robots_blocks(robots, t)]
2102
2235
  if blocks:
2103
2236
  findings.append(Finding(
2104
2237
  "B050", SEVERITY_ERROR, path, 0,
2105
- f"channels.md declares AI search a target while "
2106
- f"{', '.join(blocks)} is blocked here -- content quality "
2107
- f"is irrelevant to a crawler that never arrives",
2238
+ f"channels.md declares AI search a target while the "
2239
+ f"retrieval agent(s) {', '.join(blocks)} are blocked here. "
2240
+ f"An answer engine cannot cite a page it may not fetch. "
2241
+ f"Training crawlers ({', '.join(TRAINING_AGENTS)}) are a "
2242
+ f"separate decision and are not judged here",
2108
2243
  ))
2109
2244
 
2110
2245
  records = surfaces(brand_dir)
@@ -2197,10 +2332,11 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
2197
2332
 
2198
2333
 
2199
2334
  def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
2200
- """B060-B063 -- machine-drafting markers, and the one absolute ban.
2335
+ """B060-B063 and B067 -- machine-drafting markers, and the absolute bans.
2201
2336
 
2202
2337
  B060 counts vocabulary markers, B061 bans levity where the user is
2203
- losing something, B062 is AT-06 (the rhetorical dash) and B063 is AT-07
2338
+ losing something, B062 is AT-06 (the rhetorical dash), B067 is AT-06's
2339
+ hard layer (the em dash glyph in new text) and B063 is AT-07
2204
2340
  (a title that ends in a full stop) outside the string registry, which
2205
2341
  B026 already covers. The grades and the reasoning are in
2206
2342
  `references/ai-tells.md`; this file carries only the provable subset.
@@ -2231,12 +2367,15 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
2231
2367
  text = f"{fields.get('title', '')}\n{body}"
2232
2368
  strict = not grammatical_dash_language(text, primary)
2233
2369
  findings.extend(dash_findings("B062", path, body, strict))
2370
+ # B067 -- the em dash glyph B062 does not judge (AT-06, hard layer).
2371
+ findings.extend(em_dash_findings("B067", path, body, strict))
2234
2372
 
2235
2373
  for row in registry(brand_dir):
2236
2374
  if row["kind"] == "layout":
2237
2375
  continue
2238
2376
  strict = not grammatical_dash_language(row["text"], primary)
2239
2377
  findings.extend(dash_findings("B062", row["location"], row["text"], strict))
2378
+ findings.extend(em_dash_findings("B067", row["location"], row["text"], strict))
2240
2379
 
2241
2380
  # B063 -- AT-07 outside the registry: document titles and the headings
2242
2381
  # inside them. B026 owns the same rule for `strings.md`, and the two are
@@ -40,6 +40,8 @@ Promise: <one line, checkable against a row in facts.md>
40
40
  What survives translation. Breaking one of these is a finding on any surface,
41
41
  in any locale.
42
42
 
43
+ - No em dash (U+2014) in new text, in any language; the grammatical Russian
44
+ dash only where no rewrite reads naturally (owner decision, 2026-10-18)
43
45
  - <e.g. never hedges>
44
46
  - <e.g. never claims a number that is not in facts.md>
45
47
 
@@ -48,14 +48,16 @@
48
48
  notification and accessible name. Search `strings.md` before naming one.
49
49
  - **No humor, exclamation marks or emoji** on error, destructive confirm,
50
50
  billing or paywall surfaces, in any voice.
51
- - **No rhetorical dash, and no full stop after a title.** A dash standing in
52
- for a full stop, a comma or a colon is the loudest machine-drafting marker
53
- the pack has, and a heading, button, menu item or page title is a name
54
- rather than a statement. The dash a language requires stays: the Russian
55
- copula, numeric ranges, direct speech. Choose the replacement from the
56
- meaning, because a comma, a colon and a full stop state three different
57
- relationships and find-and-replace picks the wrong one. `B062` and `B063`
58
- catch what a machine can prove; the rest is in the skill's `ai-tells.md`.
51
+ - **No em dash in new text, no rhetorical dash, and no full stop after a
52
+ title.** Text you write carries no em dash (U+2014) in any language (owner
53
+ decision, 2026-10-18), and no dash in any spelling stands in for a full
54
+ stop, a comma or a colon. A heading, button, menu item or page title is a
55
+ name rather than a statement. The dash Russian grammar needs (the copula,
56
+ direct speech) stays only where no rewrite reads naturally; a range takes
57
+ an en dash. Choose the replacement from the meaning, because a comma, a
58
+ colon and a full stop state three different relationships and
59
+ find-and-replace picks the wrong one. `B062`, `B063` and `B067` catch what
60
+ a machine can prove; the rest is in the skill's `ai-tells.md`.
59
61
  - Run `python3 docs/brand/lint.py` after any text change and before calling
60
62
  work done. It must exit 0 — an error blocks and a warning is advice, which
61
63
  is the same policy the UX linter has; `--strict` makes warnings block too.