Skip to content

feat(workflows): add upper, lower, split, length, and to_json expression filters - #4766

Open
Quratulain-bilal wants to merge 5 commits into
github:mainfrom
Quratulain-bilal:feat/4614-expand-expression-filters
Open

Quratulain-bilal wants to merge 5 commits into
github:mainfrom
Quratulain-bilal:feat/4614-expand-expression-filters

Conversation

@Quratulain-bilal

@Quratulain-bilal Quratulain-bilal commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4614 (Option A)

Adds upper, lower, split, length, and to_json to the workflow expression evaluator. Per the agreed Option A scope, first, last, sort, unique, and trim stay deferred until real usage justifies their semantics.

Semantics

Every filter validates its own input types and raises ValueError naming the problem instead of coercing, matching the strict argument handling already used by join/map/from_json. Coercion is rejected on purpose: a type mismatch almost always means the pipeline is wired to the wrong variable, and a coerced result (e.g. "3" for an int) would hide that — it would also leak a cryptic TypeError/AttributeError past the evaluator, which the engine does not wrap in a try/except, crashing the whole run.

Filter Input → Output Empty/edge Invalid input
upper str → uppercased str "" → "" non-str → ValueError
lower str → lowercased str "" → "" non-str → ValueError
split str → list[str], split(sep) only "".split(",") → [""] non-str value or sep → ValueError
length list/str → int (Jinja2 behavior) []/"" → 0 mappings and other types → ValueError
to_json any serializable value → str None → "null" non-serializable or non-finite float → ValueError
  • length rejects mappings so the same expression cannot mean key count for one shape and value count for another.
  • to_json pins sort_keys=True and ensure_ascii=False, so output is byte-stable regardless of dict insertion order or platform — that is what makes the to_json ↔ from_json round trip reproducible, and keeps non-ASCII readable instead of \uXXXX-escaped. Reproducible is not the same as shell-safe: interpolation adds no quoting or escaping, so the value is still read by the shell if it lands in a run field. See Interpolation and shell safety in docs/reference/workflows.md.

Implementation

The strict no-argument branch is generalized into _ZERO_ARG_FILTERS, shared by from_json, upper, lower, length, and to_json, so mis-wired forms (| upper('x'), | length extra, | to_json()) raise naming the filter rather than falling through to the unknown-filter path. split joins the single-argument dispatch in _apply_filter.

Tests

  • Existing tests that used length/upper as examples of unknown filters were retargeted at a genuinely unknown name (truncate), not deleted — that coverage still asserts unknown names fail loudly.
  • test_registered_filters_unaffected now asserts all ten registered filters, and test_registered_filters_covers_every_implemented_filter keeps _REGISTERED_FILTERS in sync with what is implemented.
  • New coverage: happy paths, empty/edge inputs, every ValueError path, filter chaining, the from_json ↔ to_json round trip, length driving conditions, and strict zero-arg forms across all five.

tests/test_workflows.py + tests/unit/test_condition_expression_block.py: 1069 passed, 5 skipped. Full suite: no new failures against an origin/main baseline (the 30 pre-existing failures were reproduced by stashing this change). ruff@0.15.0 check src tests is clean for the changed files.

Documentation

Behavior documented per constitution Principles I/II/V in workflows/README.md, workflows/ARCHITECTURE.md, and docs/reference/workflows.md. Two source comments that hardcoded the old five-filter list were rewritten so they cannot go stale again.

Correction to the issue's design note

The issue says "length enables conditionals: Unlocks {% if items | length > 0 %} patterns". That does not hold:

  • There is no {% if %} syntax in the evaluator at all — conditions are condition: "{{ ... }}" expressions.
  • The pipe binds tighter than comparison operators, so a trailing comparison after any filter raises. This is pre-existing design, not new: {{ count | default(0) > 5 }} already raises today.

Docs therefore show the form that does work — condition: "{{ items | length }}", where 0 is falsy and any non-zero count is truthy — rather than documenting a pattern that would fail at runtime.

AI disclosure

