diff --git a/.commitlintrc.json b/.commitlintrc.json deleted file mode 100644 index c50d343104..0000000000 --- a/.commitlintrc.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "parserPreset": "@commitlint/config-conventional", - "rules": { - "body-leading-blank": [1,"always"], - "footer-leading-blank": [1, "always"], - "header-max-length": [2, "always", 100], - "scope-case": [2, "always", "lower-case"], - "subject-case": [2, "never", - [ - "sentence-case", - "start-case", - "pascal-case", - "upper-case" - ] - ], - "subject-empty": [2, "never"], - "subject-full-stop": [2, "never", "."], - "type-case": [2, "always", "lower-case"], - "type-empty": [2, "never" ], - "type-enum": [2, "always", - [ - "build", - "chore", - "ci", - "docs", - "deprecate", - "feat", - "feature", - "features", - "fix", - "bugfix", - "fixes", - "bugfixes", - "improvement", - "perf", - "refactor", - "revert", - "style", - "test" - ] - ] - } -} diff --git a/commitlint.config.cts b/commitlint.config.cts new file mode 100644 index 0000000000..0d05c8f72a --- /dev/null +++ b/commitlint.config.cts @@ -0,0 +1,50 @@ +import type { + UserConfig, +} from '@commitlint/types'; + +export default { + extends: [ + '@commitlint/config-conventional', + '@commitlint/config-angular' + ], + rules: { + 'body-leading-blank': [1, 'always'], + 'footer-leading-blank': [1, 'always'], + 'header-max-length': [2, 'always', 100], + 'scope-case': [2, 'always', 'lower-case'], + 'subject-case': [2, 'never', + [ + 'sentence-case', + 'start-case', + 'pascal-case', + 'upper-case' + ] + ], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'type-enum': [2, 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'deprecate', + 'feat', + 'feature', + 'features', + 'fix', + 'bugfix', + 'fixes', + 'bugfixes', + 'improvement', + 'perf', + 'refactor', + 'revert', + 'style', + 'test' + ] + ] + } +} as const satisfies UserConfig; diff --git a/package.json b/package.json index 9ee6293d04..ca4cdaea04 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,9 @@ "@babel/core": "~7.26.0", "@babel/preset-typescript": "~7.26.0", "@commitlint/cli": "^19.0.0", + "@commitlint/config-angular": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@commitlint/types": "^19.0.0", "@compodoc/compodoc": "^1.1.19", "@design-factory/design-factory": "~18.1.0", "@eslint-community/eslint-plugin-eslint-comments": "^4.4.0", diff --git a/packages/@o3r/schematics/src/tasks/package-manager/index.ts b/packages/@o3r/schematics/src/tasks/package-manager/index.ts index ba0d304c06..18c6f31ebf 100644 --- a/packages/@o3r/schematics/src/tasks/package-manager/index.ts +++ b/packages/@o3r/schematics/src/tasks/package-manager/index.ts @@ -1,4 +1,5 @@ export * from './interfaces'; export * from './npm-exec'; export * from './npm-install'; +export * from './npm-node-run'; export * from './npm-run'; diff --git a/packages/@o3r/schematics/src/tasks/package-manager/npm-node-run.spec.ts b/packages/@o3r/schematics/src/tasks/package-manager/npm-node-run.spec.ts new file mode 100644 index 0000000000..bee931816a --- /dev/null +++ b/packages/@o3r/schematics/src/tasks/package-manager/npm-node-run.spec.ts @@ -0,0 +1,36 @@ +import { + NodeRunScriptTask, +} from './npm-node-run'; + +describe('NodeRunScriptTask', () => { + test('should use the correct working directory and provided package manager', () => { + const task1 = new NodeRunScriptTask('', 'test-directory-1', 'npm'); + const task2 = new NodeRunScriptTask('', 'test-directory-2', 'yarn'); + const config1: any = task1.toConfiguration(); + const config2: any = task2.toConfiguration(); + + expect(config1.options.command).toBe('exec'); + expect(config1.options.workingDirectory).toBe('test-directory-1'); + expect(config1.options.packageManager).toBe('npm'); + + expect(config2.options.command).toBe('exec'); + expect(config2.options.workingDirectory).toBe('test-directory-2'); + expect(config2.options.packageManager).toBe('yarn'); + }); + + describe('script', () => { + const scriptToRun = `console.log('test mesagge with "double quotes" and \\'single quote\\'.')`; + + test('should generate proper command in npm context', () => { + const task = new NodeRunScriptTask(scriptToRun, undefined, 'npm'); + expect(task.toConfiguration().options.packageName) + .toBe(`exec --call "node -e \\"console.log('test mesagge with ' + String.fromCharCode(34) + 'double quotes' + String.fromCharCode(34) + ' and \\'single quote\\'.')\\""`); + }); + + test('should generate proper command in yarn context', () => { + const task = new NodeRunScriptTask(scriptToRun, undefined, 'yarn'); + expect(task.toConfiguration().options.packageName) + .toBe(`node -e "console.log('test mesagge with \\"double quotes\\" and \\\\'single quote\\\\'.')"`); + }); + }); +}); diff --git a/packages/@o3r/schematics/src/tasks/package-manager/npm-node-run.ts b/packages/@o3r/schematics/src/tasks/package-manager/npm-node-run.ts new file mode 100644 index 0000000000..8377de33da --- /dev/null +++ b/packages/@o3r/schematics/src/tasks/package-manager/npm-node-run.ts @@ -0,0 +1,44 @@ +import { + TaskConfiguration, + TaskConfigurationGenerator, +} from '@angular-devkit/schematics'; +import { + NodePackageName, + NodePackageTaskOptions, +} from '@angular-devkit/schematics/tasks/package-manager/options'; +import { + getPackageManager, + type SupportedPackageManagers, +} from '../../utility/package-manager-runner'; + +/** + * Configuration used to run Node script via Package Manager. + * Warning: The command only supports single quote strings when run with NPM. In NPM, the " character will be replaced by its char code + * Note that this only works if the necessary files are created on the disk (doesn't work on tree) + */ +export class NodeRunScriptTask implements TaskConfigurationGenerator { + constructor( + private readonly script: string, + private readonly workingDirectory?: string, + private readonly packageManager?: SupportedPackageManagers + ) {} + + public toConfiguration(): TaskConfiguration { + const packageManager = this.packageManager || getPackageManager(); + const scriptString = JSON.stringify(this.script); + const scriptStringInQuotes = this.script + .replace(/"/g, '\' + String.fromCharCode(34) + \''); + const script = packageManager === 'npm' + ? `exec --call "node -e \\"${scriptStringInQuotes}\\""` + : `node -e ${scriptString}`; + return { + name: NodePackageName, + options: { + command: 'exec', + packageName: script, + workingDirectory: this.workingDirectory, + packageManager + } + }; + } +} diff --git a/packages/@o3r/workspace/package.json b/packages/@o3r/workspace/package.json index 8eda0a0d9a..f4f7be9205 100644 --- a/packages/@o3r/workspace/package.json +++ b/packages/@o3r/workspace/package.json @@ -124,10 +124,17 @@ }, "generatorDependencies": { "@angular/material": "~18.2.0", + "@commitlint/cli": "^19.0.0", + "@commitlint/config-angular": "^19.0.0", + "@commitlint/config-conventional": "^19.0.0", + "@commitlint/types": "^19.0.0", "@ngrx/router-store": "~18.1.0", "@ngrx/effects": "~18.1.0", "@ngrx/store-devtools": "~18.1.0", - "lerna": "^8.1.7" + "editorconfig-checker": "^5.1.8", + "husky": "~9.1.0", + "lerna": "^8.1.7", + "lint-staged": "^15.0.0" }, "engines": { "node": "^18.19.1 || ^20.11.1 || >=22.0.0" diff --git a/packages/@o3r/workspace/schematics/index.it.spec.ts b/packages/@o3r/workspace/schematics/index.it.spec.ts index 896c6fd7c0..4ea838578b 100644 --- a/packages/@o3r/workspace/schematics/index.it.spec.ts +++ b/packages/@o3r/workspace/schematics/index.it.spec.ts @@ -110,7 +110,8 @@ describe('new otter workspace', () => { 'jest.config.js', 'tsconfig.builders.json', 'tsconfig.json', - 'testing/setup-jest.ts']; + 'testing/setup-jest.ts' + ]; expect(() => packageManagerExec({ script: 'ng', args: ['g', 'library', libName] }, execAppOptions)).not.toThrow(); expect(existsSync(path.join(workspacePath, 'project'))).toBe(false); generatedLibFiles.forEach((file) => expect(existsSync(path.join(inLibraryPath, file))).toBe(true)); @@ -134,5 +135,9 @@ describe('new otter workspace', () => { expect(rootPackageJson.workspaces).toContain('apps/*'); expect(existsSync(path.join(workspacePath, '.renovaterc.json'))).toBe(true); expect(existsSync(path.join(workspacePath, '.editorconfig'))).toBe(true); + expect(existsSync(path.join(workspacePath, '.husky/commit-msg'))).toBe(true); + expect(existsSync(path.join(workspacePath, '.husky/pre-commit'))).toBe(true); + expect(existsSync(path.join(workspacePath, 'commitlint.config.cts'))).toBe(true); + await expect(fs.readFile(path.join(workspacePath, '.husky/pre-commit'), { encoding: 'utf8' })).resolves.toMatch(/lint-stage/); }); }); diff --git a/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.spec.ts b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.spec.ts new file mode 100644 index 0000000000..2957bc7f7b --- /dev/null +++ b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.spec.ts @@ -0,0 +1,83 @@ +import * as path from 'node:path'; +import { + Tree, +} from '@angular-devkit/schematics'; +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { + firstValueFrom, +} from 'rxjs'; +import { + editPackageJson, + generateCommitLintConfig, + getCommitHookInitTask, +} from './index'; + +const collectionPath = path.join(__dirname, '..', '..', '..', '..', 'collection.json'); + +describe('getCommitHookInitTask', () => { + let context: any; + + beforeEach(() => { + context = { + addTask: jest.fn().mockReturnValue({ id: 123 }) + }; + }); + + test('should correctly register the tasks', () => { + const runAfter = [{ id: 111 }]; + getCommitHookInitTask(context)(runAfter); + + expect(context.addTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ script: 'husky init' }), runAfter); + expect(context.addTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ script: expect.stringMatching(/\.husky\/pre-commit/) }), [{ id: 123 }]); + expect(context.addTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ script: expect.stringMatching(/exec lint-stage/) }), [{ id: 123 }]); + }); +}); + +describe('generateCommitLintConfig', () => { + const initialTree = new UnitTestTree(Tree.empty()); + const apply = jest.fn(); + jest.mock('@angular-devkit/schematics', () => ({ + apply, + getTemplateFolder: jest.fn(), + template: jest.fn(), + renameTemplateFiles: jest.fn(), + url: jest.fn(), + mergeWith: jest.fn().mockReturnValue(initialTree) + })); + + test('should generate template', () => { + expect(() => generateCommitLintConfig()(initialTree, {} as any)).not.toThrow(); + expect(apply).not.toHaveBeenCalled(); + }); +}); + +describe('editPackageJson', () => { + let initialTree: UnitTestTree; + + beforeEach(() => { + initialTree = new UnitTestTree(Tree.empty()); + initialTree.create('/package.json', '{}'); + }); + + test('should add stage-lint if not present', async () => { + const runner = new SchematicTestRunner( + '@o3r/workspace', + collectionPath + ); + const tree = await firstValueFrom(runner.callRule(editPackageJson, initialTree)); + expect((tree.readJson('/package.json') as any)['lint-staged']).toBeDefined(); + }); + + test('should not touche stage-lint if present', async () => { + initialTree.overwrite('/package.json', '{"lint-staged": "test"}'); + const runner = new SchematicTestRunner( + '@o3r/workspace', + collectionPath + ); + const tree = await firstValueFrom(runner.callRule(editPackageJson, initialTree)); + expect((tree.readJson('/package.json') as any)['lint-staged']).toBe('test'); + }); +}); diff --git a/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.ts b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.ts new file mode 100644 index 0000000000..938bb21f49 --- /dev/null +++ b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.ts @@ -0,0 +1,84 @@ +import { + apply, + chain, + MergeStrategy, + mergeWith, + renameTemplateFiles, + type Rule, + type SchematicContext, + type TaskId, + template, + url, +} from '@angular-devkit/schematics'; +import { + getPackageManager, + NodeRunScriptTask, + NpmExecTask, +} from '@o3r/schematics'; +import type { + PackageJson, +} from 'type-fest'; + +/** Dev Dependencies to install to setup Commit hooks */ +export const commitHookDevDependencies = [ + 'lint-staged', + 'editorconfig-checker', + '@commitlint/cli', + '@commitlint/config-angular', + '@commitlint/config-conventional', + '@commitlint/types' +]; + +/** + * Retrieve the task callback function to initialization the commit hooks + * @param context + */ +export function getCommitHookInitTask(context: SchematicContext) { + return (taskIds?: TaskId[]) => { + const packageManager = getPackageManager(); + const huskyTask = new NpmExecTask('husky init'); + const taskId = context.addTask(huskyTask, taskIds); + const setupLintStage = new NodeRunScriptTask(`require('node:fs').writeFileSync('.husky/pre-commit', '${packageManager} exec lint-stage');`); + context.addTask(setupLintStage, [taskId]); + }; +} + +/** + * Edit package.json to setup lint-staged config + * @param tree + * @param context + */ +export const editPackageJson: Rule = (tree, context) => { + const packageJson = tree.readJson('/package.json') as PackageJson; + if (packageJson['lint-staged']) { + context.logger.debug('A Lint-stage configuration is already defined, the default value will not be applied'); + return tree; + } + packageJson['lint-staged'] = { + '*': [ + 'editorconfig-checker --verbose' + ] + }; + tree.overwrite('/package.json', JSON.stringify(packageJson)); +}; + +/** + * Add Commit Lint and husky configurations to Otter project + */ +export function generateCommitLintConfig(): Rule { + return () => { + const packageManager = getPackageManager(); + const templateSource = apply(url('./helpers/commit-hooks/templates'), [ + template({ + empty: '', + packageManager + }), + renameTemplateFiles() + ]); + const rule = mergeWith(templateSource, MergeStrategy.Overwrite); + return chain([ + editPackageJson, + rule + ]); + }; +} diff --git a/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/templates/__empty__.husky/commit-msg.template b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/templates/__empty__.husky/commit-msg.template new file mode 100644 index 0000000000..a103720a60 --- /dev/null +++ b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/templates/__empty__.husky/commit-msg.template @@ -0,0 +1 @@ +<%= packageManager %> exec commitlint <%= packageManager === 'npm' ? '-- ' : '' %>--edit $1 diff --git a/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/templates/commitlint.config.cts.template b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/templates/commitlint.config.cts.template new file mode 100644 index 0000000000..c022df3979 --- /dev/null +++ b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/templates/commitlint.config.cts.template @@ -0,0 +1,10 @@ +import type { + UserConfig, +} from '@commitlint/types'; + +export default { + extends: [ + '@commitlint/config-conventional', + '@commitlint/config-angular' + ] +} as const satisfies UserConfig; diff --git a/packages/@o3r/workspace/schematics/ng-add/project-setup.ts b/packages/@o3r/workspace/schematics/ng-add/project-setup.ts index 5dc3a6301b..2b462d1451 100644 --- a/packages/@o3r/workspace/schematics/ng-add/project-setup.ts +++ b/packages/@o3r/workspace/schematics/ng-add/project-setup.ts @@ -8,19 +8,22 @@ import { import { addVsCodeRecommendations, applyEsLintFix, + DependencyToAdd, getO3rPeerDeps, getWorkspaceConfig, setupDependencies, } from '@o3r/schematics'; -import type { - DependencyToAdd, -} from '@o3r/schematics'; import { NodeDependencyType, } from '@schematics/angular/utility/dependencies'; import type { PackageJson, } from 'type-fest'; +import { + commitHookDevDependencies, + generateCommitLintConfig, + getCommitHookInitTask, +} from './helpers/commit-hooks'; import { updateGitIgnore, } from './helpers/gitignore-update'; @@ -64,10 +67,13 @@ export const prepareProject = (options: NgAddSchematicsSchema): Rule => { 'EditorConfig.EditorConfig', 'angular.ng-template' ]; - const dependenciesToInstall = [ + const otterDependencies = [ '@ama-sdk/core', '@ama-sdk/schematics' ]; + const devDependenciesToInstall = [ + ...(options.skipPreCommitChecks ? [] : commitHookDevDependencies) + ]; const ownSchematicsFolder = path.resolve(__dirname, '..'); const ownPackageJsonPath = path.resolve(ownSchematicsFolder, '..', 'package.json'); const depsInfo = getO3rPeerDeps(ownPackageJsonPath); @@ -83,7 +89,7 @@ export const prepareProject = (options: NgAddSchematicsSchema): Rule => { ...depsInfo.o3rPeerDeps ])); - const dependencies = [...internalPackagesToInstallWithNgAdd, ...dependenciesToInstall].reduce((acc, dep) => { + const dependencies = [...internalPackagesToInstallWithNgAdd, ...otterDependencies].reduce((acc, dep) => { acc[dep] = { inManifest: [{ range: `${options.exactO3rVersion ? '' : '~'}${depsInfo.packageVersion}`, @@ -94,6 +100,15 @@ export const prepareProject = (options: NgAddSchematicsSchema): Rule => { return acc; }, {} as Record); + devDependenciesToInstall.forEach((dep) => { + dependencies[dep] ||= { + inManifest: [{ + range: ownPackageJsonContent.devDependencies?.[dep] || ownPackageJsonContent.generatorDependencies?.[dep] || 'latest', + types: [NodeDependencyType.Dev] + }] + }; + }); + if (installOtterLinter) { vsCodeExtensions.push('dbaeumer.vscode-eslint'); } @@ -102,6 +117,7 @@ export const prepareProject = (options: NgAddSchematicsSchema): Rule => { return () => chain([ generateRenovateConfig(__dirname), + ...(options.skipPreCommitChecks ? [] : [generateCommitLintConfig()]), updateEditorConfig, addVsCodeRecommendations(vsCodeExtensions), updateGitIgnore(workspaceConfig), @@ -109,7 +125,12 @@ export const prepareProject = (options: NgAddSchematicsSchema): Rule => { setupDependencies({ dependencies, skipInstall: options.skipInstall, - ngAddToRun: internalPackagesToInstallWithNgAdd + ngAddToRun: internalPackagesToInstallWithNgAdd, + scheduleTaskCallback: (taskIds) => { + if (!options.skipPreCommitChecks) { + getCommitHookInitTask(context)(taskIds); + } + } }), !options.skipLinter && installOtterLinter ? applyEsLintFix() : noop(), addWorkspacesToProject(), diff --git a/packages/@o3r/workspace/schematics/ng-add/schema.json b/packages/@o3r/workspace/schematics/ng-add/schema.json index 45e518b40c..ceb8128ff9 100644 --- a/packages/@o3r/workspace/schematics/ng-add/schema.json +++ b/packages/@o3r/workspace/schematics/ng-add/schema.json @@ -46,6 +46,11 @@ "default": false, "alias": "g" }, + "skipPreCommitChecks": { + "description": "Skip the setup of CommitLint and Lint-Stage configurations and pre-commit checks", + "type": "boolean", + "default": false + }, "exactO3rVersion": { "type": "boolean", "description": "Use a pinned version for otter packages", diff --git a/packages/@o3r/workspace/schematics/ng-add/schema.ts b/packages/@o3r/workspace/schematics/ng-add/schema.ts index 8f9b06c380..e28fc67509 100644 --- a/packages/@o3r/workspace/schematics/ng-add/schema.ts +++ b/packages/@o3r/workspace/schematics/ng-add/schema.ts @@ -18,6 +18,9 @@ export interface NgAddSchematicsSchema extends SchematicOptionObject { /** Do not initialize a git repository. */ skipGit: boolean; + /** Skip the setup of CommitLint and Lint-Stage configurations and pre-commit checks */ + skipPreCommitChecks: boolean; + /** Use a pinned version for otter packages */ exactO3rVersion?: boolean; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 3194f403b7..df09d6da4c 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -4,6 +4,7 @@ ".github/**/*.mjs", "eslint*.config.mjs", "jest.config.js", + "commitlint.config.cts", "jest.config.*.js", "testing/*", "scripts/**/*.js", diff --git a/yarn.lock b/yarn.lock index 8b25e224f3..5d49a32364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3691,6 +3691,22 @@ __metadata: languageName: node linkType: hard +"@commitlint/config-angular-type-enum@npm:^19.5.0": + version: 19.5.0 + resolution: "@commitlint/config-angular-type-enum@npm:19.5.0" + checksum: 10/91b1d9d3791c293470c2e91a3dc8e39bc894e64303c3d371baaab48053fc16119d7fae75066d68cbd964ecd88b00fcf3ef4e25f6489f2507ddb43322fd322f99 + languageName: node + linkType: hard + +"@commitlint/config-angular@npm:^19.0.0": + version: 19.7.0 + resolution: "@commitlint/config-angular@npm:19.7.0" + dependencies: + "@commitlint/config-angular-type-enum": "npm:^19.5.0" + checksum: 10/a0e3bac35965d520e7ca6f04982bf5aabc185692399664eae16e2ff8e36a3fab8d8a3f70b35bd051ade28627ddb43707c883e76f1cb6d6ddb53598d94af1048d + languageName: node + linkType: hard + "@commitlint/config-conventional@npm:^19.0.0": version: 19.6.0 resolution: "@commitlint/config-conventional@npm:19.6.0" @@ -3855,7 +3871,7 @@ __metadata: languageName: node linkType: hard -"@commitlint/types@npm:^19.5.0": +"@commitlint/types@npm:^19.0.0, @commitlint/types@npm:^19.5.0": version: 19.5.0 resolution: "@commitlint/types@npm:19.5.0" dependencies: @@ -9391,7 +9407,9 @@ __metadata: "@babel/core": "npm:~7.26.0" "@babel/preset-typescript": "npm:~7.26.0" "@commitlint/cli": "npm:^19.0.0" + "@commitlint/config-angular": "npm:^19.0.0" "@commitlint/config-conventional": "npm:^19.0.0" + "@commitlint/types": "npm:^19.0.0" "@compodoc/compodoc": "npm:^1.1.19" "@design-factory/design-factory": "npm:~18.1.0" "@eslint-community/eslint-plugin-eslint-comments": "npm:^4.4.0"