diff --git a/src/server/infra/connectors/rules/rules-connector.ts b/src/server/infra/connectors/rules/rules-connector.ts index 04b33eae7..e6cf811da 100644 --- a/src/server/infra/connectors/rules/rules-connector.ts +++ b/src/server/infra/connectors/rules/rules-connector.ts @@ -12,7 +12,7 @@ import type {IFileService} from '../../../core/interfaces/services/i-file-servic import type {IRuleTemplateService} from '../../../core/interfaces/services/i-rule-template-service.js' import {AGENT_CONNECTOR_CONFIG} from '../../../core/domain/entities/agent.js' -import {hasMcpToolsInBrvSection} from '../shared/constants.js' +import {extractInstalledAgentFromBrvSection, hasMcpToolsInBrvSection} from '../shared/constants.js' import {RuleFileManager} from '../shared/rule-file-manager.js' import {RULES_CONNECTOR_CONFIGS} from './rules-connector-config.js' @@ -134,7 +134,12 @@ export class RulesConnector implements IConnector { const content = await this.fileService.read(fullPath) const hasMcpTools = hasMcpToolsInBrvSection(content) - const installed = hasMarkers && !hasMcpTools + const footerAgent = extractInstalledAgentFromBrvSection(content) + // Footer present: only the agent named in the footer owns this rule file. + // Footer absent (legacy file pre-footer): fall back to marker presence so + // existing installs keep reporting installed until the next reinstall. + const matchesFooter = footerAgent === undefined ? true : footerAgent === agent + const installed = hasMarkers && !hasMcpTools && matchesFooter return { configExists: true, diff --git a/src/server/infra/connectors/shared/constants.ts b/src/server/infra/connectors/shared/constants.ts index 86ba9a706..0e424c5ec 100644 --- a/src/server/infra/connectors/shared/constants.ts +++ b/src/server/infra/connectors/shared/constants.ts @@ -12,15 +12,42 @@ export const BRV_RULE_MARKERS = { START: '', } as const +const sliceBrvSection = (content: string): string | undefined => { + const startIdx = content.indexOf(BRV_RULE_MARKERS.START) + const endIdx = content.indexOf(BRV_RULE_MARKERS.END) + if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return undefined + return content.slice(startIdx, endIdx) +} + /** * Checks if the BRV markers section contains MCP tool references (brv-query/brv-curate). * Only checks within the markers section to avoid false positives from user content. */ export const hasMcpToolsInBrvSection = (content: string): boolean => { - const startIdx = content.indexOf(BRV_RULE_MARKERS.START) - const endIdx = content.indexOf(BRV_RULE_MARKERS.END) - if (startIdx === -1 || endIdx === -1) return false - // eslint-disable-next-line unicorn/prefer-set-has - const brvSection = content.slice(startIdx, endIdx) + const brvSection = sliceBrvSection(content) + if (brvSection === undefined) return false return brvSection.includes('brv-query') || brvSection.includes('brv-curate') } + +/** + * Extracts the agent name from a `Generated by ByteRover CLI for X` footer + * inside the BRV markers section. Used to disambiguate which agent owns a + * shared rule file (Amp / Codex / OpenCode all map to AGENTS.md). + * + * Returns undefined when markers are missing, when the footer is absent + * (legacy pre-footer installs), or when the footer line is empty. + */ +export const extractInstalledAgentFromBrvSection = (content: string): string | undefined => { + const brvSection = sliceBrvSection(content) + if (brvSection === undefined) return undefined + + const tagWithDelimiter = `${BRV_RULE_TAG} ` + const tagIdx = brvSection.indexOf(tagWithDelimiter) + if (tagIdx === -1) return undefined + + const afterTag = brvSection.slice(tagIdx + tagWithDelimiter.length) + const newlineIdx = afterTag.indexOf('\n') + const agentLine = newlineIdx === -1 ? afterTag : afterTag.slice(0, newlineIdx) + const agent = agentLine.trim() + return agent.length === 0 ? undefined : agent +} diff --git a/test/unit/infra/connectors/rules/rules-connector.test.ts b/test/unit/infra/connectors/rules/rules-connector.test.ts new file mode 100644 index 000000000..09d4f13e1 --- /dev/null +++ b/test/unit/infra/connectors/rules/rules-connector.test.ts @@ -0,0 +1,96 @@ +import {expect} from 'chai' +import {mkdir, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' + +import type {Agent} from '../../../../../src/server/core/domain/entities/agent.js' +import type {ConnectorType} from '../../../../../src/server/core/domain/entities/connector-type.js' +import type {IRuleTemplateService} from '../../../../../src/server/core/interfaces/services/i-rule-template-service.js' + +import {RulesConnector} from '../../../../../src/server/infra/connectors/rules/rules-connector.js' +import {BRV_RULE_MARKERS, BRV_RULE_TAG} from '../../../../../src/server/infra/connectors/shared/constants.js' +import {FsFileService} from '../../../../../src/server/infra/file/fs-file-service.js' + +class StubTemplateService implements IRuleTemplateService { + async generateRuleContent(_agent: Agent, _type?: ConnectorType): Promise { + throw new Error('StubTemplateService.generateRuleContent should not be called from status flow') + } +} + +const buildAgentsMd = (footerAgent?: Agent): string => { + const footer = footerAgent === undefined ? '' : `\n---\n${BRV_RULE_TAG} ${footerAgent}` + return `${BRV_RULE_MARKERS.START}\nrule body${footer}\n${BRV_RULE_MARKERS.END}\n` +} + +describe('RulesConnector.status (shared AGENTS.md disambiguation)', () => { + let testDir: string + let connector: RulesConnector + + beforeEach(async () => { + testDir = path.join(tmpdir(), `rules-connector-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + await mkdir(testDir, {recursive: true}) + connector = new RulesConnector({ + fileService: new FsFileService(), + projectRoot: testDir, + templateService: new StubTemplateService(), + }) + }) + + afterEach(async () => { + await rm(testDir, {force: true, recursive: true}) + }) + + it('reports installed:true only for the agent named in the footer (Codex)', async () => { + await writeFile(path.join(testDir, 'AGENTS.md'), buildAgentsMd('Codex')) + + const codex = await connector.status('Codex') + const amp = await connector.status('Amp') + const opencode = await connector.status('OpenCode') + + expect(codex.installed, 'Codex should be reported installed').to.equal(true) + expect(amp.installed, 'Amp should NOT be reported installed for a Codex-authored AGENTS.md').to.equal(false) + expect(opencode.installed, 'OpenCode should NOT be reported installed for a Codex-authored AGENTS.md').to.equal(false) + }) + + it('reports installed:true for the agent named in the footer (Amp)', async () => { + await writeFile(path.join(testDir, 'AGENTS.md'), buildAgentsMd('Amp')) + + const codex = await connector.status('Codex') + const amp = await connector.status('Amp') + + expect(amp.installed).to.equal(true) + expect(codex.installed).to.equal(false) + }) + + it('falls back to the legacy behavior when the BRV section has no footer (markers => installed for all sharing agents)', async () => { + await writeFile(path.join(testDir, 'AGENTS.md'), buildAgentsMd()) + + const codex = await connector.status('Codex') + const amp = await connector.status('Amp') + const opencode = await connector.status('OpenCode') + + expect(codex.installed, 'legacy footer-less file should remain installed for Codex').to.equal(true) + expect(amp.installed, 'legacy footer-less file should remain installed for Amp').to.equal(true) + expect(opencode.installed, 'legacy footer-less file should remain installed for OpenCode').to.equal(true) + }) + + it('does not mistakenly mark Amp installed when only Claude Code (CLAUDE.md) was installed', async () => { + await writeFile(path.join(testDir, 'CLAUDE.md'), buildAgentsMd('Claude Code')) + + const claudeCode = await connector.status('Claude Code') + const amp = await connector.status('Amp') + + expect(claudeCode.installed).to.equal(true) + expect(amp.installed).to.equal(false) + expect(amp.configExists, 'Amp\'s AGENTS.md does not exist in this project').to.equal(false) + }) + + it('still reports installed:false when MCP tool markers are present (existing rule)', async () => { + const content = `${BRV_RULE_MARKERS.START}\nuse the brv-query tool\n---\n${BRV_RULE_TAG} Codex\n${BRV_RULE_MARKERS.END}\n` + await writeFile(path.join(testDir, 'AGENTS.md'), content) + + const codex = await connector.status('Codex') + + expect(codex.installed, 'a section that contains brv-query should not count as a rules install').to.equal(false) + }) +}) diff --git a/test/unit/infra/connectors/shared/constants.test.ts b/test/unit/infra/connectors/shared/constants.test.ts new file mode 100644 index 000000000..339c1073e --- /dev/null +++ b/test/unit/infra/connectors/shared/constants.test.ts @@ -0,0 +1,72 @@ +import {expect} from 'chai' + +import { + BRV_RULE_MARKERS, + BRV_RULE_TAG, + extractInstalledAgentFromBrvSection, + hasMcpToolsInBrvSection, +} from '../../../../../src/server/infra/connectors/shared/constants.js' + +const wrapWithBrvSection = (footer: string): string => + `Some user content\n${BRV_RULE_MARKERS.START}\nrule body\n---\n${footer}\n${BRV_RULE_MARKERS.END}\nMore content` + +describe('shared/constants', () => { + describe('extractInstalledAgentFromBrvSection', () => { + it('returns the agent name when the footer is present inside the BRV section', () => { + const content = wrapWithBrvSection(`${BRV_RULE_TAG} Codex`) + expect(extractInstalledAgentFromBrvSection(content)).to.equal('Codex') + }) + + it('returns multi-word agent names verbatim (e.g. "Augment Code")', () => { + const content = wrapWithBrvSection(`${BRV_RULE_TAG} Augment Code`) + expect(extractInstalledAgentFromBrvSection(content)).to.equal('Augment Code') + }) + + it('returns undefined when the BRV section has no footer (legacy file)', () => { + const content = `${BRV_RULE_MARKERS.START}\nrule body without footer\n${BRV_RULE_MARKERS.END}` + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + + it('returns undefined when start marker is missing', () => { + const content = `rule body\n---\n${BRV_RULE_TAG} Codex\n${BRV_RULE_MARKERS.END}` + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + + it('returns undefined when end marker is missing', () => { + const content = `${BRV_RULE_MARKERS.START}\nrule body\n---\n${BRV_RULE_TAG} Codex` + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + + it('ignores a footer that appears outside the BRV section', () => { + const content = `Earlier in the file: ${BRV_RULE_TAG} Codex\n${BRV_RULE_MARKERS.START}\nrule body\n${BRV_RULE_MARKERS.END}` + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + + it('returns undefined when end marker precedes start marker', () => { + const content = `${BRV_RULE_MARKERS.END}\nstuff\n${BRV_RULE_MARKERS.START}\n${BRV_RULE_TAG} Codex` + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + + it('returns undefined when the footer line is blank after the tag', () => { + const content = wrapWithBrvSection(`${BRV_RULE_TAG} `) + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + + it('does not match a malformed tag with no space delimiter (e.g. "...CLI forXxx")', () => { + const content = wrapWithBrvSection(`${BRV_RULE_TAG}Xxx`) + expect(extractInstalledAgentFromBrvSection(content)).to.equal(undefined) + }) + }) + + describe('hasMcpToolsInBrvSection (regression guard)', () => { + it('detects brv-query inside the BRV section', () => { + const content = `${BRV_RULE_MARKERS.START}\nuse the brv-query tool\n${BRV_RULE_MARKERS.END}` + expect(hasMcpToolsInBrvSection(content)).to.equal(true) + }) + + it('does not flag brv-query that appears only outside the BRV section', () => { + const content = `brv-query mentioned outside\n${BRV_RULE_MARKERS.START}\nno tools here\n${BRV_RULE_MARKERS.END}` + expect(hasMcpToolsInBrvSection(content)).to.equal(false) + }) + }) +})