This change was developed under human direction by @Quratulain-bilal and generated by the opencode CLI agent.

Agent/tool opencode (CLI coding agent)
Model mimo-v2.6-flash-free
Mode Autonomous execution under direct human direction
Authoring extent All implementation, tests, comments, and documentation text in this PR
Human extent Scope selection, requirement direction, review-round decisions, verification review, GitHub credentials, and every release decision

How the human was involved:

  • Chose and confirmed the scope. Adopted the maintainer-approved Option A subset (length, split, upper, lower, to_json) and kept first/last/sort/unique/trim deferred rather than taking the larger batch.
  • Directed every round of work. Read each review round and decided what to act on — the latest round instructed the agent to address the outstanding Copilot finding first, covering the PR description and the test comment, not just the code.
  • Verified against the agreed specification. Directed an item-by-item check of the implementation against the exact semantics table in [Feature]: expand expression evaluator filter set (upper, lower, length, split, sort, to_json) #4614 (34/34 assertions), including the invalid-input and empty-input rows.
  • Reviewed results, not diffs. Reviewed the verification output (spec conformance, test totals, and a stashed origin/main baseline showing no new failures) before authorizing commits.
  • Controlled credentials and publication. Held the GitHub credentials behind every API call, and approved each commit, push, PR body edit, and comment before it went out.
  • Audited this disclosure. Required that it state the human's actual contribution instead of crediting the whole change to the agent.

The agent authored the code autonomously; the human did not line-by-line review the diff before commit, which is why the commit trailer records autonomous rather than supervised. The human is expected to be able to answer for this change during review.

Disclosure per CONTRIBUTING.md — AI contributions in Spec Kit.

Option A of github#4614: the smallest high-value batch of expression filters
(length, split, upper, lower, to_json), leaving first, last, sort,
unique, and trim deferred until real usage justifies their semantics.

Each filter validates its own input types and raises ValueError naming
the problem rather than coercing, matching the strict argument handling
already used by join/map/from_json. Coercion is rejected because a type
mismatch almost always means the workflow is wired to the wrong
variable, and a coerced result (e.g. "3" for an int) would hide that;
it would also leak a cryptic TypeError/AttributeError past the
evaluator, which the engine does not wrap in a try/except, crashing the
whole run.

- upper/lower: str -> str, empty string valid, non-str -> ValueError
- split: str -> list[str], single split(sep) form only, "" -> [""],
  both value and separator must be strings
- length: list and str -> int (Jinja2 behavior), mappings rejected so
  the same expression cannot mean key count for one shape and value
  count for another, empty -> 0, other types -> ValueError
- to_json: any serializable value via json.dumps(..., sort_keys=True,
  ensure_ascii=False), None -> "null", non-serializable -> ValueError

to_json pins sort_keys and ensure_ascii so output is byte-stable
regardless of dict insertion order or platform, which is what makes a
to_json -> shell step -> from_json round trip safe.

The strict no-argument branch is generalized into _ZERO_ARG_FILTERS so
from_json, upper, lower, length, and to_json share it; mis-wired forms
(upper('x'), length extra, to_json()) raise naming the filter instead
of falling through to the unknown-filter path.

Existing tests that used length/upper as examples of unknown filters
are retargeted at a genuinely unknown name rather than deleted, and
test_registered_filters_unaffected now asserts all ten registered
filters. Behavior is documented in workflows/README.md,
workflows/ARCHITECTURE.md, and docs/reference/workflows.md.

Note: the issue's claim that length unlocks `{% if items | length > 0 %}`
does not hold. There is no `{% if %}` syntax in the evaluator, and the
pipe binds tighter than comparisons, so a trailing comparison after any
filter raises (pre-existing design, same as `count | default(0) > 5`).
Docs instead show condition: "{{ items | length }}" (0 is falsy).

Closes github#4614

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
@mnriem mnriem added the triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate label Sep 28, 2026
@mnriem
mnriem requested a balanced review from Copilot September 28, 2026 12:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Serialization edge cases and misleading shell-safety documentation leave the new filter contract incomplete.

