Skip to content
Open
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
29 changes: 20 additions & 9 deletions src/coder_eval/criteria/cli_called.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,27 @@ def _record_matches(criterion: CliCalledCriterion, argv: list[str], record: dict
)

offset = 0
if criterion.verb is not None:
verb_tokens = criterion.verb.split()
# ORDERED prefix, not a token subset: `labellings confirm` must never be
# satisfied by `labellings unconfirm`, and a project name that happens to
# equal a subcommand must not stand in for the subcommand.
if positional[: len(verb_tokens)] != verb_tokens:
spellings = criterion.verb_spellings
if spellings:
# ORDERED prefix compared token by token — not a subset, and not a string
# startswith: `labellings confirm` must never be satisfied by
# `labellings unconfirm`, nor `projects list` by `projects lists`.
matched = next((tokens for tokens in spellings if positional[: len(tokens)] == tokens), None)
if matched is None:
return False
offset = len(verb_tokens)
# Offset comes from the candidate that matched, since spellings may differ in
# length. Validation rejects one spelling being a prefix of another, so at
# most one can match and this cannot depend on list order.
offset = len(matched)

if criterion.positional is not None:
expected = criterion.positional
if positional[offset : offset + len(expected)] != expected:
return False
# Otherwise the match is a prefix: `projects list` accepts
# `projects list dummy`, crediting a malformed invocation.
if criterion.exact_positional and len(positional) != offset + len(expected):
return False

if criterion.flags:
for name, predicate in criterion.flags.items():
Expand Down Expand Up @@ -279,9 +287,12 @@ def _check_impl(
if criterion.tool is not None:
facets.append(f"tool={criterion.tool!r}")
if criterion.verb is not None:
facets.append(f"verb={criterion.verb!r}")
# ' | ' rather than repr of the list: a bare list reads as "the verb is
# these tokens". A single verb renders exactly as it did before.
facets.append(f"verb={' | '.join(' '.join(t) for t in criterion.verb_spellings)!r}")
if criterion.positional is not None:
facets.append(f"positional={criterion.positional!r}")
exact = " exactly" if criterion.exact_positional else ""
facets.append(f"positional{exact}={criterion.positional!r}")
if criterion.flags:
facets.append(f"flags={sorted(criterion.flags)}")
wanted = ", ".join(facets)
Expand Down
84 changes: 75 additions & 9 deletions src/coder_eval/models/criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,13 +576,17 @@ class CliCalledCriterion(BaseSuccessCriterion):
"generated recorders never repeats it"
),
)
verb: str | None = Field(
verb: str | list[str] | None = Field(
default=None,
min_length=1,
description=(
"Whitespace-separated subcommand chain that must be an ORDERED PREFIX of the invocation's "
"non-flag arguments. Order matters, so 'labellings confirm' never matches "
"'labellings unconfirm'"
"non-flag arguments, compared token by token (so 'projects list' never matches "
"'projects lists'). Order matters, so 'labellings confirm' never matches "
"'labellings unconfirm'. A LIST matches if ANY entry does, for a verb the tool spells "
"several ways. Prefer listing full verbs over truncating one to cover several: a short "
"verb leaves the following tokens unconstrained, which is safe for a max_count 0 guard "
"(it fires on more) but NOT for a positive assertion, where 'projects' would credit "
"'projects delete' as readily as 'projects get'"
),
)
tool: str | None = Field(
Expand All @@ -591,7 +595,21 @@ class CliCalledCriterion(BaseSuccessCriterion):
)
positional: list[str] | None = Field(
default=None,
description="Non-flag arguments that must follow the verb, in order",
description=(
"Non-flag arguments that must follow the verb, in order. A PREFIX of what followed: "
"trailing arguments beyond these are unconstrained unless exact_positional is set"
),
)
exact_positional: bool = Field(
default=False,
description=(
"Require the non-flag arguments after the verb to be EXACTLY `positional`, with nothing "
"trailing. Without it `verb: 'projects list'` also matches `projects list dummy`. Set it "
"with `positional: []` to assert the verb took no arguments at all. Note the asymmetry "
"runs opposite to a short verb's: tightening suits a positive assertion, but on a "
"max_count 0 guard it makes the forbidden call EASIER to slip past, since one stray "
"argument stops the match"
),
)
flags: dict[str, FlagMatch] | None = Field(
default=None,
Expand Down Expand Up @@ -632,6 +650,18 @@ class CliCalledCriterion(BaseSuccessCriterion):
),
)

@property
def verb_spellings(self) -> list[list[str]]:
"""Each accepted verb as its token list; empty when there is no verb constraint.

One place splits the field, so the validator and the checker cannot disagree
about what a spelling is.
"""
if self.verb is None:
return []
spellings = [self.verb] if isinstance(self.verb, str) else self.verb
return [spelling.split() for spelling in spellings]

@model_validator(mode="after")
def _validate_bounds(self) -> CliCalledCriterion:
# min_count 0 with no upper bound is satisfied by every possible log, so
Expand All @@ -645,10 +675,46 @@ def _validate_bounds(self) -> CliCalledCriterion:
if self.max_count is not None and self.max_count < self.min_count:
msg = f"max_count ({self.max_count}) must be >= min_count ({self.min_count})"
raise ValueError(msg)
# min_length=1 counts characters, so " " passes it — and `" ".split()`
# is `[]`, an empty prefix that matches every record.
if self.verb is not None and not self.verb.strip():
msg = "cli_called verb must not be blank: a blank verb is an empty prefix and matches every record"
if self.verb is not None:
spellings = [self.verb] if isinstance(self.verb, str) else self.verb
# `verb: []` is falsy, so the "at least one facet" check below would let
# it through whenever positional/flags/tool is set — as "no verb
# constraint", quietly matching more than the author wrote.
if not spellings:
msg = "cli_called verb list must not be empty: drop the field to match any verb"
raise ValueError(msg)
# A character count would pass " ", and `" ".split()` is `[]` — an
# empty prefix that matches every record.
if any(not spelling.strip() for spelling in spellings):
msg = (
"cli_called verb must not be blank: a blank verb is an empty prefix and matches "
"every record"
)
raise ValueError(msg)
# One candidate being a prefix of another makes the match ambiguous: both
# accept the same argv but consume a different number of tokens, so the
# offset `positional` is measured from would depend on candidate order.
# Identical entries land here too, a prefix of itself.
token_lists = [spelling.split() for spelling in spellings]
for outer, shorter in enumerate(token_lists):
for inner, longer in enumerate(token_lists):
if outer != inner and longer[: len(shorter)] == shorter:
msg = (
f"cli_called verb {' '.join(shorter)!r} is a prefix of "
f"{' '.join(longer)!r}; both would match the same invocation while "
"consuming a different number of tokens, making the `positional` offset "
"ambiguous. List only the verbs you mean, or keep the shorter one alone."
)
raise ValueError(msg)
# `positional: []` alone asserts nothing (an empty slice equals an empty
# expectation), so an author writing it to mean "took no arguments" gets a
# silent no-op. exact_positional is what gives it meaning, and requiring the
# pair keeps "exactly nothing" distinct from "unset".
if self.exact_positional and self.positional is None:
msg = (
"cli_called exact_positional requires positional to be set. Use `positional: []` to "
"assert the verb took no arguments."
)
raise ValueError(msg)
# Falsiness-symmetric on purpose: `verb: ""` used to slip past an `is None`
# check here and then match EVERY record (empty prefix), silently scoring 1.0.
Expand Down
Loading
Loading