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 36b2d49361..4ee623b314 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,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/npm-exec.ts b/packages/@o3r/schematics/src/tasks/package-manager/npm-exec.ts index 06b4c3aac3..fab6996fb6 100644 --- a/packages/@o3r/schematics/src/tasks/package-manager/npm-exec.ts +++ b/packages/@o3r/schematics/src/tasks/package-manager/npm-exec.ts @@ -8,6 +8,7 @@ import { } from '@angular-devkit/schematics/tasks/package-manager/options'; import { getPackageManager, + type SupportedPackageManagers, } from '../../utility/package-manager-runner'; /** @@ -15,15 +16,20 @@ import { * Note that this only works if the necessary files are created on the disk (doesn't work on tree) */ export class NpmExecTask implements TaskConfigurationGenerator { - constructor(private readonly script: string, private readonly args: string[] = [], private readonly workingDirectory?: string) {} + constructor( + private readonly script: string, + private readonly args: string[] = [], + private readonly workingDirectory?: string, + private readonly packageManager?: SupportedPackageManagers + ) {} public toConfiguration(): TaskConfiguration { - const packageManager = getPackageManager(); + const packageManager = this.packageManager || getPackageManager(); return { name: NodePackageName, options: { command: 'exec', - packageName: `exec ${this.script} ${packageManager === 'npm' ? '-- ' : ''}${this.args.map((arg) => `"${arg}"`).join(' ')}`, + packageName: `exec ${this.script} ${packageManager === 'npm' && this.args.length > 0 ? '-- ' : ''}${this.args.map((arg) => `"${arg}"`).join(' ')}`, workingDirectory: this.workingDirectory, packageManager } diff --git a/packages/@o3r/workspace/package.json b/packages/@o3r/workspace/package.json index fad65ab40a..f2eb6ebe58 100644 --- a/packages/@o3r/workspace/package.json +++ b/packages/@o3r/workspace/package.json @@ -125,10 +125,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.0.0", "@ngrx/effects": "~18.0.0", "@ngrx/store-devtools": "~18.0.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..3e1cad7e57 100644 --- a/packages/@o3r/workspace/schematics/index.it.spec.ts +++ b/packages/@o3r/workspace/schematics/index.it.spec.ts @@ -90,7 +90,7 @@ describe('new otter workspace', () => { expect(() => packageManagerRunOnProject('@my-sdk/sdk', isInWorkspace, { script: 'spec:upgrade' }, execAppOptions)).not.toThrow(); }); - test('should add a library to an existing workspace', () => { + test('should add a library to an existing workspace', async () => { const { workspacePath } = o3rEnvironment.testEnvironment; const execAppOptions = { ...getDefaultExecSyncOptions(), cwd: workspacePath }; const libName = 'test-library'; @@ -110,11 +110,16 @@ describe('new otter workspace', () => { 'jest.config.js', 'tsconfig.builders.json', 'tsconfig.json', - 'testing/setup-jest.ts']; + 'commitlint.config.cts', + 'testing/setup-jest.ts', + '.husky/commit-msg', + '.husky/pre-commit' + ]; 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)); expect(() => packageManagerRunOnProject(libName, true, { script: 'build' }, execAppOptions)).not.toThrow(); + await expect(fs.readFile(path.join(workspacePath, '.husky/pre-commit'), { encoding: 'utf8' })).resolves.toMatch(/lint-stage/); }); test('should generate a monorepo setup', async () => { 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..b0cb3b9912 --- /dev/null +++ b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.spec.ts @@ -0,0 +1,85 @@ +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(() => { + // jest.clearAllMocks(); + process.env.ENFORCED_PACKAGE_MANAGER = undefined; + context = { + addTask: jest.fn().mockReturnValue({ id: 123 }) + }; + }); + + test('should correctly register the tasks', () => { + process.env.ENFORCED_PACKAGE_MANAGER = 'yarn'; + 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: `-c 'echo "yarn exec lint-stage" > .husky/pre-commit'` }), [{ 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..d58a798b7c --- /dev/null +++ b/packages/@o3r/workspace/schematics/ng-add/helpers/commit-hooks/index.ts @@ -0,0 +1,79 @@ +import { + apply, + chain, + MergeStrategy, + mergeWith, + renameTemplateFiles, + type Rule, + type SchematicContext, + type TaskId, + template, + url, +} from '@angular-devkit/schematics'; +import { + getPackageManager, + 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 NpmExecTask(`-c 'echo "${packageManager} exec lint-stage" > .husky/pre-commit'`, undefined, undefined, 'npm'); + context.addTask(setupLintStage, [taskId]); + }; +} + +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 + * @param rootPath @see RuleFactory.rootPath + */ +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..73c582b12a 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 = [ + ...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), + generateCommitLintConfig(), updateEditorConfig, addVsCodeRecommendations(vsCodeExtensions), updateGitIgnore(workspaceConfig), @@ -109,7 +125,10 @@ export const prepareProject = (options: NgAddSchematicsSchema): Rule => { setupDependencies({ dependencies, skipInstall: options.skipInstall, - ngAddToRun: internalPackagesToInstallWithNgAdd + ngAddToRun: internalPackagesToInstallWithNgAdd, + scheduleTaskCallback: (taskIds) => { + getCommitHookInitTask(context)(taskIds); + } }), !options.skipLinter && installOtterLinter ? applyEsLintFix() : noop(), addWorkspacesToProject(), 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 91381b97e7..890b2f2183 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3671,6 +3671,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" @@ -3835,7 +3851,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: @@ -9330,7 +9346,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"