From 31795ba7d3741acc37bbcc7be6e64dfbec32395d Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 3 Sep 2026 00:30:09 +0200 Subject: [PATCH 1/2] fix(ci): bootstrap declarative scope validation --- .github/scope-contract.json | 127 ++++++++ .github/scripts/scope-contract.cjs | 357 +++++++++++++++++++++ .github/scripts/scope-contract.sh | 6 + .github/scripts/scope-contract.test.cjs | 255 +++++++++++++++ .github/workflows/ci-release-contract.yaml | 37 +++ .github/workflows/pr-title.yaml | 115 +------ 6 files changed, 786 insertions(+), 111 deletions(-) create mode 100644 .github/scope-contract.json create mode 100644 .github/scripts/scope-contract.cjs create mode 100644 .github/scripts/scope-contract.sh create mode 100644 .github/scripts/scope-contract.test.cjs create mode 100644 .github/workflows/ci-release-contract.yaml diff --git a/.github/scope-contract.json b/.github/scope-contract.json new file mode 100644 index 0000000000..9cf5fedaeb --- /dev/null +++ b/.github/scope-contract.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": 1, + "scopes": [ + "analyzer", + "autobuilder", + "ci", + "cli", + "core", + "docs", + "github", + "gitlab", + "infra", + "model", + "rules" + ], + "compatibility": { + "forbiddenPairs": [ + ["cli", "analyzer"], + ["cli", "autobuilder"], + ["cli", "core"], + ["cli", "model"], + ["cli", "rules"] + ], + "exclusiveGroups": [ + ["github", "gitlab", "infra"] + ], + "companions": { + "github": ["docs", "ci"], + "gitlab": ["docs", "ci"], + "infra": ["docs", "ci"] + } + }, + "ownership": { + "ignoredRoots": [ + "formal" + ], + "overrides": [ + { + "scope": "ci", + "globs": [ + "release-notes-transform.cjs", + "**/.releaserc.cjs" + ] + } + ], + "roots": { + "rules": { + "defaultScope": "rules" + }, + "model": { + "defaultScope": "model" + }, + "github": { + "defaultScope": "github" + }, + "gitlab": { + "defaultScope": "gitlab" + }, + "infra": { + "defaultScope": "infra" + }, + "cli": { + "defaultScope": "cli" + }, + "core": { + "defaultScope": "core", + "rules": [ + { + "scope": "autobuilder", + "globs": [ + "opentaint-jvm-autobuilder/**", + "opentaint-project-model/**", + "opentaint-utils/cli-util/**" + ] + }, + { + "scope": "analyzer", + "globs": [ + "opentaint-ir/**/src/test/**", + "src/**", + "samples/**", + "opentaint-jvm-sast-dataflow/**", + "opentaint-jvm-sast-project/**", + "opentaint-jvm-sast-se-api/**", + "opentaint-go-querylang/**", + "opentaint-java-querylang/**", + "opentaint-config/**", + "opentaint-configuration-rules/**", + "opentaint-utils/common-util/**", + "opentaint-utils/opentaint-jvm-util/**" + ] + } + ] + }, + ".github": { + "defaultScope": "ci" + } + }, + "documentation": { + "scope": "docs", + "rootDirectories": [ + "public", + "docs", + "skills", + "skills-templates", + "scripts", + "logos", + ".claude-plugin", + ".codex-plugin" + ], + "basenames": [ + "README.md", + "CHANGELOG.md", + "LICENSE", + "LICENSE.md" + ], + "extensions": [ + ".svg", + ".png" + ], + "rootFiles": [ + ".gitignore", + "Makefile" + ] + } + } +} diff --git a/.github/scripts/scope-contract.cjs b/.github/scripts/scope-contract.cjs new file mode 100644 index 0000000000..ec4c292fd9 --- /dev/null +++ b/.github/scripts/scope-contract.cjs @@ -0,0 +1,357 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const contractPath = path.join(__dirname, '..', 'scope-contract.json'); +const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); + +const RELEASE_TYPES = Object.freeze([ + ['feat', 'minor'], + ['fix', 'patch'], + ['refactor', 'patch'], + ['revert', 'patch'], +]); + +const RELEASE_PARSER_OPTIONS = Object.freeze({ + headerPattern: /^(\w*)(?:\(([^)]*)\))?!?: (.*)$/, + headerCorrespondence: ['type', 'scope', 'subject'], +}); + +class ScopeContractError extends Error {} + +function assert(condition, message) { + if (!condition) throw new ScopeContractError(message); +} + +function assertScope(scope, knownScopes, location) { + assert( + typeof scope === 'string' && knownScopes.has(scope), + `${location} refers to unknown scope '${scope}'`, + ); +} + +function validateRule(rule, knownScopes, location) { + assert(rule && typeof rule === 'object', `${location} must be an object`); + assertScope(rule.scope, knownScopes, `${location}.scope`); + assert( + Array.isArray(rule.globs) && rule.globs.length > 0, + `${location}.globs must be a non-empty array`, + ); + for (const glob of rule.globs) { + assert( + typeof glob === 'string' && glob.length > 0, + `${location}.globs must contain non-empty strings`, + ); + } +} + +function validateContract(candidate) { + assert(candidate && typeof candidate === 'object', 'The contract must be an object'); + assert(candidate.schemaVersion === 1, 'The contract schema version must be 1'); + assert(Array.isArray(candidate.scopes), 'The contract scopes must be an array'); + + const knownScopes = new Set(candidate.scopes); + assert(knownScopes.size === candidate.scopes.length, 'Contract scopes must be unique'); + for (const scope of candidate.scopes) { + assert(typeof scope === 'string' && scope.length > 0, 'Scopes must be non-empty strings'); + } + + const compatibility = candidate.compatibility; + assert(compatibility && typeof compatibility === 'object', 'Compatibility is required'); + for (const [index, pair] of compatibility.forbiddenPairs.entries()) { + assert(Array.isArray(pair) && pair.length === 2, `forbiddenPairs[${index}] must contain two scopes`); + assertScope(pair[0], knownScopes, `forbiddenPairs[${index}]`); + assertScope(pair[1], knownScopes, `forbiddenPairs[${index}]`); + assert(pair[0] !== pair[1], `forbiddenPairs[${index}] must contain different scopes`); + } + for (const [index, group] of compatibility.exclusiveGroups.entries()) { + assert(Array.isArray(group) && group.length > 1, `exclusiveGroups[${index}] must contain two or more scopes`); + assert(new Set(group).size === group.length, `exclusiveGroups[${index}] must be unique`); + for (const scope of group) assertScope(scope, knownScopes, `exclusiveGroups[${index}]`); + } + for (const [scope, companions] of Object.entries(compatibility.companions)) { + assertScope(scope, knownScopes, 'companions'); + assert(Array.isArray(companions), `Companions for '${scope}' must be an array`); + assert(new Set(companions).size === companions.length, `Companions for '${scope}' must be unique`); + for (const companion of companions) assertScope(companion, knownScopes, `companions.${scope}`); + } + + const ownership = candidate.ownership; + assert(ownership && typeof ownership === 'object', 'Ownership is required'); + assert(Array.isArray(ownership.ignoredRoots), 'Ignored ownership roots must be an array'); + assert( + new Set(ownership.ignoredRoots).size === ownership.ignoredRoots.length, + 'Ignored ownership roots must be unique', + ); + for (const root of ownership.ignoredRoots) { + assert( + typeof root === 'string' && root.length > 0 && !root.includes('/'), + `Invalid ignored ownership root '${root}'`, + ); + assert( + !Object.hasOwn(ownership.roots, root), + `Ignored ownership root '${root}' also has an owner`, + ); + } + for (const [index, rule] of ownership.overrides.entries()) { + validateRule(rule, knownScopes, `ownership.overrides[${index}]`); + } + for (const [root, route] of Object.entries(ownership.roots)) { + assert(root.length > 0 && !root.includes('/'), `Invalid ownership root '${root}'`); + assertScope(route.defaultScope, knownScopes, `ownership.roots.${root}.defaultScope`); + for (const [index, rule] of (route.rules || []).entries()) { + validateRule(rule, knownScopes, `ownership.roots.${root}.rules[${index}]`); + } + } + const documentation = ownership.documentation; + assert(documentation && typeof documentation === 'object', 'Documentation ownership is required'); + assertScope(documentation.scope, knownScopes, 'ownership.documentation.scope'); + for (const key of ['rootDirectories', 'basenames', 'extensions', 'rootFiles']) { + assert(Array.isArray(documentation[key]), `ownership.documentation.${key} must be an array`); + assert(new Set(documentation[key]).size === documentation[key].length, `ownership.documentation.${key} must be unique`); + } + + return candidate; +} + +function globToRegExp(glob) { + let expression = '^'; + for (let index = 0; index < glob.length; index += 1) { + const character = glob[index]; + if (character === '*' && glob[index + 1] === '*') { + if (glob[index + 2] === '/') { + expression += '(?:.*/)?'; + index += 2; + } else { + expression += '.*'; + index += 1; + } + } else if (character === '*') { + expression += '[^/]*'; + } else if (character === '?') { + expression += '[^/]'; + } else { + expression += character.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + } + } + return new RegExp(`${expression}$`); +} + +function ruleMatches(rule, candidatePath) { + return rule.globs.some(glob => globToRegExp(glob).test(candidatePath)); +} + +function oneRuleOwner(rules, candidatePath, location) { + const matches = rules.filter(rule => ruleMatches(rule, candidatePath)); + if (matches.length > 1) { + const scopes = matches.map(rule => rule.scope).join(','); + throw new ScopeContractError(`${location} has multiple scopes: ${scopes}`); + } + return matches.length === 1 ? matches[0].scope : undefined; +} + +function splitScopes(scopeList) { + if (typeof scopeList !== 'string' || scopeList.length === 0) return []; + return scopeList.split(','); +} + +function validateScopeList(scopeList, candidate = contract) { + validateContract(candidate); + assert(typeof scopeList === 'string' && scopeList.length > 0, 'A scope list is required'); + const scopes = splitScopes(scopeList); + assert(scopes.every(Boolean), 'Scope lists cannot contain an empty scope'); + + const knownScopes = new Set(candidate.scopes); + const selected = new Set(); + for (const scope of scopes) { + assert(knownScopes.has(scope), `Unknown scope: ${scope}`); + assert(!selected.has(scope), `Scope occurs more than once: ${scope}`); + selected.add(scope); + } + + for (const [left, right] of candidate.compatibility.forbiddenPairs) { + assert(!selected.has(left) || !selected.has(right), `The ${left} scope cannot occur with the ${right} scope`); + } + for (const group of candidate.compatibility.exclusiveGroups) { + const present = group.filter(scope => selected.has(scope)); + assert(present.length < 2, `These scopes are mutually exclusive: ${present.join(',')}`); + } + for (const [scope, companions] of Object.entries(candidate.compatibility.companions)) { + if (!selected.has(scope)) continue; + const allowed = new Set([scope, ...companions]); + const invalid = scopes.filter(other => !allowed.has(other)); + assert(invalid.length === 0, `The ${scope} scope cannot occur with ${invalid.join(',')}`); + } + + return scopes; +} + +function scopeForPath(candidatePath, candidate = contract) { + validateContract(candidate); + assert(typeof candidatePath === 'string' && candidatePath.length > 0, 'A changed path is required'); + assert(!candidatePath.startsWith('/'), `Changed paths must be relative: ${candidatePath}`); + assert(!candidatePath.includes('\\'), `Changed paths must use forward slashes: ${candidatePath}`); + assert(!candidatePath.split('/').includes('..'), `Changed paths cannot contain '..': ${candidatePath}`); + + const separator = candidatePath.indexOf('/'); + const root = separator === -1 ? candidatePath : candidatePath.slice(0, separator); + const relative = separator === -1 ? '' : candidatePath.slice(separator + 1); + if (candidate.ownership.ignoredRoots.includes(root)) return null; + + const override = oneRuleOwner(candidate.ownership.overrides, candidatePath, candidatePath); + if (override) return override; + + const route = candidate.ownership.roots[root]; + if (route) { + const refined = oneRuleOwner(route.rules || [], relative, candidatePath); + return refined || route.defaultScope; + } + + const docs = candidate.ownership.documentation; + const basename = path.posix.basename(candidatePath); + const extension = path.posix.extname(candidatePath); + if ( + docs.rootDirectories.includes(root) || + docs.basenames.includes(basename) || + docs.extensions.includes(extension) || + docs.rootFiles.includes(candidatePath) + ) { + return docs.scope; + } + + throw new ScopeContractError(`No scope owns changed path: ${candidatePath}`); +} + +function validateScopePaths(scopeList, paths, candidate = contract) { + const scopes = validateScopeList(scopeList, candidate); + assert(Array.isArray(paths), 'Changed paths must be an array'); + assert(paths.length > 0, 'At least one changed path is required'); + const owners = paths.map(changedPath => scopeForPath(changedPath, candidate)); + const guardedOwners = owners.filter(owner => owner !== null); + if (guardedOwners.length === 0) return scopes; + + const selected = new Set(scopes); + const used = new Set(); + + for (let index = 0; index < paths.length; index += 1) { + const changedPath = paths[index]; + const owner = owners[index]; + if (owner === null) continue; + assert(selected.has(owner), `Changed path '${changedPath}' requires scope '${owner}'`); + used.add(owner); + } + for (const scope of scopes) { + assert(used.has(scope), `Scope '${scope}' does not own a changed path`); + } + return scopes; +} + +function hasAnyScope(scopeList, allowedScopes) { + const scopes = new Set(splitScopes(scopeList)); + return allowedScopes.some(scope => scopes.has(scope)); +} + +function scopePatterns(scope) { + return [scope, `${scope},*`, `*,${scope}`, `*,${scope},*`]; +} + +function scopeExclusionPattern(scope) { + return `!(${scopePatterns(scope).join('|')})`; +} + +function createReleaseRules(scope) { + const rules = []; + for (const pattern of scopePatterns(scope)) { + rules.push({ scope: pattern, breaking: true, release: 'major' }); + for (const [type, release] of RELEASE_TYPES) { + rules.push({ scope: pattern, type, release }); + } + } + rules.push({ scope: scopeExclusionPattern(scope), release: false }); + return rules; +} + +function scopeListFromTitle(subject) { + const match = /^(?:chore|feat|fix|refactor|revert|style|test)\(([^()]*)\)!?:\s/.exec(subject); + assert(match, `Could not extract scopes from title: ${subject}`); + validateScopeList(match[1]); + return match[1]; +} + +function commitScopeList(subject) { + const match = /^[a-z]+\(([^()]*)\)!?:\s/.exec(subject); + return match ? match[1] : undefined; +} + +function filterScopedCommits(releaseScopes, subjects) { + const allowed = Array.isArray(releaseScopes) ? releaseScopes : splitScopes(releaseScopes); + return subjects.filter(subject => { + const scopes = commitScopeList(subject); + return scopes !== undefined && hasAnyScope(scopes, allowed); + }); +} + +function readStandardInput() { + return fs.readFileSync(0, 'utf8').split('\n').filter(Boolean); +} + +function runCli(argv) { + const [command, argument] = argv; + if (command === 'validate-paths') { + validateScopePaths(argument, readStandardInput()); + return; + } + if (command === 'validate-list') { + validateScopeList(argument); + return; + } + if (command === 'scope-for-path') { + const owner = scopeForPath(argument); + process.stdout.write(`${owner === null ? 'ignored' : owner}\n`); + return; + } + if (command === 'title-scopes') { + process.stdout.write(`${scopeListFromTitle(argument)}\n`); + return; + } + if (command === 'filter-commits') { + const matches = filterScopedCommits(splitScopes(argument), readStandardInput()); + if (matches.length > 0) process.stdout.write(`${matches.join('\n')}\n`); + return; + } + throw new ScopeContractError( + 'Usage: scope-contract.cjs validate-paths|validate-list|scope-for-path|title-scopes|filter-commits ARGUMENT', + ); +} + +validateContract(contract); + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { + RELEASE_PARSER_OPTIONS, + ScopeContractError, + commitScopeList, + contract, + createReleaseRules, + filterScopedCommits, + globToRegExp, + hasAnyScope, + runCli, + scopeExclusionPattern, + scopeForPath, + scopeListFromTitle, + scopePatterns, + splitScopes, + validateContract, + validateScopeList, + validateScopePaths, +}; diff --git a/.github/scripts/scope-contract.sh b/.github/scripts/scope-contract.sh new file mode 100644 index 0000000000..c7e1a24c4b --- /dev/null +++ b/.github/scripts/scope-contract.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +exec node "$SCRIPT_DIR/scope-contract.cjs" validate-paths "$@" diff --git a/.github/scripts/scope-contract.test.cjs b/.github/scripts/scope-contract.test.cjs new file mode 100644 index 0000000000..9324b23a80 --- /dev/null +++ b/.github/scripts/scope-contract.test.cjs @@ -0,0 +1,255 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); + +const engine = require('./scope-contract.cjs'); + +const EXPECTED_SCOPES = [ + 'analyzer', + 'autobuilder', + 'ci', + 'cli', + 'core', + 'docs', + 'github', + 'gitlab', + 'infra', + 'model', + 'rules', +]; + +test('the contract has one ordered scope declaration', () => { + assert.deepEqual(engine.contract.scopes, EXPECTED_SCOPES); + assert.doesNotThrow(() => engine.validateContract(engine.contract)); +}); + +test('scope validation is independent of scope order', () => { + assert.deepEqual( + engine.validateScopeList('rules,model,analyzer'), + ['rules', 'model', 'analyzer'], + ); + assert.deepEqual( + engine.validateScopeList('analyzer,model,rules'), + ['analyzer', 'model', 'rules'], + ); +}); + +test('scope validation rejects duplicates and unknown scopes', () => { + assert.throws(() => engine.validateScopeList('')); + assert.throws(() => engine.validateScopeList('model,model')); + assert.throws(() => engine.validateScopeList('formal')); + assert.throws(() => engine.validateScopeList('model,unknown')); + assert.throws(() => engine.validateScopeList('model,,rules')); +}); + +test('cli cannot occur with release component scopes', () => { + for (const forbidden of ['analyzer', 'autobuilder', 'core', 'model', 'rules']) { + assert.throws(() => engine.validateScopeList(`cli,${forbidden}`)); + } + assert.doesNotThrow(() => engine.validateScopeList('cli,docs')); + assert.doesNotThrow(() => engine.validateScopeList('cli,ci')); +}); + +test('repository integration scopes are mutually exclusive', () => { + for (const pair of [ + 'github,gitlab', + 'github,infra', + 'gitlab,infra', + 'github,gitlab,docs,ci', + ]) { + assert.throws(() => engine.validateScopeList(pair)); + } +}); + +test('repository integration scopes permit docs and ci companions', () => { + for (const scope of ['github', 'gitlab', 'infra']) { + assert.doesNotThrow(() => engine.validateScopeList(scope)); + assert.doesNotThrow(() => engine.validateScopeList(`${scope},docs`)); + assert.doesNotThrow(() => engine.validateScopeList(`ci,${scope}`)); + assert.doesNotThrow(() => engine.validateScopeList(`${scope},docs,ci`)); + assert.throws(() => engine.validateScopeList(`${scope},model`)); + } +}); + +test('all scope subsets agree with the formal compatibility policy', () => { + const cliForbidden = new Set([ + 'analyzer', + 'autobuilder', + 'core', + 'model', + 'rules', + ]); + const integrations = new Set(['github', 'gitlab', 'infra']); + + function expectedValid(scopes) { + const selected = new Set(scopes); + if (selected.has('cli') && scopes.some(scope => cliForbidden.has(scope))) { + return false; + } + const present = scopes.filter(scope => integrations.has(scope)); + if (present.length > 1) return false; + if (present.length === 1) { + const allowed = new Set([present[0], 'docs', 'ci']); + if (scopes.some(scope => !allowed.has(scope))) return false; + } + return true; + } + + let checked = 0; + for (let mask = 1; mask < 2 ** EXPECTED_SCOPES.length; mask += 1) { + const selected = EXPECTED_SCOPES.filter( + (_, index) => (mask & (1 << index)) !== 0, + ); + for (const ordered of [selected, [...selected].reverse()]) { + const scopeList = ordered.join(','); + if (expectedValid(ordered)) { + assert.doesNotThrow(() => engine.validateScopeList(scopeList), scopeList); + } else { + assert.throws(() => engine.validateScopeList(scopeList), scopeList); + } + checked += 1; + } + } + assert.equal(checked, 4094); +}); + +test('path ownership uses the declarative root contract', () => { + const cases = new Map([ + ['model/go/dataflow/example/model.go', 'model'], + ['core/src/test/kotlin/example/ModelTest.kt', 'analyzer'], + ['core/opentaint-ir/go/tests/src/test/kotlin/IrTest.kt', 'analyzer'], + ['core/build.gradle.kts', 'core'], + ['core/opentaint-jvm-autobuilder/src/Main.kt', 'autobuilder'], + ['rules/ruleset/go/lib/example.yaml', 'rules'], + ['cli/README.md', 'cli'], + ['formal/release-scopes/Main.lean', null], + ['github/action.yml', 'github'], + ['gitlab/action.yml', 'gitlab'], + ['infra/pulumi/index.ts', 'infra'], + ['.github/workflows/ci-rules.yaml', 'ci'], + ['.github/workflows/ci-analyzer-owasp.yaml', 'ci'], + ['.github/workflows/ci-github.yaml', 'ci'], + ['.github/workflows/ci-cli.yaml', 'ci'], + ['.github/workflows/ci-autobuilder.yaml', 'ci'], + ['.github/workflows/ci-analyzer.yaml', 'ci'], + ['.github/workflows/ci-dataflow.yaml', 'ci'], + ['.github/workflows/ci-ir.yaml', 'ci'], + ['.github/workflows/release-rules.yaml', 'ci'], + ['.github/workflows/release-github.yaml', 'ci'], + ['.github/workflows/release-gitlab.yaml', 'ci'], + ['.github/workflows/release-cli.yaml', 'ci'], + ['.github/workflows/publish-autobuilder.yaml', 'ci'], + ['.github/workflows/publish-analyzer.yaml', 'ci'], + ['.github/workflows/pr-title.yaml', 'ci'], + ['cli/.releaserc.cjs', 'ci'], + ['README.md', 'docs'], + ]); + + for (const [path, expected] of cases) { + assert.equal(engine.scopeForPath(path), expected, path); + } +}); + +test('every workflow path uses the ci scope', () => { + const workflows = execFileSync( + 'git', + ['ls-files', '.github/workflows'], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .filter(Boolean); + assert(workflows.length > 0); + for (const workflow of workflows) { + assert.equal(engine.scopeForPath(workflow), 'ci', workflow); + } +}); + +test('path ownership rejects an ambiguous refinement', () => { + const ambiguous = structuredClone(engine.contract); + ambiguous.ownership.roots.core.rules.push({ + scope: 'autobuilder', + globs: ['src/**'], + }); + assert.throws( + () => engine.scopeForPath('core/src/Main.kt', ambiguous), + /multiple scopes/, + ); +}); + +test('exact path validation requires all and only used owners', () => { + const paths = [ + 'model/go/dataflow/example/model.go', + 'core/src/test/kotlin/example/ModelTest.kt', + 'rules/ruleset/go/lib/example.yaml', + ]; + assert.doesNotThrow( + () => engine.validateScopePaths('model,analyzer,rules', paths), + ); + assert.throws(() => engine.validateScopePaths('model', paths)); + assert.throws( + () => engine.validateScopePaths('model,analyzer,rules,docs', paths), + ); +}); + +test('every tracked path has one owner or is explicitly ignored', () => { + const tracked = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .filter(candidatePath => candidatePath && fs.existsSync(candidatePath)); + for (const path of tracked) { + const owner = engine.scopeForPath(path); + if (owner === null) { + assert.equal(path.split('/')[0], 'formal', path); + } + } +}); + +test('formal paths are ignored but still require a scoped title', () => { + const paths = ['formal/go-models/OpenTaint/GoModels/Core.lean']; + assert.throws(() => engine.validateScopePaths('', paths)); + assert.doesNotThrow(() => engine.validateScopePaths('ci', paths)); +}); + +test('a guarded path cannot omit its scope', () => { + assert.throws(() => engine.validateScopePaths('', [ + 'model/go/dataflow/example/model.go', + ])); +}); + +test('ignored formal paths can accompany guarded paths', () => { + assert.doesNotThrow(() => engine.validateScopePaths('model', [ + 'formal/go-models/OpenTaint/GoModels/Core.lean', + 'model/go/dataflow/example/model.go', + ])); +}); + +test('pull request title parsing keeps the complete scope list', () => { + assert.equal( + engine.scopeListFromTitle('fix(model,analyzer,rules): Update models'), + 'model,analyzer,rules', + ); + assert.throws( + () => engine.scopeListFromTitle('chore: Update formal specifications'), + ); + assert.throws(() => engine.scopeListFromTitle('Update models')); +}); + +test('release filtering uses scope intersection', () => { + const subjects = [ + 'fix(model,analyzer,rules): Update models', + 'feat(cli): Update the CLI', + 'fix(model): Update one model', + ]; + assert.deepEqual( + engine.filterScopedCommits(['analyzer', 'core', 'model'], subjects), + [subjects[0], subjects[2]], + ); +}); diff --git a/.github/workflows/ci-release-contract.yaml b/.github/workflows/ci-release-contract.yaml new file mode 100644 index 0000000000..ed7d2c370f --- /dev/null +++ b/.github/workflows/ci-release-contract.yaml @@ -0,0 +1,37 @@ +name: CI Scope Contract + +on: + workflow_dispatch: + + push: + branches: [ "main" ] + paths: + - '.github/scope-contract.json' + - '.github/scripts/scope-contract*' + - '.github/workflows/ci-release-contract.yaml' + - '.github/workflows/pr-title.yaml' + pull_request: + branches: [ "main" ] + paths: + - '.github/scope-contract.json' + - '.github/scripts/scope-contract*' + - '.github/workflows/ci-release-contract.yaml' + - '.github/workflows/pr-title.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Test scope validation + run: node --test .github/scripts/scope-contract.test.cjs + + - name: Check the shell adapter + run: bash -n .github/scripts/scope-contract.sh diff --git a/.github/workflows/pr-title.yaml b/.github/workflows/pr-title.yaml index 60addfe581..8e819daecc 100644 --- a/.github/workflows/pr-title.yaml +++ b/.github/workflows/pr-title.yaml @@ -34,18 +34,6 @@ jobs: revert style test - scopes: | - cli - rules - model - github - gitlab - analyzer - autobuilder - core - infra - ci - docs requireScope: true subjectPattern: ^[A-Z].+$ subjectPatternError: | @@ -72,104 +60,9 @@ jobs: run: | set -euo pipefail - SCOPE=$(echo "$PR_TITLE" | sed -n 's/^[a-z]*(\([^)]*\)).*/\1/p') - - if [ -z "$SCOPE" ]; then - echo "Could not extract scope from PR title: $PR_TITLE" - exit 1 - fi - - declare -A SCOPE_PATHS - - SCOPE_PATHS[rules]="^(rules/|\.github/workflows/((ci|release)-rules|ci-analyzer-owasp)\.)" - SCOPE_PATHS[model]="^model/" - SCOPE_PATHS[github]="^(github/|\.github/workflows/(ci|release)-github\.)" - SCOPE_PATHS[gitlab]="^(gitlab/|\.github/workflows/release-gitlab\.)" - AUTOBUILDER_MODULES=( - "opentaint-jvm-autobuilder/" - "opentaint-project-model/" - "opentaint-common-build/" - "opentaint-utils/cli-util/" - "opentaint-utils/build\.gradle\.kts" - "opentaint-utils/settings\.gradle\.kts" - "opentaint-utils/buildSrc/" - "build\.gradle\.kts" - "settings\.gradle\.kts" - "buildSrc/" - ) - AUTOBUILDER_PATTERN=$(IFS="|"; echo "${AUTOBUILDER_MODULES[*]}") - SCOPE_PATHS[autobuilder]="^(core/(${AUTOBUILDER_PATTERN})|\.github/workflows/(ci|publish)-autobuilder\.)" - SCOPE_PATHS[infra]="^(infra/|\.github/workflows/publish-infra)" - SCOPE_PATHS[ci]="^(\.github/|release-notes-transform\.cjs|[^/]+/\.releaserc\.cjs)" - - ANALYZER_MODULES=( - "opentaint-jvm-sast-dataflow/" - "opentaint-jvm-sast-project/" - "opentaint-jvm-sast-se-api/" - "opentaint-go-querylang/" - "opentaint-java-querylang/" - "opentaint-config/" - "opentaint-configuration-rules/" - "opentaint-ir/" - "opentaint-dataflow-core/" - "opentaint-utils/common-util/" - "opentaint-utils/opentaint-jvm-util/" - "opentaint-utils/build\.gradle\.kts" - "opentaint-utils/settings\.gradle\.kts" - "opentaint-utils/buildSrc/" - "build\.gradle\.kts" - "settings\.gradle\.kts" - "buildSrc/" - "src/" - "samples/" - ) - ANALYZER_PATTERN=$(IFS="|"; echo "${ANALYZER_MODULES[*]}") - SCOPE_PATHS[analyzer]="^(core/(${ANALYZER_PATTERN})|\.github/workflows/((ci|publish)-analyzer|ci-analyzer-owasp|ci-(dataflow|ir))\.)" - - SCOPE_PATHS[core]="^(core/|\.github/workflows/(pr-title|((ci|publish)-analyzer)|ci-analyzer-owasp|((ci|publish)-autobuilder)|ci-(dataflow|ir))\.)" - - DOCS_PATTERNS=( - "public/" - "docs/" - "skills/" - "skills-templates/" - "scripts/" - "(.*/)?README\.md" - "(.*/)?CHANGELOG\.md" - "logos/" - "(.*/)?LICENSE(\.md)?" - ".*\.svg" - ".*\.png" - ".gitignore" - "Makefile" - ) - DOCS_PATTERN=$(IFS="|"; echo "${DOCS_PATTERNS[*]}") - SCOPE_PATHS[docs]="^(${DOCS_PATTERN})" - SCOPE_PATHS[cli]="^(cli/|\.github/workflows/(ci|release)-cli\.|${DOCS_PATTERN})" - - ALLOWED="${SCOPE_PATHS[$SCOPE]:-}" - if [ -z "$ALLOWED" ]; then - echo "Unknown scope: $SCOPE" - exit 1 - fi + SCOPE=$(node .github/scripts/scope-contract.cjs title-scopes "$PR_TITLE") CHANGED=$(gh pr diff "$PR_NUMBER" --name-only --repo "$GITHUB_REPOSITORY") - - VIOLATIONS="" - while IFS= read -r file; do - [ -z "$file" ] && continue - if ! echo "$file" | grep -qE "$ALLOWED"; then - VIOLATIONS="$VIOLATIONS\n $file" - fi - done <<< "$CHANGED" - - if [ -n "$VIOLATIONS" ]; then - echo "ERROR: PR scope ($SCOPE) does not match these changed files:" - echo -e "$VIOLATIONS" - echo "" - echo "Each PR must only touch files belonging to its scope." - echo "Allowed pattern for scope '$SCOPE': $ALLOWED" - exit 1 - fi - - echo "All changed files match scope '$SCOPE'" + printf '%s\n' "$CHANGED" | + node .github/scripts/scope-contract.cjs validate-paths "$SCOPE" + echo "All changed files have the exact scopes '$SCOPE'" From db96ab1ea8a8523e4b2a81af1af30c1fdf9eeaa7 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 3 Sep 2026 00:31:45 +0200 Subject: [PATCH 2/2] fix(ci): apply the scope contract to releases --- .../actions/check-scoped-commits/action.yml | 7 +- .../scripts/release-scope-semantic.test.mjs | 63 +++++++++++++ .github/scripts/release-scope.test.cjs | 92 +++++++++++++++++++ .github/workflows/ci-ir.yaml | 2 + .github/workflows/ci-release-contract.yaml | 22 ++++- cli/.releaserc.cjs | 15 ++- github/.releaserc.cjs | 15 ++- gitlab/.releaserc.cjs | 15 ++- release-notes-transform.cjs | 5 +- rules/.releaserc.cjs | 15 ++- 10 files changed, 212 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/release-scope-semantic.test.mjs create mode 100644 .github/scripts/release-scope.test.cjs diff --git a/.github/actions/check-scoped-commits/action.yml b/.github/actions/check-scoped-commits/action.yml index 52121c445e..c204721ed8 100644 --- a/.github/actions/check-scoped-commits/action.yml +++ b/.github/actions/check-scoped-commits/action.yml @@ -35,9 +35,6 @@ runs: TAG_PREFIX="${{ inputs.tag-prefix }}" SCOPES="${{ inputs.scopes }}" - SCOPE_PATTERN=$(echo "$SCOPES" | tr ',' '|') - REGEX="^\w+\((${SCOPE_PATTERN})\)" - LAST_TAG=$(git tag -l "${TAG_PREFIX}*" --sort=-creatordate | head -1) if [ -z "$LAST_TAG" ]; then @@ -48,7 +45,9 @@ runs: COMMITS=$(git log --oneline --format='%s' "${LAST_TAG}..HEAD") fi - RELEVANT=$(echo "$COMMITS" | grep -E "$REGEX" || true) + RELEVANT=$(printf '%s\n' "$COMMITS" | + node "$GITHUB_WORKSPACE/.github/scripts/scope-contract.cjs" \ + filter-commits "$SCOPES") if [ -z "$RELEVANT" ]; then echo "has-changes=false" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/release-scope-semantic.test.mjs b/.github/scripts/release-scope-semantic.test.mjs new file mode 100644 index 0000000000..2c6e681895 --- /dev/null +++ b/.github/scripts/release-scope-semantic.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { analyzeCommits } from '@semantic-release/commit-analyzer'; + +const require = createRequire(import.meta.url); +const { + RELEASE_PARSER_OPTIONS, + createReleaseRules, +} = require('./scope-contract.cjs'); + +const logger = { log() {} }; + +async function releaseFor(scope, message) { + return analyzeCommits( + { + preset: 'conventionalcommits', + parserOpts: RELEASE_PARSER_OPTIONS, + releaseRules: createReleaseRules(scope), + }, + { + commits: [{ message }], + logger, + }, + ); +} + +test('real analyzer matches each scope token position', async () => { + assert.equal( + await releaseFor('model', 'fix(model,analyzer,rules): Update models'), + 'patch', + ); + assert.equal( + await releaseFor('model', 'fix(analyzer,model,rules): Update models'), + 'patch', + ); + assert.equal( + await releaseFor('model', 'fix(analyzer,rules,model): Update models'), + 'patch', + ); +}); + +test('real analyzer keeps release types', async () => { + assert.equal( + await releaseFor('rules', 'feat(model,analyzer,rules): Update models'), + 'minor', + ); + assert.equal( + await releaseFor('rules', 'fix(model,analyzer,rules): Update models'), + 'patch', + ); +}); + +test('real analyzer does not release an absent scope', async () => { + assert.equal( + await releaseFor('cli', 'fix(model,analyzer,rules): Update models'), + null, + ); +}); + +test('real analyzer keeps single-scope compatibility', async () => { + assert.equal(await releaseFor('rules', 'fix(rules): Update rules'), 'patch'); +}); diff --git a/.github/scripts/release-scope.test.cjs b/.github/scripts/release-scope.test.cjs new file mode 100644 index 0000000000..a3d6c7239d --- /dev/null +++ b/.github/scripts/release-scope.test.cjs @@ -0,0 +1,92 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + RELEASE_PARSER_OPTIONS, + createReleaseRules, + hasAnyScope, + scopeExclusionPattern, + scopePatterns, + splitScopes, +} = require('./scope-contract.cjs'); +const { createTransform } = require('../../release-notes-transform.cjs'); + +test('splitScopes keeps ordered scope tokens', () => { + assert.deepEqual( + splitScopes('model,analyzer,rules'), + ['model', 'analyzer', 'rules'], + ); +}); + +test('release parser accepts a comma-separated scope list', () => { + const parsed = RELEASE_PARSER_OPTIONS.headerPattern.exec( + 'fix(model,analyzer,rules): Update models', + ); + assert.notEqual(parsed, null); + assert.deepEqual(parsed.slice(1), [ + 'fix', + 'model,analyzer,rules', + 'Update models', + ]); +}); + +test('release matching uses scope intersection', () => { + assert.equal( + hasAnyScope('model,analyzer,rules', ['analyzer', 'core', 'model']), + true, + ); + assert.equal(hasAnyScope('model,analyzer,rules', ['rules']), true); + assert.equal(hasAnyScope('model,analyzer,rules', ['cli']), false); +}); + +test('scope patterns match each token position', () => { + assert.deepEqual( + scopePatterns('rules'), + ['rules', 'rules,*', '*,rules', '*,rules,*'], + ); +}); + +test('release rules include every token position and an exact exclusion', () => { + const rules = createReleaseRules('rules'); + assert.equal( + rules.some(rule => + rule.scope === '*,rules,*' && rule.type === 'feat' && rule.release === 'minor' + ), + true, + ); + assert.equal( + rules.some(rule => + rule.scope === scopeExclusionPattern('rules') && rule.release === false + ), + true, + ); +}); + +function commit(scope) { + return { + scope, + type: 'fix', + notes: [], + hash: '1234567890', + subject: 'Update release matching', + references: [], + }; +} + +test('release notes include a commit with an allowed scope token', () => { + const transformed = createTransform(['rules'])( + commit('model,analyzer,rules'), + {}, + ); + assert.notEqual(transformed, null); +}); + +test('release notes exclude a commit without an allowed scope token', () => { + const transformed = createTransform(['cli'])( + commit('model,analyzer,rules'), + {}, + ); + assert.equal(transformed, null); +}); diff --git a/.github/workflows/ci-ir.yaml b/.github/workflows/ci-ir.yaml index 37f63171c0..c05a582cd4 100644 --- a/.github/workflows/ci-ir.yaml +++ b/.github/workflows/ci-ir.yaml @@ -7,12 +7,14 @@ on: paths: - 'core/opentaint-ir/**' - 'core/opentaint-common-build/**' + - 'model/go/dataflow/**' - '.github/workflows/ci-ir.yaml' branches: [ "main" ] pull_request: paths: - 'core/opentaint-ir/**' - 'core/opentaint-common-build/**' + - 'model/go/dataflow/**' - '.github/workflows/ci-ir.yaml' branches: [ "main" ] diff --git a/.github/workflows/ci-release-contract.yaml b/.github/workflows/ci-release-contract.yaml index ed7d2c370f..dc9bc06033 100644 --- a/.github/workflows/ci-release-contract.yaml +++ b/.github/workflows/ci-release-contract.yaml @@ -1,4 +1,4 @@ -name: CI Scope Contract +name: CI Release Contract on: workflow_dispatch: @@ -6,17 +6,25 @@ on: push: branches: [ "main" ] paths: + - '.github/actions/check-scoped-commits/**' - '.github/scope-contract.json' - '.github/scripts/scope-contract*' + - '.github/scripts/release-scope*' - '.github/workflows/ci-release-contract.yaml' - '.github/workflows/pr-title.yaml' + - 'release-notes-transform.cjs' + - '*/.releaserc.cjs' pull_request: branches: [ "main" ] paths: + - '.github/actions/check-scoped-commits/**' - '.github/scope-contract.json' - '.github/scripts/scope-contract*' + - '.github/scripts/release-scope*' - '.github/workflows/ci-release-contract.yaml' - '.github/workflows/pr-title.yaml' + - 'release-notes-transform.cjs' + - '*/.releaserc.cjs' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -35,3 +43,15 @@ jobs: - name: Check the shell adapter run: bash -n .github/scripts/scope-contract.sh + + - name: Test release matching + run: node --test .github/scripts/release-scope.test.cjs + + - name: Install Semantic Release test dependencies + run: | + npm install --no-save --no-package-lock \ + @semantic-release/commit-analyzer@11.1.0 \ + conventional-changelog-conventionalcommits@7.0.2 + + - name: Test Semantic Release integration + run: node --test .github/scripts/release-scope-semantic.test.mjs diff --git a/cli/.releaserc.cjs b/cli/.releaserc.cjs index 64aa077677..3d403eb29a 100644 --- a/cli/.releaserc.cjs +++ b/cli/.releaserc.cjs @@ -1,6 +1,10 @@ 'use strict'; const { createTransform, TYPES } = require('../release-notes-transform.cjs'); +const { + RELEASE_PARSER_OPTIONS, + createReleaseRules, +} = require('../.github/scripts/scope-contract.cjs'); module.exports = { branches: [ @@ -16,20 +20,15 @@ module.exports = { '@semantic-release/commit-analyzer', { preset: 'conventionalcommits', - releaseRules: [ - { scope: 'cli', breaking: true, release: 'major' }, - { scope: 'cli', type: 'feat', release: 'minor' }, - { scope: 'cli', type: 'fix', release: 'patch' }, - { scope: 'cli', type: 'refactor', release: 'patch' }, - { scope: 'cli', type: 'revert', release: 'patch' }, - { scope: '!(cli)', release: false }, - ], + parserOpts: RELEASE_PARSER_OPTIONS, + releaseRules: createReleaseRules('cli'), }, ], [ '@semantic-release/release-notes-generator', { preset: 'conventionalcommits', + parserOpts: RELEASE_PARSER_OPTIONS, presetConfig: { types: TYPES }, writerOpts: { transform: createTransform(['cli', 'rules', 'analyzer', 'autobuilder', 'core']), diff --git a/github/.releaserc.cjs b/github/.releaserc.cjs index 744690472a..8986ddc199 100644 --- a/github/.releaserc.cjs +++ b/github/.releaserc.cjs @@ -1,6 +1,10 @@ 'use strict'; const { createTransform, TYPES } = require('../release-notes-transform.cjs'); +const { + RELEASE_PARSER_OPTIONS, + createReleaseRules, +} = require('../.github/scripts/scope-contract.cjs'); module.exports = { tagFormat: 'github/v${version}', @@ -17,20 +21,15 @@ module.exports = { '@semantic-release/commit-analyzer', { preset: 'conventionalcommits', - releaseRules: [ - { scope: 'github', breaking: true, release: 'major' }, - { scope: 'github', type: 'feat', release: 'minor' }, - { scope: 'github', type: 'fix', release: 'patch' }, - { scope: 'github', type: 'refactor', release: 'patch' }, - { scope: 'github', type: 'revert', release: 'patch' }, - { scope: '!(github)', release: false }, - ], + parserOpts: RELEASE_PARSER_OPTIONS, + releaseRules: createReleaseRules('github'), }, ], [ '@semantic-release/release-notes-generator', { preset: 'conventionalcommits', + parserOpts: RELEASE_PARSER_OPTIONS, presetConfig: { types: TYPES }, writerOpts: { transform: createTransform(['github']), diff --git a/gitlab/.releaserc.cjs b/gitlab/.releaserc.cjs index 4d9070c3b3..6a81e0bbb1 100644 --- a/gitlab/.releaserc.cjs +++ b/gitlab/.releaserc.cjs @@ -1,6 +1,10 @@ 'use strict'; const { createTransform, TYPES } = require('../release-notes-transform.cjs'); +const { + RELEASE_PARSER_OPTIONS, + createReleaseRules, +} = require('../.github/scripts/scope-contract.cjs'); module.exports = { tagFormat: 'gitlab/v${version}', @@ -17,20 +21,15 @@ module.exports = { '@semantic-release/commit-analyzer', { preset: 'conventionalcommits', - releaseRules: [ - { scope: 'gitlab', breaking: true, release: 'major' }, - { scope: 'gitlab', type: 'feat', release: 'minor' }, - { scope: 'gitlab', type: 'fix', release: 'patch' }, - { scope: 'gitlab', type: 'refactor', release: 'patch' }, - { scope: 'gitlab', type: 'revert', release: 'patch' }, - { scope: '!(gitlab)', release: false }, - ], + parserOpts: RELEASE_PARSER_OPTIONS, + releaseRules: createReleaseRules('gitlab'), }, ], [ '@semantic-release/release-notes-generator', { preset: 'conventionalcommits', + parserOpts: RELEASE_PARSER_OPTIONS, presetConfig: { types: TYPES }, writerOpts: { transform: createTransform(['gitlab']), diff --git a/release-notes-transform.cjs b/release-notes-transform.cjs index a2d1710fe8..1d19e6e869 100644 --- a/release-notes-transform.cjs +++ b/release-notes-transform.cjs @@ -1,5 +1,7 @@ 'use strict'; +const { hasAnyScope } = require('./.github/scripts/scope-contract.cjs'); + const TYPES = [ { type: 'chore', hidden: true }, { type: 'feat', section: ':gift: Features', hidden: false }, @@ -11,11 +13,10 @@ const TYPES = [ ]; function createTransform(allowedScopes) { - const scopeSet = new Set(allowedScopes); const typeMap = new Map(TYPES.map(t => [t.type, t])); return (commit, context) => { - if (!scopeSet.has(commit.scope)) return null; + if (!hasAnyScope(commit.scope, allowedScopes)) return null; let discard = true; const issues = []; diff --git a/rules/.releaserc.cjs b/rules/.releaserc.cjs index 17a634c444..c9101b214f 100644 --- a/rules/.releaserc.cjs +++ b/rules/.releaserc.cjs @@ -1,6 +1,10 @@ 'use strict'; const { createTransform, TYPES } = require('../release-notes-transform.cjs'); +const { + RELEASE_PARSER_OPTIONS, + createReleaseRules, +} = require('../.github/scripts/scope-contract.cjs'); module.exports = { tagFormat: 'rules/v${version}', @@ -17,20 +21,15 @@ module.exports = { '@semantic-release/commit-analyzer', { preset: 'conventionalcommits', - releaseRules: [ - { scope: 'rules', breaking: true, release: 'major' }, - { scope: 'rules', type: 'feat', release: 'minor' }, - { scope: 'rules', type: 'fix', release: 'patch' }, - { scope: 'rules', type: 'refactor', release: 'patch' }, - { scope: 'rules', type: 'revert', release: 'patch' }, - { scope: '!(rules)', release: false }, - ], + parserOpts: RELEASE_PARSER_OPTIONS, + releaseRules: createReleaseRules('rules'), }, ], [ '@semantic-release/release-notes-generator', { preset: 'conventionalcommits', + parserOpts: RELEASE_PARSER_OPTIONS, presetConfig: { types: TYPES }, writerOpts: { transform: createTransform(['rules']),