mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
AutoReview: - 审查器回到 Codex Guardian 风格的对话式审查,默认跟随会话当前主模型 (每次审查实时读取 ctx.model);provider/model 必须同时设置才固定审查 模型,只设其一视为配置错误并转人工。 - 结论只通过 approve / reject 两个工具给出,参数仅 reason;不强制 toolChoice,正文文本(即使是合法 JSON)一律忽略。 - approve 自动放行;reject 转人工并在弹窗显示理由(标注模型生成)。 超时、Provider 错误、截断、未调用工具、多次调用、参数无效等异常同样 转人工,并附固定的系统说明;用户按 Esc 中止时直接拒绝。 - 模型调用改为 modelRegistry.streamSimple(...).result():Pi 1.3.0 面向 扩展的 ModelRegistry 没有 completeSimple,原写法在真实环境中每次都会 转人工。 - 对话记录保留可信度标注;工具结果在有更短的 pi-compaction 摘要时替换 为摘要;token 估算改为非 ASCII 字符按 1 token 计;预算可配置。 - 同一条用户消息内完全相同的请求只缓存 approve 结论。 - 默认 reasoning low、总超时 30 秒、最多 2 次尝试、maxOutputTokens 4000。 删除: - 未发布过的 TypeSafe Jev 分类器实现、DenialCircuitBreaker。 - 整个 annotator 机制(model-explanation、注册表、registerAnnotator、 provider-timeout);annotators 配置键保留为已弃用并忽略,避免旧配置 被拒绝。 测试: - 新增 reviewer / transcript / 弹窗说明 / 默认配置测试;清理重复、恒真 或只测实现细节的旧测试。 - 使用 deepseek-v4.1-flash 实测 5 个场景各 5 次,25 次结论全部符合预期。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
classifyBareTokenCandidate,
|
|
classifyTokenAsPathCandidate,
|
|
classifyTokenAsRuleCandidate,
|
|
} from "#src/access-intent/bash/token-classification";
|
|
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
|
|
|
// ── Shared rejection behaviour ─────────────────────────────────────────────
|
|
//
|
|
// Both classifiers delegate to the private `rejectNonPathToken` predicate for
|
|
// the six shared rejection cases tested below. Testing via both exports
|
|
// pins that predicate through each caller.
|
|
|
|
describe("classifyTokenAsPathCandidate", () => {
|
|
describe("shared rejection: rejectNonPathToken", () => {
|
|
test("empty string → null", () => {
|
|
expect(classifyTokenAsPathCandidate("")).toBeNull();
|
|
});
|
|
|
|
test("flag (leading dash) → null", () => {
|
|
expect(classifyTokenAsPathCandidate("-r")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
|
|
});
|
|
|
|
test("env assignment (= before any /) → null", () => {
|
|
expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
|
|
});
|
|
|
|
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
|
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
|
|
// Starts with /, so path candidate accepts it.
|
|
expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
|
|
});
|
|
|
|
test("URL → null", () => {
|
|
expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
|
|
expect(
|
|
classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("@scope/package → null", () => {
|
|
expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
|
|
});
|
|
|
|
test("regex metacharacters → null", () => {
|
|
// REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
|
|
expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("path-candidate acceptance gate", () => {
|
|
test("absolute path (starts with /) → returned as-is", () => {
|
|
expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
|
|
expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
|
|
expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
|
|
"/home/user/file.txt",
|
|
);
|
|
});
|
|
|
|
test("bare-slash token (filesystem root) → returned as-is", () => {
|
|
// `find /` scans the whole filesystem from root — a deliberate
|
|
// external-directory access the gate must see, not drop (#583).
|
|
expect(classifyTokenAsPathCandidate("/")).toBe("/");
|
|
expect(classifyTokenAsPathCandidate("//")).toBe("//");
|
|
expect(classifyTokenAsPathCandidate("///")).toBe("///");
|
|
});
|
|
|
|
test("home-relative path (starts with ~/) → returned as-is", () => {
|
|
expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
|
|
expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
|
|
"~/.ssh/config",
|
|
);
|
|
});
|
|
|
|
test("parent-traversal (contains ..) → returned as-is", () => {
|
|
expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
|
|
"../../etc/passwd",
|
|
);
|
|
expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
|
|
expect(classifyTokenAsPathCandidate("..")).toBe("..");
|
|
});
|
|
|
|
test("plain word with no path shape → null", () => {
|
|
expect(classifyTokenAsPathCandidate("hello")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
|
|
});
|
|
|
|
test("dot-file (starts with .) → null (strict path gate)", () => {
|
|
// Path candidate does NOT accept dot-files; rule candidate does.
|
|
expect(classifyTokenAsPathCandidate(".env")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
|
|
});
|
|
|
|
test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
|
|
// Path candidate does NOT accept bare relative paths; rule candidate does.
|
|
expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
|
|
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Windows drive-letter acceptance gate", () => {
|
|
test("forward-slash drive path → returned as-is", () => {
|
|
expect(classifyTokenAsPathCandidate("C:/Windows/win.ini")).toBe(
|
|
"C:/Windows/win.ini",
|
|
);
|
|
expect(classifyTokenAsPathCandidate("D:/secrets/password.txt")).toBe(
|
|
"D:/secrets/password.txt",
|
|
);
|
|
});
|
|
|
|
test("backslash drive path → returned as-is", () => {
|
|
expect(classifyTokenAsPathCandidate("C:\\Windows\\win.ini")).toBe(
|
|
"C:\\Windows\\win.ini",
|
|
);
|
|
expect(classifyTokenAsPathCandidate("D:\\secrets\\password.txt")).toBe(
|
|
"D:\\secrets\\password.txt",
|
|
);
|
|
});
|
|
|
|
test("lowercase drive letter → returned as-is", () => {
|
|
expect(classifyTokenAsPathCandidate("c:/foo")).toBe("c:/foo");
|
|
});
|
|
|
|
test("single-letter scheme with double-slash (c://x) → null (URL_PATTERN fires first)", () => {
|
|
// c:// matches URL_PATTERN before the drive-letter check runs.
|
|
expect(classifyTokenAsPathCandidate("c://x")).toBeNull();
|
|
});
|
|
|
|
test("drive-relative path without separator (C:foo) → null", () => {
|
|
// No / or \ after the colon — not an absolute drive path per node:path.
|
|
expect(classifyTokenAsPathCandidate("C:foo")).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("classifyTokenAsRuleCandidate", () => {
|
|
describe("shared rejection: rejectNonPathToken", () => {
|
|
test("empty string → null", () => {
|
|
expect(classifyTokenAsRuleCandidate("", posixPathFlavor)).toBeNull();
|
|
});
|
|
|
|
test("flag (leading dash) → null", () => {
|
|
expect(classifyTokenAsRuleCandidate("-r", posixPathFlavor)).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("--recursive", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("env assignment (= before any /) → null", () => {
|
|
expect(
|
|
classifyTokenAsRuleCandidate("FOO=/bar", posixPathFlavor),
|
|
).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("HOME=/home/user", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
|
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
|
|
// Contains /, so rule candidate accepts it.
|
|
expect(classifyTokenAsRuleCandidate("/foo=bar", posixPathFlavor)).toBe(
|
|
"/foo=bar",
|
|
);
|
|
});
|
|
|
|
test("URL → null", () => {
|
|
expect(
|
|
classifyTokenAsRuleCandidate("https://example.com", posixPathFlavor),
|
|
).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("http://localhost:3000", posixPathFlavor),
|
|
).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("file:///tmp/foo", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("@scope/package → null", () => {
|
|
expect(
|
|
classifyTokenAsRuleCandidate("@foo/bar", posixPathFlavor),
|
|
).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("@scope/pkg", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("regex metacharacters → null", () => {
|
|
expect(classifyTokenAsRuleCandidate("foo.*", posixPathFlavor)).toBeNull();
|
|
expect(classifyTokenAsRuleCandidate("bar.+", posixPathFlavor)).toBeNull();
|
|
expect(classifyTokenAsRuleCandidate("a\\|b", posixPathFlavor)).toBeNull();
|
|
expect(classifyTokenAsRuleCandidate("[abc]", posixPathFlavor)).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("^/start", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("rule-candidate acceptance gate (broader than path)", () => {
|
|
test("absolute path (starts with /) → returned as-is", () => {
|
|
expect(classifyTokenAsRuleCandidate("/etc/hosts", posixPathFlavor)).toBe(
|
|
"/etc/hosts",
|
|
);
|
|
});
|
|
|
|
test("bare-slash token (filesystem root) → returned as-is", () => {
|
|
// Root is a path-shaped token via `hasPathSeparator`; a `path` rule for
|
|
// `/` must be able to match it, same as any other absolute (#583).
|
|
expect(classifyTokenAsRuleCandidate("/", posixPathFlavor)).toBe("/");
|
|
expect(classifyTokenAsRuleCandidate("//", posixPathFlavor)).toBe("//");
|
|
});
|
|
|
|
test("home-relative path (starts with ~/) → returned as-is", () => {
|
|
expect(classifyTokenAsRuleCandidate("~/Documents", posixPathFlavor)).toBe(
|
|
"~/Documents",
|
|
);
|
|
});
|
|
|
|
test("parent-traversal (contains ..) → returned as-is", () => {
|
|
expect(classifyTokenAsRuleCandidate("../foo", posixPathFlavor)).toBe(
|
|
"../foo",
|
|
);
|
|
expect(classifyTokenAsRuleCandidate("..", posixPathFlavor)).toBe("..");
|
|
});
|
|
|
|
test("dot-file (starts with .) → returned as-is", () => {
|
|
// Rule candidate accepts dot-files; path candidate does not.
|
|
expect(classifyTokenAsRuleCandidate(".env", posixPathFlavor)).toBe(
|
|
".env",
|
|
);
|
|
expect(classifyTokenAsRuleCandidate(".gitignore", posixPathFlavor)).toBe(
|
|
".gitignore",
|
|
);
|
|
});
|
|
|
|
test("current-dir relative (starts with ./) → returned as-is", () => {
|
|
expect(classifyTokenAsRuleCandidate("./src", posixPathFlavor)).toBe(
|
|
"./src",
|
|
);
|
|
expect(
|
|
classifyTokenAsRuleCandidate("./build/output.js", posixPathFlavor),
|
|
).toBe("./build/output.js");
|
|
});
|
|
|
|
test("relative path containing / → returned as-is", () => {
|
|
// Rule candidate accepts any token with / (not already rejected).
|
|
expect(classifyTokenAsRuleCandidate("src/foo.ts", posixPathFlavor)).toBe(
|
|
"src/foo.ts",
|
|
);
|
|
expect(
|
|
classifyTokenAsRuleCandidate(
|
|
"packages/pi-foo/index.ts",
|
|
posixPathFlavor,
|
|
),
|
|
).toBe("packages/pi-foo/index.ts");
|
|
});
|
|
|
|
test("plain word with no path shape → null", () => {
|
|
expect(classifyTokenAsRuleCandidate("hello", posixPathFlavor)).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("myfile.txt", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Windows drive-letter acceptance gate", () => {
|
|
test("forward-slash drive path → returned as-is", () => {
|
|
// Forward-slash form was already accepted via token.includes("/").
|
|
// The explicit branch makes it first-class and order-independent.
|
|
expect(
|
|
classifyTokenAsRuleCandidate("C:/Windows/win.ini", posixPathFlavor),
|
|
).toBe("C:/Windows/win.ini");
|
|
});
|
|
|
|
test("backslash drive path → returned as-is (new: no forward slash)", () => {
|
|
// Previously dropped by both classifiers; the backslash form has no /
|
|
// so the includes("/") branch could not catch it.
|
|
expect(
|
|
classifyTokenAsRuleCandidate(
|
|
"D:\\secrets\\password.txt",
|
|
posixPathFlavor,
|
|
),
|
|
).toBe("D:\\secrets\\password.txt");
|
|
expect(
|
|
classifyTokenAsRuleCandidate("C:\\Windows\\win.ini", posixPathFlavor),
|
|
).toBe("C:\\Windows\\win.ini");
|
|
});
|
|
|
|
test("lowercase drive letter (backslash) → returned as-is", () => {
|
|
expect(classifyTokenAsRuleCandidate("c:\\foo", posixPathFlavor)).toBe(
|
|
"c:\\foo",
|
|
);
|
|
});
|
|
|
|
test("drive-relative path without separator (C:foo) → null", () => {
|
|
expect(classifyTokenAsRuleCandidate("C:foo", posixPathFlavor)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Windows backslash-relative acceptance gate (win32 flavor, #520)", () => {
|
|
test("backslash-relative token accepted under the win32 flavor", () => {
|
|
expect(classifyTokenAsRuleCandidate("dir\\file", win32PathFlavor)).toBe(
|
|
"dir\\file",
|
|
);
|
|
});
|
|
|
|
test("backslash-relative token rejected under the posix flavor", () => {
|
|
expect(
|
|
classifyTokenAsRuleCandidate("dir\\file", posixPathFlavor),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("backslash regex-metacharacter token still rejected under the win32 flavor", () => {
|
|
// rejectNonPathToken's REGEX_METACHAR_PATTERN fires before the separator
|
|
// branch is reached, regardless of flavor.
|
|
expect(classifyTokenAsRuleCandidate("a\\|b", win32PathFlavor)).toBeNull();
|
|
expect(
|
|
classifyTokenAsRuleCandidate("\\(group\\)", win32PathFlavor),
|
|
).toBeNull();
|
|
});
|
|
|
|
test("backslash traversal accepted regardless of flavor (already via ..)", () => {
|
|
expect(classifyTokenAsRuleCandidate("..\\secret", posixPathFlavor)).toBe(
|
|
"..\\secret",
|
|
);
|
|
expect(classifyTokenAsRuleCandidate("..\\secret", win32PathFlavor)).toBe(
|
|
"..\\secret",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("rule-vs-path divergence", () => {
|
|
test("dot-files and bare relative paths: rule accepts, path rejects", () => {
|
|
for (const tok of [".env", ".gitignore", "src/index.ts"]) {
|
|
expect(classifyTokenAsRuleCandidate(tok, posixPathFlavor)).toBe(tok);
|
|
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("classifyBareTokenCandidate", () => {
|
|
// Prelude-only: returns the token when nothing about its *shape* rules out
|
|
// being a path. Whether it names a real entry is the existence probe's
|
|
// question, decided by the resolver (ADR 0009), not by this classifier.
|
|
|
|
test("bare word → returned unchanged", () => {
|
|
expect(classifyBareTokenCandidate("id_rsa")).toBe("id_rsa");
|
|
expect(classifyBareTokenCandidate("key.pem")).toBe("key.pem");
|
|
expect(classifyBareTokenCandidate("outside-link")).toBe("outside-link");
|
|
});
|
|
|
|
test("bare word that names no file is still returned — existence is not its question", () => {
|
|
expect(classifyBareTokenCandidate("status")).toBe("status");
|
|
expect(classifyBareTokenCandidate("build")).toBe("build");
|
|
});
|
|
|
|
describe("shared rejection prelude", () => {
|
|
test("flag (leading dash) → null", () => {
|
|
expect(classifyBareTokenCandidate("-r")).toBeNull();
|
|
expect(classifyBareTokenCandidate("--recursive")).toBeNull();
|
|
});
|
|
|
|
test("env assignment → null", () => {
|
|
expect(classifyBareTokenCandidate("FOO=/bar")).toBeNull();
|
|
});
|
|
|
|
test("URL → null", () => {
|
|
expect(classifyBareTokenCandidate("https://example.com")).toBeNull();
|
|
});
|
|
|
|
test("@scope/package → null", () => {
|
|
expect(classifyBareTokenCandidate("@foo/bar")).toBeNull();
|
|
});
|
|
|
|
test("regex metacharacters → null", () => {
|
|
expect(classifyBareTokenCandidate("foo.*")).toBeNull();
|
|
});
|
|
|
|
test("empty string → null", () => {
|
|
expect(classifyBareTokenCandidate("")).toBeNull();
|
|
});
|
|
});
|
|
});
|