From 8c560ac0cdb6129161cc0dae47b06fa7ff1f7977 Mon Sep 17 00:00:00 2001 From: sybertoooth7-lgtm Date: Mon, 5 Oct 2026 10:17:49 +0300 Subject: [PATCH] fix(ci): close catalog gate holes on README project tree The catalog gate validated only the first README project-tree block and had no skills/commands counterpart to the agents pattern, so: - README.md project tree (skills) and (commands) were never checked. - A stale count in a second tree block was invisible because the agents pattern used String.match with a non-global regex, which reads only the first occurrence. Both are now fail-closed. parseReadmeExpectations walks every tree block for all three categories via matchAll, reports the offending line number in the source label, and throws when an entry is missing entirely rather than skipping it. syncEnglishReadme uses replaceAllOrThrow so --write repairs every block instead of only the first; replaceOrThrow could not be reused because it replaces only the first match and its regex.test() probe is unsafe once the pattern carries the g flag. README.md:847 documented 67 agents against an actual 68 and was never checked, so it was corrected. Fixtures in tests/ci/catalog.test.js and tests/ci/validators.test.js now emit the skills and commands tree lines the gate requires, and both gain regression coverage that fails against the previous parser (5 new tests). --- README.md | 2 +- scripts/ci/catalog.js | 64 ++++++++++++++++++++++++------ tests/ci/catalog.test.js | 75 ++++++++++++++++++++++++++++++++++- tests/ci/validators.test.js | 79 ++++++++++++++++++++++++++++++++++++- 4 files changed, 205 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 95673de4b..0fe60d44e 100644 --- a/README.md +++ b/README.md @@ -848,7 +848,7 @@ ECC/ | |-- plugin.json # Plugin metadata and component paths | |-- marketplace.json # Marketplace catalog for /plugin marketplace add | -|-- agents/ # 67 specialized subagents for delegation +|-- agents/ # 68 specialized subagents for delegation | |-- planner.md # Feature implementation planning | |-- architect.md # System design decisions | |-- tdd-guide.md # Test-driven development diff --git a/scripts/ci/catalog.js b/scripts/ci/catalog.js index fffa37a3d..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 projectTreeAgentsMatch = readmeContent.match(/^\|\s*--\s*agents\/\s*#\s*(\d+)\s+specialized subagents for delegation\s*$/im); - if (!projectTreeAgentsMatch) { - 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)' } + ]; - expectations.push({ - category: 'agents', - mode: 'exact', - expected: Number(projectTreeAgentsMatch[1]), - source: 'README.md project tree (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*)$/im, + /^(\|\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 f05eb6bc2..4eee19058 100644 --- a/tests/ci/catalog.test.js +++ b/tests/ci/catalog.test.js @@ -46,6 +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 +|-- skills/ # ${counts.skills} reusable workflows loaded on demand +|-- commands/ # ${counts.commands} maintained slash-command shims +${options.extraTreeBlock || ''} | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | | --- | --- | --- | --- | --- | | Agents | PASS: ${tableCounts.agents} agents | @@ -170,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); @@ -292,6 +295,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');