mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed PR2835 qualified configuration protections
Preserve exact-file protections, all shared assertions and private cleanup. Source-PR: https://github.com/affaan-m/ECC/pull/2835
This commit is contained in:
@@ -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 `<tool>.config.<qualifier>.<ext>` and
|
||||
* `.<tool>rc.<qualifier>.<ext>` 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user