From 6df6c66ef97ec7ada9ef41c8aa2909ee5c69eea8 Mon Sep 17 00:00:00 2001 From: JamesHenry Date: Sun, 9 Nov 2025 17:24:37 +0400 Subject: [PATCH 1/5] feat(builder): support {projectName} and {projectRoot} placeholders in outputFile Add support for placeholder interpolation in the outputFile option of the builder. This allows users to configure dynamic output paths for multi-project workspaces, preventing lint results from being overwritten when linting multiple projects. Supported placeholders: - {projectName}: Replaced with the current project name - {projectRoot}: Replaced with the current project root path Example usage: "outputFile": "reports/{projectName}.json" Closes #944 --- .../output-file-interpolation.test.ts.snap | 16 ++ e2e/src/output-file-interpolation.test.ts | 156 ++++++++++++++++++ packages/builder/src/lint.impl.spec.ts | 147 ++++++++++++++++- packages/builder/src/lint.impl.ts | 45 ++++- packages/builder/src/schema.json | 2 +- 5 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 e2e/src/__snapshots__/output-file-interpolation.test.ts.snap create mode 100644 e2e/src/output-file-interpolation.test.ts diff --git a/e2e/src/__snapshots__/output-file-interpolation.test.ts.snap b/e2e/src/__snapshots__/output-file-interpolation.test.ts.snap new file mode 100644 index 000000000..04b0f42ea --- /dev/null +++ b/e2e/src/__snapshots__/output-file-interpolation.test.ts.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`output-file-interpolation > should interpolate placeholders when running ng lint without project name (all projects) 1`] = ` +{ + "app-one": [], + "app-two": [], + "lib-one": [], + "output-file-interpolation": [], +} +`; + +exports[`output-file-interpolation > should support dynamic override of outputFile with placeholder interpolation 1`] = `[]`; + +exports[`output-file-interpolation > should write lint reports to interpolated paths for projects with outputFile configuration with known placeholders 1`] = `[]`; + +exports[`output-file-interpolation > should write lint reports to interpolated paths for projects with outputFile configuration with known placeholders 2`] = `[]`; diff --git a/e2e/src/output-file-interpolation.test.ts b/e2e/src/output-file-interpolation.test.ts new file mode 100644 index 000000000..7cfbe29b1 --- /dev/null +++ b/e2e/src/output-file-interpolation.test.ts @@ -0,0 +1,156 @@ +import path from 'node:path'; +import { setWorkspaceRoot } from 'nx/src/utils/workspace-root'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { + FIXTURES_DIR, + Fixture, + resetFixtureDirectory, +} from '../utils/fixtures'; +import { + LONG_TIMEOUT_MS, + runNgAdd, + runNgGenerate, + runNgNew, +} from '../utils/local-registry-process'; +import { normalizeVersionsOfPackagesWeDoNotControl } from '../utils/snapshot-serializers'; + +expect.addSnapshotSerializer(normalizeVersionsOfPackagesWeDoNotControl); + +const fixtureDirectory = 'output-file-interpolation'; +let fixture: Fixture; + +describe('output-file-interpolation', () => { + vi.setConfig({ testTimeout: LONG_TIMEOUT_MS }); + + beforeAll(async () => { + resetFixtureDirectory(fixtureDirectory); + process.chdir(FIXTURES_DIR); + + await runNgNew(fixtureDirectory); + + process.env.NX_DAEMON = 'false'; + process.env.NX_CACHE_PROJECT_GRAPH = 'false'; + + const workspaceRoot = path.join(FIXTURES_DIR, fixtureDirectory); + process.chdir(workspaceRoot); + process.env.NX_WORKSPACE_ROOT_PATH = workspaceRoot; + setWorkspaceRoot(workspaceRoot); + + fixture = new Fixture(workspaceRoot); + + await runNgAdd(); + await runNgGenerate(['app', 'app-one', '--interactive=false']); + await runNgGenerate(['app', 'app-two', '--interactive=false']); + await runNgGenerate(['lib', 'lib-one', '--interactive=false']); + + // Configure app-one with outputFile using {projectName} placeholder + const angularJson = fixture.readJson('angular.json'); + angularJson.projects['app-one'].architect.lint.options.outputFile = + 'lint-reports/{projectName}-report.json'; + angularJson.projects['app-one'].architect.lint.options.format = 'json'; + fixture.writeJson('angular.json', angularJson); + + // Configure lib-one with outputFile using both placeholders + angularJson.projects['lib-one'].architect.lint.options.outputFile = + '{projectRoot}/reports/{projectName}-results.json'; + angularJson.projects['lib-one'].architect.lint.options.format = 'json'; + fixture.writeJson('angular.json', angularJson); + }); + + it('should write lint reports to interpolated paths for projects with outputFile configuration with known placeholders', async () => { + expect.assertions(2); + + // Run lint for app-one + try { + fixture.runCommand('npx ng lint app-one'); + } catch { + // Lint may fail, but we're interested in the output files + } + + // Verify report was written to the interpolated path and snapshot the contents + const appOneReport = fixture.readJson('lint-reports/app-one-report.json'); + expect(appOneReport).toMatchSnapshot(); + + // Run lint for lib-one + try { + fixture.runCommand('npx ng lint lib-one'); + } catch { + // Lint may fail, but we're interested in the output files + } + + // Verify report was written to the interpolated path using projectRoot and snapshot the contents + const libOneReport = fixture.readJson( + 'projects/lib-one/reports/lib-one-results.json', + ); + expect(libOneReport).toMatchSnapshot(); + }); + + it('should support dynamic override of outputFile with placeholder interpolation', async () => { + expect.assertions(5); + + // Run lint for app-two with dynamic outputFile override + try { + fixture.runCommand( + 'npx ng lint app-two --output-file="dynamic-reports/{projectName}/{projectRoot}/lint.json" --format=json', + ); + } catch { + // Lint may fail, but we're interested in the output files + } + + // Verify report was written to the dynamically interpolated path and snapshot the contents + const appTwoReport = fixture.readJson( + 'dynamic-reports/app-two/projects/app-two/lint.json', + ); + expect(appTwoReport).toMatchSnapshot(); + }); + + it('should interpolate placeholders when running ng lint without project name (all projects)', async () => { + // Clean up any previous reports + if (fixture.directoryExists('all-projects-reports')) { + fixture.deleteFileOrDirectory('all-projects-reports'); + } + + // Run ng lint without a project name - this lints ALL projects + try { + fixture.runCommand( + 'npx ng lint --output-file="all-projects-reports/{projectName}-{projectRoot}.json" --format=json', + ); + } catch { + // Lint may fail, but we're interested in the output files + } + + // Verify each project has its own report file with interpolated paths + // The default project name should get its own file + const defaultProjectReport = + 'all-projects-reports/output-file-interpolation-.json'; + expect(fixture.fileExists(defaultProjectReport)).toBe(true); + + // app-one should have its own file + const appOneReportPath = + 'all-projects-reports/app-one-projects/app-one.json'; + expect(fixture.fileExists(appOneReportPath)).toBe(true); + + // app-two should have its own file + const appTwoReportPath = + 'all-projects-reports/app-two-projects/app-two.json'; + expect(fixture.fileExists(appTwoReportPath)).toBe(true); + + // lib-one should have its own file + const libOneReportPath = + 'all-projects-reports/lib-one-projects/lib-one.json'; + expect(fixture.fileExists(libOneReportPath)).toBe(true); + + // Snapshot all reports to ensure they contain the correct lint results + const defaultReport = fixture.readJson(defaultProjectReport); + const appOneReport = fixture.readJson(appOneReportPath); + const appTwoReport = fixture.readJson(appTwoReportPath); + const libOneReport = fixture.readJson(libOneReportPath); + + expect({ + 'output-file-interpolation': defaultReport, + 'app-one': appOneReport, + 'app-two': appTwoReport, + 'lib-one': libOneReport, + }).toMatchSnapshot(); + }); +}); diff --git a/packages/builder/src/lint.impl.spec.ts b/packages/builder/src/lint.impl.spec.ts index 76fb43976..b2fde6ad1 100644 --- a/packages/builder/src/lint.impl.spec.ts +++ b/packages/builder/src/lint.impl.spec.ts @@ -115,9 +115,13 @@ function createValidRunBuilderOptions( const registry = new json.schema.CoreSchemaRegistry(); registry.addPostTransform(schema.transforms.addUndefinedDefaults); +const mockGetProjectMetadata = vi.fn(); const testArchitectHost = new TestingArchitectHost( testWorkspaceRoot, testWorkspaceRoot, + { + getProjectMetadata: mockGetProjectMetadata, + } as any, ); const builderName = '@angular-eslint/builder:lint'; @@ -147,6 +151,12 @@ describe('Linter Builder', () => { beforeEach(() => { MockESLint.version = VALID_ESLINT_VERSION; mockReports = [{ results: [], messages: [], usedDeprecatedRules: [] }]; + mockGetProjectMetadata.mockReturnValue({ + root: 'packages/test-project', + sourceRoot: 'packages/test-project/src', + projectType: 'application', + name: 'test-project', + }); console.warn = vi.fn(); console.error = vi.fn(); console.info = vi.fn(); @@ -218,7 +228,7 @@ describe('Linter Builder', () => { fix: true, quiet: false, cache: true, - cacheLocation: `cacheLocation1${sep}`, + cacheLocation: `cacheLocation1${sep}test-project`, cacheStrategy: 'content', format: 'stylish', force: false, @@ -259,7 +269,7 @@ describe('Linter Builder', () => { fix: true, quiet: false, cache: true, - cacheLocation: `cacheLocation1${sep}`, + cacheLocation: `cacheLocation1${sep}test-project`, cacheStrategy: 'content', format: 'stylish', force: false, @@ -300,7 +310,7 @@ describe('Linter Builder', () => { fix: true, quiet: false, cache: true, - cacheLocation: `cacheLocation1${sep}`, + cacheLocation: `cacheLocation1${sep}test-project`, cacheStrategy: 'content', format: 'stylish', force: false, @@ -341,7 +351,7 @@ describe('Linter Builder', () => { fix: true, quiet: false, cache: true, - cacheLocation: `cacheLocation1${sep}`, + cacheLocation: `cacheLocation1${sep}test-project`, cacheStrategy: 'content', format: 'stylish', force: false, @@ -801,6 +811,133 @@ describe('Linter Builder', () => { ); }); + it('should interpolate {projectName} placeholder in outputFile path', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: './.eslintrc.json', + lintFilePatterns: ['includedFile1'], + format: 'json', + silent: true, + outputFile: 'reports/{projectName}.json', + }), + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'reports'), + { + recursive: true, + }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'reports/test-project.json'), + mockFormatter.format(mockReports), + ); + }); + + it('should interpolate {projectRoot} placeholder in outputFile path', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: './.eslintrc.json', + lintFilePatterns: ['includedFile1'], + format: 'json', + silent: true, + outputFile: '{projectRoot}/lint-results.json', + }), + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'packages/test-project'), + { + recursive: true, + }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'packages/test-project/lint-results.json'), + mockFormatter.format(mockReports), + ); + }); + + it('should interpolate both {projectName} and {projectRoot} placeholders in outputFile path', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: './.eslintrc.json', + lintFilePatterns: ['includedFile1'], + format: 'json', + silent: true, + outputFile: '{projectRoot}/reports/{projectName}/results.json', + }), + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'packages/test-project/reports/test-project'), + { + recursive: true, + }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + join( + testWorkspaceRoot, + 'packages/test-project/reports/test-project/results.json', + ), + mockFormatter.format(mockReports), + ); + }); + + it('should handle multiple occurrences of {projectName} placeholder', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: './.eslintrc.json', + lintFilePatterns: ['includedFile1'], + format: 'json', + silent: true, + outputFile: '{projectName}/reports/{projectName}.json', + }), + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'test-project/reports'), + { + recursive: true, + }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'test-project/reports/test-project.json'), + mockFormatter.format(mockReports), + ); + }); + it('should pass stats option to resolveAndInstantiateESLint', async () => { vi.mocked(fs.existsSync).mockImplementation((path) => { const pathStr = String(path); @@ -827,7 +964,7 @@ describe('Linter Builder', () => { fix: true, quiet: false, cache: true, - cacheLocation: `cacheLocation1${sep}`, + cacheLocation: `cacheLocation1${sep}test-project`, cacheStrategy: 'content', format: 'stylish', force: false, diff --git a/packages/builder/src/lint.impl.ts b/packages/builder/src/lint.impl.ts index 767a4f88c..81dc1ee4f 100644 --- a/packages/builder/src/lint.impl.ts +++ b/packages/builder/src/lint.impl.ts @@ -18,7 +18,21 @@ export default createBuilder( // root to be able to run the lint executor from any subfolder. process.chdir(systemRoot); - const projectName = context.target?.project ?? ''; + // Resolve all relevant project metadata from the context + let projectName = ''; + let projectMetadata: Record | undefined; + let projectRoot = ''; + try { + projectMetadata = await context.getProjectMetadata( + context.target?.project ?? '', + ); + projectRoot = projectMetadata?.root ?? ''; + projectName = + projectMetadata?.name ?? context.target?.project ?? ''; + } catch { + /* empty */ + } + const printInfo = options.format && !options.silent; if (printInfo) { @@ -69,11 +83,10 @@ export default createBuilder( ) ) { let eslintConfigPathForError = `for ${projectName}`; - const projectMetadata = await context.getProjectMetadata(projectName); - if (projectMetadata?.root) { - const { root } = projectMetadata; + + if (projectRoot) { eslintConfigPathForError = - resolveESLintConfigPath(root as string) ?? ''; + resolveESLintConfigPath(projectRoot) ?? ''; } console.error(` @@ -166,7 +179,27 @@ For full guidance on how to resolve this issue, please see https://github.com/an const formattedResults = await formatter.format(finalLintResults); if (options.outputFile) { - const pathToOutputFile = join(systemRoot, options.outputFile); + // Interpolate placeholders in outputFile path + let interpolatedOutputFile = options.outputFile; + if (interpolatedOutputFile.includes('{projectName}')) { + interpolatedOutputFile = interpolatedOutputFile.replace( + /{projectName}/g, + projectName, + ); + } + if (interpolatedOutputFile.includes('{projectRoot}')) { + interpolatedOutputFile = interpolatedOutputFile.replace( + /{projectRoot}/g, + projectRoot, + ); + } + + // Clean up any resulting double slashes or leading slashes from empty replacements + interpolatedOutputFile = interpolatedOutputFile + .replace(/\/+/g, '/') + .replace(/^\//, ''); + + const pathToOutputFile = join(systemRoot, interpolatedOutputFile); mkdirSync(dirname(pathToOutputFile), { recursive: true }); writeFileSync(pathToOutputFile, formattedResults); } else { diff --git a/packages/builder/src/schema.json b/packages/builder/src/schema.json index 8a38b7274..29fff2e05 100644 --- a/packages/builder/src/schema.json +++ b/packages/builder/src/schema.json @@ -24,7 +24,7 @@ }, "outputFile": { "type": "string", - "description": "File to write report to instead of the console." + "description": "File to write report to instead of the console. Supports placeholders: {projectName} will be replaced with the project name, {projectRoot} will be replaced with the project root path." }, "stats": { "type": "boolean", From 20ecc6da1fdc94d151236913314558e6d5c7a096 Mon Sep 17 00:00:00 2001 From: JamesHenry Date: Sun, 9 Nov 2025 17:33:29 +0400 Subject: [PATCH 2/5] chore: fix assertions --- e2e/src/output-file-interpolation.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/src/output-file-interpolation.test.ts b/e2e/src/output-file-interpolation.test.ts index 7cfbe29b1..afe6a5df6 100644 --- a/e2e/src/output-file-interpolation.test.ts +++ b/e2e/src/output-file-interpolation.test.ts @@ -86,7 +86,7 @@ describe('output-file-interpolation', () => { }); it('should support dynamic override of outputFile with placeholder interpolation', async () => { - expect.assertions(5); + expect.assertions(2); // Run lint for app-two with dynamic outputFile override try { @@ -105,6 +105,8 @@ describe('output-file-interpolation', () => { }); it('should interpolate placeholders when running ng lint without project name (all projects)', async () => { + expect.assertions(5); + // Clean up any previous reports if (fixture.directoryExists('all-projects-reports')) { fixture.deleteFileOrDirectory('all-projects-reports'); From 8bab23c9b88600df7bb2fe28a2bb29ac4c8d1778 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Sun, 9 Nov 2025 13:39:32 +0000 Subject: [PATCH 3/5] test(e2e): fix assertion count in output-file-interpolation test Co-authored-by: JamesHenry --- e2e/src/output-file-interpolation.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/e2e/src/output-file-interpolation.test.ts b/e2e/src/output-file-interpolation.test.ts index afe6a5df6..2ba03a40f 100644 --- a/e2e/src/output-file-interpolation.test.ts +++ b/e2e/src/output-file-interpolation.test.ts @@ -98,9 +98,11 @@ describe('output-file-interpolation', () => { } // Verify report was written to the dynamically interpolated path and snapshot the contents - const appTwoReport = fixture.readJson( - 'dynamic-reports/app-two/projects/app-two/lint.json', - ); + const appTwoReportPath = + 'dynamic-reports/app-two/projects/app-two/lint.json'; + expect(fixture.fileExists(appTwoReportPath)).toBe(true); + + const appTwoReport = fixture.readJson(appTwoReportPath); expect(appTwoReport).toMatchSnapshot(); }); From d337a18dcf1e642e2021308c4bd7f8f39366850a Mon Sep 17 00:00:00 2001 From: JamesHenry Date: Sun, 9 Nov 2025 18:30:07 +0400 Subject: [PATCH 4/5] chore: coverage --- packages/builder/src/lint.impl.spec.ts | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/builder/src/lint.impl.spec.ts b/packages/builder/src/lint.impl.spec.ts index b2fde6ad1..91ebaaa12 100644 --- a/packages/builder/src/lint.impl.spec.ts +++ b/packages/builder/src/lint.impl.spec.ts @@ -938,6 +938,69 @@ describe('Linter Builder', () => { ); }); + it('should handle {projectRoot} gracefully when getProjectMetadata fails', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + // Make getProjectMetadata throw an error to test the catch block + mockGetProjectMetadata.mockRejectedValueOnce( + new Error('Failed to get metadata'), + ); + await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: './.eslintrc.json', + lintFilePatterns: ['includedFile1'], + format: 'json', + silent: true, + outputFile: '{projectRoot}/lint-results.json', + }), + ); + // When projectRoot is empty, it should clean up to just 'lint-results.json' + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'lint-results.json'), + mockFormatter.format(mockReports), + ); + }); + + it('should clean up double slashes in interpolated outputFile paths', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + // Set projectRoot to empty to trigger cleanup logic + mockGetProjectMetadata.mockReturnValueOnce({ + root: '', + sourceRoot: '', + projectType: 'application', + name: 'test-project', + }); + await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: './.eslintrc.json', + lintFilePatterns: ['includedFile1'], + format: 'json', + silent: true, + outputFile: '{projectRoot}/reports/{projectName}/lint.json', + }), + ); + // Should clean up leading slash and double slashes: '/reports/test-project/lint.json' -> 'reports/test-project/lint.json' + expect(fs.writeFileSync).toHaveBeenCalledWith( + join(testWorkspaceRoot, 'reports/test-project/lint.json'), + mockFormatter.format(mockReports), + ); + }); + it('should pass stats option to resolveAndInstantiateESLint', async () => { vi.mocked(fs.existsSync).mockImplementation((path) => { const pathStr = String(path); From 4530fdbd780c1f1316da58a3f98395857fdb08e5 Mon Sep 17 00:00:00 2001 From: JamesHenry Date: Sun, 9 Nov 2025 18:38:54 +0400 Subject: [PATCH 5/5] chore: coverage --- packages/builder/src/lint.impl.spec.ts | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/builder/src/lint.impl.spec.ts b/packages/builder/src/lint.impl.spec.ts index 91ebaaa12..80f0546ad 100644 --- a/packages/builder/src/lint.impl.spec.ts +++ b/packages/builder/src/lint.impl.spec.ts @@ -1046,4 +1046,55 @@ describe('Linter Builder', () => { true, // useFlatConfig ); }); + + it('should use projectRoot to resolve eslint config path in error message when parserOptions.project error occurs', async () => { + mockReports = [ + { + errorCount: 0, + warningCount: 0, + results: [], + messages: [], + usedDeprecatedRules: [], + }, + ]; + + // Mock lintFiles to throw the specific TypeScript parser error + const mockESLintInstance = { + lintFiles: vi + .fn() + .mockRejectedValue( + new Error( + 'You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser', + ), + ), + loadFormatter: mockLoadFormatter, + isPathIgnored: vi.fn().mockReturnValue(false), + }; + + mockResolveAndInstantiateESLint.mockResolvedValueOnce({ + ESLint: MockESLint, + eslint: mockESLintInstance, + }); + + // Mock existsSync to return true for eslint.config.js in the project root + vi.mocked(fs.existsSync).mockImplementation((path) => { + const pathStr = String(path); + if (pathStr.includes('packages/test-project/eslint.config.js')) { + return true; + } + return false; + }); + + const result = await runBuilder( + createValidRunBuilderOptions({ + eslintConfig: null, + lintFilePatterns: ['includedFile1'], + }), + ); + + expect(result.success).toBe(false); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('packages/test-project/eslint.config.js'), + ); + }); });