super-ux 0.56.1 → 0.56.3
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 +31 -0
- package/README.md +1 -1
- package/cursor/rules/copywriting.mdc +20 -9
- package/package.json +2 -2
- package/plugins/super-ux/scripts/brand_lint.py +222 -18
- package/templates/brand/README.md +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
## 0.56.3 — check the heading the reader sees
|
|
2
|
+
|
|
3
|
+
The no-title-period rule already existed, but its linter read Markdown headings
|
|
4
|
+
only and let multi-sentence titles escape. It now reads HTML headings, inline
|
|
5
|
+
markup and line-break fragments, with source-line evidence. HTML registry checks
|
|
6
|
+
read visible text rather than attribute fragments. Paragraph punctuation,
|
|
7
|
+
questions, abbreviations, versions and URLs keep their meaning.
|
|
8
|
+
|
|
9
|
+
- `--fail-on B063` makes selected warnings block without promoting unrelated
|
|
10
|
+
advice; default warning behavior and JSON output remain compatible.
|
|
11
|
+
- `B009` blocks unmatched declared source globs. Brace patterns advertised by
|
|
12
|
+
the contract now expand; seed projects must replace placeholder sources.
|
|
13
|
+
- Copywriting now verifies rendered roles, generators, actual linter paths and
|
|
14
|
+
gate coverage. The contract no longer describes advisory B060 as an error.
|
|
15
|
+
- Regression receipts and scope limits: [DC-01](docs/evidence/audits/2026-10-01-display-copy/report.md).
|
|
16
|
+
|
|
17
|
+
## 0.56.2 — the closure says it is a closure
|
|
18
|
+
|
|
19
|
+
Hygiene from the 2026-09-13 family audit (HK-11). Five skills ship more contracts than
|
|
20
|
+
their bodies name — `ux-audit` names 8 of 21, `ux-scenarios` 5 of 13, `ux-foundation`
|
|
21
|
+
8 of 14, `brand-voice` 6 of 10, `copywriting` 10 of 12. That is the transitive closure
|
|
22
|
+
`test/sync_references.py` builds on purpose: a named contract links others, and the
|
|
23
|
+
skills CLI installs one directory, so a sibling's file would arrive dangling.
|
|
24
|
+
|
|
25
|
+
It was a design choice nothing stated, which reads the same as clutter. Each of the five
|
|
26
|
+
now carries one sentence naming both counts — measured from the tree, not written — and
|
|
27
|
+
saying that the unnamed files are reached BY NAME from the contracts that link them.
|
|
28
|
+
|
|
29
|
+
`ux-flows` is not in this list: its body is at 4747 of the 4750 working limit and the
|
|
30
|
+
answer at that ceiling is a split, not three more tokens.
|
|
31
|
+
|
|
1
32
|
## 0.56.1 — the one contract that pointed at a neighbour
|
|
2
33
|
|
|
3
34
|
`vision/SKILL.md` named its section contract as `` `ux-scenarios/references/scenario-format.md` `` —
|
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
|
+
42 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
|
|
@@ -16,12 +16,18 @@ Read the pack: `docs/brand/voice.md`, `terminology.md`, and the
|
|
|
16
16
|
`/brand-init`.** Guessing the voice and being wrong costs more than the
|
|
17
17
|
pause.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
Ownership is by file: `brand-voice` owns voice, terms, facts, channels and
|
|
20
|
+
locale policy. Missing terms or facts are reported, never invented. This pass
|
|
21
|
+
owns product text and `strings.md` rows (`Status: proposed`); claim coordinated
|
|
22
|
+
files before editing. A file living in `docs/brand/` does not forbid the edit.
|
|
22
23
|
|
|
23
24
|
## Rules that hold in every voice
|
|
24
25
|
|
|
26
|
+
- **No decorative full stop in headings or labels**, even when inline markup
|
|
27
|
+
or line breaks split the title. “Your agents. Your tools.” is still a title.
|
|
28
|
+
Keep questions, ellipses, abbreviations, versions, URLs and paragraph punctuation.
|
|
29
|
+
Apply a project's explicit hero/caption policy to those additional roles.
|
|
30
|
+
|
|
25
31
|
- **Every figure comes from `facts.md`.** No sourced fact, no number. Never
|
|
26
32
|
fabricate a statistic, a quote, or an expert.
|
|
27
33
|
- **One action keeps one name** across button, confirmation, toast, history,
|
|
@@ -56,10 +62,15 @@ lags the primary says so rather than pretending to be current.
|
|
|
56
62
|
|
|
57
63
|
## Check
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
Resolve the actual linter: use `docs/brand/lint.py` if present, otherwise locate
|
|
66
|
+
`brand_lint.py` in the installed super-ux scripts directory and pass the project's
|
|
67
|
+
brand directory. If neither exists, state the missing tool and manual coverage.
|
|
68
|
+
Confirm Sources patterns match the touched files and copy projections retain
|
|
69
|
+
heading roles. Inspect rendered output and generators; static checks cannot
|
|
70
|
+
resolve external CSS or runtime text.
|
|
62
71
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
Run the resolved script with `--fail-on B063` when title punctuation must block.
|
|
73
|
+
An older script rejecting the flag needs updating, not a retry that drops the gate.
|
|
74
|
+
Exit 0 means clean or advisory warnings; selected warnings exit 1; errors exit 2.
|
|
75
|
+
`--strict` selects every warning. Report actual surfaces, warnings and command.
|
|
76
|
+
Tone, grounding and voice still require `/ux-audit copy`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.56.
|
|
3
|
+
"version": "0.56.3",
|
|
4
4
|
"scripts": {
|
|
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 && npm run test:audit",
|
|
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"
|
|
7
7
|
},
|
|
8
8
|
"description": "Scenario-driven UI development for AI agents (Claude Code, Cursor, 70+ agents): a versioned design chain in docs/ux/, a scenario-first hard rule, a deterministic drift linter, and evidence-backed UX audits. This package is the installer CLI.",
|
|
@@ -20,7 +20,10 @@ Read-only by default. `--fix` applies only the changes that cannot be wrong.
|
|
|
20
20
|
python3 brand_lint.py [path] --json # machine-readable findings
|
|
21
21
|
python3 brand_lint.py [path] --strict # warnings block too
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
python3 brand_lint.py [path] --fail-on B063 # selected warnings block
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 clean or advisory warnings, 1 selected warnings (`--strict` or
|
|
26
|
+
`--fail-on`), 2 any error. JSON remains an array of findings.
|
|
24
27
|
|
|
25
28
|
One pack, one policy: `ux_lint.py` has always blocked on errors and needed
|
|
26
29
|
`--strict` before a warning could fail a build, and this file returned 1 for
|
|
@@ -36,6 +39,8 @@ import argparse
|
|
|
36
39
|
import json
|
|
37
40
|
import re
|
|
38
41
|
import sys
|
|
42
|
+
from html import unescape
|
|
43
|
+
from html.parser import HTMLParser
|
|
39
44
|
from collections import namedtuple
|
|
40
45
|
from pathlib import Path
|
|
41
46
|
|
|
@@ -76,6 +81,7 @@ STRING_KINDS = ("copy", "layout")
|
|
|
76
81
|
|
|
77
82
|
SEVERITY_ERROR = "error"
|
|
78
83
|
SEVERITY_WARN = "warn"
|
|
84
|
+
WARNING_CODES = set("B003 B005 B007 B022 B025 B026 B031 B043 B051 B053 B054 B060 B063 B064 B071 B072".split())
|
|
79
85
|
|
|
80
86
|
Finding = namedtuple("Finding", "code severity path line message")
|
|
81
87
|
|
|
@@ -240,7 +246,7 @@ def load_sources(brand_dir: Path) -> dict[str, list[str]]:
|
|
|
240
246
|
The linter cannot guess where a project keeps its text, and guessing
|
|
241
247
|
wrong produces the worst possible output: a clean report about a surface
|
|
242
248
|
that was never read. So an absent block is a finding (B006) and an
|
|
243
|
-
absent key means its checks are
|
|
249
|
+
absent key means its checks are not run (not a passed coverage verdict).
|
|
244
250
|
"""
|
|
245
251
|
text = read(brand_dir / "README.md") or ""
|
|
246
252
|
block = re.search(r"^Sources:\s*$(.*?)(?=^\S|\Z)", text, re.M | re.S)
|
|
@@ -248,7 +254,7 @@ def load_sources(brand_dir: Path) -> dict[str, list[str]]:
|
|
|
248
254
|
return {}
|
|
249
255
|
sources: dict[str, list[str]] = {}
|
|
250
256
|
for line in block.group(1).splitlines():
|
|
251
|
-
entry = re.match(r"^\s+(\w+):\s*(
|
|
257
|
+
entry = re.match(r"^\s+(\w+):\s*(.*?)\s*$", line)
|
|
252
258
|
if not entry:
|
|
253
259
|
continue
|
|
254
260
|
key, value = entry.group(1), entry.group(2)
|
|
@@ -714,7 +720,7 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
714
720
|
text = row["text"].rstrip()
|
|
715
721
|
if not text.endswith(".") or text.endswith("..") or text.endswith("…"):
|
|
716
722
|
continue
|
|
717
|
-
if
|
|
723
|
+
if not title_full_stop(text):
|
|
718
724
|
continue
|
|
719
725
|
findings.append(Finding(
|
|
720
726
|
"B026", SEVERITY_WARN, "strings.md", 0,
|
|
@@ -734,11 +740,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
734
740
|
continue
|
|
735
741
|
|
|
736
742
|
body = read(target) or ""
|
|
737
|
-
|
|
743
|
+
html = html_copy(body) if target.suffix.lower() in HTML_SUFFIXES else None
|
|
744
|
+
source_copy = " ".join(html.blocks) if html is not None else body
|
|
745
|
+
literals = html.interface_texts if html is not None else [
|
|
738
746
|
lit for lit in code_literals(_strip_comments(body, target.suffix))
|
|
739
747
|
if _looks_like_copy(lit)
|
|
740
748
|
]
|
|
741
|
-
if literals:
|
|
749
|
+
if literals or html is not None:
|
|
742
750
|
# The registry records what a reader sees, so the built page is the
|
|
743
751
|
# authority whenever there is one. Source is the fallback and stays
|
|
744
752
|
# byte-exact for projects that do not build.
|
|
@@ -760,9 +768,9 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
760
768
|
# would then refuse, which is a check with no passing answer. It
|
|
761
769
|
# compares the same wording across a newline the reader never sees,
|
|
762
770
|
# which is exactly what the rendered-page branch above already does.
|
|
763
|
-
elif row["text"] not in
|
|
764
|
-
and row["text"].strip() not in
|
|
765
|
-
and normalise(row["text"]) not in normalise(
|
|
771
|
+
elif row["text"] not in source_copy \
|
|
772
|
+
and row["text"].strip() not in source_copy \
|
|
773
|
+
and normalise(row["text"]) not in normalise(source_copy):
|
|
766
774
|
findings.append(Finding(
|
|
767
775
|
"B021", SEVERITY_ERROR, location, 0,
|
|
768
776
|
f"`{row['key']}` is \"{row['text']}\" in the registry, "
|
|
@@ -889,6 +897,157 @@ CODE_SUFFIXES = {
|
|
|
889
897
|
".py", ".go", ".rb", ".rs", ".java", ".kt", ".swift", ".php",
|
|
890
898
|
}
|
|
891
899
|
|
|
900
|
+
HTML_SUFFIXES = {".html", ".htm"}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class HTMLCopy(HTMLParser):
|
|
904
|
+
"""Static visible text and heading fragments, never attribute values.
|
|
905
|
+
|
|
906
|
+
Inline CSS/hidden/template exclusions are knowable from this source. External
|
|
907
|
+
CSS, scripts and responsive visibility still need a browser review.
|
|
908
|
+
aria-hidden is deliberately not excluded: it does not hide visual text.
|
|
909
|
+
"""
|
|
910
|
+
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
911
|
+
"link", "meta", "param", "source", "track", "wbr"}
|
|
912
|
+
BLOCK = {"p", "div", "section", "article", "main", "li", "ul", "ol",
|
|
913
|
+
"header", "footer", "nav", "button", "figcaption", "td", "tr"}
|
|
914
|
+
OMIT = {"script", "style", "template", "noscript", "head"}
|
|
915
|
+
PHRASING = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote",
|
|
916
|
+
"li", "dt", "dd", "td", "th", "figcaption", "label", "summary"}
|
|
917
|
+
CONTROLS = {"a", "button", "label", "legend", "summary", "option"}
|
|
918
|
+
INTERFACE_ROLES = {"button", "link", "menuitem", "tab", "option", "checkbox",
|
|
919
|
+
"radio", "switch", "alert", "status"}
|
|
920
|
+
|
|
921
|
+
def __init__(self):
|
|
922
|
+
super().__init__(convert_charrefs=True)
|
|
923
|
+
self.stack = []
|
|
924
|
+
self.parts = []
|
|
925
|
+
self.headings = []
|
|
926
|
+
self.heading = None
|
|
927
|
+
self.last_anchor_end = None
|
|
928
|
+
self.controls = []
|
|
929
|
+
self.interface_texts = []
|
|
930
|
+
|
|
931
|
+
@property
|
|
932
|
+
def blocked(self):
|
|
933
|
+
return bool(self.stack and self.stack[-1][1])
|
|
934
|
+
|
|
935
|
+
def handle_starttag(self, tag, attrs):
|
|
936
|
+
attrs = dict(attrs)
|
|
937
|
+
hidden = self.blocked or tag in self.OMIT or "hidden" in attrs or bool(
|
|
938
|
+
re.search(r"(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:!important\s*)?(?:;|$)",
|
|
939
|
+
attrs.get("style", ""), re.I))
|
|
940
|
+
if not hidden:
|
|
941
|
+
if tag in self.CONTROLS or attrs.get("role", "").lower() in self.INTERFACE_ROLES:
|
|
942
|
+
self.controls.append((tag, len(self.stack), []))
|
|
943
|
+
if tag == "br" or tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag):
|
|
944
|
+
for control in self.controls:
|
|
945
|
+
control[2].append(" ")
|
|
946
|
+
# Adjacent navigation/CTA anchors are separate labels, even when
|
|
947
|
+
# minified HTML has no intervening whitespace. A link within prose
|
|
948
|
+
# stays inline; turning every <a> into a block splits sentences.
|
|
949
|
+
if tag == "a" and self.last_anchor_end is not None \
|
|
950
|
+
and not any(parent[0] in self.PHRASING for parent in self.stack) \
|
|
951
|
+
and not any(part.strip() for part in self.parts[self.last_anchor_end:]):
|
|
952
|
+
self.parts.append("\n")
|
|
953
|
+
if re.fullmatch(r"h[1-6]", tag):
|
|
954
|
+
self.parts.append("\n")
|
|
955
|
+
self.heading = (tag, self.getpos()[0], [])
|
|
956
|
+
elif tag in self.BLOCK:
|
|
957
|
+
self.parts.append("\n")
|
|
958
|
+
elif tag == "br":
|
|
959
|
+
self.parts.append("\n")
|
|
960
|
+
if self.heading:
|
|
961
|
+
self.heading[2].append("\n")
|
|
962
|
+
if tag not in self.VOID:
|
|
963
|
+
self.stack.append((tag, hidden))
|
|
964
|
+
|
|
965
|
+
def handle_startendtag(self, tag, attrs):
|
|
966
|
+
self.handle_starttag(tag, attrs)
|
|
967
|
+
if tag not in self.VOID:
|
|
968
|
+
self.handle_endtag(tag)
|
|
969
|
+
|
|
970
|
+
def handle_endtag(self, tag):
|
|
971
|
+
if not self.blocked and (tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag)):
|
|
972
|
+
for control in self.controls:
|
|
973
|
+
control[2].append(" ")
|
|
974
|
+
# Registry candidates are interface controls/messages, not every
|
|
975
|
+
# marketing paragraph on a page that contains one registered action.
|
|
976
|
+
for control in list(self.controls):
|
|
977
|
+
if control[0] == tag and control[1] == len(self.stack) - 1:
|
|
978
|
+
text = " ".join("".join(control[2]).split())
|
|
979
|
+
if text:
|
|
980
|
+
self.interface_texts.append(text)
|
|
981
|
+
self.controls.remove(control)
|
|
982
|
+
if tag == "a" and not self.blocked:
|
|
983
|
+
self.last_anchor_end = len(self.parts)
|
|
984
|
+
if self.heading and tag == self.heading[0]:
|
|
985
|
+
_, line, parts = self.heading
|
|
986
|
+
for fragment in "".join(parts).split("\n"):
|
|
987
|
+
text = " ".join(fragment.split())
|
|
988
|
+
if text:
|
|
989
|
+
self.headings.append((line, text))
|
|
990
|
+
self.heading = None
|
|
991
|
+
if not self.blocked and (tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag)):
|
|
992
|
+
self.parts.append("\n")
|
|
993
|
+
for index in range(len(self.stack) - 1, -1, -1):
|
|
994
|
+
if self.stack[index][0] == tag:
|
|
995
|
+
del self.stack[index:]
|
|
996
|
+
break
|
|
997
|
+
|
|
998
|
+
def handle_data(self, data):
|
|
999
|
+
if not self.blocked:
|
|
1000
|
+
self.parts.append(re.sub(r"\s+", " ", data))
|
|
1001
|
+
for control in self.controls:
|
|
1002
|
+
control[2].append(data)
|
|
1003
|
+
if self.heading:
|
|
1004
|
+
# Source wrapping does not create a displayed line break.
|
|
1005
|
+
self.heading[2].append(re.sub(r"\s+", " ", data))
|
|
1006
|
+
|
|
1007
|
+
@property
|
|
1008
|
+
def blocks(self):
|
|
1009
|
+
return [" ".join(line.split()) for line in "".join(self.parts).split("\n")
|
|
1010
|
+
if line.strip()]
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def html_copy(text: str) -> HTMLCopy:
|
|
1014
|
+
parser = HTMLCopy()
|
|
1015
|
+
parser.feed(text)
|
|
1016
|
+
parser.close()
|
|
1017
|
+
return parser
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def source_paths(root: Path, pattern: str) -> list[Path]:
|
|
1021
|
+
"""Expand comma braces advertised by the brand contract, then glob."""
|
|
1022
|
+
brace = re.search(r"\{([^{}]+)\}", pattern)
|
|
1023
|
+
if brace and "," in brace.group(1):
|
|
1024
|
+
return sorted({path for choice in brace.group(1).split(",")
|
|
1025
|
+
for path in source_paths(root, pattern[:brace.start()] + choice
|
|
1026
|
+
+ pattern[brace.end():])})
|
|
1027
|
+
return sorted(path for path in root.glob(pattern) if path.is_file())
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def check_source_coverage(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
1031
|
+
root = brand_dir.parent.parent
|
|
1032
|
+
findings = []
|
|
1033
|
+
for key, patterns in sources.items():
|
|
1034
|
+
if key == "robots": # Missing robots is handled by the crawler policy.
|
|
1035
|
+
continue
|
|
1036
|
+
for pattern in patterns or [""]:
|
|
1037
|
+
if not pattern:
|
|
1038
|
+
findings.append(Finding(
|
|
1039
|
+
"B009", SEVERITY_ERROR, "README.md", 0,
|
|
1040
|
+
f"Sources {key}: no pattern declared; configure the source or remove its key",
|
|
1041
|
+
))
|
|
1042
|
+
continue
|
|
1043
|
+
if not source_paths(root, pattern):
|
|
1044
|
+
findings.append(Finding(
|
|
1045
|
+
"B009", SEVERITY_ERROR, "README.md", 0,
|
|
1046
|
+
f"Sources {key}: `{pattern}` matches no files; correct the "
|
|
1047
|
+
"path or remove the source declaration. No copy was scanned",
|
|
1048
|
+
))
|
|
1049
|
+
return findings
|
|
1050
|
+
|
|
892
1051
|
# `${...}` in a template literal is a value, not a word.
|
|
893
1052
|
#
|
|
894
1053
|
# Replaced by a space rather than dropped, so `founder of ${n} products` does not
|
|
@@ -974,12 +1133,16 @@ def documents(brand_dir: Path, sources: dict, key: str) -> list[tuple]:
|
|
|
974
1133
|
"""(relative path, front matter, body) for one declared source key."""
|
|
975
1134
|
root = brand_dir.parent.parent
|
|
976
1135
|
out = []
|
|
1136
|
+
seen = set()
|
|
977
1137
|
for pattern in sources.get(key, []):
|
|
978
|
-
for path in
|
|
979
|
-
if
|
|
1138
|
+
for path in source_paths(root, pattern):
|
|
1139
|
+
if path in seen:
|
|
980
1140
|
continue
|
|
1141
|
+
seen.add(path)
|
|
981
1142
|
fields, body = _front_matter(read(path) or "")
|
|
982
|
-
if path.suffix in
|
|
1143
|
+
if path.suffix.lower() in HTML_SUFFIXES:
|
|
1144
|
+
body = "\n\n".join(html_copy(body).blocks)
|
|
1145
|
+
elif path.suffix in CODE_SUFFIXES:
|
|
983
1146
|
body = _copy_in_code(body, path.suffix)
|
|
984
1147
|
out.append((path.relative_to(root).as_posix(), fields, body))
|
|
985
1148
|
return out
|
|
@@ -1589,15 +1752,45 @@ def dash_findings(code: str, path: str, text: str, strict: bool) -> list[Finding
|
|
|
1589
1752
|
return findings
|
|
1590
1753
|
|
|
1591
1754
|
|
|
1755
|
+
def markdown_title(text: str) -> str:
|
|
1756
|
+
"""Read common inline heading markup as the displayed label."""
|
|
1757
|
+
text = re.sub(r"!?\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
1758
|
+
text = re.sub(r"\s+#+\s*$", "", text)
|
|
1759
|
+
if re.search(r"</?[A-Za-z][^>]*>", text):
|
|
1760
|
+
text = " ".join(html_copy(text).blocks)
|
|
1761
|
+
return unescape(re.sub(r"[*_~`]", "", text)).strip()
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def markdown_headings(text: str) -> list[tuple[int, str]]:
|
|
1765
|
+
"""ATX headings with source lines; fences are examples, inline code is a label."""
|
|
1766
|
+
out = []
|
|
1767
|
+
match = FRONT_MATTER_RE.match(text)
|
|
1768
|
+
if match:
|
|
1769
|
+
text = "\n" * match.group(0).count("\n") + text[match.end():]
|
|
1770
|
+
fence = None
|
|
1771
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
1772
|
+
marker = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
|
|
1773
|
+
if marker:
|
|
1774
|
+
current = marker.group(1)
|
|
1775
|
+
if fence is None:
|
|
1776
|
+
fence = current
|
|
1777
|
+
elif current[0] == fence[0] and len(current) >= len(fence):
|
|
1778
|
+
fence = None
|
|
1779
|
+
continue
|
|
1780
|
+
if fence is None and (match := re.match(r"^ {0,3}#{1,6}\s+(.+?)\s*$", line)):
|
|
1781
|
+
out.append((number, markdown_title(match.group(1))))
|
|
1782
|
+
return out
|
|
1783
|
+
|
|
1784
|
+
|
|
1592
1785
|
def title_full_stop(title: str) -> bool:
|
|
1593
1786
|
"""AT-07. True when a title ends in a full stop that is a full stop."""
|
|
1594
1787
|
text = title.strip()
|
|
1788
|
+
if text == "." or text.endswith(" ."):
|
|
1789
|
+
return False # A standalone current-directory token is not punctuation.
|
|
1595
1790
|
if not text.endswith("."):
|
|
1596
1791
|
return False
|
|
1597
1792
|
if text.endswith("..") or text.endswith("…"):
|
|
1598
1793
|
return False
|
|
1599
|
-
if ". " in text: # several sentences -- a different defect
|
|
1600
|
-
return False
|
|
1601
1794
|
return not ABBREVIATION_RE.search(text)
|
|
1602
1795
|
|
|
1603
1796
|
|
|
@@ -1773,10 +1966,15 @@ def check_ai_tells(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1773
1966
|
f'the title ends in a full stop: "{title.strip()}". '
|
|
1774
1967
|
f"A title is a name, not a statement",
|
|
1775
1968
|
))
|
|
1776
|
-
|
|
1969
|
+
source = brand_dir.parent.parent / path
|
|
1970
|
+
if source.suffix.lower() in HTML_SUFFIXES:
|
|
1971
|
+
headings = html_copy(read(source) or "").headings
|
|
1972
|
+
else:
|
|
1973
|
+
headings = markdown_headings(read(source) or "")
|
|
1974
|
+
for line, heading in headings:
|
|
1777
1975
|
if title_full_stop(heading):
|
|
1778
1976
|
findings.append(Finding(
|
|
1779
|
-
"B063", SEVERITY_WARN, path,
|
|
1977
|
+
"B063", SEVERITY_WARN, path, line,
|
|
1780
1978
|
f'a heading ends in a full stop: "{heading.strip()}". '
|
|
1781
1979
|
f"A heading is a name, not a statement",
|
|
1782
1980
|
))
|
|
@@ -1844,7 +2042,7 @@ def check_locales(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
1844
2042
|
if primary and limit and "locales" in sources:
|
|
1845
2043
|
catalogues: dict[str, set] = {}
|
|
1846
2044
|
for pattern in sources["locales"]:
|
|
1847
|
-
for path in
|
|
2045
|
+
for path in source_paths(root, pattern):
|
|
1848
2046
|
try:
|
|
1849
2047
|
data = json.loads(read(path) or "{}")
|
|
1850
2048
|
except json.JSONDecodeError:
|
|
@@ -1961,6 +2159,7 @@ def run(brand_dir: Path, fix: bool = False) -> list[Finding]:
|
|
|
1961
2159
|
sources = load_sources(brand_dir)
|
|
1962
2160
|
findings: list[Finding] = []
|
|
1963
2161
|
findings.extend(check_contract(brand_dir))
|
|
2162
|
+
findings.extend(check_source_coverage(brand_dir, sources))
|
|
1964
2163
|
findings.extend(check_terminology(brand_dir))
|
|
1965
2164
|
findings.extend(check_consistency(brand_dir, sources))
|
|
1966
2165
|
findings.extend(check_facts(brand_dir, sources))
|
|
@@ -1997,11 +2196,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1997
2196
|
parser.add_argument("--fix", action="store_true")
|
|
1998
2197
|
parser.add_argument("--brief", action="store_true")
|
|
1999
2198
|
parser.add_argument("--json", action="store_true")
|
|
2199
|
+
parser.add_argument("--fail-on", action="append", default=[], metavar="CODE",
|
|
2200
|
+
help="make selected warning codes block (repeat or comma-separate)")
|
|
2000
2201
|
parser.add_argument(
|
|
2001
2202
|
"--strict", action="store_true",
|
|
2002
2203
|
help="let warnings fail the run too (same meaning as ux_lint.py --strict)",
|
|
2003
2204
|
)
|
|
2004
2205
|
args = parser.parse_args(argv)
|
|
2206
|
+
fail_on = {code.strip() for value in args.fail_on for code in value.split(",")}
|
|
2207
|
+
if fail_on - WARNING_CODES:
|
|
2208
|
+
parser.error("--fail-on expects a warning code: " + ", ".join(sorted(WARNING_CODES)))
|
|
2005
2209
|
|
|
2006
2210
|
brand_dir = Path(args.path)
|
|
2007
2211
|
if not brand_dir.is_dir():
|
|
@@ -2020,7 +2224,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2020
2224
|
# A warning is advice about a judgement a person still has to make -- an
|
|
2021
2225
|
# unregistered string, a fact past review, a label naming no outcome. It
|
|
2022
2226
|
# blocks only when the caller asks, which is what `--strict` is for.
|
|
2023
|
-
return 1 if (args.strict and findings) else 0
|
|
2227
|
+
return 1 if ((args.strict and findings) or any(f.code in fail_on for f in findings)) else 0
|
|
2024
2228
|
|
|
2025
2229
|
|
|
2026
2230
|
if __name__ == "__main__":
|
|
@@ -22,7 +22,7 @@ Owned by the `brand-voice` skill. Written by `copywriting`. Checked by
|
|
|
22
22
|
|
|
23
23
|
**Fill this in before anything else.** The linter scans nothing outside these
|
|
24
24
|
paths, and refuses to report a clean run over a surface it never read
|
|
25
|
-
(`B006`). Delete the keys this project does not have — a declared-but-absent
|
|
25
|
+
(`B006` for no block, `B009` for an unmatched glob). Delete the keys this project does not have — a declared-but-absent
|
|
26
26
|
source is worse than an omitted one.
|
|
27
27
|
|
|
28
28
|
```
|
|
@@ -34,6 +34,12 @@ Sources:
|
|
|
34
34
|
locales: src/locales/*.json
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
For a website, list the HTML under `marketing` or retain heading markers in a
|
|
38
|
+
copy projection. Flat paragraphs cannot prove heading coverage. To make the
|
|
39
|
+
no-title-period rule a required gate, run the resolved brand linter with
|
|
40
|
+
`--fail-on B063`; default warnings remain advisory. The placeholder source
|
|
41
|
+
patterns above must be replaced before the first real lint run.
|
|
42
|
+
|
|
37
43
|
`ui` and `marketing` also classify findings — several checks apply to only
|
|
38
44
|
one of the two.
|
|
39
45
|
|