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.
This commit is contained in:
ayo 2026-07-16 21:17:44 +02:00 committed by GitHub
parent 287dc0885b
commit 0a3571e2f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 885 additions and 2 deletions

94
.github/workflows/checks.yml vendored Normal file
View file

@ -0,0 +1,94 @@
name: Checks
on:
pull_request:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
content:
runs-on: ubuntu-latest
steps:
# Full history: the scan walks a commit range.
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set node
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
# No install — the scanner has no dependencies.
- name: Scan the pull request range
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch --no-tags --quiet origin "$BASE_REF"
node scripts/check-content.mjs --range "origin/$BASE_REF...HEAD"
# Title and body reach the script as files, never as inline shell.
- name: Scan the pull request title and body
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
printf '%s\n' "$PR_TITLE" > "$RUNNER_TEMP/pr-title.txt"
printf '%s\n' "$PR_BODY" > "$RUNNER_TEMP/pr-body.txt"
node scripts/check-content.mjs --text "$RUNNER_TEMP/pr-title.txt" --text "$RUNNER_TEMP/pr-body.txt"
- name: Scan the head branch name
if: github.event_name == 'pull_request'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: node scripts/check-content.mjs --branch "$HEAD_REF"
- name: Scan the pushed range
if: github.event_name == 'push'
env:
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.sha }}
run: |
# A new branch or a force-push can leave `before` unreachable.
before="$PUSH_BEFORE"
if ! git rev-parse --quiet --verify "$before^{commit}" > /dev/null; then
before="$(git rev-parse "$PUSH_AFTER~1")"
fi
node scripts/check-content.mjs --range "$before..$PUSH_AFTER"

2
.husky/commit-msg Normal file
View file

@ -0,0 +1,2 @@
echo "content-check (commit message)..."
node scripts/check-content.mjs --message "$1"

View file

@ -9,3 +9,8 @@ if [ -n "$staged" ]; then
echo "secret-scan (staged files)..." echo "secret-scan (staged files)..."
printf '%s\n' "$staged" | xargs -r -d '\n' npx secretlint --secretlintignore .secretlintignore printf '%s\n' "$staged" | xargs -r -d '\n' npx secretlint --secretlintignore .secretlintignore
fi fi
# Content policy check of the staged diff and the branch name. Config:
# .repo-policy.json. Full-tree sweep: `node scripts/check-content.mjs --all`.
echo "content-check (staged)..."
node scripts/check-content.mjs --staged --branch "$(git branch --show-current)"

10
.repo-policy.json Normal file
View file

@ -0,0 +1,10 @@
{
"version": 1,
"salt": "40f076a2ae183c348be3e11a8b58ebac",
"digests": [],
"allowedCoAuthors": [
"ayo@ayco.io",
"ramon.aycojr@gmail.com",
"*@users.noreply.github.com"
]
}

View file

