Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/infermatrix_copilot/sdk/v1/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 37 additions & 0 deletions test/test_sdk_knowledge_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading