diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0328475f2bbc..f46b278c6ad8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,53 +91,10 @@ jobs: run: pnpm install --frozen-lockfile - name: Build packages (restored from Turbo cache) run: pnpm build - - name: Lint dependencies - run: pnpm lint:deps - - name: Lint code shape - run: pnpm lint:code - - name: Lint packages - run: pnpm lint:packages - - name: Lint examples - run: pnpm lint:examples - - name: Validate rules - run: pnpm lint:rules - - name: Validate rule symlinks - run: pnpm lint:rules:symlinks - - name: Validate skills - run: pnpm lint:skills - - name: Check rules footprint - run: pnpm lint:rules:footprint - - name: Validate package READMEs - run: pnpm lint:docs - - name: Validate package manifests (license declarations) - run: pnpm lint:manifests - - name: Lint workflow triggers (forbid Pwn Request pattern) - run: pnpm lint:workflows - - name: Test scripts/ - run: pnpm test:scripts - - name: Lint casts - run: pnpm lint:casts - - name: Lint throws - run: pnpm lint:throws - - name: Lint framework vocabulary - run: pnpm lint:framework-vocabulary - - name: Lint consumer internal imports - run: pnpm lint:consumer-internal-imports - - name: Lint the legacy product name - run: pnpm lint:legacy-name - - - name: Lint publishability matches the directory layout - run: pnpm lint:publishability - - name: Check upgrade-instruction coverage - env: - BASE: ${{ github.base_ref || 'main' }} - run: pnpm check:upgrade-coverage --mode pr --prev "origin/$BASE" - - name: Check error-reference completeness - run: pnpm check:error-reference - - name: Check release notes + - name: Run lint gates env: BASE: ${{ github.base_ref || 'main' }} - run: pnpm check:release-notes --mode pr --prev "origin/$BASE" + run: pnpm lint:ci fixtures: name: Fixtures diff --git a/package.json b/package.json index 7ac11e156b5f..fb8d9407b795 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,19 @@ "coverage:packages:merge": "vitest --merge-reports=.vitest/blob --coverage", "lint": "turbo run lint", "lint:agent": "node scripts/run-logged.mjs lint pnpm lint", + "lint:ci": "node scripts/lint-ci.mjs", "lint:fix": "pnpm biome check --write .", "lint:fix:unsafe": "pnpm biome check --write --unsafe .", "lint:packages": "turbo run lint --filter='!./examples/**'", + "lint:packages:ci": "node scripts/lint-workspaces.mjs packages", "lint:examples": "turbo run lint --filter='./examples/**'", + "lint:examples:ci": "node scripts/lint-workspaces.mjs examples", "lint:deps": "depcruise --config dependency-cruiser.config.mjs packages && node scripts/lint-framework-target-imports.mjs && node scripts/lint-app-space-id.mjs && node scripts/lint-single-import-root.mjs", "lint:deps:agent": "node scripts/run-logged.mjs lint-deps pnpm lint:deps", "lint:casts": "node scripts/lint-casts.mjs", "lint:throws": "node scripts/lint-throws.mjs", "lint:framework-vocabulary": "node scripts/lint-framework-vocabulary.mjs", + "lint:ratchets": "node scripts/lint-ratchets.mjs", "lint:code": "node scripts/lint-no-contract-cast.mjs", "lint:no-contract-cast": "node scripts/lint-no-contract-cast.mjs", "lint:rules": "node scripts/validate-rules.mjs", @@ -47,7 +51,7 @@ "lint:docs": "node scripts/validate-package-readmes.mjs", "lint:manifests": "node scripts/validate-package-manifests.mjs && node scripts/validate-typescript-peer.mjs", "lint:workflows": "node scripts/lint-workflow-triggers.mjs", - "test:scripts": "node --test scripts/coverage-config.test.mjs scripts/coverage-report.test.mjs scripts/lint-workflow-triggers.test.mjs scripts/validate-skills.test.mjs scripts/determine-version-utils.test.ts scripts/check-upgrade-coverage.test.mjs scripts/check-release-notes.test.mjs scripts/set-version-utils.test.ts scripts/check-publish-deps.test.mjs scripts/check-conformance.test.mjs scripts/check-publish-deps-pn-pins.test.mjs scripts/check-publish-deps-declarations.test.mjs scripts/validate-package-manifests.test.mjs scripts/validate-package-readmes.test.mjs scripts/publish-packages-utils.test.mjs scripts/check-clean-tree.test.mjs scripts/lint-casts.test.mjs scripts/lint-throws.test.mjs scripts/list-error-codes.test.mjs scripts/lint-framework-vocabulary.test.mjs scripts/lint-single-import-root.test.mjs scripts/lint-legacy-name.test.mjs scripts/lint-consumer-internal-imports.test.mjs scripts/sync-agent-rules.test.mjs scripts/validate-typescript-peer.test.mjs scripts/run-logged.test.mjs scripts/migrate-migrations-layout.test.mjs skills-contrib/review-fetch-phase/scripts/render-review-state.test.mjs skills-contrib/review-triage-phase/scripts/render-review-actions.test.mjs", + "test:scripts": "node --test scripts/coverage-config.test.mjs scripts/coverage-report.test.mjs scripts/lint-workflow-triggers.test.mjs scripts/validate-skills.test.mjs scripts/determine-version-utils.test.ts scripts/check-upgrade-coverage.test.mjs scripts/check-release-notes.test.mjs scripts/set-version-utils.test.ts scripts/check-publish-deps.test.mjs scripts/check-conformance.test.mjs scripts/check-publish-deps-pn-pins.test.mjs scripts/check-publish-deps-declarations.test.mjs scripts/validate-package-manifests.test.mjs scripts/validate-package-readmes.test.mjs scripts/publish-packages-utils.test.mjs scripts/check-clean-tree.test.mjs scripts/lint-casts.test.mjs scripts/lint-throws.test.mjs scripts/lint-ratchets.test.mjs scripts/lint-workspaces.test.mjs scripts/lint-ci.test.mjs scripts/list-error-codes.test.mjs scripts/lint-framework-vocabulary.test.mjs scripts/lint-single-import-root.test.mjs scripts/lint-legacy-name.test.mjs scripts/lint-consumer-internal-imports.test.mjs scripts/sync-agent-rules.test.mjs scripts/validate-typescript-peer.test.mjs scripts/run-logged.test.mjs scripts/migrate-migrations-layout.test.mjs skills-contrib/review-fetch-phase/scripts/render-review-state.test.mjs skills-contrib/review-triage-phase/scripts/render-review-actions.test.mjs", "bump-version": "node scripts/bump-version.ts", "check:publish-deps": "node scripts/check-publish-deps.mjs", "check:conformance": "node scripts/check-conformance.mjs", diff --git a/scripts/lint-ci.mjs b/scripts/lint-ci.mjs new file mode 100644 index 000000000000..9c9a60c91ba8 --- /dev/null +++ b/scripts/lint-ci.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; + +const DEFAULT_CONCURRENCY = 4; + +export function createLintTasks(base) { + return [ + { name: 'packages', args: ['lint:packages:ci'] }, + { name: 'deps', args: ['lint:deps'] }, + { name: 'script_tests', args: ['test:scripts'] }, + { name: 'ratchets', args: ['lint:ratchets'] }, + { name: 'legacy_name', args: ['lint:legacy-name'] }, + { name: 'examples', args: ['lint:examples:ci'] }, + { name: 'code', args: ['lint:code'] }, + { name: 'rules', args: ['lint:rules'] }, + { name: 'rule_symlinks', args: ['lint:rules:symlinks'] }, + { name: 'skills', args: ['lint:skills'] }, + { name: 'rule_footprint', args: ['lint:rules:footprint'] }, + { name: 'docs', args: ['lint:docs'] }, + { name: 'manifests', args: ['lint:manifests'] }, + { name: 'workflows', args: ['lint:workflows'] }, + { name: 'consumer_imports', args: ['lint:consumer-internal-imports'] }, + { name: 'publishability', args: ['lint:publishability'] }, + { + name: 'upgrade_coverage', + args: ['check:upgrade-coverage', '--mode', 'pr', '--prev', `origin/${base}`], + }, + { name: 'error_reference', args: ['check:error-reference'] }, + { + name: 'release_notes', + args: ['check:release-notes', '--mode', 'pr', '--prev', `origin/${base}`], + }, + ]; +} + +export async function runTasks(tasks, concurrency, execute) { + let next = 0; + const failures = []; + + async function worker() { + while (next < tasks.length) { + const task = tasks[next]; + next += 1; + if ((await execute(task)) !== 0) failures.push(task.name); + } + } + + const workerCount = Math.min(concurrency, tasks.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return failures; +} + +function executeTask(task) { + const start = Date.now(); + console.log(`[lint:ci] start ${task.name}: pnpm ${task.args.join(' ')}`); + return new Promise((resolve) => { + const child = spawn('pnpm', task.args, { stdio: 'inherit' }); + child.once('error', (error) => { + console.error(`[lint:ci] ${task.name} failed to start: ${error.message}`); + resolve(1); + }); + child.once('close', (status) => { + const elapsed = ((Date.now() - start) / 1000).toFixed(3); + console.log(`[lint:ci] finish ${task.name}: status=${status ?? 1}`); + console.log(`LINT_METRIC ${task.name}_s=${elapsed}`); + resolve(status ?? 1); + }); + }); +} + +function concurrencyFromEnvironment() { + const parsed = Number.parseInt(process.env.LINT_CONCURRENCY ?? '', 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CONCURRENCY; +} + +async function main() { + const base = process.env.BASE || 'main'; + const failures = await runTasks(createLintTasks(base), concurrencyFromEnvironment(), executeTask); + if (failures.length > 0) { + console.error(`[lint:ci] failed: ${failures.join(', ')}`); + process.exit(1); + } +} + +if (process.argv[1] === import.meta.filename) await main(); diff --git a/scripts/lint-ci.test.mjs b/scripts/lint-ci.test.mjs new file mode 100644 index 000000000000..faa95ee02d88 --- /dev/null +++ b/scripts/lint-ci.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { createLintTasks, runTasks } from './lint-ci.mjs'; + +describe('createLintTasks', () => { + test('includes every post-build CI lint gate', () => { + assert.deepEqual( + createLintTasks('develop').map(({ name, args }) => [name, args]), + [ + ['packages', ['lint:packages:ci']], + ['deps', ['lint:deps']], + ['script_tests', ['test:scripts']], + ['ratchets', ['lint:ratchets']], + ['legacy_name', ['lint:legacy-name']], + ['examples', ['lint:examples:ci']], + ['code', ['lint:code']], + ['rules', ['lint:rules']], + ['rule_symlinks', ['lint:rules:symlinks']], + ['skills', ['lint:skills']], + ['rule_footprint', ['lint:rules:footprint']], + ['docs', ['lint:docs']], + ['manifests', ['lint:manifests']], + ['workflows', ['lint:workflows']], + ['consumer_imports', ['lint:consumer-internal-imports']], + ['publishability', ['lint:publishability']], + [ + 'upgrade_coverage', + ['check:upgrade-coverage', '--mode', 'pr', '--prev', 'origin/develop'], + ], + ['error_reference', ['check:error-reference']], + ['release_notes', ['check:release-notes', '--mode', 'pr', '--prev', 'origin/develop']], + ], + ); + }); +}); + +describe('runTasks', () => { + test('bounds concurrency and reports every failure', async () => { + let active = 0; + let peak = 0; + const completed = []; + const tasks = ['a', 'b', 'c', 'd'].map((name) => ({ name, args: [] })); + + const failures = await runTasks(tasks, 2, async ({ name }) => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + completed.push(name); + return name === 'b' || name === 'd' ? 1 : 0; + }); + + assert.equal(peak, 2); + assert.deepEqual(completed.sort(), ['a', 'b', 'c', 'd']); + assert.deepEqual(failures.sort(), ['b', 'd']); + }); +}); diff --git a/scripts/lint-ratchets.mjs b/scripts/lint-ratchets.mjs new file mode 100644 index 000000000000..4397382fdb6a --- /dev/null +++ b/scripts/lint-ratchets.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { filterNoBarecastDiags } from './lint-casts.mjs'; +import { dedupeSites, loadConfig } from './lint-framework-vocabulary.mjs'; +import { filterNoBareThrowDiags } from './lint-throws.mjs'; + +const REPO_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const BIOME_BIN = join(REPO_ROOT, 'node_modules', '.bin', 'biome'); +const BIOME_CONFIG = join(REPO_ROOT, 'biome.jsonc'); +const FRAMEWORK_CONFIG_PATH = join('scripts', 'lint-framework-vocabulary.config.json'); +const INCLUDED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts']); + +function diagnosticSites(diagnostics) { + return diagnostics.map((diagnostic) => { + const location = diagnostic.location ?? {}; + return `${location.path ?? ''}:${location.start?.line ?? 0}`; + }); +} + +export function compareDiagnostics(headDiagnostics, baseDiagnostics, filter) { + const headSites = diagnosticSites(filter(headDiagnostics)); + const baseSites = diagnosticSites(filter(baseDiagnostics)); + const baseSet = new Set(baseSites); + return { + current: headSites.length, + baseline: baseSites.length, + delta: headSites.length - baseSites.length, + added: headSites.filter((site) => !baseSet.has(site)), + }; +} + +export function sitesForScope(diagnostics, scopePath) { + const prefix = `${scopePath.replace(/\/$/, '')}/`; + return dedupeSites(diagnostics).filter((site) => site.startsWith(prefix)); +} + +export function inferScopeCount(baseThreshold, headDiagnostics, baseDiagnostics, scopePath) { + return ( + baseThreshold + + sitesForScope(headDiagnostics, scopePath).length - + sitesForScope(baseDiagnostics, scopePath).length + ); +} + +function scanDiagnostics(scanDir, paths) { + if (paths.length === 0) return []; + const result = spawnSync( + BIOME_BIN, + ['lint', '--config-path', BIOME_CONFIG, '--reporter=json', ...paths], + { cwd: scanDir, encoding: 'utf-8', maxBuffer: 400 * 1024 * 1024 }, + ); + + if (result.error) throw new Error(`biome spawn failed: ${result.error.message}`); + + const raw = (result.stdout ?? '').trim(); + if (!raw) return []; + + try { + return JSON.parse(raw).diagnostics ?? []; + } catch (error) { + throw new Error( + `biome JSON parse failed: ${error.message}\nraw output (first 500 chars): ${raw.slice(0, 500)}`, + ); + } +} + +function git(...args) { + return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf-8', stdio: 'pipe' }).trim(); +} + +function lines(value) { + return value === '' ? [] : value.split('\n'); +} + +function changedSourceFiles(mergeBase, diffFilter) { + return lines( + git('diff', '--name-only', '--no-renames', `--diff-filter=${diffFilter}`, mergeBase, '--'), + ).filter((path) => INCLUDED_EXTENSIONS.has(extname(path))); +} + +function allChangedFiles(mergeBase) { + return lines(git('diff', '--name-only', '--no-renames', mergeBase, '--')); +} + +function untrackedSourceFiles() { + return lines(git('ls-files', '--others', '--exclude-standard')).filter((path) => + INCLUDED_EXTENSIONS.has(extname(path)), + ); +} + +function parseManifest(contents, source) { + try { + return JSON.parse(contents); + } catch (error) { + throw new Error(`Cannot parse package.json from ${source}`, { cause: error }); + } +} + +function biomeVersionAt(ref) { + const manifest = ref + ? parseManifest(git('show', `${ref}:package.json`), ref) + : parseManifest(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'), REPO_ROOT); + return manifest.devDependencies?.['@biomejs/biome']; +} + +function requiresFullScan(paths, mergeBase) { + return ( + paths.some( + (path) => + path === 'biome.jsonc' || + path === 'pnpm-lock.yaml' || + path.startsWith('biome-plugins/') || + path.endsWith('/biome.jsonc'), + ) || + (paths.includes('package.json') && biomeVersionAt() !== biomeVersionAt(mergeBase)) + ); +} + +function reportDelta(name, result, failureMessage) { + const sign = result.delta > 0 ? '+' : ''; + console.log( + `lint:${name}: changed-current=${result.current} changed-merge-base=${result.baseline} delta=${sign}${result.delta}`, + ); + if (result.delta <= 0) return false; + + console.error(failureMessage(result.delta)); + for (const site of result.added) console.error(` ${site}`); + return true; +} + +function reportFrameworkVocabulary( + headDiagnostics, + baseDiagnostics, + headConfig, + baseConfig, + directCount, +) { + const list = process.argv.slice(2).includes('--list'); + const baseScopes = new Map(baseConfig.scopes.map((scope) => [scope.path, scope])); + let failed = false; + + for (const scope of headConfig.scopes) { + const headSites = sitesForScope(headDiagnostics, scope.path); + const baseScope = baseScopes.get(scope.path); + if (!directCount && baseScope === undefined) { + console.error(`lint:framework-vocabulary: scope=${scope.path} is absent at the merge-base.`); + return true; + } + const count = directCount + ? headSites.length + : inferScopeCount(baseScope.threshold, headDiagnostics, baseDiagnostics, scope.path); + const threshold = scope.threshold; + console.log( + `lint:framework-vocabulary: scope=${scope.path} count=${count} threshold=${threshold}`, + ); + + if (list) for (const site of headSites) console.log(` ${site}`); + + if (count > threshold) { + failed = true; + console.error( + `lint:framework-vocabulary: ${count - threshold} new family/target-vocabulary line(s) in ${scope.path}.`, + ); + console.error( + ' The framework domain is family-blind — move the new SQL/Mongo/target concept out of it.', + ); + console.error(` Find your additions: git diff origin/main -- ${scope.path}`); + console.error(' List all current sites: pnpm lint:ratchets --list'); + console.error( + ' If a site is genuinely family-blind, suppress it with `// biome-ignore lint/plugin/no-family-vocabulary: `.', + ); + } else if (count < threshold) { + failed = true; + console.error( + `lint:framework-vocabulary: scope=${scope.path} improved (count=${count} < threshold=${threshold}).`, + ); + console.error( + ` Lower "threshold" to ${count} in scripts/lint-framework-vocabulary.config.json to lock in the reduction.`, + ); + } + } + + return failed; +} + +function main() { + try { + git('rev-parse', 'origin/main'); + } catch { + console.error('lint:ratchets: error — origin/main is not available.'); + console.error(' Run: git fetch --no-tags origin main:refs/remotes/origin/main'); + process.exit(1); + } + + const head = git('rev-parse', 'HEAD'); + const mergeBase = git('merge-base', 'origin/main', 'HEAD'); + const headConfig = loadConfig(join(REPO_ROOT, FRAMEWORK_CONFIG_PATH)); + const list = process.argv.slice(2).includes('--list'); + + if (head === mergeBase) { + const scopes = headConfig.scopes.map((scope) => scope.path); + const headDiagnostics = scanDiagnostics(REPO_ROOT, scopes); + const failed = reportFrameworkVocabulary(headDiagnostics, [], headConfig, headConfig, true); + console.log( + 'lint:casts: HEAD is at merge-base with origin/main — no branch diff to ratchet. Skipping.', + ); + console.log( + 'lint:throws: HEAD is at merge-base with origin/main — no branch diff to ratchet. Skipping.', + ); + if (failed) process.exit(1); + return; + } + + const changed = allChangedFiles(mergeBase); + const fullScan = requiresFullScan(changed, mergeBase); + const headPaths = fullScan + ? ['.'] + : [...new Set([...changedSourceFiles(mergeBase, 'ACMRTUXB'), ...untrackedSourceFiles()])]; + if (list && !fullScan) { + headPaths.push(...headConfig.scopes.map((scope) => scope.path)); + } + const headDiagnostics = scanDiagnostics(REPO_ROOT, [...new Set(headPaths)]); + + const tempDir = mkdtempSync(join(tmpdir(), 'lint-ratchets-')); + let baseDiagnostics; + let baseConfig; + try { + git('worktree', 'add', '--detach', tempDir, mergeBase); + const basePaths = fullScan ? ['.'] : changedSourceFiles(mergeBase, 'DMRTUXB'); + baseDiagnostics = scanDiagnostics(tempDir, basePaths); + baseConfig = loadConfig(join(tempDir, FRAMEWORK_CONFIG_PATH)); + } finally { + try { + git('worktree', 'remove', '--force', tempDir); + } catch {} + rmSync(tempDir, { recursive: true, force: true }); + } + + let failed = reportFrameworkVocabulary( + headDiagnostics, + baseDiagnostics, + headConfig, + baseConfig, + fullScan || list, + ); + failed = + reportDelta( + 'casts', + compareDiagnostics(headDiagnostics, baseDiagnostics, filterNoBarecastDiags), + (delta) => + `lint:casts: ${delta} new bare \`as\` cast(s) introduced. Replace with blindCast(...) or castAs(value):`, + ) || failed; + failed = + reportDelta( + 'throws', + compareDiagnostics(headDiagnostics, baseDiagnostics, filterNoBareThrowDiags), + (delta) => + `lint:throws: ${delta} new bare \`throw new Error(...)\` introduced. Use structuredError(...) for user-facing errors, or InternalError/assertNever for bugs:`, + ) || failed; + + if (failed) process.exit(1); +} + +if (process.argv[1] === import.meta.filename) main(); diff --git a/scripts/lint-ratchets.test.mjs b/scripts/lint-ratchets.test.mjs new file mode 100644 index 000000000000..f0cc4f896356 --- /dev/null +++ b/scripts/lint-ratchets.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { compareDiagnostics, inferScopeCount, sitesForScope } from './lint-ratchets.mjs'; + +const diagnostic = (message, path, line) => ({ + category: 'plugin', + message, + location: { path, start: { line } }, +}); + +describe('compareDiagnostics', () => { + test('reports the count delta and added sites', () => { + const base = [diagnostic('no-bare-cast: x', 'src/a.ts', 1)]; + const head = [ + ...base, + diagnostic('no-bare-cast: x', 'src/b.ts', 2), + diagnostic('no-bare-throw: x', 'src/c.ts', 3), + ]; + const filter = (diagnostics) => + diagnostics.filter((entry) => entry.message.startsWith('no-bare-cast:')); + + assert.deepEqual(compareDiagnostics(head, base, filter), { + current: 2, + baseline: 1, + delta: 1, + added: ['src/b.ts:2'], + }); + }); +}); + +describe('sitesForScope', () => { + test('deduplicates lines and restricts diagnostics to the configured scope', () => { + const diagnostics = [ + diagnostic('no-family-vocabulary: x', 'packages/1-framework/src/a.ts', 4), + diagnostic('no-family-vocabulary: x', 'packages/1-framework/src/a.ts', 4), + diagnostic('no-family-vocabulary: x', 'packages/2-sql/src/a.ts', 4), + diagnostic('no-bare-cast: x', 'packages/1-framework/src/b.ts', 5), + ]; + + assert.deepEqual(sitesForScope(diagnostics, 'packages/1-framework'), [ + 'packages/1-framework/src/a.ts:4', + ]); + }); +}); + +describe('inferScopeCount', () => { + test('applies the changed-file diagnostic delta to the merge-base threshold', () => { + const base = [diagnostic('no-family-vocabulary: x', 'packages/1-framework/src/a.ts', 1)]; + const head = [ + diagnostic('no-family-vocabulary: x', 'packages/1-framework/src/a.ts', 1), + diagnostic('no-family-vocabulary: x', 'packages/1-framework/src/b.ts', 2), + ]; + + assert.equal(inferScopeCount(310, head, base, 'packages/1-framework'), 311); + }); +}); diff --git a/scripts/lint-workspaces.mjs b/scripts/lint-workspaces.mjs new file mode 100644 index 000000000000..66d6560d02a3 --- /dev/null +++ b/scripts/lint-workspaces.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const BIOME_BIN = join(REPO_ROOT, 'node_modules', '.bin', 'biome'); +const LINT_COMMAND = 'biome check . --error-on-warnings'; +const EXCLUDED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'dist-tsc', + 'dist-tsc-prod', + 'coverage', + '.next', + '.turbo', + 'build', +]); + +function* packageDirectories(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || EXCLUDED_DIRECTORIES.has(entry.name)) continue; + const path = join(directory, entry.name); + const manifestPath = join(path, 'package.json'); + if (existsSync(manifestPath)) yield path; + yield* packageDirectories(path); + } +} + +function usesRootConfig(directory) { + const configPath = join(directory, 'biome.jsonc'); + if (!existsSync(configPath)) return true; + + try { + const config = JSON.parse(readFileSync(configPath, 'utf8')); + const keys = Object.keys(config).filter((key) => key !== '$schema'); + return keys.length === 1 && keys[0] === 'extends' && config.extends === '//'; + } catch { + return false; + } +} + +function readManifest(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + throw new Error(`Cannot parse ${path}`, { cause: error }); + } +} + +export function planWorkspaceLint(repoRoot, scope) { + const batched = []; + const custom = []; + for (const directory of packageDirectories(join(repoRoot, scope))) { + const manifest = readManifest(join(directory, 'package.json')); + if (manifest.scripts?.lint !== LINT_COMMAND) continue; + const destination = usesRootConfig(directory) ? batched : custom; + destination.push(relative(repoRoot, directory)); + } + return { batched: batched.sort(), custom: custom.sort() }; +} + +export function createBiomeRuns(repoRoot, plan) { + const runs = []; + if (plan.batched.length > 0) { + runs.push({ + cwd: repoRoot, + args: [ + 'check', + '--config-path', + join(repoRoot, 'biome.jsonc'), + '--error-on-warnings', + ...plan.batched, + ], + }); + } + for (const directory of plan.custom) { + runs.push({ + cwd: join(repoRoot, directory), + args: ['check', '.', '--error-on-warnings'], + }); + } + return runs; +} + +function runBiome({ cwd, args }) { + return new Promise((resolve, reject) => { + const child = spawn(BIOME_BIN, args, { cwd, stdio: 'inherit' }); + child.once('error', reject); + child.once('close', (status) => resolve(status ?? 1)); + }); +} + +async function main() { + const scope = process.argv[2]; + if (scope !== 'packages' && scope !== 'examples') { + console.error('Usage: node scripts/lint-workspaces.mjs '); + process.exit(1); + } + + const plan = planWorkspaceLint(REPO_ROOT, scope); + const statuses = await Promise.all(createBiomeRuns(REPO_ROOT, plan).map(runBiome)); + if (statuses.some((status) => status !== 0)) process.exit(1); +} + +if (process.argv[1] === import.meta.filename) await main(); diff --git a/scripts/lint-workspaces.test.mjs b/scripts/lint-workspaces.test.mjs new file mode 100644 index 000000000000..f96e5695db8c --- /dev/null +++ b/scripts/lint-workspaces.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import { createBiomeRuns, planWorkspaceLint } from './lint-workspaces.mjs'; + +let root; + +function write(relativePath, contents) { + const path = join(root, relativePath); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, contents); +} + +function pkg(name, lint = true) { + write( + `packages/${name}/package.json`, + JSON.stringify({ name, scripts: lint ? { lint: 'biome check . --error-on-warnings' } : {} }), + ); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'lint-workspaces-')); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('planWorkspaceLint', () => { + test('batches root-equivalent configs and isolates custom configs', () => { + pkg('inherited'); + write('packages/inherited/biome.jsonc', JSON.stringify({ $schema: 'schema', extends: '//' })); + pkg('root-config'); + pkg('custom'); + write( + 'packages/custom/biome.jsonc', + JSON.stringify({ extends: '//', javascript: { globals: ['CUSTOM'] } }), + ); + pkg('not-linted', false); + + assert.deepEqual(planWorkspaceLint(root, 'packages'), { + batched: ['packages/inherited', 'packages/root-config'], + custom: ['packages/custom'], + }); + }); +}); + +describe('createBiomeRuns', () => { + test('creates one root batch and one run per custom config', () => { + assert.deepEqual( + createBiomeRuns(root, { + batched: ['packages/a', 'packages/b'], + custom: ['packages/custom'], + }), + [ + { + cwd: root, + args: [ + 'check', + '--config-path', + join(root, 'biome.jsonc'), + '--error-on-warnings', + 'packages/a', + 'packages/b', + ], + }, + { + cwd: join(root, 'packages/custom'), + args: ['check', '.', '--error-on-warnings'], + }, + ], + ); + }); +});