From 5409a2f8bbcf3c21b2a277693fddc51374200f59 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Sun, 4 Oct 2026 19:58:59 +0900 Subject: [PATCH 1/6] feat: implement new config resolution --- README.md | 72 ++++++++++++++++------ package.json | 7 ++- schema.json | 37 +++++++++++ src/cli/commands/check.ts | 39 +++++++----- src/cli/commands/sync.ts | 64 +++++++++++-------- src/core/{ => config}/define-config.ts | 5 +- src/core/config/index.ts | 2 + src/core/config/resolve-config.ts | 85 ++++++++++++++++++++++++++ src/core/config/schema.ts | 27 ++++++++ src/core/config/types.ts | 8 +++ src/core/index.ts | 5 +- src/core/resolve-config.ts | 14 ----- tsconfig.json | 2 + tsdown.config.ts | 15 +++++ yarn.lock | 23 +++++++ 15 files changed, 328 insertions(+), 77 deletions(-) create mode 100644 schema.json rename src/core/{ => config}/define-config.ts (55%) create mode 100644 src/core/config/index.ts create mode 100644 src/core/config/resolve-config.ts create mode 100644 src/core/config/schema.ts create mode 100644 src/core/config/types.ts delete mode 100644 src/core/resolve-config.ts diff --git a/README.md b/README.md index 190d982..0407056 100644 --- a/README.md +++ b/README.md @@ -43,42 +43,78 @@ pnpm add -D @pull-up/cli ## Configuration -Create a config file in your project root: +Create `pullup.yml` or `pullup.yaml` in your project root: -```ts -import { defineConfig, codeownersJob } from '@pull-up/cli'; - -export default defineConfig(codeownersJob()); -// or export default defineConfig([codeownersJob()]); +```yaml +jobs: + codeowners: + type: codeowners ``` +`jobs` maps job identifiers to their settings. Configuration is validated before jobs run; invalid settings cause the command to exit with code 1. + +`pullup.yaml` is searched before `pullup.yml`. + ### Built-in Jobs -#### `codeownersJob` +#### `codeowners` Collects `CODEOWNERS` files from your monorepo and merges them into a single file. +```yaml +jobs: + codeowners: + type: codeowners + input: + - '**/CODEOWNERS' + - '!**/fixtures/**' + output: .github/CODEOWNERS +``` + +| Field | Type | Required | Default | +| -------- | ------------ | -------- | -------------------- | +| `type` | `codeowners` | Yes | | +| `input` | `string[]` | No | `['**/CODEOWNERS']` | +| `output` | `string` | No | `.github/CODEOWNERS` | + +Input patterns and the output path are relative to the repository root. Prefix an input pattern with `!` to exclude matching files. The output file is automatically excluded from the input. + +### Editor Support + +The package includes `schema.json` for autocomplete and validation in editors that support YAML Language Server. In VS Code, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and add this line at the top of your config: + +```yaml +# yaml-language-server: $schema=./node_modules/@pull-up/cli/schema.json +``` + +The path is relative to the YAML file. For Yarn Plug'n'Play or a remote schema, use `https://unpkg.com/@pull-up/cli@/schema.json`, replacing `` with your installed package version. + +### Legacy JavaScript and TypeScript Configuration + +Existing `pullup.config.ts`, `pullup.config.js`, `pullup.config.cjs`, `pullup.config.mjs`, `.pulluprc.js`, and `.pulluprc.cjs` files remain supported with a deprecation warning. + +For example, in `pullup.config.ts`: + ```ts -codeownersJob({ - from: ['**/CODEOWNERS'], // default - output: '.github/CODEOWNERS', // default -}); +import { defineConfig, codeownersJob } from '@pull-up/cli'; + +export default defineConfig(codeownersJob()); ``` -### Custom Jobs +YAML and legacy configuration files cannot coexist. Configuration in the `package.json` `pullup` field is no longer supported. + +#### Custom Jobs -You can define custom jobs using `defineJob`: +YAML currently supports built-in jobs only. To define a custom job, use `defineJob` in a JavaScript or TypeScript config: ```ts import { defineConfig, defineJob } from '@pull-up/cli'; const myJob = defineJob({ name: 'my-job', - from: ['packages/*/config.json'], + input: ['packages/*/config.json'], output: 'merged-config.json', - transform: (sources, context) => { - // sources: array of { path, contents } - // context: { root, outputPath, existingContents } + transform: (sources) => { return JSON.stringify(sources.map((s) => JSON.parse(s.contents))); }, }); @@ -86,6 +122,8 @@ const myJob = defineJob({ export default defineConfig([myJob()]); ``` +Transform functions receive source files as `{ path, contents }` objects and a context containing `rootDir` and `outputPath`. They can return a string or a promise of a string. + ## Usage ### Sync diff --git a/package.json b/package.json index c1f7c3e..2fda3f3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "url": "git+https://github.com/toss/pull-up.git" }, "files": [ - "dist" + "dist", + "schema.json" ], "scripts": { "build": "tsdown", @@ -49,6 +50,7 @@ "@eslint/js": "^9.39.2", "@fixture-kit/core": "^1.0.1", "@types/node": "^24.10.0", + "@valibot/to-json-schema": "^1.8.0", "eslint": "^9.7.0", "eslint-plugin-import-x": "^4.0.0", "eslint-plugin-simple-import-sort": "^12.1.1", @@ -64,7 +66,8 @@ "clipanion": "^4.0.0-rc.4", "cosmiconfig": "^9.0.0", "fast-glob": "^3.3.3", - "find-up": "^8.0.0" + "find-up": "^8.0.0", + "valibot": "^1.5.0" }, "engines": { "node": ">=22.8.0" diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..2c0fdf1 --- /dev/null +++ b/schema.json @@ -0,0 +1,37 @@ +{ + "type": "object", + "properties": { + "jobs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "const": "codeowners" + }, + "input": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Input file paths for the job", + "examples": [["**/CODEOWNERS", "!**/fixtures/**"]] + }, + "output": { + "type": "string", + "description": "Output file path for the job", + "examples": ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"] + } + }, + "required": ["type"], + "additionalProperties": false + } + } + }, + "required": ["jobs"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index dc4ac0d..551bb68 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -1,8 +1,9 @@ import { styleText } from 'node:util'; import { Command, Option } from 'clipanion'; +import * as v from 'valibot'; -import { resolveConfig } from '../../core/resolve-config'; +import { resolveConfig } from '../../core'; import { runJob } from '../../core/run-job'; import { resolveRepositoryRoot } from '../utils'; @@ -24,23 +25,33 @@ export class CheckCommand extends Command { }); async execute() { - const cwd = this.cwd ?? process.cwd(); - const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]); + try { + const cwd = this.cwd ?? process.cwd(); + const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]); - if (jobs.length === 0) { - console.log(styleText('yellow', '✘ No jobs found to check')); - return; - } + if (jobs.length === 0) { + console.log(styleText('yellow', '✘ No jobs found to check')); + return; + } - const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); + const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); - for (const { isSame, jobInfo } of results) { - if (!isSame) { - console.error(styleText('red', `✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); - process.exit(1); + for (const { isSame, jobInfo } of results) { + if (!isSame) { + console.error(styleText('red', `✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); + process.exit(1); + } } - } - console.log(styleText('green', '✔ All files are up to date')); + console.log(styleText('green', '✔ All files are up to date')); + return 0; + } catch (error) { + if (v.isValiError(error)) { + console.error(styleText('red', v.summarize(error.issues))); + return 1; + } + console.error(styleText('red', `✘ Error occurred while checking: ${error}`)); + return 1; + } } } diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 31fbcdd..fe8c0f1 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -3,9 +3,9 @@ import path from 'node:path'; import { styleText } from 'node:util'; import { Command, Option } from 'clipanion'; +import * as v from 'valibot'; -import { resolveConfig } from '../../core/resolve-config'; -import { runJob } from '../../core/run-job'; +import { resolveConfig, runJob } from '../../core'; import { resolveRepositoryRoot } from '../utils'; export class SyncCommand extends Command { @@ -31,37 +31,47 @@ export class SyncCommand extends Command { }); async execute() { - const cwd = this.cwd ?? process.cwd(); - const repoRoot = await resolveRepositoryRoot(cwd, this.root); - const jobs = await resolveConfig(cwd); + try { + const cwd = this.cwd ?? process.cwd(); + const repoRoot = await resolveRepositoryRoot(cwd, this.root); + const jobs = await resolveConfig(cwd); - if (jobs.length === 0) { - console.log(styleText('yellow', '✘ No jobs found to sync')); - return; - } + if (jobs.length === 0) { + console.log(styleText('yellow', '✘ No jobs found to sync')); + return; + } - const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); + const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); - for (const { jobInfo, generated, isSame } of results) { - if (this.dryRun === true) { - console.log(styleText('cyan', `┌─ [Job] ${jobInfo.name}`)); - console.log(`${styleText('cyan', '│')} ${styleText('dim', `Output: ${jobInfo.output}`)}`); - console.log(`${styleText('cyan', '│')}`); - generated.contents.split('\n').forEach((line) => { - console.log(`${styleText('cyan', '│')} ${line}`); - }); - console.log(`${styleText('cyan', '└─')}`); - continue; - } + for (const { jobInfo, generated, isSame } of results) { + if (this.dryRun === true) { + console.log(styleText('cyan', `┌─ [Job] ${jobInfo.name}`)); + console.log(`${styleText('cyan', '│')} ${styleText('dim', `Output: ${jobInfo.output}`)}`); + console.log(`${styleText('cyan', '│')}`); + generated.contents.split('\n').forEach((line) => { + console.log(`${styleText('cyan', '│')} ${line}`); + }); + console.log(`${styleText('cyan', '└─')}`); + continue; + } - if (!isSame) { - const outputPath = path.resolve(repoRoot, jobInfo.output); + if (!isSame) { + const outputPath = path.resolve(repoRoot, jobInfo.output); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, generated.contents); - } + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, generated.contents); + } - console.log(styleText('green', `✔ ${jobInfo.name} synced`)); + console.log(styleText('green', `✔ ${jobInfo.name} synced`)); + } + return 0; + } catch (error) { + if (v.isValiError(error)) { + console.error(styleText('red', v.summarize(error.issues))); + return 1; + } + console.error(styleText('red', `✘ Error occurred while syncing: ${error}`)); + return 1; } } } diff --git a/src/core/define-config.ts b/src/core/config/define-config.ts similarity index 55% rename from src/core/define-config.ts rename to src/core/config/define-config.ts index 0e365f6..cbfbe92 100644 --- a/src/core/define-config.ts +++ b/src/core/config/define-config.ts @@ -1,5 +1,8 @@ -import type { Job } from './types'; +import type { Job } from '../types'; +/** + * @deprecated use `pullup.yaml` or `pullup.yml` instead. + */ export function defineConfig(config: Job | Job[]) { if (Array.isArray(config)) { return config; diff --git a/src/core/config/index.ts b/src/core/config/index.ts new file mode 100644 index 0000000..235aa93 --- /dev/null +++ b/src/core/config/index.ts @@ -0,0 +1,2 @@ +export * from './define-config'; +export * from './resolve-config'; diff --git a/src/core/config/resolve-config.ts b/src/core/config/resolve-config.ts new file mode 100644 index 0000000..cc61a48 --- /dev/null +++ b/src/core/config/resolve-config.ts @@ -0,0 +1,85 @@ +import path from 'node:path'; +import { styleText } from 'node:util'; + +import { cosmiconfig, type CosmiconfigResult, defaultLoaders } from 'cosmiconfig'; +import * as v from 'valibot'; + +import { codeownersJob } from '../jobs'; +import type { Job } from '../types'; +import { type Config, ConfigSchema } from './schema'; + +export async function searchLegacyConfig(cwd: string): Promise { + const explorer = cosmiconfig('pullup', { + searchPlaces: [ + 'pullup.config.ts', + 'pullup.config.js', + 'pullup.config.cjs', + 'pullup.config.mjs', + '.pulluprc.js', + '.pulluprc.cjs', + ], + loaders: loadersForExtensions(['.ts', '.js', '.cjs', '.mjs']), + }); + const result = await explorer.search(cwd); + + return result; +} + +export async function searchConfig(cwd: string): Promise { + const explorer = cosmiconfig('pullup', { + searchPlaces: ['pullup.yaml', 'pullup.yml'], + loaders: loadersForExtensions(['.yaml', '.yml']), + }); + const result = await explorer.search(cwd); + + if (result == null) { + return null; + } + + return { filepath: result.filepath, config: v.parse(ConfigSchema, result.config) }; +} + +export async function resolveConfig(cwd: string): Promise { + const [legacyConfig, config] = await Promise.all([searchLegacyConfig(cwd), searchConfig(cwd)]); + + if (legacyConfig != null && config != null) { + throw new Error('Both legacy config and new config are found. Please use only one of them.'); + } + + if (legacyConfig != null) { + console.warn( + styleText( + 'yellow', + `Legacy config is found at ${path.relative(cwd, legacyConfig.filepath)}. Please consider migrating to the new config format.`, + ), + ); + return legacyConfig.config as Job[]; + } + + if (config == null) { + throw new Error('No config found. Please create a config file.'); + } + + return toJobs(config.config); +} + +function toJobs(config: Config): Job[] { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return Object.entries(config.jobs).map(([_name, job]) => { + switch (job.type) { + case 'codeowners': + return codeownersJob({ input: job.input, output: job.output }); + default: + throw new Error(`Unknown job type: ${job.type}`); + } + }); +} + +function loadersForExtensions(extensions: string[]) { + return Object.fromEntries( + Object.entries(defaultLoaders).map(([extension, loader]) => [ + extension, + extensions.includes(extension) ? loader : () => null, + ]), + ); +} diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts new file mode 100644 index 0000000..657b0b0 --- /dev/null +++ b/src/core/config/schema.ts @@ -0,0 +1,27 @@ +import * as v from 'valibot'; + +export const CodeownersJobSchema = v.strictObject({ + type: v.literal('codeowners'), + input: v.pipe( + v.optional(v.array(v.string())), + v.metadata({ description: 'Input file paths for the job', examples: [['**/CODEOWNERS', '!**/fixtures/**']] }), + ), + output: v.pipe( + v.optional(v.string()), + v.metadata({ + description: 'Output file path for the job', + examples: ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS'], + }), + ), +}); + +export const ConfigSchema = v.strictObject({ + jobs: v.pipe( + v.unknown(), + v.check((input) => !Array.isArray(input), 'jobs must be an object.'), + v.record(v.string(), CodeownersJobSchema), + ), +}); + +export type CodeownersJob = v.InferOutput; +export type Config = v.InferOutput; diff --git a/src/core/config/types.ts b/src/core/config/types.ts new file mode 100644 index 0000000..b6b789e --- /dev/null +++ b/src/core/config/types.ts @@ -0,0 +1,8 @@ +import type { Job } from '../types'; + +export type LegacyConfig = Job[]; + +export type LoadedConfig = { + filepath: string; + config: T; +}; diff --git a/src/core/index.ts b/src/core/index.ts index 236be71..048655f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,5 @@ -export type * from './types'; +export * from './config'; export * from './define-job'; export * from './jobs'; -export * from './define-config'; +export * from './run-job'; +export type * from './types'; diff --git a/src/core/resolve-config.ts b/src/core/resolve-config.ts deleted file mode 100644 index 91bcd90..0000000 --- a/src/core/resolve-config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { cosmiconfig } from 'cosmiconfig'; - -import type { Job } from './types'; - -export async function resolveConfig(cwd: string): Promise { - const explorer = cosmiconfig('pullup'); - const result = await explorer.search(cwd); - - if (result == null) { - return []; - } - - return result.config as Job[]; -} diff --git a/tsconfig.json b/tsconfig.json index 01c8c8d..1bb730a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "module": "esnext", "target": "esnext", "moduleResolution": "bundler", + "noEmit": true, + "allowImportingTsExtensions": true, "moduleDetection": "force", "resolveJsonModule": true, "isolatedModules": true, diff --git a/tsdown.config.ts b/tsdown.config.ts index cabae2d..bc67062 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,5 +1,19 @@ +import { writeFile } from 'node:fs/promises'; + +import { toJsonSchema } from '@valibot/to-json-schema'; import { defineConfig } from 'tsdown'; +import { ConfigSchema } from './src/core/config/schema.ts'; + +async function generateSchema() { + const schema = toJsonSchema(ConfigSchema, { + target: 'draft-07', + typeMode: 'output', + }); + + await writeFile('./schema.json', `${JSON.stringify(schema, null, 2)}\n`); +} + export default defineConfig([ { entry: 'src/cli/index.ts', @@ -13,5 +27,6 @@ export default defineConfig([ entry: 'src/core/index.ts', dts: true, outDir: 'dist/core', + onSuccess: generateSchema, }, ]); diff --git a/yarn.lock b/yarn.lock index 6127f38..419d2b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -678,6 +678,7 @@ __metadata: "@eslint/js": "npm:^9.39.2" "@fixture-kit/core": "npm:^1.0.1" "@types/node": "npm:^24.10.0" + "@valibot/to-json-schema": "npm:^1.8.0" clipanion: "npm:^4.0.0-rc.4" cosmiconfig: "npm:^9.0.0" eslint: "npm:^9.7.0" @@ -690,6 +691,7 @@ __metadata: tsdown: "npm:^0.19.0" typescript: "npm:^5.9.2" typescript-eslint: "npm:^8.27.0" + valibot: "npm:^1.5.0" vite: "npm:^8.3.1" vitest: "npm:^5.0.2" bin: @@ -1351,6 +1353,15 @@ __metadata: languageName: node linkType: hard +"@valibot/to-json-schema@npm:^1.8.0": + version: 1.8.0 + resolution: "@valibot/to-json-schema@npm:1.8.0" + peerDependencies: + valibot: ^1.5.0 + checksum: 10c0/8206940f4b892a34c2d78448541de926234bd97741ad72f4b75ce22bbe56172e46492d02efaf71d75cfc71cec6fa0c55d2edbd977aa4fd2cb2dc236f87005c04 + languageName: node + linkType: hard + "@vitest/mocker@npm:5.0.2": version: 5.0.2 resolution: "@vitest/mocker@npm:5.0.2" @@ -3729,6 +3740,18 @@ __metadata: languageName: node linkType: hard +"valibot@npm:^1.5.0": + version: 1.5.0 + resolution: "valibot@npm:1.5.0" + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/d49dab2b2fcbb477c894def73466de598b602859a47ab92b26d387c4d2a472cbfd4ef57b49eea91aeeff143c38f5a9523914dc5a73469ec93c7cfd4326eb5fbf + languageName: node + linkType: hard + "vite@npm:^8.3.1": version: 8.3.1 resolution: "vite@npm:8.3.1" From e150239e871b85cac9e9a6d069731b9ceb1ff8a0 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Sun, 4 Oct 2026 20:04:01 +0900 Subject: [PATCH 2/6] docs: add changeset --- .changeset/lemon-dryers-drive.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/lemon-dryers-drive.md diff --git a/.changeset/lemon-dryers-drive.md b/.changeset/lemon-dryers-drive.md new file mode 100644 index 0000000..78b7f08 --- /dev/null +++ b/.changeset/lemon-dryers-drive.md @@ -0,0 +1,6 @@ +--- +"@pull-up/cli": minor +--- + +Add YAML configuration and JSON Schema support. +Deprecate legacy JS/TS configuration and remove package.json#pullup support. From a340b00cb4a1d1d570bd3dfd28b39b7965f40379 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Sun, 4 Oct 2026 20:20:30 +0900 Subject: [PATCH 3/6] feat: revert bug fixes --- .changeset/lemon-dryers-drive.md | 3 +- .prettierignore | 1 + README.md | 10 +- schema.json | 21 +++- src/cli/commands/check.ts | 39 +++---- src/cli/commands/sync.ts | 64 +++++----- src/core/config/define-config.ts | 3 - src/core/config/index.ts | 1 - src/core/config/resolve-config.spec.ts | 156 +++++++++++++++++++++++++ src/core/config/resolve-config.ts | 65 ++--------- src/core/config/schema.spec.ts | 44 +++++++ src/core/index.ts | 1 - tests/config-cli.spec.ts | 116 ++++++++++++++++++ tests/config-schema.spec.ts | 28 +++++ 14 files changed, 416 insertions(+), 136 deletions(-) create mode 100644 src/core/config/resolve-config.spec.ts create mode 100644 src/core/config/schema.spec.ts create mode 100644 tests/config-cli.spec.ts create mode 100644 tests/config-schema.spec.ts diff --git a/.changeset/lemon-dryers-drive.md b/.changeset/lemon-dryers-drive.md index 78b7f08..99ef9c0 100644 --- a/.changeset/lemon-dryers-drive.md +++ b/.changeset/lemon-dryers-drive.md @@ -1,6 +1,5 @@ --- -"@pull-up/cli": minor +'@pull-up/cli': patch --- Add YAML configuration and JSON Schema support. -Deprecate legacy JS/TS configuration and remove package.json#pullup support. diff --git a/.prettierignore b/.prettierignore index 61c3bc7..d05becf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ .yarn +/schema.json diff --git a/README.md b/README.md index 0407056..7cedf11 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ jobs: type: codeowners ``` -`jobs` maps job identifiers to their settings. Configuration is validated before jobs run; invalid settings cause the command to exit with code 1. +`jobs` maps job identifiers to their settings. YAML configuration is validated before jobs run; invalid settings cause the command to exit with code 1. `pullup.yaml` is searched before `pullup.yml`. @@ -89,11 +89,9 @@ The package includes `schema.json` for autocomplete and validation in editors th The path is relative to the YAML file. For Yarn Plug'n'Play or a remote schema, use `https://unpkg.com/@pull-up/cli@/schema.json`, replacing `` with your installed package version. -### Legacy JavaScript and TypeScript Configuration +### JavaScript and TypeScript Configuration -Existing `pullup.config.ts`, `pullup.config.js`, `pullup.config.cjs`, `pullup.config.mjs`, `.pulluprc.js`, and `.pulluprc.cjs` files remain supported with a deprecation warning. - -For example, in `pullup.config.ts`: +Use `pullup.config.ts`, `pullup.config.js`, `pullup.config.cjs`, or `pullup.config.mjs` for a JavaScript or TypeScript configuration: ```ts import { defineConfig, codeownersJob } from '@pull-up/cli'; @@ -101,7 +99,7 @@ import { defineConfig, codeownersJob } from '@pull-up/cli'; export default defineConfig(codeownersJob()); ``` -YAML and legacy configuration files cannot coexist. Configuration in the `package.json` `pullup` field is no longer supported. +The `package.json` `pullup` field and `.pulluprc` files are also supported. These formats are searched before `pullup.yaml` and `pullup.yml`. #### Custom Jobs diff --git a/schema.json b/schema.json index 2c0fdf1..1c5b477 100644 --- a/schema.json +++ b/schema.json @@ -18,20 +18,33 @@ "type": "string" }, "description": "Input file paths for the job", - "examples": [["**/CODEOWNERS", "!**/fixtures/**"]] + "examples": [ + [ + "**/CODEOWNERS", + "!**/fixtures/**" + ] + ] }, "output": { "type": "string", "description": "Output file path for the job", - "examples": ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"] + "examples": [ + "CODEOWNERS", + ".github/CODEOWNERS", + "docs/CODEOWNERS" + ] } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false } } }, - "required": ["jobs"], + "required": [ + "jobs" + ], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" } diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 551bb68..a937256 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -1,9 +1,8 @@ import { styleText } from 'node:util'; import { Command, Option } from 'clipanion'; -import * as v from 'valibot'; -import { resolveConfig } from '../../core'; +import { resolveConfig } from '../../core/config/resolve-config'; import { runJob } from '../../core/run-job'; import { resolveRepositoryRoot } from '../utils'; @@ -25,33 +24,23 @@ export class CheckCommand extends Command { }); async execute() { - try { - const cwd = this.cwd ?? process.cwd(); - const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]); + const cwd = this.cwd ?? process.cwd(); + const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]); - if (jobs.length === 0) { - console.log(styleText('yellow', '✘ No jobs found to check')); - return; - } + if (jobs.length === 0) { + console.log(styleText('yellow', '✘ No jobs found to check')); + return; + } - const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); + const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); - for (const { isSame, jobInfo } of results) { - if (!isSame) { - console.error(styleText('red', `✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); - process.exit(1); - } + for (const { isSame, jobInfo } of results) { + if (!isSame) { + console.error(styleText('red', `✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); + process.exit(1); } - - console.log(styleText('green', '✔ All files are up to date')); - return 0; - } catch (error) { - if (v.isValiError(error)) { - console.error(styleText('red', v.summarize(error.issues))); - return 1; - } - console.error(styleText('red', `✘ Error occurred while checking: ${error}`)); - return 1; } + + console.log(styleText('green', '✔ All files are up to date')); } } diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index fe8c0f1..491f8e9 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -3,9 +3,9 @@ import path from 'node:path'; import { styleText } from 'node:util'; import { Command, Option } from 'clipanion'; -import * as v from 'valibot'; -import { resolveConfig, runJob } from '../../core'; +import { resolveConfig } from '../../core/config/resolve-config'; +import { runJob } from '../../core/run-job'; import { resolveRepositoryRoot } from '../utils'; export class SyncCommand extends Command { @@ -31,47 +31,37 @@ export class SyncCommand extends Command { }); async execute() { - try { - const cwd = this.cwd ?? process.cwd(); - const repoRoot = await resolveRepositoryRoot(cwd, this.root); - const jobs = await resolveConfig(cwd); + const cwd = this.cwd ?? process.cwd(); + const repoRoot = await resolveRepositoryRoot(cwd, this.root); + const jobs = await resolveConfig(cwd); - if (jobs.length === 0) { - console.log(styleText('yellow', '✘ No jobs found to sync')); - return; - } - - const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); + if (jobs.length === 0) { + console.log(styleText('yellow', '✘ No jobs found to sync')); + return; + } - for (const { jobInfo, generated, isSame } of results) { - if (this.dryRun === true) { - console.log(styleText('cyan', `┌─ [Job] ${jobInfo.name}`)); - console.log(`${styleText('cyan', '│')} ${styleText('dim', `Output: ${jobInfo.output}`)}`); - console.log(`${styleText('cyan', '│')}`); - generated.contents.split('\n').forEach((line) => { - console.log(`${styleText('cyan', '│')} ${line}`); - }); - console.log(`${styleText('cyan', '└─')}`); - continue; - } + const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); - if (!isSame) { - const outputPath = path.resolve(repoRoot, jobInfo.output); + for (const { jobInfo, generated, isSame } of results) { + if (this.dryRun === true) { + console.log(styleText('cyan', `┌─ [Job] ${jobInfo.name}`)); + console.log(`${styleText('cyan', '│')} ${styleText('dim', `Output: ${jobInfo.output}`)}`); + console.log(`${styleText('cyan', '│')}`); + generated.contents.split('\n').forEach((line) => { + console.log(`${styleText('cyan', '│')} ${line}`); + }); + console.log(`${styleText('cyan', '└─')}`); + continue; + } - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, generated.contents); - } + if (!isSame) { + const outputPath = path.resolve(repoRoot, jobInfo.output); - console.log(styleText('green', `✔ ${jobInfo.name} synced`)); - } - return 0; - } catch (error) { - if (v.isValiError(error)) { - console.error(styleText('red', v.summarize(error.issues))); - return 1; + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, generated.contents); } - console.error(styleText('red', `✘ Error occurred while syncing: ${error}`)); - return 1; + + console.log(styleText('green', `✔ ${jobInfo.name} synced`)); } } } diff --git a/src/core/config/define-config.ts b/src/core/config/define-config.ts index cbfbe92..c95f7ce 100644 --- a/src/core/config/define-config.ts +++ b/src/core/config/define-config.ts @@ -1,8 +1,5 @@ import type { Job } from '../types'; -/** - * @deprecated use `pullup.yaml` or `pullup.yml` instead. - */ export function defineConfig(config: Job | Job[]) { if (Array.isArray(config)) { return config; diff --git a/src/core/config/index.ts b/src/core/config/index.ts index 235aa93..35809ee 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -1,2 +1 @@ export * from './define-config'; -export * from './resolve-config'; diff --git a/src/core/config/resolve-config.spec.ts b/src/core/config/resolve-config.spec.ts new file mode 100644 index 0000000..719e6d7 --- /dev/null +++ b/src/core/config/resolve-config.spec.ts @@ -0,0 +1,156 @@ +import { Fixture } from '@fixture-kit/core'; + +import { resolveConfig } from './resolve-config'; + +const customJobs = [{ name: 'custom-job', input: ['packages/*/config.json'], output: 'merged-config.json' }]; +const jsonConfig = JSON.stringify(customJobs); +const esmConfig = `export default ${jsonConfig};`; +const tsConfig = `export default ${jsonConfig} as const;`; +const cjsConfig = `module.exports = ${jsonConfig};`; +const rcConfig = `- name: custom-job + input: + - 'packages/*/config.json' + output: merged-config.json +`; +const defaultYamlConfig = `jobs: + owners: + type: codeowners +`; + +describe('resolveConfig', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(['pullup.yaml', 'pullup.yml'])('resolves default codeowners options from %s', async (filename) => { + await using fixture = await Fixture.create({ [filename]: defaultYamlConfig }); + + expect(await resolveConfig(fixture.root)).toEqual([ + { + name: 'codeowners', + input: ['**/CODEOWNERS'], + output: '.github/CODEOWNERS', + transform: expect.any(Function), + }, + ]); + }); + + it('resolves explicit input and output from pullup.yaml', async () => { + await using fixture = await Fixture.create({ + 'pullup.yaml': `jobs: + packages: + type: codeowners + input: + - 'packages/**/CODEOWNERS' + - '!packages/fixtures/**' + output: .github/CODEOWNERS + docs: + type: codeowners + input: + - 'docs/**/CODEOWNERS' + output: docs/CODEOWNERS +`, + }); + + expect(await resolveConfig(fixture.root)).toEqual([ + { + name: 'codeowners', + input: ['packages/**/CODEOWNERS', '!packages/fixtures/**'], + output: '.github/CODEOWNERS', + transform: expect.any(Function), + }, + { + name: 'codeowners', + input: ['docs/**/CODEOWNERS'], + output: 'docs/CODEOWNERS', + transform: expect.any(Function), + }, + ]); + }); + + it('returns no jobs for an empty jobs mapping in pullup.yml', async () => { + await using fixture = await Fixture.create({ 'pullup.yml': 'jobs: {}\n' }); + + expect(await resolveConfig(fixture.root)).toEqual([]); + }); + + it.each([ + ['pullup.config.js', esmConfig], + ['pullup.config.ts', tsConfig], + ['pullup.config.cjs', cjsConfig], + ['pullup.config.mjs', esmConfig], + ['.pulluprc.js', esmConfig], + ['.pulluprc.ts', tsConfig], + ['.pulluprc.cjs', cjsConfig], + ['.pulluprc.mjs', esmConfig], + ['.pulluprc', rcConfig], + ['.pulluprc.json', jsonConfig], + ['.pulluprc.yaml', rcConfig], + ['.pulluprc.yml', rcConfig], + ])('resolves jobs from %s', async (filename, contents) => { + await using fixture = await Fixture.create({ + 'package.json': JSON.stringify({ type: 'module' }), + [filename]: contents, + }); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + }); + + it('resolves jobs from package.json#pullup', async () => { + await using fixture = await Fixture.create({ 'package.json': JSON.stringify({ pullup: customJobs }) }); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + }); + + it('resolves jobs from .config/pulluprc', async () => { + await using fixture = await Fixture.create({ '.config': { pulluprc: rcConfig } }); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + }); + + it('returns no jobs when no config exists', async () => { + await using fixture = await Fixture.create({}); + + expect(await resolveConfig(fixture.root)).toEqual([]); + }); + + it('prefers pullup.yaml over pullup.yml', async () => { + await using fixture = await Fixture.create({ + 'pullup.yaml': `${defaultYamlConfig} output: yaml/CODEOWNERS\n`, + 'pullup.yml': `${defaultYamlConfig} output: yml/CODEOWNERS\n`, + }); + + expect(await resolveConfig(fixture.root)).toMatchObject([{ name: 'codeowners', output: 'yaml/CODEOWNERS' }]); + }); + + it('prefers pullup.config.js over pullup.yml', async () => { + await using fixture = await Fixture.create({ + 'package.json': JSON.stringify({ type: 'module' }), + 'pullup.config.js': esmConfig, + 'pullup.yml': 'jobs: []\n', + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + expect(warn).not.toHaveBeenCalled(); + }); + + it('prefers package.json over pullup.config.js', async () => { + await using fixture = await Fixture.create({ + 'package.json': JSON.stringify({ type: 'module', pullup: customJobs }), + 'pullup.config.js': 'export default [{ name: "other-job", input: [], output: "other.json" }];', + }); + + expect(await resolveConfig(fixture.root)).toEqual(customJobs); + }); + + it('uses cosmiconfig.searchPlaces from package.json', async () => { + await using fixture = await Fixture.create({ + 'package.json': JSON.stringify({ cosmiconfig: { searchPlaces: ['pullup.yml'], mergeSearchPlaces: false } }), + 'pullup.yml': `${defaultYamlConfig} output: custom/CODEOWNERS\n`, + }); + vi.spyOn(process, 'cwd').mockReturnValue(fixture.root); + + expect(await resolveConfig(fixture.root)).toMatchObject([{ name: 'codeowners', output: 'custom/CODEOWNERS' }]); + }); +}); diff --git a/src/core/config/resolve-config.ts b/src/core/config/resolve-config.ts index cc61a48..574328f 100644 --- a/src/core/config/resolve-config.ts +++ b/src/core/config/resolve-config.ts @@ -1,71 +1,31 @@ import path from 'node:path'; -import { styleText } from 'node:util'; -import { cosmiconfig, type CosmiconfigResult, defaultLoaders } from 'cosmiconfig'; +import { cosmiconfig, getDefaultSearchPlaces } from 'cosmiconfig'; import * as v from 'valibot'; import { codeownersJob } from '../jobs'; import type { Job } from '../types'; import { type Config, ConfigSchema } from './schema'; -export async function searchLegacyConfig(cwd: string): Promise { - const explorer = cosmiconfig('pullup', { - searchPlaces: [ - 'pullup.config.ts', - 'pullup.config.js', - 'pullup.config.cjs', - 'pullup.config.mjs', - '.pulluprc.js', - '.pulluprc.cjs', - ], - loaders: loadersForExtensions(['.ts', '.js', '.cjs', '.mjs']), - }); - const result = await explorer.search(cwd); - - return result; -} - -export async function searchConfig(cwd: string): Promise { +export async function resolveConfig(cwd: string): Promise { const explorer = cosmiconfig('pullup', { - searchPlaces: ['pullup.yaml', 'pullup.yml'], - loaders: loadersForExtensions(['.yaml', '.yml']), + searchPlaces: [...getDefaultSearchPlaces('pullup'), 'pullup.yaml', 'pullup.yml'], }); const result = await explorer.search(cwd); if (result == null) { - return null; + return []; } - return { filepath: result.filepath, config: v.parse(ConfigSchema, result.config) }; -} - -export async function resolveConfig(cwd: string): Promise { - const [legacyConfig, config] = await Promise.all([searchLegacyConfig(cwd), searchConfig(cwd)]); - - if (legacyConfig != null && config != null) { - throw new Error('Both legacy config and new config are found. Please use only one of them.'); - } - - if (legacyConfig != null) { - console.warn( - styleText( - 'yellow', - `Legacy config is found at ${path.relative(cwd, legacyConfig.filepath)}. Please consider migrating to the new config format.`, - ), - ); - return legacyConfig.config as Job[]; + if (['pullup.yaml', 'pullup.yml'].includes(path.basename(result.filepath))) { + return toJobs(v.parse(ConfigSchema, result.config)); } - if (config == null) { - throw new Error('No config found. Please create a config file.'); - } - - return toJobs(config.config); + return result.config as Job[]; } function toJobs(config: Config): Job[] { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return Object.entries(config.jobs).map(([_name, job]) => { + return Object.values(config.jobs).map((job) => { switch (job.type) { case 'codeowners': return codeownersJob({ input: job.input, output: job.output }); @@ -74,12 +34,3 @@ function toJobs(config: Config): Job[] { } }); } - -function loadersForExtensions(extensions: string[]) { - return Object.fromEntries( - Object.entries(defaultLoaders).map(([extension, loader]) => [ - extension, - extensions.includes(extension) ? loader : () => null, - ]), - ); -} diff --git a/src/core/config/schema.spec.ts b/src/core/config/schema.spec.ts new file mode 100644 index 0000000..5b188c2 --- /dev/null +++ b/src/core/config/schema.spec.ts @@ -0,0 +1,44 @@ +import * as v from 'valibot'; + +import { ConfigSchema } from './schema'; + +describe('ConfigSchema', () => { + it.each([ + { name: 'a job with default options', config: { jobs: { owners: { type: 'codeowners' } } } }, + { + name: 'named jobs with explicit options', + config: { + jobs: { + packages: { + type: 'codeowners', + input: ['packages/**/CODEOWNERS', '!packages/fixtures/**'], + output: '.github/CODEOWNERS', + }, + docs: { type: 'codeowners', input: ['docs/**/CODEOWNERS'], output: 'docs/CODEOWNERS' }, + }, + }, + }, + { name: 'an empty jobs mapping', config: { jobs: {} } }, + ])('accepts $name', ({ config }) => { + expect(v.parse(ConfigSchema, config)).toEqual(config); + }); + + it.each([ + { name: 'missing jobs', config: {} }, + { name: 'an empty jobs array', config: { jobs: [] } }, + { name: 'a non-empty jobs array', config: { jobs: [{ type: 'codeowners' }] } }, + { name: 'a missing job type', config: { jobs: { owners: {} } } }, + { name: 'an unsupported job type', config: { jobs: { owners: { type: 'custom' } } } }, + { name: 'a scalar input', config: { jobs: { owners: { type: 'codeowners', input: '**/CODEOWNERS' } } } }, + { name: 'a non-string input pattern', config: { jobs: { owners: { type: 'codeowners', input: [123] } } } }, + { name: 'an output array', config: { jobs: { owners: { type: 'codeowners', output: ['CODEOWNERS'] } } } }, + { name: 'a null output', config: { jobs: { owners: { type: 'codeowners', output: null } } } }, + { name: 'an unknown root property', config: { jobs: {}, extra: true } }, + { + name: 'an unknown job property', + config: { jobs: { owners: { type: 'codeowners', from: ['**/CODEOWNERS'] } } }, + }, + ])('rejects $name', ({ config }) => { + expect(v.safeParse(ConfigSchema, config).success).toBe(false); + }); +}); diff --git a/src/core/index.ts b/src/core/index.ts index 048655f..5b4d646 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,5 +1,4 @@ export * from './config'; export * from './define-job'; export * from './jobs'; -export * from './run-job'; export type * from './types'; diff --git a/tests/config-cli.spec.ts b/tests/config-cli.spec.ts new file mode 100644 index 0000000..766949e --- /dev/null +++ b/tests/config-cli.spec.ts @@ -0,0 +1,116 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { Fixture } from '@fixture-kit/core'; + +const execFileAsync = promisify(execFile); +const CLI_PATH = fileURLToPath(new URL('../dist/bin/index.mjs', import.meta.url)); +const existingContents = '* @existing-team\n'; +const generatedContents = 'custom generated output\n'; +const scriptConfig = `export default [ + { name: 'custom', input: [], output: '.github/CODEOWNERS', transform: () => ${JSON.stringify(generatedContents)} }, +]; +`; +const config = `jobs: + codeowners: + type: codeowners + input: + - '**/CODEOWNERS' + - '!**/fixtures/**' + output: .github/CODEOWNERS +`; + +function runCli(command: string, rootDir: string) { + return execFileAsync(process.execPath, [CLI_PATH, command], { + cwd: rootDir, + timeout: 10_000, + }).then( + (result) => ({ ...result, code: 0 }), + (error: { code: number; stdout: string; stderr: string }) => error, + ); +} + +describe('YAML config CLI', () => { + it.each(['pullup.yaml', 'pullup.yml'])('syncs and checks a codeowners job from %s', async (filename) => { + await using fixture = await Fixture.create({ + '.git': {}, + [filename]: config, + '.github': { CODEOWNERS: existingContents }, + CODEOWNERS: '* @root-team\n', + packages: { + web: { CODEOWNERS: '*.ts @frontend-team\n' }, + fixtures: { CODEOWNERS: '* @ignored-team\n' }, + }, + }); + const outputPath = path.join(fixture.root, '.github/CODEOWNERS'); + + const sync = await runCli('sync', fixture.root); + + expect(sync.code).toBe(0); + expect(sync.stderr).toBe(''); + expect(sync.stdout).toContain('codeowners synced'); + expect(await readFile(outputPath, 'utf8')).toBe('* @root-team\n/packages/web/**/*.ts @frontend-team\n'); + + const check = await runCli('check', fixture.root); + + expect(check.code).toBe(0); + expect(check.stderr).toBe(''); + expect(check.stdout).toContain('All files are up to date'); + }); + + describe.each(['sync', 'check'])('%s', (command) => { + it.each([ + { name: 'an empty jobs array', config: 'jobs: []\n' }, + { name: 'a nonempty jobs array', config: 'jobs:\n - type: codeowners\n' }, + ])('rejects $name without changing the output', async (scenario) => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.yml': scenario.config, + '.github': { CODEOWNERS: existingContents }, + CODEOWNERS: '* @source-team\n', + }); + + const result = await runCli(command, fixture.root); + const output = result.stdout + result.stderr; + + expect(result.code).toBe(1); + expect(output).toContain('jobs must be an object.'); + expect(output).not.toContain('synced'); + expect(output).not.toContain('All files are up to date'); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + + it('succeeds without a config and leaves the output unchanged', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + '.github': { CODEOWNERS: existingContents }, + }); + + const result = await runCli(command, fixture.root); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain(`No jobs found to ${command}`); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + + it('uses pullup.config.mjs before pullup.yml', async () => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.config.mjs': scriptConfig, + 'pullup.yml': 'jobs: []\n', + '.github': { CODEOWNERS: generatedContents }, + }); + + const result = await runCli(command, fixture.root); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain(command === 'sync' ? 'custom synced' : 'All files are up to date'); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(generatedContents); + }); + }); +}); diff --git a/tests/config-schema.spec.ts b/tests/config-schema.spec.ts new file mode 100644 index 0000000..9d0a5a4 --- /dev/null +++ b/tests/config-schema.spec.ts @@ -0,0 +1,28 @@ +import { readFile } from 'node:fs/promises'; + +describe('generated config schema', () => { + it('describes job mappings and their fields for YAML editors', async () => { + const schema = JSON.parse(await readFile(new URL('../schema.json', import.meta.url), 'utf8')); + + expect(schema).toMatchObject({ + type: 'object', + properties: { + jobs: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + type: { const: 'codeowners' }, + input: { type: 'array', items: { type: 'string' } }, + output: { type: 'string' }, + }, + required: ['type'], + additionalProperties: false, + }, + }, + }, + required: ['jobs'], + additionalProperties: false, + }); + }); +}); From 87c3a6d0e76a825f9c44964df2d2bfab1255aa0c Mon Sep 17 00:00:00 2001 From: hoyeon Date: Sun, 4 Oct 2026 20:23:57 +0900 Subject: [PATCH 4/6] refactor: remove unused symbols --- src/core/config/schema.ts | 3 +-- src/core/config/types.ts | 8 -------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 src/core/config/types.ts diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 657b0b0..a716e15 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -1,6 +1,6 @@ import * as v from 'valibot'; -export const CodeownersJobSchema = v.strictObject({ +const CodeownersJobSchema = v.strictObject({ type: v.literal('codeowners'), input: v.pipe( v.optional(v.array(v.string())), @@ -23,5 +23,4 @@ export const ConfigSchema = v.strictObject({ ), }); -export type CodeownersJob = v.InferOutput; export type Config = v.InferOutput; diff --git a/src/core/config/types.ts b/src/core/config/types.ts deleted file mode 100644 index b6b789e..0000000 --- a/src/core/config/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Job } from '../types'; - -export type LegacyConfig = Job[]; - -export type LoadedConfig = { - filepath: string; - config: T; -}; From f8d1e57a32e02b97ea15d4656d34920846ff2f0a Mon Sep 17 00:00:00 2001 From: hoyeon Date: Sun, 4 Oct 2026 20:30:52 +0900 Subject: [PATCH 5/6] refactor: move generateSchema function to 'build:done' hook --- tsdown.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index bc67062..8f975c1 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -27,6 +27,8 @@ export default defineConfig([ entry: 'src/core/index.ts', dts: true, outDir: 'dist/core', - onSuccess: generateSchema, + hooks: { + 'build:done': generateSchema, + }, }, ]); From 25a1ef64a724256da736f1250761380fa1191953 Mon Sep 17 00:00:00 2001 From: hoyeon Date: Sun, 4 Oct 2026 20:55:39 +0900 Subject: [PATCH 6/6] refactor: reject prototype names --- schema.json | 9 ++++++++- src/core/config/schema.spec.ts | 20 ++++++++++++++++++++ src/core/config/schema.ts | 14 +++++++++++--- tests/config-cli.spec.ts | 29 +++++++++++++++++++++++++++++ tests/config-schema.spec.ts | 4 ++++ 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/schema.json b/schema.json index 1c5b477..51c659a 100644 --- a/schema.json +++ b/schema.json @@ -4,7 +4,14 @@ "jobs": { "type": "object", "propertyNames": { - "type": "string" + "type": "string", + "not": { + "enum": [ + "constructor", + "prototype", + "__proto__" + ] + } }, "additionalProperties": { "type": "object", diff --git a/src/core/config/schema.spec.ts b/src/core/config/schema.spec.ts index 5b188c2..0de3df4 100644 --- a/src/core/config/schema.spec.ts +++ b/src/core/config/schema.spec.ts @@ -23,8 +23,16 @@ describe('ConfigSchema', () => { expect(v.parse(ConfigSchema, config)).toEqual(config); }); + it.each(['constructor-job', 'prototype-job', '__proto__-job', 'toString'])('accepts job key %s', (key) => { + const config = { jobs: { [key]: { type: 'codeowners' } } }; + + expect(v.parse(ConfigSchema, config)).toEqual(config); + }); + it.each([ { name: 'missing jobs', config: {} }, + { name: 'null jobs', config: { jobs: null } }, + { name: 'scalar jobs', config: { jobs: 'owners' } }, { name: 'an empty jobs array', config: { jobs: [] } }, { name: 'a non-empty jobs array', config: { jobs: [{ type: 'codeowners' }] } }, { name: 'a missing job type', config: { jobs: { owners: {} } } }, @@ -41,4 +49,16 @@ describe('ConfigSchema', () => { ])('rejects $name', ({ config }) => { expect(v.safeParse(ConfigSchema, config).success).toBe(false); }); + + describe.each(['constructor', 'prototype', '__proto__'])('job key %s', (key) => { + it.each([ + { name: 'a valid job', job: { type: 'codeowners' } }, + { name: 'an invalid job', job: { type: 'unsupported', input: 123 } }, + ])('rejects $name instead of discarding it', ({ job }) => { + const result = v.safeParse(ConfigSchema, { jobs: { owners: { type: 'codeowners' }, [key]: job } }); + + expect(result.success).toBe(false); + expect(result.issues?.[0].message).toBe('jobs must not use constructor, prototype, or __proto__ as keys.'); + }); + }); }); diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index a716e15..f88d52d 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -1,5 +1,7 @@ import * as v from 'valibot'; +const JobKeySchema = v.pipe(v.string(), v.notValues(['constructor', 'prototype', '__proto__'])); + const CodeownersJobSchema = v.strictObject({ type: v.literal('codeowners'), input: v.pipe( @@ -17,9 +19,15 @@ const CodeownersJobSchema = v.strictObject({ export const ConfigSchema = v.strictObject({ jobs: v.pipe( - v.unknown(), - v.check((input) => !Array.isArray(input), 'jobs must be an object.'), - v.record(v.string(), CodeownersJobSchema), + v.custom>( + (input) => input !== null && typeof input === 'object' && !Array.isArray(input), + 'jobs must be an object.', + ), + v.check( + (jobs) => Object.keys(jobs).every((key) => v.is(JobKeySchema, key)), + 'jobs must not use constructor, prototype, or __proto__ as keys.', + ), + v.record(JobKeySchema, CodeownersJobSchema), ), }); diff --git a/tests/config-cli.spec.ts b/tests/config-cli.spec.ts index 766949e..44dfb14 100644 --- a/tests/config-cli.spec.ts +++ b/tests/config-cli.spec.ts @@ -83,6 +83,35 @@ describe('YAML config CLI', () => { expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); }); + it.each( + ['constructor', 'prototype', '__proto__'].flatMap((key) => [ + { + name: `a ${key} job`, + config: `jobs:\n ${key}:\n type: codeowners\n`, + }, + { + name: `a ${key} job alongside an owners job`, + config: `jobs:\n owners:\n type: codeowners\n output: .github/CODEOWNERS\n ${key}:\n type: codeowners\n`, + }, + ]), + )('rejects $name without changing the output', async (scenario) => { + await using fixture = await Fixture.create({ + '.git': {}, + 'pullup.yml': scenario.config, + '.github': { CODEOWNERS: existingContents }, + CODEOWNERS: '* @source-team\n', + }); + + const result = await runCli(command, fixture.root); + const output = result.stdout + result.stderr; + + expect(result.code).toBe(1); + expect(output).toContain('jobs must not use constructor, prototype, or __proto__ as keys.'); + expect(output).not.toContain('synced'); + expect(output).not.toContain('All files are up to date'); + expect(await readFile(path.join(fixture.root, '.github/CODEOWNERS'), 'utf8')).toBe(existingContents); + }); + it('succeeds without a config and leaves the output unchanged', async () => { await using fixture = await Fixture.create({ '.git': {}, diff --git a/tests/config-schema.spec.ts b/tests/config-schema.spec.ts index 9d0a5a4..751c823 100644 --- a/tests/config-schema.spec.ts +++ b/tests/config-schema.spec.ts @@ -9,6 +9,10 @@ describe('generated config schema', () => { properties: { jobs: { type: 'object', + propertyNames: { + type: 'string', + not: { enum: ['constructor', 'prototype', '__proto__'] }, + }, additionalProperties: { type: 'object', properties: {