Skip to content

Commit 91a6d89

Browse files
justin808claude
andauthored
Tighten helper binstub parity and PATH lookup (#1128)
## Summary Adds focused parity coverage for the helper binstub contract introduced in #1123. The PR keeps the checked-in Ruby helper wrappers, the dummy app wrapper, and the `createBinStub` TypeScript template aligned so future helper drift fails CI quickly. ## Changes - Export `createBinStub` from `package/configExporter/cli.ts` for test use. - Add `test/configExporter/createBinStub.test.js`, which generates `shakapacker-config` and `diff-bundler-config` and byte-compares them with `lib/install/bin/*`. - Assert generated helper binstubs are executable. - Keep `mkdirSync` and `chmodSync` in the top-level `fs` import instead of using inline `require("fs")`. - Improve `spec/shakapacker/binstub_sync_spec.rb` failure guidance so contributors are told to update all synchronized copies. ## Review follow-up Addressed the current actionable review threads: - Switched to flat `test.each(["shakapacker-config", "diff-bundler-config"])`. - Removed the dead `if (tmp)` cleanup guard. - Added a note explaining why `diff-bundler-config` is covered even though production init only generates `shakapacker-config`. - Added executable-permission coverage for generated binstubs. - Expanded the Ruby sync failure message to name all synchronized copies. - Moved `mkdirSync` and `chmodSync` into the module-level `fs` import. - Fixed leading and trailing empty `PATH` segment handling so helper binstubs honor the current directory during Node lookup, and added RSpec coverage for both helper commands. Discussion/advice: - The generated-user-binstub comment concern is intentionally left as discussion-only for this PR. This parity test is meant to prove the generated template matches the checked-in install files byte-for-byte; changing generated comments separately would weaken that contract. - Empty `PATH` segment handling turned out to be a real edge case: Ruby `File.join("", "node")` checks `/node`, not the current directory, and Ruby `String#split` drops trailing empty fields unless called with a negative limit. The helper now preserves trailing entries and maps empty segments to `Dir.pwd` before joining, preserving normal PATH semantics even though the binstub later `chdir`s to the app root. ## CI note This branch is rebased onto current `origin/main`. The earlier `Test Both Bundlers` failure was Bundler frozen-mode lockfile drift in `spec/dummy/Gemfile.lock`: the lockfile still recorded `shakapacker (10.1.0.rc.1)` while the current gemspec resolves to `10.1.0`. That lockfile fix is now already included in current `origin/main`, and this branch has been rebased on top of it, so the PR diff no longer carries a lockfile change. The previous `claude-review` failure was due to the external Claude weekly limit, not the repository diff. ## Test plan - [x] `bundle exec rspec spec/shakapacker/helper_binstubs_spec.rb:211 --format documentation` - 4 empty-`PATH` examples pass for leading and trailing entries across both helper commands - [x] `bundle exec rspec spec/shakapacker/binstub_sync_spec.rb spec/shakapacker/helper_binstubs_spec.rb` - 21 examples, 0 failures - [x] `yarn jest test/configExporter/createBinStub.test.js` - 2 tests passed - [x] `yarn eslint package/configExporter/cli.ts test/configExporter/createBinStub.test.js` - [x] `bundle exec rubocop spec/shakapacker/helper_binstubs_spec.rb spec/shakapacker/binstub_sync_spec.rb` - no offenses - [x] `git diff --check origin/main...HEAD` Refs #1123. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are confined to install/helper binstub templates and test tooling; behavior fix is narrow (PATH parsing) with new regression coverage. > > **Overview** > Tightens **helper binstub** parity and fixes **Node lookup** when `PATH` contains empty segments (leading/trailing `:`). > > **PATH lookup:** `shakapacker_find_executable` now splits `PATH` with a negative limit so trailing empty entries are kept, and treats an empty segment as **`Dir.pwd`** instead of joining to `/node`. The same Ruby logic is updated in `lib/install/bin/*`, `spec/dummy/bin/shakapacker-config`, and the **`createBinStub`** template in `package/configExporter/cli.ts`. > > **Parity & CI:** **`createBinStub`** is exported (test-only) and uses top-level `fs` imports; **`test/configExporter/createBinStub.test.js`** byte-compares generated `shakapacker-config` / `diff-bundler-config` stubs to `lib/install/bin/*` and checks execute bits. **`binstub_sync_spec`** failure text lists all four sync targets. **`helper_binstubs_spec`** adds RSpec for leading/trailing empty `PATH` entries, refactors `real_node_path`, and wraps some cases in **`Bundler.with_unbundled_env`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 88a44c9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f00c28c commit 91a6d89

7 files changed

Lines changed: 150 additions & 23 deletions

File tree

lib/install/bin/diff-bundler-config

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ def shakapacker_executable_candidates(executable)
2828
end
2929

3030
def shakapacker_find_executable(executable)
31-
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
31+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path|
32+
search_path = path.empty? ? Dir.pwd : path
33+
3234
shakapacker_executable_candidates(executable).each do |candidate|
33-
executable_path = File.join(path, candidate)
35+
executable_path = File.join(search_path, candidate)
3436
return executable_path if File.file?(executable_path) && File.executable?(executable_path)
3537
end
3638
end

lib/install/bin/shakapacker-config

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ def shakapacker_executable_candidates(executable)
2828
end
2929

3030
def shakapacker_find_executable(executable)
31-
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
31+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path|
32+
search_path = path.empty? ? Dir.pwd : path
33+
3234
shakapacker_executable_candidates(executable).each do |candidate|
33-
executable_path = File.join(path, candidate)
35+
executable_path = File.join(search_path, candidate)
3436
return executable_path if File.file?(executable_path) && File.executable?(executable_path)
3537
end
3638
end

package/configExporter/cli.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
// This will be a substantial file - the main CLI entry point
22
// Originally migrated from bin/export-bundler-config, now bin/shakapacker-config
33

4-
import { existsSync, readFileSync, writeFileSync } from "fs"
4+
import {
5+
chmodSync,
6+
existsSync,
7+
mkdirSync,
8+
readFileSync,
9+
writeFileSync
10+
} from "fs"
511
import { resolve, dirname, sep, delimiter, basename } from "path"
612
import { inspect } from "util"
713
import { load as loadYaml } from "js-yaml"
@@ -510,10 +516,15 @@ function runInitCommand(options: ExportOptions): number {
510516
return 0
511517
}
512518

513-
function createBinStub(binStubPath: string): void {
519+
/**
520+
* Exported for test use only: verifies generated content matches
521+
* lib/install/bin/* binstubs. Not part of the public API.
522+
*
523+
* @internal
524+
*/
525+
export function createBinStub(binStubPath: string): void {
514526
const binDir = dirname(binStubPath)
515527
const packageScript = `${basename(binStubPath)}.cjs`
516-
const { mkdirSync, chmodSync } = require("fs")
517528

518529
// Ensure bin directory exists
519530
if (!existsSync(binDir)) {
@@ -550,9 +561,11 @@ def shakapacker_executable_candidates(executable)
550561
end
551562
552563
def shakapacker_find_executable(executable)
553-
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
564+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path|
565+
search_path = path.empty? ? Dir.pwd : path
566+
554567
shakapacker_executable_candidates(executable).each do |candidate|
555-
executable_path = File.join(path, candidate)
568+
executable_path = File.join(search_path, candidate)
556569
return executable_path if File.file?(executable_path) && File.executable?(executable_path)
557570
end
558571
end

spec/dummy/bin/shakapacker-config

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ def shakapacker_executable_candidates(executable)
2828
end
2929

3030
def shakapacker_find_executable(executable)
31-
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |path|
31+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR, -1).each do |path|
32+
search_path = path.empty? ? Dir.pwd : path
33+
3234
shakapacker_executable_candidates(executable).each do |candidate|
33-
executable_path = File.join(path, candidate)
35+
executable_path = File.join(search_path, candidate)
3436
return executable_path if File.file?(executable_path) && File.executable?(executable_path)
3537
end
3638
end

spec/shakapacker/binstub_sync_spec.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555

5656
expect(dummy_content).to eq(install_content),
5757
"spec/dummy/bin/shakapacker-config and lib/install/bin/shakapacker-config have diverged. " \
58-
"Update both files to keep them in sync."
58+
"All four copies must stay byte-for-byte identical — update each one: " \
59+
"lib/install/bin/shakapacker-config, lib/install/bin/diff-bundler-config, " \
60+
"spec/dummy/bin/shakapacker-config, and the createBinStub template in package/configExporter/cli.ts."
5961
end
6062

6163
# lib/install/bin/diff-bundler-config and lib/install/bin/shakapacker-config share

spec/shakapacker/helper_binstubs_spec.rb

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def install_fake_node_script(app_path, command)
3838
FileUtils.chmod(0o755, script_path)
3939
end
4040

41+
def real_node_path
42+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
43+
.map { |dir| File.join(dir, "node") }
44+
.find { |candidate| File.file?(candidate) && File.executable?(candidate) }
45+
end
46+
4147
%w[shakapacker-config diff-bundler-config].each do |command|
4248
it "runs #{command} through a CommonJS package script when the app is ESM" do
4349
Dir.mktmpdir("shakapacker-binstub-") do |app_path|
@@ -160,11 +166,9 @@ def install_fake_node_script(app_path, command)
160166
FileUtils.mkdir_p(File.join(app_path, "bin"))
161167
install_fake_node_script(app_path, command)
162168

163-
real_node_path = ENV.fetch("PATH").split(File::PATH_SEPARATOR).map { |path| File.join(path, "node") }.find do |path|
164-
File.file?(path) && File.executable?(path)
165-
end
169+
node_path = real_node_path
166170

167-
skip "node not found in PATH" unless real_node_path
171+
skip "node not found in PATH" unless node_path
168172

169173
fake_bin_path = File.join(app_path, "fake-bin")
170174
FileUtils.mkdir_p(fake_bin_path)
@@ -176,7 +180,7 @@ def install_fake_node_script(app_path, command)
176180
echo probed >> "$SHAKAPACKER_NODE_PROBE_OUTPUT"
177181
exit 0
178182
fi
179-
exec #{real_node_path.shellescape} "$@"
183+
exec #{node_path.shellescape} "$@"
180184
SH
181185
FileUtils.chmod(0o755, fake_node_path)
182186

@@ -200,6 +204,55 @@ def install_fake_node_script(app_path, command)
200204
end
201205
end
202206

207+
{
208+
"leading" => "#{File::PATH_SEPARATOR}/nonexistent",
209+
"trailing" => "/nonexistent#{File::PATH_SEPARATOR}"
210+
}.each do |position, path_value|
211+
it "honors a #{position} empty PATH entry as the current directory for #{command}" do
212+
Dir.mktmpdir("shakapacker-binstub-") do |app_path|
213+
File.write(File.join(app_path, "Gemfile"), "")
214+
FileUtils.mkdir_p(File.join(app_path, "bin"))
215+
install_fake_node_script(app_path, command)
216+
217+
node_path = real_node_path
218+
skip "node not found in PATH" unless node_path
219+
220+
launch_path = File.join(app_path, "launch")
221+
FileUtils.mkdir_p(launch_path)
222+
fake_node_path = File.join(launch_path, "node")
223+
File.write(fake_node_path, <<~SH)
224+
#!/bin/sh
225+
exec #{node_path.shellescape} "$@"
226+
SH
227+
FileUtils.chmod(0o755, fake_node_path)
228+
229+
binstub_path = File.join(app_path, "bin", command)
230+
FileUtils.cp(File.join(gem_root, "lib", "install", "bin", command), binstub_path)
231+
FileUtils.chmod(0o755, binstub_path)
232+
233+
output_path = File.join(app_path, "binstub-output.json")
234+
_stdout, stderr, status = Bundler.with_unbundled_env do
235+
Open3.capture3(
236+
{
237+
"BUNDLE_GEMFILE" => nil,
238+
"PATH" => path_value,
239+
"RUBYOPT" => nil,
240+
"SHAKAPACKER_BINSTUB_OUTPUT" => output_path
241+
},
242+
RbConfig.ruby,
243+
binstub_path,
244+
chdir: launch_path
245+
)
246+
end
247+
248+
expect(status).to be_success, stderr
249+
expect(JSON.parse(File.read(output_path))).to include(
250+
"cwd" => File.realpath(app_path)
251+
)
252+
end
253+
end
254+
end
255+
203256
it "exits with an error when #{command}'s package script is missing" do
204257
Dir.mktmpdir("shakapacker-binstub-") do |app_path|
205258
File.write(File.join(app_path, "Gemfile"), "")
@@ -232,12 +285,14 @@ def install_fake_node_script(app_path, command)
232285
FileUtils.cp(File.join(gem_root, "lib", "install", "bin", command), binstub_path)
233286
FileUtils.chmod(0o755, binstub_path)
234287

235-
_stdout, stderr, status = Open3.capture3(
236-
{ "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "PATH" => "/nonexistent" },
237-
RbConfig.ruby,
238-
binstub_path,
239-
chdir: app_path
240-
)
288+
_stdout, stderr, status = Bundler.with_unbundled_env do
289+
Open3.capture3(
290+
{ "BUNDLE_GEMFILE" => nil, "RUBYOPT" => nil, "PATH" => "/nonexistent" },
291+
RbConfig.ruby,
292+
binstub_path,
293+
chdir: app_path
294+
)
295+
end
241296

242297
expect(status.exitstatus).to eq(1)
243298
expect(stderr).to include('[Shakapacker] Could not find Node.js executable "node"')
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
const {
2+
accessSync,
3+
constants,
4+
mkdtempSync,
5+
readFileSync,
6+
rmSync
7+
} = require("fs")
8+
const { join, resolve } = require("path")
9+
const { tmpdir } = require("os")
10+
const { createBinStub } = require("../../package/configExporter/cli")
11+
12+
const gemRoot = resolve(__dirname, "../..")
13+
14+
// The Ruby logic in lib/install/bin/shakapacker-config and
15+
// lib/install/bin/diff-bundler-config is also duplicated inside the
16+
// `createBinStub` template in package/configExporter/cli.ts. The Ruby spec
17+
// (spec/shakapacker/binstub_sync_spec.rb) keeps the three checked-in copies
18+
// (install template, install diff template, and the dummy app's binstub)
19+
// honest, but it cannot reach into the JS template. This test closes that
20+
// gap by invoking createBinStub for both helper names and asserting the
21+
// generated content matches the corresponding lib/install/bin/* file.
22+
describe("createBinStub template parity", () => {
23+
let tmp
24+
25+
beforeEach(() => {
26+
tmp = mkdtempSync(join(tmpdir(), "shakapacker-createBinStub-"))
27+
})
28+
29+
afterEach(() => {
30+
rmSync(tmp, { recursive: true, force: true })
31+
})
32+
33+
// diff-bundler-config is a white-box parity case; production init only
34+
// generates shakapacker-config.
35+
test.each(["shakapacker-config", "diff-bundler-config"])(
36+
"generates lib/install/bin/%s byte-for-byte",
37+
(binstubName) => {
38+
const generatedPath = join(tmp, "bin", binstubName)
39+
createBinStub(generatedPath)
40+
41+
const generated = readFileSync(generatedPath, "utf8")
42+
const installed = readFileSync(
43+
join(gemRoot, "lib", "install", "bin", binstubName),
44+
"utf8"
45+
)
46+
47+
expect(generated).toBe(installed)
48+
expect(() => accessSync(generatedPath, constants.X_OK)).not.toThrow()
49+
}
50+
)
51+
})

0 commit comments

Comments
 (0)