@ -114,9 +114,11 @@ The **Firebase config in `leader-board.js` is intentionally public and committed
- **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`). - **Code style is enforced by ESLint Stylistic**, not Prettier: 2-space indent, single quotes, **no semicolons**, no trailing commas, spaces inside `{ braces }` but not `[brackets]`. Run `pnpm lint:fix` before committing. Both `**/*.js` and `**/*.css` are linted (CSS via `@eslint/css`).
- The engine uses **plain functions and `var`/`let` closures**, not classes; `packages/utils/` and `apps/mnswpr/modules/` use ES classes. Match the surrounding style of the file you edit. - The engine uses **plain functions and `var`/`let` closures**, not classes; `packages/utils/` and `apps/mnswpr/modules/` use ES classes. Match the surrounding style of the file you edit.
- **Content policy.** Commit messages, branch names, PR text, and contributed lines are checked by `scripts/check-content.mjs` (hooks + the `Checks` workflow) against `.repo-policy.json`. Write commit messages in plain project voice; no tool-attribution trailers or footers, no `Co-Authored-By:` line for anyone outside the policy's `allowedCoAuthors`, no session links.
- The same scanner matches text against a maintainer-managed reserved-terms list. Findings report a location and a masked preview, never the term. If one flags your change, reword it or ask a maintainer — don't edit `.repo-policy.json`.
- **Types are generated, not authored.** Source stays JS + JSDoc (`// @ts-check`); `tsc` is a build-time tool that emits `.d.ts` from that JSDoc so published `@cozy-games/*` packages ship types. Declarations are emitted **co-located** next to each source file and **committed**`pnpm build:types` (`scripts/build-types.mjs`) deletes the previous ones and re-runs `tsc -p tsconfig.types.json`, since TypeScript won't emit over an existing `.d.ts` (TS5055). The type-check runs `strict: false` and covers only the files named in that config's `include` list — adding a new published source file means adding it there, or it ships without types. After touching JSDoc on an included file, run `pnpm build:types` and commit the regenerated declarations. - **Types are generated, not authored.** Source stays JS + JSDoc (`// @ts-check`); `tsc` is a build-time tool that emits `.d.ts` from that JSDoc so published `@cozy-games/*` packages ship types. Declarations are emitted **co-located** next to each source file and **committed**`pnpm build:types` (`scripts/build-types.mjs`) deletes the previous ones and re-runs `tsc -p tsconfig.types.json`, since TypeScript won't emit over an existing `.d.ts` (TS5055). The type-check runs `strict: false` and covers only the files named in that config's `include` list — adding a new published source file means adding it there, or it ships without types. After touching JSDoc on an included file, run `pnpm build:types` and commit the regenerated declarations.
## Release & git hooks (maintainer workflow) ## Release & git hooks (maintainer workflow)
- **Husky hooks:** `pre-commit` runs `pnpm lint`; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem. - **Husky hooks:** `pre-commit` runs `pnpm lint`, the secret scan, and the content check (staged diff + branch name); `commit-msg` runs the content check over the message; `post-commit` auto-pushes to two extra remotes (`git push gh`, `git push sh`). If those remotes aren't configured locally, expect post-commit failures — that's environmental, not a code problem.
- **Releasing** (`pnpm release`) builds the lib, runs `bumpp` (version bump + tag) in `packages/mnswpr/`, then `scripts/release.js` force-pushes a `release` branch and tags to remotes `gh`/`sh`. Pushing a `v*` tag triggers `.github/workflows/release.yml` (`changelogithub`) to publish GitHub release notes. Only run this when explicitly releasing. - **Releasing** (`pnpm release`) builds the lib, runs `bumpp` (version bump + tag) in `packages/mnswpr/`, then `scripts/release.js` force-pushes a `release` branch and tags to remotes `gh`/`sh`. Pushing a `v*` tag triggers `.github/workflows/release.yml` (`changelogithub`) to publish GitHub release notes. Only run this when explicitly releasing.

View file

