super-ux 0.59.1 → 0.59.2

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,24 @@
1
+ ## 0.59.2 — brand-lint names the line, and a release refuses a lightweight tag
2
+
3
+ - **`B022` reports the line.** An unregistered literal was reported at line 0 —
4
+ `B022 bin/super-ux.js:0` for a string on line 303 — so the finding named a file
5
+ and left the search to the reader. Code literals now carry the line their first
6
+ character is on (template-literal paragraphs included), HTML controls the line
7
+ they open on. The comment scanner keeps a block comment's newlines so a stripped
8
+ comment cannot shift the count. Fixture `b022_line_cases` in
9
+ `test/brand_lint_test.py`; three plants (line 0 back, comment newlines dropped,
10
+ HTML line pinned) each turned it red.
11
+ - **The probe string that fired it is retired, not registered.** `claude --version`
12
+ in `bin/super-ux.js` was the `command` field of the typed result returned when the
13
+ claude CLI is absent; nothing prints it. B022's doctrine is "agree it or retire
14
+ it", and a row in `strings.md` would register as interface copy a string no
15
+ reader sees, so the field is now derived from the probe's own argv, the way
16
+ `run()` derives every other `command`. `python3 docs/brand/lint.py` is clean.
17
+ - **The release refuses a lightweight tag.** A step right after checkout in
18
+ `release.yml` fails unless `git cat-file -t <tag>` is `tag`. `git describe` and
19
+ `git submodule status` see annotated tags only; v0.59.1 was cut lightweight and
20
+ the hub read super-ux as its previous release.
21
+
1
22
  ## 0.59.1 — inventories and audits work without delegation
2
23
 
3
24
  Brand inventory, scenario inventory and UX audit now use the same areas, batches
