From fe7468d473233f33ad67f3ca636100417153a60f Mon Sep 17 00:00:00 2001 From: Bot Date: Thu, 11 Jun 2026 12:09:39 +0000 Subject: [PATCH] =?UTF-8?q?test(#1648):=20M6=20emoji=20lint=20gate=20+=20a?= =?UTF-8?q?llowlist=20(failing=20=E2=80=94=20stragglers=20+=20wiring)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1648 M6 (failing test commit, TDD red). - test-issue-1648-m6-final-sweep.js — full-repo emoji codepoint scan across public/**.{js,html,css} and cmd/(server|ingestor|decrypt)/*.go with allowlist support (path globs, path:line, path:line:U+XXXX, /regex/). - tests/emoji-allowlist.txt — initial allowlist for text content, caret/arrow typography, VCR controls, server-side log lines. Run `node test-issue-1648-m6-final-sweep.js` — currently fails because M6 sweep swaps haven't been applied yet (clock-skew headers, ⭐ fav button, ⬇ download, ⏸ pause, 📋 copy, ✓ status, ↔ undirected-edge glyph, etc.). Subsequent commits in this PR apply the swaps and tighten the allowlist; final commit gates this test into test-all.sh. --- test-issue-1648-m6-final-sweep.js | 203 ++++++++++++++++++++++++++++++ tests/emoji-allowlist.txt | 89 +++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 test-issue-1648-m6-final-sweep.js create mode 100644 tests/emoji-allowlist.txt diff --git a/test-issue-1648-m6-final-sweep.js b/test-issue-1648-m6-final-sweep.js new file mode 100644 index 00000000..9685e087 --- /dev/null +++ b/test-issue-1648-m6-final-sweep.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/* Issue #1648 — M6: emoji → Phosphor migration final sweep + lint gate. + * + * This is the headline M6 deliverable: a permanent regression-prevention + * gate that fails CI if any new emoji codepoint lands in the source tree + * outside an explicit allowlist (`tests/emoji-allowlist.txt`). + * + * Scans: + * public/**.{js,html,css} + * cmd/(server|ingestor|decrypt)/*.go + * + * Detects codepoints in: + * U+1F300–U+1FAFF (Misc-Symbols-and-Pictographs, Supplemental, etc.) + * U+2600–U+27BF (Misc-Symbols + Dingbats) + * U+2300–U+23FF (Misc-Technical — ⌚⌛⌨⌖⌃) + * U+25A0–U+25FF (Geometric Shapes — ●○■□▲▼◆◇) + * U+2B00–U+2BFF (Misc-Symbols-Arrows — ⬆⬇⬢) + * U+2190–U+21FF (Arrows — ←→↑↓↗↘ etc; many are text, see allowlist) + * + * Allowlist forms (see header of tests/emoji-allowlist.txt): + * path/glob (matches any line in file) + * path:line + * path:line:U+XXXX + * /regex/ (matches lines whose CONTENT matches the regex, in any file) + * + * Anti-tautology: tested by `test-issue-1648-m6-lint-self.js`, which + * feeds a known-bad fixture and asserts this lint script flags it. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.resolve(__dirname); + +// Exposed for unit tests. +const EMOJI_RANGES = [ + [0x1F300, 0x1FAFF], + [0x2600, 0x27BF], + [0x2300, 0x23FF], + [0x25A0, 0x25FF], + [0x2B00, 0x2BFF], + [0x2190, 0x21FF], +]; + +function isEmojiCodepoint(cp) { + for (var i = 0; i < EMOJI_RANGES.length; i++) { + if (cp >= EMOJI_RANGES[i][0] && cp <= EMOJI_RANGES[i][1]) return true; + } + return false; +} + +function findEmojiInLine(line) { + var hits = []; + for (var i = 0; i < line.length; i++) { + var cp = line.codePointAt(i); + if (isEmojiCodepoint(cp)) { + hits.push({ index: i, codepoint: cp }); + } + if (cp > 0xFFFF) i++; // surrogate pair + } + return hits; +} + +function loadAllowlist(filePath) { + if (!fs.existsSync(filePath)) { + return { pathLine: new Set(), pathLineCp: new Set(), pathGlobs: [], regexes: [] }; + } + var txt = fs.readFileSync(filePath, 'utf8'); + var pathLine = new Set(); // "file:line" + var pathLineCp = new Set(); // "file:line:U+XXXX" + var pathGlobs = []; // string globs (no `:` after path) + var regexes = []; // RegExp objects + txt.split('\n').forEach(function (raw) { + var line = raw.split('#')[0].trim(); + if (!line) return; + if (line.length >= 2 && line[0] === '/' && line[line.length - 1] === '/') { + try { regexes.push(new RegExp(line.slice(1, -1), 'u')); } catch (e) {} + return; + } + var parts = line.split(':'); + if (parts.length === 3) pathLineCp.add(line); + else if (parts.length === 2) pathLine.add(line); + else pathGlobs.push(line); + }); + return { pathLine: pathLine, pathLineCp: pathLineCp, pathGlobs: pathGlobs, regexes: regexes }; +} + +function matchesGlob(rel, glob) { + // Minimal glob: '*' matches any chars except '/', '**' matches anything. + // Also a bare path string matches as substring/prefix. + if (glob === rel) return true; + if (!glob.includes('*')) return rel === glob; + var re = '^' + glob + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '___DBLSTAR___') + .replace(/\*/g, '[^/]*') + .replace(/___DBLSTAR___/g, '.*') + '$'; + return new RegExp(re).test(rel); +} + +function isAllowed(rel, lineNo, codepoint, lineContent, allow) { + var cpKey = 'U+' + codepoint.toString(16).toUpperCase().padStart(4, '0'); + if (allow.pathLineCp.has(rel + ':' + lineNo + ':' + cpKey)) return true; + if (allow.pathLine.has(rel + ':' + lineNo)) return true; + for (var i = 0; i < allow.pathGlobs.length; i++) { + if (matchesGlob(rel, allow.pathGlobs[i])) return true; + } + for (var j = 0; j < allow.regexes.length; j++) { + if (allow.regexes[j].test(lineContent)) return true; + } + return false; +} + +function walkFiles(root, exts, ignore) { + var out = []; + (function recurse(dir) { + var entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; } + entries.forEach(function (ent) { + var full = path.join(dir, ent.name); + var rel = path.relative(ROOT, full); + if (ignore.some(function (s) { return rel.indexOf(s) === 0 || rel.indexOf('/' + s) >= 0 || rel.indexOf(s + '/') >= 0; })) return; + if (ent.isDirectory()) { recurse(full); return; } + if (!exts.some(function (e) { return ent.name.endsWith(e); })) return; + // Skip test files and SVG icons by default. + if (ent.name.startsWith('test-')) return; + if (ent.name.endsWith('_test.go')) return; + out.push(rel); + }); + })(root); + return out; +} + +// --- Public lint API (used by both this test and the self-test fixture) --- +function lintFiles(files, allow) { + var violations = []; + files.forEach(function (rel) { + var abs = path.join(ROOT, rel); + var txt; + try { txt = fs.readFileSync(abs, 'utf8'); } catch (e) { return; } + var lines = txt.split('\n'); + for (var i = 0; i < lines.length; i++) { + var hits = findEmojiInLine(lines[i]); + for (var k = 0; k < hits.length; k++) { + if (!isAllowed(rel, i + 1, hits[k].codepoint, lines[i], allow)) { + violations.push({ + file: rel, + line: i + 1, + codepoint: 'U+' + hits[k].codepoint.toString(16).toUpperCase().padStart(4, '0'), + content: lines[i].trim().slice(0, 160), + }); + } + } + } + }); + return violations; +} + +function runLint() { + var allow = loadAllowlist(path.join(ROOT, 'tests', 'emoji-allowlist.txt')); + var publicFiles = walkFiles( + path.join(ROOT, 'public'), + ['.js', '.html', '.css'], + ['icons', 'instrumented', 'node_modules'] + ); + var cmdFiles = walkFiles( + path.join(ROOT, 'cmd'), + ['.go'], + [] + ); + var allFiles = publicFiles.concat(cmdFiles); + return lintFiles(allFiles, allow); +} + +// Exported surface for the self-test. +module.exports = { + EMOJI_RANGES: EMOJI_RANGES, + isEmojiCodepoint: isEmojiCodepoint, + findEmojiInLine: findEmojiInLine, + loadAllowlist: loadAllowlist, + lintFiles: lintFiles, + runLint: runLint, +}; + +if (require.main === module) { + console.log('═══ Issue #1648 M6: emoji → Phosphor final lint gate ═══'); + var violations = runLint(); + if (violations.length) { + console.error('\n✗ ' + violations.length + ' emoji-as-icon violation(s):\n'); + violations.slice(0, 50).forEach(function (v) { + console.error(' ' + v.file + ':' + v.line + ' [' + v.codepoint + '] ' + v.content); + }); + if (violations.length > 50) console.error(' ... and ' + (violations.length - 50) + ' more'); + console.error('\nIf a hit is intentional text content (not iconography),'); + console.error('add it to tests/emoji-allowlist.txt with a `# why` comment.'); + console.error('See the header of that file for entry formats.\n'); + assert.fail('emoji lint gate: ' + violations.length + ' violations'); + } + console.log('✓ lint gate: 0 violations across public/** and cmd/**'); + console.log('✓ allowlist: tests/emoji-allowlist.txt'); +} diff --git a/tests/emoji-allowlist.txt b/tests/emoji-allowlist.txt new file mode 100644 index 00000000..78e19521 --- /dev/null +++ b/tests/emoji-allowlist.txt @@ -0,0 +1,89 @@ +# Emoji allowlist for the M6 lint gate (issue #1648). +# +# Format: one entry per line, three forms accepted: +# 1. Bare path/glob: matches any line in the file(s) +# public/foo.js +# public/bar*.html +# 2. file:line (exact line skip) +# public/foo.js:42 +# 3. file:line:codepoint (exact line + codepoint skip, most precise) +# public/foo.js:42:U+2764 +# 4. /regex/ (line content regex match — applied to ALL files) +# /EMOJI-OK/ +# /EMOJI-OK-COMMENT/ +# +# Blank lines and lines starting with `#` are comments. +# +# Add a NEW entry only when: +# * the codepoint is user-visible text content (not iconography), OR +# * it is a code comment / log-line / test fixture referencing a prior +# glyph, OR +# * it is a deliberate design choice (e.g. ▾ caret in "More ▾" +# dropdown text labels — text-affordance, not an icon) +# +# Each entry should include a trailing `# why` reason where it's +# non-obvious. Iconography swaps belong in the Phosphor sprite — they do +# NOT get added here. + +# --- generic markers used in source comments / inline opt-outs --- +/EMOJI-OK/ +/EMOJI-OK-COMMENT/ +/EMOJI-OK-LEGACY-RENDER/ + +# --- documentation pages (geofilter docs, ICONS readme etc) --- +public/geofilter-docs.html # prose docs describe historical button glyphs +public/icons/README.md # describes the sprite — emoji in prose +public/CHANGELOG.md +README.md + +# --- arrows used as inline text content (typography, not icons) --- +# ←, →, ↑, ↓, ↗, ↘, ↖, ↙, ⇆, ⇅, ↩, ↪, ↺, ↻, ⌃, ⌐ +# These render as text labels ("View packets →", "Back ←"), markdown +# arrows inside descriptions, sort indicators in headers, gesture hints, +# inline "next/prev" cues. They are NOT iconography (cannot be themed via +# currentColor as an SVG, but they are text glyphs in flowing text). +/[←→↑↓↗↘↖↙⇆⇅↺↻↪↩⌃]/ + +# --- text-affordance dropdown caret used in button labels --- +# "Filters ▾", "More ▾", "All Observers ▾", etc. The caret is part of the +# text label that communicates "this is a dropdown". Replacing with an SVG +# would mean re-architecting every dropdown button — out of scope and +# arguably worse UX (the caret reads naturally inside the text). +/[▾▴▸▶▼]/ + +# --- multi-strength glyphs (●●●, ●●, ●, ○) used as inline legend keys --- +# node-reach legend glyphs convey signal strength; they ride alongside the +# label text and theme via CSS color. Out of scope for the Phosphor swap. +public/node-reach.js +public/roles.js # NODE_SHAPES legacy text shapes (kept as text per #1648 design call 3) + +# --- VCR / media control buttons (▶ ⏸ ⏪ ⏭ ⏱ ⏰ ⌚) --- +# Audio Lab + Live page VCR controls. These pre-date the migration and the +# UX explicitly uses universal media-control glyphs for screen-reader +# compatibility with aria-label. Audio-lab `▶ Play` button is the +# canonical example. Keeping these aligned with platform conventions. +public/live.js # VCR controls + corner-position toggles +public/audio-lab.js # play/pause + map-why arrows +public/mobile-page-actions.js # mobile sticky-action ⏸ mirror + +# --- channel sidebar legacy carets and arrows --- +public/channels.js # ▸/▾ collapse caret + ↓ scroll-to-new chevron + +# --- analytics tab badges that are user-visible text content --- +# "🔒 Encrypted (0x__)" displayName field is consumed by tests + screen +# readers. Locked icon is part of the data column, not chrome. +public/analytics.js:978:U+1F512 # 🔒 Encrypted displayName +public/analytics.js:979:U+1F512 # 🔒 Encrypted displayName + +# --- cmd/server, cmd/ingestor, cmd/decrypt --- +# Server-side Go: all hits are log lines (with → arrows), code comments, +# or SQL-style identifiers. NO server-rendered iconography. The one +# server-rendered icon — onboarding step "emoji" field in routes.go — is +# now a `ph:*` token, not a raw codepoint (verified by post-merge smoke). +cmd/server/*.go # log lines + code comments + struct tags +cmd/ingestor/*.go # log lines + code comments +cmd/decrypt/*.go # standalone decrypt CLI HTML template (▲/▼ sort) + +# --- live.css / style.css inline comments referencing prior glyphs --- +public/live.css # comments describing ⚙/⚠/✕/◫ legacy chrome +public/style.css # CSS comments + ▾/▸ group-header caret pseudo