From cf2c46a4836df60571cce77fa02ec56e80099060 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:46:25 +0000 Subject: [PATCH 01/30] feat: add rslib monorepo harness runner --- package.json | 2 + rslib.harness.config.mjs | 30 ++ scripts/rslib-harness.mjs | 797 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 829 insertions(+) create mode 100644 rslib.harness.config.mjs create mode 100644 scripts/rslib-harness.mjs diff --git a/package.json b/package.json index cf89212762f..dc15567a52e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "lint": "nx run-many --target=lint", "test": "nx run-many --target=test", "build": "NX_TUI=false nx run-many --target=build --parallel=5 --projects=tag:type:pkg", + "rslib:harness": "node ./scripts/rslib-harness.mjs", + "rslib:harness:build": "node ./scripts/rslib-harness.mjs build", "build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache", "test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache", "lint-fix": "nx format:write --uncommitted", diff --git a/rslib.harness.config.mjs b/rslib.harness.config.mjs new file mode 100644 index 00000000000..ed9045f45cd --- /dev/null +++ b/rslib.harness.config.mjs @@ -0,0 +1,30 @@ +/** + * Rslib monorepo harness configuration. + * + * Supported project entry forms: + * - string path / glob: + * - directory (auto-detect rslib.config.*) + * - explicit rslib.config.* file + * - nested rslib.harness.config.* file + * - object: + * { + * name?: string; + * root?: string; + * config?: string; + * args?: string[]; + * ignore?: string[]; + * projects?: (string | object)[]; + * } + * + * Notes: + * - `` token is supported in path values. + * - this root harness intentionally targets publishable package projects and + * app-level rslib projects in apps/. + */ +export default { + ignore: ['**/dist/**', '**/.{idea,cache,output,temp}/**'], + projects: [ + 'packages/*/rslib.config.{mjs,ts,js,cjs,mts,cts}', + 'apps/**/rslib.config.{mjs,ts,js,cjs,mts,cts}', + ], +}; diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs new file mode 100644 index 00000000000..39f94a4309b --- /dev/null +++ b/scripts/rslib-harness.mjs @@ -0,0 +1,797 @@ +#!/usr/bin/env node +import { + existsSync, + readFileSync, + realpathSync, + statSync, + globSync, +} from 'node:fs'; +import { resolve, dirname, basename, isAbsolute, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_HARNESS_CONFIG = 'rslib.harness.config.mjs'; +const DEFAULT_PARALLEL = 1; +const RSLIB_CONFIG_FILES = [ + 'rslib.config.mjs', + 'rslib.config.ts', + 'rslib.config.js', + 'rslib.config.cjs', + 'rslib.config.mts', + 'rslib.config.cts', +]; +const HARNESS_CONFIG_PATTERN = + /^rslib\.harness\.config\.(?:mjs|js|cjs|mts|cts|ts)$/; +const GLOB_MAGIC_PATTERN = /[*?[\]{}()!]/; + +function printUsage() { + console.log(`Rslib monorepo harness + +Usage: + node scripts/rslib-harness.mjs [command] [options] [-- passthrough] + +Commands: + build Run "rslib build" in resolved projects (default) + inspect Run "rslib inspect" in resolved projects + mf-dev Run "rslib mf-dev" (single-project only) + list Resolve and print projects only + +Options: + -c, --config Harness config path (default: ${DEFAULT_HARNESS_CONFIG}) + -r, --root Root directory for resolving config and projects + -p, --project Filter project(s) by name or path (repeatable or comma-separated) + --parallel Concurrent project commands (default: ${DEFAULT_PARALLEL}) + --dry-run Print commands without executing + --list Print resolved projects before execution + --continue-on-error Continue running remaining projects when one fails + -h, --help Show help +`); +} + +function parseCliArgs(argv) { + const parsed = { + command: 'build', + config: DEFAULT_HARNESS_CONFIG, + root: process.cwd(), + projectFilters: [], + parallel: DEFAULT_PARALLEL, + dryRun: false, + list: false, + continueOnError: false, + passthroughArgs: [], + }; + + let commandSet = false; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === '--') { + parsed.passthroughArgs = argv.slice(i + 1); + break; + } + + if (arg === '-h' || arg === '--help') { + printUsage(); + process.exit(0); + } + + if (!arg.startsWith('-') && !commandSet) { + parsed.command = arg; + commandSet = true; + continue; + } + + if (arg === '-c' || arg === '--config') { + parsed.config = requireNextValue(argv, i, arg); + i += 1; + continue; + } + + if (arg === '-r' || arg === '--root') { + parsed.root = requireNextValue(argv, i, arg); + i += 1; + continue; + } + + if (arg === '-p' || arg === '--project') { + const value = requireNextValue(argv, i, arg); + i += 1; + parsed.projectFilters.push(...splitListOption(value)); + continue; + } + + if (arg === '--parallel') { + const value = requireNextValue(argv, i, arg); + i += 1; + const parallel = Number.parseInt(value, 10); + if (!Number.isFinite(parallel) || parallel < 1) { + throw new Error(`Invalid --parallel value "${value}". Expected >= 1.`); + } + parsed.parallel = parallel; + continue; + } + + if (arg === '--dry-run') { + parsed.dryRun = true; + continue; + } + + if (arg === '--list') { + parsed.list = true; + continue; + } + + if (arg === '--continue-on-error') { + parsed.continueOnError = true; + continue; + } + + if (arg.startsWith('--project=')) { + parsed.projectFilters.push( + ...splitListOption(arg.slice('--project='.length)), + ); + continue; + } + + if (arg.startsWith('--parallel=')) { + const value = arg.slice('--parallel='.length); + const parallel = Number.parseInt(value, 10); + if (!Number.isFinite(parallel) || parallel < 1) { + throw new Error(`Invalid --parallel value "${value}". Expected >= 1.`); + } + parsed.parallel = parallel; + continue; + } + + if (arg.startsWith('-')) { + throw new Error(`Unknown option "${arg}". Use --help for usage.`); + } + + throw new Error( + `Unexpected positional argument "${arg}". Pass through command arguments after "--".`, + ); + } + + if (!['build', 'inspect', 'mf-dev', 'list'].includes(parsed.command)) { + throw new Error( + `Unknown command "${parsed.command}". Expected build | inspect | mf-dev | list.`, + ); + } + + if (parsed.command === 'list') { + parsed.list = true; + } + + parsed.projectFilters = Array.from(new Set(parsed.projectFilters)); + parsed.root = resolve(process.cwd(), parsed.root); + + return parsed; +} + +function requireNextValue(argv, index, optionName) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`Missing value for ${optionName}.`); + } + return value; +} + +function splitListOption(value) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function hasGlobMagic(value) { + return GLOB_MAGIC_PATTERN.test(value); +} + +function readPackageName(projectRoot) { + const packageJsonPath = join(projectRoot, 'package.json'); + if (!existsSync(packageJsonPath)) { + return basename(projectRoot); + } + + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return typeof pkg.name === 'string' && pkg.name.trim() + ? pkg.name.trim() + : basename(projectRoot); + } catch { + return basename(projectRoot); + } +} + +function toCanonicalPath(targetPath) { + try { + return realpathSync(targetPath); + } catch { + return resolve(targetPath); + } +} + +function resolveWithRootDirToken(value, rootDir) { + const withToken = value.replaceAll('', rootDir); + if (isAbsolute(withToken)) { + return withToken; + } + return resolve(rootDir, withToken); +} + +function isHarnessConfigFile(filePath) { + return HARNESS_CONFIG_PATTERN.test(basename(filePath)); +} + +function isRslibConfigFile(filePath) { + return RSLIB_CONFIG_FILES.includes(basename(filePath)); +} + +function findProjectRslibConfig(projectRoot) { + for (const file of RSLIB_CONFIG_FILES) { + const configPath = join(projectRoot, file); + if (existsSync(configPath)) { + return configPath; + } + } + return null; +} + +async function loadHarnessConfig(configPath) { + const absolutePath = resolve(configPath); + if (!existsSync(absolutePath)) { + throw new Error(`Harness config not found: ${absolutePath}`); + } + + const moduleUrl = pathToFileURL(absolutePath).href; + const imported = await import(moduleUrl); + const config = imported.default ?? imported; + + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error( + `Invalid harness config at ${absolutePath}: expected object export.`, + ); + } + + if (!Array.isArray(config.projects)) { + throw new Error( + `Invalid harness config at ${absolutePath}: "projects" must be an array.`, + ); + } + + return { + path: absolutePath, + config, + }; +} + +function mergeIgnorePatterns(parentIgnore, configIgnore) { + const combined = [ + ...parentIgnore, + ...(Array.isArray(configIgnore) ? configIgnore : []), + ]; + return Array.from(new Set(combined)); +} + +function createProjectRecord({ name, root, configFile, args, sourceConfig }) { + return { + name, + root, + configFile: configFile ?? null, + args: args ?? [], + sourceConfig, + }; +} + +function ensureUniqueProjectName(projectByName, project, dedupeKey) { + const existing = projectByName.get(project.name); + if (!existing) { + projectByName.set(project.name, { + dedupeKey, + root: project.root, + configFile: project.configFile, + }); + return; + } + + if (existing.dedupeKey === dedupeKey) { + return; + } + + throw new Error( + `Duplicate project name "${project.name}" detected.\n` + + `- Existing: ${existing.configFile ?? existing.root}\n` + + `- New: ${project.configFile ?? project.root}\n` + + 'Use explicit unique "name" values in harness entries.', + ); +} + +function shouldFilterProject(project, projectFilters) { + if (projectFilters.length === 0) { + return true; + } + + const rootLower = project.root.toLowerCase(); + const configLower = (project.configFile ?? '').toLowerCase(); + const nameLower = project.name.toLowerCase(); + + return projectFilters.some((filterValue) => { + const needle = filterValue.toLowerCase(); + return ( + nameLower === needle || + nameLower.includes(needle) || + rootLower.includes(needle) || + configLower.includes(needle) + ); + }); +} + +async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { + const dedupeMap = new Map(); + const projectByName = new Map(); + const resolvedProjects = []; + + async function recurseConfig({ + configPath, + inheritedRootDir, + inheritedIgnore, + inheritedArgs, + }) { + const { path: absoluteConfigPath, config } = + await loadHarnessConfig(configPath); + const configDir = dirname(absoluteConfigPath); + const configRootDir = config.root + ? resolveWithRootDirToken(config.root, configDir) + : (inheritedRootDir ?? configDir); + const configIgnore = mergeIgnorePatterns(inheritedIgnore, config.ignore); + const configArgs = [ + ...inheritedArgs, + ...(Array.isArray(config.defaults?.args) ? config.defaults.args : []), + ]; + + for (const entry of config.projects) { + await resolveEntry({ + entry, + entryRootDir: configRootDir, + ignorePatterns: configIgnore, + inheritedArgs: configArgs, + sourceConfig: absoluteConfigPath, + }); + } + } + + async function addProject({ + name, + projectRoot, + configFile, + args, + sourceConfig, + }) { + const canonicalConfig = configFile ? toCanonicalPath(configFile) : null; + const canonicalRoot = toCanonicalPath(projectRoot); + const dedupeKey = canonicalConfig + ? `config:${canonicalConfig}` + : `root:${canonicalRoot}`; + + if (dedupeMap.has(dedupeKey)) { + return; + } + + const finalName = name ?? readPackageName(projectRoot); + const project = createProjectRecord({ + name: finalName, + root: canonicalRoot, + configFile: canonicalConfig, + args, + sourceConfig, + }); + + ensureUniqueProjectName(projectByName, project, dedupeKey); + dedupeMap.set(dedupeKey, project); + resolvedProjects.push(project); + } + + async function resolvePathLikeEntry({ + targetPath, + inheritedArgs, + sourceConfig, + entryRootDir, + ignorePatterns, + }) { + const absolutePath = resolveWithRootDirToken(targetPath, entryRootDir); + + if (!existsSync(absolutePath)) { + throw new Error( + `Project entry "${targetPath}" resolved to missing path: ${absolutePath} (from ${sourceConfig})`, + ); + } + + const stats = statSync(absolutePath); + if (stats.isDirectory()) { + const nestedHarnessConfig = join(absolutePath, DEFAULT_HARNESS_CONFIG); + if (existsSync(nestedHarnessConfig)) { + await recurseConfig({ + configPath: nestedHarnessConfig, + inheritedRootDir: absolutePath, + inheritedIgnore: ignorePatterns, + inheritedArgs, + }); + return; + } + + const rslibConfigPath = findProjectRslibConfig(absolutePath); + if (!rslibConfigPath) { + return; + } + + await addProject({ + projectRoot: absolutePath, + configFile: rslibConfigPath, + args: inheritedArgs, + sourceConfig, + }); + return; + } + + if (isHarnessConfigFile(absolutePath)) { + await recurseConfig({ + configPath: absolutePath, + inheritedRootDir: dirname(absolutePath), + inheritedIgnore: ignorePatterns, + inheritedArgs, + }); + return; + } + + if (!isRslibConfigFile(absolutePath)) { + throw new Error( + `Unsupported project file "${absolutePath}". Expected rslib.config.* or rslib.harness.config.*`, + ); + } + + await addProject({ + projectRoot: dirname(absolutePath), + configFile: absolutePath, + args: inheritedArgs, + sourceConfig, + }); + } + + async function resolveStringEntry({ + value, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }) { + const expandedValue = value.replaceAll('', entryRootDir); + + if (hasGlobMagic(expandedValue)) { + const matches = globSync(expandedValue, { + cwd: entryRootDir, + exclude: ignorePatterns, + dot: true, + nodir: false, + withFileTypes: false, + }).map((match) => + isAbsolute(match) ? match : resolve(entryRootDir, match), + ); + + matches.sort((a, b) => a.localeCompare(b)); + + for (const match of matches) { + await resolvePathLikeEntry({ + targetPath: match, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + } + return; + } + + await resolvePathLikeEntry({ + targetPath: expandedValue, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + } + + async function resolveObjectEntry({ + entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error( + `Invalid project entry in ${sourceConfig}. Expected object, got ${typeof entry}.`, + ); + } + + const objectRootDir = entry.root + ? resolveWithRootDirToken(entry.root, entryRootDir) + : entryRootDir; + const objectIgnore = mergeIgnorePatterns(ignorePatterns, entry.ignore); + const objectArgs = [ + ...inheritedArgs, + ...(Array.isArray(entry.args) ? entry.args : []), + ]; + + if (Array.isArray(entry.projects)) { + for (const nestedEntry of entry.projects) { + await resolveEntry({ + entry: nestedEntry, + entryRootDir: objectRootDir, + ignorePatterns: objectIgnore, + inheritedArgs: objectArgs, + sourceConfig, + }); + } + return; + } + + const explicitConfigFile = entry.config + ? resolveWithRootDirToken(entry.config, objectRootDir) + : findProjectRslibConfig(objectRootDir); + + if (!explicitConfigFile) { + throw new Error( + `Project entry in ${sourceConfig} resolved to "${objectRootDir}" but no rslib.config.* file was found.`, + ); + } + + if (!existsSync(explicitConfigFile)) { + throw new Error( + `Project config path "${explicitConfigFile}" does not exist (from ${sourceConfig}).`, + ); + } + + await addProject({ + name: entry.name, + projectRoot: dirname(explicitConfigFile), + configFile: explicitConfigFile, + args: objectArgs, + sourceConfig, + }); + } + + async function resolveEntry({ + entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }) { + if (typeof entry === 'string') { + await resolveStringEntry({ + value: entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + return; + } + + await resolveObjectEntry({ + entry, + entryRootDir, + ignorePatterns, + inheritedArgs, + sourceConfig, + }); + } + + await recurseConfig({ + configPath: harnessConfigPath, + inheritedRootDir: rootDir, + inheritedIgnore: ['**/node_modules/**', '**/.git/**'], + inheritedArgs: [], + }); + + const filteredProjects = resolvedProjects.filter((project) => + shouldFilterProject(project, projectFilters), + ); + + if (projectFilters.length > 0 && filteredProjects.length === 0) { + throw new Error( + `No projects matched filters: ${projectFilters.join(', ')}`, + ); + } + + return filteredProjects; +} + +function printResolvedProjects(projects, rootDir) { + console.log(`[rslib-harness] Resolved ${projects.length} project(s):`); + for (const project of projects) { + const root = project.root.startsWith(rootDir) + ? project.root.slice(rootDir.length + 1) || '.' + : project.root; + const configPath = project.configFile?.startsWith(rootDir) + ? project.configFile.slice(rootDir.length + 1) || '.' + : (project.configFile ?? '(auto)'); + console.log(`- ${project.name}`); + console.log(` root: ${root}`); + console.log(` config: ${configPath}`); + if (project.args.length > 0) { + console.log(` args: ${project.args.join(' ')}`); + } + } +} + +function spawnProjectCommand({ project, command, passthroughArgs, dryRun }) { + const args = ['exec', 'rslib', command, ...project.args]; + if (project.configFile) { + args.push('--config', project.configFile); + } + args.push(...passthroughArgs); + + const commandLine = `pnpm ${args.join(' ')}`; + console.log(`[rslib-harness] ${project.name}: ${commandLine}`); + + if (dryRun) { + return Promise.resolve({ code: 0, project }); + } + + return new Promise((resolvePromise) => { + const child = spawn('pnpm', args, { + cwd: project.root, + stdio: 'inherit', + env: process.env, + }); + + child.on('close', (code) => { + resolvePromise({ + code: code ?? 1, + project, + }); + }); + }); +} + +async function runWithConcurrency({ + projects, + command, + passthroughArgs, + parallel, + dryRun, + continueOnError, +}) { + const failures = []; + const queue = [...projects]; + const active = new Set(); + let shouldStop = false; + + async function launchNext() { + if (shouldStop) { + return; + } + + const nextProject = queue.shift(); + if (!nextProject) { + return; + } + + const runPromise = spawnProjectCommand({ + project: nextProject, + command, + passthroughArgs, + dryRun, + }).then((result) => { + if (result.code !== 0) { + failures.push(result); + if (!continueOnError) { + shouldStop = true; + } + } + }); + + active.add(runPromise); + runPromise.finally(() => active.delete(runPromise)); + + if (active.size >= parallel) { + await Promise.race(active); + } + + await launchNext(); + } + + const initialWorkers = Math.min(parallel, queue.length); + const workers = []; + for (let i = 0; i < initialWorkers; i += 1) { + workers.push(launchNext()); + } + await Promise.all(workers); + await Promise.all(active); + + return failures; +} + +function validateCommandGuards({ + command, + passthroughArgs, + projects, + parallel, +}) { + const watchRequested = + command === 'mf-dev' || + passthroughArgs.includes('--watch') || + passthroughArgs.includes('-w'); + + if (watchRequested && projects.length > 1) { + throw new Error( + 'Watch/mf-dev mode is currently single-project only. Use --project to select one project.', + ); + } + + if (watchRequested && parallel !== 1) { + throw new Error('Watch/mf-dev mode does not support --parallel > 1.'); + } +} + +async function main() { + const cli = parseCliArgs(process.argv.slice(2)); + const harnessConfigPath = isAbsolute(cli.config) + ? cli.config + : resolve(cli.root, cli.config); + + const projects = await resolveProjects({ + harnessConfigPath, + rootDir: cli.root, + projectFilters: cli.projectFilters, + }); + + if (projects.length === 0) { + throw new Error('No projects were resolved from harness config.'); + } + + if (cli.list || cli.dryRun) { + printResolvedProjects(projects, cli.root); + } + + if (cli.command === 'list') { + return; + } + + validateCommandGuards({ + command: cli.command, + passthroughArgs: cli.passthroughArgs, + projects, + parallel: cli.parallel, + }); + + const failures = await runWithConcurrency({ + projects, + command: cli.command, + passthroughArgs: cli.passthroughArgs, + parallel: cli.parallel, + dryRun: cli.dryRun, + continueOnError: cli.continueOnError, + }); + + if (failures.length > 0) { + console.error( + `[rslib-harness] ${failures.length} project(s) failed:\n` + + failures + .map( + (failure) => `- ${failure.project.name} (${failure.project.root})`, + ) + .join('\n'), + ); + process.exit(1); + } +} + +main().catch((error) => { + console.error( + `[rslib-harness] ${error instanceof Error ? error.message : error}`, + ); + process.exit(1); +}); From 018a8313b8740f6a0066356cd239483fb0b4a028 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:49:25 +0000 Subject: [PATCH 02/30] test: add coverage for rslib harness resolver --- scripts/__tests__/rslib-harness.test.mjs | 180 +++++++++++++++++++++++ scripts/rslib-harness.mjs | 22 ++- 2 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 scripts/__tests__/rslib-harness.test.mjs diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs new file mode 100644 index 00000000000..d644e17770b --- /dev/null +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import test from 'node:test'; +import { + parseCliArgs, + resolveProjects, + validateCommandGuards, +} from '../rslib-harness.mjs'; + +async function withTempDir(run) { + const tempRoot = mkdtempSync(join(tmpdir(), 'rslib-harness-test-')); + try { + return await run(tempRoot); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function writeFile(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); +} + +function writeRslibProject(root, projectDir, packageName) { + const fullProjectRoot = join(root, projectDir); + writeFile( + join(fullProjectRoot, 'package.json'), + JSON.stringify({ name: packageName }, null, 2), + ); + writeFile( + join(fullProjectRoot, 'rslib.config.ts'), + 'export default { lib: [{ format: "esm" }] };\n', + ); +} + +test('parseCliArgs parses project filters, parallel and passthrough', () => { + const parsed = parseCliArgs([ + 'build', + '--project', + 'pkg-a,pkg-b', + '--project', + 'pkg-c', + '--parallel', + '3', + '--', + '--watch', + ]); + + assert.equal(parsed.command, 'build'); + assert.deepEqual(parsed.projectFilters, ['pkg-a', 'pkg-b', 'pkg-c']); + assert.equal(parsed.parallel, 3); + assert.deepEqual(parsed.passthroughArgs, ['--watch']); +}); + +test('resolveProjects discovers projects from glob entries', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 2); + assert.deepEqual( + projects.map((project) => project.name), + ['pkg-a', 'pkg-b'], + ); + }); +}); + +test('resolveProjects supports nested harness config recursion', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/leaf', 'leaf'); + writeRslibProject(root, 'packages/group/nested', 'nested-fallback-name'); + writeFile( + join(root, 'packages/group/rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + name: 'nested-explicit', + root: './nested', + config: './rslib.config.ts', + }, + ], +}; +`, + ); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 2); + assert.deepEqual( + projects.map((project) => project.name), + ['nested-explicit', 'leaf'], + ); + }); +}); + +test('resolveProjects enforces unique project names', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'duplicate-name'); + writeRslibProject(root, 'packages/pkg-b', 'duplicate-name'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /Duplicate project name "duplicate-name"/, + ); + }); +}); + +test('resolveProjects applies project filters', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: ['pkg-b'], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-b'); + }); +}); + +test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { + assert.throws( + () => + validateCommandGuards({ + command: 'mf-dev', + passthroughArgs: [], + projects: [{ name: 'a' }, { name: 'b' }], + parallel: 1, + }), + /single-project only/, + ); + + assert.throws( + () => + validateCommandGuards({ + command: 'build', + passthroughArgs: ['--watch'], + projects: [{ name: 'a' }], + parallel: 2, + }), + /does not support --parallel > 1/, + ); +}); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 39f94a4309b..8e183f3944f 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -8,7 +8,7 @@ import { } from 'node:fs'; import { resolve, dirname, basename, isAbsolute, join } from 'node:path'; import { spawn } from 'node:child_process'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const DEFAULT_HARNESS_CONFIG = 'rslib.harness.config.mjs'; const DEFAULT_PARALLEL = 1; @@ -789,9 +789,17 @@ async function main() { } } -main().catch((error) => { - console.error( - `[rslib-harness] ${error instanceof Error ? error.message : error}`, - ); - process.exit(1); -}); +const isMainModule = + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (isMainModule) { + main().catch((error) => { + console.error( + `[rslib-harness] ${error instanceof Error ? error.message : error}`, + ); + process.exit(1); + }); +} + +export { parseCliArgs, resolveProjects, validateCommandGuards }; From e5e5a1bdb0a40d54bc8dd86336f4cfb2311a7764 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:50:59 +0000 Subject: [PATCH 03/30] test: expand rslib harness parser and args coverage --- package.json | 1 + scripts/__tests__/rslib-harness.test.mjs | 42 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/package.json b/package.json index dc15567a52e..86fb4b12a34 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "build": "NX_TUI=false nx run-many --target=build --parallel=5 --projects=tag:type:pkg", "rslib:harness": "node ./scripts/rslib-harness.mjs", "rslib:harness:build": "node ./scripts/rslib-harness.mjs build", + "test:rslib-harness": "node --test ./scripts/__tests__/rslib-harness.test.mjs", "build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache", "test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache", "lint-fix": "nx format:write --uncommitted", diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index d644e17770b..9aef0972cf8 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -54,6 +54,13 @@ test('parseCliArgs parses project filters, parallel and passthrough', () => { assert.deepEqual(parsed.passthroughArgs, ['--watch']); }); +test('parseCliArgs enables list mode for list command', () => { + const parsed = parseCliArgs(['list', '--project', 'pkg-a']); + assert.equal(parsed.command, 'list'); + assert.equal(parsed.list, true); + assert.deepEqual(parsed.projectFilters, ['pkg-a']); +}); + test('resolveProjects discovers projects from glob entries', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); @@ -114,6 +121,41 @@ export default { }); }); +test('resolveProjects merges default and per-project args', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: { args: ['--lib', 'esm'] }, + projects: [ + { + name: 'pkg-a', + root: './packages/pkg-a', + args: ['--log-level', 'warn'], + }, + ], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 1); + assert.deepEqual(projects[0]?.args, [ + '--lib', + 'esm', + '--log-level', + 'warn', + ]); + }); +}); + test('resolveProjects enforces unique project names', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'duplicate-name'); From 47d79214b664e436ee2af4f4c7562317b0ccd90c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:51:32 +0000 Subject: [PATCH 04/30] docs: add rslib harness usage examples --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 4d43d56612d..ef4eeb4b426 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,34 @@ To get started with Module Federation, see the [Quick Start](https://module-fede Come and chat with us on [Discussions](https://github.com/module-federation/universe/discussions) or [Discord](https://discord.gg/n69NnT3ACV)! The Module federation team and users are active there, and we're always looking for contributions. +## ๐Ÿงช Rslib Monorepo Harness + +This repository includes a workspace-level Rslib harness that can orchestrate +multiple `rslib.config.*` projects from the repo root (including nested project +definitions via harness config files). + +Quick examples: + +- List resolved projects: + + ```bash + pnpm run rslib:harness list + ``` + +- Build a single project by name/path filter: + + ```bash + pnpm run rslib:harness:build --project create-module-federation + ``` + +- Show commands without executing: + + ```bash + pnpm run rslib:harness:build --project create-module-federation --dry-run + ``` + +The default root harness config is `rslib.harness.config.mjs`. + ## ๐Ÿค Contribution > New contributors welcome! From bbd8664fd9a2b3a9656abe4c39492585c6b77655 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:53:02 +0000 Subject: [PATCH 05/30] ci: run rslib harness tests in ci-local jobs --- tools/scripts/ci-local.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/scripts/ci-local.mjs b/tools/scripts/ci-local.mjs index ba8da12452a..8bb6bfd1a4e 100644 --- a/tools/scripts/ci-local.mjs +++ b/tools/scripts/ci-local.mjs @@ -73,6 +73,9 @@ const jobs = [ ctx, ), ), + step('Run Rslib Harness Tests', (ctx) => + runCommand('pnpm', ['run', 'test:rslib-harness'], ctx), + ), step('Print number of CPU cores', (ctx) => runCommand('nproc', [], ctx)), step('Build packages (cold cache)', (ctx) => runCommand( @@ -177,6 +180,9 @@ const jobs = [ ctx, ), ), + step('Run Rslib Harness Tests', (ctx) => + runCommand('pnpm', ['run', 'test:rslib-harness'], ctx), + ), step('Build all required packages', (ctx) => runCommand( 'npx', From 6c8b8e632fed8cf5b6dbc7675876e9bd4145f8af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:55:00 +0000 Subject: [PATCH 06/30] chore: add rslib harness list and inspect scripts --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 86fb4b12a34..39245579d9a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "test": "nx run-many --target=test", "build": "NX_TUI=false nx run-many --target=build --parallel=5 --projects=tag:type:pkg", "rslib:harness": "node ./scripts/rslib-harness.mjs", + "rslib:harness:list": "node ./scripts/rslib-harness.mjs list", "rslib:harness:build": "node ./scripts/rslib-harness.mjs build", + "rslib:harness:inspect": "node ./scripts/rslib-harness.mjs inspect", "test:rslib-harness": "node --test ./scripts/__tests__/rslib-harness.test.mjs", "build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache", "test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache", From 1ab9fef583b0abcab577ee27e26dc212b3c61801 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:55:29 +0000 Subject: [PATCH 07/30] docs: document harness inspect command usage --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ef4eeb4b426..dae7be6747f 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ Quick examples: pnpm run rslib:harness:build --project create-module-federation --dry-run ``` +- Inspect one project's generated config outputs: + + ```bash + pnpm run rslib:harness:inspect --project create-module-federation + ``` + The default root harness config is `rslib.harness.config.mjs`. ## ๐Ÿค Contribution From 7ac934d75279d8f4b969f45c756afa7069488fa5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:56:54 +0000 Subject: [PATCH 08/30] fix: use fast-glob for node20 harness compatibility --- scripts/rslib-harness.mjs | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 8e183f3944f..bda66a08988 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -1,14 +1,9 @@ #!/usr/bin/env node -import { - existsSync, - readFileSync, - realpathSync, - statSync, - globSync, -} from 'node:fs'; +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { resolve, dirname, basename, isAbsolute, join } from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import fg from 'fast-glob'; const DEFAULT_HARNESS_CONFIG = 'rslib.harness.config.mjs'; const DEFAULT_PARALLEL = 1; @@ -22,7 +17,6 @@ const RSLIB_CONFIG_FILES = [ ]; const HARNESS_CONFIG_PATTERN = /^rslib\.harness\.config\.(?:mjs|js|cjs|mts|cts|ts)$/; -const GLOB_MAGIC_PATTERN = /[*?[\]{}()!]/; function printUsage() { console.log(`Rslib monorepo harness @@ -184,10 +178,6 @@ function splitListOption(value) { .filter(Boolean); } -function hasGlobMagic(value) { - return GLOB_MAGIC_PATTERN.test(value); -} - function readPackageName(projectRoot) { const packageJsonPath = join(projectRoot, 'package.json'); if (!existsSync(packageJsonPath)) { @@ -467,16 +457,16 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { }) { const expandedValue = value.replaceAll('', entryRootDir); - if (hasGlobMagic(expandedValue)) { - const matches = globSync(expandedValue, { + if (fg.isDynamicPattern(expandedValue)) { + const matches = await fg(expandedValue, { cwd: entryRootDir, - exclude: ignorePatterns, + absolute: true, dot: true, - nodir: false, - withFileTypes: false, - }).map((match) => - isAbsolute(match) ? match : resolve(entryRootDir, match), - ); + onlyFiles: false, + unique: true, + followSymbolicLinks: false, + ignore: ignorePatterns, + }); matches.sort((a, b) => a.localeCompare(b)); From 217b4b69f894b3a64f7a45ddeff7c78ec6434cc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 02:58:57 +0000 Subject: [PATCH 09/30] ci: verify rslib harness coverage in local pipeline --- package.json | 1 + tools/scripts/ci-local.mjs | 6 ++ .../scripts/verify-rslib-harness-coverage.mjs | 94 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 tools/scripts/verify-rslib-harness-coverage.mjs diff --git a/package.json b/package.json index 39245579d9a..ec5dcae6e87 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "rslib:harness:build": "node ./scripts/rslib-harness.mjs build", "rslib:harness:inspect": "node ./scripts/rslib-harness.mjs inspect", "test:rslib-harness": "node --test ./scripts/__tests__/rslib-harness.test.mjs", + "verify:rslib-harness": "node ./tools/scripts/verify-rslib-harness-coverage.mjs", "build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache", "test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache", "lint-fix": "nx format:write --uncommitted", diff --git a/tools/scripts/ci-local.mjs b/tools/scripts/ci-local.mjs index 8bb6bfd1a4e..4f0bc576414 100644 --- a/tools/scripts/ci-local.mjs +++ b/tools/scripts/ci-local.mjs @@ -76,6 +76,9 @@ const jobs = [ step('Run Rslib Harness Tests', (ctx) => runCommand('pnpm', ['run', 'test:rslib-harness'], ctx), ), + step('Verify Rslib Harness Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness'], ctx), + ), step('Print number of CPU cores', (ctx) => runCommand('nproc', [], ctx)), step('Build packages (cold cache)', (ctx) => runCommand( @@ -183,6 +186,9 @@ const jobs = [ step('Run Rslib Harness Tests', (ctx) => runCommand('pnpm', ['run', 'test:rslib-harness'], ctx), ), + step('Verify Rslib Harness Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness'], ctx), + ), step('Build all required packages', (ctx) => runCommand( 'npx', diff --git a/tools/scripts/verify-rslib-harness-coverage.mjs b/tools/scripts/verify-rslib-harness-coverage.mjs new file mode 100644 index 00000000000..38f65c8d938 --- /dev/null +++ b/tools/scripts/verify-rslib-harness-coverage.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { existsSync, realpathSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fg from 'fast-glob'; +import { resolveProjects } from '../../scripts/rslib-harness.mjs'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(SCRIPT_DIR, '../..'); +const HARNESS_CONFIG_PATH = join(ROOT, 'rslib.harness.config.mjs'); +const RSLIB_CONFIG_GLOBS = [ + 'packages/*/rslib.config.{mjs,ts,js,cjs,mts,cts}', + 'apps/**/rslib.config.{mjs,ts,js,cjs,mts,cts}', +]; +const EXPECTED_MIN_CONFIGS = Number.parseInt( + process.env.MIN_EXPECTED_RSLIB_HARNESS_PROJECTS ?? '20', + 10, +); + +function getCanonicalPath(value) { + try { + return realpathSync(value); + } catch { + return resolve(value); + } +} + +async function main() { + process.chdir(ROOT); + + if (!existsSync(HARNESS_CONFIG_PATH)) { + throw new Error(`Harness config not found at ${HARNESS_CONFIG_PATH}`); + } + + const expectedConfigs = fg + .sync(RSLIB_CONFIG_GLOBS, { + cwd: ROOT, + absolute: true, + dot: true, + onlyFiles: true, + unique: true, + followSymbolicLinks: false, + ignore: ['**/node_modules/**', '**/dist/**'], + }) + .map(getCanonicalPath) + .sort((a, b) => a.localeCompare(b)); + + if ( + Number.isFinite(EXPECTED_MIN_CONFIGS) && + EXPECTED_MIN_CONFIGS > 0 && + expectedConfigs.length < EXPECTED_MIN_CONFIGS + ) { + throw new Error( + `Expected at least ${EXPECTED_MIN_CONFIGS} rslib configs, found ${expectedConfigs.length}.`, + ); + } + + const resolvedProjects = await resolveProjects({ + harnessConfigPath: HARNESS_CONFIG_PATH, + rootDir: ROOT, + projectFilters: [], + }); + + const resolvedConfigSet = new Set( + resolvedProjects + .map((project) => project.configFile) + .filter(Boolean) + .map(getCanonicalPath), + ); + + const missingConfigs = expectedConfigs.filter( + (configPath) => !resolvedConfigSet.has(configPath), + ); + + if (missingConfigs.length > 0) { + const prettyMissing = missingConfigs + .map((configPath) => `- ${configPath.replace(`${ROOT}/`, '')}`) + .join('\n'); + throw new Error( + `Harness is missing ${missingConfigs.length} rslib config(s):\n${prettyMissing}`, + ); + } + + console.log( + `[verify-rslib-harness-coverage] Verified ${resolvedProjects.length} harness projects covering ${expectedConfigs.length} rslib config(s).`, + ); +} + +main().catch((error) => { + console.error( + `[verify-rslib-harness-coverage] ${error instanceof Error ? error.message : error}`, + ); + process.exit(1); +}); From fae10d44dab2fc8a8238e29a1368020a17f06cdc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:00:13 +0000 Subject: [PATCH 10/30] test: cover harness ignore patterns --- README.md | 6 ++++++ scripts/__tests__/rslib-harness.test.mjs | 25 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index dae7be6747f..ee7d2ef0ca8 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ Quick examples: pnpm run rslib:harness:inspect --project create-module-federation ``` +- Verify harness coverage against repo Rslib configs: + + ```bash + pnpm run verify:rslib-harness + ``` + The default root harness config is `rslib.harness.config.mjs`. ## ๐Ÿค Contribution diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 9aef0972cf8..2e4dbcced27 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -197,6 +197,31 @@ test('resolveProjects applies project filters', async () => { }); }); +test('resolveProjects respects ignore patterns in harness config', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + ignore: ['packages/pkg-b/**'], + projects: ['packages/*'], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-a'); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => From 758f2e1ac6adf7a7167e708308d65285062fcb45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:01:55 +0000 Subject: [PATCH 11/30] feat: add json output mode for harness listing --- README.md | 6 +++++ scripts/__tests__/rslib-harness.test.mjs | 3 ++- scripts/rslib-harness.mjs | 30 ++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee7d2ef0ca8..b4f3a11b255 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ Quick examples: pnpm run rslib:harness list ``` +- List resolved projects as JSON: + + ```bash + pnpm run rslib:harness list --json + ``` + - Build a single project by name/path filter: ```bash diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 2e4dbcced27..6d7269fbed0 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -55,9 +55,10 @@ test('parseCliArgs parses project filters, parallel and passthrough', () => { }); test('parseCliArgs enables list mode for list command', () => { - const parsed = parseCliArgs(['list', '--project', 'pkg-a']); + const parsed = parseCliArgs(['list', '--project', 'pkg-a', '--json']); assert.equal(parsed.command, 'list'); assert.equal(parsed.list, true); + assert.equal(parsed.json, true); assert.deepEqual(parsed.projectFilters, ['pkg-a']); }); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index bda66a08988..9a30b029d7d 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -37,6 +37,7 @@ Options: --parallel Concurrent project commands (default: ${DEFAULT_PARALLEL}) --dry-run Print commands without executing --list Print resolved projects before execution + --json Print resolved projects as JSON --continue-on-error Continue running remaining projects when one fails -h, --help Show help `); @@ -51,6 +52,7 @@ function parseCliArgs(argv) { parallel: DEFAULT_PARALLEL, dryRun: false, list: false, + json: false, continueOnError: false, passthroughArgs: [], }; @@ -116,6 +118,11 @@ function parseCliArgs(argv) { continue; } + if (arg === '--json') { + parsed.json = true; + continue; + } + if (arg === '--continue-on-error') { parsed.continueOnError = true; continue; @@ -598,7 +605,26 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { return filteredProjects; } -function printResolvedProjects(projects, rootDir) { +function printResolvedProjects(projects, rootDir, options = {}) { + if (options.json === true) { + const payload = projects.map((project) => { + const root = project.root.startsWith(rootDir) + ? project.root.slice(rootDir.length + 1) || '.' + : project.root; + const configPath = project.configFile?.startsWith(rootDir) + ? project.configFile.slice(rootDir.length + 1) || '.' + : (project.configFile ?? '(auto)'); + return { + name: project.name, + root, + config: configPath, + args: project.args, + }; + }); + console.log(JSON.stringify(payload, null, 2)); + return; + } + console.log(`[rslib-harness] Resolved ${projects.length} project(s):`); for (const project of projects) { const root = project.root.startsWith(rootDir) @@ -743,7 +769,7 @@ async function main() { } if (cli.list || cli.dryRun) { - printResolvedProjects(projects, cli.root); + printResolvedProjects(projects, cli.root, { json: cli.json }); } if (cli.command === 'list') { From af1ac716f851c67b92f7be27d2ec4587f6078efe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:03:41 +0000 Subject: [PATCH 12/30] test: validate harness json list output --- scripts/__tests__/rslib-harness.test.mjs | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 6d7269fbed0..15b2d577f06 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -1,14 +1,21 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { parseCliArgs, resolveProjects, validateCommandGuards, } from '../rslib-harness.mjs'; +const HARNESS_CLI_PATH = join( + dirname(fileURLToPath(import.meta.url)), + '../rslib-harness.mjs', +); + async function withTempDir(run) { const tempRoot = mkdtempSync(join(tmpdir(), 'rslib-harness-test-')); try { @@ -246,3 +253,38 @@ test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { /does not support --parallel > 1/, ); }); + +test('list --json emits machine-readable project metadata', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'list', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--json', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.length, 1); + assert.equal(payload[0]?.name, 'pkg-a'); + assert.equal(payload[0]?.root, 'packages/pkg-a'); + assert.equal(payload[0]?.config, 'packages/pkg-a/rslib.config.ts'); + assert.deepEqual(payload[0]?.args, []); + }); +}); From a065fd59fd3981782b4e576dbfd56d371e348c79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:04:46 +0000 Subject: [PATCH 13/30] test: add harness dedupe and filter failure coverage --- scripts/__tests__/rslib-harness.test.mjs | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 15b2d577f06..e3c9e57f989 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -205,6 +205,32 @@ test('resolveProjects applies project filters', async () => { }); }); +test('resolveProjects deduplicates projects resolved from multiple entries', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + 'packages/*', + 'packages/pkg-a/rslib.config.ts', + ], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-a'); + }); +}); + test('resolveProjects respects ignore patterns in harness config', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); @@ -230,6 +256,26 @@ export default { }); }); +test('resolveProjects throws when project filter has no matches', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + `export default { projects: ['packages/*'] };`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: ['does-not-exist'], + }), + /No projects matched filters/, + ); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => From 20e992a09e62170853346acf9547be7ac88a01c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:06:23 +0000 Subject: [PATCH 14/30] ci: run harness checks in build-and-test workflow --- .github/workflows/build-and-test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 58c5bc3c1a4..607683e2325 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -78,6 +78,12 @@ jobs: - name: Verify Publint Workflow Coverage run: node tools/scripts/verify-publint-workflow-coverage.mjs + - name: Run Rslib Harness Tests + run: pnpm run test:rslib-harness + + - name: Verify Rslib Harness Coverage + run: pnpm run verify:rslib-harness + - name: Print Number of CPU Cores run: nproc From 290057d23216ee090cb0273843e79e02b62c6c1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:08:30 +0000 Subject: [PATCH 15/30] ci: verify harness checks are wired in workflows --- .github/workflows/build-and-test.yml | 3 + package.json | 1 + tools/scripts/ci-local.mjs | 6 + ...verify-rslib-harness-workflow-coverage.mjs | 141 ++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 tools/scripts/verify-rslib-harness-workflow-coverage.mjs diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 607683e2325..a72d362845d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -84,6 +84,9 @@ jobs: - name: Verify Rslib Harness Coverage run: pnpm run verify:rslib-harness + - name: Verify Rslib Harness Workflow Coverage + run: pnpm run verify:rslib-harness:workflow + - name: Print Number of CPU Cores run: nproc diff --git a/package.json b/package.json index ec5dcae6e87..d3ce1b26a7b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "rslib:harness:inspect": "node ./scripts/rslib-harness.mjs inspect", "test:rslib-harness": "node --test ./scripts/__tests__/rslib-harness.test.mjs", "verify:rslib-harness": "node ./tools/scripts/verify-rslib-harness-coverage.mjs", + "verify:rslib-harness:workflow": "node ./tools/scripts/verify-rslib-harness-workflow-coverage.mjs", "build:pkg": "NX_TUI=false nx run-many --targets=build --projects=tag:type:pkg --skip-nx-cache", "test:pkg": "NX_TUI=false nx run-many --targets=test --projects=tag:type:pkg --skip-nx-cache", "lint-fix": "nx format:write --uncommitted", diff --git a/tools/scripts/ci-local.mjs b/tools/scripts/ci-local.mjs index 4f0bc576414..226bd57e7dc 100644 --- a/tools/scripts/ci-local.mjs +++ b/tools/scripts/ci-local.mjs @@ -79,6 +79,9 @@ const jobs = [ step('Verify Rslib Harness Coverage', (ctx) => runCommand('pnpm', ['run', 'verify:rslib-harness'], ctx), ), + step('Verify Rslib Harness Workflow Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness:workflow'], ctx), + ), step('Print number of CPU cores', (ctx) => runCommand('nproc', [], ctx)), step('Build packages (cold cache)', (ctx) => runCommand( @@ -189,6 +192,9 @@ const jobs = [ step('Verify Rslib Harness Coverage', (ctx) => runCommand('pnpm', ['run', 'verify:rslib-harness'], ctx), ), + step('Verify Rslib Harness Workflow Coverage', (ctx) => + runCommand('pnpm', ['run', 'verify:rslib-harness:workflow'], ctx), + ), step('Build all required packages', (ctx) => runCommand( 'npx', diff --git a/tools/scripts/verify-rslib-harness-workflow-coverage.mjs b/tools/scripts/verify-rslib-harness-workflow-coverage.mjs new file mode 100644 index 00000000000..a296a3418ed --- /dev/null +++ b/tools/scripts/verify-rslib-harness-workflow-coverage.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(SCRIPT_DIR, '../..'); +const BUILD_AND_TEST_WORKFLOW_PATH = resolve( + ROOT, + '.github/workflows/build-and-test.yml', +); +const CI_LOCAL_PATH = resolve(ROOT, 'tools/scripts/ci-local.mjs'); +const PACKAGE_JSON_PATH = resolve(ROOT, 'package.json'); + +const REQUIRED_PACKAGE_SCRIPTS = ['test:rslib-harness', 'verify:rslib-harness']; +const REQUIRED_WORKFLOW_STEPS = [ + { + name: 'Run Rslib Harness Tests', + runPattern: /pnpm run test:rslib-harness/, + }, + { + name: 'Verify Rslib Harness Coverage', + runPattern: /pnpm run verify:rslib-harness/, + }, +]; + +function fail(message) { + console.error(`[verify-rslib-harness-workflow-coverage] ${message}`); + process.exit(1); +} + +function readYaml(path) { + try { + return yaml.load(readFileSync(path, 'utf8')); + } catch (error) { + fail( + `Failed to read/parse YAML file ${path}: ${ + error instanceof Error ? error.message : error + }`, + ); + } +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail( + `Failed to read/parse JSON file ${path}: ${ + error instanceof Error ? error.message : error + }`, + ); + } +} + +function assertFileExists(path) { + if (!existsSync(path)) { + fail(`Required file does not exist: ${path}`); + } +} + +function assertPackageScripts(packageJson) { + const scripts = packageJson?.scripts ?? {}; + const missing = REQUIRED_PACKAGE_SCRIPTS.filter( + (scriptName) => typeof scripts[scriptName] !== 'string', + ); + + if (missing.length > 0) { + fail( + `Missing required package scripts: ${missing.join(', ')} in ${PACKAGE_JSON_PATH}`, + ); + } +} + +function assertWorkflowSteps(workflow) { + const steps = workflow?.jobs?.['checkout-install']?.steps; + if (!Array.isArray(steps)) { + fail( + `Unable to locate checkout-install steps in ${BUILD_AND_TEST_WORKFLOW_PATH}`, + ); + } + + for (const requiredStep of REQUIRED_WORKFLOW_STEPS) { + const step = steps.find( + (candidate) => candidate?.name === requiredStep.name, + ); + if (!step) { + fail( + `Missing workflow step "${requiredStep.name}" in ${BUILD_AND_TEST_WORKFLOW_PATH}`, + ); + } + + if ( + typeof step.run !== 'string' || + !requiredStep.runPattern.test(step.run) + ) { + fail( + `Workflow step "${requiredStep.name}" does not match expected run command pattern ${requiredStep.runPattern}`, + ); + } + } +} + +function assertCiLocalSteps(ciLocalText) { + for (const requiredStep of REQUIRED_WORKFLOW_STEPS) { + const escapedStepName = requiredStep.name.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ); + const stepRegex = new RegExp(`step\\('${escapedStepName}'`, 'g'); + const matches = ciLocalText.match(stepRegex) ?? []; + if (matches.length < 2) { + fail( + `Expected ci-local to include step "${requiredStep.name}" in both build-and-test and build-metro jobs (found ${matches.length}).`, + ); + } + } +} + +function main() { + process.chdir(ROOT); + + assertFileExists(PACKAGE_JSON_PATH); + assertFileExists(BUILD_AND_TEST_WORKFLOW_PATH); + assertFileExists(CI_LOCAL_PATH); + + const packageJson = readJson(PACKAGE_JSON_PATH); + const workflow = readYaml(BUILD_AND_TEST_WORKFLOW_PATH); + const ciLocalText = readFileSync(CI_LOCAL_PATH, 'utf8'); + + assertPackageScripts(packageJson); + assertWorkflowSteps(workflow); + assertCiLocalSteps(ciLocalText); + + console.log( + '[verify-rslib-harness-workflow-coverage] Verified harness checks in package scripts, GitHub workflow, and ci-local jobs.', + ); +} + +main(); From 3415554070a77aa8eea353d10c305d967befe155 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:10:02 +0000 Subject: [PATCH 16/30] feat: make json mode auto-enable project listing --- README.md | 2 ++ scripts/__tests__/rslib-harness.test.mjs | 7 +++++++ scripts/rslib-harness.mjs | 1 + 3 files changed, 10 insertions(+) diff --git a/README.md b/README.md index b4f3a11b255..2608febc9d8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ Quick examples: pnpm run rslib:harness list --json ``` + `--json` also implies list output when used with other commands. + - Build a single project by name/path filter: ```bash diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index e3c9e57f989..448a9b1820d 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -69,6 +69,13 @@ test('parseCliArgs enables list mode for list command', () => { assert.deepEqual(parsed.projectFilters, ['pkg-a']); }); +test('parseCliArgs enables list mode when --json is passed', () => { + const parsed = parseCliArgs(['build', '--json']); + assert.equal(parsed.command, 'build'); + assert.equal(parsed.json, true); + assert.equal(parsed.list, true); +}); + test('resolveProjects discovers projects from glob entries', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 9a30b029d7d..c6daf12cd9e 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -120,6 +120,7 @@ function parseCliArgs(argv) { if (arg === '--json') { parsed.json = true; + parsed.list = true; continue; } From 798010057235ce2432b34ecca1ef701d05a39ea6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:12:30 +0000 Subject: [PATCH 17/30] feat: emit structured command plans for json dry-runs --- README.md | 6 ++ scripts/__tests__/rslib-harness.test.mjs | 42 ++++++++++++++ scripts/rslib-harness.mjs | 74 +++++++++++++++++------- 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2608febc9d8..96b6b91b4c0 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ Quick examples: `--json` also implies list output when used with other commands. +- Emit a machine-readable dry-run command plan: + + ```bash + pnpm run rslib:harness:build --project create-module-federation --json --dry-run + ``` + - Build a single project by name/path filter: ```bash diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 448a9b1820d..63df2077c4a 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -341,3 +341,45 @@ test('list --json emits machine-readable project metadata', async () => { assert.deepEqual(payload[0]?.args, []); }); }); + +test('build --json --dry-run emits machine-readable command plan', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'build', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--project', + 'pkg-a', + '--json', + '--dry-run', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.command, 'build'); + assert.equal(payload.dryRun, true); + assert.equal(payload.projects.length, 1); + assert.equal(payload.projects[0]?.name, 'pkg-a'); + assert.equal(payload.commands.length, 1); + assert.match( + payload.commands[0]?.command ?? '', + /^pnpm exec rslib build --config /, + ); + }); +}); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index c6daf12cd9e..964beb83eef 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -325,6 +325,33 @@ function shouldFilterProject(project, projectFilters) { }); } +function toDisplayPath(pathValue, rootDir, fallback = '(auto)') { + if (!pathValue) { + return fallback; + } + return pathValue.startsWith(rootDir) + ? pathValue.slice(rootDir.length + 1) || '.' + : pathValue; +} + +function toProjectOutput(project, rootDir) { + return { + name: project.name, + root: toDisplayPath(project.root, rootDir, '.'), + config: toDisplayPath(project.configFile, rootDir, '(auto)'), + args: project.args, + }; +} + +function getProjectCommandArgs(project, command, passthroughArgs) { + const args = ['exec', 'rslib', command, ...project.args]; + if (project.configFile) { + args.push('--config', project.configFile); + } + args.push(...passthroughArgs); + return args; +} + async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { const dedupeMap = new Map(); const projectByName = new Map(); @@ -608,20 +635,9 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { function printResolvedProjects(projects, rootDir, options = {}) { if (options.json === true) { - const payload = projects.map((project) => { - const root = project.root.startsWith(rootDir) - ? project.root.slice(rootDir.length + 1) || '.' - : project.root; - const configPath = project.configFile?.startsWith(rootDir) - ? project.configFile.slice(rootDir.length + 1) || '.' - : (project.configFile ?? '(auto)'); - return { - name: project.name, - root, - config: configPath, - args: project.args, - }; - }); + const payload = projects.map((project) => + toProjectOutput(project, rootDir), + ); console.log(JSON.stringify(payload, null, 2)); return; } @@ -644,11 +660,7 @@ function printResolvedProjects(projects, rootDir, options = {}) { } function spawnProjectCommand({ project, command, passthroughArgs, dryRun }) { - const args = ['exec', 'rslib', command, ...project.args]; - if (project.configFile) { - args.push('--config', project.configFile); - } - args.push(...passthroughArgs); + const args = getProjectCommandArgs(project, command, passthroughArgs); const commandLine = `pnpm ${args.join(' ')}`; console.log(`[rslib-harness] ${project.name}: ${commandLine}`); @@ -769,14 +781,34 @@ async function main() { throw new Error('No projects were resolved from harness config.'); } - if (cli.list || cli.dryRun) { + if (cli.command === 'list') { printResolvedProjects(projects, cli.root, { json: cli.json }); + return; } - if (cli.command === 'list') { + if (cli.json && cli.dryRun) { + const payload = { + command: cli.command, + dryRun: true, + projects: projects.map((project) => toProjectOutput(project, cli.root)), + commands: projects.map((project) => ({ + name: project.name, + cwd: toDisplayPath(project.root, cli.root, '.'), + command: `pnpm ${getProjectCommandArgs( + project, + cli.command, + cli.passthroughArgs, + ).join(' ')}`, + })), + }; + console.log(JSON.stringify(payload, null, 2)); return; } + if (cli.list || cli.dryRun) { + printResolvedProjects(projects, cli.root, { json: cli.json }); + } + validateCommandGuards({ command: cli.command, passthroughArgs: cli.passthroughArgs, From 99da96010ebad84a33031cab8901ac411daec295 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:14:13 +0000 Subject: [PATCH 18/30] test: enforce deterministic harness project ordering --- scripts/__tests__/rslib-harness.test.mjs | 30 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 10 ++++++++ 2 files changed, 40 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 63df2077c4a..95f79bb1373 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -238,6 +238,36 @@ export default { }); }); +test('resolveProjects returns deterministic sorted project ordering', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + 'packages/pkg-b', + 'packages/pkg-a', + ], +}; +`, + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }); + + assert.equal(projects.length, 2); + assert.deepEqual( + projects.map((project) => project.name), + ['pkg-a', 'pkg-b'], + ); + }); +}); + test('resolveProjects respects ignore patterns in harness config', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 964beb83eef..1d6be9f5c34 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -620,6 +620,16 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { inheritedArgs: [], }); + resolvedProjects.sort((left, right) => { + if (left.root !== right.root) { + return left.root.localeCompare(right.root); + } + if (left.name !== right.name) { + return left.name.localeCompare(right.name); + } + return (left.configFile ?? '').localeCompare(right.configFile ?? ''); + }); + const filteredProjects = resolvedProjects.filter((project) => shouldFilterProject(project, projectFilters), ); From 77d329b5cd01224aa3fff6fe458710474ef2f31e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:15:53 +0000 Subject: [PATCH 19/30] test: assert sorted json dry-run command plans --- scripts/__tests__/rslib-harness.test.mjs | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 95f79bb1373..9ad59f62b00 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -413,3 +413,50 @@ test('build --json --dry-run emits machine-readable command plan', async () => { ); }); }); + +test('build --json --dry-run command plan follows deterministic project order', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + 'packages/pkg-b', + 'packages/pkg-a', + ], +}; +`, + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'build', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--json', + '--dry-run', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.deepEqual( + payload.projects.map((project) => project.name), + ['pkg-a', 'pkg-b'], + ); + assert.deepEqual( + payload.commands.map((command) => command.name), + ['pkg-a', 'pkg-b'], + ); + }); +}); From a044010bb45d38e80384a6a6643efdb80e585726 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:18:44 +0000 Subject: [PATCH 20/30] fix: require dry-run for json command output --- README.md | 2 ++ scripts/__tests__/rslib-harness.test.mjs | 33 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 6 +++++ 3 files changed, 41 insertions(+) diff --git a/README.md b/README.md index 96b6b91b4c0..30e3500efb7 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Quick examples: pnpm run rslib:harness:build --project create-module-federation --json --dry-run ``` + For non-`list` commands, `--json` is only supported with `--dry-run`. + - Build a single project by name/path filter: ```bash diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 9ad59f62b00..6bebddfff19 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -460,3 +460,36 @@ export default { ); }); }); + +test('build --json without --dry-run fails fast', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const result = spawnSync( + process.execPath, + [ + HARNESS_CLI_PATH, + 'build', + '--root', + root, + '--config', + join(root, 'rslib.harness.config.mjs'), + '--json', + ], + { + cwd: root, + encoding: 'utf8', + }, + ); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /--json requires list mode or --dry-run to avoid mixed structured and live command output/, + ); + }); +}); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 1d6be9f5c34..c2ce6bca6cb 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -781,6 +781,12 @@ async function main() { ? cli.config : resolve(cli.root, cli.config); + if (cli.json && cli.command !== 'list' && !cli.dryRun) { + throw new Error( + '--json requires list mode or --dry-run to avoid mixed structured and live command output.', + ); + } + const projects = await resolveProjects({ harnessConfigPath, rootDir: cli.root, From 200ff0a3521022265f3d29f63b79d9be12dc0cbe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:20:32 +0000 Subject: [PATCH 21/30] fix: validate harness config entry shapes --- scripts/__tests__/rslib-harness.test.mjs | 54 +++++++++++++++ scripts/rslib-harness.mjs | 85 ++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 6bebddfff19..4ab9f518f33 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -313,6 +313,60 @@ test('resolveProjects throws when project filter has no matches', async () => { }); }); +test('resolveProjects validates defaults.args as string array', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: { args: [true] }, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"defaults\.args" must be an array of strings/, + ); + }); +}); + +test('resolveProjects validates project args as string array', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + args: ['--lib', 123], + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]\.args" must be an array of strings/, + ); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index c2ce6bca6cb..d02750f3d68 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -258,12 +258,97 @@ async function loadHarnessConfig(configPath) { ); } + validateHarnessConfigShape(config, absolutePath); + return { path: absolutePath, config, }; } +function assertStringArray(value, pathLabel, configPath) { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}" must be an array of strings.`, + ); + } +} + +function validateProjectEntryShape(entry, pathLabel, configPath) { + if (typeof entry === 'string') { + return; + } + + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}" must be a string or object entry.`, + ); + } + + if (entry.name !== undefined && typeof entry.name !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.name" must be a string.`, + ); + } + + if (entry.root !== undefined && typeof entry.root !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.root" must be a string.`, + ); + } + + if (entry.config !== undefined && typeof entry.config !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.config" must be a string.`, + ); + } + + if (entry.args !== undefined) { + assertStringArray(entry.args, `${pathLabel}.args`, configPath); + } + + if (entry.ignore !== undefined) { + assertStringArray(entry.ignore, `${pathLabel}.ignore`, configPath); + } + + if (entry.projects !== undefined) { + if (!Array.isArray(entry.projects)) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}.projects" must be an array.`, + ); + } + entry.projects.forEach((childEntry, index) => + validateProjectEntryShape( + childEntry, + `${pathLabel}.projects[${index}]`, + configPath, + ), + ); + } +} + +function validateHarnessConfigShape(config, configPath) { + if (config.ignore !== undefined) { + assertStringArray(config.ignore, 'ignore', configPath); + } + + if (config.defaults !== undefined) { + if (!config.defaults || typeof config.defaults !== 'object') { + throw new Error( + `Invalid harness config at ${configPath}: "defaults" must be an object.`, + ); + } + + if (config.defaults.args !== undefined) { + assertStringArray(config.defaults.args, 'defaults.args', configPath); + } + } + + config.projects.forEach((entry, index) => + validateProjectEntryShape(entry, `projects[${index}]`, configPath), + ); +} + function mergeIgnorePatterns(parentIgnore, configIgnore) { const combined = [ ...parentIgnore, From af59ac21da64acd7abd567264a28fb4c15cec6b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:22:06 +0000 Subject: [PATCH 22/30] test: expand parser and filter validation coverage --- scripts/__tests__/rslib-harness.test.mjs | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 4ab9f518f33..bb6d3d5ca3f 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -76,6 +76,13 @@ test('parseCliArgs enables list mode when --json is passed', () => { assert.equal(parsed.list, true); }); +test('parseCliArgs rejects invalid --parallel values', () => { + assert.throws( + () => parseCliArgs(['build', '--parallel', '0']), + /Invalid --parallel value "0"/, + ); +}); + test('resolveProjects discovers projects from glob entries', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); @@ -212,6 +219,26 @@ test('resolveProjects applies project filters', async () => { }); }); +test('resolveProjects applies project filters by path case-insensitively', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeRslibProject(root, 'packages/pkg-b', 'pkg-b'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + 'export default { projects: ["packages/*"] };\n', + ); + + const projects = await resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: ['PACKAGES/PKG-B'], + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0]?.name, 'pkg-b'); + }); +}); + test('resolveProjects deduplicates projects resolved from multiple entries', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); @@ -338,6 +365,31 @@ export default { }); }); +test('resolveProjects validates defaults as object', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: true, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"defaults" must be an object/, + ); + }); +}); + test('resolveProjects validates project args as string array', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); From 980a43d2b3be7d4eb1f4fb18b813fff295431aa9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:23:44 +0000 Subject: [PATCH 23/30] fix: reject unknown harness config keys --- scripts/__tests__/rslib-harness.test.mjs | 82 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 43 +++++++++++++ 2 files changed, 125 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index bb6d3d5ca3f..7cbe7cd119a 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -390,6 +390,59 @@ export default { }); }); +test('resolveProjects rejects unknown top-level config keys', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: ['packages/*'], + mystery: true, +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /unknown top-level keys: mystery/, + ); + }); +}); + +test('resolveProjects rejects unknown defaults keys', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: { + args: ['--lib', 'esm'], + extra: true, + }, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /unknown defaults keys: extra/, + ); + }); +}); + test('resolveProjects validates project args as string array', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); @@ -419,6 +472,35 @@ export default { }); }); +test('resolveProjects rejects unknown project entry keys', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + featureFlag: true, + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]" has unknown keys: featureFlag/, + ); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index d02750f3d68..a653979b1d2 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -325,9 +325,40 @@ function validateProjectEntryShape(entry, pathLabel, configPath) { ), ); } + + const allowedEntryKeys = new Set([ + 'name', + 'root', + 'config', + 'args', + 'ignore', + 'projects', + ]); + const unknownEntryKeys = Object.keys(entry).filter( + (key) => !allowedEntryKeys.has(key), + ); + if (unknownEntryKeys.length > 0) { + throw new Error( + `Invalid harness config at ${configPath}: "${pathLabel}" has unknown keys: ${unknownEntryKeys.join( + ', ', + )}.`, + ); + } } function validateHarnessConfigShape(config, configPath) { + const allowedConfigKeys = new Set(['root', 'ignore', 'defaults', 'projects']); + const unknownConfigKeys = Object.keys(config).filter( + (key) => !allowedConfigKeys.has(key), + ); + if (unknownConfigKeys.length > 0) { + throw new Error( + `Invalid harness config at ${configPath}: unknown top-level keys: ${unknownConfigKeys.join( + ', ', + )}.`, + ); + } + if (config.ignore !== undefined) { assertStringArray(config.ignore, 'ignore', configPath); } @@ -342,6 +373,18 @@ function validateHarnessConfigShape(config, configPath) { if (config.defaults.args !== undefined) { assertStringArray(config.defaults.args, 'defaults.args', configPath); } + + const allowedDefaultsKeys = new Set(['args']); + const unknownDefaultsKeys = Object.keys(config.defaults).filter( + (key) => !allowedDefaultsKeys.has(key), + ); + if (unknownDefaultsKeys.length > 0) { + throw new Error( + `Invalid harness config at ${configPath}: unknown defaults keys: ${unknownDefaultsKeys.join( + ', ', + )}.`, + ); + } } config.projects.forEach((entry, index) => From 15ae47b5f7e6217732129072f376d2196548ca35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:25:25 +0000 Subject: [PATCH 24/30] test: broaden parseCliArgs failure case coverage --- scripts/__tests__/rslib-harness.test.mjs | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 7cbe7cd119a..db9bc42b843 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -83,6 +83,34 @@ test('parseCliArgs rejects invalid --parallel values', () => { ); }); +test('parseCliArgs rejects unknown command values', () => { + assert.throws(() => parseCliArgs(['deploy']), /Unknown command "deploy"/); +}); + +test('parseCliArgs supports inline option assignments', () => { + const parsed = parseCliArgs([ + 'build', + '--project=pkg-a,pkg-b', + '--parallel=2', + ]); + assert.deepEqual(parsed.projectFilters, ['pkg-a', 'pkg-b']); + assert.equal(parsed.parallel, 2); +}); + +test('parseCliArgs rejects unknown options', () => { + assert.throws( + () => parseCliArgs(['build', '--mystery']), + /Unknown option "--mystery"/, + ); +}); + +test('parseCliArgs rejects missing option values', () => { + assert.throws(() => parseCliArgs(['build', '--config']), /Missing value/); + assert.throws(() => parseCliArgs(['build', '--root']), /Missing value/); + assert.throws(() => parseCliArgs(['build', '--project']), /Missing value/); + assert.throws(() => parseCliArgs(['build', '--parallel']), /Missing value/); +}); + test('resolveProjects discovers projects from glob entries', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); From 66b1040b9720a886f243959cbaaece1dd9c5d817 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:26:58 +0000 Subject: [PATCH 25/30] test: cover nested schema validation errors --- scripts/__tests__/rslib-harness.test.mjs | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index db9bc42b843..0dad6fa7f77 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -529,6 +529,67 @@ export default { }); }); +test('resolveProjects validates project root type', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: 123, + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]\.root" must be a string/, + ); + }); +}); + +test('resolveProjects validates nested project entry keys recursively', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + projects: [ + { + root: './packages/pkg-a', + mystery: true, + }, + ], + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"projects\[0\]\.projects\[0\]" has unknown keys: mystery/, + ); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => From 806750392d6f103014cd7f14e23f774138f69ed8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:31:50 +0000 Subject: [PATCH 26/30] fix: validate harness root type --- scripts/__tests__/rslib-harness.test.mjs | 25 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 6 ++++++ 2 files changed, 31 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 0dad6fa7f77..8a46ecf7cc5 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -418,6 +418,31 @@ export default { }); }); +test('resolveProjects validates top-level root as string', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + root: 123, + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"root" must be a string/, + ); + }); +}); + test('resolveProjects rejects unknown top-level config keys', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index a653979b1d2..2d2a4af2c40 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -359,6 +359,12 @@ function validateHarnessConfigShape(config, configPath) { ); } + if (config.root !== undefined && typeof config.root !== 'string') { + throw new Error( + `Invalid harness config at ${configPath}: "root" must be a string.`, + ); + } + if (config.ignore !== undefined) { assertStringArray(config.ignore, 'ignore', configPath); } From efdd0f399c0d6a1cd0c9f30b6b571a083d2342e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:33:13 +0000 Subject: [PATCH 27/30] fix: reject array defaults in harness config --- scripts/__tests__/rslib-harness.test.mjs | 25 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 6 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index 8a46ecf7cc5..adf2d14f9b7 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -418,6 +418,31 @@ export default { }); }); +test('resolveProjects rejects defaults array shape', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + defaults: [], + projects: ['packages/*'], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /"defaults" must be an object/, + ); + }); +}); + test('resolveProjects validates top-level root as string', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 2d2a4af2c40..c7ef1029147 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -370,7 +370,11 @@ function validateHarnessConfigShape(config, configPath) { } if (config.defaults !== undefined) { - if (!config.defaults || typeof config.defaults !== 'object') { + if ( + !config.defaults || + typeof config.defaults !== 'object' || + Array.isArray(config.defaults) + ) { throw new Error( `Invalid harness config at ${configPath}: "defaults" must be an object.`, ); From 32bb5ea15873535a1dd2fe43487628d9e1a718aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:34:36 +0000 Subject: [PATCH 28/30] feat: support inline config and root options --- scripts/__tests__/rslib-harness.test.mjs | 15 +++++++++++++++ scripts/rslib-harness.mjs | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index adf2d14f9b7..f9a3f4abe4c 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -90,13 +90,28 @@ test('parseCliArgs rejects unknown command values', () => { test('parseCliArgs supports inline option assignments', () => { const parsed = parseCliArgs([ 'build', + '--config=./custom.harness.mjs', + '--root=./apps', '--project=pkg-a,pkg-b', '--parallel=2', ]); + assert.equal(parsed.config, './custom.harness.mjs'); + assert.match(parsed.root, /\/apps$/); assert.deepEqual(parsed.projectFilters, ['pkg-a', 'pkg-b']); assert.equal(parsed.parallel, 2); }); +test('parseCliArgs rejects empty inline config/root assignments', () => { + assert.throws( + () => parseCliArgs(['build', '--config=']), + /Missing value for --config/, + ); + assert.throws( + () => parseCliArgs(['build', '--root=']), + /Missing value for --root/, + ); +}); + test('parseCliArgs rejects unknown options', () => { assert.throws( () => parseCliArgs(['build', '--mystery']), diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index c7ef1029147..5c45c7489e0 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -136,6 +136,22 @@ function parseCliArgs(argv) { continue; } + if (arg.startsWith('--config=')) { + parsed.config = arg.slice('--config='.length); + if (!parsed.config) { + throw new Error('Missing value for --config.'); + } + continue; + } + + if (arg.startsWith('--root=')) { + parsed.root = arg.slice('--root='.length); + if (!parsed.root) { + throw new Error('Missing value for --root.'); + } + continue; + } + if (arg.startsWith('--parallel=')) { const value = arg.slice('--parallel='.length); const parallel = Number.parseInt(value, 10); From 3673ee97695683fd9fc4c482d3bece97235ceae9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:36:07 +0000 Subject: [PATCH 29/30] fix: validate explicit project config filenames --- scripts/__tests__/rslib-harness.test.mjs | 33 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 6 +++++ 2 files changed, 39 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index f9a3f4abe4c..d3c878ac18f 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -655,6 +655,39 @@ export default { }); }); +test('resolveProjects rejects unsupported explicit project config files', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + writeFile( + join(root, 'packages/pkg-a/custom.config.ts'), + 'export default {};\n', + ); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + config: './custom.config.ts', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /is not a supported rslib\.config\.\* file/, + ); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 5c45c7489e0..3951759f51b 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -731,6 +731,12 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { ); } + if (!isRslibConfigFile(explicitConfigFile)) { + throw new Error( + `Project config path "${explicitConfigFile}" is not a supported rslib.config.* file (from ${sourceConfig}).`, + ); + } + await addProject({ name: entry.name, projectRoot: dirname(explicitConfigFile), From b46cf17ac79df3c0c851d9919ced40298de8998f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Feb 2026 03:37:55 +0000 Subject: [PATCH 30/30] fix: harden object entry path validation --- scripts/__tests__/rslib-harness.test.mjs | 89 ++++++++++++++++++++++++ scripts/rslib-harness.mjs | 18 +++++ 2 files changed, 107 insertions(+) diff --git a/scripts/__tests__/rslib-harness.test.mjs b/scripts/__tests__/rslib-harness.test.mjs index d3c878ac18f..c3b77064b7a 100644 --- a/scripts/__tests__/rslib-harness.test.mjs +++ b/scripts/__tests__/rslib-harness.test.mjs @@ -622,6 +622,63 @@ export default { }); }); +test('resolveProjects rejects missing project root paths', async () => { + await withTempDir(async (root) => { + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/missing-pkg', + config: './rslib.config.ts', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /resolved root ".*packages\/missing-pkg" but the path does not exist/, + ); + }); +}); + +test('resolveProjects rejects non-directory project roots', async () => { + await withTempDir(async (root) => { + writeFile(join(root, 'packages/pkg-a.txt'), 'not a directory\n'); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a.txt', + config: './rslib.config.ts', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /resolved root ".*packages\/pkg-a\.txt" but it is not a directory/, + ); + }); +}); + test('resolveProjects validates nested project entry keys recursively', async () => { await withTempDir(async (root) => { writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); @@ -688,6 +745,38 @@ export default { }); }); +test('resolveProjects rejects explicit config paths that are not files', async () => { + await withTempDir(async (root) => { + writeRslibProject(root, 'packages/pkg-a', 'pkg-a'); + mkdirSync(join(root, 'packages/pkg-a/rslib.config.js'), { + recursive: true, + }); + writeFile( + join(root, 'rslib.harness.config.mjs'), + ` +export default { + projects: [ + { + root: './packages/pkg-a', + config: './rslib.config.js', + }, + ], +}; +`, + ); + + await assert.rejects( + () => + resolveProjects({ + harnessConfigPath: join(root, 'rslib.harness.config.mjs'), + rootDir: root, + projectFilters: [], + }), + /is not a file/, + ); + }); +}); + test('validateCommandGuards rejects multi-project watch/mf-dev mode', () => { assert.throws( () => diff --git a/scripts/rslib-harness.mjs b/scripts/rslib-harness.mjs index 3951759f51b..61bf8da7b9c 100644 --- a/scripts/rslib-harness.mjs +++ b/scripts/rslib-harness.mjs @@ -696,6 +696,18 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { const objectRootDir = entry.root ? resolveWithRootDirToken(entry.root, entryRootDir) : entryRootDir; + if (!existsSync(objectRootDir)) { + throw new Error( + `Project entry in ${sourceConfig} resolved root "${objectRootDir}" but the path does not exist.`, + ); + } + + if (!statSync(objectRootDir).isDirectory()) { + throw new Error( + `Project entry in ${sourceConfig} resolved root "${objectRootDir}" but it is not a directory.`, + ); + } + const objectIgnore = mergeIgnorePatterns(ignorePatterns, entry.ignore); const objectArgs = [ ...inheritedArgs, @@ -737,6 +749,12 @@ async function resolveProjects({ harnessConfigPath, rootDir, projectFilters }) { ); } + if (!statSync(explicitConfigFile).isFile()) { + throw new Error( + `Project config path "${explicitConfigFile}" is not a file (from ${sourceConfig}).`, + ); + } + await addProject({ name: entry.name, projectRoot: dirname(explicitConfigFile),