Repository: abathur/resholve (language: python)

Issue:
Resholve doesn't seem to resholve shebangs
Furthermore, if the shebang can't be rewritten in to an absolute path, maybe fail unless it is explicitly authorized.


Discussion / hints:
"do something smarter" seems like a good goal. :)

2 things make me leery of committing until I have a good handle on how to do this:
- I don't think the Oil AST visitor resholve uses exposes the comments or the shebang (it's possible I just need to dig more to figure it out--I haven't picked at this too much previously since I don't otherwise need the comments), making this more manual/custom/involved.
- I'm _aware_ of stuff like platform quirks and multiline shebangs--but it's shallow knowledge.  

The obvious approach seems to be a resholve feature we could think of as a `checkedPatchShebangs`, but I want to brainstorm approaches (feel free to add) in case this is a hammer/nail mistake?

## obvious: add something like a checkedPatchShebangs to resholve
- not too hard if it's as simple as recognizing a first-line shebang and parsing `#!first-word-executable` and `#!/usr/bin/env second-word-executable` 
- I worry a little that it won't be this simple, and resholve will end up on a treadmill of increasingly-nuanced shebang support beyond the above (but I lack the perspective to know how realistic this concern is)
- I worry a little that the smarter the behavior gets, the more likely there are to be gaps between the correct *general* behavior, and the correct behavior *in a Nix build*, i.e.:
    - During a Nix build, a shebang of `#!/bin/bash` should do something like trigger a search for a bash on `RESHOLVE_PATH`, and replace it if one is found
    - Outside of a Nix build, a shebang of `#!/bin/bash` should do something like fail if `/bin/` wasn't in RESHOLVE_PATH?

