super-ux 0.41.0 → 0.41.4
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 +150 -1
- package/package.json +1 -1
- package/plugins/super-ux/scripts/brand_lint.py +265 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,155 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.41.4 — 2026-08-16
|
|
4
|
+
|
|
5
|
+
**This gate can now see an invariant it breaks one repository away.** The family umbrella
|
|
6
|
+
routes work by matching a prompt against a table in `lib/triggers.js`, and every trigger
|
|
7
|
+
there must be a word this skill's own `description` advertises. Nothing here knew that
|
|
8
|
+
table existed. On 2026-08-16 `sheleg-design` 1.37.0 shipped green having dropped a phrase
|
|
9
|
+
that was still a live trigger, the umbrella found out minutes after the tag, and it cost a
|
|
10
|
+
patch release — because the member releases FIRST and the umbrella re-pins after.
|
|
11
|
+
|
|
12
|
+
`test/validate.py` now asks the umbrella's own checker (`test/advertised_check.js`), which
|
|
13
|
+
reads the module the hook itself calls. **No copy of the table lives here**, so there is
|
|
14
|
+
nothing to drift. With no umbrella above this checkout — a standalone clone, and CI — it
|
|
15
|
+
discloses rather than passing, because a check that cannot look must never read as one
|
|
16
|
+
that looked.
|
|
17
|
+
|
|
18
|
+
Watched refusing a real drop before shipping: every one of the seven members carrying
|
|
19
|
+
routed triggers had one of its own advertised phrases removed and every one of them failed
|
|
20
|
+
its own gate.
|
|
21
|
+
|
|
22
|
+
## 0.41.3 — 2026-08-16
|
|
23
|
+
|
|
24
|
+
**`facts.md` is a document, not only a facts table.** `facts()` took any
|
|
25
|
+
six-column row anywhere in the file, and a project that keeps a product ledger
|
|
26
|
+
there has one whose columns mean something else entirely:
|
|
27
|
+
`Product | App Store name | id | Released | Sold | Publisher today`. Its **Sold**
|
|
28
|
+
year landed in `Review`, so three completed sales produced "was due for review
|
|
29
|
+
on 2022" warnings, and the ledger's own header row became a fact called
|
|
30
|
+
`Product`. Four phantom rows in a registry of 43.
|
|
31
|
+
|
|
32
|
+
Scoped by header now — a table qualifies when its first column says `Fact`,
|
|
33
|
+
which is the only thing in a markdown table that declares what its columns
|
|
34
|
+
mean. A `tables()` helper groups rows per table beside the existing
|
|
35
|
+
`table_rows()`, which flattens every table in a file and is right for a caller
|
|
36
|
+
that wants every row.
|
|
37
|
+
|
|
38
|
+
Found on `sshlg.me` and fixed there first, in that project's copy of the linter,
|
|
39
|
+
where it would have been overwritten by the next sync — a plugin-owned file
|
|
40
|
+
edited in a downstream project serves that project until the day it silently
|
|
41
|
+
does not. Ported up with its fixture, which was watched failing.
|
|
42
|
+
|
|
43
|
+
### Three more changes landed in this version, from a concurrent session
|
|
44
|
+
|
|
45
|
+
They carry the same `0.41.3` in every manifest and sit inside the tag, so the
|
|
46
|
+
entry names them rather than leaving a release whose changelog describes a
|
|
47
|
+
quarter of it. Summarised from their own commit messages, not re-derived.
|
|
48
|
+
|
|
49
|
+
- **`B021` reads the built page** (`6fe309e`). The registry records what a
|
|
50
|
+
reader sees, and the check looked for that string in component *source*, where
|
|
51
|
+
an interpolated value never appears literally and an inline `<strong>` or `<a>`
|
|
52
|
+
splits a sentence the registry stores whole. On `sshlg.me` that was five errors
|
|
53
|
+
with no honest repair — hardcode the number and lose the guarantee it is
|
|
54
|
+
derived, or delete the rows and lose the check. `rendered_text()` now reads
|
|
55
|
+
`dist`, `build`, `out` or `_site`, first that exists, and a project that does
|
|
56
|
+
not build keeps the byte-exact source comparison. The message says which of the
|
|
57
|
+
two it checked. **This closes the last class `sshlg.me` had open**: that
|
|
58
|
+
project went from five errors to zero.
|
|
59
|
+
- **The same fix, applied to the script this package actually ships**
|
|
60
|
+
(`213ae1b`). It had gone into `docs/brand/lint.py`, the dogfood copy, while
|
|
61
|
+
`plugins/super-ux/scripts/brand_lint.py` is what `package.json` ships and what
|
|
62
|
+
`validate.py` reads as the authority — so it reached this repository's own
|
|
63
|
+
linting and no installed project. The pair check was red the whole time and the
|
|
64
|
+
commit went in without running it, which its author recorded rather than
|
|
65
|
+
quietly fixed.
|
|
66
|
+
- **`guardedFiles` covers the linters** (`3e133ab`). The manifests, the evidence
|
|
67
|
+
ledgers and `test/validate.py` were guarded; `docs/brand/lint.py` and
|
|
68
|
+
`plugins/super-ux/scripts/brand_lint.py` were not, and that is exactly where two
|
|
69
|
+
agents collided on 2026-08-16 and lost about twenty minutes. "A config that
|
|
70
|
+
guards what is edited rarely and leaves what is edited hourly open describes a
|
|
71
|
+
project nobody works on."
|
|
72
|
+
|
|
73
|
+
**The tag was placed on the tip rather than on the release commit**, by the run
|
|
74
|
+
that wrote the section above this one: `git tag` was given no target and took
|
|
75
|
+
`HEAD`, which by then carried three commits it had not seen. Left where it is —
|
|
76
|
+
a published tag is not moved — and the entry was widened to match it instead.
|
|
77
|
+
Both are recoveries from the same mistake, and only one of them is safe.
|
|
78
|
+
|
|
79
|
+
## 0.41.2 — 2026-08-16
|
|
80
|
+
|
|
81
|
+
**A source file is not prose, and the prose rules were reading all of it.** On
|
|
82
|
+
`sshlg.me`, whose `Sources:` block points `marketing` at `src/data/*.ts`, that
|
|
83
|
+
produced 20 rhetorical-dash errors inside `//` comments and 7 keyword-stuffing
|
|
84
|
+
errors on `const`, `string`, `name` and `category`. Twenty-seven standing errors
|
|
85
|
+
that no edit to the copy could clear, sitting in a report meant to be read. The
|
|
86
|
+
failure is the same one 0.40.1 fixed from the other direction: a check nobody
|
|
87
|
+
can act on gets ignored, and then it is not a check.
|
|
88
|
+
|
|
89
|
+
For a file with a code suffix the body is now its **copy** — the string literals
|
|
90
|
+
`_looks_like_copy` already accepts, which is the definition `B022` sweeps with.
|
|
91
|
+
A comment is addressed to a maintainer and an identifier is not a word.
|
|
92
|
+
|
|
93
|
+
Three things this had to get right, and the first two were found by getting them
|
|
94
|
+
wrong:
|
|
95
|
+
|
|
96
|
+
- **Comments are stripped by a scanner, not a regex.** `"https://x"` contains
|
|
97
|
+
`//`. The first attempt used a pattern and immediately reported a rhetorical
|
|
98
|
+
dash inside a comment that quoted a phrase — in the very change meant to stop
|
|
99
|
+
reading comments.
|
|
100
|
+
- **`${...}` is substituted, not dropped.** `CODE_FRAGMENT_RE` rejects any
|
|
101
|
+
literal carrying an interpolation, so without this every interpolated string
|
|
102
|
+
would fail `_looks_like_copy`. On the site that would have silently dropped the
|
|
103
|
+
whole biography. It turns out interpolated copy was **not** covered before
|
|
104
|
+
either: the fixture for it fails against 0.40.1, so this widens coverage rather
|
|
105
|
+
than preserving it.
|
|
106
|
+
- **A literal with no space is skipped.** `"@/data/site"` counted as copy would
|
|
107
|
+
put `data` into the density figures.
|
|
108
|
+
|
|
109
|
+
Ten fixtures, five end-to-end and five on the scanner directly. Four were watched
|
|
110
|
+
failing before the fix went in; the fifth end-to-end case is the boundary that
|
|
111
|
+
must not move — a rhetorical dash in a rendered string is still an error.
|
|
112
|
+
|
|
113
|
+
**And the same sentence has a second half: a source file is not a *document*
|
|
114
|
+
either.** `B051` measures keyword density, which is a property of the page a
|
|
115
|
+
reader meets. A project that keeps its copy in `src/data/*.ts` splits one page
|
|
116
|
+
across seven files, so measuring each file separately measures the split. On
|
|
117
|
+
`sshlg.me` that produced six errors — `co-founder` at 2.0% of
|
|
118
|
+
`track-record.ts`, `account` at 1.2% of `site.ts` — while the rendered page
|
|
119
|
+
carried **nothing above 1%** and those two words sat at 0.07% and 0.04%.
|
|
120
|
+
|
|
121
|
+
Code files are pooled into one document for that check; markdown sources are
|
|
122
|
+
not, because there one file really is one page. A pooled finding names the set
|
|
123
|
+
rather than a file, since no single file is the defect. The fixture that proves
|
|
124
|
+
it has a twin that must keep firing: a word genuinely dense across the whole
|
|
125
|
+
pool is still an error.
|
|
126
|
+
|
|
127
|
+
Measured on `sshlg.me`: **32 errors to 5**, and the five that remain are one
|
|
128
|
+
class — `B021` on registry rows whose text carries an interpolated count or an
|
|
129
|
+
inline link, which needs a linter that reads `dist/` rather than `src/`. One
|
|
130
|
+
real finding surfaced on the way: a rhetorical dash in live copy that had been
|
|
131
|
+
buried under twenty false ones.
|
|
132
|
+
|
|
133
|
+
## 0.41.1 — 2026-08-16
|
|
134
|
+
|
|
135
|
+
**`B024` fined the writer for meeting a threshold this pack sets.** The
|
|
136
|
+
sentence-case check reads any capitalised word inside a sentence as Title Case,
|
|
137
|
+
and a contraction of the first person survived every exemption it had: `I'm` is
|
|
138
|
+
not upper-case, is in no entity table, and starts with a capital. So it fired.
|
|
139
|
+
|
|
140
|
+
That is not a cosmetic false positive. `formats.md` asks for **4 to 8
|
|
141
|
+
contractions per 1000 words** on a published surface and names their absence as
|
|
142
|
+
"the single loudest reason our prose reads as assembled" — and the first-person
|
|
143
|
+
contractions are the loudest ones available. The check and the threshold were
|
|
144
|
+
pulling in opposite directions, and the only repair available to a writer was to
|
|
145
|
+
delete the contraction the pack had just asked for. Found on `sshlg.me`, where
|
|
146
|
+
three registry rows tripped it on 2026-08-15 and none of them was miscased.
|
|
147
|
+
|
|
148
|
+
`I'm`, `I'll`, `I've` and `I'd` are now exempt, with either apostrophe. Nothing
|
|
149
|
+
else is: `We're` and `They'd` inside a sentence really are miscased, and the
|
|
150
|
+
capital is grammar only for the first person. Both halves are fixtures, so the
|
|
151
|
+
exemption cannot widen without a test saying so.
|
|
152
|
+
|
|
3
153
|
## 0.41.0 — 2026-08-16
|
|
4
154
|
|
|
5
155
|
### Added
|
|
@@ -257,7 +407,6 @@ developer landing page (`zernio.com`, 2026-08-12) rather than from a survey.
|
|
|
257
407
|
- `test/floors.json`: `validate.py` 3160 → 3236 — four entries, their field and
|
|
258
408
|
tag checks, and three routing rows.
|
|
259
409
|
|
|
260
|
-
|
|
261
410
|
## 0.37.0 — 2026-08-12
|
|
262
411
|
|
|
263
412
|
The last five findings from the R-14 run — the ones that needed a **decision**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "super-ux",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.4",
|
|
4
4
|
"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.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"super-ux": "bin/super-ux.js"
|
|
@@ -351,6 +351,11 @@ def check_terminology(brand_dir: Path) -> list[Finding]:
|
|
|
351
351
|
return findings
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
# `I'm`, `I'll`, `I've`, `I'd`, with either apostrophe. Nothing else: `We're`
|
|
355
|
+
# and `They'd` mid-sentence really are miscased, and only the first person has a
|
|
356
|
+
# capital that is grammar rather than a choice.
|
|
357
|
+
CONTRACTED_I = re.compile(r"^I['\u2019](m|ll|ve|d)$")
|
|
358
|
+
|
|
354
359
|
WEAK_LABELS = {
|
|
355
360
|
"ok", "yes", "no", "submit", "done", "go", "click here",
|
|
356
361
|
"learn more", "get started", "continue",
|
|
@@ -373,6 +378,71 @@ CODE_FRAGMENT_RE = re.compile(
|
|
|
373
378
|
)
|
|
374
379
|
|
|
375
380
|
|
|
381
|
+
# Where a build puts the page a reader actually gets. Checked in this order and
|
|
382
|
+
# the first one that exists wins; a project with none of them is checked against
|
|
383
|
+
# its source, exactly as before.
|
|
384
|
+
RENDER_DIRS = ("dist", "build", "out", "_site")
|
|
385
|
+
|
|
386
|
+
_ENTITIES = {"&": "&", "<": "<", ">": ">", """: '"',
|
|
387
|
+
"'": "'", "'": "'", " ": " ", "’": "\u2019",
|
|
388
|
+
"‘": "\u2018", "—": "\u2014", "–": "\u2013"}
|
|
389
|
+
|
|
390
|
+
_rendered_cache: dict = {}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def normalise(text: str) -> str:
|
|
394
|
+
"""Collapse whitespace so an 80-column wrap and a rendered line agree.
|
|
395
|
+
|
|
396
|
+
Punctuation spacing is normalised too, and that is not cosmetic tidying.
|
|
397
|
+
Stripping `<strong>people</strong>,` leaves `people ,` once the tag becomes
|
|
398
|
+
a space, so an inline span inside a sentence would fail the comparison over
|
|
399
|
+
a space no reader can see. Applied to both sides, so the check still
|
|
400
|
+
compares wording and no longer compares markup.
|
|
401
|
+
"""
|
|
402
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
403
|
+
text = re.sub(r"\s+([,.;:!?%)\]}\u00bb\u201d\u2019])", r"\1", text)
|
|
404
|
+
return re.sub(r"([(\[{\u00ab\u201c])\s+", r"\1", text)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def rendered_text(root: Path) -> str | None:
|
|
408
|
+
"""All built HTML as one normalised string, or None if nothing is built.
|
|
409
|
+
|
|
410
|
+
B021 asks whether the registry's string is what a reader sees, and used to
|
|
411
|
+
look for it in the component source. Two things make that unanswerable
|
|
412
|
+
there: an interpolated value never appears literally -- `{years} years,
|
|
413
|
+
installable.` cannot contain `13 years, installable.` -- and an inline
|
|
414
|
+
`<strong>` or `<a>` splits a sentence the registry stores whole. On one site
|
|
415
|
+
that was five errors nobody could fix, and both honest repairs were bad:
|
|
416
|
+
hardcode the number and lose the guarantee that it is derived, or delete the
|
|
417
|
+
rows and lose the check.
|
|
418
|
+
|
|
419
|
+
The text a reader gets exists, just not in `src/`. Tags come out, entities
|
|
420
|
+
come back, whitespace collapses, and the same comparison then answers the
|
|
421
|
+
question it was always asking.
|
|
422
|
+
"""
|
|
423
|
+
if root in _rendered_cache:
|
|
424
|
+
return _rendered_cache[root]
|
|
425
|
+
out = None
|
|
426
|
+
for name in RENDER_DIRS:
|
|
427
|
+
built = root / name
|
|
428
|
+
if not built.is_dir():
|
|
429
|
+
continue
|
|
430
|
+
chunks = []
|
|
431
|
+
for page in sorted(built.rglob("*.html")):
|
|
432
|
+
html = read(page) or ""
|
|
433
|
+
html = re.sub(r"<(script|style)\b.*?</\1>", " ", html, flags=re.S | re.I)
|
|
434
|
+
html = re.sub(r"<[^>]+>", " ", html)
|
|
435
|
+
for ent, ch in _ENTITIES.items():
|
|
436
|
+
html = html.replace(ent, ch)
|
|
437
|
+
html = re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), html)
|
|
438
|
+
chunks.append(html)
|
|
439
|
+
if chunks:
|
|
440
|
+
out = normalise(" ".join(chunks))
|
|
441
|
+
break
|
|
442
|
+
_rendered_cache[root] = out
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
|
|
376
446
|
def _looks_like_copy(literal: str) -> bool:
|
|
377
447
|
"""A quoted literal that could plausibly be user-visible text."""
|
|
378
448
|
if not literal or literal[0].islower() and " " not in literal:
|
|
@@ -463,11 +533,23 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
463
533
|
lit for _q, lit in LITERAL_RE.findall(body) if _looks_like_copy(lit)
|
|
464
534
|
]
|
|
465
535
|
if literals:
|
|
466
|
-
|
|
536
|
+
# The registry records what a reader sees, so the built page is the
|
|
537
|
+
# authority whenever there is one. Source is the fallback and stays
|
|
538
|
+
# byte-exact for projects that do not build.
|
|
539
|
+
built = rendered_text(root)
|
|
540
|
+
if built is not None:
|
|
541
|
+
if normalise(row["text"]) not in built:
|
|
542
|
+
findings.append(Finding(
|
|
543
|
+
"B021", SEVERITY_ERROR, location, 0,
|
|
544
|
+
f"`{row['key']}` is \"{row['text']}\" in the registry, "
|
|
545
|
+
f"but that text is not on the rendered page",
|
|
546
|
+
))
|
|
547
|
+
elif row["text"] not in body and row["text"].strip() not in body:
|
|
467
548
|
findings.append(Finding(
|
|
468
549
|
"B021", SEVERITY_ERROR, location, 0,
|
|
469
550
|
f"`{row['key']}` is \"{row['text']}\" in the registry, "
|
|
470
|
-
f"but that text is not in {file_part}"
|
|
551
|
+
f"but that text is not in {file_part} (no build found; "
|
|
552
|
+
f"checked the source)",
|
|
471
553
|
))
|
|
472
554
|
if file_part not in swept:
|
|
473
555
|
swept.add(file_part)
|
|
@@ -507,6 +589,14 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
507
589
|
continue
|
|
508
590
|
if bare.isupper() and len(bare) <= 4:
|
|
509
591
|
continue
|
|
592
|
+
# A contraction of "I" is not Title Case, and flagging it fought a
|
|
593
|
+
# threshold this pack sets on purpose: formats.md asks for 4-8
|
|
594
|
+
# contractions per 1000 words and the first-person ones are the
|
|
595
|
+
# loudest available. Three registry rows on sshlg.me tripped this on
|
|
596
|
+
# 2026-08-15 and none of them was miscased; the writer's only repair
|
|
597
|
+
# was to delete the contraction the pack had asked for.
|
|
598
|
+
if CONTRACTED_I.match(bare):
|
|
599
|
+
continue
|
|
510
600
|
if bare[0].isupper():
|
|
511
601
|
findings.append(Finding(
|
|
512
602
|
"B024", SEVERITY_ERROR, row["location"], 0,
|
|
@@ -563,6 +653,103 @@ def _front_matter(text: str) -> tuple[dict, str]:
|
|
|
563
653
|
return fields, text[match.end():]
|
|
564
654
|
|
|
565
655
|
|
|
656
|
+
# A source file is not prose, and the prose rules were reading all of it. On
|
|
657
|
+
# sshlg.me that produced 20 rhetorical-dash errors inside `//` comments and 7
|
|
658
|
+
# keyword-stuffing errors on `const`, `string` and `name` -- 27 standing errors
|
|
659
|
+
# that no edit to the copy could clear, in a report meant to be read. A report
|
|
660
|
+
# nobody can act on is a report nobody reads, which is the same failure as a
|
|
661
|
+
# rule that contradicts a threshold.
|
|
662
|
+
#
|
|
663
|
+
# For a code file the body is its copy: the string literals `_looks_like_copy`
|
|
664
|
+
# accepts, which is the definition `B022` already sweeps with. A comment is
|
|
665
|
+
# addressed to a maintainer and an identifier is not a word; a brand pack has no
|
|
666
|
+
# opinion about either.
|
|
667
|
+
CODE_SUFFIXES = {
|
|
668
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
669
|
+
".py", ".go", ".rb", ".rs", ".java", ".kt", ".swift", ".php",
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
# `${...}` in a template literal is a value, not a word.
|
|
673
|
+
#
|
|
674
|
+
# Replaced by a space rather than dropped, so `founder of ${n} products` does not
|
|
675
|
+
# collapse into one word. Substituted rather than left in place, because
|
|
676
|
+
# `CODE_FRAGMENT_RE` rejects any literal carrying an interpolation -- so without
|
|
677
|
+
# this line every interpolated string would fail `_looks_like_copy` and
|
|
678
|
+
# interpolated copy would lose its coverage entirely. That would be a worse bug
|
|
679
|
+
# than the one this fixes: on sshlg.me the whole biography is interpolated.
|
|
680
|
+
INTERPOLATION_RE = re.compile(r"\$\{[^{}]*\}")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# Languages whose line comment is `#` rather than `//`.
|
|
684
|
+
HASH_COMMENT_SUFFIXES = {".py", ".rb"}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _strip_comments(text: str, suffix: str) -> str:
|
|
688
|
+
"""Remove comments, leaving string literals untouched.
|
|
689
|
+
|
|
690
|
+
A regex cannot do this and the first version of this fix tried: `"https://x"`
|
|
691
|
+
contains `//`, and a comment can quote a phrase, which is how
|
|
692
|
+
`// "AI integrations" named nothing checkable -- and "AI" is the medium` came
|
|
693
|
+
back as a rhetorical-dash error in copy on the first run of the very change
|
|
694
|
+
that was supposed to stop reading comments.
|
|
695
|
+
|
|
696
|
+
The scanner is small because it only has to know one thing at a time:
|
|
697
|
+
whether it is currently inside a quote.
|
|
698
|
+
"""
|
|
699
|
+
hashed = suffix in HASH_COMMENT_SUFFIXES
|
|
700
|
+
out: list[str] = []
|
|
701
|
+
quote = None
|
|
702
|
+
i, n = 0, len(text)
|
|
703
|
+
while i < n:
|
|
704
|
+
ch = text[i]
|
|
705
|
+
if quote:
|
|
706
|
+
out.append(ch)
|
|
707
|
+
if ch == "\\" and i + 1 < n: # an escaped quote is not the end
|
|
708
|
+
out.append(text[i + 1])
|
|
709
|
+
i += 2
|
|
710
|
+
continue
|
|
711
|
+
if ch == quote:
|
|
712
|
+
quote = None
|
|
713
|
+
i += 1
|
|
714
|
+
continue
|
|
715
|
+
if ch in "\"'`":
|
|
716
|
+
quote = ch
|
|
717
|
+
out.append(ch)
|
|
718
|
+
i += 1
|
|
719
|
+
continue
|
|
720
|
+
if hashed and ch == "#":
|
|
721
|
+
while i < n and text[i] != "\n":
|
|
722
|
+
i += 1
|
|
723
|
+
continue
|
|
724
|
+
if not hashed and text.startswith("//", i):
|
|
725
|
+
while i < n and text[i] != "\n":
|
|
726
|
+
i += 1
|
|
727
|
+
continue
|
|
728
|
+
if not hashed and text.startswith("/*", i):
|
|
729
|
+
end = text.find("*/", i + 2)
|
|
730
|
+
i = n if end == -1 else end + 2
|
|
731
|
+
continue
|
|
732
|
+
out.append(ch)
|
|
733
|
+
i += 1
|
|
734
|
+
return "".join(out)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _copy_in_code(text: str, suffix: str = ".ts") -> str:
|
|
738
|
+
"""The user-visible strings in a source file, joined as prose."""
|
|
739
|
+
out = []
|
|
740
|
+
for _quote, literal in LITERAL_RE.findall(_strip_comments(text, suffix)):
|
|
741
|
+
candidate = re.sub(r"\s{2,}", " ", INTERPOLATION_RE.sub(" ", literal)).strip()
|
|
742
|
+
# A literal with no space is an import path, an identifier or a one-word
|
|
743
|
+
# label. None of them can carry a rhetorical dash or stuff a keyword, and
|
|
744
|
+
# `"@/data/site"` counted as copy would put `data` into the density
|
|
745
|
+
# figures. B022 still sees them; these checks do not need to.
|
|
746
|
+
if " " not in candidate:
|
|
747
|
+
continue
|
|
748
|
+
if _looks_like_copy(candidate):
|
|
749
|
+
out.append(candidate)
|
|
750
|
+
return "\n\n".join(out)
|
|
751
|
+
|
|
752
|
+
|
|
566
753
|
def documents(brand_dir: Path, sources: dict, key: str) -> list[tuple]:
|
|
567
754
|
"""(relative path, front matter, body) for one declared source key."""
|
|
568
755
|
root = brand_dir.parent.parent
|
|
@@ -572,6 +759,8 @@ def documents(brand_dir: Path, sources: dict, key: str) -> list[tuple]:
|
|
|
572
759
|
if not path.is_file():
|
|
573
760
|
continue
|
|
574
761
|
fields, body = _front_matter(read(path) or "")
|
|
762
|
+
if path.suffix in CODE_SUFFIXES:
|
|
763
|
+
body = _copy_in_code(body, path.suffix)
|
|
575
764
|
out.append((path.relative_to(root).as_posix(), fields, body))
|
|
576
765
|
return out
|
|
577
766
|
|
|
@@ -590,17 +779,61 @@ def surfaces(brand_dir: Path) -> dict[str, dict]:
|
|
|
590
779
|
return out
|
|
591
780
|
|
|
592
781
|
|
|
782
|
+
def tables(text: str) -> list[tuple[list[str], list[list[str]]]]:
|
|
783
|
+
"""The same rows, but grouped per table as (header, data rows).
|
|
784
|
+
|
|
785
|
+
`table_rows` flattens every table in a file into one list, which is right
|
|
786
|
+
for a caller that wants every row and wrong for one that wants the rows of
|
|
787
|
+
a particular table. `facts()` was the second kind and used the first: it
|
|
788
|
+
took any six-column row anywhere in facts.md, so the App Store table --
|
|
789
|
+
`Product | App Store name | id | Released | Sold | Publisher today` -- was
|
|
790
|
+
read as six fact fields. Its **Sold** year landed in `Review`, which
|
|
791
|
+
produced three "was due for review on 2022" warnings about sales that
|
|
792
|
+
completed on schedule, and its own header row became a fact called
|
|
793
|
+
`Product`. Four phantom rows in a registry of 43.
|
|
794
|
+
|
|
795
|
+
What it did **not** do, checked before this comment was written: the App
|
|
796
|
+
Store ids were already registered properly, by the `Sold-app store ids` row,
|
|
797
|
+
so the B030 corpus was never widened by the bug. Only the product names
|
|
798
|
+
entered it, and B030 only reads numbers.
|
|
799
|
+
|
|
800
|
+
A table is identified by its header, because that is the only thing in a
|
|
801
|
+
markdown table that says what its columns mean.
|
|
802
|
+
"""
|
|
803
|
+
blocks, current = [], []
|
|
804
|
+
for line in text.splitlines():
|
|
805
|
+
stripped = line.strip()
|
|
806
|
+
if stripped.startswith("|") and stripped.endswith("|"):
|
|
807
|
+
current.append(line)
|
|
808
|
+
elif current:
|
|
809
|
+
rows = table_rows("\n".join(current))
|
|
810
|
+
if rows:
|
|
811
|
+
blocks.append((rows[0], rows[1:]))
|
|
812
|
+
current = []
|
|
813
|
+
if current:
|
|
814
|
+
rows = table_rows("\n".join(current))
|
|
815
|
+
if rows:
|
|
816
|
+
blocks.append((rows[0], rows[1:]))
|
|
817
|
+
return blocks
|
|
818
|
+
|
|
819
|
+
|
|
593
820
|
def facts(brand_dir: Path) -> list[dict]:
|
|
594
821
|
rows = []
|
|
595
|
-
for
|
|
596
|
-
|
|
822
|
+
for header, body in tables(read(brand_dir / "facts.md") or ""):
|
|
823
|
+
# Scoped by header rather than by column count -- see tables(). Any
|
|
824
|
+
# six-column table in this file used to qualify, and one of them is a
|
|
825
|
+
# product ledger whose columns mean something else entirely.
|
|
826
|
+
if len(header) < 6 or header[0].strip().lower() != "fact":
|
|
597
827
|
continue
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
828
|
+
for cells in body:
|
|
829
|
+
if len(cells) < 6:
|
|
830
|
+
continue
|
|
831
|
+
if unfilled(cells[0]) or unfilled(cells[1]):
|
|
832
|
+
continue
|
|
833
|
+
rows.append({
|
|
834
|
+
"fact": cells[0], "value": cells[1], "source": cells[2],
|
|
835
|
+
"checked": cells[3], "review": cells[4], "public": cells[5],
|
|
836
|
+
})
|
|
604
837
|
return rows
|
|
605
838
|
|
|
606
839
|
|
|
@@ -962,7 +1195,26 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
962
1195
|
))
|
|
963
1196
|
|
|
964
1197
|
records = surfaces(brand_dir)
|
|
965
|
-
|
|
1198
|
+
marketing = documents(brand_dir, sources, "marketing")
|
|
1199
|
+
|
|
1200
|
+
# Density is a property of the DOCUMENT a reader meets, and a code file is
|
|
1201
|
+
# not one. A project that keeps its copy in `src/data/*.ts` splits one page
|
|
1202
|
+
# across seven files, and measuring each file separately measures the split:
|
|
1203
|
+
# on sshlg.me that produced six errors -- `co-founder` at 2.0% of
|
|
1204
|
+
# `track-record.ts`, `account` at 1.2% of `site.ts` -- while the rendered
|
|
1205
|
+
# page carried nothing above 1% and those two words sat at 0.07% and 0.04%.
|
|
1206
|
+
#
|
|
1207
|
+
# So the code files are pooled into one document and the markdown ones are
|
|
1208
|
+
# not, because there one file really is one page. The pooled finding names
|
|
1209
|
+
# the set rather than a file, since no single file is the defect.
|
|
1210
|
+
pooled = [d for d in marketing if Path(d[0]).suffix in CODE_SUFFIXES]
|
|
1211
|
+
per_file = [d for d in marketing if Path(d[0]).suffix not in CODE_SUFFIXES]
|
|
1212
|
+
if pooled:
|
|
1213
|
+
label = (f"{len(pooled)} source file(s) pooled"
|
|
1214
|
+
if len(pooled) > 1 else pooled[0][0])
|
|
1215
|
+
per_file.append((label, {}, "\n\n".join(d[2] for d in pooled)))
|
|
1216
|
+
|
|
1217
|
+
for path, fields, body in per_file:
|
|
966
1218
|
words = [w.lower().strip(".,:;!?()\"'") for w in body.split()]
|
|
967
1219
|
real = [w for w in words if len(w) > 3 and w not in STOPWORDS]
|
|
968
1220
|
if len(real) >= 40:
|
|
@@ -979,6 +1231,8 @@ def check_bot_safety(brand_dir: Path, sources: dict) -> list[Finding]:
|
|
|
979
1231
|
))
|
|
980
1232
|
break
|
|
981
1233
|
|
|
1234
|
+
for path, fields, body in marketing:
|
|
1235
|
+
|
|
982
1236
|
opening = body.strip().lower()[:120]
|
|
983
1237
|
for filler in FILLER_OPENERS:
|
|
984
1238
|
if opening.startswith(filler) or f"\n{filler}" in opening:
|