102 lines
6.5 KiB
JavaScript
102 lines
6.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-no-fleet-names.mjs — source text names no build-team member, attributes no request to a user, and carries
|
|
* no dated QA-round tag.
|
|
*
|
|
* The tracked source of this repo is published. Comments are where provenance creeps in: who measured a thing,
|
|
* which request a choice answered, which QA round found it. The technical fact belongs in the comment; the person,
|
|
* the request and the date-stamped round do not. A sweep removed every such line; this gate keeps the count at 0.
|
|
*
|
|
* SCANS every tracked text file EXCEPT research data (data/, plugin-designs/<plugin>/data/) and build output
|
|
* (public/). The translation catalogues ARE scanned: a name can hide in a string as well as a comment, and a
|
|
* translation that ever collides with a short name is allowed by its line, not by skipping the folder. Names match
|
|
* in any case. Three classes:
|
|
* 1. a build-team member's name (the fleet's agent names);
|
|
* 2. a per-user attribution (the words "per" and "user" in a row: a request credited to a person);
|
|
* 3. a dated QA-round tag (a lowercase or uppercase QA label followed by a 20xx date).
|
|
* ALLOWED: named LINES only (ALLOW below: a file plus the exact line pattern, with the reason), never a whole file,
|
|
* so a new name added to an allowlisted file still fails. It is empty today: this gate builds its own patterns
|
|
* from parts, so it does not match itself.
|
|
* --self-test plants each class (and a clean line, and an allowed line) and must see exactly the planted ones.
|
|
*/
|
|
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
|
// Built from parts so this file does not match itself line by line.
|
|
const NAMES = ['dx-' + 'shepherd', 'exp-' + 'shepherd', 'go-' + 'int', 'msg-' + 'agent', 're' + 'ga']
|
|
// The plugin agents are named <plugin>-plugin; the folders and bundles never carry that suffix.
|
|
const PLUGIN_AGENTS = ['deep-research', 'prayer-progress', 'poster-design', 'country-posters', 'wall-of-people-groups',
|
|
'simpleresearch', 'regions', 'big-picture', 'bible-poster', 'upg100', 'gospel', 'cities'].map((p) => p + '-' + 'plugin')
|
|
const CLASSES = [
|
|
['a build-team member named', new RegExp('\\b(' + NAMES.concat(PLUGIN_AGENTS).join('|') + ')\\b', 'i')],
|
|
['a per-user attribution', new RegExp('\\bper ' + 'user\\b', 'i')],
|
|
['a dated QA tag', new RegExp('\\(q' + 'a:|\\bq' + 'a: ?20\\d\\d-|\\bQ' + 'A 20\\d\\d-\\d\\d')],
|
|
]
|
|
const SKIP = [/^data\//, /^public\//, /^plugin-designs\/[^/]+\/data\//, /\/node_modules\//]
|
|
const TEXT = /\.(m?js|cjs|ts|vue|html|css|json|md|sh|go|py|ya?ml|txt|svg)$|(^|\/)(Dockerfile|\.gitignore|\.gitattributes)$/
|
|
// { file, line: RegExp matching the WHOLE allowed line, why }. A detector that must name what it detects goes here,
|
|
// one line at a time. Empty today.
|
|
export const ALLOW = []
|
|
|
|
export function scanText(text, file = '', allow = ALLOW) {
|
|
const hits = []
|
|
text.split('\n').forEach((line, i) => {
|
|
if (allow.some((a) => a.file === file && a.line.test(line))) return
|
|
for (const [what, re] of CLASSES) if (re.test(line)) hits.push({ file, line: i + 1, what, text: line.trim().slice(0, 140) })
|
|
})
|
|
return hits
|
|
}
|
|
|
|
function trackedFiles() {
|
|
const out = execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, maxBuffer: 1 << 28 }).toString('utf8')
|
|
return out.split('\0').filter((f) => f && TEXT.test(f) && !SKIP.some((re) => re.test(f)))
|
|
}
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
const planted = [
|
|
['measured by ' + NAMES[4] + ' at 390', 1],
|
|
['(' + NAMES[0] + "'s ruling)", 1],
|
|
['// Per ' + 'user 2026-05-02: drop the prefix', 1],
|
|
['/* fits the band (q' + 'a: 2026-05-02 iter-9) */', 1],
|
|
['// Q' + 'A 2026-04-27: rows match desktop', 1],
|
|
['// the drawer opens from the right in ar', 0],
|
|
['// a user of the map can pick a language', 0],
|
|
['// ' + NAMES[4].toUpperCase().slice(0, 1) + NAMES[4].slice(1) + ' measured it at 390', 1],
|
|
['(' + NAMES[0].toUpperCase() + "'s call)", 1],
|
|
['// on ' + PLUGIN_AGENTS[9] + "'s argument", 1],
|
|
['// the ' + PLUGIN_AGENTS[3].replace('-' + 'plugin', '') + ' bundle', 0],
|
|
['// the irrigation (reg' + 'ar) field', 0],
|
|
]
|
|
let bad = 0
|
|
for (const [line, want] of planted) {
|
|
const got = scanText(line, 'x.js').length
|
|
const ok = got === want
|
|
console.log(` ${ok ? 'ok ' : 'BAD'} ${want ? 'caught' : 'clean '}: ${line}`)
|
|
if (!ok) bad++
|
|
}
|
|
// A name inside a translation catalogue is scanned like any other file.
|
|
const localeFile = 'library/i18n/locales/en/x.json'
|
|
if (SKIP.some((re) => re.test(localeFile)) || scanText('{ "note": "by ' + NAMES[0] + '" }', localeFile).length !== 1) { console.log(' BAD locales: a name in a catalogue was not caught'); bad++ }
|
|
// The allowlist is per LINE: an allowed line passes, a different flagged line in the SAME file still fails.
|
|
const allow = [{ file: 'd.mjs', line: /^const PATTERN = .*detects$/, why: 'self-test' }]
|
|
const allowedLine = 'const PATTERN = "' + NAMES[0] + '" // detects'
|
|
if (scanText(allowedLine, 'd.mjs', allow).length !== 0) { console.log(' BAD allowlist: an allowed line was flagged'); bad++ }
|
|
if (scanText(allowedLine + '\n// measured by ' + NAMES[4], 'd.mjs', allow).length !== 1) { console.log(' BAD allowlist: a new name in an allowlisted file passed'); bad++ }
|
|
if (scanText(allowedLine, 'other.mjs', allow).length !== 1) { console.log(' BAD allowlist: the allowed line passed in another file'); bad++ }
|
|
if (bad) { console.error(`NO-FLEET-NAMES SELF-TEST: FAIL — ${bad} line(s) judged wrong.`); process.exit(1) }
|
|
console.log(`NO-FLEET-NAMES SELF-TEST: PASS — ${planted.filter((p) => p[1]).length} planted lines caught, ${planted.filter((p) => !p[1]).length} clean lines passed, the per-line allowlist holds (3 cases), catalogues are scanned.`)
|
|
process.exit(0)
|
|
}
|
|
|
|
const files = trackedFiles()
|
|
const hits = []
|
|
for (const f of files) { let t; try { t = readFileSync(join(ROOT, f), 'utf8') } catch { continue } hits.push(...scanText(t, f)) }
|
|
if (hits.length) {
|
|
console.error(`NO-FLEET-NAMES: FAIL — ${hits.length} line(s) in ${new Set(hits.map((h) => h.file)).size} file(s). State the fact; drop the person, the request and the dated round:`)
|
|
for (const h of hits.slice(0, 40)) console.error(` ✗ ${h.file}:${h.line} (${h.what}): ${h.text}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`NO-FLEET-NAMES: PASS — ${files.length} tracked source files name no build-team member, attribute no request to a user and carry no dated QA-round tag.`)
|