确定性注释审查 harness(cmr),程序决定审什么、agent 负责推理: - 轻量状态机提取注释(Java/TS/JS/Vue/SQL/YAML/Shell/Python),过程性模式分级 P0/P1/P2/INFO - 分层规则(CLI/项目/用户/内置)、全库 scan 与 Git 增量 diff(工作区/分支/提交) - 悬空引用两级判定:确认失效 P0(附近似候选与查找依据)与待核实 P1(外部路径/构建产物/跨仓裸文件名) - testdata/corpus 56 条人工判定样例库 + 记分卡回归门禁;npm test 30/30
233 lines
11 KiB
JavaScript
233 lines
11 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { extractComments } from "../src/extract.js";
|
|
import { classifyComment } from "../src/patterns.js";
|
|
import { runScan } from "../src/scan.js";
|
|
import { loadRuleConfig, resolveRule } from "../src/rules.js";
|
|
import { matchGlob } from "../src/glob.js";
|
|
|
|
const cli = fileURLToPath(new URL("../bin/cmr.js", import.meta.url));
|
|
function fixture(t) {
|
|
const root = mkdtempSync(join(tmpdir(), "cmr-test-"));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const write = (path, text) => {
|
|
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
writeFileSync(join(root, path), text);
|
|
};
|
|
write(".commentreview/rule.json", '{"rules":[]}');
|
|
const git = (...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
const commit = () => {
|
|
git("add", ".");
|
|
git("-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "-qm", "fixture");
|
|
return git("rev-parse", "HEAD");
|
|
};
|
|
return { root, write, git, commit };
|
|
}
|
|
|
|
test("documented lowercase severity retains P0 findings", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("a.js", "// 定稿\nconst a = 1;");
|
|
assert.equal(runScan(root, { minSeverity: "p0" }).stats.candidates, 1);
|
|
});
|
|
|
|
for (const args of [["scan", "--format", "jsoon"], ["scan", "--min-severity", "bad"], ["scan", "--path"], ["scan", "--unknown"], ["diff", "--from", "main"], ["scan", "--commit", "HEAD"], ["rules"]]) {
|
|
test(`CLI rejects invalid arguments: ${args.join(" ")}`, (t) => {
|
|
const { root } = fixture(t);
|
|
const result = spawnSync(process.execPath, [cli, ...args], { cwd: root, encoding: "utf8" });
|
|
assert.equal(result.status, 2, result.stderr);
|
|
});
|
|
}
|
|
|
|
test("rules does not interpret option values as file names", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("custom.json", '{"rules":[{"path":"**","rule":"custom"}]}');
|
|
const result = spawnSync(process.execPath, [cli, "rules", "src/a.js", "--rule", "custom.json"], { cwd: root, encoding: "utf8" });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout.split("\n").filter((line) => line.includes("->")).length, 1);
|
|
});
|
|
|
|
test("explicit missing and malformed rules fail instead of silently falling back", (t) => {
|
|
const { root, write } = fixture(t);
|
|
assert.throws(() => loadRuleConfig("missing.json", root), /rule|规则/i);
|
|
write("bad.json", '{"rules":[{"path":42}]}');
|
|
assert.throws(() => loadRuleConfig("bad.json", root), /rule|规则/i);
|
|
});
|
|
|
|
test("project excludes and vendor exclusions affect files and statistics", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write(".commentreview/rule.json", '{"rules":[],"excludes":["ignored/**"]}');
|
|
for (const path of ["a.js", "ignored/b.js", "vendor/c.js", "src/vendor/d.js"]) write(path, "// 定稿");
|
|
const spec = runScan(root, {});
|
|
assert.equal(spec.stats.files, 1);
|
|
assert.deepEqual(spec.results.map((r) => r.file), ["a.js"]);
|
|
});
|
|
|
|
test("symlink directories cannot recurse back into the scan", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("src/a.js", "// 定稿");
|
|
symlinkSync(root, join(root, "loop"), process.platform === "win32" ? "junction" : "dir");
|
|
assert.equal(runScan(root, {}).stats.files, 1);
|
|
});
|
|
|
|
test("reference URLs are not missing local files; explicit paths cannot match another basename", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("docs/guide.md", "guide");
|
|
write("a.js", "// https://example.org/missing-guide.md\nconst a = 1;\n// see missing/guide.md\nconst b = 2;");
|
|
const results = runScan(root, {}).results;
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].start, 3);
|
|
assert.equal(results[0].hits[0].id, "dangling_ref");
|
|
});
|
|
|
|
test("dated reference exemption does not erase a separate change date", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("docs/dated-2026-09-12.md", "guide");
|
|
write("a.js", "// see docs/dated-2026-09-12.md\nconst a = 1;\n// 2026-09-13 修改实现,见 docs/dated-2026-09-12.md\nconst b = 2;");
|
|
const results = runScan(root, {}).results;
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].severity, "P0");
|
|
});
|
|
|
|
test("TODO alone belongs in the INFO ledger", () => {
|
|
assert.equal(classifyComment({ text: "TODO: 补齐校验" })?.severity, "INFO");
|
|
assert.equal(classifyComment({ text: "新增合同变更记录" }), null);
|
|
assert.equal(classifyComment({ text: "张总:需要调整流程" })?.severity, "P1");
|
|
});
|
|
|
|
test("extractor skips escaped quotes and multiline string contents", () => {
|
|
const source = String.raw`const a = "escaped \" // 定稿";`;
|
|
assert.equal(extractComments(source.split("\n")[0], "a.js").length, 0);
|
|
assert.deepEqual(extractComments('const x = `first\n// 定稿\nlast`;\n// TODO: real', "a.js").map((c) => c.text.trim()), ["TODO: real"]);
|
|
});
|
|
|
|
test("extractor recognizes Javadoc and Python docstrings", () => {
|
|
const docs = extractComments("/** API contract */\nclass A {}", "A.java");
|
|
assert.equal(docs[0].kind, "doc");
|
|
assert.equal(docs[0].text.trim(), "API contract");
|
|
assert.equal(extractComments('"""Module contract"""\nx = 1', "a.py")[0]?.text, "Module contract");
|
|
assert.equal(extractComments('x = """data\n# 定稿\n"""', "a.py").length, 0);
|
|
});
|
|
|
|
test("hash markers respect properties and YAML syntax", () => {
|
|
assert.equal(extractComments("password=abc#secret!value", "a.properties").length, 0);
|
|
assert.equal(extractComments("url: https://example.org/#fragment", "a.yaml").length, 0);
|
|
assert.equal(extractComments("# TODO: real", "a.properties").length, 1);
|
|
});
|
|
|
|
test("glob and rule priority preserve root files and Windows separators", () => {
|
|
assert.ok(matchGlob("**/*.js", "a.js"));
|
|
assert.ok(matchGlob("src/*.js", "src\\a.js"));
|
|
assert.ok(!matchGlob("src/*.js", "src/nested/a.js"));
|
|
const rule = resolveRule({ source: "project", rules: [{ path: "**", rule: "only", merge_system_rule: false }] }, "a.js");
|
|
assert.equal(rule.text, "only");
|
|
});
|
|
|
|
test("diff reads the selected commit and branch snapshot, not dirty worktree files", (t) => {
|
|
const { root, write, git, commit } = fixture(t);
|
|
git("init", "-q");
|
|
write("中文 file.js", "// 定稿\nconst a = 1;\n");
|
|
const base = commit();
|
|
write("中文 file.js", "// 定稿\nconst a = 2;\n// 返工\nconst b = 1;\n");
|
|
const target = commit();
|
|
write("中文 file.js", "// 废弃\nconst dirty = true;\n");
|
|
for (const options of [{ commit: target }, { from: base, to: target }]) {
|
|
const spec = runScan(root, { mode: "diff", ...options });
|
|
assert.deepEqual(spec.results.map((r) => r.text.trim()), ["返工"]);
|
|
assert.equal(spec.results[0].file, "中文 file.js");
|
|
assert.equal(spec.results[0].start, 3);
|
|
}
|
|
});
|
|
|
|
test("diff supports initial commit, unborn HEAD, path scope and duplicate comments", (t) => {
|
|
const { root, write, git, commit } = fixture(t);
|
|
git("init", "-q");
|
|
write("src/a.js", "// 定稿\nconst a = 1;\n");
|
|
assert.equal(runScan(root, { mode: "diff" }).stats.candidates, 1);
|
|
const first = commit();
|
|
assert.equal(runScan(root, { mode: "diff", commit: first }).stats.candidates, 1);
|
|
write("src/a.js", "// 定稿\nconst a = 1;\n// 定稿\nconst b = 2;\n");
|
|
write("other/b.js", "// 返工\n");
|
|
const spec = runScan(root, { mode: "diff", path: "src" });
|
|
assert.deepEqual(spec.results.map((r) => r.file.replaceAll("\\", "/")), ["src/a.js"]);
|
|
assert.equal(spec.results[0].start, 3);
|
|
assert.equal(runScan(join(root, "src"), { mode: "diff", path: "." }).stats.candidates, 1);
|
|
});
|
|
|
|
test("historical reference resolution uses the target tree", (t) => {
|
|
const { root, write, git, commit } = fixture(t);
|
|
git("init", "-q");
|
|
write("a.js", "const a = 1;\n");
|
|
const base = commit();
|
|
write("a.js", "// see docs/dated-2026-09-12.md\nconst a = 1;\n");
|
|
write("docs/dated-2026-09-12.md", "guide");
|
|
const target = commit();
|
|
rmSync(join(root, "docs"), { recursive: true });
|
|
assert.equal(runScan(root, { mode: "diff", from: base, to: target }).stats.candidates, 0);
|
|
});
|
|
|
|
test("scan rejects a nonexistent path instead of returning a clean report", (t) => {
|
|
const { root } = fixture(t);
|
|
assert.throws(() => runScan(root, { path: "missing" }), /ENOENT/);
|
|
assert.throws(() => runScan(root, { path: ".." }), /inside/);
|
|
});
|
|
|
|
test("unchanged comments, deleted files and code-only changes produce no diff candidates", (t) => {
|
|
const { root, write, git, commit } = fixture(t);
|
|
git("init", "-q");
|
|
write("a.js", "// 定稿\nconst a = 1;\n");
|
|
write("b.js", "// 返工\n");
|
|
commit();
|
|
write("a.js", "// 定稿\nconst a = 2;\n");
|
|
git("rm", "-q", "b.js");
|
|
assert.equal(runScan(root, { mode: "diff" }).stats.candidates, 0);
|
|
const target = commit();
|
|
assert.equal(runScan(root, { mode: "diff", commit: target }).stats.candidates, 0);
|
|
});
|
|
|
|
test("branch range uses merge base even when the source branch has advanced", (t) => {
|
|
const { root, write, git, commit } = fixture(t);
|
|
git("init", "-q");
|
|
write("a.js", "const a = 1;\n");
|
|
const base = commit();
|
|
write("a.js", "// 定稿\nconst a = 1;\n");
|
|
const target = commit();
|
|
git("checkout", "--detach", "-q", base);
|
|
write("a.js", "// 返工\nconst a = 1;\n");
|
|
const from = commit();
|
|
const results = runScan(root, { mode: "diff", from, to: target }).results;
|
|
assert.deepEqual(results.map((r) => r.text.trim()), ["定稿"]);
|
|
});
|
|
|
|
test("a source file with no comments is counted without findings", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("a.js", 'const url = "https://example.org";\n');
|
|
const { stats } = runScan(root, {});
|
|
assert.equal(stats.files, 1);
|
|
assert.equal(stats.comments, 0);
|
|
assert.equal(stats.candidates, 0);
|
|
});
|
|
|
|
test("rules JSON contains resolved content and uses only positional files", (t) => {
|
|
const { root, write } = fixture(t);
|
|
write("custom.json", '{"rules":[{"path":"**","rule":"custom","merge_system_rule":false}]}');
|
|
const result = spawnSync(process.execPath, [cli, "rules", "a.js", "--rule", "custom.json", "--format", "json"], { cwd: root, encoding: "utf8" });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.deepEqual(JSON.parse(result.stdout), [{ file: "a.js", source: "cli", pattern: "**", text: "custom" }]);
|
|
});
|
|
|
|
test("original Java examples retain business constraints and report process history", () => {
|
|
const root = fileURLToPath(new URL("../", import.meta.url));
|
|
const { stats, results } = runScan(root, { path: "test/fixtures" });
|
|
assert.equal(stats.comments, 18);
|
|
assert.equal(stats.candidates, 12);
|
|
// HANDLER-LEDGER.md is a bare filename with no near miss: unverifiable, so
|
|
// it rides at P1 for the agent instead of a P0 deletion verdict.
|
|
assert.deepEqual(stats.bySeverity, { P0: 2, P1: 9, P2: 0, INFO: 1 });
|
|
for (const line of [45, 49, 53, 57, 73, 77]) assert.ok(!results.some((r) => r.start === line), `business/example comment at ${line}`);
|
|
});
|