diff --git a/.mcp-sdk.lock b/.mcp-sdk.lock index bcf3c617..78d0bf09 100644 --- a/.mcp-sdk.lock +++ b/.mcp-sdk.lock @@ -1,2 +1,2 @@ -version=v3.0.0 -sha256=3fc8bc7a58fec1a7e9df1fff64d6f022fd14d9561b1faf82bf4e464e70637e46 +version=v5.1.0 +sha256=6a4d91bf16569555d988ceb3c2e32c45f519ac853c96fa225f387df367126fd6 diff --git a/plugin-tests/dev-tooling/mcp_tool_console.bats b/plugin-tests/dev-tooling/mcp_tool_console.bats index 1ff454f9..f8a9d6c3 100644 --- a/plugin-tests/dev-tooling/mcp_tool_console.bats +++ b/plugin-tests/dev-tooling/mcp_tool_console.bats @@ -11,7 +11,8 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE CONSOLE_FAKE_CMD + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE CONSOLE_FAKE_CMD PROJECT_ROOT DEV_TOOLING_STATE_FILE } # Replace the wrapped invocation with a controlled shell snippet, so the @@ -227,7 +228,7 @@ bats_test_function --description "console: option name containing a line break i @test "console: malformed top-level JSON is refused rather than defaulting silently" { run tool_console_run '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } # --- output_file: stdout captured to a file --- @@ -334,8 +335,13 @@ bats_test_function --description "console: option name containing a line break i assert_output --partial "\"${target}\" exists as a directory" } -@test "console: a relative output_file resolves against the working directory" { - cd "${BATS_TEST_TMPDIR}" +# Both tests below run from a directory that is NOT the project root, because +# that is the only arrangement that tells the documented contract ("a relative +# path resolves against the project root") from resolving against $PWD. With +# the two directories equal, either implementation passes. +@test "console: a relative output_file resolves against the project root" { + mkdir -p "${BATS_TEST_TMPDIR}/elsewhere" + cd "${BATS_TEST_TMPDIR}/elsewhere" _stub_wrapped_command 'printf "ok\n"' run tool_console_run '{"command":"debug:container","output_file":"nested/dump.txt"}' assert_success @@ -344,8 +350,9 @@ bats_test_function --description "console: option name containing a line break i assert_output "ok" } -@test "console: an output_file beginning with a dash is pinned to the working directory" { - cd "${BATS_TEST_TMPDIR}" +@test "console: an output_file beginning with a dash is pinned to the project root" { + mkdir -p "${BATS_TEST_TMPDIR}/elsewhere" + cd "${BATS_TEST_TMPDIR}/elsewhere" _stub_wrapped_command 'printf "ok\n"' run tool_console_run '{"command":"debug:container","output_file":"-dash.txt"}' assert_success @@ -374,7 +381,7 @@ bats_test_function --description "console: option name containing a line break i @test "console: an empty output_file behaves as an absent parameter" { run tool_console_run '{"command":"cache:clear","output_file":""}' assert_success - assert_output 'bin/console "cache:clear"' + assert_line 'bin/console "cache:clear"' } @test "console: env and output_file compose in one call" { @@ -409,7 +416,7 @@ _stub_compose_resolution() { LINT_ENV="native" run tool_console_run '{"command":"cache:clear","feature_all":"major"}' assert_success - assert_output 'FEATURE_ALL=major bin/console "cache:clear"' + assert_line 'FEATURE_ALL=major bin/console "cache:clear"' } @test "console: feature_all opens the containerized command under docker-compose" { @@ -426,7 +433,7 @@ _stub_compose_resolution() { LINT_ENV="native" run tool_console_run '{"command":"cache:clear"}' assert_success - assert_output 'bin/console "cache:clear"' + assert_line 'bin/console "cache:clear"' } @test "console: feature_all value outside the enum is refused by the tool" { @@ -480,5 +487,5 @@ _stub_compose_resolution() { @test "console list: malformed top-level JSON is refused rather than defaulting silently" { run tool_console_list '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } diff --git a/plugin-tests/dev-tooling/mcp_tool_ecs.bats b/plugin-tests/dev-tooling/mcp_tool_ecs.bats index 3cbdff28..5097c4d5 100644 --- a/plugin-tests/dev-tooling/mcp_tool_ecs.bats +++ b/plugin-tests/dev-tooling/mcp_tool_ecs.bats @@ -11,7 +11,8 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE } @test "ecs: check uses composer ecs" { @@ -138,7 +139,7 @@ ecs_refuses_malformed_json() { local tool="$1" run "${tool}" '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } bats_test_function --description "ecs: check malformed top-level JSON is refused rather than defaulting silently" \ diff --git a/plugin-tests/dev-tooling/mcp_tool_js_admin.bats b/plugin-tests/dev-tooling/mcp_tool_js_admin.bats index 533a3756..eb2ddafe 100644 --- a/plugin-tests/dev-tooling/mcp_tool_js_admin.bats +++ b/plugin-tests/dev-tooling/mcp_tool_js_admin.bats @@ -101,6 +101,11 @@ setup() { log() { :; } source "${PLUGIN_DIR}/shared/environment.sh" source "${PLUGIN_DIR}/shared/scope.sh" + PROJECT_ROOT="${BATS_TEST_TMPDIR}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init exec_npm_command() { local cmd="$1" printf '%s\n' "${cmd}" >> "${FAKE_CALL_LOG}" @@ -144,10 +149,12 @@ setup() { } teardown() { + worktree_state_cleanup unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE JS_CONTEXT SCOPE_NAME SCOPE_CWD \ SCOPE_JS_SUBDIR FAKE_ABSENT_SCRIPTS FAKE_BODY_NAME FAKE_BODY FAKE_PROBE_OUTPUT \ FAKE_PROBE_FILE FAKE_REPORT_OUTPUT FAKE_REPORT_STORE FAKE_RUN_WRITES_REPORT \ - FAKE_REPORT_READ_FILE FAKE_CALL_LOG FAKE_CLEAR_EXIT FAKE_CLEAR_OUTPUT FAKE_RUN_EXIT + FAKE_REPORT_READ_FILE FAKE_CALL_LOG FAKE_CLEAR_EXIT FAKE_CLEAR_OUTPUT FAKE_RUN_EXIT \ + PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- ESLint: no paths keeps the aggregate script --- @@ -155,7 +162,7 @@ teardown() { @test "admin eslint check: no paths uses npm run lint" { run tool_eslint_check '{}' assert_success - assert_output "npm run lint -- -f stylish" + assert_line "npm run lint -- -f stylish" } @test "admin eslint check: json format when specified" { @@ -167,7 +174,7 @@ teardown() { @test "admin eslint fix: no paths uses npm run lint:fix" { run tool_eslint_fix '{}' assert_success - assert_output "npm run lint:fix -- --fix" + assert_line "npm run lint:fix -- --fix" } # --- ESLint: paths route at the target-less base script --- @@ -175,7 +182,7 @@ teardown() { @test "admin eslint check: paths route at lint:debugging as the only targets" { run tool_eslint_check '{"paths":["src/app/component"]}' assert_success - assert_output 'npm run lint:debugging -- -f stylish "src/app/component"' + assert_line 'npm run lint:debugging -- -f stylish "src/app/component"' } @test "admin eslint check: a path-scoped run carries no hardcoded upstream target" { @@ -187,7 +194,7 @@ teardown() { @test "admin eslint fix: paths route at lint:debugging with --fix" { run tool_eslint_fix '{"paths":["src/app/component"]}' assert_success - assert_output 'npm run lint:debugging -- --fix "src/app/component"' + assert_line 'npm run lint:debugging -- --fix "src/app/component"' } @test "admin eslint fix: a path-scoped run never reaches the aggregate lint:fix script" { @@ -199,13 +206,13 @@ teardown() { @test "admin eslint check: repo-root-relative path is rebased onto the package dir" { run tool_eslint_check '{"paths":["src/Administration/Resources/app/administration/src/app/foo.ts"]}' assert_success - assert_output 'npm run lint:debugging -- -f stylish "src/app/foo.ts"' + assert_line 'npm run lint:debugging -- -f stylish "src/app/foo.ts"' } @test "admin eslint check: keeps a path containing a space in one argument" { run tool_eslint_check '{"paths":["src/app/my component"]}' assert_success - assert_output 'npm run lint:debugging -- -f stylish "src/app/my component"' + assert_line 'npm run lint:debugging -- -f stylish "src/app/my component"' } # --- ESLint: hard failures instead of widening --- @@ -280,7 +287,7 @@ teardown() { @test "admin stylelint check: no paths appends no path target" { run tool_stylelint_check '{}' assert_success - assert_output "npm run lint:scss -- -f string" + assert_line "npm run lint:scss -- -f string" } @test "admin stylelint check: no paths leaves the script's own glob out of the command" { @@ -298,7 +305,7 @@ teardown() { @test "admin stylelint fix: no paths runs lint:scss-fix bare" { run tool_stylelint_fix '{}' assert_success - assert_output "npm run lint:scss-fix" + assert_line "npm run lint:scss-fix" } @test "admin stylelint fix: no paths adds no second --fix on top of the aggregate body" { @@ -312,13 +319,13 @@ teardown() { @test "admin stylelint check: paths route at stylelint:base as the only targets" { run tool_stylelint_check '{"paths":["src/app/assets/scss/base.scss"]}' assert_success - assert_output 'npm run stylelint:base -- -f string "src/app/assets/scss/base.scss"' + assert_line 'npm run stylelint:base -- -f string "src/app/assets/scss/base.scss"' } @test "admin stylelint fix: paths route at stylelint:base as the only targets" { run tool_stylelint_fix '{"paths":["src/app/assets/scss/base.scss"]}' assert_success - assert_output 'npm run stylelint:base -- --fix "src/app/assets/scss/base.scss"' + assert_line 'npm run stylelint:base -- --fix "src/app/assets/scss/base.scss"' } @test "admin stylelint fix: paths route carries --fix, which the base script body lacks" { @@ -364,7 +371,7 @@ teardown() { FAKE_PROBE_OUTPUT="MISSING:src/**/*.scss" run tool_stylelint_check '{"paths":["src/**/*.scss"]}' assert_success - assert_output 'npm run stylelint:base -- -f string "src/**/*.scss"' + assert_line 'npm run stylelint:base -- -f string "src/**/*.scss"' } @test "admin stylelint check: a glob path is quoted so the shell cannot expand it" { @@ -385,13 +392,13 @@ teardown() { @test "admin prettier check: no paths uses npm run format" { run tool_prettier_check assert_success - assert_output "npm run format" + assert_line "npm run format" } @test "admin prettier fix: no paths uses npm run format:fix" { run tool_prettier_fix assert_success - assert_output "npm run format:fix" + assert_line "npm run format:fix" } # --- Prettier: paths route at the target-less base script --- @@ -399,13 +406,13 @@ teardown() { @test "admin prettier check: paths route at prettier:base with --check" { run tool_prettier_check '{"paths":["src/app/main.ts"]}' assert_success - assert_output 'npm run prettier:base -- --check "src/app/main.ts"' + assert_line 'npm run prettier:base -- --check "src/app/main.ts"' } @test "admin prettier fix: paths route at prettier:base with --write" { run tool_prettier_fix '{"paths":["src/app/main.ts"]}' assert_success - assert_output 'npm run prettier:base -- --write "src/app/main.ts"' + assert_line 'npm run prettier:base -- --write "src/app/main.ts"' } @test "admin prettier fix: a path-scoped run never reaches the aggregate format script" { @@ -439,7 +446,7 @@ teardown() { FAKE_PROBE_OUTPUT="MISSING:src/**/*.ts" run tool_prettier_check '{"paths":["src/**/*.ts"]}' assert_success - assert_output 'npm run prettier:base -- --check "src/**/*.ts"' + assert_line 'npm run prettier:base -- --check "src/**/*.ts"' } # --- TypeScript --- @@ -479,7 +486,7 @@ JSON @test "admin jest: base command routes at jest:base and asks for the JSON report" { run tool_jest_run '{}' assert_success - assert_line --index 1 "npm run jest:base -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" + assert_line --index 2 "npm run jest:base -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" } @test "admin jest: default run appends no --ci" { @@ -491,13 +498,13 @@ JSON @test "admin jest: ci=true appends --ci and still asks for the JSON report" { run tool_jest_run '{"ci":true}' assert_success - assert_line --index 1 "npm run jest:base -- --ci --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" + assert_line --index 2 "npm run jest:base -- --ci --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" } @test "admin jest: testPathPatterns flag added when provided" { run tool_jest_run '{"testPathPatterns":"CartService"}' assert_success - assert_line --index 1 --partial 'npm run jest:base -- --testPathPatterns="CartService" --json' + assert_line --index 2 --partial 'npm run jest:base -- --testPathPatterns="CartService" --json' } @test "admin jest: coverage flag added when coverage=true" { @@ -509,7 +516,7 @@ JSON @test "admin jest: keeps a multi-word test name pattern in one argument" { run tool_jest_run '{"testNamePattern":"adds to cart"}' assert_success - assert_line --index 1 --partial 'npm run jest:base -- --testNamePattern="adds to cart" --json' + assert_line --index 2 --partial 'npm run jest:base -- --testNamePattern="adds to cart" --json' } @test "admin jest: refuses a test name pattern containing a single quote" { @@ -536,21 +543,21 @@ JSON FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 2 "npm run unit -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" + assert_line --index 3 "npm run unit -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" } @test "admin jest: the jest:base fallback announces that --ci is forced" { FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "Notice: the npm script \"jest:base\" is unavailable" + assert_line --partial "Notice: the npm script \"jest:base\" is unavailable" } @test "admin jest: the jest:base fallback announces the suppressed summary" { FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "jest-silent-reporter" + assert_line --partial "jest-silent-reporter" } # --- Jest: the result comes from the JSON report, not the exit code --- @@ -558,7 +565,7 @@ JSON @test "admin jest: the counts summary precedes the command output" { run tool_jest_run '{}' assert_success - assert_line --index 0 "Jest report: 13 tests total, 13 passed, 0 failed, 0 pending; 1 test suites total, 0 failed. Process exit code: 0. The status below is derived from this report." + assert_line --index 1 "Jest report: 13 tests total, 13 passed, 0 failed, 0 pending; 1 test suites total, 0 failed. Process exit code: 0. The status below is derived from this report." } @test "admin jest: the report read is issued as its own command with nothing chained onto it" { @@ -609,7 +616,7 @@ JSON FAKE_RUN_EXIT=0 run tool_jest_run '{}' assert_failure - assert_line --index 0 --partial "127 tests total, 123 passed, 4 failed" + assert_line --partial "127 tests total, 123 passed, 4 failed" } @test "admin jest: a failed test suite fails the tool when no individual test failed" { @@ -617,7 +624,7 @@ JSON FAKE_RUN_EXIT=0 run tool_jest_run '{}' assert_failure - assert_line --index 0 --partial "2 test suites total, 1 failed" + assert_line --partial "2 test suites total, 1 failed" } @test "admin jest: a report of zero tests fails the tool" { @@ -651,13 +658,13 @@ JSON @test "admin jest: all tests passed with a non-zero process exit reports that exit code" { FAKE_RUN_EXIT=7 run tool_jest_run '{}' - assert_line --index 1 --partial "every test passed, but the jest process still exited with code 7" + assert_line --index 2 --partial "every test passed, but the jest process still exited with code 7" } @test "admin jest: all tests passed with a non-zero process exit keeps the command output" { FAKE_RUN_EXIT=7 run tool_jest_run '{}' - assert_line --index 2 --partial "npm run jest:base" + assert_line --index 3 --partial "npm run jest:base" } @test "admin jest: a report that is not JSON propagates the process exit code" { @@ -665,7 +672,7 @@ JSON FAKE_RUN_EXIT=3 run tool_jest_run '{}' assert_failure 3 - assert_line --index 0 --partial "could not be read or parsed, so the status below is the process exit code (3)" + assert_line --partial "could not be read or parsed, so the status below is the process exit code (3)" } @test "admin jest: a JSON report without the count fields propagates the process exit code" { @@ -673,14 +680,14 @@ JSON FAKE_RUN_EXIT=3 run tool_jest_run '{}' assert_failure 3 - assert_line --index 0 --partial "could not be read or parsed, so the status below is the process exit code (3)" + assert_line --partial "could not be read or parsed, so the status below is the process exit code (3)" } @test "admin jest: an unreadable report announces the exit-code fallback rather than a report-derived status" { FAKE_REPORT_OUTPUT="" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "could not be read or parsed, so the status below is the process exit code (0)" + assert_line --partial "could not be read or parsed, so the status below is the process exit code (0)" refute_output --partial "Jest report:" } @@ -689,7 +696,7 @@ JSON $(_jest_report 13 13 0 1 0)" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "13 tests total, 13 passed, 0 failed" + assert_line --partial "13 tests total, 13 passed, 0 failed" } # --- Vite build --- diff --git a/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats b/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats index 2f872375..cda86b1f 100644 --- a/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats +++ b/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats @@ -94,6 +94,11 @@ setup() { log() { :; } source "${PLUGIN_DIR}/shared/environment.sh" source "${PLUGIN_DIR}/shared/scope.sh" + PROJECT_ROOT="${BATS_TEST_TMPDIR}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init exec_npm_command() { local cmd="$1" printf '%s\n' "${cmd}" >> "${FAKE_CALL_LOG}" @@ -137,10 +142,12 @@ setup() { } teardown() { + worktree_state_cleanup unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE JS_CONTEXT SCOPE_NAME SCOPE_CWD \ SCOPE_JS_SUBDIR FAKE_ABSENT_SCRIPTS FAKE_BODY_NAME FAKE_BODY FAKE_PROBE_OUTPUT \ FAKE_PROBE_FILE FAKE_REPORT_OUTPUT FAKE_REPORT_STORE FAKE_RUN_WRITES_REPORT \ - FAKE_REPORT_READ_FILE FAKE_CALL_LOG FAKE_CLEAR_EXIT FAKE_CLEAR_OUTPUT FAKE_RUN_EXIT + FAKE_REPORT_READ_FILE FAKE_CALL_LOG FAKE_CLEAR_EXIT FAKE_CLEAR_OUTPUT FAKE_RUN_EXIT \ + PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- ESLint: no paths runs the aggregate script bare --- @@ -148,13 +155,13 @@ teardown() { @test "storefront eslint check: no paths runs lint:js bare" { run tool_eslint_check '{}' assert_success - assert_output "npm run lint:js" + assert_line "npm run lint:js" } @test "storefront eslint fix: no paths runs lint:js:fix bare" { run tool_eslint_fix '{}' assert_success - assert_output "npm run lint:js:fix" + assert_line "npm run lint:js:fix" } # --- ESLint: tree routing and rebasing --- @@ -312,7 +319,7 @@ JSON @test "storefront stylelint check: no paths appends no path target" { run tool_stylelint_check '{}' assert_success - assert_output "npm run lint:scss -- -f string" + assert_line "npm run lint:scss -- -f string" } @test "storefront stylelint check: no paths leaves the script's own target out of the command" { @@ -324,7 +331,7 @@ JSON @test "storefront stylelint fix: no paths runs lint:scss-fix bare" { run tool_stylelint_fix '{}' assert_success - assert_output "npm run lint:scss-fix" + assert_line "npm run lint:scss-fix" } @test "storefront stylelint fix: no paths adds no second --fix on top of the aggregate body" { @@ -338,13 +345,13 @@ JSON @test "storefront stylelint check: paths route at stylelint:app as the only targets" { run tool_stylelint_check '{"paths":["src/scss/base.scss"]}' assert_success - assert_output 'npm run stylelint:app -- -f string "src/scss/base.scss"' + assert_line 'npm run stylelint:app -- -f string "src/scss/base.scss"' } @test "storefront stylelint fix: paths route at stylelint:app as the only targets" { run tool_stylelint_fix '{"paths":["src/scss/base.scss"]}' assert_success - assert_output 'npm run stylelint:app -- --fix "src/scss/base.scss"' + assert_line 'npm run stylelint:app -- --fix "src/scss/base.scss"' } @test "storefront stylelint fix: paths route carries --fix, which the base script body lacks" { @@ -390,7 +397,7 @@ JSON FAKE_PROBE_OUTPUT="MISSING:src/**/*.scss" run tool_stylelint_check '{"paths":["src/**/*.scss"]}' assert_success - assert_output 'npm run stylelint:app -- -f string "src/**/*.scss"' + assert_line 'npm run stylelint:app -- -f string "src/**/*.scss"' } @test "storefront stylelint check: a glob path is quoted so the shell cannot expand it" { @@ -411,7 +418,7 @@ JSON @test "storefront jest: base command routes at jest:base and asks for the JSON report" { run tool_jest_run '{}' assert_success - assert_line --index 1 "npm run jest:base -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" + assert_line --index 2 "npm run jest:base -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" } @test "storefront jest: default run appends no --ci" { @@ -423,13 +430,13 @@ JSON @test "storefront jest: ci=true appends --ci and still asks for the JSON report" { run tool_jest_run '{"ci":true}' assert_success - assert_line --index 1 "npm run jest:base -- --ci --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" + assert_line --index 2 "npm run jest:base -- --ci --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" } @test "storefront jest: testPathPatterns flag added when provided" { run tool_jest_run '{"testPathPatterns":"CartPlugin"}' assert_success - assert_line --index 1 --partial 'npm run jest:base -- --testPathPatterns="CartPlugin" --json' + assert_line --index 2 --partial 'npm run jest:base -- --testPathPatterns="CartPlugin" --json' } @test "storefront jest: coverage flag added when coverage=true" { @@ -441,7 +448,7 @@ JSON @test "storefront jest: keeps a multi-word test name pattern in one argument" { run tool_jest_run '{"testNamePattern":"adds to cart"}' assert_success - assert_line --index 1 --partial 'npm run jest:base -- --testNamePattern="adds to cart" --json' + assert_line --index 2 --partial 'npm run jest:base -- --testNamePattern="adds to cart" --json' } @test "storefront jest: fails hard when the unit fallback cannot take arguments" { @@ -457,35 +464,35 @@ JSON FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 2 "npm run unit -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" + assert_line --index 3 "npm run unit -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" } @test "storefront jest: the jest:base fallback announces that --ci is forced" { FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "Notice: the npm script \"jest:base\" is unavailable" + assert_line --partial "Notice: the npm script \"jest:base\" is unavailable" } @test "storefront jest: the jest:base fallback announces the lost new snapshots" { FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "writes no new snapshots where it otherwise would have" + assert_line --partial "writes no new snapshots where it otherwise would have" } @test "storefront jest: the jest:base fallback states that updateSnapshots still applies" { FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "\"updateSnapshots\" itself still takes effect" + assert_line --partial "\"updateSnapshots\" itself still takes effect" } @test "storefront jest: ci and updateSnapshots together keep --updateSnapshot, which wins over --ci" { run tool_jest_run '{"ci":true,"updateSnapshots":true}' assert_success - assert_line --index 1 --partial "--updateSnapshot" - assert_line --index 1 --partial "--ci" + assert_line --index 2 --partial "--updateSnapshot" + assert_line --index 2 --partial "--ci" } @test "storefront jest: refuses a test path pattern containing a single quote" { @@ -511,7 +518,7 @@ JSON @test "storefront jest: the counts summary precedes the command output" { run tool_jest_run '{}' assert_success - assert_line --index 0 "Jest report: 13 tests total, 13 passed, 0 failed, 0 pending; 1 test suites total, 0 failed. Process exit code: 0. The status below is derived from this report." + assert_line --index 1 "Jest report: 13 tests total, 13 passed, 0 failed, 0 pending; 1 test suites total, 0 failed. Process exit code: 0. The status below is derived from this report." } @test "storefront jest: the report read is issued as its own command with nothing chained onto it" { @@ -562,7 +569,7 @@ JSON FAKE_RUN_EXIT=0 run tool_jest_run '{}' assert_failure - assert_line --index 0 --partial "127 tests total, 123 passed, 4 failed" + assert_line --partial "127 tests total, 123 passed, 4 failed" } @test "storefront jest: a failed test suite fails the tool when no individual test failed" { @@ -570,7 +577,7 @@ JSON FAKE_RUN_EXIT=0 run tool_jest_run '{}' assert_failure - assert_line --index 0 --partial "2 test suites total, 1 failed" + assert_line --partial "2 test suites total, 1 failed" } @test "storefront jest: a report of zero tests fails the tool" { @@ -604,13 +611,13 @@ JSON @test "storefront jest: all tests passed with a non-zero process exit reports that exit code" { FAKE_RUN_EXIT=7 run tool_jest_run '{}' - assert_line --index 1 --partial "every test passed, but the jest process still exited with code 7" + assert_line --index 2 --partial "every test passed, but the jest process still exited with code 7" } @test "storefront jest: all tests passed with a non-zero process exit keeps the command output" { FAKE_RUN_EXIT=7 run tool_jest_run '{}' - assert_line --index 2 --partial "npm run jest:base" + assert_line --index 3 --partial "npm run jest:base" } @test "storefront jest: a report that is not JSON propagates the process exit code" { @@ -618,7 +625,7 @@ JSON FAKE_RUN_EXIT=3 run tool_jest_run '{}' assert_failure 3 - assert_line --index 0 --partial "could not be read or parsed, so the status below is the process exit code (3)" + assert_line --partial "could not be read or parsed, so the status below is the process exit code (3)" } @test "storefront jest: a JSON report without the count fields propagates the process exit code" { @@ -626,14 +633,14 @@ JSON FAKE_RUN_EXIT=3 run tool_jest_run '{}' assert_failure 3 - assert_line --index 0 --partial "could not be read or parsed, so the status below is the process exit code (3)" + assert_line --partial "could not be read or parsed, so the status below is the process exit code (3)" } @test "storefront jest: an unreadable report announces the exit-code fallback rather than a report-derived status" { FAKE_REPORT_OUTPUT="" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "could not be read or parsed, so the status below is the process exit code (0)" + assert_line --partial "could not be read or parsed, so the status below is the process exit code (0)" refute_output --partial "Jest report:" } @@ -642,7 +649,7 @@ JSON $(_jest_report 13 13 0 1 0)" run tool_jest_run '{}' assert_success - assert_line --index 0 --partial "13 tests total, 13 passed, 0 failed" + assert_line --partial "13 tests total, 13 passed, 0 failed" } # --- Vitest --- @@ -650,37 +657,37 @@ $(_jest_report 13 13 0 1 0)" @test "storefront vitest: no arguments runs unit:components bare" { run tool_vitest_run '{}' assert_success - assert_output "npm run unit:components" + assert_line "npm run unit:components" } @test "storefront vitest: coverage selects unit:components:coverage" { run tool_vitest_run '{"coverage":true}' assert_success - assert_output "npm run unit:components:coverage" + assert_line "npm run unit:components:coverage" } @test "storefront vitest: testNamePattern maps to -t" { run tool_vitest_run '{"testNamePattern":"renders the cart"}' assert_success - assert_output 'npm run unit:components -- -t "renders the cart"' + assert_line 'npm run unit:components -- -t "renders the cart"' } @test "storefront vitest: updateSnapshots maps to -u" { run tool_vitest_run '{"updateSnapshots":true}' assert_success - assert_output "npm run unit:components -- -u" + assert_line "npm run unit:components -- -u" } @test "storefront vitest: paths are rebased onto views/components" { run tool_vitest_run '{"paths":["src/Storefront/Resources/views/components/checkout/cart.test.js"]}' assert_success - assert_output 'npm run unit:components -- "views/components/checkout/cart.test.js"' + assert_line 'npm run unit:components -- "views/components/checkout/cart.test.js"' } @test "storefront vitest: keeps a multi-word test name pattern in one argument" { run tool_vitest_run '{"testNamePattern":"adds to cart"}' assert_success - assert_output 'npm run unit:components -- -t "adds to cart"' + assert_line 'npm run unit:components -- -t "adds to cart"' } @test "storefront vitest: refuses a component path that does not exist" { @@ -696,13 +703,13 @@ $(_jest_report 13 13 0 1 0)" @test "storefront ludtwig check: runs composer ludtwig:storefront" { run tool_ludtwig_check '{}' assert_success - assert_output "composer ludtwig:storefront" + assert_line "composer ludtwig:storefront" } @test "storefront ludtwig fix: runs composer ludtwig:storefront:fix" { run tool_ludtwig_fix '{}' assert_success - assert_output "composer ludtwig:storefront:fix" + assert_line "composer ludtwig:storefront:fix" } # --- Webpack build --- diff --git a/plugin-tests/dev-tooling/mcp_tool_phpstan.bats b/plugin-tests/dev-tooling/mcp_tool_phpstan.bats index 4b961733..5388d26f 100644 --- a/plugin-tests/dev-tooling/mcp_tool_phpstan.bats +++ b/plugin-tests/dev-tooling/mcp_tool_phpstan.bats @@ -11,7 +11,8 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE } @test "phpstan: runs composer phpstan by default" { @@ -93,7 +94,7 @@ teardown() { @test "phpstan: malformed top-level JSON is refused rather than defaulting silently" { run tool_phpstan_analyze '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } # --- Values that cannot be quoted safely are refused --- diff --git a/plugin-tests/dev-tooling/mcp_tool_phpunit.bats b/plugin-tests/dev-tooling/mcp_tool_phpunit.bats index 6fa5ec3a..5bc8a2b2 100644 --- a/plugin-tests/dev-tooling/mcp_tool_phpunit.bats +++ b/plugin-tests/dev-tooling/mcp_tool_phpunit.bats @@ -11,7 +11,8 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- Basic command construction --- @@ -117,7 +118,7 @@ bats_test_function --description "phpunit: config containing a trailing line bre @test "phpunit: malformed top-level JSON is refused rather than defaulting silently" { run tool_phpunit_run '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } # --- Coverage formats --- diff --git a/plugin-tests/dev-tooling/mcp_tool_phpunit_coverage.bats b/plugin-tests/dev-tooling/mcp_tool_phpunit_coverage.bats index 217dc924..acc5a68f 100644 --- a/plugin-tests/dev-tooling/mcp_tool_phpunit_coverage.bats +++ b/plugin-tests/dev-tooling/mcp_tool_phpunit_coverage.bats @@ -16,7 +16,8 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- Default clover_path --- @@ -87,7 +88,7 @@ teardown() { exec_command() { echo "exec_command must not be reached"; } run tool_phpunit_coverage_gaps '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } # --- Basic parsing --- diff --git a/plugin-tests/dev-tooling/mcp_tool_rector.bats b/plugin-tests/dev-tooling/mcp_tool_rector.bats index c9574b94..043d4aeb 100644 --- a/plugin-tests/dev-tooling/mcp_tool_rector.bats +++ b/plugin-tests/dev-tooling/mcp_tool_rector.bats @@ -11,7 +11,8 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- rector_fix (preferred tool) --- @@ -174,7 +175,7 @@ rector_refuses_malformed_json() { local tool="$1" run "${tool}" '{not valid json' assert_failure - assert_output --partial "Refusing to run: could not parse arguments as JSON" + assert_output --partial 'Refusing to run: the tool call'"'"'s own arguments do not parse as a JSON object, so "project_root" could not be read from them.' } bats_test_function --description "rector_fix: malformed top-level JSON is refused rather than defaulting silently" \ diff --git a/plugin-tests/dev-tooling/scope_js_tools.bats b/plugin-tests/dev-tooling/scope_js_tools.bats index 68a2db9a..1fbd84ed 100644 --- a/plugin-tests/dev-tooling/scope_js_tools.bats +++ b/plugin-tests/dev-tooling/scope_js_tools.bats @@ -24,6 +24,8 @@ _fake_admin_script_body() { printf '%s\n' '"npm run lint:scss -- --fix"' ;; jest:base) printf '%s\n' '"jest --config jest.config.js"' ;; + build) + printf '%s\n' '"vite build"' ;; *) printf '%s\n' '{}' ;; esac @@ -52,6 +54,11 @@ JSON CALLS_FILE="${BATS_TEST_TMPDIR}/calls.log" source "${PLUGIN_DIR}/shared/environment.sh" source "${PLUGIN_DIR}/shared/scope.sh" + PROJECT_ROOT="${BATS_TEST_TMPDIR}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init # Set LINT_ENV/LINT_WORKDIR AFTER sourcing environment.sh so its module-level # initializers ("") don't clobber our test values. LINT_ENV="native" @@ -72,10 +79,15 @@ JSON } source "${PLUGIN_DIR}/mcp-server-js-admin/lib/eslint.sh" source "${PLUGIN_DIR}/mcp-server-js-admin/lib/jest.sh" + source "${PLUGIN_DIR}/mcp-server-js-admin/lib/lint-all.sh" + source "${PLUGIN_DIR}/mcp-server-js-admin/lib/build.sh" + source "${PLUGIN_DIR}/mcp-server-js-storefront/lib/build.sh" } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE JS_CONTEXT SCOPE_CWD SCOPE_NAME SCOPE_JS_SUBDIR CALLS_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE JS_CONTEXT SCOPE_CWD SCOPE_NAME SCOPE_JS_SUBDIR CALLS_FILE \ + PROJECT_ROOT DEV_TOOLING_STATE_FILE } @test "eslint scoped: runs under scope cwd" { @@ -158,6 +170,19 @@ _set_jest_env() { refute_line --partial "npm ci" } +@test "jest scoped: install_if_missing measures the host path under a container environment" { + # The [[ -d ]] runs on the host, and LINT_WORKDIR is environment-side under + # a container — composed from it, the test was always false and every + # scoped run paid a full `npm ci` although node_modules existed. + LINT_ENV="docker" + LINT_WORKDIR="/srv/app" + mkdir -p "${BATS_TEST_TMPDIR}/custom/plugins/X/tests/jest/administration/node_modules" + run tool_jest_run '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + refute_line --partial "npm ci" +} + @test "jest unscoped: no ADMIN_PATH env export in command" { run tool_jest_run '{}' assert_success @@ -178,3 +203,38 @@ _set_jest_env() { assert_output --partial "npm run jest:base" refute_output --partial "npm run unit" } + +@test "lint_all scoped: runs under scope cwd" { + run tool_lint_all '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + assert_line --partial "[scope=custom/plugins/X|sub=]" +} + +@test "lint_twig scoped: runs under scope cwd" { + run tool_lint_twig '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + assert_line --partial "[scope=custom/plugins/X|sub=]" +} + +@test "unit_setup scoped: runs under scope cwd" { + run tool_unit_setup '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + assert_line --partial "[scope=custom/plugins/X|sub=]" +} + +@test "vite_build scoped: runs under scope cwd" { + run tool_vite_build '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + assert_line --partial "[scope=custom/plugins/X|sub=]" +} + +@test "webpack_build scoped: runs under scope cwd" { + run tool_webpack_build '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + assert_line --partial "[scope=custom/plugins/X|sub=]" +} diff --git a/plugin-tests/dev-tooling/scope_php_tools.bats b/plugin-tests/dev-tooling/scope_php_tools.bats index d3581529..004a0eba 100644 --- a/plugin-tests/dev-tooling/scope_php_tools.bats +++ b/plugin-tests/dev-tooling/scope_php_tools.bats @@ -7,8 +7,6 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" setup() { - LINT_ENV="native" - LINT_WORKDIR="${BATS_TEST_TMPDIR}" LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/.mcp-php-tooling.json" cat > "${LINT_CONFIG_FILE}" <<'JSON' { @@ -28,6 +26,15 @@ JSON CALLS_FILE="${BATS_TEST_TMPDIR}/calls.log" source "${PLUGIN_DIR}/shared/environment.sh" source "${PLUGIN_DIR}/shared/scope.sh" + # Set AFTER sourcing environment.sh: its module-level initializers ("") + # clobber these otherwise. + LINT_ENV="native" + LINT_WORKDIR="${BATS_TEST_TMPDIR}" + PROJECT_ROOT="${BATS_TEST_TMPDIR}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init # Capture each exec_command invocation for inspection. # Defined after environment.sh to override its real implementation. exec_command() { echo "[scope=${SCOPE_CWD:-}] $1" >> "${CALLS_FILE}"; echo "$1"; } @@ -36,10 +43,13 @@ JSON source "${PLUGIN_DIR}/mcp-server-php/lib/ecs.sh" source "${PLUGIN_DIR}/mcp-server-php/lib/phpunit.sh" source "${PLUGIN_DIR}/mcp-server-php/lib/console.sh" + source "${PLUGIN_DIR}/mcp-server-php/lib/phpunit_coverage.sh" } teardown() { - unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE SCOPE_NAME SCOPE_CWD CALLS_FILE + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE SCOPE_NAME SCOPE_CWD CALLS_FILE \ + PROJECT_ROOT DEV_TOOLING_STATE_FILE } @test "phpstan scoped: runs bootstrap before phpstan" { @@ -157,3 +167,20 @@ teardown() { run cat "${CALLS_FILE}" assert_line --partial "[scope=custom/plugins/X]" } + +@test "console scoped: output_file runs the command in the scoped working directory, not the dispatch cwd" { + mkdir -p "${BATS_TEST_TMPDIR}/custom/plugins/X" + wrap_command() { printf '%s\n' 'pwd'; } + local target="${BATS_TEST_TMPDIR}/dump.txt" + run tool_console_run "{\"scope\":\"plugin-x\",\"command\":\"debug:container\",\"output_file\":\"${target}\"}" + assert_success + run cat "${target}" + assert_output "${BATS_TEST_TMPDIR}/custom/plugins/X" +} + +@test "phpunit_coverage_gaps scoped: runs under scope cwd" { + run tool_phpunit_coverage_gaps '{"scope":"plugin-x"}' + assert_success + run cat "${CALLS_FILE}" + assert_line --partial "[scope=custom/plugins/X]" +} diff --git a/plugin-tests/dev-tooling/test_helper/common_setup.bash b/plugin-tests/dev-tooling/test_helper/common_setup.bash index f3bb00cc..8c16078c 100644 --- a/plugin-tests/dev-tooling/test_helper/common_setup.bash +++ b/plugin-tests/dev-tooling/test_helper/common_setup.bash @@ -24,6 +24,145 @@ teardown() { unset CLAUDE_PROJECT_DIR } +# The path $2 relative to the directory $1. Both must be absolute and +# canonical. No external tool computes this portably — BSD realpath has no +# --relative-to — and the fixtures below have both sides already. +# Args: $1 = the directory the result is relative to, $2 = the target path +# Stdout: the relative path +_absolute_path_relative_to() { + local from="${1%/}" to="$2" + local -a from_parts=() to_parts=() + local IFS='/' + read -r -a from_parts <<< "${from}" + read -r -a to_parts <<< "${to}" + + local i=0 + while [[ "${i}" -lt "${#from_parts[@]}" && "${i}" -lt "${#to_parts[@]}" \ + && "${from_parts[${i}]}" == "${to_parts[${i}]}" ]]; do + i=$((i + 1)) + done + + local -a result=() + local j + for (( j = i; j < ${#from_parts[@]}; j++ )); do + result+=("..") + done + for (( j = i; j < ${#to_parts[@]}; j++ )); do + result+=("${to_parts[${j}]}") + done + + printf '%s\n' "${result[*]}" +} + +# Rewrite the linked worktree at $1 to RELATIVE linkage on BOTH sides — the +# ".git" file's "gitdir" pointer and the per-worktree directory's "gitdir" +# back-pointer — which is the state `git worktree add --relative-paths` and +# `git -c worktree.useRelativePaths=true worktree repair` produce. Rewriting +# only the ".git" file leaves a mixed state no git 2.48+ relink produces, and +# the back-pointer resolution defect that refused every real relative-linkage +# worktree passed against exactly that mixed state. +# +# `git worktree add` writes absolute linkage unless the repository sets +# worktree.useRelativePaths, a key git 2.48 introduced. Delegating to that key +# would make these fixtures depend on the host git's version and default — +# measured: git 2.55.0 with no such key writes an absolute pointer — and the +# repository's own CI does not pin a git new enough to honor it. The pointers +# written here are the ones git resolves identically: both sides are canonical, +# so each relative form reaches the same directory as the absolute one it +# replaces. +# Args: $1 = the worktree root +worktree_gitdir_relative() { + local wt="$1" + local canonical target relative + canonical=$(cd "${wt}" && pwd -P) + target=$(< "${wt}/.git") + target="${target#gitdir: }" + target="${target%$'\n'}" + + # The pointer a host's git wrote is not known in advance — a git with + # worktree.useRelativePaths set, or one new enough to default it, writes the + # relative form already, and feeding that relative form to the relative-path + # computation below would produce a pointer to a directory nobody named. It + # is resolved against the worktree root first, so the absolute form is what + # this always starts from. + if [[ "${target}" != /* ]]; then + target=$(cd "${wt}/${target}" && pwd -P) + fi + + relative=$(_absolute_path_relative_to "${canonical}" "${target}") + printf 'gitdir: %s\n' "${relative}" > "${wt}/.git" + + # The back-pointer is relative to the per-worktree directory that holds it. + local back_relative + back_relative=$(_absolute_path_relative_to "${target}" "${canonical}/.git") + printf '%s\n' "${back_relative}" > "${target}/gitdir" +} + +# Refuse the existence probe rather than let it run, and record that it was +# attempted. Install this in setup of every suite that can reach the probe, so +# a container CLI is unreachable by construction rather than by every test +# remembering to stub one. +# +# Without it a test that resolves a worktree under a container environment +# without stubbing `exec_command` runs the real wrapper, and under +# docker-compose that means `docker compose ps` on the host. It passes on a +# machine with no docker — the command-not-found is swallowed by the wrapper and +# the refusal happens to read like a verdict — and behaves differently on one +# that has it, which is the worst shape for a suite whose subject is not the +# probe at all. +# +# A test that means to exercise the probe calls stub_worktree_probe instead. +# Globals: sets WORKTREE_TEST_PROBE_LOG; the file counts attempts +worktree_test_guard_probe() { + WORKTREE_TEST_PROBE_LOG="${BATS_TEST_TMPDIR}/probe-attempts" + : > "${WORKTREE_TEST_PROBE_LOG}" + exec_command() { + if [[ "${1}" == "test -d "* ]]; then + printf '%s\n' "${1}" >> "${WORKTREE_TEST_PROBE_LOG}" + return 1 + fi + printf '%s\n' "${1}" + } +} + +# True when something reached the probe without an explicit verdict — the +# condition worktree_test_guard_probe exists to make visible. A suite asserts it +# after a call that should not have probed. +# Returns: 0 when the probe was attempted, 1 when it was not +worktree_test_probe_was_attempted() { + [[ -s "${WORKTREE_TEST_PROBE_LOG:-}" ]] +} + +# Replace exec_command with a stub that answers worktree_enter's existence +# probe and nothing else. Under a container environment that probe would reach +# a container CLI, which no suite here may execute; the answer it gives is the +# argument, so a suite drives the probe's verdict without an environment. +# +# The verdict is stored in a global rather than captured as a local of this +# function: exec_command is called from inside a command substitution, a +# subshell that inherits the caller's globals but not a local of a function +# that has already returned. A local is unbound there, which under `set -u` +# takes the probe down with a message about the variable instead of a verdict. +# +# Every attempt is appended to WORKTREE_TEST_PROBE_LOG, the same file the +# guard writes, so a test asserting how often — or against which mapped path — +# the probe ran reads the log instead of hand-rolling its own recording stub. +# Args: $1 = "pass" or "fail" +# Globals: sets WORKTREE_PROBE_STUB_VERDICT and WORKTREE_TEST_PROBE_LOG +stub_worktree_probe() { + WORKTREE_PROBE_STUB_VERDICT="$1" + WORKTREE_TEST_PROBE_LOG="${BATS_TEST_TMPDIR}/probe-attempts" + : > "${WORKTREE_TEST_PROBE_LOG}" + exec_command() { + if [[ "${1}" == "test -d "* ]]; then + printf '%s\n' "${1}" >> "${WORKTREE_TEST_PROBE_LOG}" + [[ "${WORKTREE_PROBE_STUB_VERDICT}" == "pass" ]] + return + fi + printf '%s\n' "${1}" + } +} + # Shared setup for PHP MCP tool tests. # Sets LINT_ENV, LINT_WORKDIR, LINT_CONFIG_FILE, stubs log/exec_command, # sources environment.sh, then sources the given tool library. @@ -31,12 +170,19 @@ teardown() { setup_php_mcp_env() { local plugin_dir="$1" lib_path="$2" echo '{"environment":"native"}' > "${BATS_TEST_TMPDIR}/.mcp-php-tooling.json" - LINT_ENV="native" - LINT_WORKDIR="${BATS_TEST_TMPDIR}" LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/.mcp-php-tooling.json" log() { :; } source "${plugin_dir}/shared/environment.sh" source "${plugin_dir}/shared/scope.sh" + # Set AFTER sourcing environment.sh: its module-level initializers ("") + # clobber these otherwise. + LINT_ENV="native" + LINT_WORKDIR="${BATS_TEST_TMPDIR}" + PROJECT_ROOT="${BATS_TEST_TMPDIR}" + export PROJECT_ROOT + # shellcheck source=/dev/null # plugin_dir is caller-supplied at runtime + source "${plugin_dir}/shared/worktree.sh" + worktree_state_init exec_command() { echo "$1"; } # shellcheck source=/dev/null # lib_path is caller-supplied at runtime; each test passes a different tool library source "${lib_path}" diff --git a/plugin-tests/dev-tooling/tool_schema.bats b/plugin-tests/dev-tooling/tool_schema.bats new file mode 100644 index 00000000..95fe84e3 --- /dev/null +++ b/plugin-tests/dev-tooling/tool_schema.bats @@ -0,0 +1,39 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,mcp-tools,schema +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +teardown() { + unset MCP_TOOLS_LIST_FILE +} + +# Put one argument set through the vendored MCP validator against a server's +# real tools.json, the way the server does before it dispatches a tool. +# Args: $1 = tools.json path, $2 = tool name, $3 = arguments JSON +_validate_tool_call() { + MCP_TOOLS_LIST_FILE="$1" + # shellcheck source=/dev/null # sourced for validate_tool_arguments only + source "${PLUGIN_DIR}/shared/mcpserver_core.sh" + validate_tool_arguments "$2" "$3" +} + +@test "php-tooling schema: an unknown parameter on phpstan_analyze is refused" { + run _validate_tool_call "${PLUGIN_DIR}/mcp-server-php/tools.json" "phpstan_analyze" '{"levell":8}' + assert_failure + assert_output --partial "Unknown parameter(s): levell" +} + +@test "js-admin-tooling schema: an unknown parameter on eslint_check is refused" { + run _validate_tool_call "${PLUGIN_DIR}/mcp-server-js-admin/tools.json" "eslint_check" '{"output_formatt":"json"}' + assert_failure + assert_output --partial "Unknown parameter(s): output_formatt" +} + +@test "js-storefront-tooling schema: an unknown parameter on jest_run is refused" { + run _validate_tool_call "${PLUGIN_DIR}/mcp-server-js-storefront/tools.json" "jest_run" '{"testNamePatternn":"foo"}' + assert_failure + assert_output --partial "Unknown parameter(s): testNamePatternn" +} diff --git a/plugin-tests/dev-tooling/worktree_conformance.bats b/plugin-tests/dev-tooling/worktree_conformance.bats new file mode 100644 index 00000000..b5d5c5ea --- /dev/null +++ b/plugin-tests/dev-tooling/worktree_conformance.bats @@ -0,0 +1,262 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,worktree,conformance +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +_make_git_worktree_fixture() { + LAUNCH_ROOT="${BATS_TEST_TMPDIR}/launch" + mkdir -p "${LAUNCH_ROOT}" + git -C "${LAUNCH_ROOT}" init -q + git -C "${LAUNCH_ROOT}" config user.email "test@example.com" + git -C "${LAUNCH_ROOT}" config user.name "Test User" + printf 'seed\n' > "${LAUNCH_ROOT}/seed.txt" + git -C "${LAUNCH_ROOT}" add seed.txt + git -C "${LAUNCH_ROOT}" commit -q -m "seed" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-js-tooling.json" + mkdir -p "${LAUNCH_ROOT}/vendor" + printf '' > "${LAUNCH_ROOT}/vendor/autoload.php" + + WORKTREE_ROOT="${BATS_TEST_TMPDIR}/wt" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_ROOT}" -b wt-branch + worktree_gitdir_relative "${WORKTREE_ROOT}" + mkdir -p "${WORKTREE_ROOT}/vendor" + printf '' > "${WORKTREE_ROOT}/vendor/autoload.php" + mkdir -p "${WORKTREE_ROOT}/src/Administration/Resources/app/administration/node_modules" + mkdir -p "${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront/node_modules" + + # Not a worktree of the launch root, for the refusal pass. + OUTSIDE_ROOT="${BATS_TEST_TMPDIR}/outside" + mkdir -p "${OUTSIDE_ROOT}" +} + +setup() { + _make_git_worktree_fixture +} + +teardown() { + unset LAUNCH_ROOT WORKTREE_ROOT OUTSIDE_ROOT +} + +# Generates a script that sources one server whole, then calls every tool_* +# function defined in that process — `compgen -A function tool_` builds the +# enumeration from the live function set, so a tool added later is covered +# without being told to — with the given project_root, and reports where each +# one ended up. +# +# The signal is the working directory the tool's command would have run in. +# This file is the per-tool positive coverage for that signal on all three +# servers; worktree_php_tools.bats and worktree_js_tools.bats keep only what is +# not scanned here (a failed clover read, and the derived npm working +# directory). Nothing here names worktree_enter or any other function inside the resolution +# path: hoisting the resolution into the dispatcher, renaming it, or splitting +# it keeps this suite passing as long as the command still runs in the directory +# the caller asked for. +# +# Args: $1 = server dir name, $2 = JS_CONTEXT value or empty for PHP, +# $3 = the project_root to pass, $4 = "positive" or "negative" +_scan_server_tools() { + local server_dir="$1" js_context="$2" project_root="$3" mode="$4" + local script="${BATS_TEST_TMPDIR}/scan-${BATS_TEST_NUMBER}.sh" + local calls_file="${BATS_TEST_TMPDIR}/scan-calls-${BATS_TEST_NUMBER}.log" + local config_prefix="php-tooling" + local js_context_line="" + if [[ -n "${js_context}" ]]; then + config_prefix="js-tooling" + js_context_line="JS_CONTEXT=$(printf '%q' "${js_context}")" + fi + + cat > "${script}" <
> "${script}" <<'BODY' +# Both wrappers log the shell's own cwd and LINT_WORKDIR: a regression that +# keeps the resolver's cd but drops the LINT_WORKDIR rebind passes on cwd alone. +exec_command() { + printf '[cwd=%s][workdir=%s]\n' "$(pwd)" "${LINT_WORKDIR}" >> "${CALLS_FILE}" + printf '%s\n' "$1" +} +exec_npm_command() { + printf '[cwd=%s][workdir=%s]\n' "$(pwd)" "${LINT_WORKDIR}" >> "${CALLS_FILE}" + case "$1" in + 'npm pkg get "scripts.'*) printf '"a-script"\n' ;; + *) printf '%s\n' "$1" ;; + esac +} + +# Parameters a tool refuses to run without. Only "command" qualifies today; the +# rest of the surface takes project_root alone. A tool added with a new required +# parameter and no entry here reports NO-COMMAND, which is a failure that names +# the tool rather than a silent pass. +declare -A TOOL_EXTRA_ARGS=( + [tool_console_run]=',"command":"cache:clear"' +) + +# The enumeration is itself asserted, because every marker below is a name +# anchor: a scan that never saw a tool is otherwise indistinguishable from one +# where that tool passed. tools.json is the server's own declaration of what it +# exposes, so reconciling against it keeps a tool added later covered without +# this file being edited — the property the compgen enumeration exists for. +exempt_re="^($(printf '%s' "${EXEMPT_TOOLS}" | tr ' ' '|'))$" +declared_file="${CALLS_FILE}.declared" +defined_file="${CALLS_FILE}.defined" +jq -r '.tools[].name' "${TOOLS_JSON}" | sed 's/^/tool_/' | grep -Ev "${exempt_re}" | sort -u > "${declared_file}" +compgen -A function tool_ | grep -Ev "${exempt_re}" | sort -u > "${defined_file}" + +enumeration_rc=0 +while read -r declared_only; do + printf 'MISSING-TOOL %s\n' "${declared_only}" + enumeration_rc=1 +done < <(comm -23 "${declared_file}" "${defined_file}") +while read -r defined_only; do + printf 'UNDECLARED-TOOL %s\n' "${defined_only}" + enumeration_rc=1 +done < <(comm -13 "${declared_file}" "${defined_file}") +if [[ "${enumeration_rc}" -ne 0 ]]; then + exit 1 +fi + +for fn in $(compgen -A function tool_); do + case " ${EXEMPT_TOOLS} " in + *" ${fn} "*) continue ;; + esac + + args="{\"project_root\":\"${TARGET_ROOT}\"${TOOL_EXTRA_ARGS[${fn}]:-}}" + : > "${CALLS_FILE}" + rc=0 + ( "${fn}" "${args}" >/dev/null 2>&1 ) || rc=$? + + if [[ "${SCAN_MODE}" == "negative" ]]; then + if [[ -s "${CALLS_FILE}" ]]; then + printf 'RAN-ANYWAY %s\n' "${fn}" + elif [[ "${rc}" -eq 0 ]]; then + printf 'SILENT-PASS %s\n' "${fn}" + else + printf 'REFUSED %s\n' "${fn}" + fi + continue + fi + + if [[ ! -s "${CALLS_FILE}" ]]; then + printf 'NO-COMMAND %s\n' "${fn}" + elif [[ "$(head -n 1 -- "${CALLS_FILE}")" != "[cwd=${TARGET_ROOT}][workdir=${TARGET_ROOT}]" ]]; then + printf 'WRONG-DIRECTORY %s %s\n' "${fn}" "$(head -n 1 -- "${CALLS_FILE}")" + else + printf 'OK %s\n' "${fn}" + fi +done +BODY + + run bash "${script}" +} + +# --- Every tool runs its command in the worktree the caller named --- + +@test "every php-tooling tool_* function except the exempt tools runs in the named worktree" { + _scan_server_tools mcp-server-php "" "${WORKTREE_ROOT}" positive + assert_success + refute_output --partial "MISSING-TOOL" + refute_output --partial "UNDECLARED-TOOL" + refute_output --partial "NO-COMMAND" + refute_output --partial "WRONG-DIRECTORY" + assert_output --partial "OK tool_phpstan_analyze" + assert_output --partial "OK tool_phpunit_coverage_gaps" + assert_output --partial "OK tool_console_run" +} + +@test "every js-admin-tooling tool_* function except the exempt tools runs in the named worktree" { + _scan_server_tools mcp-server-js-admin admin "${WORKTREE_ROOT}" positive + assert_success + refute_output --partial "MISSING-TOOL" + refute_output --partial "UNDECLARED-TOOL" + refute_output --partial "NO-COMMAND" + refute_output --partial "WRONG-DIRECTORY" + assert_output --partial "OK tool_lint_all" + assert_output --partial "OK tool_lint_twig" + assert_output --partial "OK tool_unit_setup" + assert_output --partial "OK tool_vite_build" +} + +@test "every js-storefront-tooling tool_* function except the exempt tools runs in the named worktree" { + _scan_server_tools mcp-server-js-storefront storefront "${WORKTREE_ROOT}" positive + assert_success + refute_output --partial "MISSING-TOOL" + refute_output --partial "UNDECLARED-TOOL" + refute_output --partial "NO-COMMAND" + refute_output --partial "WRONG-DIRECTORY" + assert_output --partial "OK tool_webpack_build" + assert_output --partial "OK tool_ludtwig_check" + assert_output --partial "OK tool_ludtwig_fix" +} + +# --- Every tool stops when the named root is refused --- +# +# Reaching the resolver is not the same as obeying it. A tool that resolves and +# then runs anyway executes against the launch root while the caller asked for a +# directory the resolver rejected, and the banner attributes the result to the +# directory that was refused. + +@test "every php-tooling tool_* function except the exempt tools runs no command against a refused root" { + _scan_server_tools mcp-server-php "" "${OUTSIDE_ROOT}" negative + assert_success + refute_output --partial "MISSING-TOOL" + refute_output --partial "UNDECLARED-TOOL" + refute_output --partial "RAN-ANYWAY" + refute_output --partial "SILENT-PASS" + assert_output --partial "REFUSED tool_phpstan_analyze" +} + +@test "every js-admin-tooling tool_* function except the exempt tools runs no command against a refused root" { + _scan_server_tools mcp-server-js-admin admin "${OUTSIDE_ROOT}" negative + assert_success + refute_output --partial "MISSING-TOOL" + refute_output --partial "UNDECLARED-TOOL" + refute_output --partial "RAN-ANYWAY" + refute_output --partial "SILENT-PASS" + assert_output --partial "REFUSED tool_lint_all" +} + +@test "every js-storefront-tooling tool_* function except the exempt tools runs no command against a refused root" { + _scan_server_tools mcp-server-js-storefront storefront "${OUTSIDE_ROOT}" negative + assert_success + refute_output --partial "MISSING-TOOL" + refute_output --partial "UNDECLARED-TOOL" + refute_output --partial "RAN-ANYWAY" + refute_output --partial "SILENT-PASS" + assert_output --partial "REFUSED tool_ludtwig_check" +} diff --git a/plugin-tests/dev-tooling/worktree_hook.bats b/plugin-tests/dev-tooling/worktree_hook.bats new file mode 100644 index 00000000..38029c4a --- /dev/null +++ b/plugin-tests/dev-tooling/worktree_hook.bats @@ -0,0 +1,185 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,worktree,hook +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +SCRIPTS_DIR="${REPO_ROOT}/plugins/dev-tooling/hooks/scripts" +HOOK_SCRIPT="${SCRIPTS_DIR}/worktree-directives.sh" + +# Runs the hook with a raw JSON payload on stdin. +# Args: $1 = the JSON payload +_run_worktree_hook() { + local payload="$1" + run bash -c 'printf "%s" "$1" | bash "$2"' _ "${payload}" "${HOOK_SCRIPT}" +} + +# Runs the hook and replaces $output with the decoded additionalContext string. +# The hook emits JSON, so a substring assertion against its raw stdout matches +# escape artifacts — the message's own quotes arrive as \" — and passes just as +# well when the envelope around the message is wrong. Every message assertion +# below runs against the decoded string instead. +# Args: $1 = the JSON payload +_run_hook_and_decode_context() { + local payload="$1" + run bash -c 'printf "%s" "$1" | bash "$2" | jq -r ".hookSpecificOutput.additionalContext"' \ + _ "${payload}" "${HOOK_SCRIPT}" +} + +_enter_input() { + local worktree_path="$1" cwd_path="$2" + jq -cn --arg wp "${worktree_path}" --arg cwd "${cwd_path}" \ + '{tool_name: "EnterWorktree", tool_response: {worktreePath: $wp}, cwd: $cwd}' +} + +# --- The JSON envelope --- + +# Claude Code reads the directive out of this exact structure. A hook that keeps +# emitting the right sentence in the wrong envelope reaches no session at all. +# One test covers both branches: they share the single here-document that writes +# the envelope, so the Exit branch would add a second name for one shape. The +# jq filter runs under `run` and prints the paths it read, because a bare +# `jq -e` inside a test fails with an exit status and no diagnostic at all. +@test "the hook emits a PostToolUse hookSpecificOutput envelope holding the directive as a string" { + run bash -c 'printf "%s" "$1" | bash "$2" | jq -r "[ + (.hookSpecificOutput.hookEventName // \"\"), + (.hookSpecificOutput.additionalContext | type), + (.hookSpecificOutput.additionalContext | startswith(\"Worktree entered: \") | tostring) + ] | join(\"|\")"' \ + _ "$(_enter_input "/repo/.claude/worktrees/feature" "/repo/.claude/worktrees/feature")" \ + "${HOOK_SCRIPT}" + assert_success + assert_output "PostToolUse|string|true" +} + +# --- The directive text --- + +# Claude Code's hooks reference states that cwd "is the worktree root after +# Claude enters a worktree", so a real EnterWorktree payload carries the same +# directory in both fields. The precedence test below is the one that makes them +# disagree, and it does that to show which field is read rather than to model a +# payload that occurs. +@test "EnterWorktree with a worktree path names it and every server" { + _run_hook_and_decode_context "$(_enter_input "/repo/.claude/worktrees/feature" "/repo/.claude/worktrees/feature")" + assert_success + assert_output --partial "Worktree entered: /repo/.claude/worktrees/feature" + assert_output --partial 'set_project_root with project_root "/repo/.claude/worktrees/feature"' + assert_output --partial "php-tooling" + assert_output --partial "js-admin-tooling" + assert_output --partial "js-storefront-tooling" +} + +@test "EnterWorktree on an absolute-linkage worktree names the repair command" { + local wt="${BATS_TEST_TMPDIR}/wt-abs" + mkdir -p "${wt}" + printf 'gitdir: /somewhere/.git/worktrees/wt-abs\n' > "${wt}/.git" + _run_hook_and_decode_context "$(_enter_input "${wt}" "${wt}")" + assert_success + assert_output --partial "carries an absolute gitdir pointer" + assert_output --partial "git -c worktree.useRelativePaths=true worktree repair \"${wt}\"" +} + +@test "EnterWorktree names the repair command when the .git file has no trailing newline" { + # `read` populates the variable and still returns 1 at EOF on a final line + # without a newline; a guard that clears the variable on that status drops + # the hint for exactly the file a hand-written tool produces. + local wt="${BATS_TEST_TMPDIR}/wt-noeol" + mkdir -p "${wt}" + printf 'gitdir: /somewhere/.git/worktrees/wt-noeol' > "${wt}/.git" + _run_hook_and_decode_context "$(_enter_input "${wt}" "${wt}")" + assert_success + assert_output --partial "carries an absolute gitdir pointer" +} + +@test "EnterWorktree quotes a repair path carrying a space" { + local wt="${BATS_TEST_TMPDIR}/wt abs" + mkdir -p "${wt}" + printf 'gitdir: /somewhere/.git/worktrees/wt-abs\n' > "${wt}/.git" + _run_hook_and_decode_context "$(_enter_input "${wt}" "${wt}")" + assert_success + # The named command must survive copy-paste as ONE argument. + assert_output --partial "worktree repair \"${wt}\"" +} + +@test "EnterWorktree on a relative-linkage worktree carries no repair command" { + local wt="${BATS_TEST_TMPDIR}/wt-rel" + mkdir -p "${wt}" + printf 'gitdir: ../../../.git/worktrees/wt-rel\n' > "${wt}/.git" + _run_hook_and_decode_context "$(_enter_input "${wt}" "${wt}")" + assert_success + assert_output --partial "Worktree entered: ${wt}" + refute_output --partial "worktree repair" +} + +@test "EnterWorktree with a worktree path carries the baseRef warning" { + _run_hook_and_decode_context "$(_enter_input "/repo/.claude/worktrees/feature" "/repo/.claude/worktrees/feature")" + assert_success + assert_output --partial 'worktree.baseRef is "head"' +} + +@test "EnterWorktree reads tool_response.worktreePath in preference to cwd" { + _run_hook_and_decode_context "$(_enter_input "/repo/.claude/worktrees/feature" "/repo/somewhere-else")" + assert_success + assert_output --partial "Worktree entered: /repo/.claude/worktrees/feature" + refute_output --partial "/repo/somewhere-else" +} + +@test "EnterWorktree falls back to cwd when tool_response.worktreePath is absent" { + local payload + payload=$(jq -cn --arg cwd "/repo/.claude/worktrees/feature" \ + '{tool_name: "EnterWorktree", tool_response: {}, cwd: $cwd}') + _run_hook_and_decode_context "${payload}" + assert_success + assert_output --partial "Worktree entered: /repo/.claude/worktrees/feature" +} + +@test "EnterWorktree with neither worktreePath nor cwd emits the diagnostic" { + local payload + payload=$(jq -cn '{tool_name: "EnterWorktree", tool_response: {}, cwd: ""}') + _run_hook_and_decode_context "${payload}" + assert_success + assert_output --partial 'neither "tool_response.worktreePath" nor "cwd" held one' + assert_output --partial "call set_project_root on all three with the worktree path" +} + +@test "EnterWorktree with tool_response as a non-object string falls back to cwd" { + local payload + payload=$(jq -cn --arg cwd "/repo/.claude/worktrees/feature" \ + '{tool_name: "EnterWorktree", tool_response: "not an object", cwd: $cwd}') + _run_hook_and_decode_context "${payload}" + assert_success + assert_output --partial "Worktree entered: /repo/.claude/worktrees/feature" +} + +@test "ExitWorktree names every server and the no-argument call" { + local payload + payload=$(jq -cn '{tool_name: "ExitWorktree", tool_response: {}, cwd: "/repo"}') + _run_hook_and_decode_context "${payload}" + assert_success + assert_output --partial "Call set_project_root with no project_root argument" + assert_output --partial "php-tooling" + assert_output --partial "js-admin-tooling" + assert_output --partial "js-storefront-tooling" +} + +# --- Input the hook cannot use leaves the session untouched --- + +@test "a tool name outside EnterWorktree/ExitWorktree produces no output" { + local payload + payload=$(jq -cn '{tool_name: "SomeOtherTool", tool_response: {}, cwd: "/repo"}') + _run_worktree_hook "${payload}" + assert_success + assert_output "" +} + +@test "malformed JSON input exits 0 with no output" { + run bash -c 'printf "%s" "$1" | bash "$2"' _ '{not valid json' "${HOOK_SCRIPT}" + assert_success + assert_output "" +} + +@test "empty input exits 0 with no output" { + run bash -c 'printf "" | bash "$1"' _ "${HOOK_SCRIPT}" + assert_success + assert_output "" +} diff --git a/plugin-tests/dev-tooling/worktree_js_tools.bats b/plugin-tests/dev-tooling/worktree_js_tools.bats new file mode 100644 index 00000000..c7ceaa5c --- /dev/null +++ b/plugin-tests/dev-tooling/worktree_js_tools.bats @@ -0,0 +1,192 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,worktree,js +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +_make_git_worktree_fixture() { + LAUNCH_ROOT="${BATS_TEST_TMPDIR}/launch" + mkdir -p "${LAUNCH_ROOT}" + git -C "${LAUNCH_ROOT}" init -q + git -C "${LAUNCH_ROOT}" config user.email "test@example.com" + git -C "${LAUNCH_ROOT}" config user.name "Test User" + printf 'seed\n' > "${LAUNCH_ROOT}/seed.txt" + git -C "${LAUNCH_ROOT}" add seed.txt + git -C "${LAUNCH_ROOT}" commit -q -m "seed" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-js-tooling.json" + + WORKTREE_ROOT="${BATS_TEST_TMPDIR}/wt" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_ROOT}" -b wt-branch + worktree_gitdir_relative "${WORKTREE_ROOT}" + mkdir -p "${WORKTREE_ROOT}/src/Administration/Resources/app/administration/node_modules" + mkdir -p "${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront/node_modules" +} + +setup() { + _make_git_worktree_fixture +} + +teardown() { + unset LAUNCH_ROOT WORKTREE_ROOT +} + +# Sources one server's shared modules and lib files fresh in an isolated +# process, calls the given tool with project_root set to WORKTREE_ROOT, and +# captures the shell's cwd at the moment the (stubbed) command would run — the +# same signal scope_php_tools.bats uses for SCOPE_CWD, applied to the cd +# worktree_resolve_root performs. A generated script file rather than an +# inline `bash -c` string: %q-quoting every substituted value keeps the +# argument JSON's embedded quotes from having to survive a second shell layer. +# Args: $1 = js_context (admin|storefront), $2 = server dir name, +# $3 = tool function name, $4 = tool arguments JSON, $@ = lib file names +_run_js_tool() { + local js_context="$1" server_dir="$2" tool_fn="$3" json_args="$4" + shift 4 + local script="${BATS_TEST_TMPDIR}/run-tool-${BATS_TEST_NUMBER}.sh" + local calls_file="${BATS_TEST_TMPDIR}/calls-${BATS_TEST_NUMBER}.log" + + # Header: every value here is substituted NOW, from this outer shell's own + # variables — the unquoted heredoc delimiter is what makes that happen. + cat > "${script}" <
> "${script}" <<'BODY' +exec_npm_command() { + local jswd + jswd=$(get_js_workdir) + printf '[cwd=%s][workdir=%s][jsworkdir=%s] %s\n' "$(pwd)" "${LINT_WORKDIR}" "${jswd}" "$1" >> "${CALLS_FILE}" + case "$1" in + 'npm pkg get "scripts.'*) printf '"a-script"\n' ;; + *) printf '%s\n' "$1" ;; + esac +} +exec_command() { printf '[cwd=%s][workdir=%s] %s\n' "$(pwd)" "${LINT_WORKDIR}" "$1" >> "${CALLS_FILE}"; printf '%s\n' "$1"; } +BODY + + local lib + for lib in "$@"; do + printf 'source "%s/%s/lib/%s"\n' "${PLUGIN_DIR}" "${server_dir}" "${lib}" >> "${script}" + done + printf '%s %q > /dev/null\n' "${tool_fn}" "${json_args}" >> "${script}" + # shellcheck disable=SC2016 # ${CALLS_FILE} must expand in the generated script when it runs, not here + printf 'cat -- "${CALLS_FILE}"\n' >> "${script}" + + run bash "${script}" +} + +# Every tool on both JS servers is already covered per tool by +# worktree_conformance.bats for the [cwd][workdir] pair. The cases here stay +# because they additionally pin [jsworkdir] — the directory get_js_workdir() +# derives for the npm invocation itself out of LINT_WORKDIR and JS_CONTEXT — +# which the conformance scan does not capture. The two ludtwig cases asserted +# the [cwd][workdir] pair alone and were cut as duplicates of that scan. + +# --- Admin --- + +# worktree_prepare exists for exactly the state every other tool refuses, so +# both prepare cases delete node_modules first: a regression that reintroduces +# the dependency gate into the tool would refuse the call, and the conformance +# scan would not catch that because its fixture worktree carries node_modules. +@test "admin worktree_prepare: runs npm ci in the package directory of a worktree with no node_modules" { + rm -rf "${WORKTREE_ROOT}/src/Administration/Resources/app/administration/node_modules" + _run_js_tool admin mcp-server-js-admin tool_worktree_prepare \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" prepare.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration] npm ci" + # The success summary is not assertable here: _run_js_tool discards the + # tool's stdout and prints only the calls log. worktree_php_tools.bats pins + # the summary-on-success / full-output-on-failure contract. +} + +@test "storefront worktree_prepare: runs npm ci in the package directory of a worktree with no node_modules" { + rm -rf "${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront/node_modules" + _run_js_tool storefront mcp-server-js-storefront tool_worktree_prepare \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" prepare.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront] npm ci" + # The success summary is not assertable here: _run_js_tool discards the + # tool's stdout and prints only the calls log. worktree_php_tools.bats pins + # the summary-on-success / full-output-on-failure contract. +} + +@test "admin lint_all: a project_root argument reaches the resolved working directory" { + _run_js_tool admin mcp-server-js-admin tool_lint_all \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" lint-all.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration]" +} + +@test "admin lint_twig: a project_root argument reaches the resolved working directory" { + _run_js_tool admin mcp-server-js-admin tool_lint_twig \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" lint-all.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration]" +} + +@test "admin unit_setup: a project_root argument reaches the resolved working directory" { + _run_js_tool admin mcp-server-js-admin tool_unit_setup \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" lint-all.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration]" +} + +@test "admin vite_build: a project_root argument reaches the resolved working directory" { + _run_js_tool admin mcp-server-js-admin tool_vite_build \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" build.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration]" +} + +# --- Storefront --- + +@test "storefront webpack_build: a project_root argument reaches the resolved working directory" { + _run_js_tool storefront mcp-server-js-storefront tool_webpack_build \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" build.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront]" +} + +# --- eslint_check, one per server --- + +@test "admin eslint_check: a project_root argument reaches the resolved working directory" { + _run_js_tool admin mcp-server-js-admin tool_eslint_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" eslint.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration]" +} + +@test "storefront eslint_check: a project_root argument reaches the resolved working directory" { + _run_js_tool storefront mcp-server-js-storefront tool_eslint_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\"}" eslint.sh + assert_success + assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront]" +} diff --git a/plugin-tests/dev-tooling/worktree_php_tools.bats b/plugin-tests/dev-tooling/worktree_php_tools.bats new file mode 100644 index 00000000..27f8e580 --- /dev/null +++ b/plugin-tests/dev-tooling/worktree_php_tools.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,worktree,php +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +_make_git_worktree_fixture() { + LAUNCH_ROOT="${BATS_TEST_TMPDIR}/launch" + mkdir -p "${LAUNCH_ROOT}" + git -C "${LAUNCH_ROOT}" init -q + git -C "${LAUNCH_ROOT}" config user.email "test@example.com" + git -C "${LAUNCH_ROOT}" config user.name "Test User" + printf 'seed\n' > "${LAUNCH_ROOT}/seed.txt" + git -C "${LAUNCH_ROOT}" add seed.txt + git -C "${LAUNCH_ROOT}" commit -q -m "seed" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + + WORKTREE_ROOT="${BATS_TEST_TMPDIR}/wt" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_ROOT}" -b wt-branch + worktree_gitdir_relative "${WORKTREE_ROOT}" + mkdir -p "${WORKTREE_ROOT}/vendor" + touch "${WORKTREE_ROOT}/vendor/autoload.php" +} + +setup() { + _make_git_worktree_fixture + CONFIG_PREFIX="php-tooling" + LINT_CONFIG_FILE="${LAUNCH_ROOT}/.mcp-php-tooling.json" + log() { :; } + CALLS_FILE="${BATS_TEST_TMPDIR}/calls.log" + # Captured BEFORE the sources below, because config.sh installs + # `trap config_cleanup EXIT` at source time and that replaces the EXIT trap + # bats emits its result through. Without the restore at the end of this + # function a FAILING test in this file produces no "not ok" line at all — + # only "Executed N instead of expected M" — so a regression reads as a count + # mismatch naming no test. Measured on this file: an assertion that cannot + # hold reported nothing until this was added. + BATS_EXIT_TRAP=$(trap -p EXIT) + source "${PLUGIN_DIR}/shared/config.sh" + source "${PLUGIN_DIR}/shared/environment.sh" + source "${PLUGIN_DIR}/shared/scope.sh" + LINT_ENV="native" + LINT_WORKDIR="${LAUNCH_ROOT}" + PROJECT_ROOT="${LAUNCH_ROOT}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init + # Captures both the shell's cwd (proves the resolver's cd reached this + # call) and LINT_WORKDIR (proves the resolver also rebound the variable + # every real command-wrapping path reads). A regression that keeps the cd + # but drops the LINT_WORKDIR rebind would pass on cwd alone. + exec_command() { printf '[cwd=%s][workdir=%s] %s\n' "$(pwd)" "${LINT_WORKDIR}" "$1" >> "${CALLS_FILE}"; echo "$1"; } + source "${PLUGIN_DIR}/mcp-server-php/lib/phpunit_coverage.sh" + source "${PLUGIN_DIR}/mcp-server-php/lib/prepare.sh" + eval "${BATS_EXIT_TRAP:-trap - EXIT}" +} + +teardown() { + # The cleanup the displaced trap would have run at exit. + declare -F config_cleanup >/dev/null && config_cleanup + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE SCOPE_NAME SCOPE_CWD CALLS_FILE \ + PROJECT_ROOT DEV_TOOLING_STATE_FILE CONFIG_PREFIX LAUNCH_ROOT WORKTREE_ROOT \ + WORKTREE_ENV_WORKDIR WORKTREE_LAUNCH_CONFIG_FILE WORKTREE_SELECTED_CONFIG_FILE +} + +# The coverage-gap strip removes the directory the wrapped command ran in from +# the paths the Clover report carries, which are the environment's own paths. +# Under a container environment that prefix is the environment-side working +# directory, not the host root — and get_workdir is the tool's bare call site +# for it, unchanged by the rebinding. A run that stripped the host root instead +# would leave the container prefix on every reported path. +@test "phpunit_coverage_gaps strips the environment-side working directory from the clover paths" { + local in_root="${LAUNCH_ROOT}/.claude/worktrees/in-root" + mkdir -p "$(dirname "${in_root}")" + git -C "${LAUNCH_ROOT}" worktree add -q "${in_root}" -b wt-in-root-branch + worktree_gitdir_relative "${in_root}" + mkdir -p "${in_root}/vendor" + touch "${in_root}/vendor/autoload.php" + printf '{"environment":"docker","docker":{"workdir":"/srv/app","container":"shopware_app"}}\n' \ + > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + LINT_ENV="docker" + # Bound with LINT_ENV, because detect_environment binds both from the same + # file: the environment decides which workdir key is read, and the workdir is + # what the mapping is based on. Leaving this at the launch root would model a + # server that never started, and the mapped path would then measure its + # suffix below a directory the configuration never named. + LINT_WORKDIR="/srv/app" + + exec_command() { + case "$1" in + "test -d "*) return 0 ;; + esac + printf '%s\n' '' + } + + run tool_phpunit_coverage_gaps "{\"project_root\":\"${in_root}\"}" + assert_success + assert_output --partial "src/Covered.php" + refute_output --partial "/srv/app" +} + +# worktree_prepare exists for exactly the state every other tool refuses, so +# its case here deletes vendor/ first: a regression that reintroduces the +# dependency gate into the tool would refuse this call, and the conformance +# scan would not catch that because its fixture worktree carries vendor/. +@test "worktree_prepare runs composer install in a worktree that has no vendor and summarizes the output" { + rm -rf "${WORKTREE_ROOT}/vendor" + run tool_worktree_prepare "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + # A successful install returns a summary, not the per-package flood. + assert_output --partial "composer install completed." + assert_output --partial "Showing the last 3 of" + + run cat "${CALLS_FILE}" + assert_line --index 0 --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}] composer install --no-interaction" +} + +@test "worktree_prepare passes a failed install's output through in full" { + rm -rf "${WORKTREE_ROOT}/vendor" + exec_command() { printf 'line-1\nline-2\nline-3\nline-4\nProblem 1: package not found\n'; return 1; } + run tool_worktree_prepare "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_failure + assert_output --partial "line-1" + assert_output --partial "Problem 1: package not found" + refute_output --partial "Showing the last 3 of" +} + +# Every PHP tool's positive path — this one included — is covered per tool by +# worktree_conformance.bats, which scans the whole live tool_* set for the same +# [cwd][workdir] signal. phpunit_coverage_gaps resolves scope like the rest of +# them (mcp-server-php/lib/phpunit_coverage.sh calls resolve_scope between +# worktree_enter and worktree_assert_dependencies), so it needs no separate +# positive case here. What lives only here is the path where the clover read +# fails: the tool has to report its own read error rather than fall through to +# an empty-coverage result, and it has to have run in the named worktree while +# doing so. +@test "phpunit_coverage_gaps: a refused clover read reports the tool's own error from the resolved working directory" { + exec_command() { printf '[cwd=%s][workdir=%s] %s\n' "$(pwd)" "${LINT_WORKDIR}" "$1" >> "${CALLS_FILE}"; echo "stub: clover read refused"; return 1; } + run tool_phpunit_coverage_gaps "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_failure + # The tool's own message for an unreadable report, naming the default + # clover_path it resolved — not the stub's echo, which proves only that the + # stub ran. + assert_output --partial "Error: Cannot read clover XML at 'coverage.xml'" + + run cat "${CALLS_FILE}" + assert_line --index 0 --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}]" +} diff --git a/plugin-tests/dev-tooling/worktree_resolution.bats b/plugin-tests/dev-tooling/worktree_resolution.bats new file mode 100644 index 00000000..58367ae9 --- /dev/null +++ b/plugin-tests/dev-tooling/worktree_resolution.bats @@ -0,0 +1,1484 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,worktree,resolution +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +# A launch checkout plus real linked worktrees created with `git worktree add`, +# so validation against git's own registry (`_worktree_is_registered`) passes. +_make_git_worktree_fixture() { + LAUNCH_ROOT="${BATS_TEST_TMPDIR}/launch" + mkdir -p "${LAUNCH_ROOT}" + git -C "${LAUNCH_ROOT}" init -q + git -C "${LAUNCH_ROOT}" config user.email "test@example.com" + git -C "${LAUNCH_ROOT}" config user.name "Test User" + printf 'seed\n' > "${LAUNCH_ROOT}/seed.txt" + git -C "${LAUNCH_ROOT}" add seed.txt + git -C "${LAUNCH_ROOT}" commit -q -m "seed" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + mkdir -p "${LAUNCH_ROOT}/vendor" + touch "${LAUNCH_ROOT}/vendor/autoload.php" + + WORKTREE_A="${BATS_TEST_TMPDIR}/wt-a" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_A}" -b wt-a-branch + worktree_gitdir_relative "${WORKTREE_A}" + WORKTREE_B="${BATS_TEST_TMPDIR}/wt-b" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_B}" -b wt-b-branch + worktree_gitdir_relative "${WORKTREE_B}" + + # Inside the launch root, which is where the four container environments + # require a worktree target to sit: a container mounts the launch root and + # nothing else. + WORKTREE_IN_ROOT="${LAUNCH_ROOT}/.claude/worktrees/in-root" + mkdir -p "$(dirname "${WORKTREE_IN_ROOT}")" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_IN_ROOT}" -b wt-in-root-branch + worktree_gitdir_relative "${WORKTREE_IN_ROOT}" + mkdir -p "${WORKTREE_IN_ROOT}/vendor" + touch "${WORKTREE_IN_ROOT}/vendor/autoload.php" +} + +setup() { + _make_git_worktree_fixture + CONFIG_PREFIX="php-tooling" + LINT_CONFIG_FILE="${LAUNCH_ROOT}/.mcp-php-tooling.json" + log() { :; } + # Captured BEFORE the sources below, because config.sh installs + # `trap config_cleanup EXIT` at source time and that replaces the EXIT trap + # bats emits its result through. Without the restore at the end of this + # function a FAILING test in this file produces no "not ok" line at all — + # only "Executed N instead of expected M" — so a regression here reads as a + # count mismatch naming no test. Capturing after the sources saves the + # already-displaced trap and fixes nothing. + BATS_EXIT_TRAP=$(trap -p EXIT) + source "${PLUGIN_DIR}/shared/config.sh" + source "${PLUGIN_DIR}/shared/environment.sh" + source "${PLUGIN_DIR}/shared/scope.sh" + LINT_ENV="native" + LINT_WORKDIR="${LAUNCH_ROOT}" + PROJECT_ROOT="${LAUNCH_ROOT}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init + # Before any test body runs: the real exec_command reaches a container CLI + # under a container environment. A test that means to exercise the probe + # calls stub_worktree_probe to replace this. + worktree_test_guard_probe + _wrap_with_trap_restore worktree_resolve_root + _wrap_with_trap_restore tool_set_project_root + _restore_bats_exit_trap +} + +_restore_bats_exit_trap() { + eval "${BATS_EXIT_TRAP:-trap - EXIT}" +} + +# The tools that install a dispatch-subshell EXIT trap for their call-owned temp +# files do it in this process too when a test calls them WITHOUT `run`, where it +# displaces bats' trap — and then a later failing assertion in that test produces +# no "not ok" line at all, only "Executed N instead of expected M", naming no +# test. Two functions install one: worktree_resolve_root and +# tool_set_project_root, the latter since it gained the same arrangement. +# +# Restoring the trap at each call site would work and would be forgotten: the +# next test added to this file has to know a rule nothing enforces. So the +# restore is moved into the function itself, under its own name, for the +# lifetime of this process. Tests call them as they would anywhere, and there is +# nothing left to remember. +# +# Only this process is affected. The generated scripts below run in their own +# bash processes and source shared/worktree.sh fresh, so they get the real +# functions and the real traps, which is what makes them a faithful model of a +# tool dispatch. +# Args: $1 = the function to wrap +_wrap_with_trap_restore() { + local fn="$1" + eval "_${fn}_without_trap_restore() $(declare -f "${fn}" | tail -n +2)" + eval "${fn}() { + local rc=0 + _${fn}_without_trap_restore \"\$@\" || rc=\$? + # Restored only in the test process itself. \`run\` resolves in a subshell + # where the tool's trap dies on its own, and installing bats' trap + # there makes it fire a second time as that subshell ends — one test then + # emits two result lines and the file reports more tests than it has. + if [[ \"\${BASHPID}\" == \"\$\$\" ]]; then + _restore_bats_exit_trap + fi + return \"\${rc}\" +}" +} + +teardown() { + # The cleanups the displaced traps would have run at exit. Both are guarded + # against an empty variable, so calling them here is safe whether or not the + # test reached the code that arms them. + declare -F config_cleanup >/dev/null && config_cleanup + declare -F worktree_release_owned_temp >/dev/null && worktree_release_owned_temp + worktree_state_cleanup + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE \ + CONFIG_PREFIX LAUNCH_ROOT WORKTREE_A WORKTREE_B WORKTREE_IN_ROOT \ + WORKTREE_ENV_WORKDIR WORKTREE_LAUNCH_CONFIG_FILE WORKTREE_SELECTED_CONFIG_FILE \ + WORKTREE_PROBE_STUB_VERDICT +} + +# Calls worktree_resolve_root and, on success, prints "root|source" so `run` +# can capture the two globals it sets without leaking the cd it performs into +# this test's own shell. +_resolve_and_report() { + local args="$1" + worktree_resolve_root "${args}" || return 1 + printf '%s|%s\n' "${WORKTREE_EFFECTIVE_ROOT}" "${WORKTREE_ROOT_SOURCE}" +} + +# The environment-side binding the resolution left behind, which `run` would +# otherwise discard with the subshell that performed the cd. +_resolve_and_report_workdir() { + local args="$1" + worktree_resolve_root "${args}" || return 1 + printf '%s|%s\n' "${LINT_WORKDIR}" "${WORKTREE_ENV_WORKDIR}" +} + +# The environment the resolution left in force, and the environment-side path. +_resolve_and_report_binding() { + local args="$1" + worktree_resolve_root "${args}" || return 1 + printf '%s|%s|%s\n' "${LINT_ENV}" "${LINT_WORKDIR}" "${WORKTREE_ENV_WORKDIR}" +} + +# --- Precedence: call argument > sticky > launch --- + +@test "a call argument takes precedence over a sticky root" { + tool_set_project_root "{\"project_root\":\"${WORKTREE_A}\"}" + # The sticky root has to be armed for the precedence claim to mean anything. + # Without this the test passes with tool_set_project_root doing nothing at + # all, and then it only repeats "a real linked worktree of the launch root + # resolves successfully". + run _worktree_state_read_sticky + assert_success + assert_output "${WORKTREE_A}" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_B}\"}" + assert_success + assert_output "${WORKTREE_B}|call" +} + +@test "a sticky root takes precedence over the launch root" { + tool_set_project_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run _resolve_and_report '{}' + assert_success + assert_output "${WORKTREE_A}|sticky" +} + +@test "the launch root is used when neither a call argument nor a sticky root is set" { + run _resolve_and_report '{}' + assert_success + assert_output "${LAUNCH_ROOT}|launch" +} + +# --- Successful match against a real worktree --- + +@test "a real linked worktree of the launch root resolves successfully" { + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_success + assert_output "${WORKTREE_A}|call" +} + +# --- Rejections --- + +@test "a directory that is not a linked worktree of the launch root is refused" { + local plain_dir="${BATS_TEST_TMPDIR}/not-a-worktree" + mkdir -p "${plain_dir}" + + run _resolve_and_report "{\"project_root\":\"${plain_dir}\"}" + assert_failure + assert_output --partial "it holds no \".git\" file" +} + +# The two below are the forgeries the git-dir/common-dir inequality admits. Both +# were verified to kill the check they name: neutering the back-pointer test +# makes this pair the only failures in the file, and without them the whole +# cluster can be removed with every other test still green. + +@test "a git too old for --path-format is refused by naming the version, not the flag" { + # _worktree_git_dir_pair runs git through `env`, which execs a program and + # cannot see a shell function, so the stand-in has to be a real executable + # ahead of git on PATH. It reproduces what every git below 2.31 does with an + # option it does not know: echo it back and exit 0, which shifts the two-line + # read by one and used to surface as a git directory named + # "--path-format=absolute". + local fake_bin="${BATS_TEST_TMPDIR}/fake-bin" + mkdir -p "${fake_bin}" + cat > "${fake_bin}/git" <<'FAKE' +#!/usr/bin/env bash +for arg in "$@"; do + case "${arg}" in + --path-format=*) printf '%s\n' "${arg}" ;; + --git-dir) printf '.git\n' ;; + --git-common-dir) printf '.git\n' ;; + esac +done +exit 0 +FAKE + chmod +x "${fake_bin}/git" + + PATH="${fake_bin}:${PATH}" run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial "git 2.31" + refute_output --partial 'the git directory "--path-format=absolute"' +} + +@test "a hand-written .git pointing at a real sibling worktree's git directory is refused" { + local forged="${BATS_TEST_TMPDIR}/forged" + mkdir -p "${forged}" + # Borrow worktree B's per-worktree directory. Every earlier test passes — + # ".git" is a regular file, the git dir differs from the common dir, and the + # common dir is the launch root's — but B's back-pointer names B, not this. + local sibling_gitdir + sibling_gitdir=$(git -C "${WORKTREE_B}" rev-parse --absolute-git-dir) + printf 'gitdir: %s\n' "${sibling_gitdir}" > "${forged}/.git" + + run _resolve_and_report "{\"project_root\":\"${forged}\"}" + assert_failure + assert_output --partial "belongs to the worktree at" + assert_output --partial "${WORKTREE_B}" +} + +@test "a back-pointer written relative resolves against the per-worktree directory" { + # `git worktree add --relative-paths` writes BOTH links relative: the + # ".git" pointer and the back-pointer, the latter relative to the + # per-worktree directory holding it. Resolving that back-pointer from the + # process working directory instead refused every such worktree as + # hand-written. The preconditions pin the all-relative state so this test + # cannot silently weaken if the fixture regresses to absolute linkage. + local wt_gitdir gitdir_line backpointer + wt_gitdir=$(git -C "${WORKTREE_A}" rev-parse --path-format=absolute --git-dir) + gitdir_line=$(< "${WORKTREE_A}/.git") + assert [ "${gitdir_line#gitdir: /}" = "${gitdir_line}" ] + backpointer=$(< "${wt_gitdir}/gitdir") + assert [ "${backpointer#/}" = "${backpointer}" ] + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_success + assert_output "${WORKTREE_A}|call" +} + +@test "a relative pointer with an absolute back-pointer is still accepted" { + # The mixed state a partial relink leaves behind: ".git" relative, the + # back-pointer absolute. Both spellings name the same directory, so the + # identity holds. + local wt_gitdir canonical + wt_gitdir=$(git -C "${WORKTREE_A}" rev-parse --path-format=absolute --git-dir) + canonical=$(cd "${WORKTREE_A}" && pwd -P) + printf '%s/.git\n' "${canonical}" > "${wt_gitdir}/gitdir" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_success + assert_output "${WORKTREE_A}|call" +} + +@test "a .git pointing at an administrative directory with no gitdir back-pointer is refused" { + local real_gitdir orphan_gitdir orphan + real_gitdir=$(git -C "${WORKTREE_B}" rev-parse --absolute-git-dir) + orphan_gitdir="$(dirname "${real_gitdir}")/orphan" + # A complete administrative directory minus its back-pointer. A directory + # holding only "commondir" is rejected by git itself ("not a git + # repository"), which lands on the earlier refusal instead and never reaches + # the one under test; a full copy resolves and does reach it. + cp -R "${real_gitdir}" "${orphan_gitdir}" + rm -f "${orphan_gitdir}/gitdir" + orphan="${BATS_TEST_TMPDIR}/orphan-root" + mkdir -p "${orphan}" + printf 'gitdir: %s\n' "${orphan_gitdir}" > "${orphan}/.git" + + run _resolve_and_report "{\"project_root\":\"${orphan}\"}" + assert_failure + assert_output --partial "holds no \"gitdir\" back-pointer" +} + +# --- The environment decides whether an out-of-root target is reachable --- +# +# The old gate refused every container environment outright and refused any +# worktree that declared one. What actually decides is whether the container +# can reach the root at all, which is a question about the root's place in the +# launch tree rather than about the environment's name. + +# The environments whose commands run inside a container that mounts the launch +# root. Enumerated at runtime because the verdict and the message are one shape +# for all four, and because the list is the one the module's own case statement +# carries — a fifth added there fails here without an edit. +_CONTAINER_ENVS=(docker docker-compose vagrant ddev) + +# Point the launch configuration at one environment, with every configured +# workdir the same so a suite asserting a mapped path holds one expectation +# whichever of the three it is driving. +# +# LINT_WORKDIR is bound too, because the startup binding is two values and not +# one: detect_environment reads the same file for LINT_ENV and for the workdir +# the mapping is based on. A fixture that bound only the first would model a +# state no server starts in, and the mapping — which reads the startup scalar — +# would then measure the suffix below a directory the configuration never named. +_write_launch_environment() { + printf '{"environment":"%s","docker":{"workdir":"/srv/app","container":"shopware_app"},"vagrant":{"workdir":"/srv/app"},"ddev":{"workdir":"/srv/app"}}\n' "$1" \ + > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + + # The value _set_workdir_from_config assigns for each environment: the + # configured workdir for the three, the call-time sentinel for + # docker-compose, and the project root for native. + case "$1" in + docker|vagrant|ddev) LINT_WORKDIR="/srv/app" ;; + docker-compose) LINT_WORKDIR="(resolved at call time)" ;; + *) LINT_WORKDIR="${LAUNCH_ROOT}" ;; + esac +} + +@test "an out-of-root worktree target is refused under every container environment" { + local environment + for environment in "${_CONTAINER_ENVS[@]}"; do + _assert_out_of_root_refused "${environment}" + done +} + +@test "a worktree whose own configuration declares an unrecognized environment is refused" { + # The environment value selects which guards apply, so an unrecognized + # spelling must refuse rather than fall through to the permissive native + # path: a "docker " typo in the worktree's own config would otherwise skip + # containment, charset and probe and run the command on the host. + _write_launch_environment native + LINT_ENV="native" + printf '{"environment":"podman"}\n' > "${WORKTREE_A}/.mcp-php-tooling.json" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial '"podman"' + assert_output --partial "does not recognize" +} + +@test "an out-of-root worktree whose OWN configuration declares a container environment is refused for containment" { + # Containment must key on the configuration in force for the call — the + # worktree's own — not on the launch environment: a regression that reads + # LINT_ENV here accepts this root because the launch config is native. + _write_launch_environment native + LINT_ENV="native" + printf '{"environment":"docker","docker":{"workdir":"/srv/app","container":"c"}}\n' > "${WORKTREE_A}/.mcp-php-tooling.json" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial 'the "docker" environment runs commands inside a container' +} + +_assert_out_of_root_refused() { + local environment="$1" + _write_launch_environment "${environment}" + LINT_ENV="${environment}" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial "the \"${environment}\" environment runs commands inside a container" + assert_output --partial "The supported pattern is a worktree created inside the launch project root" +} + +# The containment test is what makes an out-of-root target unreachable, so it +# has to measure where a path IS and not how it is spelled. Every check ahead of +# it compares canonical paths — the back-pointer test and `git worktree list` +# both do — so a registered worktree outside the launch root reached through a +# ".." segment passes all of them and has to be caught here. +@test "an out-of-root worktree spelled through a dot segment is refused under a container environment" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + # WORKTREE_A really is outside LAUNCH_ROOT; this spelling walks out of it + # and back in, so a raw prefix comparison reads it as inside. + local spelled + spelled="$(dirname "${LAUNCH_ROOT}")/$(basename "${LAUNCH_ROOT}")/../$(basename "${WORKTREE_A}")" + + run _resolve_and_report "{\"project_root\":\"${spelled}\"}" + assert_failure + assert_output --partial "runs commands inside a container" + assert_output --partial "The supported pattern is a worktree created inside the launch project root" +} + +# The supported pattern itself, under the three environments whose mapping is +# read from the config alone. docker-compose resolves its path through the +# compose CLI and is covered separately. +_CONFIG_ONLY_CONTAINER_ENVS=(docker vagrant ddev) + +@test "an in-root worktree target resolves to the environment-side path under every config-only container environment" { + local environment + for environment in "${_CONFIG_ONLY_CONTAINER_ENVS[@]}"; do + _assert_in_root_resolved "${environment}" + done +} + +_assert_in_root_resolved() { + local environment="$1" + _write_launch_environment "${environment}" + LINT_ENV="${environment}" + stub_worktree_probe pass + + run _resolve_and_report_workdir "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + assert_output "/srv/app/.claude/worktrees/in-root|/srv/app/.claude/worktrees/in-root" +} + +# Containment measures canonical paths, so the mapping that follows it has to +# measure the same pair. A root the caller spelled through a symlink is inside +# the launch root by every test validation runs — the back-pointer check and +# `git worktree list` compare canonically — and a mapping that compared the +# symlink's own spelling against the launch root then refused it as out-of-root, +# a peer of the dot-segment case above where the answer is the opposite one. +@test "an in-root worktree reached through a symlink resolves under a container environment" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + # Beside the launch root rather than inside it, so the symlink's own + # spelling is genuinely not below PROJECT_ROOT: this is the input the old + # mapping refused, not one it would have accepted by accident. + local link="${BATS_TEST_TMPDIR}/in-root-link" + ln -s "${WORKTREE_IN_ROOT}" "${link}" + + run _resolve_and_report_workdir "{\"project_root\":\"${link}\"}" + assert_success + assert_output "/srv/app/.claude/worktrees/in-root|/srv/app/.claude/worktrees/in-root" +} + +# The docker-compose arm reads its mount list from the compose CLI, which no +# suite here executes. What is asserted is the wiring: worktree_enter binds +# LINT_WORKDIR to the resolver's answer and records it as the environment-side +# path, rather than binding the host root. The mapping itself is covered in +# plugin-tests/mcp-shared/docker_compose.bats. +@test "an in-root worktree target under docker-compose binds the resolver's container path" { + _write_launch_environment docker-compose + LINT_ENV="docker-compose" + stub_worktree_probe pass + resolve_env_workdir() { printf '%s\n' "/var/www/html/.claude/worktrees/in-root"; } + + run _resolve_and_report_workdir "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + assert_output "/var/www/html/.claude/worktrees/in-root|/var/www/html/.claude/worktrees/in-root" +} + +# Writes the given bytes as the worktree's own configuration and asserts the +# call is refused for it, naming the file. Each caller supplies one shape that +# nothing can be read from; jq answers them with three different exit statuses, +# and the guard has to refuse all of them. +_assert_worktree_config_refused() { + printf '%s' "$1" > "${WORKTREE_A}/.mcp-php-tooling.json" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial "${WORKTREE_A}/.mcp-php-tooling.json" + assert_output --partial "the configuration file in force" + assert_output --partial "does not parse as a JSON object" +} + +@test "a worktree config that does not parse as JSON is refused, naming the file" { + _assert_worktree_config_refused '{"environment": "native"' +} + +@test "a worktree config that is empty is refused, naming the file" { + _assert_worktree_config_refused '' +} + +@test "a worktree config holding only whitespace is refused, naming the file" { + _assert_worktree_config_refused ' ' +} + +@test "a worktree config holding a JSON value that is not an object is refused, naming the file" { + _assert_worktree_config_refused 'null' +} + +# The launch configuration parses at server start, so this state needs someone +# to edit the file while the session runs — which is ordinary. Before the check +# covered the inherited config, the call went through and ran with the +# environment, the scopes and every per-tool setting reading as absent, which is +# the outcome the check exists to prevent. +@test "a worktree carrying no config of its own is refused for a launch config that stopped parsing" { + printf '{"environment": "native"\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial "the configuration file in force" + assert_output --partial "does not parse as a JSON object" + assert_output --partial "${LAUNCH_ROOT}/.mcp-php-tooling.json" +} + +# --- Fallback to the launch configuration --- + +@test "a worktree carrying no config of its own falls back to the launch configuration" { + printf '{"environment":"native","default_scope":"launch-marker","scopes":{"launch-marker":{"cwd":"x"}}}\n' \ + > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + # The resolution result is the same one a worktree carrying its own config + # produces, so the fallback is observed through the configuration the call + # ends up holding. "launch-marker" exists only in the launch config written + # above. + run jq -r '.default_scope' "${WORKTREE_SELECTED_CONFIG_FILE}" + assert_success + assert_output "launch-marker" +} + +# --- Temp-file ownership on a re-run of load_config --- + +# Both roots carry two configs, so load_config merges at each into a temp file. +# The worktree call must delete its own merged temp and leave the launch one, and +# the consequence of getting that wrong is visible one call later: the launch +# config is gone, every read of it answers empty, and the next launch-root call +# runs unscoped at the project root reporting no error at all. That later call is +# what this asserts — the ownership variables are internal, and worktree.sh's own +# comments mark one of them as not the signal and the other's assignment as a +# no-op today. +# +# The worktree call runs in a subshell because the real dispatcher runs every +# tool call in one, and the EXIT trap that releases the owned temp is installed +# there. Reproducing that is the whole point: the deletion happens as the call +# ends, and the launch root has to survive it. +@test "a later launch-root call still resolves its scope after a worktree call merged and released its own config" { + mkdir -p "${LAUNCH_ROOT}/.claude" "${WORKTREE_A}/.claude" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + printf '{"default_scope":"launch-scope","scopes":{"launch-scope":{"cwd":"custom/plugins/Launch"}}}\n' \ + > "${LAUNCH_ROOT}/.claude/.mcp-php-tooling.json" + printf '{"environment":"native"}\n' > "${WORKTREE_A}/.mcp-php-tooling.json" + printf '{"default_scope":"wt-scope","scopes":{"wt-scope":{"cwd":"custom/plugins/Worktree"}}}\n' \ + > "${WORKTREE_A}/.claude/.mcp-php-tooling.json" + + local script="${BATS_TEST_TMPDIR}/ownership.sh" + cat > "${script}" <
> "${script}" <<'BODY' +source "${PLUGIN_DIR}/shared/config.sh" +# The launch-time merge, in server.sh's own order: LINT_CONFIG_FILE becomes a +# temp file that no later call may delete. +load_config "${PROJECT_ROOT}" +source "${PLUGIN_DIR}/shared/environment.sh" +source "${PLUGIN_DIR}/shared/scope.sh" +LINT_ENV="native" +LINT_WORKDIR="${PROJECT_ROOT}" +source "${PLUGIN_DIR}/shared/worktree.sh" +worktree_state_init + +( worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" >/dev/null 2>&1 ) + +worktree_resolve_root '{}' >/dev/null 2>&1 +resolve_scope "" +printf '%s|%s\n' "${SCOPE_NAME}" "${SCOPE_CWD}" +BODY + + run bash "${script}" + assert_success + assert_output "launch-scope|custom/plugins/Launch" +} + +# The test above proves the release does not delete the WRONG file, and it holds +# whether or not the release runs at all: the launch configuration survives a +# missing trap untouched. So the deletion itself is asserted here — the merged +# file the worktree call ended up holding, checked once while the call is still +# in flight and once after its subshell has closed. +# +# The path is named exactly rather than counted out of a directory, so wherever +# mktemp places the merged file the observation still points at it, and no file +# another process left behind can be mistaken for it. TMPDIR is redirected into +# this test's own directory all the same: worktree_state_init writes its state +# file through an explicit TMPDIR template, and the script below has no cleanup +# that would remove it. +@test "the config a worktree call merged into is deleted once that call's subshell has ended" { + mkdir -p "${LAUNCH_ROOT}/.claude" "${WORKTREE_A}/.claude" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + printf '{"default_scope":"launch-scope","scopes":{"launch-scope":{"cwd":"custom/plugins/Launch"}}}\n' \ + > "${LAUNCH_ROOT}/.claude/.mcp-php-tooling.json" + printf '{"environment":"native"}\n' > "${WORKTREE_A}/.mcp-php-tooling.json" + printf '{"default_scope":"wt-scope","scopes":{"wt-scope":{"cwd":"custom/plugins/Worktree"}}}\n' \ + > "${WORKTREE_A}/.claude/.mcp-php-tooling.json" + + local script="${BATS_TEST_TMPDIR}/release.sh" + cat > "${script}" <
> "${script}" <<'BODY' +source "${PLUGIN_DIR}/shared/config.sh" +# The launch-time merge, in server.sh's own order, so the configuration this +# call inherits is a temp file too and cannot be confused with the one below. +load_config "${PROJECT_ROOT}" +source "${PLUGIN_DIR}/shared/environment.sh" +source "${PLUGIN_DIR}/shared/scope.sh" +LINT_ENV="native" +LINT_WORKDIR="${PROJECT_ROOT}" +source "${PLUGIN_DIR}/shared/worktree.sh" +worktree_state_init + +_exists() { ls -1d "$1" 2>/dev/null | wc -l | tr -d ' '; } + +# The command substitution IS the subshell the call runs in — the handler +# dispatches every tool inside one, which is where worktree_resolve_root +# installs the EXIT trap that releases the merged file. Both values are produced +# inside it, on one line, because the trap fires as it closes. +in_call=$( + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" >/dev/null 2>&1 + printf '%s|%s\n' "${WORKTREE_SELECTED_CONFIG_FILE}" "$(_exists "${WORKTREE_SELECTED_CONFIG_FILE}")" +) + +printf 'in-flight=%s\n' "${in_call##*|}" +printf 'after-call=%s\n' "$(_exists "${in_call%%|*}")" +BODY + + run bash "${script}" + assert_success + # The precondition: the call really did merge into a file that existed while + # it ran. A worktree carrying one config, or a refused resolution, leaves + # this at 0 and the case fails as setup rather than passing on an absence. + assert_line "in-flight=1" + assert_line "after-call=0" +} + +# set_project_root and cwd are the two tools worktree_resolve_root does not +# reach, and both create the same call-owned files — the merged configuration +# during validation, the derivation log after it. Unlike the resolver they +# release those files explicitly on every return path, so only a call that ends +# WITHOUT reaching that release can leave one behind: that is the cancellation +# the dispatch-subshell EXIT trap they install is for, and the call below is +# driven to end that way. +# +# The interruption is placed after validation, which is where the merged file +# exists and where the tool's own release has not run yet. The interposed +# function is a timing device and not the subject, so it reports what the call +# held; a refactor that removes the seam leaves "in-flight" absent and fails +# here rather than quietly turning this into a test of a call that released on +# its own. It reports into a file because the call it interrupts has its own +# stdout redirected. +@test "the config a set_project_root call merged into is deleted when the call ends without its own release" { + mkdir -p "${LAUNCH_ROOT}/.claude" "${WORKTREE_A}/.claude" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + printf '{"default_scope":"launch-scope","scopes":{"launch-scope":{"cwd":"custom/plugins/Launch"}}}\n' \ + > "${LAUNCH_ROOT}/.claude/.mcp-php-tooling.json" + printf '{"environment":"native"}\n' > "${WORKTREE_A}/.mcp-php-tooling.json" + printf '{"default_scope":"wt-scope","scopes":{"wt-scope":{"cwd":"custom/plugins/Worktree"}}}\n' \ + > "${WORKTREE_A}/.claude/.mcp-php-tooling.json" + + local script="${BATS_TEST_TMPDIR}/setter-release.sh" + cat > "${script}" <
> "${script}" <<'BODY' +source "${PLUGIN_DIR}/shared/config.sh" +# The launch-time merge, in server.sh's own order, so the configuration this +# call inherits is a temp file too and cannot be mistaken for the call's own. +load_config "${PROJECT_ROOT}" +source "${PLUGIN_DIR}/shared/environment.sh" +source "${PLUGIN_DIR}/shared/scope.sh" +LINT_ENV="native" +LINT_WORKDIR="${PROJECT_ROOT}" +source "${PLUGIN_DIR}/shared/worktree.sh" +worktree_state_init + +_exists() { ls -1d "$1" 2>/dev/null | wc -l | tr -d ' '; } +REPORT_FILE="${TMPDIR}/setter-report" +: > "${REPORT_FILE}" + +# The command substitution IS the subshell the handler dispatches a tool call +# in, so it is where the trap under test fires. The interposed function records +# what the call held, then ends that subshell the way a cancellation does — +# after validation has merged the configuration and before the tool's own +# release runs. The `|| true` keeps the substitution's status from ending this +# script: SHELLOPTS reaches it with errexit set, so an unhandled 143 would stop +# the run before anything below is observed. +captured=$( + _worktree_bind_call_environment() { + { + printf 'merged=%s\n' "${WORKTREE_SELECTED_CONFIG_FILE}" + printf 'in-flight=%s\n' "$(_exists "${WORKTREE_SELECTED_CONFIG_FILE}")" + } > "${REPORT_FILE}" + exit 143 + } + tool_set_project_root "{\"project_root\":\"${WORKTREE_A}\"}" >/dev/null 2>&1 + printf 'released-by-the-call\n' +) || true + +cat "${REPORT_FILE}" +merged=$(sed -n 's/^merged=//p' "${REPORT_FILE}") +printf 'after-call=%s\n' "$(_exists "${merged}")" +printf 'call-returned=%s\n' "${captured}" +BODY + + run bash "${script}" + assert_success + # in-flight=1 is the precondition: the call really did merge into a file + # that existed while it ran. Without it a refused resolution leaves + # after-call=0 and the case would pass on an absence. + assert_line "in-flight=1" + # Empty because the substituted shell ended at the interruption instead of + # reaching the line after the call — the state the trap is there for. + assert_line "call-returned=" + assert_line "after-call=0" +} + +# --- worktree_assert_dependencies --- + +@test "worktree_assert_dependencies refuses a PHP worktree with no installed vendor directory" { + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run worktree_assert_dependencies + assert_failure + assert_output --partial "${WORKTREE_A}/vendor/autoload.php" + assert_output --partial "composer install" +} + +@test "worktree_assert_dependencies passes a PHP worktree with vendor/autoload.php present" { + mkdir -p "${WORKTREE_A}/vendor" + touch "${WORKTREE_A}/vendor/autoload.php" + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run worktree_assert_dependencies + assert_success + assert_output "" +} + +@test "worktree_assert_dependencies refuses a JS worktree with no installed node_modules" { + JS_CONTEXT="admin" + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run worktree_assert_dependencies + assert_failure + assert_output --partial "node_modules" + assert_output --partial "npm ci" + unset JS_CONTEXT +} + +@test "worktree_assert_dependencies php passes a JS-server worktree carrying vendor but no node_modules" { + JS_CONTEXT="storefront" + mkdir -p "${WORKTREE_A}/vendor" + touch "${WORKTREE_A}/vendor/autoload.php" + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run worktree_assert_dependencies php + assert_success + assert_output "" + unset JS_CONTEXT +} + +@test "worktree_assert_dependencies php refuses a JS-server worktree carrying node_modules but no vendor" { + JS_CONTEXT="storefront" + mkdir -p "${WORKTREE_A}/src/Storefront/Resources/app/storefront/node_modules" + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run worktree_assert_dependencies php + assert_failure + assert_output --partial "${WORKTREE_A}/vendor/autoload.php" + assert_output --partial "composer install" + unset JS_CONTEXT +} + +# --- Path guard: the PHP boundary is the effective root, not the scope directory --- + +@test "worktree_assert_paths_within_root admits an absolute path inside the worktree root but outside the scope directory" { + mkdir -p "${WORKTREE_A}/custom/plugins/X" "${WORKTREE_A}/src/Other" + printf '{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X"}}}\n' \ + > "${WORKTREE_A}/.mcp-php-tooling.json" + # Not wrapped in `run`: the resolver's cd and resolve_scope's SCOPE_CWD + # assignment must land in this test's own process for the direct call + # below to see them. Each call is asserted explicitly rather than left as + # a bare statement, so a failure here reads as "setup failed" and not as + # a false pass of the assertion that follows. + if ! worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}"; then + fail "worktree_resolve_root did not resolve the worktree root" + fi + if ! resolve_scope "plugin-x"; then + fail "resolve_scope did not resolve the declared scope" + fi + [[ "${SCOPE_CWD}" == "custom/plugins/X" ]] || fail "resolve_scope did not set SCOPE_CWD to the scope's cwd" + + run worktree_assert_paths_within_root "[\"${WORKTREE_A}/src/Other/File.php\"]" + assert_success + assert_output "" +} + +@test "worktree_assert_paths_within_root refuses an absolute path outside the worktree root" { + if ! worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}"; then + fail "worktree_resolve_root did not resolve the worktree root" + fi + if ! resolve_scope ""; then + fail "resolve_scope did not resolve the unscoped call" + fi + + run worktree_assert_paths_within_root "[\"${WORKTREE_B}/src/File.php\"]" + assert_failure + assert_output --partial "resolve outside" + assert_output --partial "${WORKTREE_A}" +} + +# --- Path guard: a relative path may not climb out of the worktree --- +# +# The guard cannot resolve a relative path to check containment: the storefront +# tools accept three spellings of one file and only their own routing tells them +# apart, and the tree-relative spelling does not exist under the effective root +# at all. It refuses the ".." segment instead, so both sides of that decision +# are asserted — the escapes it must catch, and the documented spellings it must +# still admit. + +_enter_worktree_a_unscoped() { + if ! worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}"; then + fail "worktree_resolve_root did not resolve the worktree root" + fi + if ! resolve_scope ""; then + fail "resolve_scope did not resolve the unscoped call" + fi +} + +_assert_relative_refused() { + run worktree_assert_paths_within_root "[\"$1\"]" + assert_failure + assert_output --partial "climb out of the tree" + assert_output --partial "$1" +} + +_assert_relative_admitted() { + run worktree_assert_paths_within_root "[\"$1\"]" + assert_success + assert_output "" +} + +@test "worktree_assert_paths_within_root refuses a relative path that climbs out of the worktree" { + _enter_worktree_a_unscoped + _assert_relative_refused "../../../src/Core" +} + +@test "worktree_assert_paths_within_root refuses an interior .. segment" { + _enter_worktree_a_unscoped + _assert_relative_refused "src/../../escape/File.php" +} + +@test "worktree_assert_paths_within_root refuses a trailing .. segment" { + _enter_worktree_a_unscoped + _assert_relative_refused "src/.." +} + +@test "worktree_assert_paths_within_root refuses a bare .. path" { + _enter_worktree_a_unscoped + _assert_relative_refused ".." +} + +@test "worktree_assert_paths_within_root admits a repo-root-relative path" { + _enter_worktree_a_unscoped + _assert_relative_admitted "src/Storefront/Resources/views/components/Example.js" +} + +@test "worktree_assert_paths_within_root admits a tree-relative path that does not exist under the root" { + _enter_worktree_a_unscoped + _assert_relative_admitted "views/components/Example.js" +} + +@test "worktree_assert_paths_within_root admits a filename whose basename begins with dots" { + _enter_worktree_a_unscoped + _assert_relative_admitted "src/..gitignore" +} + +# --- Gitdir linkage classification --- +# +# The classifier is the unit; _worktree_gitdir_linkage is the file read in +# front of it. Every class is asserted here because the resolution path cannot +# produce all three: a ".git" file git itself cannot parse is refused one step +# earlier, so "invalid" is reachable only through the unit. + +_assert_gitdir_classified() { + run _worktree_classify_gitdir "$1" + assert_success + assert_output "$2" +} + +@test "the gitdir classifier reads a rooted pointer as absolute" { + _assert_gitdir_classified 'gitdir: /var/lib/repo/.git/worktrees/wt' absolute +} + +@test "the gitdir classifier reads a parent-relative pointer as relative" { + _assert_gitdir_classified 'gitdir: ../repo/.git/worktrees/wt' relative +} + +@test "the gitdir classifier reads a dot-relative pointer as relative" { + _assert_gitdir_classified 'gitdir: ./repo/.git/worktrees/wt' relative +} + +@test "the gitdir classifier reads a bare relative pointer as relative" { + _assert_gitdir_classified 'gitdir: repo/.git/worktrees/wt' relative +} + +@test "the gitdir classifier refuses a line carrying no gitdir prefix" { + _assert_gitdir_classified 'worktree: /var/lib/repo/.git/worktrees/wt' invalid +} + +@test "the gitdir classifier refuses a gitdir prefix followed by nothing" { + _assert_gitdir_classified 'gitdir: ' invalid +} + +@test "the gitdir classifier refuses an empty file" { + _assert_gitdir_classified '' invalid +} + +@test "the gitdir classifier refuses a pointer carrying a line break" { + _assert_gitdir_classified $'gitdir: /a\nb' invalid +} + +# --- An absolutely linked worktree is refused everywhere --- +# +# Enumerated at runtime, because the verdict does not vary with the +# environment: which environment a call runs under is a property of a +# configuration file, not of the pointer, so a root accepted natively today is +# reached through a container the moment one is configured. + +_ALL_ENVS=(native docker docker-compose vagrant ddev) + +# Rewrite a worktree's ".git" file so its pointer is the absolute per-worktree +# directory, which is what `git worktree add` writes unless the repository sets +# worktree.useRelativePaths. +_worktree_gitdir_absolute() { + local wt="$1" + local target + target=$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR \ + git -C "${wt}" rev-parse --absolute-git-dir) + printf 'gitdir: %s\n' "${target}" > "${wt}/.git" +} + +@test "an absolutely linked worktree is refused under every environment, naming the remediation" { + _worktree_gitdir_absolute "${WORKTREE_A}" + + local environment + for environment in "${_ALL_ENVS[@]}"; do + _write_launch_environment "${environment}" + LINT_ENV="${environment}" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial "carries an absolute gitdir pointer" + assert_output --partial "git -c worktree.useRelativePaths=true worktree repair \"${WORKTREE_A}\"" + done + + # Restore the fixture for any later test in this file. + worktree_gitdir_relative "${WORKTREE_A}" +} + +# --- Charset refusals --- + +# A worktree created inside the launch root whose directory name carries the +# given characters. A branch name cannot carry them, so it is sanitized — and +# because that sanitization maps several characters onto the same letter, a +# caller creating more than one in a single test passes a distinct suffix. +# Args: $1 = the directory name, $2 = optional discriminator for the branch name +_make_in_root_worktree() { + local name="$1" + local discriminator="${2:-}" + local path="${LAUNCH_ROOT}/.claude/worktrees/${name}" + mkdir -p "$(dirname "${path}")" + git -C "${LAUNCH_ROOT}" worktree add -q "${path}" -b "wt-${name//[!a-zA-Z0-9]/x}${discriminator}branch" + worktree_gitdir_relative "${path}" + printf '%s\n' "${path}" +} + +@test "a worktree root carrying a single quote is refused under every environment" { + local hostile + hostile=$(_make_in_root_worktree "it's") + + local environment + for environment in "${_ALL_ENVS[@]}"; do + _write_launch_environment "${environment}" + LINT_ENV="${environment}" + + run _resolve_and_report "{\"project_root\":\"${hostile}\"}" + assert_failure + assert_output --partial "contains a single quote" + done +} + +# One row per failure class a root can realistically carry: whitespace, which +# ends the directory name where the value is unquoted, and the two metacharacter +# shapes that matter — one that separates commands, one that substitutes. The +# whole set is covered by the shared-environment suite, which reads +# SHELL_HOSTILE_METACHARS rather than restating it, so this table does not grow +# a row per character. +# shellcheck disable=SC2016 # the third name really does contain a dollar sign; shellcheck reads the whole assignment as one statement +_ROOT_CHARSET_CASES=( + 'a b' + 'a;b' + 'a$b' +) + +@test "a worktree root carrying a character a container command cannot carry is refused under every container environment" { + local index name hostile + for index in "${!_ROOT_CHARSET_CASES[@]}"; do + name="${_ROOT_CHARSET_CASES[${index}]}" + hostile=$(_make_in_root_worktree "${name}" "${index}") + _assert_root_charset_refused "${hostile}" + done +} + +_assert_root_charset_refused() { + local root="$1" + local environment + for environment in "${_CONTAINER_ENVS[@]}"; do + _write_launch_environment "${environment}" + LINT_ENV="${environment}" + + run _resolve_and_report "{\"project_root\":\"${root}\"}" + assert_failure + # One sentence for all four, so the refusal does not depend on which + # environment happened to be configured. + assert_output --partial "the \"${environment}\" environment sends this path into the container as part of the working directory" + done +} + +@test "a worktree root carrying whitespace or a metacharacter is admitted under native" { + local accepted + accepted=$(_make_in_root_worktree 'a b') + + _write_launch_environment native + LINT_ENV="native" + + # Native's wrapper emits no cd and therefore no directory at all, so the + # value reaches no shell and there is nothing for the character to break. + run _resolve_and_report "{\"project_root\":\"${accepted}\"}" + assert_success + assert_output "${accepted}|call" +} + +@test "the refusal names the character that cannot be carried" { + local hostile + hostile=$(_make_in_root_worktree 'a;b') + + _write_launch_environment docker + LINT_ENV="docker" + + run _resolve_and_report "{\"project_root\":\"${hostile}\"}" + assert_failure + assert_output --partial "acts on the character \\; it carries" +} + +# --- Mid-session edits of the launch configuration --- + +# The launch configuration is read once, when the server starts, and the +# environment every check is keyed on comes from that reading. A test that only +# rewrote the file would model a server that re-reads it per call, which is the +# state these two cover; so the file is written with the startup content first +# and then edited to another environment, while LINT_ENV keeps the value the +# server bound. +# +# The consequence of getting this wrong is measured: validation reads the edited +# file, finds native, skips containment, the charset rule and the probe, and the +# command then runs under the startup docker binding the wrappers are still keyed +# on. Validation and execution answering for different environments is the whole +# of the defect. +_edit_launch_config_to_native() { + printf '{"environment":"docker","docker":{"workdir":"/srv/app","container":"shopware_app"}}\n' \ + > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + LINT_ENV="docker" + + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" +} + +@test "an edit of the launch config to native still refuses an out-of-root worktree" { + _edit_launch_config_to_native + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_A}\"}" + assert_failure + assert_output --partial "the \"docker\" environment runs commands inside a container" +} + +@test "an edit of the launch config to native still probes the worktree it maps" { + _edit_launch_config_to_native + + run _resolve_and_report_workdir "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "the \"docker\" environment does not reach" +} + +# The mapping base is the third thing the launch config binds at startup, beside +# the environment and the container name, and it is inert to a mid-session edit +# for the same reason: _set_workdir_from_config assigned it when the server +# started, and the mapping reads that scalar rather than the file. Reading +# `.docker.workdir` afresh here would move where a running server's +# worktree-targeted commands run while a launch-root call kept running under the +# workdir the server started with — the same class of defect as the environment +# edit above, one layer further out. +# +# Distinct from the test above, which edits the environment: this one leaves the +# environment alone and moves only the workdir. +@test "an edit of the launch workdir does not move where a worktree call maps" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + # The edit: same environment, different workdir. + printf '{"environment":"docker","docker":{"workdir":"/srv/edited"}}\n' \ + > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + + run _resolve_and_report_workdir "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + assert_output "/srv/app/.claude/worktrees/in-root|/srv/app/.claude/worktrees/in-root" +} + +# The spelling the wrappers embed is the CANONICAL one, not the one the caller +# typed: validation resolves the root, and the resolved path is what the mapping +# and every wrapper string carry. A root reached through a symlink therefore +# passes a charset check run on the caller's spelling while the metacharacter +# travels into the command. The pair is in-root on both sides, so containment +# and the git linkage tests accept it and the charset check is the only step +# between the call and the wrapper. +@test "a worktree reached through a clean-named symlink is refused for a metacharacter in its canonical spelling" { + local real_root="${LAUNCH_ROOT}/.claude/worktrees/real;dir" + mkdir -p "$(dirname "${real_root}")" + git -C "${LAUNCH_ROOT}" worktree add -q "${real_root}" -b wt-canonical-branch + worktree_gitdir_relative "${real_root}" + + local clean_link="${LAUNCH_ROOT}/.claude/worktrees/clean" + ln -s "real;dir" "${clean_link}" + + # The resolved spelling, which is what the refusal names: on macOS a + # "/var/..." path resolves under "/private/var/...", and the point of the + # assertion is that the message carries the path the shell is handed rather + # than the clean one the caller typed. + local canonical_real + canonical_real=$(cd "${real_root}" && pwd -P) + + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + run _resolve_and_report "{\"project_root\":\"${clean_link}\"}" + assert_failure + assert_output --partial "\"${canonical_real}\"" + assert_output --partial 'the character \; it carries' +} + +# --- Environment re-derivation from the worktree's own configuration --- + +@test "a worktree whose own configuration declares another environment runs under it" { + printf '{"environment":"docker","docker":{"workdir":"/srv/wt","container":"app"}}\n' \ + > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + _write_launch_environment native + LINT_ENV="native" + stub_worktree_probe pass + + run _resolve_and_report_binding "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + assert_output "docker|/srv/wt/.claude/worktrees/in-root|/srv/wt/.claude/worktrees/in-root" +} + +@test "a worktree whose own configuration declares native binds the host root" { + printf '{"environment":"native"}\n' > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + _write_launch_environment docker + LINT_ENV="docker" + + run _resolve_and_report_binding "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + assert_output "native|${WORKTREE_IN_ROOT}|" +} + +@test "a worktree configuration whose docker arm names no container is refused, naming the file and the field" { + printf '{"environment":"docker","docker":{"workdir":"/srv/wt"}}\n' \ + > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + _write_launch_environment native + LINT_ENV="native" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + assert_output --partial ".docker.container" +} + +@test "a worktree configuration declaring no environment is refused, naming the file and the field" { + printf '{"default_scope":"shopware"}\n' > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + _write_launch_environment native + LINT_ENV="native" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + assert_output --partial "\"environment\" field" +} + +# --- The existence probe --- + +@test "the probe refuses a call whose mapped root the environment does not reach" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe fail + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "does not reach \"/srv/app/.claude/worktrees/in-root\"" + assert_output --partial "the \"docker\" environment" +} + +@test "the probe is not run for a native call, whose mapped root is the host root" { + # The suite-wide guard from setup records every probe attempt, so the + # assertion is the shared helper rather than a hand-rolled recording stub. + _write_launch_environment native + LINT_ENV="native" + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + if worktree_test_probe_was_attempted; then + fail "the probe was attempted for a native call" + fi +} + +@test "a later call for the same environment and root reads the recorded probe instead of probing again" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + run cat "${WORKTREE_TEST_PROBE_LOG}" + assert_success + assert_output --partial "test -d" + assert_equal "$(wc -l < "${WORKTREE_TEST_PROBE_LOG}" | tr -d ' ')" "1" +} + +@test "an edited mapped path probes again instead of reusing the pass recorded for the old mapping" { + # The worktree's own config is re-read per call, so its workdir can change + # between two calls while the (environment, root) pair stays identical. A + # cache keyed on that pair alone answers for a path nothing ever probed. + _write_launch_environment native + LINT_ENV="native" + printf '{"environment":"docker","docker":{"workdir":"/srv/a","container":"c"}}\n' > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + stub_worktree_probe pass + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + printf '{"environment":"docker","docker":{"workdir":"/srv/b","container":"c"}}\n' > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + run cat "${WORKTREE_TEST_PROBE_LOG}" + assert_success + assert_line --index 0 --partial "/srv/a/.claude/worktrees/in-root" + assert_line --index 1 --partial "/srv/b/.claude/worktrees/in-root" +} + +@test "a failing probe is not recorded, so the next call probes again" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe fail + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + + run jq -r '.probe_cache // "absent"' "${DEV_TOOLING_STATE_FILE}" + assert_success + assert_output "absent" +} + +# --- The path guard's boundary is host-side under a container environment --- + +@test "the path guard measures host paths against the host root under a container environment" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + if ! worktree_resolve_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}"; then + fail "worktree_resolve_root did not resolve the in-root worktree" + fi + if ! resolve_scope ""; then + fail "resolve_scope did not resolve the unscoped call" + fi + + run worktree_assert_paths_within_root "[\"${WORKTREE_IN_ROOT}/src/File.php\"]" + assert_success + assert_output "" +} + +@test "the path guard refuses a host path outside the worktree under a container environment" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + if ! worktree_resolve_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}"; then + fail "worktree_resolve_root did not resolve the in-root worktree" + fi + if ! resolve_scope ""; then + fail "resolve_scope did not resolve the unscoped call" + fi + + run worktree_assert_paths_within_root "[\"${WORKTREE_A}/src/File.php\"]" + assert_failure + assert_output --partial "resolve outside" + # The boundary named is the host root, not the container path the command + # runs in, and the message states the mapping between the two. + assert_output --partial "${WORKTREE_IN_ROOT}" +} + +# --- set_project_root validates what it commits --- +# +# The sticky value outlives the call that writes it, so a set that succeeds and +# then refuses on every later call is the one failure a session cannot see: the +# tool reported success and the next call names a reason the set never showed. +# Every check a per-call root goes through therefore runs here too. + +@test "set_project_root refuses a root every later call would refuse, naming the same reason" { + printf '{"environment":"docker","docker":{"workdir":"/srv/app"}}\n' \ + > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + _write_launch_environment native + LINT_ENV="native" + + # The per-call refusal, captured first so the set can be asserted to give + # the same one rather than merely to fail. + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial ".docker.container" + + run tool_set_project_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial ".docker.container" + + run _worktree_state_read_sticky + assert_success + assert_output "" +} + +@test "set_project_root refuses a root its environment cannot reach, naming the mapped path" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe fail + + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "does not reach \"/srv/app/.claude/worktrees/in-root\"" + + run tool_set_project_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "does not reach \"/srv/app/.claude/worktrees/in-root\"" + + run _worktree_state_read_sticky + assert_success + assert_output "" +} + +@test "set_project_root records the probe result a later call then reads instead of probing again" { + _write_launch_environment docker + LINT_ENV="docker" + stub_worktree_probe pass + + run tool_set_project_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + # Reading here rather than only at the end is what separates "the set probed + # and cached" from "the set probed nothing and the call that followed probed + # once" — both leave one line behind them. + assert_equal "$(wc -l < "${WORKTREE_TEST_PROBE_LOG}" | tr -d ' ')" "1" + + run worktree_resolve_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + assert_equal "$(wc -l < "${WORKTREE_TEST_PROBE_LOG}" | tr -d ' ')" "1" +} + +# --- tool_cwd reports what a call would actually do --- +# +# This tool is the session's only way to see the resolution state, so a line it +# prints has to be true of a call. Both refusals below run after validation in +# worktree_resolve_root and are absent from validation itself, so a report built +# on validation alone claims a root resolves that no call would accept. + +@test "cwd does not report a root as resolving when its configuration names no environment" { + # The sticky root is set while the worktree's configuration still declares + # an environment: _worktree_validate_root does not read that field, and it + # is the only thing tool_set_project_root runs. The file is rewritten + # afterwards, which is the state a session reaches by editing a worktree's + # config, or by a worktree whose config was already in that shape and was + # never stuck through this tool. + printf '{"environment":"native"}\n' > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + _write_launch_environment native + LINT_ENV="native" + + run tool_set_project_root "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_success + + printf '{"default_scope":"shopware"}\n' > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + + # A call refuses this root, because worktree_resolve_root asks for the + # field the configuration does not carry. The report has to say so rather + # than print a working directory no call would ever use. + run _resolve_and_report "{\"project_root\":\"${WORKTREE_IN_ROOT}\"}" + assert_failure + assert_output --partial "\"environment\" field" + + run tool_cwd '{}' + assert_success + assert_output --partial "Effective project root resolves: no" + assert_output --partial "\"environment\" field" +} + +@test "cwd reports a worktree whose own compose configuration applies its own workdir" { + printf '{"environment":"docker-compose","docker-compose":{"workdir":"/worktree/base"}}\n' \ + > "${WORKTREE_IN_ROOT}/.mcp-php-tooling.json" + printf '{"environment":"docker-compose","docker-compose":{"workdir":"/launch/base"}}\n' \ + > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + LINT_ENV="docker-compose" + # The launch configuration is what the process started with, and the + # worktree's own file is what a call re-derives from — which is the + # distinction the reported working directory has to respect. + WORKTREE_LAUNCH_CONFIG_FILE="${LAUNCH_ROOT}/.mcp-php-tooling.json" + + # The sticky value is written directly rather than through + # tool_set_project_root: that tool now probes, and this environment's probe + # reaches the compose CLI. What this test is about is the line cwd prints, + # so the state it needs is arranged without running a container command. + run _worktree_state_write_sticky "${WORKTREE_IN_ROOT}" + assert_success + + run tool_cwd '{}' + assert_success + assert_output --partial "Effective project root: ${WORKTREE_IN_ROOT} (sticky)" + assert_output --partial "Working directory, no scope applied: /worktree/base" + refute_output --partial "/launch/base" +} + +# --- Banner contents for each source --- + +@test "the banner names the call root and the call source" { + worktree_resolve_root "{\"project_root\":\"${WORKTREE_A}\"}" + + run worktree_root_banner + assert_success + assert_output "Project root: ${WORKTREE_A} (call)" +} + diff --git a/plugin-tests/dev-tooling/worktree_state.bats b/plugin-tests/dev-tooling/worktree_state.bats new file mode 100644 index 00000000..67367d27 --- /dev/null +++ b/plugin-tests/dev-tooling/worktree_state.bats @@ -0,0 +1,203 @@ +#!/usr/bin/env bats +# bats file_tags=dev-tooling,worktree,state +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" + +# A launch checkout plus one real linked worktree of it, created with +# `git worktree add`, both declaring a native environment so tool_set_project_root's +# validation passes. +_make_git_worktree_fixture() { + LAUNCH_ROOT="${BATS_TEST_TMPDIR}/launch" + mkdir -p "${LAUNCH_ROOT}" + git -C "${LAUNCH_ROOT}" init -q + git -C "${LAUNCH_ROOT}" config user.email "test@example.com" + git -C "${LAUNCH_ROOT}" config user.name "Test User" + printf 'seed\n' > "${LAUNCH_ROOT}/seed.txt" + git -C "${LAUNCH_ROOT}" add seed.txt + git -C "${LAUNCH_ROOT}" commit -q -m "seed" + printf '{"environment":"native"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + + WORKTREE_ROOT="${BATS_TEST_TMPDIR}/wt" + git -C "${LAUNCH_ROOT}" worktree add -q "${WORKTREE_ROOT}" -b wt-branch + worktree_gitdir_relative "${WORKTREE_ROOT}" +} + +setup() { + _make_git_worktree_fixture + # A private TMPDIR, so the leak test at the end of this file can compare the + # whole directory rather than a filename pattern. Both the state file and any + # mktemp called without an explicit directory land here, and nothing else + # writes into it. + ORIGINAL_TMPDIR="${TMPDIR:-}" + TMPDIR="${BATS_TEST_TMPDIR}/tmp" + mkdir -p "${TMPDIR}" + export TMPDIR + CONFIG_PREFIX="php-tooling" + LINT_CONFIG_FILE="${LAUNCH_ROOT}/.mcp-php-tooling.json" + log() { :; } + # Captured BEFORE the sources below, because config.sh installs + # `trap config_cleanup EXIT` at source time and that replaces the EXIT trap + # bats emits its result through. Without the restore at the end of this + # function a FAILING test in this file produces no "not ok" line at all — + # only "Executed N instead of expected M" — so a regression reads as a count + # mismatch naming no test. Measured on this file: an assertion that cannot + # hold reported nothing until this was added. + BATS_EXIT_TRAP=$(trap -p EXIT) + source "${PLUGIN_DIR}/shared/config.sh" + source "${PLUGIN_DIR}/shared/environment.sh" + source "${PLUGIN_DIR}/shared/scope.sh" + LINT_ENV="native" + LINT_WORKDIR="${LAUNCH_ROOT}" + PROJECT_ROOT="${LAUNCH_ROOT}" + export PROJECT_ROOT + # shellcheck source=/dev/null + source "${PLUGIN_DIR}/shared/worktree.sh" + worktree_state_init + # Before any test body runs: the real exec_command reaches a container CLI + # under a container environment. A test that means to exercise the probe + # calls stub_worktree_probe to replace this. + worktree_test_guard_probe + eval "${BATS_EXIT_TRAP:-trap - EXIT}" +} + +teardown() { + # The cleanup the displaced trap would have run at exit. + declare -F config_cleanup >/dev/null && config_cleanup + worktree_state_cleanup + TMPDIR="${ORIGINAL_TMPDIR}" + export TMPDIR + unset LINT_ENV LINT_WORKDIR LINT_CONFIG_FILE PROJECT_ROOT DEV_TOOLING_STATE_FILE \ + CONFIG_PREFIX LAUNCH_ROOT WORKTREE_ROOT ORIGINAL_TMPDIR \ + WORKTREE_ENV_WORKDIR WORKTREE_LAUNCH_CONFIG_FILE WORKTREE_SELECTED_CONFIG_FILE \ + WORKTREE_PROBE_STUB_VERDICT +} + +# The state file has two writers — the sticky root and the probe cache — and a +# write that rebuilt the object from nothing would drop the other's key with no +# error anywhere. The chain below is the one that produces both keys in the +# order a session does: set the sticky root, then run a probe, then write the +# sticky key again. +@test "a probe result recorded while a sticky root is set survives the next sticky write" { + local in_root="${LAUNCH_ROOT}/.claude/worktrees/in-root" + mkdir -p "$(dirname "${in_root}")" + git -C "${LAUNCH_ROOT}" worktree add -q "${in_root}" -b wt-in-root-branch + worktree_gitdir_relative "${in_root}" + printf '{"environment":"docker"}\n' > "${LAUNCH_ROOT}/.mcp-php-tooling.json" + LINT_ENV="docker" + stub_worktree_probe pass + + run tool_set_project_root "{\"project_root\":\"${in_root}\"}" + assert_success + + run worktree_resolve_root "{\"project_root\":\"${in_root}\"}" + assert_success + + run jq -r '.probe_cache.docker | keys | length' "${DEV_TOOLING_STATE_FILE}" + assert_success + assert_output "1" + + run tool_set_project_root '{}' + assert_success + + run jq -r '.probe_cache.docker | keys | length' "${DEV_TOOLING_STATE_FILE}" + assert_success + assert_output "1" + run jq -r '.sticky_root // "absent"' "${DEV_TOOLING_STATE_FILE}" + assert_success + assert_output "absent" +} + +@test "a sticky value written by set_project_root is read back by cwd" { + run tool_set_project_root "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + + run tool_cwd '{}' + assert_success + assert_output --partial "Sticky project root: ${WORKTREE_ROOT}" + assert_output --partial "Effective project root: ${WORKTREE_ROOT} (sticky)" +} + +@test "set_project_root with no argument clears the sticky value" { + run tool_set_project_root "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + + run tool_set_project_root '{}' + assert_success + assert_output --partial "Sticky project root cleared" + + run tool_cwd '{}' + assert_success + assert_output --partial "Sticky project root: none" + assert_output --partial "Effective project root: ${LAUNCH_ROOT} (launch)" +} + +@test "set_project_root succeeds when sticky_root names a directory that no longer exists" { + run tool_set_project_root "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + rm -rf "${WORKTREE_ROOT}" + + run tool_set_project_root '{}' + assert_success + assert_output --partial "Sticky project root cleared" +} + +@test "cwd succeeds when sticky_root names a directory that no longer exists" { + run tool_set_project_root "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + rm -rf "${WORKTREE_ROOT}" + + run tool_cwd '{}' + assert_success + assert_output --partial "Effective project root resolves: no" +} + +# Clearing the sticky root is the recovery a session reaches for after +# ExitWorktree, so it is the one write that must not be blocked by the state +# file's own condition. A file removed underneath the process, or one whose +# content nothing can read, leaves nothing to preserve — a write that refused +# here would strand the session in a sticky root it cannot clear. +@test "set_project_root with no argument clears the sticky value when the state file is gone" { + run tool_set_project_root "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + + rm -f "${DEV_TOOLING_STATE_FILE}" + + run tool_set_project_root '{}' + assert_success + assert_output --partial "Sticky project root cleared" + + run jq -r '.sticky_root // "absent"' "${DEV_TOOLING_STATE_FILE}" + assert_success + assert_output "absent" +} + +@test "set_project_root with no argument clears the sticky value when the state file holds no object" { + printf 'not json at all\n' > "${DEV_TOOLING_STATE_FILE}" + + run tool_set_project_root '{}' + assert_success + assert_output --partial "Sticky project root cleared" + + run jq -r '.sticky_root // "absent"' "${DEV_TOOLING_STATE_FILE}" + assert_success + assert_output "absent" +} + +@test "a state write leaves the temporary directory exactly as it found it" { + # The whole private TMPDIR is compared, not a name matching mktemp's + # template. A glob built from the state file's name goes green the moment + # the write puts its temp somewhere else, which is a change that leaves the + # leak in place; the directory listing does not care what the file is called + # or which mktemp form produced it. + local before after + before=$(find "${TMPDIR}" -mindepth 1 -maxdepth 1 | sort) + + run tool_set_project_root "{\"project_root\":\"${WORKTREE_ROOT}\"}" + assert_success + + after=$(find "${TMPDIR}" -mindepth 1 -maxdepth 1 | sort) + assert_equal "${after}" "${before}" +} diff --git a/plugin-tests/mcp-shared/docker_compose.bats b/plugin-tests/mcp-shared/docker_compose.bats index 416a89dc..978c5c25 100644 --- a/plugin-tests/mcp-shared/docker_compose.bats +++ b/plugin-tests/mcp-shared/docker_compose.bats @@ -17,6 +17,12 @@ setup() { COMPOSE_WORKDIR_OVERRIDE="" COMPOSE_FILE_OVERRIDE="" PROJECT_ROOT="${BATS_TEST_TMPDIR}" + # A test here constructs container commands; none of them may run one. This + # is what makes that a property of the suite rather than of each fixture, + # since a stub that falls through with `command docker "$@"` bypasses a + # shell function and would otherwise reach the real binary on any machine + # that has docker installed. + container_cli_refuse_real # environment.sh first: the wrappers quote the resolved container name with # its shell_quote_arg, the same order detect_environment establishes. source "${TEMPLATE_DIR}/environment.sh" @@ -25,7 +31,48 @@ setup() { teardown() { unset COMPOSE_SERVICE COMPOSE_WORKDIR_OVERRIDE COMPOSE_FILE_OVERRIDE PROJECT_ROOT - unset LINT_ENV LINT_WORKDIR DOCKER_CONTAINER + unset LINT_ENV LINT_WORKDIR DOCKER_CONTAINER WORKTREE_EFFECTIVE_ROOT + unset CONTAINER_CLI_LOG COMPOSE_CWD_LOG COMPOSE_ELSEWHERE_DIR +} + +# Drive the compose CLI through a stub that records the working directory each +# invocation actually ran from, and a directory that is not the project root for +# a test to invoke from. +# +# `docker compose` discovers its project from the working directory: the file it +# reads, the project name it derives and the sources its relative binds resolve +# against all come from there. So which directory the CLI ran in is the whole of +# what the anchoring decides, and it is invisible in the string the resolver +# returns — a worktree-targeted call that ran the CLI from the worktree would +# still return a plausible container path. Recording the directory is the +# observation; nothing here executes compose. +# Globals: sets COMPOSE_CWD_LOG and COMPOSE_ELSEWHERE_DIR +_compose_record_invocations() { + COMPOSE_CWD_LOG="${BATS_TEST_TMPDIR}/compose-invocations-cwd" + : > "${COMPOSE_CWD_LOG}" + COMPOSE_ELSEWHERE_DIR="${BATS_TEST_TMPDIR}/elsewhere" + mkdir -p "${COMPOSE_ELSEWHERE_DIR}" + + docker() { + if [[ "$1" == "compose" && "$2" == "ps" ]]; then + printf '%s\n' "${PWD}" >> "${COMPOSE_CWD_LOG}" + printf '{"Service":"web","Name":"shopware-web-1","State":"running"}\n' + return 0 + fi + if [[ "$1" == "compose" && "$2" == "config" ]]; then + printf '%s\n' "${PWD}" >> "${COMPOSE_CWD_LOG}" + printf '{"services":{"web":{"volumes":[{"type":"bind","source":"%s","target":"/var/www/html"}]}}}\n' \ + "${BATS_TEST_TMPDIR}" + return 0 + fi + command docker "$@" + } +} + +# Where the recorded invocations ran. One line per invocation, so a call that +# reaches the CLI more than once asserts every one of them. +_compose_recorded_dirs() { + cat "${COMPOSE_CWD_LOG}" } # --- _compose_cmd --- @@ -113,12 +160,21 @@ teardown() { assert_output "shopware-web-1" } +# The falling back to `config` is the branch under test, so the stub answers it +# explicitly. Leaving it to the fallthrough let the real CLI run here: `config` +# fails on a machine with docker and on one without it, for different reasons +# that happened to produce the same message, so the suite passed while +# asserting nothing about which reason it got. @test "_compose_resolve_container: fails when service not running" { docker() { if [[ "$1" == "compose" && "$2" == "ps" ]]; then echo '{"Service":"database","Name":"shopware-database-1","State":"running"}' return 0 fi + if [[ "$1" == "compose" && "$2" == "config" ]]; then + echo 'no configuration file provided: not found' + return 1 + fi command docker "$@" } export -f docker @@ -126,6 +182,7 @@ teardown() { COMPOSE_FILE_OVERRIDE="" run _compose_resolve_container assert_failure + refute container_cli_was_invoked assert_output --partial "Service 'web' is not running" assert_output --partial "docker compose up -d web" } @@ -172,6 +229,31 @@ teardown() { # --- _compose_resolve_workdir --- +# The resolver answers for the root the call targets, not for the launch tree: +# answering for the launch tree is what makes a worktree call cd into the launch +# tree's container path and run there under the worktree's name. +@test "_compose_resolve_workdir: resolves the targeted root rather than the launch root" { + docker() { + if [[ "$1" == "compose" && "$2" == "config" ]]; then + cat </dev/null || rc=$? + + assert_equal "${rc}" 0 + assert_equal "${LINT_WORKDIR}" "(resolved at call time)" +} + @test "_compose_wrap_npm_command: propagates prerequisite failure" { _compose_check_prerequisites() { echo "Docker daemon is not running. Start Docker Desktop or the Docker service." diff --git a/plugin-tests/mcp-shared/environment.bats b/plugin-tests/mcp-shared/environment.bats index d36a84f4..0be42432 100644 --- a/plugin-tests/mcp-shared/environment.bats +++ b/plugin-tests/mcp-shared/environment.bats @@ -16,6 +16,8 @@ setup() { teardown() { unset LINT_ENV LINT_WORKDIR DOCKER_CONTAINER + unset PROJECT_ROOT WORKTREE_EFFECTIVE_ROOT LINT_CONFIG_FILE + unset COMPOSE_SERVICE COMPOSE_WORKDIR_OVERRIDE COMPOSE_FILE_OVERRIDE } # --- Native environment --- @@ -539,3 +541,397 @@ _probe_against_tmpdir() { assert_success assert_output "" } + +# --- resolve_env_workdir --- + +# The environments whose working directory is a configured base plus the host +# path's suffix below the project root. They differ only in the config key +# carrying that base, so every case below runs against all three rather than +# being copied per environment. +_CONFIGURED_WORKDIR_ENVS=(docker vagrant ddev) + +# Write the config fixture holding a distinct workdir per environment above, +# and point LINT_CONFIG_FILE at it. +_write_workdir_config() { + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/workdirs.json" + printf '%s\n' \ + '{"docker":{"workdir":"/docker/base","container":"shopware_app"},"vagrant":{"workdir":"/vagrant/base"},"ddev":{"workdir":"/ddev/base"}}' \ + > "${LINT_CONFIG_FILE}" +} + +# The base _write_workdir_config gives one environment. +_config_fixture_base() { + printf '%s\n' "/$1/base" +} + +# Assert resolve_env_workdir for one host root under every environment whose +# base comes from the config. Both sides are built from the fixture base, so a +# change to the expected shape is one edit here. +# Args: $1 = host root relative to PROJECT_ROOT ("" for PROJECT_ROOT itself) +# $2 = spelling appended to PROJECT_ROOT ("" plain, "/" trailing slash) +# $3 = expected suffix below the base ("" for the base itself) +_assert_configured_mapping() { + local host_rel="$1" + local root_spelling="$2" + local expected_rel="$3" + + local env base host_root expected + for env in "${_CONFIGURED_WORKDIR_ENVS[@]}"; do + LINT_ENV="${env}" + PROJECT_ROOT="${BATS_TEST_TMPDIR}${root_spelling}" + base="$(_config_fixture_base "${env}")" + + host_root="${PROJECT_ROOT}" + if [[ -n "${host_rel}" ]]; then + host_root="${host_root%/}/${host_rel}" + fi + + expected="${base}" + if [[ -n "${expected_rel}" ]]; then + expected="${base}/${expected_rel}" + fi + + run resolve_env_workdir "${host_root}" + assert_success + assert_output "${expected}" + done +} + +# Assert that a host root no environment-side path exists for is refused, in +# every environment whose base comes from the config. +# Args: $1 = host root, $2 = PROJECT_ROOT +_assert_configured_mapping_refuses() { + local host_root="$1" + local project_root="$2" + + local env + for env in "${_CONFIGURED_WORKDIR_ENVS[@]}"; do + LINT_ENV="${env}" + PROJECT_ROOT="${project_root}" + + run resolve_env_workdir "${host_root}" + assert_failure 1 + assert_output --partial "${host_root}" + assert_output --partial "not inside the project root" + done +} + +@test "resolve_env_workdir: the launch root maps to the configured workdir" { + _write_workdir_config + _assert_configured_mapping "" "" "" +} + +@test "resolve_env_workdir: a root below the launch root keeps its suffix below the configured workdir" { + _write_workdir_config + _assert_configured_mapping "custom/plugins/X" "" "custom/plugins/X" +} + +@test "resolve_env_workdir: a trailing slash on the host root is not an extra path segment" { + _write_workdir_config + _assert_configured_mapping "custom/plugins/X/" "" "custom/plugins/X" +} + +@test "resolve_env_workdir: a trailing slash on the project root is not an extra path segment" { + _write_workdir_config + _assert_configured_mapping "custom/plugins/X" "/" "custom/plugins/X" +} + +@test "resolve_env_workdir: refuses a root outside the project root, naming it" { + _write_workdir_config + _assert_configured_mapping_refuses "${BATS_TEST_TMPDIR}/elsewhere" "${BATS_TEST_TMPDIR}/project" +} + +@test "resolve_env_workdir: refuses a sibling whose path merely begins with the project root" { + _write_workdir_config + _assert_configured_mapping_refuses "${BATS_TEST_TMPDIR}/projectile" "${BATS_TEST_TMPDIR}/project" +} + +# The launch-side base is an argument, not always PROJECT_ROOT. A caller whose +# root has already been resolved — the worktree module canonicalizes the call's +# root and the launch root together, so containment and the mapping measure the +# same two paths — hands that resolved launch root in, and the suffix has to be +# measured against it. Measured against the raw PROJECT_ROOT instead, the +# resolved root reads as outside the launch root and a root that exists is +# refused. +@test "resolve_env_workdir: measures the suffix against the launch base it is given" { + _write_workdir_config + LINT_ENV="docker" + # Two spellings of one directory, as the two sides reach this function: + # PROJECT_ROOT is the raw one the server started with, and the pair handed + # in is what a symlink-resolving caller established. + mkdir -p "${BATS_TEST_TMPDIR}/real/launch/.claude/worktrees/x" + ln -s "${BATS_TEST_TMPDIR}/real/launch" "${BATS_TEST_TMPDIR}/launch-link" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/launch-link" + local resolved_root resolved_launch + resolved_root=$(cd "${PROJECT_ROOT}/.claude/worktrees/x" && pwd -P) + resolved_launch=$(cd "${PROJECT_ROOT}" && pwd -P) + + run resolve_env_workdir "${resolved_root}" "${resolved_launch}" + assert_success + assert_output "$(_config_fixture_base docker)/.claude/worktrees/x" +} + +@test "resolve_env_workdir: an unset workdir falls back to the environment's documented default" { + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/defaults.json" + printf '%s\n' '{"environment":"docker"}' > "${LINT_CONFIG_FILE}" + + local env base + for env in "${_CONFIGURED_WORKDIR_ENVS[@]}"; do + LINT_ENV="${env}" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + case "${env}" in + vagrant) base="/vagrant" ;; + *) base="/var/www/html" ;; + esac + + run resolve_env_workdir "${PROJECT_ROOT}/custom/plugins/X" + assert_success + assert_output "${base}/custom/plugins/X" + done +} + +@test "resolve_env_workdir: native returns the host path unchanged" { + LINT_ENV="native" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + run resolve_env_workdir "${PROJECT_ROOT}/custom/plugins/X" + assert_success + assert_output "${PROJECT_ROOT}/custom/plugins/X" +} + +# native builds no environment-side path: its wrapper runs the command against +# the host path it is given, so nothing is mapped and nothing is refused. +@test "resolve_env_workdir: native returns a path outside the project root unchanged" { + LINT_ENV="native" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + run resolve_env_workdir "${BATS_TEST_TMPDIR}/elsewhere" + assert_success + assert_output "${BATS_TEST_TMPDIR}/elsewhere" +} + +@test "resolve_env_workdir: refuses a call with no host path" { + LINT_ENV="native" + run resolve_env_workdir "" + assert_failure 1 + assert_output --partial "a host path is required" +} + +# --- _set_workdir_from_config: the re-derivation seam --- + +# The seam is called for a configuration other than the launch one. Its results +# are asserted on the globals, which is where every wrapper reads them, and in +# the order a tool call reaches them: it runs inside the dispatch subshell, so +# what it writes there is what the call runs under. +@test "_set_workdir_from_config: writes the derived scalars into the globals the wrappers read" { + local config="${BATS_TEST_TMPDIR}/worktree.json" + printf '%s\n' '{"environment":"docker","docker":{"container":"worktree-container","workdir":"/wt/html"}}' > "${config}" + + local rc=0 + _set_workdir_from_config "/worktree" "${config}" "docker" || rc=$? + + assert_equal "${rc}" 0 + assert_equal "${LINT_WORKDIR}" "/wt/html" + assert_equal "${DOCKER_CONTAINER}" "worktree-container" +} + +@test "_set_workdir_from_config: leaves the configuration it did not read alone" { + LINT_ENV="native" + LINT_WORKDIR="/launch/root" + DOCKER_CONTAINER="launch-container" + COMPOSE_SERVICE="launch-service" + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/launch.json" + printf '%s\n' '{"environment":"native"}' > "${LINT_CONFIG_FILE}" + + local config="${BATS_TEST_TMPDIR}/worktree.json" + printf '%s\n' '{"environment":"docker","docker":{"container":"worktree-container","workdir":"/wt/html"}}' > "${config}" + + local rc=0 + _set_workdir_from_config "/worktree" "${config}" "docker" || rc=$? + + assert_equal "${rc}" 0 + # LINT_ENV and LINT_CONFIG_FILE are not this function's to write: it derives + # the scalars of the file it was handed, and naming which environment those + # belong to is the caller's decision. + assert_equal "${LINT_ENV}" "native" + assert_equal "${LINT_CONFIG_FILE}" "${BATS_TEST_TMPDIR}/launch.json" +} + +# Every scalar is assigned on every path, so a caller that re-derives never +# reads a value left over from the environment it is leaving. +@test "_set_workdir_from_config: clears the scalars the derived environment does not use" { + DOCKER_CONTAINER="launch-container" + COMPOSE_SERVICE="launch-service" + COMPOSE_WORKDIR_OVERRIDE="/launch/compose" + + local config="${BATS_TEST_TMPDIR}/worktree.json" + printf '%s\n' '{"environment":"native"}' > "${config}" + + local rc=0 + _set_workdir_from_config "/worktree" "${config}" "native" || rc=$? + + assert_equal "${rc}" 0 + assert_equal "${LINT_WORKDIR}" "/worktree" + assert_equal "${DOCKER_CONTAINER}" "" + assert_equal "${COMPOSE_SERVICE}" "" + assert_equal "${COMPOSE_WORKDIR_OVERRIDE}" "" + assert_equal "${COMPOSE_FILE_OVERRIDE}" "" +} + +@test "_set_workdir_from_config: reads each configured-workdir environment's base from the given config" { + _write_workdir_config + + local env rc=0 + for env in "${_CONFIGURED_WORKDIR_ENVS[@]}"; do + rc=0 + _set_workdir_from_config "/worktree" "${LINT_CONFIG_FILE}" "${env}" || rc=$? + assert_equal "${rc}" 0 + assert_equal "${LINT_WORKDIR}" "$(_config_fixture_base "${env}")" + done +} + +@test "_set_workdir_from_config: refuses a docker config with no container, naming the config path and the field" { + local config="${BATS_TEST_TMPDIR}/worktree.json" + printf '%s\n' '{"environment":"docker"}' > "${config}" + + run _set_workdir_from_config "/worktree" "${config}" "docker" + assert_failure 1 + assert_output --partial "${config}" + assert_output --partial ".docker.container" +} + +# The refusal has to reach the caller rather than the start of the process: the +# seam is called for a second configuration by a tool call, and an exit there +# would take the server down with no message a caller can read. +@test "_set_workdir_from_config: returns to its caller after refusing a docker config with no container" { + local config="${BATS_TEST_TMPDIR}/worktree.json" + printf '%s\n' '{"environment":"docker"}' > "${config}" + + local rc=0 + _set_workdir_from_config "/worktree" "${config}" "docker" 2>/dev/null || rc=$? + + assert_equal "${rc}" 1 +} + +@test "detect_environment: refuses a docker config with no container" { + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/launch.json" + printf '%s\n' '{"environment":"docker"}' > "${LINT_CONFIG_FILE}" + + run detect_environment "/project" + assert_failure 1 +} + +# A workdir of "/" is the container root. The join is where the doubled +# separator would appear, and "//x" is not the spelling any other component +# produced. +@test "resolve_env_workdir: a workdir of the container root does not double the separator" { + LINT_ENV="ddev" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/root-workdir.json" + printf '%s\n' '{"environment":"ddev","ddev":{"workdir":"/"}}' > "${LINT_CONFIG_FILE}" + + run resolve_env_workdir "${PROJECT_ROOT}/custom/plugins/X" + assert_success + assert_output "/custom/plugins/X" +} + +@test "_set_workdir_from_config: refuses a config file that does not exist, naming it" { + local absent="${BATS_TEST_TMPDIR}/absent.json" + run _set_workdir_from_config "/worktree" "${absent}" "docker" + assert_failure 1 + assert_output --partial "${absent}" +} + +@test "_set_workdir_from_config: refuses an empty environment, naming the field" { + local config="${BATS_TEST_TMPDIR}/worktree.json" + printf '%s\n' '{"docker":{"container":"worktree-container"}}' > "${config}" + + run _set_workdir_from_config "/worktree" "${config}" "" + assert_failure 1 + assert_output --partial '"environment"' + assert_output --partial "${config}" +} + +# --- SHELL_HOSTILE_METACHARS: one source, two readers --- + +# The set exists as a variable so the guard below and the worktree module's +# root charset check cannot drift into different answers for one value. The test +# reads it rather than restating it, so a widened set is covered here without an +# edit — the property the variable is for. +@test "SHELL_HOSTILE_METACHARS: every character of the set is refused by the ddev guard" { + LINT_ENV="ddev" + + local char + for (( char = 0; char < ${#SHELL_HOSTILE_METACHARS}; char++ )); do + local value="/srv/a${SHELL_HOSTILE_METACHARS:char:1}b" + run assert_no_shell_hostile_chars "path" "${value}" + assert_failure 1 + assert_output --partial "contains a shell metacharacter" + done +} + +@test "detect_environment: still binds the launch workdir from the launch config" { + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/launch.json" + printf '%s\n' '{"environment":"docker","docker":{"container":"shopware_app","workdir":"/var/www/html"}}' > "${LINT_CONFIG_FILE}" + + detect_environment "/project" + + assert_equal "${LINT_ENV}" "docker" + assert_equal "${LINT_WORKDIR}" "/var/www/html" + assert_equal "${DOCKER_CONTAINER}" "shopware_app" +} + +@test "detect_environment: native still binds the project root as the workdir" { + LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/launch.json" + printf '%s\n' '{"environment":"native"}' > "${LINT_CONFIG_FILE}" + + detect_environment "/project" + + assert_equal "${LINT_ENV}" "native" + assert_equal "${LINT_WORKDIR}" "/project" +} + +# --- ddev: the resolved workdir is named only for a call against a worktree --- + +@test "wrap_command ddev: an unscoped command at the launch root keeps its argv" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}" + run wrap_command "vendor/bin/phpunit" + assert_success + assert_output "ddev exec vendor/bin/phpunit" +} + +@test "wrap_command ddev: an unscoped command against a worktree names the resolved workdir" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html/.claude/worktrees/x" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}/.claude/worktrees/x" + run wrap_command "vendor/bin/phpunit" + assert_success + assert_output 'ddev exec -d "/var/www/html/.claude/worktrees/x" vendor/bin/phpunit' +} + +# The composer shortcut belongs to the launch root and nowhere else: `ddev +# composer` re-execs composer at ddev's own project root and takes no -d, so a +# worktree call routed through it would run against the launch tree while the +# banner attributes the result to the worktree. Both sides are asserted, and +# the launch-root side pins the argv byte for byte. +@test "wrap_command ddev: an unscoped composer call against a worktree names the resolved workdir" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html/.claude/worktrees/x" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}/.claude/worktrees/x" + run wrap_command "composer install" + assert_success + assert_output 'ddev exec -d "/var/www/html/.claude/worktrees/x" composer install' +} + +@test "wrap_command ddev: an unscoped composer call at the launch root keeps the shortcut" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}" + run wrap_command "composer install" + assert_success + assert_output "ddev composer install" +} diff --git a/plugin-tests/mcp-shared/scope_wrap.bats b/plugin-tests/mcp-shared/scope_wrap.bats index 74f0adeb..a6a4af97 100644 --- a/plugin-tests/mcp-shared/scope_wrap.bats +++ b/plugin-tests/mcp-shared/scope_wrap.bats @@ -17,7 +17,24 @@ setup() { } teardown() { - unset LINT_ENV LINT_WORKDIR DOCKER_CONTAINER SCOPE_CWD LINT_CONFIG_FILE + unset LINT_ENV LINT_WORKDIR DOCKER_CONTAINER SCOPE_CWD LINT_CONFIG_FILE \ + JS_CONTEXT SCOPE_JS_SUBDIR PROJECT_ROOT WORKTREE_EFFECTIVE_ROOT +} + +# A directory whose name carries four of the characters the worktree module used +# to refuse in a project root: a space, a "$", a ";" and a single quote. They are +# the four that break in the widest range of ways — word splitting, parameter +# expansion, command separation and quote termination. The native wrappers no +# longer put the working directory into the command string, so it reaches the +# shell only as a quoted argument to cd and none of these is special any more. +# Arguments: +# $1 - the leaf directory name to create under BATS_TEST_TMPDIR +# Outputs: +# The absolute path of the created directory on stdout +_make_hostile_dir() { + local dir="${BATS_TEST_TMPDIR}/$1" + mkdir -p -- "${dir}" + printf '%s\n' "${dir}" } @test "wrap_command native: no scope -> passthrough" { @@ -29,13 +46,13 @@ teardown() { assert_output "vendor/bin/phpstan analyze" } -@test "wrap_command native: with scope -> explicit cd" { +@test "wrap_command native: with scope -> passthrough, no directory in the command" { LINT_ENV="native" LINT_WORKDIR="/project" SCOPE_CWD="custom/plugins/X" run wrap_command "vendor/bin/phpstan analyze" assert_success - assert_output 'cd "/project/custom/plugins/X" && vendor/bin/phpstan analyze' + assert_output "vendor/bin/phpstan analyze" } @test "wrap_command docker: scope cwd appends to container workdir" { @@ -123,7 +140,7 @@ teardown() { assert_output "/project/custom/plugins/X/tests/jest/administration" } -@test "wrap_npm_command native: scoped jest path" { +@test "wrap_npm_command native: scoped jest path stays out of the command" { LINT_ENV="native" LINT_WORKDIR="/project" JS_CONTEXT="admin" @@ -131,7 +148,74 @@ teardown() { SCOPE_JS_SUBDIR="tests/jest/administration" run wrap_npm_command "npm run unit" assert_success - assert_output "cd /project/custom/plugins/X/tests/jest/administration && npm run unit" + assert_output "npm run unit" +} + +@test "exec_command native: runs in an unscoped workdir holding a space, a dollar, a semicolon and a quote" { + LINT_ENV="native" + LINT_WORKDIR=$(_make_hostile_dir "a b\$c;d'e") + SCOPE_CWD="" + run exec_command "pwd" + assert_success + assert_output "${LINT_WORKDIR}" +} + +@test "exec_command native: runs in a scoped workdir holding a space, a dollar, a semicolon and a quote" { + LINT_ENV="native" + LINT_WORKDIR=$(_make_hostile_dir "a b\$c;d'e") + SCOPE_CWD="p q\$r;s't" + mkdir -p -- "${LINT_WORKDIR}/${SCOPE_CWD}" + run exec_command "pwd" + assert_success + assert_output "${LINT_WORKDIR}/${SCOPE_CWD}" +} + +@test "exec_npm_command native: runs in a JS workdir holding a space, a dollar, a semicolon and a quote" { + LINT_ENV="native" + LINT_WORKDIR=$(_make_hostile_dir "a b\$c;d'e") + JS_CONTEXT="" + SCOPE_CWD="" + SCOPE_JS_SUBDIR="" + run exec_npm_command "pwd" + assert_success + assert_output "${LINT_WORKDIR}" +} + +# detect_environment assigns whatever ".environment" holds into LINT_ENV with no +# allowlist, so a typo reaches the wrappers' unknown-environment branch and the +# command it emits runs locally. The working directory has to be entered there +# too: leaving it to a `cd` inside the emitted string would put a "$" or a +# backtick from the project root into text that exec_npm_command evals. +@test "exec_npm_command: an unknown environment still runs in the workdir, not in the process directory" { + LINT_ENV="podman" + LINT_WORKDIR=$(_make_hostile_dir "a b\$c;d'e") + JS_CONTEXT="" + SCOPE_CWD="" + SCOPE_JS_SUBDIR="" + run exec_npm_command "pwd" + assert_success + assert_output "${LINT_WORKDIR}" +} + +@test "wrap_npm_command: an unknown environment emits no working directory in the command" { + LINT_ENV="podman" + LINT_WORKDIR="/project" + JS_CONTEXT="" + SCOPE_CWD="" + SCOPE_JS_SUBDIR="" + run wrap_npm_command "npm run lint" + assert_success + assert_output "npm run lint" +} + +@test "exec_command native: refuses a workdir that cannot be entered, naming it" { + LINT_ENV="native" + LINT_WORKDIR="${BATS_TEST_TMPDIR}/absent" + SCOPE_CWD="" + run exec_command "pwd" + assert_failure + assert_output --partial "could not be entered" + assert_output --partial "${BATS_TEST_TMPDIR}/absent" } @test "wrap_npm_command ddev: scoped path uses cd && ddev npm" { @@ -144,3 +228,45 @@ teardown() { assert_success assert_output "cd /var/www/html/custom/plugins/X && ddev npm run lint" } + +# A call that targets a worktree runs against a directory ddev does not know as +# its project root, so the JS working directory has to reach the container; -d +# is how ddev's exec takes one. +@test "wrap_npm_command ddev: a call against a worktree names the JS working directory" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html/.claude/worktrees/x" + JS_CONTEXT="storefront" + SCOPE_CWD="" + SCOPE_JS_SUBDIR="" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}/.claude/worktrees/x" + run wrap_npm_command "npm run lint" + assert_success + assert_output 'ddev exec -d "/var/www/html/.claude/worktrees/x/src/Storefront/Resources/app/storefront" npm run lint' +} + +@test "wrap_npm_command ddev: a worktree call carries the scope suffix in the named workdir" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html/.claude/worktrees/x" + JS_CONTEXT="storefront" + SCOPE_CWD="custom/plugins/X" + SCOPE_JS_SUBDIR="tests/jest/storefront" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}/.claude/worktrees/x" + run wrap_npm_command "npm run lint" + assert_success + assert_output 'ddev exec -d "/var/www/html/.claude/worktrees/x/custom/plugins/X/tests/jest/storefront" npm run lint' +} + +@test "wrap_npm_command ddev: a launch-root call keeps the ddev npm shortcut" { + LINT_ENV="ddev" + LINT_WORKDIR="/var/www/html" + JS_CONTEXT="storefront" + SCOPE_CWD="" + SCOPE_JS_SUBDIR="" + PROJECT_ROOT="${BATS_TEST_TMPDIR}/project" + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}" + run wrap_npm_command "npm run lint" + assert_success + assert_output "cd /var/www/html/src/Storefront/Resources/app/storefront && ddev npm run lint" +} diff --git a/plugin-tests/test_helper/common_setup.bash b/plugin-tests/test_helper/common_setup.bash index 1bc339ed..a191d286 100644 --- a/plugin-tests/test_helper/common_setup.bash +++ b/plugin-tests/test_helper/common_setup.bash @@ -40,3 +40,52 @@ assert_hook_blocks() { assert_failure 2 assert_output --partial "$suggestion" } + +# Put a refusing stand-in for every container CLI ahead of the real ones on +# PATH. No suite may execute docker, docker-compose, vagrant or ddev — a +# container command is only ever constructed as a string and inspected — so a +# call that reaches one is a defect in the fixture, and this makes it loud +# rather than dependent on whether the binary happens to be installed. +# +# PATH is the level that matters, not a shell function: a stub that falls +# through with `command docker "$@"` bypasses a function by design, and a +# machine with docker installed then runs the real CLI while a machine without +# it sees a command-not-found that the wrapper swallows — the suite passes in +# both cases and asserts nothing about either. Under this, both are the same +# loud failure naming the invocation. +# +# A test that means to drive a container CLI installs its own stub in front — +# a shell function answers before PATH is consulted, which leaves the +# deliberate `command docker "$@"` fallthroughs working as written. +# Globals: sets CONTAINER_CLI_LOG to a file recording every rejected invocation +container_cli_refuse_real() { + local bin_dir="${BATS_TEST_TMPDIR}/container-cli-refused" + mkdir -p "${bin_dir}" + + CONTAINER_CLI_LOG="${BATS_TEST_TMPDIR}/container-cli-invocations" + : > "${CONTAINER_CLI_LOG}" + export CONTAINER_CLI_LOG + + local binary + for binary in docker docker-compose vagrant ddev; do + cat > "${bin_dir}/${binary}" <<'REFUSED' +#!/usr/bin/env bash +printf '%s %s\n' "$(basename "$0")" "$*" >> "${CONTAINER_CLI_LOG}" +printf 'Refusing to run: this suite constructed a container command and is not allowed to execute one.\n' >&2 +exit 97 +REFUSED + chmod +x "${bin_dir}/${binary}" + done + + PATH="${bin_dir}:${PATH}" + export PATH +} + +# True when a container CLI was reached since container_cli_refuse_real ran. +# A suite that drives a container path asserts this where the path must not +# execute anything, which turns "the command string looks right" into "and no +# binary was run to produce it". +# Returns: 0 when something was invoked, 1 when nothing was +container_cli_was_invoked() { + [[ -s "${CONTAINER_CLI_LOG:-}" ]] +} diff --git a/plugins/dev-tooling/.claude-plugin/plugin.json b/plugins/dev-tooling/.claude-plugin/plugin.json index 93bfb180..26054066 100644 --- a/plugins/dev-tooling/.claude-plugin/plugin.json +++ b/plugins/dev-tooling/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dev-tooling", - "version": "3.20.0", + "version": "4.0.0", "description": "Three MCP servers for PHP and JavaScript operations plus an optional PHP language server. PHP (php-tooling): PHPStan, ECS, Rector, PHPUnit, PHPUnit coverage gap analysis, Symfony Console. Administration (js-admin-tooling): ESLint, Stylelint, Prettier, Jest, TypeScript, Vite builds. Storefront (js-storefront-tooling): ESLint, Stylelint, Jest, Webpack builds. LSP (opt-in): phpactor for PHP, routed through a Python URI-rewriting proxy for containerized environments. Includes SessionStart hook that injects MCP tool directives and PreToolUse hooks that enforce MCP tool usage by blocking bash commands. Supports native, Docker, Docker Compose, Vagrant and DDEV environments.", "author": { "name": "Shopware Labs" diff --git a/plugins/dev-tooling/AGENTS.md b/plugins/dev-tooling/AGENTS.md index 208f2f39..af4abe8b 100644 --- a/plugins/dev-tooling/AGENTS.md +++ b/plugins/dev-tooling/AGENTS.md @@ -10,11 +10,11 @@ plugins/dev-tooling/ │ ├── configuration.md # Config files, environments, troubleshooting │ ├── mcp-enforcement.md # Hook enforcement, blocked commands, plugin integration │ ├── lsp.md # LSP setup, phpactor limitations, troubleshooting -│ └── reference.md # Full tool parameter docs and examples (30 tools across 3 servers) +│ └── reference.md # Full tool parameter docs and examples ├── AGENTS.md # LLM navigation guide (this file) ├── CLAUDE.md # Points to AGENTS.md ├── CHANGELOG.md # Version history -├── LICENSE # MIT license +├── .claude-plugin/ # Plugin manifest (plugin.json: name, version, metadata) ├── .mcp.json # MCP server registration (php-tooling, js-admin-tooling, js-storefront-tooling) ├── .lsp.json # LSP server configuration (phpactor PHP LSP) │ @@ -34,13 +34,16 @@ plugins/dev-tooling/ │ ├── check-js-admin-tools.sh # Blocks Administration npm/npx commands (ESLint, Stylelint, Prettier, Jest, TSC, Vite) │ ├── check-js-storefront-tools.sh # Blocks Storefront npm/npx/composer commands (ESLint, Stylelint, Jest, Vitest, ludtwig, Webpack) │ ├── check-phpstan-baseline.sh # PostToolUse hook: warns when analyzed paths appear in phpstan-baseline.neon +│ ├── worktree-directives.sh # PostToolUse hook on EnterWorktree|ExitWorktree: set_project_root reminder; on enter also the repair command for an absolute gitdir pointer and the worktree.baseRef warning │ └── lib/ │ └── common.sh # Shared: parse_hook_input(), load_mcp_config(), block_tool() │ ├── shared/ # SHARED FRAMEWORK (language-agnostic) │ ├── mcpserver_core.sh # JSON-RPC 2.0 protocol handler + validate_tool_arguments() │ ├── config.sh # Config discovery & merging (parameterized via CONFIG_PREFIX) -│ ├── environment.sh # Environment detection, PHP & JS command wrapping, argument quoting, path guards, noise filtering +│ ├── environment.sh # Environment detection, PHP & JS command wrapping, argument quoting, path guards, environment-side path mapping, noise filtering +│ ├── worktree.sh # Per-call root resolution and worktree validation: worktree_enter(), set_project_root, cwd (dev-tooling-owned, not templated) +│ ├── server_run.sh # Shared server tail: run_mcp_server() call and why it is not isolated in a subshell (dev-tooling-owned, not templated) │ ├── scope.sh # Scope resolution: resolve_scope(), scope_get_tool_field() │ ├── docker-compose.sh # Docker Compose environment: call-time resolution of container/workdir │ ├── lsp_bootstrap.sh # LSP entry point: picks phpactor or the null stub from .lsp-php-tooling.json @@ -64,7 +67,8 @@ plugins/dev-tooling/ │ ├── phpunit.sh # tool_phpunit_run() │ ├── phpunit_coverage.sh # tool_phpunit_coverage_gaps() │ ├── rector.sh # tool_rector_check(), tool_rector_fix() -│ └── console.sh # tool_console_run(), tool_console_list() +│ ├── console.sh # tool_console_run(), tool_console_list() +│ └── prepare.sh # tool_worktree_prepare() — composer install for a fresh worktree │ ├── mcp-server-js-admin/ # ADMIN JS TOOLS MCP SERVER │ ├── server.sh # Entry point - sets CONFIG_PREFIX="js-tooling" (shared) @@ -77,7 +81,8 @@ plugins/dev-tooling/ │ ├── jest.sh # tool_jest_run() │ ├── tsc.sh # tool_tsc_check() │ ├── lint-all.sh # tool_lint_all(), tool_lint_twig(), tool_unit_setup() -│ └── build.sh # tool_vite_build() +│ ├── build.sh # tool_vite_build() +│ └── prepare.sh # tool_worktree_prepare() — npm ci for a fresh worktree │ └── mcp-server-js-storefront/ # STOREFRONT JS TOOLS MCP SERVER ├── server.sh # Entry point - sets CONFIG_PREFIX="js-tooling" (shared) @@ -89,7 +94,8 @@ plugins/dev-tooling/ ├── jest.sh # tool_jest_run() — app/storefront package suite only ├── vitest.sh # tool_vitest_run() — views/components component suite ├── ludtwig.sh # tool_ludtwig_check(), tool_ludtwig_fix() - └── build.sh # tool_webpack_build() + ├── build.sh # tool_webpack_build() + └── prepare.sh # tool_worktree_prepare() — npm ci for a fresh worktree ``` ## 🧱 Component Overview @@ -98,7 +104,7 @@ This plugin provides: - **Three MCP Servers** via `.mcp.json`: - `php-tooling` - PHP linting/testing tools - `js-admin-tooling` - Administration JavaScript tools (Vue 3/Vite) - - `js-storefront-tooling` - Storefront JavaScript tools (vanilla JS/Webpack): `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`, `ludtwig_fix`, `webpack_build` + - `js-storefront-tooling` - Storefront JavaScript tools (vanilla JS/Webpack): `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`, `ludtwig_fix`, `webpack_build`, `worktree_prepare` - **PHP LSP (phpactor, opt-in)** via `.lsp.json`: - Active PHP code discovery: document symbols, hover, go-to-definition, references - Runs natively on the host or inside a container (docker, docker-compose, vagrant, ddev) via the URI-rewriting proxy @@ -106,20 +112,19 @@ This plugin provides: - Requires the `phpactor` binary available where the LSP runs (host or container) - **Subagent** via `agents/`: - `dev-tooling-runner` — executor for dev-tooling checks (and rule-driven fixes); run it to keep verbose output out of the conversation and get back a lean pass/fail report (runs on haiku); see [Agents](#agents) -- **SessionStart Hook** via `hooks/hooks.json`: - - Injects MCP tool directives into conversation context at session start - - Prompt maintained in `hooks/prompts/mcp-tool-directives.md` - - Outputs JSON `additionalContext` format - - Also steers the active session to delegate heavy dev-tool runs to `dev-tooling-runner` +- **SessionStart Hooks** via `hooks/hooks.json`: + - `session-start.sh` injects MCP tool directives into conversation context at session start; prompt maintained in `hooks/prompts/mcp-tool-directives.md`; outputs JSON `additionalContext` format; also steers the active session to delegate heavy dev-tool runs to `dev-tooling-runner` + - `lsp-directives.sh` injects LSP usage directives, only when `.lsp-php-tooling.json` enables a language server - **PreToolUse Hooks** via `hooks/hooks.json`: - Blocks bash commands that should use MCP tools instead - PHP hook: blocks PHPStan, ECS, PHPUnit, Rector, bin/console - Admin JS hook: blocks ESLint, Stylelint, Prettier, Jest, TSC, lint_all/lint_twig, Vite commands - Storefront JS hook: blocks ESLint, Stylelint, Jest, Vitest, ludtwig, Webpack commands -- **PostToolUse Hook** via `hooks/hooks.json`: +- **PostToolUse Hooks** via `hooks/hooks.json`: - `check-phpstan-baseline.sh` warns when a targeted `phpstan_analyze` run covers paths listed in `phpstan-baseline.neon` (or `.php`) - - Ignores `enforce_mcp_tools` and always runs -- The SessionStart and PreToolUse hook types are configurable via `enforce_mcp_tools: false` in config files + - `worktree-directives.sh` fires on `EnterWorktree`/`ExitWorktree` and reminds the session to call `set_project_root` on all three servers; on enter it additionally names the repair command when the worktree's `.git` file carries an absolute gitdir pointer, and warns that a native worktree branches from the default remote branch unless `worktree.baseRef` is `head` + - Both ignore `enforce_mcp_tools` and always run +- `session-start.sh` and every PreToolUse hook are configurable via `enforce_mcp_tools: false` in config files; `lsp-directives.sh` and both PostToolUse hooks read no such flag - **Shared Framework** in `shared/` - reusable across all servers ## 🤖 Agents @@ -130,11 +135,11 @@ This plugin provides: **Scope ownership**: none. It acts only on the targets and checks it is given — no git diffing, file discovery, or blast-radius guessing — and never decides on its own to fix something it was told only to check. Deciding what to check (paths + any affected tests) and whether to apply a fix is the caller's job. -**Bounded mutation, not freeform editing**: the three dev-tooling servers are granted by wildcard, so the rule-driven fixers (`ecs_fix`, `rector_fix`, `eslint_fix`, `stylelint_fix`, `prettier_fix`, `ludtwig_fix`) are available — the agent does not choose *what* changes, the linter ruleset does. It has no `Edit`/`Write`, so it cannot freeform-edit; its only file changes come from those deterministic fixers. `console_run`, `console_list`, and `unit_setup` are subtracted via `disallowedTools` (applied before `tools`, so the wildcard cannot re-add them). No `Bash`/`Glob`/`Grep` — scope discovery is the caller's job; `Read` is the only non-MCP tool, for quoting a flagged line. +**Bounded mutation, not freeform editing**: the three dev-tooling servers are granted by wildcard, so the rule-driven fixers (`ecs_fix`, `rector_fix`, `eslint_fix`, `stylelint_fix`, `prettier_fix`, `ludtwig_fix`) are available — the agent does not choose *what* changes, the linter ruleset does. It has no `Edit`/`Write`, so it cannot freeform-edit; its only file changes come from those deterministic fixers. `console_run`, `console_list`, `unit_setup`, `worktree_prepare` (on all three servers), and `set_project_root` (on all three servers) are subtracted via `disallowedTools` (applied before `tools`, so the wildcard cannot re-add them). `worktree_prepare` is subtracted because it rewrites `vendor/`/`node_modules` — a setup mutation outside the fixer boundary, and one the dependency refusals would otherwise steer the agent into. `set_project_root` is subtracted because its value is sticky and outlives the call that set it — an agent call would otherwise redirect every later tool call in the session that spawned it; the agent still reaches a worktree by passing `project_root` on the individual call. No `Bash`/`Glob`/`Grep` — scope discovery is the caller's job; `Read` is the only non-MCP tool, for quoting a flagged line. -**Model**: Haiku | **Mutation boundary**: enforced via `tools` + `disallowedTools` — no `Edit`/`Write`, no `console_*` / `unit_setup` (`permissionMode` is ignored for plugin subagents) +**Model**: Haiku | **Mutation boundary**: enforced via `tools` + `disallowedTools` — no `Edit`/`Write`, no `console_*` / `unit_setup` / `worktree_prepare` / `set_project_root` (`permissionMode` is ignored for plugin subagents) -**Tools**: `Read`, `mcp__plugin_dev-tooling_php-tooling__*`, `mcp__plugin_dev-tooling_js-admin-tooling__*`, `mcp__plugin_dev-tooling_js-storefront-tooling__*` (`console_run` / `console_list` / `unit_setup` removed via `disallowedTools`) +**Tools**: `Read`, `mcp__plugin_dev-tooling_php-tooling__*`, `mcp__plugin_dev-tooling_js-admin-tooling__*`, `mcp__plugin_dev-tooling_js-storefront-tooling__*` (`console_run` / `console_list` / `unit_setup` / `worktree_prepare` / `set_project_root` removed via `disallowedTools`) ## 🏗️ Architecture @@ -218,7 +223,7 @@ Both handle environment-specific execution (native/docker/docker-compose/vagrant | Add Admin JS tool | `mcp-server-js-admin/lib/.sh` | `mcp-server-js-admin/tools.json` | `tool_*()`, `exec_npm_command()` | | Add Storefront JS tool | `mcp-server-js-storefront/lib/.sh` | `mcp-server-js-storefront/tools.json` | `tool_*()`, `exec_npm_command()` | | Edit SessionStart prompt | `hooks/prompts/mcp-tool-directives.md` | `hooks/scripts/session-start.sh` | Plain markdown, read by script | -| Edit dev-tooling runner agent | `agents/dev-tooling-runner.md` | - | `tools`/`disallowedTools` (no Edit/Write, no console_*/unit_setup), check/fix-kind→tool table, report template | +| Edit dev-tooling runner agent | `agents/dev-tooling-runner.md` | - | `tools`/`disallowedTools` (no Edit/Write, no console_*/unit_setup/worktree_prepare/set_project_root), check/fix-kind→tool table, report template | | Add blocked PHP command | `hooks/scripts/check-php-tools.sh` | - | `block_tool()`, grep pattern | | Add blocked Admin JS command | `hooks/scripts/check-js-admin-tools.sh` | - | `block_tool()`, `is_admin_context()` | | Add blocked Storefront JS command | `hooks/scripts/check-js-storefront-tools.sh` | - | `block_tool()`, `is_storefront_context()` | @@ -302,12 +307,21 @@ This plugin's own suites are in `plugin-tests/dev-tooling/`: | `mcp_tool_phpstan.bats` | PHPStan tool command construction | | `mcp_tool_phpunit.bats` | PHPUnit tool command construction (coverage, config, drivers) | | `mcp_tool_phpunit_coverage.bats` | PHPUnit coverage gap parsing (clover XML, filtering, ranges) | +| `tool_schema.bats` | Every server's `tools.json` refuses an undeclared parameter | | `scope_resolution.bats` | `resolve_scope()` and scope field lookup | | `scope_php_tools.bats` | Scope handling in the PHP MCP tools | | `scope_js_tools.bats` | Scope handling in the JS MCP tools | | `scope_session_start.bats` | Scope surfacing in the SessionStart output | | `lsp_bootstrap.bats` | LSP bootstrap: binary preflight, direct vs proxy dispatch | | `lsp_null.bats` | LSP null stub protocol behavior | +| `worktree_resolution.bats` | Worktree resolution, identity, linkage, charset, probe, config, deps, path guard | +| `worktree_state.bats` | State file: sticky read-back, reset, a removed root, probe cache, atomic writes | +| `worktree_hook.bats` | `worktree-directives.sh`: the `PostToolUse` JSON envelope, enter/exit directive text read out of the decoded `additionalContext`, the `.cwd` fallback, the no-path diagnostic, exit 0 on malformed input | +| `worktree_php_tools.bats` | `phpunit_coverage_gaps`: clover paths relative to the mapped workdir, refused read | +| `worktree_js_tools.bats` | `project_root` reaching the JS package directory, which the conformance scan does not capture | +| `worktree_conformance.bats` | Every enumerated `tool_*` function runs in the named worktree and runs nothing against a refused one, with the enumeration reconciled against `tools.json` | + +Three non-suite entries sit alongside them: `test_helper/common_setup.bash` — the shared helpers `setup_config()`/`setup()`, `setup_php_mcp_env()` (which stubs `log()` and `exec_command()` before sourcing a tool library), the worktree git fixtures `worktree_gitdir_relative()` and `_absolute_path_relative_to()`, and the probe stubs `stub_worktree_probe()` / `worktree_test_guard_probe()`; `fixtures/coverage/` — Clover XML samples for `mcp_tool_phpunit_coverage.bats`; and `lsp_proxy/` — the Python `pytest` suite for `shared/lsp_proxy.py`, run outside BATS. The modules this plugin consumes from `templates/mcp-shared/` are covered once, for every consuming plugin, in `plugin-tests/mcp-shared/`: diff --git a/plugins/dev-tooling/CHANGELOG.md b/plugins/dev-tooling/CHANGELOG.md index 12b300be..ea9ea42d 100644 --- a/plugins/dev-tooling/CHANGELOG.md +++ b/plugins/dev-tooling/CHANGELOG.md @@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2026-09-14 + +### Added +- **Worktree support: a `project_root` parameter on every tool except `cwd`, across all three servers.** A call may target a linked git worktree of the launch root instead of the tree the server started in. The root is validated against git's own worktree registry before the call runs, and the call is refused when the dependencies the tool needs (`vendor/autoload.php`, `node_modules`) are missing under the resolved directory. Effectively needs git 2.48 or newer: resolution needs 2.31 (`git rev-parse --path-format`), and the mandatory relative-linkage rule plus its repair command are 2.48 features — on 2.31–2.47 every stock worktree is refused with a remedy that git cannot run. Below either floor the refusal names the version it needs. +- **Worktree targeting is accepted under all five environments.** A worktree target runs under `native`, `docker`, `docker-compose`, `vagrant` and `ddev` alike; no environment excludes it. Under `docker`, `docker-compose`, `vagrant` and `ddev` the worktree has to sit inside the launch project root, the only tree the container or VM mounts, and its environment-side path is the environment's configured workdir plus its position below that root — under `docker-compose`, a configured `docker-compose.workdir` takes the place of the match, and without one the destination of the longest matching bind mount does. A `ddev` call against a worktree always names the working directory with `ddev exec -d`, where an unscoped launch-root call keeps its existing argv. +- **A worktree whose `.git` file carries an absolute `gitdir` pointer is refused, under `native` included.** An absolute pointer names a host path that does not exist inside a container, and `git worktree add` writes one unless the repository sets `worktree.useRelativePaths` — so on a fresh checkout the refusal is the ordinary case, and it carries the remedy: `git -c worktree.useRelativePaths=true worktree repair `, which needs git 2.48 or newer. +- **An existence probe runs after validation.** The tool checks that the environment reaches the path the worktree maps to, through the same command wrapper the call itself uses, so a directory the environment cannot see — an unsynced Vagrant folder, say — refuses the call and names the mapped path. It runs under the four container environments; under `native` the mapped path is the host path validation already checked. A passing result is cached per environment, root and mapped path for the life of the server process, and a failure is not cached. +- **A worktree's own `.mcp-php-tooling.json` or `.mcp-js-tooling.json` is honored per call.** When the worktree carries one, its scalars and the `environment` field it declares are derived for that call, leaving later calls against the launch root on the environment the server started with. +- **`set_project_root` and `cwd`, on all three servers.** `set_project_root` validates and sticks a project root for every later call on that server process until cleared; `cwd` reports the effective root, its source (`launch` or `sticky`), whether it resolves, the resolved working directory, the environment, and the configuration in use. Neither is subject to worktree resolution itself, so both keep working when a sticky root names a directory that no longer exists. +- **A `PostToolUse` hook on `EnterWorktree`/`ExitWorktree`.** `hooks/scripts/worktree-directives.sh` reminds the session to call `set_project_root` on all three servers after either event, since each server is a separate process holding its own sticky root. On enter it also reads the worktree's `.git` file and, when the pointer is absolute — which `EnterWorktree` writes unless the repository sets `worktree.useRelativePaths` — names the repair command up front, and it warns that a native worktree branches from the default remote branch unless the Claude Code setting `worktree.baseRef` is `head`. +- **`worktree_prepare`, on all three servers.** A fresh worktree holds tracked files only, so `vendor/` and `node_modules` are missing and the composer- and npm-backed tools refuse until they exist. The tool installs the server's own toolchain dependencies in the target root through the configured environment — `composer install --no-interaction` on `php-tooling`, `npm ci` in the package directory on the JS servers — which under a container runs the install inside the container at the mapped path. It skips the dependency check the other tools run, and the dependency refusals now name it as the remedy. A successful install returns a short completion summary instead of the per-package installer output; a failed one returns the full output. Symlinking `vendor/` from the launch tree is not an alternative and the tool description says why: composer's autoloaders resolve through the symlink and load the launch tree's classes. + +### Changed +- **The vendored protocol handler moves to bash-mcp-sdk v5.1.0**, up from v3.0.0. v5.1.0 runs the `EXIT` handler its own traps displace, so a server no longer has to isolate the call to keep its cleanup. +- **Every tool that resolves a project root now carries a leading banner line in its result** — every tool but `set_project_root` and `cwd`, which report their own state instead — naming the effective project root and its source (`Project root: (launch|sticky|call)`), so the tree a result refers to is visible in the transcript. This changes the shape of most tool responses a consumer parses. +- **The native command wrappers no longer embed a `cd` in the emitted command.** The executing function changes into the working directory itself before the command runs, rather than prefixing the command string with `cd &&`. +- **`config.sh`'s cleanup is now the public `config_cleanup`**, renamed from `_config_cleanup` because six callers outside the module reached for the private name: the `EXIT` trap in each of the three servers, and three test teardowns. The `run_mcp_server` call and its rationale move into `shared/server_run.sh`, which each server sources; the `EXIT` trap stays in each `server.sh`, since moving it would run the server loop before the libs are sourced. +- **`get_environment_info` is removed from the shared environment module.** It reported the detected environment and had no callers. + +### Fixed +- **Six tools accepted a `scope` and ignored it.** `lint_all`, `lint_twig`, `unit_setup`, `vite_build`, `webpack_build` and `phpunit_coverage_gaps` declared a `scope` parameter in their server's `tools.json` and never called `resolve_scope`, so a scoped call ran against the project root and said nothing about it. `phpunit_coverage_gaps` was the most visible of the six: a scoped project's Clover report sits under the scope's own cwd, and the tool read the project root. Each now resolves the scope its siblings already did, between `worktree_enter` and the dependency assertion. +- **Three unrelated refusals shared one phrase.** A configuration file that does not parse, tool-call arguments that do not parse, and `set_project_root` arguments that do not parse all reported "not a JSON object", so neither a reader nor a test could tell which one fired. Each reason now carries wording the other two do not. +- **`wrap_command`'s unscoped native branch emitted no `cd` at all.** A command ran in the server process's own launch directory and ignored `PROJECT_ROOT` entirely. +- **A signalled shutdown left the protocol handler's temp files behind.** All three servers ran `run_mcp_server` inside a subshell, so the handler's `INT`/`TERM`/`HUP` traps lived in a process the client never signals: a `SIGTERM` reached the parent, which carried no signal handler of its own, and `mcp-lifeline.*` and `mcp-partial.*` survived every restart. The call is no longer wrapped, which is what the subshell was protecting against and what bash-mcp-sdk v5.1.0 now handles instead. +- **A relative path could send a worktree-targeted call into the launch tree.** `worktree_assert_paths_within_root` measured only the paths beginning with `/`, so `../../../src/Core` was admitted while the absolute spelling of the same directory was refused — and the refusal recommended exactly that relative form. Relative paths carrying a `..` segment are now refused. They are still not resolved, because the Storefront tools accept three spellings of one file and the tree-relative one does not exist under the project root at all. +- **A relative `output_file` resolved against the server process's own directory.** `console_run` built the target from `$PWD`, which on a call against the launch root is wherever the server was started, not the project root its documentation has named since 3.19.0. +- **An unrecognized environment name in a worktree's own configuration ran the command on the host.** The environment value selects which guards apply, and a name outside the known set — a `"docker "` typo, `"podman"` — fell through to the permissive native path, skipping containment, the charset rule and the probe while the launch wrappers stayed container-keyed. A worktree target now refuses unrecognized names; the known set lives in one `_env_is_container_environment` predicate in the shared environment module, read by containment, charset and probe alike, so an environment added there is picked up by all three guards in the same edit. +- **The probe cache reused a stale pass after a worktree's own configuration changed its workdir.** The cache was keyed on (environment, root) while the mapped path derives from the per-call re-read configuration; the mapped path is now part of the key, so an edited workdir probes again. +- **A scoped `worktree_prepare` on `php-tooling` installed where the dependency gate does not look.** The install ran in the scope's cwd while the gate checks `vendor/autoload.php` at the effective root, so the refusal's named remedy could never clear the refusal. The PHP tool no longer resolves a scope and its schema drops the parameter; the JS tools keep theirs, whose install and gate both derive the same package directory through `get_js_workdir`. +- **The `EnterWorktree` directive lost its repair hint on a `.git` file without a trailing newline**, because the read guard cleared a line `read` had populated; and both the directive and the server refusal embedded the repair path unquoted, so a root carrying a space split into wrong arguments when copy-pasted. The repair command now carries the path quoted in both messages. +- **`jest`'s `install_if_missing` tested an environment-side path on the host.** Under a container the composed `node_modules` path never exists on the host (and under `docker-compose` it is built from a literal sentinel), so every scoped `jest_run` paid a full `npm ci` first. The check now measures the host-side package directory, the same binding the dependency gate uses. +- **`dev-tooling-runner` could be steered into dependency installs.** The runner's server wildcard granted the new `worktree_prepare`, a `vendor/`/`node_modules`-rewriting setup command outside its documented mutation boundary — and the dependency refusals name that tool as the remedy. It is now subtracted via `disallowedTools`; the runner reports such refusals instead of acting on them. +- **A git older than 2.31 was refused with a message naming a git flag as a directory.** `git rev-parse` answers an option it does not recognise by echoing it back at exit 0, so below the release that added `--path-format` the flag itself was read as the worktree's git directory. Worktree targeting now names the version it needs. + +### BREAKING CHANGE +- **A call carrying a parameter the tool does not declare is now refused.** Every tool's `inputSchema` sets `additionalProperties: false`, so a misspelled or unknown parameter returns `Unknown parameter(s): ` instead of being accepted and ignored. Remove the parameter, or correct its spelling to the declared name. Every tool function's argument reads were checked against its declared properties first, so no tool refuses a parameter its own code still reads. +- **The response shape changes**: most tool results (every tool except `set_project_root` and `cwd`) now begin with the project-root banner line described above, ahead of the tool's own output. +- **The vendored protocol handler now enforces a startup floor of bash 4.1 and jq 1.7.** Below either version the server refuses to start and prints a message naming the floor it requires. + ## [3.20.0] - 2026-09-04 ### Added diff --git a/plugins/dev-tooling/README.md b/plugins/dev-tooling/README.md index 4afa1a11..12fb355e 100644 --- a/plugins/dev-tooling/README.md +++ b/plugins/dev-tooling/README.md @@ -11,6 +11,7 @@ PHP and JavaScript tooling for Shopware 6 exposed through three MCP servers, plu - `phpunit_coverage_gaps`: uncovered line and method discovery from a Clover report - `console_run`, `console_list`: Symfony Console - `rector_fix`, `rector_check`: Rector refactoring +- `worktree_prepare`: `composer install` for a fresh worktree ### Administration Tools (`js-admin-tooling`) - `eslint_check`, `eslint_fix`: ESLint @@ -22,6 +23,7 @@ PHP and JavaScript tooling for Shopware 6 exposed through three MCP servers, plu - `jest_run`: Jest unit tests - `unit_setup`: regenerate the component import resolver map - `vite_build`: Vite build +- `worktree_prepare`: `npm ci` for a fresh worktree ### Storefront Tools (`js-storefront-tooling`) - `eslint_check`, `eslint_fix`: ESLint @@ -30,6 +32,7 @@ PHP and JavaScript tooling for Shopware 6 exposed through three MCP servers, plu - `vitest_run`: Vitest tests for the component suite under `views/components/` - `ludtwig_check`, `ludtwig_fix`: ludtwig linting for Twig templates - `webpack_build`: Webpack build +- `worktree_prepare`: `npm ci` for a fresh worktree > [!NOTE] > Storefront tests are split across two runners. Jest's `rootDir` is the `app/storefront` package, so it never collects the component tests under `src/Storefront/Resources/views/components/` — those run through `vitest_run`, and `jest_run` rejects a `testPathPatterns` value naming that tree. @@ -79,13 +82,30 @@ Run `/mcp` and confirm `php-tooling`, `js-admin-tooling`, and `js-storefront-too ## 🗜️ Tools Reference -30 tools across three MCP servers. The [full reference](./docs/reference.md) has parameter tables and examples for every tool; the list below is the quick scan. +The [full reference](./docs/reference.md) has parameter tables and examples for every tool; the table below is the quick scan, by server. -| Server | Tools | -|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `php-tooling` | `phpstan_analyze`, `ecs_check`, `ecs_fix`, `phpunit_run`, `phpunit_coverage_gaps`, `console_run`, `console_list`, `rector_fix`, `rector_check` | -| `js-admin-tooling` | `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `prettier_check`, `prettier_fix`, `jest_run`, `tsc_check`, `lint_all`, `lint_twig`, `unit_setup`, `vite_build` | -| `js-storefront-tooling` | `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`, `ludtwig_fix`, `webpack_build` | +| Server | Tools | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `php-tooling` | `phpstan_analyze`, `ecs_check`, `ecs_fix`, `phpunit_run`, `phpunit_coverage_gaps`, `console_run`, `console_list`, `rector_fix`, `rector_check`, `worktree_prepare`, `set_project_root`, `cwd` | +| `js-admin-tooling` | `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `prettier_check`, `prettier_fix`, `jest_run`, `tsc_check`, `lint_all`, `lint_twig`, `unit_setup`, `vite_build`, `worktree_prepare`, `set_project_root`, `cwd` | +| `js-storefront-tooling` | `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `jest_run`, `vitest_run`, `ludtwig_check`, `ludtwig_fix`, `webpack_build`, `worktree_prepare`, `set_project_root`, `cwd` | + +## 🌳 Worktree Support + +Every tool except `cwd`, on all three servers, takes an optional `project_root` to run one call against a linked git worktree of the root the server was launched in. `set_project_root` and `cwd` manage that state rather than targeting one call with it — `set_project_root` takes a `project_root` and sticks it for every later call on that server process (each server is a separate process holding its own sticky value), and omitting the argument clears it; `cwd` takes no parameters and reports what a server currently resolves to. + +> [!NOTE] +> Worktree targeting works in every environment. Under `docker`, `docker-compose`, `vagrant`, and `ddev` the worktree has to sit inside the launch project root — that root is the only tree the container or VM mounts — so a worktree created elsewhere on the host is refused. An in-root worktree is reached at its own position below the environment's working directory: `docker.workdir`, `vagrant.workdir`, or `ddev.workdir`, and for `docker-compose` a configured `docker-compose.workdir`, or else the destination of the longest matching bind mount. + +> [!IMPORTANT] +> A worktree's `.git` file has to carry a relative `gitdir` pointer. `git worktree add` writes an absolute one unless the repository sets `worktree.useRelativePaths`, and an absolute pointer names a host path that does not exist inside a container — so it is refused in every environment, `native` included. Relink an existing worktree with `git -c worktree.useRelativePaths=true worktree repair ` (needs git 2.48 or newer), or create one with `git worktree add --relative-paths`. Setting `git config worktree.useRelativePaths true` once in the repository makes every later worktree — including ones Claude Code's native `EnterWorktree` tool creates — carry relative linkage from the start. + +A fresh worktree holds tracked files only, so the launch tree's `vendor/` and `node_modules` are not in it and every composer- or npm-backed tool refuses until they exist. Each server's `worktree_prepare` installs its own toolchain's dependencies in the target root — `composer install` on `php-tooling`, `npm ci` in the package directory on the JS servers — through the configured environment, which under a container runs the install inside the container at the mapped path. Re-run it after the worktree's `composer.json`/`composer.lock` or `package-lock.json` changes; the dependency check only tests that the install directory exists. + +> [!WARNING] +> Do not symlink `vendor/` from the launch tree into a worktree. Composer's generated autoloaders derive their base directory from `__DIR__`, which resolves through the symlink, so the worktree would silently autoload the launch tree's classes — tests would then exercise the wrong code. + +See [docs/reference.md](./docs/reference.md#-worktree-support) for the full parameter reference, and [docs/configuration.md](./docs/configuration.md) for how a worktree's own configuration is selected. ## 🤖 Agents @@ -98,7 +118,7 @@ You decide what to check, whether to apply a fix, and which targets to give it. The SessionStart guidance steers Claude to delegate larger dev-tool runs to this agent. It is a soft default — a quick single-file check can still call the MCP tool inline. > [!NOTE] -> The runner never freeform-edits and never decides scope on its own. It has no `Edit`/`Write`, so its only file changes come from the deterministic rule-driven fixers (`ecs_fix`, `rector_fix`, `eslint_fix`, `stylelint_fix`, `prettier_fix`, `ludtwig_fix`) — and only when your request asks for that fix. `console_run`, `console_list`, and `unit_setup` are denied via `disallowedTools`. +> The runner never freeform-edits and never decides scope on its own. It has no `Edit`/`Write`, so its only file changes come from the deterministic rule-driven fixers (`ecs_fix`, `rector_fix`, `eslint_fix`, `stylelint_fix`, `prettier_fix`, `ludtwig_fix`) — and only when your request asks for that fix. `console_run`, `console_list`, `unit_setup`, `worktree_prepare`, and `set_project_root` are denied via `disallowedTools` — `set_project_root`'s value is sticky and outlives the call that set it, so it would otherwise redirect every later dev-tooling call in your session, and `worktree_prepare` rewrites `vendor/`/`node_modules`, a setup mutation a check-only dispatch never authorized. ## 🧭 Scopes diff --git a/plugins/dev-tooling/agents/dev-tooling-runner.md b/plugins/dev-tooling/agents/dev-tooling-runner.md index 4f0cf863..bed70a27 100644 --- a/plugins/dev-tooling/agents/dev-tooling-runner.md +++ b/plugins/dev-tooling/agents/dev-tooling-runner.md @@ -2,7 +2,7 @@ name: dev-tooling-runner description: Runs Shopware dev-tooling checks — PHPStan, ECS, PHPUnit, Rector, ESLint, Stylelint, Prettier, TypeScript, Jest, Vitest, ludtwig, and Vite/Webpack builds — plus the rule-driven fixers (ecs_fix, rector_fix, eslint/stylelint/prettier/ludtwig fix) on the files it is given and returns a condensed pass/fail report. Use when verbose dev-tool output would otherwise fill the conversation, especially during a large task; provide explicit target paths and the checks (and any fixes) to run. Does not discover or expand scope, run arbitrary console commands, or make freeform edits. tools: Read, mcp__plugin_dev-tooling_php-tooling__*, mcp__plugin_dev-tooling_js-admin-tooling__*, mcp__plugin_dev-tooling_js-storefront-tooling__* -disallowedTools: mcp__plugin_dev-tooling_php-tooling__console_run, mcp__plugin_dev-tooling_php-tooling__console_list, mcp__plugin_dev-tooling_js-admin-tooling__unit_setup +disallowedTools: mcp__plugin_dev-tooling_php-tooling__console_run, mcp__plugin_dev-tooling_php-tooling__console_list, mcp__plugin_dev-tooling_js-admin-tooling__unit_setup, mcp__plugin_dev-tooling_php-tooling__set_project_root, mcp__plugin_dev-tooling_js-admin-tooling__set_project_root, mcp__plugin_dev-tooling_js-storefront-tooling__set_project_root, mcp__plugin_dev-tooling_php-tooling__worktree_prepare, mcp__plugin_dev-tooling_js-admin-tooling__worktree_prepare, mcp__plugin_dev-tooling_js-storefront-tooling__worktree_prepare model: haiku color: blue --- @@ -105,9 +105,10 @@ Summarize each check; for a fixer, report the files changed and the fix count. F ## Constraints - Never freeform-edit: you have no `Edit`/`Write`. Your only file changes come from the rule-driven MCP fixers (`ecs_fix`, `rector_fix`, `eslint_fix`, `stylelint_fix`, `prettier_fix`, `ludtwig_fix`), and only when your instructions ask for that fix. -- Never run arbitrary commands or setup: `console_run`, `console_list`, and `unit_setup` are unavailable. +- Never run arbitrary commands or setup: `console_run`, `console_list`, `unit_setup`, and `worktree_prepare` are unavailable. A dependency refusal that names `worktree_prepare` is reported back, not acted on — provisioning is the caller's decision. +- Never repoint a server's project root: `set_project_root` is unavailable on all three servers. Its value is sticky and outlives your call, so it would redirect every later tool call in the session that spawned you. To target a tree other than the one a server was launched in, pass `project_root` on the individual call instead. - Use the dev-tooling MCP tools; never bash equivalents. -- Always pass paths relative to the project root on every tool call — both `targets` and any path inside `scope` (e.g. `src/Core/Content/Product/ProductEntity.php`, never `/Users/...`). Absolute host paths do not resolve inside docker/docker-compose/vagrant/ddev. If given an absolute path, relativize it to the project root first. +- Always pass paths relative to the project root on every tool call — both `targets` and any path inside `scope` (e.g. `src/Core/Content/Product/ProductEntity.php`, never `/Users/...`). Absolute host paths do not resolve inside docker/docker-compose/vagrant/ddev. If given an absolute path, relativize it to the project root first. A relative path may not climb out of the tree with a `..` segment; a worktree-targeted call refuses one that does. `project_root` is the exception to all of this: it is a host path and is passed absolute. - Run only the given targets and checks/fixes; do not discover or expand scope. - Keep the report within ~1–2k tokens. diff --git a/plugins/dev-tooling/docs/configuration.md b/plugins/dev-tooling/docs/configuration.md index 1bb29d1c..031d20d8 100644 --- a/plugins/dev-tooling/docs/configuration.md +++ b/plugins/dev-tooling/docs/configuration.md @@ -94,9 +94,31 @@ A common pattern is to check a shared `.mcp-php-tooling.json` into git and keep | `ddev.workdir` | string | `/var/www/html` | Working directory inside DDEV | | `log_file` | string | - | Additional log file; relative paths resolve against project root | +## 🌳 Worktree Configuration + +A tool call may target a linked git worktree of the launch root by passing `project_root`, or by sticking one first with `set_project_root`. Targeting works in every environment: `native`, `docker`, `docker-compose`, `vagrant`, and `ddev`. + +Under the four container environments the worktree has to sit inside the launch project root, because that root is the only tree the container or VM mounts and a worktree outside it has no path inside. The worktree is reached at its own position below the environment's working directory (`docker.workdir`, `vagrant.workdir`, `ddev.workdir`), and under `docker-compose` below a configured `docker-compose.workdir`, or below the destination of the longest matching bind mount when none is set. Once the mapping exists, `cwd` and the messages raised after it name both the host path and the environment-side path; the refusals that precede the mapping name the host path alone, since none exists yet. + +Validation is the same in every environment: the root has to be one `git worktree add` created, its per-worktree git directory has to point back at that root, and its `.git` file has to carry a **relative** `gitdir` pointer. `git worktree add` writes an absolute one unless the repository sets `worktree.useRelativePaths`, and an absolute pointer names a host path that does not exist inside a container — so it is refused under `native` as well. Relink it with `git -c worktree.useRelativePaths=true worktree repair `, which needs git 2.48 or newer, or create the worktree with `git worktree add --relative-paths`. + +> [!TIP] +> Two one-time settings remove the recurring worktree friction. `git config worktree.useRelativePaths true` (git 2.48 or newer) makes every later worktree carry relative linkage from the start — including worktrees Claude Code's native `EnterWorktree` tool creates, which are otherwise absolute-linked and need the repair command before the first tool call. And the Claude Code setting `worktree.baseRef: head` makes `EnterWorktree` branch from your current HEAD; its default branches from the repository's default remote branch, so a native worktree would carry different code than the branch you are working on. + +A root carrying a single quote or a control character is refused in every environment: a single quote would terminate the single-quoted string the `docker`, `docker-compose` and `vagrant` wrappers embed the command in, and a line break would split the probe's command. Under `docker`, `docker-compose`, `vagrant`, and `ddev` the root charset check refuses whitespace and the set `$`, backtick, backslash, `"`, `;`, `&`, `|`, `<`, `>`, `(`, `)`, `{`, `}`. For the first three the reason is that the wrapper embeds the working directory unquoted inside a single-quoted `bash -c` string, so a space ends the directory name; `ddev` quotes that directory but parses it a second time inside the container. Glob characters (`*`, `?`, bracket patterns) are admitted deliberately in every environment. `native` emits no `cd` and admits all of these. + +After validation the tool probes that the environment reaches the mapped path, through the same wrapper the tool call itself uses. A missing directory — an unsynced Vagrant folder, say — refuses the call and names the path. The probe runs under the four container environments; under `native` the mapped path is the host path validation already checked. A passing result is cached for the server process, per environment, root and mapped path — so an edited workdir in a worktree's own configuration probes again; a failure is not cached. + +**Configuration read timing** differs between the launch root and a worktree target: + +- The launch root's configuration is discovered and bound once, when the server starts, and its environment scalars — the environment, the container name, and the environment-side working directory — are derived then. Individual values are still read out of that file on each use, but a mid-session edit to any of those three does not reach a worktree-targeted call until the server restarts: validation, the mapping and the existence probe all answer from the startup binding, so a running server keeps running against the environment it started with. +- A worktree's own configuration is discovered fresh on every call that targets it. A call targeting the launch root never picks up a worktree's file. + +**Configuration selection for a worktree**: a worktree created under `.claude/worktrees/` holds tracked files only, and the tooling config files (`.mcp-php-tooling.json`, `.mcp-js-tooling.json`) are user-local and untracked — so a worktree ordinarily carries no configuration of its own. When it has none, the launch root's configuration applies unchanged: a worktree of the same checkout runs the same toolchain. When it has one, that configuration is discovered and applied for calls targeting that worktree, following the same discovery priority as the launch root — including the `environment` field it declares, so a worktree with its own configuration runs under that configuration's environment for that call, while a later call against the launch root still runs under the environment the server started with. + ## 📌 Dependencies -You need `bash` 4.0+, `jq`, and Node.js 20+ for the JS tools. The MCP servers don't bundle any of the actual linters or test runners. They shell out to whatever is already installed in the target project, so PHPStan, ECS, PHPUnit, Rector, ESLint, Stylelint, Prettier, Jest, and TypeScript all need to be available there (usually via `composer.json` or `package.json` in the Shopware checkout). +You need `bash` 4.1+, `jq` 1.7+, and Node.js 20+ for the JS tools. The vendored protocol handler checks both floors at startup and refuses to run below either. Worktree targeting — the optional `project_root` parameter — effectively needs `git` 2.48+: resolution itself needs 2.31 (`git rev-parse --path-format`), but the mandatory relative `gitdir` linkage and its repair command are 2.48 features, so on 2.31–2.47 every stock worktree is refused with a remedy that git cannot run. Below either floor a worktree-targeted call is refused with a message naming the version, and every other tool is unaffected. The MCP servers don't bundle any of the actual linters or test runners. They shell out to whatever is already installed in the target project, so PHPStan, ECS, PHPUnit, Rector, ESLint, Stylelint, Prettier, Jest, and TypeScript all need to be available there (usually via `composer.json` or `package.json` in the Shopware checkout). ## 🩺 Troubleshooting diff --git a/plugins/dev-tooling/docs/mcp-enforcement.md b/plugins/dev-tooling/docs/mcp-enforcement.md index 0044ad55..8784cf3b 100644 --- a/plugins/dev-tooling/docs/mcp-enforcement.md +++ b/plugins/dev-tooling/docs/mcp-enforcement.md @@ -8,9 +8,15 @@ MCP is a synchronous request-response protocol. A long-running watcher like `npm ## 🛡️ Enforcement Hooks -Three hooks work together. A **SessionStart** hook injects a directive at the top of every conversation that lists the available MCP tools and tells Claude to prefer them over bash; the prompt lives in `hooks/prompts/mcp-tool-directives.md` if you want to read or tweak it. A **PreToolUse** hook is the safety net: it intercepts bash commands that map to a known MCP tool and points Claude at the replacement, so even if the SessionStart directive got ignored or compacted away, the bad call gets caught before it runs. A **PostToolUse** hook watches `phpstan_analyze`. When it runs against specific files, it cross-references `phpstan-baseline.neon` (or `.php`) and surfaces a warning if any of the analyzed paths appear in the baseline, which usually means a baseline entry has gone stale. Full-project PHPStan runs skip the check because PHPStan validates the baseline natively there. +Three hook events carry scripts between them: SessionStart, PreToolUse, and PostToolUse. -The SessionStart and PreToolUse hooks honor `enforce_mcp_tools` and turn off when it's `false`. The PostToolUse baseline check ignores the flag and always runs. +**SessionStart** runs two. The first injects a directive at the top of every conversation that lists the available MCP tools and tells Claude to prefer them over bash; the prompt lives in `hooks/prompts/mcp-tool-directives.md` if you want to read or tweak it. The second emits the LSP directives, and only when `.lsp-php-tooling.json` enables a language server. + +**PreToolUse** runs one script per server, and they are the safety net: they intercept bash commands that map to a known MCP tool and point Claude at the replacement, so even if the SessionStart directive got ignored or compacted away, the bad call gets caught before it runs. + +**PostToolUse** runs two. One watches `phpstan_analyze`: when it runs against specific files, it cross-references `phpstan-baseline.neon` (or `.php`) and surfaces a warning if any of the analyzed paths appear in the baseline, which usually means a baseline entry has gone stale. Full-project PHPStan runs skip the check because PHPStan validates the baseline natively there. The other watches `EnterWorktree` and `ExitWorktree` and reminds the session to call `set_project_root` on all three servers, since each holds its own sticky root. + +Within SessionStart, `session-start.sh` honors `enforce_mcp_tools` and turns off when it's `false`; `lsp-directives.sh` ignores the flag and runs whenever `.lsp-php-tooling.json` enables a language server. Every PreToolUse script honors the flag. Both PostToolUse scripts, the baseline check and the worktree-directives reminder, ignore the flag and always run. ### Disabling Enforcement diff --git a/plugins/dev-tooling/docs/reference.md b/plugins/dev-tooling/docs/reference.md index 1a54d29a..b760e817 100644 --- a/plugins/dev-tooling/docs/reference.md +++ b/plugins/dev-tooling/docs/reference.md @@ -1,6 +1,6 @@ # Tools Reference -30 tools across three MCP servers: 9 in `php-tooling`, 12 in `js-admin-tooling`, and 9 in `js-storefront-tooling`. +Parameters and examples for every tool of the three MCP servers: `php-tooling`, `js-admin-tooling`, and `js-storefront-tooling`. Every tool except `cwd` takes an optional `project_root` (see [🌳 Worktree Support](#-worktree-support)), and each server adds `worktree_prepare`, `set_project_root`, and `cwd` — all three documented in that section. ## 🐘 PHP Tools (`php-tooling`) @@ -12,13 +12,15 @@ PHPStan static analysis. Returns type errors as JSON. Use phpstan_analyze with paths ["src/Core/"] and level 8 ``` -| Parameter | Type | Description | -|----------------|---------------|----------------------------------------| -| `paths` | array | File paths or directories | -| `level` | integer (0-9) | Analysis strictness | -| `config` | string | PHPStan config file path | -| `memory_limit` | string | PHP memory limit (e.g. `2G`, `512M`) | -| `error_format` | string | `json`, `table`, or `raw` | +| Parameter | Type | Description | +|----------------|---------------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | File paths or directories | +| `level` | integer (0-9) | Analysis strictness | +| `config` | string | PHPStan config file path | +| `memory_limit` | string | PHP memory limit (e.g. `2G`, `512M`) | +| `error_format` | string | `json`, `table`, or `raw` | +| `scope` | string | Scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `ecs_check` / `ecs_fix` @@ -28,11 +30,13 @@ ECS (PHP-CS-Fixer) code style. `ecs_fix` detects and fixes in one step and is pr Use ecs_fix to fix src/Core/Content/Product/ ``` -| Parameter | Type | Tool | Description | -|-----------------|--------|-------------------------|--------------------------------| -| `paths` | array | both | File paths or directories | -| `config` | string | both | ECS config file path | -| `output_format` | string | `ecs_check` only | Output format | +| Parameter | Type | Tool | Description | +|-----------------|--------|------------------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | both | File paths or directories | +| `config` | string | both | ECS config file path | +| `output_format` | string | `ecs_check` only | Output format | +| `scope` | string | both | Scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | both | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `phpunit_run` @@ -43,32 +47,39 @@ Use phpunit_run with testsuite "unit" Use phpunit_run with paths ["tests/unit/Core/Checkout/"] filter "testAddProduct" ``` -| Parameter | Type | Description | -|--------------------|---------|------------------------------------------------------------------------------------------------------------| -| `testsuite` | string | Test suite (`unit`, `integration`, …) | -| `paths` | array | Specific test files or directories | -| `filter` | string | Filter tests by name pattern | -| `config` | string | PHPUnit config file path | -| `coverage` | boolean | Generate coverage report | -| `coverage_format` | string | `text` (default, aggregate only), `clover`/`cobertura` (per-line XML), `html` (visual report) | -| `coverage_path` | string | Output path. Defaults: `clover`/`cobertura` → `coverage.xml`, `html` → `coverage/`. Ignored for `text`. | -| `coverage_driver` | string | `xdebug` injects `XDEBUG_MODE=coverage` (Xdebug 3). `pcov` relies on the extension. Omit for auto-detect. | -| `output_format` | string | `default` or `testdox` | -| `stop_on_failure` | boolean | Stop on first failure | +| Parameter | Type | Description | +|-------------------|---------|-----------------------------------------------------------------------------------------------------------| +| `testsuite` | string | Test suite (`unit`, `integration`, …) | +| `paths` | array | Specific test files or directories | +| `filter` | string | Filter tests by name pattern | +| `config` | string | PHPUnit config file path | +| `coverage` | boolean | Generate coverage report | +| `coverage_format` | string | `text` (default, aggregate only), `clover`/`cobertura` (per-line XML), `html` (visual report) | +| `coverage_path` | string | Output path. Defaults: `clover`/`cobertura` → `coverage.xml`, `html` → `coverage/`. Ignored for `text`. | +| `coverage_driver` | string | `xdebug` injects `XDEBUG_MODE=coverage` (Xdebug 3). `pcov` relies on the extension. Omit for auto-detect. | +| `output_format` | string | `default` or `testdox` | +| `stop_on_failure` | boolean | Stop on first failure | +| `scope` | string | Scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `phpunit_coverage_gaps` Parse a Clover XML coverage report and surface uncovered methods and line ranges grouped by file (worst coverage first). Two-step workflow: run `phpunit_run` with `coverage: true, coverage_format: "clover"` first. +> [!NOTE] +> This tool reads a file and runs no PHP, but it still asserts the PHP dependencies like every other tool on this server: against a `project_root` with no `vendor/autoload.php` it refuses rather than reading the report. That follows from the two-step workflow — the `phpunit_run` that produces the report needs those dependencies anyway — so the assertion costs nothing on the documented path. Reading a report generated somewhere else, in a tree with nothing installed, is not supported. + ``` Use phpunit_run with coverage true coverage_format "clover" Use phpunit_coverage_gaps with source_filter "src/Core/" ``` -| Parameter | Type | Description | -|-----------------|--------|-------------------------------------------------------------------------------------------------------------------| -| `clover_path` | string | Clover XML path. Default: `coverage.xml` | +| Parameter | Type | Description | +|-----------------|--------|-------------------------------------------------------------------------------------------------------------------------------------| +| `clover_path` | string | Clover XML path. Default: `coverage.xml` | | `source_filter` | string | Substring filter on file path. Use to drop framework base classes that leak into coverage (`AbstractFieldSerializer`, `CloneTrait`) | +| `scope` | string | Scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `console_run` / `console_list` @@ -84,21 +95,25 @@ Use console_list with namespace "cache" `console_run` parameters: -| Parameter | Type | Description | -|------------------|---------------|------------------------------------------------| -| `command` | string | Console command (required) | -| `arguments` | array | Positional arguments | -| `options` | object | Options as key/value | -| `env` | string | Symfony env passed as `--env`. Any name the installation defines (`dev`, `prod`, `test`, `staging`, …); `^[A-Za-z0-9_]{1,32}$` | -| `output_file` | string | Write stdout to this file instead of returning it (see below) | -| `feature_all` | string | `major` or `true`. Sets `FEATURE_ALL=` in the command's process environment inside the target environment — `feature_all "major"` with `command "cache:clear"` is the deprecation gate `FEATURE_ALL=major bin/console cache:clear` | -| `verbosity` | string | `quiet`, `normal`, `verbose`, `very-verbose`, `debug` | -| `no_debug` | boolean | Disable debug mode | -| `no_interaction` | boolean | Non-interactive | +| Parameter | Type | Description | +|------------------|---------|--------------------------------------------------------------------------------------------------------------------------------| +| `command` | string | Console command (required) | +| `arguments` | array | Positional arguments | +| `options` | object | Options as key/value | +| `env` | string | Symfony env passed as `--env`. Any name the installation defines (`dev`, `prod`, `test`, `staging`, …); `^[A-Za-z0-9_]{1,32}$` | +| `output_file` | string | Write stdout to this file instead of returning it (see below) | +| `feature_all` | string | `major` or `true`. Sets `FEATURE_ALL` in the target environment; see below. | +| `verbosity` | string | `quiet`, `normal`, `verbose`, `very-verbose`, `debug` | +| `no_debug` | boolean | Disable debug mode | +| `no_interaction` | boolean | Non-interactive | +| `scope` | string | Scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | With `output_file` set, stdout goes to the file and the response carries only the resolved path, the byte count and the exit status — stderr still comes back in the response. A relative path resolves against the project root, parent directories are created, and the file is replaced only after the command exits zero. A failed command leaves the target as it was and returns its stdout in the response instead. The value is refused when it is longer than 4096 bytes, or when the target already exists as a symlink or as anything other than a regular file. Omit it (or pass an empty string) to get the output in the response as before. -`console_list` parameters: `namespace` (string), `format` (string). +`feature_all` sets `FEATURE_ALL=` in the command's process environment inside the target environment. `feature_all "major"` with `command "cache:clear"` is the deprecation gate `FEATURE_ALL=major bin/console cache:clear`. + +`console_list` parameters: `namespace` (string), `format` (string), `scope` (string — scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior), `project_root` (string — see [🌳 Worktree Support](#-worktree-support)). ### `rector_fix` / `rector_check` @@ -110,14 +125,39 @@ Use rector_fix with only "CountArrayToEmptyArrayComparisonRector" Use rector_fix with only_suffix "Controller" ``` -| Parameter | Type | Description | -|-----------------|---------|------------------------------------------------------------------| -| `paths` | array | File paths or directories | -| `config` | string | Rector config file path | -| `only` | string | Filter to a single rule (FQCN or short name) | -| `only_suffix` | string | Filter files by name suffix (e.g. `Controller` → `*Controller.php`) | -| `output_format` | string | `json` (default) or `console` | -| `clear_cache` | boolean | Clear Rector cache before processing | +| Parameter | Type | Description | +|-----------------|---------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | File paths or directories | +| `config` | string | Rector config file path | +| `only` | string | Filter to a single rule (FQCN or short name) | +| `only_suffix` | string | Filter files by name suffix (e.g. `Controller` → `*Controller.php`) | +| `output_format` | string | `json` (default) or `console` | +| `clear_cache` | boolean | Clear Rector cache before processing | +| `scope` | string | Scope name from `.mcp-php-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +### `set_project_root` + +Sets or clears the sticky project root for this server process. With a `project_root`, validates it as a linked git worktree of the launch root and sticks it — every later call on this server targets it until `set_project_root` is called again with no argument. Without one, clears the sticky root unconditionally (no validation of the value discarded) and returns the server to its launch root. + +``` +Use php-tooling set_project_root with project_root "/path/to/worktree" +Use php-tooling set_project_root +``` + +| Parameter | Type | Description | +|----------------|--------|-------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to a linked git worktree of the launch root. Omit to clear the sticky root. | + +### `cwd` + +Reports this server's resolution state: the effective project root, its source (`launch` or `sticky` — `cwd` takes no `project_root`, so it never reports the `call` source the other tools' banners can show), whether it currently resolves, the resolved working directory, the environment, and the configuration file(s) in use. No parameters, validates nothing, never fails — including when a sticky root names a directory that no longer exists. + +"Resolves" reports resolution and mapping, not reachability: the existence probe runs on tool calls, not here, so a root `cwd` reports as resolving can still be refused by the next tool call's probe. + +``` +Use php-tooling cwd +``` ## 🖥️ Administration Tools (`js-admin-tooling`) @@ -131,11 +171,14 @@ ESLint linting / auto-fix for Administration. Two routes, selected by whether `p Use js-admin-tooling eslint_fix with paths ["src/app/component/"] ``` -| Parameter | Type | Tool | Description | -|-----------------|--------|----------------|-------------------------------------------------------------------------------------------------| -| `paths` | array | both | File paths or directories. When supplied, these are the ONLY targets the run covers; a path containing `app/administration/` is rebased onto the package directory by stripping everything up to and including that prefix. Omit to run the targets the aggregate `lint` / `lint:fix` script configures for itself. | -| `output_format` | string | `eslint_check` | `stylish` (default) or `json` | -| `scope` | string | both | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| Parameter | Type | Tool | Description | +|-----------------|--------|----------------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | both | File paths or directories; see below. | +| `output_format` | string | `eslint_check` | `stylish` (default) or `json` | +| `scope` | string | both | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | both | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +When `paths` is supplied, these are the ONLY targets the run covers; a path containing `app/administration/` is rebased onto the package directory by stripping everything up to and including that prefix. Omit `paths` to run the targets the aggregate `lint` / `lint:fix` script configures for itself. Every path is validated before ESLint runs: it must exist and resolve to at least one file carrying an extension the Administration ESLint config reads (`js`, `ts`, `tsx`, `vue`, `json`, `twig`). A path that fails either check is refused with a message naming it, rather than linting nothing and reporting success. @@ -147,11 +190,14 @@ Stylelint SCSS linting / auto-fix. Two routes, selected by whether `paths` is su Use js-admin-tooling stylelint_fix with paths ["src/**/*.scss"] ``` -| Parameter | Type | Tool | Description | -|-----------------|--------|-------------------|-------------------------------------------------------------------| -| `paths` | array | both | File paths or glob patterns, relative to the Administration package directory. When supplied, these are the ONLY targets the run covers. Omit to run the targets the aggregate SCSS script configures for itself. | -| `output_format` | string | `stylelint_check` | `string` (default), `json`, or `compact` | -| `scope` | string | both | Scope name from `.mcp-js-tooling.json` | +| Parameter | Type | Tool | Description | +|-----------------|--------|-------------------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | both | File paths or glob patterns, relative to the Administration package directory; see below. | +| `output_format` | string | `stylelint_check` | `string` (default), `json`, or `compact` | +| `scope` | string | both | Scope name from `.mcp-js-tooling.json` | +| `project_root` | string | both | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +When `paths` is supplied, these are the ONLY targets the run covers. Omit `paths` to run the targets the aggregate SCSS script configures for itself. > [!NOTE] > A literal path is validated before Stylelint runs: it must exist and resolve to at least one `.scss` or `.css` file, or it is refused with a message naming it rather than linting nothing and reporting success. A glob pattern (containing `*`, `?`, or `[`) skips that check — the existence probe cannot resolve a glob — and reaches Stylelint unchecked for expansion there. @@ -164,10 +210,13 @@ Prettier format check / auto-format for Administration. Two routes, selected by Use js-admin-tooling prettier_check with paths ["src/app/component/"] ``` -| Parameter | Type | Description | -|-----------|--------|-----------------------------------------------------------------------------------------------------| -| `paths` | array | File paths or glob patterns, relative to the Administration package directory. When supplied, these are the ONLY targets the run covers. Omit to check/format the targets the aggregate `format` / `format:fix` script configures for itself. | -| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | File paths or glob patterns, relative to the Administration package directory; see below. | +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +When `paths` is supplied, these are the ONLY targets the run covers. Omit `paths` to check/format the targets the aggregate `format` / `format:fix` script configures for itself. > [!NOTE] > A literal path is validated before Prettier runs: it must exist and resolve to at least one `.js` or `.ts` file, or it is refused with a message naming it. A glob pattern (containing `*`, `?`, or `[`) skips that check and reaches Prettier unchecked for expansion there. @@ -180,13 +229,13 @@ Runs at the target-less npm script `jest:base`. When that script is unavailable, **The result comes from Jest's own JSON report, not the process exit code.** Every run appends `--json --outputFile=`; the tool clears that path before starting, reads it afterwards, and decides from the counts it carries: -| Report says | Tool reports | -|--------------------------------------------|-----------------------------------------------------------------------| -| any failed test or suite | failure | -| zero tests ran | failure, naming the patterns that matched nothing | -| all passed, process exited 0 | success | -| all passed, process exited non-zero | success, stating the exit code prominently and keeping the output | -| report missing or unparseable | falls back to the exit code, and says the status came from there | +| Report says | Tool reports | +|-------------------------------------|-------------------------------------------------------------------| +| any failed test or suite | failure | +| zero tests ran | failure, naming the patterns that matched nothing | +| all passed, process exited 0 | success | +| all passed, process exited non-zero | success, stating the exit code prominently and keeping the output | +| report missing or unparseable | falls back to the exit code, and says the status came from there | The last two rows are the point. A coverage-threshold breach or a post-run writer erroring makes Jest exit non-zero after every test passed, and that was previously reported as a failed run. The report also carries the counts when `ci` is true, where Jest's own summary line is suppressed. @@ -198,38 +247,75 @@ Use js-admin-tooling jest_run with testPathPatterns "component" Use js-admin-tooling jest_run with coverage true ``` -| Parameter | Type | Description | -|--------------------|---------|------------------------------------| -| `testPathPatterns` | string | Regex on test file paths | -| `testNamePattern` | string | Regex on test names | -| `coverage` | boolean | Generate coverage report | -| `updateSnapshots` | boolean | Update snapshots | -| `ci` | boolean | Run Jest in CI mode (`--ci`, default `false`). `jest.config.ts` derives `isCi` from an exact `--ci` match in `process.argv` and uses it for both `collectCoverage` and the reporter choice, so turning this on collects coverage regardless of `coverage` and swaps the reporters to `jest-silent-reporter` plus `jest-junit`, suppressing the per-test lines and the summary. Forced on, without recourse, whenever the `unit` fallback above is in effect. Leave it off unless CI-identical output is needed. | -| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| Parameter | Type | Description | +|--------------------|---------|----------------------------------------------------------------------------------------------------------| +| `testPathPatterns` | string | Regex on test file paths | +| `testNamePattern` | string | Regex on test names | +| `coverage` | boolean | Generate coverage report | +| `updateSnapshots` | boolean | Update snapshots | +| `ci` | boolean | Run Jest in CI mode (`--ci`, default `false`); see below. | +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +`ci` runs Jest in CI mode (`--ci`). `jest.config.ts` derives `isCi` from an exact `--ci` match in `process.argv` and uses it for both `collectCoverage` and the reporter choice: turning `ci` on collects coverage regardless of `coverage` and swaps the reporters to `jest-silent-reporter` plus `jest-junit`, suppressing the per-test lines and the summary. `ci` is forced on, without recourse, whenever the `unit` fallback above is in effect. Leave it off unless CI-identical output is needed. ### `tsc_check` -`npm run lint:types` against the project tsconfig. No parameters. +`npm run lint:types` against the project tsconfig. + +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `lint_all` -Runs TypeScript, ESLint, Stylelint, and Prettier in one shot. Intended for pre-commit validation. No parameters. +Runs TypeScript, ESLint, Stylelint, and Prettier in one shot. Intended for pre-commit validation. + +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `lint_twig` -ESLint against `.html.twig` files. Validates Admin Vue component templates. No parameters. +ESLint against `.html.twig` files. Validates Admin Vue component templates. + +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `unit_setup` -Regenerates the component import resolver map. Run it when Jest fails with import/module resolution errors. No parameters. +Regenerates the component import resolver map. Run it when Jest fails with import/module resolution errors. + +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | ### `vite_build` Vite build for Administration (Vue 3). -| Parameter | Type | Description | -|-----------|--------|--------------------------| -| `mode` | string | `development` or `production` | +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `mode` | string | `development` or `production` | +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +### `set_project_root` + +Sets or clears the sticky project root for this server process. Same behavior as the PHP server's `set_project_root` above — a separate process, a separate sticky value. + +| Parameter | Type | Description | +|----------------|--------|-------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to a linked git worktree of the launch root. Omit to clear the sticky root. | + +### `cwd` + +Reports this server's resolution state. No parameters. Same shape as the PHP server's `cwd` above. ## 🛒 Storefront Tools (`js-storefront-tooling`) @@ -247,11 +333,12 @@ Use js-storefront-tooling eslint_fix with paths ["src/plugin/"] Use js-storefront-tooling eslint_check with paths ["views/components/cms/"] ``` -| Parameter | Type | Tool | Description | -|-----------------|--------|----------------|-------------------------------------------------| -| `paths` | array | both | File paths or directories, in any of the three forms below | -| `output_format` | string | `eslint_check` | `stylish` (default) or `json` | -| `scope` | string | both | Scope name from `.mcp-js-tooling.json` | +| Parameter | Type | Tool | Description | +|-----------------|--------|----------------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | both | File paths or directories, in any of the three forms below | +| `output_format` | string | `eslint_check` | `stylish` (default) or `json` | +| `scope` | string | both | Scope name from `.mcp-js-tooling.json` | +| `project_root` | string | both | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | Three path forms are accepted, per path: @@ -271,7 +358,7 @@ Omitting `paths` runs the aggregate `lint:js` / `lint:js:fix` script bare across ### `stylelint_check` / `stylelint_fix` -Stylelint SCSS linting / auto-fix. Same routing as the Administration versions: with `paths` supplied, the run goes to the target-less npm script `stylelint:app` so the given paths are the only targets; without `paths`, the aggregate `lint:scss` / `lint:scss-fix` script runs and its own `./src/scss` target stays authoritative. The aggregate is never a fallback for a path-scoped run, and a path-scoped run refuses outright when `stylelint:app` is unavailable. Parameters: `paths` (file paths or glob patterns, relative to the `app/storefront` package directory), `output_format` on the check tool (`string` default, `json`, `compact`), and `scope`. +Stylelint SCSS linting / auto-fix. Same routing as the Administration versions: with `paths` supplied, the run goes to the target-less npm script `stylelint:app` so the given paths are the only targets; without `paths`, the aggregate `lint:scss` / `lint:scss-fix` script runs and its own `./src/scss` target stays authoritative. The aggregate is never a fallback for a path-scoped run, and a path-scoped run refuses outright when `stylelint:app` is unavailable. Parameters: `paths` (file paths or glob patterns, relative to the `app/storefront` package directory), `output_format` on the check tool (`string` default, `json`, `compact`), `scope`, and `project_root` (see [🌳 Worktree Support](#-worktree-support)). ``` Use js-storefront-tooling stylelint_fix with paths ["src/**/*.scss"] @@ -288,7 +375,7 @@ Runs at the target-less npm script `jest:base`. When that script is unavailable, The result comes from Jest's JSON report rather than the process exit code, exactly as described for [the Admin version](#jest_run) — including the cleared-before-each-run report path. This matters more here than on Administration: the Storefront config collects coverage unconditionally and enforces global thresholds, so a path-scoped run of a few files exits non-zero on the threshold while every test passes. Those runs previously reported as errors. -Same parameters as the Admin version — `testPathPatterns`, `testNamePattern`, `coverage`, `updateSnapshots`, `ci`, plus `scope` — except `ci`'s consequence differs here: the Storefront `jest.config.js` sets `collectCoverage: true` unconditionally, so `coverage` is unaffected either way and `ci` changes only Jest's own CI-mode behavior. Under CI mode Jest declines to write *new* snapshots — but an explicit `updateSnapshots` still wins, because Jest resolves `--ci` without `--updateSnapshot` to snapshot mode `none` and an explicit `--updateSnapshot` to `all` regardless of `--ci`. +Same parameters as the Admin version — `testPathPatterns`, `testNamePattern`, `coverage`, `updateSnapshots`, `ci`, plus `scope` and `project_root` (see [🌳 Worktree Support](#-worktree-support)) — except `ci`'s consequence differs here: the Storefront `jest.config.js` sets `collectCoverage: true` unconditionally, so `coverage` is unaffected either way and `ci` changes only Jest's own CI-mode behavior. Under CI mode Jest declines to write *new* snapshots — but an explicit `updateSnapshots` still wins, because Jest resolves `--ci` without `--updateSnapshot` to snapshot mode `none` and an explicit `--updateSnapshot` to `all` regardless of `--ci`. ### `vitest_run` @@ -299,13 +386,14 @@ Use js-storefront-tooling vitest_run with paths ["views/components/cms/"] Use js-storefront-tooling vitest_run with testNamePattern "renders the slider" ``` -| Parameter | Type | Description | -|-------------------|---------|-------------------------------------------------------------------| -| `paths` | array | Test file or directory filters. Omit to run the whole component suite | -| `testNamePattern` | string | Regex on test names (`-t`) | -| `coverage` | boolean | Run `unit:components:coverage` instead | -| `updateSnapshots` | boolean | Update Vitest snapshots (`-u`) | -| `scope` | string | Scope name from `.mcp-js-tooling.json` | +| Parameter | Type | Description | +|-------------------|---------|----------------------------------------------------------------------------------------------------------| +| `paths` | array | Test file or directory filters. Omit to run the whole component suite | +| `testNamePattern` | string | Regex on test names (`-t`) | +| `coverage` | boolean | Run `unit:components:coverage` instead | +| `updateSnapshots` | boolean | Update Vitest snapshots (`-u`) | +| `scope` | string | Scope name from `.mcp-js-tooling.json` | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | `paths` accepts the same three forms as the Storefront ESLint tools (repo-root-relative, tree-relative `views/components/…`, or a path already carrying the package prefix) and each one is checked for existence before Vitest runs. A path containing a single quote or a line break is refused. @@ -317,7 +405,11 @@ ludtwig linting / auto-fix for Storefront Twig templates. Runs `composer ludtwig Use js-storefront-tooling ludtwig_check ``` -No parameters. Neither tool takes `paths` and neither takes `scope`: the composer script body is `cd ./src/Storefront/Resources/views; ludtwig .`, whose `;` makes appended arguments impossible, and the run is pinned to the project root so a scope set by an earlier call in the same server process cannot move it. +Neither tool takes `paths` and neither takes `scope`: the composer script body is `cd ./src/Storefront/Resources/views; ludtwig .`, whose `;` makes appended arguments impossible, and the scope is pinned to the project root rather than resolved from the call — a `default_scope` in the configuration would otherwise move the run into a plugin directory, and the composer script enters the core Storefront views tree relative to wherever composer is invoked. + +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | > [!IMPORTANT] > Both tools require a `ludtwig` binary where the command runs — on the host for a native setup, inside the container otherwise. No tool in this plugin installs it. When it is missing the composer script fails and the tool surfaces that failure. @@ -326,6 +418,42 @@ No parameters. Neither tool takes `paths` and neither takes `scope`: the compose Webpack build for Storefront (vanilla JS). -| Parameter | Type | Description | -|-----------|--------|-------------------------------| -| `mode` | string | `development` or `production` | +| Parameter | Type | Description | +|----------------|--------|----------------------------------------------------------------------------------------------------------| +| `mode` | string | `development` or `production` | +| `scope` | string | Scope name from `.mcp-js-tooling.json`; `shopware` forces project-root behavior | +| `project_root` | string | Absolute path to a linked git worktree of the launch root. See [🌳 Worktree Support](#-worktree-support). | + +### `set_project_root` + +Sets or clears the sticky project root for this server process. Same behavior as the PHP server's `set_project_root` above — a separate process, a separate sticky value. + +| Parameter | Type | Description | +|----------------|--------|-------------------------------------------------------------------------------------------| +| `project_root` | string | Absolute path to a linked git worktree of the launch root. Omit to clear the sticky root. | + +### `cwd` + +Reports this server's resolution state. No parameters. Same shape as the PHP server's `cwd` above. + +## 🌳 Worktree Support + +Every tool except `cwd`, on all three servers, accepts `project_root`. Pass it to run one call against a linked git worktree of the root the server was launched in, instead of the launch root itself. Targeting works in every environment: `native`, `docker`, `docker-compose`, `vagrant`, and `ddev`. + +What the environment adds: + +- **In-root under containers.** `docker`, `docker-compose`, `vagrant`, and `ddev` refuse a worktree outside the launch project root, which is the only tree the container or VM mounts. An in-root worktree is reached at its own position below the environment's working directory; under `docker-compose` a configured `docker-compose.workdir` decides, or else the longest matching bind mount. +- **Relative linkage, in every environment.** The worktree's `.git` file has to carry a relative `gitdir` pointer. `git worktree add` writes an absolute one unless the repository sets `worktree.useRelativePaths`, and an absolute pointer names a host path that does not exist inside a container — so the call is refused under `native` too. Relink the worktree with `git -c worktree.useRelativePaths=true worktree repair ` (git 2.48 or newer), or create it with `git worktree add --relative-paths`. Setting `git config worktree.useRelativePaths true` once makes every later worktree carry relative linkage from the start, including ones Claude Code's native `EnterWorktree` tool creates. +- **Dependency provisioning.** A fresh worktree holds tracked files only, so `vendor/` and `node_modules` are missing and the composer- and npm-backed tools refuse until they exist. Each server's `worktree_prepare` installs its own toolchain's dependencies in the target root through the configured environment: `composer install --no-interaction` on `php-tooling`, `npm ci` in the package directory on `js-admin-tooling` and `js-storefront-tooling`. It takes `project_root` like every other tool and skips the dependency check the other tools run. The JS variants also take `scope` — their install and dependency gate both derive the same package directory through `get_js_workdir`; the PHP variant deliberately does not, because the gate checks `vendor/autoload.php` at the effective root and a scoped install would land where the gate never looks. A successful install returns a short completion summary — the per-package installer output is suppressed — while a failed one returns the full output. Re-run it after the worktree's `composer.json`/`composer.lock` or `package-lock.json` changes — the dependency check only tests that the install directory exists. Admin Jest additionally needs `unit_setup` after the install plus the gitignored `test/_mocks_/entity-schema.json`, which `unit_setup` does not regenerate — dump it with `console_run` on `php-tooling` (env `prod`): `framework:schema -s entity-schema src/Administration/Resources/app/administration/test/_mocks_/entity-schema.json` (the flag is `-s`/`--schema-format`; `--schema` does not exist). `ludtwig` needs `worktree_prepare` on `php-tooling`, not on its own server, because it runs composer. Never symlink `vendor/` from the launch tree instead: composer's autoloaders resolve through the symlink and load the launch tree's classes. +- **Charset.** A root carrying a single quote or a control character is refused in every environment. Under `docker`, `docker-compose`, `vagrant`, and `ddev`, whitespace and the metacharacter set `$`, backtick, backslash, `"`, `;`, `&`, `|`, `<`, `>`, `(`, `)`, `{`, `}` are refused too — the first three embed the working directory unquoted, and `ddev` parses it a second time inside the container. Glob characters stay admitted. +- **Existence probe.** After validation the tool checks that the environment reaches the mapped path, through the same wrapper the call uses. A missing directory refuses the call and names the path; a passing result is cached for that server process per environment, root and mapped path, so an edited workdir in a worktree's own configuration probes again. +- **Per-worktree configuration.** A worktree carrying its own `.mcp-*-tooling.json` is read on every call that targets it, and the `environment` that configuration declares applies to that call. + +Without `project_root`, a call targets the sticky root set by `set_project_root` on that server, or the launch root when no sticky root is set. Each server is a separate process holding its own sticky value, so pointing all three at a worktree takes three `set_project_root` calls, and returning to the launch root after an `ExitWorktree` takes three more with no argument. A `PostToolUse` hook fires on `EnterWorktree`/`ExitWorktree` to remind the session of this. + +Every tool result that resolves a project root (i.e. every tool but `set_project_root` and `cwd`, which report their own state instead) begins with a banner line naming the effective root and its source, e.g. `Project root: /path/to/worktree (call)`. + +See [docs/configuration.md](./configuration.md#-worktree-configuration) for the configuration discovery order, the read timing, and the path mapping. + +> [!WARNING] +> `hooks/scripts/check-phpstan-baseline.sh` resolves the analyzed paths and the baseline file from the session's own tree, not from `project_root`. A worktree-targeted `phpstan_analyze` has its baseline overlap computed against the main checkout. diff --git a/plugins/dev-tooling/hooks/hooks.json b/plugins/dev-tooling/hooks/hooks.json index 7e89f947..a98b5da1 100644 --- a/plugins/dev-tooling/hooks/hooks.json +++ b/plugins/dev-tooling/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "SessionStart hooks inject MCP tool and LSP usage directives. PreToolUse hooks enforce MCP dev tools over bash equivalents.", + "description": "SessionStart hooks inject MCP tool and LSP usage directives. PreToolUse hooks enforce MCP dev tools over bash equivalents. PostToolUse hooks warn on stale PHPStan baseline entries and remind the session to re-point `set_project_root` after a worktree switch.", "hooks": { "SessionStart": [ { @@ -49,6 +49,16 @@ "timeout": 5 } ] + }, + { + "matcher": "EnterWorktree|ExitWorktree", + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/worktree-directives.sh", + "timeout": 5 + } + ] } ] } diff --git a/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md b/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md index 27465d92..849be4bd 100644 --- a/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md +++ b/plugins/dev-tooling/hooks/prompts/mcp-tool-directives.md @@ -2,9 +2,11 @@ ALWAYS use MCP dev tools for PHP and JavaScript operations — NEVER run these v MCP tools auto-detect the development environment (native/docker/vagrant/ddev) and apply project configuration. -php-tooling: phpstan_analyze, ecs_check, ecs_fix, phpunit_run, phpunit_coverage_gaps, console_run, console_list, rector_fix, rector_check -js-admin-tooling: eslint_check/fix, stylelint_check/fix, prettier_check/fix, jest_run, tsc_check, lint_all, lint_twig, unit_setup, vite_build -js-storefront-tooling: eslint_check/fix, stylelint_check/fix, jest_run, vitest_run, ludtwig_check/fix, webpack_build +php-tooling: phpstan_analyze, ecs_check, ecs_fix, phpunit_run, phpunit_coverage_gaps, console_run, console_list, rector_fix, rector_check, worktree_prepare, set_project_root, cwd +js-admin-tooling: eslint_check/fix, stylelint_check/fix, prettier_check/fix, jest_run, tsc_check, lint_all, lint_twig, unit_setup, vite_build, worktree_prepare, set_project_root, cwd +js-storefront-tooling: eslint_check/fix, stylelint_check/fix, jest_run, vitest_run, ludtwig_check/fix, webpack_build, worktree_prepare, set_project_root, cwd + +Every tool except cwd takes an optional project_root to target a linked git worktree of the launch root, in every environment; refusals name what to fix and how. After EnterWorktree/ExitWorktree, call set_project_root (with the worktree path, or with none to clear) on all three servers — each is a separate process with its own sticky value; cwd reports a server's current state. A fresh worktree lacks vendor/ and node_modules — call worktree_prepare on the refusing server instead of provisioning by hand, and NEVER symlink vendor/ from the launch tree (composer's autoloaders resolve through the symlink to the launch tree's classes). Storefront tests are split across two runners: jest_run covers the app/storefront package suite, vitest_run covers the component suite under src/Storefront/Resources/views/components/. jest_run rejects views/components patterns. diff --git a/plugins/dev-tooling/hooks/scripts/worktree-directives.sh b/plugins/dev-tooling/hooks/scripts/worktree-directives.sh new file mode 100755 index 00000000..2e0a4867 --- /dev/null +++ b/plugins/dev-tooling/hooks/scripts/worktree-directives.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# Claude Code Hook: Worktree Project-Root Directives (PostToolUse) +# ================================================================ +# After EnterWorktree or ExitWorktree, reminds the session to repoint the three +# dev-tooling MCP servers. Each server is a separate process holding its own +# sticky project root, so one set_project_root call never reaches the other two. +# +# Exit codes: +# 0 - always; input this hook cannot use leaves the session untouched + +set -euo pipefail + +INPUT=$(cat) + +# One jq process for the whole input. Every subprocess counts against the hook +# timeout, and this hook has no reason to spend more than one. +# +# The separator is the ASCII unit separator rather than a tab: a tab is an IFS +# whitespace character, so bash collapses a run of them into one delimiter and +# drops leading ones — an absent "worktreePath" would then shift "cwd" into its +# variable and the fallback would look like it never fired. \x1f is not IFS +# whitespace, so every empty field survives as an empty field. +# +# tool_response is type-tested before it is indexed: a tool that answers with a +# string rather than an object would otherwise abort the whole filter, and the +# hook would go quiet on exactly the shape change the diagnostic below exists to +# surface. +TOOL_NAME="" +RESPONSE_PATH="" +CWD_PATH="" +IFS=$'\x1f' read -r TOOL_NAME RESPONSE_PATH CWD_PATH < <( + printf '%s' "$INPUT" | jq -r '[ + (.tool_name // ""), + (if (.tool_response | type) == "object" then (.tool_response.worktreePath // "") else "" end), + (.cwd // "") + ] | join("")' 2>/dev/null +) || true + +if [[ -z "$TOOL_NAME" ]]; then + exit 0 +fi + +SERVERS="php-tooling, js-admin-tooling and js-storefront-tooling" + +case "$TOOL_NAME" in + EnterWorktree) + WORKTREE_PATH="$RESPONSE_PATH" + if [[ -z "$WORKTREE_PATH" ]]; then + WORKTREE_PATH="$CWD_PATH" + fi + + if [[ -z "$WORKTREE_PATH" ]]; then + # Named fields, so a changed response shape reads as this message + # rather than as a hook that quietly stopped firing. + MESSAGE="dev-tooling worktree hook: EnterWorktree carried no worktree path — neither \"tool_response.worktreePath\" nor \"cwd\" held one — so this reminder cannot name the directory. The dev-tooling MCP servers ${SERVERS} each still target the root they were launched in; call set_project_root on all three with the worktree path before running any dev-tooling tool." + else + MESSAGE="Worktree entered: ${WORKTREE_PATH}. The dev-tooling MCP servers ${SERVERS} each hold their own project root, so call set_project_root with project_root \"${WORKTREE_PATH}\" on all three before running any dev-tooling tool. Three separate processes, three separate sticky values — one call does not cover the others." + + # EnterWorktree shells out to `git worktree add`, which writes an + # absolute gitdir pointer unless the repository sets + # worktree.useRelativePaths — and the servers refuse an absolute + # pointer in every environment. Naming the repair here saves the + # session the refusal round-trip on its first tool call. + GITDIR_LINE="" + if [[ -f "${WORKTREE_PATH}/.git" ]]; then + # `read` populates the variable and still returns 1 at EOF on a + # final line without a trailing newline, so the status must not + # clear what it read. + IFS= read -r GITDIR_LINE < "${WORKTREE_PATH}/.git" || true + GITDIR_LINE="${GITDIR_LINE%$'\r'}" + fi + case "$GITDIR_LINE" in + "gitdir: /"*) + MESSAGE="${MESSAGE} This worktree's \".git\" file carries an absolute gitdir pointer, which the dev-tooling servers refuse in every environment; relink it first with \`git -c worktree.useRelativePaths=true worktree repair \"${WORKTREE_PATH}\"\` (needs git 2.48 or newer)." + ;; + esac + + MESSAGE="${MESSAGE} A worktree created by EnterWorktree branches from the repository's default remote branch unless the Claude Code setting worktree.baseRef is \"head\" — confirm the worktree carries the code you mean to test before running tools against it." + fi + ;; + ExitWorktree) + MESSAGE="Worktree exited. Call set_project_root with no project_root argument on each of the dev-tooling MCP servers ${SERVERS}, so all three return to the root they were launched in. Three separate processes, three separate sticky values — a sticky root left behind names a directory that may no longer exist." + ;; + *) + exit 0 + ;; +esac + +CONTEXT=$(printf '%s' "$MESSAGE" | jq -Rs '.') +cat </dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + worktree_assert_dependencies || return 1 + local mode mode=$(echo "${args}" | jq -r '.mode // "production"') diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/eslint.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/eslint.sh index cd3e9bf4..05fb0587 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/eslint.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/eslint.sh @@ -53,6 +53,10 @@ _eslint_dispatch_admin() { return 1 fi + # The caller's own spelling is what is checked, before the rebase below + # strips a package prefix off it. + worktree_assert_paths_within_root "${paths_json}" || return 1 + local body local gate_code=0 @@ -112,12 +116,17 @@ _eslint_dispatch_admin() { tool_eslint_check() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field eslint config) @@ -141,12 +150,17 @@ tool_eslint_check() { tool_eslint_fix() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field eslint config) diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh index a4fd6fb8..88480200 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh @@ -91,7 +91,16 @@ if ! declare -F _jest_install_if_missing >/dev/null; then flag=$(jq -r --arg name "${SCOPE_NAME}" '.scopes[$name].jest.install_if_missing // false' "${LINT_CONFIG_FILE}" 2>/dev/null || echo "false") [[ "${flag}" != "true" ]] && return 0 - local node_modules_path="${LINT_WORKDIR}/${SCOPE_CWD}/${SCOPE_JS_SUBDIR}/node_modules" + # The [[ -d ]] runs on the HOST, so it has to measure a host path: + # LINT_WORKDIR is environment-side under a container (and a literal + # sentinel under docker-compose), so composing from it made this test + # always false there and every scoped run paid a full `npm ci` first. + # Same host-root rebinding _worktree_dependency_workdir uses. + local inherited_workdir="${LINT_WORKDIR:-}" host_js_dir + LINT_WORKDIR="${WORKTREE_EFFECTIVE_ROOT:-${PROJECT_ROOT:-}}" + host_js_dir=$(get_js_workdir) + LINT_WORKDIR="${inherited_workdir}" + local node_modules_path="${host_js_dir}/node_modules" [[ -d "${node_modules_path}" ]] && return 0 log "INFO" "Jest install_if_missing: running npm ci in ${node_modules_path%/node_modules}" @@ -240,6 +249,8 @@ _admin_jest_report_result() { tool_jest_run() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then @@ -249,11 +260,16 @@ tool_jest_run() { SCOPE_JS_SUBDIR="" if [[ "${SCOPE_NAME}" != "shopware" ]]; then + # shellcheck disable=SC2034 # read by get_js_workdir in shared/environment.sh SCOPE_JS_SUBDIR=$(scope_get_tool_field jest cwd) fi _jest_install_if_missing || return 1 + # After the opt-in install, which is what creates the node_modules this + # check would otherwise refuse the run over. + worktree_assert_dependencies || return 1 + local env_prefix env_prefix=$(_jest_scope_env_prefix) || { printf '%s\n' "${env_prefix}"; return 1; } diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/lint-all.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/lint-all.sh index f0ed968b..2125b03d 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/lint-all.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/lint-all.sh @@ -4,6 +4,19 @@ # tool_lint_all - runs TypeScript, ESLint, Stylelint, and Prettier via npm run lint:all. tool_lint_all() { + local args="${1:-}" + + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + worktree_assert_dependencies || return 1 + local cmd="npm run lint:all" log "INFO" "Running all lint checks (admin): ${cmd}" @@ -13,6 +26,19 @@ tool_lint_all() { # tool_lint_twig - ESLint check for Admin Vue Twig templates (.html.twig files) via npm run lint:twig. tool_lint_twig() { + local args="${1:-}" + + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + worktree_assert_dependencies || return 1 + local cmd="npm run lint:twig" log "INFO" "Running Twig template linting (admin): ${cmd}" @@ -23,6 +49,19 @@ tool_lint_twig() { # Regenerate component import resolver map for Jest # Run this when tests fail with import/module resolution errors tool_unit_setup() { + local args="${1:-}" + + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + worktree_assert_dependencies || return 1 + local cmd="npm run unit-setup" log "INFO" "Running unit test setup (admin): ${cmd}" diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/prepare.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/prepare.sh new file mode 100644 index 00000000..5ac109f6 --- /dev/null +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/prepare.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Worktree dependency provisioning for the Administration JS MCP server. +# +# A linked worktree holds tracked files only, so the launch tree's +# node_modules is not in it and every npm-backed tool refuses until it exists. +# This tool installs it in the Administration package directory through the +# same environment wrapping the refusing tools use — under a container +# environment the install has to run inside the container at the mapped path, +# which a session on the host cannot do on its own. +# +# Jest additionally needs two generated artifacts after this install: the +# component import resolver map (unit_setup) and the gitignored +# test/_mocks_/entity-schema.json, which unit_setup does NOT regenerate — it +# comes from console_run on the php-tooling server +# (`framework:schema -s entity-schema `). +# +# The scope is resolved here, unlike the PHP copy: the npm install and the JS +# dependency gate both derive their directory through get_js_workdir, so under +# a scope they converge on the same package directory. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true + +# tool_worktree_prepare - MCP tool function +tool_worktree_prepare() { + local args="${1:-}" + + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + # Deliberately no worktree_assert_dependencies: this tool installs what + # that gate demands, so running the gate here would refuse every call the + # tool exists for. + worktree_prepare_execute "npm ci" exec_npm_command "admin js" +} diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh index 428e4d0d..d19e23b7 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh @@ -57,6 +57,8 @@ _prettier_dispatch_admin() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local body local gate_code=0 @@ -127,6 +129,8 @@ _prettier_dispatch_admin() { tool_prettier_check() { local args="${1:-}" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then @@ -134,6 +138,8 @@ tool_prettier_check() { return 1 fi + worktree_assert_dependencies || return 1 + _prettier_dispatch_admin "format" "prettier_check" "--check" "${args}" } @@ -142,6 +148,8 @@ tool_prettier_check() { tool_prettier_fix() { local args="${1:-}" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then @@ -149,5 +157,7 @@ tool_prettier_fix() { return 1 fi + worktree_assert_dependencies || return 1 + _prettier_dispatch_admin "format:fix" "prettier_fix" "--write" "${args}" } diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh index aac12f2c..7772cf7c 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh @@ -63,6 +63,8 @@ _stylelint_dispatch_admin() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local body local gate_code=0 @@ -146,12 +148,17 @@ _stylelint_dispatch_admin() { tool_stylelint_check() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field stylelint config) @@ -177,12 +184,17 @@ tool_stylelint_check() { tool_stylelint_fix() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field stylelint config) diff --git a/plugins/dev-tooling/mcp-server-js-admin/lib/tsc.sh b/plugins/dev-tooling/mcp-server-js-admin/lib/tsc.sh index b7fa1585..005ec781 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/tsc.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/tsc.sh @@ -8,12 +8,17 @@ tool_tsc_check() { local args="${1:-}" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field tsc config) diff --git a/plugins/dev-tooling/mcp-server-js-admin/server.sh b/plugins/dev-tooling/mcp-server-js-admin/server.sh index c862494f..69cb6026 100755 --- a/plugins/dev-tooling/mcp-server-js-admin/server.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/server.sh @@ -16,6 +16,8 @@ # - jest_run: Run Jest tests # - unit_setup: Fix Jest import resolution # - vite_build: Build with Vite +# - set_project_root: Set or clear the sticky project root +# - cwd: Report the project root, working directory and config in use # # Supports environments: native, docker, docker-compose, vagrant, ddev # @@ -63,6 +65,15 @@ source "${SHARED_DIR}/scope.sh" if ! scope_validate; then exit 1 fi +source "${SHARED_DIR}/worktree.sh" +if ! worktree_state_init; then + exit 1 +fi + +# One EXIT trap doing both jobs. A second `trap ... EXIT` would replace the one +# config.sh installs at source time, leaving the merged-config temp file behind. +trap 'worktree_state_cleanup; config_cleanup' EXIT + source "${SCRIPT_DIR}/lib/eslint.sh" source "${SCRIPT_DIR}/lib/stylelint.sh" source "${SCRIPT_DIR}/lib/prettier.sh" @@ -70,6 +81,7 @@ source "${SCRIPT_DIR}/lib/jest.sh" source "${SCRIPT_DIR}/lib/tsc.sh" source "${SCRIPT_DIR}/lib/lint-all.sh" source "${SCRIPT_DIR}/lib/build.sh" +source "${SCRIPT_DIR}/lib/prepare.sh" trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR @@ -86,4 +98,4 @@ log "INFO" "Working dir: ${LINT_WORKDIR}" log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" log "INFO" "======================================" -run_mcp_server "$@" +source "${SHARED_DIR}/server_run.sh" diff --git a/plugins/dev-tooling/mcp-server-js-admin/tools.json b/plugins/dev-tooling/mcp-server-js-admin/tools.json index a920aa44..397bfca4 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/tools.json +++ b/plugins/dev-tooling/mcp-server-js-admin/tools.json @@ -6,6 +6,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -26,7 +30,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -35,6 +40,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -46,7 +55,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -55,6 +65,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -76,7 +90,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -85,6 +100,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -96,7 +115,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -105,6 +125,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -116,7 +140,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -125,6 +150,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -136,7 +165,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -145,6 +175,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "testPathPatterns": { "type": "string", "description": "Regex pattern for test file paths (--testPathPatterns)" @@ -172,7 +206,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -181,11 +216,16 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "scope": { "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -194,11 +234,16 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "scope": { "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -207,11 +252,16 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "scope": { "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -220,11 +270,16 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "scope": { "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -233,6 +288,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "mode": { "type": "string", "enum": [ @@ -246,7 +305,49 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false + } + }, + { + "name": "worktree_prepare", + "description": "Runs `npm ci` in the Administration package directory the npm-backed tools use (scope-aware, same directory the dependency gate checks) through the configured environment. Run when a tool refuses for missing node_modules, and again after package-lock.json changes. Jest additionally needs unit_setup plus the gitignored entity-schema mock — console_run on php-tooling with env prod: `framework:schema -s entity-schema src/Administration/Resources/app/administration/test/_mocks_/entity-schema.json`.", + "inputSchema": { + "type": "object", + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, + "scope": { + "type": "string", + "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." + } + }, + "additionalProperties": false + } + }, + { + "name": "set_project_root", + "description": "Set the project root every later call on this server targets, or clear it. Pass a linked git worktree of the root the server was launched in to move the server onto that tree; omit project_root to return it to the launch root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", + "inputSchema": { + "type": "object", + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. Omit to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + } + }, + "additionalProperties": false + } + }, + { + "name": "cwd", + "description": "Report where this server resolves to: the effective project root and whether it came from set_project_root or from the launch, whether that root still resolves, the working directory, the environment, and the configuration files in use. Takes no parameters. Validates nothing and never fails, so it also reports a project root that has been removed.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false } } ] diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/build.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/build.sh index 651723ed..13d4afaf 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/build.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/build.sh @@ -8,6 +8,17 @@ tool_webpack_build() { local args="$1" + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + worktree_assert_dependencies || return 1 + local mode mode=$(echo "${args}" | jq -r '.mode // "production"') diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh index cb345f57..bd55277f 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh @@ -113,6 +113,12 @@ _eslint_dispatch() { return 1 fi + # The caller's own spelling is what is checked, before the routing below + # rebases it onto one of the two trees. Only absolute paths are examined, so + # the upward-traversing relative form the components tree is reached through + # is untouched. + worktree_assert_paths_within_root "${paths_json}" || return 1 + if [[ -z "${paths}" ]]; then # A no-path run executes the aggregate script bare. That script chains # bare `npm run` calls, so a flags-only append lands past npm's own CLI @@ -173,12 +179,17 @@ _eslint_dispatch() { tool_eslint_check() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field eslint config) @@ -202,12 +213,17 @@ tool_eslint_check() { tool_eslint_fix() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field eslint config) diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/jest.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/jest.sh index 37e0967f..a4899095 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/jest.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/jest.sh @@ -97,7 +97,16 @@ if ! declare -F _jest_install_if_missing >/dev/null; then flag=$(jq -r --arg name "${SCOPE_NAME}" '.scopes[$name].jest.install_if_missing // false' "${LINT_CONFIG_FILE}" 2>/dev/null || echo "false") [[ "${flag}" != "true" ]] && return 0 - local node_modules_path="${LINT_WORKDIR}/${SCOPE_CWD}/${SCOPE_JS_SUBDIR}/node_modules" + # The [[ -d ]] runs on the HOST, so it has to measure a host path: + # LINT_WORKDIR is environment-side under a container (and a literal + # sentinel under docker-compose), so composing from it made this test + # always false there and every scoped run paid a full `npm ci` first. + # Same host-root rebinding _worktree_dependency_workdir uses. + local inherited_workdir="${LINT_WORKDIR:-}" host_js_dir + LINT_WORKDIR="${WORKTREE_EFFECTIVE_ROOT:-${PROJECT_ROOT:-}}" + host_js_dir=$(get_js_workdir) + LINT_WORKDIR="${inherited_workdir}" + local node_modules_path="${host_js_dir}/node_modules" [[ -d "${node_modules_path}" ]] && return 0 log "INFO" "Jest install_if_missing: running npm ci in ${node_modules_path%/node_modules}" @@ -246,6 +255,8 @@ _storefront_jest_report_result() { tool_jest_run() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then @@ -255,11 +266,16 @@ tool_jest_run() { SCOPE_JS_SUBDIR="" if [[ "${SCOPE_NAME}" != "shopware" ]]; then + # shellcheck disable=SC2034 # read by get_js_workdir in shared/environment.sh SCOPE_JS_SUBDIR=$(scope_get_tool_field jest cwd) fi _jest_install_if_missing || return 1 + # After the opt-in install, which is what creates the node_modules this + # check would otherwise refuse the run over. + worktree_assert_dependencies || return 1 + local env_prefix env_prefix=$(_jest_scope_env_prefix) || { printf '%s\n' "${env_prefix}"; return 1; } diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/ludtwig.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/ludtwig.sh index c9eff07d..22c690b9 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/ludtwig.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/ludtwig.sh @@ -8,17 +8,30 @@ # therefore not take appended arguments, so neither tool offers path scoping. # Run ludtwig from the project root, unscoped. -# Args: $1 = composer script name +# Both tools declare no parameter beyond "project_root" and reach their scope +# through this helper, so this is the single place their "project_root" can be +# resolved. Its stdout is the tool's stdout, which is what lets the banner be +# printed here. +# Args: $1 = composer script name, $2 = the tool call's arguments JSON _ludtwig_run() { local composer_script="$1" + local args="${2:-}" - # Pin the project root: SCOPE_CWD persists across tool calls in one server - # process, and ludtwig always runs against the core Storefront views tree. + worktree_enter "${args}" || return 1 + + # Pinned to the reserved scope rather than resolved from the call: a + # default_scope in the configuration would otherwise move the run into a + # plugin directory, and the composer script enters the core Storefront views + # tree relative to wherever composer is invoked. if ! resolve_scope "shopware"; then echo "Scope resolution error" return 1 fi + # This server sets JS_CONTEXT, but both tools run composer, so the + # dependency that has to be installed is the PHP one. + worktree_assert_dependencies php || return 1 + local cmd="composer ${composer_script}" log "INFO" "Running ludtwig (storefront): ${cmd}" @@ -27,11 +40,13 @@ _ludtwig_run() { } # ludtwig check (dry-run) +# Args: $1 = JSON arguments, carrying project_root only tool_ludtwig_check() { - _ludtwig_run "ludtwig:storefront" + _ludtwig_run "ludtwig:storefront" "${1:-}" } # ludtwig fix (auto-fix violations) +# Args: $1 = JSON arguments, carrying project_root only tool_ludtwig_fix() { - _ludtwig_run "ludtwig:storefront:fix" + _ludtwig_run "ludtwig:storefront:fix" "${1:-}" } diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/prepare.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/prepare.sh new file mode 100644 index 00000000..72ec3333 --- /dev/null +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/prepare.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Worktree dependency provisioning for the Storefront JS MCP server. +# +# A linked worktree holds tracked files only, so the launch tree's +# node_modules is not in it and every npm-backed tool refuses until it exists. +# This tool installs it in the Storefront package directory through the same +# environment wrapping the refusing tools use — under a container environment +# the install has to run inside the container at the mapped path, which a +# session on the host cannot do on its own. +# +# ludtwig is the one tool on this server it does not provision for: ludtwig +# runs composer, so its vendor/ comes from worktree_prepare on the php-tooling +# server. +# +# The scope is resolved here, unlike the PHP copy: the npm install and the JS +# dependency gate both derive their directory through get_js_workdir, so under +# a scope they converge on the same package directory. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true + +# tool_worktree_prepare - MCP tool function +tool_worktree_prepare() { + local args="${1:-}" + + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + # Deliberately no worktree_assert_dependencies: this tool installs what + # that gate demands, so running the gate here would refuse every call the + # tool exists for. + worktree_prepare_execute "npm ci" exec_npm_command "storefront js" +} diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh index 3de543ae..690ab422 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh @@ -64,6 +64,8 @@ _stylelint_dispatch_storefront() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local body local gate_code=0 @@ -147,12 +149,17 @@ _stylelint_dispatch_storefront() { tool_stylelint_check() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field stylelint config) @@ -178,12 +185,17 @@ tool_stylelint_check() { tool_stylelint_fix() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field stylelint config) diff --git a/plugins/dev-tooling/mcp-server-js-storefront/lib/vitest.sh b/plugins/dev-tooling/mcp-server-js-storefront/lib/vitest.sh index 1df5b34d..17ff9ad0 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/vitest.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/vitest.sh @@ -28,6 +28,8 @@ _vitest_rebase_component_path() { tool_vitest_run() { local args="$1" + worktree_enter "${args}" || return 1 + local scope_arg scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") if ! resolve_scope "${scope_arg}"; then @@ -41,6 +43,8 @@ tool_vitest_run() { SCOPE_JS_SUBDIR=$(scope_get_tool_field jest cwd) fi + worktree_assert_dependencies || return 1 + local coverage coverage=$(echo "${args}" | jq -r '.coverage // false') @@ -72,6 +76,10 @@ tool_vitest_run() { return 1 fi + # The caller's own spelling is what is checked, before the rebase below + # moves it onto the components tree. + worktree_assert_paths_within_root "${paths_json}" || return 1 + if [[ ${#flags[@]} -eq 0 && -z "${paths}" ]]; then local bare_cmd="npm run ${script}" log "INFO" "Running Vitest (storefront components): ${bare_cmd}" diff --git a/plugins/dev-tooling/mcp-server-js-storefront/server.sh b/plugins/dev-tooling/mcp-server-js-storefront/server.sh index 35b9cf2f..1f874ff7 100755 --- a/plugins/dev-tooling/mcp-server-js-storefront/server.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/server.sh @@ -16,6 +16,8 @@ # - ludtwig_check: Run ludtwig on Storefront Twig templates # - ludtwig_fix: Auto-fix ludtwig violations # - webpack_build: Build with Webpack +# - set_project_root: Set or clear the sticky project root +# - cwd: Report the project root, working directory and config in use # # Supports environments: native, docker, docker-compose, vagrant, ddev # @@ -63,12 +65,22 @@ source "${SHARED_DIR}/scope.sh" if ! scope_validate; then exit 1 fi +source "${SHARED_DIR}/worktree.sh" +if ! worktree_state_init; then + exit 1 +fi + +# One EXIT trap doing both jobs. A second `trap ... EXIT` would replace the one +# config.sh installs at source time, leaving the merged-config temp file behind. +trap 'worktree_state_cleanup; config_cleanup' EXIT + source "${SCRIPT_DIR}/lib/eslint.sh" source "${SCRIPT_DIR}/lib/stylelint.sh" source "${SCRIPT_DIR}/lib/jest.sh" source "${SCRIPT_DIR}/lib/vitest.sh" source "${SCRIPT_DIR}/lib/ludtwig.sh" source "${SCRIPT_DIR}/lib/build.sh" +source "${SCRIPT_DIR}/lib/prepare.sh" trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR @@ -85,4 +97,4 @@ log "INFO" "Working dir: ${LINT_WORKDIR}" log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" log "INFO" "======================================" -run_mcp_server "$@" +source "${SHARED_DIR}/server_run.sh" diff --git a/plugins/dev-tooling/mcp-server-js-storefront/tools.json b/plugins/dev-tooling/mcp-server-js-storefront/tools.json index 4336d054..b259aa9f 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/tools.json +++ b/plugins/dev-tooling/mcp-server-js-storefront/tools.json @@ -6,6 +6,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -26,7 +30,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -35,6 +40,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -46,7 +55,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -55,6 +65,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -76,7 +90,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -85,6 +100,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -96,7 +115,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -105,6 +125,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "testPathPatterns": { "type": "string", "description": "Regex pattern for test file paths (--testPathPatterns). Patterns targeting views/components/ are rejected; use vitest_run instead." @@ -132,7 +156,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -141,6 +166,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { @@ -166,7 +195,8 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -174,7 +204,13 @@ "description": "ludtwig check (dry-run) for Storefront Twig templates. Runs composer ludtwig:storefront from the project root against src/Storefront/Resources/views. Path scoping is not offered: the composer script body is not able to receive appended arguments.", "inputSchema": { "type": "object", - "properties": {} + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + } + }, + "additionalProperties": false } }, { @@ -182,7 +218,13 @@ "description": "ludtwig auto-fix for Storefront Twig templates. Runs composer ludtwig:storefront:fix from the project root against src/Storefront/Resources/views. Path scoping is not offered: the composer script body is not able to receive appended arguments.", "inputSchema": { "type": "object", - "properties": {} + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + } + }, + "additionalProperties": false } }, { @@ -191,6 +233,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "mode": { "type": "string", "enum": [ @@ -204,7 +250,49 @@ "type": "string", "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false + } + }, + { + "name": "worktree_prepare", + "description": "Runs `npm ci` in the Storefront package directory the npm-backed tools use (scope-aware, same directory the dependency gate checks) through the configured environment. Run when a tool refuses for missing node_modules, and again after package-lock.json changes. ludtwig's vendor/ comes from worktree_prepare on php-tooling instead.", + "inputSchema": { + "type": "object", + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, + "scope": { + "type": "string", + "description": "Scope name from .mcp-js-tooling.json. Omit to use the configured default_scope. Pass \"shopware\" to force project-root behavior." + } + }, + "additionalProperties": false + } + }, + { + "name": "set_project_root", + "description": "Set the project root every later call on this server targets, or clear it. Pass a linked git worktree of the root the server was launched in to move the server onto that tree; omit project_root to return it to the launch root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", + "inputSchema": { + "type": "object", + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. Omit to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + } + }, + "additionalProperties": false + } + }, + { + "name": "cwd", + "description": "Report where this server resolves to: the effective project root and whether it came from set_project_root or from the launch, whether that root still resolves, the working directory, the environment, and the configuration files in use. Takes no parameters. Validates nothing and never fails, so it also reports a project root that has been removed.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false } } ] diff --git a/plugins/dev-tooling/mcp-server-php/lib/console.sh b/plugins/dev-tooling/mcp-server-php/lib/console.sh index 2c1941c0..4e4fb3d0 100644 --- a/plugins/dev-tooling/mcp-server-php/lib/console.sh +++ b/plugins/dev-tooling/mcp-server-php/lib/console.sh @@ -43,9 +43,26 @@ _console_resolve_output_file() { -*) value="./${value}" ;; esac + # Against the effective project root rather than $PWD. A launch-root call + # returns from worktree_resolve_root without changing directory, so $PWD is + # still the directory the server process was started in, which need not be + # the project root at all — the same divergence the native wrapper stopped + # relying on. CHANGELOG 3.19.0 states the project root as this parameter's + # contract, and worktree_enter has already run by the time this is called. local resolved="${value}" if [[ "${resolved}" != /* ]]; then - resolved="${PWD}/${resolved}" + resolved="${WORKTREE_EFFECTIVE_ROOT}/${resolved}" + fi + + # A linked git worktree's ".git" is a REGULAR FILE holding the worktree's + # identity, so it passes the regular-file test below and the atomic write + # would replace it. Every later project_root validation of that tree then + # fails, because the identity the checks read is gone. Refused whether or + # not it exists: creating one where none exists leaves a directory that + # looks like a broken worktree. + if [[ "${resolved##*/}" == ".git" ]]; then + printf '%s\n' "Refusing to run: \"output_file\" \"${resolved}\" targets a \".git\" entry. In a linked git worktree that file holds the worktree's identity, and overwriting it breaks the worktree." + return 1 fi if [[ -L "${resolved}" ]]; then @@ -89,6 +106,24 @@ _console_run_to_file() { return 1 } + # wrap_command's native branch carries no working directory: the directory + # is handed to cd as a quoted argument instead of being written into the + # command string, which is what lets a project root holding a space or a + # metacharacter work at all. exec_command enters it for every other tool; + # this path evals the wrapped command itself, so it has to do the same, with + # the same expression exec_command uses. + local workdir="${LINT_WORKDIR}${SCOPE_CWD:+/${SCOPE_CWD}}" + + # Probed here, before any temp file exists, so an unusable working directory + # answers as this function's own refusal rather than as a failed command. + # The command substitution confines the probe's own cd to a subshell that + # ends with it, which is why the run below has to enter the directory again. + local enter_error + if ! enter_error=$(_enter_command_workdir "${workdir}"); then + printf '%s\n' "${enter_error}" + return 1 + fi + log "INFO" "Executing: ${wrapped}" local tmp @@ -110,8 +145,12 @@ _console_run_to_file() { # it forever instead of seeing EOF. # The eval runs in a subshell so that an `exit` reached inside the wrapped # command ends that subshell rather than the server; exec_command gets the - # same containment from the command substitution it assigns through. - ( eval "${wrapped}" ) "${tmp}" 2>"${err_file}" || exit_code=$? + # same containment from the command substitution it assigns through. The + # working-directory change is confined to that same subshell. Its stdout is + # the target file here, so the helper's message is discarded — the probe + # above already reported it — and the `&&` is what keeps a failed cd from + # falling through to running the command in the wrong tree. + ( _enter_command_workdir "${workdir}" >/dev/null && eval "${wrapped}" ) "${tmp}" 2>"${err_file}" || exit_code=$? local stderr_text stderr_text=$(_filter_env_noise < "${err_file}") @@ -166,6 +205,11 @@ _console_run_to_file() { tool_console_run() { local args="$1" + # The banner is safe ahead of the "output_file" route: _console_run_to_file + # redirects the wrapped command's own stdout into the target file, so + # nothing written to this function's stdout can reach it. + worktree_enter "${args}" || return 1 + _refuse_linebreak_args "${args}" || return 1 local scope_arg @@ -175,6 +219,8 @@ tool_console_run() { return 1 fi + worktree_assert_dependencies || return 1 + local default_env default_verbosity default_no_debug default_no_interaction default_env=$(_get_config_value ".console.env") default_verbosity=$(_get_config_value ".console.verbosity") @@ -367,6 +413,10 @@ _format_console_list_llm() { tool_console_list() { local args="$1" + # The banner is safe ahead of the "llm" route: that route parses raw_output, + # captured from exec_command, and never reads this function's stdout. + worktree_enter "${args}" || return 1 + _refuse_linebreak_args "${args}" || return 1 local scope_arg @@ -376,6 +426,8 @@ tool_console_list() { return 1 fi + worktree_assert_dependencies || return 1 + local parsed if ! parsed=$(echo "${args}" | jq -c '{ namespace: (.namespace // null), diff --git a/plugins/dev-tooling/mcp-server-php/lib/ecs.sh b/plugins/dev-tooling/mcp-server-php/lib/ecs.sh index d3460d8a..9ef9992f 100755 --- a/plugins/dev-tooling/mcp-server-php/lib/ecs.sh +++ b/plugins/dev-tooling/mcp-server-php/lib/ecs.sh @@ -42,6 +42,8 @@ _style_config_default() { tool_ecs_check() { local args="$1" + worktree_enter "${args}" || return 1 + _refuse_linebreak_args "${args}" || return 1 local scope_arg @@ -51,6 +53,8 @@ tool_ecs_check() { return 1 fi + worktree_assert_dependencies || return 1 + local default_config backend default_config=$(_style_config_default) backend=$(_style_backend) @@ -80,6 +84,8 @@ tool_ecs_check() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local guard if [[ -n "${config}" ]] && ! guard=$(assert_no_shell_hostile_chars "style configuration" "${config}"); then printf '%s\n' "${guard}" @@ -115,6 +121,8 @@ tool_ecs_check() { tool_ecs_fix() { local args="$1" + worktree_enter "${args}" || return 1 + _refuse_linebreak_args "${args}" || return 1 local scope_arg @@ -124,6 +132,8 @@ tool_ecs_fix() { return 1 fi + worktree_assert_dependencies || return 1 + local default_config backend default_config=$(_style_config_default) backend=$(_style_backend) @@ -151,6 +161,8 @@ tool_ecs_fix() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local guard if [[ -n "${config}" ]] && ! guard=$(assert_no_shell_hostile_chars "style configuration" "${config}"); then printf '%s\n' "${guard}" diff --git a/plugins/dev-tooling/mcp-server-php/lib/phpstan.sh b/plugins/dev-tooling/mcp-server-php/lib/phpstan.sh index 530e02ee..7237c7eb 100755 --- a/plugins/dev-tooling/mcp-server-php/lib/phpstan.sh +++ b/plugins/dev-tooling/mcp-server-php/lib/phpstan.sh @@ -30,6 +30,8 @@ _run_scope_bootstrap() { tool_phpstan_analyze() { local args="$1" + worktree_enter "${args}" || return 1 + if echo "${args}" | jq -e 'any((.. | strings), (.. | objects | keys[]); contains("\n") or contains("\r"))' >/dev/null 2>&1; then printf '%s\n' "Refusing to run: arguments contain a line break, which cannot be embedded in a single command." return 1 @@ -42,6 +44,8 @@ tool_phpstan_analyze() { return 1 fi + worktree_assert_dependencies || return 1 + if ! _run_scope_bootstrap phpstan; then return 1 fi @@ -81,6 +85,8 @@ tool_phpstan_analyze() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local guard if [[ -n "${config}" ]] && ! guard=$(assert_no_shell_hostile_chars "PHPStan configuration" "${config}"); then printf '%s\n' "${guard}" diff --git a/plugins/dev-tooling/mcp-server-php/lib/phpunit.sh b/plugins/dev-tooling/mcp-server-php/lib/phpunit.sh index 75fb0020..e51dae78 100644 --- a/plugins/dev-tooling/mcp-server-php/lib/phpunit.sh +++ b/plugins/dev-tooling/mcp-server-php/lib/phpunit.sh @@ -10,6 +10,8 @@ shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+ tool_phpunit_run() { local args="$1" + worktree_enter "${args}" || return 1 + if echo "${args}" | jq -e 'any((.. | strings), (.. | objects | keys[]); contains("\n") or contains("\r"))' >/dev/null 2>&1; then printf '%s\n' "Refusing to run: arguments contain a line break, which cannot be embedded in a single command." return 1 @@ -21,6 +23,9 @@ tool_phpunit_run() { echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + local scoped_config scoped_config=$(scope_get_tool_field phpunit config) @@ -70,6 +75,8 @@ tool_phpunit_run() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local guard if [[ -n "${testsuite}" ]] && ! guard=$(assert_no_shell_hostile_chars "testsuite" "${testsuite}"); then printf '%s\n' "${guard}" diff --git a/plugins/dev-tooling/mcp-server-php/lib/phpunit_coverage.sh b/plugins/dev-tooling/mcp-server-php/lib/phpunit_coverage.sh index 5e9fc4c7..75f7bbe9 100644 --- a/plugins/dev-tooling/mcp-server-php/lib/phpunit_coverage.sh +++ b/plugins/dev-tooling/mcp-server-php/lib/phpunit_coverage.sh @@ -11,6 +11,17 @@ shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+ tool_phpunit_coverage_gaps() { local args="$1" + worktree_enter "${args}" || return 1 + + local scope_arg + scope_arg=$(echo "${args}" | jq -r '.scope // empty' 2>/dev/null || echo "") + if ! resolve_scope "${scope_arg}"; then + echo "Scope resolution error" + return 1 + fi + + worktree_assert_dependencies || return 1 + if echo "${args}" | jq -e 'any((.. | strings), (.. | objects | keys[]); contains("\n") or contains("\r"))' >/dev/null 2>&1; then printf '%s\n' "Refusing to run: arguments contain a line break, which cannot be embedded in a single command." return 1 diff --git a/plugins/dev-tooling/mcp-server-php/lib/prepare.sh b/plugins/dev-tooling/mcp-server-php/lib/prepare.sh new file mode 100644 index 00000000..8204679a --- /dev/null +++ b/plugins/dev-tooling/mcp-server-php/lib/prepare.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Worktree dependency provisioning for the PHP MCP server. +# +# A linked worktree holds tracked files only, so the launch tree's vendor/ is +# not in it and every composer-backed tool refuses until it exists. This tool +# installs it through the same environment wrapping the refusing tools use — +# under a container environment the install has to run inside the container at +# the mapped path, which a session on the host cannot do on its own. A vendor/ +# symlinked from the launch tree is not an alternative: composer's generated +# autoloaders derive their base directory from `__DIR__`, which resolves +# through the symlink, so the worktree would silently autoload the launch +# tree's classes. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true + +# tool_worktree_prepare - MCP tool function +tool_worktree_prepare() { + local args="${1:-}" + + worktree_enter "${args}" || return 1 + + # Deliberately no scope resolution and no worktree_assert_dependencies: + # this tool installs what that gate demands, at the directory the gate + # checks — the effective root. A resolved scope would send the install + # into the scope's cwd while the gate keeps measuring the root, so the + # refusal's named remedy could never clear the refusal. + worktree_prepare_execute "composer install --no-interaction" exec_command "php" +} diff --git a/plugins/dev-tooling/mcp-server-php/lib/rector.sh b/plugins/dev-tooling/mcp-server-php/lib/rector.sh index 9f2818d5..a14db54c 100644 --- a/plugins/dev-tooling/mcp-server-php/lib/rector.sh +++ b/plugins/dev-tooling/mcp-server-php/lib/rector.sh @@ -88,6 +88,8 @@ _parse_rector_args() { return 1 fi + worktree_assert_paths_within_root "${paths_json}" || return 1 + local guard if ! guard=$(assert_no_shell_hostile_chars "Rector output format" "${output_format}"); then printf '%s\n' "${guard}" @@ -115,6 +117,8 @@ _parse_rector_args() { tool_rector_fix() { local args="$1" + worktree_enter "${args}" || return 1 + _refuse_linebreak_args "${args}" || return 1 local scope_arg @@ -123,6 +127,9 @@ tool_rector_fix() { echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + if ! _run_scope_bootstrap rector; then return 1 fi @@ -152,6 +159,8 @@ tool_rector_fix() { tool_rector_check() { local args="$1" + worktree_enter "${args}" || return 1 + _refuse_linebreak_args "${args}" || return 1 local scope_arg @@ -160,6 +169,9 @@ tool_rector_check() { echo "Scope resolution error" return 1 fi + + worktree_assert_dependencies || return 1 + if ! _run_scope_bootstrap rector; then return 1 fi diff --git a/plugins/dev-tooling/mcp-server-php/server.sh b/plugins/dev-tooling/mcp-server-php/server.sh index 22bb7043..b0fdbc4a 100755 --- a/plugins/dev-tooling/mcp-server-php/server.sh +++ b/plugins/dev-tooling/mcp-server-php/server.sh @@ -1,12 +1,20 @@ #!/usr/bin/env bash # PHP Linting MCP Server -# Provides PHPStan, ECS, and PHPUnit tools via Model Context Protocol +# Provides PHPStan, ECS, PHPUnit, Symfony Console and Rector tools via Model +# Context Protocol # # Tools: # - phpstan_analyze: Run PHPStan static analysis # - ecs_check: Check coding standards (dry-run) # - ecs_fix: Fix coding standard violations # - phpunit_run: Run PHPUnit tests +# - phpunit_coverage_gaps: Report uncovered lines from a Clover report +# - console_run: Run a bin/console command +# - console_list: List available bin/console commands +# - rector_check: Preview Rector transformations (dry-run) +# - rector_fix: Apply Rector transformations +# - set_project_root: Set or clear the sticky project root +# - cwd: Report the project root, working directory and config in use # # Supports environments: native, docker, docker-compose, vagrant, ddev # @@ -47,12 +55,22 @@ source "${SHARED_DIR}/scope.sh" if ! scope_validate; then exit 1 fi +source "${SHARED_DIR}/worktree.sh" +if ! worktree_state_init; then + exit 1 +fi + +# One EXIT trap doing both jobs. A second `trap ... EXIT` would replace the one +# config.sh installs at source time, leaving the merged-config temp file behind. +trap 'worktree_state_cleanup; config_cleanup' EXIT + source "${SCRIPT_DIR}/lib/phpstan.sh" source "${SCRIPT_DIR}/lib/ecs.sh" source "${SCRIPT_DIR}/lib/phpunit.sh" source "${SCRIPT_DIR}/lib/phpunit_coverage.sh" source "${SCRIPT_DIR}/lib/console.sh" source "${SCRIPT_DIR}/lib/rector.sh" +source "${SCRIPT_DIR}/lib/prepare.sh" trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR @@ -69,4 +87,4 @@ log "INFO" "Working dir: ${LINT_WORKDIR}" log "INFO" "Extra log: ${MCP_EXTRA_LOG_FILE:-}" log "INFO" "======================================" -run_mcp_server "$@" +source "${SHARED_DIR}/server_run.sh" diff --git a/plugins/dev-tooling/mcp-server-php/tools.json b/plugins/dev-tooling/mcp-server-php/tools.json index 4c25c117..d242aca8 100644 --- a/plugins/dev-tooling/mcp-server-php/tools.json +++ b/plugins/dev-tooling/mcp-server-php/tools.json @@ -6,6 +6,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { "type": "string" } }, "level": { "type": "integer" }, "error_format": { "enum": ["table", "json", "raw"] }, @@ -15,7 +19,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -24,6 +29,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { "type": "string" } }, "output_format": { "enum": ["text", "json"] }, "config": { "type": "string", "description": "Config file path" }, @@ -31,7 +40,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -40,13 +50,18 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { "type": "string" } }, "config": { "type": "string", "description": "Config file path" }, "scope": { "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -55,6 +70,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "testsuite": { "type": "string", "description": "Test suite to run. Falls back to .lintrc.local.json phpunit.testsuite if not provided.", @@ -104,7 +123,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -113,6 +133,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "clover_path": { "type": "string", "description": "Path to the Clover XML file generated by phpunit_run with coverage_format=\"clover\". Default: 'coverage.xml'." @@ -125,7 +149,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -135,6 +160,10 @@ "type": "object", "required": ["command"], "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "command": { "type": "string", "description": "Console command name (e.g., 'cache:clear', 'theme:compile', 'plugin:install')" @@ -186,7 +215,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -195,6 +225,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "namespace": { "type": "string", "description": "Filter by command namespace (e.g., 'cache', 'theme', 'plugin', 'database')" @@ -209,7 +243,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -218,6 +253,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { "type": "string" }, @@ -249,7 +288,8 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false } }, { @@ -258,6 +298,10 @@ "inputSchema": { "type": "object", "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + }, "paths": { "type": "array", "items": { "type": "string" }, @@ -289,7 +333,45 @@ "type": "string", "description": "Scope name from .mcp-php-tooling.json. Omit to use the configured default_scope (or project-root behavior if none). Pass \"shopware\" to force project-root behavior." } - } + }, + "additionalProperties": false + } + }, + { + "name": "worktree_prepare", + "description": "Runs `composer install --no-interaction` at the target root — the directory the dependency gate checks; scopes deliberately do not redirect it. Run when a tool refuses for missing vendor/, and again after composer.json/composer.lock change. Never symlink vendor/ from the launch tree — composer's autoloaders resolve through the symlink to the launch tree's classes.", + "inputSchema": { + "type": "object", + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. The call runs against that tree. Omit to use the sticky root set by set_project_root, or the launch root when none is set. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + } + }, + "additionalProperties": false + } + }, + { + "name": "set_project_root", + "description": "Set the project root every later call on this server targets, or clear it. Pass a linked git worktree of the root the server was launched in to move the server onto that tree; omit project_root to return it to the launch root. Each of php-tooling, js-admin-tooling and js-storefront-tooling is a separate process and keeps its own setting, so call this on each server you use.", + "inputSchema": { + "type": "object", + "properties": { + "project_root": { + "type": "string", + "description": "Absolute path to a linked git worktree of the root this server was launched in. Omit to clear the setting and return the server to its launch root; the value being discarded is not validated, so this also recovers from a worktree that has been removed. Works in every environment: the call runs against that tree as the server's environment reaches it, and under docker, docker-compose, vagrant and ddev the worktree must sit inside the launch project root, which is the only tree the container or VM mounts." + } + }, + "additionalProperties": false + } + }, + { + "name": "cwd", + "description": "Report where this server resolves to: the effective project root and whether it came from set_project_root or from the launch, whether that root still resolves, the working directory, the environment, and the configuration files in use. Takes no parameters. Validates nothing and never fails, so it also reports a project root that has been removed.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false } } ] diff --git a/plugins/dev-tooling/shared/config.sh b/plugins/dev-tooling/shared/config.sh index 902bd5ba..e3550829 100644 --- a/plugins/dev-tooling/shared/config.sh +++ b/plugins/dev-tooling/shared/config.sh @@ -66,12 +66,16 @@ CONFIG_LOCATIONS=( _CONFIG_TEMP_FILE="" -_config_cleanup() { +# Removes the merged-config temp file _merge_configs created, if any. +# Installed below as the module's own EXIT trap; a caller whose EXIT trap +# runs after this one is sourced (e.g. worktree state cleanup) calls this +# directly so the removal still happens once that trap replaces this one. +config_cleanup() { if [[ -n "${_CONFIG_TEMP_FILE}" && -f "${_CONFIG_TEMP_FILE}" ]]; then rm -f "${_CONFIG_TEMP_FILE}" fi } -trap _config_cleanup EXIT +trap config_cleanup EXIT # Args: config file paths (merge order, later wins) # Returns: path to temp file with merged JSON diff --git a/plugins/dev-tooling/shared/docker-compose.sh b/plugins/dev-tooling/shared/docker-compose.sh index 025ecae0..48731af7 100644 --- a/plugins/dev-tooling/shared/docker-compose.sh +++ b/plugins/dev-tooling/shared/docker-compose.sh @@ -25,6 +25,39 @@ _compose_cmd() { echo "${cmd}" } +# Run one docker compose invocation from the launch root, and only from there. +# +# `docker compose` discovers its project from the working directory: the compose +# file it reads, the project name it derives and the sources its relative bind +# mounts resolve against all come from there. Every caller below builds a +# command string and eval's it, so which directory that runs in was until now +# whatever the caller happened to be in — and a worktree-targeted call runs its +# dispatch subshell inside the worktree. A worktree of a checkout whose compose +# file is tracked carries that file, with a relative `.:/var/www/html` bind and +# no `name:`, so the invocation would resolve the WORKTREE as the compose +# project: the service would read as absent and the call refused as +# not-running, or with a stable project name the container would be entered at +# the LAUNCH checkout's path while the banner names the worktree. +# +# Anchoring here rather than at each call site makes the project name, the file +# discovery and the bind sources launch-root facts whatever directory the call +# is running in, and leaves no site to forget. The cd is confined to a subshell +# so the caller's own working directory — the worktree it resolved — survives. +# Uses: PROJECT_ROOT +# Args: $1 = the docker compose invocation to run +# Stdout: the invocation's own output +# Returns: the invocation's own status, 1 when the launch root is not a directory +_compose_run() { + local invocation="$1" + + if [[ -z "${PROJECT_ROOT:-}" || ! -d "${PROJECT_ROOT}" ]]; then + printf '%s\n' "Refusing to run: the launch project root \"${PROJECT_ROOT:-}\" is not a directory, so \"docker compose\" cannot be run from it. Compose resolves its project from the working directory, so running it anywhere else would resolve a different one." + return 1 + fi + + ( cd "${PROJECT_ROOT}" && eval "${invocation}" ) +} + # Verify docker and docker compose are available. # Returns: 0 on success, 1 with error message on stdout on failure _compose_check_prerequisites() { @@ -54,7 +87,7 @@ _compose_resolve_container() { base_cmd=$(_compose_cmd) local ps_output - ps_output=$(eval "${base_cmd} ps --format json --status running" 2>&1) || { + ps_output=$(_compose_run "${base_cmd} ps --format json --status running" 2>&1) || { echo "Failed to query running containers: ${ps_output}" return 1 } @@ -70,9 +103,15 @@ _compose_resolve_container() { fi # Service not running — check if it exists in compose config + # + # The captured output is appended rather than discarded: this branch used to + # have one cause — `compose config` failing — so naming that cause was + # accurate. The anchored run below added a second one, a launch root that is + # no longer there, and a call refused by it would otherwise be told the + # service is not running while the real reason never reaches the caller. local config_output - config_output=$(eval "${base_cmd} config --format json" 2>&1) || { - echo "Service '${COMPOSE_SERVICE}' is not running. Start it with: docker compose up -d ${COMPOSE_SERVICE}" + config_output=$(_compose_run "${base_cmd} config --format json" 2>&1) || { + echo "Service '${COMPOSE_SERVICE}' is not running. Start it with: docker compose up -d ${COMPOSE_SERVICE}${config_output:+ The compose config read said: ${config_output}}" return 1 } @@ -91,14 +130,111 @@ _compose_resolve_container() { return 1 } -# Resolve the working directory inside the container for the configured service. -# If COMPOSE_WORKDIR_OVERRIDE is set, returns that directly. -# Otherwise, finds the bind mount whose source matches PROJECT_ROOT. -# Uses: COMPOSE_WORKDIR_OVERRIDE, COMPOSE_SERVICE, COMPOSE_FILE_OVERRIDE, PROJECT_ROOT +# The container path a host path is reached by: the destination of the longest +# bind-mount source that is the host path or an ancestor of it, followed by the +# remainder of the host path past that source. The longest match wins so a +# mount nested inside another decides its own subtree, and a path outside every +# mount is refused rather than answered with a shorter match's destination. +# +# Both sides are canonicalized before the prefix compare, because the compare +# is what decides the match and a spelling must not decide it. A compose file +# spells its bind-mount sources the way the shell that wrote it spelled them — +# on macOS often "/var/..." where the filesystem reaches "/private/var/..." — +# and the host path handed in carries whatever spelling its own caller used. +# Compared raw, one spelling of one directory reads as outside the other and a +# reachable root is refused. Canonicalizing both is idempotent for a value that +# was already resolved, which is what a worktree-targeted call passes. +# +# A source that cannot be canonicalized is skipped as a non-match rather than +# refused: another mount may still cover the host path, and refusing the whole +# call over one unusable entry would refuse a root that is reachable. +# Pure apart from that resolution: the mount list is an argument, so the match +# is covered directly. +# Args: $1 = host path, $2 = one "sourcetarget" pair per line, +# $3 = service name, for the refusal only +# Stdout: the container path, or the sentence naming the unmatched value +# Returns: 0 on a match, 1 when no bind mount covers the host path +_compose_match_mount() { + local host_path="$1" + local mounts="$2" + local service="$3" + + local host_root + host_root=$(_env_canonical_path "${host_path}") || { + printf '%s\n' "Cannot map ${host_path} on service '${service}': its location could not be resolved, so whether a bind mount covers it cannot be established." + return 1 + } + + while [[ "${host_root}" != "/" && "${host_root}" == */ ]]; do + host_root="${host_root%/}" + done + + local best_source="" + local best_target="" + local source target canonical_source + while IFS=$'\t' read -r source target; do + [[ -n "${source}" && -n "${target}" ]] || continue + + canonical_source=$(_env_canonical_path "${source}") || continue + source="${canonical_source}" + + while [[ "${source}" != "/" && "${source}" == */ ]]; do + source="${source%/}" + done + + # The separator is appended once here rather than written into the + # pattern: for a source of "/" the two would concatenate into "//*", + # which matches nothing and would refuse every path a root mount covers. + local prefix="${source}" + if [[ "${prefix}" != "/" ]]; then + prefix="${prefix}/" + fi + + if [[ "${host_root}" != "${source}" && "${host_root}" != "${prefix}"* ]]; then + continue + fi + + if [[ -z "${best_source}" || "${#source}" -gt "${#best_source}" ]]; then + best_source="${source}" + best_target="${target}" + fi + done <<< "${mounts}" + + if [[ -z "${best_source}" ]]; then + printf '%s\n' "No bind mount for ${host_path} found on service '${service}'. Set docker-compose.workdir in config." + return 1 + fi + + local remainder="${host_root#"${best_source}"}" + remainder="${remainder#/}" + + if [[ -z "${remainder}" ]]; then + printf '%s\n' "${best_target}" + return 0 + fi + + _env_join_workdir "${best_target}" "${remainder}" +} + +# Resolve the working directory inside the container for one host path. +# COMPOSE_WORKDIR_OVERRIDE names PROJECT_ROOT's container path, so it takes the +# place of the match there and the host path's suffix below PROJECT_ROOT is +# appended to it. Without an override the longest matching bind mount decides. +# Uses: COMPOSE_WORKDIR_OVERRIDE, COMPOSE_SERVICE, COMPOSE_FILE_OVERRIDE +# Args: $1 = host path, $2 = the launch-side base (default PROJECT_ROOT) # Returns: workdir path on stdout, or error message + return 1 -_compose_resolve_workdir() { +_compose_resolve_workdir_for() { + local host_root="$1" + local launch_base="${2:-${PROJECT_ROOT:-}}" + + local suffix + if ! suffix=$(_env_relative_suffix "${host_root}" "${launch_base}"); then + printf '%s\n' "${suffix}" + return 1 + fi + if [[ -n "${COMPOSE_WORKDIR_OVERRIDE}" ]]; then - echo "${COMPOSE_WORKDIR_OVERRIDE}" + _env_join_workdir "${COMPOSE_WORKDIR_OVERRIDE}" "${suffix}" return 0 fi @@ -106,24 +242,42 @@ _compose_resolve_workdir() { base_cmd=$(_compose_cmd) local config_output - config_output=$(eval "${base_cmd} config --format json" 2>&1) || { + config_output=$(_compose_run "${base_cmd} config --format json" 2>&1) || { echo "Failed to read compose config: ${config_output}" return 1 } - local workdir - workdir=$(echo "${config_output}" | jq -r --arg svc "${COMPOSE_SERVICE}" \ - --arg root "${PROJECT_ROOT}" \ - '.services[$svc].volumes // [] | .[] | select(.type == "bind" and .source == $root) | .target' \ - 2>/dev/null | head -1) + local mounts + mounts=$(echo "${config_output}" | jq -r --arg svc "${COMPOSE_SERVICE}" \ + '.services[$svc].volumes // [] | .[] | select(.type == "bind") | "\(.source)\t\(.target)"' \ + 2>/dev/null) || mounts="" - if [[ -n "${workdir}" ]]; then - echo "${workdir}" - return 0 - fi + _compose_match_mount "${host_root}" "${mounts}" "${COMPOSE_SERVICE}" +} - echo "No bind mount for ${PROJECT_ROOT} found on service '${COMPOSE_SERVICE}'. Set docker-compose.workdir in config." - return 1 +# Resolve the working directory inside the container for the root this call +# targets. +# +# That is WORKTREE_EFFECTIVE_ROOT when something set it — the worktree module +# does, to the root the call resolved — and PROJECT_ROOT otherwise. Without +# this the resolver always answers for the launch tree, so a call targeting a +# worktree would cd into the launch tree's container path and run there while +# the result is attributed to the worktree. A launch-root call reads the same +# value it did before, because the two are equal there. +# +# The worktree module also sets the canonical pair it resolved, and those are +# preferred when present: its validation measured containment between exactly +# those two paths, so the mapping has to answer for the same pair rather than +# for the raw spelling one of them carries. Both fall back to the values a +# server that has no worktree module leaves in place. +# Uses: COMPOSE_WORKDIR_OVERRIDE, COMPOSE_SERVICE, COMPOSE_FILE_OVERRIDE, +# PROJECT_ROOT, WORKTREE_EFFECTIVE_ROOT, WORKTREE_CANONICAL_ROOT, +# WORKTREE_CANONICAL_LAUNCH +# Returns: workdir path on stdout, or error message + return 1 +_compose_resolve_workdir() { + _compose_resolve_workdir_for \ + "${WORKTREE_CANONICAL_ROOT:-${WORKTREE_EFFECTIVE_ROOT:-${PROJECT_ROOT}}}" \ + "${WORKTREE_CANONICAL_LAUNCH:-${PROJECT_ROOT:-}}" } # Wrap a PHP/generic command for execution in the docker-compose environment. @@ -150,10 +304,11 @@ _compose_wrap_command() { } # Wrap an npm command for execution in the docker-compose environment. -# When SCOPE_CWD is set: base workdir + SCOPE_CWD [+ SCOPE_JS_SUBDIR]. -# Otherwise: base workdir + JS_CONTEXT-specific top-level path. +# The JS working directory is composed by get_js_workdir, the one place the +# scope and context suffixes are built, so this wrapper and wrap_npm_command's +# docker branch cannot drift apart. # Args: $1 = command to execute -# Uses: SCOPE_CWD, SCOPE_JS_SUBDIR, JS_CONTEXT (admin|storefront) +# Uses: SCOPE_CWD, SCOPE_JS_SUBDIR, JS_CONTEXT (admin|storefront), LINT_WORKDIR # Returns: wrapped command string on stdout, or error message + return 1 _compose_wrap_npm_command() { local cmd="$1" @@ -166,20 +321,14 @@ _compose_wrap_npm_command() { local base_workdir base_workdir=$(_compose_resolve_workdir) || { echo "${base_workdir}"; return 1; } - local workdir="${base_workdir}" - if [[ -n "${SCOPE_CWD:-}" ]]; then - workdir="${base_workdir}/${SCOPE_CWD}" - [[ -n "${SCOPE_JS_SUBDIR:-}" ]] && workdir="${workdir}/${SCOPE_JS_SUBDIR}" - else - case "${JS_CONTEXT:-}" in - "admin") - workdir="${base_workdir}/src/Administration/Resources/app/administration" - ;; - "storefront") - workdir="${base_workdir}/src/Storefront/Resources/app/storefront" - ;; - esac - fi + # LINT_WORKDIR holds the call-time sentinel under docker-compose, so the + # base resolved here is bound for the composition and given back: this + # function runs in the caller's shell, and the next command built from + # LINT_WORKDIR has to read the value that was in force before it. + local workdir inherited_workdir="${LINT_WORKDIR:-}" + LINT_WORKDIR="${base_workdir}" + workdir=$(get_js_workdir) + LINT_WORKDIR="${inherited_workdir}" printf '%s\n' "docker exec -i $(shell_quote_arg "${container}") bash -c 'cd ${workdir} && ${cmd}'" } diff --git a/plugins/dev-tooling/shared/environment.sh b/plugins/dev-tooling/shared/environment.sh old mode 100755 new mode 100644 index 07a27b61..3c845116 --- a/plugins/dev-tooling/shared/environment.sh +++ b/plugins/dev-tooling/shared/environment.sh @@ -35,25 +35,28 @@ _filter_env_noise() { sed "${sed_args[@]}" } -# Find first existing file from a list -# Args: $1 = directory, $2+ = filenames to check -# Returns: first found filename or empty string -_find_first_file() { - local dir="$1"; shift - for f in "$@"; do - [[ -f "${dir}/${f}" ]] && echo "${f}" && return - done - echo "" -} - _get_config_value() { local path="$1" local default="${2:-}" - [[ -f "${LINT_CONFIG_FILE}" ]] || { echo "${default}"; return 0; } + _config_value_from "${LINT_CONFIG_FILE:-}" "${path}" "${default}" +} + +# Read one value out of a named configuration file, or the default when the +# file or the key is absent. +# Parameterized by file rather than reading LINT_CONFIG_FILE, so a caller +# deriving a second configuration does not have to rebind the global first. +# Args: $1 = config file, $2 = jq path, $3 = default +# Stdout: the value, or the default +_config_value_from() { + local config_file="$1" + local path="$2" + local default="${3:-}" + + [[ -f "${config_file}" ]] || { echo "${default}"; return 0; } local value - value=$(jq -r "${path} // empty" "${LINT_CONFIG_FILE}" 2>/dev/null || echo "") + value=$(jq -r "${path} // empty" "${config_file}" 2>/dev/null || echo "") [[ -n "${value}" ]] && echo "${value}" || echo "${default}" } @@ -77,11 +80,26 @@ detect_environment() { fi LINT_ENV="${env_value}" - _set_workdir_from_config "${project_root}" "${LINT_CONFIG_FILE}" + + # This call is deliberately NOT wrapped in a command substitution: the + # assignments have to reach this shell. Its refusals reach stderr, which is + # not the JSON-RPC stream, so the exit below is what stops the server with a + # log line rather than with a malformed protocol frame. + if ! _set_workdir_from_config "${project_root}" "${LINT_CONFIG_FILE}" "${LINT_ENV}"; then + log "ERROR" "Could not derive the environment from ${LINT_CONFIG_FILE}: a field it needs is missing or empty. The refusal above names it." + exit 1 + fi + log "INFO" "Environment from config: ${LINT_ENV}" return 0 } +# The container name the docker environment runs commands in. +# A refusal goes to stderr rather than stdout: this is reached from +# detect_environment at startup, where stdout is the JSON-RPC stream. +# Args: $1 = config file +# Stdout: the container name on success +# Returns: 0 on success, 1 when the field is missing or empty _get_docker_container() { local config_file="$1" @@ -89,45 +107,397 @@ _get_docker_container() { container=$(jq -r '.docker.container // empty' "${config_file}" 2>/dev/null || echo "") if [[ -z "${container}" ]]; then - log "ERROR" "Docker environment requires 'docker.container' in config" - exit 1 + printf '%s\n' "Refusing to run: the \"docker\" environment requires a container name in \"${config_file}\", and the field \".docker.container\" is missing or empty." >&2 + return 1 fi - echo "${container}" + printf '%s\n' "${container}" } +# Derive the environment scalars from one configuration file, into the globals +# every command wrapper reads. +# +# It reads neither LINT_ENV nor LINT_CONFIG_FILE — both arrive as arguments — so +# the values it writes are those of the file it was handed. That is what makes +# it usable for a second configuration: startup derives the launch values once, +# and a tool call that selected another configuration re-derives them inside its +# own dispatch subshell, where the assignments die with that subshell and the +# server process keeps the values it started with. +# +# Every scalar is assigned on every path, empty when the environment does not +# use it, so a caller that re-derives never reads a value left over from the +# environment it is leaving. +# Args: +# $1 = the project root the scalars are derived for +# $2 = the configuration file to read +# $3 = the environment name from that file +# Stdout: nothing on success +# Stderr: the sentence naming the failure otherwise, matching +# _get_docker_container's refusal below — reachable from +# detect_environment, where stdout is the JSON-RPC stream and a +# sentence printed there would corrupt the protocol frame +# Returns: 0 on success, 1 when the config file or the environment is unusable _set_workdir_from_config() { local project_root="$1" local config_file="$2" + local environment="$3" - case "${LINT_ENV}" in + if [[ ! -f "${config_file}" ]]; then + printf '%s\n' "Refusing to run: the config file \"${config_file}\" does not exist, so the \"${environment}\" environment cannot be derived from it." >&2 + return 1 + fi + + if [[ -z "${environment}" ]]; then + printf '%s\n' "Refusing to run: the \"environment\" field is missing or empty in \"${config_file}\", so no environment can be derived from it." >&2 + return 1 + fi + + local workdir="" + local container="" + local compose_service="" + local compose_workdir="" + local compose_file="" + + case "${environment}" in docker) - LINT_WORKDIR=$(_get_config_value ".docker.workdir" "/var/www/html") - DOCKER_CONTAINER=$(_get_docker_container "${config_file}") + workdir=$(_config_value_from "${config_file}" ".docker.workdir" "/var/www/html") + if ! container=$(_get_docker_container "${config_file}"); then + return 1 + fi ;; docker-compose) - # Source compose module on first use - if ! declare -f _compose_wrap_command &>/dev/null; then - local shared_dir - shared_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - source "${shared_dir}/docker-compose.sh" - fi + _ensure_compose_module || return 1 # Read config values — no CLI calls at startup - COMPOSE_SERVICE=$(_get_config_value '."docker-compose".service' "web") - COMPOSE_WORKDIR_OVERRIDE=$(_get_config_value '."docker-compose".workdir' "") - COMPOSE_FILE_OVERRIDE=$(_get_config_value '."docker-compose".file' "") - LINT_WORKDIR="(resolved at call time)" + compose_service=$(_config_value_from "${config_file}" '."docker-compose".service' "web") + compose_workdir=$(_config_value_from "${config_file}" '."docker-compose".workdir' "") + compose_file=$(_config_value_from "${config_file}" '."docker-compose".file' "") + workdir="(resolved at call time)" ;; vagrant) - LINT_WORKDIR=$(_get_config_value ".vagrant.workdir" "/vagrant") + workdir=$(_config_value_from "${config_file}" ".vagrant.workdir" "/vagrant") ;; ddev) - LINT_WORKDIR=$(_get_config_value ".ddev.workdir" "/var/www/html") + workdir=$(_config_value_from "${config_file}" ".ddev.workdir" "/var/www/html") ;; native|*) - LINT_WORKDIR="${project_root}" + workdir="${project_root}" ;; esac + + LINT_WORKDIR="${workdir}" + DOCKER_CONTAINER="${container}" + COMPOSE_SERVICE="${compose_service}" + COMPOSE_WORKDIR_OVERRIDE="${compose_workdir}" + COMPOSE_FILE_OVERRIDE="${compose_file}" + + return 0 +} + +# Source the docker-compose module, once, when it is not loaded yet. Both the +# docker-compose derivation above and resolve_env_workdir below reach the +# compose resolver, and the module is only ever loaded for that environment. +# Stdout: nothing on success, the sentence naming the failure otherwise +# Returns: 0 when the module is available, 1 otherwise +_ensure_compose_module() { + if declare -f _compose_resolve_workdir_for &>/dev/null; then + return 0 + fi + + local shared_dir + shared_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + if [[ ! -f "${shared_dir}/docker-compose.sh" ]]; then + printf '%s\n' "Refusing to run: docker-compose.sh was not found next to environment.sh, so the docker-compose workdir cannot be resolved." + return 1 + fi + + source "${shared_dir}/docker-compose.sh" +} + +# The suffix of a host path below a project root. +# Pure: both values are arguments and nothing else is read. +# A trailing slash is ignored on either side, so one directory has one suffix +# whichever way it is spelled and a suffix never ends in an empty segment. +# Args: $1 = host root, $2 = project root +# Stdout: the suffix, empty when the two name the same directory, or the +# sentence naming the value that lies outside the project root +# Returns: 0 when the host root is the project root or below it, 1 otherwise +_env_relative_suffix() { + local host_root="$1" + local project_root="$2" + + if [[ -z "${host_root}" || -z "${project_root}" ]]; then + printf '%s\n' "Refusing to run: a host root and a project root are both required to map one onto the other." + return 1 + fi + + if [[ "${host_root}" != /* || "${project_root}" != /* ]]; then + printf '%s\n' "Refusing to run: \"${host_root}\" cannot be mapped onto \"${project_root}\": both must be absolute paths." + return 1 + fi + + while [[ "${project_root}" != "/" && "${project_root}" == */ ]]; do + project_root="${project_root%/}" + done + while [[ "${host_root}" != "/" && "${host_root}" == */ ]]; do + host_root="${host_root%/}" + done + + if [[ "${project_root}" == "/" ]]; then + printf '%s\n' "${host_root#/}" + return 0 + fi + + if [[ "${host_root}" == "${project_root}" ]]; then + printf '%s\n' "" + return 0 + fi + + if [[ "${host_root}" == "${project_root}"/* ]]; then + printf '%s\n' "${host_root#"${project_root}"/}" + return 0 + fi + + printf '%s\n' "Refusing to run: \"${host_root}\" is not inside the project root \"${project_root}\", so the environment has no path that reaches it." + return 1 +} + +# Join a base directory to a relative suffix. +# Pure: both values are arguments and nothing else is read. +# Args: $1 = base directory, $2 = suffix, empty when the base is the answer +# Stdout: the joined path, without a doubled separator +_env_join_workdir() { + local base="$1" + local suffix="$2" + + while [[ "${base}" != "/" && "${base}" == */ ]]; do + base="${base%/}" + done + + if [[ -z "${suffix}" ]]; then + printf '%s\n' "${base}" + return 0 + fi + + # The root is the one base whose trailing separator is the separator: + # "${base}/${suffix}" would emit "//x", a path that names the same directory + # but is not the spelling any other component produced. + if [[ "${base}" == "/" ]]; then + printf '%s\n' "/${suffix}" + return 0 + fi + + printf '%s\n' "${base}/${suffix}" +} + +# The workdir one configured-workdir environment declares. +# +# While the launch configuration is the one in force, this answers from the +# value the server bound at startup rather than from the file. LINT_WORKDIR is +# where _set_workdir_from_config put the configured workdir for docker, vagrant +# and ddev, and the mapping is a launch-root fact either way — so reading the +# file again let an edit of the launch configuration move the directory a +# running server's calls map into, while a launch-root call kept running under +# the workdir the server started with. The two must answer alike or a worktree +# call and a launch-root call disagree about the same server. +# +# A call carrying a configuration of its own has re-derived the scalars for +# that file, so its selected config differs from the launch config and it reads +# the file — the re-derivation path. So does every server that has no worktree +# module: those globals are unset and the plain read below is the whole +# behavior. +# Args: $1 = environment name +# Globals: reads LINT_WORKDIR, WORKTREE_SELECTED_CONFIG_FILE, +# WORKTREE_LAUNCH_CONFIG_FILE; reads LINT_CONFIG_FILE through +# _get_config_value +# Stdout: the configured workdir, or the default +_env_configured_workdir() { + if [[ -n "${LINT_WORKDIR:-}" && -n "${WORKTREE_LAUNCH_CONFIG_FILE:-}" \ + && "${WORKTREE_SELECTED_CONFIG_FILE:-}" == "${WORKTREE_LAUNCH_CONFIG_FILE}" ]]; then + printf '%s\n' "${LINT_WORKDIR}" + return 0 + fi + + case "$1" in + docker) _get_config_value ".docker.workdir" "/var/www/html" ;; + vagrant) _get_config_value ".vagrant.workdir" "/vagrant" ;; + ddev) _get_config_value ".ddev.workdir" "/var/www/html" ;; + *) printf '%s\n' "" ;; + esac +} + +# Collapse ".", ".." and repeated slashes without touching the filesystem, so +# a containment test cannot be defeated by spelling alone. +# Args: $1 = absolute path +# Stdout: the normalized path, "/" when every segment cancels +# Returns: 0 always +_env_lexical_normalize() { + local rest="$1" + local normalized="" segment + + while [[ -n "${rest}" ]]; do + segment="${rest%%/*}" + if [[ "${rest}" == */* ]]; then + rest="${rest#*/}" + else + rest="" + fi + + case "${segment}" in + ""|.) + ;; + ..) + # Already at the root: ".." there is the root, as in the kernel. + normalized="${normalized%/*}" + ;; + *) + normalized="${normalized}/${segment}" + ;; + esac + done + + printf '%s\n' "${normalized:-/}" +} + +# _env_canonical_path +# The spelling the filesystem would actually reach for an absolute path, +# whether or not every component exists yet. The deepest existing ancestor is +# resolved with realpath, which follows every symlink including the leaf; the +# segments below it are collapsed lexically. +# +# Both sides of a mapping comparison go through this before they are compared. +# A bind mount's source is whatever the compose file spelled — on macOS often +# the shell's own logical "/var/..." where the filesystem reaches +# "/private/var/..." — and the call's root is a host path the caller spelled. +# Compared raw, one spelling of one directory reads as outside the other. +# +# Trailing components that do not exist resolve rather than fail: a bind mount +# source may name a directory that is not present on this host, and the mount +# still decides where a path below it lands. A deepest EXISTING component that +# cannot be resolved fails instead, because its location cannot be established +# and a pass would be a guess. +# Args: $1 = absolute path +# Stdout: the resolved path +# Returns: 0 when the path resolved, 1 when it did not, or when the argument is +# not absolute — the ancestor walk has no root to stop at +_env_canonical_path() { + local path="$1" + + if [[ "${path}" != /* ]]; then + return 1 + fi + + # -L is tested alongside -e so a dangling symlink stops the walk at itself, + # and realpath below then fails on it — refused rather than measured by its + # spelling. + local head="${path}" tail="" segment parent + while [[ "${head}" != "/" && ! -e "${head}" && ! -L "${head}" ]]; do + segment="${head##*/}" + parent="${head%/*}" + [[ -z "${parent}" ]] && parent="/" + if [[ -z "${tail}" ]]; then + tail="${segment}" + else + tail="${segment}/${tail}" + fi + head="${parent}" + done + + local resolved rc=0 + resolved=$(realpath -- "${head}" 2>/dev/null) || rc=$? + if [[ "${rc}" -ne 0 || -z "${resolved}" ]]; then + return 1 + fi + + if [[ -z "${tail}" ]]; then + printf '%s\n' "${resolved}" + return 0 + fi + + _env_lexical_normalize "${resolved}/${tail}" +} + +# Map a host path onto the path the active environment reaches it by. +# Thin wrapper: the environment and the configured workdir are read here; the +# mapping itself is the pure pair above and the compose matcher. +# +# The launch-side base is an argument rather than always PROJECT_ROOT. A caller +# whose root has already been resolved — the worktree module canonicalizes the +# call's root and the launch root together, so that containment and the mapping +# measure the same two paths — hands that resolved launch root in, and the +# mapping must not substitute the raw spelling for it. Callers that pass no +# base get PROJECT_ROOT unchanged, which is the whole behavior for every +# caller that never resolved anything. +# Args: $1 = the host path, $2 = the launch-side base (default PROJECT_ROOT) +# Globals: reads LINT_ENV, PROJECT_ROOT and, through _get_config_value, +# LINT_CONFIG_FILE +# Stdout: the environment-side path, or the sentence naming why none exists +# Returns: 0 on success, 1 when the environment has no path for this root +resolve_env_workdir() { + local host_root="${1:-}" + local launch_base="${2:-${PROJECT_ROOT:-}}" + + if [[ -z "${host_root}" ]]; then + printf '%s\n' "resolve_env_workdir: a host path is required." + return 1 + fi + + local base suffix + case "${LINT_ENV}" in + docker|vagrant|ddev) + base=$(_env_configured_workdir "${LINT_ENV}") + if ! suffix=$(_env_relative_suffix "${host_root}" "${launch_base}"); then + printf '%s\n' "${suffix}" + return 1 + fi + _env_join_workdir "${base}" "${suffix}" + return 0 + ;; + docker-compose) + if ! _ensure_compose_module; then + return 1 + fi + _compose_resolve_workdir_for "${host_root}" "${launch_base}" + return $? + ;; + *) + # native, and every name detect_environment assigned without an + # allowlist: those wrappers emit a command that runs locally, so + # the host path is already the path the command reaches. + printf '%s\n' "${host_root}" + return 0 + ;; + esac +} + +# _env_is_container_environment +# True when the named environment runs commands inside a container or guest — +# the names wrap_command routes away from the host. Every guard that asks +# "does this command leave the host" reads THIS predicate instead of restating +# the name list, so an environment added here is picked up by containment, +# charset and the existence probe in the same edit that teaches the wrappers +# about it. +# Pure: the environment name is the only input. +# Args: $1 = environment name +# Returns: 0 for docker, docker-compose, vagrant and ddev, 1 otherwise +_env_is_container_environment() { + case "${1:-}" in + docker|docker-compose|vagrant|ddev) return 0 ;; + esac + + return 1 +} + +# True when this call targets a root other than the one the server was launched +# in. The worktree module sets WORKTREE_EFFECTIVE_ROOT and rebinds it per call; +# a server that has no worktree module — shopware-env — leaves it unset, and +# PROJECT_ROOT is then the answer, so the launch behavior applies unchanged. +# Globals: reads WORKTREE_EFFECTIVE_ROOT, PROJECT_ROOT +# Returns: 0 when the call targets another root, 1 otherwise +_env_targets_worktree() { + local effective="${WORKTREE_EFFECTIVE_ROOT:-${PROJECT_ROOT:-}}" + + [[ "${effective}" != "${PROJECT_ROOT:-}" ]] } # The base working directory the active environment runs commands in. @@ -175,11 +545,18 @@ wrap_command() { case "${LINT_ENV}" in native) - if [[ -n "${scoped}" ]]; then - echo "cd \"${workdir}\" && ${cmd}" - else - echo "${cmd}" - fi + # No cd, and therefore no working directory anywhere in the emitted + # string. A caller that executes this output enters the directory + # itself — exec_command does, handing the path to cd as a quoted + # argument rather than as program text — so a path holding a space, + # a "$", a quote or any other metacharacter is never parsed as + # shell. Inlining it here is what no quoting can make safe: double + # quotes leave "$", a backtick and a backslash live, and the value + # reaches the eval in exec_command as program text either way. + # The container branches below keep their cd because the directory + # they name exists only inside the container or guest, which the + # local process cannot enter. + printf '%s\n' "${cmd}" ;; docker) printf '%s\n' "docker exec -i $(shell_quote_arg "${DOCKER_CONTAINER}") bash -c 'cd ${workdir} && ${cmd}'" @@ -191,7 +568,19 @@ wrap_command() { echo "vagrant ssh -c 'cd ${workdir} && ${cmd}'" ;; ddev) - if [[ -n "${scoped}" ]]; then + if [[ -n "${scoped}" ]] || _env_targets_worktree; then + # A call targeting a worktree runs against a directory ddev does + # not know as its project root, so the resolved workdir has to + # be named; -d is how ddev's exec takes one. A launch-root call + # keeps the argv below, which is the ddev default. + # + # The composer shortcut stays inside the launch-root branch and + # deliberately does not cover a worktree call: `ddev composer` + # re-execs composer at ddev's own project root and takes no -d, + # so routing a worktree call through it would run the command + # against the launch tree and have the result attributed to the + # worktree. A worktree call names the workdir and reaches the + # composer binary directly instead. echo "ddev exec -d \"${workdir}\" ${cmd}" elif [[ "${cmd}" == composer* ]]; then echo "ddev ${cmd}" @@ -206,6 +595,62 @@ wrap_command() { esac } +# _enter_command_workdir +# Enter the directory a native command runs in, so that directory never has to +# appear inside the command string where a shell would parse it. +# +# A no-op under docker, docker-compose, vagrant and ddev: the directory those +# wrap into the command is inside the container or guest, the local process +# cannot enter it, and their wrappers still carry their own cd. +# +# The gate names those four rather than testing for "native", because +# detect_environment applies no allowlist — it assigns whatever ".environment" +# holds straight into LINT_ENV — so a typo such as "nativ" or an unsupported +# name such as "podman" falls through both wrappers to a command that runs +# locally, against the host path _set_workdir_from_config gave it. Testing for +# "native" would leave exactly that case with no directory entered, which is the +# one shape that would force a workdir back into a command string. +# +# Call it from inside the command substitution exec_command and +# exec_npm_command run their body in. A function body is not a subshell, so the +# change reaches the caller — and that caller is the substitution, which ends +# when the command does, so neither the server process nor a later call sees it. +# Globals: +# LINT_ENV - read, to decide whether this environment runs commands locally +# Arguments: +# $1 - the directory the command runs in +# Outputs: +# Nothing on success; on stdout the sentence naming the directory it could not +# enter, which the caller's substitution captures as the command's output +# Returns: +# 0 when the directory is current, or the environment runs the command +# elsewhere; 1 when the directory is empty or could not be entered +_enter_command_workdir() { + local workdir="$1" + + case "${LINT_ENV}" in + docker|docker-compose|vagrant|ddev) + return 0 + ;; + esac + + # `cd ""` succeeds and stays put, so an empty value would silently run the + # command wherever the server process happens to sit. + if [[ -z "${workdir}" ]]; then + printf '%s\n' "Refusing to run: the working directory is empty, so the command has no known directory to run in." + return 1 + fi + + # cd's own diagnostic is replaced rather than passed through: it would land + # on the server's stderr, separated from the text this call returns. + if ! cd -- "${workdir}" >/dev/null 2>&1; then + printf '%s\n' "Refusing to run: the working directory \"${workdir}\" could not be entered." + return 1 + fi + + return 0 +} + # Execute command in detected environment # Usage: exec_command "composer phpstan" # Returns: command output on stdout, exit code @@ -222,7 +667,9 @@ wrap_command() { # literally and the remote shell is the parse the double quotes are written # for. Only a single quote would terminate that string locally, which is # what assert_no_shell_hostile_chars refuses for tool-supplied values. -# native: no remote shell — the local eval is the only parse. +# native: no remote shell — the local eval is the only parse, and the working +# directory is not in the string at all, because _enter_command_workdir +# entered it before the eval ran. # ddev: no quoting wrapper at all. `ddev [exec …] ` is emitted as bare # argv, so the local eval already consumes shell_quote_arg's escaping. # `ddev composer` re-execs that argv directly and is safe, but `ddev exec` @@ -236,7 +683,8 @@ wrap_command() { # is ddev, which is what keeps this branch safe; wrap_npm_command's ddev # branch has the same shape and the same guard covers it. # A workdir taken from the config file is trusted at the same level as the -# config itself. +# config itself; the container branches are the only place a workdir still +# reaches a command string at all. exec_command() { local cmd="$1" local wrapped_cmd @@ -254,7 +702,11 @@ exec_command() { # The child must not inherit the server's stdin: it is the client's # JSON-RPC protocol pipe, and a stdin-reading tool child would block on # it forever instead of seeing EOF. - output=$(eval "${wrapped_cmd}" &1) || exit_code=$? + # + # The scoped suffix is composed here rather than read back from + # wrap_command, which no longer emits it: it is the same value that + # function computes for the container branches. + output=$(_enter_command_workdir "${LINT_WORKDIR}${SCOPE_CWD:+/${SCOPE_CWD}}" && eval "${wrapped_cmd}" &1) || exit_code=$? output=$(printf '%s' "${output}" | _filter_env_noise) log "INFO" "Command exit code: ${exit_code}" @@ -263,50 +715,6 @@ exec_command() { return "${exit_code}" } -get_environment_info() { - local project_root="$1" - local has_config="false" - local phpstan_config="" - local ecs_config="" - - [[ -f "${LINT_CONFIG_FILE}" ]] && has_config="true" - - phpstan_config=$(_find_first_file "${project_root}" phpstan.neon phpstan.neon.dist phpstan.dist.neon) - ecs_config=$(_find_first_file "${project_root}" .php-cs-fixer.php .php-cs-fixer.dist.php ecs.php ecs.dist.php) - - local example_cmd - example_cmd=$(wrap_command "composer phpstan") - - cat <&1) || exit_code=$? + # + # get_js_workdir is re-read here rather than taken from wrap_npm_command, + # which no longer emits it. Under a non-native environment the value it + # returns is a container path and _enter_command_workdir discards it. + output=$(_enter_command_workdir "$(get_js_workdir)" && eval "${wrapped_cmd}" &1) || exit_code=$? output=$(printf '%s' "${output}" | _filter_env_noise) log "INFO" "Command exit code: ${exit_code}" diff --git a/plugins/dev-tooling/shared/mcpserver_core.sh b/plugins/dev-tooling/shared/mcpserver_core.sh index 9729465b..dc85619c 100755 --- a/plugins/dev-tooling/shared/mcpserver_core.sh +++ b/plugins/dev-tooling/shared/mcpserver_core.sh @@ -1,7 +1,212 @@ #!/usr/bin/env bash # MCP Server Core - JSON-RPC 2.0 Protocol Handler # Based on Model Context Protocol specification -# Requires: bash 4+, jq 1.7+ +# Requires: bash 4.1+, jq 1.7+ + +# Pre-flight dependency guards for the two floors the header line declares. +# This block alone uses no construct newer than bash 3.2, sits above the file's +# own `set -euo pipefail`, and diagnoses on stderr. AGENTS.md §Pre-flight +# guards. + +# Print remediation lines for a failed dependency check. `dep` is `bash` or +# `jq`. Output lands on stdout so the function stays pure and testable, which +# makes the `>&2` at every call site mandatory: a call site that forgets it +# writes the hint into the JSON-RPC stream. `uname` is the only external command +# reached for, and an absent or failing `uname` falls through to the generic +# line rather than failing the hint. +_mcp_install_hint() { + local dep="$1" + + local floor="${dep}" + case "${dep}" in + bash) floor="bash 4.1 or newer" ;; + jq) floor="jq 1.7 or newer" ;; + esac + + local platform="" + if command -v uname >/dev/null 2>&1; then + platform=$(uname -s 2>/dev/null) || platform="" + fi + + case "${platform}" in + Darwin) + # Probe for brew, as the Linux arm probes its package managers: a + # Mac with no Homebrew — MacPorts, nix, a managed image — would + # otherwise be handed a command that does not exist. The generic + # line stands in for it, so such a Mac still gets remediation. + if command -v brew >/dev/null 2>&1; then + printf '%s\n' " brew install ${dep}" + else + printf '%s\n' " install ${floor} with your platform's package manager" + fi + if [[ "${dep}" == "bash" ]]; then + # The install on its own changes nothing: macOS keeps 3.2.57 at + # /bin/bash, and both usual launch paths — a + # `#!/usr/bin/env bash` shebang, and a host manifest whose + # command is `bash` — resolve through PATH. A GUI-launched MCP + # host reads no shell profile, so the PATH it hands the server + # is the one that has to be ordered. Printed whether or not brew + # was found: a GUI host hands the server a PATH with no brew on + # it, and that is precisely the case where the fix is a PATH + # fix, so this advice cannot sit inside the brew probe. + printf '%s\n' " then place its directory before /usr/bin in the PATH the MCP host launches this server with" + fi + return 0 + ;; + Linux) + if command -v apt-get >/dev/null 2>&1; then + printf '%s\n' " apt-get install ${dep}" + return 0 + fi + if command -v apk >/dev/null 2>&1; then + printf '%s\n' " apk add ${dep}" + return 0 + fi + if command -v dnf >/dev/null 2>&1; then + printf '%s\n' " dnf install ${dep}" + return 0 + fi + ;; + esac + + printf '%s\n' " install ${floor} with your platform's package manager" + return 0 +} + +# Whether bash . is at or above the 4.1 floor. Takes the version +# as arguments so a suite can drive it from a table. AGENTS.md §Pre-flight +# guards. A non-numeric or empty component reads as below the floor. +_mcp_bash_meets_floor() { + local major="${1:-}" + local minor="${2:-}" + + case "${major}" in ''|*[!0-9]*) return 1 ;; esac + case "${minor}" in ''|*[!0-9]*) return 1 ;; esac + + # Arithmetic comparison, with `10#` forcing base 10: the guards above admit + # any all-digit string, and a leading zero is otherwise read as octal, where + # `08` is not a number and bash writes its own complaint to stderr. + # `(( ))` rather than `[[ -gt ]]`: both evaluate an operand arithmetically + # and agree on every value that reaches them here, but ShellCheck's SC2309 + # flags a built-up operand handed to `[[ ]]`'s numeric operators, and the + # arithmetic form states the intent without needing a directive. + if (( 10#${major} > 4 )); then + return 0 + fi + if (( 10#${major} == 4 && 10#${minor} >= 1 )); then + return 0 + fi + return 1 +} + +# Whether a raw `jq --version` string is at or above the 1.7 floor. +# The comparison is numeric, not lexical: `jq-1.10` is above `jq-1.7` and a +# string comparison gets that backwards. A patch component, and any distro +# suffix — attached to the minor or trailing the patch — is ignored, so +# `jq-1.7.1-Debian-1` and `jq-1.7-Debian-1` are both above the floor. +# Anything that does not parse as `jq-.` is below it, a +# prerelease included: `jq-1.7rc1` is not evidence of released 1.7 behavior, +# and refusing what cannot be read is the direction that fails loudly. +# The `case`-based technique is the one the test images use +# (docker/debian.Dockerfile, docker/alpine.Dockerfile), but this helper admits a +# non-numeric patch or distro suffix they reject: the images gate their own +# build, where a version they cannot fully parse is a reason to fail it, while +# this helper decides whether a consumer's server may run. +_mcp_jq_meets_floor() { + local raw="${1:-}" + + case "${raw}" in + jq-*) ;; + *) return 1 ;; + esac + + local version="${raw#jq-}" + case "${version}" in + *.*) ;; + *) return 1 ;; + esac + + local major="${version%%.*}" + local rest="${version#*.}" + local minor="${rest%%.*}" + # A distro suffix may attach to the minor with no patch component between + # them (`jq-1.8-1`); strip it so it cannot reach the digit guard and refuse a + # version that meets the floor. jq's own prereleases append alphanumerics + # with no hyphen (`jq-1.7rc1`) and stay refused; a hyphenated one would + # survive this strip, and no jq release carries one. Major is left + # unstripped: no real version needs it. + minor="${minor%%-*}" + + case "${major}" in ''|*[!0-9]*) return 1 ;; esac + case "${minor}" in ''|*[!0-9]*) return 1 ;; esac + + # Same shape and same reason as _mcp_bash_meets_floor: a leading zero would + # otherwise be read as octal, and `jq-1.08` would fail arithmetically rather + # than compare. + if (( 10#${major} > 1 )); then + return 0 + fi + if (( 10#${major} == 1 && 10#${minor} >= 7 )); then + return 0 + fi + return 1 +} + +# bash floor. Below 4.1 the file still parses, so there is no early error to go +# on: `run_mcp_server` dies on the `exec {_MCP_LIFELINE_FD}<>` redirection with +# `{_MCP_LIFELINE_FD}: not found`, which an MCP host reports as nothing more +# than a server that failed to start. +if [[ -z "${BASH_VERSINFO[0]:-}" ]] \ + || ! _mcp_bash_meets_floor "${BASH_VERSINFO[0]:-}" "${BASH_VERSINFO[1]:-}"; then + printf '%s\n' "bash-mcp-sdk: bash 4.1 or newer is required; this shell reports ${BASH_VERSION:-unknown}." >&2 + _mcp_install_hint bash >&2 + # `return` when this file is sourced, `exit` when it is executed; the + # complaint `return` raises outside a function is suppressed because it is + # only ever raised on the executed path. + # Known gap: a consumer that sources this file without `set -e` carries on + # past the refusal and reaches `run_mcp_server: command not found`, with the + # message above already on stderr. An unconditional `exit` would close that + # gap and take the BATS runner down with it — the suites source this file + # into the test process. + # shellcheck disable=SC2317 # `return` succeeds only on the sourced path; on the executed path it fails and `exit 1` is what runs. + return 1 2>/dev/null || exit 1 +fi + +# jq presence. +if ! command -v jq >/dev/null 2>&1; then + printf '%s\n' "bash-mcp-sdk: jq was not found on PATH; jq 1.7 or newer is required." >&2 + _mcp_install_hint jq >&2 + # shellcheck disable=SC2317 # `return` succeeds only on the sourced path; on the executed path it fails and `exit 1` is what runs. + return 1 2>/dev/null || exit 1 +fi + +# jq floor. Below 1.7 the server starts and answers every request, and the +# damage is silent: `validate_tool_arguments` reads a number back through +# `tojson` to catch a fraction the conversion to a double rounded away, and +# that depends on jq preserving the number literal — jq 1.7's "use decimal +# number literals to preserve precision". Older jq parses every number to a +# double, so `4503599627370496.5` renders whole and passes as an integer. +# Probed once here, at source time, never on the request path. +_mcp_jq_version=$(jq --version 2>/dev/null) || _mcp_jq_version="" +if ! _mcp_jq_meets_floor "${_mcp_jq_version}"; then + if [[ -z "${_mcp_jq_version}" ]]; then + # The presence check above resolved a path, so jq is installed; it just + # did not answer. Wrong architecture, a missing shared library, a file + # that cannot execute. Naming the resolved path points the operator at + # the binary that is broken, and neither a version nor the + # distro-package warning below applies to a jq that is already on the + # machine. + printf '%s\n' "bash-mcp-sdk: jq was found on PATH but could not be run; jq 1.7 or newer is required." >&2 + printf '%s\n' " the jq at $(command -v jq) did not answer 'jq --version'" >&2 + else + printf '%s\n' "bash-mcp-sdk: jq 1.7 or newer is required; jq reports '${_mcp_jq_version}'." >&2 + printf '%s\n' " a distribution package may itself sit below the floor; a newer jq may have to come from elsewhere" >&2 + fi + _mcp_install_hint jq >&2 + # shellcheck disable=SC2317 # `return` succeeds only on the sourced path; on the executed path it fails and `exit 1` is what runs. + return 1 2>/dev/null || exit 1 +fi +unset _mcp_jq_version set -euo pipefail @@ -9,6 +214,18 @@ set -euo pipefail : "${MCP_TOOLS_LIST_FILE:=tools.json}" : "${MCP_LOG_FILE:=/dev/null}" : "${MCP_EXTRA_LOG_FILE:=}" +: "${MCP_LOG_STDERR:=0}" + +# Seconds a cancelled tool call gets to die after SIGTERM before SIGKILL, and +# a tool__cancel hook gets before it is abandoned. Internal constant: +# the README configuration table lists the MCP_* variables a consumer sets, +# and this is not one of them. +# Guarded so the file can be sourced twice in one shell: a second `readonly` on +# a name the first source already marked fails under `set -e` and takes the +# shell with it. +if [[ -z "${_MCP_CANCEL_GRACE_SECONDS:-}" ]]; then + readonly _MCP_CANCEL_GRACE_SECONDS=2 +fi log() { local level="$1" @@ -17,6 +234,7 @@ log() { line="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message" echo "$line" >> "$MCP_LOG_FILE" [[ -n "${MCP_EXTRA_LOG_FILE}" ]] && echo "$line" >> "$MCP_EXTRA_LOG_FILE" + [[ "${MCP_LOG_STDERR}" == "1" ]] && printf '%s\n' "$line" >&2 return 0 } @@ -44,14 +262,30 @@ _configure_extra_log_file() { log "INFO" "Extra log file configured: ${MCP_EXTRA_LOG_FILE}" } +# Read one JSON document from a file and print it compact when it is a JSON +# object; prints nothing and returns 1 otherwise. README.md §API. +# The callers both hand this output to `--argjson` and index it — `.tools` in +# handle_tools_list, `.protocolVersion`, `.serverInfo` and `.capabilities` in +# handle_initialize — so only an object is usable: an index of any other type +# either errors or yields `null` in place of the caller's key, and a `null` or +# `false` document is that accident rather than a configuration. Never a +# fallback: each caller answers the failure instead of treating the +# configuration as empty, so a degraded result is not passed off as a correct +# one. `jq -e` is not used to decide the parse — its exit status comes from the +# truthiness of the output rather than from whether the input parsed, and the +# object test this function needs is made explicitly in the program below. read_json_file() { local file="$1" - if [[ -f "$file" ]]; then - cat "$file" - else + if [[ ! -f "$file" ]]; then log "ERROR" "File not found: $file" - echo "{}" + return 1 fi + local parsed + if ! parsed=$(jq -cs 'if length == 1 and (.[0] | type) == "object" then .[0] else ("expected exactly one JSON object" | halt_error) end' -- "$file" 2>/dev/null); then + log "ERROR" "Not exactly one parseable JSON object: ${file}" + return 1 + fi + printf '%s\n' "$parsed" } create_response() { @@ -68,12 +302,22 @@ create_error_response() { local id="$1" local code="$2" local message="$3" + local data="${4:-}" - jq -n -c \ - --argjson id "$id" \ - --argjson code "$code" \ - --arg message "$message" \ - '{"jsonrpc": "2.0", "id": $id, "error": {"code": $code, "message": $message}}' + if [[ -z "$data" ]]; then + jq -n -c \ + --argjson id "$id" \ + --argjson code "$code" \ + --arg message "$message" \ + '{"jsonrpc": "2.0", "id": $id, "error": {"code": $code, "message": $message}}' + else + jq -n -c \ + --argjson id "$id" \ + --argjson code "$code" \ + --arg message "$message" \ + --argjson data "$data" \ + '{"jsonrpc": "2.0", "id": $id, "error": {"code": $code, "message": $message, "data": $data}}' + fi } handle_initialize() { @@ -83,7 +327,10 @@ handle_initialize() { log "INFO" "Handling initialize request" local config - config=$(read_json_file "$MCP_CONFIG_FILE") + if ! config=$(read_json_file "$MCP_CONFIG_FILE"); then + create_error_response "$id" -32603 "Cannot read server configuration: ${MCP_CONFIG_FILE}" + return + fi local result result=$(jq -n -c \ @@ -103,7 +350,10 @@ handle_tools_list() { log "INFO" "Handling tools/list request" local tools_config - tools_config=$(read_json_file "$MCP_TOOLS_LIST_FILE") + if ! tools_config=$(read_json_file "$MCP_TOOLS_LIST_FILE"); then + create_error_response "$id" -32603 "Cannot read tools list: ${MCP_TOOLS_LIST_FILE}" + return + fi local tools tools=$(echo "$tools_config" | jq -c '.tools // []') @@ -114,41 +364,29 @@ handle_tools_list() { create_response "$id" "$result" } -# Validate call arguments against the tool's declared inputSchema. -# Rejects arguments that are not a JSON object, enforces `required` (every -# listed field must be present), when the schema sets -# `additionalProperties: false` rejects any field not in `properties`, -# enforces a declared `type` (string, integer, number, boolean, array, -# object) on any present field, enforces a declared `pattern` against any -# present string-valued field, enforces declared `minimum`, `maximum`, -# `exclusiveMinimum` and `exclusiveMaximum` bounds against any present -# number-valued field, enforces a declared array `items.type` and -# `items.enum` against every element of a present array-valued field, and -# rejects any present field whose schema declares an `enum` when the supplied -# value is not one of the declared values. A declared `type` — on a property -# or on `items` — is either one name or a list of alternatives, and a value -# satisfies it by matching any member; a list that is empty or carries a -# non-string member is malformed and left unenforced. A declared `integer` is -# satisfied by a whole-valued number, decided from the number as jq renders it -# and not from its double value alone, so a fractional literal at or above -# 2^52 = 4503599627370496 is rejected instead of being read as whole; a -# rendering that carries an exponent keeps the double-based verdict, which -# admits a fractional value below the smallest subnormal double. A bound that -# is not a number is malformed the same way, which also leaves the draft-04 -# boolean form `"exclusiveMinimum": true` unenforced. Diagnostics take -# precedence in that order — missing, unknown, type, pattern, range, items, -# enum — so a value that fails more than one constraint is reported with the -# most fundamental defect first (a type mismatch is reported before an -# unrelated enum mismatch). +# Validate call arguments against the tool's declared inputSchema, rejecting +# arguments that are not a JSON object. The enforced keywords, the union-type +# rule, the range-bound rule and the diagnostic precedence order are +# README.md §API. +# A declared `integer` is satisfied by a whole-valued number, decided from the +# number as jq renders it and not from its double value alone, so a fractional +# literal at or above 2^52 = 4503599627370496 is rejected instead of being read +# as whole; a rendering that carries an exponent keeps the double-based verdict, +# which admits a fractional value below the smallest subnormal double. # A tool with no entry in the tools list, or whose entry declares no -# inputSchema, is not validated. A jq failure is a rejection and never a skip: +# inputSchema, is not validated. A tools list that cannot be read is a +# rejection, whether it is missing, unparseable, or holds a document that is +# not a JSON object, so an unreadable list never becomes a silent skip. A jq +# failure is a rejection and never a skip: # a validator that could not evaluate its input has not validated it, and # reporting success there would wave every constraint through. That branch is # defense-in-depth for a direct call rather than a live remote-input guard — -# process_request gates the whole request through `jq -e '.'`, so arguments -# arriving over the protocol are always parseable JSON. The non-object branch -# is NOT in that category: `null`, `false` and every other JSON scalar are -# parseable, so a client can send them and they reach this validator. +# process_request admits only one JSON document per line and answers anything +# that is not a JSON object before dispatch, so arguments arriving over the +# protocol are always parseable JSON. The non-object branch is NOT in that +# category: `null`, `false` and every other JSON scalar are parseable, and +# `arguments` may be any JSON value, so a client can send them and they reach +# this validator. # Args: $1 = tool name, $2 = arguments JSON # On violation: prints a human-readable message to stdout and returns 1. validate_tool_arguments() { @@ -157,14 +395,27 @@ validate_tool_arguments() { local tools_config schema rc # errexit is off inside this function — handle_tools_call tests it in a - # conditional — so the unreadable-tools-list fallback must be explicit - # rather than left to the call site's shape. - tools_config=$(read_json_file "$MCP_TOOLS_LIST_FILE" 2>/dev/null) || tools_config='{}' + # conditional — so each failure below is handled explicitly rather than + # left to the call site's shape. A tools list that cannot be read is a + # rejection and never a skip: a validator that could not read its schemas + # has not validated anything, and reporting success there would wave every + # declared constraint through, which is how the absent-list fallback read. + # The jq failure below is a second such branch. read_json_file's object + # gate catches a file-borne document that is not an object in the branch + # above, but the jq branch stays reachable through a file: an object whose + # `tools` holds a non-object element passes the gate, and `select` then + # errors on that element — the trailing `?` guards only the iteration. + rc=0 + tools_config=$(read_json_file "$MCP_TOOLS_LIST_FILE" 2>/dev/null) || rc=$? + if [[ $rc -ne 0 ]]; then + printf '%s' "Cannot validate arguments for ${tool_name}: the tool list at ${MCP_TOOLS_LIST_FILE} is missing or does not hold one JSON object." + return 1 + fi rc=0 schema=$(echo "$tools_config" | jq -c --arg n "$tool_name" \ '(.tools[]? | select(.name == $n) | .inputSchema) // empty' 2>/dev/null) || rc=$? if [[ $rc -ne 0 ]]; then - printf '%s' "Cannot validate arguments for ${tool_name}: the tool list at ${MCP_TOOLS_LIST_FILE} is not parseable JSON." + printf '%s' "Cannot validate arguments for ${tool_name}: the tool list at ${MCP_TOOLS_LIST_FILE} does not hold a usable tools list." return 1 fi [[ -z "$schema" || "$schema" == "null" ]] && return 0 @@ -372,10 +623,38 @@ validate_tool_arguments() { return 0 } +# Clear what a tool body must not inherit from the dispatch that runs it: a +# nested dispatch that kept the server-loop state would stop the server as soon +# as the outer call returned. docs/architecture.md §Tool containment. +# A nested dispatch needing cancellation or deferred replay of its own is out of +# scope; this restores the plain-call shape a direct caller gets. +_reset_tool_dispatch_state() { + _MCP_IN_SERVER_LOOP=0 + unset _MCP_SHUTDOWN_FLAG_FILE _MCP_INFLIGHT_FILE _MCP_PARTIAL_FILE +} + handle_tools_call() { local id="$1" local params="$2" + # A tools/call carries its arguments in a params object, so a params of any + # other type is an Invalid params. This gate runs before `.name` and + # `.arguments` are read, so those extractions only ever see an object and + # none of them can fail. That is what makes the crash fix a property of this + # function rather than of its caller: under `set -o posix` bash inherits + # errexit into the command substitution those extractions run in, and a + # failed one ends the whole server instead of answering the request. It also + # keeps a non-object params' `Cannot index ...` jq diagnostics off the + # process's stderr, unrouted through `log`. And it makes handle_tools_call + # safe as a direct public entry point on its own: a caller passing invalid + # JSON makes the `type` substitution print nothing, which is not "object", + # so the gate fails closed. + if [[ "$(printf '%s\n' "$params" | jq -r 'type')" != "object" ]]; then + log "ERROR" "tools/call params is not a JSON object" + create_error_response "$id" -32602 "Invalid params: expected an object" + return + fi + local tool_name tool_name=$(echo "$params" | jq -r '.name // ""') @@ -410,52 +689,651 @@ handle_tools_call() { return fi - local output - local exit_code=0 - output=$("$func_name" "$arguments" 2>&1) || exit_code=$? + # The tool runs in the background under `set -m` so an in-flight call can be + # cancelled by process group. Job-control notices exist only for interactive + # shells, so the `set -m` here adds nothing to stderr. + # docs/architecture.md §Tool containment. + local output_file + output_file=$(mktemp "${TMPDIR:-/tmp}/mcp-tool-output.XXXXXX") + # Where the wrapper below records the sentinel's pid; _sentinel_pid_file + # carries the derivation and its reason. + local sentinel_file + sentinel_file="$(_sentinel_pid_file "$output_file")" + local pid + local monitor_was_enabled=0 + if [[ -o monitor ]]; then + monitor_was_enabled=1 + fi + set -m + # A lifeline is identified by its directory and FIFO, never by + # _MCP_LIFELINE_FD: process_request closed and cleared that descriptor on + # the way in, so it is empty here on the one path that has a lifeline. + if [[ -n "${_MCP_LIFELINE_DIR:-}" && -p "${_MCP_LIFELINE_DIR}/lifeline" ]]; then + # The wrapper opens its own reader before it starts the tool, so a + # server already gone leaves the wrapper blocked instead of starting an + # orphaned tool. + # The sentinel-pid record is best effort like the in-flight file. A + # wrapper that cannot write it leaves the group's raw liveness as the + # teardown's only measure, which is slower but not wrong. + # docs/architecture.md §Tool containment. + ( exec {lifeline_rd}<"${_MCP_LIFELINE_DIR}/lifeline"; _lifeline_sentinel "${lifeline_rd}" & sentinel_pid=$!; set +e; printf '%s\n' "$sentinel_pid" > "${sentinel_file}"; _reset_tool_dispatch_state; "$func_name" "$arguments" ) \ + >"$output_file" 2>&1 "$output_file" 2>&1 "${_MCP_INFLIGHT_FILE}" + fi - if [[ $exit_code -ne 0 ]]; then - log "ERROR" "Tool $tool_name failed with exit code $exit_code" - local error_result - error_result=$(jq -n -c \ - --arg text "Error executing $tool_name: $output" \ - '{"content": [{"type": "text", "text": $text}], "isError": true}') - create_response "$id" "$error_result" + # The poll below reads the client's stdin, and only run_mcp_server's read + # loop owns that stream. A caller that invokes this function directly owns + # its own stdin instead, so it gets the same background child in the same + # process group but a plain blocking wait: reading there would consume + # bytes the caller means to handle, and an EOF on that stream is the + # caller's business rather than a cancellation. + if [[ "${_MCP_IN_SERVER_LOOP:-0}" == "1" ]]; then + _await_tool_call "$id" "$pid" "$tool_name" "$arguments" "$output_file" + else + _wait_tool_call "$pid" "$tool_name" "$output_file" + fi + + # The call's files are released and the in-flight record truncated before + # anything is built from them, so neither the response construction nor the + # deferred replay below can run ahead of the clear. + # The output file and the sentinel pid file go first and the record second: + # those two paths exist nowhere but this subshell, so a teardown landing + # between the steps reads a record naming a path this dispatch has already + # released, and its removal is idempotent, so it loses nothing. + # docs/architecture.md §The in-flight record. + local output="" + if [[ "$_MCP_CANCELLED" -eq 1 ]]; then + # A cancelled call was already answered by its teardown, which sends no + # response for the id, so its output is discarded and only the call's + # files are left to drop here. + rm -f -- "$output_file" "$sentinel_file" + else + output=$(<"$output_file") + rm -f -- "$output_file" "$sentinel_file" + fi + # Cleared once per dispatch, whichever way it ended — completed, cancelled, + # or cleared by the teardown above. Clearing it is what keeps a shutdown from + # signalling a process group that is already gone and whose id could by then + # name something else. + if [[ -n "${_MCP_INFLIGHT_FILE:-}" ]]; then + : > "${_MCP_INFLIGHT_FILE}" + fi + + if [[ "$_MCP_CANCELLED" -eq 0 ]]; then + local exit_code="$_MCP_CHILD_STATUS" + if [[ $exit_code -ne 0 ]]; then + log "ERROR" "Tool $tool_name failed with exit code $exit_code" + local error_result + error_result=$(jq -n -c \ + --arg text "Error executing $tool_name: $output" \ + '{"content": [{"type": "text", "text": $text}], "isError": true}') + create_response "$id" "$error_result" + else + local result + result=$(jq -n -c \ + --arg text "$output" \ + '{"content": [{"type": "text", "text": $text}], "isError": false}') + create_response "$id" "$result" + fi + fi + + # Requests that arrived while the tool ran are answered after the + # in-flight response, in the order they were received. + local deferred deferred_response + if [[ ${#_MCP_DEFERRED_LINES[@]} -gt 0 ]]; then + # A replayed line that is itself a tools/call must not read stdin. This + # subshell is still the one that owns the stream while the replays run, + # so a nested poll would take the bytes of the fragment this dispatch has + # already handed to the read loop — or of the next request — as its own + # deferred line and consume them. With the flag cleared the replay takes + # the no-read path, a plain blocking call, so a replayed call runs to + # completion with no cancellation window: a cancellation naming it + # arrives later in the stream and is dropped as matching nothing. Only + # this subshell's copy is cleared, and it is put back before the + # EOF-drain check below reads the state this dispatch actually ended in. + local outer_in_server_loop + outer_in_server_loop="${_MCP_IN_SERVER_LOOP:-0}" + _MCP_IN_SERVER_LOOP=0 + for deferred in "${_MCP_DEFERRED_LINES[@]}"; do + if [[ -z "$deferred" ]]; then + continue + fi + deferred_response="" + deferred_response=$(process_request "$deferred") + if [[ -n "$deferred_response" ]]; then + printf '%s\n' "$deferred_response" + fi + done + _MCP_IN_SERVER_LOOP="$outer_in_server_loop" + fi + + # A line the client had left half-written when the tool ended has already + # left this dispatch through the partial-line file, written by the poll loop + # before it returned. The server's read loop completes it once this dispatch + # has returned: completing it here would hold the response above back behind + # a line the client might never finish. + + # Stdin reached EOF while the tool ran, so no further request can arrive. + # This dispatch is fully answered by now, so the server stops after it. The + # signal travels through a file rather than a variable because everything + # above ran in the command substitution run_mcp_server dispatches in. + if [[ "${_MCP_EOF_DRAIN:-0}" -eq 1 ]]; then + if [[ -n "${_MCP_SHUTDOWN_FLAG_FILE:-}" ]]; then + : >"$_MCP_SHUTDOWN_FLAG_FILE" + else + log "WARN" "No shutdown flag file set; the server loop cannot be signalled" + fi + fi +} + +# Wait for a tool child when the call is not driven by the server's read loop. +# Nothing is read here, so there is no cancellation window: the dispatch is a +# plain blocking call, as it is for a consumer that invokes handle_tools_call +# or process_request itself. The child already leads its own process group, so +# the shape matches the in-loop one apart from the wait. +# Args: $1 = child pid, $2 = tool name, $3 = tool output file +# Sets: _MCP_CANCELLED, _MCP_CHILD_STATUS, _MCP_DEFERRED_LINES, _MCP_EOF_DRAIN +_wait_tool_call() { + local pid="$1" + local tool_name="$2" + local output_file="$3" + + _MCP_CANCELLED=0 + _MCP_CHILD_STATUS=0 + _MCP_DEFERRED_LINES=() + _MCP_EOF_DRAIN=0 + + wait "$pid" || _MCP_CHILD_STATUS=$? + _kill_tool_group "$pid" "$tool_name" "$output_file" +} + +# Poll the client's stdin while a tool child runs, so an in-flight tools/call +# can be cancelled and so requests that arrive mid-call are answered in order +# once it finishes. Cancellation matching is docs/architecture.md §Cancellation; +# the EOF and fragment rules are docs/architecture.md §Partial-line handoff. +# Args: $1 = in-flight request id (JSON), $2 = child pid, $3 = tool name, +# $4 = original arguments JSON, $5 = tool output file +# Sets: _MCP_CANCELLED (1 when the call was cancelled, else 0), +# _MCP_CHILD_STATUS (the child's exit status), _MCP_DEFERRED_LINES, +# _MCP_EOF_DRAIN (1 when stdin reached EOF, so the server loop stops +# after this dispatch), and _MCP_PARTIAL_FILE (the fragment in hand when +# the loop ended, empty when there is none) +_await_tool_call() { + local id="$1" + local pid="$2" + local tool_name="$3" + local arguments="$4" + local output_file="$5" + + _MCP_CANCELLED=0 + _MCP_CHILD_STATUS=0 + _MCP_DEFERRED_LINES=() + _MCP_EOF_DRAIN=0 + + local buffer="" + local chunk="" + local line="" + local fragment="" + local line_ready=0 + local is_cancel=0 + local rc=0 + local status=0 + + while kill -0 "$pid" 2>/dev/null; do + chunk="" + rc=0 + line_ready=0 + # On a timeout, and on EOF reached after some bytes were read, bash + # still stores what it managed to read into the variable, and those + # bytes are gone from the pipe: they have to be kept and joined to the + # rest of the line, or a line the client wrote in more than one piece + # would lose its first fragment. + # The window is short because it is the latency every completed + # tools/call pays when the tool outruns it: the read is what the wait + # blocks on after the tool is already gone. + IFS= read -r -t 0.05 chunk || rc=$? + if [[ $rc -eq 0 ]]; then + line="${buffer}${chunk}" + buffer="" + line_ready=1 + elif [[ $rc -gt 128 ]]; then + buffer="${buffer}${chunk}" + else + # EOF on stdin: the client is gone and nothing further can be + # read, but the call was accepted and its response is still owed + # to whoever reads the stream. Reading stops here and the normal + # completion below answers it; the shutdown flag is set by + # handle_tools_call once that response and the deferred lines are + # written. The last read carries bytes even at EOF, so the + # fragment is the joined buffer and whatever that read stored. + # A fragment is only dropped when it is not JSON: one that already + # parses is the client's last request, sent with no trailing + # newline that can now ever arrive, and it is answerable — 3.0.0's + # `read || [[ -n "$line" ]]` answered it. It goes through the same + # handling as a terminated line below, and the loop then stops. + # A dropped fragment is unanswerable and its content is whatever + # the client wrote, so only its length is recorded. The buffer is + # cleared in both cases: the fragment has been accounted for, and + # nothing below may treat it as still in hand. + fragment="${buffer}${chunk}" + buffer="" + line="" + if [[ -n "$fragment" ]]; then + # The parse test is `jq empty`, which exits zero on any + # parseable input: `jq -e '.'` set its status from the + # truthiness of the output, so a fragment of `false` or `null` + # — both valid JSON — read as a parse failure and was dropped. + # `jq empty` also exits zero on input holding no documents, the + # empty string and whitespace-only input alike, so the fragment + # reaching this gate need not hold one: the `-n` guard above + # excludes only the empty string, and a whitespace-only + # fragment is handed on and answered -32700 — the same answer + # the same bytes get when a newline follows them. + if printf '%s\n' "$fragment" | jq empty >/dev/null 2>&1; then + line="$fragment" + line_ready=1 + else + log "WARN" "Discarding a partial line of ${#fragment} characters left by EOF during tools/call $id ($tool_name)" + fi + fi + _MCP_EOF_DRAIN=1 + if [[ $line_ready -eq 0 ]]; then + break + fi + fi + + if [[ $line_ready -eq 1 ]]; then + is_cancel=0 + # The id is compared as a JSON value on both sides: `--argjson` + # normalizes the in-flight id, jq compares it against the + # requestId as parsed, so 1 does not match "1" and a malformed + # line simply fails to parse and is queued instead of matching. + # The requestId presence guard precedes the comparison: an absent + # requestId reads as null, and a null in-flight id would then equal + # it and cancel an unrelated call. Matched only when the key is + # there, so the clause fails closed for an id no other message + # reaches. + if printf '%s\n' "$line" | jq -e --argjson id "$id" \ + '.jsonrpc == "2.0" and (has("id") | not) and .method == "notifications/cancelled" and (.params | has("requestId")) and .params.requestId == $id' \ + >/dev/null 2>&1; then + is_cancel=1 + fi + if [[ $is_cancel -eq 1 ]]; then + log "INFO" "Cancelling in-flight tools/call $id ($tool_name)" + _teardown_tool_call "$tool_name" "$pid" "$arguments" "$id" "$output_file" + _MCP_CANCELLED=1 + return 0 + fi + _MCP_DEFERRED_LINES+=("$line") + # The line above was the last one this loop can read; a terminated + # line leaves the drain flag clear and the loop continues. + if [[ $_MCP_EOF_DRAIN -eq 1 ]]; then + break + fi + fi + done + + # A fragment still in hand when the loop ended — the child died before the + # line it began was finished — is written out for run_mcp_server's read loop + # to complete, no trailing newline, so the byte stream the client wrote is + # the byte stream that is joined. Nothing is written for an empty fragment, + # and the EOF above already accounts for the one it discarded. The handoff is + # logged for the call it left, never for the fragment: the fragment's content + # is the client's, so only its length is recorded. + if [[ -n "$buffer" && -n "${_MCP_PARTIAL_FILE:-}" ]]; then + printf '%s' "$buffer" > "${_MCP_PARTIAL_FILE}" + log "INFO" "Handing a partial line of ${#buffer} characters out of tools/call $id ($tool_name) to the read loop" + fi + + wait "$pid" || status=$? + _MCP_CHILD_STATUS="$status" + _kill_tool_group "$pid" "$tool_name" "$output_file" +} + +# Run a tool's optional tool__cancel hook, bounded at +# _MCP_CANCEL_GRACE_SECONDS so a wedged hook cannot wedge its caller. The hook +# is consumer code: it gets the call's arguments as its one argument, and +# neither the client's stdin nor the protocol stream. +# Args: $1 = tool name, $2 = the call's arguments JSON. The teardown that runs +# from a signal handler passes an empty string: it reads the tool's name +# and process group out of the in-flight file, which records no +# arguments. +_run_cancel_hook() { + local tool_name="$1" + local arguments="$2" + + local hook="tool_${tool_name}_cancel" + if ! type "$hook" &>/dev/null; then + return 0 + fi + + local hook_pid + local waited=0 + local hook_status=0 + local monitor_was_enabled=0 + if [[ -o monitor ]]; then + monitor_was_enabled=1 + fi + set -m + ( if [[ -n "${_MCP_LIFELINE_FD:-}" ]]; then exec {_MCP_LIFELINE_FD}>&-; fi; set +e; "$hook" "$arguments" ) >/dev/null 2>&1 /dev/null \ + && [[ $waited -lt $((_MCP_CANCEL_GRACE_SECONDS * 10)) ]]; do + sleep 0.1 + waited=$((waited + 1)) + done + if kill -0 "$hook_pid" 2>/dev/null; then + kill -KILL -- "-$hook_pid" 2>/dev/null || true + wait "$hook_pid" || true + log "WARN" "Cancel hook ${hook} for ${tool_name} did not finish within ${_MCP_CANCEL_GRACE_SECONDS}s; killed" + else + wait "$hook_pid" || hook_status=$? + if [[ $hook_status -ne 0 ]]; then + log "WARN" "Cancel hook ${hook} for ${tool_name} exited with status ${hook_status}" + fi + # The hook leads a group of its own, which the in-flight record does not + # name, so no tombstone is written for it: the call's group is still + # live here and the record has to keep naming it. + _kill_tool_group "$hook_pid" "" "" + fi + return 0 +} + +# Clear what is left of a tool's process group after its wrapper has been +# reaped: the group is the tool's containment boundary, so nothing the tool +# started outlives the call. docs/architecture.md §Tool containment. +# +# The tombstone is written ahead of the check-and-kill, not after it, because +# this function is the one place the group is deliberately emptied: any other +# order leaves a window where the group is empty and the record still names its +# id. Accepted cost of that order — a teardown racing the same sliver removes +# the call's files without signalling, and the lifeline sentinel remains the +# containment backstop. docs/architecture.md §The in-flight record. +# Args: $1 = the wrapper's pid, which is the group id it leads, +# $2 = the call's tool name, $3 = the call's output file path. The last +# two are empty when the group is not the call's own — the cancel hook's +# group is a group of its own, and the record names the call's — and a +# tombstone is written only when both are non-empty, on the server loop +# only, where a trap can read the record. +_kill_tool_group() { + local pid="$1" + local tool_name="$2" + local output_file="$3" + + if [[ "${_MCP_IN_SERVER_LOOP:-0}" == "1" && -n "${_MCP_INFLIGHT_FILE:-}" \ + && -n "$tool_name" && -n "$output_file" ]]; then + printf '%s %s %s\n' "-" "$tool_name" "$output_file" > "${_MCP_INFLIGHT_FILE}" + fi + + if kill -0 -- "-${pid}" 2>/dev/null; then + kill -KILL -- "-${pid}" 2>/dev/null || true + fi + return 0 +} + +# The file a call's wrapper records its sentinel's pid in: a sibling of the +# call's output file, named from the output file's own name so it belongs to +# that call alone. It is derived rather than remembered because a shutdown +# teardown reaches a running call through the in-flight record, which holds the +# output file's path and nothing else. The name deliberately does not begin +# "mcp-tool-output", so it never reads as a second output file of the call. +# Args: $1 = the call's output file path +_sentinel_pid_file() { + local output_file="$1" + + [[ -z "$output_file" ]] && return 0 + printf '%s/mcp-sentinel.%s' "${output_file%/*}" "${output_file##*/}" +} + +# The pid recorded in a call's sentinel file. Empty when no record was written — +# no lifeline, or the wrapper had not reached it yet — which a grace poll reads +# as "unknown" and falls back to the group's raw liveness. +# Args: $1 = the sentinel pid file path +_sentinel_pid_for() { + local sentinel_file="$1" + local sentinel_pid="" + + if [[ -n "$sentinel_file" && -s "$sentinel_file" ]]; then + IFS= read -r sentinel_pid < "$sentinel_file" || true + fi + printf '%s' "$sentinel_pid" +} + +# Whether a tool's process group still holds a live member other than the +# lifeline sentinel. The sentinel waits on the server's lifeline rather than on +# the tool and ignores TERM, so it outlives a tool that dies on the first TERM; +# counting it would hold every cancellation open for the whole grace period. +# A group whose only live member is the sentinel therefore reads as dead, and +# the caller's grace loop ends. The sentinel is not forgotten: _kill_tool_group +# clears whatever is left of the group once the tool is reaped. +# A group the kernel has already released is dead here too — the scan matches +# nothing — but a caller that must not signal a recycled group id gates on +# `kill -0 -- "-"` for that, not on this. +# The members come from a full `ps` listing filtered here rather than from a +# selecting call, which cannot tell an emptied group from a probe that never +# ran. A `ps` that fails at all is unknown liveness, never an empty group: read +# as empty it would end the caller's grace loop at once and skip the SIGKILL +# after it, leaving a tool that ignores TERM alive. +# docs/architecture.md §Cancellation. +# Args: $1 = group id, $2 = sentinel pid (empty when unknown) +_tool_group_has_live_member() { + local pgid="$1" + local sentinel_pid="$2" + + local listing="" + local ps_rc=0 + # Guarded so a missing binary, which reaches a subshell as a 127 exit rather + # than through errexit, cannot end the caller. + listing="$(ps -A -o pgid=,pid= 2>/dev/null)" || ps_rc=$? + if [[ ${ps_rc} -ne 0 ]]; then + kill -0 -- "-${pgid}" 2>/dev/null return fi - local result - result=$(jq -n -c \ - --arg text "$output" \ - '{"content": [{"type": "text", "text": $text}], "isError": false}') + local entry_pgid member + while read -r entry_pgid member _; do + if [[ "${entry_pgid}" != "${pgid}" || -z "${member}" || "${member}" == "${sentinel_pid}" ]]; then + continue + fi + return 0 + done <<< "${listing}" + return 1 +} - create_response "$id" "$result" +# Stop a cancelled tool call: run its optional cancel hook, terminate the +# child's process group, and reap it. A hook or a child that ignores SIGTERM +# gets _MCP_CANCEL_GRACE_SECONDS before being killed, so a wedged consumer tool +# cannot wedge the server; the grace is measured against the group's live +# members other than the sentinel, so a tool that dies on the first TERM is +# reaped at once rather than two seconds later. +# Args: $1 = tool name, $2 = child pid, $3 = original arguments JSON, +# $4 = in-flight request id (JSON), $5 = tool output file +_teardown_tool_call() { + local tool_name="$1" + local pid="$2" + local arguments="$3" + local id="$4" + local output_file="$5" + + _run_cancel_hook "$tool_name" "$arguments" + + local sentinel_file + sentinel_file="$(_sentinel_pid_file "$output_file")" + local sentinel_pid + sentinel_pid="$(_sentinel_pid_for "$sentinel_file")" + + local alive_waited=0 + if kill -0 -- "-$pid" 2>/dev/null; then + # The child leads its own process group, so the negative pid reaches + # the tool and everything it spawned. + kill -TERM -- "-$pid" 2>/dev/null || true + while _tool_group_has_live_member "$pid" "$sentinel_pid" \ + && [[ $alive_waited -lt $((_MCP_CANCEL_GRACE_SECONDS * 10)) ]]; do + sleep 0.1 + alive_waited=$((alive_waited + 1)) + done + if _tool_group_has_live_member "$pid" "$sentinel_pid"; then + kill -KILL -- "-$pid" 2>/dev/null || true + log "WARN" "Tool ${tool_name} ignored SIGTERM; process group killed" + fi + fi + # Reaped once, after the last signal: the status reflects the signal, + # which is what a cancellation is expected to look like. + wait "$pid" || true + _kill_tool_group "$pid" "$tool_name" "$output_file" + rm -f -- "$output_file" "$sentinel_file" + log "INFO" "Cancelled tools/call $id ($tool_name)" } process_request() { local request="$1" - if ! echo "$request" | jq -e '.' >/dev/null 2>&1; then + if [[ "${_MCP_IN_SERVER_LOOP:-0}" == "1" && -n "${_MCP_LIFELINE_FD:-}" ]]; then + exec {_MCP_LIFELINE_FD}>&- + _MCP_LIFELINE_FD="" + fi + + # Parse gate: exactly one JSON document, of any type. `jq -e '.'` accepted a + # line holding two documents — it reports only its last output — and the id + # filter below then emitted one line per document, so the joined text + # reached `--argjson`, whose rejection ended the server. It also exits + # non-zero on a whole document that is `null` or `false`, both of which are + # parseable, which is why those two answered -32700 while every other + # scalar reached the version arm and answered -32600. The count is read the + # way read_json_file reads a file's, with `jq -cs` and a length test; a line + # jq cannot parse at all fails the substitution, and any other count is a + # line that is not one document. + local doc_count + if ! doc_count=$(printf '%s\n' "$request" | jq -cs 'length' 2>/dev/null); then log "ERROR" "Invalid JSON received" create_error_response "null" -32700 "Parse error: Invalid JSON" return fi + if [[ "$doc_count" != "1" ]]; then + log "ERROR" "Received a line holding ${doc_count} JSON documents; expected exactly one" + create_error_response "null" -32700 "Parse error: Expected exactly one JSON document per line" + return + fi + + # JSON-RPC 2.0 represents a call as a Request object, so a document of any + # other type is an Invalid Request. This gate runs before `.jsonrpc`, `.id`, + # `.method` and `.params` are read, so those extractions only ever see an + # object and none of them can fail. That is what makes the crash fix a + # property of this function rather than of its caller: process_request no + # longer depends on the dispatch site clearing errexit inside the command + # substitution. It also keeps a non-object line's `Cannot index ...` jq + # diagnostics off the process's stderr, unrouted through `log`. + if [[ "$(printf '%s\n' "$request" | jq -r 'type')" != "object" ]]; then + log "ERROR" "JSON-RPC request is not a JSON object" + create_error_response "null" -32600 "Invalid Request: expected a JSON object" + return + fi - local jsonrpc id method params + local jsonrpc id id_is_valid method params jsonrpc=$(echo "$request" | jq -r '.jsonrpc // ""') - id=$(echo "$request" | jq -c '.id // null') + # The id is read by key presence, not with `//`: that operator maps an + # absent key and a present null to the same output, so a request carrying + # `"id": null` was indistinguishable from a notification. `tojson` keeps + # the JSON type of a present id, so 7 stays an integer and "7" a string, + # and an absent key leaves the empty sentinel the notification gate reads. + id=$(echo "$request" | jq -r 'if has("id") then (.id | tojson) else "" end') method=$(echo "$request" | jq -r '.method // ""') params=$(echo "$request" | jq -c '.params // {}') + # Whether the id is of a type a response can echo back: a JSON string or + # integer. JSON-RPC 2.0 §5 allows a response id to be a String, Number or + # Null, so every other type — and the absent id, whose sentinel is the + # empty string — must be answered with a null id rather than reflected. + # Computed once here, because both the version arm below and the id-type + # gate read this verdict; a second test would be the same question twice. + # + # The integer test is `validate_tool_arguments`' `type_ok` test for a + # declared `integer`, restated for a whole document: `$id` is already the + # id's `tojson` rendering, so the value under test is the document jq reads + # rather than a sub-value. The duplication is deliberate — the validator's + # copy sits inside a large jq program string, and factoring the two + # together is a refactor with its own risk. The comment above that copy + # explains why a bare `floor` comparison is not enough. + id_is_valid=0 + if [[ -n "$id" ]] && printf '%s\n' "$id" | jq -e ' + (type == "string") + or ( + (type == "number") + and (. == (. | floor)) + and ((tojson) as $literal + | if ($literal | test("[eE]")) then true + else ($literal | test("\\.[0-9]*[1-9]") | not) + end) + )' >/dev/null 2>&1; then + id_is_valid=1 + fi + if [[ "$jsonrpc" != "2.0" ]]; then log "ERROR" "Invalid JSON-RPC version: $jsonrpc" - create_error_response "$id" -32600 "Invalid Request: jsonrpc must be 2.0" + # This arm runs ahead of the id-type gate, so it settles the response + # id itself: a string or integer is reflected, and the absent sentinel, + # a null, a boolean, a fractional number, an object or an array all + # answer null, which is the response id JSON-RPC reserves for an id + # that could not be echoed. + local response_id="null" + if [[ "$id_is_valid" == "1" ]]; then + response_id="$id" + fi + create_error_response "$response_id" -32600 "Invalid Request: jsonrpc must be 2.0" return fi - # JSON-RPC notifications have no id and require no response - if [[ "$id" == "null" ]]; then - log "INFO" "Received notification: $method" + # A message with no id key is a JSON-RPC notification and requires no + # response, so none of the arms below can emit one. The id is empty exactly + # when the key is absent. Between requests nothing is in flight: a + # cancellation that arrives here matches no call and is dropped. + if [[ -z "$id" ]]; then + case "$method" in + "notifications/initialized") + log "INFO" "Client initialized" + ;; + "notifications/cancelled") + log "INFO" "Cancellation received with nothing in flight; dropped" + ;; + *) + log "INFO" "Received notification: $method" + ;; + esac + return + fi + + # An id that is neither a string nor an integer is neither a request nor a + # notification: MCP requires a request id to be a string or an integer and + # forbids null, and a notification carries no id at all, so an id of any + # other type — including a fractional number — is answered rather than + # dispatched. The validity test above enforces that rule in full; its + # verdict is read here rather than re-run. + if [[ "$id_is_valid" != "1" ]]; then + log "ERROR" "Request id must be a string or an integer" + create_error_response "null" -32600 "Invalid Request: id must be a string or an integer" return fi @@ -469,9 +1347,6 @@ process_request() { "tools/call") handle_tools_call "$id" "$params" ;; - "notifications/initialized") - log "INFO" "Client initialized" - ;; "ping") create_response "$id" '{}' ;; @@ -482,10 +1357,352 @@ process_request() { esac } +# SIGKILL the tool's process group once the server is gone, however it went. +# This sentinel blocks on a read of the server's lifeline FIFO. +# docs/architecture.md §The lifeline. +# Nothing is ever expected to arrive on that read: a byte would end the wait +# early and kill the group while the server is still alive, and the only writer +# in this design never writes. +_lifeline_sentinel() { + local read_fd="$1" + + trap '' TERM INT HUP + + local pgid="" + local entry_pid entry_pgid + while read -r entry_pid entry_pgid _; do + if [[ "${entry_pid}" == "${BASHPID}" ]]; then + pgid="${entry_pgid}" + break + fi + done < <(ps -A -o pid=,pgid= 2>/dev/null) + + local line="" + IFS= read -r -u "${read_fd}" line || true + + kill -KILL -- "-${pgid}" 2>/dev/null || true +} + +# Stop whatever the server still holds, however the server is going down. +# The in-flight group is not this shell's child — it was the dispatch +# subshell's, and that subshell is often already gone — so it is reaped by +# polling the group, never by `wait`. docs/architecture.md §Shutdown. +_server_teardown() { + # A signal that arrives while this is already running would otherwise enter + # it a second time and re-signal a group, and re-run a hook, that the first + # pass has already dealt with. The flag is cleared on the way out rather + # than left set, so a shell that runs a second server still tears that one + # down. + if [[ "${_MCP_TEARDOWN_RUNNING:-0}" == "1" ]]; then + return 0 + fi + _MCP_TEARDOWN_RUNNING=1 + + local inflight_pid="" + local inflight_tool="" + local inflight_output="" + local inflight_sentinel_file="" + if [[ -n "${_MCP_INFLIGHT_FILE:-}" && -s "${_MCP_INFLIGHT_FILE}" ]]; then + read -r inflight_pid inflight_tool inflight_output < "${_MCP_INFLIGHT_FILE}" || true + inflight_sentinel_file="$(_sentinel_pid_file "${inflight_output}")" + fi + + # A pgid field of `-` is the tombstone: _kill_tool_group wrote it as it + # emptied the group, so the id it once named is free to belong to some other + # process group by now. Nothing is signalled for it, no cancel hook runs, + # and no grace is polled — only the files the tombstone still names are + # released below. A numeric pgid is a call still running, and keeps today's + # behaviour. + if [[ -n "${inflight_pid}" && "${inflight_pid}" != "-" ]] \ + && kill -0 -- "-${inflight_pid}" 2>/dev/null; then + _run_cancel_hook "${inflight_tool}" "" + # The sentinel this call's wrapper recorded beside the output file is + # left out of the group's liveness exactly as in _teardown_tool_call: it + # ignores TERM and waits on the lifeline, so counting it would hold this + # teardown for the whole grace on every shutdown. + local inflight_sentinel + inflight_sentinel="$(_sentinel_pid_for "${inflight_sentinel_file}")" + kill -TERM -- "-${inflight_pid}" 2>/dev/null || true + local waited=0 + while _tool_group_has_live_member "${inflight_pid}" "${inflight_sentinel}" \ + && [[ $waited -lt $((_MCP_CANCEL_GRACE_SECONDS * 10)) ]]; do + sleep 0.1 + waited=$((waited + 1)) + done + if _tool_group_has_live_member "${inflight_pid}" "${inflight_sentinel}"; then + kill -KILL -- "-${inflight_pid}" 2>/dev/null || true + log "WARN" "Tool ${inflight_tool} ignored SIGTERM; process group killed" + fi + log "INFO" "Stopped in-flight tool ${inflight_tool} (group ${inflight_pid}) on shutdown" + fi + + # The tool's output file is named nowhere but the dispatch subshell that + # created it, so a server going down on a signal has only the record to + # reach it; the sentinel pid file beside it is derived from that same path + # and is removed with it. This removal runs for a tombstone record too, + # which is the point of keeping the path in it. A teardown that found no + # call to stop leaves nothing to remove either way. + if [[ -n "${inflight_output}" ]]; then + rm -f -- "${inflight_output}" "${inflight_sentinel_file}" + fi + + # The variables are cleared with the files they name: a later step in this + # shell cannot recreate a path the teardown has released, and a second pass + # over a cleared name skips the removal instead of repeating it against a + # path the shell may since have given to something else. + if [[ -n "${_MCP_INFLIGHT_FILE:-}" ]]; then + rm -f -- "${_MCP_INFLIGHT_FILE}" + _MCP_INFLIGHT_FILE="" + fi + if [[ -n "${_MCP_SHUTDOWN_FLAG_FILE:-}" ]]; then + rm -f -- "${_MCP_SHUTDOWN_FLAG_FILE}" + _MCP_SHUTDOWN_FLAG_FILE="" + fi + # The partial-line handoff is the one of these the server holds for its own + # use rather than a dispatch's, so a teardown that runs at the end of a + # normal loop lifetime removes it here, exactly as the loop's own exit does. + if [[ -n "${_MCP_PARTIAL_FILE:-}" ]]; then + rm -f -- "${_MCP_PARTIAL_FILE}" + _MCP_PARTIAL_FILE="" + fi + # Dropping the last writer is what releases a sentinel still waiting; the + # group already killed above is the normal way out. The variable is cleared + # with the descriptor so a second teardown cannot close a number the shell + # has since given to something else. + if [[ -n "${_MCP_LIFELINE_FD:-}" ]]; then + exec {_MCP_LIFELINE_FD}>&- + _MCP_LIFELINE_FD="" + fi + if [[ -n "${_MCP_LIFELINE_DIR:-}" ]]; then + rm -rf -- "${_MCP_LIFELINE_DIR}" + _MCP_LIFELINE_DIR="" + fi + + # The caller's own EXIT handler runs here, after the SDK has released its + # files: the one point both routes into this function reach with the cleanup + # done and the shell still alive. It runs in its own background group under + # the cancel-hook grace, because bash defers a trapped signal until a + # foreground subshell finishes, so a handler that blocked would stall + # shutdown — an overrun is killed and logged. `set +e` and a logged, not + # returned, failure give it the no-errexit shape its contract states. Its + # stdout goes to stderr, and its stdin is `/dev/null` rather than the + # inherited client stream a handler could otherwise consume. The variable is + # cleared after the one run, so no later pass over this function — the + # loop's own teardown, then the EXIT trap — can run the handler a second + # time. docs/architecture.md §Shutdown. + if [[ -n "${_MCP_CHAINED_EXIT_TRAP:-}" ]]; then + local chained_pid="" + local chained_waited=0 + local chained_status=0 + local chained_monitor_was_enabled=0 + if [[ -o monitor ]]; then + chained_monitor_was_enabled=1 + fi + set -m + ( set +e; eval "${_MCP_CHAINED_EXIT_TRAP}" ) >&2 /dev/null \ + && [[ $chained_waited -lt $((_MCP_CANCEL_GRACE_SECONDS * 10)) ]]; do + sleep 0.1 + chained_waited=$((chained_waited + 1)) + done + if kill -0 "$chained_pid" 2>/dev/null; then + kill -KILL -- "-$chained_pid" 2>/dev/null || true + wait "$chained_pid" || true + log "WARN" "Chained caller EXIT trap did not finish within ${_MCP_CANCEL_GRACE_SECONDS}s; killed" + else + wait "$chained_pid" || chained_status=$? + if [[ $chained_status -ne 0 ]]; then + log "WARN" "Chained caller EXIT trap failed with status ${chained_status}" + fi + _kill_tool_group "$chained_pid" "" "" + fi + _MCP_CHAINED_EXIT_TRAP="" + fi + + _MCP_TEARDOWN_RUNNING=0 + + # A signal a trap recorded while this pass ran is what the shell now dies + # by. The re-raise runs at the end of every pass, not only a trap-driven + # one, so a signal recorded during a clean-exit teardown still ends the + # shell by that signal. docs/architecture.md §Shutdown. + if [[ -n "${_MCP_PENDING_SIGNAL:-}" ]]; then + local pending_signal + pending_signal="${_MCP_PENDING_SIGNAL}" + _MCP_PENDING_SIGNAL="" + trap - "$pending_signal" + kill -s "$pending_signal" "$BASHPID" + fi + return 0 +} + +# What every signal trap runs: tear the server down, then die by the signal that +# was sent, with its default disposition restored, so the exit status reports +# death by signal rather than a plain zero. +# A signal that arrives while a teardown is already running cannot re-enter it. +# It is recorded instead, and the pass in progress re-raises it when it ends: +# the teardown finishes its cleanup, and the shell still dies by the signal. +# Re-raising from here instead would abort that pass part-way, skipping the +# SIGKILL to the tool group and the removal of the call's files. +# Args: $1 = the signal name, as the trap spells it +_mcp_teardown_on_signal() { + local signal="$1" + + if [[ "${_MCP_TEARDOWN_RUNNING:-0}" == "1" ]]; then + _MCP_PENDING_SIGNAL="$signal" + return 0 + fi + + _server_teardown + trap - "$signal" + kill -s "$signal" "$BASHPID" +} + run_mcp_server() { log "INFO" "MCP Server starting..." - while IFS= read -r line || [[ -n "$line" ]]; do + # A cancellation handler runs inside the command substitution below, so a + # shell variable cannot carry its shutdown signal back out. The flag is a + # file instead, created only when the client closes stdin mid-call. + local shutdown_flag + shutdown_flag=$(mktemp "${TMPDIR:-/tmp}/mcp-shutdown.XXXXXX") + rm -f -- "$shutdown_flag" + export _MCP_SHUTDOWN_FLAG_FILE="$shutdown_flag" + + # Where a dispatch records the tool group it started, the tool's name, and + # the file its output is collected in, for the teardown below to find. + # Created once and left in place: its content, not its existence, is the + # signal, and every dispatch clears it on the way out. + local inflight_file + inflight_file=$(mktemp "${TMPDIR:-/tmp}/mcp-inflight.XXXXXX") + export _MCP_INFLIGHT_FILE="$inflight_file" + + # Where a dispatch hands out a line the client had left half-written when + # the call ended. A fragment cannot travel back out of the command + # substitution above as anything but part of its stdout, and prepending it + # there would mean the dispatch had to read the rest of the line first — + # holding back the response the call already earned. The file is created + # empty and never deleted while the server runs: the loop below empties it + # after every read, so its content, not its existence, is the signal. + local partial_file + partial_file=$(mktemp "${TMPDIR:-/tmp}/mcp-partial.XXXXXX") + export _MCP_PARTIAL_FILE="$partial_file" + + # The lifeline: a FIFO the server holds open read-write and never writes to, + # which every tool wrapper's sentinel reads. A writer disappears exactly when + # the last server process holding it dies, whatever killed it — SIGKILL + # included, which no trap can observe. The read-write open never blocks, and + # the descriptor is what makes the server a writer. + local lifeline_dir + lifeline_dir=$(mktemp -d "${TMPDIR:-/tmp}/mcp-lifeline.XXXXXX") + mkfifo "${lifeline_dir}/lifeline" + export _MCP_LIFELINE_DIR="$lifeline_dir" + exec {_MCP_LIFELINE_FD}<>"${lifeline_dir}/lifeline" + + # Known and accepted limitation, at the point it bites: a signal to this + # shell's pid alone while a dispatch runs takes effect only when that + # dispatch returns, so the in-flight call finishes first and the teardown + # then finds the tool already reaped. README.md §Cancelling and shutting + # down. + # The EXIT handler a caller installed before this call is kept rather than + # dropped: the trap below replaces it, and the teardown runs it afterwards. + # Captured only in the shell the caller's trap was installed in — a caller + # that isolates this call in a subshell keeps its handler in the parent, and + # chaining it from the subshell would run it in two shells. A plain variable + # rather than a `local`, because the EXIT trap reads it after this function + # has returned. docs/architecture.md §Shutdown. + _MCP_CHAINED_EXIT_TRAP="" + if [[ "${BASHPID}" == "$$" ]]; then + # A bash subshell inherits its parent's trap table, and `trap -p` reports + # the parent's handler until the subshell itself changes a trap — so + # this substitution's subshell reports this shell's own handler, and the + # handler does not run there. Should a future bash reset traps in a + # subshell the way POSIX describes, this capture would come back empty + # and a handler would be missed, never fired a second time. + local incumbent_trap_text + incumbent_trap_text=$(trap -p EXIT) + # An empty capture is a shell with no EXIT handler; under `set -o posix` + # an unset trap reports `trap -- - EXIT`, whose `-` is not a handler. + # Both store nothing. + if [[ -n "${incumbent_trap_text}" ]]; then + # The re-input form is `trap -- '' EXIT`, so the handler is + # the third word once that line is parsed as the shell would. + eval "set -- ${incumbent_trap_text}" + if [[ -n "${3-}" && "${3-}" != "-" ]]; then + _MCP_CHAINED_EXIT_TRAP="${3-}" + fi + fi + fi + + # The caller's handler on a signal is replaced here: it expects the shell to + # keep running, and this shell has to die by the signal. The EXIT trap is + # the SDK's own, and the caller's EXIT handler runs from the teardown + # instead. docs/architecture.md §Shutdown. + trap '_server_teardown' EXIT + trap '_mcp_teardown_on_signal INT' INT + trap '_mcp_teardown_on_signal TERM' TERM + trap '_mcp_teardown_on_signal HUP' HUP + trap '_mcp_teardown_on_signal PIPE' PIPE + + # This loop is what owns the client's stdin, so it is what licenses the + # mid-call poll in _await_tool_call to read that stream. A plain variable, + # not an export: the poll runs in the command substitution below, a + # subshell of this shell, and no other process has any business reading + # the client's side of the protocol. + _MCP_IN_SERVER_LOOP=1 + + # A fragment a dispatch could not finish is prepended to the next line this + # loop reads, so the line is dispatched as the client wrote it. The variable + # lives across iterations because the read that completes the line happens + # after the dispatch that carried the fragment is already gone. + # A fragment may only be joined to a line whose read succeeded: a read that + # fails leaves whatever it stored unterminated, so joining there would form + # a request the client never finished writing and answer a line it left + # open. The one exception is a joined fragment that already parses as JSON + # — a request the client did finish writing and never terminated — and the + # EOF branch below tells that case from an unterminated line by parsing it. + # A line a failed read stored with no fragment in hand is dispatched as + # before — a client that sent its last request without a trailing newline + # is answered rather than ignored. + local partial="" + local read_rc=0 + while true; do + read_rc=0 + IFS= read -r line || read_rc=$? + if [[ $read_rc -ne 0 && -n "$partial" ]]; then + # Nothing further can arrive, so no later read can complete this + # line, and the bytes this read stored are its tail. The joined + # fragment is still a line when it parses as JSON: a client that + # sent its last request with no trailing newline wrote exactly + # that, and 3.0.0's `read || [[ -n "$line" ]]` answered it. It + # falls through to the dispatch below like any other line. + # Otherwise both pieces are dropped together, with only their + # length logged: the content is the client's. + local eof_fragment + eof_fragment="${partial}${line}" + partial="" + # `jq empty`, not `jq -e '.'`: `-e` takes its status from the + # output's truthiness, so a fragment of `false` or `null` read as a + # parse failure. Full reason at the same test in _await_tool_call. + if ! printf '%s\n' "$eof_fragment" | jq empty >/dev/null 2>&1; then + log "WARN" "Discarding a partial line of ${#eof_fragment} characters left by EOF" + break + fi + line="$eof_fragment" + fi + if [[ $read_rc -ne 0 && -z "$line" ]]; then + break + fi + + if [[ -n "$partial" ]]; then + line="${partial}${line}" + partial="" + fi [[ -z "$line" ]] && continue log "INFO" "Received: ${line:0:100}..." @@ -497,7 +1714,27 @@ run_mcp_server() { log "RESPONSE" "${response:0:100}..." echo "$response" fi + + # Read after the response is out, so a dispatch's fragment never holds + # back the response that dispatch owed. Emptying the file hands the next + # fragment a clean slate, and the next dispatch writes one only if it + # ends with a line still unfinished. + if [[ -n "${_MCP_PARTIAL_FILE:-}" && -s "${_MCP_PARTIAL_FILE}" ]]; then + partial="$(<"${_MCP_PARTIAL_FILE}")" + : > "${_MCP_PARTIAL_FILE}" + fi + + if [[ -e "$_MCP_SHUTDOWN_FLAG_FILE" ]]; then + log "INFO" "Client closed stdin during an in-flight call; shutting down" + break + fi done + _MCP_IN_SERVER_LOOP=0 + _server_teardown + rm -f -- "$shutdown_flag" + unset _MCP_SHUTDOWN_FLAG_FILE + rm -f -- "$partial_file" + unset _MCP_PARTIAL_FILE log "INFO" "MCP Server shutting down" } diff --git a/plugins/dev-tooling/shared/server_run.sh b/plugins/dev-tooling/shared/server_run.sh new file mode 100644 index 00000000..b1c32983 --- /dev/null +++ b/plugins/dev-tooling/shared/server_run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Shared tail for the dev-tooling MCP server entry points: the run_mcp_server +# call and the rationale for why it isn't isolated in a subshell. +# +# Owned by dev-tooling. Not templated — do not add it to the mapping in +# .claude/rules/template-sync.md. +# +# Requires: run_mcp_server() from shared/mcpserver_core.sh. Source with no +# arguments — a sourced file with none of its own receives the sourcing +# script's own positional parameters as "$@", so this call forwards the +# server's argv unchanged. The EXIT trap the comment below refers to is set +# earlier in the sourcing server.sh, before this module is sourced; it stays +# there rather than moving here, so its position relative to config.sh's own +# EXIT trap is unchanged. + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+ + +# Called directly, not in a subshell. The handler installs EXIT/INT/TERM/HUP/PIPE +# traps that replace the one set above, and since bash-mcp-sdk v5.1.0 its +# teardown runs the EXIT handler it displaced, so that cleanup survives without +# isolating the call. A subshell would preserve the cleanup too, but it would +# also hold the handler's signal traps outside this process — the one the client +# signals — and the handler's own temp files were measured to survive every +# signalled shutdown in that shape. +run_mcp_server "$@" diff --git a/plugins/dev-tooling/shared/worktree.sh b/plugins/dev-tooling/shared/worktree.sh new file mode 100644 index 00000000..2b61e322 --- /dev/null +++ b/plugins/dev-tooling/shared/worktree.sh @@ -0,0 +1,1917 @@ +#!/usr/bin/env bash +# Per-call project root resolution for the dev-tooling MCP servers. +# A tool call may target a linked git worktree of the root the server was +# launched in. This module decides which root a call runs against, validates it, +# and rebinds the working directory for that call only. +# +# Owned by dev-tooling. Not templated — do not add it to the mapping in +# .claude/rules/template-sync.md. +# +# Comment rule for this file: a fact about a shared function's contract lives +# once, in that function's header, never at a call site. Keep a comment only if +# deleting it would let an obvious "simplification" break measured behavior. A +# comment explaining why the code is fast, or what it used to be, is cut. +# +# Requires: PROJECT_ROOT, LINT_CONFIG_FILE, DEV_TOOLING_STATE_FILE exported by +# server.sh; log(), load_config(), _discover_configs(), +# _get_config_value(), get_workdir(), get_js_workdir() and +# scope_validate() from the shared modules; JS_CONTEXT set by the JS servers only. +# +# Public: +# worktree_state_init - create the state file; once, from server.sh +# worktree_state_cleanup - remove it; from server.sh's EXIT trap +# worktree_enter - resolve the effective root and banner it +# worktree_resolve_root - the resolution half of worktree_enter +# worktree_root_banner - the banner half of worktree_enter +# worktree_assert_dependencies [php|js] +# - check the dependencies a tool needs are +# installed; the kind defaults to the server's +# own +# worktree_assert_paths_within_root +# - refuse an absolute "paths" entry outside it +# worktree_release_owned_temp - remove the merged-config temp file this call created +# tool_set_project_root - MCP tool: set or clear the sticky root +# tool_cwd - MCP tool: report the resolution state + +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+ + +# The launch root is the effective root until a call resolves otherwise. +WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT:-}" +WORKTREE_ROOT_SOURCE="launch" + +# Path to the merged-config temp file this call created, or empty; see +# worktree_release_owned_temp for why only a path recorded here may be removed. +WORKTREE_OWNED_TEMP_FILE="" + +# The effective root and the launch root as the filesystem reaches them, set +# together by _worktree_validate_root's containment test and read by the path +# mapping through resolve_env_workdir. Containment compares canonical paths, so +# the mapping has to measure the same pair; empty means no canonical pair has +# been established for the current call, and every reader then falls back to the +# raw spelling. Both are empty for a native call, where containment is not +# measured and the mapping is the identity. +WORKTREE_CANONICAL_ROOT="" +WORKTREE_CANONICAL_LAUNCH="" + +# The environment _worktree_validate_root established for the current call, +# read by the mapping instead of re-running the configuration read, and the +# failure message _worktree_map_call_environment leaves for its caller to +# route — printed by the binding, folded into the report by tool_cwd. +WORKTREE_VALIDATED_ENVIRONMENT="" +WORKTREE_MAP_MESSAGE="" + +# The derivation's own temp file, and the refusal it holds. Both belong to the +# call that ran the derivation; see _worktree_run_derivation. +WORKTREE_DERIVATION_LOG="" +WORKTREE_DERIVATION_MESSAGE="" + +WORKTREE_SELECTED_CONFIG_FILE="${LINT_CONFIG_FILE:-}" + +# The configuration the server started with. It stays in force for a call that +# targets the launch root, and for a worktree carrying no configuration of its +# own; a selected file differing from this one is what makes a call re-derive +# the environment from the file that applies to it. +WORKTREE_LAUNCH_CONFIG_FILE="${LINT_CONFIG_FILE:-}" + +# The environment-side path WORKTREE_EFFECTIVE_ROOT is reached by, empty when +# the two coincide — which is every native call. Diagnostic only: nothing +# branches on it, and it exists so a message naming a root can also name the +# path the command actually runs against when the two differ. +WORKTREE_ENV_WORKDIR="" + +# The launch root's common git directory; empty means it isn't a git repository, which every worktree-targeted call then refuses. +WORKTREE_LAUNCH_COMMON="" + +# Why the last _worktree_validate_root call refused. A global rather than +# stdout: reading it via command substitution would subshell away the selected config and owned temp path that call also sets. +WORKTREE_VALIDATION_MESSAGE="" + +# ============================================================================= +# State file +# ============================================================================= + +# worktree_state_init +# Creates the server's state file and exports its path. A tool runs in a +# dispatch subshell, so a value it assigns to a shell variable never reaches a +# later call; this file is how a tool reaches state that outlives its own. +# Globals: sets and exports DEV_TOOLING_STATE_FILE +# Outputs: a message on stderr when the file cannot be created +# Returns: 0 on success, 1 otherwise +worktree_state_init() { + local path rc=0 + path=$(mktemp "${TMPDIR:-/tmp}/dev-tooling-state.XXXXXX") || rc=$? + if [[ "${rc}" -ne 0 || -z "${path}" ]]; then + printf '%s\n' "Could not create the dev-tooling state file under ${TMPDIR:-/tmp}." >&2 + return 1 + fi + + # mktemp leaves the file empty; every reader below expects a JSON object. + if ! printf '%s\n' '{}' > "${path}"; then + rm -f -- "${path}" + printf '%s\n' "Could not initialize the dev-tooling state file ${path}." >&2 + return 1 + fi + + DEV_TOOLING_STATE_FILE="${path}" + export DEV_TOOLING_STATE_FILE + + # A launch root that is not a git repository leaves this empty rather than + # failing the server: only a worktree-targeted call needs the value. + WORKTREE_LAUNCH_COMMON="" + local launch_pair + if launch_pair=$(_worktree_git_dir_pair "${PROJECT_ROOT}"); then + { IFS= read -r _; IFS= read -r WORKTREE_LAUNCH_COMMON; } <<< "${launch_pair}" + fi + + return 0 +} + +# worktree_state_cleanup +# Removes the state file. Called from server.sh's EXIT trap. +# Globals: reads DEV_TOOLING_STATE_FILE +# Returns: 0 always, so it cannot displace the trap's other work +worktree_state_cleanup() { + if [[ -n "${DEV_TOOLING_STATE_FILE:-}" ]]; then + rm -f -- "${DEV_TOOLING_STATE_FILE}" + fi + return 0 +} + +# _worktree_state_read_sticky +# Globals: reads DEV_TOOLING_STATE_FILE +# Stdout: the sticky project root, empty when none is set, or the message +# naming why it could not be read +# Returns: 0 on success, 1 when the state file is absent or unusable +_worktree_state_read_sticky() { + if [[ -z "${DEV_TOOLING_STATE_FILE:-}" ]]; then + printf '%s\n' "DEV_TOOLING_STATE_FILE is not set, so the sticky project root cannot be read." + return 1 + fi + + if [[ ! -f "${DEV_TOOLING_STATE_FILE}" ]]; then + printf '%s\n' "The state file ${DEV_TOOLING_STATE_FILE} does not exist, so the sticky project root cannot be read." + return 1 + fi + + local value rc=0 + value=$(jq -r '.sticky_root // empty' "${DEV_TOOLING_STATE_FILE}" 2>/dev/null) || rc=$? + if [[ "${rc}" -ne 0 ]]; then + printf '%s\n' "The state file ${DEV_TOOLING_STATE_FILE} does not hold a JSON object, so the sticky project root cannot be read." + return 1 + fi + + printf '%s\n' "${value}" + return 0 +} + +# _worktree_state_update [--arg name value ...] +# Applies a filter to the state file's current object and renames the result +# onto the path atomically, so a call killed mid-write cannot leave a partial +# file. +# +# The current content is the filter's input, which is what keeps a key this +# writer does not own: the file has more than one writer — the sticky root and +# the probe cache — and a write that rebuilt the object from nothing would drop +# the other's key silently. +# +# A file that is absent, or that holds something other than a JSON object, is +# read as an empty object rather than refused. There is nothing to preserve in +# either: a file that is not an object has no keys a filter could carry +# forward. Refusing would instead let a damaged state file lock out the reset +# path — clearing sticky_root is the recovery a session reaches for, and it +# must not be the one write that cannot run. An empty file is in this class +# too: jq emits nothing for it at exit 0, which would otherwise rename an empty +# file over the state. +# Globals: reads DEV_TOOLING_STATE_FILE +# Stdout: nothing on success, the message naming the failure otherwise +# Returns: 0 on success, 1 otherwise +_worktree_state_update() { + local filter="${1:-}" + shift + + if [[ -z "${DEV_TOOLING_STATE_FILE:-}" ]]; then + printf '%s\n' "DEV_TOOLING_STATE_FILE is not set, so the state file cannot be written." + return 1 + fi + + local current="{}" + if [[ -f "${DEV_TOOLING_STATE_FILE}" ]]; then + local read_rc=0 + current=$(jq -c 'if type == "object" then . else empty end' "${DEV_TOOLING_STATE_FILE}" 2>/dev/null) || read_rc=$? + if [[ "${read_rc}" -ne 0 || -z "${current}" ]]; then + current="{}" + fi + fi + + local tmp rc=0 + tmp=$(mktemp "${DEV_TOOLING_STATE_FILE}.XXXXXX") || rc=$? + if [[ "${rc}" -ne 0 || -z "${tmp}" ]]; then + printf '%s\n' "Could not create a temporary file next to ${DEV_TOOLING_STATE_FILE}." + return 1 + fi + + rc=0 + printf '%s' "${current}" | jq "$@" "${filter}" > "${tmp}" 2>/dev/null || rc=$? + if [[ "${rc}" -ne 0 ]]; then + rm -f -- "${tmp}" + printf '%s\n' "Could not write the new content of the state file ${DEV_TOOLING_STATE_FILE}." + return 1 + fi + + # Same directory, therefore the same filesystem, therefore an atomic rename. + if ! mv -f -- "${tmp}" "${DEV_TOOLING_STATE_FILE}"; then + rm -f -- "${tmp}" + printf '%s\n' "Could not replace the state file ${DEV_TOOLING_STATE_FILE}." + return 1 + fi + + return 0 +} + +# _worktree_state_write_sticky [project root] +# An empty argument removes sticky_root and leaves every other key alone. +# Globals: reads DEV_TOOLING_STATE_FILE +# Stdout: nothing on success, the message naming the failure otherwise +# Returns: 0 on success, 1 otherwise +_worktree_state_write_sticky() { + local value="${1:-}" + + if [[ -z "${value}" ]]; then + _worktree_state_update 'del(.sticky_root)' + return $? + fi + + # shellcheck disable=SC2016 # the jq filter is single-quoted so jq, not the shell, reads $root + _worktree_state_update '.sticky_root = $root' --arg root "${value}" +} + +# _worktree_state_write_probe +# Records that the root was reached at this environment AND mapping, so a later +# call for the same triple reads the entry instead of running the probe again. +# The mapped path is part of the key because it derives from the worktree's own +# configuration, which is re-read every call: an edited workdir changes what +# the probe would measure while (environment, root) stays identical, and a +# cache keyed on the pair alone answered for a path nothing ever probed. +# Globals: reads DEV_TOOLING_STATE_FILE +# Stdout: nothing on success, the message naming the failure otherwise +# Returns: 0 on success, 1 otherwise +_worktree_state_write_probe() { + local environment="$1" + local root="$2" + local mapped="$3" + + # shellcheck disable=SC2016 # the jq filter is single-quoted so jq, not the shell, reads $env, $root and $mapped + _worktree_state_update '.probe_cache[$env][$root][$mapped] = true' \ + --arg env "${environment}" --arg root "${root}" --arg mapped "${mapped}" +} + +# _worktree_state_read_probe +# Stdout: "true" when a passing probe for this triple is recorded, empty when +# it is not +# Returns: 0 always — an absent or unreadable entry reads as "not probed", +# which sends the caller to the probe rather than past it. Reading a +# broken state file as a hit would be the failure that matters; this +# direction can only probe one time too many. +_worktree_state_read_probe() { + local environment="$1" + local root="$2" + local mapped="$3" + + local value="" + if [[ -n "${DEV_TOOLING_STATE_FILE:-}" && -f "${DEV_TOOLING_STATE_FILE}" ]]; then + value=$(jq -r --arg env "${environment}" --arg root "${root}" --arg mapped "${mapped}" \ + '.probe_cache[$env][$root][$mapped] // empty' "${DEV_TOOLING_STATE_FILE}" 2>/dev/null) || value="" + fi + + printf '%s\n' "${value}" +} + +# ============================================================================= +# Pure units +# +# Arguments in, stdout and exit status out. Each is covered by a table in +# worktree_resolution.bats and reached in production through the thin wrapper +# named under it, so the verdict a suite asserts is the verdict the call gets. +# ============================================================================= + +# _worktree_classify_gitdir +# Classifies the pointer a linked worktree's ".git" file carries. +# +# "invalid" is a class rather than an error because a suite has to be able to +# assert it: the resolution path reaches the classifier only for a ".git" file +# git itself already parsed, so this is the verdict a hand-written or +# truncated pointer gets, and the caller refuses it like the other non-relative +# verdict rather than falling through. +# Pure: the line is the only input. +# Args: $1 = the contents of the worktree's ".git" file +# Stdout: "absolute", "relative", or "invalid" +# Returns: 0 always +_worktree_classify_gitdir() { + local line="${1:-}" + + case "${line}" in + "gitdir: "*) ;; + *) + printf '%s\n' "invalid" + return 0 + ;; + esac + + local target="${line#gitdir: }" + target="${target%$'\n'}" + target="${target%$'\r'}" + + # A pointer carrying a control character cannot be one path, and every + # message naming it would be split by the same character. + if [[ -z "${target}" || "${target}" == *[[:cntrl:]]* ]]; then + printf '%s\n' "invalid" + return 0 + fi + + if [[ "${target}" == /* ]]; then + printf '%s\n' "absolute" + return 0 + fi + + printf '%s\n' "relative" +} + +# _worktree_gitdir_linkage +# Reads the ".git" file and hands its line to the classifier. The file read is +# the one effect the classifier cannot carry and still be a pure unit. +# Args: $1 = worktree root +# Stdout: "absolute", "relative", or "invalid" +# Returns: 0 always +_worktree_gitdir_linkage() { + local root="$1" + + local line="" + line=$(< "${root}/.git") || line="" + + _worktree_classify_gitdir "${line}" +} + +# _worktree_classify_root_charset +# The class of character that keeps a root out of a container command, for the +# four environments whose wrapper embeds a working directory inside a +# single-quoted `bash -c` string. Nothing is quoted at that position, so a space +# ends the directory name — `cd /a b` changes to /a and then runs a command +# named b — and every character the container's shell acts on becomes syntax. +# +# The set is READ from the shared environment module rather than restated here, +# so this and the guard the wrappers themselves apply cannot answer differently +# about the same value. It is the wider of the two: the guard applies it to a +# worktree root under ddev only, because that is where a value is parsed twice, +# while this applies it under all four. +# +# Native is deliberately outside the case: its wrapper emits no `cd` and no +# directory at all, so a space or a metacharacter in the root reaches no shell. +# Pure: both values are arguments. +# Args: $1 = candidate root, $2 = the environment the call runs under +# Stdout: "ok", "whitespace", or "metachar" +# Returns: 0 always +_worktree_classify_root_charset() { + local root="$1" + local environment="$2" + + if ! _env_is_container_environment "${environment}"; then + printf '%s\n' "ok" + return 0 + fi + + if [[ "${root}" == *[[:space:]]* ]]; then + printf '%s\n' "whitespace" + return 0 + fi + + if [[ "${root}" == *["${SHELL_HOSTILE_METACHARS}"]* ]]; then + printf '%s\n' "metachar" + return 0 + fi + + printf '%s\n' "ok" +} + +# _worktree_assert_charset +# Refuses a root the command wrappers cannot carry. +# +# Two questions, and the order is deliberate. +# +# The container class is asked first because it answers for all four container +# environments at once, with one sentence, and because the guard below would +# otherwise answer for ddev alone on a metacharacter and leave docker, +# docker-compose and vagrant reaching the same character through a different +# verdict. A caller reading the message should not have to know which of the +# four it was to know whether the refusal was the same one. +# +# The universal class — a single quote that would terminate a single-quoted +# wrapper string, and a line break that would split the path probes' compound +# command — is then put to the shared environment module's own guard, with the +# environment supplied as an argument rather than left to the global, so the +# verdict AND the message are the ones the wrappers themselves apply, and the +# one place that knows how a wrapper quotes a value owns the sentence about it. +# Args: $1 = candidate root, $2 = the environment the call runs under +# Globals: LINT_ENV is set and restored around the guard call +# Stdout: the refusal message +# Returns: 0 when the root is embeddable, 1 otherwise +_worktree_assert_charset() { + local root="$1" + local environment="$2" + + local verdict + verdict=$(_worktree_classify_root_charset "${root}" "${environment}") + case "${verdict}" in + ok) + ;; + whitespace) + printf '%s\n' "Refusing to run: the \"${environment}\" environment sends this path into the container as part of the working directory its command runs in, where a space ends the directory name — the command would change to a directory that is not the one this call targets. Rename the directory without a space, or use the native environment." + return 1 + ;; + *) + # The matched prefix ends where the offender begins, so its length + # is the offender's index; %q renders it visibly, as the path-shape + # check above does for a control character. + local prefix="${root%%["${SHELL_HOSTILE_METACHARS}"]*}" + local rendered + printf -v rendered '%q' "${root:${#prefix}:1}" + + printf '%s\n' "Refusing to run: the \"${environment}\" environment sends this path into the container as part of the working directory its command runs in, where the container's shell acts on the character ${rendered} it carries. Rename the directory without that character, or use the native environment." + return 1 + ;; + esac + + local inherited="${LINT_ENV:-}" + LINT_ENV="${environment}" + local message="" rc=0 + message=$(assert_no_shell_hostile_chars "project root" "${root}") || rc=$? + LINT_ENV="${inherited}" + + if [[ "${rc}" -ne 0 ]]; then + printf '%s\n' "${message}" + return 1 + fi + + return 0 +} + +# _worktree_root_label +# Names a root the way every message here should: the host path alone when the +# environment reaches it at that same path, and both when it does not. A caller +# comparing a diagnostic against the path they typed and against a path a +# container tool printed needs the mapping stated, and only this module knows +# it. +# Pure: both values are arguments. +# Args: $1 = the host path, $2 = the environment-side path, empty when equal +# Stdout: the label +_worktree_root_label() { + local host="$1" + local environment_side="$2" + + if [[ -z "${environment_side}" || "${environment_side}" == "${host}" ]]; then + printf '%s' "${host}" + return 0 + fi + + printf '%s (reached as %s)' "${host}" "${environment_side}" +} + +# _worktree_probe_command +# The command the existence probe runs: a directory test on the path the +# effective root maps to on the environment side. A plain compound command, so +# it is parsed exactly once whichever wrapper carries it. +# Pure: the path is the only input. +# Args: $1 = the environment-side path of the effective root +# Stdout: the probe command +_worktree_probe_command() { + printf 'test -d %s\n' "$(shell_quote_arg "$1")" +} + +# _worktree_probe_applicable +# True when the environment reaches the root at a path of its own, which is +# what there is to probe. Under native — and under any name the wrappers run +# locally — the mapped path IS the host path the resolution already found to +# exist, so the probe would restate that finding through a subprocess. +# Pure: the environment is the only input. +# Args: $1 = the environment this call runs under +# Returns: 0 when the probe applies, 1 when it does not +_worktree_probe_applicable() { + _env_is_container_environment "$1" +} + +# ============================================================================= +# Validation +# ============================================================================= + +# _worktree_assert_path_shape +# Refuses a project root that is empty, not absolute, or carrying a control +# character. +# +# This is the shape test alone. Which characters the command wrappers cannot +# carry is a separate step, because the answer depends on the environment the do +# call runs under and that is not known until the configuration has been +# selected and read — see _worktree_assert_charset. +# +# A control character is refused for diagnosability, not safety: a newline in +# the root splits the banner, and every log line and message naming the root, +# into fragments that read as separate records. Absolute is required because a +# relative root resolves against the directory the server was launched in. +# Args: $1 = the candidate project root +# Globals: sets WORKTREE_VALIDATION_MESSAGE on failure +# Returns: 0 when the root is usable, 1 otherwise +_worktree_assert_path_shape() { + local root="$1" + + if [[ -z "${root}" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run: the project root is empty." + return 1 + fi + + if [[ "${root}" != /* ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the project root must be an absolute path. A relative one resolves against the directory the server was launched in, which is never the tree the caller meant." + return 1 + fi + + if [[ "${root}" == *[[:cntrl:]]* ]]; then + # The matched prefix ends where the offender begins, so its length is + # the offender's index; %q renders a newline or a tab visibly. + local prefix="${root%%[[:cntrl:]]*}" + local rendered + printf -v rendered '%q' "${root:${#prefix}:1}" + + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": it contains a control character, ${rendered}, which breaks the single line every banner, log entry and message naming this root is written as. Rename the worktree directory without it." + return 1 + fi + + return 0 +} + +# _worktree_git_dir_pair +# --path-format=absolute is required: the plain form prints a relative ".git" +# from a main checkout and an absolute path from a linked worktree, so comparing +# plain output rejects every valid worktree. It also canonicalizes — on macOS +# /tmp becomes /private/tmp — so every value this module compares must come from +# this one function, or two spellings of one directory read as different. +# +# env -u is not decoration: git rev-parse honours GIT_DIR, GIT_WORK_TREE and +# GIT_COMMON_DIR over directory discovery, so an inherited value makes every +# identity test below read a different repository — measured, with GIT_DIR set +# to an unrelated checkout a valid worktree's common dir came back as that +# checkout's. Only this module's own validation is isolated; the wrapped tool +# commands keep their environment. +# Args: $1 = directory to run git in +# Stdout: two lines — the absolute git dir, then the absolute common git dir +# Returns: 0 on success, 1 when the directory is not inside a git repository, +# 2 when git is too old to understand --path-format +_worktree_git_dir_pair() { + local dir="$1" + + local value rc=0 + value=$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR \ + git -C "${dir}" rev-parse --path-format=absolute --git-dir --git-common-dir 2>/dev/null) || rc=$? + if [[ "${rc}" -ne 0 || -z "${value}" ]]; then + return 1 + fi + + # rev-parse answers an option it does not recognize by echoing it back, at + # exit 0 — measured on git 2.55.0. Below git 2.31, which is the release that + # added --path-format, the flag itself becomes the first line and every + # value read after it shifts by one, so the caller ends up treating + # "--path-format=absolute" as a git directory and says so in its refusal. + # An absolute first line is what separates the two cases. + if [[ "${value}" != /* ]]; then + return 2 + fi + + printf '%s\n' "${value}" + return 0 +} + +# _worktree_config_env_value +# The config.sh environment-variable override. An unset name is read as "no +# override" rather than raised: bare ${!CONFIG_ENV_VAR} aborts with "invalid +# indirect expansion" under set -u when the NAME is unset, which would take a +# tool call down with no text. +# Globals: reads CONFIG_ENV_VAR and the variable it names +# Stdout: the override path, or empty when there is none +_worktree_config_env_value() { + local name="${CONFIG_ENV_VAR:-}" + if [[ -z "${name}" ]]; then + return 0 + fi + printf '%s\n' "${!name:-}" +} + +# _worktree_is_registered +# Membership in `git worktree list --porcelain`, git's own registry. This ends +# the class of hand-written ".git" files: a directory git has not registered is +# refused however well its ".git" file is crafted. +# +# It does NOT settle forgery on its own. A directory registered by writing a +# per-worktree entry into /worktrees/ IS a worktree as far as git is +# concerned, and this test accepts it; producing that entry needs write access +# to the repository's own git directory, where hooks run arbitrary code on the +# next git command, so it is a full compromise rather than something this +# module can defend against. +# +# Recorded paths are absolute but may be spelled differently from the caller's, +# so both sides go through `pwd -P`. +# +# Kept as a backstop rather than for a case of its own: with this function +# stubbed to return 0, every test in worktree_resolution.bats still passes. The +# checks in `_worktree_validate_root` ahead of it already refuse everything +# reachable — a candidate that satisfies all of them has an administrative +# directory under /worktrees/ whose "gitdir" names it, which is exactly +# what makes git list it. Do not read the absence of a failing test here as +# missing coverage. +# Args: $1 = candidate root +# Globals: reads PROJECT_ROOT +# Returns: 0 when git lists the root as a worktree, 1 otherwise +_worktree_is_registered() { + local root="$1" + + local canonical_root + canonical_root=$(cd "${root}" >/dev/null 2>&1 && pwd -P) || return 1 + + local listing rc=0 + listing=$(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR \ + git -C "${PROJECT_ROOT}" worktree list --porcelain 2>/dev/null) || rc=$? + if [[ "${rc}" -ne 0 || -z "${listing}" ]]; then + return 1 + fi + + local line entry canonical_entry + while IFS= read -r line; do + [[ "${line}" == "worktree "* ]] || continue + entry="${line#worktree }" + canonical_entry=$(cd "${entry}" >/dev/null 2>&1 && pwd -P) || continue + if [[ "${canonical_entry}" == "${canonical_root}" ]]; then + return 0 + fi + done <<< "${listing}" + + return 1 +} + +# worktree_release_owned_temp +# Removes the merged-config temp file this call created, if any. Never touches +# the inherited _CONFIG_TEMP_FILE: removing that one makes _scope_jq read a +# missing LINT_CONFIG_FILE for the rest of the server's life, and every later +# launch-root call then runs unscoped at the project root with no error. +# Globals: reads and clears WORKTREE_OWNED_TEMP_FILE +# Returns: 0 always +worktree_release_owned_temp() { + if [[ -n "${WORKTREE_OWNED_TEMP_FILE}" ]]; then + rm -f -- "${WORKTREE_OWNED_TEMP_FILE}" + WORKTREE_OWNED_TEMP_FILE="" + fi + + # The derivation's own temp file, which holds a refusal rather than a + # configuration and is removed here for the same reason: it may only be + # removed by the call that created it, and this is that call's cleanup. + if [[ -n "${WORKTREE_DERIVATION_LOG}" ]]; then + rm -f -- "${WORKTREE_DERIVATION_LOG}" + WORKTREE_DERIVATION_LOG="" + fi + + return 0 +} + +# _worktree_select_config +# Picks the configuration that applies to a call targeting . The +# worktree's own config wins when it has one; with none, the launch root's +# configuration applies unchanged, which is the ordinary case — a worktree holds +# tracked files only and the tooling configs are user-local and untracked. +# Args: $1 = project root to load the configuration from +# Globals: reads CONFIG_ENV_VAR and _FOUND_CONFIGS; may set LINT_CONFIG_FILE +# (via load_config); sets WORKTREE_SELECTED_CONFIG_FILE and +# WORKTREE_OWNED_TEMP_FILE; restores _CONFIG_TEMP_FILE +# Returns: 0 always +_worktree_select_config() { + local root="$1" + + WORKTREE_OWNED_TEMP_FILE="" + local inherited_temp="${_CONFIG_TEMP_FILE:-}" + local inherited_config="${LINT_CONFIG_FILE:-}" + + local rc=0 + load_config "${root}" || rc=$? + if [[ "${rc}" -ne 0 ]]; then + # load_config returns before assigning LINT_CONFIG_FILE on every failure + # path, so the inherited launch configuration is still in force. + _CONFIG_TEMP_FILE="${inherited_temp}" + WORKTREE_SELECTED_CONFIG_FILE="${LINT_CONFIG_FILE:-}" + return 0 + fi + + # _CONFIG_TEMP_FILE is NOT the signal that a merge happened: _merge_configs + # assigns it inside the command substitution load_config captures, so it + # reads empty here. The LINT_CONFIG_FILE inequality is the guard that makes + # recording the inherited launch configuration as this call's property + # impossible. + local env_value + env_value=$(_worktree_config_env_value) + if [[ -z "${env_value}" ]]; then + if [[ "${#_FOUND_CONFIGS[@]}" -gt 1 && "${LINT_CONFIG_FILE}" != "${inherited_config}" ]]; then + WORKTREE_OWNED_TEMP_FILE="${LINT_CONFIG_FILE}" + fi + fi + + # A no-op today; keeps the invariant if _merge_configs' assignment ever reaches this shell. + _CONFIG_TEMP_FILE="${inherited_temp}" + WORKTREE_SELECTED_CONFIG_FILE="${LINT_CONFIG_FILE}" + return 0 +} + +# _worktree_environment_in_force +# The environment the call runs under, decided from the configuration that is in +# force for it. +# +# When the selected configuration is the launch one, the environment is the +# value the server bound at startup and nothing is read: the launch +# configuration is read once, when the server starts, and an edit to that file +# mid-session is inert until a restart. Reading the file here instead is what +# let a call validate under the edited environment and then execute under the +# startup one the wrappers are keyed on — measured with a launch config edited +# from docker to native, where containment, the charset rule and the probe were +# all skipped while the command still ran in the startup container. +# +# A call whose selected configuration is the worktree's own has no startup value +# to answer with, so its environment is read from that file. +# Globals: reads WORKTREE_SELECTED_CONFIG_FILE, WORKTREE_LAUNCH_CONFIG_FILE and +# LINT_ENV; reads LINT_CONFIG_FILE through _get_config_value +# Stdout: the environment name, empty when that configuration declares none +_worktree_environment_in_force() { + if [[ "${WORKTREE_SELECTED_CONFIG_FILE}" == "${WORKTREE_LAUNCH_CONFIG_FILE}" ]]; then + printf '%s\n' "${LINT_ENV}" + return 0 + fi + + _get_config_value '.environment' '' +} + +# _worktree_validate_root +# Validates a root that differs from the launch root, and selects the +# configuration that applies to it. Stops at the first step that fails. +# Args: $1 = the effective project root +# Globals: reads LINT_ENV; sets WORKTREE_VALIDATION_MESSAGE on failure; sets +# WORKTREE_SELECTED_CONFIG_FILE and WORKTREE_OWNED_TEMP_FILE; may set +# LINT_CONFIG_FILE +# Returns: 0 when the root is usable, 1 otherwise +_worktree_validate_root() { + local root="$1" + + WORKTREE_VALIDATION_MESSAGE="" + WORKTREE_OWNED_TEMP_FILE="" + + if ! _worktree_assert_path_shape "${root}"; then + return 1 + fi + + if [[ ! -d "${root}" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": it does not exist, or it is not a directory." + return 1 + fi + + # Three tests, and each rejects something the others admit. Measured against + # a fixture holding a main checkout, a subdirectory of it, a linked worktree, + # that worktree's own subdirectory, and a directory carrying a hand-written + # ".git" that points at the launch repository: + # + # directory .git git-dir != common-dir + # main checkout dir no + # main subdirectory absent no + # worktree root file YES <- the only one to accept + # worktree subdirectory absent YES + # forged ".git" file file no + # + # The file test alone admits the forgery; the inequality alone admits the + # worktree's own subdirectory. Only the conjunction isolates the worktree + # root, and the common-dir equality below is still needed on top of both, + # because it is what proves the worktree belongs to THIS repository rather + # than to a second checkout that has worktrees of its own. + # + # Preferred over comparing `rev-parse --show-toplevel` with the root: + # --show-toplevel resolves symlinks, so it would falsely refuse a worktree + # reached through one. Neither test here is affected by that. + if [[ ! -f "${root}/.git" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": it holds no \".git\" file, so it is not the root of a linked git worktree. A main checkout holds \".git\" as a directory and a subdirectory of a checkout holds no \".git\" at all; create a worktree with \`git worktree add\` and pass its root." + return 1 + fi + + local pair pair_rc=0 + pair=$(_worktree_git_dir_pair "${root}") || pair_rc=$? + if [[ "${pair_rc}" -eq 2 ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": this git does not understand \`git rev-parse --path-format\`, which git 2.31 added, so the git directory behind a worktree cannot be resolved. Worktree targeting needs git 2.31 or newer." + return 1 + fi + if [[ "${pair_rc}" -ne 0 ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": it is not inside a git repository." + return 1 + fi + local effective_gitdir effective_common + { IFS= read -r effective_gitdir; IFS= read -r effective_common; } <<< "${pair}" + + # A real linked worktree's git directory is its own, under + # /worktrees/. Equality here means the ".git" file resolves to + # the repository's own git directory, which no worktree git creates does. + if [[ "${effective_gitdir}" == "${effective_common}" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": its \".git\" file resolves to the repository's own git directory \"${effective_gitdir}\" rather than to a per-worktree one under \"${effective_common}/worktrees/\", so it was not created by \`git worktree add\`." + return 1 + fi + + # The inequality above still admits a hand-written ".git" pointing at a REAL + # sibling worktree's per-worktree directory — every earlier test passes + # while the commands run in the forged directory, and it reproduces. git's + # own back-pointer closes it: each per-worktree directory holds a "gitdir" + # file naming that worktree's own ".git", so a directory borrowing someone + # else's fails here. + local backpointer_file="${effective_gitdir}/gitdir" + if [[ ! -f "${backpointer_file}" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the git directory \"${effective_gitdir}\" its \".git\" file names holds no \"gitdir\" back-pointer, so it is not a per-worktree directory git maintains." + return 1 + fi + + # git records the back-pointer canonically while the caller's spelling may + # reach the same directory through a symlink — on macOS a /var/folders/… + # path is /private/var/folders/…, and a literal comparison of the two was + # measured to fail on a valid worktree. + local backpointer + backpointer=$(< "${backpointer_file}") + # worktree.useRelativePaths writes the back-pointer relative to the + # per-worktree directory that holds it, so that is the base it resolves + # from — resolving from the process working directory refused every + # worktree `git worktree add --relative-paths` created. + local backpointer_dir="${backpointer%/.git}" + if [[ "${backpointer_dir}" != /* ]]; then + backpointer_dir="${effective_gitdir}/${backpointer_dir}" + fi + local canonical_backpointer="" canonical_root="" + canonical_backpointer=$(cd "${backpointer_dir}" >/dev/null 2>&1 && pwd -P) || canonical_backpointer="" + canonical_root=$(cd "${root}" >/dev/null 2>&1 && pwd -P) || canonical_root="" + if [[ -z "${canonical_backpointer}" || -z "${canonical_root}" || "${canonical_backpointer}" != "${canonical_root}" ]]; then + # The resolved directory when the back-pointer resolves; a relative + # spelling alone names nothing the reader can visit. + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the git directory \"${effective_gitdir}\" belongs to the worktree at \"${canonical_backpointer:-${backpointer%/.git}}\", not to this directory, so its \".git\" file was hand-written rather than created by \`git worktree add\`." + return 1 + fi + + if [[ -z "${WORKTREE_LAUNCH_COMMON}" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the launch project root \"${PROJECT_ROOT}\" is not inside a git repository, so no worktree of it can be identified." + return 1 + fi + + if [[ "${effective_common}" != "${WORKTREE_LAUNCH_COMMON}" ]]; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": it belongs to the repository at \"${effective_common}\" and the launch root belongs to the one at \"${WORKTREE_LAUNCH_COMMON}\", so it is not a worktree of the launch root." + return 1 + fi + + # Asked last, so a shape an earlier test already names specifically doesn't fall through to this generic backstop's message instead. + if ! _worktree_is_registered "${root}"; then + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": \`git worktree list\` in \"${PROJECT_ROOT}\" does not list it, so it is not a worktree this repository created. Create one with \`git worktree add\`." + return 1 + fi + + # An absolute pointer names a host path, and relinking a worktree is a + # repository-level operation this module never performs, so the call is + # refused and the remediation named instead. + # + # Refused in EVERY environment, native included. Which environment a call + # runs under is a property of a configuration file, not of the pointer, so + # a root accepted natively today is reached through a container the moment + # one is configured — and the refusal would then arrive on a call the user + # had no reason to expect it from. + # + # Measured: `git worktree add` writes an absolute pointer unless the + # repository sets worktree.useRelativePaths, a key git 2.48 introduced, so + # this refusal is the ordinary one on a fresh checkout and the remediation + # is what relinks it. + local linkage + linkage=$(_worktree_gitdir_linkage "${root}") + case "${linkage}" in + relative) + ;; + absolute) + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": its \".git\" file carries an absolute gitdir pointer, which names a host path that does not exist inside a container. Relink it with \`git -c worktree.useRelativePaths=true worktree repair $(shell_quote_arg "${root}")\`, which needs git 2.48 or newer." + return 1 + ;; + *) + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": its \".git\" file carries no usable gitdir pointer, so the linked worktree it belongs to cannot be established. Recreate it with \`git worktree add\`." + return 1 + ;; + esac + + _worktree_select_config "${root}" + + # Every reader below swallows jq's exit status — _get_config_value and + # _scope_jq both answer a failed read with an empty string — so a file + # nothing can be read from passes the allowlist and scope_validate as though + # it declared nothing, and the call then runs with no scopes and no per-tool + # settings while the banner attributes the result to this worktree. The same + # file at the launch root makes detect_environment exit and the server refuse + # to start, so it is refused here too. + # + # `jq empty` is NOT the test, measured: it exits 0 on an empty file, on a + # whitespace-only file and on a bare `null`, catching only syntactically + # broken JSON. Asking for the object is what covers all four — rc 5 on + # malformed, 4 on no JSON value at all, 1 on a value that is not an object. + # + # Whichever configuration ends up in force is checked, the worktree's own or + # the inherited launch one. Exempting the launch config on the strength of + # its startup parse was wrong: the file is user-editable and a session + # outlives that parse, so a config edited into something unreadable + # mid-session reached this point and the call ran with the scopes and every + # per-tool setting reading as absent. + # + # This is the parse, not the binding. The environment the call runs under + # comes from the startup binding either way — see + # _worktree_environment_in_force — so a file that stopped parsing is the one + # thing here that still has to be read afresh. + if ! jq -e 'type == "object"' "${WORKTREE_SELECTED_CONFIG_FILE}" >/dev/null 2>&1; then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the configuration file in force (${WORKTREE_SELECTED_CONFIG_FILE}) does not parse as a JSON object, so nothing can be read from it — the environment, the scopes and every per-tool setting would all read as absent. Fix that file, or remove it so the launch root's configuration applies." + return 1 + fi + + # The environment this call runs under, read from the configuration in + # force — the worktree's own when it carries one, the launch root's + # otherwise. + # + # An allowlist, deliberately: the name SELECTS which of the guards below + # apply, so a name outside the known set is refused rather than treated as + # native. detect_environment runs an unknown name locally, and a worktree + # target inheriting that fallback skipped containment, the charset rule and + # the probe in one step — a "docker " typo in the worktree's own config ran + # the command on the host at the worktree root while the same config + # spelled "docker" was refused as out-of-root. A launch configuration with + # such a name is unaffected: this gate runs only for a differing target. + local environment + environment=$(_worktree_environment_in_force) + WORKTREE_VALIDATED_ENVIRONMENT="${environment}" + + # Empty falls through: a configuration that declares NO environment gets + # the dedicated refusal in the binding step, which names the file. + if [[ -n "${environment}" && "${environment}" != "native" ]] && ! _env_is_container_environment "${environment}"; then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the configuration in force declares environment \"${environment}\", which worktree targeting does not recognize. Known environments: native, docker, docker-compose, vagrant, ddev." + return 1 + fi + + # A container mounts the launch root, and no config field declares an + # additional mount, so a worktree outside it has no path inside the + # container at all. + # + # Both sides are canonicalized before they are compared, because the + # comparison is what decides containment and a spelling must not decide it. + # "/../elsewhere" is a registered worktree of the launch root that + # sits outside it, and every test above accepts it — the back-pointer check + # and `git worktree list` both compare canonical paths, so they agree it + # belongs. Compared raw, this test then accepts it too and the suffix that + # reaches the container is "../elsewhere", which names a directory outside + # the mount when one happens to exist there. + # + # The pair is kept rather than discarded, because the path mapping that runs + # later has to measure the same two paths this test just measured. It + # compares a suffix against the launch root, so a raw effective root against + # a canonical launch root — or the reverse — refuses a root this test has + # already accepted: a worktree reached through a symlink, or one under + # macOS's "/var" spelling, passes containment here and is then refused as + # outside the launch root by the mapping. Both are cleared first so a + # re-validation cannot read the previous target's pair. + WORKTREE_CANONICAL_ROOT="" + WORKTREE_CANONICAL_LAUNCH="" + if _env_is_container_environment "${environment}"; then + local canonical_root="" canonical_launch="" + if ! canonical_root=$(_worktree_canonical_path "${root}"); then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": its location could not be resolved, so whether it sits inside the launch project root \"${PROJECT_ROOT}\" cannot be established." + return 1 + fi + if ! canonical_launch=$(_worktree_canonical_path "${PROJECT_ROOT%/}"); then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the launch project root \"${PROJECT_ROOT}\" could not be resolved, so whether this root sits inside it cannot be established." + return 1 + fi + + if [[ "${canonical_root}" != "${canonical_launch}" && "${canonical_root}" != "${canonical_launch}"/* ]]; then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": the \"${environment}\" environment runs commands inside a container that mounts the launch project root \"${PROJECT_ROOT}\" and nothing else, so no path inside the container reaches this root. The supported pattern is a worktree created inside the launch project root — \`git worktree add ${PROJECT_ROOT%/}/.claude/worktrees/\`." + return 1 + fi + + WORKTREE_CANONICAL_ROOT="${canonical_root}" + WORKTREE_CANONICAL_LAUNCH="${canonical_launch}" + fi + + # The characters the wrappers can carry, which is a different question per + # environment and so cannot be asked before this one is known. + local charset_error="" + if ! charset_error=$(_worktree_assert_charset "${root}" "${environment}"); then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": ${charset_error}" + return 1 + fi + + # The canonical spelling is the one that reaches a shell: the mapping is + # built from the resolved path, and every wrapper embeds what the mapping + # returned. A metacharacter that only the resolved path carries therefore + # reaches the container's shell unquoted however clean the spelling the + # caller typed, and the check above cannot see it — it classifies the string + # the caller typed. A symlink to a directory named "a;b" is the ordinary way + # to produce one, and it passes every other test here: the directory is a + # registered worktree, it sits inside the launch root, and its link points + # back at it. + # + # The container branch is the only place a canonical pair is established, so + # this is that branch's second half. Both guards are the ones the raw + # spelling already passed: the shape guard's control-character test, whose + # message already names the spelling and the character and is passed through + # unchanged, and the charset check, whose sentence about the character is + # prefixed with the spelling it belongs to. + if [[ -n "${WORKTREE_CANONICAL_ROOT}" && "${WORKTREE_CANONICAL_ROOT}" != "${root}" ]]; then + if ! _worktree_assert_path_shape "${WORKTREE_CANONICAL_ROOT}"; then + worktree_release_owned_temp + return 1 + fi + + local canonical_charset_error="" + if ! canonical_charset_error=$(_worktree_assert_charset "${WORKTREE_CANONICAL_ROOT}" "${environment}"); then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": that path resolves to \"${WORKTREE_CANONICAL_ROOT}\", which is the spelling the wrapped command carries. ${canonical_charset_error}" + return 1 + fi + fi + + # scope_validate is what server.sh runs at startup against the launch + # config. Running it here makes a reserved scope name or an undeclared + # default_scope in the worktree's own config fail with its own message + # rather than as a per-call resolve_scope error or a silent fallback to the + # "shopware" scope. + local scope_error rc=0 + scope_error=$(scope_validate 2>&1 >/dev/null) || rc=$? + if [[ "${rc}" -ne 0 ]]; then + worktree_release_owned_temp + WORKTREE_VALIDATION_MESSAGE="Refusing to run against \"${root}\": its configuration (${WORKTREE_SELECTED_CONFIG_FILE}) is not usable — ${scope_error}" + return 1 + fi + + return 0 +} + +# ============================================================================= +# Per-call resolution +# ============================================================================= + +# worktree_resolve_root +# The resolution half of worktree_enter: the call's own project_root argument, +# then sticky_root from the state file, then the launch root in PROJECT_ROOT. +# Args: $1 = the tool call's arguments JSON +# Globals: sets WORKTREE_EFFECTIVE_ROOT, WORKTREE_ROOT_SOURCE and LINT_WORKDIR; +# reads PROJECT_ROOT +# Stdout: nothing on success, one sentence naming the failure otherwise +# Returns: 0 when the call may proceed, 1 otherwise +worktree_resolve_root() { + local args="${1:-}" + + # One jq answers both questions — presence and value — because this runs on + # every call's hot path. The marker letter carries presence: "//" alone + # cannot, since it yields the empty string for an absent key AND for an + # explicit "", and falling back for the second would silently substitute a + # valid target for an invalid one. + local marked rc=0 + marked=$(jq -r 'if has("project_root") then "P\(.project_root // "" | tostring)" else "A" end' <<< "${args}" 2>/dev/null) || rc=$? + if [[ "${rc}" -ne 0 ]]; then + printf '%s\n' "Refusing to run: the tool call's own arguments do not parse as a JSON object, so \"project_root\" could not be read from them." + return 1 + fi + + local call_root="" + if [[ "${marked}" == P* ]]; then + call_root="${marked#P}" + if [[ -z "${call_root}" ]]; then + printf '%s\n' "Refusing to run: \"project_root\" was supplied as an empty string. Omit the parameter to target the sticky project root, or the root this server was launched in." + return 1 + fi + fi + + if [[ -n "${call_root}" ]]; then + WORKTREE_EFFECTIVE_ROOT="${call_root}" + WORKTREE_ROOT_SOURCE="call" + else + local sticky + if ! sticky=$(_worktree_state_read_sticky); then + printf '%s\n' "${sticky}" + return 1 + fi + if [[ -n "${sticky}" ]]; then + WORKTREE_EFFECTIVE_ROOT="${sticky}" + WORKTREE_ROOT_SOURCE="sticky" + else + WORKTREE_EFFECTIVE_ROOT="${PROJECT_ROOT}" + WORKTREE_ROOT_SOURCE="launch" + fi + fi + + # Cleared before anything can read it: a call that re-resolves in the same + # shell must not name the previous target's environment-side path. + WORKTREE_ENV_WORKDIR="" + + # The launch root returns unvalidated — validation applies only to a differing target. + if [[ "${WORKTREE_EFFECTIVE_ROOT}" == "${PROJECT_ROOT}" ]]; then + return 0 + fi + + # Validation answers with its own message rather than reaching + # detect_environment, which calls exit 1 on a missing or invalid config. + # That exit is contained by the dispatch subshell and cannot stop the + # server, but it yields a result with no usable text. + if ! _worktree_validate_root "${WORKTREE_EFFECTIVE_ROOT}"; then + printf '%s\n' "${WORKTREE_VALIDATION_MESSAGE}" + return 1 + fi + + # The merged-config temp file _worktree_select_config may have just created + # belongs to THIS call. The handler dispatches every tool inside an explicit + # subshell, so a trap installed here fires when that subshell ends and + # cannot reach the server's own EXIT trap — which is what lets it replace a + # release call at every return path of every tool function. Installed only + # on this branch; asserted by worktree_resolution.bats. + trap 'worktree_release_owned_temp' EXIT + + if ! _worktree_bind_call_environment; then + worktree_release_owned_temp + return 1 + fi + + return 0 +} + +# _worktree_bind_call_environment +# Everything a call that targets a differing root does after validation: bind +# the environment the call runs under, map the root onto the environment's own +# path, enter the host root, and probe that the environment reaches it. +# +# Factored out because two tools owe the same work on the same terms — +# worktree_resolve_root for the call it is about to run, and +# tool_set_project_root before it commits a root that every later call will +# resolve through. A root one of them refuses and the other accepts is a set +# that appears to succeed and then fails on the next call, naming a reason the +# set never showed. +# +# The ORDER inside is load-bearing in two places, both measured: +# - the environment is established before the mapping, because the mapping +# dispatches on it and the re-derivation is what supplies the compose +# scalars the compose arm reads; +# - the root is entered before the probe, because the ddev, vagrant and +# docker-compose wrappers discover their project by walking up from the +# current directory, so a probe run from the launch root answers about the +# launch project's container and caches that verdict under the worktree's +# key. +# Globals: reads WORKTREE_EFFECTIVE_ROOT, WORKTREE_SELECTED_CONFIG_FILE, +# WORKTREE_LAUNCH_CONFIG_FILE; sets LINT_ENV, LINT_WORKDIR, +# WORKTREE_ENV_WORKDIR and the rest of the environment scalar family +# Stdout: nothing on success, the sentence naming the failure otherwise +# Returns: 0 when the call may proceed, 1 otherwise +_worktree_bind_call_environment() { + if ! _worktree_map_call_environment; then + printf '%s\n' "${WORKTREE_MAP_MESSAGE}" + return 1 + fi + + # The dispatch subshell's own directory, which the rebinding above does not + # reach: a relative path a caller passed, and anything that runs a wrapped + # command without going through exec_command, both resolve against it. + if ! cd "${WORKTREE_EFFECTIVE_ROOT}" >/dev/null; then + printf '%s\n' "Refusing to run against \"$(_worktree_root_label "${WORKTREE_EFFECTIVE_ROOT}" "${WORKTREE_ENV_WORKDIR}")\": the working directory could not be changed to it." + return 1 + fi + + if ! _worktree_probe_root "${WORKTREE_VALIDATED_ENVIRONMENT}" "${LINT_WORKDIR}"; then + return 1 + fi + + return 0 +} + +# _worktree_map_call_environment +# The binding prefix _worktree_bind_call_environment and tool_cwd share: +# establish the environment in force, re-derive the worktree's own +# configuration when it is the selected one, and map the root onto the +# environment's path. The cd and the probe stay with the binding — a call +# enters and probes, a report must not — and the failure goes to +# WORKTREE_MAP_MESSAGE rather than stdout, because tool_cwd folds it into its +# report while the binding prints it, and a captured call would lose every +# global this function exists to set. +# Globals: reads WORKTREE_EFFECTIVE_ROOT, WORKTREE_VALIDATED_ENVIRONMENT, +# WORKTREE_SELECTED_CONFIG_FILE, WORKTREE_LAUNCH_CONFIG_FILE, +# WORKTREE_CANONICAL_ROOT, WORKTREE_CANONICAL_LAUNCH; sets LINT_ENV, +# LINT_WORKDIR, WORKTREE_ENV_WORKDIR, WORKTREE_MAP_MESSAGE and the +# rest of the environment scalar family +# Returns: 0 when the mapping is bound, 1 otherwise +_worktree_map_call_environment() { + WORKTREE_MAP_MESSAGE="" + + # The environment this call runs under, as validation established it. A + # call whose selected configuration is the launch one keeps the environment + # the server bound at startup; a call whose selected configuration is the + # worktree's own re-derives it from that file, into this dispatch subshell, + # so the launch process's own values are untouched and a later launch-root + # call still runs under the environment the server started with. + local environment="${WORKTREE_VALIDATED_ENVIRONMENT}" + + if [[ "${WORKTREE_SELECTED_CONFIG_FILE}" != "${WORKTREE_LAUNCH_CONFIG_FILE}" ]]; then + if [[ -z "${environment}" ]]; then + WORKTREE_MAP_MESSAGE="Refusing to run against \"${WORKTREE_EFFECTIVE_ROOT}\": the configuration in force (${WORKTREE_SELECTED_CONFIG_FILE}) declares no \"environment\" field, so the environment this call runs under cannot be established." + return 1 + fi + + if ! _worktree_run_derivation "${WORKTREE_SELECTED_CONFIG_FILE}" "${environment}"; then + WORKTREE_MAP_MESSAGE="${WORKTREE_DERIVATION_MESSAGE}" + return 1 + fi + + LINT_ENV="${environment}" + fi + + # exec_command enters LINT_WORKDIR[/SCOPE_CWD] and exec_npm_command enters + # get_js_workdir, derived from LINT_WORKDIR — which _set_workdir_from_config + # bound to the LAUNCH root at startup. Without this rebinding every JS tool + # and every scoped PHP tool would enter the launch tree and run there, + # undoing the entry the binding performs. + # + # The value is the environment-side path, not the host path: under a + # container environment those differ, and every bare call site — + # get_workdir, wrap_command, wrap_npm_command, get_js_workdir and the + # coverage strip — reads this global and takes no root parameter, so the + # mapping has to have happened by the time tool code runs. + # + # Measured against the canonical pair the containment test resolved, not + # against the raw spellings: the mapping's own containment comparison is the + # one that decides whether a suffix exists at all, and the two paths it + # measures have to be the two containment already agreed on. A raw effective + # root against a raw launch root refuses a worktree reached through a + # symlink that validation accepted, and a canonical root against a raw + # launch root refuses one under macOS's "/var" spelling — the same defect + # mirrored. The pair is empty outside a container environment, where this + # branch's mapping is not reached. + local env_workdir="" + if ! env_workdir=$(resolve_env_workdir "${WORKTREE_CANONICAL_ROOT:-${WORKTREE_EFFECTIVE_ROOT}}" "${WORKTREE_CANONICAL_LAUNCH:-${PROJECT_ROOT:-}}"); then + WORKTREE_MAP_MESSAGE="${env_workdir}" + return 1 + fi + + LINT_WORKDIR="${env_workdir}" + if [[ "${env_workdir}" == "${WORKTREE_EFFECTIVE_ROOT}" ]]; then + WORKTREE_ENV_WORKDIR="" + else + WORKTREE_ENV_WORKDIR="${env_workdir}" + fi + + return 0 +} + +# _worktree_run_derivation +# Re-derives the environment's scalars into THIS shell through the shared seam, +# and hands back the seam's own refusal when it has one. +# +# The seam writes its results through out-parameters, so it has to run in this +# shell: wrapping the call in a command substitution would discard the derived +# values along with the subshell. Its refusal therefore goes to a file rather +# than to a pipe, and stderr is folded in because the docker arm writes its +# refusal there — that arm is shared with the startup path, where stdout is the +# protocol stream and a message printed to it would corrupt the frame. +# The message goes to a global for the same reason the seam writes its results +# into the calling shell: a caller that read it through a command substitution +# would lose the derived scalars to the subshell, and one that redirected the +# seam's output to a pipe would have to leave those assignments behind anyway. +# Args: $1 = the configuration file, $2 = the environment it declares +# Globals: writes LINT_WORKDIR, DOCKER_CONTAINER, COMPOSE_SERVICE, +# COMPOSE_WORKDIR_OVERRIDE, COMPOSE_FILE_OVERRIDE; sets +# WORKTREE_DERIVATION_MESSAGE on failure and clears it on success +# Returns: 0 on success, 1 otherwise +_worktree_run_derivation() { + local config_file="$1" + local environment="$2" + + WORKTREE_DERIVATION_MESSAGE="" + + local log rc=0 + log=$(mktemp "${TMPDIR:-/tmp}/worktree-derivation.XXXXXX") || rc=$? + if [[ "${rc}" -ne 0 || -z "${log}" ]]; then + WORKTREE_DERIVATION_MESSAGE="Refusing to run against \"${WORKTREE_EFFECTIVE_ROOT}\": a temporary file could not be created under ${TMPDIR:-/tmp}, so the \"${environment}\" environment declared by ${config_file} cannot be derived." + return 1 + fi + + # Recorded before the seam runs and removed by worktree_release_owned_temp, + # which the dispatch subshell's EXIT trap calls, so a signal arriving while + # the seam runs does not leave the file behind. + WORKTREE_DERIVATION_LOG="${log}" + + rc=0 + _set_workdir_from_config "${WORKTREE_EFFECTIVE_ROOT}" "${config_file}" "${environment}" > "${log}" 2>&1 || rc=$? + + if [[ "${rc}" -ne 0 ]]; then + # The seam writes nothing on some failure paths, so an empty log is not + # a pass: the fallback sentence is what keeps this a refusal with text. + if [[ -s "${log}" ]]; then + WORKTREE_DERIVATION_MESSAGE=$(< "${log}") + else + WORKTREE_DERIVATION_MESSAGE="Refusing to run against \"${WORKTREE_EFFECTIVE_ROOT}\": the \"${environment}\" environment declared by ${config_file} could not be derived from it." + fi + return 1 + fi + + rm -f -- "${log}" + WORKTREE_DERIVATION_LOG="" + return 0 +} + +# _worktree_probe_root +# Runs the one existence probe: a directory test of the path this root maps to +# on the environment side, through the same command wrapper the tool call +# itself uses, so the probe answers for the environment the command will run +# in and not for the host. +# +# The result of a passing probe is recorded in the state file, and a later call +# for the same environment, effective root AND mapped path reads that entry +# instead of probing again. A failing probe is deliberately NOT recorded: the +# cache is keyed on values that do not change while the environment they name +# does, so a recorded failure would keep refusing calls for the rest of the +# process after whatever caused it had been fixed. +# Args: $1 = the environment this call runs under, $2 = the mapped root +# Globals: reads WORKTREE_EFFECTIVE_ROOT, and LINT_ENV/LINT_WORKDIR through +# exec_command's wrapper +# Stdout: the refusal message when the probe fails +# Returns: 0 when the environment reaches the root, or the probe does not apply +_worktree_probe_root() { + local environment="$1" + local mapped="$2" + + if ! _worktree_probe_applicable "${environment}"; then + return 0 + fi + + local cached + cached=$(_worktree_state_read_probe "${environment}" "${WORKTREE_EFFECTIVE_ROOT}" "${mapped}") + if [[ "${cached}" == "true" ]]; then + return 0 + fi + + local probe output="" rc=0 + probe=$(_worktree_probe_command "${mapped}") + output=$(exec_command "${probe}") || rc=$? + + if [[ "${rc}" -ne 0 ]]; then + printf '%s\n' "Refusing to run against \"${WORKTREE_EFFECTIVE_ROOT}\": the \"${environment}\" environment does not reach \"${mapped}\", the path this root maps to there, so a command run against it would find nothing.${output:+ The probe said: ${output}}" + return 1 + fi + + local write_error="" + if ! write_error=$(_worktree_state_write_probe "${environment}" "${WORKTREE_EFFECTIVE_ROOT}" "${mapped}"); then + # Reported, not fatal: the probe itself passed, and failing a call over + # the bookkeeping that would have saved a later probe would refuse a + # call whose every check succeeded. The cost of the failure is one more + # probe next time, which is the stricter direction. + log "ERROR" "The worktree probe result could not be recorded: ${write_error}" + fi + + return 0 +} + +# worktree_enter +# Resolves the effective root for this call and names it in the banner. The +# first statement of every tool function except set_project_root and cwd — +# ludtwig's two tools reach it through _ludtwig_run instead. +# +# Call it directly as a statement, never through a command substitution: +# resolution sets the effective root, rebinds LINT_WORKDIR and changes the +# working directory, and a subshell would discard all three. Its refusal goes to +# the caller's own stdout, which is the tool result. +# Args: $1 = the tool call's arguments JSON +# Globals: as worktree_resolve_root +# Stdout: the banner on success, one sentence naming the failure otherwise +# Returns: 0 when the call may proceed, 1 otherwise +worktree_enter() { + if ! worktree_resolve_root "$1"; then + return 1 + fi + worktree_root_banner + return 0 +} + +# _worktree_dependency_workdir +# The directory the requested dependency tree is installed in: the full JS +# working directory including SCOPE_CWD and SCOPE_JS_SUBDIR for "js", because +# that is where node_modules sits, and the effective root for "php", because +# that is where vendor sits. Composing SCOPE_CWD onto the PHP side would look +# inside the scope's own directory, which holds no vendor of its own. +# +# Composed over the HOST root, not LINT_WORKDIR. The dependencies this looks +# for are the worktree's own files on the host, so the paths have to be +# host-side; under a container environment LINT_WORKDIR names a path inside the +# container and a host test of it finds nothing, refusing a worktree whose +# vendor directory is installed. get_js_workdir is still the one place the JS +# composition is written, so LINT_WORKDIR is bound to the host root for the +# duration of that one call and given back — the same binding +# _compose_wrap_npm_command performs for the same reason. +# +# Deliberately NOT the path guard's boundary — see +# worktree_assert_paths_within_root for why the two differ. +# Args: $1 = dependency kind, "php" or "js" +# Globals: reads WORKTREE_EFFECTIVE_ROOT, and SCOPE_CWD/SCOPE_JS_SUBDIR via +# get_js_workdir +# Stdout: the directory the dependencies are installed in +_worktree_dependency_workdir() { + if [[ "$1" == "js" ]]; then + local inherited_workdir="${LINT_WORKDIR:-}" + LINT_WORKDIR="${WORKTREE_EFFECTIVE_ROOT}" + get_js_workdir + LINT_WORKDIR="${inherited_workdir}" + return 0 + fi + + printf '%s\n' "${WORKTREE_EFFECTIVE_ROOT}" + return 0 +} + +# worktree_assert_dependencies [kind] +# Called after resolve_scope in tools that have a scope, and directly after +# worktree_enter in those that do not. It has to run after scope resolution: +# the JS working directory carries SCOPE_CWD[/SCOPE_JS_SUBDIR], so measuring it +# before SCOPE_CWD is set examines the unscoped path and refuses a valid +# worktree. +# +# The kind defaults to the server's own — "js" when JS_CONTEXT is set, "php" +# otherwise — which is right for every tool that runs the toolchain its server +# is named for. A tool that runs the other one states its kind instead, as +# ludtwig does: it sits on the storefront server and runs composer, so the +# inferred "js" would demand an npm install it has no use for and accept a tree +# with no vendor. +# Args: $1 = optional dependency kind, "php" or "js" +# Globals: reads WORKTREE_EFFECTIVE_ROOT, PROJECT_ROOT, JS_CONTEXT +# Stdout: nothing when the dependencies are present, one sentence naming the +# missing path and the command that creates it otherwise +# Returns: 0 when the call may proceed, 1 otherwise +worktree_assert_dependencies() { + local kind="${1:-}" + if [[ -z "${kind}" ]]; then + kind="php" + [[ -n "${JS_CONTEXT:-}" ]] && kind="js" + fi + + case "${kind}" in + php|js) + ;; + *) + printf '%s\n' "Refusing to run: unknown dependency kind \"${kind}\" — worktree_assert_dependencies takes \"php\" or \"js\"." + return 1 + ;; + esac + + if [[ "${WORKTREE_EFFECTIVE_ROOT}" == "${PROJECT_ROOT}" ]]; then + return 0 + fi + + local workdir label + workdir=$(_worktree_dependency_workdir "${kind}") + label=$(_worktree_root_label "${WORKTREE_EFFECTIVE_ROOT}" "${WORKTREE_ENV_WORKDIR}") + + # A worktree holds tracked files only, so the installed dependencies of the + # launch tree are not in it. + if [[ "${kind}" == "js" ]]; then + if [[ ! -d "${workdir}/node_modules" ]]; then + printf '%s\n' "Refusing to run against \"${label}\": \"${workdir}/node_modules\" does not exist. Call worktree_prepare on this server, or run \`npm ci\` in \"${workdir}\" yourself." + return 1 + fi + return 0 + fi + + if [[ ! -f "${workdir}/vendor/autoload.php" ]]; then + printf '%s\n' "Refusing to run against \"${label}\": \"${workdir}/vendor/autoload.php\" does not exist. Call worktree_prepare on the php-tooling server, or run \`composer install\` in \"${workdir}\" yourself." + return 1 + fi + + return 0 +} + +# worktree_prepare_execute