@ -116,6 +116,20 @@ backend config.
For anything large, open an issue to discuss the approach first. For anything large, open an issue to discuss the approach first.
## Commit & PR hygiene
Write commit messages in plain project voice — what changed and why. Keep them
free of tool-attribution trailers or footers (including `Co-Authored-By:` lines
for anyone who isn't a human contributor) and of session links; the same goes for
PR titles and bodies.
CI also checks your changes against a maintainer-managed reserved-terms list. If a
check flags your change, reword it — or ask a maintainer if it isn't clear why.
The checks run locally too: the pre-commit hook scans staged changes and your
branch name, and the commit-msg hook scans the message. If a line legitimately
needs wording a check objects to (prose *about* these topics, say), put a
`content-policy: allow-next-line` comment above it and mention why in the PR.
## License ## License
By contributing, you agree that your contributions are licensed under the By contributing, you agree that your contributions are licensed under the

View file

@ -3,6 +3,7 @@
A growing collection of small browser games and the shared, reusable packages that power them. A growing collection of small browser games and the shared, reusable packages that power them.
> [!Note] > [!Note]
<!-- content-policy: allow-next-line -->
> This repo was originally for [mnswpr](https://mnswpr.com) (see its [README](apps/mnswpr/README.md)) which has been evolved in _2026_ to understand AI-assisted development. The purpose of mnswpr has always included understanding the web development landscape and this has changed significantly with the rise of LLMs. > This repo was originally for [mnswpr](https://mnswpr.com) (see its [README](apps/mnswpr/README.md)) which has been evolved in _2026_ to understand AI-assisted development. The purpose of mnswpr has always included understanding the web development landscape and this has changed significantly with the rise of LLMs.
# Roadmap # Roadmap

455
scripts/check-content.mjs Normal file
View file

@ -0,0 +1,455 @@
// @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)))

View file

@ -0,0 +1,300 @@
// This file quotes the patterns the scanner looks for, so it opts out of the scan.
// content-policy: ignore-file
import { describe, it, expect } from 'vitest'
import {
buildCandidates,
digestCandidate,
maskTerm,
matchReserved,
isAllowedCoAuthor,
checkStructural,
scanText,
isAllowedByMarker,
parseAddedLines,
parseRange,
loadPolicy
} from '../check-content.mjs'
const policy = loadPolicy()
/** The policy ships an empty digest list; tests add their own dummy terms. */
function withTerms(...terms) {
return { ...policy, digests: terms.map((term) => digestCandidate(term, policy.salt)) }
}
describe('buildCandidates', () => {
it('splits on everything that is not a letter or digit', () => {
expect(buildCandidates('Foo-bar_BAZ')).toContain('foo')
expect(buildCandidates('Foo-bar_BAZ')).toContain('bar')
expect(buildCandidates('Foo-bar_BAZ')).toContain('baz')
})
it('emits 1-, 2-, and 3-grams of consecutive tokens', () => {
expect(buildCandidates('one two three')).toEqual([
'one', 'one two', 'onetwo', 'one two three', 'onetwothree',
'two', 'two three', 'twothree',
'three'
])
})
it('emits both the space-joined and concatenated form for n >= 2', () => {
const candidates = buildCandidates('wombat cactus')
expect(candidates).toContain('wombat cactus')
expect(candidates).toContain('wombatcactus')
})
it('reaches the same candidate from every separator and case', () => {
for (const text of ['wombat cactus', 'Wombat-Cactus', 'wombat_cactus', 'WombatCactus!']) {
expect(buildCandidates(text)).toContain('wombatcactus')
}
})
it('deduplicates repeats', () => {
const candidates = buildCandidates('go go go')
expect(candidates.filter((c) => c === 'go')).toHaveLength(1)
expect(candidates.filter((c) => c === 'go go')).toHaveLength(1)
})
it('handles empty and token-free text', () => {
expect(buildCandidates('')).toEqual([])
expect(buildCandidates('--- !!! ---')).toEqual([])
})
})
describe('digestCandidate', () => {
it('is a stable, salted hex digest', () => {
const digest = digestCandidate('wombat cactus', policy.salt)
expect(digest).toMatch(/^[0-9a-f]{64}$/)
expect(digestCandidate('wombat cactus', policy.salt)).toBe(digest)
})
it('depends on the salt', () => {
expect(digestCandidate('wombat cactus', 'other-salt')).not.toBe(digestCandidate('wombat cactus', policy.salt))
})
})
describe('matchReserved', () => {
it('finds nothing when the digest list is empty', () => {
expect(matchReserved('wombat cactus', policy)).toEqual([])
})
it('matches a term inside a longer text and reports its list index', () => {
const loaded = withTerms('aardvark', 'wombat cactus')
expect(matchReserved('a fix for the wombat cactus bug', loaded)).toEqual([
{ index: 1, preview: 'w************' }
])
})
// Text yields both forms as candidates, so a digest of the concatenated form
// catches every separator and casing — the form to list for a two-word term.
it('matches every spelling of a term digested in its concatenated form', () => {
const loaded = withTerms('wombatcactus')
for (const text of ['the WombatCactus module', 'a wombat cactus fix', 'wombat-cactus']) {
expect(matchReserved(text, loaded), text).toEqual([{ index: 0, preview: 'w***********' }])
}
})
it('does not match the concatenated spelling from a space-joined digest', () => {
expect(matchReserved('the WombatCactus module', withTerms('wombat cactus'))).toEqual([])
})
it('leaves unrelated text alone', () => {
expect(matchReserved('a fix for the wombat bug', withTerms('wombat cactus'))).toEqual([])
})
it('reports each matched digest once', () => {
expect(matchReserved('wombat cactus, wombat cactus', withTerms('wombat cactus'))).toHaveLength(1)
})
})
describe('maskTerm', () => {
it('keeps the first character and hides the rest', () => {
expect(maskTerm('wombat cactus')).toBe('w************')
expect(maskTerm('a')).toBe('a')
expect(maskTerm('')).toBe('')
})
})
describe('isAllowedCoAuthor', () => {
it('accepts the repo\'s listed humans, case-insensitively', () => {
expect(isAllowedCoAuthor('ayo@ayco.io', policy.allowedCoAuthors)).toBe(true)
expect(isAllowedCoAuthor('Ramon.AycoJr@gmail.com', policy.allowedCoAuthors)).toBe(true)
})
it('accepts a `*` suffix pattern', () => {
expect(isAllowedCoAuthor('1234+someone@users.noreply.github.com', policy.allowedCoAuthors)).toBe(true)
})
it('rejects anyone else, and an empty address', () => {
expect(isAllowedCoAuthor('someone@example.com', policy.allowedCoAuthors)).toBe(false)
expect(isAllowedCoAuthor('ayo@ayco.io.example.com', policy.allowedCoAuthors)).toBe(false)
expect(isAllowedCoAuthor('', policy.allowedCoAuthors)).toBe(false)
})
})
describe('checkStructural: co-author trailers', () => {
it('allows a listed co-author', () => {
expect(checkStructural('Co-authored-by: Ayo <ayo@ayco.io>', policy)).toEqual([])
})
it('flags an unlisted one, whatever the casing', () => {
const findings = checkStructural('Co-Authored-By: Some Tool <tool@example.com>', policy)
expect(findings).toHaveLength(1)
expect(findings[0].rule).toBe('unlisted-co-author')
})
it('flags a trailer with no address at all', () => {
expect(checkStructural('Co-authored-by: Some Tool', policy)).toHaveLength(1)
})
it('ignores prose that merely mentions co-authors', () => {
expect(checkStructural('The docs explain how co-authored-by trailers work', policy)).toEqual([])
})
})
describe('checkStructural: tool attribution', () => {
it('flags an attribution footer', () => {
for (const line of [
'Generated with SomeTool Code',
'Created by an assistant',
'Authored with a coding agent',
'written by some bot',
'🤖 a marker',
'This is an AI-generated change',
'llm-assisted refactor'
]) {
expect(checkStructural(line, policy), line).toHaveLength(1)
}
})
it('leaves normal project text alone', () => {
for (const line of [
'fix: send the right userAgent header',
'chore(deps): bump vite from 8.0.2 to 8.0.3',
'Dependabot will resolve any conflicts with this PR as long as you do not alter it yourself.',
'You can trigger a rebase by commenting on this PR.',
'The changelog is generated from the commit history',
'Report generated by the build on every release',
'feat: add an agent-facing docs page',
'This board was created by the player, not the seed',
'The AI opponent picks a move at random'
]) {
expect(checkStructural(line, policy), line).toEqual([])
}
})
})
describe('checkStructural: session links', () => {
it('flags a session URL or token', () => {
for (const line of [
'See https://example.com/session/abc123def456ghi7 for context',
'https://tool.example.com/sessions/AbC1-2345_678',
'ref session_0123456789abcdefghij'
]) {
expect(checkStructural(line, policy), line).toHaveLength(1)
}
})
it('leaves ordinary links and session wording alone', () => {
for (const line of [
'See https://example.com/docs/sessions for the session guide',
'The play session ends when the timer stops',
'store.set("session_id", id)',
'https://example.com/session/ab12'
]) {
expect(checkStructural(line, policy), line).toEqual([])
}
})
})
describe('scanText', () => {
it('reports the location of each finding', () => {
const text = 'chore: tidy up\n\nGenerated with SomeTool Code'
expect(scanText(text, (line) => `MSG:${line}`, policy)).toEqual([
{ location: 'MSG:3', rule: 'tool-attribution', detail: 'Generated with SomeTool Code' }
])
})
it('never echoes a reserved term, only its index and a masked preview', () => {
const findings = scanText('a wombat cactus fix', () => 'branch name', withTerms('wombat cactus'))
expect(findings).toEqual([
{ location: 'branch name', rule: 'reserved-term', detail: 'digest #0 (w************)' }
])
expect(JSON.stringify(findings)).not.toContain('wombat')
})
it('passes clean text', () => {
expect(scanText('chore: add contribution content checks', () => 'commit message', policy)).toEqual([])
})
it('skips the line after an allow marker, and only that line', () => {
const text = [
'<!-- content-policy: allow-next-line -->',
'a page about AI-assisted development',
'a second AI-assisted line'
].join('\n')
const findings = scanText(text, (line) => `notes.md:${line}`, policy)
expect(findings).toHaveLength(1)
expect(findings[0].location).toBe('notes.md:3')
})
})
describe('isAllowedByMarker', () => {
it('recognizes the marker in a comment of any flavor', () => {
expect(isAllowedByMarker('<!-- content-policy: allow-next-line -->')).toBe(true)
expect(isAllowedByMarker('// content-policy: allow-next-line')).toBe(true)
})
it('is false for anything else, including a missing line', () => {
expect(isAllowedByMarker('// just a comment')).toBe(false)
expect(isAllowedByMarker(undefined)).toBe(false)
})
})
describe('parseAddedLines', () => {
const diff = [
'diff --git a/docs/notes.md b/docs/notes.md',
'index 1234567..89abcde 100644',
'--- a/docs/notes.md',
'+++ b/docs/notes.md',
'@@ -4,0 +5,2 @@ heading',
'+first added line',
'+second added line',
'diff --git a/old.md b/old.md',
'--- a/old.md',
'+++ /dev/null',
'@@ -1 +0,0 @@',
'-a removed line',
''
].join('\n')
it('pulls added lines with their new line numbers', () => {
expect(parseAddedLines(diff)).toEqual([
{ file: 'docs/notes.md', line: 5, text: 'first added line' },
{ file: 'docs/notes.md', line: 6, text: 'second added line' }
])
})
it('ignores removed lines and deleted files', () => {
expect(parseAddedLines(diff).some((added) => added.text.includes('removed'))).toBe(false)
})
it('returns nothing for an empty diff', () => {
expect(parseAddedLines('')).toEqual([])
})
})
describe('parseRange', () => {
it('reads a merge-base range', () => {
expect(parseRange('origin/main...HEAD')).toEqual({ a: 'origin/main', b: 'HEAD', merged: true })
})
it('reads a two-dot range', () => {
expect(parseRange('abc123..def456')).toEqual({ a: 'abc123', b: 'def456', merged: false })
})
it('rejects a non-range', () => {
expect(() => parseRange('HEAD')).toThrow()
})
})

View file

@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
include: ['packages/**/test/**/*.test.js', 'apps/**/test/**/*.test.js'] include: ['packages/**/test/**/*.test.js', 'apps/**/test/**/*.test.js', 'scripts/test/**/*.test.js']
} }
}) })