Review effort: Balanced
Findings: 1 Medium severity · 6 Low severity

Open (7)
What changed in this PR

Adds five workflow expression filters with strict validation, tests, and user-facing documentation.

Changes:

  • Implements upper, lower, split, length, and to_json.
  • Expands filter registration, validation, and regression coverage.
  • Documents filter semantics and condition usage.
File Description
src/​specify_cli/​workflows/​expressions.py Implements and dispatches the new filters.
src/​specify_cli/​workflows/​step/​switch/​__init__.py Generalizes filter-related commentary.
tests/​test_workflows.py Adds filter behavior and validation tests.
tests/​unit/​test_condition_expression_block.py Updates condition-probe coverage.
workflows/​README.md Summarizes supported filters.
workflows/​ARCHITECTURE.md Documents evaluator semantics.
docs/​reference/​workflows.md Expands public workflow reference.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/specify_cli/workflows/expressions.py
Comment thread docs/reference/workflows.md Outdated
Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread workflows/ARCHITECTURE.md Outdated
Comment thread workflows/ARCHITECTURE.md Outdated
Comment thread workflows/ARCHITECTURE.md Outdated
Comment thread workflows/README.md Outdated
@mnriem

mnriem commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Seven findings from the Copilot review of github#4766, addressed in code,
tests, and docs.

Define and test empty-separator errors: `split('')` reached str.split and
raised the bare `ValueError: empty separator`, naming neither the filter
nor the expression, and no test covered it. An empty separator has no
meaning in Python, so the filter now rejects it with a filter-specific
message alongside the existing value and separator type checks.

Scope strictness claims to the new filters: the docs said "every filter"
validates its inputs, which the older filters do not — `join` stringifies
unsupported shapes and elements, and `map`/`contains` return fallbacks
rather than raising. README and ARCHITECTURE now state the contract for
the five new filters and explicitly note that the older ones are
unchanged, so authors are not promised behavior the evaluator does not
provide.

Replace shell-safety claims with reproducibility: determinism from
`sort_keys`/`ensure_ascii` was described as making a `to_json` -> shell ->
from_json round trip "safe", which contradicts the repository's shell
safety model — interpolation adds no quoting or escaping, so JSON quotes
and metacharacters are still interpreted by the shell. All three
locations now claim reproducibility only and point at the
interpolation-and-shell-safety guidance.

Remove unsupported comparison guidance: "compare it before filtering"
was not a usable alternative, because the count does not exist until
`length` runs. The docs now say plainly that `{{ items | length > 0 }}`
is unreachable and that truthiness in a `condition:` is the supported
form.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)
@Quratulain-bilal

Quratulain-bilal commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Addressed in cc70b383.

What changed

The empty-separator gap was the only code change. split('') used to fall through to str.split and surface Python's bare ValueError: empty separator, which names neither the filter nor the expression and was untested. The filter now rejects an empty separator with its own message, next to the existing value and separator type checks, with a regression test pinning it.

The rest was documentation accuracy, not behavior:

Strictness claims are now scoped to the five new filters. The text previously said "every filter" validates its inputs, which the older filters do not — join stringifies unsupported shapes and elements, and map/contains return fallbacks rather than raising. workflows/README.md and workflows/ARCHITECTURE.md now state the new filters' contract and say explicitly that the older ones are unchanged.

The to_json → shell → from_json round trip was described as "safe", which contradicts this repository's shell-safety model. Determinism from sort_keys/ensure_ascii gives reproducibility and nothing else: interpolation adds no quoting or escaping, so JSON quotes and metacharacters are still read by the shell. All three places it appeared — the _filter_to_json docstring, ARCHITECTURE.md, and docs/reference/workflows.md — now claim reproducibility only and point at the interpolation-and-shell-safety section.

The "compare it before filtering" alternative is gone. The count does not exist until length runs, so that guidance was unreachable; the docs now say plainly that {{ items | length > 0 }} cannot be written and that truthiness in a condition: is the supported form.

