Files
cleveragents-core/features/steps/uko_analyzers_steps.py
T
freemo d990fc1b41
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 19s
CI / typecheck (pull_request) Successful in 37s
CI / security (pull_request) Successful in 37s
CI / unit_tests (pull_request) Successful in 2m30s
CI / integration_tests (pull_request) Successful in 2m57s
CI / docker (pull_request) Successful in 38s
CI / coverage (pull_request) Successful in 4m22s
CI / lint (push) Successful in 12s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 33s
CI / typecheck (push) Successful in 33s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m8s
CI / docker (push) Successful in 39s
CI / integration_tests (push) Successful in 3m9s
CI / coverage (push) Successful in 4m35s
CI / benchmark-publish (push) Successful in 15m41s
CI / benchmark-regression (pull_request) Successful in 28m58s
feat(uko): add analyzer plugin framework and initial domain analyzers
Implemented the analyzer plugin framework with AnalyzerProtocol,
AnalyzerRegistry for registration/discovery by file extension,
PythonAnalyzer (AST-based extraction of modules, classes, functions,
imports, docstrings), and MarkdownAnalyzer (section, code block, and
link extraction). Both analyzers produce well-formed UKO triples with
proper URI schemes.

ISSUES CLOSED: #551
2026-03-05 19:20:39 +00:00

552 lines
19 KiB
Python

