kt-template-admin/internal/jsdoc-policy/check-jsdoc.test.mjs

253 lines
6.7 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { it } from 'vitest';
import {
applyJsdocPolicyFix,
getCodeTokenSignature,
getTrackedCodeFiles,
inspectFileSource,
} from './check-jsdoc.mjs';
function runFixtureGit(repository, args) {
const environment = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
);
execFileSync('git', args, { cwd: repository, env: environment });
}
it('allows Chinese JSDoc only on explicitly named callable declarations', () => {
const source = `
/** 读取当前配置。 */
function readConfig() {}
const loadConfig = /** 加载远端配置。 */ function loadRemoteConfig() {};
const service = {
/** 执行任务。 */
run() {},
/** 返回当前状态。 */
get status() {
return 'ready';
},
};
`;
const result = inspectFileSource('valid.ts', source);
assert.equal(result.comments.length, 4);
assert.deepEqual(result.violations, []);
});
it('rejects pure-English JSDoc on an otherwise valid named function', () => {
const result = inspectFileSource(
'english.ts',
'/** Loads the current configuration. */\nfunction loadConfig() {}\n',
);
assert.equal(result.violations.length, 1);
assert.equal(result.violations[0]?.rule, 'non-chinese-description');
assert.equal(result.violations[0]?.target, 'named-function-declaration');
});
it('rejects declarations and callables excluded by the policy', () => {
const source = `
/** 接口说明。 */
interface Options {
/** 属性说明。 */
value: string;
}
/** 类型说明。 */
type Result = string;
/** 变量说明。 */
const count = 1;
const arrow = /** 箭头函数说明。 */ () => count;
items.map(/** 匿名回调说明。 */ function () {});
class Service {
/** 构造服务。 */
constructor() {}
/** 初始化组件。 */
setup() {}
/** 组件挂载入口。 */
mounted() {}
}
/** 即使初始化器具名,注释仍属于变量。 */
const load = function loadConfig() {};
`;
const result = inspectFileSource('invalid-targets.ts', source);
assert.deepEqual(
result.violations.map(({ rule, target }) => [rule, target]),
[
['invalid-target', 'interface'],
['invalid-target', 'property'],
['invalid-target', 'type'],
['invalid-target', 'variable'],
['invalid-target', 'arrow-function'],
['invalid-target', 'anonymous-function-expression'],
['invalid-target', 'constructor'],
['invalid-target', 'setup'],
['invalid-target', 'lifecycle-entry'],
['invalid-target', 'variable'],
],
);
});
it('rejects raw JSDoc blocks that TypeScript does not attach to a node', () => {
const source = `
consume(/** 表达式内孤立注释。 */ 1);
const stringValue = '/** 字符串内容。 */';
const templateValue = \`/** 模板字符串内容。 */\`;
; /** 文件尾孤立注释。 */
`;
const result = inspectFileSource('unattached.ts', source);
assert.equal(result.comments.length, 2);
assert.deepEqual(
result.violations.map(({ rule, target }) => [rule, target]),
[
['invalid-target', 'unattached'],
['invalid-target', 'unattached'],
],
);
});
it('does not treat string, template, or TSX text content as raw JSDoc', () => {
const source = `
const stringValue = '/** 字符串内容。 */';
const templateValue = \`/** 模板字符串内容。 */\`;
const view = <div>/** TSX 文本内容。 */</div>;
consume(/** 只有这一块是真实孤立注释。 */ 1);
`;
const result = inspectFileSource('literal-content.tsx', source);
assert.equal(result.comments.length, 1);
assert.equal(result.violations[0]?.target, 'unattached');
});
it('checks Vue script and script-setup blocks without scanning template or style', () => {
const source = `
<template>
<div>/** 模板文本不属于 JSDoc。 */</div>
</template>
<script setup lang="ts">
/** setup 变量非法。 */
const count = 1;
/** 返回当前数量。 */
function readCount() {
return count;
}
</script>
<script lang="tsx">
defineComponent({
/** TSX 组件 setup 禁止使用 JSDoc。 */
setup() {
return () => <span />;
},
/** 渲染当前组件。 */
render() {
return <span />;
},
});
/** Loads remote data. */
function loadRemote() {}
</script>
<style>
/** 样式注释不属于脚本 JSDoc。 */
.root {}
</style>
`;
const result = inspectFileSource('Component.vue', source);
assert.equal(result.comments.length, 5);
assert.deepEqual(
result.violations.map(({ rule, target }) => [rule, target]),
[
['invalid-target', 'variable'],
['invalid-target', 'setup'],
['non-chinese-description', 'named-function-declaration'],
],
);
});
it('fix mode removes only violations and preserves non-comment tokens', () => {
const source = `
/** 保留有意义的中文说明。 */
function keepComment() {}
/** Remove this historical English description. */
function removeEnglishComment() {}
/** 删除变量位置的注释。 */
const value = 1;
const named = /** 保留具名函数表达式说明。 */ function namedExpression() {
return value;
};
`;
const beforeTokens = getCodeTokenSignature('cleanup.ts', source);
const fixed = applyJsdocPolicyFix('cleanup.ts', source);
assert.match(fixed.source, /保留有意义的中文说明/);
assert.match(fixed.source, /保留具名函数表达式说明/);
assert.doesNotMatch(fixed.source, /historical English/);
assert.doesNotMatch(fixed.source, /删除变量位置的注释/);
assert.equal(fixed.removedCount, 2);
assert.deepEqual(
inspectFileSource('cleanup.ts', fixed.source).violations,
[],
);
assert.equal(getCodeTokenSignature('cleanup.ts', fixed.source), beforeTokens);
});
it('tracked-file discovery also includes untracked non-ignored code', () => {
const repository = mkdtempSync(path.join(tmpdir(), 'admin-jsdoc-policy-'));
try {
runFixtureGit(repository, ['init', '--quiet']);
writeFileSync(path.join(repository, '.gitignore'), 'ignored.ts\n');
writeFileSync(path.join(repository, 'deleted.ts'), 'export {};\n');
writeFileSync(path.join(repository, 'tracked.ts'), 'export {};\n');
writeFileSync(path.join(repository, 'untracked.tsx'), 'export {};\n');
writeFileSync(path.join(repository, 'ignored.ts'), 'export {};\n');
writeFileSync(path.join(repository, 'notes.md'), '# Notes\n');
runFixtureGit(repository, [
'add',
'.gitignore',
'deleted.ts',
'tracked.ts',
]);
rmSync(path.join(repository, 'deleted.ts'));
assert.deepEqual(getTrackedCodeFiles(repository), [
'tracked.ts',
'untracked.tsx',
]);
} finally {
rmSync(repository, { force: true, recursive: true });
}
});