test(#1648): M6 emoji lint gate + allowlist (failing — stragglers + wiring)

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.
This commit is contained in:
Bot
2026-06-11 12:09:39 +00:00
parent 1116801b2f
commit fe7468d473
2 changed files with 292 additions and 0 deletions
+203
View File
@@ -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+1F300U+1FAFF (Misc-Symbols-and-Pictographs, Supplemental, etc.)
* U+2600U+27BF (Misc-Symbols + Dingbats)
* U+2300U+23FF (Misc-Technical — ⌚⌛⌨⌖⌃)
* U+25A0U+25FF (Geometric Shapes — ●○■□▲▼◆◇)
* U+2B00U+2BFF (Misc-Symbols-Arrows — ⬆⬇⬢)
* U+2190U+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');
}
+89
View File
@@ -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