Verification

tests/test_workflows.py + tests/unit/test_condition_expression_block.py: 1069 passed, 5 skipped. ruff@0.15.0 check src tests reports only two pre-existing errors, both outside this change (integrations/forge/__init__.py F811 and the untracked tests/test_exception_narrowing.py F401).


Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Mixed-type mapping keys break the stated serialization contract, and shell round-trip claims contradict the documented behavior.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Resolved since last review (7)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Mixed mapping key types cause misleading serialization errors

src/​specify_cli/​workflows/​expressions.py:234

sort_keys=True rejects some values that are otherwise JSON-serializable. For example, json.dumps({1: "a", "2": "b"}) succeeds, but this call raises TypeError because int and str keys cannot be ordered; the wrapper then inaccurately reports that the value is not serializable. Since workflow step outputs can contain arbitrary values, either explicitly validate/document string-only mapping keys or normalize supported JSON keys deterministically, and cover mixed key types with a regression test.

Comment thread docs/reference/workflows.md
@mnriem

mnriem commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

The test comment promised a structured value could pass through a shell
step and be recovered, which the evaluator does not guarantee:
interpolation adds no quoting, so ShellStep would hand the JSON to
shell=True unescaped. The test only exercises to_json/from_json inside
the evaluator, so say that and point at the interpolation-and-shell-safety
section instead of promising a shell round trip.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

to_json emits non-standard JSON for non-finite floats, and the PR description overstates shell safety.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Reject non-finite values in JSON serialization

src/​specify_cli/​workflows/​expressions.py:234

json.dumps emits NaN, Infinity, and -Infinity by default even though those tokens are not valid JSON. This lets to_json return output that standards-compliant downstream parsers reject instead of taking the documented ValueError path. Please set allow_nan=False and add regression coverage for all three non-finite values.

Low severity Clarify fallback applies only to None or empty strings

docs/​reference/​workflows.md:597

This newly added table overstates default: _filter_default only falls back for None and ""; an empty list or mapping is returned unchanged. Please describe this as “Fallback for None or an empty string” so workflow authors are not led to expect all empty values to trigger it.

Low severity Correct unsafe shell round-trip claim for JSON interpolation

workflows/​ARCHITECTURE.md:138

The PR description says deterministic serialization makes a to_json → shell → from_json round trip safe, but this documentation correctly states that interpolation adds no shell quoting or escaping. Please remove or correct that safety claim in the PR description; byte stability does not prevent JSON quotes or metacharacters from being interpreted by the shell.

@Quratulain-bilal

Quratulain-bilal commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Addressed in 51b4049b.

The remaining shell round-trip claim sat outside the docs: this PR description and the comment on test_filter_to_json_round_trips_from_json. That test only evaluates to_json/from_json inside the evaluator and never invokes a shell, so the comment now says exactly that and points at the interpolation-and-shell-safety section. The to_json paragraph in the description now claims reproducibility only, with the same caveat: interpolation adds no quoting or escaping, so a run field carrying the value is still read by the shell.

The description's test total was also stale at 1068, from before the empty-separator regression test was added. It now reads 1069, matching tests/test_workflows.py + tests/unit/test_condition_expression_block.py (1069 passed, 5 skipped).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

to_json currently emits non-standard JSON for non-finite floats.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread src/specify_cli/workflows/expressions.py Outdated
@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

json.dumps defaults to allow_nan=True, so to_json emitted bare NaN,
Infinity, and -Infinity. None of those is valid JSON: the filter promised
a JSON string and handed standards-compliant parsers something they must
reject. Pass allow_nan=False so non-finite floats take the same ValueError
path as any other unserializable value, and pin all three plus a nested
case with a regression test.

Documented alongside the existing to_json notes in README, ARCHITECTURE,
and the reference.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Serialization of mixed-key mappings violates the stated contract, and excess split arguments produce misleading diagnostics.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity sort_keys rejects heterogeneous JSON mapping keys

src/​specify_cli/​workflows/​expressions.py:237

