From 22dcc1b569d801e8bcffb8ca4c0d8f2cc0ed3851 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Wed, 22 Apr 2026 17:11:55 -0500 Subject: [PATCH 01/11] feat(projen.component.linting): extract linting backend, refactor eslint backend, implement biome backend Signed-off-by: Braden Mars --- .../projen/component/linting/src/linting.ts | 407 ++++++++++++++---- 1 file changed, 324 insertions(+), 83 deletions(-) diff --git a/packages/projen/component/linting/src/linting.ts b/packages/projen/component/linting/src/linting.ts index 5c590777..fa43091a 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,10 @@ export interface LintConfigOptions { * @default 30 */ readonly formatTimeout?: number + /** + * Options passed to Projen's Biome component when `backend: 'biome'`. + */ + readonly biomeOptions?: BiomeOptions } export interface FormatRequest { @@ -39,45 +53,48 @@ export interface FormatRequest { */ filePath: string /** - * Working directory to spawn eslint from. + * Working directory to spawn the formatter from. */ workingDirectory: string } -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`. For this repo we run biome as a dedicated Nx target, so 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) + } } +} + +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 +115,6 @@ export class LintConfig extends Component { settings: prettierConfig, }) this.prettierFile = project.tryFindObjectFile('.prettierrc.json')! - applyOverrides(this.prettierFile, prettierConfig) this.eslint.addRules({ @@ -130,10 +146,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 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: { + 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('**') + 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 + + #backend: LintBackend + #formatQueue: PQueue + #extensions: Set = new Set([ + '.js', + '.jsx', + '.mjs', + '.cjs', + '.ts', + '.tsx', + '.mts', + '.cts', + ]) + + get eslint(): Eslint | undefined { + return this.#backend instanceof EslintBackend + ? this.#backend.eslint + : undefined + } + + get eslintFile(): ObjectFile | undefined { + return this.#backend instanceof EslintBackend + ? this.#backend.eslintFile + : undefined + } + + get prettier(): Prettier | undefined { + return this.#backend instanceof EslintBackend + ? this.#backend.prettier + : undefined + } + + get prettierFile(): ObjectFile | undefined { + return this.#backend instanceof EslintBackend + ? this.#backend.prettierFile + : undefined + } + + get biome(): Biome | undefined { + return this.#backend instanceof BiomeBackend + ? this.#backend.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.#backend = + backend === 'biome' + ? new BiomeBackend(project, options) + : new EslintBackend(project, { ...options, useTypeInformation }) + } + /** * Build format queue. * @param timeout Queue timeout. @@ -160,30 +374,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 +384,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.#backend.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.#backend.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 +431,10 @@ export class LintConfig extends Component { * @param request format request. */ enqueueFormatRequest(request: FormatRequest): this { + const cmd = this.#backend.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( @@ -280,12 +466,67 @@ export class LintConfig extends Component { }) } + /** + * 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: { + formatter: { + indentStyle: 'tab' as never, + indentWidth: 2, + lineWidth: 80, + }, + javascript: { + formatter: { + quoteStyle: 'single' as never, + semicolons: 'asNeeded' as never, + }, + }, + }, + }) + + // 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.#backend.ignoreReadOnlyFiles() + this.#backend.applyResolvableExtensions(Array.from(this.#extensions)) + this.maybeEmitRootBiomeConfig() super.preSynthesize() } From 03e4a6021c472e57c60c6cdcb98db4baf11b144f Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Wed, 22 Apr 2026 17:13:58 -0500 Subject: [PATCH 02/11] feat(projen.project.typescript): support biome in ts linting builders, hooks builder Signed-off-by: Braden Mars --- .../projen/project/typescript/src/builders.ts | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/projen/project/typescript/src/builders.ts b/packages/projen/project/typescript/src/builders.ts index 48153344..200e74a1 100644 --- a/packages/projen/project/typescript/src/builders.ts +++ b/packages/projen/project/typescript/src/builders.ts @@ -270,17 +270,52 @@ 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, + }) + : new LintConfig(project, this.options) return { lintConfig: { writable: false, value: lintConfig }, } as TypedPropertyDescriptorMap> @@ -315,18 +350,28 @@ 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 tsCmd = + lintConfig?.backend === 'biome' ? this.biomeCmd : this.eslintCmd return new LintStaged(project, { entries: [ { @@ -334,7 +379,7 @@ export class TypescriptLintStagedHooksBuilder extends BaseBuildStep { commands: [ NodePackageUtils.command.exec( project.package.packageManager, - this.eslintCmd, + tsCmd, ), ], }, From 7fe30512ee55e549e6589afc20651fb29653e4c1 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Wed, 22 Apr 2026 17:34:41 -0500 Subject: [PATCH 03/11] feat(projen.project.nx-monorepo): add nx target for biome if any subproject has it Signed-off-by: Braden Mars --- .../project/nx-monorepo/src/nx-monorepo.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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() From c4b9a541b6c22a3eb10a250bdcdb9a2966afd04d Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Wed, 22 Apr 2026 17:42:17 -0500 Subject: [PATCH 04/11] feat(projen.component.linting): make backend protected, not private Signed-off-by: Braden Mars --- .../projen/component/linting/src/linting.ts | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/projen/component/linting/src/linting.ts b/packages/projen/component/linting/src/linting.ts index fa43091a..9332431d 100644 --- a/packages/projen/component/linting/src/linting.ts +++ b/packages/projen/component/linting/src/linting.ts @@ -290,7 +290,7 @@ export class LintConfig extends Component { readonly backend: LintBackendKind - #backend: LintBackend + protected linter: LintBackend #formatQueue: PQueue #extensions: Set = new Set([ '.js', @@ -304,33 +304,29 @@ export class LintConfig extends Component { ]) get eslint(): Eslint | undefined { - return this.#backend instanceof EslintBackend - ? this.#backend.eslint - : undefined + return this.linter instanceof EslintBackend ? this.linter.eslint : undefined } get eslintFile(): ObjectFile | undefined { - return this.#backend instanceof EslintBackend - ? this.#backend.eslintFile + return this.linter instanceof EslintBackend + ? this.linter.eslintFile : undefined } get prettier(): Prettier | undefined { - return this.#backend instanceof EslintBackend - ? this.#backend.prettier + return this.linter instanceof EslintBackend + ? this.linter.prettier : undefined } get prettierFile(): ObjectFile | undefined { - return this.#backend instanceof EslintBackend - ? this.#backend.prettierFile + return this.linter instanceof EslintBackend + ? this.linter.prettierFile : undefined } get biome(): Biome | undefined { - return this.#backend instanceof BiomeBackend - ? this.#backend.biome - : undefined + return this.linter instanceof BiomeBackend ? this.linter.biome : undefined } constructor(project: NodeProject, options: LintConfigOptions = {}) { @@ -342,7 +338,7 @@ export class LintConfig extends Component { } = options this.backend = backend this.#formatQueue = this.buildFormatQueue(formatTimeout) - this.#backend = + this.linter = backend === 'biome' ? new BiomeBackend(project, options) : new EslintBackend(project, { ...options, useTypeInformation }) @@ -397,7 +393,7 @@ export class LintConfig extends Component { * @param step Task step to merge. */ updateLintTask(step: TaskStep): this { - this.#backend.updateLintTask(step) + this.linter.updateLintTask(step) return this } @@ -415,7 +411,7 @@ export class LintConfig extends Component { * @param replace Existing value to replace. Defaults to 'eslint'. */ setLintExec(exec: string, replace: string = 'eslint'): this { - this.#backend.setLintExec(exec, replace) + this.linter.setLintExec(exec, replace) return this } @@ -431,7 +427,7 @@ export class LintConfig extends Component { * @param request format request. */ enqueueFormatRequest(request: FormatRequest): this { - const cmd = this.#backend.formatFileCommand(request.filePath) + const cmd = this.linter.formatFileCommand(request.filePath) void this.#formatQueue.add(async () => { this.project.logger.debug( `formatting source file: ${request.filePath} (from: ${request.workingDirectory})`, @@ -524,8 +520,8 @@ export class LintConfig extends Component { * @inheritDoc */ preSynthesize() { - this.#backend.ignoreReadOnlyFiles() - this.#backend.applyResolvableExtensions(Array.from(this.#extensions)) + this.linter.ignoreReadOnlyFiles() + this.linter.applyResolvableExtensions(Array.from(this.#extensions)) this.maybeEmitRootBiomeConfig() super.preSynthesize() } From 687c4ea4b45c6bd5ca7631341eb5057bb547b037 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Wed, 22 Apr 2026 17:46:42 -0500 Subject: [PATCH 05/11] feat(projen.component.linting): use source project linting backend when linting files Signed-off-by: Braden Mars --- packages/projen/component/linting/src/linting.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/projen/component/linting/src/linting.ts b/packages/projen/component/linting/src/linting.ts index 9332431d..f27f2cdd 100644 --- a/packages/projen/component/linting/src/linting.ts +++ b/packages/projen/component/linting/src/linting.ts @@ -56,6 +56,10 @@ export interface FormatRequest { * Working directory to spawn the formatter from. */ workingDirectory: string + /** + * Linting implementation to use. + */ + linter?: LintBackend } /** @@ -427,7 +431,8 @@ export class LintConfig extends Component { * @param request format request. */ enqueueFormatRequest(request: FormatRequest): this { - const cmd = this.linter.formatFileCommand(request.filePath) + const linter = request.linter ?? this.linter + const cmd = linter.formatFileCommand(request.filePath) void this.#formatQueue.add(async () => { this.project.logger.debug( `formatting source file: ${request.filePath} (from: ${request.workingDirectory})`, @@ -459,6 +464,7 @@ export class LintConfig extends Component { return LintConfig.of(root)!.enqueueFormatRequest({ filePath, workingDirectory: this.project.outdir, + linter: this.linter, }) } From 0e531c10887f13935761c7db264a8179e1ebd8ea Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Wed, 22 Apr 2026 17:48:15 -0500 Subject: [PATCH 06/11] feat(projenrc): use correct lint cmd in lint staged Signed-off-by: Braden Mars --- projenrc/monorepo.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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, ), ], }, From 81dfab9492ea7c40b10085c44a0bb3cabf92beda Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Thu, 23 Apr 2026 19:54:17 -0500 Subject: [PATCH 07/11] fix(projen.component.vue): ensure eslint Signed-off-by: Braden Mars --- packages/projen/component/vue/src/vue.ts | 1 + 1 file changed, 1 insertion(+) 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') From 771a888bb66280f4cf03cc0ee422ed8f689b5405 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Thu, 23 Apr 2026 20:13:34 -0500 Subject: [PATCH 08/11] fix(projen.component.linting): handle biome usage as root Signed-off-by: Braden Mars --- packages/projen/component/linting/src/linting.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/projen/component/linting/src/linting.ts b/packages/projen/component/linting/src/linting.ts index f27f2cdd..e9da8a2e 100644 --- a/packages/projen/component/linting/src/linting.ts +++ b/packages/projen/component/linting/src/linting.ts @@ -210,6 +210,7 @@ class BiomeBackend extends LintBackend { // 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 = { @@ -220,8 +221,7 @@ class BiomeBackend extends LintBackend { mergeArraysInConfiguration: userOptions.mergeArraysInConfiguration, ignoreGeneratedFiles: userOptions.ignoreGeneratedFiles, biomeConfig: { - extends: [extendsPath], - root: false, + ...(isRoot ? {} : { extends: [extendsPath], root: false }), ...(userOptions.biomeConfig ?? {}), }, } From cf285dcfa99badaf6345f4c0f972fc321ede8338 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Thu, 23 Apr 2026 20:13:53 -0500 Subject: [PATCH 09/11] fix(projen.project.typescript): handle prettier lint staged when using biome Signed-off-by: Braden Mars --- packages/projen/project/typescript/src/builders.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/projen/project/typescript/src/builders.ts b/packages/projen/project/typescript/src/builders.ts index 200e74a1..da81c40d 100644 --- a/packages/projen/project/typescript/src/builders.ts +++ b/packages/projen/project/typescript/src/builders.ts @@ -370,8 +370,9 @@ export class TypescriptLintStagedHooksBuilder extends BaseBuildStep { protected applyLintStaged(project: javascript.NodeProject) { const lintConfig = LintConfig.of(project) - const tsCmd = - lintConfig?.backend === 'biome' ? this.biomeCmd : this.eslintCmd + const useBiome = lintConfig?.backend === 'biome' + const tsCmd = useBiome ? this.biomeCmd : this.eslintCmd + const yamlCmd = useBiome ? this.biomeCmd : this.prettierCmd return new LintStaged(project, { entries: [ { @@ -388,7 +389,7 @@ export class TypescriptLintStagedHooksBuilder extends BaseBuildStep { commands: [ NodePackageUtils.command.exec( project.package.packageManager, - this.prettierCmd, + yamlCmd, ), ], }, From 989346d73b474802f74216dddec6dba3ba3f2c29 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Thu, 30 Apr 2026 14:24:34 -0500 Subject: [PATCH 10/11] fix(projen.component.linting): share defaults, make remove from test task opt-in Signed-off-by: Braden Mars --- .../projen/component/linting/src/linting.ts | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/projen/component/linting/src/linting.ts b/packages/projen/component/linting/src/linting.ts index e9da8a2e..a82e9448 100644 --- a/packages/projen/component/linting/src/linting.ts +++ b/packages/projen/component/linting/src/linting.ts @@ -45,6 +45,15 @@ export interface LintConfigOptions { * 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 { @@ -64,8 +73,9 @@ export interface FormatRequest { /** * Projen's `Biome` constructor unconditionally spawns its task into the project's - * `testTask`. For this repo we run biome as a dedicated Nx target, so undo that - * spawn so `pnpm test` doesn't write formatter fixes as a side effect. + * `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 @@ -77,6 +87,26 @@ function removeBiomeFromTestTask(project: NodeProject, biome: Biome): void { } } +/** + * 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 @@ -221,7 +251,9 @@ class BiomeBackend extends LintBackend { mergeArraysInConfiguration: userOptions.mergeArraysInConfiguration, ignoreGeneratedFiles: userOptions.ignoreGeneratedFiles, biomeConfig: { - ...(isRoot ? {} : { extends: [extendsPath], root: false }), + ...(isRoot + ? repoBiomeFormattingDefaults() + : { extends: [extendsPath], root: false }), ...(userOptions.biomeConfig ?? {}), }, } @@ -230,7 +262,9 @@ class BiomeBackend extends LintBackend { // 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('**') - removeBiomeFromTestTask(project, this.biome) + if (options.removeBiomeFromTestTask) { + removeBiomeFromTestTask(project, this.biome) + } if (this.project instanceof typescript.TypeScriptProject) { this.biome.addOverride({ @@ -485,19 +519,7 @@ export class LintConfig extends Component { const rootBiomeProject = this.project as NodeProject const rootBiome = new Biome(rootBiomeProject, { - biomeConfig: { - formatter: { - indentStyle: 'tab' as never, - indentWidth: 2, - lineWidth: 80, - }, - javascript: { - formatter: { - quoteStyle: 'single' as never, - semicolons: 'asNeeded' as never, - }, - }, - }, + biomeConfig: repoBiomeFormattingDefaults(), }) // Scope root biome to opted-in package directories. Running biome from a From 1abd59db8f0056fb0b9ca4bcb2b262e86298b048 Mon Sep 17 00:00:00 2001 From: Braden Mars Date: Thu, 30 Apr 2026 14:24:51 -0500 Subject: [PATCH 11/11] feat(projen.project.typescript): default remove biome from test task to true Signed-off-by: Braden Mars --- packages/projen/project/typescript/src/builders.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/projen/project/typescript/src/builders.ts b/packages/projen/project/typescript/src/builders.ts index da81c40d..91b378f8 100644 --- a/packages/projen/project/typescript/src/builders.ts +++ b/packages/projen/project/typescript/src/builders.ts @@ -314,6 +314,7 @@ export class TypescriptLintingBuilder extends BaseBuildStep< ...this.options, backend: 'biome', biomeOptions: this.biomeOptions, + removeBiomeFromTestTask: true, }) : new LintConfig(project, this.options) return {