chore: update repo policy to check tool co-authors
Some checks are pending
Checks / lint (push) Waiting to run
Checks / test (push) Waiting to run
Checks / content (push) Waiting to run

This commit is contained in:
ayo 2026-07-18 22:04:35 +02:00
parent f4ac3b0c12
commit 93e8258270
5 changed files with 42 additions and 31 deletions

View file

@ -2,9 +2,10 @@
"version": 1, "version": 1,
"salt": "40f076a2ae183c348be3e11a8b58ebac", "salt": "40f076a2ae183c348be3e11a8b58ebac",
"digests": [], "digests": [],
"allowedCoAuthors": [ "toolCoAuthors": [
"ayo@ayco.io", "*@anthropic.com",
"ramon.aycojr@gmail.com", "*@openai.com",
"*@users.noreply.github.com" "*@cursor.com",
"*@devin.ai"
] ]
} }

View file

@ -126,7 +126,7 @@ App-owned modules in `modules/`: `UserService` (`user/user.js`) derives a non-cr
- **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`).
- `modules/` uses ES classes; `scripts/` uses plain functions. Match the surrounding style of the file you edit. - `modules/` uses ES classes; `scripts/` uses plain functions. 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. - **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 a non-human contributor (the policy's `toolCoAuthors` list — human co-authors are always fine), 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`. - 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`.
- **Source stays JS + JSDoc (`// @ts-check`)** — no TypeScript. Nothing is published from this repo, so there is no type-generation step here; the `.d.ts` files shipped by `@cozy-games/*` are generated in the cozy-games repo. - **Source stays JS + JSDoc (`// @ts-check`)** — no TypeScript. Nothing is published from this repo, so there is no type-generation step here; the `.d.ts` files shipped by `@cozy-games/*` are generated in the cozy-games repo.

View file

