8000 fix: resolve bare programs through their detectors · habit-hooks/habit-hooks@2486d51 · GitHub
[go: up one dir, main page]

Skip to content

Commit 2486d51

Browse files
committed
fix: resolve bare programs through their detectors
Keep bare argv programs on the same detector-owned search path used by setup and placeholder expansion.
1 parent c0ba3e9 commit 2486d51

9 files changed

Lines changed: 171 additions & 18 deletions

File tree

‎CLAUDE.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,11 +1054,19 @@ name then answers `jscpd: command not found` with the tool sitting right there.
10541054
uses.** Every one is `argv = ["${python}", "${dir}/<helper>.py", ...]` or
10551055
`["node", "...cjs", ...]`, and the helper spawns `jscpd`/`pmd`/`php`/`deptry`/
10561056
`ruff` itself, one process further in — where the tools that actually go missing
1057-
on Windows go missing. `sensors/spawn.Spawner._runnable` still resolves a bare
1058-
`argv[0]`, since off Windows it names the very file the spawn's own search would
1059-
have reached; only a **bare** name is resolved, because a path (`${python}`,
1060-
`${dir}/helper.py`) is read against the directory the command runs in, and every
1061-
argument after the first is an argument whatever it looks like.
1057+
on Windows go missing. A **bare** `argv[0]` that names a declared `command`
1058+
detector is resolved through that detector — the loader puts it in
1059+
`Part.detectors` (`named_tools._bare_program`) and `command_text._program`
1060+
substitutes the file — so the bare spelling and `${detector:<name>}` agree on
1061+
the same search paths, and a binstub in the project's `bin` is found by either
1062+
spelling or by neither. `Spawner._runnable` still resolves a bare `argv[0]`,
1063+
but only as the fallback for names nobody declared: off Windows such a name
1064+
is the very file the spawn's own search would have reached, and a path
1065+
(`${python}`, `${dir}/helper.py`) is read against the directory the command
1066+
runs in, while every argument after the first is an argument whatever it looks
1067+
like. The typescript plugin's `argv = ["node", ...]` parts now resolve `node`
1068+
through its own declaration — the same file the fallback found, and a machine
1069+
without node fails by name rather than at the spawn.
10621070

10631071
The other process gets there by **naming the tool in the recipe**:
10641072
`${detector:<name>}` (`sensors/named_tools.py`) expands to the file

‎docs/authoring-plugins.spec.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ declares, and a name declared `node-module`, are both refused as the config load
119119
the ordinary missing-tool failure — the sensor is never spawned, and the notice
120120
names the tool it needed, without your script having to say so.
121121

122+
A bare `argv[0]` that names a declared `command` detector is resolved to that
123+
same file, along the detector's own `search_paths`, and fails the same way when
124+
it is not installed; only a name no plugin declares is left for the spawn to
125+
look up along the default path.
126+
122127
Hand that file to a bundled script rather than looking the tool up again inside
123128
it. Every shipped sensor that names a tool passes it as the script's first
124129
argument:

‎docs/config.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ root keys — it describes the plugin, not the whole run:
236236
| `language` | The language this plugin **declares**. A plugin is not a language: its name need not match, several plugins can declare the same language, and the runner stamps this onto the plugin's findings. `generic` declares none. |
237237
| `sensors` | An ordered list of the sensor names the plugin runs. |
238238
| `transformers` | An ordered list of the plugin's own transformers, applied to its sensors' concatenated findings before the result joins the larger run. |
239-
| `detectors` | The external tools this plugin's sensors reach for, each a `{ name, kind, install, search_paths }` table: what to look for, how to look for it (`command` — on `PATH`; `node-module` — a package `node` resolves from the project, for one read as a library rather than spawned), the command that installs it, and — optionally — `search_paths`, a list of directories under the project that a `command` is looked for in ahead of the default path (`node_modules/.bin`, `.venv/bin`, then `PATH`). A project never declares these: it names the plugins it runs, and what each of those needs installed is the plugin's to say. |
239+
| `detectors` | The external tools this plugin's sensors reach for, each a `{ name, kind, install, search_paths }` table: what to look for, how to look for it (`command` — on `PATH`; `node-module` — a package `node` resolves from the project, for one read as a library rather than spawned), the command that installs it, and — optionally — `search_paths`, a list of directories under the project that a `command` is looked for in ahead of the default path (`node_modules/.bin`, `.venv/bin`, then `PATH`) — however the recipe names it, `${detector:<name>}` or a bare `argv[0]`. A project never declares these: it names the plugins it runs, and what each of those needs installed is the plugin's to say. |
240240

