diff --git a/README.md b/README.md index a5477d222..21f35fe5e 100644 --- a/README.md +++ b/README.md @@ -827,8 +827,8 @@ Stable graduation of the 2.0 line: control-pane substrate, worktree lifecycle se ```text ECC/ |-- agents/ # 71 specialized subagents for delegation -|-- skills/ # 293 reusable workflows loaded on demand -|-- commands/ # 94 maintained slash-command shims +|-- skills/ # 302 reusable workflows loaded on demand +|-- commands/ # 95 maintained slash-command shims |-- rules/ # opt-in common and language standards |-- hooks/ # runtime automation and enforcement |-- scripts/ # install, repair, sync, orchestration, and checks diff --git a/scripts/ci/catalog.js b/scripts/ci/catalog.js index a7834349f..566178f5c 100644 --- a/scripts/ci/catalog.js +++ b/scripts/ci/catalog.js @@ -87,6 +87,23 @@ function replaceOrThrow(content, regex, replacer, source) { return content.replace(regex, replacer); } +function countMarkers(content, regex) { + return [...content.matchAll(regex)].length; +} + +function replaceAllOrThrow(content, regex, replacer, source, expectedCount) { + const found = countMarkers(content, regex); + if (found === 0) { + throw new Error(`${source} is missing the expected catalog marker`); + } + + if (expectedCount !== undefined && found !== expectedCount) { + throw new Error(`${source} expected ${expectedCount} marker(s), found ${found}`); + } + + return content.replace(regex, replacer); +} + function parseReadmeExpectations(readmeContent) { const expectations = []; @@ -103,17 +120,28 @@ function parseReadmeExpectations(readmeContent) { { category: 'commands', mode: 'exact', expected: Number(quickStartMatch[3]), source: 'README.md quick-start summary' } ); - const projectTreeAgentsMatches = [...readmeContent.matchAll(/^\|\s*--\s*agents\/\s*#\s*(\d+)\s+specialized subagents for delegation\s*$/gim)]; - if (!projectTreeAgentsMatches.length) { - throw new Error('README.md project tree is missing the agents count'); - } + const projectTreePatterns = [ + { category: 'agents', regex: /^\|\s*--\s*agents\/\s*#\s*(\d+)\s+specialized subagents for delegation\s*$/gim, source: 'README.md project tree (agents)' }, + { category: 'commands', regex: /^\|\s*--\s*commands\/\s*#\s*(\d+)\s+maintained slash-command shims\s*$/gim, source: 'README.md project tree (commands)' }, + { category: 'skills', regex: /^\|\s*--\s*skills\/\s*#\s*(\d+)\s+reusable workflows loaded on demand\s*$/gim, source: 'README.md project tree (skills)' } + ]; - for (const [index, match] of projectTreeAgentsMatches.entries()) expectations.push({ - category: 'agents', - mode: 'exact', - expected: Number(match[1]), - source: `README.md project tree ${index + 1} (agents)` - }); + for (const pattern of projectTreePatterns) { + const matches = [...readmeContent.matchAll(pattern.regex)]; + + if (matches.length === 0) { + throw new Error(`${pattern.source} is missing the ${pattern.category} count`); + } + + for (const match of matches) { + expectations.push({ + category: pattern.category, + mode: 'exact', + expected: Number(match[1]), + source: `${pattern.source} block ${readmeContent.slice(0, match.index).split('\n').length}` + }); + } + } const tablePatterns = [ { category: 'agents', regex: /\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+agents\s*\|/i, source: 'README.md comparison table' }, @@ -409,12 +437,24 @@ function syncEnglishReadme(content, catalog) { `${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count} legacy command shims`, 'README.md quick-start summary' ); - nextContent = replaceOrThrow( + nextContent = replaceAllOrThrow( nextContent, /^(\|\s*--\s*agents\/\s*#\s*)(\d+)(\s+specialized subagents for delegation\s*)$/gim, (_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`, 'README.md project tree (agents)' ); + nextContent = replaceAllOrThrow( + nextContent, + /^(\|\s*--\s*commands\/\s*#\s*)(\d+)(\s+maintained slash-command shims\s*)$/gim, + (_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`, + 'README.md project tree (commands)' + ); + nextContent = replaceAllOrThrow( + nextContent, + /^(\|\s*--\s*skills\/\s*#\s*)(\d+)(\s+reusable workflows loaded on demand\s*)$/gim, + (_, prefix, __, suffix) => `${prefix}${catalog.skills.count}${suffix}`, + 'README.md project tree (skills)' + ); nextContent = replaceOrThrow( nextContent, /(\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s+agents\s*\|)/i, diff --git a/tests/ci/catalog.test.js b/tests/ci/catalog.test.js index 9fac1c021..4fddd4702 100644 --- a/tests/ci/catalog.test.js +++ b/tests/ci/catalog.test.js @@ -46,7 +46,9 @@ function writeEnglishReadme(root, counts, options = {}) { fs.writeFileSync(path.join(root, 'README.md'), `Access to ${counts.agents} agents, ${counts.skills} skills, and ${counts.commands} commands. - **Public surface synced to the live repo** - metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: ${counts.agents} agents, ${counts.skills} skills, and ${counts.commands} legacy command shims. |-- agents/ # ${counts.agents} specialized subagents for delegation -|-- agents/ # ${counts.agents + 1} specialized subagents for delegation +|-- skills/ # ${counts.skills} reusable workflows loaded on demand +|-- commands/ # ${counts.commands} maintained slash-command shims +${options.extraTreeBlock === undefined ? `|-- agents/ # ${counts.agents + 1} specialized subagents for delegation` : options.extraTreeBlock} | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | | --- | --- | --- | --- | --- | | Agents | PASS: ${tableCounts.agents} agents | @@ -171,7 +173,7 @@ function writeCatalogFixture(root, options = {}) { fs.writeFileSync(path.join(root, 'commands', 'notes.txt'), 'not counted\n'); fs.mkdirSync(path.join(root, 'skills', 'missing-skill-file'), { recursive: true }); - writeEnglishReadme(root, documentedCounts, { unrelatedSkillsCount }); + writeEnglishReadme(root, documentedCounts, { unrelatedSkillsCount, extraTreeBlock: options.extraTreeBlock }); writeEnglishAgents(root, documentedCounts, { skillsMinimum }); writeCrossHarnessIdentityDocs(root, documentedCounts); writeZhRootReadme(root, documentedCounts); @@ -294,6 +296,76 @@ function runTests() { } })) passed++; else failed++; + if (test('flags a stale count hidden in a second project-tree block', () => { + const testDir = createTestDir(); + try { + writeCatalogFixture(testDir, { + actualCounts: { agents: 2, skills: 3, commands: 4 }, + documentedCounts: { agents: 2, skills: 3, commands: 4 }, + extraTreeBlock: '|-- agents/ # 1 specialized subagents for delegation', + }); + + const result = runCatalogCheck({ root: testDir }); + const failing = result.checks.filter(check => !check.ok && check.source.includes('project tree')); + + assert.strictEqual(failing.length, 1); + assert.match(failing[0].source, /project tree \(agents\) block \d+/); + assert.strictEqual(failing[0].expected, 1); + assert.strictEqual(failing[0].actual, 2); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + + if (test('write mode repairs every project-tree block, not just the first', () => { + const testDir = createTestDir(); + try { + writeCatalogFixture(testDir, { + actualCounts: { agents: 2, skills: 3, commands: 4 }, + documentedCounts: { agents: 2, skills: 3, commands: 4 }, + extraTreeBlock: [ + '|-- agents/ # 1 specialized subagents for delegation', + '|-- skills/ # 1 reusable workflows loaded on demand', + ].join('\n'), + }); + + const result = runCatalogCheck({ root: testDir, writeMode: true }); + + assert.strictEqual(result.checks.filter(check => !check.ok).length, 0); + + const readme = fs.readFileSync(path.join(testDir, 'README.md'), 'utf8'); + assert.ok(!readme.includes('# 1 specialized subagents for delegation')); + assert.ok(!readme.includes('# 1 reusable workflows loaded on demand')); + assert.strictEqual(readme.split('# 2 specialized subagents for delegation').length - 1, 2); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + + if (test('throws when a project-tree count entry is missing entirely', () => { + const testDir = createTestDir(); + try { + writeCatalogFixture(testDir, { + actualCounts: { agents: 2, skills: 3, commands: 4 }, + documentedCounts: { agents: 2, skills: 3, commands: 4 }, + }); + + const readmePath = path.join(testDir, 'README.md'); + const withoutSkills = fs.readFileSync(readmePath, 'utf8') + .split('\n') + .filter(line => !line.includes('reusable workflows loaded on demand')) + .join('\n'); + fs.writeFileSync(readmePath, withoutSkills); + + assert.throws( + () => runCatalogCheck({ root: testDir }), + /README\.md project tree \(skills\) is missing the skills count/ + ); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + if (test('throws a clear error for missing tracked documents', () => { const testDir = createTestDir(); try { diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js index ea9c0b4a8..ace7ccbdb 100644 --- a/tests/ci/validators.test.js +++ b/tests/ci/validators.test.js @@ -260,6 +260,9 @@ function writeCatalogFixture(testDir, options = {}) { const { readmeCounts = { agents: 1, skills: 1, commands: 1 }, readmeProjectTreeAgents = readmeCounts.agents, + readmeProjectTreeSkills = readmeCounts.skills, + readmeProjectTreeCommands = readmeCounts.commands, + readmeExtraTreeBlock = '', readmeTableCounts = readmeCounts, readmeParityCounts = readmeCounts, readmeUnrelatedSkillsCount = 16, @@ -306,7 +309,10 @@ function writeCatalogFixture(testDir, options = {}) { 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- **Public surface synced to the live repo** - metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} legacy command shims.\n|-- agents/ # ${readmeProjectTreeAgents} specialized subagents for delegation\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(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n- **Public surface synced to the live repo** - metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} legacy command shims.\n|-- agents/ # ${readmeProjectTreeAgents} specialized subagents for delegation +|-- skills/ # ${readmeProjectTreeSkills} reusable workflows loaded on demand +|-- commands/ # ${readmeProjectTreeCommands} maintained slash-command shims +${readmeExtraTreeBlock}\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`); @@ -842,6 +848,77 @@ function runTests() { cleanupTestDir(testDir); })) passed++; else failed++; + if (test('flags a stale count hidden in a second README project-tree block', () => { + const testDir = createTestDir(); + const { + readmePath, + agentsPath, + zhRootReadmePath, + zhDocsReadmePath, + zhAgentsPath, + pluginJsonPath, + marketplaceJsonPath, + } = writeCatalogFixture(testDir, { + readmeExtraTreeBlock: '|-- agents/ # 9 specialized subagents for delegation', + }); + + 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, + PLUGIN_JSON_PATH: pluginJsonPath, + MARKETPLACE_JSON_PATH: marketplaceJsonPath, + }); + + const output = result.stdout + result.stderr; + assert.strictEqual(result.code, 1, `Should fail on a stale second block, got ${output}`); + assert.ok( + output.includes('README.md project tree (agents) block'), + `Should name the offending block, got ${output}` + ); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('write mode repairs a stale second README project-tree block', () => { + const testDir = createTestDir(); + const { + readmePath, + agentsPath, + zhRootReadmePath, + zhDocsReadmePath, + zhAgentsPath, + pluginJsonPath, + marketplaceJsonPath, + } = writeCatalogFixture(testDir, { + readmeExtraTreeBlock: '|-- agents/ # 9 specialized subagents for delegation', + }); + + 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, + PLUGIN_JSON_PATH: pluginJsonPath, + MARKETPLACE_JSON_PATH: marketplaceJsonPath, + }); + + assert.strictEqual(result.code, 0, `Should sync every block, got ${result.stderr}`); + const readme = fs.readFileSync(readmePath, 'utf8'); + assert.ok(!readme.includes('# 9 specialized subagents for delegation'), 'Should repair the second block too'); + assert.strictEqual( + readme.split('# 1 specialized subagents for delegation').length - 1, + 2, + 'Should correct both blocks' + ); + cleanupTestDir(testDir); + })) passed++; else failed++; + if (test('exits 0 when hooks.json does not exist', () => { const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', '/nonexistent/hooks.json'); assert.strictEqual(result.code, 0, 'Should skip when no hooks.json');