Skip to content

jq: a wrapped module dependency sits inside the including def's scope, so that def's own name or parameters can capture names free in it #2962

Description

@newhoggy

Severity: Low

Summary

Found by code review on PR #2954 (#2865), and recorded there in
docs/compliance/jq/limitations.md. #2865 makes a module's own includes work
by wrapping each exported def's body in the dependencies it reaches. The wrap
nests inside that def's own scope, so a name left free in a dependency can
be captured by the def's own name or by one of its parameters.

Not a regression: every shape below needs a transitive include, which did not
work at all before #2865 (main answers g/0 is not defined / k/0 is not defined for each). These are residual gaps in the new functionality.

Repro

All rows confirmed live against jq 1.7.1 and PR #2954's head.

1. The self-(name, arity) exclusion strands a dependency another dependency calls

$ cat inner.jq
def c: 7;
def g: c;
$ cat mid.jq
include "inner";
def c: if . == 0 then g else (. - 1 | c) end;

$ jq            -L . -nc 'include "mid"; 0 | c'   # 7
$ succinctly jq -L . -nc 'include "mid"; 0 | c'   # error: g/0 exceeded maximum recursion depth (exit 5)

mid's body calls c directly, so the dependency c is excluded to leave the
self-recursive call binding to itself — but the kept dependency g still needs
it, and its c then resolves outward to mid's own c.

2. A parameter satisfies a name a dependency left undefined, swallowing a compile error

$ cat gb.jq
def g: b;
$ cat hb.jq
include "gb";
def h(b): g;
def q: h(99);

$ jq            -L . -nc 'include "hb"; q'   # error: b/0 is not defined at gb.jq (exit 3)
$ succinctly jq -L . -nc 'include "hb"; q'   # 99 (exit 0)

The worse half of the family: a program jq rejects produces an answer.

3. A parameter captures a dependency reached only indirectly

$ cat inner3.jq
def g: 42;
def k: [g];
$ cat h3.jq
include "inner3";
def h($g): [g, k];
def q: h(7);

$ jq            -L . -nc 'include "h3"; q'   # [7,[42]]
$ succinctly jq -L . -nc 'include "h3"; q'   # [7,[7]]

h's own g is correctly the parameter; k's g should still be the
dependency, and is not.

Root cause

ModuleLoader::load_and_bind_module / visible_deps_for
(src/bin/succinctly/jq_runner.rs). Binding is wrap_defs(body, deps), which
nests the dependency block inside the Expr::FuncDef for the def itself — so
that def's name (for self-recursion) and its parameters are enclosing binders
for every dependency in the block. Real jq binds each module's block in its own
scope and only then links it, so nothing of the caller is in scope for it.

The two exclusions in visible_deps_for are the current, partial mitigation:
they keep the def's own recursion and its parameters working for names the body
uses directly. They cannot help a name reached only through another
dependency, and the cases pull in opposite directions — excluding strands the
other dependency (row 1), keeping it would shadow the def's own binding.

Suggested fix direction

Two shapes:

  1. Targeted renaming. When a dependency has to be excluded, give it a fresh
    internal name instead of dropping it, and rewrite free calls to the original
    name inside the other kept dependency bodies — a scope-aware walk that
    stops at any nested def of the same (name, arity) and at parameters
    binding it. Contained, and fixes rows 1 and 3; row 2 needs (3) below as well.
  2. A real module scope boundary, which is what jq: a module body sees names it should not — ~/.jq's defs, and sibling included modules' defs in declaration order #2951 is about: with a
    sealed scope the wrap would not nest inside the caller at all, and the whole
    family goes away. Subsumes (1).
  3. Resolve a module's own body at load time, reporting b/0 is not defined
    against the module's own file the way jq does. That is what turns row 2 back
    into a compile error rather than something a caller can accidentally satisfy,
    and it also interacts with jq: a module's own source is parsed with no shadow-candidate seeding, so a transitively included def can't shadow a builtin inside that module #2950 (a module's source is parsed with no
    shadow-candidate seeding).

Why this is separate from #2865, #2950, #2951

#2865 delivers transitive loading and matched every ordinary shape probed
(dependencies, siblings, parameters, arity splits, self-recursion, patterns,
namespaces, diamonds, chains, $__loc__, cycles); this is the boundary of the
mechanism it uses, and needs either a scope boundary (#2951) or the narrower
rename above rather than another exclusion rule. #2950 is the shadow-candidate
half of the same module-scope story.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    OpusSuitable for an Opus-class model to implementTriagedIssue has been read, planned, and its model-class label verifiedbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions