web/test/scan.ts54 lines · 2740 bytes
| 1 | // Shared by both self-tests: the identity scan and the files it covers. |
| 2 | // The blocked names are not in the repo. They are read at test time from prep/blocked-names.txt (gitignored; one |
| 3 | // name per line, '#' starts a comment), or from the file named by BLOCKED_NAMES_FILE. Without that file the name |
| 4 | // check is skipped with a note, so a fresh clone and CI still pass; the local-path check always runs. |
| 5 | import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' |
| 6 | import { join } from 'node:path' |
| 7 | |
| 8 | /** The self-tests run from web/ (npm run selftest), so the repo root is one level up */ |
| 9 | const NAMES_FILE = process.env.BLOCKED_NAMES_FILE || join(process.cwd(), '..', 'prep', 'blocked-names.txt') |
| 10 | |
| 11 | function loadNames(): string[] { |
| 12 | if (!existsSync(NAMES_FILE)) return [] |
| 13 | return readFileSync(NAMES_FILE, 'utf8') |
| 14 | .split('\n') |
| 15 | .map((l) => l.replace(/#.*$/, '').trim()) |
| 16 | .filter(Boolean) |
| 17 | } |
| 18 | export const BLOCKED: readonly string[] = loadNames() |
| 19 | /** Says which name list the scan used; the self-tests print it next to their identity checks */ |
| 20 | export const NAMES_NOTE = BLOCKED.length |
| 21 | ? `${BLOCKED.length} ${BLOCKED.length === 1 ? 'name' : 'names'} from the local list` |
| 22 | : 'name check skipped, no prep/blocked-names.txt; local paths still checked' |
| 23 | |
| 24 | /** Local paths: a home directory on macOS or Linux, or a macOS temp dir (split so this file does not match itself) */ |
| 25 | export const LOCAL_PATH = new RegExp(['/Us', 'ers/'].join('') + '|' + ['/ho', 'me/'].join('') + '|' + ['/private/', 'var/'].join('') + '|' + ['/var/', 'folders/'].join('')) |
| 26 | |
| 27 | export function findBlocked(text: string): string[] { |
| 28 | const low = text.toLowerCase() |
| 29 | // a hit names the entry's position in the list, never the name itself, so test output stays clean too |
| 30 | const hits = BLOCKED.flatMap((b, i) => (low.includes(b.toLowerCase()) ? [`blocked name #${i + 1}`] : [])) |
| 31 | if (LOCAL_PATH.test(text)) hits.push('local path') |
| 32 | return hits |
| 33 | } |
| 34 | |
| 35 | /** Every source file of web/ that ships or builds (not node_modules, not build output) */ |
| 36 | export function webFiles(root: string): string[] { |
| 37 | const out: string[] = [] |
| 38 | const walk = (dir: string) => { |
| 39 | for (const name of readdirSync(dir)) { |
| 40 | if (name === 'node_modules' || name.startsWith('dist') || name.startsWith('.')) continue |
| 41 | const p = join(dir, name) |
| 42 | if (statSync(p).isDirectory()) walk(p) |
| 43 | else if (/\.(ts|mjs|js|json|css|html|svg)$/.test(name) && name !== 'package-lock.json') out.push(p) |
| 44 | } |
| 45 | } |
| 46 | walk(root) |
| 47 | return out |
| 48 | } |
| 49 | |
| 50 | export function scanWeb(root: string): { file: string; hits: string[] }[] { |
| 51 | return webFiles(root) |
| 52 | .map((file) => ({ file: file.slice(root.length + 1), hits: findBlocked(readFileSync(file, 'utf8')) })) |
| 53 | .filter((x) => x.hits.length) |
| 54 | } |