fix: harden install planning and sync tracked catalogs

This commit is contained in:
Affaan Mustafa
2026-03-31 22:57:48 -07:00
parent 03c4a90ffa
commit e1bc08fa6e
19 changed files with 970 additions and 118 deletions

View File

@@ -156,12 +156,19 @@ function runCatalogValidator(overrides = {}) {
const validatorPath = path.join(validatorsDir, 'catalog.js');
let source = fs.readFileSync(validatorPath, 'utf8');
source = stripShebang(source);
source = `process.argv.push('--text');\n${source}`;
const argv = Array.isArray(overrides.argv) && overrides.argv.length > 0
? overrides.argv
: ['--text'];
const argvPreamble = argv.map(arg => `process.argv.push(${JSON.stringify(arg)});`).join('\n');
source = `${argvPreamble}\n${source}`;
const resolvedOverrides = {
ROOT: repoRoot,
README_PATH: path.join(repoRoot, 'README.md'),
AGENTS_PATH: path.join(repoRoot, 'AGENTS.md'),
README_ZH_CN_PATH: path.join(repoRoot, 'README.zh-CN.md'),
DOCS_ZH_CN_README_PATH: path.join(repoRoot, 'docs', 'zh-CN', 'README.md'),
DOCS_ZH_CN_AGENTS_PATH: path.join(repoRoot, 'docs', 'zh-CN', 'AGENTS.md'),
...overrides,
};
@@ -176,29 +183,50 @@ function runCatalogValidator(overrides = {}) {
function writeCatalogFixture(testDir, options = {}) {
const {
readmeCounts = { agents: 1, skills: 1, commands: 1 },
readmeTableCounts = readmeCounts,
readmeParityCounts = readmeCounts,
readmeUnrelatedSkillsCount = 16,
summaryCounts = { agents: 1, skills: 1, commands: 1 },
structureLines = [
'agents/ — 1 specialized subagents',
'skills/ — 1 workflow skills and domain knowledge',
'commands/ — 1 slash commands',
],
zhRootReadmeCounts = { agents: 1, skills: 1, commands: 1 },
zhDocsReadmeCounts = { agents: 1, skills: 1, commands: 1 },
zhDocsTableCounts = zhDocsReadmeCounts,
zhDocsParityCounts = zhDocsReadmeCounts,
zhDocsUnrelatedSkillsCount = 16,
zhAgentsSummaryCounts = { agents: 1, skills: 1, commands: 1 },
zhAgentsStructureLines = [
'agents/ — 1 个专业子代理',
'skills/ — 1 个工作流技能和领域知识',
'commands/ — 1 个斜杠命令',
],
} = options;
const readmePath = path.join(testDir, 'README.md');
const agentsPath = path.join(testDir, 'AGENTS.md');
const zhRootReadmePath = path.join(testDir, 'README.zh-CN.md');
const zhDocsReadmePath = path.join(testDir, 'docs', 'zh-CN', 'README.md');
const zhAgentsPath = path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md');
fs.mkdirSync(path.join(testDir, 'agents'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'commands'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'skills', 'demo-skill'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'docs', 'zh-CN'), { recursive: true });
fs.writeFileSync(path.join(testDir, 'agents', 'planner.md'), '---\nmodel: sonnet\ntools: Read\n---\n# Planner');
fs.writeFileSync(path.join(testDir, 'commands', 'plan.md'), '---\ndescription: Plan\n---\n# Plan');
fs.writeFileSync(path.join(testDir, 'skills', 'demo-skill', 'SKILL.md'), '---\nname: demo-skill\ndescription: Demo skill\norigin: ECC\n---\n# Demo Skill');
fs.writeFileSync(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| Agents | PASS: ${readmeCounts.agents} agents | Shared | Shared | 1 |\n| Commands | PASS: ${readmeCounts.commands} commands | Shared | Shared | 1 |\n| Skills | PASS: ${readmeCounts.skills} skills | Shared | Shared | 1 |\n`);
fs.writeFileSync(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| Agents | PASS: ${readmeTableCounts.agents} agents | Shared | Shared | 1 |\n| Commands | PASS: ${readmeTableCounts.commands} commands | Shared | Shared | 1 |\n| Skills | PASS: ${readmeTableCounts.skills} skills | Shared | Shared | 1 |\n\n| Feature | Count | Format |\n|-----------|-------|---------|\n| Skills | ${readmeUnrelatedSkillsCount} | .agents/skills/ |\n\n## Cross-Tool Feature Parity\n\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **Agents** | ${readmeParityCounts.agents} | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |\n| **Commands** | ${readmeParityCounts.commands} | Shared | Instruction-based | 31 |\n| **Skills** | ${readmeParityCounts.skills} | Shared | 10 (native format) | 37 |\n`);
fs.writeFileSync(agentsPath, `This is a **production-ready AI coding plugin** providing ${summaryCounts.agents} specialized agents, ${summaryCounts.skills} skills, ${summaryCounts.commands} commands, and automated hook workflows for software development.\n\n\`\`\`\n${structureLines.join('\n')}\n\`\`\`\n`);
fs.writeFileSync(zhRootReadmePath, `**完成!** 你现在可以使用 ${zhRootReadmeCounts.agents} 个代理、${zhRootReadmeCounts.skills} 个技能和 ${zhRootReadmeCounts.commands} 个命令。\n`);
fs.writeFileSync(zhDocsReadmePath, `**搞定!** 你现在可以使用 ${zhDocsReadmeCounts.agents} 个智能体、${zhDocsReadmeCounts.skills} 项技能和 ${zhDocsReadmeCounts.commands} 个命令了。\n| 功能特性 | Claude Code | OpenCode | 状态 |\n|---------|-------------|----------|--------|\n| 智能体 | \u2705 ${zhDocsTableCounts.agents} 个 | \u2705 12 个 | **Claude Code 领先** |\n| 命令 | \u2705 ${zhDocsTableCounts.commands} 个 | \u2705 31 个 | **Claude Code 领先** |\n| 技能 | \u2705 ${zhDocsTableCounts.skills} 项 | \u2705 37 项 | **Claude Code 领先** |\n\n| 功能特性 | 数量 | 格式 |\n|-----------|-------|---------|\n| 技能 | ${zhDocsUnrelatedSkillsCount} | .agents/skills/ |\n\n## 跨工具功能对等\n\n| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **智能体** | ${zhDocsParityCounts.agents} | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |\n| **命令** | ${zhDocsParityCounts.commands} | 共享 | 基于指令 | 31 |\n| **技能** | ${zhDocsParityCounts.skills} | 共享 | 10 (原生格式) | 37 |\n`);
fs.writeFileSync(zhAgentsPath, `这是一个**生产就绪的 AI 编码插件**,提供 ${zhAgentsSummaryCounts.agents} 个专业代理、${zhAgentsSummaryCounts.skills} 项技能、${zhAgentsSummaryCounts.commands} 条命令以及自动化钩子工作流,用于软件开发。\n\n\`\`\`\n${zhAgentsStructureLines.join('\n')}\n\`\`\`\n`);
return { readmePath, agentsPath };
return { readmePath, agentsPath, zhRootReadmePath, zhDocsReadmePath, zhAgentsPath };
}
function runTests() {
@@ -341,20 +369,41 @@ function runTests() {
if (test('fails when README and AGENTS catalog counts drift', () => {
const testDir = createTestDir();
const { readmePath, agentsPath } = writeCatalogFixture(testDir, {
const {
readmePath,
agentsPath,
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
} = writeCatalogFixture(testDir, {
readmeCounts: { agents: 99, skills: 99, commands: 99 },
readmeTableCounts: { agents: 99, skills: 99, commands: 99 },
readmeParityCounts: { agents: 99, skills: 99, commands: 99 },
summaryCounts: { agents: 99, skills: 99, commands: 99 },
structureLines: [
'agents/ — 99 specialized subagents',
'skills/ — 99 workflow skills and domain knowledge',
'commands/ — 99 slash commands',
],
zhRootReadmeCounts: { agents: 99, skills: 99, commands: 99 },
zhDocsReadmeCounts: { agents: 99, skills: 99, commands: 99 },
zhDocsTableCounts: { agents: 99, skills: 99, commands: 99 },
zhDocsParityCounts: { agents: 99, skills: 99, commands: 99 },
zhAgentsSummaryCounts: { agents: 99, skills: 99, commands: 99 },
zhAgentsStructureLines: [
'agents/ — 99 个专业子代理',
'skills/ — 99 个工作流技能和领域知识',
'commands/ — 99 个斜杠命令',
],
});
const result = runCatalogValidator({
ROOT: testDir,
README_PATH: readmePath,
AGENTS_PATH: agentsPath,
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
});
assert.strictEqual(result.code, 1, 'Should fail when catalog counts drift');
@@ -362,20 +411,154 @@ function runTests() {
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('fails when README parity table counts drift', () => {
const testDir = createTestDir();
const {
readmePath,
agentsPath,
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
} = writeCatalogFixture(testDir, {
readmeCounts: { agents: 1, skills: 1, commands: 1 },
readmeTableCounts: { agents: 1, skills: 1, commands: 1 },
readmeParityCounts: { agents: 9, skills: 8, commands: 7 },
summaryCounts: { agents: 1, skills: 1, commands: 1 },
});
const result = runCatalogValidator({
ROOT: testDir,
README_PATH: readmePath,
AGENTS_PATH: agentsPath,
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
});
assert.strictEqual(result.code, 1, 'Should fail when README parity table drifts');
assert.ok(
(result.stdout + result.stderr).includes('README.md parity table'),
'Should mention the README parity table mismatch'
);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('fails when a tracked catalog document is missing', () => {
const testDir = createTestDir();
const {
readmePath,
agentsPath,
zhRootReadmePath,
zhDocsReadmePath,
} = writeCatalogFixture(testDir);
const missingZhAgentsPath = path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md');
fs.rmSync(missingZhAgentsPath);
const result = runCatalogValidator({
ROOT: testDir,
README_PATH: readmePath,
AGENTS_PATH: agentsPath,
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: missingZhAgentsPath,
});
assert.strictEqual(result.code, 1, 'Should fail when a tracked doc is missing');
assert.ok(
(result.stdout + result.stderr).includes('Failed to read AGENTS.md'),
'Should mention the missing tracked document'
);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('syncs tracked catalog docs in write mode without rewriting unrelated tables', () => {
const testDir = createTestDir();
const {
readmePath,
agentsPath,
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
} = writeCatalogFixture(testDir, {
readmeCounts: { agents: 9, skills: 9, commands: 9 },
readmeTableCounts: { agents: 8, skills: 8, commands: 8 },
readmeParityCounts: { agents: 7, skills: 7, commands: 7 },
summaryCounts: { agents: 6, skills: 6, commands: 6 },
zhRootReadmeCounts: { agents: 10, skills: 10, commands: 10 },
zhDocsReadmeCounts: { agents: 11, skills: 11, commands: 11 },
zhDocsTableCounts: { agents: 12, skills: 12, commands: 12 },
zhDocsParityCounts: { agents: 13, skills: 13, commands: 13 },
zhAgentsSummaryCounts: { agents: 14, skills: 14, commands: 14 },
zhAgentsStructureLines: [
'agents/ — 15 个专业子代理',
'skills/ — 16 个工作流技能和领域知识',
'commands/ — 17 个斜杠命令',
],
});
const result = runCatalogValidator({
argv: ['--write', '--text'],
ROOT: testDir,
README_PATH: readmePath,
AGENTS_PATH: agentsPath,
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
});
assert.strictEqual(result.code, 0, `Should sync and pass, got stderr: ${result.stderr}`);
const readme = fs.readFileSync(readmePath, 'utf8');
const agentsDoc = fs.readFileSync(agentsPath, 'utf8');
const zhRootReadme = fs.readFileSync(zhRootReadmePath, 'utf8');
const zhDocsReadme = fs.readFileSync(zhDocsReadmePath, 'utf8');
const zhAgentsDoc = fs.readFileSync(zhAgentsPath, 'utf8');
assert.ok(readme.includes('Access to 1 agents, 1 skills, and 1 commands.'), 'Should sync README quick-start summary');
assert.ok(readme.includes('| Agents | PASS: 1 agents |'), 'Should sync README comparison table');
assert.ok(readme.includes('| Skills | 16 | .agents/skills/ |'), 'Should not rewrite unrelated README tables');
assert.ok(readme.includes('| **Agents** | 1 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |'), 'Should sync README parity table');
assert.ok(agentsDoc.includes('providing 1 specialized agents, 1 skills, 1 commands'), 'Should sync AGENTS summary');
assert.ok(agentsDoc.includes('skills/ — 1 workflow skills and domain knowledge'), 'Should sync AGENTS structure');
assert.ok(zhRootReadme.includes('你现在可以使用 1 个代理、1 个技能和 1 个命令'), 'Should sync README.zh-CN quick-start summary');
assert.ok(zhDocsReadme.includes('你现在可以使用 1 个智能体、1 项技能和 1 个命令了'), 'Should sync docs/zh-CN/README quick-start summary');
assert.ok(zhDocsReadme.includes('| 智能体 | \u2705 1 个 |'), 'Should sync docs/zh-CN/README comparison table');
assert.ok(zhDocsReadme.includes('| 技能 | 16 | .agents/skills/ |'), 'Should not rewrite unrelated docs/zh-CN/README tables');
assert.ok(zhDocsReadme.includes('| **智能体** | 1 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |'), 'Should sync docs/zh-CN/README parity table');
assert.ok(zhAgentsDoc.includes('提供 1 个专业代理、1 项技能、1 条命令'), 'Should sync docs/zh-CN/AGENTS summary');
assert.ok(zhAgentsDoc.includes('commands/ — 1 个斜杠命令'), 'Should sync docs/zh-CN/AGENTS structure');
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('accepts AGENTS project structure entries with varied spacing and dash styles', () => {
const testDir = createTestDir();
const { readmePath, agentsPath } = writeCatalogFixture(testDir, {
const {
readmePath,
agentsPath,
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
} = writeCatalogFixture(testDir, {
structureLines: [
' agents/ - 1 specialized subagents ',
'\tskills/\t\t1+ workflow skills and domain knowledge\t',
' commands/ — 1 slash commands ',
],
zhAgentsStructureLines: [
' agents/ - 1 个专业子代理 ',
'\tskills/\t\t1+ 个工作流技能和领域知识\t',
' commands/ — 1 个斜杠命令 ',
],
});
const result = runCatalogValidator({
ROOT: testDir,
README_PATH: readmePath,
AGENTS_PATH: agentsPath,
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
});
assert.strictEqual(result.code, 0, `Should accept formatting variations, got stderr: ${result.stderr}`);

View File

@@ -25,7 +25,7 @@ async function runTests() {
try {
store = await import(pathToFileURL(storePath).href)
} catch (err) {
console.log('\n Skipping: build .opencode first (cd .opencode && npm run build)\n')
console.log('\n[warn] Skipping: build .opencode first (cd .opencode && npm run build)\n')
process.exit(0)
}

View File

@@ -253,46 +253,142 @@ function runTests() {
);
})) passed++; else failed++;
if (test('validates projectRoot and homeDir option types before adapter planning', () => {
assert.throws(
() => resolveInstallPlan({ profileId: 'core', target: 'cursor', projectRoot: 42 }),
/projectRoot must be a non-empty string when provided/
);
assert.throws(
() => resolveInstallPlan({ profileId: 'core', target: 'claude', homeDir: {} }),
/homeDir must be a non-empty string when provided/
);
})) passed++; else failed++;
if (test('skips a requested module when its dependency chain does not support the target', () => {
const repoRoot = createTestRepo();
writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), {
version: 1,
modules: [
{
id: 'parent',
kind: 'skills',
description: 'Parent',
paths: ['parent'],
targets: ['claude'],
dependencies: ['child'],
defaultInstall: false,
cost: 'light',
stability: 'stable'
},
{
id: 'child',
kind: 'skills',
description: 'Child',
paths: ['child'],
targets: ['cursor'],
dependencies: [],
defaultInstall: false,
cost: 'light',
stability: 'stable'
try {
writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), {
version: 1,
modules: [
{
id: 'parent',
kind: 'skills',
description: 'Parent',
paths: ['parent'],
targets: ['claude'],
dependencies: ['child'],
defaultInstall: false,
cost: 'light',
stability: 'stable'
},
{
id: 'child',
kind: 'skills',
description: 'Child',
paths: ['child'],
targets: ['cursor'],
dependencies: [],
defaultInstall: false,
cost: 'light',
stability: 'stable'
}
]
});
writeJson(path.join(repoRoot, 'manifests', 'install-profiles.json'), {
version: 1,
profiles: {
core: { description: 'Core', modules: ['parent'] }
}
]
});
writeJson(path.join(repoRoot, 'manifests', 'install-profiles.json'), {
version: 1,
profiles: {
core: { description: 'Core', modules: ['parent'] }
}
});
});
const plan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(plan.selectedModuleIds, []);
assert.deepStrictEqual(plan.skippedModuleIds, ['parent']);
cleanupTestRepo(repoRoot);
const plan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(plan.selectedModuleIds, []);
assert.deepStrictEqual(plan.skippedModuleIds, ['parent']);
} finally {
cleanupTestRepo(repoRoot);
}
})) passed++; else failed++;
if (test('fails fast when install manifest module targets is not an array', () => {
const repoRoot = createTestRepo();
try {
writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), {
version: 1,
modules: [
{
id: 'parent',
kind: 'skills',
description: 'Parent',
paths: ['parent'],
targets: 'claude',
dependencies: [],
defaultInstall: false,
cost: 'light',
stability: 'stable'
}
]
});
writeJson(path.join(repoRoot, 'manifests', 'install-profiles.json'), {
version: 1,
profiles: {
core: { description: 'Core', modules: ['parent'] }
}
});
assert.throws(
() => resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' }),
/Install module parent has invalid targets; expected an array of supported target ids/
);
} finally {
cleanupTestRepo(repoRoot);
}
})) passed++; else failed++;
if (test('keeps antigravity modules selected while filtering unsupported source paths', () => {
const repoRoot = createTestRepo();
try {
writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), {
version: 1,
modules: [
{
id: 'unsupported-antigravity',
kind: 'skills',
description: 'Unsupported',
paths: ['.cursor', 'skills/example'],
targets: ['antigravity'],
dependencies: [],
defaultInstall: false,
cost: 'light',
stability: 'stable'
}
]
});
writeJson(path.join(repoRoot, 'manifests', 'install-profiles.json'), {
version: 1,
profiles: {
core: { description: 'Core', modules: ['unsupported-antigravity'] }
}
});
const plan = resolveInstallPlan({
repoRoot,
profileId: 'core',
target: 'antigravity',
projectRoot: '/workspace/app',
});
assert.deepStrictEqual(plan.selectedModuleIds, ['unsupported-antigravity']);
assert.deepStrictEqual(plan.skippedModuleIds, []);
assert.ok(
plan.operations.every(operation => operation.sourceRelativePath !== '.cursor'),
'Unsupported antigravity paths should be filtered from planned operations'
);
assert.ok(
plan.operations.some(operation => operation.sourceRelativePath === 'skills/example'),
'Supported antigravity skill paths should still be planned'
);
} finally {
cleanupTestRepo(repoRoot);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);