Skip to content

feat: list a program's calls and literals for hosts - #32

Merged
myzie merged 2 commits into
mainfrom
feat/program-calls
Oct 30, 2026
Merged

myzie merged 2 commits into
mainfrom
feat/program-calls

Conversation

@myzie

@myzie myzie commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

What changes for the user

A host can now list the calls of chosen functions in a compiled expression, before it runs it. Program.Calls(names...) returns each call with its arguments as programs of their own. Program.Literal() tells a host whether an expression is only a literal, and returns its value.

Issue

Needed by deepnoodle-ai/workflow#86 (decision edges, PRD PR deepnoodle-ai/workflow#95). The workflow engine must find every decide(question, subject) call in an edge condition before it runs it. It also needs each question as a literal and each subject as something it can evaluate.

See it

p, _ := expr.Compile(`false && ask("Is it urgent?", ticket.body) > 0.8`, opts...)
for _, c := range p.Calls("ask") {
    q, isLit := c.Args[0].Literal()                 // "Is it urgent?", true
    v, _ := c.Args[1].Run(ctx, env)                 // ticket.body's value
    fmt.Println(c.Args[1].Source(), c.Bound, q, isLit, v)
}
// ticket.body false Is it urgent? true <the body>

Why

Program.Identifiers() already gives hosts the names an expression reads. Some hosts also need to see calls: what each call asks, and with which arguments. Run cannot show them all, because &&, ||, if and try skip branches. Without this, a host must parse expr's syntax itself and copy its rewrite rules (?., JSON literals, keyword fields, pipes).

Decisions to check

  • Report calls in branches that Run may skip. A host that prepares answers must see every call. Rejected: report only the calls a given env reaches.
  • Each argument is a *Program. It runs, type-checks and reports Identifiers() like any program. Rejected: return argument source text, which a host would compile again with the same options.
  • Bound marks a call in an iterating form's body, where it runs once per element. Rejected: skip those calls, which would hide them from the host.
  • Pipes are desugared before the walk. x | f("q") reports f(x, "q"), which is what Run calls. Rejected: a Piped flag. No caller needs it yet.
  • Folded constants (-1, 2 * 3) count as literals. Compile already folds them. Rejected: a stricter "as written" rule, which would need a second pass.

How to verify

go test -run 'TestCalls|TestLiteral' ./..., then go test ./... and go vet ./....

Risk and rollout

Additive API. No change to Compile, Run or Check. An argument program shares its parent's caches, which are read-only after compile. A release tag (v1.4.0) needs the maintainer's approval.

🤖 Generated with Claude Code

….Literal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approve: The walk covers every node type that validate.go accepts, pipes are desugared before the walk, and the shared caches are read-only at run time, so the design holds; two Should-fix items can land after merge.

This is the expr side of workflow#86 (decision edges, PRD workflow#95).

Should fix

S1. A named binding does not shadow a call (calls.go:62)
The doc on Calls (lines 25-27) says a name shadowed by a named binding is not a call. The walk drops the binding (coll, _, body), so map(xs, ask, ask("a?", ask)) reports the inner ask. evalCall skips the registered function when the env shadows its name (program.go:1432), so that call never reaches the function at Run. The over-report is the safe direction for a host that prepares answers, but it contradicts the doc. Fix: pass the binding name down and skip calls of it, as identifiers.go:110 does for element names, or change the doc to match. Add a test for the shadowed case.

S2. A bound argument has no it to read (calls.go:12)
Args says each argument runs like any program. For a call with Bound: true, an argument that reads it, index, or the named element has no value outside the loop, so Run on it fails. The PR example only runs top-level arguments, and no test runs a bound one. Fix: say in the Args and Bound docs and in docs/reference/spec.md that bound arguments run only per element, and add a test that pins the error. See Q1 for whether the API needs more.

Questions

  • Q1. Does workflow#86 need to evaluate a decide subject inside a map or any body, such as any(xs, decide("q", it))? If yes, S2 needs an API change, not only a doc note. (calls.go:13)

Nits

  • N1. The spec example ends with _, _, _ = q, isLit, subject. Drop the unused variables and show the call the way the PR description does. (docs/reference/spec.md)

Sensitive paths

  • CLAUDE.md: adds the calls.go row to the layout table, which matches the file. AGENTS.md is a symlink to it, so the two cannot drift.
Prompt for your AI tool: fix S1, S2
Fix these review findings in deepnoodle-ai/expr, PR #32.

1. S1, calls.go, func (p *Program) walkCalls: the named binding of a
   three-arg iterating form is dropped (`coll, _, body`), so a call
   whose name matches the binding is still reported. Wanted: a call
   whose name is shadowed by the binding is not reported, as the doc
   comment on Calls says (or change the doc comment to match). Test:
   Calls("ask") on `map(xs, ask, ask("a?", ask))` returns no calls.
2. S2, calls.go, type Call: Args says each argument runs like any
   program, but a Bound argument that reads it, index, or the named
   element cannot run outside the loop. Wanted: the Args and Bound
   docs and docs/reference/spec.md say bound arguments run only per
   element. Test: Run on a bound argument that reads `it` fails with
   an error; pin that in calls_test.go.

Run the existing tests after the changes.

…ents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@myzie

myzie commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Replies to the review. All fixes are in 62456f9.

S1. A named binding does not shadow a call

Fixed in 62456f9. walkCalls now carries the enclosing bindings, as Identifiers does. Inside the body, a call of the binding's name is not reported, but its arguments are still walked. TestCalls_NamedBindingShadows covers map(xs, ask, ask("a?", ask)) and checks the name outside the body.

S2. A bound argument has no it to read

Fixed in 62456f9. The Args doc and docs/reference/spec.md now say this: an argument of a Bound call that reads it, index or a named element has them only per element, and run on its own it fails to resolve them. TestCalls_BoundArgumentRunsOnlyPerElement pins the ErrEvaluate error.

Q1. Does workflow#86 need to evaluate a decide subject inside a map or any body?

No. workflow rejects a decision call with Bound: true at Load ("decide cannot be used inside map, filter or another list function"). In a body, the number of questions would depend on the data. So the doc note is enough, and the API stays as it is.

N1. The spec example ends with _, _, _ = q, isLit, subject.

Fixed in 62456f9. The example now runs the subject and prints it, as the PR description does.

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review at 62456f9. Approve.

  • S1 (named binding shadowing): fixed. walkCalls now carries boundIdents, the same stack Identifiers uses. A call of the binding's name inside the body is skipped, and its arguments are still walked. has handles a nil receiver, so the top-level path is safe.
  • S2 (bound arguments): fixed in the Args doc and in spec.md. TestCalls_BoundArgumentRunsOnlyPerElement pins the ErrEvaluate result when the argument runs alone.
  • Q1: agreed. A decision call with Bound: true is already rejected at Load in workflow, so the doc note is enough and the API stays as it is.
  • N1: fixed. The spec example now runs the subject and prints it.

Not run: tests, build, and lint, per the review rules. The new tests are checked by reading only.

@myzie
myzie merged commit a181486 into main Oct 30, 2026
2 checks passed
@myzie
myzie deleted the feat/program-calls branch October 30, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant