1f3c619dd2
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
100 lines
3.1 KiB
JavaScript
100 lines
3.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Verifies that every path named in ASSETS.md and NOTICE.md still exists.
|
|
*
|
|
* These two files tell forkers which assets our MIT license does not cover. If a file moves or
|
|
* is renamed and the notice is not updated, the notice quietly stops meaning anything — which is
|
|
* the failure mode that produced the problem in the first place. This check makes that loud.
|
|
*
|
|
* Usage: node scripts/check-asset-notices.mjs
|
|
* Exits 1 if any referenced path is missing.
|
|
*/
|
|
|
|
import {readFileSync, existsSync, readdirSync} from 'node:fs'
|
|
import {dirname, join, resolve} from 'node:path'
|
|
import {fileURLToPath} from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const SOURCES = ['ASSETS.md', 'NOTICE.md']
|
|
|
|
/** Tokens that look like paths but are not: package names, URLs, prose. */
|
|
const NOT_A_PATH = /^(https?:|@|[A-Z_]+$)/
|
|
|
|
/** Extract `backticked` tokens that are plausibly repo paths. */
|
|
function extractPaths(markdown) {
|
|
const out = new Set()
|
|
for (const [, token] of markdown.matchAll(/`([^`\n]+)`/g)) {
|
|
const t = token.trim()
|
|
// Only validate tokens that are unambiguously repo-relative paths. Bare filenames that
|
|
// appear in comma-separated lists ("`bskyweb/static/favicon.png`, `favicon-16x16.png`")
|
|
// are reported separately rather than guessed at.
|
|
if (!t.includes('/') || NOT_A_PATH.test(t) || t.includes(' ')) continue
|
|
out.add(t.replace(/^\.\//, ''))
|
|
}
|
|
return [...out]
|
|
}
|
|
|
|
/** Resolve a path that may end in `/` (directory) or contain a single `*` glob segment. */
|
|
function pathExists(p) {
|
|
const full = join(ROOT, p)
|
|
if (existsSync(full)) return true
|
|
if (!p.includes('*')) return false
|
|
|
|
// Handle one glob in the final segment, e.g. bskyweb/static/media/MaterialIcons.*.ttf
|
|
const dir = dirname(full)
|
|
const pattern = p.split('/').pop()
|
|
if (!existsSync(dir)) return false
|
|
const re = new RegExp(
|
|
'^' +
|
|
pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') +
|
|
'$',
|
|
)
|
|
return readdirSync(dir).some(f => re.test(f))
|
|
}
|
|
|
|
let missing = 0
|
|
let checked = 0
|
|
const skipped = new Set()
|
|
|
|
for (const source of SOURCES) {
|
|
const file = join(ROOT, source)
|
|
if (!existsSync(file)) {
|
|
console.error(`✗ ${source} is missing`)
|
|
missing++
|
|
continue
|
|
}
|
|
const markdown = readFileSync(file, 'utf8')
|
|
|
|
for (const [, token] of markdown.matchAll(/`([^`\n]+)`/g)) {
|
|
const t = token.trim()
|
|
if (!t.includes('/') && /\.[a-z0-9]{2,5}$/i.test(t)) skipped.add(t)
|
|
}
|
|
|
|
for (const p of extractPaths(markdown)) {
|
|
checked++
|
|
if (!pathExists(p)) {
|
|
console.error(`✗ ${source} references a path that does not exist: ${p}`)
|
|
missing++
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Checked ${checked} referenced paths across ${SOURCES.join(', ')}.`)
|
|
|
|
if (skipped.size) {
|
|
console.log(
|
|
`Note: ${skipped.size} bare filename(s) not verified (they are relative to a nearby path): ` +
|
|
[...skipped].sort().join(', '),
|
|
)
|
|
}
|
|
|
|
if (missing) {
|
|
console.error(
|
|
`\n${missing} problem(s) found. An asset named in a licensing notice has moved or been ` +
|
|
`removed. Update the notice — do not just silence this check.`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('All referenced paths exist.')
|