@ -112,9 +112,10 @@ For anything large, open an issue to discuss the approach first.
## Commit & PR hygiene ## Commit & PR hygiene
Write commit messages in plain project voice — what changed and why. Keep them 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 free of AI-tool attribution trailers or footers (including `Co-Authored-By:`
for anyone who isn't a human contributor) and of session links; the same goes for lines crediting a coding assistant) and of session links; the same goes for PR
PR titles and bodies. titles and bodies. Co-authoring with another *person* is always fine — use any
address you like, there is no contributor allowlist.
CI also checks your changes against a maintainer-managed reserved-terms list. If a 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. check flags your change, reword it — or ask a maintainer if it isn't clear why.

View file

@ -33,7 +33,8 @@ const ALLOW_MARKER = 'content-policy: allow-next-line'
* @property {number} version * @property {number} version
* @property {string} salt - HMAC key for the digest list. * @property {string} salt - HMAC key for the digest list.
* @property {string[]} digests - Reserved-term digests, maintainer-managed. * @property {string[]} digests - Reserved-term digests, maintainer-managed.
* @property {string[]} allowedCoAuthors - Emails; a leading `*` matches a suffix. * @property {string[]} toolCoAuthors - Non-human co-author addresses to reject;
* a leading `*` matches a suffix. Every other address is a human, and passes.
*/ */
/** /**
@ -131,14 +132,16 @@ const CO_AUTHOR_PATTERN = /^\s*co-authored-by:\s*(.+)$/i
const EMAIL_PATTERN = /<([^>]+)>/ const EMAIL_PATTERN = /<([^>]+)>/
/** /**
* Is this co-author address a tool rather than a person? Humans are the default:
* only addresses matching the policy's tool list are rejected.
* @param {string} email * @param {string} email
* @param {string[]} [allowed] - Exact emails, or `*@domain` suffix patterns. * @param {string[]} [tools] - Exact emails, or `*@domain` suffix patterns.
* @returns {boolean} * @returns {boolean}
*/ */
export function isAllowedCoAuthor(email, allowed = []) { export function isToolCoAuthor(email, tools = []) {
const value = String(email).trim().toLowerCase() const value = String(email).trim().toLowerCase()
if (!value) return false if (!value) return false
return allowed.some((entry) => { return tools.some((entry) => {
const pattern = String(entry).trim().toLowerCase() const pattern = String(entry).trim().toLowerCase()
return pattern.startsWith('*') ? value.endsWith(pattern.slice(1)) : value === pattern return pattern.startsWith('*') ? value.endsWith(pattern.slice(1)) : value === pattern
}) })
@ -171,8 +174,8 @@ export function checkStructural(line, policy) {
const trailer = CO_AUTHOR_PATTERN.exec(line) const trailer = CO_AUTHOR_PATTERN.exec(line)
if (trailer) { if (trailer) {
const email = (EMAIL_PATTERN.exec(trailer[1]) || [])[1] || '' const email = (EMAIL_PATTERN.exec(trailer[1]) || [])[1] || ''
if (!isAllowedCoAuthor(email, policy.allowedCoAuthors)) { if (isToolCoAuthor(email, policy.toolCoAuthors)) {
findings.push({ rule: 'unlisted-co-author', detail: preview(line) }) findings.push({ rule: 'tool-co-author', detail: preview(line) })
} }
} }
return findings return findings

View file

@ -7,7 +7,7 @@ import {
digestCandidate, digestCandidate,
maskTerm, maskTerm,
matchReserved, matchReserved,
isAllowedCoAuthor, isToolCoAuthor,
checkStructural, checkStructural,
scanText, scanText,
isAllowedByMarker, isAllowedByMarker,
@ -116,36 +116,42 @@ describe('maskTerm', () => {
}) })
}) })
describe('isAllowedCoAuthor', () => { describe('isToolCoAuthor', () => {
it('accepts the repo\'s listed humans, case-insensitively', () => { it('matches a tool address by domain, case-insensitively', () => {
expect(isAllowedCoAuthor('ayo@ayco.io', policy.allowedCoAuthors)).toBe(true) expect(isToolCoAuthor('noreply@anthropic.com', policy.toolCoAuthors)).toBe(true)
expect(isAllowedCoAuthor('Ramon.AycoJr@gmail.com', policy.allowedCoAuthors)).toBe(true) expect(isToolCoAuthor('NoReply@OpenAI.com', policy.toolCoAuthors)).toBe(true)
}) })
it('accepts a `*` suffix pattern', () => { it('leaves dependabot alone — it is honest automation, not AI attribution', () => {
expect(isAllowedCoAuthor('1234+someone@users.noreply.github.com', policy.allowedCoAuthors)).toBe(true) expect(isToolCoAuthor('49699333+dependabot[bot]@users.noreply.github.com', policy.toolCoAuthors)).toBe(false)
}) })
it('rejects anyone else, and an empty address', () => { it('treats every other address as a human — contributors are not allowlisted', () => {
expect(isAllowedCoAuthor('someone@example.com', policy.allowedCoAuthors)).toBe(false) expect(isToolCoAuthor('jane@example.com', policy.toolCoAuthors)).toBe(false)
expect(isAllowedCoAuthor('ayo@ayco.io.example.com', policy.allowedCoAuthors)).toBe(false) expect(isToolCoAuthor('ayo@ayco.io', policy.toolCoAuthors)).toBe(false)
expect(isAllowedCoAuthor('', policy.allowedCoAuthors)).toBe(false) expect(isToolCoAuthor('1234+someone@users.noreply.github.com', policy.toolCoAuthors)).toBe(false)
expect(isToolCoAuthor('', policy.toolCoAuthors)).toBe(false)
})
it('does not match a lookalike domain', () => {
expect(isToolCoAuthor('someone@anthropic.com.example.com', policy.toolCoAuthors)).toBe(false)
}) })
}) })
describe('checkStructural: co-author trailers', () => { describe('checkStructural: co-author trailers', () => {
it('allows a listed co-author', () => { it('allows an outside human contributor using any address', () => {
expect(checkStructural('Co-authored-by: Jane Dev <jane@example.com>', policy)).toEqual([])
expect(checkStructural('Co-authored-by: Ayo <ayo@ayco.io>', policy)).toEqual([]) expect(checkStructural('Co-authored-by: Ayo <ayo@ayco.io>', policy)).toEqual([])
}) })
it('flags an unlisted one, whatever the casing', () => { it('flags a tool trailer, whatever the casing', () => {
const findings = checkStructural('Co-Authored-By: Some Tool <tool@example.com>', policy) const findings = checkStructural('Co-Authored-By: Some Model <noreply@anthropic.com>', policy)
expect(findings).toHaveLength(1) expect(findings).toHaveLength(1)
expect(findings[0].rule).toBe('unlisted-co-author') expect(findings[0].rule).toBe('tool-co-author')
}) })
it('flags a trailer with no address at all', () => { it('allows a trailer with no address — a human typo, not a tool', () => {
expect(checkStructural('Co-authored-by: Some Tool', policy)).toHaveLength(1) expect(checkStructural('Co-authored-by: Jane Dev', policy)).toEqual([])
}) })
it('ignores prose that merely mentions co-authors', () => { it('ignores prose that merely mentions co-authors', () => {