mnswpr/scripts/check-content.mjs
ayo 0a3571e2f4
chore: add contribution content checks (#53)
Adds scripts/check-content.mjs, a zero-dependency scanner for commits, branch
names, PR text, and contributed lines. It checks structural rules (attribution
trailers and footers, session links) and a maintainer-managed reserved-terms
list, shipped as salted digests in .repo-policy.json. Findings report a location
and a masked preview, never the matched term.

Wired up opt-in: the pre-commit hook scans the staged diff and branch name, a new
commit-msg hook scans the message, and the Checks workflow gates PRs and pushes
to main. The digest list ships empty; structural checks run regardless.
2026-07-16 21:17:44 +02:00

455 lines
14 KiB
JavaScript

// @ts-check
// Checks commits, branch names, and contributed text against the repo's content
// policy: no automated-tool attribution, no session links, and a
// maintainer-managed reserved-terms list (shipped as salted digests in
// .repo-policy.json).
//
// Zero dependencies (node: builtins only), so CI can run it without installing.
// Usage: node scripts/check-content.mjs --staged --branch "$(git branch --show-current)"
//
// Escape hatches, for text that carries a pattern legitimately: `content-policy:
// ignore-file` in a file's first lines skips the whole file (this one and its
// test, which quote the patterns); `content-policy: allow-next-line` skips the
// line after it.
// content-policy: ignore-file
import { createHmac } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const root = resolve(import.meta.dirname, '..')
/** Opt-out marker, honored in the first lines of a scanned file. */
const IGNORE_MARKER = 'content-policy: ignore-file'
/** Opt-out marker for the single line that follows it. */
const ALLOW_MARKER = 'content-policy: allow-next-line'
/**
* @typedef {object} Policy
* @property {number} version
* @property {string} salt - HMAC key for the digest list.
* @property {string[]} digests - Reserved-term digests, maintainer-managed.
* @property {string[]} allowedCoAuthors - Emails; a leading `*` matches a suffix.
*/
/**
* @typedef {object} Finding
* @property {string} location - `file:line`, `commit message <sha>`, or `branch name`.
* @property {string} rule
* @property {string} detail
*/
/**
* Read the repo's content policy.
* @param {string} [file]
* @returns {Policy}
*/
export function loadPolicy(file = resolve(root, '.repo-policy.json')) {
return JSON.parse(readFileSync(file, 'utf8'))
}
/**
* Every 1-, 2-, and 3-gram of the text's alphanumeric tokens. Multi-token grams
* are emitted both space-joined and concatenated, so `foo-bar` and `FooBar` both
* reach the same candidate.
* @param {string} text
* @returns {string[]} deduplicated candidates
*/
export function buildCandidates(text) {
const tokens = String(text).toLowerCase().match(/[a-z0-9]+/g) || []
const candidates = new Set()
for (let i = 0; i < tokens.length; i++) {
for (let n = 1; n <= 3 && i + n <= tokens.length; n++) {
const gram = tokens.slice(i, i + n)
if (n === 1) candidates.add(gram[0])
else {
candidates.add(gram.join(' '))
candidates.add(gram.join(''))
}
}
}
return [...candidates]
}
/**
* @param {string} candidate
* @param {string} salt
* @returns {string} hex digest
*/
export function digestCandidate(candidate, salt) {
return createHmac('sha256', salt).update(candidate).digest('hex')
}
/**
* First character plus asterisks — enough to act on a finding without echoing
* the term into a log.
* @param {string} term
* @returns {string}
*/
export function maskTerm(term) {
const text = String(term)
return text.slice(0, 1) + '*'.repeat(Math.max(text.length - 1, 0))
}
/**
* Match a text's candidates against the policy's digest list.
* @param {string} text
* @param {Policy} policy
* @returns {{ index: number, preview: string }[]} one entry per matched digest
*/
export function matchReserved(text, policy) {
const digests = policy.digests || []
if (digests.length === 0) return []
const matches = []
const seen = new Set()
for (const candidate of buildCandidates(text)) {
const index = digests.indexOf(digestCandidate(candidate, policy.salt))
if (index === -1 || seen.has(index)) continue
seen.add(index)
matches.push({ index, preview: maskTerm(candidate) })
}
return matches
}
/** Single-line patterns, matched against text, paths, and branch names. */
const STRUCTURAL_RULES = [
{
rule: 'tool-attribution',
pattern: /(generated|created|written|authored)\s+(with|by)\b.{0,40}\b(code|agent|assistant|bot|ai|llm)\b/i
},
{ rule: 'tool-attribution', pattern: /🤖/u },
{ rule: 'tool-attribution', pattern: /\b(ai|llm)[-\s](generated|assisted|authored|written)\b/i },
{ rule: 'session-link', pattern: /https?:\/\/\S+\/sessions?\/[0-9a-zA-Z_-]{8,}/i },
{ rule: 'session-link', pattern: /\bsession[_-][0-9a-zA-Z]{16,}\b/i }
]
const CO_AUTHOR_PATTERN = /^\s*co-authored-by:\s*(.+)$/i
const EMAIL_PATTERN = /<([^>]+)>/
/**
* @param {string} email
* @param {string[]} [allowed] - Exact emails, or `*@domain` suffix patterns.
* @returns {boolean}
*/
export function isAllowedCoAuthor(email, allowed = []) {
const value = String(email).trim().toLowerCase()
if (!value) return false
return allowed.some((entry) => {
const pattern = String(entry).trim().toLowerCase()
return pattern.startsWith('*') ? value.endsWith(pattern.slice(1)) : value === pattern
})
}
/**
* @param {string} line
* @returns {string} the line, trimmed and capped for reporting
*/
function preview(line) {
const text = line.trim()
return text.length > 120 ? `${text.slice(0, 120)}` : text
}
/**
* Structural checks for one line. The line is quoted back: these patterns are
* vendor-neutral and public, unlike the reserved-terms list.
* @param {string} line
* @param {Policy} policy
* @returns {{ rule: string, detail: string }[]}
*/
export function checkStructural(line, policy) {
const findings = []
const reported = new Set()
for (const { rule, pattern } of STRUCTURAL_RULES) {
if (reported.has(rule) || !pattern.test(line)) continue
reported.add(rule)
findings.push({ rule, detail: preview(line) })
}
const trailer = CO_AUTHOR_PATTERN.exec(line)
if (trailer) {
const email = (EMAIL_PATTERN.exec(trailer[1]) || [])[1] || ''
if (!isAllowedCoAuthor(email, policy.allowedCoAuthors)) {
findings.push({ rule: 'unlisted-co-author', detail: preview(line) })
}
}
return findings
}
/**
* @param {string | undefined} line - The line before the one being scanned.
* @returns {boolean}
*/
export function isAllowedByMarker(line) {
return typeof line === 'string' && line.includes(ALLOW_MARKER)
}
/**
* Run every check over a text, line by line.
* @param {string} text
* @param {(lineNo: number) => string} locate - Builds a finding's location.
* @param {Policy} policy
* @returns {Finding[]}
*/
export function scanText(text, locate, policy) {
const findings = []
const lines = String(text).split('\n')
lines.forEach((line, index) => {
if (isAllowedByMarker(lines[index - 1])) return
const location = locate(index + 1)
for (const finding of checkStructural(line, policy)) findings.push({ location, ...finding })
for (const match of matchReserved(line, policy)) {
findings.push({ location, rule: 'reserved-term', detail: `digest #${match.index} (${match.preview})` })
}
})
return findings
}
/**
* A changed path gets the same checks as text. A reserved-term hit reports the
* path's position rather than the path, so a flagged name never lands in a log.
* @param {string} path
* @param {number} index - Position in the scanned path list.
* @param {Policy} policy
* @returns {Finding[]}
*/
export function checkPath(path, index, policy) {
const findings = checkStructural(path, policy).map((finding) => ({ location: `path ${path}`, ...finding }))
for (const match of matchReserved(path, policy)) {
findings.push({ location: `path #${index + 1}`, rule: 'reserved-term', detail: `digest #${match.index} (${match.preview})` })
}
return findings
}
/**
* Added lines and their line numbers in the new file, from a unified diff.
* @param {string} diff
* @returns {{ file: string, line: number, text: string }[]}
*/
export function parseAddedLines(diff) {
const added = []
let file = null
let lineNo = 0
for (const line of diff.split('\n')) {
if (line.startsWith('+++ ')) {
const path = line.slice(4).trim()
file = path === '/dev/null' ? null : path.replace(/^b\//, '')
continue
}
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
if (hunk) {
lineNo = Number(hunk[1])
continue
}
if (!file || line.startsWith('---')) continue
if (line.startsWith('+')) added.push({ file, line: lineNo++, text: line.slice(1) })
else if (line.startsWith(' ')) lineNo++
}
return added
}
/**
* Split `a...b` or `a..b`.
* @param {string} spec
* @returns {{ a: string, b: string, merged: boolean }}
*/
export function parseRange(spec) {
const merged = spec.includes('...')
const [a, b] = spec.split(merged ? '...' : '..')
if (!a || !b) throw new Error(`not a commit range: ${spec}`)
return { a, b, merged }
}
/**
* @param {string[]} args
* @returns {string}
*/
function git(args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
}
/**
* @param {string} content
* @returns {boolean}
*/
function hasIgnoreMarker(content) {
return content.split('\n', 20).some((line) => line.includes(IGNORE_MARKER))
}
/**
* A file's lines at a revision, so a diffed line can be read in context.
* @param {string} rev - Empty for the index.
* @param {string} path
* @returns {string[] | null} null when the file opts out of the scan
*/
function readLinesAt(rev, path) {
try {
const content = git(['show', `${rev}:${path}`])
return hasIgnoreMarker(content) ? null : content.split('\n')
}
catch {
return []
}
}
/**
* @param {string} nameOnly - Output of `git diff --name-only`.
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkPaths(nameOnly, policy) {
return nameOnly.split('\n').filter(Boolean).flatMap((path, index) => checkPath(path, index, policy))
}
/**
* @param {string} diff
* @param {string} rev - Revision to read ignore markers from; empty for the index.
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkAddedLines(diff, rev, policy) {
const findings = []
const blobs = new Map()
for (const { file, line, text } of parseAddedLines(diff)) {
if (!blobs.has(file)) blobs.set(file, readLinesAt(rev, file))
const lines = blobs.get(file)
if (lines === null || isAllowedByMarker(lines[line - 2])) continue
findings.push(...scanText(text, () => `${file}:${line}`, policy))
}
return findings
}
/**
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkStaged(policy) {
const diff = git(['diff', '--cached', '--unified=0', '--diff-filter=ACM'])
return [
...checkAddedLines(diff, '', policy),
...checkPaths(git(['diff', '--cached', '--name-only', '--diff-filter=ACMR']), policy)
]
}
/**
* @param {string} spec
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkRange(spec, policy) {
const { a, b, merged } = parseRange(spec)
const findings = []
for (const sha of git(['rev-list', `${a}..${b}`]).split('\n').filter(Boolean)) {
const message = git(['show', '-s', '--format=%B', sha])
findings.push(...scanText(message, () => `commit message ${sha.slice(0, 8)}`, policy))
}
const diffSpec = merged ? `${a}...${b}` : `${a}..${b}`
findings.push(...checkAddedLines(git(['diff', '--unified=0', diffSpec]), b, policy))
findings.push(...checkPaths(git(['diff', '--name-only', diffSpec]), policy))
return findings
}
/**
* Strip the comments git adds to a commit-message file.
* @param {string} file
* @returns {string}
*/
function readMessage(file) {
const text = readFileSync(file, 'utf8')
const scissors = text.indexOf('\n# ------------------------ >8 ------------------------')
const body = scissors === -1 ? text : text.slice(0, scissors)
return body.split('\n').filter((line) => !line.startsWith('#')).join('\n')
}
/**
* @param {string} file
* @returns {string | null} null when the file is binary or unreadable
*/
function readTracked(file) {
try {
const content = readFileSync(resolve(root, file))
return content.includes(0) ? null : content.toString('utf8')
}
catch {
return null
}
}
/**
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkAll(policy) {
const findings = []
git(['ls-files']).split('\n').filter(Boolean).forEach((file, index) => {
findings.push(...checkPath(file, index, policy))
const content = readTracked(file)
if (content === null || hasIgnoreMarker(content)) return
findings.push(...scanText(content, (line) => `${file}:${line}`, policy))
})
return findings
}
const USAGE = `Usage: node scripts/check-content.mjs <mode>...
--staged staged added lines and file paths
--message <file> a commit-message file
--range <a>...<b> commit messages, added lines, and paths in a range
--text <file> arbitrary text (a PR title and body)
--branch <name> a branch name
--all every tracked file (maintainer sweep)`
/**
* @param {Finding[]} findings
*/
function report(findings) {
for (const { location, rule, detail } of findings) console.error(`${location} ${rule}: ${detail}`)
if (findings.length) {
console.error(`\n${findings.length} content policy finding(s) — see CONTRIBUTING.md ("Commit & PR hygiene").`)
}
}
/**
* @param {string[]} argv
* @returns {number} exit code
*/
export function main(argv) {
const policy = loadPolicy()
const findings = []
if (argv.length === 0) {
console.error(USAGE)
return 2
}
for (let i = 0; i < argv.length; i++) {
const mode = argv[i]
if (mode === '--staged') findings.push(...checkStaged(policy))
else if (mode === '--all') findings.push(...checkAll(policy))
else if (mode === '--message') findings.push(...scanText(readMessage(argv[++i]), () => 'commit message', policy))
else if (mode === '--text') {
const file = argv[++i]
findings.push(...scanText(readFileSync(file, 'utf8'), (line) => `${file}:${line}`, policy))
}
else if (mode === '--range') findings.push(...checkRange(argv[++i], policy))
else if (mode === '--branch') findings.push(...checkBranchArg(argv[++i], policy))
else {
console.error(`unknown mode: ${mode}\n\n${USAGE}`)
return 2
}
}
report(findings)
return findings.length ? 1 : 0
}
/**
* A detached HEAD has no branch name — nothing to check.
* @param {string} name
* @param {Policy} policy
* @returns {Finding[]}
*/
function checkBranchArg(name, policy) {
return name && name.trim() ? scanText(name.trim(), () => 'branch name', policy) : []
}
if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)))