minimal-agent 0.2.0 → 0.3.1

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.
Files changed (108) hide show
  1. package/README.md +54 -72
  2. package/package.json +18 -13
  3. package/plugins/ralph-wiggum/plugin.js +205 -0
  4. package/plugins/ralph-wiggum/src/goalState.js +260 -0
  5. package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
  6. package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
  7. package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
  8. package/plugins/workflow-runner/commands/workflow.md +13 -3
  9. package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
  10. package/plugins/workflow-runner/src/expressions.js +369 -0
  11. package/plugins/workflow-runner/src/index.js +216 -0
  12. package/plugins/workflow-runner/src/loader.js +183 -0
  13. package/plugins/workflow-runner/src/runner.js +290 -0
  14. package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
  15. package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
  16. package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
  17. package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
  18. package/plugins/workflow-runner/src/types.js +59 -0
  19. package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
  20. package/src/bootstrap/cwdArg.js +22 -0
  21. package/src/bootstrap/workingDir.js +31 -0
  22. package/src/cli/configWizard.js +272 -0
  23. package/src/cli/print.js +197 -0
  24. package/src/config/configFile.js +78 -0
  25. package/src/config.js +118 -0
  26. package/src/context/compact.js +357 -0
  27. package/src/context/microCompactLite.js +151 -0
  28. package/src/context/persistContext.js +109 -0
  29. package/src/context/reactiveCompact.js +121 -0
  30. package/src/context/sessionPath.js +58 -0
  31. package/src/context/snipCompact.js +112 -0
  32. package/src/context/tokenCounter.js +66 -0
  33. package/src/llm/client.js +182 -0
  34. package/src/loop.js +230 -0
  35. package/src/main.js +116 -0
  36. package/src/plugin-sdk.js +24 -0
  37. package/src/plugins/commandRouter.js +169 -0
  38. package/src/plugins/hookEngine.js +258 -0
  39. package/src/plugins/pluginApi.js +23 -0
  40. package/src/plugins/pluginLoader.js +71 -0
  41. package/src/plugins/pluginRunner.js +65 -0
  42. package/src/plugins/transcript.js +171 -0
  43. package/src/prompts/projectInstructions.js +48 -0
  44. package/src/prompts/skillList.js +126 -0
  45. package/src/prompts/system.js +155 -0
  46. package/src/session/runTurn.js +41 -0
  47. package/src/session/sessionState.js +19 -0
  48. package/src/tools/bash/bash.js +352 -0
  49. package/src/tools/bash/semantics.js +85 -0
  50. package/src/tools/bash/warnings.js +98 -0
  51. package/src/tools/edit/edit.js +253 -0
  52. package/src/tools/edit/multi-edit.js +155 -0
  53. package/src/tools/glob/glob.js +97 -0
  54. package/src/tools/grep/grep.js +185 -0
  55. package/src/tools/grep/rgPath.js +173 -0
  56. package/src/tools/index.js +94 -0
  57. package/src/tools/read/read.js +209 -0
  58. package/src/tools/shared/fileState.js +61 -0
  59. package/src/tools/shared/fileUtils.js +281 -0
  60. package/src/tools/shared/schemas.js +16 -0
  61. package/src/tools/types.js +21 -0
  62. package/src/tools/webbrowser/browser.js +55 -0
  63. package/src/tools/webbrowser/webbrowser.js +194 -0
  64. package/src/tools/webfetch/preapproved.js +267 -0
  65. package/src/tools/webfetch/webfetch.js +317 -0
  66. package/src/tools/websearch/websearch.js +161 -0
  67. package/src/tools/write/write.js +125 -0
  68. package/src/types/turndown.d.ts +23 -0
  69. package/src/types.js +16 -0
  70. package/src/ui/App.js +37 -0
  71. package/src/ui/InputBox.js +240 -0
  72. package/src/ui/MessageList.js +28 -0
  73. package/src/ui/Root.js +70 -0
  74. package/src/ui/StatusLine.js +41 -0
  75. package/src/ui/ToolStatus.js +11 -0
  76. package/src/ui/hooks/useChat.js +234 -0
  77. package/src/ui/hooks/usePasteHandler.js +137 -0
  78. package/src/ui/hooks/useTextBuffer.js +55 -0
  79. package/src/ui/hooks/useTokenUsage.js +30 -0
  80. package/src/ui/textBuffer.js +217 -0
  81. package/src/utils/packageRoot.js +37 -0
  82. package/src/utils/resourcePaths.js +49 -0
  83. package/src/utils/zodToJson.js +29 -0
  84. package/dist/main.js +0 -5315
  85. package/plugins/ralph-wiggum/plugin.ts +0 -275
  86. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
  87. package/plugins/ralph-wiggum/src/goalState.ts +0 -310
  88. package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
  89. package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
  90. package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
  91. package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
  92. package/plugins/workflow-runner/src/expressions.ts +0 -371
  93. package/plugins/workflow-runner/src/index.ts +0 -194
  94. package/plugins/workflow-runner/src/loader.ts +0 -193
  95. package/plugins/workflow-runner/src/runner.ts +0 -313
  96. package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
  97. package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
  98. package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
  99. package/plugins/workflow-runner/src/types.ts +0 -183
  100. package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
  101. package/plugins/workflow-runner/test/e2e.test.ts +0 -268
  102. package/plugins/workflow-runner/test/expressions.test.ts +0 -140
  103. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
  104. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
  105. package/plugins/workflow-runner/test/graceful.test.ts +0 -139
  106. package/plugins/workflow-runner/test/loader.test.ts +0 -216
  107. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
  108. package/plugins/workflow-runner/test/runner.test.ts +0 -511