package/bin/super-ux.js CHANGED
@@ -294,13 +294,17 @@ function printUpdateLine() {
294
294
 
295
295
  function installClaudePlugin() {
296
296
  console.log(`\n--- Claude Code plugin ---`);
297
- const probe = spawnSync('claude', ['--version'], { stdio: 'ignore' });
297
+ // The probe's argv is the one source of its `command`, exactly as run()
298
+ // derives it: the typed result carries it, nothing prints it, so it is not
299
+ // interface copy and gets no strings.md row (brand-lint B022, retired).
300
+ const probeArgv = ['claude', '--version'];
301
+ const probe = spawnSync(probeArgv[0], probeArgv.slice(1), { stdio: 'ignore' });
298
302
  if (probe.error && probe.error.code === 'ENOENT') {
299
303
  console.log(`claude CLI not found. Run inside Claude Code instead:
300
304
  /plugin marketplace add ${REPO}
301
305
  /plugin install super-ux@super-ux`);
302
306
  return { status: 'unsupported', code: null, signal: null,
303
- command: 'claude --version' };
307
+ command: probeArgv.join(' ') };
304
308
  }
305
309
  if (run('claude', ['plugin', 'marketplace', 'add', REPO]).status !== 'ok') {
306
310
  console.log('(marketplace may already be added, continuing)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-ux",
3
- "version": "0.59.1",
3
+ "version": "0.59.2",
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"
@@ -560,16 +560,38 @@ def code_literals(text: str) -> list[str]:
560
560
  Comments must already be stripped by the caller -- `_strip_comments` leaves
561
561
  literals untouched, which is what makes both patterns below safe to run.
562
562
  """
563
- out: list[str] = [lit for _quote, lit in LITERAL_RE.findall(text)]
564
- for block in TEMPLATE_RE.findall(text):
563
+ return [lit for lit, _line in code_literals_at(text)]
564
+
565
+
566
+ def code_literals_at(text: str) -> list[tuple[str, int]]:
567
+ """`code_literals`, each with the 1-based line its first character is on.
568
+
569
+ B022 reported every unregistered literal at line 0 -- `bin/super-ux.js:0`
570
+ for a string on line 303 -- so the finding named a file and left the reader
571
+ to search it. The line is counted in the comment-stripped text, which
572
+ `_strip_comments` keeps line-for-line with the source (a block comment
573
+ leaves its newlines behind), so the number is the source's own.
574
+ """
575
+ def line_at(offset: int) -> int:
576
+ return text.count("\n", 0, offset) + 1
577
+
578
+ out: list[tuple[str, int]] = [
579
+ (m.group(2), line_at(m.start(2))) for m in LITERAL_RE.finditer(text)
580
+ ]
581
+ for m in TEMPLATE_RE.finditer(text):
582
+ block = m.group(1)
565
583
  if "\n" not in block:
566
584
  continue # single-line backticks are already in `out`
585
+ cursor = 0
567
586
  for para in re.split(r"\n\s*\n", block):
587
+ at = block.find(para, cursor)
588
+ cursor = at + len(para)
568
589
  joined = " ".join(
569
590
  line.strip() for line in para.splitlines() if line.strip()
570
591
  )
571
592
  if len(joined) >= 3:
572
- out.append(joined)
593
+ lead = len(para) - len(para.lstrip())
594
+ out.append((joined, line_at(m.start(1) + at + lead)))
573
595
  return out
574
596
 
575
597
  # A template literal's interpolations split it into pieces, and the pieces are
@@ -742,10 +764,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
742
764
  body = read(target) or ""
743
765
  html = html_copy(body) if target.suffix.lower() in HTML_SUFFIXES else None
744
766
  source_copy = " ".join(html.blocks) if html is not None else body
745
- literals = html.interface_texts if html is not None else [
746
- lit for lit in code_literals(_strip_comments(body, target.suffix))
747
- if _looks_like_copy(lit)
748
- ]
767
+ located = list(zip(html.interface_texts, html.interface_lines)) \
768
+ if html is not None else [
769
+ (lit, line) for lit, line
770
+ in code_literals_at(_strip_comments(body, target.suffix))
771
+ if _looks_like_copy(lit)
772
+ ]
773
+ literals = [lit for lit, _line in located]
749
774
  if literals or html is not None:
750
775
  # The registry records what a reader sees, so the built page is the
751
776
  # authority whenever there is one. Source is the fallback and stays
@@ -781,10 +806,13 @@ def check_consistency(brand_dir: Path, sources: dict) -> list[Finding]:
781
806
  swept.add(file_part)
782
807
  known = {r["text"] for r in rows}
783
808
  trimmed = {t.strip() for t in known}
784
- for lit in dict.fromkeys(literals): # one report per literal
809
+ first_line: dict[str, int] = {}
810
+ for lit, line in located: # one report per literal, at its first line
811
+ first_line.setdefault(lit, line)
812
+ for lit, line in first_line.items():
785
813
  if lit not in known and lit.strip() not in trimmed:
786
814
  findings.append(Finding(
787
- "B022", SEVERITY_WARN, f"{file_part}", 0,
815
+ "B022", SEVERITY_WARN, f"{file_part}", line,
788
816
  f"\"{lit}\" is in the code with no registry row -- "
789
817
  f"agree it or retire it",
790
818
  ))
@@ -927,6 +955,7 @@ class HTMLCopy(HTMLParser):
927
955
  self.last_anchor_end = None
928
956
  self.controls = []
929
957
  self.interface_texts = []
958
+ self.interface_lines = [] # parallel to interface_texts: the control's opening line
930
959
 
931
960
  @property
932
961
  def blocked(self):
@@ -939,7 +968,7 @@ class HTMLCopy(HTMLParser):
939
968
  attrs.get("style", ""), re.I))
940
969
  if not hidden:
941
970
  if tag in self.CONTROLS or attrs.get("role", "").lower() in self.INTERFACE_ROLES:
942
- self.controls.append((tag, len(self.stack), []))
971
+ self.controls.append((tag, len(self.stack), [], self.getpos()[0]))
943
972
  if tag == "br" or tag in self.BLOCK or re.fullmatch(r"h[1-6]", tag):
944
973
  for control in self.controls:
945
974
  control[2].append(" ")
@@ -978,6 +1007,7 @@ class HTMLCopy(HTMLParser):
978
1007
  text = " ".join("".join(control[2]).split())
979
1008
  if text:
980
1009
  self.interface_texts.append(text)
1010
+ self.interface_lines.append(control[3])
981
1011
  self.controls.remove(control)
982
1012
  if tag == "a" and not self.blocked:
983
1013
  self.last_anchor_end = len(self.parts)
@@ -1106,7 +1136,12 @@ def _strip_comments(text: str, suffix: str) -> str:
1106
1136
  continue
1107
1137
  if not hashed and text.startswith("/*", i):
1108
1138
  end = text.find("*/", i + 2)
1109
- i = n if end == -1 else end + 2
1139
+ stop = n if end == -1 else end + 2
1140
+ # Keep the comment's newlines: B022 reports a literal by its line
1141
+ # in THIS text, so a stripped multi-line comment must not shift
1142
+ # every line after it.
1143
+ out.append("\n" * text.count("\n", i, stop))
1144
+ i = stop
1110
1145
  continue
1111
1146
  out.append(ch)
1112
1147
  i += 1