diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3fdfaa1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: jdx/mise-action@v3 + - run: yarn install --immutable + - run: yarn lint + - run: yarn format:check + - run: yarn build + - run: yarn test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76062c3..a3aea05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,13 +15,13 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: jdx/mise-action@v3 - - run: yarn install + - run: yarn install --immutable - name: Create Changesets Pull Request or Publish to NPM id: changesets uses: changesets/action@c48e67d110a68bc90ccf1098e9646092baacaa87 # v1.6.0 with: - title: "chore: version packages" - commit: "chore: version packages" + title: 'chore: version packages' + commit: 'chore: version packages' version: yarn changeset version publish: yarn changeset publish env: diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..8f19e5d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 2, + "printWidth": 120, + "singleQuote": true, + "semi": true, + "trailingComma": "all" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3d0c601..2571b83 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,3 @@ { - "recommendations": [ - "arcanis.vscode-zipfs", - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint" - ] + "recommendations": ["arcanis.vscode-zipfs", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 51f4e5f..ce77b50 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,8 +5,8 @@ "**/.yarn": true, "**/.pnp.*": true }, - "typescript.tsdk": ".yarn/sdks/typescript/lib", - "typescript.enablePromptUseWorkspaceTsdk": true, "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", + "prettier.configPath": ".prettierrc.json", + "js/ts.tsdk.path": ".yarn/sdks/typescript/lib", "eslint.nodePath": ".yarn/sdks" } diff --git a/.yarnrc.yml b/.yarnrc.yml index c9821f5..9291b48 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,8 +2,8 @@ enableGlobalCache: false nodeLinker: pnp -npmPublishRegistry: "https://registry.npmjs.org" +npmPublishRegistry: 'https://registry.npmjs.org' npmRegistries: - "https://registry.npmjs.org": + 'https://registry.npmjs.org': npmAlwaysAuth: true diff --git a/README.md b/README.md index 5c77c44..190d982 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ pnpm add -D @pull-up/cli Create a config file in your project root: ```ts -import { defineConfig, codeownersJob } from "@pull-up/cli"; +import { defineConfig, codeownersJob } from '@pull-up/cli'; export default defineConfig(codeownersJob()); // or export default defineConfig([codeownersJob()]); @@ -60,8 +60,8 @@ Collects `CODEOWNERS` files from your monorepo and merges them into a single fil ```ts codeownersJob({ - from: ["**/CODEOWNERS"], // default - output: ".github/CODEOWNERS", // default + from: ['**/CODEOWNERS'], // default + output: '.github/CODEOWNERS', // default }); ``` @@ -70,12 +70,12 @@ codeownersJob({ You can define custom jobs using `defineJob`: ```ts -import { defineConfig, defineJob } from "@pull-up/cli"; +import { defineConfig, defineJob } from '@pull-up/cli'; const myJob = defineJob({ - name: "my-job", - from: ["packages/*/config.json"], - output: "merged-config.json", + name: 'my-job', + from: ['packages/*/config.json'], + output: 'merged-config.json', transform: (sources, context) => { // sources: array of { path, contents } // context: { root, outputPath, existingContents } diff --git a/eslint.config.mjs b/eslint.config.mjs index 9a0ee2f..141e8b7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,32 +1,30 @@ -// @ts-check - -import eslint from "@eslint/js"; -import importX from "eslint-plugin-import-x"; -import simpleImportSort from "eslint-plugin-simple-import-sort"; -import { defineConfig } from "eslint/config"; -import globals from "globals"; -import tseslint from "typescript-eslint"; +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import importX from 'eslint-plugin-import-x'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; export default defineConfig( { - ignores: ["**/node_modules/**", "**/dist/**", ".yarn/**"], + ignores: ['**/node_modules/**', '**/dist/**', '.yarn/**', '**/.pnp.cjs', '**/.pnp.loader.mjs'], }, eslint.configs.recommended, tseslint.configs.recommended, { - files: ["**/*.{ts,tsx}"], + files: ['**/*.{ts,tsx}'], languageOptions: { parser: tseslint.parser, parserOptions: { - project: ["./tsconfig.json"], + project: ['./tsconfig.json'], }, }, rules: { - "@typescript-eslint/strict-boolean-expressions": "error", + '@typescript-eslint/strict-boolean-expressions': 'error', }, }, { - files: ["**/*.{js,mjs,cjs,ts,tsx}"], + files: ['**/*.{js,mjs,cjs,ts,tsx}'], languageOptions: { globals: { ...globals.es2026, @@ -34,14 +32,14 @@ export default defineConfig( }, }, plugins: { - "simple-import-sort": simpleImportSort, - "import-x": importX, + 'simple-import-sort': simpleImportSort, + 'import-x': importX, }, rules: { - "simple-import-sort/imports": "error", - "import-x/first": "error", - "import-x/newline-after-import": "error", - "import-x/no-duplicates": "error", + 'simple-import-sort/imports': 'error', + 'import-x/first': 'error', + 'import-x/newline-after-import': 'error', + 'import-x/no-duplicates': 'error', }, }, ); diff --git a/package.json b/package.json index 17a22b0..0e1b808 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,15 @@ "dev": "tsdown --watch", "prepack": "yarn build", "lint": "eslint .", + "lint:fix": "eslint --fix .", "format": "prettier --write .", + "format:check": "prettier --check .", "typecheck": "tsc --noEmit" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", + "@eslint/js": "^9.39.2", "@fixture-kit/core": "^1.0.1", "@types/node": "^24.10.0", "eslint": "^9.7.0", diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 7cf0d7c..7f79931 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -1,35 +1,33 @@ -import { Command, Option } from "clipanion"; -import pc from "picocolors"; -import { resolveConfig } from "../../core/resolve-config"; -import { runJob } from "../../core/run-job"; -import { resolveRepositoryRoot } from "../utils"; +import { Command, Option } from 'clipanion'; +import pc from 'picocolors'; + +import { resolveConfig } from '../../core/resolve-config'; +import { runJob } from '../../core/run-job'; +import { resolveRepositoryRoot } from '../utils'; export class CheckCommand extends Command { - static paths = [["check"]]; + static paths = [['check']]; static usage = Command.Usage({ - description: "Check if generated files are up to date", - examples: [["Check all jobs", "pullup check"]], + description: 'Check if generated files are up to date', + examples: [['Check all jobs', 'pullup check']], }); - root = Option.String("--root", { - description: "The path to the repository root", + root = Option.String('--root', { + description: 'The path to the repository root', required: false, }); - cwd = Option.String("--cwd", { - description: "The path to the working directory", + cwd = Option.String('--cwd', { + description: 'The path to the working directory', required: false, }); async execute() { const cwd = this.cwd ?? process.cwd(); - const [repoRoot, jobs] = await Promise.all([ - resolveRepositoryRoot(cwd, this.root), - resolveConfig(cwd), - ]); + const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]); if (jobs.length === 0) { - console.log(pc.yellow("✘ No jobs found to check")); + console.log(pc.yellow('✘ No jobs found to check')); return; } @@ -37,13 +35,11 @@ export class CheckCommand extends Command { for (const { isSame, jobInfo } of results) { if (!isSame) { - console.error( - pc.red(`✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`), - ); + console.error(pc.red(`✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`)); process.exit(1); } } - console.log(pc.green("✔ All files are up to date")); + console.log(pc.green('✔ All files are up to date')); } } diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 8a8d8f1..848b59b 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,2 +1,2 @@ -export * from "./check.js"; -export * from "./sync.js"; +export * from './check.js'; +export * from './sync.js'; diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index d838e4d..f0d9d97 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -1,30 +1,32 @@ -import { Command, Option } from "clipanion"; -import pc from "picocolors"; -import fs from "node:fs/promises"; -import { resolveRepositoryRoot } from "../utils"; -import path from "node:path"; -import { resolveConfig } from "../../core/resolve-config"; -import { runJob } from "../../core/run-job"; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { Command, Option } from 'clipanion'; +import pc from 'picocolors'; + +import { resolveConfig } from '../../core/resolve-config'; +import { runJob } from '../../core/run-job'; +import { resolveRepositoryRoot } from '../utils'; export class SyncCommand extends Command { - static paths = [["sync"]]; + static paths = [['sync']]; static usage = Command.Usage({ - description: "sync files", - examples: [["Sync files", "pullup sync"]], + description: 'sync files', + examples: [['Sync files', 'pullup sync']], }); - root = Option.String("--root", { - description: "The path to the repository root", + root = Option.String('--root', { + description: 'The path to the repository root', required: false, }); - cwd = Option.String("--cwd", { - description: "The path to the working directory", + cwd = Option.String('--cwd', { + description: 'The path to the working directory', required: false, }); - dryRun = Option.Boolean("--dry-run", { - description: "Whether to dry run the check", + dryRun = Option.Boolean('--dry-run', { + description: 'Whether to dry run the check', required: false, }); @@ -34,21 +36,21 @@ export class SyncCommand extends Command { const jobs = await resolveConfig(cwd); if (jobs.length === 0) { - console.log(pc.yellow("✘ No jobs found to sync")); + console.log(pc.yellow('✘ No jobs found to sync')); return; } const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot))); for (const { jobInfo, generated, isSame } of results) { - if (this.dryRun) { + if (this.dryRun === true) { console.log(pc.cyan(`┌─ [Job] ${jobInfo.name}`)); - console.log(`${pc.cyan("│")} ${pc.dim(`Output: ${jobInfo.output}`)}`); - console.log(`${pc.cyan("│")}`); - generated.contents.split("\n").forEach((line) => { - console.log(`${pc.cyan("│")} ${line}`); + console.log(`${pc.cyan('│')} ${pc.dim(`Output: ${jobInfo.output}`)}`); + console.log(`${pc.cyan('│')}`); + generated.contents.split('\n').forEach((line) => { + console.log(`${pc.cyan('│')} ${line}`); }); - console.log(`${pc.cyan("└─")}`); + console.log(`${pc.cyan('└─')}`); continue; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 27faa56..6957c1c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,12 +1,13 @@ -import { Cli } from "clipanion"; -import packageJson from "../../package.json" with { type: "json" }; -import { CheckCommand, SyncCommand } from "./commands"; +import { Cli } from 'clipanion'; + +import packageJson from '../../package.json' with { type: 'json' }; +import { CheckCommand, SyncCommand } from './commands'; const [, , ...args] = process.argv; const cli = new Cli({ - binaryLabel: "pullup", - binaryName: "pullup", + binaryLabel: 'pullup', + binaryName: 'pullup', binaryVersion: packageJson.version, }); diff --git a/src/cli/utils.ts b/src/cli/utils.ts index 9fac376..f20a1ed 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -1,16 +1,16 @@ -import { findUp } from "find-up"; -import path from "path"; +import { findUp } from 'find-up'; +import path from 'path'; export async function resolveRepositoryRoot(cwd: string, root?: string) { if (root != null) return root; - const dotGit = await findUp(".git", { - type: "both", + const dotGit = await findUp('.git', { + type: 'both', cwd, }); if (dotGit == null) { - throw new Error("Repository root not found"); + throw new Error('Repository root not found'); } return path.dirname(dotGit); diff --git a/src/core/define-config.ts b/src/core/define-config.ts index 4aef69e..0e365f6 100644 --- a/src/core/define-config.ts +++ b/src/core/define-config.ts @@ -1,4 +1,4 @@ -import type { Job } from "./types"; +import type { Job } from './types'; export function defineConfig(config: Job | Job[]) { if (Array.isArray(config)) { diff --git a/src/core/define-job.ts b/src/core/define-job.ts index b990e8c..38574c1 100644 --- a/src/core/define-job.ts +++ b/src/core/define-job.ts @@ -1,9 +1,8 @@ -import type { Job } from "./types"; +import type { Job } from './types'; -export function defineJob( - jobFactory: Job | ((...args: Args) => Job), -) { - if (typeof jobFactory === "function") { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function defineJob(jobFactory: Job | ((...args: Args) => Job)) { + if (typeof jobFactory === 'function') { return jobFactory; } diff --git a/src/core/index.ts b/src/core/index.ts index 1dd925b..236be71 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,4 @@ -export type * from "./types"; -export * from "./define-job"; -export * from "./jobs"; -export * from "./define-config"; +export type * from './types'; +export * from './define-job'; +export * from './jobs'; +export * from './define-config'; diff --git a/src/core/jobs/codeowners/codeowners-job.ts b/src/core/jobs/codeowners/codeowners-job.ts index 21ecb6e..587de7b 100644 --- a/src/core/jobs/codeowners/codeowners-job.ts +++ b/src/core/jobs/codeowners/codeowners-job.ts @@ -1,19 +1,19 @@ -import path from "node:path"; +import path from 'node:path'; -import { defineJob } from "../../define-job"; -import type { Source } from "../../types"; -import { Codeowners } from "./codeowners"; +import { defineJob } from '../../define-job'; +import type { Source } from '../../types'; +import { Codeowners } from './codeowners'; interface CodeownersJobOptions { output?: string; input?: string[]; } -const DEFAULT_FROM_PATTERN = ["**/CODEOWNERS"]; -const DEFAULT_OUTPUT_PATH = ".github/CODEOWNERS"; +const DEFAULT_FROM_PATTERN = ['**/CODEOWNERS']; +const DEFAULT_OUTPUT_PATH = '.github/CODEOWNERS'; export const codeownersJob = defineJob((options?: CodeownersJobOptions) => ({ - name: "codeowners", + name: 'codeowners', input: options?.input ?? DEFAULT_FROM_PATTERN, output: options?.output ?? DEFAULT_OUTPUT_PATH, transform: (inputFiles, { rootDir }) => { @@ -29,7 +29,7 @@ export const codeownersJob = defineJob((options?: CodeownersJobOptions) => ({ ); if (codeowners.isEmpty()) { - return ""; + return ''; } return codeowners.stringify(); @@ -41,17 +41,14 @@ const sortByDirectory = (inputFiles: Source[], rootDir: string) => inputFiles .map((file) => { // Use the same normalized path for ordering and generated patterns. - const relativePath = path.relative( - rootDir, - path.resolve(rootDir, file.path), - ); + const relativePath = path.relative(rootDir, path.resolve(rootDir, file.path)); const directoryPath = path.dirname(relativePath); - const baseDir = directoryPath === "." ? "" : directoryPath; + const baseDir = directoryPath === '.' ? '' : directoryPath; return { file, relativePath, baseDir, - segments: baseDir === "" ? [] : baseDir.split(path.sep), + segments: baseDir === '' ? [] : baseDir.split(path.sep), }; }) .sort((a, b) => { @@ -64,15 +61,12 @@ const sortByDirectory = (inputFiles: Source[], rootDir: string) => } } - return ( - a.segments.length - b.segments.length || - a.relativePath.localeCompare(b.relativePath) - ); + return a.segments.length - b.segments.length || a.relativePath.localeCompare(b.relativePath); }); const toAbsolutePattern = (pattern: string, baseDir: string) => { - const base = baseDir !== "" ? `/${baseDir}` : ""; - return pattern === "*" ? `${base}/` : `${base}/${stripLeadingSlash(pattern)}`; + const base = baseDir !== '' ? `/${baseDir}` : ''; + return pattern === '*' ? `${base}/` : `${base}/${stripLeadingSlash(pattern)}`; }; -const stripLeadingSlash = (text: string) => text.replace(/^\//, ""); +const stripLeadingSlash = (text: string) => text.replace(/^\//, ''); diff --git a/src/core/jobs/codeowners/codeowners.ts b/src/core/jobs/codeowners/codeowners.ts index c75269c..4a17e04 100644 --- a/src/core/jobs/codeowners/codeowners.ts +++ b/src/core/jobs/codeowners/codeowners.ts @@ -8,11 +8,11 @@ export class Codeowners { static from(content: string): Codeowners { const entries = content - .split("\n") + .split('\n') .map((line) => line.trim()) - .filter((line) => line !== "" && !line.startsWith("#")) + .filter((line) => line !== '' && !line.startsWith('#')) .map((line) => { - const [pattern = "", ...owners] = line.split(/\s+/); + const [pattern = '', ...owners] = line.split(/\s+/); return { pattern, owners }; }); @@ -32,12 +32,10 @@ export class Codeowners { } stringify(): string { - if (this.isEmpty()) return ""; + if (this.isEmpty()) return ''; - return ensureEoL( - this.entries.map((e) => `${e.pattern} ${e.owners.join(" ")}`).join("\n"), - ); + return ensureEoL(this.entries.map((e) => `${e.pattern} ${e.owners.join(' ')}`).join('\n')); } } -const ensureEoL = (text: string) => (text.endsWith("\n") ? text : text + "\n"); +const ensureEoL = (text: string) => (text.endsWith('\n') ? text : text + '\n'); diff --git a/src/core/jobs/codeowners/index.ts b/src/core/jobs/codeowners/index.ts index abc3173..588e8de 100644 --- a/src/core/jobs/codeowners/index.ts +++ b/src/core/jobs/codeowners/index.ts @@ -1 +1 @@ -export * from "./codeowners-job"; +export * from './codeowners-job'; diff --git a/src/core/jobs/index.ts b/src/core/jobs/index.ts index a89f35a..6a8ca6e 100644 --- a/src/core/jobs/index.ts +++ b/src/core/jobs/index.ts @@ -1 +1 @@ -export * from "./codeowners/codeowners-job"; +export * from './codeowners/codeowners-job'; diff --git a/src/core/resolve-config.ts b/src/core/resolve-config.ts index 5bc4bdc..91bcd90 100644 --- a/src/core/resolve-config.ts +++ b/src/core/resolve-config.ts @@ -1,8 +1,9 @@ -import type { Job } from "./types"; -import { cosmiconfig } from "cosmiconfig"; +import { cosmiconfig } from 'cosmiconfig'; + +import type { Job } from './types'; export async function resolveConfig(cwd: string): Promise { - const explorer = cosmiconfig("pullup"); + const explorer = cosmiconfig('pullup'); const result = await explorer.search(cwd); if (result == null) { diff --git a/src/core/run-job.ts b/src/core/run-job.ts index b8fc252..9b5408e 100644 --- a/src/core/run-job.ts +++ b/src/core/run-job.ts @@ -1,8 +1,10 @@ -import type { Job, Source } from "./types"; -import fg from "fast-glob"; -import fs from "node:fs/promises"; -import path from "path"; -import { readFileOrNull } from "./utils"; +import fs from 'node:fs/promises'; + +import fg from 'fast-glob'; +import path from 'path'; + +import type { Job, Source } from './types'; +import { readFileOrNull } from './utils'; interface RunJobResult { jobInfo: Job; @@ -15,9 +17,7 @@ export async function runJob(job: Job, rootDir: string): Promise { cwd: rootDir, }).then((paths) => paths.toSorted((a, b) => a.localeCompare(b))); - const inputFiles = await Promise.all( - inputFilePaths.map((filePath) => readSource(rootDir, filePath)), - ); + const inputFiles = await Promise.all(inputFilePaths.map((filePath) => readSource(rootDir, filePath))); const outputPath = path.resolve(rootDir, job.output); const existing = await readFileOrNull(outputPath); @@ -39,6 +39,6 @@ export async function runJob(job: Job, rootDir: string): Promise { async function readSource(rootPath: string, filePath: string): Promise { const absPath = path.resolve(rootPath, filePath); - const contents = await fs.readFile(absPath, "utf-8"); + const contents = await fs.readFile(absPath, 'utf-8'); return { path: filePath, contents }; } diff --git a/src/core/types.ts b/src/core/types.ts index 506aa93..fa50205 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -18,10 +18,7 @@ export interface Job { input: string[]; /** Transform the collected files into a new contents. */ - transform: ( - inputFiles: Source[], - context: TransformContext, - ) => Awaitable; + transform: (inputFiles: Source[], context: TransformContext) => Awaitable; /** The path to the output file. */ output: string; diff --git a/src/core/utils.ts b/src/core/utils.ts index 1a8d375..4dd90d2 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,8 +1,8 @@ -import fs from "node:fs/promises"; +import fs from 'node:fs/promises'; export async function readFileOrNull(absPath: string): Promise { try { - return await fs.readFile(absPath, "utf-8"); + return await fs.readFile(absPath, 'utf-8'); } catch { return null; } diff --git a/tests/codeowners-cli.test.mjs b/tests/codeowners-cli.test.mjs index 698849b..331eafd 100644 --- a/tests/codeowners-cli.test.mjs +++ b/tests/codeowners-cli.test.mjs @@ -1,49 +1,45 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; -import { Fixture } from "@fixture-kit/core"; +import { Fixture } from '@fixture-kit/core'; const execFileAsync = promisify(execFile); -const cliPath = fileURLToPath( - new URL("../dist/bin/index.mjs", import.meta.url), -); -const coreUrl = new URL("../dist/core/index.mjs", import.meta.url).href; +const cliPath = fileURLToPath(new URL('../dist/bin/index.mjs', import.meta.url)); +const coreUrl = new URL('../dist/core/index.mjs', import.meta.url).href; const config = `import { codeownersJob } from ${JSON.stringify(coreUrl)}; export default [codeownersJob()]; `; for (const scenario of [ - { name: "the repository root", cwd: "repo", explicitRoot: false }, - { name: "a subdirectory", cwd: "repo/services/auth", explicitRoot: false }, + { name: 'the repository root', cwd: 'repo', explicitRoot: false }, + { name: 'a subdirectory', cwd: 'repo/services/auth', explicitRoot: false }, { - name: "another repository with --root", - cwd: "other-repo", + name: 'another repository with --root', + cwd: 'other-repo', explicitRoot: true, }, ]) { test(`sync resolves CODEOWNERS paths from ${scenario.name}`, async () => { - await using fixture = await Fixture.fromDirectory( - fileURLToPath(new URL("./fixtures/", import.meta.url)), - ); - const rootDir = path.join(fixture.root, "repo"); - const subdirectory = path.join(rootDir, "services/auth"); - const otherRoot = path.join(fixture.root, "other-repo"); + await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('./fixtures/', import.meta.url))); + const rootDir = path.join(fixture.root, 'repo'); + const subdirectory = path.join(rootDir, 'services/auth'); + const otherRoot = path.join(fixture.root, 'other-repo'); await Promise.all([ - mkdir(path.join(rootDir, ".git"), { recursive: true }), - mkdir(path.join(otherRoot, ".git"), { recursive: true }), + mkdir(path.join(rootDir, '.git'), { recursive: true }), + mkdir(path.join(otherRoot, '.git'), { recursive: true }), ]); // Config discovery uses cwd independently of the repository root. for (const directory of [rootDir, subdirectory, otherRoot]) { - await writeFile(path.join(directory, "pullup.config.mjs"), config); + await writeFile(path.join(directory, 'pullup.config.mjs'), config); } - const args = [cliPath, "sync"]; - if (scenario.explicitRoot) args.push("--root", rootDir); + const args = [cliPath, 'sync']; + if (scenario.explicitRoot) args.push('--root', rootDir); await execFileAsync(process.execPath, args, { cwd: path.join(fixture.root, scenario.cwd), @@ -51,62 +47,54 @@ for (const scenario of [ }); assert.equal( - await readFile(path.join(rootDir, ".github/CODEOWNERS"), "utf8"), - "/ @root-team\n" + - "/docs/ @docs-team\n" + - "/services/ads/ @ads-team\n" + - "/services/ads/ads-platform/ @platform-team\n" + - "/services/ads/ads-platform/special/ @special-team\n" + - "/services/auth/ @auth-team\n" + - "/services/auth/login/ @login-team\n" + - "/services/auth/login/admin/ @admin-team\n" + - "/services/auth-cert/ @cert-team\n" + - "/services/builder/desktop/ @desktop-team\n" + - "/services/builder/form/ @form-team\n" + - "/services/cart/ @cart-team\n" + - "/tools/catalog-cli/ @tools-team\n", + await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8'), + '/ @root-team\n' + + '/docs/ @docs-team\n' + + '/services/ads/ @ads-team\n' + + '/services/ads/ads-platform/ @platform-team\n' + + '/services/ads/ads-platform/special/ @special-team\n' + + '/services/auth/ @auth-team\n' + + '/services/auth/login/ @login-team\n' + + '/services/auth/login/admin/ @admin-team\n' + + '/services/auth-cert/ @cert-team\n' + + '/services/builder/desktop/ @desktop-team\n' + + '/services/builder/form/ @form-team\n' + + '/services/cart/ @cart-team\n' + + '/tools/catalog-cli/ @tools-team\n', ); if (scenario.explicitRoot) { - await assert.rejects( - readFile(path.join(otherRoot, ".github/CODEOWNERS"), "utf8"), - { code: "ENOENT" }, - ); + await assert.rejects(readFile(path.join(otherRoot, '.github/CODEOWNERS'), 'utf8'), { code: 'ENOENT' }); } }); } for (const scenario of [ { - name: "./", - input: ["services/auth/CODEOWNERS", "./services/auth/login/CODEOWNERS"], + name: './', + input: ['services/auth/CODEOWNERS', './services/auth/login/CODEOWNERS'], }, { - name: "..", - input: [ - "services/auth/CODEOWNERS", - "services/../services/auth/login/CODEOWNERS", - ], + name: '..', + input: ['services/auth/CODEOWNERS', 'services/../services/auth/login/CODEOWNERS'], }, ]) { test(`sync normalizes ${scenario.name} in CODEOWNERS input paths before sorting`, async () => { - await using fixture = await Fixture.fromDirectory( - fileURLToPath(new URL("./fixtures/repo/", import.meta.url)), - ); + await using fixture = await Fixture.fromDirectory(fileURLToPath(new URL('./fixtures/repo/', import.meta.url))); const rootDir = fixture.root; const customConfig = `import { codeownersJob } from ${JSON.stringify(coreUrl)}; export default [codeownersJob({ input: ${JSON.stringify(scenario.input)} })]; `; - await mkdir(path.join(rootDir, ".git")); - await writeFile(path.join(rootDir, "pullup.config.mjs"), customConfig); - await execFileAsync(process.execPath, [cliPath, "sync"], { + await mkdir(path.join(rootDir, '.git')); + await writeFile(path.join(rootDir, 'pullup.config.mjs'), customConfig); + await execFileAsync(process.execPath, [cliPath, 'sync'], { cwd: rootDir, timeout: 10_000, }); assert.equal( - await readFile(path.join(rootDir, ".github/CODEOWNERS"), "utf8"), - "/services/auth/ @auth-team\n" + "/services/auth/login/ @login-team\n", + await readFile(path.join(rootDir, '.github/CODEOWNERS'), 'utf8'), + '/services/auth/ @auth-team\n' + '/services/auth/login/ @login-team\n', ); }); } diff --git a/tests/codeowners-job.test.mjs b/tests/codeowners-job.test.mjs index 6ed5978..13d02c3 100644 --- a/tests/codeowners-job.test.mjs +++ b/tests/codeowners-job.test.mjs @@ -1,247 +1,231 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; -import { Fixture } from "@fixture-kit/core"; +import { Fixture } from '@fixture-kit/core'; -import { codeownersJob } from "../dist/core/index.mjs"; +import { codeownersJob } from '../dist/core/index.mjs'; -const fixtureDirectory = fileURLToPath( - new URL("./fixtures/repo/", import.meta.url), -); +const fixtureDirectory = fileURLToPath(new URL('./fixtures/repo/', import.meta.url)); async function readFixtureFiles(rootDir, filePaths) { return Promise.all( filePaths.map(async (filePath) => ({ path: filePath, - contents: await readFile(path.resolve(rootDir, filePath), "utf8"), + contents: await readFile(path.resolve(rootDir, filePath), 'utf8'), })), ); } -test("merges parent CODEOWNERS before nested files", async () => { +test('merges parent CODEOWNERS before nested files', async () => { await using fixture = await Fixture.fromDirectory(fixtureDirectory); const rootDir = fixture.root; // Lexical path order places ads-platform/CODEOWNERS before ads/CODEOWNERS. const inputFiles = await readFixtureFiles(rootDir, [ - path.join(rootDir, "services/ads/ads-platform/CODEOWNERS"), - path.join(rootDir, "services/ads/CODEOWNERS"), + path.join(rootDir, 'services/ads/ads-platform/CODEOWNERS'), + path.join(rootDir, 'services/ads/CODEOWNERS'), ]); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); assert.equal( result, - "/services/ads/ @ads-team\n" + - "/services/ads/ads-platform/ @platform-team\n" + - "/services/ads/ads-platform/special/ @special-team\n", + '/services/ads/ @ads-team\n' + + '/services/ads/ads-platform/ @platform-team\n' + + '/services/ads/ads-platform/special/ @special-team\n', ); }); -test("keeps each directory subtree together with parents first", async () => { +test('keeps each directory subtree together with parents first', async () => { await using fixture = await Fixture.fromDirectory(fixtureDirectory); const rootDir = fixture.root; const inputFiles = await readFixtureFiles(rootDir, [ - path.join(rootDir, "tools/catalog-cli/CODEOWNERS"), - path.join(rootDir, "services/auth-cert/CODEOWNERS"), - path.join(rootDir, "services/auth/login/admin/CODEOWNERS"), - path.join(rootDir, "services/auth/login/CODEOWNERS"), - path.join(rootDir, "services/auth/CODEOWNERS"), - path.join(rootDir, "CODEOWNERS"), + path.join(rootDir, 'tools/catalog-cli/CODEOWNERS'), + path.join(rootDir, 'services/auth-cert/CODEOWNERS'), + path.join(rootDir, 'services/auth/login/admin/CODEOWNERS'), + path.join(rootDir, 'services/auth/login/CODEOWNERS'), + path.join(rootDir, 'services/auth/CODEOWNERS'), + path.join(rootDir, 'CODEOWNERS'), ]); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); assert.equal( result, - "/ @root-team\n" + - "/docs/ @docs-team\n" + - "/services/auth/ @auth-team\n" + - "/services/auth/login/ @login-team\n" + - "/services/auth/login/admin/ @admin-team\n" + - "/services/auth-cert/ @cert-team\n" + - "/tools/catalog-cli/ @tools-team\n", + '/ @root-team\n' + + '/docs/ @docs-team\n' + + '/services/auth/ @auth-team\n' + + '/services/auth/login/ @login-team\n' + + '/services/auth/login/admin/ @admin-team\n' + + '/services/auth-cert/ @cert-team\n' + + '/tools/catalog-cli/ @tools-team\n', ); }); -test("groups nested directories even when their parent has no CODEOWNERS", async () => { +test('groups nested directories even when their parent has no CODEOWNERS', async () => { await using fixture = await Fixture.fromDirectory(fixtureDirectory); const rootDir = fixture.root; const inputFiles = await readFixtureFiles(rootDir, [ - path.join(rootDir, "tools/catalog-cli/CODEOWNERS"), - path.join(rootDir, "services/cart/CODEOWNERS"), - path.join(rootDir, "services/builder/form/CODEOWNERS"), - path.join(rootDir, "services/builder/desktop/CODEOWNERS"), + path.join(rootDir, 'tools/catalog-cli/CODEOWNERS'), + path.join(rootDir, 'services/cart/CODEOWNERS'), + path.join(rootDir, 'services/builder/form/CODEOWNERS'), + path.join(rootDir, 'services/builder/desktop/CODEOWNERS'), ]); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); assert.equal( result, - "/services/builder/desktop/ @desktop-team\n" + - "/services/builder/form/ @form-team\n" + - "/services/cart/ @cart-team\n" + - "/tools/catalog-cli/ @tools-team\n", + '/services/builder/desktop/ @desktop-team\n' + + '/services/builder/form/ @form-team\n' + + '/services/cart/ @cart-team\n' + + '/tools/catalog-cli/ @tools-team\n', ); }); -test("resolves relative and absolute CODEOWNERS paths against rootDir", async () => { +test('resolves relative and absolute CODEOWNERS paths against rootDir', async () => { await using fixture = await Fixture.fromDirectory(fixtureDirectory); const rootDir = fixture.root; const inputFiles = await readFixtureFiles(rootDir, [ - "services/auth/login/CODEOWNERS", - path.join(rootDir, "tools/catalog-cli/CODEOWNERS"), - "services/auth/CODEOWNERS", - "CODEOWNERS", + 'services/auth/login/CODEOWNERS', + path.join(rootDir, 'tools/catalog-cli/CODEOWNERS'), + 'services/auth/CODEOWNERS', + 'CODEOWNERS', ]); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); assert.equal( result, - "/ @root-team\n" + - "/docs/ @docs-team\n" + - "/services/auth/ @auth-team\n" + - "/services/auth/login/ @login-team\n" + - "/tools/catalog-cli/ @tools-team\n", + '/ @root-team\n' + + '/docs/ @docs-team\n' + + '/services/auth/ @auth-team\n' + + '/services/auth/login/ @login-team\n' + + '/tools/catalog-cli/ @tools-team\n', ); }); -for (const childPath of [ - "./services/auth/login/CODEOWNERS", - "services/./auth/../../services/auth/login/CODEOWNERS", -]) { +for (const childPath of ['./services/auth/login/CODEOWNERS', 'services/./auth/../../services/auth/login/CODEOWNERS']) { test(`normalizes ${childPath} before ordering parent and child rules`, async () => { await using fixture = await Fixture.fromDirectory(fixtureDirectory); const rootDir = fixture.root; - const inputFiles = await readFixtureFiles(rootDir, [ - childPath, - "services/auth/CODEOWNERS", - ]); + const inputFiles = await readFixtureFiles(rootDir, [childPath, 'services/auth/CODEOWNERS']); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); - assert.equal( - result, - "/services/auth/ @auth-team\n/services/auth/login/ @login-team\n", - ); + assert.equal(result, '/services/auth/ @auth-team\n/services/auth/login/ @login-team\n'); }); } -test("keeps normalized path aliases stable without mutating the input", async () => { +test('keeps normalized path aliases stable without mutating the input', async () => { const rootDir = process.cwd(); const inputFiles = Object.freeze( [ - { path: "services/auth/CODEOWNERS", contents: "* @first\n" }, + { path: 'services/auth/CODEOWNERS', contents: '* @first\n' }, { - path: path.join(rootDir, "services/auth/CODEOWNERS"), - contents: "* @second\n", + path: path.join(rootDir, 'services/auth/CODEOWNERS'), + contents: '* @second\n', }, { - path: "./services/auth/login/../CODEOWNERS", - contents: "* @last\n", + path: './services/auth/login/../CODEOWNERS', + contents: '* @last\n', }, ].map((file) => Object.freeze(file)), ); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); - assert.equal( - result, - "/services/auth/ @first\n" + - "/services/auth/ @second\n" + - "/services/auth/ @last\n", - ); + assert.equal(result, '/services/auth/ @first\n' + '/services/auth/ @second\n' + '/services/auth/ @last\n'); }); -test("keeps locale-equivalent directory names in separate subtrees", async () => { +test('keeps locale-equivalent directory names in separate subtrees', async () => { const rootDir = process.cwd(); const inputFiles = [ { - path: "services/é/child/CODEOWNERS", - contents: "* @composed-child\n", + path: 'services/é/child/CODEOWNERS', + contents: '* @composed-child\n', }, { - path: "services/e\u0301/CODEOWNERS", - contents: "* @decomposed-parent\n", + path: 'services/e\u0301/CODEOWNERS', + contents: '* @decomposed-parent\n', }, { - path: "services/é/CODEOWNERS", - contents: "* @composed-parent\n", + path: 'services/é/CODEOWNERS', + contents: '* @composed-parent\n', }, { - path: "services/e\u0301/child/CODEOWNERS", - contents: "* @decomposed-child\n", + path: 'services/e\u0301/child/CODEOWNERS', + contents: '* @decomposed-child\n', }, ]; const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); assert.equal( result, - "/services/e\u0301/ @decomposed-parent\n" + - "/services/e\u0301/child/ @decomposed-child\n" + - "/services/é/ @composed-parent\n" + - "/services/é/child/ @composed-child\n", + '/services/e\u0301/ @decomposed-parent\n' + + '/services/e\u0301/child/ @decomposed-child\n' + + '/services/é/ @composed-parent\n' + + '/services/é/child/ @composed-child\n', ); }); -test("sorts filenames stably without mutating the input", async () => { +test('sorts filenames stably without mutating the input', async () => { const rootDir = process.cwd(); const inputFiles = Object.freeze( [ { - path: "services/auth/Z-CODEOWNERS", - contents: "* @last\n", + path: 'services/auth/Z-CODEOWNERS', + contents: '* @last\n', }, { - path: "services/auth/é-CODEOWNERS", - contents: "* @composed\n", + path: 'services/auth/é-CODEOWNERS', + contents: '* @composed\n', }, { - path: "services/auth/e\u0301-CODEOWNERS", - contents: "* @decomposed\n", + path: 'services/auth/e\u0301-CODEOWNERS', + contents: '* @decomposed\n', }, { - path: "services/auth/A-CODEOWNERS", - contents: "* @first\n", + path: 'services/auth/A-CODEOWNERS', + contents: '* @first\n', }, ].map((file) => Object.freeze(file)), ); const result = await codeownersJob().transform(inputFiles, { rootDir, - outputPath: path.join(rootDir, ".github/CODEOWNERS"), + outputPath: path.join(rootDir, '.github/CODEOWNERS'), }); assert.equal( result, - "/services/auth/ @first\n" + - "/services/auth/ @composed\n" + - "/services/auth/ @decomposed\n" + - "/services/auth/ @last\n", + '/services/auth/ @first\n' + + '/services/auth/ @composed\n' + + '/services/auth/ @decomposed\n' + + '/services/auth/ @last\n', ); }); diff --git a/tsconfig.json b/tsconfig.json index 4b99508..c1d56bf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "allowJs": false, "skipLibCheck": true }, - "include": ["src/**/*.ts", "tsdown.config.ts"], + "include": ["**/*.ts"], "exclude": ["**/dist/*"] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 4a9b8c5..cabae2d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,17 +1,17 @@ -import { defineConfig } from "tsdown"; +import { defineConfig } from 'tsdown'; export default defineConfig([ { - entry: "src/cli/index.ts", - banner: "#!/usr/bin/env node", + entry: 'src/cli/index.ts', + banner: '#!/usr/bin/env node', dts: false, minify: true, - noExternal: ["clipanion", "picocolors"], - outDir: "dist/bin", + noExternal: ['clipanion', 'picocolors'], + outDir: 'dist/bin', }, { - entry: "src/core/index.ts", + entry: 'src/core/index.ts', dts: true, - outDir: "dist/core", + outDir: 'dist/core', }, ]); diff --git a/yarn.lock b/yarn.lock index c6b6e4a..e238803 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,6 +425,13 @@ __metadata: languageName: node linkType: hard +"@eslint/js@npm:^9.39.2": + version: 9.39.5 + resolution: "@eslint/js@npm:9.39.5" + checksum: 10c0/49894e98ba313a7d3bfb1b20d55c0f9826be45a7db876fd84e533ac7f101b1d95b51cd22c6a6db3a45066b9c0d068c31b4a22fa0db8a6cc8744a25540efdb824 + languageName: node + linkType: hard + "@eslint/object-schema@npm:^2.1.7": version: 2.1.7 resolution: "@eslint/object-schema@npm:2.1.7" @@ -645,6 +652,7 @@ __metadata: dependencies: "@changesets/changelog-github": "npm:^0.5.2" "@changesets/cli": "npm:^2.29.8" + "@eslint/js": "npm:^9.39.2" "@fixture-kit/core": "npm:^1.0.1" "@types/node": "npm:^24.10.0" clipanion: "npm:^4.0.0-rc.4"