sort_keys=True rejects mappings that are otherwise accepted by json.dumps, such as {1: "a", "2": "b"}, because heterogeneous keys cannot be ordered. This path is caught and reported as “not JSON-serializable,” contradicting the filter's “any serializable value” contract. Either validate and document string-only mapping keys, or normalize supported JSON keys deterministically before sorting, and add regression coverage.

Medium severity Enforce single-argument contract before separator evaluation

src/​specify_cli/​workflows/​expressions.py:625

The single-argument contract is not enforced before evaluating the argument: split(',', 1) evaluates the whole comma expression to None, then reports expected a string separator, got NoneType instead of identifying unsupported arity. Since a max-split argument is a likely authoring mistake, detect multiple top-level arguments before dispatch and cover this malformed form with a regression test.

@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

@Quratulain-bilal

Quratulain-bilal commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Addressed in 64d12171.

to_json was passing allow_nan=True (the json.dumps default), so NaN, Infinity, and -Infinity serialized to bare tokens, none of which is valid JSON — the filter promised a JSON string and handed standards-compliant parsers something they must reject. It now passes allow_nan=False, so all three take the existing ValueError path used for unserializable values, with the underlying message still chained so the cause stays visible.

A regression test covers each non-finite value at the top level and nested inside a container, since a container is how they normally reach the filter. The note is documented next to the other to_json behavior in workflows/README.md, workflows/ARCHITECTURE.md, and docs/reference/workflows.md, and the semantics table in the PR description now says non-finite floats are rejected.

Verification: tests/test_workflows.py + tests/unit/test_condition_expression_block.py — 1070 passed, 5 skipped (one new test). uvx ruff@0.15.0 check src tests reports only the two pre-existing errors outside this change (integrations/forge/__init__.py F811, the untracked tests/test_exception_narrowing.py F401).

@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for fixing the non-finite JSON case and adding regression coverage. The earlier inline review threads are resolved, but I don’t think the PR is ready for another review yet. The latest review identifies two previously missed cases still present at the current head: to_json rejects mixed-type mapping keys despite the “any serializable value” contract, and split(',', 1) reports a separator-type error rather than an unsupported extra argument. Please resolve each contract/diagnostic issue and add focused regression coverage.

There’s also an earlier documentation concern still present: the default table says “empty values,” but the implementation falls back only for None and an empty string. Please correct that wording as part of the follow-up.

Disclosure: Drafted for @mnriem with GitHub Copilot (GPT-6 Sol, interactive mode); AI assistance was limited to assessing the PR feedback and drafting this reply.

@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Are the following comments addressed?

Medium severity sort_keys rejects heterogeneous JSON mapping keys
Medium severity Enforce single-argument contract before separator evaluation

Both findings from the latest review round hid the real authoring mistake
behind a less useful error.

to_json relied on sort_keys=True for determinism, so a mapping with
mixed key types raised an ordering TypeError that the generic handler
reported as "not JSON-serializable", while a mapping with a lone integer
key was silently coerced to "1" and could collide with an existing "1"
key. JSON objects have string keys, so keys are validated before
json.dumps is reached and the message names the key type instead. The
walk is iterative with a seen set, leaving circular structures for
json.dumps to report.

split(',', 1) evaluated the argument expression first. The evaluator has
no comma operator, so the fragment evaluated to None and the reported
error was "expected a string separator, got NoneType" -- the extra
argument never surfaced. Arity for the single-argument filters is now
counted on the top-level comma split, before evaluation, so the message
names the filter and the argument count.

Documented in workflows/README.md, workflows/ARCHITECTURE.md, and
docs/reference/workflows.md, with a regression test for each behavior.

Assisted-by: opencode (model: mimo-v2.6-flash-free, autonomous)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The implementation matches the stated strict semantics and includes thorough positive and negative coverage.

Review effort: Balanced
Findings: None

@mnriem

mnriem commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Please fix test & lint errors

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: expand expression evaluator filter set (upper, lower, length, split, sort, to_json)

3 participants