From 0a3571e2f488b9a18e1b5f33cc57d983c1e05f3d Mon Sep 17 00:00:00 2001 From: ayo Date: Thu, 16 Jul 2026 21:17:44 +0200 Subject: [PATCH] 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. --- .github/workflows/checks.yml | 94 ++++++ .husky/commit-msg | 2 + .husky/pre-commit | 5 + .repo-policy.json | 10 + AGENTS.md | 4 +- CONTRIBUTING.md | 14 + README.md | 1 + scripts/check-content.mjs | 455 +++++++++++++++++++++++++++++ scripts/test/check-content.test.js | 300 +++++++++++++++++++ vitest.config.js | 2 +- 10 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 .husky/commit-msg create mode 100644 .repo-policy.json create mode 100644 scripts/check-content.mjs create mode 100644 scripts/test/check-content.test.js diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..2e03705 --- /dev/null +++ b/.github/workflows/checks.yml @@ -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" diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..7418bbc --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,2 @@ +echo "content-check (commit message)..." +node scripts/check-content.mjs --message "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit index bd4e307..ec2f228 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -9,3 +9,8 @@ if [ -n "$staged" ]; then echo "secret-scan (staged files)..." printf '%s\n' "$staged" | xargs -r -d '\n' npx secretlint --secretlintignore .secretlintignore 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)" diff --git a/.repo-policy.json b/.repo-policy.json new file mode 100644 index 0000000..3d6897e --- /dev/null +++ b/.repo-policy.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "salt": "40f076a2ae183c348be3e11a8b58ebac", + "digests": [], + "allowedCoAuthors": [ + "ayo@ayco.io", + "ramon.aycojr@gmail.com", + "*@users.noreply.github.com" + ] +} diff --git a/AGENTS.md b/AGENTS.md index 33a08b6..1e5d055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). - 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. ## 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c1473b..2ecca71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,6 +116,20 @@ backend config. 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 By contributing, you agree that your contributions are licensed under the diff --git a/README.md b/README.md index 967d0a6..9bc0184 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ A growing collection of small browser games and the shared, reusable packages that power them. > [!Note] + > 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 diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs new file mode 100644 index 0000000..b57f0cc --- /dev/null +++ b/scripts/check-content.mjs @@ -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 `, 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 ... + + --staged staged added lines and file paths + --message a commit-message file + --range ... commit messages, added lines, and paths in a range + --text arbitrary text (a PR title and body) + --branch 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))) diff --git a/scripts/test/check-content.test.js b/scripts/test/check-content.test.js new file mode 100644 index 0000000..f930bff --- /dev/null +++ b/scripts/test/check-content.test.js @@ -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 ', policy)).toEqual([]) + }) + + it('flags an unlisted one, whatever the casing', () => { + const findings = checkStructural('Co-Authored-By: Some Tool ', 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 = [ + '', + '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('')).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() + }) +}) diff --git a/vitest.config.js b/vitest.config.js index 8eafa4c..f83210e 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'jsdom', - include: ['packages/**/test/**/*.test.js', 'apps/**/test/**/*.test.js'] + include: ['packages/**/test/**/*.test.js', 'apps/**/test/**/*.test.js', 'scripts/test/**/*.test.js'] } })