| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- import { describe, expect, test } from 'bun:test'
- import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import {
- classifyChangedPaths,
- formatGitHubOutput,
- parseChangedPaths,
- } from './detect-pr-changes'
- const NO_CHANGES = {
- 'has-evals': false,
- 'has-docs': false,
- 'has-workflows': false,
- 'has-packages': false,
- 'has-canonical': false,
- }
- const SCRIPT_PATH = join(import.meta.dir, 'detect-pr-changes.ts')
- const WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'pr-checks.yml')
- const PACKAGES_WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'packages-checks.yml')
- const DRIFT_SCRIPT_PATH = join(import.meta.dir, 'check-build-drift.sh')
- type CliResult = {
- exitCode: number
- stderr: string
- stdout: string
- }
- async function runCli(input: string, outputPath?: string): Promise<CliResult> {
- const env = { ...process.env }
- if (outputPath === undefined) delete env.GITHUB_OUTPUT
- else env.GITHUB_OUTPUT = outputPath
- const subprocess = Bun.spawn([process.execPath, 'run', SCRIPT_PATH], {
- env,
- stderr: 'pipe',
- stdin: new Blob([input]),
- stdout: 'pipe',
- })
- const [exitCode, stderr, stdout] = await Promise.all([
- subprocess.exited,
- new Response(subprocess.stderr).text(),
- new Response(subprocess.stdout).text(),
- ])
- return { exitCode, stderr, stdout }
- }
- async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
- const directory = await mkdtemp(join(tmpdir(), 'detect-pr-changes-'))
- try {
- return await run(directory)
- } finally {
- await rm(directory, { force: true, recursive: true })
- }
- }
- describe('classifyChangedPaths', () => {
- test('detects eval changes', () => {
- expect(classifyChangedPaths(['evals/framework/src/index.ts'])).toEqual({
- ...NO_CHANGES,
- 'has-evals': true,
- })
- })
- test('detects docs changes', () => {
- expect(classifyChangedPaths(['docs/maintenance/guide.md'])).toEqual({
- ...NO_CHANGES,
- 'has-docs': true,
- })
- })
- test('detects workflow changes', () => {
- expect(classifyChangedPaths(['.github/workflows/pr-checks.yml'])).toEqual({
- ...NO_CHANGES,
- 'has-workflows': true,
- })
- })
- test('detects packages changes', () => {
- expect(classifyChangedPaths(['packages/cli/src/index.ts'])).toEqual({
- ...NO_CHANGES,
- 'has-packages': true,
- 'has-canonical': true,
- })
- expect(classifyChangedPaths(['packages/compatibility-layer/package.json'])).toEqual({
- ...NO_CHANGES,
- 'has-packages': true,
- 'has-canonical': true,
- })
- })
- test('detects shared workspace dependency changes', () => {
- for (const path of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
- expect(classifyChangedPaths([path])).toEqual({
- ...NO_CHANGES,
- 'has-evals': true,
- 'has-packages': true,
- 'has-canonical': true,
- })
- }
- })
- test('detects package automation changes', () => {
- for (const path of [
- '.github/dependabot.yml',
- '.github/workflows/packages-checks.yml',
- 'scripts/validation/detect-pr-changes.ts',
- 'scripts/validation/detect-pr-changes.test.ts',
- ]) {
- expect(classifyChangedPaths([path])).toEqual({
- ...NO_CHANGES,
- 'has-packages': true,
- ...(path.startsWith('.github/workflows/') ? { 'has-workflows': true } : {}),
- ...(path === '.github/workflows/packages-checks.yml' ? { 'has-canonical': true } : {}),
- })
- }
- })
- test('routes canonical source changes to the drift gate', () => {
- for (const path of ['content/agents/core/openagent.md', 'content/profiles/default.yaml']) {
- expect(classifyChangedPaths([path])).toEqual({
- ...NO_CHANGES,
- 'has-canonical': true,
- })
- }
- })
- test('routes generated-tree changes to the drift gate', () => {
- // A PR that hand-edits generated output touches no packages/** path at all. If these did
- // not set has-canonical, the gate would skip exactly the change it exists to catch.
- for (const path of [
- '.opencode/agent/core/openagent.md',
- '.oac/build-manifest.json',
- 'registry.json',
- ]) {
- expect(classifyChangedPaths([path])).toEqual({
- ...NO_CHANGES,
- 'has-canonical': true,
- })
- }
- })
- test('routes drift-gate machinery changes to the drift gate', () => {
- for (const path of ['Makefile', 'scripts/validation/check-build-drift.sh']) {
- expect(classifyChangedPaths([path])).toEqual({
- ...NO_CHANGES,
- 'has-canonical': true,
- })
- }
- })
- test('rejects near-prefix paths for canonical routing', () => {
- expect(
- classifyChangedPaths([
- 'content-old/agents/x.md',
- 'nested/content/agents/x.md',
- 'contents/x.md',
- '.opencode/command/add-context.md',
- 'registry.json.bak',
- 'Makefile.old',
- ]),
- ).toEqual(NO_CHANGES)
- })
- test('detects mixed changes', () => {
- expect(
- classifyChangedPaths([
- 'evals/framework/package.json',
- 'docs/README.md',
- '.github/workflows/release.yml',
- 'packages/cli/package.json',
- 'src/index.ts',
- ]),
- ).toEqual({
- 'has-evals': true,
- 'has-docs': true,
- 'has-workflows': true,
- 'has-packages': true,
- 'has-canonical': true,
- })
- })
- test('returns false for every category when paths do not match', () => {
- expect(classifyChangedPaths(['README.md', 'scripts/check.ts'])).toEqual(NO_CHANGES)
- })
- test('returns false for every category when input is empty', () => {
- expect(classifyChangedPaths([])).toEqual(NO_CHANGES)
- })
- test('rejects near-prefix paths', () => {
- expect(
- classifyChangedPaths([
- 'evals-old/test.ts',
- 'docs.md',
- '.github/workflows-old/check.yml',
- 'nested/evals/test.ts',
- 'packages-old/cli/index.ts',
- 'packages.json',
- 'nested/packages/cli/index.ts',
- ]),
- ).toEqual(NO_CHANGES)
- })
- })
- describe('parseChangedPaths', () => {
- test('preserves path identity and removes empty NUL records', () => {
- expect(parseChangedPaths(' evals/test.ts \0\0docs/雪\n$HOME; file.md\0')).toEqual([
- ' evals/test.ts ',
- 'docs/雪\n$HOME; file.md',
- ])
- })
- })
- describe('formatGitHubOutput', () => {
- test('formats newline-delimited GitHub outputs', () => {
- expect(
- formatGitHubOutput({
- 'has-evals': true,
- 'has-docs': false,
- 'has-workflows': true,
- 'has-packages': false,
- 'has-canonical': false,
- }),
- ).toBe(
- 'has-evals=true\nhas-docs=false\nhas-workflows=true\nhas-packages=false\n' +
- 'has-canonical=false\n',
- )
- })
- })
- describe('CLI', () => {
- test('appends outputs to GITHUB_OUTPUT', async () => {
- await withTempDir(async (directory) => {
- const outputPath = join(directory, 'github-output')
- await writeFile(outputPath, 'existing=value\n')
- const result = await runCli('evals/test.ts\0', outputPath)
- expect(result).toEqual({ exitCode: 0, stderr: '', stdout: '' })
- expect(await readFile(outputPath, 'utf8')).toBe(
- 'existing=value\nhas-evals=true\nhas-docs=false\nhas-workflows=false\n' +
- 'has-packages=false\nhas-canonical=false\n',
- )
- })
- })
- test('writes false outputs for empty stdin', async () => {
- await withTempDir(async (directory) => {
- const outputPath = join(directory, 'github-output')
- const result = await runCli('', outputPath)
- expect(result.exitCode).toBe(0)
- expect(await readFile(outputPath, 'utf8')).toBe(
- 'has-evals=false\nhas-docs=false\nhas-workflows=false\nhas-packages=false\n' +
- 'has-canonical=false\n',
- )
- })
- })
- test('fails clearly when GITHUB_OUTPUT is missing', async () => {
- const result = await runCli('evals/test.ts\0')
- expect(result.exitCode).not.toBe(0)
- expect(result.stderr).toContain('GITHUB_OUTPUT is required but was not set')
- })
- test('writes an error to stderr and exits nonzero for an unwritable target', async () => {
- await withTempDir(async (directory) => {
- const outputPath = join(directory, 'output-directory')
- await mkdir(outputPath)
- const result = await runCli('docs/guide.md\0', outputPath)
- expect(result.exitCode).not.toBe(0)
- expect(result.stderr).toContain('Unable to append change detection outputs to GITHUB_OUTPUT')
- })
- })
- test('handles NUL-delimited Unicode and metacharacter filenames without changing identity', async () => {
- await withTempDir(async (directory) => {
- const outputPath = join(directory, 'github-output')
- const paths = [
- 'evals/ leading and trailing .ts ',
- 'docs/雪\n$HOME;$(touch never).md',
- '.github/workflows/[check]& weird.yml',
- 'packages/cli/src/ spaced 雪.ts',
- ]
- const result = await runCli(`${paths.join('\0')}\0`, outputPath)
- expect(result.exitCode).toBe(0)
- expect(await readFile(outputPath, 'utf8')).toBe(
- 'has-evals=true\nhas-docs=true\nhas-workflows=true\nhas-packages=true\n' +
- 'has-canonical=true\n',
- )
- })
- })
- test('does not trim leading whitespace into a matching path', async () => {
- await withTempDir(async (directory) => {
- const outputPath = join(directory, 'github-output')
- const result = await runCli(' evals/not-under-evals.ts\0', outputPath)
- expect(result.exitCode).toBe(0)
- expect(await readFile(outputPath, 'utf8')).toContain('has-evals=false\n')
- })
- })
- })
- describe('PR checks workflow contract', () => {
- test('uses the NUL-delimited detector and exact output names', async () => {
- const workflow = await readFile(WORKFLOW_PATH, 'utf8')
- expect(workflow).toContain('git diff --name-only -z')
- expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
- expect(workflow).toContain('has-evals: ${{ steps.filter.outputs.has-evals }}')
- expect(workflow).toContain('has-docs: ${{ steps.filter.outputs.has-docs }}')
- expect(workflow).toContain('has-workflows: ${{ steps.filter.outputs.has-workflows }}')
- expect(workflow).not.toMatch(/steps\.filter\.outputs\.(evals|docs|workflows)(?:\s|})/)
- expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
- expect(workflow).toContain('bun-version: 1.3.14')
- })
- test('requires successful change detection before reporting overall success', async () => {
- const workflow = await readFile(WORKFLOW_PATH, 'utf8')
- const overallStatus = workflow.slice(workflow.indexOf('# Overall status'))
- expect(overallStatus).toContain('needs.check-changes.result }}" == "success"')
- expect(overallStatus).toContain('needs.check-changes.outputs.has-evals }}" != "true"')
- expect(overallStatus).toContain('exit 1')
- })
- })
- describe('Packages checks workflow contract', () => {
- test('uses the NUL-delimited detector and the has-packages output', async () => {
- const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
- expect(workflow).toContain('git diff --name-only -z')
- expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
- expect(workflow).toContain('has-packages: ${{ steps.filter.outputs.has-packages }}')
- expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
- expect(workflow).toContain('bun-version: 1.3.14')
- })
- test('gates every package check job on the has-packages flag', async () => {
- const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
- expect(workflow).toContain('cli-checks:')
- expect(workflow).toContain('compatibility-layer-checks:')
- const gates = workflow.match(/needs\.check-changes\.outputs\.has-packages == 'true'/g) ?? []
- expect(gates.length).toBeGreaterThanOrEqual(2)
- })
- test('triggers on packages/** pull request changes', async () => {
- const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
- expect(workflow).toContain('pull_request:')
- expect(workflow).toContain("- 'packages/**'")
- })
- })
- describe('Canonical drift gate contract', () => {
- test('triggers on the canonical source and the generated trees', async () => {
- const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
- for (const path of [
- "- 'content/**'",
- "- '.opencode/agent/**'",
- "- '.oac/**'",
- "- 'registry.json'",
- "- 'Makefile'",
- "- 'scripts/validation/check-build-drift.sh'",
- ]) {
- expect(workflow).toContain(path)
- }
- })
- test('gates the drift job on has-canonical and runs the same make target as contributors', async () => {
- const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
- expect(workflow).toContain('canonical-drift:')
- expect(workflow).toContain('has-canonical: ${{ steps.filter.outputs.has-canonical }}')
- expect(workflow).toContain("needs.check-changes.outputs.has-canonical == 'true'")
- expect(workflow).toContain('run: make check-canonical-drift')
- })
- test('is blocking — never continue-on-error, and the summary fails on it', async () => {
- const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
- // A gate that cannot fail the PR reads as coverage while providing none. Match the YAML
- // key rather than the bare phrase, which also appears in the job's own comment.
- expect(workflow).not.toMatch(/^\s*continue-on-error:\s*true/m)
- expect(workflow).toContain('needs: [check-changes, cli-checks, compatibility-layer-checks, canonical-drift]')
- expect(workflow).toContain('"${{ needs.canonical-drift.result }}"')
- const summary = workflow.slice(workflow.indexOf(' summary:'))
- expect(summary).toContain('exit 1')
- })
- test('the drift script rebuilds and compares the committed generated trees', async () => {
- const script = await readFile(DRIFT_SCRIPT_PATH, 'utf8')
- // A write build, not `--check`: check() never compares the manifest, which is committed.
- expect(script).toContain('bun packages/cli/dist/index.js build')
- expect(script).toContain('.oac/build-manifest.json')
- expect(script).toContain('git status --porcelain')
- expect(script).toContain('exit 1')
- // plugins/claude-code/** is staged and compared, never emitted — not this gate's business.
- expect(script).not.toContain('plugins/claude-code/agents)')
- })
- })
|