diff --git a/packages/projen/component/linting/src/linting.ts b/packages/projen/component/linting/src/linting.ts index 5c590777..a82e9448 100644 --- a/packages/projen/component/linting/src/linting.ts +++ b/packages/projen/component/linting/src/linting.ts @@ -1,5 +1,6 @@ import child_process from 'node:child_process' import * as os from 'os' +import * as path from 'path' import { applyOverrides, findRootProject, @@ -14,6 +15,8 @@ import { typescript, } from 'projen' import { + Biome, + type BiomeOptions, Eslint, type NodeProject, Prettier, @@ -21,9 +24,16 @@ import { } from 'projen/lib/javascript' import shellquote from 'shell-quote' +export type LintBackendKind = 'eslint' | 'biome' + export interface LintConfigOptions { /** - * Enable type-enriched linting in eslint. + * Which linting/formatting backend to use. + * @default 'eslint' + */ + readonly backend?: LintBackendKind + /** + * Enable type-enriched linting in eslint (ignored for biome backend). */ readonly useTypeInformation?: boolean /** @@ -31,6 +41,19 @@ export interface LintConfigOptions { * @default 30 */ readonly formatTimeout?: number + /** + * Options passed to Projen's Biome component when `backend: 'biome'`. + */ + readonly biomeOptions?: BiomeOptions + /** + * Strip Projen's default Biome spawn from the project's `testTask`. + * Enable this when biome runs as a dedicated task target (e.g. an Nx + * target) and you don't want `pnpm test` to write formatter fixes as a + * side effect. Standalone consumers should leave this off so biome stays + * in `pnpm test`. + * @default false + */ + readonly removeBiomeFromTestTask?: boolean } export interface FormatRequest { @@ -39,45 +62,73 @@ export interface FormatRequest { */ filePath: string /** - * Working directory to spawn eslint from. + * Working directory to spawn the formatter from. */ workingDirectory: string + /** + * Linting implementation to use. + */ + linter?: LintBackend } -export class LintConfig extends Component { - public static of(project: Project): LintConfig | undefined { - const isLintConfig = (o: Component): o is LintConfig => - o instanceof LintConfig - return project.components.find(isLintConfig) +/** + * Projen's `Biome` constructor unconditionally spawns its task into the project's + * `testTask`. Callers that run biome as a dedicated task target (e.g. an Nx + * target) can use this to undo that spawn so `pnpm test` doesn't write + * formatter fixes as a side effect. + */ +function removeBiomeFromTestTask(project: NodeProject, biome: Biome): void { + const testTask = project.testTask + const steps = testTask.steps + for (let i = steps.length - 1; i >= 0; i--) { + if (steps[i].spawn === biome.task.name) { + testTask.removeStep(i) + } } +} + +/** + * Repo-wide formatting defaults shared between the root biome.jsonc and any + * standalone/root BiomeBackend so leaves and root agree on style. + */ +function repoBiomeFormattingDefaults(): BiomeOptions['biomeConfig'] { + return { + formatter: { + indentStyle: 'tab' as never, + indentWidth: 2, + lineWidth: 80, + }, + javascript: { + formatter: { + quoteStyle: 'single' as never, + semicolons: 'asNeeded' as never, + }, + }, + } +} + +abstract class LintBackend { + abstract readonly kind: LintBackendKind + abstract ignoreReadOnlyFiles(): void + abstract applyResolvableExtensions(extensions: readonly string[]): void + abstract formatFileCommand(filePath: string): string + abstract lintTaskName(): string + abstract setLintExec(exec: string, replace?: string): void + abstract updateLintTask(step: TaskStep): void +} +class EslintBackend extends LintBackend { + readonly kind = 'eslint' as const readonly eslint: Eslint readonly eslintFile: ObjectFile readonly prettier: Prettier readonly prettierFile: ObjectFile - #formatQueue: PQueue - #extensions: Set = new Set([ - '.js', - '.jsx', - '.mjs', - '.cjs', - '.ts', - '.tsx', - '.mts', - '.cts', - ]) - constructor( - project: NodeProject, - options: LintConfigOptions = { - useTypeInformation: true, - formatTimeout: 30, - }, + private readonly project: NodeProject, + options: LintConfigOptions, ) { - super(project) - this.#formatQueue = this.buildFormatQueue(options.formatTimeout) - + super() this.eslint = Eslint.of(project) ?? new Eslint(project, { @@ -98,7 +149,6 @@ export class LintConfig extends Component { settings: prettierConfig, }) this.prettierFile = project.tryFindObjectFile('.prettierrc.json')! - applyOverrides(this.prettierFile, prettierConfig) this.eslint.addRules({ @@ -130,10 +180,208 @@ export class LintConfig extends Component { this.eslintFile = project.tryFindObjectFile('.eslintrc.json')! if (options.useTypeInformation) { - this.enableEslintTypeInformation() + this.enableTypeInformation() } } + protected enableTypeInformation(): void { + this.eslint.addExtends( + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + ) + this.eslintFile.addOverride('parserOptions.tsconfigRootDir', '.') + } + + ignoreReadOnlyFiles(): void { + this.project.files + .filter((f) => f.readonly) + .forEach((f) => { + this.eslint.addIgnorePattern(f.path) + this.prettier.addIgnorePattern(f.path) + }) + } + + applyResolvableExtensions(extensions: readonly string[]): void { + const arr = Array.from(extensions) + this.eslintFile.addOverride('settings.import/resolver.node.extensions', arr) + this.eslintFile.addOverride('settings.import/extensions', arr) + } + + formatFileCommand(filePath: string): string { + return `eslint --no-ignore --fix ` + shellquote.quote([filePath]) + } + + lintTaskName(): string { + return 'eslint' + } + + setLintExec(exec: string, replace: string = 'eslint'): void { + const task = this.project.tasks.tryFind('eslint')! + const cmd = task.steps[0].exec!.replace(replace, exec) + replaceTask(this.project, 'eslint', [{ exec: cmd }]) + } + + updateLintTask(step: TaskStep): void { + replaceTask(this.project, 'eslint', [step]) + } +} + +class BiomeBackend extends LintBackend { + readonly kind = 'biome' as const + readonly biome: Biome + + constructor( + private readonly project: NodeProject, + options: LintConfigOptions, + ) { + super() + this.cleanupForeignConfig() + + // Leaf biome.jsonc extends root; disable default sections locally so + // inherited rules aren't shadowed by locally re-emitted defaults. + const userOptions = options.biomeOptions ?? {} + const root = findRootProject(project) + const isRoot = root === project + const rootRelative = path.relative(project.outdir, root.outdir) || '.' + const extendsPath = `${rootRelative}/biome.jsonc` + const leafOptions: BiomeOptions = { + linter: userOptions.linter ?? false, + formatter: userOptions.formatter ?? false, + assist: userOptions.assist ?? false, + version: userOptions.version, + mergeArraysInConfiguration: userOptions.mergeArraysInConfiguration, + ignoreGeneratedFiles: userOptions.ignoreGeneratedFiles, + biomeConfig: { + ...(isRoot + ? repoBiomeFormattingDefaults() + : { extends: [extendsPath], root: false }), + ...(userOptions.biomeConfig ?? {}), + }, + } + this.biome = new Biome(project, leafOptions) + // Biome needs at least one positive include pattern when extending, otherwise + // the inherited all-negative includes list causes every file to be treated + // as excluded. Prepend `**` so the package's own files match. + this.biome.addFilePattern('**') + if (options.removeBiomeFromTestTask) { + removeBiomeFromTestTask(project, this.biome) + } + + if (this.project instanceof typescript.TypeScriptProject) { + this.biome.addOverride({ + includes: [`${this.project.testdir}/**`], + linter: { + rules: { + suspicious: { + useAwait: 'warn', + }, + }, + }, + }) + } + } + + protected cleanupForeignConfig(): void { + for (const name of [ + '.eslintrc.json', + '.prettierrc.json', + '.prettierignore', + '.eslintignore', + ]) { + this.project.tryRemoveFile(name) + } + } + + ignoreReadOnlyFiles(): void { + // Biome's `ignoreGeneratedFiles` default handles this. + } + + applyResolvableExtensions(): void { + // No-op; Biome resolves file extensions natively. + } + + formatFileCommand(filePath: string): string { + return `biome format --write ` + shellquote.quote([filePath]) + } + + lintTaskName(): string { + return 'biome' + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setLintExec(_exec: string, _replace?: string): void { + this.project.logger.debug( + 'LintConfig.setLintExec is a no-op when backend is biome', + ) + } + + updateLintTask(step: TaskStep): void { + replaceTask(this.project, 'biome', [step]) + } +} + +export class LintConfig extends Component { + public static of(project: Project): LintConfig | undefined { + const isLintConfig = (o: Component): o is LintConfig => + o instanceof LintConfig + return project.components.find(isLintConfig) + } + + readonly backend: LintBackendKind + + protected linter: LintBackend + #formatQueue: PQueue + #extensions: Set = new Set([ + '.js', + '.jsx', + '.mjs', + '.cjs', + '.ts', + '.tsx', + '.mts', + '.cts', + ]) + + get eslint(): Eslint | undefined { + return this.linter instanceof EslintBackend ? this.linter.eslint : undefined + } + + get eslintFile(): ObjectFile | undefined { + return this.linter instanceof EslintBackend + ? this.linter.eslintFile + : undefined + } + + get prettier(): Prettier | undefined { + return this.linter instanceof EslintBackend + ? this.linter.prettier + : undefined + } + + get prettierFile(): ObjectFile | undefined { + return this.linter instanceof EslintBackend + ? this.linter.prettierFile + : undefined + } + + get biome(): Biome | undefined { + return this.linter instanceof BiomeBackend ? this.linter.biome : undefined + } + + constructor(project: NodeProject, options: LintConfigOptions = {}) { + super(project) + const { + backend = 'eslint', + formatTimeout = 30, + useTypeInformation = true, + } = options + this.backend = backend + this.#formatQueue = this.buildFormatQueue(formatTimeout) + this.linter = + backend === 'biome' + ? new BiomeBackend(project, options) + : new EslintBackend(project, { ...options, useTypeInformation }) + } + /** * Build format queue. * @param timeout Queue timeout. @@ -160,30 +408,6 @@ export class LintConfig extends Component { return queue } - /** - * Enable type enriched linting. - * @protected - */ - protected enableEslintTypeInformation() { - this.eslint.addExtends( - 'plugin:@typescript-eslint/recommended-requiring-type-checking', - ) - this.eslintFile.addOverride('parserOptions.tsconfigRootDir', '.') - } - - /** - * Ensure all readonly files are ignored by lint tools. - * @protected - */ - protected ignoreReadOnlyFiles() { - this.project.files - .filter((f) => f.readonly) - .forEach((f) => { - this.eslint.addIgnorePattern(f.path) - this.prettier.addIgnorePattern(f.path) - }) - } - /** * Resolve and process queued file format request. * Requests are processed in parallel for speed. @@ -194,49 +418,46 @@ export class LintConfig extends Component { } /** - * Apply eslint extensions. - * @protected - */ - protected applyResolvableExtensions(): void { - const extensions = Array.from(this.#extensions) - this.eslintFile.addOverride( - 'settings.import/resolver.node.extensions', - extensions, - ) - this.eslintFile.addOverride('settings.import/extensions', extensions) - } - - /* - * Add extensions to eslint resolvable settings. + * Add extensions to the lint backend's resolvable settings. * @param extensions Extensions to add. */ addResolvableExtensions(...extensions: string[]): this { - // projen runs all patch calls as a single patch for some reason - // and just lets test ops throw, so only add once presynth. extensions.forEach((ext) => this.#extensions.add(ext)) return this } /** - * Merge task step into eslint task. + * Merge a task step into the active lint task. * @param step Task step to merge. */ - updateEslintTask(step: TaskStep): this { - replaceTask(this.project, 'eslint', [step]) + updateLintTask(step: TaskStep): this { + this.linter.updateLintTask(step) return this } /** - * Replace eslint executable command. + * Merge a task step into the eslint task (compat shim). + * Use {@link updateLintTask} for backend-neutral code. + */ + updateEslintTask(step: TaskStep): this { + return this.updateLintTask(step) + } + + /** + * Replace the lint task executable command. * @param exec Replacement value. * @param replace Existing value to replace. Defaults to 'eslint'. */ + setLintExec(exec: string, replace: string = 'eslint'): this { + this.linter.setLintExec(exec, replace) + return this + } + + /** + * Compat shim for eslint-only callers. Use {@link setLintExec}. + */ setEslintExec(exec: string, replace: string = 'eslint'): this { - const eslintTask = this.project.tasks.tryFind('eslint')! - const eslintCmd = eslintTask.steps[0].exec!.replace(replace, exec) - return this.updateEslintTask({ - exec: eslintCmd, - }) + return this.setLintExec(exec, replace) } /** @@ -244,11 +465,11 @@ export class LintConfig extends Component { * @param request format request. */ enqueueFormatRequest(request: FormatRequest): this { + const linter = request.linter ?? this.linter + const cmd = linter.formatFileCommand(request.filePath) void this.#formatQueue.add(async () => { - const cmd = - `eslint --no-ignore --fix ` + shellquote.quote([request.filePath]) this.project.logger.debug( - `formatting typescript source file: ${request.filePath} (from: ${request.workingDirectory})`, + `formatting source file: ${request.filePath} (from: ${request.workingDirectory})`, ) return new Promise((resolve) => child_process.exec( @@ -277,15 +498,59 @@ export class LintConfig extends Component { return LintConfig.of(root)!.enqueueFormatRequest({ filePath, workingDirectory: this.project.outdir, + linter: this.linter, + }) + } + + /** + * Emit a root biome.jsonc when any subproject opts into the biome backend. + * Only runs when this LintConfig is attached to a project with children. + * @protected + */ + protected maybeEmitRootBiomeConfig(): void { + if (this.project.subprojects.length === 0) return + if (Biome.of(this.project)) return + + const biomeChildren = this.project.subprojects.filter((p) => { + const lc = LintConfig.of(p) + return lc?.backend === 'biome' + }) + if (biomeChildren.length === 0) return + + const rootBiomeProject = this.project as NodeProject + const rootBiome = new Biome(rootBiomeProject, { + biomeConfig: repoBiomeFormattingDefaults(), + }) + + // Scope root biome to opted-in package directories. Running biome from a + // leaf uses that leaf's config (which extends this root for rules) and + // the leaf injects its own `**` include so paths match correctly. + for (const child of biomeChildren) { + const relPath = path.relative(this.project.outdir, child.outdir) + rootBiome.addFilePattern(`${relPath}/**`) + } + rootBiome.addFilePattern('!**/node_modules/**') + rootBiome.addFilePattern('!**/dist/**') + rootBiome.addFilePattern('!**/dist-types/**') + + rootBiome.expandLinterRules({ + style: { + useConsistentTypeDefinitions: { + level: 'error', + options: { style: 'interface' }, + }, + }, }) + removeBiomeFromTestTask(rootBiomeProject, rootBiome) } /** * @inheritDoc */ preSynthesize() { - this.ignoreReadOnlyFiles() - this.applyResolvableExtensions() + this.linter.ignoreReadOnlyFiles() + this.linter.applyResolvableExtensions(Array.from(this.#extensions)) + this.maybeEmitRootBiomeConfig() super.preSynthesize() } diff --git a/packages/projen/component/vue/src/vue.ts b/packages/projen/component/vue/src/vue.ts index f0fea933..3d671568 100644 --- a/packages/projen/component/vue/src/vue.ts +++ b/packages/projen/component/vue/src/vue.ts @@ -71,6 +71,7 @@ export class Vue extends Component { applyLintConfig(component?: LintConfig): this { if (!component) return this + if (!component.eslint || !component.eslintFile) return this this.project.addDevDeps('eslint-plugin-vue') component.eslint.addPlugins('eslint-plugin-vue') component.eslint.addExtends('plugin:vue/vue3-recommended') diff --git a/packages/projen/project/nx-monorepo/src/nx-monorepo.ts b/packages/projen/project/nx-monorepo/src/nx-monorepo.ts index f274e097..73c42802 100644 --- a/packages/projen/project/nx-monorepo/src/nx-monorepo.ts +++ b/packages/projen/project/nx-monorepo/src/nx-monorepo.ts @@ -402,6 +402,23 @@ export class MonorepoProject extends NxMonorepoProject { nxJson.addOverride('useDaemonProcess', this.options.nxUseDaemon ?? true) nxJson.addOverride('analytics', false) } + // add target for biome + this.applyPreSynth(() => { + const hasBiome = this.sortedSubProjects.find((p) => + findComponent(p, javascript.Biome), + ) + if (hasBiome) { + this.nx.setTargetDefault('biome', { + cache: true, + inputs: [ + 'default', + '{workspaceRoot}/biome.jsonc', + // @ts-expect-error outdated type + { externalDependencies: ['@biomejs/biome'] }, + ], + }) + } + }) return this.applyNxCloudAccessToken() .applyNxCacheDefaults() .applyNxJsPlugin() diff --git a/packages/projen/project/typescript/src/builders.ts b/packages/projen/project/typescript/src/builders.ts index 48153344..91b378f8 100644 --- a/packages/projen/project/typescript/src/builders.ts +++ b/packages/projen/project/typescript/src/builders.ts @@ -270,17 +270,53 @@ export class TypescriptLintingBuilder extends BaseBuildStep< object, { readonly lintConfig: LintConfig } > { + private useBiome = false + private biomeOptions: javascript.BiomeOptions | undefined + constructor(readonly options?: LintConfigOptions) { super() } + applyOptions( + options: ProjectOptions & this['_outputOptions'], + ): ProjectOptions & this['_outputOptions'] { + const typed = options as ProjectOptions & + this['_outputOptions'] & { + biome?: boolean + biomeOptions?: javascript.BiomeOptions + eslint?: boolean + prettier?: boolean + } + // Always reset instance state — this builder is shared across projects. + this.useBiome = !!typed.biome + this.biomeOptions = typed.biomeOptions + if (this.useBiome) { + // Take over linting: skip Projen's native biome/eslint/prettier wiring + // so LintConfig can manage a single tool end-to-end. + return { + ...options, + biome: false, + eslint: false, + prettier: false, + } + } + return options + } + applyProject( project: Project, ): TypedPropertyDescriptorMap> { if (!isNodeProject(project)) { throw new TypeError('Project must be a NodeProject') } - const lintConfig = new LintConfig(project, this.options) + const lintConfig = this.useBiome + ? new LintConfig(project, { + ...this.options, + backend: 'biome', + biomeOptions: this.biomeOptions, + removeBiomeFromTestTask: true, + }) + : new LintConfig(project, this.options) return { lintConfig: { writable: false, value: lintConfig }, } as TypedPropertyDescriptorMap> @@ -315,18 +351,29 @@ export class TypescriptReleasePleaseBuilder extends BaseBuildStep { */ export class TypescriptLintStagedHooksBuilder extends BaseBuildStep { private readonly eslintCmd: string + private readonly biomeCmd: string private readonly prettierCmd: string - constructor(options?: { eslintCmd?: string; prettierCmd?: string }) { + constructor(options?: { + eslintCmd?: string + biomeCmd?: string + prettierCmd?: string + }) { super() const { eslintCmd = 'eslint --fix --no-error-on-unmatched-pattern', + biomeCmd = 'biome check --files-ignore-unknown=true --no-errors-on-unmatched --write', prettierCmd = 'prettier --write', } = options ?? {} this.eslintCmd = eslintCmd + this.biomeCmd = biomeCmd this.prettierCmd = prettierCmd } protected applyLintStaged(project: javascript.NodeProject) { + const lintConfig = LintConfig.of(project) + const useBiome = lintConfig?.backend === 'biome' + const tsCmd = useBiome ? this.biomeCmd : this.eslintCmd + const yamlCmd = useBiome ? this.biomeCmd : this.prettierCmd return new LintStaged(project, { entries: [ { @@ -334,7 +381,7 @@ export class TypescriptLintStagedHooksBuilder extends BaseBuildStep { commands: [ NodePackageUtils.command.exec( project.package.packageManager, - this.eslintCmd, + tsCmd, ), ], }, @@ -343,7 +390,7 @@ export class TypescriptLintStagedHooksBuilder extends BaseBuildStep { commands: [ NodePackageUtils.command.exec( project.package.packageManager, - this.prettierCmd, + yamlCmd, ), ], }, diff --git a/projenrc/monorepo.ts b/projenrc/monorepo.ts index 45c1b9fd..6d9a187c 100644 --- a/projenrc/monorepo.ts +++ b/projenrc/monorepo.ts @@ -81,6 +81,11 @@ export class ComponentsMonorepo this.applyRecursive( (project) => { if (NodePackageUtils.isNodeProject(project)) { + const lintConfig = LintConfig.of(project) + const tsCmd = + lintConfig?.backend === 'biome' + ? 'biome check --files-ignore-unknown=true --no-errors-on-unmatched --write' + : 'eslint --no-error-on-unmatched-pattern --fix' new LintStaged(project, { entries: [ { @@ -88,7 +93,7 @@ export class ComponentsMonorepo commands: [ NodePackageUtils.command.exec( this.package.packageManager, - 'eslint --no-error-on-unmatched-pattern --fix', + tsCmd, ), ], },