241241
```toml
242242
# habit_hooks_ruby/config.toml

‎src/habit_hooks/sensors/command_text.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
and with no shell in the way a quote character would simply become part of
99
the filename. It is also the only form that runs where there is no POSIX
1010
shell: on Windows ``bash`` is usually the WSL launcher, which answers from
11-
another filesystem entirely.
11+
another filesystem entirely. Its first element may name a tool as a bare
12+
name, resolved to that tool's file exactly as ``${detector:<name>}`` is
13+
(``named_tools``).
1214
* ``command = "..."`` is text for ``bash -c``, and buys syntax a list cannot
1315
carry — the ``ruff`` and ``eslint`` sensors pipe their tool through ``jq`` —
1416
at the price of a shell to read it, which is why a platform without one
@@ -92,13 +94,24 @@ def _argv_form(part: Part, files: list[str], config_path: Path | None) -> list[s
9294
"${args}": part.args,
9395
"${config}": _config_arguments(config_path),
9496
}
97+
elements = part.argv or []
98+
program = [_program(part, elements[0])] if elements else []
9599
return [
96100
argument
97-
for element in part.argv or []
101+
for element in [*program, *elements[1:]]
98102
for argument in _element_arguments(part, element, lists)
99103
]
100104

101105

106+
def _program(part: Part, element: str) -> str:
107+
"""The program element as the file this project runs for it, where a name
108+
this run's plugins declared resolved to one (``named_tools``): a bare name
109+
is looked up again by whatever spawns it, along paths that know nothing of
110+
the detector's own. Anything else keeps the element it was."""
111+
file = part.detectors.get(element)
112+
return element if file is None else file
113+
114+
102115
def _element_arguments(
103116
part: Part, element: str, lists: dict[str, list[str]]
104117
) -> list[str]:

‎src/habit_hooks/sensors/model.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ class Part:
3232
# A sensor's own discovery globs: when set, the run's scope is narrowed to
3333
# this subset for this sensor alone. ``None`` means "the whole scope".
3434
files: list[str] | None = None
35-
# The file this project runs for each tool the recipe names with
36-
# ``${detector:<name>}``, or ``None`` where that tool is not installed
37-
# (``named_tools``). Only the tools it names, never its plugin's others.
35+
# The file this project runs for each tool the recipe names — with
36+
# ``${detector:<name>}``, or as the bare program an ``argv`` spells — or
37+
# ``None`` where that tool is not installed (``named_tools``). Only the
38+
# tools it names, never its plugin's others.
3839
detectors: dict[str, str | None] = field(default_factory=dict)
3940

4041
@property

‎src/habit_hooks/sensors/named_tools.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
and on Windows that lookup adds ``.exe`` and nothing else, where npm installs
1010
every Node tool as a ``.cmd`` shim.
1111
12-
Only the tools a recipe actually names are resolved for it. A part answers for
12+
Only the tools a recipe actually names are resolved for it: a placeholder, or
13+
the bare program an ``argv`` spells as its first element. A part answers for
1314
what it reaches for and never for its plugin's other tools, or a project missing
1415
a tool no sensor of that plugin names would fail the sensors that never wanted
1516
it.
@@ -32,6 +33,7 @@
3233

3334
from __future__ import annotations
3435

36+
import os
3537
import re
3638
import shlex
3739
from collections.abc import Callable
@@ -101,7 +103,9 @@ def files_for(part: Part, kind: str, tools: DeclaredTools) -> dict[str, str | No
101103
"""The file this project runs for each tool ``part`` names, ``None`` if absent.
102104
103105
A name that can never stand for a file at all stops the run here, naming the
104-
part, why, and the config line that stops running it.
106+
part, why, and the config line that stops running it. The bare program an
107+
``argv`` spells is a name too, resolved the same way and refused nothing:
108+
a program nobody declared is the spawn's own to look up.
105109
"""
106110
named = _names_in(part)
107111
for name in named:
@@ -111,7 +115,11 @@ def files_for(part: Part, kind: str, tools: DeclaredTools) -> dict[str, str | No
111115
f"{kind} {part.name!r} names ${{detector:{name}}}, {unusable} — "
112116
f"{switch_off(kind, part.name)}"
113117
)
114-
return {name: tools.file_for(name) for name in named}
118+
files = {name: tools.file_for(name) for name in named}
119+
program = _bare_program(part)
120+
if program and program not in files and COMMAND_KIND in tools.kinds_of(program):
121+
files[program] = tools.file_for(program)
122+
return files
115123

116124

117125
def spelled_plainly(part: Part, text: str) -> str:
@@ -144,6 +152,13 @@ def _names_in(part: Part) -> list[str]:
144152
return list(dict.fromkeys(DETECTOR.findall(recipe)))
145153

146154

155+
def _bare_program(part: Part) -> str | None:
156+
"""The program an ``argv`` recipe spells as a bare name, if it spells one."""
157+
if part.argv is None or os.path.dirname(part.argv[0]):
158+
return None
159+
return part.argv[0]
160+
161+
147162
def _why_no_file_for(name: str, tools: DeclaredTools) -> str | None:
148163
"""Why ``name`` can never stand for a file to spawn, or ``None`` when it can."""
149164
kinds = tools.kinds_of(name)

‎src/habit_hooks/sensors/spawn.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ def run(
8585
def _runnable(self, argv: list[str], tools: Sequence[str]) -> list[str]:
8686
"""``argv`` with its program named by the file this project runs for it.
8787
88-
A bare command name is the only thing in question. Left as a name it is
88+
A bare command name is the only thing in question, and one a detector
89+
declared arrives already resolved to its file (``command_text``) — this
90+
lookup is the fallback for names nobody declared. Left as a name it is
8991
looked up by whatever the spawn uses, which on Windows is not the
9092
lookup the tool was cleared by — so this asks the one that cleared it
9193
(``project_paths.tool_executable``) and hands the spawn its answer.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""A bare ``argv[0]`` that names a declared tool, and the file it then runs.
2+
3+
``${detector:<name>}`` is one way a recipe names a tool; a bare program name is
4+
the other, and the authoring guide promises both
5+
(``docs/authoring-plugins.spec.md``). The bare spelling used to be looked up
6+
along the default path alone, whatever the tool's detector declared — so a
7+
binstub installed only under the project's ``bin`` was cleared by the setup
8+
(``missing_tools`` asks the detector's own search paths) and then not found by
9+
the run, the very disagreement ``project_paths.tool_executable`` exists to
10+
prevent. A bare name this run's plugins declared is resolved to the same file
11+
the placeholder spelling stands for; a name nobody declared keeps the lookup
12+
the spawn always made.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from pathlib import Path
18+
19+
import pytest
20+
from bare_machine import project_with_no_tools
21+
from detector_declarations import declaring
22+
from executable_stub import write_stub
23+
from plugin_fixture import one_sensor
24+
25+
from habit_hooks.project_paths import tool_executable
26+
from habit_hooks.scope import Scope
27+
< 10BC8 span class=pl-k>from habit_hooks.sensors.execution import Execution
28+
from habit_hooks.sensors.model import Part
29+
30+
BUNDLER = (
31+
'{ name = "rubocop", kind = "command", install = "gem install rubocop", '
32+
'search_paths = ["bin"] }'
33+
)
34+
35+
36+
def _argv(project: Path, part: Part) -> list[str]:
37+
"""The argv spawning ``part`` in ``project`` would carry."""
38+
return Execution(project_dir=project, scope=Scope(files=[]))._expand(part)
39+
40+
41+
def _run(project: Path, part: Part) -> object:
42+
"""What running ``part`` over one file comes to."""
43+
scope = Scope(files=["a.rb"])
44+
return Execution(project_dir=project, scope=scope).run_sensors([part])
45+
46+
47+
def test_a_bare_program_is_looked_for_along_the_search_path_its_detector_names(
48+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
49+
) -> None:
50+
"""The bare spelling of a name is resolved exactly as ``${detector:<name>}``
51+
is: the same detector, the same search paths, the same file."""
52+
project = project_with_no_tools(tmp_path, monkeypatch)
53+
write_stub(project / "bin", "rubocop")
54+
part = one_sensor(project, 'argv = ["rubocop", "--json"]', declaring(BUNDLER))
55+
56+
argv = _argv(project, part)
57+
58+
assert Path(argv[0]).parent == project / "bin"
59+
assert Path(argv[0]).stem == "rubocop"
60+
assert argv[1:] == ["--json"]
61+
62+
63+
def test_a_bare_program_only_the_detector_could_find_is_the_file_that_runs(
64+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
65+
) -> None:
66+
"""The whole gap: the default lookup cannot find the program, and the run
67+
still executes the detector's file — a clean run, where the bare name alone
68+
was the missing command the notice would have named."""
69+
project = project_with_no_tools(tmp_path, monkeypatch)
70+
write_stub(project / "bin", "rubocop")
71+
part = one_sensor(project, 'argv = ["rubocop", "--json"]', declaring(BUNDLER))
72+
73+
assert tool_executable("rubocop", project) is None
74+
75+
run = _run(project, part)
76+
77+
assert run.notices == []
78+
assert run.findings == []
79+
80+
81+
def test_a_bare_program_declared_and_absent_fails_by_name(
82+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
83+
) -> None:
84+
"""A declared tool the project has not got is the everyday missing tool,
85+
whichever spelling named it — answered before the spawn, naming what to
86+
install."""
87+
project = project_with_no_tools(tmp_path, monkeypatch)
88+
part = one_sensor(project, 'argv = ["rubocop", "--json"]', declaring(BUNDLER))
89+
90+
run = _run(project, part)
91+
92+
assert run.notices == [
93+
"habit-sensors: sensor 's' needs the 'rubocop' command, which is not "
94+
"installed — install it, or disable the sensor with [sensors.s] "
95+
"disabled = true"
96+
]
97+
98+
99+
def test_a_bare_program_nobody_declared_keeps_the_name_it_spelled(
100+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
101+
) -> None:
102+
"""Only a declared name is resolved through its detector: ``node`` here is
103+
nobody's, and the recipe keeps the name it spelled for the spawn to look
104+
up as it always did."""
105+
project = project_with_no_tools(tmp_path, monkeypatch)
106+
part = one_sensor(project, 'argv = ["node", "h.cjs"]', declaring(BUNDLER))
107+
108+
assert _argv(project, part)[0] == "node"

‎tests/test_a_tool_a_part_cannot_run.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
to install and how to stop running it in the meantime.
1010
1111
Every recipe here is the shape a shipped sensor has — a helper of its own, handed
12-
the tool it is to spawn — and never the tool as ``argv[0]``. That one fails at the
13-
spawn whatever the loader knew, so it would prove nothing; a helper handed a name
14-
it cannot run prints its clean ``[]`` and the run believes it, which is the
12+
the tool it is to spawn — and never the tool as ``argv[0]``: a declared one is
13+
refused before the spawn (``test_a_bare_program_names_its_tool``) and an
14+
undeclared one fails at it, so neither would prove anything; a helper handed a
15+
name it cannot run prints its clean ``[]`` and the run believes it, which is the
1516
false-clean these cases are about.
1617
"""
1718

0 commit comments

Comments
 (0)
0