-
-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathbranding.test.js
More file actions
419 lines (378 loc) · 13.8 KB
/
Copy pathbranding.test.js
File metadata and controls
419 lines (378 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
/**
* Branding lock — fails if forbidden partner brand names appear in
* user-facing surfaces of the three.ws codebase.
*
* Scope and exemptions are documented inline below and in
* ./branding-allowlist.json. This test deliberately uses only `node:fs`
* and `node:path` so it adds no new dependencies.
*/
import { describe, test, expect } from 'vitest';
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
const ALLOWLIST_PATH = join(REPO_ROOT, 'tests', 'branding-allowlist.json');
/** @type {Array<{ pattern: string, file: string, reason: string }>} */
const allowlist = JSON.parse(readFileSync(ALLOWLIST_PATH, 'utf8'));
// Module-level read cache — each file is read exactly once across all test cases.
const _fileCache = new Map();
function cachedRead(absPath) {
if (!_fileCache.has(absPath)) _fileCache.set(absPath, readFileSync(absPath, 'utf8'));
return _fileCache.get(absPath);
}
// ── Forbidden strings ───────────────────────────────────────────────────────
//
// Each entry: { id, regex, label }
// - id — stable identifier used by allowlist + test naming
// - regex — case-insensitive matcher
// - label — human-readable brand
//
// "RPM" is special-cased below (only flagged near avatar/selfie).
const FORBIDDEN = [
{ id: 'avaturn', regex: /avaturn/i, label: 'Avaturn' },
{
id: 'character-studio',
regex: /character\s*studio/i,
label: 'Character Studio',
},
{
id: 'ready-player-me',
regex: /ready\s*player\s*me|readyplayer\.me/i,
label: 'Ready Player Me',
},
];
// ── Path scoping ────────────────────────────────────────────────────────────
const SKIP_DIR_NAMES = new Set([
'node_modules',
'dist',
'dist-lib',
'tests',
'migrations',
'.git',
]);
/**
* Return true if a repo-relative path should be excluded entirely
* regardless of which scope it would otherwise match.
*/
function isHardSkipped(relPath) {
const parts = relPath.split(sep);
for (const part of parts) {
if (SKIP_DIR_NAMES.has(part)) return true;
}
// avatar-sdk has its own dist subtree to skip.
if (relPath.startsWith(`avatar-sdk${sep}dist${sep}`)) return true;
// docs/audit/* are internal engineering audit + remediation logs that by
// nature quote the exact brand strings they are cataloguing for removal.
// They are not user-facing product copy, so they are out of scope.
if (relPath.startsWith(`docs${sep}audit${sep}`)) return true;
// public/internal/* are headless automation harnesses (noindex,nofollow),
// driven only by server-side puppeteer — the browser twins of the api/_lib
// headless helpers that are already out of scope. They carry vendor SDK
// identifiers and result-channel globals as code, never product copy that a
// user reads, so they are out of scope on the same grounds.
if (relPath.startsWith(`public${sep}internal${sep}`)) return true;
return false;
}
/**
* Walk a directory recursively, yielding absolute file paths that pass
* the optional `match` predicate and are not hard-skipped.
*/
function* walk(absDir, match) {
if (!existsSync(absDir)) return;
let entries;
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
const abs = join(absDir, ent.name);
const rel = relative(REPO_ROOT, abs);
if (isHardSkipped(rel)) continue;
if (ent.isDirectory()) {
yield* walk(abs, match);
} else if (ent.isFile()) {
if (!match || match(abs, rel)) yield abs;
}
}
}
/**
* Collect all in-scope files for the branding scan.
* Scope (per spec):
* - pages/**\/*.html (bundled top-level pages, formerly at repo root)
* - public/**\/*.html
* - docs/**\/*.md
* - public/docs/**\/*.md
* - avatar-sdk/README.md
* - avatar-sdk/types/**\/*.d.ts
* - src/**\/*.js (JSDoc on exported symbols only — handled specially)
* - README.md
*/
function collectScopedFiles() {
const files = [];
// 1. pages/**/*.html — bundled top-level pages
for (const abs of walk(
join(REPO_ROOT, 'pages'),
(_a, rel) => rel.endsWith('.html'),
)) {
files.push({ abs, kind: 'html' });
}
// 2. public/**/*.html
for (const abs of walk(
join(REPO_ROOT, 'public'),
(_a, rel) => rel.endsWith('.html'),
)) {
files.push({ abs, kind: 'html' });
}
// 3. docs/**/*.md
// docs/ALL.md is a read-only generated concatenation of every other doc
// (scripts/combine-docs.mjs). Its sources are each already in scope, so
// scanning it too would double every finding and force brittle parallel
// allowlist entries that silently drift on each regeneration. Skip it.
for (const abs of walk(
join(REPO_ROOT, 'docs'),
(_a, rel) => rel.endsWith('.md') && rel !== join('docs', 'ALL.md'),
)) {
files.push({ abs, kind: 'md' });
}
// 4. public/docs/**/*.md
for (const abs of walk(
join(REPO_ROOT, 'public', 'docs'),
(_a, rel) => rel.endsWith('.md'),
)) {
files.push({ abs, kind: 'md' });
}
// 5. avatar-sdk/README.md
const sdkReadme = join(REPO_ROOT, 'avatar-sdk', 'README.md');
if (existsSync(sdkReadme)) files.push({ abs: sdkReadme, kind: 'md' });
// 6. avatar-sdk/types/**/*.d.ts
for (const abs of walk(
join(REPO_ROOT, 'avatar-sdk', 'types'),
(_a, rel) => rel.endsWith('.d.ts'),
)) {
files.push({ abs, kind: 'dts' });
}
// 7. src/**/*.js (JSDoc on exports — scanned via separate path)
for (const abs of walk(
join(REPO_ROOT, 'src'),
(_a, rel) => rel.endsWith('.js'),
)) {
files.push({ abs, kind: 'src-js' });
}
// 8. Top-level README
for (const rel of ['README.md']) {
const abs = join(REPO_ROOT, rel);
if (existsSync(abs)) files.push({ abs, kind: 'readme' });
}
return files;
}
// ── Special-case helpers ────────────────────────────────────────────────────
/**
* Extract the line ranges inside a JS source that belong to a JSDoc block
* immediately followed by an `export class|function|const` (or `export
* default class|function`) declaration.
*
* "Immediately followed" means: between the JSDoc's closing `*\/` and the
* `export` keyword there is ONLY whitespace — no line comments, no other
* code. We scan structurally (not with one greedy regex) to avoid the
* non-greedy backtracking trap where a single JSDoc could be paired with
* a far-away export, swallowing intermediate code.
*
* Returns an array of { startLine, endLine } 1-based inclusive ranges
* covering only the JSDoc bodies themselves.
*/
function findExportedJSDocRanges(source) {
// Precompute cumulative byte offsets per line for O(log n) char→line lookup.
// Without this, source.slice(0, idx).split('\n').length is O(n) per call,
// making the whole function O(n * jsdocCount) — too slow on large src/ files.
const lineOffsets = [0];
for (let k = 0; k < source.length; k++) {
if (source[k] === '\n') lineOffsets.push(k + 1);
}
function charToLine(idx) {
let lo = 0, hi = lineOffsets.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineOffsets[mid] <= idx) lo = mid;
else hi = mid - 1;
}
return lo + 1;
}
const ranges = [];
const openRe = /\/\*\*/g;
let om;
while ((om = openRe.exec(source)) !== null) {
const openIdx = om.index;
const closeIdx = source.indexOf('*/', openIdx + 3);
if (closeIdx === -1) break;
const afterClose = closeIdx + 2;
// Inspect whitespace-only run after the closing */.
let i = afterClose;
while (i < source.length && /\s/.test(source[i])) i++;
const tail = source.slice(i, i + 80);
if (
/^export\s+(?:default\s+)?(?:async\s+)?(?:class|function|const|let|var)\b/.test(
tail,
)
) {
ranges.push({ startLine: charToLine(openIdx), endLine: charToLine(afterClose) });
}
// Continue scanning *after* this JSDoc's close so we don't overlap.
openRe.lastIndex = afterClose;
}
return ranges;
}
/**
* For src/**\/*.js — return only the lines that live inside a JSDoc block
* directly preceding an exported symbol. Returns an array of
* { lineNo, text } pairs.
*/
function getExportedJSDocLines(absPath) {
const source = cachedRead(absPath);
const lines = source.split('\n');
const ranges = findExportedJSDocRanges(source);
const out = [];
for (const { startLine, endLine } of ranges) {
for (let n = startLine; n <= endLine; n++) {
out.push({ lineNo: n, text: lines[n - 1] ?? '' });
}
}
return out;
}
/**
* Detect fenced code blocks (``` ... ```) in a markdown source. Returns
* a Set of 1-based line numbers that sit inside a fence.
*/
function fencedCodeLines(text) {
const inside = new Set();
const lines = text.split('\n');
let open = false;
for (let i = 0; i < lines.length; i++) {
if (/^```/.test(lines[i].trim())) {
open = !open;
continue; // the fence line itself is not "inside"
}
if (open) inside.add(i + 1);
}
return inside;
}
// ── Allowlist matching ──────────────────────────────────────────────────────
/**
* Return true if a hit is excused by an allowlist entry. An entry
* matches when:
* - entry.file is "*" OR is a suffix of the repo-relative path, AND
* - the offending line contains entry.pattern as a literal substring
* (case-sensitive — the patterns in the allowlist are literal code).
*
* Pattern matching is intentionally substring-based: the allowlist holds
* literal code fragments like `source: 'avaturn'` or `from '@avaturn/sdk'`.
*/
function isAllowed(relPath, lineText) {
for (const entry of allowlist) {
const fileMatches =
entry.file === '*' ||
relPath === entry.file ||
relPath.endsWith(entry.file);
if (!fileMatches) continue;
if (lineText.includes(entry.pattern)) return true;
}
return false;
}
// ── Core scanner ────────────────────────────────────────────────────────────
/**
* Returns all hits across the in-scope files for a given forbidden
* pattern. RPM gets special handling via the optional `extraGate`.
*/
function scanForPattern(forbidden, files, extraGate) {
const hits = [];
for (const f of files) {
const relPath = relative(REPO_ROOT, f.abs);
const text = cachedRead(f.abs);
const allLines = text.split('\n');
// For src JS files, restrict to JSDoc-on-exports lines.
let candidateLines;
if (f.kind === 'src-js') {
candidateLines = getExportedJSDocLines(f.abs);
} else {
candidateLines = allLines.map((text, i) => ({
lineNo: i + 1,
text,
}));
}
// Pre-compute fenced code line set for md/readme files so the
// extraGate can use it.
const fenced =
f.kind === 'md' || f.kind === 'readme'
? fencedCodeLines(text)
: null;
for (const { lineNo, text: line } of candidateLines) {
if (!forbidden.regex.test(line)) continue;
// Optional gate (used by RPM heuristic).
if (extraGate && !extraGate({ relPath, line, fenced, lineNo })) {
continue;
}
if (isAllowed(relPath, line)) continue;
hits.push({ file: relPath, line: lineNo, text: line.trim() });
}
}
return hits;
}
// ── Test cases ──────────────────────────────────────────────────────────────
describe('three.ws branding lock', () => {
const files = collectScopedFiles();
// The scanner walks every user-facing file end-to-end for each brand. On a
// large monorepo (hundreds of HTML/JS/MD files in scope) that comfortably
// exceeds the default 20s vitest timeout — bump per-test to 5 minutes so
// CI doesn't false-fail on perfectly clean trees.
const SCAN_TIMEOUT_MS = 5 * 60 * 1000;
for (const forbidden of FORBIDDEN) {
test(`no "${forbidden.label}" in user-facing files`, () => {
const hits = scanForPattern(forbidden, files);
if (hits.length > 0) {
const formatted = hits
.map(
(h) =>
` ${h.file}:${h.line}\n ${h.text}`,
)
.join('\n');
throw new Error(
`Forbidden brand "${forbidden.label}" found in ${hits.length} user-facing location(s):\n${formatted}\n\nReplace with three.ws-branded language, or add a documented exemption to tests/branding-allowlist.json.`,
);
}
expect(hits).toEqual([]);
}, SCAN_TIMEOUT_MS);
}
test('no "RPM" referring to Ready Player Me (heuristic: near avatar/selfie)', () => {
const RPM_LINE = /\bRPM\b/;
const NEARBY = /avatar|selfie/i;
const hits = [];
for (const f of files) {
const relPath = relative(REPO_ROOT, f.abs);
const text = cachedRead(f.abs);
const allLines = text.split('\n');
const candidateLines =
f.kind === 'src-js'
? getExportedJSDocLines(f.abs)
: allLines.map((t, i) => ({ lineNo: i + 1, text: t }));
for (const { lineNo, text: line } of candidateLines) {
if (!RPM_LINE.test(line)) continue;
// Window: the line itself plus the two surrounding lines.
const ctxStart = Math.max(0, lineNo - 2);
const ctxEnd = Math.min(allLines.length, lineNo + 1);
const ctx = allLines.slice(ctxStart, ctxEnd).join(' ');
if (!NEARBY.test(ctx)) continue;
if (isAllowed(relPath, line)) continue;
hits.push({ file: relPath, line: lineNo, text: line.trim() });
}
}
if (hits.length > 0) {
const formatted = hits
.map((h) => ` ${h.file}:${h.line}\n ${h.text}`)
.join('\n');
throw new Error(
`Found "RPM" near avatar/selfie context in ${hits.length} location(s) — assume Ready Player Me:\n${formatted}`,
);
}
expect(hits).toEqual([]);
}, SCAN_TIMEOUT_MS);
});