From 34c934118732afa960260fff7cc64d76fa417d46 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Sun, 11 Oct 2026 02:01:35 +0200 Subject: [PATCH 1/5] fix(dev-tooling): run path-scoped JS lints through the local binary `stylelint_check`/`stylelint_fix` and `prettier_check`/`prettier_fix` on `js-admin-tooling`, and `stylelint_check`/`stylelint_fix` and `eslint_check`/`eslint_fix` on `js-storefront-tooling`, routed a call with `paths` to `stylelint:base`, `prettier:base`, `stylelint:app`, `eslint:app` and `eslint:components`. No Shopware `package.json` defines these scripts, so every such call refused. They now run `npm exec --no -- ` with the flags of the aggregate script, after checking that `node_modules/.bin/` exists, because `npm exec --no` falls back to a global binary. Directory paths reach Stylelint and Prettier as a quoted pattern limited to the extensions the aggregate script lints, and are refused under ddev on a linked worktree. Components-tree ESLint paths are refused under a scope, Storefront Stylelint adds the core `--config` only without a scope, and a scoped config on a run without `paths` is refused when the script already passes one. Tests answer script probes from the `scripts` blocks of Shopware trunk instead of fixtures that defined the missing scripts. Co-Authored-By: Claude --- .../administration-package-scripts.json | 49 ++ .../storefront-package-scripts.json | 23 + .../dev-tooling/mcp_tool_js_admin.bats | 488 ++++++++++++++--- .../dev-tooling/mcp_tool_js_storefront.bats | 513 ++++++++++++++---- plugin-tests/dev-tooling/scope_js_tools.bats | 39 +- .../dev-tooling/test_helper/common_setup.bash | 109 ++++ .../dev-tooling/worktree_js_tools.bats | 70 ++- plugins/dev-tooling/AGENTS.md | 24 +- plugins/dev-tooling/docs/reference.md | 18 +- .../mcp-server-js-admin/lib/jest.sh | 9 +- .../mcp-server-js-admin/lib/prettier.sh | 183 ++++++- .../mcp-server-js-admin/lib/stylelint.sh | 311 +++++++++-- .../mcp-server-js-storefront/lib/eslint.sh | 151 ++++-- .../mcp-server-js-storefront/lib/jest.sh | 4 +- .../mcp-server-js-storefront/lib/stylelint.sh | 316 ++++++++++- .../mcp-server-js-storefront/tools.json | 4 +- 16 files changed, 1952 insertions(+), 359 deletions(-) create mode 100644 plugin-tests/dev-tooling/fixtures/shopware-trunk/administration-package-scripts.json create mode 100644 plugin-tests/dev-tooling/fixtures/shopware-trunk/storefront-package-scripts.json diff --git a/plugin-tests/dev-tooling/fixtures/shopware-trunk/administration-package-scripts.json b/plugin-tests/dev-tooling/fixtures/shopware-trunk/administration-package-scripts.json new file mode 100644 index 00000000..5c505ada --- /dev/null +++ b/plugin-tests/dev-tooling/fixtures/shopware-trunk/administration-package-scripts.json @@ -0,0 +1,49 @@ +{ + "build": "export VITE_MODE=production && ts-node -T build.ts", + "code-mods": "node code-mods.js", + "codemod:js-vue3-feature-flag-removal": "ts-node --transpileOnly ./scripts/codemods/js-vue3-feature-flag-removal/index.ts", + "codemod:twig-feature-flag-removal": "ts-node --transpileOnly ./scripts/codemods/twig-feature-flag-removal/index.ts", + "codemod:twig-remove-blocks": "ts-node --transpileOnly ./scripts/codemods/twig-block-removal/index.ts", + "codemod:sfc-migration": "ts-node --transpileOnly ./scripts/codemods/sfc-migration/run-sfc-migration.ts", + "convert-entity-schema": "ts-node --transpileOnly ./scripts/entitySchemaConverter/convert-schema.ts", + "create-test": "node ./scripts/create-spec-file/index.mjs", + "dev": "export VITE_MODE=development && ts-node -T build.ts", + "extension-tooling:check": "ts-node --transpileOnly ./scripts/extensionTooling/check.ts", + "extension-tooling:setup": "ts-node --transpileOnly ./scripts/extensionTooling/setup.ts", + "format": "prettier --check \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" \"extension-tooling/**/*.mjs\" \"scripts/extensionTooling/**/*.ts\" \"scripts/codemods/**/*.ts\" \"scripts/generate-shopware-modules/**/*.ts\" \"build/vite-plugins/**/*.{js,ts}\" \"build/vue-setup-transform/**/*.{js,ts}\" \"build/plugins.vite.ts\" \"build.ts\"", + "format:fix": "prettier --write \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" \"extension-tooling/**/*.mjs\" \"scripts/extensionTooling/**/*.ts\" \"scripts/codemods/**/*.ts\" \"scripts/generate-shopware-modules/**/*.ts\" \"build/vite-plugins/**/*.{js,ts}\" \"build/vue-setup-transform/**/*.{js,ts}\" \"build/plugins.vite.ts\" \"build.ts\" --cache", + "generate-api-docs": "./node_modules/.bin/jsdoc src -r -c jsdoc.config.js", + "generate-component-import-resolver-map": "ts-node --transpileOnly ./scripts/componentImportResolver/generate.ts", + "generate-data-set-list": "ts-node --transpileOnly ./scripts/generate-data-set-list/index.ts", + "generate-blocks-list": "ts-node --transpileOnly ./scripts/generate-block-list/index.ts", + "generate-entity-schema-package": "ts-node --transpileOnly ./scripts/entitySchemaConverter/generate-package.ts", + "generate-position-identifier-list": "ts-node --transpileOnly ./scripts/generate-position-identifier-list/index.ts", + "generate-shopware-modules": "ts-node --transpileOnly ./scripts/generate-shopware-modules/index.ts", + "preinstall": "node ./scripts/validate-package-json/index.mjs", + "postinstall": "patch-package", + "lint": "eslint src test build/vue-setup-transform build/vite-plugins/virtual-shopware-modules extension-tooling scripts/extensionTooling scripts/generate-shopware-modules scripts/codemods build.ts --cache", + "lint:all": "npm run lint:types && npm run lint && npm run lint:scss && npm run format", + "lint:debugging": "eslint", + "lint:deprecations": "FILES=$(git status -s | awk '/(js|ts|vue|html\\.twig)$/ {print $2}'); [ -z \"$FILES\" ] || eslint --rule '\"sw-deprecation-rules/private-feature-declarations\": \"error\"' $FILES", + "lint:fix": "npm run lint -- --fix", + "lint:junit": "eslint --format junit src test build/vite-plugins/virtual-shopware-modules extension-tooling scripts/extensionTooling scripts/generate-shopware-modules scripts/codemods build.ts > eslint.junit.xml", + "lint:scss": "stylelint **/*.scss --cache", + "lint:scss-fix": "npm run lint:scss -- --fix", + "lint:snippets": "eslint \"src/**/snippet/*.json\" --no-cache", + "lint:snippets:all": "SNIPPET_ALLOW_LIST_DISABLED=1 eslint \"src/**/snippet/*.json\" --no-cache", + "lint:test-parser": "eslint test/eslint/error-reference.html.twig --no-ignore", + "lint:twig": "eslint src", + "lint:types": "tsc", + "lint-fix": "eslint --fix src test build/vite-plugins/virtual-shopware-modules extension-tooling scripts/extensionTooling scripts/generate-shopware-modules scripts/codemods build.ts", + "security-check": "./node_modules/.bin/nsp check", + "test-debug": "node --inspect-brk TZ=UTC node_modules/.bin/jest --watch --config jest.config.js --runInBand --no-cache", + "unit": "jest --config jest.config.js --ci", + "unit:extension-tooling-e2e": "EXTENSION_TOOLING_E2E=1 jest --config jest.config.js --ci --coverage=false scripts/extensionTooling/e2e.spec", + "unit-setup": "npm run generate-component-import-resolver-map", + "unit-watch": "jest --config jest.config.js --watch", + "vite": "vite", + "vite:build": "vite build", + "perf": "lhci autorun --config=./lighthouserc.js", + "perf:collect": "lhci collect --config=./lighthouserc.js", + "perf:assert": "lhci assert --config=./lighthouserc.js" +} diff --git a/plugin-tests/dev-tooling/fixtures/shopware-trunk/storefront-package-scripts.json b/plugin-tests/dev-tooling/fixtures/shopware-trunk/storefront-package-scripts.json new file mode 100644 index 00000000..115e97ab --- /dev/null +++ b/plugin-tests/dev-tooling/fixtures/shopware-trunk/storefront-package-scripts.json @@ -0,0 +1,23 @@ +{ + "build:shopware": "vite build --config vite.shopware.config.mts", + "build:components": "node build/vite/build-components.js", + "dev:server": "vite --config vite.components.config.mts", + "development": "NODE_ENV=development webpack --config webpack.config.js", + "hot": "NODE_ENV=development MODE=hot webpack-dev-server --config webpack.config.js", + "hot-proxy": "NODE_ENV=development MODE=hot node ./build/start-hot-reload.js", + "lint": "eslint --no-error-on-unmatched-pattern --report-unused-disable-directives ./src --cache && stylelint --config stylelint.config.js ./src/scss --cache", + "lint-fix": "npm run lint:js:fix && npm run lint:scss-fix", + "lint:js": "eslint --no-error-on-unmatched-pattern --report-unused-disable-directives ./src ./build && (cd ../.. && eslint --no-error-on-unmatched-pattern --config ./app/storefront/eslint.config.js --report-unused-disable-directives views/components)", + "lint:js:fix": "eslint --no-error-on-unmatched-pattern --fix --report-unused-disable-directives ./src ./build && (cd ../.. && eslint --no-error-on-unmatched-pattern --fix --config ./app/storefront/eslint.config.js --report-unused-disable-directives views/components)", + "lint:scss": "stylelint --config stylelint.config.js ./src/scss --cache", + "lint:scss-fix": "npm run lint:scss -- --fix", + "production": "NODE_ENV=production webpack --config webpack.config.js && npm run build:shopware && npm run build:components", + "unit": "jest --config jest.config.js --ci", + "unit-watch": "jest --config jest.config.js --watch", + "unit:components": "vitest run --config vitest.config.mts", + "unit:components:watch": "vitest --config vitest.config.mts", + "unit:components:coverage": "vitest run --coverage --config vitest.config.mts", + "postinstall": "patch-package && node build/link-component-node-modules.js", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" +} diff --git a/plugin-tests/dev-tooling/mcp_tool_js_admin.bats b/plugin-tests/dev-tooling/mcp_tool_js_admin.bats index eb2ddafe..f9f43d22 100644 --- a/plugin-tests/dev-tooling/mcp_tool_js_admin.bats +++ b/plugin-tests/dev-tooling/mcp_tool_js_admin.bats @@ -6,12 +6,15 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" -# Answers the `npm pkg get "scripts."` probe with the body the Shopware -# Administration package.json declares. Quoted glob arguments are reproduced -# unquoted: the append gate reads command shape, not quoting, and embedded -# double quotes would need escaping through this fake's own JSON-ish string. -# FAKE_ABSENT_SCRIPTS makes a script look undefined; FAKE_BODY_NAME/FAKE_BODY -# makes one report a different body. +# Answers the `npm pkg get "scripts."` probe from the Shopware trunk +# Administration package.json (fixtures/shopware-trunk). FAKE_ABSENT_SCRIPTS +# makes a script look undefined; FAKE_BODY_NAME/FAKE_BODY makes one report a +# different body. +# +# "jest:base" is the one script answered that trunk does not define: the jest +# tool routes at it when a package declares it, and these tests model such a +# package. FAKE_ABSENT_SCRIPTS="jest:base" restores the trunk layout, where the +# tool falls back to "unit". _fake_admin_script_body() { local name="$1" @@ -24,45 +27,12 @@ _fake_admin_script_body() { return fi - case "${name}" in - lint) - printf '%s\n' '"eslint src test build.ts --cache"' ;; - lint:fix) - printf '%s\n' '"npm run lint -- --fix"' ;; - lint:debugging) - printf '%s\n' '"eslint"' ;; - stylelint:base) - printf '%s\n' '"stylelint --cache"' ;; - lint:scss) - printf '%s\n' '"npm run stylelint:base -- **/*.scss"' ;; - lint:scss-fix) - printf '%s\n' '"npm run lint:scss -- --fix"' ;; - prettier:base) - printf '%s\n' '"prettier"' ;; - format) - printf '%s\n' '"npm run prettier:base -- --check src/**/*.{js,ts} build.ts"' ;; - format:fix) - printf '%s\n' '"npm run prettier:base -- --write src/**/*.{js,ts} build.ts --cache"' ;; - jest:base) - printf '%s\n' '"jest --config jest.config.js"' ;; - lint:types) - printf '%s\n' '"tsc"' ;; - build) - printf '%s\n' '"export VITE_MODE=production && ts-node -T build.ts"' ;; - unit) - # A package that declares jest:base writes "unit" in terms of it. - # A package that does not — the pre-refactor layout the fallback - # route exists for — spells the runner out instead. Keying on - # FAKE_ABSENT_SCRIPTS keeps both configurations describing a - # package.json that could actually exist. - case " ${FAKE_ABSENT_SCRIPTS} " in - *" jest:base "*) printf '%s\n' '"jest --config jest.config.js --ci"' ;; - *) printf '%s\n' '"npm run jest:base -- --ci"' ;; - esac - ;; - *) - printf '%s\n' '{}' ;; - esac + if [[ "${name}" == "jest:base" ]]; then + printf '%s\n' '"jest --config jest.config.js"' + return + fi + + shopware_trunk_script_body administration "${name}" } # Builds a Jest JSON report body carrying the counts a test needs. @@ -98,6 +68,14 @@ setup() { FAKE_CLEAR_EXIT=0 FAKE_CLEAR_OUTPUT="" FAKE_RUN_EXIT=0 + # Binaries the package's node_modules/.bin lacks, as the local-binary + # probe of a path-scoped run sees it. + FAKE_MISSING_BINARIES="" + # Output of a binary probe that fails before it can test anything, such as + # a stopped container. + FAKE_BINARY_PROBE_ERROR="" + # Output of a directory test that fails before it can test anything. + FAKE_DIRECTORY_PROBE_ERROR="" log() { :; } source "${PLUGIN_DIR}/shared/environment.sh" source "${PLUGIN_DIR}/shared/scope.sh" @@ -114,6 +92,32 @@ setup() { local name="${cmd#npm pkg get \"scripts.}" _fake_admin_script_body "${name%\"}" ;; + 'npm exec --no -c "[ -d '*) + # The directory test runs for real, as sh in the package + # directory, so it answers about the files a test created. + if [[ -n "${FAKE_DIRECTORY_PROBE_ERROR}" ]]; then + printf '%s\n' "${FAKE_DIRECTORY_PROBE_ERROR}" + return 1 + fi + # LINT_WORKDIR is empty here: environment.sh resets it when + # sourced, and setup() sets it before that. + (LINT_WORKDIR="${BATS_TEST_TMPDIR}"; cd "$(get_js_workdir)" 2>/dev/null || true; eval "sh -c ${cmd#npm exec --no -c }") + ;; + 'npm exec --no -c "test -x node_modules/.bin/'*) + if [[ -n "${FAKE_BINARY_PROBE_ERROR}" ]]; then + printf '%s\n' "${FAKE_BINARY_PROBE_ERROR}" + return 1 + fi + local binary="${cmd#npm exec --no -c \"test -x node_modules/.bin/}" + case " ${FAKE_MISSING_BINARIES} " in + *" ${binary%% *} "*) + # The directory as the environment reports it, which + # under a container is not a host path. + printf '%s\n%s\n' "LOCAL_BINARY_MISSING_IN" "/var/www/html/src/Administration/Resources/app/administration" + return 3 + ;; + esac + ;; 'cd "'*) printf '%s\n' "$1" > "${FAKE_PROBE_FILE}" printf '%s\n' "${FAKE_PROBE_OUTPUT}" @@ -131,6 +135,17 @@ setup() { cat -- "${FAKE_REPORT_STORE}" ;; *) + # npm refuses a script the package does not define before it + # runs anything, so a route naming one fails as it would on a + # real checkout. + if [[ "${cmd}" == "npm run "* ]]; then + local script="${cmd#npm run }" + script="${script%% *}" + if [[ "$(_fake_admin_script_body "${script}")" == "{}" ]]; then + printf '%s\n' "npm error Missing script: \"${script}\"" + return 1 + fi + fi if [[ "${FAKE_RUN_WRITES_REPORT}" == "1" ]]; then printf '%s\n' "${FAKE_REPORT_OUTPUT}" > "${FAKE_REPORT_STORE}" fi @@ -154,7 +169,7 @@ teardown() { 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 \ - PROJECT_ROOT DEV_TOOLING_STATE_FILE + FAKE_MISSING_BINARIES FAKE_BINARY_PROBE_ERROR FAKE_DIRECTORY_PROBE_ERROR PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- ESLint: no paths keeps the aggregate script --- @@ -188,7 +203,7 @@ teardown() { @test "admin eslint check: a path-scoped run carries no hardcoded upstream target" { run tool_eslint_check '{"paths":["src/app/component"]}' assert_success - refute_output --partial "src test build.ts" + refute_output --partial "build/vue-setup-transform" } @test "admin eslint fix: paths route at lint:debugging with --fix" { @@ -314,24 +329,74 @@ teardown() { refute_output --partial "--fix" } -# --- Stylelint: paths route at the target-less base script --- +# Trunk's "lint:scss" passes no config of its own, so a scope config is +# appended to it, and to "lint:scss-fix", which reaches it through `npm run`. +@test "admin stylelint fix: a scoped config without paths is appended when no script in the chain passes a config" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + run tool_stylelint_fix '{"scope":"plugin-x"}' + assert_success + assert_line "npm run lint:scss-fix -- --config .stylelintrc.plugin" +} -@test "admin stylelint check: paths route at stylelint:base as the only targets" { - run tool_stylelint_check '{"paths":["src/app/assets/scss/base.scss"]}' +@test "admin stylelint fix: a scoped config without paths refuses when the chained lint:scss passes its own config" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + FAKE_BODY_NAME="lint:scss" + FAKE_BODY="stylelint --config .stylelintrc **/*.scss --cache" + run tool_stylelint_fix '{"scope":"plugin-x"}' + assert_failure + assert_output --partial "\"npm run lint:scss-fix\" already passes a config of its own" +} + +@test "admin stylelint check: a scoped config without paths refuses when lint:scss passes -c" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + FAKE_BODY_NAME="lint:scss" + FAKE_BODY="stylelint -c .stylelintrc **/*.scss" + run tool_stylelint_check '{"scope":"plugin-x"}' + assert_failure + assert_output --partial "\"npm run lint:scss\" already passes a config of its own" +} + +@test "admin stylelint check: a scoped config without paths refuses when lint:scss passes -c=" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + FAKE_BODY_NAME="lint:scss" + FAKE_BODY="stylelint -c=.stylelintrc **/*.scss" + run tool_stylelint_check '{"scope":"plugin-x"}' + assert_failure + assert_output --partial "\"npm run lint:scss\" already passes a config of its own" +} + +# The -c belongs to eslint, the command before the stylelint one. +@test "admin stylelint check: a scoped config without paths is appended when only another program in lint:scss takes -c" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + FAKE_BODY_NAME="lint:scss" + FAKE_BODY="eslint -c eslint.config.js . && stylelint **/*.scss" + run tool_stylelint_check '{"scope":"plugin-x"}' assert_success - assert_line 'npm run stylelint:base -- -f string "src/app/assets/scss/base.scss"' + assert_line "npm run lint:scss -- -f string --config .stylelintrc.plugin" } -@test "admin stylelint fix: paths route at stylelint:base as the only targets" { - run tool_stylelint_fix '{"paths":["src/app/assets/scss/base.scss"]}' +# --- Stylelint: paths run the package's own binary as the only targets --- + +@test "admin stylelint check: paths run the local stylelint with the lint:scss flags" { + run tool_stylelint_check '{"paths":["src/app/assets/scss/base.scss"]}' assert_success - assert_line 'npm run stylelint:base -- --fix "src/app/assets/scss/base.scss"' + assert_line 'npm exec --no -- stylelint --cache -f string "src/app/assets/scss/base.scss"' } -@test "admin stylelint fix: paths route carries --fix, which the base script body lacks" { +@test "admin stylelint fix: paths run the local stylelint with --fix" { run tool_stylelint_fix '{"paths":["src/app/assets/scss/base.scss"]}' assert_success - assert_output --partial "-- --fix " + assert_line 'npm exec --no -- stylelint --cache --fix "src/app/assets/scss/base.scss"' } @test "admin stylelint check: paths route carries no --fix" { @@ -346,18 +411,29 @@ teardown() { refute_output --partial "lint:scss-fix" } -@test "admin stylelint fix: fails when stylelint:base is absent and paths were supplied" { - FAKE_ABSENT_SCRIPTS="stylelint:base" +@test "admin stylelint check: a scoped config follows the lint:scss flags" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + run tool_stylelint_check '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' + assert_success + assert_line 'npm exec --no -- stylelint --cache -f string --config .stylelintrc.plugin "src/scss/base.scss"' +} + +@test "admin stylelint fix: refuses with paths when the package has no local stylelint" { + FAKE_MISSING_BINARIES="stylelint" run tool_stylelint_fix '{"paths":["src/app/assets/scss/base.scss"]}' assert_failure - assert_output --partial "stylelint:base" + assert_output --partial "\"node_modules/.bin/stylelint\" is not installed in the package directory \"/var/www/html/src/Administration/Resources/app/administration\"" } -@test "admin stylelint fix: refuses rather than falling back to the aggregate fix script" { - FAKE_ABSENT_SCRIPTS="stylelint:base" +@test "admin stylelint fix: runs neither stylelint nor the aggregate when the local stylelint is missing" { + FAKE_MISSING_BINARIES="stylelint" run tool_stylelint_fix '{"paths":["src/app/assets/scss/base.scss"]}' assert_failure - refute_output --partial "npm run lint:scss" + run cat "${FAKE_CALL_LOG}" + refute_output --partial "-- stylelint" + refute_output --partial "npm run" } @test "admin stylelint check: refuses a path that holds no file Stylelint reads" { @@ -371,13 +447,7 @@ teardown() { FAKE_PROBE_OUTPUT="MISSING:src/**/*.scss" run tool_stylelint_check '{"paths":["src/**/*.scss"]}' assert_success - 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" { - run tool_stylelint_check '{"paths":["src/**/*.scss"]}' - assert_success - assert_output --partial '"src/**/*.scss"' + assert_line 'npm exec --no -- stylelint --cache -f string "src/**/*.scss"' } @test "admin stylelint check: a literal path alongside a glob still passes the guard" { @@ -401,18 +471,24 @@ teardown() { assert_line "npm run format:fix" } -# --- Prettier: paths route at the target-less base script --- +# --- Prettier: paths run the package's own binary as the only targets --- -@test "admin prettier check: paths route at prettier:base with --check" { +@test "admin prettier check: paths run the local prettier with --check" { run tool_prettier_check '{"paths":["src/app/main.ts"]}' assert_success - assert_line 'npm run prettier:base -- --check "src/app/main.ts"' + assert_line 'npm exec --no -- prettier --check "src/app/main.ts"' } -@test "admin prettier fix: paths route at prettier:base with --write" { +@test "admin prettier fix: paths run the local prettier with the format:fix flags" { run tool_prettier_fix '{"paths":["src/app/main.ts"]}' assert_success - assert_line 'npm run prettier:base -- --write "src/app/main.ts"' + assert_line 'npm exec --no -- prettier --write --cache "src/app/main.ts"' +} + +@test "admin prettier check: paths route carries no --write" { + run tool_prettier_check '{"paths":["src/app/main.ts"]}' + assert_success + refute_output --partial "--write" } @test "admin prettier fix: a path-scoped run never reaches the aggregate format script" { @@ -421,18 +497,29 @@ teardown() { refute_output --partial "npm run format" } -@test "admin prettier check: fails when prettier:base is absent and paths were supplied" { - FAKE_ABSENT_SCRIPTS="prettier:base" +@test "admin prettier check: a scoped config follows the mode flag" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","prettier":{"config":".prettierrc.plugin"}}}} +JSON + run tool_prettier_check '{"scope":"plugin-x","paths":["src/main.ts"]}' + assert_success + assert_line 'npm exec --no -- prettier --check --config .prettierrc.plugin "src/main.ts"' +} + +@test "admin prettier check: refuses with paths when the package has no local prettier" { + FAKE_MISSING_BINARIES="prettier" run tool_prettier_check '{"paths":["src/app/main.ts"]}' assert_failure - assert_output --partial "prettier:base" + assert_output --partial "\"node_modules/.bin/prettier\" is not installed in the package directory \"/var/www/html/src/Administration/Resources/app/administration\"" } -@test "admin prettier check: refuses rather than falling back to the aggregate script" { - FAKE_ABSENT_SCRIPTS="prettier:base" - run tool_prettier_check '{"paths":["src/app/main.ts"]}' +@test "admin prettier fix: runs neither prettier nor the aggregate when the local prettier is missing" { + FAKE_MISSING_BINARIES="prettier" + run tool_prettier_fix '{"paths":["src/app/main.ts"]}' assert_failure - refute_output --partial "npm run format" + run cat "${FAKE_CALL_LOG}" + refute_output --partial "-- prettier" + refute_output --partial "npm run" } @test "admin prettier check: refuses a path that holds no file Prettier reads" { @@ -442,11 +529,248 @@ teardown() { assert_output --partial "Accepted extensions" } +@test "admin prettier check: probes for the extensions the format scripts cover" { + run tool_prettier_check '{"paths":["extension-tooling/index.mjs"]}' + assert_success + run cat "${FAKE_PROBE_FILE}" + assert_output --partial '*.js|*.ts|*.mjs)' +} + @test "admin prettier check: a glob path skips the existence guard" { FAKE_PROBE_OUTPUT="MISSING:src/**/*.ts" run tool_prettier_check '{"paths":["src/**/*.ts"]}' assert_success - assert_line 'npm run prettier:base -- --check "src/**/*.ts"' + assert_line 'npm exec --no -- prettier --check "src/**/*.ts"' +} + +@test "admin stylelint check: probes a path for .scss files only, as lint:scss does" { + run tool_stylelint_check '{"paths":["src/app/assets"]}' + assert_success + run cat "${FAKE_PROBE_FILE}" + assert_output --partial 'in *.scss)' +} + +@test "admin stylelint check: a directory reaches Stylelint as its .scss files only" { + local dir="${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration/src/sty" + mkdir -p "${dir}/nested" + touch "${dir}/a.scss" "${dir}/nested/b.scss" "${dir}/c.css" "${dir}/e.js" "${dir}/f.json" "${dir}/g.twig" "${dir}/notes.txt" + tool_stylelint_check '{"paths":["src/sty"]}' > /dev/null + run js_execute_in_env native "$(grep '^npm exec --no -- stylelint ' "${FAKE_CALL_LOG}")" + assert_success + assert_line '[src/sty/**/*.scss]' + run grep '^match:' <<< "${output}" + assert_output "match:src/sty/a.scss +match:src/sty/nested/b.scss" +} + +@test "admin prettier fix: a directory reaches Prettier as its .js, .ts and .mjs files only" { + local dir="${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration/src/app/component" + mkdir -p "${dir}" + touch "${dir}/a.js" "${dir}/b.json" "${dir}/c.scss" "${dir}/d.md" "${dir}/e.vue" "${dir}/f.ts" "${dir}/g.mjs" + tool_prettier_fix '{"paths":["src/app/component/"]}' > /dev/null + run js_execute_in_env native "$(grep '^npm exec --no -- prettier ' "${FAKE_CALL_LOG}")" + assert_success + assert_line '[src/app/component/**/*.{js,ts,mjs}]' + run grep '^match:' <<< "${output}" + assert_output "match:src/app/component/a.js +match:src/app/component/f.ts +match:src/app/component/g.mjs" +} + +@test "admin prettier check: a literal file path passes through unchanged" { + run tool_prettier_check '{"paths":["extension-tooling/index.mjs"]}' + assert_success + assert_line 'npm exec --no -- prettier --check "extension-tooling/index.mjs"' +} + +# Under ddev on a worktree `ddev exec` re-parses the command in the container's +# bash and leaves a value without space, quote or "#" unquoted, so the bash +# there would expand a directory's glob before the tool sees it. +# A directory named like a file is still a directory: Stylelint and Prettier +# given it bare would read every file in it. +@test "admin stylelint check: a directory named like a .scss file reaches Stylelint as its .scss files" { + mkdir -p "${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration/src/legacy.scss" + run tool_stylelint_check '{"paths":["src/legacy.scss"]}' + assert_success + assert_line 'npm exec --no -- stylelint --cache -f string "src/legacy.scss/**/*.scss"' +} + +@test "admin prettier check: a directory named like a .mjs file reaches Prettier as its .js, .ts and .mjs files" { + mkdir -p "${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration/src/lib.mjs" + run tool_prettier_check '{"paths":["src/lib.mjs"]}' + assert_success + assert_line 'npm exec --no -- prettier --check "src/lib.mjs/**/*.{js,ts,mjs}"' +} + +@test "admin prettier check: refuses when the directory test itself fails" { + FAKE_DIRECTORY_PROBE_ERROR='Error response from daemon: container shopware-web-1 is not running' + run tool_prettier_check '{"paths":["src/app/main.ts"]}' + assert_failure + assert_output --partial "could not check which of them are directories; the probe exited with 1. Probe output: Error response from daemon" +} + +# The ddev environment, with the call treated as one on a linked worktree. +_as_ddev_worktree_call() { + LINT_ENV="ddev" + _env_targets_worktree() { return 0; } +} + +@test "admin stylelint fix: a directory path is refused under ddev on a worktree" { + _as_ddev_worktree_call + run tool_stylelint_fix '{"paths":["src/app/assets/"]}' + assert_failure + assert_output --partial "under ddev on a worktree these paths would reach Stylelint as glob patterns" +} + +@test "admin prettier fix: a directory path is refused under ddev on a worktree" { + _as_ddev_worktree_call + run tool_prettier_fix '{"paths":["src/app/component/"]}' + assert_failure + assert_output --partial "under ddev on a worktree these paths would reach Prettier as glob patterns" +} + +@test "admin stylelint fix: a glob path is refused under ddev on a worktree" { + _as_ddev_worktree_call + run tool_stylelint_fix '{"paths":["src/**/*.scss"]}' + assert_failure + assert_output --partial "these paths would reach Stylelint as glob patterns the container shell expands first: src/**/*.scss" +} + +@test "admin prettier fix: a file path still runs under ddev on a worktree" { + _as_ddev_worktree_call + run tool_prettier_fix '{"paths":["src/app/main.ts"]}' + assert_success + assert_line 'npm exec --no -- prettier --write --cache "src/app/main.ts"' +} + +# --- Admin Stylelint and Prettier: the built commands executed through each environment wrapper --- + +_admin_command() { + "tool_$1" "{\"paths\":[\"$2\"]}" > /dev/null + grep "^npm exec --no -- ${1%%_*} " "${FAKE_CALL_LOG}" +} + +_assert_admin_stylelint_check_runs_in_package_dir() { + run js_execute_in_env "$1" "$(_admin_command stylelint_check "src/app/my dir/base.scss")" + assert_success + assert_output "cwd=${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration +[--cache] +[-f] +[string] +[src/app/my dir/base.scss]" +} + +_assert_admin_prettier_check_runs_in_package_dir() { + run js_execute_in_env "$1" "$(_admin_command prettier_check "src/app/my dir/main.ts")" + assert_success + assert_output "cwd=${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration +[--check] +[src/app/my dir/main.ts]" +} + +# The package installs prettier, so the probe finds it and prints nothing. +_assert_admin_prettier_probe_finds_binary() { + tool_prettier_check '{"paths":["src/app/main.ts"]}' > /dev/null + run js_execute_in_env "$1" "$(grep '^npm exec --no -c "test -x node_modules/.bin/prettier ' "${FAKE_CALL_LOG}")" + assert_success + assert_output "" +} + +@test "admin stylelint command: runs in the package dir with its paths intact under native" { + _assert_admin_stylelint_check_runs_in_package_dir native +} + +@test "admin stylelint command: runs in the package dir with its paths intact under docker" { + _assert_admin_stylelint_check_runs_in_package_dir docker +} + +@test "admin stylelint command: runs in the package dir with its paths intact under docker-compose" { + _assert_admin_stylelint_check_runs_in_package_dir docker-compose +} + +@test "admin stylelint command: runs in the package dir with its paths intact under vagrant" { + _assert_admin_stylelint_check_runs_in_package_dir vagrant +} + +@test "admin stylelint command: runs in the package dir with its paths intact under ddev" { + _assert_admin_stylelint_check_runs_in_package_dir ddev +} + +@test "admin stylelint command: runs in the package dir with its paths intact under ddev on a worktree" { + _assert_admin_stylelint_check_runs_in_package_dir ddev-worktree +} + +@test "admin prettier command: runs in the package dir with its paths intact under native" { + _assert_admin_prettier_check_runs_in_package_dir native +} + +@test "admin prettier command: runs in the package dir with its paths intact under docker" { + _assert_admin_prettier_check_runs_in_package_dir docker +} + +@test "admin prettier command: runs in the package dir with its paths intact under docker-compose" { + _assert_admin_prettier_check_runs_in_package_dir docker-compose +} + +@test "admin prettier command: runs in the package dir with its paths intact under vagrant" { + _assert_admin_prettier_check_runs_in_package_dir vagrant +} + +@test "admin prettier command: runs in the package dir with its paths intact under ddev" { + _assert_admin_prettier_check_runs_in_package_dir ddev +} + +@test "admin prettier command: runs in the package dir with its paths intact under ddev on a worktree" { + _assert_admin_prettier_check_runs_in_package_dir ddev-worktree +} + +@test "admin local-binary probe: finds the package's prettier under native" { + _assert_admin_prettier_probe_finds_binary native +} + +@test "admin local-binary probe: finds the package's prettier under docker" { + _assert_admin_prettier_probe_finds_binary docker +} + +@test "admin local-binary probe: finds the package's prettier under docker-compose" { + _assert_admin_prettier_probe_finds_binary docker-compose +} + +@test "admin local-binary probe: finds the package's prettier under vagrant" { + _assert_admin_prettier_probe_finds_binary vagrant +} + +@test "admin local-binary probe: finds the package's prettier under ddev" { + _assert_admin_prettier_probe_finds_binary ddev +} + +@test "admin local-binary probe: finds the package's prettier under ddev on a worktree" { + _assert_admin_prettier_probe_finds_binary ddev-worktree +} + +# --- Every tool with a paths parameter, against the trunk package.json --- + +# A path-scoped route that names an npm script Shopware does not define refuses +# every call on a real checkout. The tool list comes from tools.json, so a tool +# that gains a paths parameter is covered without an edit here. +@test "admin tools: no path-scoped call names an npm script the trunk package.json lacks" { + local -a tools=() + local tool + while IFS= read -r tool; do + tools+=("${tool}") + done < <(jq -r '.tools[] | select(.inputSchema.properties.paths) | .name' \ + "${PLUGIN_DIR}/mcp-server-js-admin/tools.json") + + local refused="" + for tool in "${tools[@]}"; do + if ! "tool_${tool}" '{"paths":["src/app/main.ts"]}' > "${BATS_TEST_TMPDIR}/out.txt" 2>&1 \ + || grep -q -e "is not defined in package.json" -e "Missing script" "${BATS_TEST_TMPDIR}/out.txt"; then + refused="${refused} ${tool}: $(cat "${BATS_TEST_TMPDIR}/out.txt")" + fi + done + + assert [ "${#tools[@]}" -ge 6 ] + assert_equal "${refused}" "" } # --- TypeScript --- diff --git a/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats b/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats index cda86b1f..da525bb5 100644 --- a/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats +++ b/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats @@ -6,9 +6,15 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" -# Answers the `npm pkg get "scripts."` probe with the body the Shopware -# Storefront package.json declares. FAKE_ABSENT_SCRIPTS makes a script look -# undefined; FAKE_BODY_NAME/FAKE_BODY makes one report a different body. +# Answers the `npm pkg get "scripts."` probe from the Shopware trunk +# Storefront package.json (fixtures/shopware-trunk). FAKE_ABSENT_SCRIPTS makes +# a script look undefined; FAKE_BODY_NAME/FAKE_BODY makes one report a +# different body. +# +# "jest:base" is the one script answered that trunk does not define: the jest +# tool routes at it when a package declares it, and these tests model such a +# package. FAKE_ABSENT_SCRIPTS="jest:base" restores the trunk layout, where the +# tool falls back to "unit". _fake_script_body() { local name="$1" @@ -21,41 +27,12 @@ _fake_script_body() { return fi - case "${name}" in - eslint:app) - printf '%s\n' '"eslint --no-error-on-unmatched-pattern --report-unused-disable-directives"' ;; - eslint:components) - printf '%s\n' '"cd ../.. && eslint --no-error-on-unmatched-pattern --config ./app/storefront/eslint.config.js --report-unused-disable-directives"' ;; - lint:js) - printf '%s\n' '"npm run lint:js:app && npm run lint:js:components"' ;; - lint:js:fix) - printf '%s\n' '"npm run lint:js:app:fix && npm run lint:js:components:fix"' ;; - stylelint:app) - printf '%s\n' '"stylelint --config stylelint.config.js --cache"' ;; - lint:scss) - printf '%s\n' '"npm run stylelint:app -- ./src/scss"' ;; - lint:scss-fix) - printf '%s\n' '"npm run lint:scss -- --fix"' ;; - jest:base) - printf '%s\n' '"jest --config jest.config.js"' ;; - unit) - # A package that declares jest:base writes "unit" in terms of it. - # A package that does not — the pre-refactor layout the fallback - # route exists for — spells the runner out instead. Keying on - # FAKE_ABSENT_SCRIPTS keeps both configurations describing a - # package.json that could actually exist. - case " ${FAKE_ABSENT_SCRIPTS} " in - *" jest:base "*) printf '%s\n' '"jest --config jest.config.js --ci"' ;; - *) printf '%s\n' '"npm run jest:base -- --ci"' ;; - esac - ;; - unit:components) - printf '%s\n' '"vitest run --config vitest.config.mts"' ;; - unit:components:coverage) - printf '%s\n' '"vitest run --coverage --config vitest.config.mts"' ;; - *) - printf '%s\n' '{}' ;; - esac + if [[ "${name}" == "jest:base" ]]; then + printf '%s\n' '"jest --config jest.config.js"' + return + fi + + shopware_trunk_script_body storefront "${name}" } # Builds a Jest JSON report body carrying the counts a test needs. @@ -91,6 +68,14 @@ setup() { FAKE_CLEAR_EXIT=0 FAKE_CLEAR_OUTPUT="" FAKE_RUN_EXIT=0 + # Binaries the package's node_modules/.bin lacks, as the local-binary + # probe of a path-scoped run sees it. + FAKE_MISSING_BINARIES="" + # Output of a binary probe that fails before it can test anything, such as + # a stopped container. + FAKE_BINARY_PROBE_ERROR="" + # Output of a directory test that fails before it can test anything. + FAKE_DIRECTORY_PROBE_ERROR="" log() { :; } source "${PLUGIN_DIR}/shared/environment.sh" source "${PLUGIN_DIR}/shared/scope.sh" @@ -107,6 +92,32 @@ setup() { local name="${cmd#npm pkg get \"scripts.}" _fake_script_body "${name%\"}" ;; + 'npm exec --no -c "[ -d '*) + # The directory test runs for real, as sh in the package + # directory, so it answers about the files a test created. + if [[ -n "${FAKE_DIRECTORY_PROBE_ERROR}" ]]; then + printf '%s\n' "${FAKE_DIRECTORY_PROBE_ERROR}" + return 1 + fi + # LINT_WORKDIR is empty here: environment.sh resets it when + # sourced, and setup() sets it before that. + (LINT_WORKDIR="${BATS_TEST_TMPDIR}"; cd "$(get_js_workdir)" 2>/dev/null || true; eval "sh -c ${cmd#npm exec --no -c }") + ;; + 'npm exec --no -c "test -x node_modules/.bin/'*) + if [[ -n "${FAKE_BINARY_PROBE_ERROR}" ]]; then + printf '%s\n' "${FAKE_BINARY_PROBE_ERROR}" + return 1 + fi + local binary="${cmd#npm exec --no -c \"test -x node_modules/.bin/}" + case " ${FAKE_MISSING_BINARIES} " in + *" ${binary%% *} "*) + # The directory as the environment reports it, which + # under a container is not a host path. + printf '%s\n%s\n' "LOCAL_BINARY_MISSING_IN" "/var/www/html/src/Storefront/Resources/app/storefront" + return 3 + ;; + esac + ;; 'cd "'*) printf '%s\n' "$1" > "${FAKE_PROBE_FILE}" printf '%s\n' "${FAKE_PROBE_OUTPUT}" @@ -124,6 +135,17 @@ setup() { cat -- "${FAKE_REPORT_STORE}" ;; *) + # npm refuses a script the package does not define before it + # runs anything, so a route naming one fails as it would on a + # real checkout. + if [[ "${cmd}" == "npm run "* ]]; then + local script="${cmd#npm run }" + script="${script%% *}" + if [[ "$(_fake_script_body "${script}")" == "{}" ]]; then + printf '%s\n' "npm error Missing script: \"${script}\"" + return 1 + fi + fi if [[ "${FAKE_RUN_WRITES_REPORT}" == "1" ]]; then printf '%s\n' "${FAKE_REPORT_OUTPUT}" > "${FAKE_REPORT_STORE}" fi @@ -147,7 +169,7 @@ teardown() { 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 \ - PROJECT_ROOT DEV_TOOLING_STATE_FILE + FAKE_MISSING_BINARIES FAKE_BINARY_PROBE_ERROR FAKE_DIRECTORY_PROBE_ERROR PROJECT_ROOT DEV_TOOLING_STATE_FILE } # --- ESLint: no paths runs the aggregate script bare --- @@ -164,37 +186,55 @@ teardown() { assert_line "npm run lint:js:fix" } -# --- ESLint: tree routing and rebasing --- +# --- ESLint: paths run the package's own binary, one call per tree --- -@test "storefront eslint check: app-tree path uses eslint:app rebased to the package dir" { +@test "storefront eslint check: app-tree path runs the local eslint rebased to the package dir" { run tool_eslint_check '{"paths":["src/Storefront/Resources/app/storefront/src/plugin/cart.plugin.js"]}' assert_success - assert_output --partial 'npm run eslint:app -- -f stylish "src/plugin/cart.plugin.js"' + assert_line 'npm exec --no -- eslint --no-error-on-unmatched-pattern --report-unused-disable-directives -f stylish "src/plugin/cart.plugin.js"' +} + +@test "storefront eslint fix: app-tree path runs the local eslint with --fix" { + run tool_eslint_fix '{"paths":["src/plugin/cart.plugin.js"]}' + assert_success + assert_line 'npm exec --no -- eslint --no-error-on-unmatched-pattern --report-unused-disable-directives --fix "src/plugin/cart.plugin.js"' } @test "storefront eslint check: tree-relative app path is passed through unchanged" { run tool_eslint_check '{"paths":["build/webpack/config.js"]}' assert_success - assert_output --partial 'npm run eslint:app -- -f stylish "build/webpack/config.js"' + assert_output --partial '-f stylish "build/webpack/config.js"' } -@test "storefront eslint check: components path uses eslint:components rebased to the resources dir" { +@test "storefront eslint check: components path runs from ../.. with the components config" { run tool_eslint_check '{"paths":["src/Storefront/Resources/views/components/checkout/cart.js"]}' assert_success - assert_output --partial 'npm run eslint:components -- -f stylish "views/components/checkout/cart.js"' + assert_line 'npm exec --no -c "cd ../.. && eslint --no-error-on-unmatched-pattern --report-unused-disable-directives --config ./app/storefront/eslint.config.js -f stylish \"views/components/checkout/cart.js\""' +} + +@test "storefront eslint fix: components path runs from ../.. with --fix" { + run tool_eslint_fix '{"paths":["views/components/checkout/cart.js"]}' + assert_success + assert_line 'npm exec --no -c "cd ../.. && eslint --no-error-on-unmatched-pattern --report-unused-disable-directives --config ./app/storefront/eslint.config.js --fix \"views/components/checkout/cart.js\""' } @test "storefront eslint check: tree-relative components path keeps its views/components prefix" { run tool_eslint_check '{"paths":["views/components/checkout/cart.js"]}' assert_success - assert_output --partial 'npm run eslint:components -- -f stylish "views/components/checkout/cart.js"' + assert_output --partial '-f stylish \"views/components/checkout/cart.js\""' +} + +@test "storefront eslint check: mixed paths run each tree on its own paths only" { + run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js","views/components/checkout/cart.js"]}' + assert_success + assert_line 'npm exec --no -- eslint --no-error-on-unmatched-pattern --report-unused-disable-directives -f stylish "src/plugin/cart.plugin.js"' + assert_line 'npm exec --no -c "cd ../.. && eslint --no-error-on-unmatched-pattern --report-unused-disable-directives --config ./app/storefront/eslint.config.js -f stylish \"views/components/checkout/cart.js\""' } -@test "storefront eslint check: mixed paths run both the app and components trees" { +@test "storefront eslint check: paths route carries no --fix" { run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js","views/components/checkout/cart.js"]}' assert_success - assert_output --partial 'npm run eslint:app -- -f stylish "src/plugin/cart.plugin.js"' - assert_output --partial 'npm run eslint:components -- -f stylish "views/components/checkout/cart.js"' + refute_output --partial "--fix" } @test "storefront eslint check: json format applied when paths are supplied" { @@ -203,56 +243,222 @@ teardown() { assert_output --partial "-f json" } -@test "storefront eslint fix: appends --fix ahead of the paths" { - run tool_eslint_fix '{"paths":["src/plugin/cart.plugin.js"]}' +_set_eslint_scope() { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","eslint":{"config":"eslint.config.mjs"}}}} +JSON +} + +@test "storefront eslint check: an app path under a scope runs with the scope config" { + _set_eslint_scope + run tool_eslint_check '{"scope":"plugin-x","paths":["src/main.js"]}' assert_success - assert_output --partial 'npm run eslint:app -- --fix "src/plugin/cart.plugin.js"' + assert_line 'npm exec --no -- eslint --no-error-on-unmatched-pattern --report-unused-disable-directives -f stylish --config eslint.config.mjs "src/main.js"' } -# --- ESLint: fallback and hard failures --- +@test "storefront eslint check: a components path under a scope refuses" { + _set_eslint_scope + run tool_eslint_check '{"scope":"plugin-x","paths":["views/components/checkout/cart.js"]}' + assert_failure + assert_output --partial 'scope "plugin-x" runs in "custom/plugins/X", but these paths route to the core Storefront components tree' +} -@test "storefront eslint check: refuses a path-scoped app-tree run when eslint:app is absent" { - FAKE_ABSENT_SCRIPTS="eslint:app" +@test "storefront eslint fix: a refused components path under a scope runs nothing" { + _set_eslint_scope + run tool_eslint_fix '{"scope":"plugin-x","paths":["src/main.js","views/components/checkout/cart.js"]}' + assert_failure + run cat "${FAKE_CALL_LOG}" + refute_output --partial "npm exec" +} + +@test "storefront eslint check: refuses with paths when the package has no local eslint" { + FAKE_MISSING_BINARIES="eslint" run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js"]}' assert_failure - assert_output --partial "Refusing to lint the app tree with paths" - assert_output --partial '"eslint:app"' + assert_output --partial "\"node_modules/.bin/eslint\" is not installed in the package directory \"/var/www/html/src/Storefront/Resources/app/storefront\"" } -@test "storefront eslint check: refuses a path-scoped components-tree run when eslint:components is absent" { - FAKE_ABSENT_SCRIPTS="eslint:components" - run tool_eslint_check '{"paths":["views/components/checkout/cart.js"]}' +@test "storefront eslint fix: runs neither eslint nor the aggregate when the local eslint is missing" { + FAKE_MISSING_BINARIES="eslint" + run tool_eslint_fix '{"paths":["src/plugin/cart.plugin.js","views/components/checkout/cart.js"]}' assert_failure - assert_output --partial "Refusing to lint the components tree with paths" - assert_output --partial '"eslint:components"' + run cat "${FAKE_CALL_LOG}" + refute_output --partial "eslint --no-error-on-unmatched-pattern" + refute_output --partial "npm run" } -@test "storefront eslint check: never substitutes the lint:js aggregate even when it would accept arguments" { - FAKE_ABSENT_SCRIPTS="eslint:app" - FAKE_BODY_NAME="lint:js" - FAKE_BODY="eslint --no-error-on-unmatched-pattern" +@test "storefront eslint check: a probe that fails before testing is not reported as a missing binary" { + FAKE_BINARY_PROBE_ERROR='Error response from daemon: container shopware-web-1 is not running' run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js"]}' assert_failure - assert_output --partial 'The aggregate "lint:js" script is not a substitute' - refute_output --partial "npm run" + assert_output --partial "could not check whether the package's own eslint binary is installed; the probe exited with 1. Probe output: Error response from daemon" + refute_output --partial "is not installed" } @test "storefront eslint fix: names lint:js:fix as the aggregate it will not substitute" { - FAKE_ABSENT_SCRIPTS="eslint:app" + FAKE_MISSING_BINARIES="eslint" run tool_eslint_fix '{"paths":["src/plugin/cart.plugin.js"]}' assert_failure - assert_output --partial 'The aggregate "lint:js:fix" script is not a substitute' + assert_output --partial 'the aggregate "lint:js:fix" script is not a substitute' } -@test "storefront eslint check: fails hard when the selected script cannot take arguments" { - FAKE_BODY_NAME="eslint:app" - FAKE_BODY="a && (cd .. && b)" - run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js"]}' - assert_failure - assert_output --partial "eslint:app" - assert_output --partial "cannot take appended arguments" +# --- ESLint: the built commands executed through each environment wrapper --- + +# The command a components-tree check builds for one path holding a space. +_components_check_command() { + tool_eslint_check '{"paths":["views/components/my dir/cart.js"]}' > /dev/null + grep '^npm exec --no -c "cd ../.. ' "${FAKE_CALL_LOG}" +} + +_assert_components_run_from_resources() { + assert_success + assert_output "cwd=${BATS_TEST_TMPDIR}/src/Storefront/Resources +[--no-error-on-unmatched-pattern] +[--report-unused-disable-directives] +[--config] +[./app/storefront/eslint.config.js] +[-f] +[stylish] +[views/components/my dir/cart.js]" +} + +@test "storefront eslint components command: runs from ../.. with its paths intact under native" { + run js_execute_in_env native "$(_components_check_command)" + _assert_components_run_from_resources +} + +@test "storefront eslint components command: runs from ../.. with its paths intact under docker" { + run js_execute_in_env docker "$(_components_check_command)" + _assert_components_run_from_resources +} + +@test "storefront eslint components command: runs from ../.. with its paths intact under docker-compose" { + run js_execute_in_env docker-compose "$(_components_check_command)" + _assert_components_run_from_resources +} + +@test "storefront eslint components command: runs from ../.. with its paths intact under vagrant" { + run js_execute_in_env vagrant "$(_components_check_command)" + _assert_components_run_from_resources +} + +@test "storefront eslint components command: runs from ../.. with its paths intact under ddev" { + run js_execute_in_env ddev "$(_components_check_command)" + _assert_components_run_from_resources +} + +@test "storefront eslint components command: runs from ../.. with its paths intact under ddev on a worktree" { + run js_execute_in_env ddev-worktree "$(_components_check_command)" + _assert_components_run_from_resources +} + +# --- Stylelint: the built commands executed through each environment wrapper --- + +_stylelint_probe_command() { + tool_stylelint_check '{"paths":["src/scss/base.scss"]}' > /dev/null + grep '^npm exec --no -c "test -x node_modules/.bin/stylelint ' "${FAKE_CALL_LOG}" +} + +_stylelint_check_command() { + tool_stylelint_check '{"paths":["src/scss/my dir/base.scss"]}' > /dev/null + grep '^npm exec --no -- stylelint ' "${FAKE_CALL_LOG}" +} + +# The package installs no stylelint, so the probe misses. +_assert_probe_reports_missing_stylelint() { + JS_FAKE_BINARIES="eslint" + run js_execute_in_env "$1" "$(_stylelint_probe_command)" + assert_failure 3 + assert_output "LOCAL_BINARY_MISSING_IN +${BATS_TEST_TMPDIR}/src/Storefront/Resources/app/storefront" +} + +_assert_stylelint_check_runs_in_package_dir() { + run js_execute_in_env "$1" "$(_stylelint_check_command)" + assert_success + assert_output "cwd=${BATS_TEST_TMPDIR}/src/Storefront/Resources/app/storefront +[--config] +[stylelint.config.js] +[--cache] +[-f] +[string] +[src/scss/my dir/base.scss]" +} + +@test "storefront local-binary probe: reports the missing stylelint and its directory under native" { + _assert_probe_reports_missing_stylelint native +} + +@test "storefront local-binary probe: reports the missing stylelint and its directory under docker" { + _assert_probe_reports_missing_stylelint docker +} + +@test "storefront local-binary probe: reports the missing stylelint and its directory under docker-compose" { + _assert_probe_reports_missing_stylelint docker-compose +} + +@test "storefront local-binary probe: reports the missing stylelint and its directory under vagrant" { + _assert_probe_reports_missing_stylelint vagrant } +@test "storefront local-binary probe: reports the missing stylelint and its directory under ddev" { + _assert_probe_reports_missing_stylelint ddev +} + +@test "storefront local-binary probe: reports the missing stylelint and its directory under ddev on a worktree" { + _assert_probe_reports_missing_stylelint ddev-worktree +} + +@test "storefront stylelint command: runs in the package dir with its paths intact under native" { + _assert_stylelint_check_runs_in_package_dir native +} + +@test "storefront stylelint command: runs in the package dir with its paths intact under docker" { + _assert_stylelint_check_runs_in_package_dir docker +} + +@test "storefront stylelint command: runs in the package dir with its paths intact under docker-compose" { + _assert_stylelint_check_runs_in_package_dir docker-compose +} + +@test "storefront stylelint command: runs in the package dir with its paths intact under vagrant" { + _assert_stylelint_check_runs_in_package_dir vagrant +} + +@test "storefront stylelint command: runs in the package dir with its paths intact under ddev" { + _assert_stylelint_check_runs_in_package_dir ddev +} + +@test "storefront stylelint command: runs in the package dir with its paths intact under ddev on a worktree" { + _assert_stylelint_check_runs_in_package_dir ddev-worktree +} + +@test "storefront stylelint check: a directory reaches Stylelint as its .scss and .css files only" { + local dir="${BATS_TEST_TMPDIR}/src/Storefront/Resources/app/storefront/src/sty" + mkdir -p "${dir}/nested" + touch "${dir}/a.scss" "${dir}/nested/b.css" "${dir}/e.js" "${dir}/f.json" "${dir}/g.twig" "${dir}/h.md" "${dir}/notes.txt" + tool_stylelint_check '{"paths":["src/sty/"]}' > /dev/null + run js_execute_in_env native "$(grep '^npm exec --no -- stylelint ' "${FAKE_CALL_LOG}")" + assert_success + assert_line '[src/sty/**/*.{scss,css}]' + run grep '^match:' <<< "${output}" + assert_output "match:src/sty/a.scss +match:src/sty/nested/b.css" +} + +@test "storefront eslint app command: runs in the package dir with its paths intact under ddev on a worktree" { + tool_eslint_check '{"paths":["src/my plugin/cart.plugin.js"]}' > /dev/null + run js_execute_in_env ddev-worktree "$(grep '^npm exec --no -- eslint ' "${FAKE_CALL_LOG}")" + assert_success + assert_output "cwd=${BATS_TEST_TMPDIR}/src/Storefront/Resources/app/storefront +[--no-error-on-unmatched-pattern] +[--report-unused-disable-directives] +[-f] +[stylish] +[src/my plugin/cart.plugin.js]" +} + + @test "storefront eslint check: refuses to lint a path that does not exist" { FAKE_PROBE_OUTPUT="MISSING:src/gone.js" run tool_eslint_check '{"paths":["src/gone.js"]}' @@ -280,7 +486,7 @@ teardown() { @test "storefront eslint check: keeps a path containing a space in one argument" { run tool_eslint_check '{"paths":["src/plugin/cart plugin.js"]}' assert_success - assert_output --partial 'npm run eslint:app -- -f stylish "src/plugin/cart plugin.js"' + assert_output --partial '-f stylish "src/plugin/cart plugin.js"' } @test "storefront eslint check: refuses a path containing a single quote" { @@ -340,24 +546,50 @@ JSON refute_output --partial "--fix" } -# --- Stylelint: paths route at the target-less base script --- +# Trunk's "lint:scss" passes --config stylelint.config.js, and "lint:scss-fix" +# reaches it through `npm run lint:scss`. Stylelint exits with "The flag +# --config can only be set once." on a second --config, so appending a scope +# config to either cannot run. +_set_stylelint_scope() { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +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_line 'npm run stylelint:app -- -f string "src/scss/base.scss"' +@test "storefront stylelint check: a scoped config without paths refuses when lint:scss passes its own config" { + _set_stylelint_scope + run tool_stylelint_check '{"scope":"plugin-x"}' + assert_failure + assert_output --partial "\"npm run lint:scss\" already passes a config of its own" } -@test "storefront stylelint fix: paths route at stylelint:app as the only targets" { - run tool_stylelint_fix '{"paths":["src/scss/base.scss"]}' +@test "storefront stylelint fix: a scoped config without paths refuses when lint:scss-fix reaches a script passing its own config" { + _set_stylelint_scope + run tool_stylelint_fix '{"scope":"plugin-x"}' + assert_failure + assert_output --partial "\"npm run lint:scss-fix\" already passes a config of its own" +} + +@test "storefront stylelint fix: the refused scoped run executes no aggregate script" { + _set_stylelint_scope + run tool_stylelint_fix '{"scope":"plugin-x"}' + assert_failure + run cat "${FAKE_CALL_LOG}" + refute_output --partial "npm run lint:scss" +} + +# --- Stylelint: paths run the package's own binary as the only targets --- + +@test "storefront stylelint check: paths run the local stylelint with the lint:scss flags" { + run tool_stylelint_check '{"paths":["src/scss/base.scss"]}' assert_success - assert_line 'npm run stylelint:app -- --fix "src/scss/base.scss"' + assert_line 'npm exec --no -- stylelint --config stylelint.config.js --cache -f string "src/scss/base.scss"' } -@test "storefront stylelint fix: paths route carries --fix, which the base script body lacks" { +@test "storefront stylelint fix: paths run the local stylelint with --fix" { run tool_stylelint_fix '{"paths":["src/scss/base.scss"]}' assert_success - assert_output --partial "-- --fix " + assert_line 'npm exec --no -- stylelint --config stylelint.config.js --cache --fix "src/scss/base.scss"' } @test "storefront stylelint check: paths route carries no --fix" { @@ -372,18 +604,78 @@ JSON refute_output --partial "lint:scss-fix" } -@test "storefront stylelint fix: fails when stylelint:app is absent and paths were supplied" { - FAKE_ABSENT_SCRIPTS="stylelint:app" +# Stylelint exits with "The flag --config can only be set once." on a second +# --config, so the scoped one has to replace the package's rather than follow it. +@test "storefront stylelint fix: a scoped config replaces stylelint.config.js" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + run tool_stylelint_fix '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' + assert_success + assert_line 'npm exec --no -- stylelint --cache --config .stylelintrc.plugin --fix "src/scss/base.scss"' +} + +@test "storefront stylelint fix: a directory path is refused under ddev on a worktree" { + LINT_ENV="ddev" + _env_targets_worktree() { return 0; } + run tool_stylelint_fix '{"paths":["src/scss/"]}' + assert_failure + assert_output --partial "under ddev on a worktree these paths would reach Stylelint as glob patterns" +} + +@test "storefront stylelint check: a directory named like a .css file reaches Stylelint as its .scss and .css files" { + mkdir -p "${BATS_TEST_TMPDIR}/src/Storefront/Resources/app/storefront/src/legacy.css" + run tool_stylelint_check '{"paths":["src/legacy.css"]}' + assert_success + assert_line 'npm exec --no -- stylelint --config stylelint.config.js --cache -f string "src/legacy.css/**/*.{scss,css}"' +} + +_set_stylelint_scope_with_body() { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON + FAKE_BODY_NAME="lint:scss" + FAKE_BODY="$1" +} + +@test "storefront stylelint check: a scoped config without paths refuses when lint:scss passes -c=" { + _set_stylelint_scope_with_body "stylelint -c=stylelint.config.js ./src/scss" + run tool_stylelint_check '{"scope":"plugin-x"}' + assert_failure + assert_output --partial "\"npm run lint:scss\" already passes a config of its own" +} + +# The -c belongs to eslint, the command before the stylelint one. +@test "storefront stylelint check: a scoped config without paths is appended when only another program in lint:scss takes -c" { + _set_stylelint_scope_with_body "eslint -c eslint.config.js ./src && stylelint ./src/scss" + run tool_stylelint_check '{"scope":"plugin-x"}' + assert_success + assert_line "npm run lint:scss -- -f string --config .stylelintrc.plugin" +} + +@test "storefront stylelint check: a scope without a Stylelint config names no --config" { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X"}}} +JSON + run tool_stylelint_check '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' + assert_success + assert_line 'npm exec --no -- stylelint --cache -f string "src/scss/base.scss"' +} + +@test "storefront stylelint fix: refuses with paths when the package has no local stylelint" { + FAKE_MISSING_BINARIES="stylelint" run tool_stylelint_fix '{"paths":["src/scss/base.scss"]}' assert_failure - assert_output --partial "stylelint:app" + assert_output --partial "\"node_modules/.bin/stylelint\" is not installed in the package directory \"/var/www/html/src/Storefront/Resources/app/storefront\"" } -@test "storefront stylelint fix: refuses rather than falling back to the aggregate fix script" { - FAKE_ABSENT_SCRIPTS="stylelint:app" +@test "storefront stylelint fix: runs neither stylelint nor the aggregate when the local stylelint is missing" { + FAKE_MISSING_BINARIES="stylelint" run tool_stylelint_fix '{"paths":["src/scss/base.scss"]}' assert_failure - refute_output --partial "npm run lint:scss" + run cat "${FAKE_CALL_LOG}" + refute_output --partial "-- stylelint" + refute_output --partial "npm run" } @test "storefront stylelint check: refuses a path that holds no file Stylelint reads" { @@ -397,13 +689,7 @@ JSON FAKE_PROBE_OUTPUT="MISSING:src/**/*.scss" run tool_stylelint_check '{"paths":["src/**/*.scss"]}' assert_success - 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" { - run tool_stylelint_check '{"paths":["src/**/*.scss"]}' - assert_success - assert_output --partial '"src/**/*.scss"' + assert_line 'npm exec --no -- stylelint --config stylelint.config.js --cache -f string "src/**/*.scss"' } @test "storefront stylelint check: a literal path alongside a glob still passes the guard" { @@ -413,6 +699,31 @@ JSON assert_output --partial "src/plugin" } +# --- Every tool with a paths parameter, against the trunk package.json --- + +# A path-scoped route that names an npm script Shopware does not define refuses +# every call on a real checkout. The tool list comes from tools.json, so a tool +# that gains a paths parameter is covered without an edit here. +@test "storefront tools: no path-scoped call names an npm script the trunk package.json lacks" { + local -a tools=() + local tool + while IFS= read -r tool; do + tools+=("${tool}") + done < <(jq -r '.tools[] | select(.inputSchema.properties.paths) | .name' \ + "${PLUGIN_DIR}/mcp-server-js-storefront/tools.json") + + local refused="" + for tool in "${tools[@]}"; do + if ! "tool_${tool}" '{"paths":["views/components/checkout/cart.js"]}' > "${BATS_TEST_TMPDIR}/out.txt" 2>&1 \ + || grep -q -e "is not defined in package.json" -e "Missing script" "${BATS_TEST_TMPDIR}/out.txt"; then + refused="${refused} ${tool}: $(cat "${BATS_TEST_TMPDIR}/out.txt")" + fi + done + + assert [ "${#tools[@]}" -ge 5 ] + assert_equal "${refused}" "" +} + # --- Jest --- @test "storefront jest: base command routes at jest:base and asks for the JSON report" { diff --git a/plugin-tests/dev-tooling/scope_js_tools.bats b/plugin-tests/dev-tooling/scope_js_tools.bats index 1fbd84ed..888653de 100644 --- a/plugin-tests/dev-tooling/scope_js_tools.bats +++ b/plugin-tests/dev-tooling/scope_js_tools.bats @@ -6,29 +6,19 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" -# Answers the `npm pkg get "scripts."` probe with the body the Shopware -# Administration package.json declares. +# Answers the `npm pkg get "scripts."` probe from the Shopware trunk +# Administration package.json (fixtures/shopware-trunk), plus "jest:base", +# which trunk does not define: the jest tool routes at it when a package +# declares it, and the scoped jest cases below model such a package. _fake_admin_script_body() { local name="$1" - case "${name}" in - lint) - printf '%s\n' '"eslint src test build.ts --cache"' ;; - lint:fix) - printf '%s\n' '"npm run lint -- --fix"' ;; - lint:debugging) - printf '%s\n' '"eslint"' ;; - lint:scss) - printf '%s\n' '"npm run stylelint:base -- **/*.scss"' ;; - lint:scss-fix) - 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 + if [[ "${name}" == "jest:base" ]]; then + printf '%s\n' '"jest --config jest.config.js"' + return + fi + + shopware_trunk_script_body administration "${name}" } setup() { @@ -78,6 +68,7 @@ JSON esac } source "${PLUGIN_DIR}/mcp-server-js-admin/lib/eslint.sh" + source "${PLUGIN_DIR}/mcp-server-js-admin/lib/stylelint.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" @@ -116,6 +107,14 @@ teardown() { assert_output --partial 'Scope "ghost" is not declared' } +@test "stylelint scoped with paths: probes for and runs the local binary under the scope cwd" { + run tool_stylelint_check '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' + assert_success + run cat "${CALLS_FILE}" + assert_line '[scope=custom/plugins/X|sub=] npm exec --no -c "test -x node_modules/.bin/stylelint || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }"' + assert_line '[scope=custom/plugins/X|sub=] npm exec --no -- stylelint --cache -f string "src/scss/base.scss"' +} + @test "jest scoped: runs in scope cwd + jest.cwd" { mkdir -p "${BATS_TEST_TMPDIR}/custom/plugins/X/tests/jest/administration/node_modules" run tool_jest_run '{"scope":"plugin-x"}' diff --git a/plugin-tests/dev-tooling/test_helper/common_setup.bash b/plugin-tests/dev-tooling/test_helper/common_setup.bash index 310c3fb4..942b12ee 100644 --- a/plugin-tests/dev-tooling/test_helper/common_setup.bash +++ b/plugin-tests/dev-tooling/test_helper/common_setup.bash @@ -207,3 +207,112 @@ setup_php_mcp_env() { # shellcheck source=/dev/null # lib_path is caller-supplied at runtime; each test passes a different tool library source "${lib_path}" } + +# Answer an `npm pkg get "scripts."` probe the way npm answers it against +# the Shopware package.json whose "scripts" block the fixture copies verbatim +# from shopware/shopware trunk (c59a7d3484c): the body as a JSON string, or +# `{}` for a script the package does not define. +# Args: $1 = package, "administration" or "storefront", $2 = script name +# Stdout: the probe answer +shopware_trunk_script_body() { + jq --arg name "$2" 'if has($name) then .[$name] else {} end' \ + "${BATS_TEST_DIRNAME}/fixtures/shopware-trunk/$1-package-scripts.json" +} + +# Executes one command a JS tool built the way exec_npm_command does: +# wrap_npm_command wraps it for the environment, and the result is evaluated +# from the package directory get_js_workdir names under LINT_WORKDIR set to +# BATS_TEST_TMPDIR. docker, vagrant and ddev are functions doing what each does +# with its argv: docker and vagrant hand their string to a shell; `ddev npm` +# passes argv unchanged (its command file sets ExecRaw); `ddev exec -d` joins +# argv, double-quoting an argument holding a space, tab, line break, `"` or +# `#`, and parses the result with `bash -c` again. Under docker-compose the real +# compose module builds the wrapper, with only its prerequisite check and its +# container and workdir resolvers, which query docker compose, replaced. npm is +# a script implementing the two `npm exec` forms as npm 11 runs them: `-c` hands +# the string to sh with the package's node_modules/.bin first on PATH, `--` +# runs that directory's binary. Each binary named in JS_FAKE_BINARIES (default: +# eslint, stylelint and prettier) is installed in the package's node_modules/.bin +# and prints its working directory, one bracketed line per argument, and, for +# an argument holding `*`, one `match:` line per file the pattern names, as +# bash expands it with globstar from that directory. +# Globals: reads JS_CONTEXT, PLUGIN_DIR, JS_FAKE_BINARIES +# Args: $1 = native, docker, docker-compose, vagrant, ddev or ddev-worktree, +# $2 = the command +js_execute_in_env() { + local env="$1" cmd="$2" + + # environment.sh resets LINT_WORKDIR when sourced; a tool call rebinds it + # through worktree_enter, and this helper calls no tool. + LINT_WORKDIR="${BATS_TEST_TMPDIR}" + local package_dir + package_dir=$(get_js_workdir) + local bin_dir="${BATS_TEST_TMPDIR}/fake-bin" + mkdir -p "${package_dir}/node_modules/.bin" "${bin_dir}" + cat > "${bin_dir}/npm" <<'NPM' +#!/usr/bin/env bash +[[ "$1 $2" == "exec --no" ]] || exit 90 +case "$3" in + -c) PATH="${PWD}/node_modules/.bin:${PATH}" exec sh -c "$4" ;; + --) shift 3; bin="$1"; shift; exec "${PWD}/node_modules/.bin/${bin}" "$@" ;; +esac +exit 91 +NPM + chmod +x "${bin_dir}/npm" + + local binary + for binary in ${JS_FAKE_BINARIES-eslint stylelint prettier}; do + cat > "${package_dir}/node_modules/.bin/${binary}" <<'LINTER' +#!/usr/bin/env bash +shopt -s globstar nullglob +printf 'cwd=%s\n' "$(pwd)" +for arg in "$@"; do printf '[%s]\n' "${arg}"; done +for arg in "$@"; do + [[ "${arg}" == *"*"* ]] || continue + eval "matches=( ${arg} )" + for match in "${matches[@]}"; do printf 'match:%s\n' "${match}"; done +done +LINTER + chmod +x "${package_dir}/node_modules/.bin/${binary}" + done + + docker() { bash -c "${!#}"; } + vagrant() { bash -c "$3"; } + ddev() { + if [[ "$1" == "npm" ]]; then + shift + npm "$@" + return + fi + [[ "$1 $2" == "exec -d" ]] || return 92 + local dir="$3" joined="" arg + shift 3 + for arg in "$@"; do + if [[ "${arg}" == *[\"\ $'\t'$'\r'$'\n'\#]* ]]; then + arg="\"${arg//\"/\\\"}\"" + fi + joined="${joined:+${joined} }${arg}" + done + bash -c "cd \"${dir}\" && ( ${joined} )" + } + + LINT_ENV="${env}" + if [[ "${env}" == "docker-compose" ]]; then + # PLUGIN_DIR is set by the test file that loads this helper. + # shellcheck source=/dev/null disable=SC2153 + source "${PLUGIN_DIR}/shared/docker-compose.sh" + _compose_check_prerequisites() { return 0; } + _compose_resolve_container() { printf '%s\n' "shopware-web-1"; } + _compose_resolve_workdir() { printf '%s\n' "${BATS_TEST_TMPDIR}"; } + fi + if [[ "${env}" == "ddev-worktree" ]]; then + LINT_ENV="ddev" + WORKTREE_EFFECTIVE_ROOT="${BATS_TEST_TMPDIR}/linked-worktree" + fi + DOCKER_CONTAINER="web" + local wrapped + wrapped=$(wrap_npm_command "${cmd}") + PATH="${bin_dir}:${PATH}" + cd "${package_dir}" || return 93 + eval "${wrapped}" +} diff --git a/plugin-tests/dev-tooling/worktree_js_tools.bats b/plugin-tests/dev-tooling/worktree_js_tools.bats index ae6ef39a..997a699a 100644 --- a/plugin-tests/dev-tooling/worktree_js_tools.bats +++ b/plugin-tests/dev-tooling/worktree_js_tools.bats @@ -20,8 +20,14 @@ _make_git_worktree_fixture() { 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" + local package binary + for package in src/Administration/Resources/app/administration src/Storefront/Resources/app/storefront; do + mkdir -p "${WORKTREE_ROOT}/${package}/node_modules/.bin" + for binary in eslint stylelint prettier; do + printf '#!/bin/sh\n' > "${WORKTREE_ROOT}/${package}/node_modules/.bin/${binary}" + chmod +x "${WORKTREE_ROOT}/${package}/node_modules/.bin/${binary}" + done + done } setup() { @@ -87,6 +93,10 @@ exec_npm_command() { 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' ;; + # The local-binary probe and the directory test run for real, as sh in + # the directory the command would run in, so they answer about that + # directory's files. + 'npm exec --no -c "test -x '*|'npm exec --no -c "[ -d '*) (cd "${jswd}" && eval "sh -c ${1#npm exec --no -c }") ;; *) printf '%s\n' "$1" ;; esac } @@ -97,9 +107,15 @@ BODY 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}" + # The tool's own output and exit status follow the calls log, after a + # separator line. + { + printf 'tool_code=0\n%s %q > %q || tool_code=$?\n' "${tool_fn}" "${json_args}" "${calls_file}.out" + # shellcheck disable=SC2016 # ${CALLS_FILE} must expand in the generated script when it runs, not here + printf 'cat -- "${CALLS_FILE}"\n' + # shellcheck disable=SC2016 # ${tool_code} must expand in the generated script when it runs, not here + printf 'printf -- "--- tool output, exit %%s\\n" "${tool_code}"\ncat -- %q\n' "${calls_file}.out" + } >> "${script}" run bash "${script}" } @@ -191,3 +207,47 @@ BODY assert_success assert_output --partial "[cwd=${WORKTREE_ROOT}][workdir=${WORKTREE_ROOT}][jsworkdir=${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront]" } + +# --- path-scoped runs of the package's own binary, one per route --- + +# The path-scoped routes run `npm exec` with no npm script in between, so the +# package directory the binary is resolved from is the JS working directory +# itself. Each case pins the run, not only the probe, to the worktree's package. + +@test "admin stylelint_check with paths: runs the local binary in the worktree's package directory" { + _run_js_tool admin mcp-server-js-admin tool_stylelint_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\",\"paths\":[\"src/app/base.scss\"]}" stylelint.sh + assert_success + assert_output --partial "[jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration] npm exec --no -- stylelint --cache" +} + +@test "admin prettier_check with paths: runs the local binary in the worktree's package directory" { + _run_js_tool admin mcp-server-js-admin tool_prettier_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\",\"paths\":[\"src/app/main.ts\"]}" prettier.sh + assert_success + assert_output --partial "[jsworkdir=${WORKTREE_ROOT}/src/Administration/Resources/app/administration] npm exec --no -- prettier --check" +} + +@test "storefront stylelint_check with paths: runs the local binary in the worktree's package directory" { + _run_js_tool storefront mcp-server-js-storefront tool_stylelint_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\",\"paths\":[\"src/scss/base.scss\"]}" stylelint.sh + assert_success + assert_output --partial "[jsworkdir=${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront] npm exec --no -- stylelint --config stylelint.config.js" +} + +@test "storefront eslint_check with components paths: starts in the worktree's package directory" { + _run_js_tool storefront mcp-server-js-storefront tool_eslint_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\",\"paths\":[\"views/components/checkout/cart.js\"]}" eslint.sh + assert_success + assert_output --partial "[jsworkdir=${WORKTREE_ROOT}/src/Storefront/Resources/app/storefront] npm exec --no -c \"cd ../.. && eslint" +} + +@test "admin stylelint_check with paths: refuses when the worktree's package has no stylelint" { + rm "${WORKTREE_ROOT}/src/Administration/Resources/app/administration/node_modules/.bin/stylelint" + _run_js_tool admin mcp-server-js-admin tool_stylelint_check \ + "{\"project_root\":\"${WORKTREE_ROOT}\",\"paths\":[\"src/app/base.scss\"]}" stylelint.sh + assert_success + assert_output --partial "--- tool output, exit 1" + assert_output --partial "\"node_modules/.bin/stylelint\" is not installed in the package directory \"${WORKTREE_ROOT}/src/Administration/Resources/app/administration\"" + refute_output --partial "npm exec --no -- stylelint" +} diff --git a/plugins/dev-tooling/AGENTS.md b/plugins/dev-tooling/AGENTS.md index ce623c85..9d0a3216 100644 --- a/plugins/dev-tooling/AGENTS.md +++ b/plugins/dev-tooling/AGENTS.md @@ -93,7 +93,7 @@ plugins/dev-tooling/ ├── config.json # Server metadata (name="js-storefront-tooling") ├── tools.json # ESLint, Stylelint, Jest, Vitest, ludtwig, Webpack tools └── lib/ - ├── eslint.sh # tool_eslint_check(), tool_eslint_fix() — routes paths to eslint:app / eslint:components + ├── eslint.sh # tool_eslint_check(), tool_eslint_fix() — routes paths to the app tree / the components tree ├── stylelint.sh # tool_stylelint_check(), tool_stylelint_fix() ├── jest.sh # tool_jest_run() — app/storefront package suite only ├── vitest.sh # tool_vitest_run() — views/components component suite @@ -201,15 +201,21 @@ Tools in `tools.json` map to bash functions with `tool_` prefix: # Admin/Storefront servers - hardcoded npm script names from Shopware package.json. # Two routes per tool, selected by whether the caller supplied paths: appending # to the aggregate script only ever widens it (npm appends `--` args to the end -# of the whole script body), so a path-scoped call is routed at a separate -# target-less base script instead, and refuses when that script is unusable. +# of the whole script body), so a path-scoped call runs without it. tool_eslint_check() { local args="$1" # No paths: aggregate script's own targets stay authoritative. local cmd="npm run lint -- ..." # Admin uses "lint", Storefront uses "lint:js" - # Paths supplied: routed at a target-less base script instead, so the - # given paths are the ONLY targets (Admin: "lint:debugging"; Storefront: - # "eslint:app" / "eslint:components", picked per path). + # Paths supplied: the given paths are the ONLY targets. Admin ESLint runs + # the target-less script "lint:debugging" and refuses when it is unusable. + # Admin Stylelint and Prettier and both Storefront linters run the + # package's own binary through `npm exec --no -- `, + # with the flags of the aggregate script's body, and refuse when + # node_modules/.bin/ is missing. The Storefront components tree + # runs `npm exec --no -c "cd ../.. && eslint ..."`, as "lint:js" does. + # Stylelint and Prettier get a directory path as the quoted pattern + # `/**/*.`, so only the extensions the aggregate lints + # are read; under ddev on a worktree they refuse directory and glob paths. exec_npm_command "${cmd}" } ``` @@ -310,8 +316,8 @@ This plugin's own suites are in `plugin-tests/dev-tooling/`: | `mcp_tool_console.bats` | Console tool command construction | | `mcp_tool_ecs.bats` | ECS tool command construction | | `mcp_tool_rector.bats` | Rector tool command construction | -| `mcp_tool_js_admin.bats` | Admin JS MCP tool command construction | -| `mcp_tool_js_storefront.bats` | Storefront JS MCP tool command construction (ESLint routing, Jest, Vitest, ludtwig) | +| `mcp_tool_js_admin.bats` | Admin JS MCP tool command construction, and the Stylelint and Prettier commands and the local-binary probe executed through the native, docker, docker-compose, vagrant and ddev wrappers | +| `mcp_tool_js_storefront.bats` | Storefront JS MCP tool command construction (ESLint routing, Jest, Vitest, ludtwig), and the ESLint and Stylelint commands and the local-binary probe executed through the native, docker, docker-compose, vagrant and ddev wrappers | | `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) | @@ -331,7 +337,7 @@ This plugin's own suites are in `plugin-tests/dev-tooling/`: | `codex_launch.bats` | Launch mode and the bind: a server started in its plugin directory lists tools and refuses every other call, `set_project_root` binds it (exercised under `native`, `docker-compose` and `ddev`) and refuses a bad root, `cwd` reports both states, and a server started in a project directory runs a tool there with no bind | | `codex_manifest.bats` | Codex packaging: `.codex-plugin/plugin.json` version and shared-metadata equality with the Claude Code manifest and a description and keywords free of the LSP, `codex.mcp.json` server launch and forwarded variables, the `.agents/plugins/marketplace.json` entry, and that both directive prompts (Claude Code and Codex) list each server's `tools.json` tools and name a server at every tool mention; the Codex prompt carries no runner-agent or `EnterWorktree`/`ExitWorktree` guidance | -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. +Four 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()`, the probe stubs `stub_worktree_probe()` / `worktree_test_guard_probe()`, `shopware_trunk_script_body()`, which answers an `npm pkg get "scripts."` probe from the trunk fixtures, and `js_execute_in_env()`, which runs a command a JS tool built through `wrap_npm_command` for one environment against fake `npm`, `docker`, `vagrant` and `ddev` and fake linter binaries; `fixtures/coverage/` — Clover XML samples for `mcp_tool_phpunit_coverage.bats`; `fixtures/shopware-trunk/` — the `scripts` blocks of the Administration and Storefront `package.json` files, copied verbatim from shopware/shopware trunk, which the JS tool suites answer script probes from; 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/docs/reference.md b/plugins/dev-tooling/docs/reference.md index 4dc0237c..fb81cb69 100644 --- a/plugins/dev-tooling/docs/reference.md +++ b/plugins/dev-tooling/docs/reference.md @@ -188,7 +188,7 @@ Every path is validated before ESLint runs: it must exist and resolve to at leas ### `stylelint_check` / `stylelint_fix` -Stylelint SCSS linting / auto-fix. Two routes, selected by whether `paths` is supplied: with paths, the run goes to the target-less npm script `stylelint:base` so the given paths are the only targets; without paths, the aggregate `lint:scss` / `lint:scss-fix` script runs (bare when there is nothing else to append) and its own `**/*.scss` target stays authoritative. The aggregate is never a fallback for a path-scoped run — appending a path to a body that already names `**/*.scss` would widen the run to every SCSS file plus the path, never narrow it. That matters most for `stylelint_fix`, which writes: a widened fix would modify files nobody named. A path-scoped run refuses outright when `stylelint:base` is unavailable. +Stylelint SCSS linting / auto-fix. Two routes, selected by whether `paths` is supplied: with paths, the package's own Stylelint binary runs through `npm exec --no -- stylelint --cache`, the flags of the `lint:scss` script without its target, so the given paths are the only targets; without paths, the aggregate `lint:scss` / `lint:scss-fix` script runs (bare when there is nothing else to append) and its own `**/*.scss` target stays authoritative. The aggregate is never a fallback for a path-scoped run — appending a path to a body that already names `**/*.scss` would widen the run to every SCSS file plus the path, never narrow it. That matters most for `stylelint_fix`, which writes: a widened fix would modify files nobody named. A path-scoped run refuses outright when `node_modules/.bin/stylelint` is missing from the package directory: `npm exec --no` installs nothing, but it would run a globally installed `stylelint` instead, so the tool checks for the package's own binary first. With a scope-selected Stylelint config and no `paths`, the run is refused when `lint:scss` / `lint:scss-fix`, or a script it reaches through `npm run`, already passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command: Stylelint refuses a second `--config`. ``` Use js-admin-tooling stylelint_fix with paths ["src/**/*.scss"] @@ -204,11 +204,11 @@ Use js-admin-tooling stylelint_fix with paths ["src/**/*.scss"] 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. +> A literal path is validated before Stylelint runs: a file must carry the `.scss` extension `lint:scss` lints, and a directory must hold at least one `.scss` file, or the path is refused with a message naming it rather than linting nothing and reporting success. A directory, including one whose name ends in `.scss`, reaches Stylelint as the quoted pattern `/**/*.scss`, which Stylelint expands, so only its `.scss` files are linted or fixed — Stylelint given a bare directory would read every file in it. A glob pattern (containing `*`, `?`, or `[`) skips the validation — the existence probe cannot resolve a glob — and reaches Stylelint unchecked for expansion there. Under `ddev` on a linked worktree, directory and glob paths are refused: `ddev exec` re-parses the command in the container's shell, which would expand the pattern itself first; pass file paths there. ### `prettier_check` / `prettier_fix` -Prettier format check / auto-format for Administration. Two routes, selected by whether `paths` is supplied: with paths, the run goes to the target-less npm script `prettier:base`, with the `--check` / `--write` mode flag supplied by the tool, so the given paths are the only targets; without paths, the aggregate `format` / `format:fix` script runs bare (plus a `--config` override when a scope selects one) and its own glob targets stay authoritative. The aggregate is never a fallback for a path-scoped run, for the same reason as ESLint and Stylelint above — appending would widen, not narrow. That matters most for `prettier_fix`, which writes. A path-scoped run refuses outright when `prettier:base` is unavailable. +Prettier format check / auto-format for Administration. Two routes, selected by whether `paths` is supplied: with paths, the package's own Prettier binary runs through `npm exec --no -- prettier` with `--check` (`prettier_check`) or `--write --cache` (`prettier_fix`), the flags of the `format` / `format:fix` scripts without their globs, so the given paths are the only targets; without paths, the aggregate `format` / `format:fix` script runs bare (plus a `--config` override when a scope selects one) and its own glob targets stay authoritative. The aggregate is never a fallback for a path-scoped run, for the same reason as ESLint and Stylelint above — appending would widen, not narrow. That matters most for `prettier_fix`, which writes. A path-scoped run refuses outright when `node_modules/.bin/prettier` is missing from the package directory. ``` Use js-admin-tooling prettier_check with paths ["src/app/component/"] @@ -223,7 +223,7 @@ Use js-admin-tooling prettier_check with paths ["src/app/component/"] 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. +> A literal path is validated before Prettier runs: a file must carry one of the extensions the `format` globs name (`.js`, `.ts`, `.mjs`), and a directory must hold at least one such file, or the path is refused with a message naming it. A directory, including one whose name ends in one of those extensions, reaches Prettier as the quoted pattern `/**/*.{js,ts,mjs}`, which Prettier expands, so only those files are checked or rewritten — Prettier given a bare directory would format every file type it supports in it. A glob pattern (containing `*`, `?`, or `[`) skips the validation and reaches Prettier unchecked for expansion there. Under `ddev` on a linked worktree, directory and glob paths are refused, for the reason given for Stylelint above. ### `jest_run` @@ -261,7 +261,7 @@ Use js-admin-tooling jest_run with coverage true | `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. +`ci` runs Jest in CI mode (`--ci`). `_jest.config.ts`, which the Administration's `jest.config.js` loads, 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` @@ -330,7 +330,7 @@ Runs inside `src/Storefront/Resources/app/storefront`. No context parameter. ### `eslint_check` / `eslint_fix` -ESLint linting / auto-fix for Storefront. The Storefront splits ESLint across two npm scripts — `eslint:app` for the `app/storefront` package and `eslint:components` for the component tree under `src/Storefront/Resources/views/components` — so each supplied path is routed to the tree that owns it and both scripts run when both trees are targeted. A path containing `views/components` goes to the components tree; everything else goes to the app tree. +ESLint linting / auto-fix for Storefront. The Storefront's `lint:js` script runs ESLint twice — once in the `app/storefront` package, and once from `src/Storefront/Resources` with `--config ./app/storefront/eslint.config.js` for the component tree under `views/components` — so each supplied path is routed to the tree that owns it, and both trees run when both are targeted. A path containing `views/components` goes to the components tree; everything else goes to the app tree. With paths, each tree runs the package's own ESLint binary through `npm exec --no` with the flags of its `lint:js` call (`--no-error-on-unmatched-pattern --report-unused-disable-directives`, plus the components config) and the given paths as the only targets; the components call starts with `npm exec --no -c` in the package directory and changes to `../..` before ESLint runs. Under a scope other than `shopware`, a path routed to the components tree is refused before anything runs: that tree and `./app/storefront/eslint.config.js` belong to the core Storefront package, not to the scope's package. App-tree paths under a scope run with the scope's ESLint config. A path-scoped run refuses outright when `node_modules/.bin/eslint` is missing from the directory it runs in. ``` Use js-storefront-tooling eslint_fix with paths ["src/plugin/"] @@ -355,21 +355,21 @@ Every path is validated before ESLint runs. A path that does not exist, or that Omitting `paths` runs the aggregate `lint:js` / `lint:js:fix` script bare across both trees. > [!WARNING] -> `output_format` is ignored on a no-paths `eslint_check`. The no-paths branch runs `npm run lint:js` bare, and that script chains bare `npm run` calls, so an appended reporter flag would never reach ESLint. Pass `paths` when you need `json` output. +> `output_format` is ignored on a no-paths `eslint_check`. The no-paths branch runs `npm run lint:js` bare, because that script chains two ESLint calls and ends in a subshell, so an appended reporter flag would land after the subshell instead of reaching either call. Pass `paths` when you need `json` output. > [!NOTE] > A no-paths run under an active scope that selects its own ESLint config is refused rather than run, because the bare script would silently apply the package's own config instead. Pass `paths`, or call with `scope: "shopware"`. ### `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`), `scope`, and `project_root` (see [🌳 Worktree Support](#-worktree-support)). +Stylelint SCSS linting / auto-fix. Same routing as the Administration versions: with `paths` supplied, the package's own Stylelint binary runs through `npm exec --no -- stylelint --config stylelint.config.js --cache`, the flags of the `lint:scss` script without its target, so the given paths are the only targets. Under a scope, `--config stylelint.config.js` is left out, because that file belongs to the core Storefront package: a scope that selects its own Stylelint config passes that one alone (Stylelint refuses a second `--config`), and without one Stylelint finds the scope package's own config. Without `paths`, the aggregate `lint:scss` / `lint:scss-fix` script runs and its own `./src/scss` target stays authoritative; with a scope-selected Stylelint config, that run is refused when the script, or a script it reaches through `npm run`, already passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command, for the same reason. The aggregate is never a fallback for a path-scoped run, and a path-scoped run refuses outright when `node_modules/.bin/stylelint` is missing from the package directory. 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"] ``` > [!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. A glob pattern (containing `*`, `?`, or `[`) skips that check and reaches Stylelint unchecked for expansion there. +> A literal path is validated before Stylelint runs: a file must carry the `.scss` or `.css` extension, and a directory must hold at least one such file, or the path is refused with a message naming it. A directory, including one whose name ends in `.scss` or `.css`, reaches Stylelint as the quoted pattern `/**/*.{scss,css}`, so only those files are linted or fixed. A glob pattern (containing `*`, `?`, or `[`) skips the validation and reaches Stylelint unchecked for expansion there. Under `ddev` on a linked worktree, directory and glob paths are refused, for the reason given for the Administration versions. ### `jest_run` 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 88480200..87b99dcc 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh @@ -4,8 +4,9 @@ # Note: watch mode is not supported - long-running processes hang MCP servers # # Runs route at the target-less npm script "jest:base". The aggregate "unit" -# script is `npm run jest:base -- --ci`, and jest.config.ts derives -# `isCi` from an exact `--ci` match in process.argv: it then sets +# script is `jest --config jest.config.js --ci`, and _jest.config.ts, which +# jest.config.js loads, derives `isCi` from an exact `--ci` match in +# process.argv: it then sets # `collectCoverage: isCi` and swaps the reporters for jest-silent-reporter plus # jest-junit. Routing at "unit" therefore forces coverage on every run and # suppresses the per-test and summary output the caller asked for, and @@ -142,7 +143,7 @@ _admin_jest_clear_report() { # Read the JSON report back from the environment the run happened in, as its own # wrapped command. Nothing is chained onto it — see _admin_jest_clear_report for # why, and for what deletes the file. -# jest.config.ts prints a console.info banner ahead of everything, so anything +# _jest.config.ts prints a console.info banner ahead of everything, so anything # before the first "{" is dropped. # Args: $1 = report path # Stdout: the report, banner stripped @@ -317,7 +318,7 @@ tool_jest_run() { # than refusing, but the caller has to know which arguments the # fallback overrides. script="${ADMIN_JEST_AGGREGATE_SCRIPT}" - printf '%s\n' "Notice: the npm script \"${ADMIN_JEST_BASE_SCRIPT}\" is unavailable, so this run falls back to \"${ADMIN_JEST_AGGREGATE_SCRIPT}\", whose body hardcodes --ci. Consequences: the \"ci\" argument is ignored and CI mode is forced; coverage is collected regardless of the \"coverage\" argument, because jest.config.ts sets collectCoverage from that same --ci; and the reporters are swapped to jest-silent-reporter plus jest-junit, so the per-test lines and the summary are suppressed. Reason: ${body}" + printf '%s\n' "Notice: the npm script \"${ADMIN_JEST_BASE_SCRIPT}\" is unavailable, so this run falls back to \"${ADMIN_JEST_AGGREGATE_SCRIPT}\", whose body hardcodes --ci. Consequences: the \"ci\" argument is ignored and CI mode is forced; coverage is collected regardless of the \"coverage\" argument, because _jest.config.ts, which jest.config.js loads, sets collectCoverage from that same --ci; and the reporters are swapped to jest-silent-reporter plus jest-junit, so the per-test lines and the summary are suppressed. Reason: ${body}" elif [[ "${ci}" == "true" ]]; then # Only the base script needs this appended; the aggregate already # carries --ci in its body. 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 d19e23b7..09e7e18d 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh @@ -3,9 +3,12 @@ # Provides prettier_check and prettier_fix MCP tools # # Two routes, selected by whether the caller supplied paths: -# paths supplied -> npm script "prettier:base", whose body is a bare -# `prettier`, so the caller's paths are the ONLY targets -# and the mode flag (--check / --write) comes from here. +# paths supplied -> the package's own Prettier binary through +# `npm exec --no -- prettier`, with the flags of the +# aggregate body ("--check", or "--write --cache") but none +# of its globs, so the caller's paths are the ONLY targets. +# A directory path is narrowed to its .js, .ts and .mjs +# files, the extensions those globs name. # paths omitted -> the aggregate script ("format" / "format:fix"), whose # body carries both the mode flag and its own glob # targets, which stay authoritative. @@ -15,12 +18,15 @@ # globs PLUS the path — it can never narrow it. That matters most for # prettier_fix, which writes: a widened fix reformats files nobody named. -ADMIN_PRETTIER_BASE_SCRIPT="prettier:base" +ADMIN_PRETTIER_BINARY="prettier" # Extensions Prettier is pointed at here, read off the globs in the `format` -# script body: src/**/*.{js,ts}, test/**/*.{js,ts}, -# build/vite-plugins/**/*.{js,ts}, build/plugins.vite.ts and build.ts. A path -# that resolves to no file with one of them formats nothing and still exits 0. -ADMIN_PRETTIER_EXTENSIONS="js ts" +# and `format:fix` script bodies: `{js,ts}` and `ts` under src, test, scripts +# and build, `extension-tooling/**/*.mjs`, build/plugins.vite.ts and build.ts. +# A path that resolves to no file with one of them formats nothing and still +# exits 0. +ADMIN_PRETTIER_EXTENSIONS="js ts mjs" +# What a directory path is narrowed to: the extensions above, below it. +ADMIN_PRETTIER_DIRECTORY_GLOB="**/*.{js,ts,mjs}" # True when the path is a glob pattern Prettier expands itself rather than a # literal filesystem path. The existence probe cannot resolve a glob, so glob @@ -35,16 +41,130 @@ if ! declare -F _prettier_admin_path_is_glob >/dev/null; then } fi +# Print, one per line, the caller's literal paths that name directories. A +# literal path without an accepted extension has passed the lintable probe only +# as a directory holding such a file. One carrying an accepted extension can +# still be a directory, so those are tested where the command runs, through +# `npm exec -c` like the binary probe. +# Args: $1.. = literal paths that passed the lintable probe +# Stdout: the directories, or the failure message +# Returns: 0 on success, 1 when the test could not run +_prettier_admin_directories() { + local -a extensions=() + read -r -a extensions <<< "${ADMIN_PRETTIER_EXTENSIONS}" + + local -a directories=() + local p ext named script="" + for p in "$@"; do + named=0 + for ext in "${extensions[@]}"; do + [[ "${p}" == *".${ext}" ]] && named=1 + done + if [[ "${named}" -eq 0 ]]; then + directories+=("${p}") + else + script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " + fi + done + + if [[ -n "${script}" ]]; then + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? + if [[ "${probe_code}" -ne 0 ]]; then + printf '%s\n' "Refusing to run with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 + fi + local line + while IFS= read -r line; do + [[ "${line}" == DIRECTORY:* ]] && directories+=("${line#DIRECTORY:}") + done <<< "${output}" + fi + + if [[ ${#directories[@]} -gt 0 ]]; then + printf '%s\n' "${directories[@]}" + fi +} + +# The target a directory reaches Prettier as. Prettier given a directory +# formats every file type it supports under it, so a directory becomes a glob +# limited to the accepted extensions, which Prettier expands itself because the +# caller's quoting keeps it away from the shell. +# Args: $1 = directory path +# Stdout: the pattern +_prettier_admin_directory_pattern() { + local path="$1" + + while [[ "${path}" == */ && "${path}" != "/" ]]; do + path="${path%/}" + done + printf '%s\n' "${path}/${ADMIN_PRETTIER_DIRECTORY_GLOB}" +} + +# Under ddev on a worktree the command reaches `ddev exec`, which re-parses it +# in the container's bash without quoting a value that holds no space, quote +# or "#", so that bash would expand a glob pattern itself, without globstar, +# before Prettier sees it. A run there takes file paths only. +# Args: $1 = tool name, $2.. = the caller's glob patterns and directories +# Stdout: the refusal +# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise +_prettier_admin_assert_no_pattern_under_ddev_worktree() { + local tool_name="$1" + shift + + if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then + return 0 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach Prettier as glob patterns the container shell expands first: $*. Pass file paths instead." + return 1 +} + +# Refuse a path-scoped run when the package's own Prettier binary is not +# installed where the command runs. `npm exec --no` never installs, but on a +# miss it still runs a globally installed binary of the same name, so the probe +# runs first and the run never reaches a binary the package did not pin. +# Args: $1 = tool name, $2 = aggregate npm script, named in the refusal +# Stdout: the refusal, when the binary is missing +# Returns: 0 when node_modules/.bin/prettier is executable, 1 otherwise +_prettier_admin_assert_local_binary() { + local tool_name="$1" + local aggregate="$2" + local bin_path="node_modules/.bin/${ADMIN_PRETTIER_BINARY}" + + # On a miss the probe prints a marker and the directory it ran in, which + # under a container environment is the container's path, and exits 3; any + # other non-zero exit is the probe itself failing. + local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? + if [[ "${probe_code}" -eq 0 ]]; then + return 0 + fi + + if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then + local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" + package_dir="${package_dir%%$'\n'*}" + printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${ADMIN_PRETTIER_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own glob targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." + return 1 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${ADMIN_PRETTIER_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 +} + # Run one Prettier invocation for this tool. # Args: $1 = aggregate npm script used when no paths are supplied, # $2 = tool name, for the refusal message, -# $3 = mode flag for the path-scoped route (--check or --write), +# $3 = flags for the path-scoped route: the aggregate body's flags +# without its globs ("--check", or "--write --cache"), # $4 = tool arguments JSON # Stdout: Prettier output, or the failure message _prettier_dispatch_admin() { local aggregate="$1" local tool_name="$2" - local mode_flag="$3" + local mode_flags="$3" local args="$4" local scoped_config @@ -93,12 +213,7 @@ _prettier_dispatch_admin() { fi done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') - body=$(npm_script_append_gate "${ADMIN_PRETTIER_BASE_SCRIPT}") || gate_code=$? - if [[ "${gate_code}" -ne 0 ]]; then - printf '%s\n' "${body}" - printf '%s\n' "Refusing to run ${tool_name} with paths: a path-scoped run needs the target-less npm script \"${ADMIN_PRETTIER_BASE_SCRIPT}\", which is unusable here. The aggregate \"${aggregate}\" script is not a substitute — its body names its own glob targets and npm appends arguments after them, so it would cover that whole target set on top of the requested paths rather than only the requested paths." - return 1 - fi + _prettier_admin_assert_local_binary "${tool_name}" "${aggregate}" || return 1 if [[ ${#literals[@]} -gt 0 ]]; then local missing_report @@ -108,18 +223,45 @@ _prettier_dispatch_admin() { fi fi + local -a directories=() + if [[ ${#literals[@]} -gt 0 ]]; then + local directory_report + if ! directory_report=$(_prettier_admin_directories "${literals[@]}"); then + printf '%s\n' "${directory_report}" + return 1 + fi + while IFS= read -r p; do + [[ -n "${p}" ]] && directories+=("${p}") + done <<< "${directory_report}" + fi + + local -a patterns=() + for p in "${supplied[@]}"; do + _prettier_admin_path_is_glob "${p}" && patterns+=("${p}") + done + _prettier_admin_assert_no_pattern_under_ddev_worktree "${tool_name}" \ + "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 + local -a quoted=() + local target directory for p in "${supplied[@]}"; do - quoted+=("$(shell_quote_arg "${p}")") + target="${p}" + for directory in "${directories[@]+"${directories[@]}"}"; do + if [[ "${directory}" == "${p}" ]]; then + target=$(_prettier_admin_directory_pattern "${p}") + break + fi + done + quoted+=("$(shell_quote_arg "${target}")") done - local cmd="npm run ${ADMIN_PRETTIER_BASE_SCRIPT} -- ${mode_flag}" + local cmd="npm exec --no -- ${ADMIN_PRETTIER_BINARY} ${mode_flags}" if [[ -n "${scoped_config}" ]]; then cmd="${cmd} --config ${scoped_config}" fi cmd="${cmd} ${quoted[*]}" - log "INFO" "Running Prettier (admin, ${ADMIN_PRETTIER_BASE_SCRIPT}): ${cmd}" + log "INFO" "Running Prettier (admin, npm exec): ${cmd}" exec_npm_command "${cmd}" } @@ -159,5 +301,6 @@ tool_prettier_fix() { worktree_assert_dependencies || return 1 - _prettier_dispatch_admin "format:fix" "prettier_fix" "--write" "${args}" + # "--cache" is in the "format:fix" body; the "format" body has none. + _prettier_dispatch_admin "format:fix" "prettier_fix" "--write --cache" "${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 7772cf7c..d90c233a 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh @@ -3,9 +3,11 @@ # Provides stylelint_check and stylelint_fix MCP tools # # Two routes, selected by whether the caller supplied paths: -# paths supplied -> npm script "stylelint:base", whose body is a target-less -# `stylelint --cache`, so the caller's paths are the ONLY -# targets. +# paths supplied -> the package's own Stylelint binary through +# `npm exec --no -- stylelint --cache`, the flags of the +# "lint:scss" body without its `**/*.scss` target, so the +# caller's paths are the ONLY targets. A directory path is +# narrowed to its .scss files, as that glob is. # paths omitted -> the aggregate script ("lint:scss" / "lint:scss-fix"), # whose body carries its own `**/*.scss` target, which # stays authoritative. @@ -17,14 +19,15 @@ # # The two routes also differ in where `--fix` comes from. "lint:scss-fix" # carries `--fix` in its own body, so the no-paths route must not add a second -# one; "stylelint:base" carries none, so the path-scoped route must add it or -# stylelint_fix silently runs as a check and writes nothing. +# one; the path-scoped command carries none, so the path-scoped route must add +# it or stylelint_fix silently runs as a check and writes nothing. -ADMIN_STYLELINT_BASE_SCRIPT="stylelint:base" -# Extensions Stylelint reads here. Neither .stylelintrc nor the CLI restricts -# the set, so this is the pair the SCSS rulesets actually parse. A path that -# resolves to no file with one of them lints nothing and still exits 0. -ADMIN_STYLELINT_EXTENSIONS="scss css" +ADMIN_STYLELINT_BINARY="stylelint" +# Extensions a path-scoped run lints: the one the "lint:scss" glob `**/*.scss` +# names. A file path with another extension is refused, and a directory is +# narrowed to ADMIN_STYLELINT_DIRECTORY_GLOB below it. +ADMIN_STYLELINT_EXTENSIONS="scss" +ADMIN_STYLELINT_DIRECTORY_GLOB="**/*.scss" # True when the path is a glob pattern Stylelint expands itself rather than a # literal filesystem path. The existence probe cannot resolve a glob, so glob @@ -41,13 +44,221 @@ if ! declare -F _stylelint_admin_path_is_glob >/dev/null; then } fi +# Refuse a path-scoped run when the package's own Stylelint binary is not +# installed where the command runs. `npm exec --no` never installs, but on a +# miss it still runs a globally installed binary of the same name, so the probe +# runs first and the run never reaches a binary the package did not pin. +# Args: $1 = tool name, $2 = aggregate npm script, named in the refusal +# Stdout: the refusal, when the binary is missing +# Returns: 0 when node_modules/.bin/stylelint is executable, 1 otherwise +_stylelint_admin_assert_local_binary() { + local tool_name="$1" + local aggregate="$2" + local bin_path="node_modules/.bin/${ADMIN_STYLELINT_BINARY}" + + # On a miss the probe prints a marker and the directory it ran in, which + # under a container environment is the container's path, and exits 3; any + # other non-zero exit is the probe itself failing. + local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? + if [[ "${probe_code}" -eq 0 ]]; then + return 0 + fi + + if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then + local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" + package_dir="${package_dir%%$'\n'*}" + printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${ADMIN_STYLELINT_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." + return 1 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${ADMIN_STYLELINT_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 +} + +# True when a command in an npm script body runs Stylelint with a config flag +# of its own: --config, --config=, -c or -c=, counted only after the stylelint +# token of the same command, so another program's -c in the same script is not +# taken for one. Stylelint refuses a second config flag. The Administration's +# Stylelint 14 rejects -c and -c= outright, so a script using either fails on +# its own; counting them refuses that script earlier, with a clearer message. +# Args: $1 = script body +# Returns: 0 when a Stylelint command in the body passes a config, 1 otherwise +_stylelint_admin_body_passes_config() { + local body="$1" + + body="${body//&&/;}" + body="${body//||/;}" + body="${body//|/;}" + + local -a segments=() + IFS=';' read -r -a segments <<< "${body}" + + local segment args + for segment in "${segments[@]+"${segments[@]}"}"; do + segment=" ${segment} " + case "${segment}" in + *" stylelint "*) args=" ${segment#* stylelint }" ;; + *"/stylelint "*) args=" ${segment#*/stylelint }" ;; + *) continue ;; + esac + case "${args}" in + *" --config "*|*" --config="*|*" -c "*|*" -c="*) return 0 ;; + esac + done + + return 1 +} + +# Print, one per line, the caller's literal paths that name directories. A +# literal path without an accepted extension has passed the lintable probe only +# as a directory holding such a file. One carrying an accepted extension can +# still be a directory, so those are tested where the command runs, through +# `npm exec -c` like the binary probe. +# Args: $1.. = literal paths that passed the lintable probe +# Stdout: the directories, or the failure message +# Returns: 0 on success, 1 when the test could not run +_stylelint_admin_directories() { + local -a extensions=() + read -r -a extensions <<< "${ADMIN_STYLELINT_EXTENSIONS}" + + local -a directories=() + local p ext named script="" + for p in "$@"; do + named=0 + for ext in "${extensions[@]}"; do + [[ "${p}" == *".${ext}" ]] && named=1 + done + if [[ "${named}" -eq 0 ]]; then + directories+=("${p}") + else + script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " + fi + done + + if [[ -n "${script}" ]]; then + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? + if [[ "${probe_code}" -ne 0 ]]; then + printf '%s\n' "Refusing to run with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 + fi + local line + while IFS= read -r line; do + [[ "${line}" == DIRECTORY:* ]] && directories+=("${line#DIRECTORY:}") + done <<< "${output}" + fi + + if [[ ${#directories[@]} -gt 0 ]]; then + printf '%s\n' "${directories[@]}" + fi +} + +# The target a directory reaches Stylelint as. Stylelint given a directory +# reads every file under it, so a directory becomes a glob limited to the +# accepted extensions, which Stylelint expands itself because the caller's +# quoting keeps it away from the shell. +# Args: $1 = directory path +# Stdout: the pattern +_stylelint_admin_directory_pattern() { + local path="$1" + + while [[ "${path}" == */ && "${path}" != "/" ]]; do + path="${path%/}" + done + printf '%s\n' "${path}/${ADMIN_STYLELINT_DIRECTORY_GLOB}" +} + +# Under ddev on a worktree the command reaches `ddev exec`, which re-parses it +# in the container's bash without quoting a value that holds no space, quote +# or "#", so that bash would expand a glob pattern itself, without globstar, +# before Stylelint sees it. A run there takes file paths only. +# Args: $1 = tool name, $2.. = the caller's glob patterns and directories +# Stdout: the refusal +# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise +_stylelint_admin_assert_no_pattern_under_ddev_worktree() { + local tool_name="$1" + shift + + if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then + return 0 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach Stylelint as glob patterns the container shell expands first: $*. Pass file paths instead." + return 1 +} + +# True when an npm script hands Stylelint a config of its own, directly or +# through an `npm run` of another script in its body. Stylelint refuses a second +# --config ("The flag --config can only be set once."), so a scope config cannot +# be appended to such a script. +# Args: $1 = npm script name, $2 = how many nested `npm run` levels to follow +# Stdout: a failure message, when a body cannot be read or the chain is deeper +# Returns: 0 = passes a config, 1 = passes none, 2 = a body could not be read, +# or the chain runs deeper than $2 levels +_stylelint_admin_script_passes_config() { + local script="$1" + local depth="$2" + + local body + local body_code=0 + body=$(npm_script_body "${script}") || body_code=$? + if [[ "${body_code}" -eq 1 ]]; then + printf '%s\n' "npm script \"${script}\" is not defined in package.json." + return 2 + fi + if [[ "${body_code}" -ne 0 ]]; then + printf '%s\n' "Could not read npm script \"${script}\" from package.json to check it for its own Stylelint config; see the server log for the failed probe." + return 2 + fi + + if _stylelint_admin_body_passes_config "${body}"; then + return 0 + fi + + local padded=" ${body} " + + if [[ "${padded}" != *" npm run "* ]]; then + return 1 + fi + + # A chain longer than this is not read to its end, so whether it passes a + # config is unknown; that is refused rather than taken as "none". + if [[ "${depth}" -le 0 ]]; then + printf '%s\n' "npm script \"${script}\" runs further npm scripts nested deeper than this tool follows, so it cannot be checked for its own Stylelint config." + return 2 + fi + + local rest="${padded}" + local inner nested_code + while [[ "${rest}" == *" npm run "* ]]; do + rest="${rest#* npm run }" + # Options such as --silent precede the script name. + while [[ "${rest}" == -* ]]; do + rest="${rest#* }" + done + inner="${rest%% *}" + nested_code=0 + _stylelint_admin_script_passes_config "${inner}" "$((depth - 1))" || nested_code=$? + if [[ "${nested_code}" -ne 1 ]]; then + return "${nested_code}" + fi + done + + return 1 +} + # Run one Stylelint invocation for this tool. # Args: $1 = aggregate npm script used when no paths are supplied, # $2 = tool name, for the refusal message, # $3 = pre-path flag string used on both routes (may be empty), # $4 = pre-path flag string used ONLY on the path-scoped route, for flags # the aggregate script already carries in its own body (may be empty), -# $5 = tool arguments JSON +# $5 = tool arguments JSON, +# $6 = scope-configured Stylelint config, already part of $3 (may be empty) # Stdout: linter output, or the failure message _stylelint_dispatch_admin() { local aggregate="$1" @@ -55,6 +266,7 @@ _stylelint_dispatch_admin() { local flag_string="$3" local path_only_flags="$4" local args="$5" + local scoped_config="${6:-}" local paths_json paths paths_json=$(echo "${args}" | jq -c '.paths // []') @@ -84,6 +296,20 @@ _stylelint_dispatch_admin() { return 1 fi + if [[ -n "${scoped_config}" ]]; then + local config_check + local config_code=0 + config_check=$(_stylelint_admin_script_passes_config "${aggregate}" 3) || config_code=$? + if [[ "${config_code}" -eq 0 ]]; then + printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the Stylelint config \"${scoped_config}\", but \"npm run ${aggregate}\" already passes a config of its own, and Stylelint refuses a second --config. Pass paths so the run uses the scope config alone, or call with scope \"shopware\"." + return 1 + fi + if [[ "${config_code}" -ne 1 ]]; then + printf '%s\n' "${config_check}" + return 1 + fi + fi + local aggregate_cmd="npm run ${aggregate} -- ${flag_string}" log "INFO" "Running Stylelint (admin, ${aggregate}): ${aggregate_cmd}" exec_npm_command "${aggregate_cmd}" @@ -101,12 +327,7 @@ _stylelint_dispatch_admin() { fi done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') - body=$(npm_script_append_gate "${ADMIN_STYLELINT_BASE_SCRIPT}") || gate_code=$? - if [[ "${gate_code}" -ne 0 ]]; then - printf '%s\n' "${body}" - printf '%s\n' "Refusing to run ${tool_name} with paths: a path-scoped run needs the target-less npm script \"${ADMIN_STYLELINT_BASE_SCRIPT}\", which is unusable here. The aggregate \"${aggregate}\" script is not a substitute — its body names its own targets and npm appends arguments after them, so it would cover that whole target set on top of the requested paths rather than only the requested paths." - return 1 - fi + _stylelint_admin_assert_local_binary "${tool_name}" "${aggregate}" || return 1 if [[ ${#literals[@]} -gt 0 ]]; then local missing_report @@ -116,29 +337,51 @@ _stylelint_dispatch_admin() { fi fi + local -a directories=() + if [[ ${#literals[@]} -gt 0 ]]; then + local directory_report + if ! directory_report=$(_stylelint_admin_directories "${literals[@]}"); then + printf '%s\n' "${directory_report}" + return 1 + fi + while IFS= read -r p; do + [[ -n "${p}" ]] && directories+=("${p}") + done <<< "${directory_report}" + fi + + local -a patterns=() + for p in "${supplied[@]}"; do + _stylelint_admin_path_is_glob "${p}" && patterns+=("${p}") + done + _stylelint_admin_assert_no_pattern_under_ddev_worktree "${tool_name}" \ + "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 + local -a quoted=() + local target directory for p in "${supplied[@]}"; do - quoted+=("$(shell_quote_arg "${p}")") + target="${p}" + for directory in "${directories[@]+"${directories[@]}"}"; do + if [[ "${directory}" == "${p}" ]]; then + target=$(_stylelint_admin_directory_pattern "${p}") + break + fi + done + quoted+=("$(shell_quote_arg "${target}")") done - # The base script's body carries no mode flag of its own, so anything the - # aggregate would have supplied has to be added here instead. - local path_flags="${flag_string}" - if [[ -n "${path_only_flags}" ]]; then - if [[ -n "${path_flags}" ]]; then - path_flags="${path_flags} ${path_only_flags}" - else - path_flags="${path_only_flags}" - fi + # `--cache` is the flag the "lint:scss" body passes besides its target. + # Anything the aggregate fix script would have supplied has to be added + # here as well. + local cmd="npm exec --no -- ${ADMIN_STYLELINT_BINARY} --cache" + if [[ -n "${flag_string}" ]]; then + cmd="${cmd} ${flag_string}" fi - - local cmd="npm run ${ADMIN_STYLELINT_BASE_SCRIPT} --" - if [[ -n "${path_flags}" ]]; then - cmd="${cmd} ${path_flags}" + if [[ -n "${path_only_flags}" ]]; then + cmd="${cmd} ${path_only_flags}" fi cmd="${cmd} ${quoted[*]}" - log "INFO" "Running Stylelint (admin, ${ADMIN_STYLELINT_BASE_SCRIPT}): ${cmd}" + log "INFO" "Running Stylelint (admin, npm exec): ${cmd}" exec_npm_command "${cmd}" } @@ -176,7 +419,7 @@ tool_stylelint_check() { [[ -n "${scoped_config}" ]] && flags+=("--config" "${scoped_config}") - _stylelint_dispatch_admin "lint:scss" "stylelint_check" "${flags[*]}" "" "${args}" + _stylelint_dispatch_admin "lint:scss" "stylelint_check" "${flags[*]}" "" "${args}" "${scoped_config}" } # Stylelint fix (auto-fix violations) @@ -210,5 +453,5 @@ tool_stylelint_fix() { fi # "--fix" is path-route only: the "lint:scss-fix" body already carries one. - _stylelint_dispatch_admin "lint:scss-fix" "stylelint_fix" "${flag_string}" "--fix" "${args}" + _stylelint_dispatch_admin "lint:scss-fix" "stylelint_fix" "${flag_string}" "--fix" "${args}" "${scoped_config}" } 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 bd55277f..40a02d97 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh @@ -2,17 +2,39 @@ # ESLint tool implementation for Storefront Tooling MCP Server # Provides eslint_check and eslint_fix MCP tools # -# The Storefront splits ESLint across two trees: -# app tree -> npm script "eslint:app", paths relative to the package -# directory src/Storefront/Resources/app/storefront -# components tree -> npm script "eslint:components", which runs after +# The Storefront's "lint:js" script lints two trees with two ESLint calls: +# app tree -> `eslint --no-error-on-unmatched-pattern +# --report-unused-disable-directives` in the package +# directory src/Storefront/Resources/app/storefront, so +# paths are relative to it +# components tree -> the same flags plus `--config +# ./app/storefront/eslint.config.js`, run after # `cd ../..`, so paths are relative to # src/Storefront/Resources # A run without paths executes the aggregate (lint:js / lint:js:fix), whose own -# targets stay authoritative. A run with paths never reaches the aggregate: on a -# version predating the split, or with either base script otherwise unusable, -# the path-scoped run fails rather than widening to the aggregate's targets. - +# targets stay authoritative. A run with paths never reaches the aggregate: it +# runs those two calls without their targets through the package's own ESLint +# binary (`npm exec --no`), so the caller's paths are the ONLY targets, and it +# fails rather than widening to the aggregate's targets when that binary is not +# installed. +# +# The components call has to start in ../..: ESLint reports a file outside its +# base path as "File ignored because outside of base path" and still exits 0, +# and with an explicit --config the base path is the working directory. It +# still has to start through npm in the package directory: ../.. holds no +# package.json, so `npm exec` started there would resolve its binary from +# another directory, and wrap_npm_command's ddev branch hands a command to +# `ddev npm` only when it starts with npm; the host shell splits any other +# compound command at its first `&&`, so only the cd would reach the container. +# `npm exec -c` runs its string through a shell in the package directory with +# the package's node_modules/.bin first on PATH, and the string does the cd. + +STOREFRONT_ESLINT_BINARY="eslint" +# Flags both "lint:js" ESLint calls pass besides their targets. +STOREFRONT_ESLINT_FLAGS="--no-error-on-unmatched-pattern --report-unused-disable-directives" +# The config the components call names, relative to its ../.. working +# directory. +STOREFRONT_ESLINT_COMPONENTS_CONFIG="./app/storefront/eslint.config.js" STOREFRONT_ESLINT_APP_BASE="." STOREFRONT_ESLINT_COMPONENTS_BASE="../.." # Extensions the Storefront ESLint config can match. A path that resolves to no @@ -45,33 +67,54 @@ _eslint_rebase_app_path() { printf '%s\n' "${path}" } +# Refuse a path-scoped run when the package's own ESLint binary is not +# installed where the command runs. `npm exec --no` never installs, but on a +# miss it still runs a globally installed binary of the same name, so the probe +# runs first and the run never reaches a binary the package did not pin. Both +# trees resolve the binary from the package directory, so one probe covers +# both. +# Args: $1 = aggregate npm script, named in the refusal +# Stdout: the refusal, when the binary is missing +# Returns: 0 when node_modules/.bin/eslint is executable, 1 otherwise +_eslint_assert_local_binary() { + local aggregate="$1" + local bin_path="node_modules/.bin/${STOREFRONT_ESLINT_BINARY}" + + # On a miss the probe prints a marker and the directory it ran in, which + # under a container environment is the container's path, and exits 3; any + # other non-zero exit is the probe itself failing. + local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? + if [[ "${probe_code}" -eq 0 ]]; then + return 0 + fi + + if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then + local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" + package_dir="${package_dir%%$'\n'*}" + printf '%s\n' "Refusing to lint with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${STOREFRONT_ESLINT_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." + return 1 + fi + + printf '%s\n' "Refusing to lint with paths: could not check whether the package's own ${STOREFRONT_ESLINT_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 +} + # Run one tree's ESLint invocation. Reached only when the caller supplied # paths, so the aggregate is never a fallback here: its body names its own # targets, and npm appends `--` arguments after them, so substituting it would # widen the run instead of narrowing it to the requested paths. -# Args: $1 = target-less npm script for this tree, -# $2 = aggregate npm script, named in the refusal, -# $3 = tree name, for the refusal message, -# $4 = base directory for the existence check, -# $5 = pre-path flag string (may be empty), $6.. = rebased paths +# Args: $1 = tree name, "app" or "components", +# $2 = base directory for the existence check, +# $3 = pre-path flag string (may be empty), $4.. = rebased paths # Stdout: linter output, or the failure message _eslint_invoke_tree() { - local script="$1" - local aggregate="$2" - local tree="$3" - local base_dir="$4" - local flag_string="$5" - shift 5 - - local body - local exit_code=0 - body=$(npm_script_append_gate "${script}") || exit_code=$? - - if [[ "${exit_code}" -ne 0 ]]; then - printf '%s\n' "${body}" - printf '%s\n' "Refusing to lint the ${tree} tree with paths: a path-scoped run needs the target-less npm script \"${script}\", which is unusable here. The aggregate \"${aggregate}\" script is not a substitute — its body names its own targets and npm appends arguments after them, so it would cover that whole target set on top of the requested paths rather than only the requested paths." - return 1 - fi + local tree="$1" + local base_dir="$2" + local flag_string="$3" + shift 3 local missing_report if ! missing_report=$(assert_paths_lintable "${base_dir}" "${STOREFRONT_ESLINT_EXTENSIONS}" "$@"); then @@ -85,19 +128,33 @@ _eslint_invoke_tree() { quoted+=("$(shell_quote_arg "${p}")") done - local cmd="npm run ${script} --" + local eslint_cmd="${STOREFRONT_ESLINT_BINARY} ${STOREFRONT_ESLINT_FLAGS}" + # _eslint_dispatch refuses the components tree under a scope, so no scope + # config reaches this call. + if [[ "${tree}" == "components" ]]; then + eslint_cmd="${eslint_cmd} --config ${STOREFRONT_ESLINT_COMPONENTS_CONFIG}" + fi if [[ -n "${flag_string}" ]]; then - cmd="${cmd} ${flag_string}" + eslint_cmd="${eslint_cmd} ${flag_string}" + fi + eslint_cmd="${eslint_cmd} ${quoted[*]}" + + local cmd + if [[ "${tree}" == "components" ]]; then + # The inner string is quoted once more for the shell that parses this + # command; npm's own shell then parses it, including each path's quotes. + cmd="npm exec --no -c $(shell_quote_arg "cd ${STOREFRONT_ESLINT_COMPONENTS_BASE} && ${eslint_cmd}")" + else + cmd="npm exec --no -- ${eslint_cmd}" fi - cmd="${cmd} ${quoted[*]}" - log "INFO" "Running ESLint (storefront, ${script}): ${cmd}" + log "INFO" "Running ESLint (storefront, ${tree} tree): ${cmd}" exec_npm_command "${cmd}" } # Route the caller's paths onto the two trees and run each one that is used. -# Args: $1 = tool arguments JSON, $2 = fallback npm script, +# Args: $1 = tool arguments JSON, $2 = aggregate npm script, # $3 = pre-path flag string (may be empty), $4 = scope-configured ESLint # config (may be empty) _eslint_dispatch() { @@ -121,12 +178,13 @@ _eslint_dispatch() { 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 - # parser and never reaches ESLint — npm_script_append_gate refuses it. + # two ESLint calls and ends in a subshell, so an appended flag would + # land after that subshell, outside both calls — npm_script_append_gate + # refuses it. # Running bare anyway would silently substitute the package script's own # configuration for the scope-selected one. if [[ -n "${scoped_config}" ]]; then - printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the ESLint config \"${scoped_config}\", but a run without paths can only execute \"npm run ${fallback}\" bare, which would apply the package script's own config instead. \"${fallback}\" chains bare run-scripts, so the config cannot be appended to it either. Pass paths so the run is routed to eslint:app / eslint:components, or call with scope \"shopware\"." + printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the ESLint config \"${scoped_config}\", but a run without paths can only execute \"npm run ${fallback}\" bare, which would apply the package script's own config instead. \"${fallback}\" chains two ESLint calls, so the config cannot be appended to it either. Pass paths so the run executes ESLint on them directly, or call with scope \"shopware\"." return 1 fi local bare_cmd="npm run ${fallback}" @@ -147,14 +205,25 @@ _eslint_dispatch() { fi done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') + # The components call is the core Storefront's own: it changes to ../.. and + # names ./app/storefront/eslint.config.js, which exist only relative to + # src/Storefront/Resources/app/storefront. Under a scope the working + # directory is the scope's package instead, where both name something else. + if [[ ${#component_paths[@]} -gt 0 && -n "${SCOPE_CWD:-}" ]]; then + printf '%s\n' "Refusing to lint with paths: scope \"${SCOPE_NAME}\" runs in \"${SCOPE_CWD}\", but these paths route to the core Storefront components tree, which is linted from src/Storefront/Resources with ./app/storefront/eslint.config.js: ${component_paths[*]}. Lint them with scope \"shopware\"." + return 1 + fi + + _eslint_assert_local_binary "${fallback}" || return 1 + local overall=0 local tree_output local tree_code if [[ ${#app_paths[@]} -gt 0 ]]; then tree_code=0 - tree_output=$(_eslint_invoke_tree "eslint:app" "${fallback}" "app" \ - "${STOREFRONT_ESLINT_APP_BASE}" "${flag_string}" "${app_paths[@]}") || tree_code=$? + tree_output=$(_eslint_invoke_tree "app" "${STOREFRONT_ESLINT_APP_BASE}" \ + "${flag_string}" "${app_paths[@]}") || tree_code=$? printf '%s\n' "${tree_output}" if [[ "${tree_code}" -ne 0 ]]; then overall="${tree_code}" @@ -163,8 +232,8 @@ _eslint_dispatch() { if [[ ${#component_paths[@]} -gt 0 ]]; then tree_code=0 - tree_output=$(_eslint_invoke_tree "eslint:components" "${fallback}" "components" \ - "${STOREFRONT_ESLINT_COMPONENTS_BASE}" "${flag_string}" "${component_paths[@]}") || tree_code=$? + tree_output=$(_eslint_invoke_tree "components" "${STOREFRONT_ESLINT_COMPONENTS_BASE}" \ + "${flag_string}" "${component_paths[@]}") || tree_code=$? printf '%s\n' "${tree_output}" if [[ "${tree_code}" -ne 0 ]]; then overall="${tree_code}" 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 a4899095..bbf51898 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/jest.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/jest.sh @@ -4,8 +4,8 @@ # Note: watch mode is not supported - long-running processes hang MCP servers # # Runs route at the target-less npm script "jest:base". The aggregate "unit" -# script is `npm run jest:base -- --ci`, and appending `--ci=false` does not -# undo that because the literal `--ci` is still in argv. +# script is `jest --config jest.config.js --ci`, and appending `--ci=false` +# does not undo that because the literal `--ci` is still in argv. # What CI mode changes here is snapshot writing. jest-config resolves the # snapshot mode as `ci && !updateSnapshot ? 'none' : updateSnapshot ? 'all' # : 'new'`, so `--ci` alone downgrades the default 'new' (write missing 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 690ab422..f5ca30ba 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh @@ -3,9 +3,15 @@ # Provides stylelint_check and stylelint_fix MCP tools # # Two routes, selected by whether the caller supplied paths: -# paths supplied -> npm script "stylelint:app", whose body is a target-less -# `stylelint --config stylelint.config.js --cache`, so the -# caller's paths are the ONLY targets. +# paths supplied -> the package's own Stylelint binary through +# `npm exec --no -- stylelint --config stylelint.config.js +# --cache`, the flags of the "lint:scss" body without its +# `./src/scss` target, so the caller's paths are the ONLY +# targets. Under a scope that `--config` is left out: a +# scope-configured config takes its place (Stylelint +# refuses a second `--config` flag outright), and without +# one Stylelint finds the scope package's own config. +# A directory path is narrowed to its .scss and .css files. # paths omitted -> the aggregate script ("lint:scss" / "lint:scss-fix"), # whose body carries its own `./src/scss` target, which # stays authoritative. @@ -17,15 +23,19 @@ # # The two routes also differ in where `--fix` comes from. "lint:scss-fix" # carries `--fix` in its own body, so the no-paths route must not add a second -# one; "stylelint:app" carries none, so the path-scoped route must add it or -# stylelint_fix silently runs as a check and writes nothing. +# one; the path-scoped command carries none, so the path-scoped route must add +# it or stylelint_fix silently runs as a check and writes nothing. -STOREFRONT_STYLELINT_BASE_SCRIPT="stylelint:app" +STOREFRONT_STYLELINT_BINARY="stylelint" +# The config the "lint:scss" body names, relative to the package directory. +STOREFRONT_STYLELINT_CONFIG="stylelint.config.js" # Extensions Stylelint reads here. Neither stylelint.config.js nor the CLI # restricts the set, so this is the pair the SCSS rulesets actually parse. A # path that resolves to no file with one of them lints nothing and still # exits 0. STOREFRONT_STYLELINT_EXTENSIONS="scss css" +# What a directory path is narrowed to: the extensions above, below it. +STOREFRONT_STYLELINT_DIRECTORY_GLOB="**/*.{scss,css}" # True when the path is a glob pattern Stylelint expands itself rather than a # literal filesystem path. The existence probe cannot resolve a glob, so glob @@ -42,13 +52,220 @@ if ! declare -F _stylelint_storefront_path_is_glob >/dev/null; then } fi +# Refuse a path-scoped run when the package's own Stylelint binary is not +# installed where the command runs. `npm exec --no` never installs, but on a +# miss it still runs a globally installed binary of the same name, so the probe +# runs first and the run never reaches a binary the package did not pin. +# Args: $1 = tool name, $2 = aggregate npm script, named in the refusal +# Stdout: the refusal, when the binary is missing +# Returns: 0 when node_modules/.bin/stylelint is executable, 1 otherwise +_stylelint_storefront_assert_local_binary() { + local tool_name="$1" + local aggregate="$2" + local bin_path="node_modules/.bin/${STOREFRONT_STYLELINT_BINARY}" + + # On a miss the probe prints a marker and the directory it ran in, which + # under a container environment is the container's path, and exits 3; any + # other non-zero exit is the probe itself failing. + local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? + if [[ "${probe_code}" -eq 0 ]]; then + return 0 + fi + + if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then + local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" + package_dir="${package_dir%%$'\n'*}" + printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${STOREFRONT_STYLELINT_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." + return 1 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${STOREFRONT_STYLELINT_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 +} + +# True when a command in an npm script body runs Stylelint with a config flag +# of its own: --config, --config=, -c or -c=, counted only after the stylelint +# token of the same command, so another program's -c in the same script is not +# taken for one. The Storefront's Stylelint 16 accepts each of the four and +# refuses any second one. +# Args: $1 = script body +# Returns: 0 when a Stylelint command in the body passes a config, 1 otherwise +_stylelint_storefront_body_passes_config() { + local body="$1" + + body="${body//&&/;}" + body="${body//||/;}" + body="${body//|/;}" + + local -a segments=() + IFS=';' read -r -a segments <<< "${body}" + + local segment args + for segment in "${segments[@]+"${segments[@]}"}"; do + segment=" ${segment} " + case "${segment}" in + *" stylelint "*) args=" ${segment#* stylelint }" ;; + *"/stylelint "*) args=" ${segment#*/stylelint }" ;; + *) continue ;; + esac + case "${args}" in + *" --config "*|*" --config="*|*" -c "*|*" -c="*) return 0 ;; + esac + done + + return 1 +} + +# Print, one per line, the caller's literal paths that name directories. A +# literal path without an accepted extension has passed the lintable probe only +# as a directory holding such a file. One carrying an accepted extension can +# still be a directory, so those are tested where the command runs, through +# `npm exec -c` like the binary probe. +# Args: $1.. = literal paths that passed the lintable probe +# Stdout: the directories, or the failure message +# Returns: 0 on success, 1 when the test could not run +_stylelint_storefront_directories() { + local -a extensions=() + read -r -a extensions <<< "${STOREFRONT_STYLELINT_EXTENSIONS}" + + local -a directories=() + local p ext named script="" + for p in "$@"; do + named=0 + for ext in "${extensions[@]}"; do + [[ "${p}" == *".${ext}" ]] && named=1 + done + if [[ "${named}" -eq 0 ]]; then + directories+=("${p}") + else + script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " + fi + done + + if [[ -n "${script}" ]]; then + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? + if [[ "${probe_code}" -ne 0 ]]; then + printf '%s\n' "Refusing to run with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 + fi + local line + while IFS= read -r line; do + [[ "${line}" == DIRECTORY:* ]] && directories+=("${line#DIRECTORY:}") + done <<< "${output}" + fi + + if [[ ${#directories[@]} -gt 0 ]]; then + printf '%s\n' "${directories[@]}" + fi +} + +# The target a directory reaches Stylelint as. Stylelint given a directory +# reads every file under it, so a directory becomes a glob limited to the +# accepted extensions, which Stylelint expands itself because the caller's +# quoting keeps it away from the shell. +# Args: $1 = directory path +# Stdout: the pattern +_stylelint_storefront_directory_pattern() { + local path="$1" + + while [[ "${path}" == */ && "${path}" != "/" ]]; do + path="${path%/}" + done + printf '%s\n' "${path}/${STOREFRONT_STYLELINT_DIRECTORY_GLOB}" +} + +# Under ddev on a worktree the command reaches `ddev exec`, which re-parses it +# in the container's bash without quoting a value that holds no space, quote +# or "#", so that bash would expand a glob pattern itself, without globstar, +# before Stylelint sees it. A run there takes file paths only. +# Args: $1 = tool name, $2.. = the caller's glob patterns and directories +# Stdout: the refusal +# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise +_stylelint_storefront_assert_no_pattern_under_ddev_worktree() { + local tool_name="$1" + shift + + if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then + return 0 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach Stylelint as glob patterns the container shell expands first: $*. Pass file paths instead." + return 1 +} + +# True when an npm script hands Stylelint a config of its own, directly or +# through an `npm run` of another script in its body. Stylelint refuses a second +# --config ("The flag --config can only be set once."), so a scope config cannot +# be appended to such a script. +# Args: $1 = npm script name, $2 = how many nested `npm run` levels to follow +# Stdout: a failure message, when a body cannot be read or the chain is deeper +# Returns: 0 = passes a config, 1 = passes none, 2 = a body could not be read, +# or the chain runs deeper than $2 levels +_stylelint_storefront_script_passes_config() { + local script="$1" + local depth="$2" + + local body + local body_code=0 + body=$(npm_script_body "${script}") || body_code=$? + if [[ "${body_code}" -eq 1 ]]; then + printf '%s\n' "npm script \"${script}\" is not defined in package.json." + return 2 + fi + if [[ "${body_code}" -ne 0 ]]; then + printf '%s\n' "Could not read npm script \"${script}\" from package.json to check it for its own Stylelint config; see the server log for the failed probe." + return 2 + fi + + if _stylelint_storefront_body_passes_config "${body}"; then + return 0 + fi + + local padded=" ${body} " + + if [[ "${padded}" != *" npm run "* ]]; then + return 1 + fi + + # A chain longer than this is not read to its end, so whether it passes a + # config is unknown; that is refused rather than taken as "none". + if [[ "${depth}" -le 0 ]]; then + printf '%s\n' "npm script \"${script}\" runs further npm scripts nested deeper than this tool follows, so it cannot be checked for its own Stylelint config." + return 2 + fi + + local rest="${padded}" + local inner nested_code + while [[ "${rest}" == *" npm run "* ]]; do + rest="${rest#* npm run }" + # Options such as --silent precede the script name. + while [[ "${rest}" == -* ]]; do + rest="${rest#* }" + done + inner="${rest%% *}" + nested_code=0 + _stylelint_storefront_script_passes_config "${inner}" "$((depth - 1))" || nested_code=$? + if [[ "${nested_code}" -ne 1 ]]; then + return "${nested_code}" + fi + done + + return 1 +} + # Run one Stylelint invocation for this tool. # Args: $1 = aggregate npm script used when no paths are supplied, # $2 = tool name, for the refusal message, # $3 = pre-path flag string used on both routes (may be empty), # $4 = pre-path flag string used ONLY on the path-scoped route, for flags # the aggregate script already carries in its own body (may be empty), -# $5 = tool arguments JSON +# $5 = tool arguments JSON, +# $6 = scope-configured Stylelint config, already part of $3 (may be empty) # Stdout: linter output, or the failure message _stylelint_dispatch_storefront() { local aggregate="$1" @@ -56,6 +273,7 @@ _stylelint_dispatch_storefront() { local flag_string="$3" local path_only_flags="$4" local args="$5" + local scoped_config="${6:-}" local paths_json paths paths_json=$(echo "${args}" | jq -c '.paths // []') @@ -85,6 +303,20 @@ _stylelint_dispatch_storefront() { return 1 fi + if [[ -n "${scoped_config}" ]]; then + local config_check + local config_code=0 + config_check=$(_stylelint_storefront_script_passes_config "${aggregate}" 3) || config_code=$? + if [[ "${config_code}" -eq 0 ]]; then + printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the Stylelint config \"${scoped_config}\", but \"npm run ${aggregate}\" already passes a config of its own, and Stylelint refuses a second --config. Pass paths so the run uses the scope config alone, or call with scope \"shopware\"." + return 1 + fi + if [[ "${config_code}" -ne 1 ]]; then + printf '%s\n' "${config_check}" + return 1 + fi + fi + local aggregate_cmd="npm run ${aggregate} -- ${flag_string}" log "INFO" "Running Stylelint (storefront, ${aggregate}): ${aggregate_cmd}" exec_npm_command "${aggregate_cmd}" @@ -102,12 +334,7 @@ _stylelint_dispatch_storefront() { fi done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') - body=$(npm_script_append_gate "${STOREFRONT_STYLELINT_BASE_SCRIPT}") || gate_code=$? - if [[ "${gate_code}" -ne 0 ]]; then - printf '%s\n' "${body}" - printf '%s\n' "Refusing to run ${tool_name} with paths: a path-scoped run needs the target-less npm script \"${STOREFRONT_STYLELINT_BASE_SCRIPT}\", which is unusable here. The aggregate \"${aggregate}\" script is not a substitute — its body names its own targets and npm appends arguments after them, so it would cover that whole target set on top of the requested paths rather than only the requested paths." - return 1 - fi + _stylelint_storefront_assert_local_binary "${tool_name}" "${aggregate}" || return 1 if [[ ${#literals[@]} -gt 0 ]]; then local missing_report @@ -117,29 +344,58 @@ _stylelint_dispatch_storefront() { fi fi + local -a directories=() + if [[ ${#literals[@]} -gt 0 ]]; then + local directory_report + if ! directory_report=$(_stylelint_storefront_directories "${literals[@]}"); then + printf '%s\n' "${directory_report}" + return 1 + fi + while IFS= read -r p; do + [[ -n "${p}" ]] && directories+=("${p}") + done <<< "${directory_report}" + fi + + local -a patterns=() + for p in "${supplied[@]}"; do + _stylelint_storefront_path_is_glob "${p}" && patterns+=("${p}") + done + _stylelint_storefront_assert_no_pattern_under_ddev_worktree "${tool_name}" \ + "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 + local -a quoted=() + local target directory for p in "${supplied[@]}"; do - quoted+=("$(shell_quote_arg "${p}")") + target="${p}" + for directory in "${directories[@]+"${directories[@]}"}"; do + if [[ "${directory}" == "${p}" ]]; then + target=$(_stylelint_storefront_directory_pattern "${p}") + break + fi + done + quoted+=("$(shell_quote_arg "${target}")") done - # The base script's body carries no mode flag of its own, so anything the - # aggregate would have supplied has to be added here instead. - local path_flags="${flag_string}" - if [[ -n "${path_only_flags}" ]]; then - if [[ -n "${path_flags}" ]]; then - path_flags="${path_flags} ${path_only_flags}" - else - path_flags="${path_only_flags}" - fi + # `--config stylelint.config.js --cache` are the flags the "lint:scss" + # body passes besides its target. The config is the core Storefront + # package's, so it is named only on an unscoped run. Under a scope a + # configured Stylelint config is already in the flag string, and without + # one Stylelint finds the scope package's own config itself. Anything the + # aggregate fix script would have supplied has to be added here as well. + local cmd="npm exec --no -- ${STOREFRONT_STYLELINT_BINARY}" + if [[ -z "${SCOPE_CWD:-}" ]]; then + cmd="${cmd} --config ${STOREFRONT_STYLELINT_CONFIG}" fi - - local cmd="npm run ${STOREFRONT_STYLELINT_BASE_SCRIPT} --" - if [[ -n "${path_flags}" ]]; then - cmd="${cmd} ${path_flags}" + cmd="${cmd} --cache" + if [[ -n "${flag_string}" ]]; then + cmd="${cmd} ${flag_string}" + fi + if [[ -n "${path_only_flags}" ]]; then + cmd="${cmd} ${path_only_flags}" fi cmd="${cmd} ${quoted[*]}" - log "INFO" "Running Stylelint (storefront, ${STOREFRONT_STYLELINT_BASE_SCRIPT}): ${cmd}" + log "INFO" "Running Stylelint (storefront, npm exec): ${cmd}" exec_npm_command "${cmd}" } @@ -177,7 +433,7 @@ tool_stylelint_check() { [[ -n "${scoped_config}" ]] && flags+=("--config" "${scoped_config}") - _stylelint_dispatch_storefront "lint:scss" "stylelint_check" "${flags[*]}" "" "${args}" + _stylelint_dispatch_storefront "lint:scss" "stylelint_check" "${flags[*]}" "" "${args}" "${scoped_config}" } # Stylelint fix (auto-fix violations) @@ -211,5 +467,5 @@ tool_stylelint_fix() { fi # "--fix" is path-route only: the "lint:scss-fix" body already carries one. - _stylelint_dispatch_storefront "lint:scss-fix" "stylelint_fix" "${flag_string}" "--fix" "${args}" + _stylelint_dispatch_storefront "lint:scss-fix" "stylelint_fix" "${flag_string}" "--fix" "${args}" "${scoped_config}" } diff --git a/plugins/dev-tooling/mcp-server-js-storefront/tools.json b/plugins/dev-tooling/mcp-server-js-storefront/tools.json index 3209869b..1c6ba172 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/tools.json +++ b/plugins/dev-tooling/mcp-server-js-storefront/tools.json @@ -2,7 +2,7 @@ "tools": [ { "name": "eslint_check", - "description": "ESLint code quality check (dry-run). Returns violations. Runs in Storefront context. Routes each path to the app tree (eslint:app) or the components tree (eslint:components) and runs both when both are targeted.", + "description": "ESLint code quality check (dry-run). Returns violations. Runs in Storefront context. Routes each path to the app tree (linted from the app/storefront package directory, or from the scope's directory under a scope) or the components tree (linted from src/Storefront/Resources with ./app/storefront/eslint.config.js, as lint:js does) and runs both when both are targeted. Components-tree paths are refused under any scope other than \"shopware\", because that tree belongs to the core Storefront package.", "inputSchema": { "type": "object", "properties": { @@ -36,7 +36,7 @@ }, { "name": "eslint_fix", - "description": "ESLint auto-fix. Applies code style fixes in Storefront context. Routes each path to the app tree (eslint:app) or the components tree (eslint:components) and runs both when both are targeted.", + "description": "ESLint auto-fix. Applies code style fixes in Storefront context. Routes each path to the app tree (linted from the app/storefront package directory, or from the scope's directory under a scope) or the components tree (linted from src/Storefront/Resources with ./app/storefront/eslint.config.js, as lint:js does) and runs both when both are targeted. Components-tree paths are refused under any scope other than \"shopware\", because that tree belongs to the core Storefront package.", "inputSchema": { "type": "object", "properties": { From a38f21d22f571696aed3dec68cc22b1623448cbe Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Sun, 11 Oct 2026 02:02:31 +0200 Subject: [PATCH 2/5] fix(dev-tooling): block npm exec and local-binary runs of JS linters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Administration and Storefront PreToolUse hooks matched only `npx ` without flags, so `npm exec eslint`, `npm x eslint`, `node_modules/.bin/eslint`, `npx eslint@9` and `npm exec --no -c "cd ../.. && eslint …"` ran the tools past the MCP servers. `BINARY_INVOCATION` now covers `npx`, `npm exec` and `npm x` with the valueless flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--`, an `@version` suffix, and the `node_modules/.bin/` path. `runs_binary` also matches a `-c` or `--call` string that runs the binary. Both apply to `eslint`, `stylelint`, `prettier`, `jest`, `tsc` and `vitest`. `docs/mcp-enforcement.md` drops the claim that both `package.json` files declare `jest:base`, and documents that the hooks match each line on its own, with `git commit -F ` as the way around it. Co-Authored-By: Claude --- plugin-tests/dev-tooling/js_admin_tools.bats | 128 +++++++++++++++++- .../dev-tooling/js_storefront_tools.bats | 87 +++++++++++- plugins/dev-tooling/docs/mcp-enforcement.md | 63 +++++---- .../hooks/scripts/check-js-admin-tools.sh | 45 ++++-- .../scripts/check-js-storefront-tools.sh | 51 +++++-- 5 files changed, 318 insertions(+), 56 deletions(-) diff --git a/plugin-tests/dev-tooling/js_admin_tools.bats b/plugin-tests/dev-tooling/js_admin_tools.bats index d9c0cbb1..2e39a5e2 100644 --- a/plugin-tests/dev-tooling/js_admin_tools.bats +++ b/plugin-tests/dev-tooling/js_admin_tools.bats @@ -64,9 +64,11 @@ bats_test_function --description "blocks npx eslint → suggests eslint_check" \ bats_test_function --description "blocks npx jest → suggests jest_run" \ -- js_admin_hook_blocks "npx jest --watch" "jest_run" -# The target-less base scripts the MCP tools route path-scoped runs at. None of -# them matched a pattern before: `:` is not in the `(\s|--|$)` tail the shorter -# names use, so `npm run lint` never covered `npm run lint:debugging`. +# The target-less base scripts. Only lint:debugging (Admin ESLint) and jest:base +# are still called by the MCP tools; Shopware defines none of the others, and +# the redirects stay for a project that does. None of them matched a pattern +# before: `:` is not in the `(\s|--|$)` tail the shorter names use, so +# `npm run lint` never covered `npm run lint:debugging`. # bats test_tags=blocking,base-scripts bats_test_function --description "blocks npm run lint:debugging → suggests eslint_check" \ -- js_admin_hook_blocks "npm run lint:debugging" "eslint_check" @@ -77,14 +79,101 @@ bats_test_function --description "blocks npm run prettier:base → suggests pret bats_test_function --description "blocks npm run jest:base → suggests jest_run" \ -- js_admin_hook_blocks "npm run jest:base" "jest_run" +# A linter binary run through `npx`, `npm exec` or its node_modules/.bin path. +# The MCP servers run the binaries themselves through `npm exec --no --`, so an +# agent that copies that form from a server log reaches the hook with it. +# Each binary is checked in every form: a form missing for one binary fails. +# bats test_tags=blocking,binaries +for _entry in eslint:eslint_check stylelint:stylelint_check prettier:prettier_check; do + _binary="${_entry%%:*}" + _tool="${_entry##*:}" + for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ + "npx BIN" "npx --no-install BIN" "npx -y BIN" \ + "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ + "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do + _command="${_form//BIN/${_binary}} src/module" + bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ + -- js_admin_hook_blocks "${_command}" "${_tool}" + done + bats_test_function --description "blocks ${_binary} run through npm exec in the Administration tree → suggests ${_tool}" \ + -- js_admin_hook_blocks \ + "cd src/Administration/Resources/app/administration && npm exec --no -- ${_binary} src/module" "${_tool}" +done +unset _entry _binary _tool _form _command + +# jest and tsc take the same forms as the linters, with the tool the npx rows +# above already name. +# bats test_tags=blocking,binaries +for _entry in jest:jest_run tsc:tsc_check; do + _binary="${_entry%%:*}" + _tool="${_entry##*:}" + for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ + "npx BIN" "npx --no-install BIN" "npx -y BIN" \ + "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ + "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do + _command="${_form//BIN/${_binary}} src/module" + bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ + -- js_admin_hook_blocks "${_command}" "${_tool}" + done + bats_test_function --description "blocks ${_binary} run through npm exec in the Administration tree → suggests ${_tool}" \ + -- js_admin_hook_blocks \ + "cd src/Administration/Resources/app/administration && npm exec --no -- ${_binary} src/module" "${_tool}" +done +unset _entry _binary _tool _form _command + +# The binary forms follow the same context rule as the scripts: a command that +# names the Storefront tree belongs to the Storefront hook. +# bats test_tags=context,allow +admin_hook_allows_storefront_binary() { + run_hook "check-js-admin-tools.sh" "cd src/Storefront/Resources/app/storefront && npm exec --no -- $1 src" + assert_success +} +bats_test_function --description "allows npm exec eslint when the command names the Storefront tree" \ + -- admin_hook_allows_storefront_binary eslint +bats_test_function --description "allows npm exec stylelint when the command names the Storefront tree" \ + -- admin_hook_allows_storefront_binary stylelint +bats_test_function --description "allows npm exec prettier when the command names the Storefront tree" \ + -- admin_hook_allows_storefront_binary prettier +bats_test_function --description "allows npm exec jest when the command names the Storefront tree" \ + -- admin_hook_allows_storefront_binary jest +bats_test_function --description "allows npm exec tsc when the command names the Storefront tree" \ + -- admin_hook_allows_storefront_binary tsc + +# `npx` and `npm exec` run other packages too, and a binary's name turns up in +# arguments and quoted text. None of these invokes a linter. +# bats test_tags=allow +admin_hook_allows() { + run_hook "check-js-admin-tools.sh" "$1" + assert_success +} +bats_test_function --description "allows npm exec vitest, which the Administration server has no tool for" \ + -- admin_hook_allows "npm exec -- vitest src" +bats_test_function --description "allows npm exec running another tool" \ + -- admin_hook_allows "npm exec -- some-other-tool src" +# `--package` takes a value, so the linter name after it is not the command. +bats_test_function --description "allows npm exec --package naming a linter but running another tool" \ + -- admin_hook_allows "npm exec --package eslint some-other-tool" +bats_test_function --description "allows npx running another tool" \ + -- admin_hook_allows "npx --yes some-other-tool src" +bats_test_function --description "allows a linter name after another tool in an npx command" \ + -- admin_hook_allows "npx --yes some-other-tool eslint" +bats_test_function --description "allows npm exec running a package whose name starts with eslint" \ + -- admin_hook_allows "npm exec -- eslint-plugin-foo" +bats_test_function --description "allows installing a linter" \ + -- admin_hook_allows "npm install eslint" +bats_test_function --description "allows reading a linter binary" \ + -- admin_hook_allows "cat node_modules/.bin/eslint" +bats_test_function --description "allows a linter name inside quoted text after a semicolon" \ + -- admin_hook_allows 'git commit -m "tidy; eslint config"' + # bats test_tags=context,allow -@test "allows npm run stylelint:app, which only the Storefront package declares" { +@test "allows npm run stylelint:app, a Storefront script name" { run_hook "check-js-admin-tools.sh" "npm run stylelint:app" assert_success } # bats test_tags=context,allow -@test "allows npm run eslint:app, which only the Storefront package declares" { +@test "allows npm run eslint:app, a Storefront script name" { run_hook "check-js-admin-tools.sh" "npm run eslint:app" assert_success } @@ -107,3 +196,32 @@ bats_test_function --description "blocks npm run jest:base → suggests jest_run run_hook "check-js-admin-tools.sh" "npm run lint" assert_success } + +# `npm x` is the alias of `npm exec`; the -c / --call form runs a command +# string, and a binary at a command position inside it is blocked. +# bats test_tags=binary,blocking +for spec in "eslint eslint_check" "stylelint stylelint_check" "prettier prettier_check" "jest jest_run" "tsc tsc_check"; do + read -r bin tool <<<"$spec" + bats_test_function --description "blocks npm x running ${bin}" \ + -- js_admin_hook_blocks "npm x -- ${bin} src" "$tool" + bats_test_function --description "blocks npm exec -c running ${bin} after cd" \ + -- js_admin_hook_blocks "npm exec --no -c \"cd ../.. && ${bin} src\"" "$tool" +done +bats_test_function --description "blocks the npm exec -c form the MCP tools run for the components tree" \ + -- js_admin_hook_blocks 'npm exec --no -c "cd ../.. && eslint views/components"' "eslint_check" +bats_test_function --description "blocks npx -c with a single-quoted string running stylelint" \ + -- js_admin_hook_blocks "npx -c 'stylelint src'" "stylelint_check" +bats_test_function --description "blocks npm exec --call running jest" \ + -- js_admin_hook_blocks 'npm exec --call "jest src"' "jest_run" +bats_test_function --description "blocks npm exec -c after --package with its value" \ + -- js_admin_hook_blocks 'npm exec --package foo -c "eslint src"' "eslint_check" +bats_test_function --description "blocks npm exec -c with leading blanks in the string" \ + -- js_admin_hook_blocks 'npm exec -c " eslint src"' "eslint_check" +bats_test_function --description "blocks npm exec -c with a variable assignment before the binary" \ + -- js_admin_hook_blocks 'npm exec -c "NODE_ENV=test jest src"' "jest_run" + +# bats test_tags=binary,allow +bats_test_function --description "allows npm exec -c naming a linter as an argument" \ + -- admin_hook_allows 'npm exec -c "echo eslint"' +bats_test_function --description "allows npm x running another tool" \ + -- admin_hook_allows "npm x -- some-other-tool src" diff --git a/plugin-tests/dev-tooling/js_storefront_tools.bats b/plugin-tests/dev-tooling/js_storefront_tools.bats index 6a650d7f..a1bb6551 100644 --- a/plugin-tests/dev-tooling/js_storefront_tools.bats +++ b/plugin-tests/dev-tooling/js_storefront_tools.bats @@ -107,9 +107,12 @@ bats_test_function --description "blocks a runner passing its own flag before lu bats_test_function --description "blocks a runner passing a flag and -- before ludtwig (pnpm dlx) → suggests ludtwig_check" \ -- js_storefront_hook_blocks "pnpm dlx --silent -- ludtwig" "ludtwig_check" -# The target-less base scripts the MCP tools route path-scoped runs at. -# eslint:app, eslint:components and stylelint:app are Storefront-only names, so -# they are also Storefront context signals in their own right. +# The target-less base scripts. Shopware's Storefront package.json defines none +# of them. The MCP tools no longer call eslint:app, eslint:components and +# stylelint:app, but jest_run still tries jest:base first; the redirects stay for +# a project that defines them. eslint:app, eslint:components and stylelint:app +# are Storefront names, so they are also Storefront context signals in their own +# right. # bats test_tags=blocking,base-scripts bats_test_function --description "blocks npm run eslint:app → suggests eslint_check" \ -- js_storefront_hook_blocks "npm run eslint:app" "eslint_check" @@ -120,7 +123,70 @@ bats_test_function --description "blocks npm run stylelint:app → suggests styl bats_test_function --description "blocks npm run jest:base in Storefront context → suggests jest_run" \ -- js_storefront_hook_blocks "cd src/Storefront && npm run jest:base" "jest_run" -# jest:base is declared by both packages. The Admin hook's unknown-context +# A linter binary run through `npx`, `npm exec` or its node_modules/.bin path. +# The MCP servers run the binaries themselves through `npm exec --no --`, so an +# agent that copies that form from a server log reaches the hook with it. +# Each binary is checked in every form: a form missing for one binary fails. +# Prettier is not here: the Storefront server has no prettier tool. +# bats test_tags=blocking,binaries +for _entry in eslint:eslint_check stylelint:stylelint_check; do + _binary="${_entry%%:*}" + _tool="${_entry##*:}" + for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ + "npx BIN" "npx --no-install BIN" "npx -y BIN" \ + "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ + "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do + _command="cd src/Storefront/Resources/app/storefront && ${_form//BIN/${_binary}} src/js" + bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ + -- js_storefront_hook_blocks "${_command}" "${_tool}" + done +done +unset _entry _binary _tool _form _command + +# jest and vitest take the same forms as the linters, with the tool the npx rows +# above already name. tsc is not here: the Storefront server has no tsc tool. +# bats test_tags=blocking,binaries +for _entry in jest:jest_run vitest:vitest_run; do + _binary="${_entry%%:*}" + _tool="${_entry##*:}" + for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ + "npx BIN" "npx --no-install BIN" "npx -y BIN" \ + "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ + "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do + _command="cd src/Storefront/Resources/app/storefront && ${_form//BIN/${_binary}} src/js" + bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ + -- js_storefront_hook_blocks "${_command}" "${_tool}" + done +done +unset _entry _binary _tool _form _command + +# Without a Storefront marker the command belongs to the Admin hook, and a +# command that names the Administration tree is never claimed here. +# bats test_tags=context,allow +storefront_hook_allows() { + run_hook "check-js-storefront-tools.sh" "$1" + assert_success +} +bats_test_function --description "allows npm exec eslint when no tree is named, which the Admin hook owns" \ + -- storefront_hook_allows "npm exec --no -- eslint src" +bats_test_function --description "allows npm exec stylelint in the Administration tree" \ + -- storefront_hook_allows "cd src/Administration/Resources/app/administration && npm exec --no -- stylelint src" +bats_test_function --description "allows npm exec tsc in the Storefront tree, which has no tsc tool" \ + -- storefront_hook_allows "cd src/Storefront/Resources/app/storefront && npm exec --no -- tsc src" +bats_test_function --description "allows npm exec vitest in the Administration tree" \ + -- storefront_hook_allows "cd src/Administration/Resources/app/administration && npm exec --no -- vitest src" +bats_test_function --description "allows npm exec prettier in the Storefront tree, which has no prettier tool" \ + -- storefront_hook_allows "cd src/Storefront/Resources/app/storefront && npm exec --no -- prettier --check src" +bats_test_function --description "allows npm exec running another tool in the Storefront tree" \ + -- storefront_hook_allows "cd src/Storefront/Resources/app/storefront && npm exec -- some-other-tool src" +bats_test_function --description "allows npm exec --package naming a linter but running another tool in the Storefront tree" \ + -- storefront_hook_allows "cd src/Storefront/Resources/app/storefront && npm exec --package stylelint some-other-tool" +bats_test_function --description "allows a linter name after another tool in an npx command in the Storefront tree" \ + -- storefront_hook_allows "cd src/Storefront/Resources/app/storefront && npx --yes some-other-tool eslint" +bats_test_function --description "allows a linter name inside quoted text after a semicolon in the Storefront tree" \ + -- storefront_hook_allows 'cd src/Storefront && git commit -m "tidy; eslint config"' + +# jest:base carries no side-specific marker. The Admin hook's unknown-context # fallback owns the bare form, so this hook must decline it or the command # would be blocked twice with two different tool names. # bats test_tags=context,allow @@ -192,3 +258,16 @@ bats_test_function --description "blocks npm run jest:base in Storefront context run_hook "check-js-storefront-tools.sh" "cd Storefront && npm run lint:js" assert_success } + +# `npm x` is the alias of `npm exec`; the -c / --call form runs a command +# string, and a binary at a command position inside it is blocked. +# bats test_tags=binary,blocking +for spec in "eslint eslint_check" "stylelint stylelint_check" "jest jest_run" "vitest vitest_run"; do + read -r bin tool <<<"$spec" + bats_test_function --description "blocks npm x running ${bin} in the Storefront tree" \ + -- js_storefront_hook_blocks "cd src/Storefront && npm x -- ${bin} src" "$tool" + bats_test_function --description "blocks npm exec -c running ${bin} in the Storefront tree" \ + -- js_storefront_hook_blocks "cd src/Storefront && npm exec --no -c \"cd ../.. && ${bin} src\"" "$tool" +done +bats_test_function --description "blocks npm x running ludtwig" \ + -- js_storefront_hook_blocks "npm x ludtwig src/Storefront/Resources/views" "ludtwig_check" diff --git a/plugins/dev-tooling/docs/mcp-enforcement.md b/plugins/dev-tooling/docs/mcp-enforcement.md index 419182c4..77a8fffe 100644 --- a/plugins/dev-tooling/docs/mcp-enforcement.md +++ b/plugins/dev-tooling/docs/mcp-enforcement.md @@ -14,6 +14,9 @@ Three hook events carry scripts between them: SessionStart, PreToolUse, and Post **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. Each block message names the tool and its server — ``Use `phpstan_analyze` on the `php-tooling` MCP server instead!`` — which reads the same on Claude Code and on Codex, where the full tool name differs. +> [!NOTE] +> The PreToolUse hooks match each line of a command on its own. A heredoc body or a multi-line quoted string with a line that starts like a blocked command, such as a commit message line `npx eslint now runs` or `vendor/bin/phpstan is clean`, is blocked as if that line were a command. Write the text to a file with the Write tool and pass the file instead: `git commit -F `, `gh pr create --body-file `. + **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. Its matcher carries both host spellings of the tool name, so it fires for a Claude Code plugin install and for a Codex install alike. The other watches `EnterWorktree` and `ExitWorktree` — both Claude Code tools — 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`; the project-root hint above is not gated on the flag, because a server started without a project root refuses every tool whatever the flag says. `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. @@ -40,42 +43,44 @@ Flip the switch per config file (`.mcp-php-tooling.json` or `.mcp-js-tooling.jso ### Blocked JavaScript Commands -The JS hook picks Admin vs Storefront from the command itself and redirects to the matching server. Detection is by path pattern first, then by scripts that only exist on one side — `lint:js`, `production`, `development`, `unit:components`, `eslint:app`, `eslint:components` and `stylelint:app` for Storefront, plus a bare `ludtwig`, which lints Storefront Twig templates and nothing else. `jest:base` is declared by both `package.json` files and carries no side-specific marker, so it cannot be classified this way: a command that also names the Storefront tree (path pattern or another Storefront-only script) is claimed by the Storefront hook, and every other `jest:base` invocation — including a bare one — falls to the Admin hook's unknown-context default. +The JS hook picks Admin vs Storefront from the command itself and redirects to the matching server. Detection is by path pattern first, then by script names that belong to one side — `lint:js`, `production`, `development`, `unit:components`, `eslint:app`, `eslint:components` and `stylelint:app` for Storefront, plus a bare `ludtwig`, which lints Storefront Twig templates and nothing else. `jest:base` carries no side-specific marker, so it cannot be classified this way: a command that also names the Storefront tree (path pattern or another Storefront-only script) is claimed by the Storefront hook, and every other `jest:base` invocation — including a bare one — falls to the Admin hook's unknown-context default. > [!NOTE] > Detection reads the whole command string, so a compound command that names both trees — `git diff -- src/Administration src/Storefront && npm run jest:base` — satisfies both detectors and is blocked twice, once per server. This applies to any command mentioning both trees, not only the base scripts. -`lint:debugging`, `stylelint:base`, `prettier:base` and `jest:base` (Admin) and `eslint:app`, `eslint:components`, `stylelint:app` and `jest:base` (Storefront) are the target-less base npm scripts the corresponding MCP tools route path-scoped runs at directly — see [reference.md](./reference.md) for the routing rules. They are blocked the same as every other script below. - -| Bash Command | Admin MCP Tool | Storefront MCP Tool | -|-------------------------------------------------------------------------------------------------------------|-------------------|---------------------| -| `npm run lint`, `npx eslint` | `eslint_check` | `eslint_check` | -| `npm run lint:js` | N/A | `eslint_check` | -| `npm run lint:debugging` | `eslint_check` | N/A | -| `npm run eslint:app`, `npm run eslint:components` | N/A | `eslint_check` | -| `npm run lint:fix` | `eslint_fix` | `eslint_fix` | -| `npm run lint:js:fix` | N/A | `eslint_fix` | -| `npm run lint:scss`, `npx stylelint` | `stylelint_check` | `stylelint_check` | -| `npm run stylelint:base` | `stylelint_check` | N/A | -| `npm run stylelint:app` | N/A | `stylelint_check` | -| `npm run lint:scss-fix` | `stylelint_fix` | `stylelint_fix` | -| `npm run format`, `npx prettier` | `prettier_check` | N/A (Admin only) | -| `npm run prettier:base` | `prettier_check` | N/A (Admin only) | -| `npm run format:fix` | `prettier_fix` | N/A (Admin only) | -| `npm run unit`, `npx jest` | `jest_run` | `jest_run` | -| `npm run jest:base` | `jest_run` | `jest_run` | -| `npm run lint:types`, `npx tsc` | `tsc_check` | N/A (Admin only) | -| `npm run lint:all` | `lint_all` | N/A (Admin only) | -| `npm run lint:twig` | `lint_twig` | N/A (Admin only) | -| `npm run build` | `vite_build` | N/A | -| `npm run unit:components` (and `:watch` / `:coverage`), `npx vitest`, `composer storefront:components:unit` | N/A | `vitest_run` | -| `composer ludtwig:storefront`, bare `ludtwig` | N/A | `ludtwig_check` | -| `composer ludtwig:storefront:fix` | N/A | `ludtwig_fix` | -| `npm run production/development` | N/A | `webpack_build` | +Tool binaries are blocked in four forms, each matched at a command position: `npx `, `npm exec ` or its alias `npm x `, `/node_modules/.bin/`, and the command-string form `npx -c ''`, `npm exec -c ""` or `--call`, when the string runs the binary at its start or after `;`, `&&` or `|` inside it, past leading blanks and variable assignments (`npm exec --no -c "cd ../.. && eslint views/components"`, the form the Storefront components ESLint run uses). Before `-c`, the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet` and `--silent` and the flags `--package`, `-p`, `--workspace` and `-w` with their value are allowed, because with `-c` the string names what runs. Between the runner and the binary, the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--` are allowed (`npm exec --no -- eslint` is the form the MCP tools run), and the binary may carry a version suffix (`npx eslint@9`). The binaries are `eslint`, `stylelint` and `jest` on both servers, `prettier` and `tsc` on Administration only, and `vitest` on Storefront only. Not blocked: a flag that takes a value before the binary (`npx --package=eslint eslint`, `npm exec --workspace foo -- eslint`), because reading its value as the binary would block `npm exec --package eslint some-other-tool`; a bare binary name (`eslint`, `jest` and the rest), because a plain command-position match would block a commit message containing `; eslint config`; and a binary name in an argument. + +Path-scoped runs of `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `prettier_check` and `prettier_fix` call the local binary through `npm exec --no -- `, with two exceptions. Storefront ESLint on the components tree runs `npm exec --no -c "cd ../.. && eslint …"` from the storefront package directory, and Administration ESLint runs the `lint:debugging` script. `jest_run` tries `jest:base` and falls back to `unit`. Of the scripts named in this paragraph, only `lint:debugging` (Administration) and `unit` (both packages) exist in Shopware's `package.json` files; `jest:base`, `stylelint:base`, `prettier:base`, `eslint:app`, `eslint:components` and `stylelint:app` do not. The hooks still block all of them, because a project can define them. See [reference.md](./reference.md) for the routing rules. + +| Bash Command | Admin MCP Tool | Storefront MCP Tool | +|------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------|---------------------| +| `npm run lint`, `npx eslint`, `npm exec eslint`, `node_modules/.bin/eslint` | `eslint_check` | `eslint_check` | +| `npm run lint:js` | N/A | `eslint_check` | +| `npm run lint:debugging` | `eslint_check` | N/A | +| `npm run eslint:app`, `npm run eslint:components` | N/A | `eslint_check` | +| `npm run lint:fix` | `eslint_fix` | `eslint_fix` | +| `npm run lint:js:fix` | N/A | `eslint_fix` | +| `npm run lint:scss`, `npx stylelint`, `npm exec stylelint`, `node_modules/.bin/stylelint` | `stylelint_check` | `stylelint_check` | +| `npm run stylelint:base` | `stylelint_check` | N/A | +| `npm run stylelint:app` | N/A | `stylelint_check` | +| `npm run lint:scss-fix` | `stylelint_fix` | `stylelint_fix` | +| `npm run format`, `npx prettier`, `npm exec prettier`, `node_modules/.bin/prettier` | `prettier_check` | N/A (Admin only) | +| `npm run prettier:base` | `prettier_check` | N/A (Admin only) | +| `npm run format:fix` | `prettier_fix` | N/A (Admin only) | +| `npm run unit`, `npx jest`, `npm exec jest`, `node_modules/.bin/jest` | `jest_run` | `jest_run` | +| `npm run jest:base` | `jest_run` | `jest_run` | +| `npm run lint:types`, `npx tsc`, `npm exec tsc`, `node_modules/.bin/tsc` | `tsc_check` | N/A (Admin only) | +| `npm run lint:all` | `lint_all` | N/A (Admin only) | +| `npm run lint:twig` | `lint_twig` | N/A (Admin only) | +| `npm run build` | `vite_build` | N/A | +| `npm run unit:components` (and `:watch` / `:coverage`), `npx vitest`, `npm exec vitest`, `node_modules/.bin/vitest`, `composer storefront:components:unit` | N/A | `vitest_run` | +| `composer ludtwig:storefront`, bare `ludtwig` | N/A | `ludtwig_check` | +| `composer ludtwig:storefront:fix` | N/A | `ludtwig_fix` | +| `npm run production/development` | N/A | `webpack_build` | `npm run unit` and `npm run unit:components` are matched by separate patterns, so the component suite is redirected to `vitest_run` and never to `jest_run`. The `:fix` ludtwig pattern is tested before the plain one, so `composer ludtwig:storefront:fix` resolves to `ludtwig_fix` rather than being shadowed by `ludtwig_check`. -Commands that aren't blocked: `npm install`, `composer install`, watch-mode scripts other than `npm run unit:components:watch`, and any npm script that doesn't match one of the patterns above. +Commands that aren't blocked: `npm install`, `composer install`, watch-mode scripts other than `npm run unit:components:watch`, any npm script that doesn't match one of the patterns above, the bare binary names described above, and `npx`, `npm exec` or `npm x` running a tool not named in the table. ## 🔗 Plugin Integration diff --git a/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh b/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh index 4d982087..2ab39856 100755 --- a/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh @@ -33,12 +33,41 @@ is_admin_context() { return 1 fi # Unknown context - Admin hook handles generic commands. - # This is what claims a bare `npm run jest:base`, a script name both - # packages declare. The Storefront hook declines an unknown context, so - # exactly one of the two hooks blocks it. + # This is what claims a bare `npm run jest:base`, a script name that carries + # no side-specific marker. The Storefront hook declines an unknown context, + # so exactly one of the two hooks blocks it. return 0 } +# A direct run of a tool binary (a linter, jest, tsc or vitest) at a command +# position: `npx`, `npm exec` or `npm x` invoking it, or its node_modules/.bin path. The +# runner and the binary are separated by zero or more of the listed flags that +# take no value, and an optional `--` (`npx --no-install eslint`, +# `npm exec --no -- prettier`). A flag that takes a value +# (`--package eslint some-tool`) is not listed: its value would read as the +# binary and block a command that does not run it. A version suffix +# (`npx eslint@9`) is allowed after the binary. A bare `eslint` is not matched: +# the word sits at a command position inside any quoted text that follows `;`, +# `&&` or `|`, so a commit message would turn into a hard block. `npm x` is +# matched wherever `npm exec` is. +BINARY_INVOCATION='(^|;|&&|\|)\s*((npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|--))*\s+|\S*node_modules/\.bin/)' + +# The -c / --call form of the same runners takes a command string; the binary +# is matched at a command position inside it: its start, or after `;`, `&&` or +# `|` within the string, past leading blanks and variable assignments +# (`npm exec --no -c "cd ../.. && eslint views/components"`, the form the MCP +# tools run). Before -c, the flags above and --package, -p, --workspace, -w with +# their value are allowed: with -c the string, not the package, names what runs. +# A closing quote may follow the binary. +CALL_INVOCATION="(^|;|&&|\|)\s*(npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|(--package|--workspace|-p|-w)(=|\s+)[^[:space:]\"';&|]+))*\s+(-c|--call)(\s+|=)[\"']?\s*([^\"';&|]*(;|&&|\|)\s*)*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]\"';&|]*\s+)*" + +# Args: $1 = binary name +# Returns: 0 when the command runs the binary in one of the forms above +runs_binary() { + echo "$COMMAND" | grep -qE "${BINARY_INVOCATION}$1(@\S+)?(\s|\$)" \ + || echo "$COMMAND" | grep -qE "${CALL_INVOCATION}$1(@\S+)?(\s|\$|[\"'])" +} + # Only process if in Admin context if ! is_admin_context; then exit 0 @@ -63,7 +92,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:debugging(\s|--|$) "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." "eslint_check" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+eslint(\s|$)'; then +if runs_binary eslint; then block_tool "js-admin-tooling" \ "Use eslint_check for linting or eslint_fix to auto-fix." "eslint_check" fi @@ -87,7 +116,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+stylelint:base(\s|--|$) "Use stylelint_check with paths for SCSS/CSS linting, or stylelint_fix with paths to auto-fix." "stylelint_check" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+stylelint(\s|$)'; then +if runs_binary stylelint; then block_tool "js-admin-tooling" \ "Use stylelint_check for SCSS/CSS linting or stylelint_fix to auto-fix." "stylelint_check" fi @@ -111,7 +140,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+prettier:base(\s|--|$)' "Use prettier_check with paths to verify formatting, or prettier_fix with paths to auto-format." "prettier_check" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+prettier(\s|$)'; then +if runs_binary prettier; then block_tool "js-admin-tooling" \ "Use prettier_check to verify formatting or prettier_fix to auto-format." "prettier_check" fi @@ -130,7 +159,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+jest:base(\s|--|$)'; th "Use jest_run with testPathPatterns, testNamePattern, coverage, ci options." "jest_run" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+jest(\s|$)'; then +if runs_binary jest; then block_tool "js-admin-tooling" \ "Use jest_run with testPathPattern, testNamePattern, coverage options." "jest_run" fi @@ -144,7 +173,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:types(\s|$)'; then "Use tsc_check for TypeScript type checking." "tsc_check" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+tsc(\s|$)'; then +if runs_binary tsc; then block_tool "js-admin-tooling" \ "Use tsc_check for TypeScript type checking." "tsc_check" fi diff --git a/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh b/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh index 4c4a0011..a9c537b5 100755 --- a/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh @@ -22,9 +22,9 @@ is_storefront_context() { return 0 fi # Storefront-specific npm scripts. eslint:app, eslint:components and - # stylelint:app are declared only by the Storefront package.json; without - # them here the Admin hook's unknown-context fallback would claim them and - # name the wrong server's tool. + # stylelint:app are Storefront names; without them here the Admin hook's + # unknown-context fallback would claim them and name the wrong server's + # tool. # `:` is absent from the trailing `(\s|$)` alternation, so a bare `lint:js` # alternative never covers the longer colon-suffixed names — lint:js:fix and # the lint:js:app / lint:js:components pairs need the explicit suffix group @@ -45,6 +45,35 @@ is_storefront_context() { return 1 } +# A direct run of a tool binary (a linter, jest, tsc or vitest) at a command +# position: `npx`, `npm exec` or `npm x` invoking it, or its node_modules/.bin path. The +# runner and the binary are separated by zero or more of the listed flags that +# take no value, and an optional `--` (`npx --no-install eslint`, +# `npm exec --no -- prettier`). A flag that takes a value +# (`--package eslint some-tool`) is not listed: its value would read as the +# binary and block a command that does not run it. A version suffix +# (`npx eslint@9`) is allowed after the binary. A bare `eslint` is not matched: +# the word sits at a command position inside any quoted text that follows `;`, +# `&&` or `|`, so a commit message would turn into a hard block. `npm x` is +# matched wherever `npm exec` is. +BINARY_INVOCATION='(^|;|&&|\|)\s*((npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|--))*\s+|\S*node_modules/\.bin/)' + +# The -c / --call form of the same runners takes a command string; the binary +# is matched at a command position inside it: its start, or after `;`, `&&` or +# `|` within the string, past leading blanks and variable assignments +# (`npm exec --no -c "cd ../.. && eslint views/components"`, the form the MCP +# tools run). Before -c, the flags above and --package, -p, --workspace, -w with +# their value are allowed: with -c the string, not the package, names what runs. +# A closing quote may follow the binary. +CALL_INVOCATION="(^|;|&&|\|)\s*(npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|(--package|--workspace|-p|-w)(=|\s+)[^[:space:]\"';&|]+))*\s+(-c|--call)(\s+|=)[\"']?\s*([^\"';&|]*(;|&&|\|)\s*)*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]\"';&|]*\s+)*" + +# Args: $1 = binary name +# Returns: 0 when the command runs the binary in one of the forms above +runs_binary() { + echo "$COMMAND" | grep -qE "${BINARY_INVOCATION}$1(@\S+)?(\s|\$)" \ + || echo "$COMMAND" | grep -qE "${CALL_INVOCATION}$1(@\S+)?(\s|\$|[\"'])" +} + # Only process if in Storefront context if ! is_storefront_context; then exit 0 @@ -98,7 +127,9 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:fix(\s|$)'; then "Use eslint_fix to auto-fix ESLint violations." "eslint_fix" fi -# Target-less base scripts the MCP tools route path-scoped runs at +# Target-less base scripts. Shopware's Storefront package.json does not define +# them and the MCP tools no longer call them; the redirects stay for a project +# that does. if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+eslint:app(\s|--|$)'; then block_tool "js-storefront-tooling" \ "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." "eslint_check" @@ -109,7 +140,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+eslint:components(\s|-- "Use eslint_check with paths under views/components/ for linting, or eslint_fix to auto-fix." "eslint_check" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+eslint(\s|$)'; then +if runs_binary eslint; then block_tool "js-storefront-tooling" \ "Use eslint_check for linting or eslint_fix to auto-fix." "eslint_check" fi @@ -133,7 +164,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+stylelint:app(\s|--|$)' "Use stylelint_check with paths for SCSS/CSS linting, or stylelint_fix with paths to auto-fix." "stylelint_check" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+stylelint(\s|$)'; then +if runs_binary stylelint; then block_tool "js-storefront-tooling" \ "Use stylelint_check for SCSS/CSS linting or stylelint_fix to auto-fix." "stylelint_check" fi @@ -147,7 +178,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+unit(\s|--|$)'; then "Use jest_run with testPathPatterns, testNamePattern, coverage options." "jest_run" fi -# jest:base is declared by both packages, so it reaches this block only when +# jest:base carries no side-specific marker, so it reaches this block only when # the command names the Storefront tree; a bare invocation stays with the Admin # hook's unknown-context fallback. if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+jest:base(\s|--|$)'; then @@ -155,7 +186,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+jest:base(\s|--|$)'; th "Use jest_run with testPathPatterns, testNamePattern, coverage, ci options." "jest_run" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+jest(\s|$)'; then +if runs_binary jest; then block_tool "js-storefront-tooling" \ "Use jest_run with testPathPatterns, testNamePattern, coverage options." "jest_run" fi @@ -169,7 +200,7 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+unit:components(:watch| "Use vitest_run with paths, testNamePattern, coverage options." "vitest_run" fi -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npx\s+vitest(\s|$)'; then +if runs_binary vitest; then block_tool "js-storefront-tooling" \ "Use vitest_run with paths, testNamePattern, coverage options." "vitest_run" fi @@ -202,7 +233,7 @@ fi # optional `--`, because Composer *requires* `composer exec -- ludtwig` as soon # as ludtwig takes its own options — the likeliest real invocation is the one # with the separator, and `npx -y` / `pnpm dlx --` are the same shape. -if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*ludtwig(\s|$)|(^|;|&&|\|)\s*(composer\s+exec|npm\s+exec|npx|pnpm\s+exec|pnpm\s+dlx|bunx|yarn\s+exec|yarn\s+run)(\s+(--?[A-Za-z0-9][-A-Za-z0-9]*|--))*\s+ludtwig(\s|$)'; then +if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*ludtwig(\s|$)|(^|;|&&|\|)\s*(composer\s+exec|npm\s+(exec|x)|npx|pnpm\s+exec|pnpm\s+dlx|bunx|yarn\s+exec|yarn\s+run)(\s+(--?[A-Za-z0-9][-A-Za-z0-9]*|--))*\s+ludtwig(\s|$)'; then block_tool "js-storefront-tooling" \ "Use ludtwig_check for Twig template linting or ludtwig_fix to auto-fix." "ludtwig_check" fi From af67c79091243415cc787205c3e1a40ebd15ab1f Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Sun, 11 Oct 2026 02:03:40 +0200 Subject: [PATCH 3/5] chore: release dev-tooling 4.1.1 Co-Authored-By: Claude --- plugins/dev-tooling/.claude-plugin/plugin.json | 2 +- plugins/dev-tooling/.codex-plugin/plugin.json | 2 +- plugins/dev-tooling/CHANGELOG.md | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/dev-tooling/.claude-plugin/plugin.json b/plugins/dev-tooling/.claude-plugin/plugin.json index 2ab9e6c5..04d61fcf 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": "4.1.0", + "version": "4.1.1", "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/.codex-plugin/plugin.json b/plugins/dev-tooling/.codex-plugin/plugin.json index 7e7aadca..c263b55e 100644 --- a/plugins/dev-tooling/.codex-plugin/plugin.json +++ b/plugins/dev-tooling/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dev-tooling", - "version": "4.1.0", + "version": "4.1.1", "description": "Three MCP servers for PHP and JavaScript operations. 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. 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/CHANGELOG.md b/plugins/dev-tooling/CHANGELOG.md index 10e80d81..f76f1cac 100644 --- a/plugins/dev-tooling/CHANGELOG.md +++ b/plugins/dev-tooling/CHANGELOG.md @@ -5,6 +5,18 @@ 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.1.1] - 2026-10-11 + +### Changed +- **The PreToolUse JS hooks block more direct runs of a tool binary.** They previously matched only `npx ` with no flag and no version. They now also block `npm exec` and `npm x`, a `node_modules/.bin/` path, an `@version` suffix (`npx eslint@9`), the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--` before the binary, and `npx`/`npm exec`/`npm x` with `-c` or `--call` when the command string runs the binary (`npm exec --no -c "cd ../.. && eslint views/components"`). The binaries are `eslint`, `stylelint`, `prettier`, `jest`, `tsc` and, on the Storefront hook, `vitest`. +### Fixed +- **Path-scoped Stylelint, Prettier and ESLint runs no longer call npm scripts Shopware does not define.** `stylelint_check`/`stylelint_fix` and `prettier_check`/`prettier_fix` on `js-admin-tooling`, and `stylelint_check`/`stylelint_fix` and `eslint_check`/`eslint_fix` on `js-storefront-tooling`, routed a run with `paths` to `stylelint:base`, `prettier:base`, `stylelint:app`, `eslint:app` and `eslint:components`, so they refused on every Shopware checkout. They now run the package's own binary through `npm exec --no -- ` with the flags of the aggregate script. The Storefront components tree runs `npm exec --no -c "cd ../.. && eslint …"`, as `lint:js` does. Admin ESLint still uses `lint:debugging`, and runs without `paths` are unchanged. +- **A path-scoped run checks that the package's own binary is installed.** `npm exec --no` installs nothing but runs a global binary of the same name, so the tool first tests for `node_modules/.bin/`. When it is missing, the run is refused with a message naming the binary and the directory the test ran in. +- **Directory paths for Stylelint and Prettier reach the tool as a quoted pattern.** A directory becomes `/**/*.scss` (Admin Stylelint), `/**/*.{scss,css}` (Storefront Stylelint) or `/**/*.{js,ts,mjs}` (Prettier), so only the files the aggregate script lints are touched. The same extension lists gate literal file paths: Admin Stylelint accepts `.scss` only, matching `lint:scss`, and Admin Prettier accepts `.mjs` besides `.js` and `.ts`, matching the `format` globs. Tools detect a directory whose name ends in a lintable extension. Under `ddev` on a linked worktree, directory and glob paths for these routes are refused, because `ddev exec` re-parses the command in the container shell, which would expand the pattern first. +- **ESLint paths in the Storefront components tree are refused under a scope other than `shopware`.** That tree and `./app/storefront/eslint.config.js` belong to the core Storefront package, not to the scope's package. +- **Storefront Stylelint passes the core `--config stylelint.config.js` only without a scope.** Under a scope the run uses the scope's configured Stylelint config alone, or the scope package's own config. +- **A scope-selected Stylelint config on a run without `paths` no longer produces two `--config` flags.** On `main` the tool appended `--config ` to `lint:scss` even when that script already passes one, as Storefront's does, and Stylelint refuses a second `--config`. The run is now refused when the script, or a script it reaches through `npm run`, passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command. + ## [4.1.0] - 2026-10-07 ### Added From 2db187c831d629d2429d127a7a855b47eea81941 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Sun, 11 Oct 2026 03:14:39 +0200 Subject: [PATCH 4/5] refactor(dev-tooling): share the JS path pipeline and hook matcher The local-binary probe, the directory detection and patterns, the ddev-worktree refusal and the Stylelint `--config` detector were copied into three or four JS server libraries, and the hook binary matcher into both JS hooks. They now live once in the new `shared/js_paths.sh` and `hooks/scripts/lib/js_binary.sh`, which only dev-tooling ships. `environment.sh` and the hook library `common.sh` stay untouched, because they are template copies that shopware-env also ships. The refusals of the shared pipeline name the tool: "Refusing to run `` with paths". Tests generate the hook rows from one form list, run the missing-binary probe through every environment wrapper, default to the trunk script layout with `jest:base` as an opt-in, and check the guard against the call log. The trunk fixtures get a README and a `regenerate.sh` that record their source commit. Co-Authored-By: Claude --- .../fixtures/shopware-trunk/README.md | 20 ++ .../fixtures/shopware-trunk/regenerate.sh | 30 ++ plugin-tests/dev-tooling/js_admin_tools.bats | 37 +- .../dev-tooling/js_storefront_tools.bats | 50 +-- .../dev-tooling/mcp_tool_js_admin.bats | 194 +++++------ .../dev-tooling/mcp_tool_js_storefront.bats | 155 +++------ plugin-tests/dev-tooling/scope_js_tools.bats | 26 +- .../dev-tooling/test_helper/common_setup.bash | 166 +++++++-- .../dev-tooling/worktree_conformance.bats | 1 + .../dev-tooling/worktree_js_tools.bats | 7 +- .../hooks/scripts/check-js-admin-tools.sh | 30 +- .../scripts/check-js-storefront-tools.sh | 35 +- .../hooks/scripts/lib/js_binary.sh | 24 ++ .../mcp-server-js-admin/lib/jest.sh | 12 +- .../mcp-server-js-admin/lib/prettier.sh | 200 +---------- .../mcp-server-js-admin/lib/stylelint.sh | 301 +---------------- .../dev-tooling/mcp-server-js-admin/server.sh | 1 + .../mcp-server-js-storefront/lib/eslint.sh | 55 +-- .../mcp-server-js-storefront/lib/stylelint.sh | 304 +---------------- .../mcp-server-js-storefront/server.sh | 1 + .../mcp-server-js-storefront/tools.json | 4 +- plugins/dev-tooling/shared/js_paths.sh | 316 ++++++++++++++++++ 22 files changed, 744 insertions(+), 1225 deletions(-) create mode 100644 plugin-tests/dev-tooling/fixtures/shopware-trunk/README.md create mode 100755 plugin-tests/dev-tooling/fixtures/shopware-trunk/regenerate.sh create mode 100644 plugins/dev-tooling/hooks/scripts/lib/js_binary.sh create mode 100644 plugins/dev-tooling/shared/js_paths.sh diff --git a/plugin-tests/dev-tooling/fixtures/shopware-trunk/README.md b/plugin-tests/dev-tooling/fixtures/shopware-trunk/README.md new file mode 100644 index 00000000..be4e5ad5 --- /dev/null +++ b/plugin-tests/dev-tooling/fixtures/shopware-trunk/README.md @@ -0,0 +1,20 @@ +# Shopware Trunk Fixtures + +## 🔖 Source + +The `scripts` blocks of two `package.json` files from [shopware/shopware](https://github.com/shopware/shopware), commit `c59a7d3484c`: + +| Fixture | Mirrors | +|---------------------------------------|----------------------------------------------------------------| +| `administration-package-scripts.json` | `src/Administration/Resources/app/administration/package.json` | +| `storefront-package-scripts.json` | `src/Storefront/Resources/app/storefront/package.json` | + +Only the `scripts` block is kept, in the key order of the source. The JS tool suites answer `npm pkg get "scripts."` probes from these files through `shopware_trunk_script_body` in `test_helper/common_setup.bash`, so a route naming a script trunk does not define fails as it would on a real checkout. + +## ♻️ Regeneration + +```bash +./regenerate.sh /path/to/shopware [output-dir] +``` + +The script reads the checkout and writes both files into `output-dir`, by default this directory. Check out the commit named above first to reproduce the committed files; a newer trunk can change the scripts. diff --git a/plugin-tests/dev-tooling/fixtures/shopware-trunk/regenerate.sh b/plugin-tests/dev-tooling/fixtures/shopware-trunk/regenerate.sh new file mode 100755 index 00000000..7b3a9066 --- /dev/null +++ b/plugin-tests/dev-tooling/fixtures/shopware-trunk/regenerate.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Rewrites the two package-scripts fixtures from a shopware/shopware checkout. +# Usage: regenerate.sh [output-dir] +# The output directory defaults to the directory of this script. The checkout +# is only read. +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + printf 'Usage: %s [output-dir]\n' "${0##*/}" >&2 + exit 64 +fi + +checkout="$1" +output_dir="${2:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" + +# fixture file name -> package.json below the checkout +declare -A sources=( + [administration-package-scripts.json]="src/Administration/Resources/app/administration/package.json" + [storefront-package-scripts.json]="src/Storefront/Resources/app/storefront/package.json" +) + +for fixture in "${!sources[@]}"; do + package_json="${checkout}/${sources[${fixture}]}" + if [[ ! -f "${package_json}" ]]; then + printf 'No package.json at %s\n' "${package_json}" >&2 + exit 66 + fi + # No -S: the fixtures keep the key order of the package.json. + jq '.scripts' "${package_json}" > "${output_dir}/${fixture}" +done diff --git a/plugin-tests/dev-tooling/js_admin_tools.bats b/plugin-tests/dev-tooling/js_admin_tools.bats index 2e39a5e2..d771fd43 100644 --- a/plugin-tests/dev-tooling/js_admin_tools.bats +++ b/plugin-tests/dev-tooling/js_admin_tools.bats @@ -64,11 +64,9 @@ bats_test_function --description "blocks npx eslint → suggests eslint_check" \ bats_test_function --description "blocks npx jest → suggests jest_run" \ -- js_admin_hook_blocks "npx jest --watch" "jest_run" -# The target-less base scripts. Only lint:debugging (Admin ESLint) and jest:base -# are still called by the MCP tools; Shopware defines none of the others, and -# the redirects stay for a project that does. None of them matched a pattern -# before: `:` is not in the `(\s|--|$)` tail the shorter names use, so -# `npm run lint` never covered `npm run lint:debugging`. +# The target-less base scripts. The MCP tools call lint:debugging (Admin ESLint) +# and jest:base; Shopware defines none of the others, and the redirects stay for +# a project that does. # bats test_tags=blocking,base-scripts bats_test_function --description "blocks npm run lint:debugging → suggests eslint_check" \ -- js_admin_hook_blocks "npm run lint:debugging" "eslint_check" @@ -82,35 +80,12 @@ bats_test_function --description "blocks npm run jest:base → suggests jest_run # A linter binary run through `npx`, `npm exec` or its node_modules/.bin path. # The MCP servers run the binaries themselves through `npm exec --no --`, so an # agent that copies that form from a server log reaches the hook with it. -# Each binary is checked in every form: a form missing for one binary fails. # bats test_tags=blocking,binaries -for _entry in eslint:eslint_check stylelint:stylelint_check prettier:prettier_check; do +for _entry in eslint:eslint_check stylelint:stylelint_check prettier:prettier_check \ + jest:jest_run tsc:tsc_check; do _binary="${_entry%%:*}" _tool="${_entry##*:}" - for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ - "npx BIN" "npx --no-install BIN" "npx -y BIN" \ - "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ - "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do - _command="${_form//BIN/${_binary}} src/module" - bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ - -- js_admin_hook_blocks "${_command}" "${_tool}" - done - bats_test_function --description "blocks ${_binary} run through npm exec in the Administration tree → suggests ${_tool}" \ - -- js_admin_hook_blocks \ - "cd src/Administration/Resources/app/administration && npm exec --no -- ${_binary} src/module" "${_tool}" -done -unset _entry _binary _tool _form _command - -# jest and tsc take the same forms as the linters, with the tool the npx rows -# above already name. -# bats test_tags=blocking,binaries -for _entry in jest:jest_run tsc:tsc_check; do - _binary="${_entry%%:*}" - _tool="${_entry##*:}" - for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ - "npx BIN" "npx --no-install BIN" "npx -y BIN" \ - "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ - "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do + for _form in "${JS_HOOK_BINARY_FORMS[@]}"; do _command="${_form//BIN/${_binary}} src/module" bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ -- js_admin_hook_blocks "${_command}" "${_tool}" diff --git a/plugin-tests/dev-tooling/js_storefront_tools.bats b/plugin-tests/dev-tooling/js_storefront_tools.bats index a1bb6551..65961883 100644 --- a/plugin-tests/dev-tooling/js_storefront_tools.bats +++ b/plugin-tests/dev-tooling/js_storefront_tools.bats @@ -56,8 +56,7 @@ bats_test_function --description "blocks npm run production → suggests webpack bats_test_function --description "blocks npm run lint:js in Storefront context → suggests eslint_check" \ -- js_storefront_hook_blocks "cd src/Storefront && npm run lint:js" "eslint_check" # `lint:js:fix` carries a colon where the context detector's trailing -# alternation expects whitespace or end-of-string, so a bare invocation used to -# miss the detector entirely and reach neither hook's block. +# alternation expects whitespace or end-of-string. bats_test_function --description "blocks a bare npm run lint:js:fix → suggests eslint_fix" \ -- js_storefront_hook_blocks "npm run lint:js:fix" "eslint_fix" bats_test_function --description "blocks a bare npm run lint:js → suggests eslint_check" \ @@ -88,11 +87,8 @@ bats_test_function --description "blocks composer ludtwig:storefront:fix → sug -- js_storefront_hook_blocks "composer ludtwig:storefront:fix" "ludtwig_fix" bats_test_function --description "blocks a bare ludtwig invocation → suggests ludtwig_check" \ -- js_storefront_hook_blocks "ludtwig ." "ludtwig_check" -# The bare-ludtwig block pattern's leading boundary used to require ^, ;, &&, -# or |, so ludtwig invoked through a wrapper (e.g. "composer exec ludtwig") -# passed the context detector but was never blocked. The fix recognizes a -# fixed list of package runners immediately before the token rather than any -# whitespace boundary — a plain-whitespace boundary over-blocks (see the +# The bare-ludtwig block pattern recognizes a fixed list of package runners +# immediately before the token: a plain-whitespace boundary over-blocks (see the # commit-message case below). bats_test_function --description "blocks ludtwig invoked via a known runner (composer exec) → suggests ludtwig_check" \ -- js_storefront_hook_blocks "composer exec ludtwig" "ludtwig_check" @@ -106,13 +102,14 @@ bats_test_function --description "blocks a runner passing its own flag before lu -- js_storefront_hook_blocks "npx -y ludtwig" "ludtwig_check" bats_test_function --description "blocks a runner passing a flag and -- before ludtwig (pnpm dlx) → suggests ludtwig_check" \ -- js_storefront_hook_blocks "pnpm dlx --silent -- ludtwig" "ludtwig_check" +bats_test_function --description "blocks npm x running ludtwig" \ + -- js_storefront_hook_blocks "npm x ludtwig src/Storefront/Resources/views" "ludtwig_check" # The target-less base scripts. Shopware's Storefront package.json defines none -# of them. The MCP tools no longer call eslint:app, eslint:components and -# stylelint:app, but jest_run still tries jest:base first; the redirects stay for -# a project that defines them. eslint:app, eslint:components and stylelint:app -# are Storefront names, so they are also Storefront context signals in their own -# right. +# of them; the MCP tools call jest:base first, and the redirects stay for a +# project that defines the others. eslint:app, eslint:components and +# stylelint:app are Storefront names, so they are also Storefront context +# signals in their own right. # bats test_tags=blocking,base-scripts bats_test_function --description "blocks npm run eslint:app → suggests eslint_check" \ -- js_storefront_hook_blocks "npm run eslint:app" "eslint_check" @@ -126,33 +123,12 @@ bats_test_function --description "blocks npm run jest:base in Storefront context # A linter binary run through `npx`, `npm exec` or its node_modules/.bin path. # The MCP servers run the binaries themselves through `npm exec --no --`, so an # agent that copies that form from a server log reaches the hook with it. -# Each binary is checked in every form: a form missing for one binary fails. -# Prettier is not here: the Storefront server has no prettier tool. -# bats test_tags=blocking,binaries -for _entry in eslint:eslint_check stylelint:stylelint_check; do - _binary="${_entry%%:*}" - _tool="${_entry##*:}" - for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ - "npx BIN" "npx --no-install BIN" "npx -y BIN" \ - "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ - "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do - _command="cd src/Storefront/Resources/app/storefront && ${_form//BIN/${_binary}} src/js" - bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ - -- js_storefront_hook_blocks "${_command}" "${_tool}" - done -done -unset _entry _binary _tool _form _command - -# jest and vitest take the same forms as the linters, with the tool the npx rows -# above already name. tsc is not here: the Storefront server has no tsc tool. +# The Storefront server has no prettier and no tsc tool, so neither is here. # bats test_tags=blocking,binaries -for _entry in jest:jest_run vitest:vitest_run; do +for _entry in eslint:eslint_check stylelint:stylelint_check jest:jest_run vitest:vitest_run; do _binary="${_entry%%:*}" _tool="${_entry##*:}" - for _form in "npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" \ - "npx BIN" "npx --no-install BIN" "npx -y BIN" \ - "npx BIN@9.1.0" "npm exec BIN@9.1.0" \ - "node_modules/.bin/BIN" "./node_modules/.bin/BIN"; do + for _form in "${JS_HOOK_BINARY_FORMS[@]}"; do _command="cd src/Storefront/Resources/app/storefront && ${_form//BIN/${_binary}} src/js" bats_test_function --description "blocks ${_command} → suggests ${_tool}" \ -- js_storefront_hook_blocks "${_command}" "${_tool}" @@ -269,5 +245,3 @@ for spec in "eslint eslint_check" "stylelint stylelint_check" "jest jest_run" "v bats_test_function --description "blocks npm exec -c running ${bin} in the Storefront tree" \ -- js_storefront_hook_blocks "cd src/Storefront && npm exec --no -c \"cd ../.. && ${bin} src\"" "$tool" done -bats_test_function --description "blocks npm x running ludtwig" \ - -- js_storefront_hook_blocks "npm x ludtwig src/Storefront/Resources/views" "ludtwig_check" diff --git a/plugin-tests/dev-tooling/mcp_tool_js_admin.bats b/plugin-tests/dev-tooling/mcp_tool_js_admin.bats index f9f43d22..368345f9 100644 --- a/plugin-tests/dev-tooling/mcp_tool_js_admin.bats +++ b/plugin-tests/dev-tooling/mcp_tool_js_admin.bats @@ -6,15 +6,8 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" -# Answers the `npm pkg get "scripts."` probe from the Shopware trunk -# Administration package.json (fixtures/shopware-trunk). FAKE_ABSENT_SCRIPTS -# makes a script look undefined; FAKE_BODY_NAME/FAKE_BODY makes one report a -# different body. -# -# "jest:base" is the one script answered that trunk does not define: the jest -# tool routes at it when a package declares it, and these tests model such a -# package. FAKE_ABSENT_SCRIPTS="jest:base" restores the trunk layout, where the -# tool falls back to "unit". +# Answers the `npm pkg get "scripts."` probe from the trunk fixture; see +# shopware_trunk_script_body and the FAKE_* list in common_setup.bash. _fake_admin_script_body() { local name="$1" @@ -27,11 +20,6 @@ _fake_admin_script_body() { return fi - if [[ "${name}" == "jest:base" ]]; then - printf '%s\n' '"jest --config jest.config.js"' - return - fi - shopware_trunk_script_body administration "${name}" } @@ -48,36 +36,27 @@ setup() { LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/.mcp-js-tooling.json" echo '{"environment":"native"}' > "${LINT_CONFIG_FILE}" JS_CONTEXT="admin" + # The FAKE_* variables are listed in common_setup.bash. FAKE_ABSENT_SCRIPTS="" FAKE_BODY_NAME="" FAKE_BODY="" + FAKE_DECLARES_JEST_BASE="" FAKE_PROBE_OUTPUT="" FAKE_PROBE_FILE="${BATS_TEST_TMPDIR}/probe.txt" - # The report path the jest tool uses is modelled as a real file rather than - # a canned answer, so a report left behind by an earlier run and a report - # written by this one are distinguishable: the run writes - # FAKE_REPORT_OUTPUT into the store (unless FAKE_RUN_WRITES_REPORT is 0), - # the delete removes it, and the read returns whatever is there. FAKE_REPORT_OUTPUT="$(_jest_report 13 13 0 1 0)" FAKE_REPORT_STORE="${BATS_TEST_TMPDIR}/jest-report-store.json" FAKE_RUN_WRITES_REPORT=1 - # Records the command that read the report, so a test can assert its shape. FAKE_REPORT_READ_FILE="${BATS_TEST_TMPDIR}/report-read.txt" - # Every wrapped command in order, so a test can assert call sequence. FAKE_CALL_LOG="${BATS_TEST_TMPDIR}/npm-calls.log" FAKE_CLEAR_EXIT=0 FAKE_CLEAR_OUTPUT="" FAKE_RUN_EXIT=0 - # Binaries the package's node_modules/.bin lacks, as the local-binary - # probe of a path-scoped run sees it. FAKE_MISSING_BINARIES="" - # Output of a binary probe that fails before it can test anything, such as - # a stopped container. FAKE_BINARY_PROBE_ERROR="" - # Output of a directory test that fails before it can test anything. FAKE_DIRECTORY_PROBE_ERROR="" log() { :; } source "${PLUGIN_DIR}/shared/environment.sh" + source "${PLUGIN_DIR}/shared/js_paths.sh" source "${PLUGIN_DIR}/shared/scope.sh" PROJECT_ROOT="${BATS_TEST_TMPDIR}" export PROJECT_ROOT @@ -87,37 +66,16 @@ setup() { exec_npm_command() { local cmd="$1" printf '%s\n' "${cmd}" >> "${FAKE_CALL_LOG}" + local probe_code=0 + js_fake_npm_probe "${cmd}" "/var/www/html/src/Administration/Resources/app/administration" || probe_code=$? + if [[ "${probe_code}" -ne "${JS_FAKE_NPM_PROBE_UNHANDLED}" ]]; then + return "${probe_code}" + fi case "${cmd}" in 'npm pkg get "scripts.'*) local name="${cmd#npm pkg get \"scripts.}" _fake_admin_script_body "${name%\"}" ;; - 'npm exec --no -c "[ -d '*) - # The directory test runs for real, as sh in the package - # directory, so it answers about the files a test created. - if [[ -n "${FAKE_DIRECTORY_PROBE_ERROR}" ]]; then - printf '%s\n' "${FAKE_DIRECTORY_PROBE_ERROR}" - return 1 - fi - # LINT_WORKDIR is empty here: environment.sh resets it when - # sourced, and setup() sets it before that. - (LINT_WORKDIR="${BATS_TEST_TMPDIR}"; cd "$(get_js_workdir)" 2>/dev/null || true; eval "sh -c ${cmd#npm exec --no -c }") - ;; - 'npm exec --no -c "test -x node_modules/.bin/'*) - if [[ -n "${FAKE_BINARY_PROBE_ERROR}" ]]; then - printf '%s\n' "${FAKE_BINARY_PROBE_ERROR}" - return 1 - fi - local binary="${cmd#npm exec --no -c \"test -x node_modules/.bin/}" - case " ${FAKE_MISSING_BINARIES} " in - *" ${binary%% *} "*) - # The directory as the environment reports it, which - # under a container is not a host path. - printf '%s\n%s\n' "LOCAL_BINARY_MISSING_IN" "/var/www/html/src/Administration/Resources/app/administration" - return 3 - ;; - esac - ;; 'cd "'*) printf '%s\n' "$1" > "${FAKE_PROBE_FILE}" printf '%s\n' "${FAKE_PROBE_OUTPUT}" @@ -135,9 +93,7 @@ setup() { cat -- "${FAKE_REPORT_STORE}" ;; *) - # npm refuses a script the package does not define before it - # runs anything, so a route naming one fails as it would on a - # real checkout. + # npm refuses an undefined script before running anything. if [[ "${cmd}" == "npm run "* ]]; then local script="${cmd#npm run }" script="${script%% *}" @@ -166,7 +122,7 @@ 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 \ + SCOPE_JS_SUBDIR FAKE_ABSENT_SCRIPTS FAKE_BODY_NAME FAKE_BODY FAKE_DECLARES_JEST_BASE 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_MISSING_BINARIES FAKE_BINARY_PROBE_ERROR FAKE_DIRECTORY_PROBE_ERROR PROJECT_ROOT DEV_TOOLING_STATE_FILE @@ -329,21 +285,17 @@ teardown() { refute_output --partial "--fix" } -# Trunk's "lint:scss" passes no config of its own, so a scope config is -# appended to it, and to "lint:scss-fix", which reaches it through `npm run`. +# Trunk's "lint:scss" passes no config, so a scope config is appended to it and +# to "lint:scss-fix", which reaches it through `npm run`. @test "admin stylelint fix: a scoped config without paths is appended when no script in the chain passes a config" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config run tool_stylelint_fix '{"scope":"plugin-x"}' assert_success assert_line "npm run lint:scss-fix -- --config .stylelintrc.plugin" } @test "admin stylelint fix: a scoped config without paths refuses when the chained lint:scss passes its own config" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config FAKE_BODY_NAME="lint:scss" FAKE_BODY="stylelint --config .stylelintrc **/*.scss --cache" run tool_stylelint_fix '{"scope":"plugin-x"}' @@ -352,9 +304,7 @@ JSON } @test "admin stylelint check: a scoped config without paths refuses when lint:scss passes -c" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config FAKE_BODY_NAME="lint:scss" FAKE_BODY="stylelint -c .stylelintrc **/*.scss" run tool_stylelint_check '{"scope":"plugin-x"}' @@ -363,9 +313,7 @@ JSON } @test "admin stylelint check: a scoped config without paths refuses when lint:scss passes -c=" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config FAKE_BODY_NAME="lint:scss" FAKE_BODY="stylelint -c=.stylelintrc **/*.scss" run tool_stylelint_check '{"scope":"plugin-x"}' @@ -375,9 +323,7 @@ JSON # The -c belongs to eslint, the command before the stylelint one. @test "admin stylelint check: a scoped config without paths is appended when only another program in lint:scss takes -c" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config FAKE_BODY_NAME="lint:scss" FAKE_BODY="eslint -c eslint.config.js . && stylelint **/*.scss" run tool_stylelint_check '{"scope":"plugin-x"}' @@ -399,12 +345,6 @@ JSON assert_line 'npm exec --no -- stylelint --cache --fix "src/app/assets/scss/base.scss"' } -@test "admin stylelint check: paths route carries no --fix" { - run tool_stylelint_check '{"paths":["src/app/assets/scss/base.scss"]}' - assert_success - refute_output --partial "--fix" -} - @test "admin stylelint fix: a path-scoped run never reaches the aggregate fix script" { run tool_stylelint_fix '{"paths":["src/app/assets/scss/base.scss"]}' assert_success @@ -412,9 +352,7 @@ JSON } @test "admin stylelint check: a scoped config follows the lint:scss flags" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config run tool_stylelint_check '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' assert_success assert_line 'npm exec --no -- stylelint --cache -f string --config .stylelintrc.plugin "src/scss/base.scss"' @@ -485,12 +423,6 @@ JSON assert_line 'npm exec --no -- prettier --write --cache "src/app/main.ts"' } -@test "admin prettier check: paths route carries no --write" { - run tool_prettier_check '{"paths":["src/app/main.ts"]}' - assert_success - refute_output --partial "--write" -} - @test "admin prettier fix: a path-scoped run never reaches the aggregate format script" { run tool_prettier_fix '{"paths":["src/app/main.ts"]}' assert_success @@ -609,12 +541,6 @@ match:src/app/component/g.mjs" assert_output --partial "could not check which of them are directories; the probe exited with 1. Probe output: Error response from daemon" } -# The ddev environment, with the call treated as one on a linked worktree. -_as_ddev_worktree_call() { - LINT_ENV="ddev" - _env_targets_worktree() { return 0; } -} - @test "admin stylelint fix: a directory path is refused under ddev on a worktree" { _as_ddev_worktree_call run tool_stylelint_fix '{"paths":["src/app/assets/"]}' @@ -668,14 +594,27 @@ _assert_admin_prettier_check_runs_in_package_dir() { [src/app/my dir/main.ts]" } +_admin_prettier_probe_command() { + tool_prettier_check '{"paths":["src/app/main.ts"]}' > /dev/null + grep '^npm exec --no -c "test -x node_modules/.bin/prettier ' "${FAKE_CALL_LOG}" +} + # The package installs prettier, so the probe finds it and prints nothing. _assert_admin_prettier_probe_finds_binary() { - tool_prettier_check '{"paths":["src/app/main.ts"]}' > /dev/null - run js_execute_in_env "$1" "$(grep '^npm exec --no -c "test -x node_modules/.bin/prettier ' "${FAKE_CALL_LOG}")" + run js_execute_in_env "$1" "$(_admin_prettier_probe_command)" assert_success assert_output "" } +# The package installs no prettier, so the probe misses. +_assert_admin_prettier_probe_reports_missing_binary() { + JS_FAKE_BINARIES="eslint stylelint" + run js_execute_in_env "$1" "$(_admin_prettier_probe_command)" + assert_failure 3 + assert_output "LOCAL_BINARY_MISSING_IN +${BATS_TEST_TMPDIR}/src/Administration/Resources/app/administration" +} + @test "admin stylelint command: runs in the package dir with its paths intact under native" { _assert_admin_stylelint_check_runs_in_package_dir native } @@ -748,6 +687,30 @@ _assert_admin_prettier_probe_finds_binary() { _assert_admin_prettier_probe_finds_binary ddev-worktree } +@test "admin local-binary probe: reports the missing prettier and its directory under native" { + _assert_admin_prettier_probe_reports_missing_binary native +} + +@test "admin local-binary probe: reports the missing prettier and its directory under docker" { + _assert_admin_prettier_probe_reports_missing_binary docker +} + +@test "admin local-binary probe: reports the missing prettier and its directory under docker-compose" { + _assert_admin_prettier_probe_reports_missing_binary docker-compose +} + +@test "admin local-binary probe: reports the missing prettier and its directory under vagrant" { + _assert_admin_prettier_probe_reports_missing_binary vagrant +} + +@test "admin local-binary probe: reports the missing prettier and its directory under ddev" { + _assert_admin_prettier_probe_reports_missing_binary ddev +} + +@test "admin local-binary probe: reports the missing prettier and its directory under ddev on a worktree" { + _assert_admin_prettier_probe_reports_missing_binary ddev-worktree +} + # --- Every tool with a paths parameter, against the trunk package.json --- # A path-scoped route that names an npm script Shopware does not define refuses @@ -763,14 +726,23 @@ _assert_admin_prettier_probe_finds_binary() { local refused="" for tool in "${tools[@]}"; do - if ! "tool_${tool}" '{"paths":["src/app/main.ts"]}' > "${BATS_TEST_TMPDIR}/out.txt" 2>&1 \ - || grep -q -e "is not defined in package.json" -e "Missing script" "${BATS_TEST_TMPDIR}/out.txt"; then - refused="${refused} ${tool}: $(cat "${BATS_TEST_TMPDIR}/out.txt")" - fi + "tool_${tool}" '{"paths":["src/app/main.ts"]}' > "${BATS_TEST_TMPDIR}/out.txt" 2>&1 \ + || refused="${refused} ${tool}: $(cat "${BATS_TEST_TMPDIR}/out.txt")" done + local line script undefined="" + while IFS= read -r line; do + script="${line#npm run }" + script="${script%% *}" + if [[ "$(shopware_trunk_script_body administration "${script}")" == "{}" ]]; then + undefined="${undefined} ${script}" + fi + done < <(grep '^npm run ' "${FAKE_CALL_LOG}" || true) + + # An empty enumeration would pass the loops above without running a tool. assert [ "${#tools[@]}" -ge 6 ] assert_equal "${refused}" "" + assert_equal "${undefined}" "" } # --- TypeScript --- @@ -808,18 +780,21 @@ JSON # --- Jest --- @test "admin jest: base command routes at jest:base and asks for the JSON report" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{}' assert_success assert_line --index 2 "npm run jest:base -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" } @test "admin jest: default run appends no --ci" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{}' assert_success refute_output --partial "--ci" } @test "admin jest: ci=true appends --ci and still asks for the JSON report" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{"ci":true}' assert_success assert_line --index 2 "npm run jest:base -- --ci --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" @@ -828,7 +803,7 @@ JSON @test "admin jest: testPathPatterns flag added when provided" { run tool_jest_run '{"testPathPatterns":"CartService"}' assert_success - assert_line --index 2 --partial 'npm run jest:base -- --testPathPatterns="CartService" --json' + assert_line --index 3 --partial 'npm run unit -- --testPathPatterns="CartService" --json' } @test "admin jest: coverage flag added when coverage=true" { @@ -840,7 +815,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 2 --partial 'npm run jest:base -- --testNamePattern="adds to cart" --json' + assert_line --index 3 --partial 'npm run unit -- --testNamePattern="adds to cart" --json' } @test "admin jest: refuses a test name pattern containing a single quote" { @@ -864,21 +839,18 @@ JSON } @test "admin jest: falls back to npm run unit when jest:base is absent" { - FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success 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 --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 --partial "jest-silent-reporter" @@ -889,7 +861,7 @@ JSON @test "admin jest: the counts summary precedes the command output" { run tool_jest_run '{}' assert_success - 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." + assert_line --index 2 "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" { @@ -905,8 +877,8 @@ JSON run tool_jest_run '{}' assert_success run cat "${FAKE_CALL_LOG}" - assert_line --index 1 "rm -f -- \"${ADMIN_JEST_REPORT_FILE}\"" - assert_line --index 2 "npm run jest:base -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" + assert_line --index 2 "rm -f -- \"${ADMIN_JEST_REPORT_FILE}\"" + assert_line --index 3 "npm run unit -- --json --outputFile=\"${ADMIN_JEST_REPORT_FILE}\"" } @test "admin jest: a run that writes no report fails instead of reusing the report left by the previous run" { @@ -932,7 +904,7 @@ JSON run tool_jest_run '{}' assert_failure 1 run cat "${FAKE_CALL_LOG}" - refute_output --partial "npm run jest:base --" + refute_output --partial "npm run unit --" } @test "admin jest: failed tests in the report fail the tool even when the process exited 0" { @@ -982,13 +954,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 2 --partial "every test passed, but the jest process still exited with code 7" + assert_line --index 3 --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 3 --partial "npm run jest:base" + assert_line --index 4 --partial "npm run unit" } @test "admin jest: a report that is not JSON propagates the process exit code" { diff --git a/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats b/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats index da525bb5..42fe6a9c 100644 --- a/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats +++ b/plugin-tests/dev-tooling/mcp_tool_js_storefront.bats @@ -6,15 +6,8 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" -# Answers the `npm pkg get "scripts."` probe from the Shopware trunk -# Storefront package.json (fixtures/shopware-trunk). FAKE_ABSENT_SCRIPTS makes -# a script look undefined; FAKE_BODY_NAME/FAKE_BODY makes one report a -# different body. -# -# "jest:base" is the one script answered that trunk does not define: the jest -# tool routes at it when a package declares it, and these tests model such a -# package. FAKE_ABSENT_SCRIPTS="jest:base" restores the trunk layout, where the -# tool falls back to "unit". +# Answers the `npm pkg get "scripts."` probe from the trunk fixture; see +# shopware_trunk_script_body and the FAKE_* list in common_setup.bash. _fake_script_body() { local name="$1" @@ -27,11 +20,6 @@ _fake_script_body() { return fi - if [[ "${name}" == "jest:base" ]]; then - printf '%s\n' '"jest --config jest.config.js"' - return - fi - shopware_trunk_script_body storefront "${name}" } @@ -48,36 +36,27 @@ setup() { LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/.mcp-js-tooling.json" echo '{"environment":"native"}' > "${LINT_CONFIG_FILE}" JS_CONTEXT="storefront" + # The FAKE_* variables are listed in common_setup.bash. FAKE_ABSENT_SCRIPTS="" FAKE_BODY_NAME="" FAKE_BODY="" + FAKE_DECLARES_JEST_BASE="" FAKE_PROBE_OUTPUT="" FAKE_PROBE_FILE="${BATS_TEST_TMPDIR}/probe.txt" - # The report path the jest tool uses is modelled as a real file rather than - # a canned answer, so a report left behind by an earlier run and a report - # written by this one are distinguishable: the run writes - # FAKE_REPORT_OUTPUT into the store (unless FAKE_RUN_WRITES_REPORT is 0), - # the delete removes it, and the read returns whatever is there. FAKE_REPORT_OUTPUT="$(_jest_report 13 13 0 1 0)" FAKE_REPORT_STORE="${BATS_TEST_TMPDIR}/jest-report-store.json" FAKE_RUN_WRITES_REPORT=1 - # Records the command that read the report, so a test can assert its shape. FAKE_REPORT_READ_FILE="${BATS_TEST_TMPDIR}/report-read.txt" - # Every wrapped command in order, so a test can assert call sequence. FAKE_CALL_LOG="${BATS_TEST_TMPDIR}/npm-calls.log" FAKE_CLEAR_EXIT=0 FAKE_CLEAR_OUTPUT="" FAKE_RUN_EXIT=0 - # Binaries the package's node_modules/.bin lacks, as the local-binary - # probe of a path-scoped run sees it. FAKE_MISSING_BINARIES="" - # Output of a binary probe that fails before it can test anything, such as - # a stopped container. FAKE_BINARY_PROBE_ERROR="" - # Output of a directory test that fails before it can test anything. FAKE_DIRECTORY_PROBE_ERROR="" log() { :; } source "${PLUGIN_DIR}/shared/environment.sh" + source "${PLUGIN_DIR}/shared/js_paths.sh" source "${PLUGIN_DIR}/shared/scope.sh" PROJECT_ROOT="${BATS_TEST_TMPDIR}" export PROJECT_ROOT @@ -87,37 +66,16 @@ setup() { exec_npm_command() { local cmd="$1" printf '%s\n' "${cmd}" >> "${FAKE_CALL_LOG}" + local probe_code=0 + js_fake_npm_probe "${cmd}" "/var/www/html/src/Storefront/Resources/app/storefront" || probe_code=$? + if [[ "${probe_code}" -ne "${JS_FAKE_NPM_PROBE_UNHANDLED}" ]]; then + return "${probe_code}" + fi case "${cmd}" in 'npm pkg get "scripts.'*) local name="${cmd#npm pkg get \"scripts.}" _fake_script_body "${name%\"}" ;; - 'npm exec --no -c "[ -d '*) - # The directory test runs for real, as sh in the package - # directory, so it answers about the files a test created. - if [[ -n "${FAKE_DIRECTORY_PROBE_ERROR}" ]]; then - printf '%s\n' "${FAKE_DIRECTORY_PROBE_ERROR}" - return 1 - fi - # LINT_WORKDIR is empty here: environment.sh resets it when - # sourced, and setup() sets it before that. - (LINT_WORKDIR="${BATS_TEST_TMPDIR}"; cd "$(get_js_workdir)" 2>/dev/null || true; eval "sh -c ${cmd#npm exec --no -c }") - ;; - 'npm exec --no -c "test -x node_modules/.bin/'*) - if [[ -n "${FAKE_BINARY_PROBE_ERROR}" ]]; then - printf '%s\n' "${FAKE_BINARY_PROBE_ERROR}" - return 1 - fi - local binary="${cmd#npm exec --no -c \"test -x node_modules/.bin/}" - case " ${FAKE_MISSING_BINARIES} " in - *" ${binary%% *} "*) - # The directory as the environment reports it, which - # under a container is not a host path. - printf '%s\n%s\n' "LOCAL_BINARY_MISSING_IN" "/var/www/html/src/Storefront/Resources/app/storefront" - return 3 - ;; - esac - ;; 'cd "'*) printf '%s\n' "$1" > "${FAKE_PROBE_FILE}" printf '%s\n' "${FAKE_PROBE_OUTPUT}" @@ -135,9 +93,7 @@ setup() { cat -- "${FAKE_REPORT_STORE}" ;; *) - # npm refuses a script the package does not define before it - # runs anything, so a route naming one fails as it would on a - # real checkout. + # npm refuses an undefined script before running anything. if [[ "${cmd}" == "npm run "* ]]; then local script="${cmd#npm run }" script="${script%% *}" @@ -166,7 +122,7 @@ 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 \ + SCOPE_JS_SUBDIR FAKE_ABSENT_SCRIPTS FAKE_BODY_NAME FAKE_BODY FAKE_DECLARES_JEST_BASE 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_MISSING_BINARIES FAKE_BINARY_PROBE_ERROR FAKE_DIRECTORY_PROBE_ERROR PROJECT_ROOT DEV_TOOLING_STATE_FILE @@ -231,12 +187,6 @@ teardown() { assert_line 'npm exec --no -c "cd ../.. && eslint --no-error-on-unmatched-pattern --report-unused-disable-directives --config ./app/storefront/eslint.config.js -f stylish \"views/components/checkout/cart.js\""' } -@test "storefront eslint check: paths route carries no --fix" { - run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js","views/components/checkout/cart.js"]}' - assert_success - refute_output --partial "--fix" -} - @test "storefront eslint check: json format applied when paths are supplied" { run tool_eslint_check '{"paths":["src/plugin/cart.plugin.js"],"output_format":"json"}' assert_success @@ -304,7 +254,6 @@ JSON # --- ESLint: the built commands executed through each environment wrapper --- -# The command a components-tree check builds for one path holding a space. _components_check_command() { tool_eslint_check '{"paths":["views/components/my dir/cart.js"]}' > /dev/null grep '^npm exec --no -c "cd ../.. ' "${FAKE_CALL_LOG}" @@ -546,32 +495,24 @@ JSON refute_output --partial "--fix" } -# Trunk's "lint:scss" passes --config stylelint.config.js, and "lint:scss-fix" -# reaches it through `npm run lint:scss`. Stylelint exits with "The flag -# --config can only be set once." on a second --config, so appending a scope -# config to either cannot run. -_set_stylelint_scope() { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON -} - +# Trunk's "lint:scss" passes --config, and "lint:scss-fix" runs it, so a scope +# config appended to either is a second --config, which Stylelint refuses. @test "storefront stylelint check: a scoped config without paths refuses when lint:scss passes its own config" { - _set_stylelint_scope + _use_stylelint_scope_config run tool_stylelint_check '{"scope":"plugin-x"}' assert_failure assert_output --partial "\"npm run lint:scss\" already passes a config of its own" } @test "storefront stylelint fix: a scoped config without paths refuses when lint:scss-fix reaches a script passing its own config" { - _set_stylelint_scope + _use_stylelint_scope_config run tool_stylelint_fix '{"scope":"plugin-x"}' assert_failure assert_output --partial "\"npm run lint:scss-fix\" already passes a config of its own" } @test "storefront stylelint fix: the refused scoped run executes no aggregate script" { - _set_stylelint_scope + _use_stylelint_scope_config run tool_stylelint_fix '{"scope":"plugin-x"}' assert_failure run cat "${FAKE_CALL_LOG}" @@ -592,32 +533,22 @@ JSON assert_line 'npm exec --no -- stylelint --config stylelint.config.js --cache --fix "src/scss/base.scss"' } -@test "storefront stylelint check: paths route carries no --fix" { - run tool_stylelint_check '{"paths":["src/scss/base.scss"]}' - assert_success - refute_output --partial "--fix" -} - @test "storefront stylelint fix: a path-scoped run never reaches the aggregate fix script" { run tool_stylelint_fix '{"paths":["src/scss/base.scss"]}' assert_success refute_output --partial "lint:scss-fix" } -# Stylelint exits with "The flag --config can only be set once." on a second -# --config, so the scoped one has to replace the package's rather than follow it. +# A second --config is refused, so the scoped one replaces the package's. @test "storefront stylelint fix: a scoped config replaces stylelint.config.js" { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config run tool_stylelint_fix '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' assert_success assert_line 'npm exec --no -- stylelint --cache --config .stylelintrc.plugin --fix "src/scss/base.scss"' } @test "storefront stylelint fix: a directory path is refused under ddev on a worktree" { - LINT_ENV="ddev" - _env_targets_worktree() { return 0; } + _as_ddev_worktree_call run tool_stylelint_fix '{"paths":["src/scss/"]}' assert_failure assert_output --partial "under ddev on a worktree these paths would reach Stylelint as glob patterns" @@ -631,9 +562,7 @@ JSON } _set_stylelint_scope_with_body() { - cat > "${LINT_CONFIG_FILE}" <<'JSON' -{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} -JSON + _use_stylelint_scope_config FAKE_BODY_NAME="lint:scss" FAKE_BODY="$1" } @@ -714,31 +643,43 @@ JSON local refused="" for tool in "${tools[@]}"; do - if ! "tool_${tool}" '{"paths":["views/components/checkout/cart.js"]}' > "${BATS_TEST_TMPDIR}/out.txt" 2>&1 \ - || grep -q -e "is not defined in package.json" -e "Missing script" "${BATS_TEST_TMPDIR}/out.txt"; then - refused="${refused} ${tool}: $(cat "${BATS_TEST_TMPDIR}/out.txt")" - fi + "tool_${tool}" '{"paths":["views/components/checkout/cart.js"]}' > "${BATS_TEST_TMPDIR}/out.txt" 2>&1 \ + || refused="${refused} ${tool}: $(cat "${BATS_TEST_TMPDIR}/out.txt")" done + local line script undefined="" + while IFS= read -r line; do + script="${line#npm run }" + script="${script%% *}" + if [[ "$(shopware_trunk_script_body storefront "${script}")" == "{}" ]]; then + undefined="${undefined} ${script}" + fi + done < <(grep '^npm run ' "${FAKE_CALL_LOG}" || true) + + # An empty enumeration would pass the loops above without running a tool. assert [ "${#tools[@]}" -ge 5 ] assert_equal "${refused}" "" + assert_equal "${undefined}" "" } # --- Jest --- @test "storefront jest: base command routes at jest:base and asks for the JSON report" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{}' assert_success assert_line --index 2 "npm run jest:base -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" } @test "storefront jest: default run appends no --ci" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{}' assert_success refute_output --partial "--ci" } @test "storefront jest: ci=true appends --ci and still asks for the JSON report" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{"ci":true}' assert_success assert_line --index 2 "npm run jest:base -- --ci --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" @@ -747,7 +688,7 @@ JSON @test "storefront jest: testPathPatterns flag added when provided" { run tool_jest_run '{"testPathPatterns":"CartPlugin"}' assert_success - assert_line --index 2 --partial 'npm run jest:base -- --testPathPatterns="CartPlugin" --json' + assert_line --index 3 --partial 'npm run unit -- --testPathPatterns="CartPlugin" --json' } @test "storefront jest: coverage flag added when coverage=true" { @@ -759,11 +700,10 @@ 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 2 --partial 'npm run jest:base -- --testNamePattern="adds to cart" --json' + assert_line --index 3 --partial 'npm run unit -- --testNamePattern="adds to cart" --json' } @test "storefront jest: fails hard when the unit fallback cannot take arguments" { - FAKE_ABSENT_SCRIPTS="jest:base" FAKE_BODY_NAME="unit" FAKE_BODY="npm run unit:ci" run tool_jest_run '{"testNamePattern":"adds to cart"}' @@ -772,34 +712,31 @@ JSON } @test "storefront jest: falls back to npm run unit when jest:base is absent" { - FAKE_ABSENT_SCRIPTS="jest:base" run tool_jest_run '{}' assert_success 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 --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 --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 --partial "\"updateSnapshots\" itself still takes effect" } @test "storefront jest: ci and updateSnapshots together keep --updateSnapshot, which wins over --ci" { + FAKE_DECLARES_JEST_BASE=1 run tool_jest_run '{"ci":true,"updateSnapshots":true}' assert_success assert_line --index 2 --partial "--updateSnapshot" @@ -829,7 +766,7 @@ JSON @test "storefront jest: the counts summary precedes the command output" { run tool_jest_run '{}' assert_success - 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." + assert_line --index 2 "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" { @@ -845,8 +782,8 @@ JSON run tool_jest_run '{}' assert_success run cat "${FAKE_CALL_LOG}" - assert_line --index 1 "rm -f -- \"${STOREFRONT_JEST_REPORT_FILE}\"" - assert_line --index 2 "npm run jest:base -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" + assert_line --index 2 "rm -f -- \"${STOREFRONT_JEST_REPORT_FILE}\"" + assert_line --index 3 "npm run unit -- --json --outputFile=\"${STOREFRONT_JEST_REPORT_FILE}\"" } @test "storefront jest: a run that writes no report fails instead of reusing the report left by the previous run" { @@ -872,7 +809,7 @@ JSON run tool_jest_run '{}' assert_failure 1 run cat "${FAKE_CALL_LOG}" - refute_output --partial "npm run jest:base --" + refute_output --partial "npm run unit --" } @test "storefront jest: failed tests in the report fail the tool even when the process exited 0" { @@ -922,13 +859,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 2 --partial "every test passed, but the jest process still exited with code 7" + assert_line --index 3 --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 3 --partial "npm run jest:base" + assert_line --index 4 --partial "npm run unit" } @test "storefront jest: a report that is not JSON propagates the process exit code" { diff --git a/plugin-tests/dev-tooling/scope_js_tools.bats b/plugin-tests/dev-tooling/scope_js_tools.bats index 888653de..68fe4cf0 100644 --- a/plugin-tests/dev-tooling/scope_js_tools.bats +++ b/plugin-tests/dev-tooling/scope_js_tools.bats @@ -6,21 +6,6 @@ load 'test_helper/common_setup' PLUGIN_DIR="${REPO_ROOT}/plugins/dev-tooling" -# Answers the `npm pkg get "scripts."` probe from the Shopware trunk -# Administration package.json (fixtures/shopware-trunk), plus "jest:base", -# which trunk does not define: the jest tool routes at it when a package -# declares it, and the scoped jest cases below model such a package. -_fake_admin_script_body() { - local name="$1" - - if [[ "${name}" == "jest:base" ]]; then - printf '%s\n' '"jest --config jest.config.js"' - return - fi - - shopware_trunk_script_body administration "${name}" -} - setup() { LINT_CONFIG_FILE="${BATS_TEST_TMPDIR}/.mcp-js-tooling.json" JS_CONTEXT="admin" @@ -41,8 +26,10 @@ setup() { } JSON log() { :; } + FAKE_DECLARES_JEST_BASE="" CALLS_FILE="${BATS_TEST_TMPDIR}/calls.log" source "${PLUGIN_DIR}/shared/environment.sh" + source "${PLUGIN_DIR}/shared/js_paths.sh" source "${PLUGIN_DIR}/shared/scope.sh" PROJECT_ROOT="${BATS_TEST_TMPDIR}" export PROJECT_ROOT @@ -60,7 +47,7 @@ JSON case "${cmd}" in 'npm pkg get "scripts.'*) local name="${cmd#npm pkg get \"scripts.}" - _fake_admin_script_body "${name%\"}" + shopware_trunk_script_body administration "${name%\"}" ;; *) printf '%s\n' "${cmd}" @@ -78,7 +65,7 @@ JSON teardown() { 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 + FAKE_DECLARES_JEST_BASE PROJECT_ROOT DEV_TOOLING_STATE_FILE } @test "eslint scoped: runs under scope cwd" { @@ -111,7 +98,7 @@ teardown() { run tool_stylelint_check '{"scope":"plugin-x","paths":["src/scss/base.scss"]}' assert_success run cat "${CALLS_FILE}" - assert_line '[scope=custom/plugins/X|sub=] npm exec --no -c "test -x node_modules/.bin/stylelint || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }"' + assert_line --partial '[scope=custom/plugins/X|sub=] npm exec --no -c "test -x node_modules/.bin/stylelint' assert_line '[scope=custom/plugins/X|sub=] npm exec --no -- stylelint --cache -f string "src/scss/base.scss"' } @@ -142,7 +129,7 @@ _set_jest_env() { _set_jest_env '{"NODE_OPTIONS":"--require ./bootstrap.js"}' run tool_jest_run '{"scope":"plugin-x"}' assert_success - assert_output --partial 'NODE_OPTIONS="--require ./bootstrap.js" npm run jest:base' + assert_output --partial 'NODE_OPTIONS="--require ./bootstrap.js" npm run unit' } @test "jest scoped: an env value containing a line break is refused" { @@ -196,6 +183,7 @@ _set_jest_env() { } @test "jest scoped: routes at jest:base, not the fallback aggregate" { + FAKE_DECLARES_JEST_BASE=1 mkdir -p "${BATS_TEST_TMPDIR}/custom/plugins/X/tests/jest/administration/node_modules" run tool_jest_run '{"scope":"plugin-x"}' assert_success diff --git a/plugin-tests/dev-tooling/test_helper/common_setup.bash b/plugin-tests/dev-tooling/test_helper/common_setup.bash index 942b12ee..3ed8f243 100644 --- a/plugin-tests/dev-tooling/test_helper/common_setup.bash +++ b/plugin-tests/dev-tooling/test_helper/common_setup.bash @@ -211,44 +211,89 @@ setup_php_mcp_env() { # Answer an `npm pkg get "scripts."` probe the way npm answers it against # the Shopware package.json whose "scripts" block the fixture copies verbatim # from shopware/shopware trunk (c59a7d3484c): the body as a JSON string, or -# `{}` for a script the package does not define. +# `{}` for a script the package does not define. Trunk defines no "jest:base"; +# FAKE_DECLARES_JEST_BASE=1 models a package that does. +# Globals: reads FAKE_DECLARES_JEST_BASE # Args: $1 = package, "administration" or "storefront", $2 = script name # Stdout: the probe answer shopware_trunk_script_body() { + if [[ "$2" == "jest:base" && "${FAKE_DECLARES_JEST_BASE:-}" == "1" ]]; then + printf '%s\n' '"jest --config jest.config.js"' + return + fi jq --arg name "$2" 'if has($name) then .[$name] else {} end' \ "${BATS_TEST_DIRNAME}/fixtures/shopware-trunk/$1-package-scripts.json" } -# Executes one command a JS tool built the way exec_npm_command does: -# wrap_npm_command wraps it for the environment, and the result is evaluated -# from the package directory get_js_workdir names under LINT_WORKDIR set to -# BATS_TEST_TMPDIR. docker, vagrant and ddev are functions doing what each does -# with its argv: docker and vagrant hand their string to a shell; `ddev npm` -# passes argv unchanged (its command file sets ExecRaw); `ddev exec -d` joins -# argv, double-quoting an argument holding a space, tab, line break, `"` or -# `#`, and parses the result with `bash -c` again. Under docker-compose the real -# compose module builds the wrapper, with only its prerequisite check and its -# container and workdir resolvers, which query docker compose, replaced. npm is -# a script implementing the two `npm exec` forms as npm 11 runs them: `-c` hands -# the string to sh with the package's node_modules/.bin first on PATH, `--` -# runs that directory's binary. Each binary named in JS_FAKE_BINARIES (default: -# eslint, stylelint and prettier) is installed in the package's node_modules/.bin -# and prints its working directory, one bracketed line per argument, and, for -# an argument holding `*`, one `match:` line per file the pattern names, as -# bash expands it with globstar from that directory. -# Globals: reads JS_CONTEXT, PLUGIN_DIR, JS_FAKE_BINARIES -# Args: $1 = native, docker, docker-compose, vagrant, ddev or ddev-worktree, -# $2 = the command -js_execute_in_env() { - local env="$1" cmd="$2" +# The status js_fake_npm_probe returns for a command that is neither probe. +JS_FAKE_NPM_PROBE_UNHANDLED=125 - # environment.sh resets LINT_WORKDIR when sourced; a tool call rebinds it - # through worktree_enter, and this helper calls no tool. - LINT_WORKDIR="${BATS_TEST_TMPDIR}" - local package_dir - package_dir=$(get_js_workdir) - local bin_dir="${BATS_TEST_TMPDIR}/fake-bin" - mkdir -p "${package_dir}/node_modules/.bin" "${bin_dir}" +# The fake exec_npm_command of mcp_tool_js_admin.bats and +# mcp_tool_js_storefront.bats answers from these variables, set in their setup(): +# FAKE_ABSENT_SCRIPTS scripts answered `{}`, as if the package lacked them +# FAKE_BODY_NAME, FAKE_BODY one script answered with a different body +# FAKE_PROBE_OUTPUT the answer of the path probe; FAKE_PROBE_FILE records its command +# FAKE_CALL_LOG every wrapped command, in order +# FAKE_REPORT_OUTPUT what a Jest run writes into FAKE_REPORT_STORE, a real file, so +# a report an earlier run left differs from one this run wrote; +# FAKE_RUN_WRITES_REPORT=0 makes the run write none, the clear +# deletes the file and the read returns whatever is there +# FAKE_REPORT_READ_FILE the command that read the report +# FAKE_CLEAR_EXIT, FAKE_CLEAR_OUTPUT, FAKE_RUN_EXIT status and output of the clear and the run +# The probe variables are listed at js_fake_npm_probe. + +# Answer the two probes a path-scoped JS lint runs through `npm exec --no -c`, +# for an exec_npm_command fake. The directory test runs for real, as sh in the +# package directory, so it answers about the files a test created. The +# local-binary probe reports a binary named in FAKE_MISSING_BINARIES missing in +# the given directory. FAKE_DIRECTORY_PROBE_ERROR and FAKE_BINARY_PROBE_ERROR +# make the probe fail before it can test anything, such as on a stopped +# container. +# Globals: reads FAKE_DIRECTORY_PROBE_ERROR, FAKE_BINARY_PROBE_ERROR, +# FAKE_MISSING_BINARIES, BATS_TEST_TMPDIR +# Args: $1 = the command, $2 = the package directory the binary probe reports, +# which under a container is not a host path +# Returns: the probe's status, or JS_FAKE_NPM_PROBE_UNHANDLED for any other +# command +js_fake_npm_probe() { + local cmd="$1" + local package_dir="$2" + + case "${cmd}" in + 'npm exec --no -c "[ -d '*) + if [[ -n "${FAKE_DIRECTORY_PROBE_ERROR}" ]]; then + printf '%s\n' "${FAKE_DIRECTORY_PROBE_ERROR}" + return 1 + fi + # LINT_WORKDIR is empty here: environment.sh resets it when + # sourced, and setup() sets it before that. + (LINT_WORKDIR="${BATS_TEST_TMPDIR}"; cd "$(get_js_workdir)" 2>/dev/null || true; eval "sh -c ${cmd#npm exec --no -c }") + ;; + 'npm exec --no -c "test -x node_modules/.bin/'*) + if [[ -n "${FAKE_BINARY_PROBE_ERROR}" ]]; then + printf '%s\n' "${FAKE_BINARY_PROBE_ERROR}" + return 1 + fi + local binary="${cmd#npm exec --no -c \"test -x node_modules/.bin/}" + case " ${FAKE_MISSING_BINARIES} " in + *" ${binary%% *} "*) + printf '%s\n%s\n' "LOCAL_BINARY_MISSING_IN" "${package_dir}" + return 3 + ;; + esac + ;; + *) + return "${JS_FAKE_NPM_PROBE_UNHANDLED}" + ;; + esac +} + +# Installs the fake npm in the directory $1. It implements the two `npm exec` +# forms as npm 11 runs them: `-c` hands the string to sh with the package's +# node_modules/.bin first on PATH, `--` runs that directory's binary. +_js_install_fake_npm() { + local bin_dir="$1" + mkdir -p "${bin_dir}" cat > "${bin_dir}/npm" <<'NPM' #!/usr/bin/env bash [[ "$1 $2" == "exec --no" ]] || exit 90 @@ -259,8 +304,16 @@ esac exit 91 NPM chmod +x "${bin_dir}/npm" +} - local binary +# Installs each binary named in JS_FAKE_BINARIES (default: eslint, stylelint and +# prettier) in the package directory $1's node_modules/.bin. A fake prints its +# working directory, one bracketed line per argument and, for an argument +# holding `*`, one `match:` line per file the pattern names, as bash expands it +# with globstar from that directory. +_js_install_fake_linters() { + local package_dir="$1" binary + mkdir -p "${package_dir}/node_modules/.bin" for binary in ${JS_FAKE_BINARIES-eslint stylelint prettier}; do cat > "${package_dir}/node_modules/.bin/${binary}" <<'LINTER' #!/usr/bin/env bash @@ -275,7 +328,17 @@ done LINTER chmod +x "${package_dir}/node_modules/.bin/${binary}" done +} +# Defines docker, vagrant and ddev as functions doing what each does with its +# argv. docker and vagrant hand their string to a shell. The ddev shim models +# ddev/ddev main 94c92c96415f: `ddev npm` is the command file +# pkg/ddevapp/global_dotddev_assets/commands/web/npm (`## ExecRaw: true`), and +# cmd/ddev/cmd/commands.go:528 (`opts.RawCmd = append([]string{fullPath}, +# osArgs...)`) hands it argv unchanged; `ddev exec -d` joins argv, +# double-quoting an argument holding a space, tab, line break, `"` or `#` +# (quoteArgs), and parses the result with `bash -c` again. +_js_define_env_shims() { docker() { bash -c "${!#}"; } vagrant() { bash -c "$3"; } ddev() { @@ -295,6 +358,27 @@ LINTER done bash -c "cd \"${dir}\" && ( ${joined} )" } +} + +# Runs a command a JS tool built, through the real environment wrapper, with +# fakes that reproduce each argv-parsing step, so a quoting bug surfaces as it +# would on the real tool. The docker-compose path builds its wrapper with the +# real compose module but stubs `_compose_resolve_workdir`, so the real workdir +# join is not exercised. +# Globals: reads JS_CONTEXT, PLUGIN_DIR, JS_FAKE_BINARIES +# Args: $1 = native, docker, docker-compose, vagrant, ddev or ddev-worktree, +# $2 = the command +js_execute_in_env() { + local env="$1" cmd="$2" + + # environment.sh resets LINT_WORKDIR when sourced; a tool call rebinds it + # through worktree_enter, and this helper calls no tool. + LINT_WORKDIR="${BATS_TEST_TMPDIR}" + local package_dir bin_dir="${BATS_TEST_TMPDIR}/fake-bin" + package_dir=$(get_js_workdir) + _js_install_fake_npm "${bin_dir}" + _js_install_fake_linters "${package_dir}" + _js_define_env_shims LINT_ENV="${env}" if [[ "${env}" == "docker-compose" ]]; then @@ -316,3 +400,23 @@ LINTER cd "${package_dir}" || return 93 eval "${wrapped}" } + +# Overrides _env_targets_worktree and LINT_ENV for the rest of the test. +_as_ddev_worktree_call() { + LINT_ENV="ddev" + _env_targets_worktree() { return 0; } +} + +# Writes LINT_CONFIG_FILE with scope "plugin-x" carrying a Stylelint config. +_use_stylelint_scope_config() { + cat > "${LINT_CONFIG_FILE}" <<'JSON' +{"environment":"native","scopes":{"plugin-x":{"cwd":"custom/plugins/X","stylelint":{"config":".stylelintrc.plugin"}}}} +JSON +} + +# The forms a linter binary takes in a command; BIN stands for its name. The hook +# suites check every binary in every form: a form missing for one binary fails. +JS_HOOK_BINARY_FORMS=("npm exec BIN" "npm exec -- BIN" "npm exec --no -- BIN" + "npx BIN" "npx --no-install BIN" "npx -y BIN" + "npx BIN@9.1.0" "npm exec BIN@9.1.0" + "node_modules/.bin/BIN" "./node_modules/.bin/BIN") diff --git a/plugin-tests/dev-tooling/worktree_conformance.bats b/plugin-tests/dev-tooling/worktree_conformance.bats index bd367d69..98c24d1b 100644 --- a/plugin-tests/dev-tooling/worktree_conformance.bats +++ b/plugin-tests/dev-tooling/worktree_conformance.bats @@ -83,6 +83,7 @@ TOOLS_JSON="\${PLUGIN_DIR}/${server_dir}/tools.json" log() { :; } source "\${PLUGIN_DIR}/shared/config.sh" source "\${PLUGIN_DIR}/shared/environment.sh" +source "\${PLUGIN_DIR}/shared/js_paths.sh" source "\${PLUGIN_DIR}/shared/scope.sh" PROJECT_ROOT=$(printf '%q' "${LAUNCH_ROOT}") export PROJECT_ROOT diff --git a/plugin-tests/dev-tooling/worktree_js_tools.bats b/plugin-tests/dev-tooling/worktree_js_tools.bats index 997a699a..ac7cfb91 100644 --- a/plugin-tests/dev-tooling/worktree_js_tools.bats +++ b/plugin-tests/dev-tooling/worktree_js_tools.bats @@ -65,6 +65,7 @@ CALLS_FILE=$(printf '%q' "${calls_file}") log() { :; } source "\${PLUGIN_DIR}/shared/config.sh" source "\${PLUGIN_DIR}/shared/environment.sh" +source "\${PLUGIN_DIR}/shared/js_paths.sh" source "\${PLUGIN_DIR}/shared/scope.sh" PROJECT_ROOT=$(printf '%q' "${LAUNCH_ROOT}") export PROJECT_ROOT @@ -139,9 +140,6 @@ BODY "{\"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" { @@ -150,9 +148,6 @@ BODY "{\"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" { diff --git a/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh b/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh index 2ab39856..ab093848 100755 --- a/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-js-admin-tools.sh @@ -11,6 +11,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/js_binary.sh" parse_hook_input load_mcp_config "js-tooling" @@ -39,35 +40,6 @@ is_admin_context() { return 0 } -# A direct run of a tool binary (a linter, jest, tsc or vitest) at a command -# position: `npx`, `npm exec` or `npm x` invoking it, or its node_modules/.bin path. The -# runner and the binary are separated by zero or more of the listed flags that -# take no value, and an optional `--` (`npx --no-install eslint`, -# `npm exec --no -- prettier`). A flag that takes a value -# (`--package eslint some-tool`) is not listed: its value would read as the -# binary and block a command that does not run it. A version suffix -# (`npx eslint@9`) is allowed after the binary. A bare `eslint` is not matched: -# the word sits at a command position inside any quoted text that follows `;`, -# `&&` or `|`, so a commit message would turn into a hard block. `npm x` is -# matched wherever `npm exec` is. -BINARY_INVOCATION='(^|;|&&|\|)\s*((npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|--))*\s+|\S*node_modules/\.bin/)' - -# The -c / --call form of the same runners takes a command string; the binary -# is matched at a command position inside it: its start, or after `;`, `&&` or -# `|` within the string, past leading blanks and variable assignments -# (`npm exec --no -c "cd ../.. && eslint views/components"`, the form the MCP -# tools run). Before -c, the flags above and --package, -p, --workspace, -w with -# their value are allowed: with -c the string, not the package, names what runs. -# A closing quote may follow the binary. -CALL_INVOCATION="(^|;|&&|\|)\s*(npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|(--package|--workspace|-p|-w)(=|\s+)[^[:space:]\"';&|]+))*\s+(-c|--call)(\s+|=)[\"']?\s*([^\"';&|]*(;|&&|\|)\s*)*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]\"';&|]*\s+)*" - -# Args: $1 = binary name -# Returns: 0 when the command runs the binary in one of the forms above -runs_binary() { - echo "$COMMAND" | grep -qE "${BINARY_INVOCATION}$1(@\S+)?(\s|\$)" \ - || echo "$COMMAND" | grep -qE "${CALL_INVOCATION}$1(@\S+)?(\s|\$|[\"'])" -} - # Only process if in Admin context if ! is_admin_context; then exit 0 diff --git a/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh b/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh index a9c537b5..ae39f9a7 100755 --- a/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh +++ b/plugins/dev-tooling/hooks/scripts/check-js-storefront-tools.sh @@ -11,6 +11,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/js_binary.sh" parse_hook_input load_mcp_config "js-tooling" @@ -45,35 +46,6 @@ is_storefront_context() { return 1 } -# A direct run of a tool binary (a linter, jest, tsc or vitest) at a command -# position: `npx`, `npm exec` or `npm x` invoking it, or its node_modules/.bin path. The -# runner and the binary are separated by zero or more of the listed flags that -# take no value, and an optional `--` (`npx --no-install eslint`, -# `npm exec --no -- prettier`). A flag that takes a value -# (`--package eslint some-tool`) is not listed: its value would read as the -# binary and block a command that does not run it. A version suffix -# (`npx eslint@9`) is allowed after the binary. A bare `eslint` is not matched: -# the word sits at a command position inside any quoted text that follows `;`, -# `&&` or `|`, so a commit message would turn into a hard block. `npm x` is -# matched wherever `npm exec` is. -BINARY_INVOCATION='(^|;|&&|\|)\s*((npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|--))*\s+|\S*node_modules/\.bin/)' - -# The -c / --call form of the same runners takes a command string; the binary -# is matched at a command position inside it: its start, or after `;`, `&&` or -# `|` within the string, past leading blanks and variable assignments -# (`npm exec --no -c "cd ../.. && eslint views/components"`, the form the MCP -# tools run). Before -c, the flags above and --package, -p, --workspace, -w with -# their value are allowed: with -c the string, not the package, names what runs. -# A closing quote may follow the binary. -CALL_INVOCATION="(^|;|&&|\|)\s*(npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|(--package|--workspace|-p|-w)(=|\s+)[^[:space:]\"';&|]+))*\s+(-c|--call)(\s+|=)[\"']?\s*([^\"';&|]*(;|&&|\|)\s*)*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]\"';&|]*\s+)*" - -# Args: $1 = binary name -# Returns: 0 when the command runs the binary in one of the forms above -runs_binary() { - echo "$COMMAND" | grep -qE "${BINARY_INVOCATION}$1(@\S+)?(\s|\$)" \ - || echo "$COMMAND" | grep -qE "${CALL_INVOCATION}$1(@\S+)?(\s|\$|[\"'])" -} - # Only process if in Storefront context if ! is_storefront_context; then exit 0 @@ -127,9 +99,8 @@ if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+lint:fix(\s|$)'; then "Use eslint_fix to auto-fix ESLint violations." "eslint_fix" fi -# Target-less base scripts. Shopware's Storefront package.json does not define -# them and the MCP tools no longer call them; the redirects stay for a project -# that does. +# Target-less base scripts that Shopware's Storefront package.json does not +# define and the MCP tools do not call; the redirects cover a project that does. if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*npm\s+run\s+eslint:app(\s|--|$)'; then block_tool "js-storefront-tooling" \ "Use eslint_check with paths for linting, or eslint_fix with paths to auto-fix." "eslint_check" diff --git a/plugins/dev-tooling/hooks/scripts/lib/js_binary.sh b/plugins/dev-tooling/hooks/scripts/lib/js_binary.sh new file mode 100644 index 00000000..cdd135b5 --- /dev/null +++ b/plugins/dev-tooling/hooks/scripts/lib/js_binary.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Matches a direct run of a JS tool binary, for the Admin and Storefront JS +# hooks; each hook decides which binaries it checks. Owned by dev-tooling, not +# templated like common.sh. +# +# Matched at a command position: `npx`, `npm exec` or `npm x` followed by flags +# that take no value and an optional `--`; a node_modules/.bin path; and the +# -c / --call string of those runners, at its start or after `;`, `&&` or `|`. +# A bare binary name is not matched: it also sits at a command position inside +# quoted text such as a commit message, which would then be blocked. +# A flag that takes a value is not skipped, because its value would read as the +# binary; before -c it is, because there the string names what runs. + +BINARY_INVOCATION='(^|;|&&|\|)\s*((npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|--))*\s+|\S*node_modules/\.bin/)' + +CALL_INVOCATION="(^|;|&&|\|)\s*(npm\s+(exec|x)|npx)(\s+(-y|--yes|--no|--no-install|-q|--quiet|--silent|(--package|--workspace|-p|-w)(=|\s+)[^[:space:]\"';&|]+))*\s+(-c|--call)(\s+|=)[\"']?\s*([^\"';&|]*(;|&&|\|)\s*)*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]\"';&|]*\s+)*" + +# Globals: reads COMMAND, set by parse_hook_input in common.sh +# Args: $1 = binary name +# Returns: 0 when COMMAND runs the binary in one of the forms above +runs_binary() { + echo "$COMMAND" | grep -qE "${BINARY_INVOCATION}$1(@\S+)?(\s|\$)" \ + || echo "$COMMAND" | grep -qE "${CALL_INVOCATION}$1(@\S+)?(\s|\$|[\"'])" +} 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 87b99dcc..f396b5c4 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/jest.sh @@ -6,12 +6,12 @@ # Runs route at the target-less npm script "jest:base". The aggregate "unit" # script is `jest --config jest.config.js --ci`, and _jest.config.ts, which # jest.config.js loads, derives `isCi` from an exact `--ci` match in -# process.argv: it then sets -# `collectCoverage: isCi` and swaps the reporters for jest-silent-reporter plus -# jest-junit. Routing at "unit" therefore forces coverage on every run and -# suppresses the per-test and summary output the caller asked for, and -# appending `--ci=false` does not undo it because the literal `--ci` is still -# in argv. The `ci` argument puts that mode back under the caller's control. +# process.argv: it then sets `collectCoverage: isCi` and swaps the reporters +# for jest-silent-reporter plus jest-junit. Routing at "unit" therefore forces +# coverage on every run and suppresses the per-test and summary output the +# caller asked for, and appending `--ci=false` does not undo it because the +# literal `--ci` is still in argv. The `ci` argument puts that mode back under +# the caller's control. # # The tool's result comes from Jest's own JSON report, not from the process # exit code. The exit code answers "did the jest process end cleanly", which is 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 09e7e18d..8ce780dc 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/prettier.sh @@ -12,148 +12,16 @@ # paths omitted -> the aggregate script ("format" / "format:fix"), whose # body carries both the mode flag and its own glob # targets, which stay authoritative. -# The aggregate is never a fallback for a path-scoped run. npm appends `--` -# arguments to the END of the whole script body, so appending a path to a body -# that already names `src/**/*.{js,ts}` and friends widens the run to those -# globs PLUS the path — it can never narrow it. That matters most for -# prettier_fix, which writes: a widened fix reformats files nobody named. +# Why the aggregate never serves a path-scoped run: shared/js_paths.sh. ADMIN_PRETTIER_BINARY="prettier" -# Extensions Prettier is pointed at here, read off the globs in the `format` -# and `format:fix` script bodies: `{js,ts}` and `ts` under src, test, scripts -# and build, `extension-tooling/**/*.mjs`, build/plugins.vite.ts and build.ts. -# A path that resolves to no file with one of them formats nothing and still -# exits 0. +# The extensions the `format` and `format:fix` globs name: `{js,ts}` and `ts` +# under src, test, scripts and build, `extension-tooling/**/*.mjs`, +# build/plugins.vite.ts and build.ts. A path that resolves to no file with one +# of them formats nothing and still exits 0. ADMIN_PRETTIER_EXTENSIONS="js ts mjs" -# What a directory path is narrowed to: the extensions above, below it. ADMIN_PRETTIER_DIRECTORY_GLOB="**/*.{js,ts,mjs}" -# True when the path is a glob pattern Prettier expands itself rather than a -# literal filesystem path. The existence probe cannot resolve a glob, so glob -# entries skip the guard. -# Args: $1 = caller-supplied path -if ! declare -F _prettier_admin_path_is_glob >/dev/null; then - _prettier_admin_path_is_glob() { - case "$1" in - *"*"*|*"?"*|*"["*) return 0 ;; - esac - return 1 - } -fi - -# Print, one per line, the caller's literal paths that name directories. A -# literal path without an accepted extension has passed the lintable probe only -# as a directory holding such a file. One carrying an accepted extension can -# still be a directory, so those are tested where the command runs, through -# `npm exec -c` like the binary probe. -# Args: $1.. = literal paths that passed the lintable probe -# Stdout: the directories, or the failure message -# Returns: 0 on success, 1 when the test could not run -_prettier_admin_directories() { - local -a extensions=() - read -r -a extensions <<< "${ADMIN_PRETTIER_EXTENSIONS}" - - local -a directories=() - local p ext named script="" - for p in "$@"; do - named=0 - for ext in "${extensions[@]}"; do - [[ "${p}" == *".${ext}" ]] && named=1 - done - if [[ "${named}" -eq 0 ]]; then - directories+=("${p}") - else - script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " - fi - done - - if [[ -n "${script}" ]]; then - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? - if [[ "${probe_code}" -ne 0 ]]; then - printf '%s\n' "Refusing to run with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 - fi - local line - while IFS= read -r line; do - [[ "${line}" == DIRECTORY:* ]] && directories+=("${line#DIRECTORY:}") - done <<< "${output}" - fi - - if [[ ${#directories[@]} -gt 0 ]]; then - printf '%s\n' "${directories[@]}" - fi -} - -# The target a directory reaches Prettier as. Prettier given a directory -# formats every file type it supports under it, so a directory becomes a glob -# limited to the accepted extensions, which Prettier expands itself because the -# caller's quoting keeps it away from the shell. -# Args: $1 = directory path -# Stdout: the pattern -_prettier_admin_directory_pattern() { - local path="$1" - - while [[ "${path}" == */ && "${path}" != "/" ]]; do - path="${path%/}" - done - printf '%s\n' "${path}/${ADMIN_PRETTIER_DIRECTORY_GLOB}" -} - -# Under ddev on a worktree the command reaches `ddev exec`, which re-parses it -# in the container's bash without quoting a value that holds no space, quote -# or "#", so that bash would expand a glob pattern itself, without globstar, -# before Prettier sees it. A run there takes file paths only. -# Args: $1 = tool name, $2.. = the caller's glob patterns and directories -# Stdout: the refusal -# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise -_prettier_admin_assert_no_pattern_under_ddev_worktree() { - local tool_name="$1" - shift - - if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then - return 0 - fi - - printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach Prettier as glob patterns the container shell expands first: $*. Pass file paths instead." - return 1 -} - -# Refuse a path-scoped run when the package's own Prettier binary is not -# installed where the command runs. `npm exec --no` never installs, but on a -# miss it still runs a globally installed binary of the same name, so the probe -# runs first and the run never reaches a binary the package did not pin. -# Args: $1 = tool name, $2 = aggregate npm script, named in the refusal -# Stdout: the refusal, when the binary is missing -# Returns: 0 when node_modules/.bin/prettier is executable, 1 otherwise -_prettier_admin_assert_local_binary() { - local tool_name="$1" - local aggregate="$2" - local bin_path="node_modules/.bin/${ADMIN_PRETTIER_BINARY}" - - # On a miss the probe prints a marker and the directory it ran in, which - # under a container environment is the container's path, and exits 3; any - # other non-zero exit is the probe itself failing. - local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? - if [[ "${probe_code}" -eq 0 ]]; then - return 0 - fi - - if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then - local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" - package_dir="${package_dir%%$'\n'*}" - printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${ADMIN_PRETTIER_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own glob targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." - return 1 - fi - - printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${ADMIN_PRETTIER_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 -} - # Run one Prettier invocation for this tool. # Args: $1 = aggregate npm script used when no paths are supplied, # $2 = tool name, for the refusal message, @@ -202,64 +70,18 @@ _prettier_dispatch_admin() { return fi - local -a supplied=() - local -a literals=() - local p - while IFS= read -r p; do - [[ -z "${p}" ]] && continue - supplied+=("${p}") - if ! _prettier_admin_path_is_glob "${p}"; then - literals+=("${p}") - fi - done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') - - _prettier_admin_assert_local_binary "${tool_name}" "${aggregate}" || return 1 - - if [[ ${#literals[@]} -gt 0 ]]; then - local missing_report - if ! missing_report=$(assert_paths_lintable "." "${ADMIN_PRETTIER_EXTENSIONS}" "${literals[@]}"); then - printf '%s\n' "${missing_report}" - return 1 - fi - fi - - local -a directories=() - if [[ ${#literals[@]} -gt 0 ]]; then - local directory_report - if ! directory_report=$(_prettier_admin_directories "${literals[@]}"); then - printf '%s\n' "${directory_report}" - return 1 - fi - while IFS= read -r p; do - [[ -n "${p}" ]] && directories+=("${p}") - done <<< "${directory_report}" + local targets + if ! targets=$(js_path_scoped_targets "${tool_name}" "${ADMIN_PRETTIER_BINARY}" "${aggregate}" \ + "${ADMIN_PRETTIER_EXTENSIONS}" "${ADMIN_PRETTIER_DIRECTORY_GLOB}" "Prettier" "${paths_json}"); then + printf '%s\n' "${targets}" + return 1 fi - local -a patterns=() - for p in "${supplied[@]}"; do - _prettier_admin_path_is_glob "${p}" && patterns+=("${p}") - done - _prettier_admin_assert_no_pattern_under_ddev_worktree "${tool_name}" \ - "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 - - local -a quoted=() - local target directory - for p in "${supplied[@]}"; do - target="${p}" - for directory in "${directories[@]+"${directories[@]}"}"; do - if [[ "${directory}" == "${p}" ]]; then - target=$(_prettier_admin_directory_pattern "${p}") - break - fi - done - quoted+=("$(shell_quote_arg "${target}")") - done - local cmd="npm exec --no -- ${ADMIN_PRETTIER_BINARY} ${mode_flags}" if [[ -n "${scoped_config}" ]]; then cmd="${cmd} --config ${scoped_config}" fi - cmd="${cmd} ${quoted[*]}" + cmd="${cmd} ${targets}" log "INFO" "Running Prettier (admin, npm exec): ${cmd}" 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 d90c233a..9310cc33 100644 --- a/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/lib/stylelint.sh @@ -11,11 +11,7 @@ # paths omitted -> the aggregate script ("lint:scss" / "lint:scss-fix"), # whose body carries its own `**/*.scss` target, which # stays authoritative. -# The aggregate is never a fallback for a path-scoped run. npm appends `--` -# arguments to the END of the whole script body, so appending a path to a body -# that already names `**/*.scss` widens the run to every SCSS file PLUS the -# path — it can never narrow it. That matters most for stylelint_fix, which -# writes: a widened fix modifies files the caller never named. +# Why the aggregate never serves a path-scoped run: shared/js_paths.sh. # # The two routes also differ in where `--fix` comes from. "lint:scss-fix" # carries `--fix` in its own body, so the no-paths route must not add a second @@ -23,234 +19,10 @@ # it or stylelint_fix silently runs as a check and writes nothing. ADMIN_STYLELINT_BINARY="stylelint" -# Extensions a path-scoped run lints: the one the "lint:scss" glob `**/*.scss` -# names. A file path with another extension is refused, and a directory is -# narrowed to ADMIN_STYLELINT_DIRECTORY_GLOB below it. +# The extension the "lint:scss" glob `**/*.scss` names. ADMIN_STYLELINT_EXTENSIONS="scss" ADMIN_STYLELINT_DIRECTORY_GLOB="**/*.scss" -# True when the path is a glob pattern Stylelint expands itself rather than a -# literal filesystem path. The existence probe cannot resolve a glob, so glob -# entries skip the guard. Nothing is lost by skipping it: Stylelint exits 1 on a -# glob that matches no file and names the pattern, so the unmatched-glob case is -# still reported to the caller by the tool itself. -# Args: $1 = caller-supplied path -if ! declare -F _stylelint_admin_path_is_glob >/dev/null; then - _stylelint_admin_path_is_glob() { - case "$1" in - *"*"*|*"?"*|*"["*) return 0 ;; - esac - return 1 - } -fi - -# Refuse a path-scoped run when the package's own Stylelint binary is not -# installed where the command runs. `npm exec --no` never installs, but on a -# miss it still runs a globally installed binary of the same name, so the probe -# runs first and the run never reaches a binary the package did not pin. -# Args: $1 = tool name, $2 = aggregate npm script, named in the refusal -# Stdout: the refusal, when the binary is missing -# Returns: 0 when node_modules/.bin/stylelint is executable, 1 otherwise -_stylelint_admin_assert_local_binary() { - local tool_name="$1" - local aggregate="$2" - local bin_path="node_modules/.bin/${ADMIN_STYLELINT_BINARY}" - - # On a miss the probe prints a marker and the directory it ran in, which - # under a container environment is the container's path, and exits 3; any - # other non-zero exit is the probe itself failing. - local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? - if [[ "${probe_code}" -eq 0 ]]; then - return 0 - fi - - if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then - local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" - package_dir="${package_dir%%$'\n'*}" - printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${ADMIN_STYLELINT_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." - return 1 - fi - - printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${ADMIN_STYLELINT_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 -} - -# True when a command in an npm script body runs Stylelint with a config flag -# of its own: --config, --config=, -c or -c=, counted only after the stylelint -# token of the same command, so another program's -c in the same script is not -# taken for one. Stylelint refuses a second config flag. The Administration's -# Stylelint 14 rejects -c and -c= outright, so a script using either fails on -# its own; counting them refuses that script earlier, with a clearer message. -# Args: $1 = script body -# Returns: 0 when a Stylelint command in the body passes a config, 1 otherwise -_stylelint_admin_body_passes_config() { - local body="$1" - - body="${body//&&/;}" - body="${body//||/;}" - body="${body//|/;}" - - local -a segments=() - IFS=';' read -r -a segments <<< "${body}" - - local segment args - for segment in "${segments[@]+"${segments[@]}"}"; do - segment=" ${segment} " - case "${segment}" in - *" stylelint "*) args=" ${segment#* stylelint }" ;; - *"/stylelint "*) args=" ${segment#*/stylelint }" ;; - *) continue ;; - esac - case "${args}" in - *" --config "*|*" --config="*|*" -c "*|*" -c="*) return 0 ;; - esac - done - - return 1 -} - -# Print, one per line, the caller's literal paths that name directories. A -# literal path without an accepted extension has passed the lintable probe only -# as a directory holding such a file. One carrying an accepted extension can -# still be a directory, so those are tested where the command runs, through -# `npm exec -c` like the binary probe. -# Args: $1.. = literal paths that passed the lintable probe -# Stdout: the directories, or the failure message -# Returns: 0 on success, 1 when the test could not run -_stylelint_admin_directories() { - local -a extensions=() - read -r -a extensions <<< "${ADMIN_STYLELINT_EXTENSIONS}" - - local -a directories=() - local p ext named script="" - for p in "$@"; do - named=0 - for ext in "${extensions[@]}"; do - [[ "${p}" == *".${ext}" ]] && named=1 - done - if [[ "${named}" -eq 0 ]]; then - directories+=("${p}") - else - script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " - fi - done - - if [[ -n "${script}" ]]; then - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? - if [[ "${probe_code}" -ne 0 ]]; then - printf '%s\n' "Refusing to run with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 - fi - local line - while IFS= read -r line; do - [[ "${line}" == DIRECTORY:* ]] && directories+=("${line#DIRECTORY:}") - done <<< "${output}" - fi - - if [[ ${#directories[@]} -gt 0 ]]; then - printf '%s\n' "${directories[@]}" - fi -} - -# The target a directory reaches Stylelint as. Stylelint given a directory -# reads every file under it, so a directory becomes a glob limited to the -# accepted extensions, which Stylelint expands itself because the caller's -# quoting keeps it away from the shell. -# Args: $1 = directory path -# Stdout: the pattern -_stylelint_admin_directory_pattern() { - local path="$1" - - while [[ "${path}" == */ && "${path}" != "/" ]]; do - path="${path%/}" - done - printf '%s\n' "${path}/${ADMIN_STYLELINT_DIRECTORY_GLOB}" -} - -# Under ddev on a worktree the command reaches `ddev exec`, which re-parses it -# in the container's bash without quoting a value that holds no space, quote -# or "#", so that bash would expand a glob pattern itself, without globstar, -# before Stylelint sees it. A run there takes file paths only. -# Args: $1 = tool name, $2.. = the caller's glob patterns and directories -# Stdout: the refusal -# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise -_stylelint_admin_assert_no_pattern_under_ddev_worktree() { - local tool_name="$1" - shift - - if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then - return 0 - fi - - printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach Stylelint as glob patterns the container shell expands first: $*. Pass file paths instead." - return 1 -} - -# True when an npm script hands Stylelint a config of its own, directly or -# through an `npm run` of another script in its body. Stylelint refuses a second -# --config ("The flag --config can only be set once."), so a scope config cannot -# be appended to such a script. -# Args: $1 = npm script name, $2 = how many nested `npm run` levels to follow -# Stdout: a failure message, when a body cannot be read or the chain is deeper -# Returns: 0 = passes a config, 1 = passes none, 2 = a body could not be read, -# or the chain runs deeper than $2 levels -_stylelint_admin_script_passes_config() { - local script="$1" - local depth="$2" - - local body - local body_code=0 - body=$(npm_script_body "${script}") || body_code=$? - if [[ "${body_code}" -eq 1 ]]; then - printf '%s\n' "npm script \"${script}\" is not defined in package.json." - return 2 - fi - if [[ "${body_code}" -ne 0 ]]; then - printf '%s\n' "Could not read npm script \"${script}\" from package.json to check it for its own Stylelint config; see the server log for the failed probe." - return 2 - fi - - if _stylelint_admin_body_passes_config "${body}"; then - return 0 - fi - - local padded=" ${body} " - - if [[ "${padded}" != *" npm run "* ]]; then - return 1 - fi - - # A chain longer than this is not read to its end, so whether it passes a - # config is unknown; that is refused rather than taken as "none". - if [[ "${depth}" -le 0 ]]; then - printf '%s\n' "npm script \"${script}\" runs further npm scripts nested deeper than this tool follows, so it cannot be checked for its own Stylelint config." - return 2 - fi - - local rest="${padded}" - local inner nested_code - while [[ "${rest}" == *" npm run "* ]]; do - rest="${rest#* npm run }" - # Options such as --silent precede the script name. - while [[ "${rest}" == -* ]]; do - rest="${rest#* }" - done - inner="${rest%% *}" - nested_code=0 - _stylelint_admin_script_passes_config "${inner}" "$((depth - 1))" || nested_code=$? - if [[ "${nested_code}" -ne 1 ]]; then - return "${nested_code}" - fi - done - - return 1 -} - # Run one Stylelint invocation for this tool. # Args: $1 = aggregate npm script used when no paths are supplied, # $2 = tool name, for the refusal message, @@ -258,7 +30,8 @@ _stylelint_admin_script_passes_config() { # $4 = pre-path flag string used ONLY on the path-scoped route, for flags # the aggregate script already carries in its own body (may be empty), # $5 = tool arguments JSON, -# $6 = scope-configured Stylelint config, already part of $3 (may be empty) +# $6 = scope-configured Stylelint config, already part of $3 (empty +# when the scope configures none) # Stdout: linter output, or the failure message _stylelint_dispatch_admin() { local aggregate="$1" @@ -266,7 +39,7 @@ _stylelint_dispatch_admin() { local flag_string="$3" local path_only_flags="$4" local args="$5" - local scoped_config="${6:-}" + local scoped_config="$6" local paths_json paths paths_json=$(echo "${args}" | jq -c '.paths // []') @@ -299,7 +72,7 @@ _stylelint_dispatch_admin() { if [[ -n "${scoped_config}" ]]; then local config_check local config_code=0 - config_check=$(_stylelint_admin_script_passes_config "${aggregate}" 3) || config_code=$? + config_check=$(js_stylelint_script_passes_config "${aggregate}" 3) || config_code=$? if [[ "${config_code}" -eq 0 ]]; then printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the Stylelint config \"${scoped_config}\", but \"npm run ${aggregate}\" already passes a config of its own, and Stylelint refuses a second --config. Pass paths so the run uses the scope config alone, or call with scope \"shopware\"." return 1 @@ -316,62 +89,14 @@ _stylelint_dispatch_admin() { return fi - local -a supplied=() - local -a literals=() - local p - while IFS= read -r p; do - [[ -z "${p}" ]] && continue - supplied+=("${p}") - if ! _stylelint_admin_path_is_glob "${p}"; then - literals+=("${p}") - fi - done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') - - _stylelint_admin_assert_local_binary "${tool_name}" "${aggregate}" || return 1 - - if [[ ${#literals[@]} -gt 0 ]]; then - local missing_report - if ! missing_report=$(assert_paths_lintable "." "${ADMIN_STYLELINT_EXTENSIONS}" "${literals[@]}"); then - printf '%s\n' "${missing_report}" - return 1 - fi - fi - - local -a directories=() - if [[ ${#literals[@]} -gt 0 ]]; then - local directory_report - if ! directory_report=$(_stylelint_admin_directories "${literals[@]}"); then - printf '%s\n' "${directory_report}" - return 1 - fi - while IFS= read -r p; do - [[ -n "${p}" ]] && directories+=("${p}") - done <<< "${directory_report}" + local targets + if ! targets=$(js_path_scoped_targets "${tool_name}" "${ADMIN_STYLELINT_BINARY}" "${aggregate}" \ + "${ADMIN_STYLELINT_EXTENSIONS}" "${ADMIN_STYLELINT_DIRECTORY_GLOB}" "Stylelint" "${paths_json}"); then + printf '%s\n' "${targets}" + return 1 fi - local -a patterns=() - for p in "${supplied[@]}"; do - _stylelint_admin_path_is_glob "${p}" && patterns+=("${p}") - done - _stylelint_admin_assert_no_pattern_under_ddev_worktree "${tool_name}" \ - "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 - - local -a quoted=() - local target directory - for p in "${supplied[@]}"; do - target="${p}" - for directory in "${directories[@]+"${directories[@]}"}"; do - if [[ "${directory}" == "${p}" ]]; then - target=$(_stylelint_admin_directory_pattern "${p}") - break - fi - done - quoted+=("$(shell_quote_arg "${target}")") - done - - # `--cache` is the flag the "lint:scss" body passes besides its target. - # Anything the aggregate fix script would have supplied has to be added - # here as well. + # `--cache` is what the "lint:scss" body passes besides its target. local cmd="npm exec --no -- ${ADMIN_STYLELINT_BINARY} --cache" if [[ -n "${flag_string}" ]]; then cmd="${cmd} ${flag_string}" @@ -379,7 +104,7 @@ _stylelint_dispatch_admin() { if [[ -n "${path_only_flags}" ]]; then cmd="${cmd} ${path_only_flags}" fi - cmd="${cmd} ${quoted[*]}" + cmd="${cmd} ${targets}" log "INFO" "Running Stylelint (admin, npm exec): ${cmd}" diff --git a/plugins/dev-tooling/mcp-server-js-admin/server.sh b/plugins/dev-tooling/mcp-server-js-admin/server.sh index 5b5d0207..b7089e3c 100755 --- a/plugins/dev-tooling/mcp-server-js-admin/server.sh +++ b/plugins/dev-tooling/mcp-server-js-admin/server.sh @@ -67,6 +67,7 @@ fi # Source remaining modules source "${SHARED_DIR}/environment.sh" +source "${SHARED_DIR}/js_paths.sh" source "${SHARED_DIR}/scope.sh" if launch_is_bound; then if ! scope_validate; then 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 40a02d97..3945b0b4 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/eslint.sh @@ -67,41 +67,6 @@ _eslint_rebase_app_path() { printf '%s\n' "${path}" } -# Refuse a path-scoped run when the package's own ESLint binary is not -# installed where the command runs. `npm exec --no` never installs, but on a -# miss it still runs a globally installed binary of the same name, so the probe -# runs first and the run never reaches a binary the package did not pin. Both -# trees resolve the binary from the package directory, so one probe covers -# both. -# Args: $1 = aggregate npm script, named in the refusal -# Stdout: the refusal, when the binary is missing -# Returns: 0 when node_modules/.bin/eslint is executable, 1 otherwise -_eslint_assert_local_binary() { - local aggregate="$1" - local bin_path="node_modules/.bin/${STOREFRONT_ESLINT_BINARY}" - - # On a miss the probe prints a marker and the directory it ran in, which - # under a container environment is the container's path, and exits 3; any - # other non-zero exit is the probe itself failing. - local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? - if [[ "${probe_code}" -eq 0 ]]; then - return 0 - fi - - if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then - local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" - package_dir="${package_dir%%$'\n'*}" - printf '%s\n' "Refusing to lint with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${STOREFRONT_ESLINT_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." - return 1 - fi - - printf '%s\n' "Refusing to lint with paths: could not check whether the package's own ${STOREFRONT_ESLINT_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 -} - # Run one tree's ESLint invocation. Reached only when the caller supplied # paths, so the aggregate is never a fallback here: its body names its own # targets, and npm appends `--` arguments after them, so substituting it would @@ -156,12 +121,13 @@ _eslint_invoke_tree() { # Route the caller's paths onto the two trees and run each one that is used. # Args: $1 = tool arguments JSON, $2 = aggregate npm script, # $3 = pre-path flag string (may be empty), $4 = scope-configured ESLint -# config (may be empty) +# config (may be empty), $5 = tool name, for refusals _eslint_dispatch() { local args="$1" - local fallback="$2" + local aggregate="$2" local flag_string="$3" local scoped_config="$4" + local tool_name="$5" local paths_json paths paths_json=$(echo "${args}" | jq -c '.paths // []') @@ -184,11 +150,11 @@ _eslint_dispatch() { # Running bare anyway would silently substitute the package script's own # configuration for the scope-selected one. if [[ -n "${scoped_config}" ]]; then - printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the ESLint config \"${scoped_config}\", but a run without paths can only execute \"npm run ${fallback}\" bare, which would apply the package script's own config instead. \"${fallback}\" chains two ESLint calls, so the config cannot be appended to it either. Pass paths so the run executes ESLint on them directly, or call with scope \"shopware\"." + printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the ESLint config \"${scoped_config}\", but a run without paths can only execute \"npm run ${aggregate}\" bare, which would apply the package script's own config instead. \"${aggregate}\" chains two ESLint calls, so the config cannot be appended to it either. Pass paths so the run executes ESLint on them directly, or call with scope \"shopware\"." return 1 fi - local bare_cmd="npm run ${fallback}" - log "INFO" "Running ESLint (storefront, ${fallback}): ${bare_cmd}" + local bare_cmd="npm run ${aggregate}" + log "INFO" "Running ESLint (storefront, ${aggregate}): ${bare_cmd}" exec_npm_command "${bare_cmd}" return fi @@ -210,11 +176,12 @@ _eslint_dispatch() { # src/Storefront/Resources/app/storefront. Under a scope the working # directory is the scope's package instead, where both name something else. if [[ ${#component_paths[@]} -gt 0 && -n "${SCOPE_CWD:-}" ]]; then - printf '%s\n' "Refusing to lint with paths: scope \"${SCOPE_NAME}\" runs in \"${SCOPE_CWD}\", but these paths route to the core Storefront components tree, which is linted from src/Storefront/Resources with ./app/storefront/eslint.config.js: ${component_paths[*]}. Lint them with scope \"shopware\"." + printf '%s\n' "Refusing to run ${tool_name} with paths: scope \"${SCOPE_NAME}\" runs in \"${SCOPE_CWD}\", but these paths route to the core Storefront components tree, which is linted from src/Storefront/Resources with ./app/storefront/eslint.config.js: ${component_paths[*]}. Lint them with scope \"shopware\"." return 1 fi - _eslint_assert_local_binary "${fallback}" || return 1 + # Both trees resolve the binary from the package directory. + js_assert_local_binary "${tool_name}" "${STOREFRONT_ESLINT_BINARY}" "${aggregate}" || return 1 local overall=0 local tree_output @@ -274,7 +241,7 @@ tool_eslint_check() { [[ -n "${scoped_config}" ]] && flags+=("--config" "${scoped_config}") - _eslint_dispatch "${args}" "lint:js" "${flags[*]}" "${scoped_config}" + _eslint_dispatch "${args}" "lint:js" "${flags[*]}" "${scoped_config}" "eslint_check" } # ESLint fix (auto-fix violations) @@ -299,5 +266,5 @@ tool_eslint_fix() { local -a flags=("--fix") [[ -n "${scoped_config}" ]] && flags+=("--config" "${scoped_config}") - _eslint_dispatch "${args}" "lint:js:fix" "${flags[*]}" "${scoped_config}" + _eslint_dispatch "${args}" "lint:js:fix" "${flags[*]}" "${scoped_config}" "eslint_fix" } 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 f5ca30ba..eba7e82b 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/lib/stylelint.sh @@ -8,18 +8,13 @@ # --cache`, the flags of the "lint:scss" body without its # `./src/scss` target, so the caller's paths are the ONLY # targets. Under a scope that `--config` is left out: a -# scope-configured config takes its place (Stylelint -# refuses a second `--config` flag outright), and without +# scope-configured config takes its place, and without # one Stylelint finds the scope package's own config. # A directory path is narrowed to its .scss and .css files. # paths omitted -> the aggregate script ("lint:scss" / "lint:scss-fix"), # whose body carries its own `./src/scss` target, which # stays authoritative. -# The aggregate is never a fallback for a path-scoped run. npm appends `--` -# arguments to the END of the whole script body, so appending a path to a body -# that already names `./src/scss` widens the run to that whole tree PLUS the -# path — it can never narrow it. That matters most for stylelint_fix, which -# writes: a widened fix modifies files the caller never named. +# Why the aggregate never serves a path-scoped run: shared/js_paths.sh. # # The two routes also differ in where `--fix` comes from. "lint:scss-fix" # carries `--fix` in its own body, so the no-paths route must not add a second @@ -34,230 +29,8 @@ STOREFRONT_STYLELINT_CONFIG="stylelint.config.js" # path that resolves to no file with one of them lints nothing and still # exits 0. STOREFRONT_STYLELINT_EXTENSIONS="scss css" -# What a directory path is narrowed to: the extensions above, below it. STOREFRONT_STYLELINT_DIRECTORY_GLOB="**/*.{scss,css}" -# True when the path is a glob pattern Stylelint expands itself rather than a -# literal filesystem path. The existence probe cannot resolve a glob, so glob -# entries skip the guard. Nothing is lost by skipping it: Stylelint exits 1 on a -# glob that matches no file and names the pattern, so the unmatched-glob case is -# still reported to the caller by the tool itself. -# Args: $1 = caller-supplied path -if ! declare -F _stylelint_storefront_path_is_glob >/dev/null; then - _stylelint_storefront_path_is_glob() { - case "$1" in - *"*"*|*"?"*|*"["*) return 0 ;; - esac - return 1 - } -fi - -# Refuse a path-scoped run when the package's own Stylelint binary is not -# installed where the command runs. `npm exec --no` never installs, but on a -# miss it still runs a globally installed binary of the same name, so the probe -# runs first and the run never reaches a binary the package did not pin. -# Args: $1 = tool name, $2 = aggregate npm script, named in the refusal -# Stdout: the refusal, when the binary is missing -# Returns: 0 when node_modules/.bin/stylelint is executable, 1 otherwise -_stylelint_storefront_assert_local_binary() { - local tool_name="$1" - local aggregate="$2" - local bin_path="node_modules/.bin/${STOREFRONT_STYLELINT_BINARY}" - - # On a miss the probe prints a marker and the directory it ran in, which - # under a container environment is the container's path, and exits 3; any - # other non-zero exit is the probe itself failing. - local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? - if [[ "${probe_code}" -eq 0 ]]; then - return 0 - fi - - if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then - local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" - package_dir="${package_dir%%$'\n'*}" - printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${STOREFRONT_STYLELINT_BINARY} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." - return 1 - fi - - printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${STOREFRONT_STYLELINT_BINARY} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 -} - -# True when a command in an npm script body runs Stylelint with a config flag -# of its own: --config, --config=, -c or -c=, counted only after the stylelint -# token of the same command, so another program's -c in the same script is not -# taken for one. The Storefront's Stylelint 16 accepts each of the four and -# refuses any second one. -# Args: $1 = script body -# Returns: 0 when a Stylelint command in the body passes a config, 1 otherwise -_stylelint_storefront_body_passes_config() { - local body="$1" - - body="${body//&&/;}" - body="${body//||/;}" - body="${body//|/;}" - - local -a segments=() - IFS=';' read -r -a segments <<< "${body}" - - local segment args - for segment in "${segments[@]+"${segments[@]}"}"; do - segment=" ${segment} " - case "${segment}" in - *" stylelint "*) args=" ${segment#* stylelint }" ;; - *"/stylelint "*) args=" ${segment#*/stylelint }" ;; - *) continue ;; - esac - case "${args}" in - *" --config "*|*" --config="*|*" -c "*|*" -c="*) return 0 ;; - esac - done - - return 1 -} - -# Print, one per line, the caller's literal paths that name directories. A -# literal path without an accepted extension has passed the lintable probe only -# as a directory holding such a file. One carrying an accepted extension can -# still be a directory, so those are tested where the command runs, through -# `npm exec -c` like the binary probe. -# Args: $1.. = literal paths that passed the lintable probe -# Stdout: the directories, or the failure message -# Returns: 0 on success, 1 when the test could not run -_stylelint_storefront_directories() { - local -a extensions=() - read -r -a extensions <<< "${STOREFRONT_STYLELINT_EXTENSIONS}" - - local -a directories=() - local p ext named script="" - for p in "$@"; do - named=0 - for ext in "${extensions[@]}"; do - [[ "${p}" == *".${ext}" ]] && named=1 - done - if [[ "${named}" -eq 0 ]]; then - directories+=("${p}") - else - script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " - fi - done - - if [[ -n "${script}" ]]; then - local output - local probe_code=0 - output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? - if [[ "${probe_code}" -ne 0 ]]; then - printf '%s\n' "Refusing to run with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" - return 1 - fi - local line - while IFS= read -r line; do - [[ "${line}" == DIRECTORY:* ]] && directories+=("${line#DIRECTORY:}") - done <<< "${output}" - fi - - if [[ ${#directories[@]} -gt 0 ]]; then - printf '%s\n' "${directories[@]}" - fi -} - -# The target a directory reaches Stylelint as. Stylelint given a directory -# reads every file under it, so a directory becomes a glob limited to the -# accepted extensions, which Stylelint expands itself because the caller's -# quoting keeps it away from the shell. -# Args: $1 = directory path -# Stdout: the pattern -_stylelint_storefront_directory_pattern() { - local path="$1" - - while [[ "${path}" == */ && "${path}" != "/" ]]; do - path="${path%/}" - done - printf '%s\n' "${path}/${STOREFRONT_STYLELINT_DIRECTORY_GLOB}" -} - -# Under ddev on a worktree the command reaches `ddev exec`, which re-parses it -# in the container's bash without quoting a value that holds no space, quote -# or "#", so that bash would expand a glob pattern itself, without globstar, -# before Stylelint sees it. A run there takes file paths only. -# Args: $1 = tool name, $2.. = the caller's glob patterns and directories -# Stdout: the refusal -# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise -_stylelint_storefront_assert_no_pattern_under_ddev_worktree() { - local tool_name="$1" - shift - - if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then - return 0 - fi - - printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach Stylelint as glob patterns the container shell expands first: $*. Pass file paths instead." - return 1 -} - -# True when an npm script hands Stylelint a config of its own, directly or -# through an `npm run` of another script in its body. Stylelint refuses a second -# --config ("The flag --config can only be set once."), so a scope config cannot -# be appended to such a script. -# Args: $1 = npm script name, $2 = how many nested `npm run` levels to follow -# Stdout: a failure message, when a body cannot be read or the chain is deeper -# Returns: 0 = passes a config, 1 = passes none, 2 = a body could not be read, -# or the chain runs deeper than $2 levels -_stylelint_storefront_script_passes_config() { - local script="$1" - local depth="$2" - - local body - local body_code=0 - body=$(npm_script_body "${script}") || body_code=$? - if [[ "${body_code}" -eq 1 ]]; then - printf '%s\n' "npm script \"${script}\" is not defined in package.json." - return 2 - fi - if [[ "${body_code}" -ne 0 ]]; then - printf '%s\n' "Could not read npm script \"${script}\" from package.json to check it for its own Stylelint config; see the server log for the failed probe." - return 2 - fi - - if _stylelint_storefront_body_passes_config "${body}"; then - return 0 - fi - - local padded=" ${body} " - - if [[ "${padded}" != *" npm run "* ]]; then - return 1 - fi - - # A chain longer than this is not read to its end, so whether it passes a - # config is unknown; that is refused rather than taken as "none". - if [[ "${depth}" -le 0 ]]; then - printf '%s\n' "npm script \"${script}\" runs further npm scripts nested deeper than this tool follows, so it cannot be checked for its own Stylelint config." - return 2 - fi - - local rest="${padded}" - local inner nested_code - while [[ "${rest}" == *" npm run "* ]]; do - rest="${rest#* npm run }" - # Options such as --silent precede the script name. - while [[ "${rest}" == -* ]]; do - rest="${rest#* }" - done - inner="${rest%% *}" - nested_code=0 - _stylelint_storefront_script_passes_config "${inner}" "$((depth - 1))" || nested_code=$? - if [[ "${nested_code}" -ne 1 ]]; then - return "${nested_code}" - fi - done - - return 1 -} - # Run one Stylelint invocation for this tool. # Args: $1 = aggregate npm script used when no paths are supplied, # $2 = tool name, for the refusal message, @@ -265,7 +38,8 @@ _stylelint_storefront_script_passes_config() { # $4 = pre-path flag string used ONLY on the path-scoped route, for flags # the aggregate script already carries in its own body (may be empty), # $5 = tool arguments JSON, -# $6 = scope-configured Stylelint config, already part of $3 (may be empty) +# $6 = scope-configured Stylelint config, already part of $3 (empty +# when the scope configures none) # Stdout: linter output, or the failure message _stylelint_dispatch_storefront() { local aggregate="$1" @@ -273,7 +47,7 @@ _stylelint_dispatch_storefront() { local flag_string="$3" local path_only_flags="$4" local args="$5" - local scoped_config="${6:-}" + local scoped_config="$6" local paths_json paths paths_json=$(echo "${args}" | jq -c '.paths // []') @@ -306,7 +80,7 @@ _stylelint_dispatch_storefront() { if [[ -n "${scoped_config}" ]]; then local config_check local config_code=0 - config_check=$(_stylelint_storefront_script_passes_config "${aggregate}" 3) || config_code=$? + config_check=$(js_stylelint_script_passes_config "${aggregate}" 3) || config_code=$? if [[ "${config_code}" -eq 0 ]]; then printf '%s\n' "Refusing to run: scope \"${SCOPE_NAME}\" selects the Stylelint config \"${scoped_config}\", but \"npm run ${aggregate}\" already passes a config of its own, and Stylelint refuses a second --config. Pass paths so the run uses the scope config alone, or call with scope \"shopware\"." return 1 @@ -323,66 +97,16 @@ _stylelint_dispatch_storefront() { return fi - local -a supplied=() - local -a literals=() - local p - while IFS= read -r p; do - [[ -z "${p}" ]] && continue - supplied+=("${p}") - if ! _stylelint_storefront_path_is_glob "${p}"; then - literals+=("${p}") - fi - done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') - - _stylelint_storefront_assert_local_binary "${tool_name}" "${aggregate}" || return 1 - - if [[ ${#literals[@]} -gt 0 ]]; then - local missing_report - if ! missing_report=$(assert_paths_lintable "." "${STOREFRONT_STYLELINT_EXTENSIONS}" "${literals[@]}"); then - printf '%s\n' "${missing_report}" - return 1 - fi - fi - - local -a directories=() - if [[ ${#literals[@]} -gt 0 ]]; then - local directory_report - if ! directory_report=$(_stylelint_storefront_directories "${literals[@]}"); then - printf '%s\n' "${directory_report}" - return 1 - fi - while IFS= read -r p; do - [[ -n "${p}" ]] && directories+=("${p}") - done <<< "${directory_report}" + local targets + if ! targets=$(js_path_scoped_targets "${tool_name}" "${STOREFRONT_STYLELINT_BINARY}" "${aggregate}" \ + "${STOREFRONT_STYLELINT_EXTENSIONS}" "${STOREFRONT_STYLELINT_DIRECTORY_GLOB}" "Stylelint" "${paths_json}"); then + printf '%s\n' "${targets}" + return 1 fi - local -a patterns=() - for p in "${supplied[@]}"; do - _stylelint_storefront_path_is_glob "${p}" && patterns+=("${p}") - done - _stylelint_storefront_assert_no_pattern_under_ddev_worktree "${tool_name}" \ - "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 - - local -a quoted=() - local target directory - for p in "${supplied[@]}"; do - target="${p}" - for directory in "${directories[@]+"${directories[@]}"}"; do - if [[ "${directory}" == "${p}" ]]; then - target=$(_stylelint_storefront_directory_pattern "${p}") - break - fi - done - quoted+=("$(shell_quote_arg "${target}")") - done - - # `--config stylelint.config.js --cache` are the flags the "lint:scss" - # body passes besides its target. The config is the core Storefront - # package's, so it is named only on an unscoped run. Under a scope a - # configured Stylelint config is already in the flag string, and without - # one Stylelint finds the scope package's own config itself. Anything the - # aggregate fix script would have supplied has to be added here as well. + # The "lint:scss" body's flags besides its target. local cmd="npm exec --no -- ${STOREFRONT_STYLELINT_BINARY}" + # SCOPE_CWD, not scoped_config: any scope runs outside the package holding this config. if [[ -z "${SCOPE_CWD:-}" ]]; then cmd="${cmd} --config ${STOREFRONT_STYLELINT_CONFIG}" fi @@ -393,7 +117,7 @@ _stylelint_dispatch_storefront() { if [[ -n "${path_only_flags}" ]]; then cmd="${cmd} ${path_only_flags}" fi - cmd="${cmd} ${quoted[*]}" + cmd="${cmd} ${targets}" log "INFO" "Running Stylelint (storefront, npm exec): ${cmd}" diff --git a/plugins/dev-tooling/mcp-server-js-storefront/server.sh b/plugins/dev-tooling/mcp-server-js-storefront/server.sh index d18e4316..a93ecb5f 100755 --- a/plugins/dev-tooling/mcp-server-js-storefront/server.sh +++ b/plugins/dev-tooling/mcp-server-js-storefront/server.sh @@ -67,6 +67,7 @@ fi # Source remaining modules source "${SHARED_DIR}/environment.sh" +source "${SHARED_DIR}/js_paths.sh" source "${SHARED_DIR}/scope.sh" if launch_is_bound; then if ! scope_validate; then diff --git a/plugins/dev-tooling/mcp-server-js-storefront/tools.json b/plugins/dev-tooling/mcp-server-js-storefront/tools.json index 1c6ba172..3bc9776c 100644 --- a/plugins/dev-tooling/mcp-server-js-storefront/tools.json +++ b/plugins/dev-tooling/mcp-server-js-storefront/tools.json @@ -2,7 +2,7 @@ "tools": [ { "name": "eslint_check", - "description": "ESLint code quality check (dry-run). Returns violations. Runs in Storefront context. Routes each path to the app tree (linted from the app/storefront package directory, or from the scope's directory under a scope) or the components tree (linted from src/Storefront/Resources with ./app/storefront/eslint.config.js, as lint:js does) and runs both when both are targeted. Components-tree paths are refused under any scope other than \"shopware\", because that tree belongs to the core Storefront package.", + "description": "ESLint code quality check (dry-run). Returns violations. Runs in Storefront context. Routes each path at or below views/components to the components tree and every other path to the app tree, and runs both when both are targeted. Components-tree paths are refused under any scope other than \"shopware\", because that tree belongs to the core Storefront package.", "inputSchema": { "type": "object", "properties": { @@ -36,7 +36,7 @@ }, { "name": "eslint_fix", - "description": "ESLint auto-fix. Applies code style fixes in Storefront context. Routes each path to the app tree (linted from the app/storefront package directory, or from the scope's directory under a scope) or the components tree (linted from src/Storefront/Resources with ./app/storefront/eslint.config.js, as lint:js does) and runs both when both are targeted. Components-tree paths are refused under any scope other than \"shopware\", because that tree belongs to the core Storefront package.", + "description": "ESLint auto-fix. Applies code style fixes in Storefront context. Routes each path at or below views/components to the components tree and every other path to the app tree, and runs both when both are targeted. Components-tree paths are refused under any scope other than \"shopware\", because that tree belongs to the core Storefront package.", "inputSchema": { "type": "object", "properties": { diff --git a/plugins/dev-tooling/shared/js_paths.sh b/plugins/dev-tooling/shared/js_paths.sh new file mode 100644 index 00000000..d4366c73 --- /dev/null +++ b/plugins/dev-tooling/shared/js_paths.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# Path-scoped runs of a JS package's own linter binary, for the JS MCP servers: +# Admin Prettier and Stylelint, Storefront Stylelint, and the Storefront ESLint +# binary probe. +# +# Owned by dev-tooling. Not templated — do not add it to the mapping in +# .claude/rules/template-sync.md. +# +# A path-scoped run executes the package's own binary through `npm exec --no`, +# never the aggregate npm script: npm appends `--` arguments after the script +# body's own targets, so a path appended to the aggregate widens the run to +# those targets and cannot narrow it. +# +# Requires: exec_npm_command(), shell_quote_arg(), assert_paths_lintable() and +# _env_targets_worktree() from environment.sh, npm_script_body() +# for the Stylelint config check, LINT_ENV, jq. + +# True when the path is a glob pattern the linter expands itself. The existence +# probe cannot resolve a glob, so a glob skips it. +# Args: $1 = caller-supplied path +js_path_is_glob() { + case "$1" in + *"*"*|*"?"*|*"["*) return 0 ;; + esac + return 1 +} + +# Refuse a path-scoped run when the package's own binary is not executable in +# node_modules/.bin where the command runs: `npm exec --no` installs nothing, +# but runs a global binary of the same name when the package has none. Probe +# exit 3 means the binary is missing, and the probe then prints the directory +# it ran in, under a container the container's path; any other non-zero exit +# means the probe itself failed. +# Args: $1 = tool name, $2 = binary, $3 = aggregate npm script, named in the +# refusal +# Stdout: the refusal +# Returns: 0 when the binary is executable, 1 otherwise +js_assert_local_binary() { + local tool_name="$1" + local binary="$2" + local aggregate="$3" + local bin_path="node_modules/.bin/${binary}" + + local probe="test -x ${bin_path} || { echo LOCAL_BINARY_MISSING_IN; pwd; exit 3; }" + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${probe}")") || probe_code=$? + if [[ "${probe_code}" -eq 0 ]]; then + return 0 + fi + + if [[ "${probe_code}" -eq 3 && "${output}" == *LOCAL_BINARY_MISSING_IN$'\n'* ]]; then + local package_dir="${output##*LOCAL_BINARY_MISSING_IN$'\n'}" + package_dir="${package_dir%%$'\n'*}" + printf '%s\n' "Refusing to run ${tool_name} with paths: \"${bin_path}\" is not installed in the package directory \"${package_dir}\". A path-scoped run executes only the package's own ${binary} binary: it installs none and uses no global one, and the aggregate \"${aggregate}\" script is not a substitute, because its own targets would widen the run. Run \`npm ci\` in that directory, or call worktree_prepare on this server for a worktree." + return 1 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: could not check whether the package's own ${binary} binary is installed; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 +} + +# Print, one per line, the literal paths that name directories. A path without +# an accepted extension passed the lintable probe only as a directory; one with +# an accepted extension may still be a directory, so those are tested where the +# command runs. +# Args: $1 = tool name, $2 = space-separated accepted extensions, +# $3.. = literal paths that passed the lintable probe +# Stdout: the directories, or the refusal +# Returns: 0 on success, 1 when the test could not run +js_path_directories() { + local tool_name="$1" + local -a extensions=() + read -r -a extensions <<< "$2" + shift 2 + + local -a directories=() + local p ext named script="" + for p in "$@"; do + named=0 + for ext in "${extensions[@]}"; do + if [[ "${p}" == *".${ext}" ]]; then + named=1 + fi + done + if [[ "${named}" -eq 0 ]]; then + directories+=("${p}") + else + script="${script}[ -d $(shell_quote_arg "${p}") ] && echo DIRECTORY:$(shell_quote_arg "${p}"); " + fi + done + + if [[ -n "${script}" ]]; then + local output + local probe_code=0 + output=$(exec_npm_command "npm exec --no -c $(shell_quote_arg "${script}true")") || probe_code=$? + if [[ "${probe_code}" -ne 0 ]]; then + printf '%s\n' "Refusing to run ${tool_name} with paths: could not check which of them are directories; the probe exited with ${probe_code}.${output:+ Probe output: ${output}}" + return 1 + fi + local line + while IFS= read -r line; do + if [[ "${line}" == DIRECTORY:* ]]; then + directories+=("${line#DIRECTORY:}") + fi + done <<< "${output}" + fi + + if [[ ${#directories[@]} -gt 0 ]]; then + printf '%s\n' "${directories[@]}" + fi +} + +# The pattern a directory reaches the linter as: the linter reads every file +# type it supports below a directory, so the directory is narrowed to the +# accepted extensions. The caller's quoting keeps the pattern away from the +# shell, so the linter expands it. +# Args: $1 = directory path, $2 = glob below it, e.g. "**/*.scss" +# Stdout: the pattern +js_directory_pattern() { + local path="$1" + + while [[ "${path}" == */ && "${path}" != "/" ]]; do + path="${path%/}" + done + printf '%s\n' "${path}/$2" +} + +# Refuse glob patterns and directories under ddev on a worktree: the command +# reaches `ddev exec`, which re-parses it in the container's bash without +# quoting a value that holds no space, quote or "#", so that bash would expand +# the pattern itself, without globstar, before the linter sees it. +# Args: $1 = tool name, $2 = linter name for the refusal, +# $3.. = the caller's glob patterns and directories +# Stdout: the refusal +# Returns: 0 when no such path is given or the call runs elsewhere, 1 otherwise +js_assert_no_pattern_under_ddev_worktree() { + local tool_name="$1" + local linter="$2" + shift 2 + + if [[ $# -eq 0 || "${LINT_ENV:-}" != "ddev" ]] || ! _env_targets_worktree; then + return 0 + fi + + printf '%s\n' "Refusing to run ${tool_name} with paths: under ddev on a worktree these paths would reach ${linter} as glob patterns the container shell expands first: $*. Pass file paths instead." + return 1 +} + +# Check the caller's paths for a path-scoped run and turn them into the +# linter's targets, each shell-quoted, with a directory narrowed to the +# directory glob. +# Args: $1 = tool name, $2 = binary, $3 = aggregate npm script, +# $4 = space-separated accepted extensions, $5 = directory glob, +# $6 = linter name for refusals, $7 = paths JSON array +# Stdout: the targets joined by spaces, or the refusal +# Returns: 0 on success, 1 on a refusal +js_path_scoped_targets() { + local tool_name="$1" + local binary="$2" + local aggregate="$3" + local extension_list="$4" + local directory_glob="$5" + local linter="$6" + local paths_json="$7" + + local -a supplied=() + local -a literals=() + local p + while IFS= read -r p; do + if [[ -z "${p}" ]]; then + continue + fi + supplied+=("${p}") + if ! js_path_is_glob "${p}"; then + literals+=("${p}") + fi + done < <(printf '%s\n' "${paths_json}" | jq -r '.[]') + + js_assert_local_binary "${tool_name}" "${binary}" "${aggregate}" || return 1 + + local -a directories=() + if [[ ${#literals[@]} -gt 0 ]]; then + local report + if ! report=$(assert_paths_lintable "." "${extension_list}" "${literals[@]}"); then + printf '%s\n' "${report}" + return 1 + fi + if ! report=$(js_path_directories "${tool_name}" "${extension_list}" "${literals[@]}"); then + printf '%s\n' "${report}" + return 1 + fi + while IFS= read -r p; do + if [[ -n "${p}" ]]; then + directories+=("${p}") + fi + done <<< "${report}" + fi + + local -a patterns=() + for p in "${supplied[@]}"; do + if js_path_is_glob "${p}"; then + patterns+=("${p}") + fi + done + js_assert_no_pattern_under_ddev_worktree "${tool_name}" "${linter}" \ + "${patterns[@]+"${patterns[@]}"}" "${directories[@]+"${directories[@]}"}" || return 1 + + local -a quoted=() + local target directory + for p in "${supplied[@]}"; do + target="${p}" + for directory in "${directories[@]+"${directories[@]}"}"; do + if [[ "${directory}" == "${p}" ]]; then + target=$(js_directory_pattern "${p}" "${directory_glob}") + break + fi + done + quoted+=("$(shell_quote_arg "${target}")") + done + + printf '%s\n' "${quoted[*]}" +} + +# True when a command in an npm script body runs Stylelint with a config flag +# of its own: --config, --config=, -c or -c=, counted only after the stylelint +# token of the same command, so another program's -c is not taken for one. +# Stylelint refuses a second config flag. The Storefront's Stylelint 16 accepts +# all four; the Administration's Stylelint 14 rejects -c and -c=, so a script +# using either fails on its own, and counting them refuses it earlier with a +# clearer message. +# Args: $1 = script body +# Returns: 0 when a Stylelint command in the body passes a config, 1 otherwise +js_stylelint_body_passes_config() { + local body="$1" + + body="${body//&&/;}" + body="${body//||/;}" + body="${body//|/;}" + + local -a segments=() + IFS=';' read -r -a segments <<< "${body}" + + local segment args + for segment in "${segments[@]+"${segments[@]}"}"; do + segment=" ${segment} " + case "${segment}" in + *" stylelint "*) args=" ${segment#* stylelint }" ;; + *"/stylelint "*) args=" ${segment#*/stylelint }" ;; + *) continue ;; + esac + case "${args}" in + *" --config "*|*" --config="*|*" -c "*|*" -c="*) return 0 ;; + esac + done + + return 1 +} + +# True when an npm script hands Stylelint a config of its own, directly or +# through an `npm run` of another script in its body, so a scope config cannot +# be appended to it. +# Args: $1 = npm script name, $2 = how many nested `npm run` levels to follow +# Stdout: a failure message, when a body cannot be read or the chain is deeper +# Returns: 0 = passes a config, 1 = passes none, 2 = a body could not be read, +# or the chain runs deeper than $2 levels +js_stylelint_script_passes_config() { + local script="$1" + local depth="$2" + + local body + local body_code=0 + body=$(npm_script_body "${script}") || body_code=$? + if [[ "${body_code}" -eq 1 ]]; then + printf '%s\n' "npm script \"${script}\" is not defined in package.json." + return 2 + fi + if [[ "${body_code}" -ne 0 ]]; then + printf '%s\n' "Could not read npm script \"${script}\" from package.json to check it for its own Stylelint config; see the server log for the failed probe." + return 2 + fi + + if js_stylelint_body_passes_config "${body}"; then + return 0 + fi + + local padded=" ${body} " + + if [[ "${padded}" != *" npm run "* ]]; then + return 1 + fi + + # An unread chain may pass a config, so it is refused rather than taken as + # passing none. + if [[ "${depth}" -le 0 ]]; then + printf '%s\n' "npm script \"${script}\" runs further npm scripts nested deeper than this tool follows, so it cannot be checked for its own Stylelint config." + return 2 + fi + + local rest="${padded}" + local inner nested_code + while [[ "${rest}" == *" npm run "* ]]; do + rest="${rest#* npm run }" + while [[ "${rest}" == -* ]]; do + rest="${rest#* }" + done + inner="${rest%% *}" + nested_code=0 + js_stylelint_script_passes_config "${inner}" "$((depth - 1))" || nested_code=$? + if [[ "${nested_code}" -ne 1 ]]; then + return "${nested_code}" + fi + done + + return 1 +} From 11abaa59e152d1c38239c95182e1eb801de01a35 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Sun, 11 Oct 2026 03:15:13 +0200 Subject: [PATCH 5/5] docs(dev-tooling): correct and shorten the 4.1.1 changelog and JS docs The 4.1.1 entry named `prettier` and `tsc` as blocked by both hooks, but only the Administration hook blocks them. `mcp-enforcement.md` lists the blocked binary forms instead of one long paragraph and links to `reference.md` for routing, `reference.md` explains the ddev-worktree refusal once, and `AGENTS.md` lists `shared/js_paths.sh` and `hooks/scripts/lib/js_binary.sh`. Co-Authored-By: Claude --- plugins/dev-tooling/AGENTS.md | 34 +++++----- plugins/dev-tooling/CHANGELOG.md | 13 ++-- plugins/dev-tooling/docs/mcp-enforcement.md | 16 ++++- plugins/dev-tooling/docs/reference.md | 69 ++++++++++++++++++--- 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/plugins/dev-tooling/AGENTS.md b/plugins/dev-tooling/AGENTS.md index 9d0a3216..577027be 100644 --- a/plugins/dev-tooling/AGENTS.md +++ b/plugins/dev-tooling/AGENTS.md @@ -39,7 +39,8 @@ plugins/dev-tooling/ │ ├── 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 (template-synced from templates/hooks-shared/common.sh): parse_hook_input(), load_mcp_config(), resolve_project_dir(), block_tool() +│ ├── common.sh # Shared (template-synced from templates/hooks-shared/common.sh): parse_hook_input(), load_mcp_config(), resolve_project_dir(), block_tool() +│ └── js_binary.sh # Shared binary-run matcher of the two JS hooks (dev-tooling-owned, not templated) │ ├── shared/ # SHARED FRAMEWORK (language-agnostic) │ ├── mcpserver_core.sh # JSON-RPC 2.0 protocol handler + validate_tool_arguments() @@ -48,6 +49,7 @@ plugins/dev-tooling/ │ ├── 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) +│ ├── js_paths.sh # JS path-scoped runs: local-binary probe, directory patterns, ddev-worktree refusal, Stylelint config detector (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 @@ -206,16 +208,9 @@ tool_eslint_check() { local args="$1" # No paths: aggregate script's own targets stay authoritative. local cmd="npm run lint -- ..." # Admin uses "lint", Storefront uses "lint:js" - # Paths supplied: the given paths are the ONLY targets. Admin ESLint runs - # the target-less script "lint:debugging" and refuses when it is unusable. - # Admin Stylelint and Prettier and both Storefront linters run the - # package's own binary through `npm exec --no -- `, - # with the flags of the aggregate script's body, and refuse when - # node_modules/.bin/ is missing. The Storefront components tree - # runs `npm exec --no -c "cd ../.. && eslint ..."`, as "lint:js" does. - # Stylelint and Prettier get a directory path as the quoted pattern - # `/**/*.`, so only the extensions the aggregate lints - # are read; under ddev on a worktree they refuse directory and glob paths. + # Paths supplied: the given paths are the ONLY targets, so the run goes to + # "lint:debugging" (Admin ESLint) or the package's own binary through + # `npm exec --no`. docs/reference.md lists each tool's route and refusals. exec_npm_command "${cmd}" } ``` @@ -316,8 +311,8 @@ This plugin's own suites are in `plugin-tests/dev-tooling/`: | `mcp_tool_console.bats` | Console tool command construction | | `mcp_tool_ecs.bats` | ECS tool command construction | | `mcp_tool_rector.bats` | Rector tool command construction | -| `mcp_tool_js_admin.bats` | Admin JS MCP tool command construction, and the Stylelint and Prettier commands and the local-binary probe executed through the native, docker, docker-compose, vagrant and ddev wrappers | -| `mcp_tool_js_storefront.bats` | Storefront JS MCP tool command construction (ESLint routing, Jest, Vitest, ludtwig), and the ESLint and Stylelint commands and the local-binary probe executed through the native, docker, docker-compose, vagrant and ddev wrappers | +| `mcp_tool_js_admin.bats` | Admin JS tool commands; Stylelint and Prettier executed through the env wrappers | +| `mcp_tool_js_storefront.bats` | Storefront JS tool commands; ESLint and Stylelint executed through the env wrappers | | `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) | @@ -337,7 +332,18 @@ This plugin's own suites are in `plugin-tests/dev-tooling/`: | `codex_launch.bats` | Launch mode and the bind: a server started in its plugin directory lists tools and refuses every other call, `set_project_root` binds it (exercised under `native`, `docker-compose` and `ddev`) and refuses a bad root, `cwd` reports both states, and a server started in a project directory runs a tool there with no bind | | `codex_manifest.bats` | Codex packaging: `.codex-plugin/plugin.json` version and shared-metadata equality with the Claude Code manifest and a description and keywords free of the LSP, `codex.mcp.json` server launch and forwarded variables, the `.agents/plugins/marketplace.json` entry, and that both directive prompts (Claude Code and Codex) list each server's `tools.json` tools and name a server at every tool mention; the Codex prompt carries no runner-agent or `EnterWorktree`/`ExitWorktree` guidance | -Four 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()`, the probe stubs `stub_worktree_probe()` / `worktree_test_guard_probe()`, `shopware_trunk_script_body()`, which answers an `npm pkg get "scripts."` probe from the trunk fixtures, and `js_execute_in_env()`, which runs a command a JS tool built through `wrap_npm_command` for one environment against fake `npm`, `docker`, `vagrant` and `ddev` and fake linter binaries; `fixtures/coverage/` — Clover XML samples for `mcp_tool_phpunit_coverage.bats`; `fixtures/shopware-trunk/` — the `scripts` blocks of the Administration and Storefront `package.json` files, copied verbatim from shopware/shopware trunk, which the JS tool suites answer script probes from; and `lsp_proxy/` — the Python `pytest` suite for `shared/lsp_proxy.py`, run outside BATS. +Four non-suite entries sit alongside them: + +- `test_helper/common_setup.bash`: shared helpers. + - `setup_config()` and `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()`. + - The probe stubs `stub_worktree_probe()` and `worktree_test_guard_probe()`. + - `shopware_trunk_script_body()`, which answers an `npm pkg get "scripts."` probe from the trunk fixtures. + - `js_execute_in_env()`, which runs a command a JS tool built through `wrap_npm_command` for one environment against fake `npm`, `docker`, `vagrant` and `ddev` and fake linter binaries. +- `fixtures/coverage/`: Clover XML samples for `mcp_tool_phpunit_coverage.bats`. +- `fixtures/shopware-trunk/`: the `scripts` blocks of the Administration and Storefront `package.json` files, copied verbatim from shopware/shopware trunk. The JS tool suites answer script probes from them. +- `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 f76f1cac..3e00b247 100644 --- a/plugins/dev-tooling/CHANGELOG.md +++ b/plugins/dev-tooling/CHANGELOG.md @@ -8,14 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [4.1.1] - 2026-10-11 ### Changed -- **The PreToolUse JS hooks block more direct runs of a tool binary.** They previously matched only `npx ` with no flag and no version. They now also block `npm exec` and `npm x`, a `node_modules/.bin/` path, an `@version` suffix (`npx eslint@9`), the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--` before the binary, and `npx`/`npm exec`/`npm x` with `-c` or `--call` when the command string runs the binary (`npm exec --no -c "cd ../.. && eslint views/components"`). The binaries are `eslint`, `stylelint`, `prettier`, `jest`, `tsc` and, on the Storefront hook, `vitest`. +- **The PreToolUse JS hooks block more direct runs of a tool binary.** They previously matched only `npx ` with no flag and no version. They now also block `npm exec` and `npm x`, a `node_modules/.bin/` path, an `@version` suffix (`npx eslint@9`), the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--` before the binary, and `-c` or `--call` when the command string runs the binary (`npm exec --no -c "cd ../.. && eslint views/components"`). Both hooks block `eslint`, `stylelint` and `jest`. The Administration hook also blocks `prettier` and `tsc`, and the Storefront hook also blocks `vitest`. + ### Fixed -- **Path-scoped Stylelint, Prettier and ESLint runs no longer call npm scripts Shopware does not define.** `stylelint_check`/`stylelint_fix` and `prettier_check`/`prettier_fix` on `js-admin-tooling`, and `stylelint_check`/`stylelint_fix` and `eslint_check`/`eslint_fix` on `js-storefront-tooling`, routed a run with `paths` to `stylelint:base`, `prettier:base`, `stylelint:app`, `eslint:app` and `eslint:components`, so they refused on every Shopware checkout. They now run the package's own binary through `npm exec --no -- ` with the flags of the aggregate script. The Storefront components tree runs `npm exec --no -c "cd ../.. && eslint …"`, as `lint:js` does. Admin ESLint still uses `lint:debugging`, and runs without `paths` are unchanged. -- **A path-scoped run checks that the package's own binary is installed.** `npm exec --no` installs nothing but runs a global binary of the same name, so the tool first tests for `node_modules/.bin/`. When it is missing, the run is refused with a message naming the binary and the directory the test ran in. -- **Directory paths for Stylelint and Prettier reach the tool as a quoted pattern.** A directory becomes `/**/*.scss` (Admin Stylelint), `/**/*.{scss,css}` (Storefront Stylelint) or `/**/*.{js,ts,mjs}` (Prettier), so only the files the aggregate script lints are touched. The same extension lists gate literal file paths: Admin Stylelint accepts `.scss` only, matching `lint:scss`, and Admin Prettier accepts `.mjs` besides `.js` and `.ts`, matching the `format` globs. Tools detect a directory whose name ends in a lintable extension. Under `ddev` on a linked worktree, directory and glob paths for these routes are refused, because `ddev exec` re-parses the command in the container shell, which would expand the pattern first. +- **Path-scoped Stylelint, Prettier and ESLint runs no longer call npm scripts Shopware does not define.** `stylelint_check`/`stylelint_fix` and `prettier_check`/`prettier_fix` on `js-admin-tooling`, and `stylelint_check`/`stylelint_fix` and `eslint_check`/`eslint_fix` on `js-storefront-tooling`, routed a run with `paths` to `stylelint:base`, `prettier:base`, `stylelint:app`, `eslint:app` and `eslint:components`, so they refused on every Shopware checkout. They now run the package's own binary through `npm exec --no -- ` with the flags of the aggregate script. The Storefront components tree runs `npm exec --no -c "cd ../.. && eslint …"`, as `lint:js` does. Admin ESLint still uses `lint:debugging`. Runs without `paths` are unchanged. +- **A path-scoped run refuses when the package's own binary is not installed.** The refusal names the binary and the directory it looked in. The check keeps `npm exec --no` from running a global binary of the same name. +- **A directory path for Stylelint or Prettier reaches the tool as a quoted pattern.** A directory becomes `/**/*.scss` (Admin Stylelint), `/**/*.{scss,css}` (Storefront Stylelint) or `/**/*.{js,ts,mjs}` (Prettier), so only the files the aggregate script lints are touched. A directory whose name ends in a lintable extension (`legacy.scss/`) is still treated as a directory. +- **File paths are gated by the extensions the aggregate script lints.** Admin Stylelint accepts `.scss` only, matching `lint:scss`. Admin Prettier accepts `.mjs` besides `.js` and `.ts`, matching the `format` globs. +- **Directory and glob paths for Stylelint and Prettier are refused under `ddev` on a linked worktree.** Pass file paths there. - **ESLint paths in the Storefront components tree are refused under a scope other than `shopware`.** That tree and `./app/storefront/eslint.config.js` belong to the core Storefront package, not to the scope's package. - **Storefront Stylelint passes the core `--config stylelint.config.js` only without a scope.** Under a scope the run uses the scope's configured Stylelint config alone, or the scope package's own config. -- **A scope-selected Stylelint config on a run without `paths` no longer produces two `--config` flags.** On `main` the tool appended `--config ` to `lint:scss` even when that script already passes one, as Storefront's does, and Stylelint refuses a second `--config`. The run is now refused when the script, or a script it reaches through `npm run`, passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command. +- **A scope-selected Stylelint config on a run without `paths` no longer produces two `--config` flags.** Previously the tool appended `--config ` to `lint:scss` even when that script already passes one, as Storefront's does, and Stylelint refuses a second `--config`. The run is now refused when the script, or a script it reaches through `npm run`, passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command. ## [4.1.0] - 2026-10-07 diff --git a/plugins/dev-tooling/docs/mcp-enforcement.md b/plugins/dev-tooling/docs/mcp-enforcement.md index 77a8fffe..141126c7 100644 --- a/plugins/dev-tooling/docs/mcp-enforcement.md +++ b/plugins/dev-tooling/docs/mcp-enforcement.md @@ -48,9 +48,21 @@ The JS hook picks Admin vs Storefront from the command itself and redirects to t > [!NOTE] > Detection reads the whole command string, so a compound command that names both trees — `git diff -- src/Administration src/Storefront && npm run jest:base` — satisfies both detectors and is blocked twice, once per server. This applies to any command mentioning both trees, not only the base scripts. -Tool binaries are blocked in four forms, each matched at a command position: `npx `, `npm exec ` or its alias `npm x `, `/node_modules/.bin/`, and the command-string form `npx -c ''`, `npm exec -c ""` or `--call`, when the string runs the binary at its start or after `;`, `&&` or `|` inside it, past leading blanks and variable assignments (`npm exec --no -c "cd ../.. && eslint views/components"`, the form the Storefront components ESLint run uses). Before `-c`, the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet` and `--silent` and the flags `--package`, `-p`, `--workspace` and `-w` with their value are allowed, because with `-c` the string names what runs. Between the runner and the binary, the flags `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--` are allowed (`npm exec --no -- eslint` is the form the MCP tools run), and the binary may carry a version suffix (`npx eslint@9`). The binaries are `eslint`, `stylelint` and `jest` on both servers, `prettier` and `tsc` on Administration only, and `vitest` on Storefront only. Not blocked: a flag that takes a value before the binary (`npx --package=eslint eslint`, `npm exec --workspace foo -- eslint`), because reading its value as the binary would block `npm exec --package eslint some-other-tool`; a bare binary name (`eslint`, `jest` and the rest), because a plain command-position match would block a commit message containing `; eslint config`; and a binary name in an argument. +Tool binaries are blocked when run directly at a command position, in three forms: -Path-scoped runs of `eslint_check`, `eslint_fix`, `stylelint_check`, `stylelint_fix`, `prettier_check` and `prettier_fix` call the local binary through `npm exec --no -- `, with two exceptions. Storefront ESLint on the components tree runs `npm exec --no -c "cd ../.. && eslint …"` from the storefront package directory, and Administration ESLint runs the `lint:debugging` script. `jest_run` tries `jest:base` and falls back to `unit`. Of the scripts named in this paragraph, only `lint:debugging` (Administration) and `unit` (both packages) exist in Shopware's `package.json` files; `jest:base`, `stylelint:base`, `prettier:base`, `eslint:app`, `eslint:components` and `stylelint:app` do not. The hooks still block all of them, because a project can define them. See [reference.md](./reference.md) for the routing rules. +- `npx `, `npm exec ` or its alias `npm x `, with an optional version suffix (`npx eslint@9`). +- `/node_modules/.bin/`. +- The command-string form, `npx -c ''`, `npm exec -c ""` or `--call`, when the string runs the binary at its start or after `;`, `&&` or `|` (`npm exec --no -c "cd ../.. && eslint views/components"`). + +The binaries are `eslint`, `stylelint` and `jest` on both servers, `prettier` and `tsc` on Administration only, and `vitest` on Storefront only. Between the runner and the binary the hooks look past `-y`, `--yes`, `--no`, `--no-install`, `-q`, `--quiet`, `--silent` and `--`. Before `-c` they look past the same flags except `--`, plus `--package`, `-p`, `--workspace` and `-w` with their value. + +Not blocked: + +- A flag that takes a value before the binary (`npx --package=eslint eslint`, `npm exec --workspace foo -- eslint`). Reading its value as the binary would also block `npm exec --package eslint some-other-tool`. +- A bare binary name (`eslint`, `jest`). A command-position match would block a commit message containing `; eslint config`. +- A binary name in an argument. + +[reference.md](./reference.md) describes how each MCP tool routes its own path-scoped runs. The table still lists `stylelint:base`, `prettier:base`, `jest:base`, `eslint:app`, `eslint:components` and `stylelint:app`, which Shopware's `package.json` files do not define, because a project can define them. | Bash Command | Admin MCP Tool | Storefront MCP Tool | |------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------|---------------------| diff --git a/plugins/dev-tooling/docs/reference.md b/plugins/dev-tooling/docs/reference.md index fb81cb69..d6d83586 100644 --- a/plugins/dev-tooling/docs/reference.md +++ b/plugins/dev-tooling/docs/reference.md @@ -188,7 +188,17 @@ Every path is validated before ESLint runs: it must exist and resolve to at leas ### `stylelint_check` / `stylelint_fix` -Stylelint SCSS linting / auto-fix. Two routes, selected by whether `paths` is supplied: with paths, the package's own Stylelint binary runs through `npm exec --no -- stylelint --cache`, the flags of the `lint:scss` script without its target, so the given paths are the only targets; without paths, the aggregate `lint:scss` / `lint:scss-fix` script runs (bare when there is nothing else to append) and its own `**/*.scss` target stays authoritative. The aggregate is never a fallback for a path-scoped run — appending a path to a body that already names `**/*.scss` would widen the run to every SCSS file plus the path, never narrow it. That matters most for `stylelint_fix`, which writes: a widened fix would modify files nobody named. A path-scoped run refuses outright when `node_modules/.bin/stylelint` is missing from the package directory: `npm exec --no` installs nothing, but it would run a globally installed `stylelint` instead, so the tool checks for the package's own binary first. With a scope-selected Stylelint config and no `paths`, the run is refused when `lint:scss` / `lint:scss-fix`, or a script it reaches through `npm run`, already passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command: Stylelint refuses a second `--config`. +Stylelint SCSS linting / auto-fix. Two routes, selected by whether `paths` is supplied: + +- With `paths`, the package's own Stylelint binary runs through `npm exec --no -- stylelint --cache`, the flags of the `lint:scss` script without its target. The given paths are the only targets. +- Without `paths`, the aggregate `lint:scss` / `lint:scss-fix` script runs, bare when there is nothing else to append. Its own `**/*.scss` target stays authoritative. + +The aggregate is never a fallback for a path-scoped run. Appending a path to a body that already names `**/*.scss` would widen the run to every SCSS file plus the path. That matters most for `stylelint_fix`, which writes: a widened fix would modify files nobody named. + +The run is refused when: + +- A path-scoped run finds no `node_modules/.bin/stylelint` in the package directory. `npm exec --no` installs nothing but would run a globally installed `stylelint`, so the tool checks for the package's own binary first. +- A scope selects its own Stylelint config, `paths` is omitted, and `lint:scss` / `lint:scss-fix`, or a script it reaches through `npm run`, already passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command. Stylelint refuses a second `--config`. ``` Use js-admin-tooling stylelint_fix with paths ["src/**/*.scss"] @@ -204,11 +214,23 @@ Use js-admin-tooling stylelint_fix with paths ["src/**/*.scss"] 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: a file must carry the `.scss` extension `lint:scss` lints, and a directory must hold at least one `.scss` file, or the path is refused with a message naming it rather than linting nothing and reporting success. A directory, including one whose name ends in `.scss`, reaches Stylelint as the quoted pattern `/**/*.scss`, which Stylelint expands, so only its `.scss` files are linted or fixed — Stylelint given a bare directory would read every file in it. A glob pattern (containing `*`, `?`, or `[`) skips the validation — the existence probe cannot resolve a glob — and reaches Stylelint unchecked for expansion there. Under `ddev` on a linked worktree, directory and glob paths are refused: `ddev exec` re-parses the command in the container's shell, which would expand the pattern itself first; pass file paths there. +> A literal path is validated before Stylelint runs. A file must carry the `.scss` extension `lint:scss` lints, and a directory must hold at least one `.scss` file. Otherwise the path is refused with a message naming it, rather than linting nothing and reporting success. +> +> A directory, including one whose name ends in `.scss`, reaches Stylelint as the quoted pattern `/**/*.scss`. Stylelint expands it, so only `.scss` files are linted or fixed. Stylelint given a bare directory would read every file in it. +> +> A glob pattern (containing `*`, `?`, or `[`) skips the validation, because the existence probe cannot resolve a glob. It reaches Stylelint unchecked for expansion there. + +> [!NOTE] +> Under `ddev` on a linked worktree, directory and glob paths are refused. `ddev exec` re-parses the command in the container's shell, which would expand the pattern itself before Stylelint sees it. Pass file paths there. ### `prettier_check` / `prettier_fix` -Prettier format check / auto-format for Administration. Two routes, selected by whether `paths` is supplied: with paths, the package's own Prettier binary runs through `npm exec --no -- prettier` with `--check` (`prettier_check`) or `--write --cache` (`prettier_fix`), the flags of the `format` / `format:fix` scripts without their globs, so the given paths are the only targets; without paths, the aggregate `format` / `format:fix` script runs bare (plus a `--config` override when a scope selects one) and its own glob targets stay authoritative. The aggregate is never a fallback for a path-scoped run, for the same reason as ESLint and Stylelint above — appending would widen, not narrow. That matters most for `prettier_fix`, which writes. A path-scoped run refuses outright when `node_modules/.bin/prettier` is missing from the package directory. +Prettier format check / auto-format for Administration. Two routes, selected by whether `paths` is supplied: + +- With `paths`, the package's own Prettier binary runs through `npm exec --no -- prettier` with `--check` (`prettier_check`) or `--write --cache` (`prettier_fix`), the flags of the `format` / `format:fix` scripts without their globs. The given paths are the only targets. +- Without `paths`, the aggregate `format` / `format:fix` script runs bare, plus a `--config` override when a scope selects one. Its own glob targets stay authoritative. + +The aggregate is never a fallback for a path-scoped run, for the reason given for [Stylelint](#stylelint_check--stylelint_fix). That matters most for `prettier_fix`, which writes. A path-scoped run is refused when `node_modules/.bin/prettier` is missing from the package directory. ``` Use js-admin-tooling prettier_check with paths ["src/app/component/"] @@ -223,7 +245,13 @@ Use js-admin-tooling prettier_check with paths ["src/app/component/"] 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: a file must carry one of the extensions the `format` globs name (`.js`, `.ts`, `.mjs`), and a directory must hold at least one such file, or the path is refused with a message naming it. A directory, including one whose name ends in one of those extensions, reaches Prettier as the quoted pattern `/**/*.{js,ts,mjs}`, which Prettier expands, so only those files are checked or rewritten — Prettier given a bare directory would format every file type it supports in it. A glob pattern (containing `*`, `?`, or `[`) skips the validation and reaches Prettier unchecked for expansion there. Under `ddev` on a linked worktree, directory and glob paths are refused, for the reason given for Stylelint above. +> A literal path is validated before Prettier runs. A file must carry one of the extensions the `format` globs name (`.js`, `.ts`, `.mjs`), and a directory must hold at least one such file. Otherwise the path is refused with a message naming it. +> +> A directory, including one whose name ends in one of those extensions, reaches Prettier as the quoted pattern `/**/*.{js,ts,mjs}`. Prettier expands it, so only those files are checked or rewritten. Prettier given a bare directory would format every file type it supports. +> +> A glob pattern (containing `*`, `?`, or `[`) skips the validation and reaches Prettier unchecked for expansion there. +> +> Under `ddev` on a linked worktree, directory and glob paths are refused, as for [Stylelint](#stylelint_check--stylelint_fix). ### `jest_run` @@ -330,7 +358,14 @@ Runs inside `src/Storefront/Resources/app/storefront`. No context parameter. ### `eslint_check` / `eslint_fix` -ESLint linting / auto-fix for Storefront. The Storefront's `lint:js` script runs ESLint twice — once in the `app/storefront` package, and once from `src/Storefront/Resources` with `--config ./app/storefront/eslint.config.js` for the component tree under `views/components` — so each supplied path is routed to the tree that owns it, and both trees run when both are targeted. A path containing `views/components` goes to the components tree; everything else goes to the app tree. With paths, each tree runs the package's own ESLint binary through `npm exec --no` with the flags of its `lint:js` call (`--no-error-on-unmatched-pattern --report-unused-disable-directives`, plus the components config) and the given paths as the only targets; the components call starts with `npm exec --no -c` in the package directory and changes to `../..` before ESLint runs. Under a scope other than `shopware`, a path routed to the components tree is refused before anything runs: that tree and `./app/storefront/eslint.config.js` belong to the core Storefront package, not to the scope's package. App-tree paths under a scope run with the scope's ESLint config. A path-scoped run refuses outright when `node_modules/.bin/eslint` is missing from the directory it runs in. +ESLint linting / auto-fix for Storefront. The Storefront's `lint:js` script runs ESLint twice: once in the `app/storefront` package, and once from `src/Storefront/Resources` with `--config ./app/storefront/eslint.config.js` for the component tree under `views/components`. Each supplied path is routed to the tree that owns it, and both trees run when both are targeted. A path containing `views/components` goes to the components tree; everything else goes to the app tree. + +With paths, each tree runs the package's own ESLint binary through `npm exec --no`, with the flags of its `lint:js` call (`--no-error-on-unmatched-pattern --report-unused-disable-directives`, plus the components config). The given paths are the only targets. The components call starts with `npm exec --no -c` in the package directory and changes to `../..` before ESLint runs. App-tree paths under a scope run with the scope's ESLint config. + +A path-scoped run is refused when: + +- `node_modules/.bin/eslint` is missing from the package directory. +- A scope other than `shopware` is active and a path routes to the components tree. That tree and `./app/storefront/eslint.config.js` belong to the core Storefront package, not to the scope's package. The refusal comes before anything runs. ``` Use js-storefront-tooling eslint_fix with paths ["src/plugin/"] @@ -362,14 +397,34 @@ 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 package's own Stylelint binary runs through `npm exec --no -- stylelint --config stylelint.config.js --cache`, the flags of the `lint:scss` script without its target, so the given paths are the only targets. Under a scope, `--config stylelint.config.js` is left out, because that file belongs to the core Storefront package: a scope that selects its own Stylelint config passes that one alone (Stylelint refuses a second `--config`), and without one Stylelint finds the scope package's own config. Without `paths`, the aggregate `lint:scss` / `lint:scss-fix` script runs and its own `./src/scss` target stays authoritative; with a scope-selected Stylelint config, that run is refused when the script, or a script it reaches through `npm run`, already passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command, for the same reason. The aggregate is never a fallback for a path-scoped run, and a path-scoped run refuses outright when `node_modules/.bin/stylelint` is missing from the package directory. 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)). +Stylelint SCSS linting / auto-fix. Same routing as the [Administration versions](#stylelint_check--stylelint_fix), selected by whether `paths` is supplied: + +- With `paths`, the package's own Stylelint binary runs through `npm exec --no -- stylelint --config stylelint.config.js --cache`, the flags of the `lint:scss` script without its target. 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. + +Under a scope, `--config stylelint.config.js` is left out, because that file belongs to the core Storefront package. A scope that selects its own Stylelint config passes that one alone, because Stylelint refuses a second `--config`. Without one, Stylelint finds the scope package's own config. + +The run is refused when: + +- A path-scoped run finds no `node_modules/.bin/stylelint` in the package directory. +- A scope selects its own Stylelint config, `paths` is omitted, and `lint:scss` / `lint:scss-fix`, or a script it reaches through `npm run`, already passes `--config`, `--config=`, `-c` or `-c=` after its `stylelint` command. + +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"] ``` > [!NOTE] -> A literal path is validated before Stylelint runs: a file must carry the `.scss` or `.css` extension, and a directory must hold at least one such file, or the path is refused with a message naming it. A directory, including one whose name ends in `.scss` or `.css`, reaches Stylelint as the quoted pattern `/**/*.{scss,css}`, so only those files are linted or fixed. A glob pattern (containing `*`, `?`, or `[`) skips the validation and reaches Stylelint unchecked for expansion there. Under `ddev` on a linked worktree, directory and glob paths are refused, for the reason given for the Administration versions. +> A literal path is validated before Stylelint runs. A file must carry the `.scss` or `.css` extension, and a directory must hold at least one such file. Otherwise the path is refused with a message naming it. +> +> A directory, including one whose name ends in `.scss` or `.css`, reaches Stylelint as the quoted pattern `/**/*.{scss,css}`, so only those files are linted or fixed. +> +> A glob pattern (containing `*`, `?`, or `[`) skips the validation and reaches Stylelint unchecked for expansion there. +> +> Under `ddev` on a linked worktree, directory and glob paths are refused, as for [Administration Stylelint](#stylelint_check--stylelint_fix). ### `jest_run`