fix: 收紧CSS所有权与历史兼容门禁

This commit is contained in:
hectorzhao
2026-09-05 09:33:34 +08:00
parent 64bfb9ad3d
commit ca1fc2847f
11 changed files with 670 additions and 41 deletions
@@ -4,6 +4,8 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
import { cssFingerprint, localImports, unscopedSelectors, verifyOwnership } from './css-policy.mjs';
import stylelint from 'stylelint';
test('AST metrics ignore formatting and count selectors and important declarations', () => {
assert.deepEqual(analyzeCss('.page { color: red !important; }\n.page a, .page button { display: block; }'), {
@@ -31,3 +33,132 @@ test('global.css cannot be recreated after migration', () => {
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
fs.rmSync(directory, { recursive: true, force: true });
});
test('unrooted tags, universal selectors, standalone states and sibling escapes are rejected', () => {
const selectors = ['body button', '* button', 'h1', '.selected', ':not(.page) button', '.page + button'];
assert.deepEqual(
broadBusinessSelectors(selectors.map((selector) => `${selector} { color: red; }`).join('\n')),
selectors,
);
assert.deepEqual(broadBusinessSelectors('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }'), []);
});
test('page ownership rejects unrelated classes and pseudo-class ownership tricks', () => {
assert.deepEqual(
unscopedSelectors('.page > button, .page.is-active, .page .row + .row { color: red; }', ['page']),
[],
);
for (const selector of ['.other', '.page + .other', ':has(.page)', ':is(.page, body)', '.page ~ button']) {
assert.deepEqual(unscopedSelectors(`${selector} { color: red; }`, ['page']), [selector]);
}
});
test('fingerprint detects declaration values, media conditions, declaration order and important changes', () => {
const original = '@media (max-width: 780px) { .page { color: red; display: grid; } }';
for (const modified of [
original.replace('red', 'blue'),
original.replace('780px', '781px'),
original.replace('color: red; display: grid;', 'display: grid; color: red;'),
original.replace('red;', 'red !important;'),
]) {
assert.notEqual(cssFingerprint(original), cssFingerprint(modified));
}
assert.equal(cssFingerprint('.page{color:red}'), cssFingerprint('/* note */ .page {\n color: red;\n}'));
const multiline = '.page,\n.other { transition: color 1s,\n background 2s; }';
assert.equal(cssFingerprint(multiline), cssFingerprint(multiline.replaceAll('\n', '\r\n')));
});
test('import parser ignores comments and strings and recognizes static, dynamic and CSS url imports', () => {
assert.deepEqual(
localImports(
'page.tsx',
`// import './fake.css';\nconst note = "real.css"; import './real.css'; const Page = import('./Page');`,
),
['./real.css', './Page'],
);
assert.deepEqual(localImports('entry.css', `/* @import './fake.css'; */ @import url('./real.css');`), ['./real.css']);
});
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cmpp-css-policy-'));
// Only this test-created, absolute temporary directory is removed.
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
function write(file, text) {
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true });
fs.writeFileSync(path.join(directory, file), text);
}
write('src/main.tsx', `import { Page } from './apps/Page';`);
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="page" />;`);
write('src/apps/Page.css', '.page { color: red; }');
write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: [] }] }));
const policy = { files: [{ file: 'src/apps/Page.css', owners: ['src/apps/Page.tsx'], roots: ['page'] }] };
return { directory, write, policy };
}
test('new page with real reachable owner and matching roots passes without .git', (t) => {
const { directory, policy } = fixture(t);
assert.doesNotThrow(() => verifyOwnership(directory, policy));
});
test('comment-only imports, disconnected imports and wrong same-name paths fail', (t) => {
const { directory, policy, write } = fixture(t);
write('src/apps/Page.tsx', `// import './Page.css';\nexport const Page = () => <div className="page" />;`);
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="page" />;`);
write('src/main.tsx', 'const note = "Page.tsx";');
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
write('src/main.tsx', `import './apps/Page';`);
write('src/apps/Page.tsx', `import './missing/Page.css';`);
assert.throws(() => verifyOwnership(directory, policy), /import 不存在/);
});
test('ownership registration must match exact importers and rendered class names', (t) => {
const { directory, policy, write } = fixture(t);
policy.files[0].owners = ['src/main.tsx'];
assert.throws(() => verifyOwnership(directory, policy), /所有者与登记不一致/);
policy.files[0].owners = ['src/apps/Page.tsx'];
write(
'src/apps/Page.tsx',
`import './Page.css'; const note = 'page'; export const Page = () => <div className="different" />;`,
);
assert.throws(() => verifyOwnership(directory, policy), /className/);
});
test('unregistered files and new important fail in archive mode too', (t) => {
const { directory, policy, write } = fixture(t);
write('src/apps/Page.css', '.page { color: red !important; }');
assert.throws(() => verifyOwnership(directory, policy), /important/);
write('src/apps/Page.css', '.page { color: red; }');
write('src/apps/Unknown.css', '.unknown { color: red; }');
assert.throws(() => verifyOwnership(directory, policy), /必须登记/);
});
test('historical declaration or media change fails even when AST counts match', (t) => {
const { directory, policy, write } = fixture(t);
Object.assign(policy.files[0], {
legacyFingerprint: cssFingerprint('.page { color: red; }'),
reason: 'legacy',
removalCondition: 'review',
});
assert.doesNotThrow(() => verifyOwnership(directory, policy));
write('src/apps/Page.css', '.page { color: blue; }');
assert.throws(() => verifyOwnership(directory, policy), /历史 CSS 内容/);
});
test('Stylelint wildcard compatibility expansion is rejected', (t) => {
const { directory, policy, write } = fixture(t);
write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: ['src/styles/*.css'] }] }));
assert.throws(() => verifyOwnership(directory, policy), /精确历史文件清单/);
});
test('new stylesheet under legacy directories actually receives strict Stylelint rules', async () => {
for (const file of ['src/styles/new-page.css', 'src/styles/domains/15-new-page.css', 'src/apps/NewPage.css']) {
const config = await stylelint.resolveConfig(file);
assert.deepEqual(config.rules['no-duplicate-selectors'], [true]);
const result = await stylelint.lint({
code: '.page { color: red; }\n.page { color: blue; }',
codeFilename: path.resolve(file),
});
assert.ok(result.results[0].warnings.some((warning) => warning.rule === 'no-duplicate-selectors'));
}
});