@@ -1,310 +0,0 @@
1
- import { mkdir, appendFile, writeFile, unlink, rmdir } from 'node:fs/promises';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import type { Check } from './verificationGate.ts';
5
-
6
- export enum Phase {
7
- PLAN = 'plan',
8
- BUILD = 'build',
9
- VERIFY = 'verify',
10
- HEAL = 'heal',
11
- DONE = 'done',
12
- }
13
-
14
- const VALID_PHASES = new Set<string>(Object.values(Phase));
15
-
16
- interface DecisionEntry {
17
- iteration: number;
18
- phase: Phase;
19
- contextSummary: string;
20
- options: string[];
21
- chosen: string;
22
- reasoning: string;
23
- outcome?: string;
24
- }
25
-
26
- interface DecisionContext {
27
- iteration: number;
28
- phase: Phase;
29
- summary: string;
30
- }
31
-
32
- const PHASE_TRANSITIONS: Record<Phase, Record<string, Phase>> = {
33
- [Phase.PLAN]: {
34
- plan_complete: Phase.BUILD,
35
- stuck: Phase.PLAN,
36
- },
37
- [Phase.BUILD]: {
38
- task_complete: Phase.VERIFY,
39
- need_replan: Phase.PLAN,
40
- tests_failing: Phase.HEAL,
41
- },
42
- [Phase.VERIFY]: {
43
- all_pass: Phase.BUILD,
44
- failures: Phase.HEAL,
45
- goal_complete: Phase.DONE,
46
- },
47
- [Phase.HEAL]: {
48
- fixed: Phase.VERIFY,
49
- cannot_fix_locally: Phase.PLAN,
50
- },
51
- [Phase.DONE]: {},
52
- };
53
-
54
- function isLegalTransition(from: Phase, to: Phase): boolean {
55
- if (from === to) return true;
56
- const allowed = PHASE_TRANSITIONS[from];
57
- if (!allowed) return false;
58
- return Object.values(allowed).includes(to);
59
- }
60
-
61
- const LEARNINGS_TAIL_LINES = 20;
62
-
63
- export class GoalState {
64
- readonly dir: string;
65
-
66
- constructor(workspaceDir: string, sessionTag?: string) {
67
- const suffix = sessionTag ? `-${sessionTag}` : '';
68
- this.dir = join(workspaceDir, `.minimal-agent${suffix}`);
69
- }
70
-
71
- async reset(): Promise<void> {
72
- const files = ['goal.md', 'completion.md', 'phase.md', 'progress.md', 'learnings.md', 'decisions.md'];
73
- for (const f of files) {
74
- try {
75
- await unlink(join(this.dir, f));
76
- } catch {
77
- }
78
- }
79
- }
80
-
81
- async init(goal: string, criteria: Check[]): Promise<void> {
82
- await mkdir(this.dir, { recursive: true });
83
-
84
- const files: Record<string, string> = {
85
- goal: `# 不可变目标 (IMMUTABLE GOAL)\n\n${goal}\n`,
86
- completion: JSON.stringify(criteria, null, 2),
87
- phase: Phase.PLAN,
88
- progress: '',
89
- learnings: '',
90
- decisions: '',
91
- };
92
-
93
- for (const [name, content] of Object.entries(files)) {
94
- const path = join(this.dir, `${name}.md`);
95
- if (!existsSync(path)) {
96
- await writeFile(path, content);
97
- }
98
- }
99
- }
100
-
101
- get goal(): string {
102
- try {
103
- return readFileSync(join(this.dir, 'goal.md'), 'utf8').trim();
104
- } catch {
105
- return '';
106
- }
107
- }
108
-
109
- get completionCriteria(): Check[] {
110
- try {
111
- const raw = readFileSync(join(this.dir, 'completion.md'), 'utf8').trim();
112
- return JSON.parse(raw) as Check[];
113
- } catch {
114
- return [];
115
- }
116
- }
117
-
118
- get currentPhase(): Phase {
119
- try {
120
- const raw = readFileSync(join(this.dir, 'phase.md'), 'utf8').trim();
121
- if (VALID_PHASES.has(raw)) {
122
- return raw as Phase;
123
- }
124
- } catch {
125
- }
126
- return Phase.PLAN;
127
- }
128
-
129
- /**
130
- * 切换阶段。`reason` 是给人看的日志文本,不参与校验。
131
- * 校验只看 from→to 是否在 PHASE_TRANSITIONS 表里有路径(任意 event 通向 to 即合法)。
132
- * 想绕开 FSM 用 forceSetPhase。
133
- */
134
- async setPhase(phase: Phase, reason: string): Promise<void> {
135
- if (!VALID_PHASES.has(phase)) {
136
- throw new Error(`Invalid phase: ${phase}`);
137
- }
138
-
139
- const current = this.currentPhase;
140
- if (!isLegalTransition(current, phase)) {
141
- throw new Error(
142
- `非法阶段切换: ${current} → ${phase}。该目标阶段不在 PHASE_TRANSITIONS[${current}] 的可达集合内,需要走 forceSetPhase。`,
143
- );
144
- }
145
-
146
- await writeFile(join(this.dir, 'phase.md'), phase);
147
- await this.appendProgress(`PHASE → ${phase}: ${reason}`);
148
- }
149
-
150
- async forceSetPhase(phase: Phase, reason: string): Promise<void> {
151
- if (!VALID_PHASES.has(phase)) {
152
- throw new Error(`Invalid phase: ${phase}`);
153
- }
154
- await writeFile(join(this.dir, 'phase.md'), phase);
155
- await this.appendProgress(`PHASE → ${phase}: ${reason}`);
156
- }
157
-
158
- async appendProgress(line: string): Promise<void> {
159
- const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
160
- await appendFile(join(this.dir, 'progress.md'), `[${timestamp}] ${line}\n`);
161
- }
162
-
163
- tailProgress(n: number): string {
164
- try {
165
- const content = readFileSync(join(this.dir, 'progress.md'), 'utf8');
166
- const lines = content.trim().split('\n').filter(Boolean);
167
- return lines.slice(-n).join('\n');
168
- } catch {
169
- return '';
170
- }
171
- }
172
-
173
- async appendLearning(lesson: string): Promise<void> {
174
- await appendFile(join(this.dir, 'learnings.md'), `- ${lesson}\n`);
175
- }
176
-
177
- get learnings(): string {
178
- try {
179
- const raw = readFileSync(join(this.dir, 'learnings.md'), 'utf8').trim();
180
- if (!raw) return '';
181
- const lines = raw.split('\n').filter(Boolean);
182
- return lines.slice(-LEARNINGS_TAIL_LINES).join('\n');
183
- } catch {
184
- return '';
185
- }
186
- }
187
-
188
- async recordDecision(
189
- ctx: DecisionContext,
190
- options: string[],
191
- chosen: string,
192
- reasoning: string,
193
- ): Promise<void> {
194
- const entry: DecisionEntry = {
195
- iteration: ctx.iteration,
196
- phase: ctx.phase,
197
- contextSummary: ctx.summary,
198
- options,
199
- chosen,
200
- reasoning,
201
- };
202
-
203
- const line = JSON.stringify(entry);
204
- await appendFile(join(this.dir, 'decisions.md'), `${line}\n`);
205
- }
206
-
207
- findSimilarDecisions(ctx: DecisionContext, k: number = 3): DecisionEntry[] {
208
- try {
209
- const content = readFileSync(join(this.dir, 'decisions.md'), 'utf8');
210
- const lines = content.trim().split('\n').filter(Boolean);
211
-
212
- const entries: DecisionEntry[] = [];
213
- for (const line of lines) {
214
- try {
215
- entries.push(JSON.parse(line));
216
- } catch {
217
- }
218
- }
219
-
220
- const scored = entries
221
- .map((entry) => ({
222
- entry,
223
- score: this._similarity(entry.contextSummary, ctx.summary),
224
- }))
225
- .sort((a, b) => b.score - a.score);
226
-
227
- return scored.slice(0, k).filter((s) => s.score > 0.3).map((s) => s.entry);
228
- } catch {
229
- return [];
230
- }
231
- }
232
-
233
- summarizeDecisions(maxEntries: number = 5): string {
234
- try {
235
- const content = readFileSync(join(this.dir, 'decisions.md'), 'utf8');
236
- const lines = content.trim().split('\n').filter(Boolean);
237
-
238
- const entries: DecisionEntry[] = [];
239
- for (const line of lines.slice(-maxEntries * 2)) {
240
- try {
241
- entries.push(JSON.parse(line));
242
- } catch {
243
- }
244
- }
245
-
246
- return entries
247
- .slice(-maxEntries)
248
- .map(
249
- (e) =>
250
- `迭代 ${e.iteration}(${e.phase}): 在 [${e.options.join(', ')}] 中选择了 **${e.chosen}**,原因:${e.reasoning.slice(0, 80)}`,
251
- )
252
- .join('\n');
253
- } catch {
254
- return '(无决策记录)';
255
- }
256
- }
257
-
258
- composeContext(iteration: number): string {
259
- const sections: string[] = [];
260
-
261
- sections.push(`# 不可变目标 (IMMUTABLE GOAL)\n${this.goal}\n⚠️ 注意:你不能修改或扩大上述目标。如果你认为目标本身有问题,请输出 <PROMISE>NEED_GOAL_REVISION</PROMISE> 并停止。`);
262
-
263
- sections.push(`# 当前阶段: ${this.currentPhase.toUpperCase()}`);
264
-
265
- const lrn = this.learnings;
266
- if (lrn) {
267
- sections.push(`# 关键教训(必须遵守,避免重复踩坑)\n${lrn}`);
268
- }
269
-
270
- const decisions = this.summarizeDecisions(3);
271
- if (decisions !== '(无决策记录)') {
272
- sections.push(`# 历史关键决策(请参考,避免重复试错)\n${decisions}`);
273
- }
274
-
275
- const recentProgress = this.tailProgress(10);
276
- if (recentProgress) {
277
- sections.push(`# 最近进度\n${recentProgress}`);
278
- }
279
-
280
- sections.push(`---\n\n# 本轮任务 (迭代 ${iteration})`);
281
-
282
- return sections.join('\n\n---\n\n');
283
- }
284
-
285
- async cleanup(): Promise<void> {
286
- const files = ['goal.md', 'completion.md', 'phase.md', 'progress.md', 'learnings.md', 'decisions.md'];
287
- for (const f of files) {
288
- try {
289
- await unlink(join(this.dir, f));
290
- } catch {
291
- }
292
- }
293
- try {
294
- await rmdir(this.dir);
295
- } catch {
296
- }
297
- }
298
-
299
- private _similarity(a: string, b: string): number {
300
- if (!a || !b) return 0;
301
- const wordsA = new Set(a.toLowerCase().split(/\s+/));
302
- const wordsB = new Set(b.toLowerCase().split(/\s+/));
303
- let intersection = 0;
304
- for (const word of wordsA) {
305
- if (wordsB.has(word)) intersection++;
306
- }
307
- const union = new Set([...wordsA, ...wordsB]).size;
308
- return union > 0 ? intersection / union : 0;
309
- }
310
- }
@@ -1,136 +0,0 @@
1
- /**
2
- * ============================================================
3
- * plugins/ralph-wiggum/src/stopHookRunner.ts
4
- * ------------------------------------------------------------
5
- * ralph-wiggum 私用的 stop-hook 执行器。读 hooks/hooks.json 里 Stop 事件
6
- * 的命令清单,stdin 传 transcript 文本,stdout 收 JSON 决策。
7
- *
8
- * 在 minimal-agent 里 stop-hook 是"咨询式"信号:
9
- * - 返回 block → driver 把 reason 注入下一轮 prompt
10
- * - pass / 错误 / Windows 缺 bash → 不影响循环继续
11
- *
12
- * 与框架级 hookEngine 的关系:hookEngine 服务于 UserPromptSubmit /
13
- * PreToolUse / PostToolUse 等框架事件;ralph 的 Stop 是插件领域内
14
- * 特有的循环-反馈机制,故而独立执行,不污染框架 hookTable。
15
- * ============================================================
16
- */
17
-
18
- import { readFile } from 'node:fs/promises';
19
- import { join } from 'node:path';
20
- import { spawn } from 'node:child_process';
21
-
22
- export interface StopHookResult {
23
- decision: 'block' | 'pass';
24
- reason?: string;
25
- systemMessage?: string;
26
- }
27
-
28
- interface HookConfig {
29
- type: string;
30
- command: string;
31
- }
32
-
33
- async function loadStopHookConfig(pluginRoot: string): Promise<HookConfig[]> {
34
- const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
35
-
36
- try {
37
- const raw = await readFile(hooksJsonPath, 'utf8');
38
- const parsed = JSON.parse(raw);
39
- const stopEntries = parsed?.hooks?.Stop;
40
- if (!Array.isArray(stopEntries)) return [];
41
-
42
- const commands: HookConfig[] = [];
43
- for (const entry of stopEntries) {
44
- const hooks = entry.hooks;
45
- if (Array.isArray(hooks)) {
46
- for (const h of hooks) {
47
- if (h.type === 'command' && h.command) {
48
- commands.push(h);
49
- }
50
- }
51
- }
52
- }
53
- return commands;
54
- } catch {
55
- return [];
56
- }
57
- }
58
-
59
- export async function executeStopHook(
60
- pluginRoot: string,
61
- transcriptText: string,
62
- ): Promise<StopHookResult> {
63
- // Windows 上 bash 通常缺失,hook 链路降级为 pass(咨询式信号不影响循环)
64
- if (process.platform === 'win32') {
65
- return { decision: 'pass' };
66
- }
67
-
68
- const hookConfigs = await loadStopHookConfig(pluginRoot);
69
- for (const hookConfig of hookConfigs) {
70
- const result = await runSingleStopHook(hookConfig, pluginRoot, transcriptText);
71
- if (result.decision === 'block') {
72
- return result;
73
- }
74
- }
75
- return { decision: 'pass' };
76
- }
77
-
78
- function runSingleStopHook(
79
- hookConfig: HookConfig,
80
- pluginRoot: string,
81
- transcriptText: string,
82
- ): Promise<StopHookResult> {
83
- const resolvedCommand = hookConfig.command.replaceAll(
84
- '${CLAUDE_PLUGIN_ROOT}',
85
- pluginRoot,
86
- );
87
-
88
- return new Promise((resolve) => {
89
- const child = spawn('bash', [resolvedCommand], {
90
- env: {
91
- ...process.env,
92
- CLAUDE_PLUGIN_ROOT: pluginRoot,
93
- },
94
- });
95
-
96
- let stdout = '';
97
-
98
- child.stdout.on('data', (data: Buffer) => {
99
- stdout += data.toString();
100
- });
101
-
102
- child.on('error', () => {
103
- resolve({ decision: 'pass' });
104
- });
105
-
106
- child.on('close', (code) => {
107
- if (code !== 0) {
108
- resolve({ decision: 'pass' });
109
- return;
110
- }
111
- const trimmed = stdout.trim();
112
- if (!trimmed) {
113
- resolve({ decision: 'pass' });
114
- return;
115
- }
116
- try {
117
- const parsed = JSON.parse(trimmed);
118
- if (parsed.decision === 'block') {
119
- resolve({
120
- decision: 'block',
121
- reason: typeof parsed.reason === 'string' ? parsed.reason : undefined,
122
- systemMessage:
123
- typeof parsed.systemMessage === 'string' ? parsed.systemMessage : undefined,
124
- });
125
- return;
126
- }
127
- resolve({ decision: 'pass' });
128
- } catch {
129
- resolve({ decision: 'pass' });
130
- }
131
- });
132
-
133
- child.stdin.write(transcriptText);
134
- child.stdin.end();
135
- });
136
- }
@@ -1,252 +0,0 @@
1
- /**
2
- * ============================================================
3
- * src/plugins/verificationGate.ts —— ralph-loop 的"完成验证门"
4
- * ------------------------------------------------------------
5
- * Agent 输出 <promise>DONE</promise> 哨兵后,pluginRunner 跑这里的
6
- * checks。全部通过才真正退出循环;任一未通过则回 BUILD 继续干。
7
- *
8
- * 支持四种 check:
9
- * - shell:<cmd> 跨平台 spawn(Windows 走 cmd /c, 其余 bash -c)
10
- * - file_exists:<path> fs.existsSync
11
- * - file_contains:<file>:<re> 正则匹配文件内容
12
- * - test_count:<min> 跑 `bun test`,从 stdout 解析 "N pass"
13
- *
14
- * 工作目录隔离:所有相对路径以 getWorkingDir() 为锚,子进程 cwd 也用它,
15
- * 确保并行 session 不会互相串台。
16
- * ============================================================
17
- */
18
-
19
- import { existsSync, readFileSync } from 'node:fs';
20
- import { spawn } from 'node:child_process';
21
-
22
- export interface Check {
23
- type: 'shell' | 'file_contains' | 'file_exists' | 'test_count';
24
- command?: string;
25
- file?: string;
26
- pattern?: string;
27
- minCount?: number;
28
- timeout?: number;
29
- }
30
-
31
- export interface CheckResult {
32
- check: Check;
33
- passed: boolean;
34
- output: string;
35
- }
36
-
37
- export interface VerifyResult {
38
- passed: boolean;
39
- details: CheckResult[];
40
- summary: string;
41
- }
42
-
43
- function parseVerifyArg(arg: string): Check | null {
44
- const colonIdx = arg.indexOf(':');
45
- if (colonIdx < 0) return null;
46
-
47
- const type = arg.slice(0, colonIdx).trim().toLowerCase();
48
- const value = arg.slice(colonIdx + 1).trim();
49
-
50
- switch (type) {
51
- case 'shell':
52
- return { type: 'shell', command: value, timeout: 30_000 };
53
- case 'file_exists':
54
- return { type: 'file_exists', file: value };
55
- case 'file_contains': {
56
- const sep = value.indexOf(':');
57
- if (sep < 0) return null;
58
- return {
59
- type: 'file_contains',
60
- file: value.slice(0, sep),
61
- pattern: value.slice(sep + 1),
62
- };
63
- }
64
- case 'test_count': {
65
- const count = parseInt(value, 10);
66
- if (isNaN(count) || count < 0) return null;
67
- return { type: 'test_count', minCount: count };
68
- }
69
- default:
70
- return null;
71
- }
72
- }
73
-
74
- export function parseVerifyArgs(args: string): Check[] {
75
- const checks: Check[] = [];
76
- const regex = /--verify\s+("[^"]*"|\S+)/gi;
77
- let match;
78
-
79
- while ((match = regex.exec(args)) !== null) {
80
- const raw = match[1].replace(/^"|"$/g, '');
81
- const check = parseVerifyArg(raw);
82
- if (check) checks.push(check);
83
- }
84
-
85
- return checks;
86
- }
87
-
88
- interface ShellRunResult {
89
- exitCode: number | null;
90
- stdout: string;
91
- stderr: string;
92
- errored: boolean;
93
- }
94
-
95
- function runShell(command: string, timeout: number): Promise<ShellRunResult> {
96
- return new Promise((resolve) => {
97
- const isWin = process.platform === 'win32';
98
- // 不显式传 cwd —— 继承 process.cwd(),main.tsx 已 chdir 到 workingDir,
99
- // 而测试里直接用 process.cwd(),避免依赖 mock 的 getWorkingDir 造成串台
100
- const child = isWin
101
- ? spawn('cmd', ['/c', command], { timeout, env: process.env })
102
- : spawn('bash', ['-c', command], { timeout, env: process.env });
103
-
104
- let stdout = '';
105
- let stderr = '';
106
-
107
- child.stdout.on('data', (d: Buffer) => {
108
- stdout += d.toString();
109
- });
110
- child.stderr.on('data', (d: Buffer) => {
111
- stderr += d.toString();
112
- });
113
-
114
- child.on('error', () => {
115
- resolve({ exitCode: null, stdout, stderr, errored: true });
116
- });
117
-
118
- child.on('close', (code) => {
119
- resolve({ exitCode: code, stdout, stderr, errored: false });
120
- });
121
- });
122
- }
123
-
124
- export async function verifyShell(command: string, timeout: number): Promise<CheckResult> {
125
- const r = await runShell(command, timeout);
126
- if (r.errored) {
127
- return {
128
- check: { type: 'shell', command },
129
- passed: false,
130
- output: `执行失败`,
131
- };
132
- }
133
- return {
134
- check: { type: 'shell', command },
135
- passed: r.exitCode === 0,
136
- output: r.stdout.trim() || r.stderr.trim() || `exit code ${r.exitCode}`,
137
- };
138
- }
139
-
140
- export function verifyFileExists(file: string): CheckResult {
141
- const exists = existsSync(file);
142
- return {
143
- check: { type: 'file_exists', file },
144
- passed: exists,
145
- output: exists ? '文件存在' : `文件不存在: ${file}`,
146
- };
147
- }
148
-
149
- export function verifyFileContains(file: string, pattern: string): CheckResult {
150
- try {
151
- const content = readFileSync(file, 'utf8');
152
- const regex = new RegExp(pattern);
153
- const matched = regex.test(content);
154
-
155
- return {
156
- check: { type: 'file_contains', file, pattern },
157
- passed: matched,
158
- output: matched ? `文件包含 "${pattern}"` : `文件不包含 "${pattern}"`,
159
- };
160
- } catch {
161
- return {
162
- check: { type: 'file_contains', file, pattern },
163
- passed: false,
164
- output: `无法读取文件: ${file}`,
165
- };
166
- }
167
- }
168
-
169
- async function verifyTestCount(minCount: number): Promise<CheckResult> {
170
- const r = await runShell(`bun test`, 60_000);
171
- // bun test 把进度打在 stderr,最终统计也常在 stderr,所以合并解析
172
- const combined = `${r.stdout}\n${r.stderr}`;
173
- if (r.errored) {
174
- return {
175
- check: { type: 'test_count', minCount },
176
- passed: false,
177
- output: '无法执行 bun test',
178
- };
179
- }
180
-
181
- const matches = [...combined.matchAll(/(\d+)\s+pass/gi)];
182
- const count = matches.length > 0 ? parseInt(matches[matches.length - 1][1], 10) : 0;
183
- const passed = count >= minCount;
184
-
185
- return {
186
- check: { type: 'test_count', minCount },
187
- passed,
188
- output: `${count} pass (需要 >=${minCount})`,
189
- };
190
- }
191
-
192
- export async function runVerification(checks: Check[]): Promise<VerifyResult> {
193
- if (checks.length === 0) {
194
- return { passed: true, details: [], summary: '无验证项' };
195
- }
196
-
197
- const results: CheckResult[] = [];
198
-
199
- for (const check of checks) {
200
- let result: CheckResult;
201
-
202
- switch (check.type) {
203
- case 'shell':
204
- result = await verifyShell(check.command ?? '', check.timeout ?? 30_000);
205
- break;
206
- case 'file_exists':
207
- result = verifyFileExists(check.file ?? '');
208
- break;
209
- case 'file_contains':
210
- result = verifyFileContains(check.file ?? '', check.pattern ?? '');
211
- break;
212
- case 'test_count':
213
- result = await verifyTestCount(check.minCount ?? 0);
214
- break;
215
- default:
216
- result = {
217
- check,
218
- passed: false,
219
- output: `未知验证类型: ${(check as { type: string }).type}`,
220
- };
221
- }
222
-
223
- results.push(result);
224
- }
225
-
226
- const allPassed = results.every((r) => r.passed);
227
- const failedNames = results.filter((r) => !r.passed).map((r) => formatCheckName(r.check));
228
-
229
- let summary: string;
230
- if (allPassed) {
231
- summary = `✅ 全部通过 (${results.length}/${results.length})`;
232
- } else {
233
- summary = `❌ 验证未通过: ${failedNames.join(', ')}`;
234
- }
235
-
236
- return { passed: allPassed, details: results, summary };
237
- }
238
-
239
- function formatCheckName(check: Check): string {
240
- switch (check.type) {
241
- case 'shell':
242
- return `shell(${(check.command ?? '').slice(0, 40)})`;
243
- case 'file_exists':
244
- return `exists(${check.file})`;
245
- case 'file_contains':
246
- return `contains(${check.file}:${check.pattern})`;
247
- case 'test_count':
248
- return `tests(>=${check.minCount})`;
249
- default:
250
- return check.type;
251
- }
252
- }