diff --git a/src/infermatrix_copilot/sdk/v1/knowledge.py b/src/infermatrix_copilot/sdk/v1/knowledge.py index 17fe1ce1..aeb534fd 100644 --- a/src/infermatrix_copilot/sdk/v1/knowledge.py +++ b/src/infermatrix_copilot/sdk/v1/knowledge.py @@ -478,13 +478,24 @@ def build_prompt(self, batch: KnowledgeEvidenceBatch) -> str: @staticmethod def _section_rule_ids(section: str) -> tuple[str, ...]: - """The rule IDs every heading in a section carries, the declared - one first: a nested ``###`` heading is a rule heading too.""" - ids: list[str] = [] - for match in _RULE_HEADING_ID.finditer(section): - if match.group("rule") not in ids: - ids.append(match.group("rule")) - return tuple(ids) + """The rule IDs every heading in a section carries, in order, the + declared one first: a nested ``###`` heading is a rule heading too. + Repeats are kept so callers can reject a section that heads one ID + twice (``## X-2`` over ``### X-2``, or two ``### X-3``).""" + return tuple( + match.group("rule") for match in _RULE_HEADING_ID.finditer(section) + ) + + @classmethod + def _repeated_section_rule_id(cls, section: str) -> str: + """The first rule ID a section heads more than once, else ''.""" + seen: set[str] = set() + for heading_id in cls._section_rule_ids(section): + if heading_id in seen: + return heading_id + seen.add(heading_id) + return "" + @staticmethod def _rule_exists(page_text: str, rule_id: str) -> bool: @@ -643,11 +654,18 @@ def validate_proposals( "rule_id duplicates an earlier proposal on " f"{proposed_ids[rule_id]}" ) + if not reason and self._repeated_section_rule_id(section): + reason = ( + "section heads rule_id " + f"{self._repeated_section_rule_id(section)} more " + "than once" + ) if not reason: # Every rule heading the section introduces, not # only the declared one: a nested heading carrying # another page's ID would otherwise land unchecked. for heading_id in self._section_rule_ids(section): + owner = existing_ids.get(heading_id) nested = heading_id != rule_id if owner is None and nested and heading_id in proposed_ids: @@ -948,6 +966,8 @@ def apply( r"^##\s+", proposal.section_markdown, re.MULTILINE )) != 1 or not 80 <= len(proposal.section_markdown) <= _MAX_SECTION_CHARS + or self._repeated_section_rule_id(proposal.section_markdown) + or not proposal.sources or len(proposal.sources) > 10 or any( diff --git a/test/test_sdk_knowledge_v1.py b/test/test_sdk_knowledge_v1.py index f899b298..b3017c4a 100644 --- a/test/test_sdk_knowledge_v1.py +++ b/test/test_sdk_knowledge_v1.py @@ -788,3 +788,40 @@ def test_nested_headings_inside_a_section_are_checked_too(workspace): assert result.rejected[0].reason == ( "nested rule heading X-2 already exists on knowledge/repos/x/rules.md" ) + + +@pytest.mark.parametrize( + "extra", + [ + "\n\n### X-2 — the declared ID repeated in a nested heading\n\n- Required: reject. ^[PR #7]", + "\n\n### X-4 — first nested\n\n- Required: a. ^[PR #7]\n\n### X-4 — second nested\n\n- Required: b. ^[PR #7]", + ], + ids=["parent-and-nested", "two-nested"], +) +def test_section_heading_one_rule_id_twice_is_rejected(workspace, extra): + """A section may not head one ID twice, whether it repeats its own + declared ID under ``###`` or two nested headings share an ID: the + validators downstream do not enforce ID uniqueness.""" + curator = KnowledgeCurator(workspace) + result = curator.validate_proposals( + _document(_rule(section=SECTION + extra)), _batch() + ) + + assert result.accepted == () + repeated = "X-2" if "X-2 — the declared" in extra else "X-4" + assert result.rejected[0].reason == ( + f"section heads rule_id {repeated} more than once" + ) + + +def test_apply_refuses_a_section_that_repeats_a_rule_id(workspace): + curator = KnowledgeCurator(workspace) + validation = curator.validate_proposals(_document(_rule()), _batch()) + smuggled = replace( + validation.accepted[0], + section_markdown=SECTION + "\n\n### X-2 — repeated\n\n- Required: reject. ^[PR #7]", + ) + tampered = replace(validation, accepted=(smuggled,)) + + with pytest.raises(KnowledgeCurationError, match="apply-time integrity"): + curator.apply(tampered, updated_on="2026-08-29")