diff --git a/jest.config.ts b/jest.config.ts index 7aa27c1ab..855914b2c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,11 +5,20 @@ const config: Config = { ...defaults, transformIgnorePatterns: [ `node_modules/(?!(?:.pnpm/)?(${[ + 'execa', 'find-up', + 'get-stream', + 'human-signals', + 'is-stream', 'locate-path', + 'mimic-fn', + 'npm-run-path', + 'onetime', 'p-limit', 'p-locate', 'path-exists', + 'path-key', + 'strip-final-newline', 'unicorn-magic', 'yocto-queue', ].join('|')}))`, diff --git a/packages/projen-lint-synthesized/src/index.test.ts b/packages/projen-lint-synthesized/src/index.test.ts index 805da3449..97887dab4 100644 --- a/packages/projen-lint-synthesized/src/index.test.ts +++ b/packages/projen-lint-synthesized/src/index.test.ts @@ -1,10 +1,13 @@ import { directorySnapshot } from 'projen/lib/util/synth' import { expect, tempy, test } from '@langri-sha/jest-test' -import { Project } from 'projen' -import { LintSynthesized } from './index' +import { promises as fs } from 'node:fs' +import path from 'node:path' -const setup = () => { +import { Project, TextFile } from 'projen' +import { LintSynthesized, type LintSynthesizedOptions } from './index' + +const setup = (options?: LintSynthesizedOptions) => { const outdir = tempy.directory() const project = new Project({ @@ -21,9 +24,9 @@ const setup = () => { project.removeTask('pre-compile') project.removeTask('test') - new LintSynthesized(project) + new LintSynthesized(project, options) - return { outdir, project } + return { project } } test('defaults', () => { @@ -32,3 +35,38 @@ test('defaults', () => { project.synth() expect(directorySnapshot(project.outdir)).toMatchSnapshot() }) + +test('lints synthesized files', async () => { + const { project } = setup({ + '*': 'prettier --ignore-unknown --write', + }) + + let file + + file = new TextFile(project, 'test.js') + file.addLine(`module.exports = ${JSON.stringify({ foo: 'bar' })}`) + + project.synth() + + file = await fs.readFile(path.join(project.outdir, 'test.js')) + const contents = file.toString('utf8') + + expect(contents).toEqual(`module.exports = { foo: "bar" };\n`) +}) + +test('preserves file modes', async () => { + const { project } = setup({ + '*': 'prettier --ignore-unknown', + }) + + new TextFile(project, 'test.sh', { + executable: true, + readonly: true, + }) + + project.synth() + + await expect( + fs.stat(path.join(project.outdir, 'test.sh')), + ).resolves.toHaveProperty('mode', 33_124) +}) diff --git a/packages/projen-lint-synthesized/src/index.ts b/packages/projen-lint-synthesized/src/index.ts index 7ef2136e6..8a44f03fc 100644 --- a/packages/projen-lint-synthesized/src/index.ts +++ b/packages/projen-lint-synthesized/src/index.ts @@ -1,12 +1,31 @@ import { Component } from 'projen' import { debug as createDebug } from 'debug' +import * as fs from 'node:fs' + +import { execaSync } from 'execa' +import { minimatch } from 'minimatch' + const debug = createDebug('projen-lint-synthesized') +export interface LintSynthesizedOptions { + [pattern: string]: string | ((files: string[]) => string) +} + +/** + * A component that lints synthesized files. + */ export class LintSynthesized extends Component { - constructor(scope: ConstructorParameters[0]) { + #options?: LintSynthesizedOptions + + constructor( + scope: ConstructorParameters[0], + options?: LintSynthesizedOptions, + ) { super(scope, 'lint-synthesized') + this.#options = options + debug('Initialized') } @@ -14,5 +33,70 @@ export class LintSynthesized extends Component { super.postSynthesize() debug('Commencing lints on synthesized files') + + const fileInfo = Object.fromEntries( + this.project.files + .filter((file) => fs.existsSync(file.absolutePath)) + .map((file) => [ + file.path, + { + file, + absolutePath: file.absolutePath, + mode: fs.statSync(file.absolutePath).mode, + }, + ]), + ) + const paths = Object.keys(fileInfo) + + debug(`Found ${paths.length} synthesized files`) + + const tasks = Object.entries(this.#options || {}) + .map(([pattern, command]) => ({ + pattern, + command, + files: minimatch.match(paths, pattern), + })) + .filter(({ files }) => files.length) + + for (const { files } of tasks) { + for (const file of files) { + const { absolutePath } = fileInfo[file] + + fs.chmodSync(absolutePath, 0o755) + } + } + + for (const { pattern, command, files } of tasks) { + debug(`Running command ${files.length} files matching pattern ${pattern}`) + + try { + this.#run(files, command) + } catch { + // Let `execa` log errors. + } + } + + for (const { files } of tasks) { + for (const file of files) { + const { absolutePath, mode } = fileInfo[file] + + fs.chmodSync(absolutePath, mode) + } + } + } + + #run(files: string[], command: string | ((files: string[]) => string)) { + const task = + typeof command === 'string' + ? `${command} ${files.join('')}` + : command(files) + + debug(`Running command ${task}`) + + execaSync(task, { + shell: true, + stdio: 'inherit', + cwd: this.project.outdir, + }) } }