"""Step definitions for the UKO Analyzer Plugin Framework feature."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from cleveragents.domain.models.acms.analyzers import (
AnalyzerProtocol,
AnalyzerRegistry,
UKOTriple,
)
from cleveragents.domain.models.acms.markdown_analyzer import MarkdownAnalyzer
from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer
__all__: list[str] = []
# ---------------------------------------------------------------------------
# UKOTriple steps
# ---------------------------------------------------------------------------
@given('a UKOTriple with subject "{subj}" predicate "{pred}" and object_uri "{obj}"')
def step_given_triple_with_obj_uri(
context: Any, subj: str, pred: str, obj: str
) -> None:
context.triple = UKOTriple(subject_uri=subj, predicate=pred, object_uri=obj)
@given('a UKOTriple with subject "{subj}" predicate "{pred}" and object_value "{val}"')
def step_given_triple_with_obj_value(
context: Any, subj: str, pred: str, val: str
) -> None:
context.triple = UKOTriple(subject_uri=subj, predicate=pred, object_value=val)
@then('the triple subject_uri should be "{expected}"')
def step_then_triple_subject(context: Any, expected: str) -> None:
assert context.triple.subject_uri == expected
@then('the triple predicate should be "{expected}"')
def step_then_triple_predicate(context: Any, expected: str) -> None:
assert context.triple.predicate == expected
@then('the triple object_uri should be "{expected}"')
def step_then_triple_object_uri(context: Any, expected: str) -> None:
assert context.triple.object_uri == expected
@then('the triple object_value should be "{expected}"')
def step_then_triple_object_value(context: Any, expected: str) -> None:
assert context.triple.object_value == expected
@then("the triple confidence should be {expected:g}")
def step_then_triple_confidence(context: Any, expected: float) -> None:
assert context.triple.confidence == expected
@then("creating a UKOTriple with empty subject_uri should raise ValueError")
def step_then_triple_empty_subject(context: Any) -> None:
try:
UKOTriple(subject_uri="", predicate="rdf:type", object_uri="uko:X")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a UKOTriple with empty predicate should raise ValueError")
def step_then_triple_empty_predicate(context: Any) -> None:
try:
UKOTriple(subject_uri="uko://a", predicate="", object_uri="uko:X")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("creating a UKOTriple with confidence {score:g} should raise ValueError")
def step_then_triple_bad_confidence(context: Any, score: float) -> None:
try:
UKOTriple(
subject_uri="uko://a",
predicate="rdf:type",
object_uri="uko:X",
confidence=score,
)
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("modifying the triple confidence should raise an error")
def step_then_triple_immutable(context: Any) -> None:
try:
context.triple.confidence = 0.5 # type: ignore[misc]
raise AssertionError("Expected frozen error")
except (TypeError, ValueError, AttributeError):
pass
# ---------------------------------------------------------------------------
# AnalyzerRegistry steps
# ---------------------------------------------------------------------------
@given("an AnalyzerRegistry with a PythonAnalyzer registered")
def step_given_registry_with_python(context: Any) -> None:
context.registry = AnalyzerRegistry()
context.registry.register(PythonAnalyzer())
@given("an empty AnalyzerRegistry")
def step_given_empty_registry(context: Any) -> None:
context.registry = AnalyzerRegistry()
@given("an AnalyzerRegistry with both Python and Markdown analyzers")
def step_given_registry_with_both(context: Any) -> None:
context.registry = AnalyzerRegistry()
context.registry.register(PythonAnalyzer())
context.registry.register(MarkdownAnalyzer())
@when('I look up the analyzer for extension "{ext}"')
def step_when_lookup_extension(context: Any, ext: str) -> None:
context.lookup_result = context.registry.get_for_extension(ext)
@when('I register another analyzer for "{ext}"')
def step_when_register_another(context: Any, ext: str) -> None:
"""Register a second MarkdownAnalyzer that also claims .py."""
class _DummyAnalyzer:
@property
def supported_extensions(self) -> frozenset[str]:
return frozenset({ext})
@property
def domain(self) -> str:
return "dummy"
def analyze(self, content: str, resource_uri: str) -> list[UKOTriple]:
return []
context.registry.register(_DummyAnalyzer())
@then('the analyzer domain should be "{expected}"')
def step_then_analyzer_domain(context: Any, expected: str) -> None:
assert context.lookup_result is not None
assert context.lookup_result.domain == expected
@then("the lookup result should be None")
def step_then_lookup_none(context: Any) -> None:
assert context.lookup_result is None
@then("registering a plain object should raise TypeError")
def step_then_register_plain_object(context: Any) -> None:
try:
context.registry.register("not an analyzer") # type: ignore[arg-type]
raise AssertionError("Expected TypeError")
except TypeError:
pass
@then("registering an analyzer with no extensions should raise ValueError")
def step_then_register_no_extensions(context: Any) -> None:
class _Empty:
@property
def supported_extensions(self) -> frozenset[str]:
return frozenset()
@property
def domain(self) -> str:
return "empty"
def analyze(self, content: str, resource_uri: str) -> list[UKOTriple]:
return []
try:
context.registry.register(_Empty())
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then('the registry should list extensions including "{ext1}" and "{ext2}"')
def step_then_list_extensions(context: Any, ext1: str, ext2: str) -> None:
exts = context.registry.list_extensions()
assert ext1 in exts, f"{ext1} not in {exts}"
assert ext2 in exts, f"{ext2} not in {exts}"
@then("the registry length should be {expected:d}")
def step_then_registry_length(context: Any, expected: int) -> None:
assert len(context.registry) == expected
# ---------------------------------------------------------------------------
# PythonAnalyzer steps
# ---------------------------------------------------------------------------
@given("a PythonAnalyzer instance")
def step_given_python_analyzer(context: Any) -> None:
context.analyzer = PythonAnalyzer()
@then("it should satisfy the AnalyzerProtocol")
def step_then_satisfies_protocol(context: Any) -> None:
assert isinstance(context.analyzer, AnalyzerProtocol)
@then('its supported extensions should include "{ext1}" and "{ext2}"')
def step_then_supported_extensions(context: Any, ext1: str, ext2: str) -> None:
exts = context.analyzer.supported_extensions
assert ext1 in exts, f"{ext1} not in {exts}"
assert ext2 in exts, f"{ext2} not in {exts}"
@when('I analyze Python content "{content}" with resource URI "{uri}"')
def step_when_analyze_python(context: Any, content: str, uri: str) -> None:
context.triples = context.analyzer.analyze(content.replace("\\n", "\n"), uri)
@when('I analyze Python content with a class "{cls_name}"')
def step_when_analyze_python_class(context: Any, cls_name: str) -> None:
code = f"class {cls_name}:\n pass\n"
context.triples = context.analyzer.analyze(code, "src/test.py")
@when('I analyze Python content with a function "{func_name}"')
def step_when_analyze_python_function(context: Any, func_name: str) -> None:
code = f"def {func_name}():\n pass\n"
context.triples = context.analyzer.analyze(code, "src/test.py")
@when('I analyze Python content with class "{cls_name}" and method "{method_name}"')
def step_when_analyze_python_method(
context: Any, cls_name: str, method_name: str
) -> None:
code = f"class {cls_name}:\n def {method_name}(self):\n pass\n"
context.triples = context.analyzer.analyze(code, "src/test.py")
@when('I analyze Python content with import "{mod_name}"')
def step_when_analyze_python_import(context: Any, mod_name: str) -> None:
code = f"import {mod_name}\n"
context.triples = context.analyzer.analyze(code, "src/test.py")
@when('I analyze Python content with from-import "{mod_name}"')
def step_when_analyze_python_from_import(context: Any, mod_name: str) -> None:
code = f"from {mod_name} import Path\n"
context.triples = context.analyzer.analyze(code, "src/test.py")
@when('I analyze Python content with docstring "{docstring}"')
def step_when_analyze_python_docstring(context: Any, docstring: str) -> None:
code = f'"""{docstring}"""\nx = 1\n'
context.triples = context.analyzer.analyze(code, "src/test.py")
@when("I analyze Python content with class docstring")
def step_when_analyze_python_class_docstring(context: Any) -> None:
code = 'class Foo:\n """Class doc."""\n pass\n'
context.triples = context.analyzer.analyze(code, "src/test.py")
@when("I analyze malformed Python content")
def step_when_analyze_malformed_python(context: Any) -> None:
context.triples = context.analyzer.analyze("def (\n broken", "src/bad.py")
@when('I analyze Python content with a class inheriting from "{base}"')
def step_when_analyze_python_inheritance(context: Any, base: str) -> None:
code = f"class Child({base}):\n pass\n"
context.triples = context.analyzer.analyze(code, "src/test.py")
@then(
'the triples should contain a triple with predicate "{pred}" and object_uri "{obj}"'
)
def step_then_triples_contain_pred_obj(context: Any, pred: str, obj: str) -> None:
found = any(t.predicate == pred and t.object_uri == obj for t in context.triples)
assert found, (
f"No triple with predicate={pred!r} object_uri={obj!r} in {context.triples}"
)
@then(
'the triples should contain a triple with predicate "{pred}" and object_value "{val}"'
)
def step_then_triples_contain_pred_val(context: Any, pred: str, val: str) -> None:
found = any(t.predicate == pred and t.object_value == val for t in context.triples)
assert found, (
f"No triple with predicate={pred!r} object_value={val!r} in {context.triples}"
)
@then(
"the triples should contain a triple with predicate"
' "uko:contains" linking module to class'
)
def step_then_triples_contain_module_contains_class(
context: Any,
) -> None:
found = any(
t.predicate == "uko:contains"
and "module" in t.subject_uri
and "class" in t.object_uri
for t in context.triples
)
assert found, f"No module-contains-class triple in {context.triples}"
@then(
'the triples should contain a method triple with predicate "{pred}" and object_value "{val}"'
)
def step_then_triples_contain_method(context: Any, pred: str, val: str) -> None:
found = any(t.predicate == pred and t.object_value == val for t in context.triples)
assert found, (
f"No method triple with predicate={pred!r} val={val!r} in {context.triples}"
)
@then(
'the triples should contain a triple with predicate "uko:references" referencing "{mod_name}"'
)
def step_then_triples_contain_import_ref(context: Any, mod_name: str) -> None:
found = any(
t.predicate == "uko:references" and mod_name in t.object_uri
for t in context.triples
)
assert found, f"No import reference to {mod_name!r} in {context.triples}"
@then("the triples should contain a docstring triple for the class")
def step_then_triples_contain_class_docstring(context: Any) -> None:
found = any(
t.predicate == "uko-doc:hasDocstring" and "class" in t.subject_uri.lower()
for t in context.triples
)
assert found, f"No class docstring triple in {context.triples}"
@then("the analyzer result should be an empty triple list")
def step_then_result_empty(context: Any) -> None:
assert context.triples == []
@then("analyzing empty content should raise ValueError")
def step_then_analyze_empty_content(context: Any) -> None:
try:
context.analyzer.analyze("", "src/test.py")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("analyzing with empty resource_uri should raise ValueError")
def step_then_analyze_empty_uri(context: Any) -> None:
try:
context.analyzer.analyze("x = 1\n", "")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# MarkdownAnalyzer steps
# ---------------------------------------------------------------------------
@given("a MarkdownAnalyzer instance")
def step_given_markdown_analyzer(context: Any) -> None:
context.analyzer = MarkdownAnalyzer()
@when('I analyze Markdown content "{content}" with resource URI "{uri}"')
def step_when_analyze_markdown(context: Any, content: str, uri: str) -> None:
context.triples = context.analyzer.analyze(content.replace("\\n", "\n"), uri)
@when('I analyze Markdown content with heading "{heading}"')
def step_when_analyze_markdown_heading(context: Any, heading: str) -> None:
content = f"# {heading}\n\nSome text here.\n"
context.triples = context.analyzer.analyze(content, "docs/test.md")
@when("I analyze Markdown with nested headings")
def step_when_analyze_markdown_nested(context: Any) -> None:
content = "# Top\n\n## Sub\n\nBody text.\n"
context.triples = context.analyzer.analyze(content, "docs/test.md")
@when("I analyze Markdown content with a Python code block")
def step_when_analyze_markdown_codeblock(context: Any) -> None:
content = "# Code\n\n```python\nprint('hello')\n```\n"
context.triples = context.analyzer.analyze(content, "docs/test.md")
@when('I analyze Markdown content with a link to "{url}"')
def step_when_analyze_markdown_link(context: Any, url: str) -> None:
content = f"See [example]({url}) for details.\n"
context.triples = context.analyzer.analyze(content, "docs/test.md")
@when("I analyze Markdown content with a bare code fence")
def step_when_analyze_markdown_bare_fence(context: Any) -> None:
content = "# Title\n\n```\nsome code\n```\n"
context.triples = context.analyzer.analyze(content, "docs/test.md")
@then("the triples should contain section containment for nested sections")
def step_then_nested_containment(context: Any) -> None:
# Document should contain Top section, Top section should contain Sub
doc_contains = [
t
for t in context.triples
if t.predicate == "uko:contains"
and "document" in t.subject_uri
and "section" in t.object_uri
]
section_contains = [
t
for t in context.triples
if t.predicate == "uko:contains"
and "section" in t.subject_uri
and "section" in t.object_uri
]
assert len(doc_contains) >= 1, f"Expected doc-contains-section, got {doc_contains}"
assert len(section_contains) >= 1, (
f"Expected section-contains-section, got {section_contains}"
)
@then("the triples should contain a code block triple")
def step_then_contains_codeblock(context: Any) -> None:
found = any(
t.predicate == "rdf:type" and t.object_uri == "uko-code:CodeBlock"
for t in context.triples
)
assert found, f"No code block triple in {context.triples}"
@then("analyzing empty markdown content should raise ValueError")
def step_then_md_analyze_empty_content(context: Any) -> None:
try:
context.analyzer.analyze("", "docs/test.md")
raise AssertionError("Expected ValueError")
except ValueError:
pass
@then("analyzing markdown with empty resource_uri should raise ValueError")
def step_then_md_analyze_empty_uri(context: Any) -> None:
try:
context.analyzer.analyze("# Title\n", "")
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# Coverage-gap steps
# ---------------------------------------------------------------------------
@when("I look up the analyzer for an empty extension")
def step_when_lookup_empty_extension(context: Any) -> None:
context.lookup_result = context.registry.get_for_extension("")
@then("the registry list_analyzers should return {expected:d} analyzers")
def step_then_list_analyzers(context: Any, expected: int) -> None:
result = context.registry.list_analyzers()
assert len(result) == expected, f"Expected {expected} analyzers, got {len(result)}"
@when("I analyze Python content with a function with docstring")
def step_when_analyze_python_func_docstring(context: Any) -> None:
code = 'def greet():\n """Say hello."""\n pass\n'
context.triples = context.analyzer.analyze(code, "src/test.py")
@then(
'the triples should contain a triple with predicate "uko-doc:hasDocstring" for a function'
)
def step_then_triples_func_docstring(context: Any) -> None:
found = any(
t.predicate == "uko-doc:hasDocstring" and "function" in t.subject_uri
for t in context.triples
)
assert found, f"No function docstring triple in {context.triples}"
@when("I analyze Python content with class and method with docstring")
def step_when_analyze_python_method_docstring(context: Any) -> None:
code = (
"class Svc:\n"
" def run(self):\n"
' """Execute the service."""\n'
" pass\n"
)
context.triples = context.analyzer.analyze(code, "src/test.py")
@then(
'the triples should contain a triple with predicate "uko-doc:hasDocstring" for a method'
)
def step_then_triples_method_docstring(context: Any) -> None:
found = any(
t.predicate == "uko-doc:hasDocstring"
and "function" in t.subject_uri
and "Svc" in t.subject_uri
for t in context.triples
)
assert found, f"No method docstring triple in {context.triples}"
@then('the analyzer domain property should be "{expected}"')
def step_then_analyzer_domain_prop(context: Any, expected: str) -> None:
assert context.analyzer.domain == expected
@when("I analyze Markdown with sibling headings at the same level")
def step_when_analyze_md_sibling_headings(context: Any) -> None:
content = "# A\n\n## B\n\n## C\n\nText\n"
context.triples = context.analyzer.analyze(content, "docs/test.md")
@then("each sibling section should be contained by the document")
def step_then_sibling_sections_contained_by_doc(context: Any) -> None:
# Sections B and C are both at level 2 under A; the second ## pops the
# first ## off the stack. Both should be contained by the # A section
# (which is contained by the document).
section_contains = [
t
for t in context.triples
if t.predicate == "uko:contains" and "section" in t.object_uri
]
assert len(section_contains) >= 3, (
f"Expected at least 3 contains-section triples, got {section_contains}"
)