diff --git a/.github/labeler.yml b/.github/labeler.yml index 7ece498..2eb3673 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,13 +4,13 @@ - any-glob-to-any-file: ["src/**"] "scope:packaging": - changed-files: - - any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml"] + - any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml", "package.json", "package-lock.json", "CHANGELOG.md", "changelog.d/**"] "scope:manifests": - changed-files: - any-glob-to-any-file: ["manifests/**"] "scope:ci": - changed-files: - - any-glob-to-any-file: [".forgejo/workflows/**"] + - any-glob-to-any-file: [".forgejo/workflows/**", ".github/**", ".ceremony/**"] "scope:docs": - changed-files: - - any-glob-to-any-file: ["README.md", "docs/**"] + - any-glob-to-any-file: ["*.md", "docs/**"] diff --git a/.github/labels.conf b/.github/labels.conf index cd4c22b..4c25eae 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -1,7 +1,7 @@ panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl triage-actors=claude-bot-andresmgsl scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js) -scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path +scope:packaging|C5DEF5|scripts/, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces scope:manifests|C5DEF5|manifests/ — the fleet repo registry data -scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates -scope:docs|C5DEF5|README and docs/ — the prose contract +scope:ci|C5DEF5|.forgejo/workflows/, .github/, and .ceremony/ — the test, label, and governance gates +scope:docs|C5DEF5|root Markdown and docs/ — the prose contract diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa39ee7..bc3541a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,10 +20,10 @@ authoritative; this table is its human-readable restatement. | Scope | Covers | | --- | --- | | `scope:cli` | `src/` — the command surface (`cli.js`, `api.js`, and `config.js`) | -| `scope:packaging` | `scripts/` and the release workflow — Debian package builds, registry publishing, and the APT install path | +| `scope:packaging` | `scripts/`, release workflow, package manifests, changelog, and fragments — release packaging and version surfaces | | `scope:manifests` | `manifests/` — fleet repository registry data | -| `scope:ci` | `.forgejo/workflows/` — test and label gates | -| `scope:docs` | `README.md` and `docs/` — the prose contract | +| `scope:ci` | `.forgejo/workflows/`, `.github/`, and `.ceremony/` — the test, label, and governance gates | +| `scope:docs` | root Markdown and `docs/` — the prose contract | These names and path descriptions restate the scope rows in [`.github/labels.conf`](.github/labels.conf). diff --git a/changelog.d/48.md b/changelog.d/48.md new file mode 100644 index 0000000..fdff546 --- /dev/null +++ b/changelog.d/48.md @@ -0,0 +1 @@ +- Cover every governed repository surface and reject unmapped tracked paths or divergent scope names. (#48). diff --git a/scripts/check-governance.js b/scripts/check-governance.js index ed50139..657719c 100644 --- a/scripts/check-governance.js +++ b/scripts/check-governance.js @@ -2,6 +2,13 @@ const fs = require('node:fs'); const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const UNSCOPED_PATHS = [ + '.gitignore', // Repository plumbing has no product surface. + 'assets/logo-mark.svg', // One legacy brand asset does not justify a scope taxonomy. + 'test/*.test.js', // Tests inherit the scope of the production surface changed beside them. +]; function parseArgs(argv) { const options = { @@ -55,6 +62,65 @@ function parseConfig(contents, filename) { return { identities, scopes }; } +function parseLabeler(contents, filename) { + const mappings = new Map(); + let current; + for (const line of contents.split(/\r?\n/)) { + const label = line.match(/^"([^"]+)":$/); + if (label) { + current = label[1]; + if (mappings.has(current)) throw new Error(`duplicate scope mapping in ${filename}: ${current}`); + mappings.set(current, []); + continue; + } + const matcher = line.match(/^\s+- any-glob-to-any-file:\s*(\[[^\n]+\])$/); + if (!matcher) continue; + if (!current) throw new Error(`scope matcher has no label in ${filename}`); + let globs; + try { + globs = JSON.parse(matcher[1]); + } catch { + throw new Error(`malformed scope matcher for ${current} in ${filename}`); + } + if (!Array.isArray(globs) || globs.length === 0 || globs.some((glob) => typeof glob !== 'string')) { + throw new Error(`malformed scope matcher for ${current} in ${filename}`); + } + mappings.get(current).push(...globs); + } + for (const [label, globs] of mappings) { + if (globs.length === 0) throw new Error(`scope mapping has no globs in ${filename}: ${label}`); + } + return mappings; +} + +function validateScopeNames(scopes, mappings) { + const configNames = new Set(scopes.map((scope) => scope.split('|', 1)[0])); + const labelerNames = new Set(mappings.keys()); + const onlyInConfig = [...configNames].filter((name) => !labelerNames.has(name)).sort(); + const onlyInLabeler = [...labelerNames].filter((name) => !configNames.has(name)).sort(); + const errors = []; + if (onlyInConfig.length > 0) errors.push(`scope names only in labels.conf: ${onlyInConfig.join(', ')}`); + if (onlyInLabeler.length > 0) errors.push(`scope names only in labeler.yml: ${onlyInLabeler.join(', ')}`); + if (errors.length > 0) throw new Error(errors.join('; ')); +} + +function validateTrackedPaths(mappings) { + let tracked; + try { + tracked = execFileSync('git', ['ls-files'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean); + } catch (error) { + throw new Error(`cannot enumerate tracked paths: ${error.message}`); + } + const globs = [...mappings.values()].flat(); + const uncovered = tracked.filter((filename) => ( + !UNSCOPED_PATHS.some((glob) => path.matchesGlob(filename, glob)) + && !globs.some((glob) => path.matchesGlob(filename, glob)) + )); + if (uncovered.length > 0) { + throw new Error(`tracked paths have no scope mapping: ${uncovered.join(', ')}`); + } +} + async function validateIdentities(apiUrl, identities) { for (const login of identities) { const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`; @@ -72,6 +138,10 @@ async function main() { const options = parseArgs(process.argv.slice(2)); const contents = fs.readFileSync(options.config, 'utf8'); const { identities, scopes } = parseConfig(contents, options.config); + const labeler = path.join(path.dirname(options.config), 'labeler.yml'); + const mappings = parseLabeler(fs.readFileSync(labeler, 'utf8'), labeler); + validateScopeNames(scopes, mappings); + validateTrackedPaths(mappings); await validateIdentities(options.apiUrl, identities); console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`); } diff --git a/test/governance.test.js b/test/governance.test.js index 1d64884..d178c20 100644 --- a/test/governance.test.js +++ b/test/governance.test.js @@ -1,6 +1,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); +const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); const http = require('node:http'); const os = require('node:os'); @@ -26,12 +27,30 @@ function writeConfig(contents) { cleanups.push(dir); const config = path.join(dir, 'labels.conf'); fs.writeFileSync(config, contents); + fs.copyFileSync(REPOSITORY_LABELER, path.join(dir, 'labeler.yml')); return config; } -function runValidator(config, apiUrl) { +function writeRepository(configContents, labelerContents, files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-repository-')); + cleanups.push(dir); + fs.mkdirSync(path.join(dir, '.github'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.github', 'labels.conf'), configContents); + fs.writeFileSync(path.join(dir, '.github', 'labeler.yml'), labelerContents); + for (const [filename, contents] of Object.entries(files)) { + const target = path.join(dir, filename); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', '.'], { cwd: dir }); + return { dir, config: path.join(dir, '.github', 'labels.conf') }; +} + +function runValidator(config, apiUrl, cwd = path.join(__dirname, '..')) { return new Promise((resolve) => { const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], { + cwd, encoding: 'utf8', }); let stdout = ''; @@ -111,6 +130,71 @@ test('governance validator rejects malformed scope rows before identity requests assert.doesNotMatch(result.stderr, /fetch failed/); }); +test('governance validator rejects a tracked path outside every scope and the residue allowlist', async () => { + const config = [ + 'panel=codex-bot-andresmgsl', + 'scope:cli|C5DEF5|src/ — command surface', + 'scope:ci|C5DEF5|.github/ — governance surface', + ].join('\n'); + const labeler = [ + '"scope:cli":', + ' - changed-files:', + ' - any-glob-to-any-file: ["src/**"]', + '"scope:ci":', + ' - changed-files:', + ' - any-glob-to-any-file: [".github/**"]', + ].join('\n'); + const repository = writeRepository(`${config}\n`, `${labeler}\n`, { + 'src/covered.js': '', + 'new-surface/uncovered.txt': '', + }); + await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => { + const result = await runValidator(repository.config, apiUrl, repository.dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /tracked paths have no scope mapping: new-surface\/uncovered\.txt/); + }); +}); + +test('governance validator rejects scope names declared only in the labeler map', async () => { + const config = [ + 'panel=codex-bot-andresmgsl', + 'scope:cli|C5DEF5|src/ — command surface', + ].join('\n'); + const labeler = [ + '"scope:cli":', + ' - changed-files:', + ' - any-glob-to-any-file: ["src/**"]', + '"scope:extra":', + ' - changed-files:', + ' - any-glob-to-any-file: ["extra/**"]', + ].join('\n'); + const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' }); + await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => { + const result = await runValidator(repository.config, apiUrl, repository.dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /scope names only in labeler\.yml: scope:extra/); + }); +}); + +test('governance validator rejects scope names declared only in labels.conf', async () => { + const config = [ + 'panel=codex-bot-andresmgsl', + 'scope:cli|C5DEF5|src/ — command surface', + 'scope:renamed|C5DEF5|renamed/ — renamed surface', + ].join('\n'); + const labeler = [ + '"scope:cli":', + ' - changed-files:', + ' - any-glob-to-any-file: ["src/**"]', + ].join('\n'); + const repository = writeRepository(`${config}\n`, `${labeler}\n`, { 'src/covered.js': '' }); + await withIdentityServer(new Set(['codex-bot-andresmgsl']), async (apiUrl) => { + const result = await runValidator(repository.config, apiUrl, repository.dir); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /scope names only in labels\.conf: scope:renamed/); + }); +}); + test('repository governance config resolves the current four-member panel and five scopes', async () => { const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']); await withIdentityServer(logins, async (apiUrl) => { @@ -120,20 +204,24 @@ test('repository governance config resolves the current four-member panel and fi }); }); -test('repository scope mapping covers every configured scope with the ruled paths', () => { +test('repository scope mapping covers every tracked path except the ruled residue', () => { const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8'); - const expected = { - 'scope:cli': ['src/**'], - 'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'], - 'scope:manifests': ['manifests/**'], - 'scope:ci': ['.forgejo/workflows/**'], - 'scope:docs': ['README.md', 'docs/**'], - }; + const globs = [...labeler.matchAll(/any-glob-to-any-file:\s*(\[[^\n]+\])/g)] + .flatMap((match) => JSON.parse(match[1])); + const tracked = execFileSync('git', ['ls-files'], { + cwd: path.join(__dirname, '..'), + encoding: 'utf8', + }).trim().split('\n'); + const allowed = new Set([ + '.gitignore', + 'assets/logo-mark.svg', + ...tracked.filter((filename) => /^test\/[^/]+\.test\.js$/.test(filename)), + ]); + const uncovered = tracked.filter((filename) => ( + !allowed.has(filename) && !globs.some((glob) => path.matchesGlob(filename, glob)) + )); - for (const [label, globs] of Object.entries(expected)) { - assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`); - for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`); - } + assert.deepEqual(uncovered, []); }); test('package lock versions match the package manifest', () => {