mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-23 08:00:11 +00:00
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:
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user