diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 9250ee5b8..6e9350d4d 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -93,6 +93,40 @@ const PROTECTED_FILES = new Set([ '.markdownlintignore' ]); +/** + * Exact basenames only catch a tool's canonical entry point. Real repos split + * flat config across files: a shared `eslint.config.base.mjs` holding the + * ignore list and rule severities, imported by per-workspace + * `eslint.config.mjs` files. That is the common monorepo shape, and matching + * basenames alone protected the leaves while leaving the trunk -- the file that + * actually carries the rules -- freely editable. + * + * These patterns cover `.config..` and + * `.rc..` for the linters and formatters listed above. + * They are case-insensitive for the same reason the Set lookup above is. + * + * Deliberately NOT matched: build and test tooling -- `vite.config.ts`, + * `vitest.config.ts`, `jest.config.js`, `playwright.config.ts`, + * `tsconfig.json`. This hook exists to stop a LINTER config being weakened in + * place of fixing the code; editing a bundler or test-runner config is + * ordinary work, and sweeping those in would make the hook obstructive. + */ +const PROTECTED_PATTERNS = [ + // eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ... + /^(eslint|prettier|stylelint|commitlint|oxlint|biome)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i, + // .eslintrc.base.json, .prettierrc.shared.yml ... + /^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i, + // biome.base.json, biome.shared.jsonc + /^biome(\.[A-Za-z0-9_-]+)*\.jsonc?$/i, +]; + +function isProtectedName(basename) { + const lower = basename.toLowerCase(); + return PROTECTED_FILES.has(basename) + || PROTECTED_FILES.has(lower) + || PROTECTED_PATTERNS.some((re) => re.test(basename)); +} + function parseInput(inputOrRaw) { if (typeof inputOrRaw === 'string') { try { @@ -132,7 +166,7 @@ function run(inputOrRaw, options = {}) { // silently overwrite the real config while the guard returned exit 0. // On genuinely case-sensitive filesystems this only costs a false positive // on a distinct file that differs from a protected name by case alone. - if (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) { + if (isProtectedName(basename)) { // Allow first-time creation — there's no existing config to weaken. // The hook's purpose is blocking modifications; writing a brand-new // config file in a project that has none is a legitimate bootstrap diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index 787fbd346..2ebedd6e7 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -418,6 +418,64 @@ function runTests() { }) ); + results.push( + test('blocks shared/base flat configs, not just the canonical entry point', () => { + // Monorepos split flat config: a shared `eslint.config.base.mjs` holding + // the ignore list and rule severities, imported by per-workspace + // `eslint.config.mjs` files. Matching basenames alone protected the + // leaves and left the trunk -- the file that carries the rules -- editable. + return withOwnedDirectory(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-base-')), tmpDir => { + const names = [ + 'eslint.config.base.mjs', 'prettier.config.shared.cjs', '.eslintrc.base.json', 'ESLint.Config.Base.MJS', + 'stylelint.config.local.ts', 'commitlint.config.shared.cts', 'oxlint.config.base.mts', + 'biome.config.shared.js', '.prettierrc.shared.yml', '.stylelintrc.team.toml', + '.markdownlintrc.team.jsonc', 'biome.shared.jsonc', 'BIOME.Team.Base.JSON' + ]; + for (const name of names) { + const absPath = path.join(tmpDir, name); + fs.writeFileSync(absPath, '{}'); + + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: absPath } }); + + assert.strictEqual(result.code, 2, 'Expected ' + name + ' to be blocked'); + assert.ok( + result.stderr.includes('BLOCKED: Modifying ' + name + ' is not allowed.'), + 'Expected block message for ' + name + ', got: ' + result.stderr + ); + } + }); + }) + ); + + results.push( + test('does not block build or test tooling configs', () => { + // Pins the boundary: this hook guards LINTER configs. A future widening + // of the patterns must not quietly start blocking ordinary work. + return withOwnedDirectory(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-allow-')), tmpDir => { + const names = [ + 'vite.config.ts', 'vitest.config.ts', 'jest.config.js', 'playwright.config.ts', 'tsconfig.json', + 'pyproject.toml', 'package.json', '.eslintignore.bak', 'not-eslint.config.base.mjs', + 'eslint.config..mjs', 'eslint.config.base.mjs.bak' + ]; + for (const name of names) { + const absPath = path.join(tmpDir, name); + fs.writeFileSync(absPath, '{}'); + + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: absPath } }); + + assert.strictEqual(result.code, 0, 'Expected ' + name + ' to be allowed, stderr: ' + result.stderr); + } + + const fresh = runHook({ + tool_name: 'Write', + tool_input: { file_path: path.join(tmpDir, 'eslint.config.new.mjs'), content: 'export default [];' } + }); + assert.strictEqual(fresh.code, 0, 'Expected first-time qualified config creation to be allowed'); + assert.strictEqual(fresh.stdout, '', 'Allowed qualified creation should not echo raw hook input'); + }); + }) + ); + const passed = results.filter(result => result === 'passed').length; const failed = results.filter(result => result === 'failed').length; const skipped = results.filter(result => result === 'skipped').length;