pi-skill-evolve 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 采集与门禁单测(零依赖,node --test)。
|
|
3
|
+
*
|
|
4
|
+
* 运行:
|
|
5
|
+
* cd .pi/extensions/skill-evolve
|
|
6
|
+
* node --experimental-strip-types --test test/evolution.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:一轮只结算一次(连续 agent_settled 只派生一次)、无曝光不派生、
|
|
9
|
+
* 子进程门禁、未命中只记「曝光了但没读」、读非曝光技能不算加载、状态与报告文本。
|
|
10
|
+
*/
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
|
|
19
|
+
import { createEvolution, isChildProcess, type Evolution } from "../lib/evolution.ts";
|
|
20
|
+
import { writeWebPid } from "../lib/web/panel-pid.ts";
|
|
21
|
+
import { directQueue, type TelemetryEvent, type TelemetrySkill } from "../lib/telemetry.ts";
|
|
22
|
+
import type { SpawnedChild, SpawnLike } from "../lib/spawn-plan.ts";
|
|
23
|
+
|
|
24
|
+
const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
|
|
25
|
+
const NOW = 1_760_000_000_000;
|
|
26
|
+
|
|
27
|
+
function skill(name: string, disabled = false): TelemetrySkill {
|
|
28
|
+
return { name, filePath: `/repo/.pi/skills/${name}/SKILL.md`, disabled, source: "project" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Harness {
|
|
32
|
+
dir: string;
|
|
33
|
+
evo: Evolution;
|
|
34
|
+
spawns: number;
|
|
35
|
+
/** 模拟某个子进程退出(收尸会顺手放锁) */
|
|
36
|
+
exitChild: (index: number) => Promise<void>;
|
|
37
|
+
children: FakeChild[];
|
|
38
|
+
events: () => TelemetryEvent[];
|
|
39
|
+
tick: (ms: number) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class FakeChild {
|
|
43
|
+
/** 锁回收用 kill(pid,0) 判存活:假 pid 会被当死进程,所以用真实存活的本进程 pid */
|
|
44
|
+
readonly pid = process.pid;
|
|
45
|
+
private listeners = new Map<string, (arg: unknown) => void>();
|
|
46
|
+
unref(): void {} // detached 子进程交还给 init,测试里无事可做
|
|
47
|
+
once(event: string, listener: (arg: unknown) => void): this {
|
|
48
|
+
this.listeners.set(event, listener);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
emit(event: string, arg: unknown): void {
|
|
52
|
+
this.listeners.get(event)?.(arg);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 夹具的 config.json:生产默认全关(clone 即静默),所以每个用例都得显式打开;
|
|
58
|
+
* 要验证「默认啥都不做」的用例直接自己建目录、不走这个 helper。
|
|
59
|
+
*/
|
|
60
|
+
function writeConfig(dir: string, config: Record<string, unknown> = {}): void {
|
|
61
|
+
const evolutionDir = path.join(dir, ".pi", "evolution");
|
|
62
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
path.join(evolutionDir, "config.json"),
|
|
65
|
+
`${JSON.stringify({ enabled: true, telemetry: true, ...config }, null, 2)}\n`,
|
|
66
|
+
"utf8",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function harness(env: NodeJS.ProcessEnv = {}, config: Record<string, unknown> = {}): Harness {
|
|
71
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-evo-"));
|
|
72
|
+
writeConfig(dir, config);
|
|
73
|
+
let clock = NOW;
|
|
74
|
+
const children: FakeChild[] = [];
|
|
75
|
+
const spawnFn: SpawnLike = () => (children.push(new FakeChild()), children[children.length - 1] as unknown as SpawnedChild);
|
|
76
|
+
const evo = createEvolution({
|
|
77
|
+
cwd: dir,
|
|
78
|
+
promptTemplatePath: TEMPLATE,
|
|
79
|
+
queue: directQueue,
|
|
80
|
+
log: () => {},
|
|
81
|
+
now: () => clock,
|
|
82
|
+
env,
|
|
83
|
+
spawnFn,
|
|
84
|
+
// 假子进程的 pid 就是本进程 pid(锁回收要真 pid 才判得活);单飞闸用注入的 alive 模拟「子进程已退出」
|
|
85
|
+
alive: () => false,
|
|
86
|
+
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
dir,
|
|
90
|
+
evo,
|
|
91
|
+
children,
|
|
92
|
+
get spawns() {
|
|
93
|
+
return children.length;
|
|
94
|
+
},
|
|
95
|
+
exitChild: async (index) => {
|
|
96
|
+
children[index].emit("exit", 0);
|
|
97
|
+
await settleAsync();
|
|
98
|
+
},
|
|
99
|
+
events: () => readTelemetry(dir),
|
|
100
|
+
tick: (ms) => {
|
|
101
|
+
clock += ms;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readTelemetry(dir: string): TelemetryEvent[] {
|
|
107
|
+
const shard = path.join(dir, ".pi", "evolution", "telemetry");
|
|
108
|
+
let names: string[];
|
|
109
|
+
try {
|
|
110
|
+
names = fs.readdirSync(shard);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const events: TelemetryEvent[] = [];
|
|
115
|
+
for (const name of names.sort()) {
|
|
116
|
+
for (const line of fs.readFileSync(path.join(shard, name), "utf8").split("\n")) {
|
|
117
|
+
if (line.trim().length === 0) continue;
|
|
118
|
+
events.push(JSON.parse(line));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return events;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 等派发链路(异步写盘 + spawn)跑完 */
|
|
125
|
+
const settleAsync = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150));
|
|
126
|
+
|
|
127
|
+
test("isChildProcess:任一子进程环境变量在场即为子进程", () => {
|
|
128
|
+
assert.equal(isChildProcess({}), false);
|
|
129
|
+
for (const key of ["SKILL_EVOLVE_CHILD", "PI_SUBAGENT_CHILD", "PI_AGENT_RUNNER", "PI_ASYNC_NATIVE_RUNNER"]) {
|
|
130
|
+
assert.equal(isChildProcess({ [key]: "1" }), true);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("连续 agent_settled 只派生一次(一轮只结算一次)", async () => {
|
|
135
|
+
const h = harness();
|
|
136
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "把需求推进到开发中", skills: [skill("a")] });
|
|
137
|
+
h.evo.settle({ sessionId: "s1" });
|
|
138
|
+
h.evo.settle({ sessionId: "s1" });
|
|
139
|
+
await settleAsync();
|
|
140
|
+
assert.equal(h.spawns, 1);
|
|
141
|
+
assert.equal(h.events().filter((event) => event.kind === "pending").length, 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("去抖:窗口内再结算一轮只写 superseded,不叠加进程", async () => {
|
|
145
|
+
const h = harness();
|
|
146
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第一轮", skills: [skill("a")] });
|
|
147
|
+
h.evo.settle({ sessionId: "s1" });
|
|
148
|
+
await settleAsync();
|
|
149
|
+
assert.equal(h.spawns, 1);
|
|
150
|
+
|
|
151
|
+
h.tick(1000); // 仍在 idleMs(90s) 内
|
|
152
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第二轮", skills: [skill("a")] });
|
|
153
|
+
h.evo.settle({ sessionId: "s1" });
|
|
154
|
+
await settleAsync();
|
|
155
|
+
assert.deepEqual([h.spawns, h.events().some((event) => event.kind === "superseded")], [1, true]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("本轮无技能曝光:不记未命中,也不派生", async () => {
|
|
159
|
+
const h = harness();
|
|
160
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "闲聊", skills: [] });
|
|
161
|
+
h.evo.settle({ sessionId: "s1" });
|
|
162
|
+
await settleAsync();
|
|
163
|
+
assert.deepEqual([h.spawns, h.events().some((event) => event.kind === "missed")], [0, false]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("子进程门禁:SKILL_EVOLVE_CHILD=1 时照常采集但不派生", async () => {
|
|
167
|
+
const h = harness({ SKILL_EVOLVE_CHILD: "1" });
|
|
168
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
169
|
+
h.evo.settle({ sessionId: "s1" });
|
|
170
|
+
await settleAsync();
|
|
171
|
+
assert.deepEqual([h.spawns, h.events().some((event) => event.kind === "pending")], [0, true]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("未命中只记「曝光了但没读」的技能,disabled 的不算曝光", async () => {
|
|
175
|
+
const h = harness();
|
|
176
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a"), skill("b"), skill("c", true)] });
|
|
177
|
+
h.evo.noteToolCall({ sessionId: "s1", toolCallId: "call-1", toolName: "read", input: { path: ".pi/skills/a/SKILL.md" } });
|
|
178
|
+
h.evo.resolveToolResult({ sessionId: "s1", toolCallId: "call-1", isError: false });
|
|
179
|
+
h.evo.settle({ sessionId: "s1" });
|
|
180
|
+
await settleAsync();
|
|
181
|
+
|
|
182
|
+
const events = h.events();
|
|
183
|
+
assert.equal(events.filter((event) => event.kind === "loaded").length, 1);
|
|
184
|
+
assert.deepEqual(events.filter((event) => event.kind === "missed").map((event) => event.skill), ["b"]);
|
|
185
|
+
const pending = events.find((event) => event.kind === "pending");
|
|
186
|
+
assert.deepEqual([pending?.exposed, pending?.missed], [2, 1]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("读技能失败记 failed,不记 loaded", async () => {
|
|
190
|
+
const h = harness();
|
|
191
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
192
|
+
h.evo.noteToolCall({ sessionId: "s1", toolCallId: "call-1", toolName: "bash", input: { command: 'cat ".pi/skills/a/SKILL.md"' } });
|
|
193
|
+
h.evo.resolveToolResult({ sessionId: "s1", toolCallId: "call-1", isError: true });
|
|
194
|
+
h.evo.settle({ sessionId: "s1" });
|
|
195
|
+
await settleAsync();
|
|
196
|
+
const kinds = h.events().map((event) => event.kind);
|
|
197
|
+
assert.deepEqual([kinds.filter((kind) => kind === "failed").length, kinds.includes("loaded")], [1, false]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("读不在本轮曝光清单里的技能文件,不算一次加载", async () => {
|
|
201
|
+
const h = harness();
|
|
202
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("alpha")] });
|
|
203
|
+
h.evo.noteToolCall({ sessionId: "s1", toolCallId: "call-1", toolName: "read", input: { path: ".pi/skills/beta/SKILL.md" } });
|
|
204
|
+
h.evo.resolveToolResult({ sessionId: "s1", toolCallId: "call-1", isError: false });
|
|
205
|
+
assert.equal(h.events().some((event) => event.kind === "loaded"), false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("tool_result 对齐不到 tool_call 时静默忽略(跨会话/子代理的工具结果)", async () => {
|
|
209
|
+
const h = harness();
|
|
210
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
211
|
+
h.evo.resolveToolResult({ sessionId: "s1", toolCallId: "未知", isError: false });
|
|
212
|
+
await settleAsync();
|
|
213
|
+
assert.equal(h.events().some((event) => event.kind === "loaded"), false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("statusText / reportText:无数据也不崩,且带上盲区提示", async () => {
|
|
217
|
+
const h = harness();
|
|
218
|
+
const status = await h.evo.statusText();
|
|
219
|
+
assert.equal(status.includes("采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:开(会派生 agent)"), true);
|
|
220
|
+
assert.equal(status.includes("锁:空闲"), true);
|
|
221
|
+
const report = h.evo.reportText();
|
|
222
|
+
assert.equal(report.includes("无曝光数据"), true);
|
|
223
|
+
assert.equal(report.includes("加载率低 ≠ 技能没用"), true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("statusText:活着的 Web 面板记录多一行地址与 pid,死记录不显示", async () => {
|
|
227
|
+
const h = harness();
|
|
228
|
+
const evolutionDir = path.join(h.dir, ".pi", "evolution");
|
|
229
|
+
const url = "http://127.0.0.1:7788";
|
|
230
|
+
writeWebPid(evolutionDir, { pid: process.pid, host: "127.0.0.1", port: 7788, url, startedAt: NOW });
|
|
231
|
+
assert.equal((await h.evo.statusText()).includes(`Web 面板:${url}(pid ${process.pid})`), true);
|
|
232
|
+
// 换成已死 pid:残记录不能当活面板显示(否则用户照着不存在的 pid 去清理)
|
|
233
|
+
writeWebPid(evolutionDir, { pid: 2_147_483_646, host: "127.0.0.1", port: 7788, url, startedAt: NOW });
|
|
234
|
+
assert.equal((await h.evo.statusText()).includes("Web 面板:"), false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("reportText:按遥测给出曝光/加载/未命中", async () => {
|
|
238
|
+
const h = harness();
|
|
239
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a"), skill("b")] });
|
|
240
|
+
h.evo.noteToolCall({ sessionId: "s1", toolCallId: "call-1", toolName: "read", input: { path: ".pi/skills/a/SKILL.md" } });
|
|
241
|
+
h.evo.resolveToolResult({ sessionId: "s1", toolCallId: "call-1", isError: false });
|
|
242
|
+
h.evo.settle({ sessionId: "s1" });
|
|
243
|
+
await settleAsync();
|
|
244
|
+
const report = h.evo.reportText();
|
|
245
|
+
assert.deepEqual(
|
|
246
|
+
[report.includes("曝光:1 轮 / 2 次"), report.includes("加载:1 次"), report.includes("50.0%"), report.includes("1. b × 1")],
|
|
247
|
+
[true, true, true, true],
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("setEnabled:写回 config.json(两个键一起切)并立即生效", async () => {
|
|
252
|
+
const h = harness();
|
|
253
|
+
const message = await h.evo.setEnabled(false);
|
|
254
|
+
assert.equal(message.includes("关闭"), true);
|
|
255
|
+
const raw = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
256
|
+
assert.deepEqual([raw.enabled, raw.telemetry], [false, false]);
|
|
257
|
+
assert.equal((await h.evo.statusText()).includes("采集:关(不写遥测)|复盘:关(不会派生 agent)"), true);
|
|
258
|
+
|
|
259
|
+
await h.evo.setEnabled(true);
|
|
260
|
+
const back = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
261
|
+
assert.deepEqual([back.enabled, back.telemetry], [true, true]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("setFlag:只切一个字段,并把当前两个开关的状态写进回执", async () => {
|
|
265
|
+
const h = harness({}, { enabled: false, telemetry: false });
|
|
266
|
+
const text = await h.evo.setFlag("telemetry", true);
|
|
267
|
+
// 回执要能看出「只开了采集、复盘还关着」,否则用户以为已经会花 token 了
|
|
268
|
+
assert.equal(text.includes("采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:关(不会派生 agent)"), true);
|
|
269
|
+
const raw = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
270
|
+
assert.deepEqual([raw.enabled, raw.telemetry], [false, true], "setFlag(telemetry) 不碰复盘");
|
|
271
|
+
await h.evo.setFlag("enabled", true);
|
|
272
|
+
const after = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
|
|
273
|
+
assert.deepEqual([after.enabled, after.telemetry], [true, true], "setFlag(enabled) 不碰采集");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("没有 config.json 时默认全关:不派生、不落遥测", async () => {
|
|
277
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-evo-default-"));
|
|
278
|
+
const evo = createEvolution({
|
|
279
|
+
cwd: dir,
|
|
280
|
+
promptTemplatePath: TEMPLATE,
|
|
281
|
+
queue: directQueue,
|
|
282
|
+
log: () => {},
|
|
283
|
+
now: () => NOW,
|
|
284
|
+
env: {},
|
|
285
|
+
spawnFn: (() => {
|
|
286
|
+
throw new Error("默认全关时不该起子进程");
|
|
287
|
+
}) as unknown as SpawnLike,
|
|
288
|
+
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
289
|
+
});
|
|
290
|
+
const status = await evo.statusText();
|
|
291
|
+
assert.equal(status.includes("采集:关(不写遥测)|复盘:关(不会派生 agent)"), true);
|
|
292
|
+
evo.recordAdvertised({ sessionId: "s1", cwd: dir, prompt: "x", skills: [skill("a")] });
|
|
293
|
+
evo.settle({ sessionId: "s1" });
|
|
294
|
+
await settleAsync();
|
|
295
|
+
assert.deepEqual(readTelemetry(dir), [], "默认关:遥测也不写");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("telemetry 关着时只记开关状态不落盘(on 之后才采集)", async () => {
|
|
299
|
+
const h = harness({}, { telemetry: false });
|
|
300
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
301
|
+
await settleAsync();
|
|
302
|
+
assert.deepEqual(h.events(), []);
|
|
303
|
+
await h.evo.setEnabled(true);
|
|
304
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "y", skills: [skill("a")] });
|
|
305
|
+
await settleAsync();
|
|
306
|
+
assert.equal(h.events().some((event) => event.kind === "advertised"), true, "on 之后同一会话就采集");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("去抖尾巴:3 轮 → 1 次派发 + 2 次 superseded,shutdown 再补发一次(覆盖后 2 轮)", async () => {
|
|
310
|
+
const h = harness();
|
|
311
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第1轮", skills: [skill("a")] });
|
|
312
|
+
h.evo.settle({ sessionId: "s1" });
|
|
313
|
+
await settleAsync();
|
|
314
|
+
assert.equal(h.spawns, 1);
|
|
315
|
+
await h.exitChild(0); // 第一轮的子进程跑完并放锁
|
|
316
|
+
|
|
317
|
+
for (const [prompt, name] of [["第2轮", "b"], ["第3轮", "c"]] as const) {
|
|
318
|
+
h.tick(1000);
|
|
319
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt, skills: [skill(name)] });
|
|
320
|
+
h.evo.settle({ sessionId: "s1" });
|
|
321
|
+
await settleAsync();
|
|
322
|
+
}
|
|
323
|
+
assert.equal(h.spawns, 1);
|
|
324
|
+
assert.equal(h.events().filter((event) => event.kind === "superseded").length, 2);
|
|
325
|
+
|
|
326
|
+
h.evo.shutdown();
|
|
327
|
+
assert.equal(h.spawns, 2);
|
|
328
|
+
const shutdownRun = readRuns(h.dir).find((record) => record.trigger === "shutdown");
|
|
329
|
+
assert.deepEqual([shutdownRun?.skills, (shutdownRun?.coverage as { rounds: number } | undefined)?.rounds], [["b", "c"], 2]);
|
|
330
|
+
assert.equal(h.events().some((event) => event.kind === "dispatched" && event.trigger === "shutdown"), true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("shutdown:锁仍被在跑的 run 占着时写 skipped,不起第二个进程", async () => {
|
|
334
|
+
const h = harness();
|
|
335
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第1轮", skills: [skill("a")] });
|
|
336
|
+
h.evo.settle({ sessionId: "s1" });
|
|
337
|
+
await settleAsync();
|
|
338
|
+
h.tick(1000);
|
|
339
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "第2轮", skills: [skill("a")] });
|
|
340
|
+
h.evo.settle({ sessionId: "s1" });
|
|
341
|
+
await settleAsync();
|
|
342
|
+
h.evo.shutdown(); // 锁仍被第一轮的 run 占着,补发必须让位
|
|
343
|
+
assert.equal(h.spawns, 1);
|
|
344
|
+
assert.equal(h.events().some((event) => event.kind === "skipped" && event.trigger === "shutdown"), true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("runNow:手动派发跳过去抖,锁被占时给明确文案", async () => {
|
|
348
|
+
const h = harness();
|
|
349
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
350
|
+
|
|
351
|
+
const first = await h.evo.runNow({ sessionId: "s1" });
|
|
352
|
+
assert.equal(first.includes("已派生复盘"), true);
|
|
353
|
+
assert.equal(first.includes(path.join(".pi", "evolution", "runs")), true);
|
|
354
|
+
assert.equal(h.spawns, 1);
|
|
355
|
+
|
|
356
|
+
// 子进程还在跑:第二次必须让位并说清是谁占着
|
|
357
|
+
const blocked = await h.evo.runNow({ sessionId: "s1" });
|
|
358
|
+
assert.equal(blocked.includes("已有复盘在跑(pid"), true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("runNow:总开关关着时提示先 on", async () => {
|
|
362
|
+
const h = harness();
|
|
363
|
+
await h.evo.setEnabled(false);
|
|
364
|
+
h.evo.recordAdvertised({ sessionId: "s1", cwd: h.dir, prompt: "x", skills: [skill("a")] });
|
|
365
|
+
const message = await h.evo.runNow({ sessionId: "s1" });
|
|
366
|
+
assert.equal(message.includes("/skill-evolve on"), true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function readRuns(dir: string): Record<string, unknown>[] {
|
|
370
|
+
const runsDir = path.join(dir, ".pi", "evolution", "runs");
|
|
371
|
+
let names: string[];
|
|
372
|
+
try {
|
|
373
|
+
names = fs.readdirSync(runsDir);
|
|
374
|
+
} catch {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
return names.map((name) => JSON.parse(fs.readFileSync(path.join(runsDir, name, "run.json"), "utf8")) as Record<string, unknown>);
|
|
378
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 评审子进程的假实现(只给测试用,文件名不匹配 *.test.ts 所以不会被当用例跑)。
|
|
3
|
+
*
|
|
4
|
+
* 真起 `pi` 一次要几十秒且要花额度;这里用 EventEmitter 复刻 runReviewChild 需要的
|
|
5
|
+
* 三个口子:stdout 收到文本、exit 结束、kill 可被调用。
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import type { ReviewChild, ReviewSpawn } from "../lib/review-run.ts";
|
|
9
|
+
import type { SupervisorSpawn } from "../lib/review-spawn.ts";
|
|
10
|
+
|
|
11
|
+
export class FakeReviewChild extends EventEmitter implements ReviewChild {
|
|
12
|
+
pid = 4242;
|
|
13
|
+
stdout = new EventEmitter();
|
|
14
|
+
killed = false;
|
|
15
|
+
kill(): void {
|
|
16
|
+
this.killed = true;
|
|
17
|
+
this.emit("exit", 0);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 每次 spawn 依次取出 replies 里的一条(取完循环用最后一条);1ms 后输出并退出 */
|
|
22
|
+
export function fakeReviewSpawn(replies: readonly string[], delayMs = 1): ReviewSpawn {
|
|
23
|
+
let index = 0;
|
|
24
|
+
return () => {
|
|
25
|
+
const child = new FakeReviewChild();
|
|
26
|
+
const reply = replies[Math.min(index, replies.length - 1)];
|
|
27
|
+
index += 1;
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
child.stdout.emit("data", reply);
|
|
30
|
+
child.emit("exit", 0);
|
|
31
|
+
}, delayMs);
|
|
32
|
+
return child;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 永不退出的子进程:用来验超时路径(配假时钟,不真等) */
|
|
37
|
+
export function hangingReviewSpawn(): ReviewSpawn {
|
|
38
|
+
return () => new FakeReviewChild();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 假时钟:sleep 推进 now,让 900s / 300s 超时在毫秒内跑完 */
|
|
42
|
+
export function fakeClock(): { now: () => number; sleep: (ms: number) => Promise<void>; elapsed: () => number } {
|
|
43
|
+
let t = 1_700_000_000_000;
|
|
44
|
+
const start = t;
|
|
45
|
+
return {
|
|
46
|
+
now: () => t,
|
|
47
|
+
sleep: async (ms: number) => {
|
|
48
|
+
t += ms;
|
|
49
|
+
},
|
|
50
|
+
elapsed: () => t - start,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 假督导 spawn:只记下参数,绝不真起 node 进程(真督导会轮询 900 秒) */
|
|
55
|
+
export function fakeSupervisorSpawn(calls: { args: string[]; options: Record<string, unknown> }[] = []): SupervisorSpawn {
|
|
56
|
+
return (_command, args, options) => {
|
|
57
|
+
calls.push({ args, options });
|
|
58
|
+
return { pid: 7777 };
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G3 机械硬禁令扫描单测(零依赖,node --test)。
|
|
3
|
+
*
|
|
4
|
+
* 运行:
|
|
5
|
+
* cd .pi/extensions/skill-evolve
|
|
6
|
+
* node --experimental-strip-types --test test/hard-rules.test.ts
|
|
7
|
+
*
|
|
8
|
+
* 覆盖:坏正则不许拖垮整条链路、行号是 1 基物理行号、allow 豁免生效、
|
|
9
|
+
* 以及仓库里那份 hard-rules.json 本身是可用的(6 条 P0、字段齐全)。
|
|
10
|
+
*/
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { compileRules, listSkillDirs, loadHardRules, scanSkillsDir, scanText } from "../lib/hard-rules.ts";
|
|
18
|
+
|
|
19
|
+
// 规则文件属于「工作区数据」而不是包里的一部分:源码仓在 .pi/evolution/rules/ 下有一份,
|
|
20
|
+
// npm 包里没有。所以路径从当前文件往上推三层(.../extensions/skill-evolve/test → 仓库根),
|
|
21
|
+
// 找不到就跳过 —— 不要写死某台机器的绝对路径(本地过、CI 挂就是死在这上面)。
|
|
22
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../..");
|
|
23
|
+
const RULES_FILE = process.env.SKILL_EVOLVE_RULES ?? path.join(REPO, ".pi/evolution/rules/hard-rules.json");
|
|
24
|
+
|
|
25
|
+
test("仓库自带的 hard-rules.json 能编译:6 条 P0、无坏规则、字段齐全", (t) => {
|
|
26
|
+
if (!fs.existsSync(RULES_FILE)) {
|
|
27
|
+
t.skip(`本机没有 ${RULES_FILE}(工作区数据,npm 包不附带)`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const loaded = loadHardRules(RULES_FILE);
|
|
31
|
+
assert.deepEqual(loaded.invalid, []);
|
|
32
|
+
assert.equal(loaded.compiled.length, 6);
|
|
33
|
+
for (const item of loaded.compiled) {
|
|
34
|
+
assert.equal(item.rule.level, "P0", `${item.rule.id} 必须是 P0`);
|
|
35
|
+
assert.ok(item.rule.expect.length > 0, `${item.rule.id} 缺 expect`);
|
|
36
|
+
assert.ok((item.rule.why ?? "").length > 0, `${item.rule.id} 缺 why`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("坏正则只丢掉那一条,其余规则照跑", () => {
|
|
41
|
+
const result = compileRules({
|
|
42
|
+
rules: [
|
|
43
|
+
{ id: "good", level: "P0", pattern: "违规", expect: "x", why: "y" },
|
|
44
|
+
{ id: "broken", level: "P0", pattern: "([", expect: "x", why: "y" },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
assert.equal(result.compiled.length, 1);
|
|
48
|
+
assert.equal(result.compiled[0].rule.id, "good");
|
|
49
|
+
assert.equal(result.invalid.length, 1);
|
|
50
|
+
assert.match(result.invalid[0], /^broken: 正则编译失败/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("缺 pattern 的规则记 invalid 且不抛", () => {
|
|
54
|
+
const result = compileRules({ rules: [{ id: "naked" }] });
|
|
55
|
+
assert.equal(result.compiled.length, 0);
|
|
56
|
+
assert.deepEqual(result.invalid, ["naked: 缺 pattern"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("扫描:行号是 1 基物理行号,quote 是当行原文", () => {
|
|
60
|
+
const rules = compileRules({ rules: [{ id: "r", level: "P0", pattern: "grep -r" }] }).compiled;
|
|
61
|
+
const text = ["# 标题", "", "正常一行", "用 grep -r 搜全仓"].join("\n");
|
|
62
|
+
const hits = scanText(text, rules, "demo");
|
|
63
|
+
assert.equal(hits.length, 1);
|
|
64
|
+
assert.equal(hits[0].line, 4);
|
|
65
|
+
assert.equal(hits[0].skill, "demo");
|
|
66
|
+
assert.equal(hits[0].ruleId, "r");
|
|
67
|
+
assert.equal(hits[0].level, "P0");
|
|
68
|
+
assert.equal(hits[0].quote, "用 grep -r 搜全仓");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("扫描:allow 命中时豁免(本仓技能大量写「禁止 grep -r」)", () => {
|
|
72
|
+
const rules = compileRules({
|
|
73
|
+
rules: [{ id: "r", level: "P0", pattern: "grep -r", allow: "禁止" }],
|
|
74
|
+
}).compiled;
|
|
75
|
+
const text = ["禁止 grep -r,必须用 rg", "用 grep -r 搜"].join("\n");
|
|
76
|
+
const hits = scanText(text, rules, "demo");
|
|
77
|
+
assert.equal(hits.length, 1);
|
|
78
|
+
assert.equal(hits[0].line, 2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("扫描:一条行同时命中两条规则时都给(不互斥)", () => {
|
|
82
|
+
const rules = compileRules({
|
|
83
|
+
rules: [
|
|
84
|
+
{ id: "first", level: "P0", pattern: "golden" },
|
|
85
|
+
{ id: "second", level: "P0", pattern: "允许" },
|
|
86
|
+
],
|
|
87
|
+
}).compiled;
|
|
88
|
+
const hits = scanText("允许写 golden 测试", rules, "demo");
|
|
89
|
+
assert.deepEqual(hits.map((hit) => hit.ruleId).sort(), ["first", "second"]);
|
|
90
|
+
assert.ok(hits.every((hit) => hit.line === 1));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("规则文件缺失 / JSON 坏都只返回 invalid", () => {
|
|
94
|
+
const missing = loadHardRules(path.join(os.tmpdir(), "不存在的规则文件.json"));
|
|
95
|
+
assert.equal(missing.compiled.length, 0);
|
|
96
|
+
assert.match(missing.invalid[0], /规则文件不存在/);
|
|
97
|
+
|
|
98
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hard-rules-"));
|
|
99
|
+
const broken = path.join(dir, "broken.json");
|
|
100
|
+
fs.writeFileSync(broken, "{ 坏 json", "utf8");
|
|
101
|
+
const parsed = loadHardRules(broken);
|
|
102
|
+
assert.equal(parsed.compiled.length, 0);
|
|
103
|
+
assert.match(parsed.invalid[0], /JSON 解析失败/);
|
|
104
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("scanSkillsDir:只扫 */SKILL.md,读不到的文件跳过不抛", () => {
|
|
108
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-"));
|
|
109
|
+
fs.mkdirSync(path.join(dir, "one"), { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(dir, "one/SKILL.md"), "# one\n允许写 golden 测试\n", "utf8");
|
|
111
|
+
fs.mkdirSync(path.join(dir, "README"), { recursive: true });
|
|
112
|
+
fs.mkdirSync(path.join(dir, "two"), { recursive: true });
|
|
113
|
+
fs.writeFileSync(path.join(dir, "two/notes.md"), "允许写 golden 测试\n", "utf8");
|
|
114
|
+
|
|
115
|
+
const rules = compileRules({ rules: [{ id: "golden", level: "P0", pattern: "允许.{0,6}golden" }] }).compiled;
|
|
116
|
+
const scan = scanSkillsDir(dir, rules);
|
|
117
|
+
assert.equal(scan.hits.length, 1);
|
|
118
|
+
assert.equal(scan.hits[0].skill, "one");
|
|
119
|
+
assert.equal(scan.hits[0].line, 2);
|
|
120
|
+
assert.deepEqual(scan.invalid, []);
|
|
121
|
+
assert.deepEqual(listSkillDirs(dir), ["README", "one", "two"]); // 码位排序,大写在前
|
|
122
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
});
|