## Error if any shebang is present
The user will need a triage API to instruct resholve to ignore the shebang (swallow the error) or specify an interpreter (relative? absolute?) so resholve can lop off all existing shebang lines and write a new one. (Maybe also support specifying a new custom shebang from scratch?)
- this feels like a fairly _resholve_ approach (it's explicit, and consistent with existing patterns)
- it creates busywork in scenarios that do seem simple to handle automatically
- it spares resholve from needing to *understand* internal shebang structure at all (let alone well enough to accurately separate scenarios it can handle automatically from ones it can't)
- it feels fairly global/stable (it seems less likely that resholve ends on a treadmill of increasingly-nuanced shebang issues/handling)

## Find an existing standalone tool that can wrangle any complexity here
I've looked a bit and didn't find anything obviously up to the task, but that doesn't mean one doesn't exist.

## Refactor patchShebangs to support adding a `checkedPatchShebangs`
The Nix API for resholve could then invoke this before actually resholving.
- avoids a slippery-slope into reimplementing patchShebangs in python2
- avoids gotcha differences between resholve's behavior and patchShebangs
- leaves resholve without a feature that is probably also useful to non-Nix users
- I'm personally a bit queasy about introducing subtle nixpkgs problems in the process, so I'd prefer finding someone who can confidently muck with it
- I assume it's an epic rebuild

## Extract a standalone tool from the core of patchShebangs
- Existing patchShebangs and resholve could invoke it (are there other obvious users that would be a knock-on benefit?)
- Would need to find someone happy to do this
- I assume it's also an epic rebuild
- it might be easier to take along if someone decides to package resholve for pip or a distribution

Not sure if GH will notify @shlevy or @zimbatm since they aren't contributors, but I'm also curious what they think.
https://www.in-ulm.de/~mascheck/various/shebang/ was my reference for https://github.com/shlevy/long-shebang. As far as I know, long-shebang and nix's shebang mechanism are the only tools that use multiple lines. shebangs *are* moderately thorny, but I do think the complexity is ultimately tractable and I doubt it will grow further in the future.
@shlevy Good to know it's tractable! :)

> As far as I know, long-shebang and nix's shebang mechanism are the only tools that use multiple lines. 

I haven't specifically tried to survey for nonstandard shebangs, but I noticed a similar project from spack: https://github.com/spack/sbang.

> shebangs _are_ moderately thorny, but I do think the complexity is ultimately tractable and I doubt it will grow further in the future.

(Can skip this para if you're familiar with what resholve does) In case more context is helpful, resholve tries to find references to relative external executables/scripts, and either resolve them to absolute paths (from directories in the RESHOLVE_PATH) or raise an error if it can't. I think @grahamc is imagining something like `RESHOLVE_PATH="${expect}/bin" resholve blah.sh` turning:
```
#!/usr/bin/env expect
...
```
into
```
#!/nix/store/ypn4mxpnimsjy54xv7y06hssqjzpvfck-expect-5.45.4/bin/expect
...
```
My treadmill worry is less (but still a little!) about the core syntax getting harder, and more about the parsing and resolution scenarios getting a lot thornier each step down the slope.

For example, consider what the user might expect if they call `RESHOLVE_PATH="${long-shebang}/bin:${bash_5}/bin" resholve ohno.sh` on:

```
#!/usr/bin/env long-shebang
#!sh -e
false
true
```

Given resholve's value-prop, they probably expect it to go one more step down the slope:
```
#!/nix/store/b89qg3zzznr1j84l469irjbr8nx6ryg0-long-shebang-1.2.0/bin/long-shebang
#!/nix/store/j5ry26cch1xghfa2gyfp5zdh20lwkb9a-bash-5.0-p17/bin/sh -e
false
true
```

That isn't incrementally too bad, but my treadmill worry is a few small reasonable steps leading users to ask for stuff that'll make me want to tear my hair out (i.e., can't really be supported without full shell parsing for each shebang line). This example isn't very sensible:
```
#!/usr/bin/env long-shebang
#!/usr/bin/env -i BLAH=hehe HEY=abathur,\ stop\ overthinking\ this\ stuff!\ :D ssh-agent bash
declare -p
```

But it does run!
```
 $ ./sshagent.sh 
BASH=/bin/bash
BASH_ARGC=()
BASH_ARGV=()
BASH_LINENO=([0]="0")
BASH_SOURCE=([0]="./sshagent.sh")
BASH_VERSINFO=([0]="3" [1]="2" [2]="57" [3]="1" [4]="release" [5]="x86_64-apple-darwin19")
BASH_VERSION='3.2.57(1)-release'
BLAH=hehe
DIRSTACK=()
EUID=501
GROUPS=()
HEY='abathur, stop overthinking this stuff! :D'
HOSTNAME=8db58a52
HOSTTYPE=x86_64
IFS=$' \t\n'
MACHTYPE=x86_64-apple-darwin19
OPTERR=1
OPTIND=1
OSTYPE=darwin19
PATH=/usr/gnu/bin:/usr/local/bin:/bin:/usr/bin:.
PPID=48047
PS4='+ '
PWD=/Users/abathur/work/nix/scripts
SHELL=/run/current-system/sw/bin/bash
SHELLOPTS=braceexpand:hashall:interactive-comments
SHLVL=1
SSH_AGENT_PID=49133
SSH_AUTH_SOCK=/tmp/ssh-Frxs1RZi9nLy/agent.49132
TERM=dumb
UID=501
_=bash
__CF_USER_TEXT_ENCODING=0x1F5:0x0:0x0
```

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 18ae6fc..9d19e66 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
       fail-fast: false
     steps:
     - uses: actions/checkout@v2
-    - uses: cachix/install-nix-action@v10
+    - uses: cachix/install-nix-action@v12
       with:
         nix_path: nixpkgs=channel:nixos-unstable
     - run: nix-build ci.nix
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 999bde4..f22154f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
 
 resholve is not yet versioned (though it will be in the near future). Until it is, I'll list major changes by date.
 
+## Dec 10 2020
+This update marks the start of a period (*hopefully a short one*) with many breaking API changes. I am trying to get a few partially-finished features into the codebase, and will then be largely reworking the flag names before finally tagging an initial 0.1.0 release.
+- *BREAKING*: Improved handling of single packages with multiple scripts (such as submodules.) 
+    - At least for now, all of the scripts you want to resolve should be passed (in the Nix api as `scripts`, and as final arguments on the command line).
+    - In order to make this work, I did need to shift the Nix API to run after the files were already in their final locations--during fixup phase (currently as preFixup). Before this change, `scripts` would be a list of filenames relative to the build, but it is now relative to $out.
+
+    If you had something like `scripts = [ "openssl.sh" ];` to resolve a script before installing it to `$out/bin/openssl.sh` the updated form is: `scripts = [ "bin/openssl.sh" ];`
+- *BREAKING*: resholve is now "handling" the interpreter/shebang. Since resholve doesn't actually understand your shebang, this takes a fairly explicit/declarative/idempotent approach. You now *must* specify an `--interpreter`, which may be
+    - a path, in which case any existing shebang will be stripped and a shebang pointing to only this interpreter added
+    - the magic string 'none', in which case any existing shebang will be stripped and nothing will replace it
+- Made the decision to overwrite a script or not explicit via the `--overwrite` flag (when passed as a path). 
+    - Before, resholve would only overwrite the script when it looked like it was running in a Nix build environment.
+    - The Nix `resholvePackage` function takes care of passing this option, so it should be transparent if you're using the Nix API.
+
 ## Sep 2 2020
 Rename resholved -> resholve (and resholver -> resholve)
 
diff --git a/ci.nix b/ci.nix
index 2b2ed9e..0ee84f3 100644
--- a/ci.nix
+++ b/ci.nix
@@ -6,7 +6,7 @@ let
   shunit2 = with pkgs.shunit2;
     resholve.resholvePackage {
       inherit pname src version installPhase;
-      scripts = [ "shunit2" ];
+      scripts = [ "bin/shunit2" ];
       inputs = [ coreutils gnused gnugrep findutils ];
       patchPhase = ''
         substituteInPlace shunit2 --replace "/usr/bin/od" "od"
@@ -31,13 +31,15 @@ let
 
     src = lib.cleanSource tests/nix/libressl/.;
 
-    scripts = [ "libressl.sh" ];
+    # submodule to demonstrate
+    scripts = [ "bin/libressl.sh" "submodule/helper.sh" ];
     inputs = [ jq test_module2 libressl.bin ];
     allow = { };
 
     installPhase = ''
-      mkdir -p $out/bin
+      mkdir -p $out/{bin,submodule}
       install libressl.sh $out/bin/libressl.sh
+      install submodule/helper.sh $out/submodule/helper.sh
     '';
   };
   test_module2 = resholve.resholvePackage {
@@ -48,7 +50,7 @@ let
     # no aliases here, so this has no impact--just using it
     # to illustrate the Nix API, and have a test that'll break
     flags = [ "--resolve-aliases" ];
-    scripts = [ "openssl.sh" ];
+    scripts = [ "bin/openssl.sh" ];
     inputs = [ shunit2 openssl.bin ];
 
     installPhase = ''
@@ -62,7 +64,7 @@ let
 
     src = lib.cleanSource tests/nix/future_perfect_tense/.;
 
-    scripts = [ "conjure.sh" ];
+    scripts = [ "bin/conjure.sh" ];
     inputs = [ test_module1 ];
 
     # TODO: try install -Dt $out/bin $src/yadm
@@ -88,6 +90,9 @@ in stdenv.mkDerivation {
 
   RESHOLVE_PATH = "${stdenv.lib.makeBinPath resolveTimeDeps}";
 
+  # explicit interpreter for demo suite; maybe some better way...
+  INTERP = "${bash}/bin/bash";
+
   checkPhase = ''
     patchShebangs .
     printf "\033[33m============================= resholver demo ===================================\033[0m\n"
@@ -95,6 +100,6 @@ in stdenv.mkDerivation {
 
     printf "\033[33m============================= resholver Nix demo ===============================\033[0m\n"
     env -i $(type -p conjure.sh)
-    bat --paging=never --color=always $(type -p conjure.sh openssl.sh libressl.sh)
+    bat --paging=never --color=always $(type -p conjure.sh ${test_module2}/bin/openssl.sh ${test_module1}/bin/libressl.sh)
   '';
 }
diff --git a/resholve b/resholve
index 3d00234..d7c1521 100755
--- a/resholve
+++ b/resholve
@@ -3,8 +3,8 @@ from __future__ import print_function
 
 import sys
 import os
+import StringIO
 import argparse
-import fileinput
 import logging
 
 from collections import defaultdict
@@ -197,6 +197,20 @@ parser.add_argument(
     dest="resolve_aliases",
     help="Resolve commands inside an alias to absolute paths when possible.",
 )
+parser.add_argument(
+    "--overwrite",
+    action="store_true",
+    dest="overwrite",
+    help="Replace script files instead of writing to script.resolved.",
+)
+parser.add_argument(
+    "--interpreter",
+    dest="interpreter",
+    type=str,
+    metavar="path",
+    help="Specify an interpreter for the shebang. Note: the interpreter doesn't otherwise impact how resholve interprets or rewrites a script.",
+    required=True,
+)
 
 
 def lookup(word):
@@ -213,21 +227,16 @@ def resolve_script(script_path):
     return resolved
 
 
-def write_resolved_script(script_path, resolved):
+def write_resolved_script(script_path, resolved, overwrite=False):
     if not script_path:
         resolved.write_to()
-    elif "NIX_BUILD_TOP" in os.environ and script_path.startswith(
-        os.environ["NIX_BUILD_TOP"]
-    ):
-        logger.info(
-            "script %r located in nix build dir; will attempt to overwrite", script_path
-        )
+    elif overwrite:
+        logger.info("attempting to overwrite script %r", script_path)
         resolved.write_to(script_path)
         sys.stderr.write("Overwrote %r\n" % (script_path))
     else:
         logger.info(
-            "script %r not in Nix build dir; will attempt to write to: %s%s",
-            script_path,
+            "overwrite not set; will attempt to write to: %s%s",
             script_path,
             ".resolved",
         )
@@ -237,6 +246,42 @@ def write_resolved_script(script_path, resolved):
         )
 
 
+def lookup_source(word):
+    """
+    wrap lookup(word) to special-case source lookups
+
+    lookup can't resolve something like `source submodule/helper.sh`
+    unless we add subdirs to PATH, but that could let all kinds of
+    stuff into the resolution scope.
+
+    Instead, we'll try to resolve source calls from the list of
+    input scripts before giving lookup a chance.
+
+    This is a little weird, but this is a not-implemented stub for
+    documentation. The business-end of this function is temporarily
+    published the sourcePATH() context manager during punshow()
+    """
+    raise NotImplementedError("lookup_source called outside of sourcePATH context")
+
+
+from contextlib import contextmanager
+
+
+@contextmanager
+def sourcePATH(script_map):
+    global lookup_source
+
+    def contextual_lookup_source(word):
+        if word in script_map:
+            return script_map[word]
+        return lookup(word)
+
+    prev = lookup_source
+    lookup_source = contextual_lookup_source
+    yield
+    lookup_source = prev
+
+
 def punshow():
     args = parser.parse_args()
     # if it makes sense at some point, vars(args) yields a dict of these
@@ -261,19 +306,31 @@ def punshow():
     if not args.resolve_aliases:
         RecordCommandlike.disable_alias_replacement()
 
+    if args.interpreter == "none":
+        shebang = None
+    else:
+        interp = args.interpreter
+        assert os.path.exists(interp), "Interpreter must exist or be the string 'none'"
+        assert os.path.isabs(interp), "Interpreter path must be absolute"
+        assert os.access(interp, os.X_OK), "Interpreter must be executable"
+        shebang = "#!{:}\n".format(interp)
     try:
         to_write = set()
         if len(args.scripts) == 0:
             # None == <stdin>
-            resolved_scripts[None] = ResolvedScript()
-            to_write.add(None)
+            with sourcePATH(dict()):
+                resolved_scripts[None] = ResolvedScript(shebang=shebang)
+                to_write.add(None)
 
         checked_scripts = list()
-        for script in map(os.path.abspath, args.scripts):
-            if os.path.exists(script):
-                checked_scripts.append(script)
+        script_map = dict()
+        for in_script in args.scripts:
+            abs_script = os.path.abspath(in_script)
+            if os.path.exists(abs_script):
+                checked_scripts.append(abs_script)
+                script_map[in_script] = abs_script
             else:
-                sys.stderr.write("Aborting due to missing file: %r\n" % script)
+                sys.stderr.write("Aborting due to missing file: %r\n" % abs_script)
                 return 2
 
         # TODO: this should probably explain why it's a problem
@@ -284,15 +341,18 @@ def punshow():
             sys.stderr.write("  Distinct: %r\n" % set(checked_scripts))
             return 2
 
-        for script in checked_scripts:
-            resolve_script(script)
-            to_write.add(script)
+        with sourcePATH(script_map):
+            for script in checked_scripts:
+                resolve_script(script)
+                to_write.add(script)
 
         # cmdlikes are cross-source; try to resolve a single time
         resolve_cmdlikes()
 
         for script_path in to_write:
-            write_resolved_script(script_path, resolved_scripts[script_path])
+            write_resolved_script(
+                script_path, resolved_scripts[script_path], args.overwrite
+            )
 
     except IOError as e:
         sys.stderr.write("Whoooo buddy " + str(e))
@@ -928,7 +988,16 @@ class ResolvedScript(RecordCommandlike):
 
     # We need to extract more info after hitting some firstwords
     # This list is just for noticing if something is breaking expectations
-    WATCH_FIRSTWORDS = {"sudo", "command", "eval", "exec", ".", "source", "alias"}
+    WATCH_FIRSTWORDS = {
+        "sudo",
+        "command",
+        "eval",
+        "exec",
+        ".",
+        "source",
+        "alias",
+        "env",
+    }
 
     @staticmethod
     def _make_parser(parse_ctx, script, arena):
@@ -937,10 +1006,37 @@ class ResolvedScript(RecordCommandlike):
         """
         return parse_ctx.MakeOshParser(reader.FileLineReader(script, arena))
 
-    def __init__(self, script_path=None):
+    def replace_shebang(self, fileob, shebang=False):
+        """
+        - skip over the shebang lines, else raise
+        - prepend new shebang if available
+        """
+        pos = fileob.tell()
+        line = fileob.readline()
+
+        while line[0:2] == "#!":
+            if shebang is False:
+                raise Exception(
+                    "Oh gosh :( you gotta get this shebang looked at", line, pos
+                )
+            elif shebang or shebang is None:
+                logger.debug("Skipping shebang line: %r", line)
+
+            pos = fileob.tell()
+            line = fileob.readline()
+
+        logger.debug("First non-shebang line: %r", line)
+        fileob.seek(pos)
+        logger.debug("appending shebang: %r", shebang)
+        return StringIO.StringIO((shebang if shebang else "") + fileob.read())
+
+    def __init__(self, script_path=None, shebang=""):
 
         # generally, defer work until we know the script loaded
         with (open(script_path) if script_path else sys.stdin) as script:
+            if shebang:  # not allow_shebang:
+                script = self.replace_shebang(script, shebang)
+
             arena = alloc.Arena()
             parse_ctx = parse_lib.ParseContext(
                 arena=arena,
@@ -1145,7 +1241,7 @@ class ResolvedScript(RecordCommandlike):
         # source?
 
         # TODO: should builtin be here? Currently not because we don't want to replace them...
-        if word1 in (".", "source", "sudo", "command", "eval", "exec", "alias"):
+        if word1 in self.WATCH_FIRSTWORDS:
             logger.info("Visiting command: %s %s", word1, word2)
             if word1 == "eval" and w_ob2.parts[0].tag in (
                 word_part_e.SingleQuoted,
@@ -1249,12 +1345,12 @@ class ResolvedScript(RecordCommandlike):
                 if word1 in ("command", "eval"):
                     # TODO: not absolutely certain about resolution order here
                     self.record_command_cmd(w_ob2, word2)
-                elif word1 in ("sudo", "exec"):
+                elif word1 in ("sudo", "exec", "env"):
                     # TODO: not absolutely certain about resolution order here
                     self.record_external_cmd(w_ob2, word2)
                 elif word1 in (".", "source"):
                     # CAUTION: in a multi-module library, we'll have to think very carefully about how to look up targets in order to parse them, but *avoid* translating the source statement into an absolute URI. (If this is sticky, another option might be a post-substitute to replace the build-path with the output path?)
-                    target = lookup(word2)
+                    target = lookup_source(word2)
                     logger.debug("Looked up source: %r -> %r", word2, target)
                     # it was already a valid absolute path
                     if target == word2 and target[0] == "/":
diff --git a/resholve-package.nix b/resholve-package.nix
index 4637880..cf642c1 100644
--- a/resholve-package.nix
+++ b/resholve-package.nix
@@ -1,4 +1,4 @@
-{ stdenv, lib, resholve, }:
+{ stdenv, lib, resholve, bash }:
 
 { pname, src, version, scripts, inputs ? [ ], allow ? { }, flags ? [ ], passthru ? { }, ...
 }@attrs:
@@ -12,10 +12,10 @@ let
       RESHOLVE_ALLOW = toString
         (lib.mapAttrsToList (name: value: map (y: name + ":" + y) value) allow);
       #LOGLEVEL="INFO";
-      buildPhase = ''
-        runHook preBuild
-        resholve ${toString (flags ++ scripts)}
-        runHook postBuild
+      preFixup = ''
+        pushd $out
+        resholve --interpreter ${bash}/bin/bash --overwrite ${toString (flags ++ scripts)}
+        popd
       '';
     }));
 in lib.extendDerivation true passthru self
diff --git a/resholve.nix b/resholve.nix
index 2284e90..e15c2c2 100644
--- a/resholve.nix
+++ b/resholve.nix
@@ -1,5 +1,5 @@
 {
-  stdenv, callPackage, file, findutils, gettext, python27, bats,
+  stdenv, callPackage, file, findutils, gettext, python27, bats, bash,
 
   doCheck ? true
 }:
@@ -35,6 +35,9 @@ in python27.pkgs.buildPythonApplication {
   inherit doCheck;
   checkInputs = [ bats ];
   RESHOLVE_PATH = "${stdenv.lib.makeBinPath resolveTimeDeps}";
+
+  # explicit interpreter for test suite; maybe some better way...
+  INTERP = "${bash}/bin/bash";
   checkPhase = ''
     PATH=$out/bin:$PATH
     patchShebangs .


Test patch:
diff --git a/tests/behavior.bats b/tests/behavior.bats
index 968cde0..d039128 100644
--- a/tests/behavior.bats
+++ b/tests/behavior.bats
@@ -14,7 +14,7 @@ quoted_eval="FEEDBACK WANTED: Letting quoted 'eval' through"
     line 9 contains "eval_quoted.sh:11: $quoted_eval"
   })
 } <<CASES
-resholve eval_quoted.sh
+resholve --interpreter $INTERP eval_quoted.sh
 CASES
 
 # TODO: replace w/ real test once issue is sorted
@@ -59,5 +59,5 @@ var_as_command="FEEDBACK WANTED: Letting dynamic command (first-word variable) t
 
   })
 } <<CASES
-resholve variable_as_command.sh
+resholve --interpreter $INTERP variable_as_command.sh
 CASES
diff --git a/tests/cli.bats b/tests/cli.bats
index 5d95905..a8ff0c5 100644
--- a/tests/cli.bats
+++ b/tests/cli.bats
@@ -16,6 +16,16 @@
 
 load helpers
 
+@test "invoking resholve without --interpreter prints an error" {
+  unset RESHOLVE_PATH
+  require <({
+    status 2
+    line -1 equals "resholve: error: argument --interpreter is required"
+  })
+} <<CASES
+resholve < file_simple.sh
+resholve file_simple.sh
+CASES
 
 @test "invoking resholve without RESHOLVE_PATH prints an error" {
   unset RESHOLVE_PATH
@@ -24,19 +34,52 @@ load helpers
     line -1 equals "AssertionError: RESHOLVE_PATH must be set"
   })
 } <<CASES
-resholve < file_simple.sh
-resholve file_simple.sh
+resholve --interpreter /usr/bin/env < file_simple.sh
+resholve --interpreter none file_simple.sh
+CASES
+
+@test "invoking resholve with missing interpreter prints an error" {
+  require <({
+    status 1
+    line -1 equals "AssertionError: Interpreter must exist or be the string 'none'"
+  })
+} <<CASES
+resholve --interpreter /blah < file_simple.sh
+resholve --interpreter /blah file_simple.sh
 CASES
 
-@test "invoking resholve without script deps prints an error" {
+@test "invoking resholve with a relative interpreter prints an error" {
+  require <({
+    status 1
+    line -1 equals "AssertionError: Interpreter path must be absolute"
+  })
+  # just using a random relative script as interp
+} <<CASES
+resholve --interpreter aliases.sh < file_simple.sh
+resholve --interpreter aliases.sh file_simple.sh
+CASES
+
+@test "invoking resholve with a non-executable interpreter prints an error" {
+  require <({
+    status 1
+    line -1 equals "AssertionError: Interpreter must be executable"
+  })
+  # just using a random non-executable script as interp
+} <<'CASES'
+resholve --interpreter $PWD/aliases.sh < file_simple.sh
+resholve --interpreter $PWD/aliases.sh file_simple.sh
+CASES
+
+
+@test "invoking resholve without script's deps prints an error" {
   RESHOLVE_PATH=''
   require <({
     status 3
     line -1 contains "Can't resolve command 'file' to a known function or executable"
   })
 } <<CASES
-resholve < file_simple.sh
-resholve file_simple.sh
+resholve --interpreter $INTERP < file_simple.sh
+resholve --interpreter $INTERP file_simple.sh
 CASES
 
 @test "resholve resolves simple external dependency from command-line args" {
@@ -45,8 +88,8 @@ CASES
     line -1 contains "wrote "
   })
 } <<CASES
-resholve file_simple.sh
-resholve file_simple.sh source_present_target.sh
+resholve --interpreter $INTERP file_simple.sh
+resholve --interpreter $INTERP file_simple.sh source_present_target.sh
 CASES
 
 @test "resholve resolves simple external dependency from stdin" {
@@ -58,7 +101,7 @@ CASES
     line -1 ends "/bin/file"
   })
 } <<CASES
-resholve < file_simple.sh
+resholve --interpreter $INTERP < file_simple.sh
 CASES
 
 @test "resholve fails if target script isn't found" {
@@ -68,8 +111,8 @@ CASES
     line -1 begins "Aborting due to missing file: '/hopenot/file_simple.sh'"
   })
 } <<CASES
-resholve /hopenot/file_simple.sh
-resholve file_simple.sh /hopenot/file_simple.sh
+resholve --interpreter $INTERP /hopenot/file_simple.sh
+resholve --interpreter $INTERP file_simple.sh /hopenot/file_simple.sh
 CASES
 
 @test "resholve fails with duplicate input scripts" {
@@ -79,7 +122,7 @@ CASES
     line 1 equals "Aborting due to duplicate script targets."
   })
 } <<CASES
-resholve file_simple.sh source_present_target.sh source_present_target.sh
+resholve --interpreter $INTERP file_simple.sh source_present_target.sh source_present_target.sh
 CASES
 
 @test "resholve fails when scripts have dynamic elements that aren't 'allowed'" {
@@ -89,8 +132,8 @@ CASES
     line -1 contains "Can't resolve 'source' with a dynamic argument"
   })
 } <<CASES
-resholve source_var_pwd.sh
-resholve source_home_pwd.sh
+resholve --interpreter $INTERP source_var_pwd.sh
+resholve --interpreter $INTERP source_home_pwd.sh
 CASES
 
 @test "resholve fails when 'allow' directives are misformatted" {
@@ -100,9 +143,9 @@ CASES
     line -1 contains "should be a scope:var pair"
   })
 } <<CASES
-resholve --allow source PWD source_var_pwd.sh
-resholve --allow PWD source_var_pwd.sh
-resholve < source_var_pwd_bad_annotation.sh
+resholve --interpreter $INTERP --allow source PWD source_var_pwd.sh
+resholve --interpreter $INTERP --allow PWD source_var_pwd.sh
+resholve --interpreter $INTERP < source_var_pwd_bad_annotation.sh
 CASES
 
 @test "resholve fails when 'allow' directive doesn't specify the right thing" {
@@ -112,13 +155,13 @@ CASES
     line -1 contains "Can't resolve 'source' with a dynamic argument"
   })
 } <<CASES
-resholve --allow command:PWD < source_var_pwd.sh
-RESHOLVE_ALLOW='command:PWD' resholve < source_var_pwd.sh
-resholve --allow source:HOME < source_var_pwd.sh
-RESHOLVE_ALLOW='source:HOME' resholve < source_var_pwd.sh
-resholve < source_var_pwd_misannotated.sh
-resholve --allow source:PWD < source_home_pwd.sh
-RESHOLVE_ALLOW='source:PWD' resholve < source_home_pwd.sh
+resholve --interpreter $INTERP --allow command:PWD < source_var_pwd.sh
+RESHOLVE_ALLOW='command:PWD' resholve --interpreter $INTERP < source_var_pwd.sh
+resholve --interpreter $INTERP --allow source:HOME < source_var_pwd.sh
+RESHOLVE_ALLOW='source:HOME' resholve --interpreter $INTERP < source_var_pwd.sh
+resholve --interpreter $INTERP < source_var_pwd_misannotated.sh
+resholve --interpreter $INTERP --allow source:PWD < source_home_pwd.sh
+RESHOLVE_ALLOW='source:PWD' resholve --interpreter $INTERP < source_home_pwd.sh
 CASES
 
 @test "resholve succeeds when 1x 'allow' directives are correct" {
@@ -129,9 +172,9 @@ CASES
     line -1 equals "# resholve: allow source:PWD"
   })
 } <<CASES
-resholve --allow source:PWD < source_var_pwd.sh
-RESHOLVE_ALLOW='source:PWD' resholve < source_var_pwd.sh
-resholve < source_var_pwd_annotated.sh
+resholve --interpreter $INTERP --allow source:PWD < source_var_pwd.sh
+RESHOLVE_ALLOW='source:PWD' resholve --interpreter $INTERP < source_var_pwd.sh
+resholve --interpreter $INTERP < source_var_pwd_annotated.sh
 CASES
 
 @test "resholve succeeds when 2x 'allow' directives are correct" {
@@ -144,30 +187,30 @@ CASES
     line -1 equals "# resholve: allow source:PWD"
   })
 } <<CASES
-resholve --allow source:PWD --allow source:HOME < source_home_pwd.sh
-RESHOLVE_ALLOW='source:PWD source:HOME' resholve < source_home_pwd.sh
-RESHOLVE_ALLOW='source:PWD' resholve --allow source:HOME < source_home_pwd.sh
-RESHOLVE_ALLOW='source:PWD' resholve < source_home_pwd_annotated_incomplete.sh
-resholve --allow source:PWD < source_home_pwd_annotated_incomplete.sh
+resholve --interpreter $INTERP --allow source:PWD --allow source:HOME < source_home_pwd.sh
+RESHOLVE_ALLOW='source:PWD source:HOME' resholve --interpreter $INTERP < source_home_pwd.sh
+RESHOLVE_ALLOW='source:PWD' resholve --interpreter $INTERP --allow source:HOME < source_home_pwd.sh
+RESHOLVE_ALLOW='source:PWD' resholve --interpreter $INTERP < source_home_pwd_annotated_incomplete.sh
+resholve --interpreter $INTERP --allow source:PWD < source_home_pwd_annotated_incomplete.sh
 CASES
 
 @test "Don't resolve aliases without --resolve-aliases" {
   require <({
     status 0
-    line 3 !contains "/nix/store"
-    line 4 !contains 'find="/nix/store'
-    line 4 !contains 'find2="/nix/store'
-    line 6 !contains "/nix/store"
-    line 8 !contains "/nix/store"
-    line 9 contains "/nix/store"
-    line 10 !contains "/nix/store"
-    line 11 contains "/nix/store"
-    line 12 equals "### resholve directives (auto-generated)"
+    line 4 !contains "/nix/store"
+    line 5 !contains 'find="/nix/store'
+    line 6 !contains 'find2="/nix/store'
+    line 7 !contains "/nix/store"
+    line 9 !contains "/nix/store"
+    line 10 contains "/nix/store"
+    line 11 !contains "/nix/store"
+    line 12 contains "/nix/store"
+    line 13 equals "### resholve directives (auto-generated)"
     # can't assert the ends; these get sorted
     # and the hash makes unstable :(
-    line 13 begins "# resholve: allow resholved_inputs:/nix/store/"
     line 14 begins "# resholve: allow resholved_inputs:/nix/store/"
+    line 15 begins "# resholve: allow resholved_inputs:/nix/store/"
   })
 } <<CASES
-resholve < alias_riddle.sh
+resholve --interpreter $INTERP < alias_riddle.sh
 CASES
diff --git a/tests/demo.bats b/tests/demo.bats
index 4874c3d..3b0d009 100644
--- a/tests/demo.bats
+++ b/tests/demo.bats
@@ -16,62 +16,62 @@ load demo
     status 3
   })
 } <<CASES
-resholve < which_simple.sh
+resholve --interpreter $INTERP < which_simple.sh
 CASES
 
 @test "Even in a function, 'which' needs to be in RESHOLVE_PATH" {
   demo "command_in_function.sh" <(status 3)
 } <<CASES
-resholve < command_in_function.sh
+resholve --interpreter $INTERP < command_in_function.sh
 CASES
 
 @test "Absolute executable paths need exemptions" {
   demo "absolute_path.sh" <(status 5)
 } <<CASES
-resholve < absolute_path.sh
+resholve --interpreter $INTERP < absolute_path.sh
 CASES
 
 @test "Source, among others, needs an exemption for arguments containing variables" {
   demo "source_var_pwd.sh" <(status 6)
 } <<CASES
-resholve < source_var_pwd.sh
+resholve --interpreter $INTERP < source_var_pwd.sh
 CASES
 
 @test "Resolves unqualified 'file' to absolute path from RESHOLVE_PATH" {
   demo "file_simple.sh" <(status 0)
 } <<CASES
-resholve < file_simple.sh
+resholve --interpreter $INTERP < file_simple.sh
 CASES
 
 # TODO: maybe it better illustrates to just collapse this test with the above (and tests 1 and 2)
 @test "Even in a function, resolves unqualified 'file' to absolute path from RESHOLVE_PATH" {
   demo "file_in_function.sh" <(status 0)
 } <<CASES
-resholve < file_in_function.sh
+resholve --interpreter $INTERP < file_in_function.sh
 CASES
 
 @test "Only some commands ('source' but NOT 'file', here) are checked for variable arguments." {
   demo "file_home_source_pwd.sh" <(status 6)
 } <<CASES
-resholve < file_home_source_pwd.sh
+resholve --interpreter $INTERP < file_home_source_pwd.sh
 CASES
 
 @test "Add an exemption with --allow <scope>:<name>" {
   demo "file_home_source_pwd.sh" <(status 0)
 } <<CASES
-resholve --allow source:PWD < file_home_source_pwd.sh
+resholve --interpreter $INTERP --allow source:PWD < file_home_source_pwd.sh
 CASES
 
 @test "Add an exemption with RESHOLVE_ALLOW="source:PWD"" {
   demo "file_home_source_pwd.sh" <(status 0)
 } <<CASES
-RESHOLVE_ALLOW="source:PWD" resholve < file_home_source_pwd.sh
+RESHOLVE_ALLOW="source:PWD" resholve --interpreter $INTERP < file_home_source_pwd.sh
 CASES
 
 @test "'source' targets also need to be in RESHOLVE_PATH" {
   demo "source_missing_target.sh" <(status 7)
 } <<CASES
-resholve < source_missing_target.sh
+resholve --interpreter $INTERP < source_missing_target.sh
 CASES
 
 @test "Resolves unqualified 'source' to absolute path from RESHOLVE_PATH" {
@@ -82,26 +82,26 @@ CASES
     line -1 ends "/bin/gettext.sh"
   })
 } <<CASES
-resholve < source_present_target.sh
+resholve --interpreter $INTERP < source_present_target.sh
 CASES
 
 @test "Has (naive) context-specific resolution rules" {
   demo "alias_riddle.sh" <({
     status 0
-    line 3 !contains "/nix/store"
-    line 4 contains 'find="/nix/store'
-    line 4 contains 'find2="/nix/store'
-    line 6 !contains "/nix/store"
-    line 8 !contains "/nix/store"
-    line 9 contains "/nix/store"
-    line 10 !contains "/nix/store"
-    line 11 contains "/nix/store"
-    line 12 equals "### resholve directives (auto-generated)"
+    line 4 !contains "/nix/store"
+    line 5 contains 'find="/nix/store'
+    line 5 contains 'find2="/nix/store'
+    line 7 !contains "/nix/store"
+    line 9 !contains "/nix/store"
+    line 10 contains "/nix/store"
+    line 11 !contains "/nix/store"
+    line 12 contains "/nix/store"
+    line 13 equals "### resholve directives (auto-generated)"
     # can't assert the ends; these get sorted
     # and the hash makes unstable :(
-    line 13 begins "# resholve: allow resholved_inputs:/nix/store/"
     line 14 begins "# resholve: allow resholved_inputs:/nix/store/"
+    line 15 begins "# resholve: allow resholved_inputs:/nix/store/"
   })
 } <<CASES
-resholve --resolve-aliases < alias_riddle.sh
+resholve --interpreter $INTERP --resolve-aliases < alias_riddle.sh
 CASES
diff --git a/tests/nix/libressl/libressl.sh b/tests/nix/libressl/libressl.sh
index f2538ca..c651a4c 100644
--- a/tests/nix/libressl/libressl.sh
+++ b/tests/nix/libressl/libressl.sh
@@ -1,3 +1,5 @@
+source submodule/helper.sh
+
 libressl_sh() {
     set -x
     jq -n --arg greeting world '{"hello":$greeting}'
@@ -5,4 +7,6 @@ libressl_sh() {
     set +x
 }
 
+just_being_helpful
+
 source openssl.sh
diff --git a/tests/nix/libressl/submodule/helper.sh b/tests/nix/libressl/submodule/helper.sh
new file mode 100644
index 0000000..6f8837c
--- /dev/null
+++ b/tests/nix/libressl/submodule/helper.sh
@@ -0,0 +1,3 @@
+just_being_helpful(){
+	echo "much help"
+}