Skip to content

Commit 2dddaa5

Browse files
authored
fix(workflows): stop offering a condition correction that inverts it (#4230)
* fix(workflows): stop offering a correction that would not repair the condition `format_condition_correction` wraps whatever it is handed — correct for a formatter, wrong to advertise as paste-ready for two inputs it cannot repair. Both reach the never-evaluated branch, and both were being suggested: condition: " " -> "{{ }}" {{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}" Measured what pasting each one does, rather than assuming: " " is True -> "{{ }}" is False "{{ inputs.name == 'abc" is True -> "{{ inputs.name == 'abc }}" is False The blank core interpolates to the empty string. The open quote survives wrapping, so the raw-close fallback evaluates a truncated comparison whose result is the string "False", which `evaluate_condition` then reads as the `false` keyword. In both cases the advertised correction silently inverts the condition — a different defect, not a fix. Add `format_condition_remediation`, which the three step validators now call in place of hand-building the sentence. It offers the correction only when wrapping would actually repair the input, and otherwise names the fault, matching the call already made for `condition_has_malformed_expression_block`. `_has_unbalanced_quote` uses the same left-to-right scan as `_find_block_close` and `_strip_stray_delimiters`, so "inside a string" means the same thing everywhere in this module. I had the second case wrong at first and said the wrapped form "stays always true" — the new test caught it, and the message and docstring now say inverted. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 133 passed (was 116) - tests/unit + tests/test_workflows.py 1216 passed (was 1199), 22 failed before and after — the pre-existing symlink tests needing Windows elevation. Mutation-checked: removing either gate fails exactly the 9 new parametrised cases and nothing else. * fix(workflows): withhold the correction whenever wrapping cannot repair the core Copilot found two more holes in the previous commit, and both were real. 1. The quote-balance gate was not sufficient. `inputs.name ==` has a non-empty, quote-balanced core, so a correction was still advertised: inputs.name == -> "{{ inputs.name == }}" True -> False The missing operand resolves to None, the comparison evaluates False, and the author again trades an always-true condition for an always-false one. 2. The message named the wrong mechanism. It said the wrapped form goes through the raw-close fallback. Measured: `_is_single_expression("{{ inputs.name == 'abc }}")` is True, so it takes the typed fast path instead. Stop enumerating broken shapes. `_wrapping_would_not_repair` now reports the first reason wrapping cannot yield the intended expression — empty core, unclosed quote, unbalanced bracket, or an operator missing an operand — and the advice names it instead of offering a suggestion. `_has_incomplete_operand` reads `_COMPARISON_OPERATORS`, extracted from `_evaluate_simple_expression`, so the check cannot drift from what the evaluator actually splits on. The messages now describe the text itself rather than the interpolator path it will take: asserting an internal route is what made the previous two versions wrong. Tests state the property rather than listing shapes: `test_every_offered_correction_is_a_complete_expression` asserts that anything advertised as paste-ready survives both validators, so a new malformed shape is caught by the invariant rather than by another fixture row. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 182 passed (was 133) - tests/unit + tests/test_workflows.py 1282 passed (was 1233), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked, each gate against its own cases: dropping the operand gate fails 12, the bracket gate 3, and removing an operator from `_COMPARISON_OPERATORS` fails 1. That last one passed vacuously at first because the test parametrised over the constant it was checking — the same can't-fail shape this module rejects — so it is hard-coded now. * fix(workflows): check every operator position and match bracket types Copilot found two more, and both were right. 1. `_has_incomplete_operand` inspected only the first occurrence of each operator, and its end-of-string check covered only trailing boolean keywords: inputs.a == inputs.b == -> correction still offered, True -> False and inputs.ready -> correction still offered, True -> False That is the same defect this PR's parent commit fixed one level up — stopping at the first match — reintroduced in the gate meant to prevent it. It now splits on every top-level occurrence and requires every operand to be non-empty. A stripped core also loses the space that delimits a word operator, so `inputs.a not in` matched nothing. `_WORD_OPERATORS` is derived from `_COMPARISON_OPERATORS` and matched against both ends without it. 2. `_has_unbalanced_bracket` counted depth, so mismatched types cancelled: inputs.f(] -> correction still offered, True -> False It tracks opener types on a stack and rejects a non-matching closer. The docstring Copilot flagged at line 950 is unchanged on purpose: it does not attribute the inversion to the raw-close fallback, it records that two earlier versions did and were wrong because `_is_single_expression` accepts the wrapped form. That thread is marked outdated and refers to the text before `6944920`. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 207 passed (was 182) - tests/unit + tests/test_workflows.py 1307 passed (was 1282), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked: depth-only brackets fails 9, first-occurrence-only fails 3, dropping the end-of-core word scan fails 16. * fix(workflows): reject an unregistered filter and prose before suggesting a wrap Copilot's remaining point was the strongest one on this PR: `reason is None` only excluded four structural shapes, and structural shapes cannot establish that wrapping produces a working expression. Two inputs proved it: inputs.items | length -> offered; wrapped form raises ValueError("unknown filter 'length'") he said "hi"\nthen left -> offered; wrapped form resolves to None, True -> False The first replaces an always-true condition with a crash, the second inverts it. Two checks close the gap, both reading the evaluator rather than guessing: - `_unregistered_filter` walks the top-level `|` segments and reports the first name missing from `_REGISTERED_FILTERS`, the same tuple `_apply_filter` raises on. - `_reads_as_prose` reports a core that is several bare terms with no operator and no filter joining them. Quoted spans and bracketed groups are skipped, so `inputs.f('a b')` and `inputs.name == 'two words'` are unaffected, and a `not ` prefix is allowed. `he said "hi"\nthen left` was in `OFFERED_CORRECTION_INPUTS` only because that fixture was built as `TRICKY_CONDITIONS + [...]`. TRICKY_CONDITIONS exists to exercise the formatter's quoting and deliberately contains prose, so reusing it asserted the wrong thing. The list is explicit now, and the tricky-quoting entries that really are expressions are carried over by hand — adding prose to that fixture can no longer widen what this invariant claims. `inputs.tags | length > 0` was also mine, and `length` is not a registered filter; it is `join(',')` now. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 212 passed (was 207) - tests/unit + tests/test_workflows.py 1312 passed (was 1307), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked: dropping either new gate fails 3 cases and nothing else. * fix(workflows): ask the evaluator whether the core parses, instead of guessing Copilot found two more shapes the structural gates did not know about: inputs.tags | join -> offered; `join` is registered, but with no argument `_apply_filter` raises ValueError inputs.count+1 -> offered; the evaluator has no arithmetic, reads it as a key named "count+1", and the wrapped form resolves to None, turning a truthy condition false That is the fifth shape in four rounds, which is the argument against enumerating shapes at all. Replace the two structural checks with two that read the evaluator: - `_evaluator_rejects` runs the core through `_evaluate_simple_expression` against a probe namespace and returns its own error. Any filter under an unknown name or in an unsupported form is now reported by the code that will actually run, so `_unregistered_filter` — which restated the filter table — is gone. - `_is_not_a_bare_path` covers what a probe cannot: a single-term core is resolved as a path lookup, so every dotted segment must be an identifier. `count+1` is not, and neither is prose, so `_reads_as_prose` is gone too. The probe namespace resolves roots but not leaves, deliberately. A namespace that answers every lookup also answers `inputs.count+1`, hiding the shape the probe exists to expose. Net effect is two helpers fewer and no restatement of the evaluator's tables. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 230 passed (was 212) - tests/unit + tests/test_workflows.py 1330 passed (was 1312), 22 failed before and after — pre-existing symlink tests needing Windows elevation. Mutation-checked: dropping either check fails 6 cases and nothing else. * fix(workflows): stop the probe rejecting valid expressions, and match the path grammar Copilot found a false positive in the probe, which is worse than the false negatives the earlier rounds fixed: it withheld a correction from a condition that was already correct. steps.emit.output.stdout | from_json -> refused inputs.tags | join(inputs.separator) -> refused Both are valid; the first is exercised in tests/test_workflows.py. The probe hands `from_json` a dict and it raises, so treating every probe error as a rejection blamed the author for the placeholder's type. `_evaluator_rejects` now reports only the two failures `_apply_filter` raises about the expression itself -- an unknown filter name, and a registered filter used in an unsupported form. Everything else a probe run raises is about probe values. `_TERM_SUFFIX` also accepted any bracket contents and repeated indexes, while `_resolve_dot_path` matches `^([\w-]+)\[(\d+)\]$` -- one numeric index. So `inputs.tags[foo]` and `inputs.matrix[0][1]` passed as paths, resolved to None, and were offered a correction that turns a truthy condition false. `_PATH_SEGMENT` is that grammar now. It also replaces `str.isidentifier`, which was wrong in the other direction: the resolver allows a hyphen and a leading digit in a key name. Lint: this branch had added 3 ruff errors (2x SIM102, PIE810/UP037 on new code) and left a top-level class without its blank lines. `ruff check` on this file is back to the 5 pre-existing errors on `main`, all in code this PR does not touch. On the E305 comment specifically: `ruff rule E305` reports "Selection `E305` has no effect because preview is not enabled", and `ruff check --select E305` on this file passes, so the repository's CI does not report it. The blank lines were still wrong and are fixed. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 236 passed (was 230) - tests/unit + tests/test_workflows.py 1336 passed (was 1330), 22 failed before and after -- pre-existing symlink tests needing Windows elevation. Mutation-checked: treating every probe error as a rejection fails 2, loosening the path grammar fails 2. * fix(workflows): validate operands recursively, and keep probe-value errors out Copilot found three more, and the first explains why this took so many rounds: every gate so far only inspected the shape it was written for. inputs.a === inputs.b -> offered; splits cleanly on `==`, and the evaluator reads `= inputs.b` as a path, resolving to None bogus == 'x' -> offered; unknown root, same result inputs.payload | from_json() -> offered; raises at run time `_unresolvable_term` replaces `_is_not_a_bare_path` and walks operands the way `_evaluate_simple_expression` does -- filters, `or`/`and`/`not`, comparisons -- down to the leaves. A leaf must be a literal or a dotted path rooted in `_NAMESPACE_ROOTS`, the roots `_build_namespace` actually supplies. Both shapes above fall out of that without either being named. `_evaluator_rejects` now keeps only the errors `_apply_filter` raises about the filter *expression*. Those quote the segment back as `got '| ...'`; its value errors name the type they received, which under a probe is the placeholder. The previous prefix list missed `from_json()` (a wiring error) and, when widened by filter name, wrongly rejected `steps.emit.output.stdout | from_json` (a value error) -- the regression the round before had just fixed. One case fell out that no review raised: `_find_top_level` matches " and " with literal spaces, so a newline before the keyword is not an operator. `inputs.x == 1\nand inputs.name == 'abc'` evaluates False wrapped, where the same expression with a space evaluates True. It was in the offered fixture; it is a refusal case now. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 253 passed (was 236) - tests/unit + tests/test_workflows.py 1353 passed (was 1336), 22 failed before and after -- pre-existing symlink tests needing Windows elevation. - ruff check on this file is back to the 5 errors already on main. Mutation-checked: dropping the recursion fails 15, dropping the namespace-root check fails 5, treating every probe error as a rejection fails 2. * fix(workflows): mirror the evaluator's literal and root tests exactly Three more from Copilot, all cases where my check approximated the evaluator instead of matching it: 1e3 -> offered; no "." so the evaluator calls int(), which fails, and it falls through to a path lookup. float() alone accepted it. 'a' 'b' -> offered; the evaluator requires the opening quote's match to be the final character, which first/last-character equality is not. inputs[0] -> offered; `_build_namespace` hands back mappings, so an indexed root resolves to None however the index is written. All three are truthy before wrapping and False after, which is the inversion this change exists to prevent. `_looks_numeric` and `_is_literal` now use the evaluator's own tests rather than a looser stand-in, and the root segment is matched without stripping an index off it first. Not fixed, and worth being explicit about: `inputs.tags | join(5)` is still offered. `join` always raises for a non-string separator, but that is a *type* rule, and `_evaluator_rejects` deliberately ignores value errors because under a probe they usually describe the placeholder rather than the author's text. The two cannot be told apart from the message alone -- `join: expected a string separator, got int` and `join: ..., got NoneType` differ only in a type name the probe may have supplied. Catching it means encoding each filter's argument types in the validator, which is the reimplementation this PR has been backing away from. Verified on Python 3.11: - tests/unit/test_condition_expression_block.py 267 passed (was 253) - tests/unit + tests/test_workflows.py 1367 passed (was 1353), 22 failed before and after -- pre-existing symlink tests needing Windows elevation. - ruff check on this file is back to the 5 errors already on main. Mutation-checked: restoring the bare float() fails 2, restoring the first/last-character quote test fails 3. * fix(workflows): mirror list literals and filter arguments in the operand check Two shapes the leaf check did not mirror, each wrong in the opposite direction. A list literal is a term the evaluator understands -- it recurses into the elements rather than resolving the brackets as a name. Resolving them as a path reported `"['x', 'y']" is not a name the evaluator can resolve` and withheld the correction from `inputs.tag in ['x', 'y']`, a condition wrapping repairs completely. A filter argument is an ordinary operand to `_apply_filter`, which evaluates it with `_evaluate_simple_expression` like any other. Skipping it offered `inputs.tags | join(bogus)` as paste-ready: `bogus` is no namespace root, arrives as None, and the wrapped form raises `join: expected a string separator, got NoneType`. Parsed with the same pattern `_apply_filter` uses, so a form this does not recognize is left to the evaluator probe rather than guessed at. Every case is asserted against what the evaluator does with the wrapped form, not against a restatement of the check. * fix(workflows): let an indexed `item` root keep the correction `item` is the only namespace root that is not always a mapping. `StepContext.item` is `Any` and a fan-out assigns the item value itself, so when that value is a list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Rejecting every indexed root withheld the correction from a condition that evaluates. The other roots come back from `_build_namespace` as mappings, so the index branch finds no list and returns None however the index is written. The strip is therefore for `item` alone, and the paired test pins that it does not widen into "any indexed root". This narrows the root check added earlier in this branch, which was written as though every root were a mapping.
1 parent d3f9212 commit 2dddaa5

5 files changed

Lines changed: 808 additions & 10 deletions

File tree

src/specify_cli/workflows/expressions.py

Lines changed: 333 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,12 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
474474
)
475475

476476

477+
# Order matters -- multi-char operators first, so "!=" is not split as "!" + "=".
478+
# Shared with the remediation check so a validator cannot drift from what the
479+
# evaluator will actually split on.
480+
_COMPARISON_OPERATORS = ("!=", "==", ">=", "<=", ">", "<", " not in ", " in ")
481+
482+
477483
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
478484
"""Evaluate a simple expression against the namespace.
479485
@@ -533,7 +539,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
533539
# Comparison operators (order matters — check multi-char ops first). Split at
534540
# the first top-level occurrence so an operator inside a quoted operand is
535541
# ignored.
536-
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
542+
for op in _COMPARISON_OPERATORS:
537543
op_idx = _find_top_level(expr, op)
538544
if op_idx != -1:
539545
left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace)
@@ -879,3 +885,329 @@ def format_condition_correction(condition: Any) -> str:
879885
# double-spaced "{{ }}" that string concatenation would otherwise produce.
880886
body = "{{ " + core + " }}" if core else "{{ }}"
881887
return json.dumps(body, ensure_ascii=False)
888+
889+
890+
def _has_unbalanced_quote(text: str) -> bool:
891+
"""True when a quote opened in *text* is never closed.
892+
893+
Same left-to-right, first-quote-wins scan the rest of this module uses, so the
894+
answer agrees with what ``_find_block_close`` and ``_strip_stray_delimiters``
895+
consider "inside a string".
896+
"""
897+
quote: str | None = None
898+
for ch in text:
899+
if quote is not None:
900+
if ch == quote:
901+
quote = None
902+
elif ch in ("'", '"'):
903+
quote = ch
904+
return quote is not None
905+
906+
907+
_BRACKET_PAIRS = {")": "(", "]": "[", "}": "{"}
908+
909+
# The operators the evaluator delimits with spaces; derived so the check cannot
910+
# drift from _COMPARISON_OPERATORS.
911+
_WORD_OPERATORS = tuple(
912+
op for op in (" or ", " and ") + _COMPARISON_OPERATORS if op.startswith(" ")
913+
)
914+
915+
916+
def _has_unbalanced_bracket(text: str) -> bool:
917+
"""True when brackets outside a quoted operand do not nest and match.
918+
919+
A depth counter is not enough: it calls ``inputs.f(]`` balanced, because the
920+
``]`` cancels the ``(``. The evaluator then resolves that body to ``None`` and
921+
the comparison is false, which is the inversion this module is trying to keep
922+
out of the suggested correction. Track the opener types instead.
923+
"""
924+
stack: list[str] = []
925+
quote: str | None = None
926+
for ch in text:
927+
if quote is not None:
928+
if ch == quote:
929+
quote = None
930+
elif ch in ("'", '"'):
931+
quote = ch
932+
elif ch in "([{":
933+
stack.append(ch)
934+
elif ch in _BRACKET_PAIRS and (not stack or stack.pop() != _BRACKET_PAIRS[ch]):
935+
return True
936+
return bool(stack)
937+
938+
939+
def _has_incomplete_operand(text: str) -> bool:
940+
"""True when an operator in *text* is missing an operand on either side.
941+
942+
Splits on **every** top-level occurrence rather than the first. Checking only
943+
the first is the same defect this module exists to reject one level up: it let
944+
``inputs.a == inputs.b ==`` through, because the leading ``==`` has operands on
945+
both sides and the scan stopped there.
946+
947+
Reads ``_COMPARISON_OPERATORS`` from the evaluator rather than restating it, so
948+
the check cannot drift from what ``_evaluate_simple_expression`` splits on.
949+
"""
950+
stripped = text.strip()
951+
if not stripped:
952+
return True
953+
954+
# `not x` is a valid prefix form; `and x` and `or x` are not, and none of the
955+
# three is valid alone or trailing. The keyword scans below use bare words
956+
# because a leading operator has no space in front of it to match on.
957+
if stripped in ("and", "or", "not") or stripped.endswith(" not"):
958+
return True
959+
# Word operators lose their delimiting space at the ends of a stripped core, so
960+
# a trailing "not in" or a leading "and" needs matching without it. Derived from
961+
# the evaluator's own table rather than restated.
962+
for op in _WORD_OPERATORS:
963+
if stripped.endswith(op.rstrip()) or stripped.startswith(op.lstrip()):
964+
return True
965+
966+
for op in (" or ", " and ") + _COMPARISON_OPERATORS:
967+
if _find_top_level(stripped, op) == -1:
968+
continue
969+
if any(not segment.strip() for segment in _split_top_level(stripped, op)):
970+
return True
971+
972+
return _find_top_level(stripped, "|") != -1 and any(
973+
not segment.strip() for segment in _split_top_level(stripped, "|")
974+
)
975+
976+
977+
# The roots _build_namespace supplies. A reference to anything else resolves to
978+
# None, so a correction built on one turns a truthy condition false.
979+
_NAMESPACE_ROOTS = ("inputs", "steps", "item", "fan_in", "context")
980+
981+
# Exactly what _resolve_dot_path accepts: a name, optionally one numeric index.
982+
_PATH_SEGMENT = re.compile(r"^[\w-]+(\[\d+\])?$")
983+
984+
985+
class _ProbeNamespace(dict):
986+
"""Namespace for the parse probe: every root exists, every leaf is absent.
987+
988+
Enough for ``_evaluate_simple_expression`` to walk the grammar without needing
989+
real inputs. Deliberately *not* resolving leaves to a sentinel value: a probe
990+
that answers every lookup also answers ``inputs.count+1``, which is the
991+
malformed shape the probe is meant to expose.
992+
"""
993+
994+
def __missing__(self, key: str) -> "_ProbeNamespace": # noqa: UP037 # pragma: no cover
995+
return _ProbeNamespace()
996+
997+
998+
def _evaluator_rejects(text: str) -> str | None:
999+
"""The evaluator's own complaint about how *text* is wired, or ``None``.
1000+
1001+
Structural checks cannot establish that a core is parseable -- four rounds of
1002+
review found a new shape each time -- so this asks the evaluator. It reports
1003+
only the two failures ``_apply_filter`` raises about the expression itself: an
1004+
unknown filter name, and a registered filter used in an unsupported form.
1005+
1006+
Anything else a probe run raises is about the probe's placeholder values, not
1007+
the author's text. ``steps.emit.output.stdout | from_json`` is valid against a
1008+
string output and is exercised in ``tests/test_workflows.py``; the probe hands
1009+
``from_json`` a dict and it raises, so treating every error as a rejection
1010+
withheld a correction from a perfectly good condition.
1011+
"""
1012+
try:
1013+
_evaluate_simple_expression(
1014+
text, {root: _ProbeNamespace() for root in _NAMESPACE_ROOTS}
1015+
)
1016+
except ValueError as exc:
1017+
message = str(exc)
1018+
# Every error _apply_filter raises about the filter *expression* quotes the
1019+
# segment back as `got '| ...'`. Its value errors instead name the type they
1020+
# received, which under a probe is the placeholder, not anything the author
1021+
# wrote -- treating those as rejections withheld corrections from valid
1022+
# conditions such as `steps.emit.output.stdout | from_json`.
1023+
if "got '| " in message:
1024+
return message.split(":", 1)[0]
1025+
except Exception: # noqa: BLE001 - probe values, not the author's text
1026+
return None
1027+
return None
1028+
1029+
1030+
1031+
def _looks_numeric(text: str) -> bool:
1032+
"""Mirror the evaluator's numeric literal test exactly.
1033+
1034+
`_evaluate_simple_expression` only calls `float()` when a `.` is present and
1035+
`int()` otherwise, so `1e3` is not a number to it -- it falls through to a path
1036+
lookup and resolves to None. A bare `float()` here accepted `1e3` and the
1037+
correction turned a truthy condition false.
1038+
"""
1039+
try:
1040+
if "." in text:
1041+
float(text)
1042+
else:
1043+
int(text)
1044+
except (ValueError, TypeError):
1045+
return False
1046+
return True
1047+
1048+
1049+
def _is_literal(text: str) -> bool:
1050+
"""Mirror the evaluator's literal tests exactly.
1051+
1052+
The string case is the opening quote's *matching close being the final
1053+
character*, not first/last-character equality: `'a' 'b'` passes the latter but
1054+
is two literals to the evaluator, which falls through to a path lookup.
1055+
"""
1056+
if text[:1] in ("'", '"') and text.find(text[0], 1) == len(text) - 1:
1057+
return True
1058+
return text.lower() in ("true", "false", "none", "null") or _looks_numeric(text)
1059+
1060+
1061+
def _unresolvable_term(text: str) -> str | None:
1062+
"""The first operand in *text* the evaluator cannot resolve, or ``None``.
1063+
1064+
Walks operands the way ``_evaluate_simple_expression`` does -- filters, then
1065+
``or``/``and``/``not``, then comparisons -- and checks each leaf. A leaf must be
1066+
a literal or a dotted path rooted in ``_NAMESPACE_ROOTS``.
1067+
1068+
Enumerating broken shapes is what made this take several rounds: each new gate
1069+
only knew the shapes named so far. ``inputs.a === inputs.b`` split cleanly on
1070+
``==`` and looked complete, while the evaluator read ``= inputs.b`` as a path
1071+
and resolved it to ``None``; ``bogus == 'x'`` passed for the same reason one
1072+
level up. Recursing to the leaves covers both without naming either.
1073+
"""
1074+
stripped = text.strip()
1075+
if not stripped:
1076+
return "an operand is empty"
1077+
1078+
if _find_top_level(stripped, "|") != -1:
1079+
segments = _split_top_level(stripped, "|")
1080+
reason = _unresolvable_term(segments[0])
1081+
if reason is not None:
1082+
return reason
1083+
# A filter argument is an ordinary operand to `_apply_filter`, which
1084+
# evaluates it with `_evaluate_simple_expression` like any other. Skipping
1085+
# it let `inputs.tags | join(bogus)` be offered as paste-ready: `bogus` is
1086+
# no namespace root, resolves to None, and the wrapped form then raises
1087+
# `join: expected a string separator, got NoneType`. Parse with the same
1088+
# pattern `_apply_filter` uses, so a form this does not recognize is left
1089+
# to the evaluator probe rather than guessed at here.
1090+
for segment in segments[1:]:
1091+
match = re.fullmatch(r"(\w+)\((.+)\)", segment.strip())
1092+
if match is None:
1093+
continue
1094+
reason = _unresolvable_term(match.group(2))
1095+
if reason is not None:
1096+
return reason
1097+
return None
1098+
1099+
for op in (" or ", " and "):
1100+
idx = _find_top_level(stripped, op)
1101+
if idx != -1:
1102+
return _unresolvable_term(stripped[:idx]) or _unresolvable_term(
1103+
stripped[idx + len(op):]
1104+
)
1105+
1106+
if stripped.startswith("not "):
1107+
return _unresolvable_term(stripped[4:])
1108+
1109+
for op in _COMPARISON_OPERATORS:
1110+
idx = _find_top_level(stripped, op)
1111+
if idx != -1:
1112+
return _unresolvable_term(stripped[:idx]) or _unresolvable_term(
1113+
stripped[idx + len(op):]
1114+
)
1115+
1116+
if _is_literal(stripped):
1117+
return None
1118+
1119+
# A list literal is a term the evaluator understands, and it recurses into the
1120+
# elements rather than resolving the brackets as a name. Not mirroring that
1121+
# denied the correction to `inputs.tag in ['x', 'y']` -- a condition wrapping
1122+
# repairs completely -- while reporting the list as an unresolvable name. The
1123+
# empty-segment skip matches `_evaluate_simple_expression`, which drops them so
1124+
# `[1, 2,]` is `[1, 2]` rather than `[1, 2, None]`.
1125+
if stripped.startswith("[") and stripped.endswith("]"):
1126+
inner = stripped[1:-1].strip()
1127+
if not inner:
1128+
return None
1129+
for element in _split_top_level_commas(inner):
1130+
if not element.strip():
1131+
continue
1132+
reason = _unresolvable_term(element)
1133+
if reason is not None:
1134+
return reason
1135+
return None
1136+
1137+
segments = _split_top_level(stripped, ".")
1138+
if not _PATH_SEGMENT.match(segments[0].strip()):
1139+
return f"{stripped!r} is not a name the evaluator can resolve"
1140+
# `item` is the only root that is not always a mapping: `StepContext.item` is
1141+
# `Any` and a fan-out assigns the item value itself, so when that value is a
1142+
# list `_resolve_dot_path` indexes it and `item[0] == 'x'` resolves. Every
1143+
# other root comes back from `_build_namespace` as a mapping, and the index
1144+
# branch returns None for those however it is written -- so the index is
1145+
# stripped for `item` alone rather than for roots in general.
1146+
root = segments[0].strip()
1147+
indexed_root = re.fullmatch(r"([\w-]+)\[\d+\]", root)
1148+
if indexed_root is not None and indexed_root.group(1) == "item":
1149+
root = indexed_root.group(1)
1150+
if root not in _NAMESPACE_ROOTS:
1151+
return (
1152+
f"{segments[0].strip()!r} is not one of the namespace roots "
1153+
f"({', '.join(_NAMESPACE_ROOTS)})"
1154+
)
1155+
for segment in segments[1:]:
1156+
if not _PATH_SEGMENT.match(segment.strip()):
1157+
return f"{segment.strip()!r} is not a valid path segment"
1158+
return None
1159+
1160+
1161+
def _wrapping_would_not_repair(core: str) -> str | None:
1162+
"""Why wrapping *core* in ``{{ }}`` would not yield the expression intended.
1163+
1164+
``None`` when it would. Each branch names something observable about the text
1165+
itself, deliberately not the interpolator path it will take: two earlier
1166+
versions of this message asserted an internal route -- the raw-close fallback --
1167+
and were wrong, because ``_is_single_expression`` accepts the wrapped form and
1168+
sends it down the typed fast path instead.
1169+
"""
1170+
if not core:
1171+
return "there is no expression here to wrap"
1172+
if _has_unbalanced_quote(core):
1173+
return "the quote opened in it is never closed"
1174+
if _has_unbalanced_bracket(core):
1175+
return "its brackets do not balance"
1176+
if _has_incomplete_operand(core):
1177+
return "an operator in it is missing an operand"
1178+
unresolvable = _unresolvable_term(core)
1179+
if unresolvable is not None:
1180+
return unresolvable
1181+
rejected = _evaluator_rejects(core)
1182+
if rejected is not None:
1183+
return f"the evaluator rejects it ({rejected})"
1184+
return None
1185+
1186+
1187+
def format_condition_remediation(condition: Any) -> str:
1188+
"""The advice sentence for a condition that is never evaluated.
1189+
1190+
``format_condition_correction`` wraps whatever it is handed, which is right for a
1191+
formatter but wrong to advertise as paste-ready when wrapping cannot repair the
1192+
input. Measured, each of these was being offered as the fix and each **inverts**
1193+
the condition instead:
1194+
1195+
" " -> "{{ }}" True -> False
1196+
{{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}" True -> False
1197+
inputs.name == -> "{{ inputs.name == }}" True -> False
1198+
1199+
The author is told the condition is always true, pastes the suggestion, and now
1200+
has an always-false one. Naming the fault beats handing back something that looks
1201+
authoritative and is not -- the same call already made for
1202+
``condition_has_malformed_expression_block``, which offers no suggestion at all.
1203+
"""
1204+
core = _strip_stray_delimiters(str(condition)).strip()
1205+
reason = _wrapping_would_not_repair(core)
1206+
if reason is None:
1207+
return "Wrap the expression: " + format_condition_correction(condition) + "."
1208+
return (
1209+
f"No correction is offered because {reason}: wrapping it as written would "
1210+
"produce a different expression from the one intended, and its result can "
1211+
"silently invert the condition rather than repair it. Complete the "
1212+
"expression, or use the literal true or false."
1213+
)

src/specify_cli/workflows/steps/do_while/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from specify_cli.workflows.expressions import (
99
condition_has_malformed_expression_block,
1010
condition_is_never_evaluated,
11-
format_condition_correction,
11+
format_condition_remediation,
1212
)
1313

1414

@@ -104,8 +104,8 @@ def validate(self, config: dict[str, Any]) -> list[str]:
104104
errors.append(
105105
f"Do-while step {config.get('id', '?')!r}: 'condition' "
106106
f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so "
107-
"it is never evaluated as an expression and is always true. Wrap the expression: "
108-
+ format_condition_correction(config["condition"]) + "."
107+
"it is never evaluated as an expression and is always true. "
108+
+ format_condition_remediation(config["condition"])
109109
)
110110
elif condition_has_malformed_expression_block(config["condition"]):
111111
# Different fault, different advice. Here the block is *not* skipped:

src/specify_cli/workflows/steps/if_then/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from specify_cli.workflows.expressions import (
99
condition_has_malformed_expression_block,
1010
condition_is_never_evaluated,
11-
format_condition_correction,
11+
format_condition_remediation,
1212
evaluate_condition,
1313
)
1414

@@ -95,8 +95,8 @@ def validate(self, config: dict[str, Any]) -> list[str]:
9595
errors.append(
9696
f"If step {config.get('id', '?')!r}: 'condition' "
9797
f"{config['condition']!r} is not a single complete '{{{{ }}}}' block, so "
98-
"it is never evaluated as an expression and is always true. Wrap the expression: "
99-
+ format_condition_correction(config["condition"]) + "."
98+
"it is never evaluated as an expression and is always true. "
99+
+ format_condition_remediation(config["condition"])
100100
)
101101
elif condition_has_malformed_expression_block(config["condition"]):
102102
# Different fault, different advice. Here the block is *not* skipped:

0 commit comments

Comments
 (0)