Files
triplestone d83b5fd5d1 feat: 发布 comment-review 1.0.0
确定性注释审查 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
2026-09-14 22:22:07 +08:00

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}`);
});