From cd5b85d8268ea049cfa6dd988d664014e1438d57 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 08:53:38 -0700 Subject: [PATCH 1/3] refactor(ci-scope): replace classifier with thin shim over nx affected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of 3 for the ci-scope thin-shim migration. PR 1 (#503) added scope:* tags to 87 project.json files; this PR makes ci-scope.mjs actually read them via `nx show projects --affected`. apps/cockpit + apps/website: namedInputs.deploymentConfig - Per-project namedInputs declare which non-project files affect each project: vercel.*.json, scripts/deploy-smoke.ts, capability-registry.ts, etc. on apps/cockpit; vercel.json on apps/website. - Referenced from each project's build target inputs so nx considers them when computing affected. - Replaces ~50 LOC of applyFallbackPathScope rules with Nx-native data. - Smoke-verified: `nx show projects --affected` correctly marks `cockpit` as affected when vercel.cockpit.json changes. scripts/ci-scope.mjs: 340 LOC → 120 LOC. - applyProjectScope (~80 LOC of project-walking rules) → replaced by ~10-LOC tag-driven classifyFromAffected reading from `nx show projects --affected --json`. - applyFallbackPathScope (~50 LOC of file-path rules) → replaced by the namedInputs above. - discoverProjects walk (~30 LOC of filesystem traversal) → gone. - GLOBAL_CI_FILES short-circuit (.github/workflows/ci.yml etc.) kept. - Backward-compatible CLI: --base/--head/--output/--event=push. scripts/ci-scope.spec.mjs: full rewrite. - Tests inject synthetic affectedProjects arrays with tag arrays directly (no more synthetic workspace+ownsPath fixtures). - 13 tests across 5 describe blocks: short-circuit, publishable lib broadcast, cockpit cap angular/python, fallback paths via namedInputs, tag isolation (non-scope tags + unknown scope keys ignored). Backward-compatible workflow gating: same scope booleans emit for the same file changes. Jobs still truly skip (no per-job fast-pass cost). This PR's own CI run is the integration test. DO NOT admin-merge — if the new shim misclassifies, its own gates fire wrong. Verify scope output in the CI scope job's logs before merging. See docs/superpowers/specs/2026-05-21-ci-scope-thin-shim-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cockpit/project.json | 22 ++- apps/website/project.json | 12 +- scripts/ci-scope.mjs | 353 +++++++++----------------------------- scripts/ci-scope.spec.mjs | 288 +++++++++++++++---------------- 4 files changed, 243 insertions(+), 432 deletions(-) diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index a4ca73ff1..82e80704b 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -27,7 +27,12 @@ "production": { "outputPath": "dist/apps/cockpit" } - } + }, + "inputs": [ + "default", + "deploymentConfig", + "^default" + ] }, "serve": { "executor": "@nx/next:server", @@ -215,5 +220,20 @@ "cwd": "." } } + }, + "namedInputs": { + "deploymentConfig": [ + "{workspaceRoot}/vercel.cockpit.json", + "{workspaceRoot}/vercel.examples.json", + "{workspaceRoot}/vercel.demo.json", + "{workspaceRoot}/scripts/assemble-demo.ts", + "{workspaceRoot}/scripts/assemble-examples.ts", + "{workspaceRoot}/scripts/demo-middleware.ts", + "{workspaceRoot}/scripts/langgraph-proxy.ts", + "{workspaceRoot}/scripts/rate-limit.ts", + "{workspaceRoot}/apps/cockpit/scripts/deploy-smoke.ts", + "{workspaceRoot}/scripts/generate-shared-deployment-config.ts", + "{workspaceRoot}/apps/cockpit/scripts/capability-registry.ts" + ] } } diff --git a/apps/website/project.json b/apps/website/project.json index 6e1a140a2..af8c7f438 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -25,7 +25,12 @@ "production": { "outputPath": "dist/apps/website" } - } + }, + "inputs": [ + "default", + "deploymentConfig", + "^default" + ] }, "serve": { "executor": "@nx/next:server", @@ -64,5 +69,10 @@ "config": "apps/website/playwright.config.ts" } } + }, + "namedInputs": { + "deploymentConfig": [ + "{workspaceRoot}/vercel.json" + ] } } diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 889e230fd..5dab8d1ef 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -1,305 +1,108 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process'; -import { appendFileSync, existsSync, readdirSync, readFileSync } from 'node:fs'; +import { appendFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; export const SCOPE_KEYS = [ - 'library', - 'website', - 'cockpit', - 'cockpit_examples', - 'cockpit_smoke', - 'cockpit_secret', - 'cockpit_deploy_smoke', - 'examples_chat', - 'cockpit_e2e', - 'website_e2e', - 'posthog', + 'library', 'website', 'website_e2e', + 'cockpit', 'cockpit_examples', 'cockpit_smoke', + 'cockpit_secret', 'cockpit_deploy_smoke', 'cockpit_e2e', + 'examples_chat', 'posthog', ]; -const PROJECT_SKIP_DIRS = new Set([ - '.git', - '.next', - '.nx', - 'coverage', - 'dist', - 'node_modules', +const GLOBAL_CI_FILES = new Set([ + '.github/workflows/ci.yml', + 'package.json', + 'package-lock.json', + 'nx.json', + 'tsconfig.json', + 'tsconfig.base.json', + 'eslint.config.mjs', ]); export function emptyScope() { - return Object.fromEntries(SCOPE_KEYS.map((key) => [key, false])); + return Object.fromEntries(SCOPE_KEYS.map((k) => [k, false])); } export function fullScope() { - return Object.fromEntries(SCOPE_KEYS.map((key) => [key, true])); + return Object.fromEntries(SCOPE_KEYS.map((k) => [k, true])); } -export function classifyChangedFiles(changedFiles, workspace) { - const scope = emptyScope(); - const projects = workspace.projects ?? []; - const publishableProjects = new Set(workspace.publishableProjects ?? []); - - for (const changedFile of changedFiles.map(normalizePath).filter(Boolean)) { - if (isGlobalCiFile(changedFile)) { - return fullScope(); - } - - const owningProjects = projects.filter((project) => ownsPath(project, changedFile)); - - for (const project of owningProjects) { - applyProjectScope(scope, project, publishableProjects); - } - - applyFallbackPathScope(scope, changedFile); - } - - return scope; -} - -export function loadWorkspaceMetadata(workspaceRoot) { - return { - projects: discoverProjects(workspaceRoot), - publishableProjects: loadPublishableProjects(workspaceRoot), - }; -} - -export function discoverProjects(workspaceRoot) { - const projects = []; - - walk(workspaceRoot, (absolutePath) => { - const projectJson = path.join(absolutePath, 'project.json'); - if (!existsSync(projectJson)) { - return; - } - - const project = JSON.parse(readFileSync(projectJson, 'utf8')); - const root = normalizePath(path.relative(workspaceRoot, absolutePath)); - projects.push({ - ...project, - root, - sourceRoot: normalizePath(project.sourceRoot ?? root), - tags: Array.isArray(project.tags) ? project.tags : [], - targets: project.targets ?? {}, - }); - }); - - return projects.sort((a, b) => b.root.length - a.root.length); -} - -function walk(directory, visit) { - visit(directory); - - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (!entry.isDirectory() || PROJECT_SKIP_DIRS.has(entry.name)) { - continue; - } - - walk(path.join(directory, entry.name), visit); - } +function normalizePath(value) { + return String(value ?? '').replaceAll(path.sep, '/').replace(/^\.\//, '').replace(/\/+$/, ''); } -function loadPublishableProjects(workspaceRoot) { - const nxJsonPath = path.join(workspaceRoot, 'nx.json'); - if (!existsSync(nxJsonPath)) { - return []; - } - - const nxJson = JSON.parse(readFileSync(nxJsonPath, 'utf8')); - return nxJson.release?.groups?.publishable?.projects ?? []; +function tagToScopeKey(tag) { + // 'scope:cockpit-e2e' → 'cockpit_e2e' + return tag.replace(/^scope:/, '').replaceAll('-', '_'); } -function applyProjectScope(scope, project, publishableProjects) { - const tags = new Set(project.tags ?? []); - const targets = project.targets ?? {}; - const root = project.root ?? ''; - const name = project.name ?? ''; - - if (publishableProjects.has(name)) { - scope.library = true; - scope.website = true; - scope.website_e2e = true; - scope.cockpit = true; - scope.cockpit_examples = true; - scope.cockpit_smoke = true; - scope.cockpit_secret = true; - scope.cockpit_deploy_smoke = true; - scope.examples_chat = true; - scope.cockpit_e2e = true; - } - - if (tags.has('scope:website') || name === 'website' || root === 'apps/website') { - scope.website = true; - scope.website_e2e = true; - } - - if (tags.has('scope:cockpit') || name === 'cockpit' || root === 'apps/cockpit') { - scope.cockpit = true; - scope.cockpit_examples = true; - scope.cockpit_deploy_smoke = true; - scope.cockpit_e2e = true; - } - - if (tags.has('scope:examples') || root === 'examples/chat' || root.startsWith('examples/chat/')) { - scope.examples_chat = true; +/** + * Pure-function classifier. + * + * @param {string[]} changedFiles - normalized repo-relative paths + * @param {Array<{name: string, tags: string[]}>} affectedProjects + * — projects nx considers affected, with their tags + * @returns {Record} scope booleans keyed by SCOPE_KEYS + */ +export function classifyFromAffected(changedFiles, affectedProjects) { + for (const f of changedFiles) { + if (GLOBAL_CI_FILES.has(f)) return fullScope(); } - - if (tags.has('scope:gtm') || name === 'posthog-tools' || root === 'tools/posthog') { - scope.posthog = true; - } - - if (root.startsWith('cockpit/')) { - if (root.includes('/angular') || project.projectType === 'application') { - scope.cockpit_examples = true; - } - - if (targets.smoke && root.includes('/python')) { - scope.cockpit_smoke = true; - } - - if (targets.integration) { - scope.cockpit_secret = true; - } - - if (targets.e2e) { - scope.cockpit_e2e = true; - } - - // Per-cap python changes affect the sibling angular's e2e + build. - // (Python project.jsons don't carry e2e/build-all targets; those live - // on the sibling angular project. This rule bridges that gap.) - if (root.includes('/python')) { - scope.cockpit_examples = true; - scope.cockpit_e2e = true; - } - } - - if ( - root.startsWith('libs/cockpit-') || - root === 'libs/design-tokens' || - root === 'libs/ui-react' || - root === 'libs/example-layouts' || - root === 'libs/e2e-harness' - ) { - scope.cockpit = true; - scope.cockpit_examples = true; - scope.cockpit_deploy_smoke = true; - scope.cockpit_e2e = true; - } - - if (root === 'libs/e2e-harness' || root === 'libs/cockpit-testing') { - scope.cockpit_e2e = true; - } -} - -function applyFallbackPathScope(scope, changedFile) { - if (changedFile === 'vercel.json') { - scope.website = true; - scope.website_e2e = true; - } - - if (changedFile === 'vercel.cockpit.json') { - scope.cockpit = true; - scope.cockpit_deploy_smoke = true; - } - - if (changedFile === 'vercel.examples.json' || changedFile === 'scripts/assemble-examples.ts') { - scope.cockpit_examples = true; - } - - if (changedFile === 'apps/cockpit/scripts/deploy-smoke.ts' || changedFile === 'scripts/deploy-smoke.ts') { - scope.cockpit_deploy_smoke = true; - } - - if ( - changedFile === 'vercel.demo.json' || - changedFile === 'scripts/assemble-demo.ts' || - changedFile === 'scripts/demo-middleware.ts' || - changedFile === 'scripts/langgraph-proxy.ts' || - changedFile === 'scripts/rate-limit.ts' - ) { - scope.examples_chat = true; - } - - if (changedFile.startsWith('tools/posthog/')) { - scope.posthog = true; - } - - if ( - changedFile === 'scripts/generate-shared-deployment-config.ts' || - changedFile === 'apps/cockpit/scripts/capability-registry.ts' - ) { - scope.cockpit = true; - scope.cockpit_examples = true; - scope.cockpit_smoke = true; - scope.cockpit_e2e = true; - scope.cockpit_deploy_smoke = true; - } -} - -function isGlobalCiFile(changedFile) { - return ( - changedFile === '.github/workflows/ci.yml' || - changedFile === 'package.json' || - changedFile === 'package-lock.json' || - changedFile === 'nx.json' || - changedFile === 'tsconfig.json' || - changedFile === 'tsconfig.base.json' || - changedFile === 'eslint.config.mjs' - ); -} - -function ownsPath(project, changedFile) { - const root = normalizePath(project.root); - const sourceRoot = normalizePath(project.sourceRoot); - - return ( - changedFile === `${root}/project.json` || - changedFile === root || - changedFile.startsWith(`${root}/`) || - changedFile === sourceRoot || - changedFile.startsWith(`${sourceRoot}/`) - ); -} - -function normalizePath(value) { - return String(value ?? '').replaceAll(path.sep, '/').replace(/^\.\//, '').replace(/\/+$/, ''); -} - -function parseArgs(argv) { - const args = {}; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (!arg.startsWith('--')) { - continue; + const scope = emptyScope(); + for (const project of affectedProjects) { + for (const tag of project.tags ?? []) { + if (!tag.startsWith('scope:')) continue; + const key = tagToScopeKey(tag); + if (SCOPE_KEYS.includes(key)) scope[key] = true; } - - args[arg.slice(2)] = argv[index + 1]; - index += 1; } - return args; + return scope; } -function changedFilesBetween(baseSha, headSha, workspaceRoot) { - return execFileSync('git', ['diff', '--name-only', baseSha, headSha], { +function changedFilesBetween(base, head, workspaceRoot) { + return execFileSync('git', ['diff', '--name-only', base, head], { cwd: workspaceRoot, encoding: 'utf8', }) .split('\n') - .map((line) => line.trim()) + .map((line) => normalizePath(line.trim())) .filter(Boolean); } -function writeOutputs(scope, outputPath) { - const lines = SCOPE_KEYS.map((key) => `${key}=${scope[key] ? 'true' : 'false'}`); +function loadAffectedProjects(base, head, workspaceRoot) { + const namesJson = execFileSync('npx', [ + 'nx', 'show', 'projects', + '--affected', + '--base', base, '--head', head, + '--json', + ], { cwd: workspaceRoot, encoding: 'utf8' }); + const names = JSON.parse(namesJson); + return names.map((name) => { + const projectJson = execFileSync('npx', [ + 'nx', 'show', 'project', name, '--json', + ], { cwd: workspaceRoot, encoding: 'utf8' }); + const project = JSON.parse(projectJson); + return { name: project.name ?? name, tags: project.tags ?? [] }; + }); +} - if (outputPath) { - appendFileSync(outputPath, `${lines.join('\n')}\n`); - } +function writeOutputs(scope, outputPath) { + const lines = SCOPE_KEYS.map((k) => `${k}=${scope[k] ? 'true' : 'false'}`); + if (outputPath) appendFileSync(outputPath, `${lines.join('\n')}\n`); + for (const line of lines) console.log(line); +} - for (const line of lines) { - console.log(line); +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith('--')) continue; + args[a.slice(2)] = argv[i + 1]; + i++; } + return args; } function main() { @@ -312,20 +115,18 @@ function main() { return; } - const base = args.base; - const head = args.head; - if (!base || !head) { + if (!args.base || !args.head) { throw new Error('Expected --base and --head for pull request scope detection.'); } - const changedFiles = changedFilesBetween(base, head, workspaceRoot); - const workspace = loadWorkspaceMetadata(workspaceRoot); - const scope = classifyChangedFiles(changedFiles, workspace); + const changedFiles = changedFilesBetween(args.base, args.head, workspaceRoot); + const affectedProjects = loadAffectedProjects(args.base, args.head, workspaceRoot); + const scope = classifyFromAffected(changedFiles, affectedProjects); console.log('Changed files:'); - for (const changedFile of changedFiles) { - console.log(` ${changedFile}`); - } + for (const f of changedFiles) console.log(` ${f}`); + console.log(`Affected projects (${affectedProjects.length}):`); + for (const p of affectedProjects) console.log(` ${p.name} [${p.tags.join(', ')}]`); writeOutputs(scope, args.output); } diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 1a50d61c1..2aaead53b 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -1,166 +1,146 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { classifyChangedFiles } from './ci-scope.mjs'; - -const projects = [ - { - name: 'chat', - root: 'libs/chat', - sourceRoot: 'libs/chat/src', - projectType: 'library', - tags: [], - targets: { build: {}, lint: {}, test: {} }, - }, - { - name: 'website', - root: 'apps/website', - sourceRoot: 'apps/website/src', - projectType: 'application', - tags: ['scope:website', 'type:app'], - targets: { build: {}, e2e: {}, lint: {} }, - }, - { - name: 'cockpit-new-cap-angular', - root: 'cockpit/new/cap/angular', - sourceRoot: 'cockpit/new/cap/angular/src', - projectType: 'application', - tags: [], - targets: { build: {}, e2e: {}, smoke: {} }, - }, - { - name: 'cockpit-new-cap-python', - root: 'cockpit/new/cap/python', - sourceRoot: 'cockpit/new/cap/python/src', - projectType: 'library', - tags: [], - targets: { build: {}, smoke: {}, integration: {} }, - }, - { - name: 'posthog-tools', - root: 'tools/posthog', - sourceRoot: 'tools/posthog', - projectType: 'library', - tags: ['scope:gtm', 'type:tool'], - targets: { 'sync:plan': {}, 'quality:live': {} }, - }, - { - name: 'e2e-harness', - root: 'libs/e2e-harness', - sourceRoot: 'libs/e2e-harness/src', - projectType: 'library', - tags: [], - targets: { build: {} }, - }, +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { + classifyFromAffected, + emptyScope, + fullScope, + SCOPE_KEYS, +} from './ci-scope.mjs'; + +const PUBLISHABLE_LIB_TAGS = [ + 'scope:library', 'scope:website', 'scope:website-e2e', + 'scope:cockpit', 'scope:cockpit-examples', 'scope:cockpit-smoke', + 'scope:cockpit-secret', 'scope:cockpit-deploy-smoke', + 'scope:cockpit-e2e', 'scope:examples-chat', ]; - -const workspace = { - projects, - publishableProjects: ['chat'], -}; - -test('publishable library changes fan out to dependent product checks', () => { - const scope = classifyChangedFiles(['libs/chat/src/public-api.ts'], workspace); - - assert.equal(scope.library, true); - assert.equal(scope.website, true); - assert.equal(scope.website_e2e, true); - assert.equal(scope.cockpit, true); - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_smoke, true); - assert.equal(scope.cockpit_secret, true); - assert.equal(scope.cockpit_deploy_smoke, true); - assert.equal(scope.examples_chat, true); - assert.equal(scope.cockpit_e2e, true); - assert.equal(scope.posthog, false); -}); - -test('cockpit angular project metadata drives examples and e2e scope', () => { - const scope = classifyChangedFiles(['cockpit/new/cap/angular/src/app/app.ts'], workspace); - - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_e2e, true); - assert.equal(scope.cockpit_smoke, false); -}); - -test('cockpit python project changes trigger smoke + secret + examples + e2e', () => { - const scope = classifyChangedFiles(['cockpit/new/cap/python/src/index.ts'], workspace); - assert.equal(scope.cockpit_smoke, true); - assert.equal(scope.cockpit_secret, true); - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_e2e, true); +const COCKPIT_CAP_ANGULAR_TAGS = ['scope:cockpit-examples', 'scope:cockpit-e2e']; +const COCKPIT_CAP_PYTHON_TAGS = ['scope:cockpit-examples', 'scope:cockpit-e2e', 'scope:cockpit-smoke']; +const WEBSITE_TAGS = ['scope:website', 'scope:website-e2e']; +const COCKPIT_APP_TAGS = ['scope:cockpit', 'scope:cockpit-examples', 'scope:cockpit-deploy-smoke', 'scope:cockpit-e2e']; +const EXAMPLES_CHAT_TAGS = ['scope:examples-chat']; +const POSTHOG_TAGS = ['scope:posthog']; + +describe('classifyFromAffected — short-circuit', () => { + it('returns full scope when a global CI file changes', () => { + const scope = classifyFromAffected(['.github/workflows/ci.yml'], []); + expect(scope).toEqual(fullScope()); + }); + + it('full scope on package.json change', () => { + expect(classifyFromAffected(['package.json'], [])).toEqual(fullScope()); + }); + + it('empty scope when no global file + no affected projects', () => { + expect(classifyFromAffected(['docs/some-readme.md'], [])).toEqual(emptyScope()); + }); }); -test('PostHog project metadata drives PostHog scope', () => { - const scope = classifyChangedFiles(['tools/posthog/live-quality.ts'], workspace); - - assert.equal(scope.posthog, true); - assert.equal(scope.library, false); - assert.equal(scope.website, false); -}); - -test('shared cockpit support libraries preserve conservative cockpit coverage', () => { - const scope = classifyChangedFiles(['libs/e2e-harness/src/index.ts'], workspace); - - assert.equal(scope.cockpit, true); - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_deploy_smoke, true); - assert.equal(scope.cockpit_e2e, true); +describe('classifyFromAffected — publishable lib broadcast', () => { + it('publishable lib triggers library + website + website_e2e + cockpit_* + examples_chat', () => { + const scope = classifyFromAffected(['libs/chat/src/foo.ts'], [ + { name: 'chat', tags: PUBLISHABLE_LIB_TAGS }, + ]); + expect(scope.library).toBe(true); + expect(scope.website).toBe(true); + expect(scope.website_e2e).toBe(true); + expect(scope.cockpit).toBe(true); + expect(scope.cockpit_examples).toBe(true); + expect(scope.cockpit_smoke).toBe(true); + expect(scope.cockpit_secret).toBe(true); + expect(scope.cockpit_deploy_smoke).toBe(true); + expect(scope.cockpit_e2e).toBe(true); + expect(scope.examples_chat).toBe(true); + expect(scope.posthog).toBe(false); + }); }); -test('global CI config changes keep full PR coverage', () => { - const scope = classifyChangedFiles(['.github/workflows/ci.yml'], workspace); - - assert.deepEqual(Object.values(scope), Object.values(scope).map(() => true)); -}); - -test('unowned docs changes do not trigger heavy CI jobs', () => { - const scope = classifyChangedFiles(['docs/notes.md'], workspace); - - assert.deepEqual(Object.values(scope), Object.values(scope).map(() => false)); +describe('classifyFromAffected — cockpit cap projects', () => { + it('cockpit cap python triggers cockpit_e2e + cockpit_examples + cockpit_smoke', () => { + const scope = classifyFromAffected( + ['cockpit/chat/messages/python/src/graph.py'], + [{ name: 'cockpit-chat-messages-python', tags: COCKPIT_CAP_PYTHON_TAGS }], + ); + expect(scope.cockpit_e2e).toBe(true); + expect(scope.cockpit_examples).toBe(true); + expect(scope.cockpit_smoke).toBe(true); + expect(scope.cockpit).toBe(false); + expect(scope.library).toBe(false); + }); + + it('cockpit cap angular triggers cockpit_e2e + cockpit_examples only', () => { + const scope = classifyFromAffected( + ['cockpit/chat/messages/angular/src/main.ts'], + [{ name: 'cockpit-chat-messages-angular', tags: COCKPIT_CAP_ANGULAR_TAGS }], + ); + expect(scope.cockpit_e2e).toBe(true); + expect(scope.cockpit_examples).toBe(true); + expect(scope.cockpit_smoke).toBe(false); + }); }); -test('per-cap chat python change triggers cockpit_e2e + cockpit_examples + cockpit_smoke', () => { - const scope = classifyChangedFiles( - ['cockpit/new/cap/python/langgraph.json'], - workspace, - ); - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_e2e, true); - assert.equal(scope.cockpit_smoke, true); // existing path — preserved +describe('classifyFromAffected — apps + fallback paths via namedInputs', () => { + it('vercel.json change marks apps/website affected → website + website_e2e', () => { + const scope = classifyFromAffected(['vercel.json'], [ + { name: 'website', tags: WEBSITE_TAGS }, + ]); + expect(scope.website).toBe(true); + expect(scope.website_e2e).toBe(true); + expect(scope.cockpit).toBe(false); + }); + + it('capability-registry.ts change marks apps/cockpit affected → all cockpit_*', () => { + const scope = classifyFromAffected( + ['apps/cockpit/scripts/capability-registry.ts'], + [{ name: 'cockpit', tags: COCKPIT_APP_TAGS }], + ); + expect(scope.cockpit).toBe(true); + expect(scope.cockpit_examples).toBe(true); + expect(scope.cockpit_deploy_smoke).toBe(true); + expect(scope.cockpit_e2e).toBe(true); + }); + + it('examples/chat change → examples_chat only', () => { + const scope = classifyFromAffected( + ['examples/chat/angular/src/main.ts'], + [{ name: 'examples-chat-angular', tags: EXAMPLES_CHAT_TAGS }], + ); + expect(scope.examples_chat).toBe(true); + expect(scope.cockpit).toBe(false); + }); + + it('tools/posthog change → posthog only', () => { + const scope = classifyFromAffected( + ['tools/posthog/src/dashboards.ts'], + [{ name: 'posthog-tools', tags: POSTHOG_TAGS }], + ); + expect(scope.posthog).toBe(true); + expect(scope.library).toBe(false); + }); }); -test('per-cap python change without smoke target still triggers e2e + examples', () => { - // Project setup: an imagined render python with only `build` target. - const renderProjects = [ - ...projects, - { - name: 'cockpit-render-fake-python', - root: 'cockpit/render/fake/python', - sourceRoot: 'cockpit/render/fake/python/src', - projectType: 'library', - tags: [], - targets: { build: {} }, - }, - ]; - const scope = classifyChangedFiles( - ['cockpit/render/fake/python/langgraph.json'], - { projects: renderProjects, publishableProjects: ['chat'] }, - ); - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_e2e, true); - // No smoke target → cockpit_smoke stays false - assert.equal(scope.cockpit_smoke, false); +describe('classifyFromAffected — tag isolation', () => { + it('tags not prefixed with "scope:" are ignored', () => { + const scope = classifyFromAffected(['some.ts'], [ + { name: 'x', tags: ['type:app', 'rotation:weekly'] }, + ]); + expect(scope).toEqual(emptyScope()); + }); + + it('unknown scope tags are ignored (no key collision)', () => { + const scope = classifyFromAffected(['some.ts'], [ + { name: 'x', tags: ['scope:not-a-real-scope'] }, + ]); + expect(scope).toEqual(emptyScope()); + }); }); -test('generate-shared-deployment-config.ts triggers full cockpit scope', () => { - const scope = classifyChangedFiles( - ['scripts/generate-shared-deployment-config.ts'], - workspace, - ); - assert.equal(scope.cockpit, true); - assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_smoke, true); - assert.equal(scope.cockpit_e2e, true); - assert.equal(scope.cockpit_deploy_smoke, true); +describe('SCOPE_KEYS export', () => { + it('contains the 11 documented scope keys', () => { + expect(SCOPE_KEYS).toEqual([ + 'library', 'website', 'website_e2e', + 'cockpit', 'cockpit_examples', 'cockpit_smoke', + 'cockpit_secret', 'cockpit_deploy_smoke', 'cockpit_e2e', + 'examples_chat', 'posthog', + ]); + }); }); From 02bca3519ecd71e5717b3df8accd0765521cf64d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:03:39 -0700 Subject: [PATCH 2/3] fix(ci-scope): use node:test (not vitest) in ci-scope.spec.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs `node --test scripts/ci-scope.spec.mjs` directly (no vitest installed at the ci-scope job's pre-npm-ci stage). My PR 2 rewrite imported `describe, it, expect` from 'vitest' and used Chai-style matchers; CI errored with ERR_MODULE_NOT_FOUND on 'vitest'. Rewrite spec to use Node's built-in test runner: - import { describe, it } from 'node:test' - import assert from 'node:assert/strict' - expect(x).toBe(y) → assert.equal(x, y) - expect(x).toEqual(y) → assert.deepEqual(x, y) All 13 tests pass via `node --test`. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ci-scope.spec.mjs | 75 ++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 2aaead53b..a1a2200a8 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; import { classifyFromAffected, emptyScope, @@ -23,15 +24,15 @@ const POSTHOG_TAGS = ['scope:posthog']; describe('classifyFromAffected — short-circuit', () => { it('returns full scope when a global CI file changes', () => { const scope = classifyFromAffected(['.github/workflows/ci.yml'], []); - expect(scope).toEqual(fullScope()); + assert.deepEqual(scope, fullScope()); }); it('full scope on package.json change', () => { - expect(classifyFromAffected(['package.json'], [])).toEqual(fullScope()); + assert.deepEqual(classifyFromAffected(['package.json'], []), fullScope()); }); it('empty scope when no global file + no affected projects', () => { - expect(classifyFromAffected(['docs/some-readme.md'], [])).toEqual(emptyScope()); + assert.deepEqual(classifyFromAffected(['docs/some-readme.md'], []), emptyScope()); }); }); @@ -40,17 +41,17 @@ describe('classifyFromAffected — publishable lib broadcast', () => { const scope = classifyFromAffected(['libs/chat/src/foo.ts'], [ { name: 'chat', tags: PUBLISHABLE_LIB_TAGS }, ]); - expect(scope.library).toBe(true); - expect(scope.website).toBe(true); - expect(scope.website_e2e).toBe(true); - expect(scope.cockpit).toBe(true); - expect(scope.cockpit_examples).toBe(true); - expect(scope.cockpit_smoke).toBe(true); - expect(scope.cockpit_secret).toBe(true); - expect(scope.cockpit_deploy_smoke).toBe(true); - expect(scope.cockpit_e2e).toBe(true); - expect(scope.examples_chat).toBe(true); - expect(scope.posthog).toBe(false); + assert.equal(scope.library, true); + assert.equal(scope.website, true); + assert.equal(scope.website_e2e, true); + assert.equal(scope.cockpit, true); + assert.equal(scope.cockpit_examples, true); + assert.equal(scope.cockpit_smoke, true); + assert.equal(scope.cockpit_secret, true); + assert.equal(scope.cockpit_deploy_smoke, true); + assert.equal(scope.cockpit_e2e, true); + assert.equal(scope.examples_chat, true); + assert.equal(scope.posthog, false); }); }); @@ -60,11 +61,11 @@ describe('classifyFromAffected — cockpit cap projects', () => { ['cockpit/chat/messages/python/src/graph.py'], [{ name: 'cockpit-chat-messages-python', tags: COCKPIT_CAP_PYTHON_TAGS }], ); - expect(scope.cockpit_e2e).toBe(true); - expect(scope.cockpit_examples).toBe(true); - expect(scope.cockpit_smoke).toBe(true); - expect(scope.cockpit).toBe(false); - expect(scope.library).toBe(false); + assert.equal(scope.cockpit_e2e, true); + assert.equal(scope.cockpit_examples, true); + assert.equal(scope.cockpit_smoke, true); + assert.equal(scope.cockpit, false); + assert.equal(scope.library, false); }); it('cockpit cap angular triggers cockpit_e2e + cockpit_examples only', () => { @@ -72,9 +73,9 @@ describe('classifyFromAffected — cockpit cap projects', () => { ['cockpit/chat/messages/angular/src/main.ts'], [{ name: 'cockpit-chat-messages-angular', tags: COCKPIT_CAP_ANGULAR_TAGS }], ); - expect(scope.cockpit_e2e).toBe(true); - expect(scope.cockpit_examples).toBe(true); - expect(scope.cockpit_smoke).toBe(false); + assert.equal(scope.cockpit_e2e, true); + assert.equal(scope.cockpit_examples, true); + assert.equal(scope.cockpit_smoke, false); }); }); @@ -83,9 +84,9 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => const scope = classifyFromAffected(['vercel.json'], [ { name: 'website', tags: WEBSITE_TAGS }, ]); - expect(scope.website).toBe(true); - expect(scope.website_e2e).toBe(true); - expect(scope.cockpit).toBe(false); + assert.equal(scope.website, true); + assert.equal(scope.website_e2e, true); + assert.equal(scope.cockpit, false); }); it('capability-registry.ts change marks apps/cockpit affected → all cockpit_*', () => { @@ -93,10 +94,10 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => ['apps/cockpit/scripts/capability-registry.ts'], [{ name: 'cockpit', tags: COCKPIT_APP_TAGS }], ); - expect(scope.cockpit).toBe(true); - expect(scope.cockpit_examples).toBe(true); - expect(scope.cockpit_deploy_smoke).toBe(true); - expect(scope.cockpit_e2e).toBe(true); + assert.equal(scope.cockpit, true); + assert.equal(scope.cockpit_examples, true); + assert.equal(scope.cockpit_deploy_smoke, true); + assert.equal(scope.cockpit_e2e, true); }); it('examples/chat change → examples_chat only', () => { @@ -104,8 +105,8 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => ['examples/chat/angular/src/main.ts'], [{ name: 'examples-chat-angular', tags: EXAMPLES_CHAT_TAGS }], ); - expect(scope.examples_chat).toBe(true); - expect(scope.cockpit).toBe(false); + assert.equal(scope.examples_chat, true); + assert.equal(scope.cockpit, false); }); it('tools/posthog change → posthog only', () => { @@ -113,8 +114,8 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => ['tools/posthog/src/dashboards.ts'], [{ name: 'posthog-tools', tags: POSTHOG_TAGS }], ); - expect(scope.posthog).toBe(true); - expect(scope.library).toBe(false); + assert.equal(scope.posthog, true); + assert.equal(scope.library, false); }); }); @@ -123,20 +124,20 @@ describe('classifyFromAffected — tag isolation', () => { const scope = classifyFromAffected(['some.ts'], [ { name: 'x', tags: ['type:app', 'rotation:weekly'] }, ]); - expect(scope).toEqual(emptyScope()); + assert.deepEqual(scope, emptyScope()); }); it('unknown scope tags are ignored (no key collision)', () => { const scope = classifyFromAffected(['some.ts'], [ { name: 'x', tags: ['scope:not-a-real-scope'] }, ]); - expect(scope).toEqual(emptyScope()); + assert.deepEqual(scope, emptyScope()); }); }); describe('SCOPE_KEYS export', () => { it('contains the 11 documented scope keys', () => { - expect(SCOPE_KEYS).toEqual([ + assert.deepEqual(SCOPE_KEYS, [ 'library', 'website', 'website_e2e', 'cockpit', 'cockpit_examples', 'cockpit_smoke', 'cockpit_secret', 'cockpit_deploy_smoke', 'cockpit_e2e', From 818aa392c656d71c31cff1737ec89446199ad8ea Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:15:44 -0700 Subject: [PATCH 3/3] ci(ci-scope): add setup-node + npm ci so the new shim can call nx affected PR 2's ci-scope.mjs rewrite delegates project ownership to `nx show projects --affected --json`. The CI scope job previously had no node setup or npm install (just `actions/checkout` + `node --test`), so the new shim errored with "Could not find Nx modules" at runtime. Add setup-node@v6 (node 22, cache: npm) + `npm ci` before the classifier test and the scope-detection step. Tax: ~15-30s warm cache, ~60s cold cache, on every CI run. This is the one-time-per-PR cost of using nx's project graph as the source of truth instead of a hand- maintained classifier walk. Comment block in the ci.yml explains the rationale so reviewers understand why setup got more elaborate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2492937fd..796ef0fe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,17 @@ jobs: - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 + - uses: actions/setup-node@v6.3.0 + with: + node-version: 22 + cache: npm + # ci-scope.mjs delegates project ownership to `nx show projects --affected` + # (introduced in PR 2 of the ci-scope thin-shim migration), which requires + # nx + the workspace's plugins to be installed. npm ci needs ~15-30s with + # the npm cache warm; ~60s cold. This is the one-time-per-PR cost of using + # nx's project graph as the source of truth instead of a hand-maintained + # classifier walk. + - run: npm ci - name: Test CI scope classifier run: node --test scripts/ci-scope.spec.mjs - name: Detect changed CI surfaces