Files
HAL9000 df8bc06f58 feat(cli): implement context list and context add CLI commands for ACMS
Implemented  command to display all indexed entries with
tier, size, and last-accessed metadata. Implemented  command
to index files/directories with optional --tag and --policy flags.

- Added features/acms_context_list_add_cli.feature with 27 scenarios
- Added test step definitions using Typer CliRunner for real CLI invocation
- Added context.py implementation with --tag, --policy, --format flags
- Updated CHANGELOG.md entry under [Unreleased] > Added
- Removed out-of-scope A2A test files that belonged to a different Epic

ISSUES CLOSED: #9585
2026-06-14 09:09:24 -04:00

462 lines
16 KiB
Python

"""Step definitions for diff review artifact BDD tests."""
from __future__ import annotations
import json
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
SpecChangeSet,
)
from cleveragents.domain.models.core.diff_review import (
DiffBuilder,
DiffSerializer,
ReviewArtifact,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_entry(
operation: str,
path: str,
resource_id: str,
plan_id: str,
*,
before_hash: str | None = None,
after_hash: str | None = None,
after_mode: int | None = None,
) -> ChangeEntry:
"""Build a ChangeEntry with sensible defaults."""
kwargs: dict[str, Any] = {
"plan_id": plan_id,
"resource_id": resource_id,
"tool_name": "builtin/test-tool",
"operation": ChangeOperation(operation),
"path": path,
}
if before_hash is not None:
kwargs["before_hash"] = before_hash
if after_hash is not None:
kwargs["after_hash"] = after_hash
if after_mode is not None:
kwargs["after_mode"] = after_mode
return ChangeEntry(**kwargs)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given('a plan ID "{plan_id}"')
def step_given_plan_id(context: Context, plan_id: str) -> None:
context.plan_id = plan_id
context.before_contents = {} # dict[str, str]
context.after_contents = {} # dict[str, str]
context.resource_names = {} # dict[str, str]
@given("an empty changeset for the plan")
def step_given_empty_changeset(context: Context) -> None:
context.changeset = SpecChangeSet(plan_id=context.plan_id)
# ---------------------------------------------------------------------------
# Change entry creation
# ---------------------------------------------------------------------------
@given('a change entry with operation "{op}" for path "{path}" in resource "{res}"')
def step_add_change_entry(context: Context, op: str, path: str, res: str) -> None:
entry = _make_entry(op, path, res, context.plan_id)
context.changeset.add_change(entry)
@given(
'a delete entry for path "{path}" in resource "{res}" with before_hash "{bhash}"'
)
def step_add_delete_with_hash(
context: Context, path: str, res: str, bhash: str
) -> None:
entry = _make_entry("delete", path, res, context.plan_id, before_hash=bhash)
context.changeset.add_change(entry)
@given('a create entry for path "{path}" in resource "{res}" with after_hash "{ahash}"')
def step_add_create_with_hash(
context: Context, path: str, res: str, ahash: str
) -> None:
entry = _make_entry("create", path, res, context.plan_id, after_hash=ahash)
context.changeset.add_change(entry)
@given(
'a hashed change entry "{op}" path "{path}" '
'resource "{res}" before_hash "{bhash}" after_hash "{ahash}"'
)
def step_add_entry_with_hashes(
context: Context,
op: str,
path: str,
res: str,
bhash: str,
ahash: str,
) -> None:
entry = _make_entry(
op, path, res, context.plan_id, before_hash=bhash, after_hash=ahash
)
context.changeset.add_change(entry)
@given('a moded change entry "{op}" path "{path}" resource "{res}" after_mode {mode}')
def step_add_entry_with_mode(
context: Context, op: str, path: str, res: str, mode: str
) -> None:
# mode comes as e.g. "0o755" — parse it
mode_int = int(mode, 0)
entry = _make_entry(op, path, res, context.plan_id, after_mode=mode_int)
context.changeset.add_change(entry)
# ---------------------------------------------------------------------------
# Content setup
# ---------------------------------------------------------------------------
@given('before content for "{path}" is "{content}"')
def step_before_content(context: Context, path: str, content: str) -> None:
# Interpret escape sequences
context.before_contents[path] = content.replace("\\n", "\n").replace(
"\\x00", "\x00"
)
@given('after content for "{path}" is "{content}"')
def step_after_content(context: Context, path: str, content: str) -> None:
context.after_contents[path] = content.replace("\\n", "\n").replace("\\x00", "\x00")
@given('after content for "{path}" is a string of {count} characters')
def step_after_content_large(context: Context, path: str, count: str) -> None:
context.after_contents[path] = "x" * int(count)
@given('resource name "{res_id}" is "{name}"')
def step_resource_name(context: Context, res_id: str, name: str) -> None:
context.resource_names[res_id] = name
# ---------------------------------------------------------------------------
# Build actions
# ---------------------------------------------------------------------------
@when("I build a review artifact from the changeset")
def step_build_artifact(context: Context) -> None:
builder = DiffBuilder()
context.artifact = builder.build(
context.changeset,
before_contents=context.before_contents,
after_contents=context.after_contents,
resource_names=context.resource_names,
)
@when("I build a review artifact with max diff size {size}")
def step_build_artifact_limited(context: Context, size: str) -> None:
builder = DiffBuilder(max_diff_size=int(size))
context.artifact = builder.build(
context.changeset,
before_contents=context.before_contents,
after_contents=context.after_contents,
resource_names=context.resource_names,
)
# ---------------------------------------------------------------------------
# Serialization actions
# ---------------------------------------------------------------------------
@when("I serialize the artifact to plain text")
def step_serialize_plain(context: Context) -> None:
context.plain_text = DiffSerializer.to_plain(context.artifact)
@when("I serialize the artifact to JSON")
def step_serialize_json(context: Context) -> None:
context.json_text = DiffSerializer.to_json(context.artifact)
@when("I serialize the artifact to rich format")
def step_serialize_rich(context: Context) -> None:
context.rich_text = DiffSerializer.to_rich(context.artifact)
# ---------------------------------------------------------------------------
# Review artifact assertions
# ---------------------------------------------------------------------------
@then("the review artifact should have {n} total changes")
def step_assert_total_changes(context: Context, n: str) -> None:
artifact: ReviewArtifact = context.artifact
assert artifact.total_changes == int(n), (
f"Expected {n} total changes, got {artifact.total_changes}"
)
@then("the review artifact should have {n} groups")
def step_assert_groups(context: Context, n: str) -> None:
artifact: ReviewArtifact = context.artifact
assert len(artifact.groups) == int(n), (
f"Expected {n} groups, got {len(artifact.groups)}"
)
@then("the review artifact should not be truncated")
def step_assert_not_truncated(context: Context) -> None:
assert not context.artifact.truncated, "Artifact should not be truncated"
@then("the review artifact should be truncated")
def step_assert_truncated(context: Context) -> None:
assert context.artifact.truncated, "Artifact should be truncated"
@then("the review artifact plan ID should match the changeset plan ID")
def step_plan_id_matches(context: Context) -> None:
assert context.artifact.plan_id == context.changeset.plan_id
@then("the review artifact changeset ID should match the changeset ID")
def step_changeset_id_matches(context: Context) -> None:
assert context.artifact.changeset_id == context.changeset.changeset_id
# ---------------------------------------------------------------------------
# Group assertions
# ---------------------------------------------------------------------------
@then("group {g} should have {n} entries")
def step_group_entries(context: Context, g: str, n: str) -> None:
group = context.artifact.groups[int(g)]
assert len(group.entries) == int(n), (
f"Expected {n} entries in group {g}, got {len(group.entries)}"
)
@then('group {g} should have resource id "{rid}"')
def step_group_resource_id(context: Context, g: str, rid: str) -> None:
group = context.artifact.groups[int(g)]
assert group.resource_id == rid, (
f"Expected resource id '{rid}', got '{group.resource_id}'"
)
@then("group {g} should have {n} added")
def step_group_added(context: Context, g: str, n: str) -> None:
group = context.artifact.groups[int(g)]
assert group.added == int(n), f"Expected {n} added, got {group.added}"
@then("group {g} should have {n} modified")
def step_group_modified(context: Context, g: str, n: str) -> None:
group = context.artifact.groups[int(g)]
assert group.modified == int(n), f"Expected {n} modified, got {group.modified}"
@then("group {g} should have {n} deleted")
def step_group_deleted(context: Context, g: str, n: str) -> None:
group = context.artifact.groups[int(g)]
assert group.deleted == int(n), f"Expected {n} deleted, got {group.deleted}"
@then("group {g} should have {n} renamed")
def step_group_renamed(context: Context, g: str, n: str) -> None:
group = context.artifact.groups[int(g)]
assert group.renamed == int(n), f"Expected {n} renamed, got {group.renamed}"
# ---------------------------------------------------------------------------
# Entry assertions
# ---------------------------------------------------------------------------
@then('entry {e} in group {g} should have change type "{ct}"')
def step_entry_change_type(context: Context, e: str, g: str, ct: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.change_type == ct, (
f"Expected change_type '{ct}', got '{entry.change_type}'"
)
@then('entry {e} in group {g} should have path "{path}"')
def step_entry_path(context: Context, e: str, g: str, path: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.path == path, f"Expected path '{path}', got '{entry.path}'"
@then("entry {e} in group {g} should be binary")
def step_entry_binary(context: Context, e: str, g: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.is_binary, "Entry should be binary"
@then("entry {e} in group {g} should be a rename")
def step_entry_is_rename(context: Context, e: str, g: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.is_rename, "Entry should be a rename"
@then("entry {e} in group {g} should not be a rename")
def step_entry_not_rename(context: Context, e: str, g: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert not entry.is_rename, "Entry should not be a rename"
@then('entry {e} in group {g} rename should be from "{src}" to "{dst}"')
def step_entry_rename_paths(
context: Context, e: str, g: str, src: str, dst: str
) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.rename_from == src, (
f"Expected rename_from '{src}', got '{entry.rename_from}'"
)
assert entry.rename_to == dst, (
f"Expected rename_to '{dst}', got '{entry.rename_to}'"
)
@then("entry {e} in group {g} should be truncated")
def step_entry_truncated(context: Context, e: str, g: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.truncated, "Entry should be truncated"
@then('entry {e} in group {g} should have before hash "{h}"')
def step_entry_before_hash(context: Context, e: str, g: str, h: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.before_hash == h, (
f"Expected before_hash '{h}', got '{entry.before_hash}'"
)
@then('entry {e} in group {g} should have after hash "{h}"')
def step_entry_after_hash(context: Context, e: str, g: str, h: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.after_hash == h, f"Expected after_hash '{h}', got '{entry.after_hash}'"
@then("entry {e} in group {g} should have file mode {mode}")
def step_entry_file_mode(context: Context, e: str, g: str, mode: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.file_mode == int(mode), (
f"Expected file_mode {mode}, got {entry.file_mode}"
)
# ---------------------------------------------------------------------------
# Diff text assertions
# ---------------------------------------------------------------------------
@then('the diff text in entry {e} of group {g} should contain "{text}"')
def step_diff_contains(context: Context, e: str, g: str, text: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert text in entry.diff_text, (
f"Expected diff_text to contain '{text}', got: {entry.diff_text!r}"
)
@then("the diff text in entry {e} of group {g} should be empty")
def step_diff_empty(context: Context, e: str, g: str) -> None:
entry = context.artifact.groups[int(g)].entries[int(e)]
assert entry.diff_text == "", f"Expected empty diff_text, got: {entry.diff_text!r}"
# ---------------------------------------------------------------------------
# Serialization assertions
# ---------------------------------------------------------------------------
@then('the plain text should contain "{text}"')
def step_plain_contains(context: Context, text: str) -> None:
assert text in context.plain_text, f"Expected plain text to contain '{text}'"
@then("the JSON output should be valid JSON")
def step_json_valid(context: Context) -> None:
try:
json.loads(context.json_text)
except json.JSONDecodeError as exc:
raise AssertionError(f"Invalid JSON: {exc}") from exc
@then('the JSON output should contain key "{key}"')
def step_json_has_key(context: Context, key: str) -> None:
# Support both context.json_text (diff review tests) and
# context.json_output (ACMS context CLI tests)
if hasattr(context, "json_output"):
data = context.json_output
else:
data = json.loads(context.json_text)
assert key in data, f"Expected key '{key}' in JSON output"
@then('the rich text should contain "{text}"')
def step_rich_contains(context: Context, text: str) -> None:
assert text in context.rich_text, f"Expected rich text to contain '{text}'"
@then('the JSON output should have entry with before hash "{h}"')
def step_json_entry_before_hash(context: Context, h: str) -> None:
data = json.loads(context.json_text)
for group in data["groups"]:
for entry in group["entries"]:
if entry.get("before_hash") == h:
return
raise AssertionError(f"No entry with before_hash '{h}' in JSON output")
@then('the JSON output should have entry with after hash "{h}"')
def step_json_entry_after_hash(context: Context, h: str) -> None:
data = json.loads(context.json_text)
for group in data["groups"]:
for entry in group["entries"]:
if entry.get("after_hash") == h:
return
raise AssertionError(f"No entry with after_hash '{h}' in JSON output")
@then("the JSON output should have entry with file mode")
def step_json_entry_file_mode(context: Context) -> None:
data = json.loads(context.json_text)
for group in data["groups"]:
for entry in group["entries"]:
if "file_mode" in entry:
return
raise AssertionError("No entry with file_mode in JSON output")
@then('the JSON output should have entry with rename from "{path}"')
def step_json_entry_rename_from(context: Context, path: str) -> None:
data = json.loads(context.json_text)
for group in data["groups"]:
for entry in group["entries"]:
if entry.get("rename_from") == path:
return
raise AssertionError(f"No entry with rename_from '{path}' in JSON output")