@winspan/claude-forge 8.51.0 → 8.53.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/CLAUDE.md +6 -6
- package/dist/cli/commands/skills.d.ts.map +1 -1
- package/dist/cli/commands/skills.js +115 -0
- package/dist/cli/commands/skills.js.map +1 -1
- package/dist/core/constants.d.ts +2 -0
- package/dist/core/constants.d.ts.map +1 -1
- package/dist/core/constants.js +4 -0
- package/dist/core/constants.js.map +1 -1
- package/dist/daemon/hook-sync.d.ts +17 -0
- package/dist/daemon/hook-sync.d.ts.map +1 -0
- package/dist/daemon/hook-sync.js +74 -0
- package/dist/daemon/hook-sync.js.map +1 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +21 -1
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/skill-sync.d.ts +21 -0
- package/dist/daemon/skill-sync.d.ts.map +1 -0
- package/dist/daemon/skill-sync.js +75 -0
- package/dist/daemon/skill-sync.js.map +1 -0
- package/dist/hooks/notification.sh +1 -1
- package/dist/hooks/post-tool-use.sh +1 -1
- package/dist/hooks/pre-tool-use.sh +1 -1
- package/dist/hooks/stop.sh +1 -1
- package/dist/hooks/user-prompt-submit.sh +1 -1
- package/dist/skills/official/code-simplifier.md +37 -1
- package/dist/skills/official/find-skills.md +120 -1
- package/dist/skills/official/official-api-design.md +14 -1
- package/dist/skills/official/official-architecture-decision.md +22 -1
- package/dist/skills/official/official-db-schema-design.md +19 -1
- package/dist/skills/official/official-debug.md +9 -1
- package/dist/skills/official/official-pr-review.md +1 -1
- package/dist/skills/official/official-security-hardening.md +7 -1
- package/dist/skills/official/planning-with-files.md +206 -2
- package/dist/skills/official/ui-ux-pro-max.md +88 -1
- package/dist/skills/official/webapp-testing.md +85 -1
- package/dist/skills/registry.d.ts +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +2 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/skills/semantic-matcher.d.ts +2 -1
- package/dist/skills/semantic-matcher.d.ts.map +1 -1
- package/dist/skills/semantic-matcher.js +6 -3
- package/dist/skills/semantic-matcher.js.map +1 -1
- package/dist/skills/upgrade-engine.d.ts +91 -0
- package/dist/skills/upgrade-engine.d.ts.map +1 -0
- package/dist/skills/upgrade-engine.js +436 -0
- package/dist/skills/upgrade-engine.js.map +1 -0
- package/dist/skills/upgrade-prompt.d.ts +20 -0
- package/dist/skills/upgrade-prompt.d.ts.map +1 -0
- package/dist/skills/upgrade-prompt.js +75 -0
- package/dist/skills/upgrade-prompt.js.map +1 -0
- package/docs/design/skill-ai-upgrade-spec-20260518-1930.md +297 -0
- package/docs/implementation/daemon-skill-sync-changelog-20260518-2000.md +22 -0
- package/docs/implementation/skill-ai-upgrade-changelog-20260518-1930.md +49 -0
- package/package.json +1 -1
- package/src/cli/commands/skills.ts +143 -0
- package/src/core/constants.ts +5 -0
- package/src/daemon/hook-sync.ts +91 -0
- package/src/daemon/index.ts +21 -1
- package/src/daemon/skill-sync.ts +88 -0
- package/src/hooks/notification.sh +1 -1
- package/src/hooks/post-tool-use.sh +1 -1
- package/src/hooks/pre-tool-use.sh +1 -1
- package/src/hooks/stop.sh +1 -1
- package/src/hooks/user-prompt-submit.sh +1 -1
- package/src/skills/official/code-simplifier.md +37 -1
- package/src/skills/official/find-skills.md +120 -1
- package/src/skills/official/official-api-design.md +14 -1
- package/src/skills/official/official-architecture-decision.md +22 -1
- package/src/skills/official/official-db-schema-design.md +19 -1
- package/src/skills/official/official-debug.md +9 -1
- package/src/skills/official/official-pr-review.md +1 -1
- package/src/skills/official/official-security-hardening.md +7 -1
- package/src/skills/official/planning-with-files.md +206 -2
- package/src/skills/official/ui-ux-pro-max.md +88 -1
- package/src/skills/official/webapp-testing.md +85 -1
- package/src/skills/registry.ts +2 -2
- package/src/skills/semantic-matcher.ts +6 -3
- package/src/skills/upgrade-engine.ts +541 -0
- package/src/skills/upgrade-prompt.ts +84 -0
- package/tests/unit/daemon/hook-sync.test.ts +71 -0
- package/tests/unit/daemon/skill-sync.test.ts +75 -0
- package/tests/unit/skills/upgrade-engine-parse.test.ts +138 -0
- package/tests/unit/skills/upgrade-engine.test.ts +401 -0
- package/tests/unit/skills/upgrade-prompt.test.ts +89 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { syncSkills } from '../../../src/daemon/skill-sync.js';
|
|
6
|
+
|
|
7
|
+
describe('syncSkills', () => {
|
|
8
|
+
let tmpRoot: string;
|
|
9
|
+
let sourceDir: string;
|
|
10
|
+
let targetDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'forge-skill-sync-'));
|
|
14
|
+
sourceDir = join(tmpRoot, 'src-skills');
|
|
15
|
+
targetDir = join(tmpRoot, 'target-skills');
|
|
16
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('source dir not found → returns zero counts, does not throw', () => {
|
|
24
|
+
const result = syncSkills({ sourceDir: join(tmpRoot, 'nonexistent'), targetDir });
|
|
25
|
+
expect(result.copied).toBe(0);
|
|
26
|
+
expect(result.checked).toBe(0);
|
|
27
|
+
expect(result.skipped_userOwned).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('source and target identical → copied=0, checked=1', () => {
|
|
31
|
+
const content = '# official-debug skill\nsome content\n';
|
|
32
|
+
writeFileSync(join(sourceDir, 'official-debug.md'), content);
|
|
33
|
+
mkdirSync(targetDir, { recursive: true });
|
|
34
|
+
writeFileSync(join(targetDir, 'official-debug.md'), content);
|
|
35
|
+
|
|
36
|
+
const result = syncSkills({ sourceDir, targetDir });
|
|
37
|
+
expect(result.copied).toBe(0);
|
|
38
|
+
expect(result.checked).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('source and target differ → copied=1, target updated', () => {
|
|
42
|
+
writeFileSync(join(sourceDir, 'official-debug.md'), '# new content\n');
|
|
43
|
+
mkdirSync(targetDir, { recursive: true });
|
|
44
|
+
writeFileSync(join(targetDir, 'official-debug.md'), '# old content\n');
|
|
45
|
+
|
|
46
|
+
const result = syncSkills({ sourceDir, targetDir });
|
|
47
|
+
expect(result.copied).toBe(1);
|
|
48
|
+
expect(result.checked).toBe(1);
|
|
49
|
+
expect(readFileSync(join(targetDir, 'official-debug.md'), 'utf-8')).toContain('new content');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('target missing file → copies it from source', () => {
|
|
53
|
+
writeFileSync(join(sourceDir, 'official-debug.md'), '# official-debug\n');
|
|
54
|
+
mkdirSync(targetDir, { recursive: true });
|
|
55
|
+
// target has no official-debug.md
|
|
56
|
+
|
|
57
|
+
const result = syncSkills({ sourceDir, targetDir });
|
|
58
|
+
expect(result.copied).toBe(1);
|
|
59
|
+
expect(existsSync(join(targetDir, 'official-debug.md'))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('user-only skill in target (not in source) → untouched', () => {
|
|
63
|
+
// source has one official skill
|
|
64
|
+
writeFileSync(join(sourceDir, 'official-debug.md'), '# official-debug\n');
|
|
65
|
+
mkdirSync(targetDir, { recursive: true });
|
|
66
|
+
// target also has a user-custom skill not present in source
|
|
67
|
+
writeFileSync(join(targetDir, 'my-custom-skill.md'), '# custom\n');
|
|
68
|
+
|
|
69
|
+
syncSkills({ sourceDir, targetDir });
|
|
70
|
+
|
|
71
|
+
// user skill must still exist and be unchanged
|
|
72
|
+
expect(existsSync(join(targetDir, 'my-custom-skill.md'))).toBe(true);
|
|
73
|
+
expect(readFileSync(join(targetDir, 'my-custom-skill.md'), 'utf-8')).toContain('custom');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AI response parsing in upgrade-engine.ts (evaluateWithAI edge cases).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
6
|
+
import { evaluateWithAI } from '../../../src/skills/upgrade-engine.js';
|
|
7
|
+
import type { CandidateSkill } from '../../../src/skills/upgrade-engine.js';
|
|
8
|
+
import type { OfficialSkill } from '../../../src/skills/official-skills.js';
|
|
9
|
+
import type { ClaudeProvider } from '../../../src/core/ai/provider.js';
|
|
10
|
+
|
|
11
|
+
const candidate: CandidateSkill = {
|
|
12
|
+
id: 'test-skill',
|
|
13
|
+
source: 'agent-skills',
|
|
14
|
+
filePath: '/tmp/test/SKILL.md',
|
|
15
|
+
name: 'test-skill',
|
|
16
|
+
description: 'A test skill',
|
|
17
|
+
keywords: ['test'],
|
|
18
|
+
content: '# Test Skill',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const official: OfficialSkill = {
|
|
22
|
+
name: 'official-test',
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
description: 'Official test skill',
|
|
25
|
+
keywords: ['test'],
|
|
26
|
+
content: '# Official Test Skill',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function makeProvider(value: string): ClaudeProvider {
|
|
30
|
+
return {
|
|
31
|
+
complete: vi.fn().mockResolvedValue(value),
|
|
32
|
+
} as unknown as ClaudeProvider;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('evaluateWithAI — response parsing', () => {
|
|
36
|
+
it('non-JSON text → needs_review=true, action=skip', async () => {
|
|
37
|
+
const provider = makeProvider('Sorry, I cannot evaluate this.');
|
|
38
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
39
|
+
expect(result.needs_review).toBe(true);
|
|
40
|
+
expect(result.action).toBe('skip');
|
|
41
|
+
expect(result.confidence).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('partial JSON (truncated) → needs_review=true', async () => {
|
|
45
|
+
const provider = makeProvider('{"action":"upgrade","confidence":80,');
|
|
46
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
47
|
+
expect(result.needs_review).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('valid JSON with action=upgrade → parsed correctly', async () => {
|
|
51
|
+
const json = JSON.stringify({
|
|
52
|
+
action: 'upgrade',
|
|
53
|
+
confidence: 90,
|
|
54
|
+
reasoning: '候选质量更高',
|
|
55
|
+
merged_content: null,
|
|
56
|
+
});
|
|
57
|
+
const provider = makeProvider(json);
|
|
58
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
59
|
+
expect(result.action).toBe('upgrade');
|
|
60
|
+
expect(result.confidence).toBe(90);
|
|
61
|
+
expect(result.reasoning).toBe('候选质量更高');
|
|
62
|
+
expect(result.needs_review).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('valid JSON with action=merge and merged_content → parsed correctly', async () => {
|
|
66
|
+
const mergedMd = '---\nname: merged\n---\n# Merged';
|
|
67
|
+
const json = JSON.stringify({
|
|
68
|
+
action: 'merge',
|
|
69
|
+
confidence: 75,
|
|
70
|
+
reasoning: '各有特色章节',
|
|
71
|
+
merged_content: mergedMd,
|
|
72
|
+
});
|
|
73
|
+
const provider = makeProvider(json);
|
|
74
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
75
|
+
expect(result.action).toBe('merge');
|
|
76
|
+
expect(result.merged_content).toBe(mergedMd);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('JSON with unknown action → defaults to skip', async () => {
|
|
80
|
+
const json = JSON.stringify({
|
|
81
|
+
action: 'replace', // invalid
|
|
82
|
+
confidence: 60,
|
|
83
|
+
reasoning: 'bad action',
|
|
84
|
+
merged_content: null,
|
|
85
|
+
});
|
|
86
|
+
const provider = makeProvider(json);
|
|
87
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
88
|
+
expect(result.action).toBe('skip');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('JSON missing confidence → defaults to 0', async () => {
|
|
92
|
+
const json = JSON.stringify({ action: 'skip', reasoning: 'ok', merged_content: null });
|
|
93
|
+
const provider = makeProvider(json);
|
|
94
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
95
|
+
expect(result.confidence).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('response wrapped in markdown code block → parsed correctly', async () => {
|
|
99
|
+
const json = JSON.stringify({
|
|
100
|
+
action: 'skip',
|
|
101
|
+
confidence: 40,
|
|
102
|
+
reasoning: 'wrapped',
|
|
103
|
+
merged_content: null,
|
|
104
|
+
});
|
|
105
|
+
const provider = makeProvider('```json\n' + json + '\n```');
|
|
106
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
107
|
+
expect(result.action).toBe('skip');
|
|
108
|
+
expect(result.needs_review).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('response wrapped in plain code block → parsed correctly', async () => {
|
|
112
|
+
const json = JSON.stringify({
|
|
113
|
+
action: 'merge',
|
|
114
|
+
confidence: 65,
|
|
115
|
+
reasoning: 'wrapped plain',
|
|
116
|
+
merged_content: null,
|
|
117
|
+
});
|
|
118
|
+
const provider = makeProvider('```\n' + json + '\n```');
|
|
119
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
120
|
+
expect(result.action).toBe('merge');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('API error → needs_review=true with error message in reasoning', async () => {
|
|
124
|
+
const provider = {
|
|
125
|
+
complete: vi.fn().mockRejectedValue(new Error('Network timeout')),
|
|
126
|
+
} as unknown as ClaudeProvider;
|
|
127
|
+
|
|
128
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
129
|
+
expect(result.needs_review).toBe(true);
|
|
130
|
+
expect(result.reasoning).toContain('Network timeout');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('empty string response → needs_review=true', async () => {
|
|
134
|
+
const provider = makeProvider('');
|
|
135
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
136
|
+
expect(result.needs_review).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for upgrade-engine.ts — matchToOfficial, evaluateWithAI,
|
|
3
|
+
* generateReport, applyDecisions, and pullCandidates (partial).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import {
|
|
11
|
+
matchToOfficial,
|
|
12
|
+
evaluateWithAI,
|
|
13
|
+
generateReport,
|
|
14
|
+
applyDecisions,
|
|
15
|
+
} from '../../../src/skills/upgrade-engine.js';
|
|
16
|
+
import type { CandidateSkill, ReportEntry } from '../../../src/skills/upgrade-engine.js';
|
|
17
|
+
import type { OfficialSkill } from '../../../src/skills/official-skills.js';
|
|
18
|
+
import type { ClaudeProvider } from '../../../src/core/ai/provider.js';
|
|
19
|
+
|
|
20
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const officialSkills: OfficialSkill[] = [
|
|
23
|
+
{
|
|
24
|
+
name: 'official-db-schema-design',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
description: '数据库设计方法论',
|
|
27
|
+
keywords: ['schema', 'database', 'sql', 'migration'],
|
|
28
|
+
content: '---\nname: official-db-schema-design\nversion: 1.0.0\n---\n# DB Schema Design\n\nOfficial content.',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'official-debug',
|
|
32
|
+
version: '1.2.0',
|
|
33
|
+
description: '系统化调试工作流',
|
|
34
|
+
keywords: ['debug', 'bug', 'troubleshooting'],
|
|
35
|
+
content: '---\nname: official-debug\nversion: 1.2.0\n---\n# Debug\n\nOfficial debug content.',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function makeCandidate(overrides: Partial<CandidateSkill> = {}): CandidateSkill {
|
|
40
|
+
return {
|
|
41
|
+
id: 'database-design',
|
|
42
|
+
source: 'agent-skills',
|
|
43
|
+
filePath: '/tmp/agent-skills/database-design/SKILL.md',
|
|
44
|
+
name: 'database-design',
|
|
45
|
+
description: 'Database schema design patterns',
|
|
46
|
+
keywords: ['database', 'schema', 'sql'],
|
|
47
|
+
content: '---\nname: database-design\n---\n# Database Design\n\nContent here.',
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeMockProvider(returnValue: string): ClaudeProvider {
|
|
53
|
+
return {
|
|
54
|
+
complete: vi.fn().mockResolvedValue(returnValue),
|
|
55
|
+
} as unknown as ClaudeProvider;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── matchToOfficial ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('matchToOfficial', () => {
|
|
61
|
+
it('returns a match for a candidate with overlapping keywords', () => {
|
|
62
|
+
const candidate = makeCandidate();
|
|
63
|
+
const result = matchToOfficial(candidate, officialSkills);
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result?.officialId).toBe('official-db-schema-design');
|
|
66
|
+
expect(result?.score).toBeGreaterThanOrEqual(30);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns null when score is below threshold (30)', () => {
|
|
70
|
+
const candidate = makeCandidate({
|
|
71
|
+
id: 'cooking-recipes',
|
|
72
|
+
name: 'cooking',
|
|
73
|
+
description: 'Culinary arts and baking',
|
|
74
|
+
keywords: ['cooking', 'baking', 'recipes', 'food'],
|
|
75
|
+
});
|
|
76
|
+
const result = matchToOfficial(candidate, officialSkills);
|
|
77
|
+
expect(result).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('matches debug candidate to official-debug', () => {
|
|
81
|
+
const candidate = makeCandidate({
|
|
82
|
+
id: 'debug-helper',
|
|
83
|
+
name: 'debug-helper',
|
|
84
|
+
description: 'Debugging workflow',
|
|
85
|
+
keywords: ['debug', 'bug', 'troubleshoot'],
|
|
86
|
+
});
|
|
87
|
+
const result = matchToOfficial(candidate, officialSkills);
|
|
88
|
+
expect(result?.officialId).toBe('official-debug');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('score is within 0-100 range', () => {
|
|
92
|
+
const candidate = makeCandidate();
|
|
93
|
+
const result = matchToOfficial(candidate, officialSkills);
|
|
94
|
+
if (result) {
|
|
95
|
+
expect(result.score).toBeGreaterThanOrEqual(0);
|
|
96
|
+
expect(result.score).toBeLessThanOrEqual(100);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns null for empty official skills list', () => {
|
|
101
|
+
const candidate = makeCandidate();
|
|
102
|
+
const result = matchToOfficial(candidate, []);
|
|
103
|
+
expect(result).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── evaluateWithAI — three action paths ─────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
describe('evaluateWithAI', () => {
|
|
110
|
+
const official = officialSkills[0];
|
|
111
|
+
const candidate = makeCandidate();
|
|
112
|
+
|
|
113
|
+
it('returns upgrade decision when AI returns action=upgrade', async () => {
|
|
114
|
+
const json = JSON.stringify({
|
|
115
|
+
action: 'upgrade',
|
|
116
|
+
confidence: 85,
|
|
117
|
+
reasoning: '候选内容更全面',
|
|
118
|
+
merged_content: null,
|
|
119
|
+
});
|
|
120
|
+
const provider = makeMockProvider(json);
|
|
121
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
122
|
+
expect(result.action).toBe('upgrade');
|
|
123
|
+
expect(result.confidence).toBe(85);
|
|
124
|
+
expect(result.reasoning).toBe('候选内容更全面');
|
|
125
|
+
expect(result.merged_content).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns merge decision with merged_content', async () => {
|
|
129
|
+
const mergedMd = '---\nname: merged\n---\n# Merged Content';
|
|
130
|
+
const json = JSON.stringify({
|
|
131
|
+
action: 'merge',
|
|
132
|
+
confidence: 72,
|
|
133
|
+
reasoning: '各有独特章节',
|
|
134
|
+
merged_content: mergedMd,
|
|
135
|
+
});
|
|
136
|
+
const provider = makeMockProvider(json);
|
|
137
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
138
|
+
expect(result.action).toBe('merge');
|
|
139
|
+
expect(result.merged_content).toBe(mergedMd);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns skip decision when AI returns action=skip', async () => {
|
|
143
|
+
const json = JSON.stringify({
|
|
144
|
+
action: 'skip',
|
|
145
|
+
confidence: 30,
|
|
146
|
+
reasoning: '主题不重叠',
|
|
147
|
+
merged_content: null,
|
|
148
|
+
});
|
|
149
|
+
const provider = makeMockProvider(json);
|
|
150
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
151
|
+
expect(result.action).toBe('skip');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns needs_review when AI response is not valid JSON', async () => {
|
|
155
|
+
const provider = makeMockProvider('这不是JSON内容,AI出错了');
|
|
156
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
157
|
+
expect(result.needs_review).toBe(true);
|
|
158
|
+
expect(result.action).toBe('skip');
|
|
159
|
+
expect(result.confidence).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('strips markdown code fences before parsing', async () => {
|
|
163
|
+
const json = JSON.stringify({ action: 'skip', confidence: 50, reasoning: 'ok', merged_content: null });
|
|
164
|
+
const provider = makeMockProvider('```json\n' + json + '\n```');
|
|
165
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
166
|
+
expect(result.action).toBe('skip');
|
|
167
|
+
expect(result.needs_review).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('clamps confidence to 0-100 range', async () => {
|
|
171
|
+
const json = JSON.stringify({ action: 'upgrade', confidence: 150, reasoning: 'ok', merged_content: null });
|
|
172
|
+
const provider = makeMockProvider(json);
|
|
173
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
174
|
+
expect(result.confidence).toBe(100);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns needs_review when AI provider throws', async () => {
|
|
178
|
+
const provider = {
|
|
179
|
+
complete: vi.fn().mockRejectedValue(new Error('API timeout')),
|
|
180
|
+
} as unknown as ClaudeProvider;
|
|
181
|
+
const result = await evaluateWithAI(candidate, official, provider);
|
|
182
|
+
expect(result.needs_review).toBe(true);
|
|
183
|
+
expect(result.action).toBe('skip');
|
|
184
|
+
expect(result.reasoning).toContain('API timeout');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── generateReport ────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe('generateReport', () => {
|
|
191
|
+
let tmpDir: string;
|
|
192
|
+
|
|
193
|
+
beforeEach(async () => {
|
|
194
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'upgrade-test-'));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(async () => {
|
|
198
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const entries: ReportEntry[] = [
|
|
202
|
+
{
|
|
203
|
+
officialId: 'official-db-schema-design',
|
|
204
|
+
candidateId: 'database-design',
|
|
205
|
+
candidateSource: 'agent-skills',
|
|
206
|
+
action: 'upgrade',
|
|
207
|
+
confidence: 85,
|
|
208
|
+
reasoning: '候选更全面',
|
|
209
|
+
candidateFilePath: '/tmp/agent-skills/database-design/SKILL.md',
|
|
210
|
+
merged_content: null,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
officialId: 'official-debug',
|
|
214
|
+
candidateId: 'debug-helper',
|
|
215
|
+
candidateSource: 'superpowers',
|
|
216
|
+
action: 'merge',
|
|
217
|
+
confidence: 70,
|
|
218
|
+
reasoning: '各有特色',
|
|
219
|
+
candidateFilePath: '/tmp/superpowers/debug-helper.md',
|
|
220
|
+
merged_content: '---\nname: merged-debug\n---\n# Merged Debug Content',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
officialId: 'official-tdd',
|
|
224
|
+
candidateId: 'tdd-guide',
|
|
225
|
+
candidateSource: 'agent-skills',
|
|
226
|
+
action: 'skip',
|
|
227
|
+
confidence: 20,
|
|
228
|
+
reasoning: '不够好',
|
|
229
|
+
candidateFilePath: '/tmp/agent-skills/tdd-guide/SKILL.md',
|
|
230
|
+
merged_content: null,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const stats = { total: 10, matched: 3, unmatched: 7 };
|
|
235
|
+
|
|
236
|
+
it('creates report file at the specified path', async () => {
|
|
237
|
+
const reportPath = path.join(tmpDir, 'report.md');
|
|
238
|
+
await generateReport(entries, reportPath, stats);
|
|
239
|
+
const exists = await fs.access(reportPath).then(() => true).catch(() => false);
|
|
240
|
+
expect(exists).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('report contains summary table with correct counts', async () => {
|
|
244
|
+
const reportPath = path.join(tmpDir, 'report.md');
|
|
245
|
+
await generateReport(entries, reportPath, stats);
|
|
246
|
+
const content = await fs.readFile(reportPath, 'utf-8');
|
|
247
|
+
expect(content).toContain('| Candidates scanned | 10 |');
|
|
248
|
+
expect(content).toContain('| Matched to official | 3 |');
|
|
249
|
+
expect(content).toContain('| Action: upgrade | 1 |');
|
|
250
|
+
expect(content).toContain('| Action: merge | 1 |');
|
|
251
|
+
expect(content).toContain('| Action: skip | 1 |');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('report contains HTML comment machine markers for upgrade', async () => {
|
|
255
|
+
const reportPath = path.join(tmpDir, 'report.md');
|
|
256
|
+
await generateReport(entries, reportPath, stats);
|
|
257
|
+
const content = await fs.readFile(reportPath, 'utf-8');
|
|
258
|
+
expect(content).toContain(
|
|
259
|
+
'<!-- upgrade-entry: official-db-schema-design | /tmp/agent-skills/database-design/SKILL.md | upgrade -->',
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('report contains merged-content-begin/end block for merge entries', async () => {
|
|
264
|
+
const reportPath = path.join(tmpDir, 'report.md');
|
|
265
|
+
await generateReport(entries, reportPath, stats);
|
|
266
|
+
const content = await fs.readFile(reportPath, 'utf-8');
|
|
267
|
+
expect(content).toContain('<!-- merged-content-begin -->');
|
|
268
|
+
expect(content).toContain('<!-- merged-content-end -->');
|
|
269
|
+
expect(content).toContain('# Merged Debug Content');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('report contains per-skill sections for all entries', async () => {
|
|
273
|
+
const reportPath = path.join(tmpDir, 'report.md');
|
|
274
|
+
await generateReport(entries, reportPath, stats);
|
|
275
|
+
const content = await fs.readFile(reportPath, 'utf-8');
|
|
276
|
+
expect(content).toContain('### official-db-schema-design');
|
|
277
|
+
expect(content).toContain('### official-debug');
|
|
278
|
+
expect(content).toContain('### official-tdd');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── applyDecisions ────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
describe('applyDecisions', () => {
|
|
285
|
+
let tmpDir: string;
|
|
286
|
+
let officialDir: string;
|
|
287
|
+
let backupBaseDir: string;
|
|
288
|
+
let reportPath: string;
|
|
289
|
+
let candidatePath: string;
|
|
290
|
+
|
|
291
|
+
beforeEach(async () => {
|
|
292
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apply-test-'));
|
|
293
|
+
officialDir = path.join(tmpDir, 'official');
|
|
294
|
+
backupBaseDir = path.join(tmpDir, 'backups');
|
|
295
|
+
reportPath = path.join(tmpDir, 'report.md');
|
|
296
|
+
candidatePath = path.join(tmpDir, 'candidate.md');
|
|
297
|
+
|
|
298
|
+
await fs.mkdir(officialDir, { recursive: true });
|
|
299
|
+
await fs.mkdir(backupBaseDir, { recursive: true });
|
|
300
|
+
|
|
301
|
+
// Create a fake official skill file
|
|
302
|
+
await fs.writeFile(
|
|
303
|
+
path.join(officialDir, 'official-db-schema-design.md'),
|
|
304
|
+
'---\nname: official-db-schema-design\n---\n# Original Content',
|
|
305
|
+
'utf-8',
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
// Create a fake candidate file
|
|
309
|
+
await fs.writeFile(
|
|
310
|
+
candidatePath,
|
|
311
|
+
'---\nname: database-design\n---\n# Better Content from candidate',
|
|
312
|
+
'utf-8',
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
afterEach(async () => {
|
|
317
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('creates a backup directory before applying', async () => {
|
|
321
|
+
const report = `# Report\n\n<!-- upgrade-entry: official-db-schema-design | ${candidatePath} | upgrade -->\n`;
|
|
322
|
+
await fs.writeFile(reportPath, report, 'utf-8');
|
|
323
|
+
|
|
324
|
+
const { backupPath } = await applyDecisions(reportPath, officialDir, backupBaseDir);
|
|
325
|
+
const backupExists = await fs.access(backupPath).then(() => true).catch(() => false);
|
|
326
|
+
expect(backupExists).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('copies existing official skills to backup before applying', async () => {
|
|
330
|
+
const report = `# Report\n\n<!-- upgrade-entry: official-db-schema-design | ${candidatePath} | upgrade -->\n`;
|
|
331
|
+
await fs.writeFile(reportPath, report, 'utf-8');
|
|
332
|
+
|
|
333
|
+
const { backupPath } = await applyDecisions(reportPath, officialDir, backupBaseDir);
|
|
334
|
+
const backupFile = path.join(backupPath, 'official-db-schema-design.md');
|
|
335
|
+
const backupExists = await fs.access(backupFile).then(() => true).catch(() => false);
|
|
336
|
+
expect(backupExists).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('applies upgrade action by writing candidate content to official dir', async () => {
|
|
340
|
+
const report = `# Report\n\n<!-- upgrade-entry: official-db-schema-design | ${candidatePath} | upgrade -->\n`;
|
|
341
|
+
await fs.writeFile(reportPath, report, 'utf-8');
|
|
342
|
+
|
|
343
|
+
const { applied } = await applyDecisions(reportPath, officialDir, backupBaseDir);
|
|
344
|
+
expect(applied).toBe(1);
|
|
345
|
+
|
|
346
|
+
const updatedContent = await fs.readFile(
|
|
347
|
+
path.join(officialDir, 'official-db-schema-design.md'),
|
|
348
|
+
'utf-8',
|
|
349
|
+
);
|
|
350
|
+
expect(updatedContent).toContain('Better Content from candidate');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('skips entries with action=skip', async () => {
|
|
354
|
+
const report = `# Report\n\n<!-- upgrade-entry: official-db-schema-design | ${candidatePath} | skip -->\n`;
|
|
355
|
+
await fs.writeFile(reportPath, report, 'utf-8');
|
|
356
|
+
|
|
357
|
+
const { applied, skipped } = await applyDecisions(reportPath, officialDir, backupBaseDir);
|
|
358
|
+
expect(applied).toBe(0);
|
|
359
|
+
expect(skipped).toBe(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('applies merge action using merged-content-begin/end block', async () => {
|
|
363
|
+
const mergedContent = '---\nname: merged\n---\n# Merged Result';
|
|
364
|
+
const report =
|
|
365
|
+
`# Report\n\n` +
|
|
366
|
+
`<!-- upgrade-entry: official-db-schema-design | ${candidatePath} | merge -->\n\n` +
|
|
367
|
+
`<!-- merged-content-begin -->\n${mergedContent}\n<!-- merged-content-end -->\n`;
|
|
368
|
+
await fs.writeFile(reportPath, report, 'utf-8');
|
|
369
|
+
|
|
370
|
+
const { applied } = await applyDecisions(reportPath, officialDir, backupBaseDir);
|
|
371
|
+
expect(applied).toBe(1);
|
|
372
|
+
|
|
373
|
+
const updatedContent = await fs.readFile(
|
|
374
|
+
path.join(officialDir, 'official-db-schema-design.md'),
|
|
375
|
+
'utf-8',
|
|
376
|
+
);
|
|
377
|
+
expect(updatedContent).toContain('# Merged Result');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('uses -2 suffix for backup when timestamp conflicts', async () => {
|
|
381
|
+
const report = `# Report\n\n<!-- upgrade-entry: official-db-schema-design | ${candidatePath} | upgrade -->\n`;
|
|
382
|
+
await fs.writeFile(reportPath, report, 'utf-8');
|
|
383
|
+
|
|
384
|
+
// Run twice — second run should get -2 suffix backup
|
|
385
|
+
const { backupPath: bp1 } = await applyDecisions(reportPath, officialDir, backupBaseDir);
|
|
386
|
+
// Re-create official file (it was overwritten)
|
|
387
|
+
await fs.writeFile(
|
|
388
|
+
path.join(officialDir, 'official-db-schema-design.md'),
|
|
389
|
+
'---\nname: official-db-schema-design\n---\n# Original',
|
|
390
|
+
'utf-8',
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Manually create a directory at the same timestamp that would be used
|
|
394
|
+
const dirs1 = await fs.readdir(backupBaseDir);
|
|
395
|
+
expect(dirs1.length).toBeGreaterThanOrEqual(1);
|
|
396
|
+
|
|
397
|
+
// The first backup path should exist
|
|
398
|
+
const bp1Exists = await fs.access(bp1).then(() => true).catch(() => false);
|
|
399
|
+
expect(bp1Exists).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
});
|