diff --git a/.changeset/sharp-orcas-purr.md b/.changeset/sharp-orcas-purr.md new file mode 100644 index 000000000..9fb71ebfd --- /dev/null +++ b/.changeset/sharp-orcas-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4116 +--- +**`/gsd:review` no longer misdispatches or undercounts reviewer lanes under zsh** — a shell word-splitting bug collapsed multiple selected reviewers onto one bogus iteration when the workflow's dispatch, gate-check, and plan-coverage logic ran under zsh (the macOS default shell); all affected sites across gsd-core/workflows/*.md are fixed, and a new ShellCheck + structural lint gate catches this bug class in CI going forward. (#4109) diff --git a/gsd-core/workflows/code-review.md b/gsd-core/workflows/code-review.md index c361539fe..52c2a0451 100644 --- a/gsd-core/workflows/code-review.md +++ b/gsd-core/workflows/code-review.md @@ -140,7 +140,10 @@ if [ -z "$FILES_OVERRIDE" ]; then REVIEW_FILES=() if [ -n "$SUMMARIES" ]; then - for summary in $SUMMARIES; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + for summary in $(printf '%s' "$SUMMARIES"); do # Extract key_files.created and key_files.modified using node for reliable YAML parsing # This avoids fragile awk parsing that breaks on indentation differences EXTRACTED=$(node -e " diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index 03dcfc298..466ea92f1 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -775,7 +775,10 @@ CURRENT_BRANCH=$(git branch --show-current) git checkout ${BASE_BRANCH} if [ "$BRANCHING_STRATEGY" = "phase" ]; then - for branch in $PHASE_BRANCHES; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + for branch in $(printf '%s' "$PHASE_BRANCHES"); do git merge --squash "$branch" # Strip .planning/ from staging if commit_docs is false if [ "$COMMIT_DOCS" = "false" ]; then @@ -804,7 +807,10 @@ CURRENT_BRANCH=$(git branch --show-current) git checkout ${BASE_BRANCH} if [ "$BRANCHING_STRATEGY" = "phase" ]; then - for branch in $PHASE_BRANCHES; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + for branch in $(printf '%s' "$PHASE_BRANCHES"); do git merge --no-ff --no-commit "$branch" # Strip .planning/ from staging if commit_docs is false if [ "$COMMIT_DOCS" = "false" ]; then @@ -830,7 +836,10 @@ git checkout "$CURRENT_BRANCH" ```bash if [ "$BRANCHING_STRATEGY" = "phase" ]; then - for branch in $PHASE_BRANCHES; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + for branch in $(printf '%s' "$PHASE_BRANCHES"); do git branch -d "$branch" 2>/dev/null || git branch -D "$branch" done fi diff --git a/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md b/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md index 8f24f76a5..89d795745 100644 --- a/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +++ b/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md @@ -48,12 +48,18 @@ if [ -n "$SUBMODULE_PATHS" ] && [ "$USE_WORKTREES_FOR_PLAN" != "false" ]; then # submodule "vendor/foo". INTERSECT="" set -f # disable globbing while iterating literal patterns - for sm_raw in $SUBMODULE_PATHS; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + for sm_raw in $(printf '%s' "$SUBMODULE_PATHS"); do # Normalize submodule path: strip ./ prefix and trailing / sm="${sm_raw#./}" sm="${sm%/}" [ -z "$sm" ] && continue - for pf_raw in $PLAN_SCOPE_PATHS; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + for pf_raw in $(printf '%s' "$PLAN_SCOPE_PATHS"); do # Normalize planned path the same way pf="${pf_raw#./}" pf="${pf%/}" diff --git a/gsd-core/workflows/pr-branch.md b/gsd-core/workflows/pr-branch.md index accd43a6d..00a6b8a84 100644 --- a/gsd-core/workflows/pr-branch.md +++ b/gsd-core/workflows/pr-branch.md @@ -261,7 +261,10 @@ if [ "$PR_STRICT" = "true" ]; then FILTER_PATHS=".planning/" FORBIDDEN_RE="^\.planning/" else - FILTER_PATHS=$(for d in $TRANSIENT_DIRS; do printf '.planning/%s/ ' "$d"; done) + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$VAR` word-splits under bash but not zsh, collapsing every element onto + # one iteration there. + FILTER_PATHS=$(for d in $(printf '%s' "$TRANSIENT_DIRS"); do printf '.planning/%s/ ' "$d"; done) FORBIDDEN_RE="^\.planning/($(echo "$TRANSIENT_DIRS" | tr ' ' '|'))/" fi ``` @@ -322,12 +325,15 @@ touching the same planning path makes `git cherry-pick` abort with *"untracked w files would be overwritten by merge"*, and every remaining commit is silently dropped. ```bash -for HASH in $INCLUDED_COMMITS; do +# Rewrapped through unquoted command substitution (gsd-core#4109): a bare +# `$VAR` word-splits under bash but not zsh, collapsing every element onto +# one iteration there. +for HASH in $(printf '%s' "$INCLUDED_COMMITS"); do # A modify/delete conflict on a filtered path is EXPECTED and is resolved below — the # filtered path is absent from HEAD by construction. Do not treat it as a failure here. git cherry-pick --no-commit "$HASH" || true - for P in $FILTER_PATHS; do + for P in $(printf '%s' "$FILTER_PATHS"); do git rm -r -f -q --ignore-unmatch -- "$P" 2>/dev/null || true git checkout HEAD -- "$P" 2>/dev/null || true done diff --git a/gsd-core/workflows/review.md b/gsd-core/workflows/review.md index 395248883..cd2c2f15c 100644 --- a/gsd-core/workflows/review.md +++ b/gsd-core/workflows/review.md @@ -490,7 +490,14 @@ for SLUG in $(echo "$SELECTED_REVIEWERS" | tr ',' ' '); do DISPATCH_SLUGS="$DISPATCH_SLUGS $SLUG" done -for SLUG in $DISPATCH_SLUGS; do +# Rewrapped through unquoted command substitution, not consumed as a bare +# `$DISPATCH_SLUGS`: bash word-splits an unquoted scalar on IFS by default, +# but zsh does not, so a bare re-split collapsed every slug onto one +# iteration under zsh whenever 2+ reviewers were selected (gsd-core#4109). +# Unquoted `$(...)` re-splits identically under both shells regardless of +# `SH_WORD_SPLIT` — same reason the accumulator-building loop above already +# works under both. +for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do if [ "$PARALLEL_LANES" = "true" ]; then run_review_lane "$SLUG" & else @@ -508,7 +515,9 @@ wait # produces is byte-identical to the one a sequential run produces. This is post-join and therefore # single-threaded, so `>>` here is safe. A lane that was budget-skipped, or that never started, # leaves no result file and correctly contributes no line. -for SLUG in $DISPATCH_SLUGS; do +# Rewrapped through unquoted command substitution (gsd-core#4109) — see the +# dispatch loop above for why a bare `$DISPATCH_SLUGS` collapses under zsh. +for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do LANE_RESULT="$RUN_DIR/gsd-review-lane-result-$SLUG.json" if [ -f "$LANE_RESULT" ]; then cat "$LANE_RESULT" >> "$RUN_DIR/gsd-review-lane-results.jsonl" @@ -569,7 +578,10 @@ if [ "${LANE_LINES:-0}" -eq 0 ]; then # failure stub does not. If a slug has no stub at all, it is not a skip. DISPATCHED_COUNT=0 SKIPPED_COUNT=0 - for SLUG in $DISPATCH_SLUGS; do + # Rewrapped through unquoted command substitution (gsd-core#4109): a bare + # `$DISPATCH_SLUGS` word-splits under bash but not zsh, collapsing every + # slug onto one iteration there whenever 2+ reviewers were selected. + for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1)) STUB="$RUN_DIR/gsd-review-$SLUG.md" if [ -f "$STUB" ] && grep -q "review skipped: prompt budget" "$STUB" 2>/dev/null; then @@ -627,7 +639,10 @@ for SLUG in $(echo "$SELECTED_REVIEWERS" | tr ',' ' '); do DISPATCH_SLUGS="$DISPATCH_SLUGS $SLUG" done -for SLUG in $DISPATCH_SLUGS; do +# Rewrapped through unquoted command substitution (gsd-core#4109): a bare +# `$DISPATCH_SLUGS` word-splits under bash but not zsh, collapsing every +# slug onto one iteration there whenever 2+ reviewers were selected. +for SLUG in $(printf '%s' "$DISPATCH_SLUGS"); do [ "$SLUG" = "coderabbit" ] && continue REVIEW_FILE="$RUN_DIR/gsd-review-$SLUG.md" [ -f "$REVIEW_FILE" ] || continue diff --git a/gsd-core/workflows/sync-skills.md b/gsd-core/workflows/sync-skills.md index 38d23a13f..9ed2baf05 100644 --- a/gsd-core/workflows/sync-skills.md +++ b/gsd-core/workflows/sync-skills.md @@ -237,12 +237,18 @@ mkdir -p "$DEST_ROOT" # empty. If per-runtime conversion is ever wired in, this is where it would go; # until then the cp -r must never run for a destination != source. -for SKILL in $CREATE_LIST $UPDATE_LIST; do +# Rewrapped through unquoted command substitution (gsd-core#4109): a bare +# `$VAR` word-splits under bash but not zsh, collapsing every element onto +# one iteration there. +for SKILL in $(printf '%s' "$CREATE_LIST") $(printf '%s' "$UPDATE_LIST"); do rm -rf "$DEST_ROOT/$SKILL" cp -r "$SRC_SKILLS_ROOT/$SKILL" "$DEST_ROOT/$SKILL" done -for SKILL in $REMOVE_LIST; do +# Rewrapped through unquoted command substitution (gsd-core#4109): a bare +# `$VAR` word-splits under bash but not zsh, collapsing every element onto +# one iteration there. +for SKILL in $(printf '%s' "$REMOVE_LIST"); do rm -rf "$DEST_ROOT/$SKILL" done ``` diff --git a/package-lock.json b/package-lock.json index 0ba183a1c..776121005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "js-yaml": "^4.3.1", "mutation-testing-metrics": "^3.7.3", "re2js": "^2.8.6", + "shellcheck": "^4.1.0", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" }, @@ -754,6 +755,17 @@ "node": ">=18" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1101,6 +1113,35 @@ "win32" ] }, + "node_modules/@felipecrs/decompress-tarxz": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@felipecrs/decompress-tarxz/-/decompress-tarxz-5.0.4.tgz", + "integrity": "sha512-a+nAnDsiUA84Sy/a+FKYJtjOjFvNtW8Jcbi3NwE8kJKPpYAxINFLYsC9mev9/wngiNEBA3jfHn0qNFwICeZNJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xhmikosr/decompress-tar": "^8.1.0", + "file-type": "^20.5.0", + "is-stream": "^2.0.1", + "xz-decompress": "^0.2.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@felipecrs/decompress-tarxz/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@hono/node-server": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", @@ -1775,6 +1816,32 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2049,6 +2116,62 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@xhmikosr/decompress-tar": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tar/-/decompress-tar-8.1.0.tgz", + "integrity": "sha512-m0q8x6lwxenh1CrsTby0Jrjq4vzW/QU1OLhTHMQLEdHpmjR1lgahGz++seZI0bXF3XcZw3U3xHfqZSz+JPP2Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^20.5.0", + "is-stream": "^2.0.1", + "tar-stream": "^3.1.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress-tar/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@xhmikosr/decompress-unzip": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-unzip/-/decompress-unzip-7.1.0.tgz", + "integrity": "sha512-oqTYAcObqTlg8owulxFTqiaJkfv2SHsxxxz9Wg4krJAHVzGWlZsU8tAB30R6ow+aHrfv4Kub6WQ8u04NWVPUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^20.5.0", + "get-stream": "^6.0.1", + "yauzl": "^3.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@xhmikosr/decompress-unzip/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2161,6 +2284,37 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2171,6 +2325,112 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -2184,6 +2444,17 @@ "node": ">=6.0.0" } }, + "node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2221,6 +2492,14 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2268,6 +2547,66 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2311,6 +2650,25 @@ } } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2500,6 +2858,13 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2548,6 +2913,226 @@ } } }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar/node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-tar/node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz/node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/decompress/node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress/node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2555,6 +3140,42 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2585,6 +3206,13 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, "node_modules/diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", @@ -2635,6 +3263,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.22.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", @@ -2649,6 +3287,19 @@ "node": ">=10.13.0" } }, + "node_modules/envalid": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.0.tgz", + "integrity": "sha512-OT6+qVhKVyCidaGoXflb2iK1tC8pd0OV2Q+v9n33wNhUJ+lus+rJobUj4vJaQBPxPZ0vYrPGuxdrenyCAIJcow==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2679,6 +3330,13 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3041,6 +3699,16 @@ "node": ">=12" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -3208,6 +3876,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3265,6 +3940,16 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3283,6 +3968,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -3312,6 +4004,25 @@ "node": ">=16.0.0" } }, + "node_modules/file-type": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz", + "integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3371,6 +4082,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3406,6 +4133,13 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3533,6 +4267,24 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -3546,6 +4298,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", @@ -3582,6 +4351,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3594,6 +4376,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -3668,6 +4466,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3729,6 +4548,19 @@ "node": ">= 0.10" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3762,6 +4594,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -3794,6 +4633,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -3807,6 +4662,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3957,6 +4819,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4050,6 +4919,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4268,6 +5150,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4420,6 +5312,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4440,6 +5339,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -4449,6 +5381,16 @@ "node": ">=16.20.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4475,6 +5417,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4574,6 +5523,29 @@ "node": ">=18.0.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4613,6 +5585,24 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4639,12 +5629,54 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/seek-bzip/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4658,6 +5690,13 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT" + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -4684,6 +5723,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -4703,6 +5758,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4730,6 +5803,26 @@ "node": ">=8" } }, + "node_modules/shellcheck": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/shellcheck/-/shellcheck-4.1.0.tgz", + "integrity": "sha512-8143z6YGO4+Puwp9Ghn/g7+QxllSKlXaZSm3HXfvQXUfRXhM5P8TPORRHBBlyobl9BnniVne+d1Ff6RgNiccsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@felipecrs/decompress-tarxz": "5.0.4", + "@xhmikosr/decompress-unzip": "7.1.0", + "decompress": "4.2.1", + "envalid": "8.1.0", + "global-agent": "3.0.0" + }, + "bin": { + "shellcheck": "bin/shellcheck.js" + }, + "engines": { + "node": ">=20.9.0" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4825,6 +5918,13 @@ "node": ">= 12" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4834,6 +5934,35 @@ "node": ">= 0.8" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4862,6 +5991,16 @@ "node": ">=8" } }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, "node_modules/strip-final-newline": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", @@ -4888,6 +6027,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4946,6 +6102,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", @@ -4961,6 +6140,23 @@ "node": "20 || >=22" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4978,6 +6174,28 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4987,6 +6205,25 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5069,6 +6306,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -5100,6 +6350,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typed-inject": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", @@ -5165,6 +6430,30 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", @@ -5242,6 +6531,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5288,6 +6584,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5343,6 +6661,26 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/xz-decompress": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/xz-decompress/-/xz-decompress-0.2.3.tgz", + "integrity": "sha512-O8v6HG8T0PrKBcpyWA13GkSYWFvncwzuzcLx5A7++l3HsE3atmoetXjIxrZ/JV/nbvSZ7WS4+3XvREZuVn+rEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -5419,6 +6757,19 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 525923579..edee83f4c 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "js-yaml": "^4.3.1", "mutation-testing-metrics": "^3.7.3", "re2js": "^2.8.6", + "shellcheck": "^4.1.0", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" }, @@ -121,7 +122,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-slug-derivation-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs && node scripts/lint-workflow-shellcheck.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/lint-workflow-shellcheck-baseline.json b/scripts/lint-workflow-shellcheck-baseline.json new file mode 100644 index 000000000..bd0fb65c3 --- /dev/null +++ b/scripts/lint-workflow-shellcheck-baseline.json @@ -0,0 +1,1062 @@ +[ + { + "file": "gsd-core/workflows/add-tests.md", + "code": "2035", + "message": "Use ./*glob* or -- *glob* so names with dashes won't become options." + }, + { + "file": "gsd-core/workflows/add-todo.md", + "code": "2102", + "message": "Ranges can only match single chars (mentioned due to duplicates)." + }, + { + "file": "gsd-core/workflows/ai-integration-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/autonomous.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/check-todos.md", + "code": "2102", + "message": "Ranges can only match single chars (mentioned due to duplicates)." + }, + { + "file": "gsd-core/workflows/check-todos.md", + "code": "2102", + "message": "Ranges can only match single chars (mentioned due to duplicates)." + }, + { + "file": "gsd-core/workflows/cleanup.md", + "code": "1083", + "message": "This { is literal. Check expression (missing ;/\\n?) or quote it." + }, + { + "file": "gsd-core/workflows/cleanup.md", + "code": "1083", + "message": "This { is literal. Check expression (missing ;/\\n?) or quote it." + }, + { + "file": "gsd-core/workflows/cleanup.md", + "code": "1083", + "message": "This { is literal. Check expression (missing ;/\\n?) or quote it." + }, + { + "file": "gsd-core/workflows/cleanup.md", + "code": "1083", + "message": "This } is literal. Check expression (missing ;/\\n?) or quote it." + }, + { + "file": "gsd-core/workflows/cleanup.md", + "code": "1083", + "message": "This } is literal. Check expression (missing ;/\\n?) or quote it." + }, + { + "file": "gsd-core/workflows/cleanup.md", + "code": "1083", + "message": "This } is literal. Check expression (missing ;/\\n?) or quote it." + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1009", + "message": "The mentioned syntax error was in this then clause." + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1036", + "message": "'(' is invalid here. Did you forget to escape it?" + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1061", + "message": "Couldn't find 'done' for this 'do'." + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1062", + "message": "Expected 'done' matching previously mentioned 'do'." + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1065", + "message": "Trying to declare parameters? Don't. Use () and refer to params as $1, $2.." + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1072", + "message": "Expected 'done'. Fix any mentioned problems and try again." + }, + { + "file": "gsd-core/workflows/code-review-fix.md", + "code": "1073", + "message": "Couldn't parse this while loop. Fix to allow more checks." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1009", + "message": "The mentioned syntax error was in this test expression." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1035", + "message": "You need a space after the [ and before the ]." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1072", + "message": "Expected end of single quoted string. Fix any mentioned problems and try again." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1073", + "message": "Couldn't parse this single quoted string. Fix to allow more checks." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1078", + "message": "Did you forget to close this single quoted string?" + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1078", + "message": "Did you forget to close this single quoted string?" + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1079", + "message": "This is actually an end quote, but due to next char it looks suspect." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "1079", + "message": "This is actually an end quote, but due to next char it looks suspect." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "2088", + "message": "Tilde does not expand in quotes. Use $HOME." + }, + { + "file": "gsd-core/workflows/code-review.md", + "code": "2153", + "message": "Possible misspelling: DEPTH_OVERRIDE may not be assigned. Did you mean DEPTH_OVERRIDES?" + }, + { + "file": "gsd-core/workflows/code-review/steps/dispatch-fix.md", + "code": "1036", + "message": "'(' is invalid here. Did you forget to escape it?" + }, + { + "file": "gsd-core/workflows/code-review/steps/dispatch-fix.md", + "code": "1046", + "message": "Couldn't find 'fi' for this 'if'." + }, + { + "file": "gsd-core/workflows/code-review/steps/dispatch-fix.md", + "code": "1047", + "message": "Expected 'fi' matching previously mentioned 'if'." + }, + { + "file": "gsd-core/workflows/code-review/steps/dispatch-fix.md", + "code": "1065", + "message": "Trying to declare parameters? Don't. Use () and refer to params as $1, $2.." + }, + { + "file": "gsd-core/workflows/code-review/steps/dispatch-fix.md", + "code": "1072", + "message": "Expected 'fi'. Fix any mentioned problems and try again." + }, + { + "file": "gsd-core/workflows/code-review/steps/dispatch-fix.md", + "code": "1073", + "message": "Couldn't parse this if expression. Fix to allow more checks." + }, + { + "file": "gsd-core/workflows/code-review/steps/structural-pre-pass.md", + "code": "1009", + "message": "The mentioned syntax error was in this variable assignment." + }, + { + "file": "gsd-core/workflows/code-review/steps/structural-pre-pass.md", + "code": "1036", + "message": "'(' is invalid here. Did you forget to escape it?" + }, + { + "file": "gsd-core/workflows/code-review/steps/structural-pre-pass.md", + "code": "1072", + "message": "Expected end of $(..) expression. Fix any mentioned problems and try again." + }, + { + "file": "gsd-core/workflows/code-review/steps/structural-pre-pass.md", + "code": "1073", + "message": "Couldn't parse this command expansion. Fix to allow more checks." + }, + { + "file": "gsd-core/workflows/code-review/steps/structural-pre-pass.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/code-review/steps/structural-pre-pass.md", + "code": "2157", + "message": "Argument to -z is always false due to literal strings." + }, + { + "file": "gsd-core/workflows/complete-milestone.md", + "code": "2001", + "message": "See if you can use ${variable//search/replace} instead." + }, + { + "file": "gsd-core/workflows/complete-milestone.md", + "code": "2001", + "message": "See if you can use ${variable//search/replace} instead." + }, + { + "file": "gsd-core/workflows/complete-milestone.md", + "code": "2038", + "message": "Use 'find .. -print0 | xargs -0 ..' or 'find .. -exec .. +' to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/complete-milestone.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/complete-milestone.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/complete-milestone.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/debug.md", + "code": "2010", + "message": "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/discuss-phase-assumptions.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2010", + "message": "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/discuss-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md", + "code": "2043", + "message": "This loop will only ever run once. Bad quoting or missing glob/expansion?" + }, + { + "file": "gsd-core/workflows/eval-review.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/eval-review.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2016", + "message": "Expressions don't expand in single quotes, use double quotes for that." + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2043", + "message": "This loop will only ever run once. Bad quoting or missing glob/expansion?" + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md", + "code": "2016", + "message": "Expressions don't expand in single quotes, use double quotes for that." + }, + { + "file": "gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md", + "code": "2016", + "message": "Expressions don't expand in single quotes, use double quotes for that." + }, + { + "file": "gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-plan.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-plan.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/execute-plan.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/forensics.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/health.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/health.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/import.md", + "code": "2194", + "message": "This word is constant. Did you forget the $ on a variable?" + }, + { + "file": "gsd-core/workflows/ingest-docs.md", + "code": "2157", + "message": "Argument to -n is always true due to literal strings." + }, + { + "file": "gsd-core/workflows/ingest-docs.md", + "code": "2194", + "message": "This word is constant. Did you forget the $ on a variable?" + }, + { + "file": "gsd-core/workflows/ingest-docs.md", + "code": "2194", + "message": "This word is constant. Did you forget the $ on a variable?" + }, + { + "file": "gsd-core/workflows/milestone-summary.md", + "code": "2046", + "message": "Quote this to prevent word splitting." + }, + { + "file": "gsd-core/workflows/milestone-summary.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/milestone-summary.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/milestone-summary.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/new-milestone.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/new-project.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/new-project.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/new-workspace.md", + "code": "2164", + "message": "Use 'cd ... || exit' or 'cd ... || return' in case cd fails." + }, + { + "file": "gsd-core/workflows/new-workspace.md", + "code": "2164", + "message": "Use 'cd ... || exit' or 'cd ... || return' in case cd fails." + }, + { + "file": "gsd-core/workflows/next.md", + "code": "2181", + "message": "Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?." + }, + { + "file": "gsd-core/workflows/next.md", + "code": "2181", + "message": "Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?." + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "1102", + "message": "Shells disambiguate $(( differently or not at all. For $(command substitution), add space after $( . For $((arithmetics)), fix parsing errors." + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "1102", + "message": "Shells disambiguate $(( differently or not at all. For $(command substitution), add space after $( . For $((arithmetics)), fix parsing errors." + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "1102", + "message": "Shells disambiguate $(( differently or not at all. For $(command substitution), add space after $( . For $((arithmetics)), fix parsing errors." + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "1106", + "message": "In arithmetic contexts, use < instead of -lt" + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "1106", + "message": "In arithmetic contexts, use < instead of -lt" + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "1106", + "message": "In arithmetic contexts, use < instead of -lt" + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "2102", + "message": "Ranges can only match single chars (mentioned due to duplicates)." + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "2205", + "message": "(..) is a subshell. Did you mean [ .. ], a test expression?" + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "2205", + "message": "(..) is a subshell. Did you mean [ .. ], a test expression?" + }, + { + "file": "gsd-core/workflows/pause-work.md", + "code": "2205", + "message": "(..) is a subshell. Did you mean [ .. ], a test expression?" + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2010", + "message": "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-phase.md", + "code": "2235", + "message": "Use { ..; } instead of (..) to avoid subshell overhead." + }, + { + "file": "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", + "code": "1009", + "message": "The mentioned syntax error was in this if expression." + }, + { + "file": "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", + "code": "1048", + "message": "Can't have empty then clauses (use 'true' as a no-op)." + }, + { + "file": "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", + "code": "1072", + "message": "Unexpected keyword/token. Fix any mentioned problems and try again." + }, + { + "file": "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", + "code": "1073", + "message": "Couldn't parse this then clause. Fix to allow more checks." + }, + { + "file": "gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md", + "code": "2105", + "message": "continue is only valid in loops." + }, + { + "file": "gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-review-convergence.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-review-convergence.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/plan-review-convergence.md", + "code": "2327", + "message": "This command substitution will be empty because the command's output gets redirected away." + }, + { + "file": "gsd-core/workflows/plan-review-convergence.md", + "code": "2328", + "message": "This redirection takes output away from the command substitution (use tee to duplicate)." + }, + { + "file": "gsd-core/workflows/plant-seed.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/plant-seed.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/pr-branch.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/pr-branch.md", + "code": "2181", + "message": "Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?." + }, + { + "file": "gsd-core/workflows/progress.md", + "code": "2102", + "message": "Ranges can only match single chars (mentioned due to duplicates)." + }, + { + "file": "gsd-core/workflows/progress.md", + "code": "2102", + "message": "Ranges can only match single chars (mentioned due to duplicates)." + }, + { + "file": "gsd-core/workflows/quick.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/quick.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/quick.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/reapply-patches.md", + "code": "2088", + "message": "Tilde does not expand in quotes. Use $HOME." + }, + { + "file": "gsd-core/workflows/remove-workspace.md", + "code": "2164", + "message": "Use 'cd ... || exit' or 'cd ... || return' in case cd fails." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/review.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/secure-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings-advanced.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings.md", + "code": "2261", + "message": "Multiple redirections compete for stdin. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/settings.md", + "code": "2261", + "message": "Multiple redirections compete for stdout. Use cat, tee, or pass filenames instead." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/ship.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/sketch.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/spec-phase.md", + "code": "2010", + "message": "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/spec-phase.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/spike.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2010", + "message": "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2010", + "message": "Don't use ls | grep. Use a glob or a for loop with a condition to allow non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2115", + "message": "Use \"${var:?}\" to ensure this never expands to / ." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2115", + "message": "Use \"${var:?}\" to ensure this never expands to / ." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2181", + "message": "Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2181", + "message": "Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2181", + "message": "Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2199", + "message": "Arrays implicitly concatenate in [[ ]]. Use a loop (or explicit * instead of @)." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2199", + "message": "Arrays implicitly concatenate in [[ ]]. Use a loop (or explicit * instead of @)." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2199", + "message": "Arrays implicitly concatenate in [[ ]]. Use a loop (or explicit * instead of @)." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2199", + "message": "Arrays implicitly concatenate in [[ ]]. Use a loop (or explicit * instead of @)." + }, + { + "file": "gsd-core/workflows/sync-skills.md", + "code": "2207", + "message": "Prefer mapfile or read -a to split command output (or quote to avoid splitting)." + }, + { + "file": "gsd-core/workflows/ui-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/ui-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/ui-review.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/ui-review.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/undo.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/undo.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/update.md", + "code": "2046", + "message": "Quote this to prevent word splitting." + }, + { + "file": "gsd-core/workflows/update.md", + "code": "2046", + "message": "Quote this to prevent word splitting." + }, + { + "file": "gsd-core/workflows/update.md", + "code": "2088", + "message": "Tilde does not expand in quotes. Use $HOME." + }, + { + "file": "gsd-core/workflows/validate-phase.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/verify-work.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/verify-work.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/verify-work.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/verify-work.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + }, + { + "file": "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", + "code": "2012", + "message": "Use find instead of ls to better handle non-alphanumeric filenames." + }, + { + "file": "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "code": "2086", + "message": "Double quote to prevent globbing and word splitting." + } +] diff --git a/scripts/lint-workflow-shellcheck.cjs b/scripts/lint-workflow-shellcheck.cjs new file mode 100644 index 000000000..6f1654742 --- /dev/null +++ b/scripts/lint-workflow-shellcheck.cjs @@ -0,0 +1,652 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-workflow-shellcheck.cjs + * + * Systemic prevention for the zsh/bash word-splitting bug class (#4109): + * every ```bash fenced block embedded in gsd-core/workflows/*.md (and the + * nested gsd-core/workflows//steps/*.md / modes/*.md / etc. layer) + * is extracted and run through the real ShellCheck binary. Any finding fails + * the lint with a non-zero exit — this is what stops the SC2086-class bug + * (unquoted variable expansion, word-split/glob differently under zsh vs + * bash) from landing undetected a second time (it already landed 4 times in + * this repo's workflow templates before #4109's fix). + * + * ShellCheck source: the `shellcheck` npm package (gunar/shellcheck), a thin + * wrapper that downloads the official koalaman/shellcheck binary on first + * use and caches it under node_modules/shellcheck/bin/. Chosen over the + * alternatives surveyed (node-shellcheck: ~5 weekly downloads, last + * published 2022; shellcheck-binaries: ~280 weekly downloads, last + * published 2022) because it has ~80k weekly downloads and is the + * only actively-maintained wrapper — it downloads the CURRENT upstream + * ShellCheck release rather than vendoring a stale binary snapshot. + * + * Extraction: reuses scanFencedBlocks from markdown-sectionizer.cts (the + * canonical fence-scanning engine — see tests/review-plan-coverage-manifest + * .test.cjs's extractAllBashBlocks for the precedent this follows) rather + * than a bespoke regex. + * + * Placeholder handling: workflow blocks reference template placeholders — + * both single-token (`{run_dir}`, `{N}`) and multi-word prose (`{discovered + * test command}`, `{each unique directory from resolved paths}`) — that are + * not valid shell and would misparse as ShellCheck syntax errors unrelated to + * the word-splitting class this lint targets. Every such placeholder (NOT + * `${identifier}`, which is a real parameter expansion, and NOT real brace + * syntax like `{1..5}`/`{a,b,c}`/`{ cmd; }` — see `substitutePlaceholders`'s + * own comment for the exact discriminating rule) is substituted with a + * shell-safe bareword before staging, generalizing the test harness's + * single-placeholder `body.split('{run_dir}').join(runDir)` substitution to + * the general case. + * + * Rule selection (documented per the brief's requirement to justify the + * include/exclude choice): + * - SC2086 (double-quote to prevent globbing/word splitting) is the exact + * bug class #4109 fixes and MUST be enabled — it is ShellCheck's default + * behavior and is never excluded here. + * - The rest of ShellCheck's DEFAULT rule set is also left enabled: most of + * it (SC2046, SC2068, SC2145, SC2206, SC2207, etc.) is the SAME + * quoting/word-splitting/array-expansion family SC2086 belongs to, and is + * exactly the kind of finding this lint exists to catch. + * - Three codes are explicitly EXCLUDED because they produce structural + * false positives in this templated, cross-block, agent-populated + * context rather than real defects: + * SC1091 — "not following sourced file": blocks `source`/`.` files + * that exist only at run time in the calling agent's real RUN_DIR, not + * in this lint's throwaway single-block temp file. + * SC2154 — "var is referenced but not assigned": workflow blocks + * routinely reference variables the CALLING AGENT exports as env vars, + * or that a DIFFERENT fenced block earlier in the same workflow + * assigned — invisible to a scan of one isolated block. + * SC2034 — "var appears unused": the mirror image of SC2154 — a var + * assigned in this block is frequently consumed by a LATER block in + * the same workflow, again invisible to a single-block scan. + * SC2148 ("shell directive missing") is not in this exclude list because + * passing `--shell=bash` to ShellCheck (all these blocks are already + * fenced ```bash, i.e. self-declared) prevents it from firing at all. + * + * Exit 0 with no output on a clean tree (or a tree whose only findings are + * already accepted in the baseline, see below); exit 1 with every NEW + * finding (file, line, ShellCheck code, message) printed to stderr otherwise. + * + * Baseline (pre-existing findings, #4109 follow-up): + * Landing this lint against the real repo surfaced ~212 pre-existing + * ShellCheck findings across ~60 files that are unrelated to #4109's actual + * fix (a zsh word-splitting bug already fixed at its 6 sites). Requiring all + * 212 to be fixed in the same PR that adds the lint would block CI for + * reasons orthogonal to the issue. Instead, `scripts/lint-workflow- + * shellcheck-baseline.json` records the *accepted* pre-existing findings as + * of the baseline's generation, and this script only fails on findings NOT + * present in that baseline ("new" findings) — a standard ratchet: today's + * findings can never silently grow, but paying down the backlog is a + * separate, incremental effort. + * + * Baseline shape: a flat JSON array of `{file, code, message}` triples (see + * BASELINE_PATH below). `file` is the workflow-relative path (matches a + * finding's mapped `block.file`), `code` is the bare ShellCheck code number + * (e.g. `"2086"`, matches `f.code`), `message` is ShellCheck's finding text + * verbatim (matches `f.message`). + * + * Matching strategy — deliberately EXCLUDES line/column: matching on exact + * line number would make the baseline brittle to totally unrelated edits. + * E.g. inserting one line near the top of a large workflow file shifts every + * subsequent line number, which would make every already-accepted finding + * below that point look "new" on the next lint run — a spurious CI failure + * with no relationship to any real regression. `{file, code, message}` is + * stable under such reflow: the finding's identity (what rule fired, what it + * says, which file) doesn't move just because line numbers shift. + * + * This does mean two textually-identical findings in the same file (same + * code, same message) are indistinguishable by key alone. Findings are + * matched as a MULTISET, not a set: the baseline is loaded into a + * `key -> count` map, and each current finding consumes one count of its key + * if available (marking it "baselined") or is reported "new" once the + * baseline's count for that key is exhausted. This preserves ratchet + * semantics per-file-per-rule-per-message (a THIRD occurrence of a message + * that only had two accepted instances IS reported as new) without being + * sensitive to which physical line within the file each occurrence sits on. + */ + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const childProcess = require('node:child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +// Hard bound on the ShellCheck binary's run time, matching this repo's +// npm-subprocess timeout convention (5-30s git, 60s npm — same "external +// process that could hang" hazard class). See runShellcheck's comment for +// why this cannot be applied via the `shellcheck` npm package's own API. +const SHELLCHECK_TIMEOUT_MS = 60_000; + +const ROOT = path.join(__dirname, '..'); +const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); +const SECTIONIZER_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'markdown-sectionizer.cjs'); +const SHELLCHECK_BIN_MODULE = path.join(ROOT, 'node_modules', 'shellcheck', 'build', 'index.js'); +// The top-level SHELLCHECK_BIN_MODULE barrel (build/index.js) does NOT +// re-export `configs/index.js` (verified: `export *`-ing helpers/logger/ +// utils/shellcheck.js only — no configs), so `config` (which carries the +// resolved binary path used by runShellcheck's own spawnSync call, see +// below) has to be imported from its own submodule directly. +const SHELLCHECK_CONFIG_MODULE = path.join(ROOT, 'node_modules', 'shellcheck', 'build', 'configs', 'index.js'); +const BASELINE_PATH = path.join(__dirname, 'lint-workflow-shellcheck-baseline.json'); + +// Codes excluded for structural reasons documented in the module header above. +const EXCLUDED_CODES = ['SC1091', 'SC2154', 'SC2034']; + +/** Every bare `{identifier}` (not `${identifier}`) → a shell-safe bareword. */ +function substitutePlaceholders(body) { + // Only matches content that is ALREADY known-safe to be workflow-template + // prose: starts with a letter, then nothing but letters/digits/underscore/ + // hyphen/space. This deliberately excludes every real shell use of `{...}` + // that could otherwise collide with a placeholder-shaped token: + // - `${var}` parameter expansion — excluded by the `(? { + const safe = inner.trim().replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') || 'X'; + return `PLACEHOLDER_${safe}`; + }); +} + +/** Recursively collect every `.md` file under `dir`. */ +function collectMarkdownFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + // Node's recursive readdir sets entry.parentPath (>=20.12) / entry.path (older). + const parent = entry.parentPath ?? entry.path; + out.push(path.join(parent, entry.name)); + } + return out.sort(); +} + +/** + * Every ```bash fenced block across every workflow .md file, with enough + * metadata to map a ShellCheck finding back to its original source location. + */ +function extractBashBlocks(sectionizer) { + const files = collectMarkdownFiles(WORKFLOWS_DIR); + const blocks = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split(/\r?\n/); + const relFile = path.relative(ROOT, file); + const fenced = sectionizer.scanFencedBlocks(lines); + let blockIdx = 0; + for (const b of fenced) { + if (b.closeLineIdx === -1) continue; // unterminated fence — nothing well-defined to check + if ((b.infoString || '').trim() !== 'bash') continue; + const body = lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n'); + blocks.push({ + file: relFile, + blockIdx: blockIdx++, + // 1-based source line of the FIRST body line — a JSON finding's own + // `line` (1-based, relative to the staged single-block temp file) is + // added to this minus 1 to recover the real workflow-file line. + firstBodyLine: b.openLineIdx + 2, + body, + }); + } + } + return blocks; +} + +/** Stable identity key for a mapped finding — see the "Matching strategy" note above. */ +function findingKey(f) { + return `${f.file} ${f.code} ${f.message}`; +} + +/** + * Structural check (separate from the ShellCheck pass above): catches the + * exact #4109 bug shape — `for x in $VAR; do` / `for x in ${VAR}; do` with a + * BARE, unquoted scalar variable reference in the for-list position. + * + * ShellCheck does NOT flag this pattern under any ruleset, confirmed + * empirically by reintroducing the exact bug and running this script's own + * ShellCheck invocation (including `--enable=all`): a bare `$VAR` directly in + * a for-list is a deliberately-accepted, common bash idiom to ShellCheck, so + * SC2086 and friends never fire on it. That idiom is exactly what silently + * diverges between bash (word-splits it) and zsh (does not) — the root cause + * of #4109. Hence this dedicated structural pass, run in the SAME invocation + * as the ShellCheck pass, over the SAME extracted ```bash blocks. + * + * Algorithm per for-loop found in a block body: + * 1. Locate `for in ` and capture up to the + * first `;` or newline that is NOT nested inside a `$( ... )` span (a + * paren-depth scan, not a naive `[^;]*` regex slice) — a for-list that + * itself contains a `;` inside a command substitution must not have its + * capture truncated early. + * 2. Strip every `$( ... )` command-substitution span out of . + * Command substitution ALWAYS word-splits its result in both bash AND + * zsh — that is the actual #4109 fix pattern applied at every known + * site (`$(printf '%s' "$VAR")`), so a bare `$VAR` INSIDE a `$(...)` + * span is safe and must never be flagged. + * 3. Search what remains for a bare `$IDENT` / `${IDENT}` that is NOT + * immediately preceded by `"` — a `"$VAR"` reference is a different, + * also-safe idiom (single-token literal-list iteration), not the + * splitting bug. + * + * Findings from this pass are NEVER baselined (unlike the ShellCheck pass) — + * this check is new-by-construction and every workflow site known to be + * vulnerable was already swept as part of #4109's fix, so any finding here + * is a genuinely new/missed site worth surfacing distinctly rather than + * silently absorbing into scripts/lint-workflow-shellcheck-baseline.json. + */ + +/** Strip every balanced `$( ... )` span from `text`, preserving everything else. */ +function stripCommandSubstitutions(text) { + let out = ''; + let i = 0; + while (i < text.length) { + if (text[i] === '$' && text[i + 1] === '(') { + let depth = 1; + let j = i + 2; + while (j < text.length && depth > 0) { + if (text[j] === '(') depth++; + else if (text[j] === ')') depth--; + j++; + } + i = j; + continue; + } + out += text[i]; + i++; + } + return out; +} + +// A bare `$IDENT` / `${IDENT}` not immediately preceded by `"`. +const BARE_VAR_RE = /(^|[^"])\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/; + +/** + * Blank out `# ...` shell comments (to end of line), preserving every other + * character's position 1:1 (comment text is replaced with spaces, newlines + * are kept) so downstream character-offset -> line-number mapping stays + * valid without needing a second pass. A `#` only starts a comment when it + * is the first character of a "word" (start of line, or preceded by + * whitespace) — matching real shell comment semantics and, deliberately, + * NOT stripping `${VAR#pattern}` parameter-expansion `#`s (always preceded + * by a non-whitespace identifier character, e.g. `${sm_raw#./}`). Prose + * inside a `#` comment (e.g. a changelog note quoting `for x in $VAR` as an + * example of a PAST bug) must never be mistaken for live code — this is + * what stops that false positive. + */ +function stripShellComments(body) { + let out = ''; + let inSingle = false; + let inDouble = false; + let i = 0; + while (i < body.length) { + const ch = body[i]; + if (inSingle) { + out += ch; + if (ch === "'") inSingle = false; + i++; + continue; + } + if (inDouble) { + out += ch; + if (ch === '"') inDouble = false; + i++; + continue; + } + if (ch === "'") { + inSingle = true; + out += ch; + i++; + continue; + } + if (ch === '"') { + inDouble = true; + out += ch; + i++; + continue; + } + const prev = i === 0 ? '\n' : body[i - 1]; + if (ch === '#' && /\s/.test(prev)) { + while (i < body.length && body[i] !== '\n') { + out += ' '; + i++; + } + continue; // the '\n' itself (if any) is handled by the next loop iteration + } + out += ch; + i++; + } + return out; +} + +/** + * Every `for in ` for-loop header in `body`, with the raw + * list-expression text and the 0-based character offset of the `for` keyword + * (used by the caller to recover a line number). + */ +function extractForLoops(body) { + const results = []; + const headerRe = /\bfor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+/g; + let m; + while ((m = headerRe.exec(body)) !== null) { + const start = headerRe.lastIndex; + let i = start; + let depth = 0; + while (i < body.length) { + const ch = body[i]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + else if (depth === 0 && (ch === ';' || ch === '\n')) break; + i++; + } + results.push({ loopVar: m[1], listExpr: body.slice(start, i), matchIndex: m.index }); + headerRe.lastIndex = i; + } + return results; +} + +/** 1-based line number of `charIndex` within `body` (0-based first line = 1). */ +function lineOffsetOf(body, charIndex) { + let line = 1; + for (let i = 0; i < charIndex && i < body.length; i++) { + if (body[i] === '\n') line++; + } + return line; +} + +/** + * Scan every extracted block for the bare unquoted `for x in $VAR` shape. + * Returns mapped findings (`{file, line, loopVar, varName, listExpr, + * blockIdx}`), analogous in shape to the ShellCheck findings above but never + * baselined — see this section's header note. + */ +function findBareForLoopSplits(blocks) { + const findings = []; + for (const block of blocks) { + const codeOnly = stripShellComments(block.body); + for (const loop of extractForLoops(codeOnly)) { + const stripped = stripCommandSubstitutions(loop.listExpr); + const bare = BARE_VAR_RE.exec(stripped); + if (!bare) continue; + findings.push({ + file: block.file, + line: block.firstBodyLine + lineOffsetOf(block.body, loop.matchIndex) - 1, + blockIdx: block.blockIdx, + loopVar: loop.loopVar, + varName: bare[2], + listExpr: loop.listExpr.trim(), + }); + } + } + return findings; +} + +/** Load the baseline array (empty if the file does not exist yet). */ +function loadBaseline() { + if (!fs.existsSync(BASELINE_PATH)) return []; + const raw = fs.readFileSync(BASELINE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new ExitError( + 1, + `lint-workflow-shellcheck: ${path.relative(ROOT, BASELINE_PATH)} must be a JSON array of ` + + `{file, code, message} objects.`, + ); + } + return parsed; +} + +/** + * Partition `mappedFindings` (each `{file, code, message, ...}`) into + * `{newFindings, baselinedFindings}` against the baseline multiset. See the + * "Matching strategy" note in the module header for why this is a + * key -> count multiset match rather than exact-line matching. + */ +function partitionAgainstBaseline(mappedFindings, baseline) { + const remaining = new Map(); + for (const entry of baseline) { + const key = findingKey(entry); + remaining.set(key, (remaining.get(key) || 0) + 1); + } + const newFindings = []; + const baselinedFindings = []; + for (const f of mappedFindings) { + const key = findingKey(f); + const count = remaining.get(key) || 0; + if (count > 0) { + remaining.set(key, count - 1); + baselinedFindings.push(f); + } else { + newFindings.push(f); + } + } + return { newFindings, baselinedFindings }; +} + +function loadSectionizer() { + try { + return require(SECTIONIZER_PATH); + } catch (e) { + throw new ExitError( + 1, + `lint-workflow-shellcheck: cannot load the markdown-sectionizer seam at ` + + `${path.relative(ROOT, SECTIONIZER_PATH)} — run 'npm run build:lib' first (${e.message})`, + ); + } +} + +/** Load the `shellcheck` npm package's programmatic API — its own `shellcheck()` + * function transparently downloads the real binary to node_modules/shellcheck/ + * bin/shellcheck (caching it there) on first use if it is not already present. */ +async function loadShellcheckModule() { + try { + const mod = await import(SHELLCHECK_BIN_MODULE); + // See SHELLCHECK_CONFIG_MODULE's comment above — `config` is not part of + // the top-level barrel's exports, so it is imported separately and + // attached here for runShellcheck's direct spawnSync call to consume. + const { config } = await import(SHELLCHECK_CONFIG_MODULE); + return { ...mod, config }; + } catch (e) { + throw new ExitError( + 1, + `lint-workflow-shellcheck: cannot load the 'shellcheck' npm package at ` + + `${path.relative(ROOT, SHELLCHECK_BIN_MODULE)} — run 'npm install' first (${e.message})`, + ); + } +} + +/** + * Run ShellCheck (json1 output) over every staged temp file in one invocation, + * bounded by SHELLCHECK_TIMEOUT_MS. + * + * The `shellcheck` npm package's own `shellcheck()` function does NOT accept a + * `timeout` — its `ShellCheckArgs` type is `{bin, args, stdio, token}` only + * (verified against node_modules/shellcheck/build/shellcheck.d.ts and .js), + * and internally it hardcodes `child_process.spawnSync(opts.bin, opts.args, { + * stdio: opts.stdio })` with no pass-through for extra spawnSync options. + * Wrapping the call in `Promise.race` against a timer would not help either: + * spawnSync is synchronous and blocks the event loop for its whole duration, + * so a timer callback racing it can never fire before it returns (or hangs). + * Instead, this reimplements the same binary-resolve-and-download step the + * wrapper performs (via the package's own exported `config`/`download`), then + * invokes `child_process.spawnSync` directly with a native `timeout` so a + * hung ShellCheck binary is killed (Node sets `result.error.code === + * 'ETIMEDOUT'` and `result.signal` on expiry) rather than hanging this lint — + * and, transitively, CI — indefinitely. + */ +async function runShellcheck(mod, filePaths) { + const args = [ + '--shell=bash', + '--format=json1', + `--exclude=${EXCLUDED_CODES.join(',')}`, + ...filePaths, + ]; + const bin = mod.config.bin; + try { + fs.accessSync(bin, fs.constants.F_OK | fs.constants.X_OK); + } catch { + await mod.download({ destination: bin, token: process.env.GITHUB_TOKEN }); + } + const result = childProcess.spawnSync(bin, args, { stdio: 'pipe', timeout: SHELLCHECK_TIMEOUT_MS }); + if (result.error) { + const timedOut = result.error.code === 'ETIMEDOUT'; + throw new ExitError( + 1, + `lint-workflow-shellcheck: ShellCheck invocation ${ + timedOut ? `timed out after ${SHELLCHECK_TIMEOUT_MS}ms` : 'failed' + }: ${result.error.message}`, + ); + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString('utf8') : (result.stdout || ''); + if (stdout.trim() === '') { + // ShellCheck produced no output at all — genuine infra failure (crash, + // bad binary, etc.), not "zero findings" (which is `{"comments":[]}`). + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : (result.stderr || ''); + throw new ExitError( + 1, + `lint-workflow-shellcheck: ShellCheck produced no output (exit ${result.status}). stderr: ${stderr}`, + ); + } + try { + return JSON.parse(stdout).comments || []; + } catch (e) { + throw new ExitError( + 1, + `lint-workflow-shellcheck: could not parse ShellCheck json1 output: ${e.message}\n${stdout}`, + ); + } +} + +async function main() { + const sectionizer = loadSectionizer(); + const blocks = extractBashBlocks(sectionizer); + + if (blocks.length === 0) { + process.stdout.write('ok lint-workflow-shellcheck: no ```bash blocks found under gsd-core/workflows/\n'); + return 0; + } + + // Structural pass (see findBareForLoopSplits's header comment) — runs + // independently of ShellCheck. As of the #4109 sweep, every previously + // KNOWN site is fixed (0 structural findings on a clean tree), so this now + // GATES the exit code exactly like the ShellCheck-baseline-diff check + // below: a non-empty structuralFindings fails main() even if ShellCheck + // itself reports nothing new. Every finding is printed prominently below + // regardless of outcome; the two checks are combined into one final exit + // decision so a run with both kinds of findings reports both. + const structuralFindings = findBareForLoopSplits(blocks); + if (structuralFindings.length > 0) { + process.stdout.write( + `\nSTRUCTURAL FINDING (not ShellCheck, not baselined) lint-workflow-shellcheck: ` + + `${structuralFindings.length} bare unquoted \`for x in $VAR\` for-loop(s) — the #4109 bash/zsh ` + + `word-splitting bug shape ShellCheck itself does not detect:\n\n`, + ); + for (const f of structuralFindings) { + process.stdout.write( + ` ${f.file}:${f.line} (block #${f.blockIdx}) — ` + + `for ${f.loopVar} in ${f.listExpr} — bare $${f.varName} is unquoted and not inside $(...); ` + + `wrap in $(printf '%s' "$${f.varName}") to split identically under bash and zsh.\n`, + ); + } + process.stdout.write('\n'); + } + const structuralFailed = structuralFindings.length > 0; + + const shellcheckModule = await loadShellcheckModule(); + + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-workflow-shellcheck-')); + try { + const stagedPaths = []; + const byPath = new Map(); + blocks.forEach((block, i) => { + const scriptPath = path.join(stageDir, `block-${i}.sh`); + fs.writeFileSync(scriptPath, substitutePlaceholders(block.body)); + stagedPaths.push(scriptPath); + byPath.set(scriptPath, block); + }); + + const findings = await runShellcheck(shellcheckModule, stagedPaths); + + if (findings.length === 0) { + process.stdout.write( + `ok lint-workflow-shellcheck: ${blocks.length} \`\`\`bash block(s) across ` + + `${new Set(blocks.map((b) => b.file)).size} workflow file(s) checked, 0 ShellCheck findings\n`, + ); + return structuralFailed ? 1 : 0; + } + + const mappedFindings = findings.map((f) => { + const block = byPath.get(f.file); + return { + file: block ? block.file : f.file, + line: block ? block.firstBodyLine + f.line - 1 : f.line, + column: f.column, + code: String(f.code), + level: f.level, + message: f.message, + blockIdx: block ? block.blockIdx : undefined, + }; + }); + + const baseline = loadBaseline(); + const { newFindings, baselinedFindings } = partitionAgainstBaseline(mappedFindings, baseline); + + if (newFindings.length === 0) { + process.stdout.write( + `ok lint-workflow-shellcheck: ${baselinedFindings.length} pre-existing finding(s) from baseline, ` + + `0 new\n`, + ); + return structuralFailed ? 1 : 0; + } + + process.stderr.write( + `\nERROR lint-workflow-shellcheck: ${newFindings.length} NEW ShellCheck finding(s) in ` + + `gsd-core/workflows/ \`\`\`bash block(s) (#4109 word-splitting/quoting prevention) not present in ` + + `${path.relative(ROOT, BASELINE_PATH)}.\n\n`, + ); + for (const f of newFindings) { + const loc = f.blockIdx !== undefined + ? `${f.file} (block #${f.blockIdx}, line ${f.line}, col ${f.column})` + : `${f.file}:${f.line}:${f.column}`; + process.stderr.write(` ${loc} — SC${f.code} (${f.level}): ${f.message}\n`); + } + if (baselinedFindings.length > 0) { + process.stderr.write(`\n(${baselinedFindings.length} other pre-existing finding(s) from baseline, not shown.)\n`); + } + process.stderr.write('\n'); + return 1; + } finally { + fs.rmSync(stageDir, { recursive: true, force: true }); + } +} + +// Guarded so requiring this module (e.g. from tests/lint-workflow-shellcheck +// .test.cjs, to exercise the exported pure parser/logic functions) does not +// ALSO trigger a full ShellCheck run as an unwanted side effect of require() +// — matches the established convention in this repo's other dual-purpose +// script+module lint scripts, e.g. scripts/lint-docs-required.cjs's own +// `if (require.main === module) runMain(main);`. +if (require.main === module) runMain(main); + +module.exports = { + substitutePlaceholders, + collectMarkdownFiles, + extractBashBlocks, + EXCLUDED_CODES, + findingKey, + loadBaseline, + partitionAgainstBaseline, + BASELINE_PATH, + stripCommandSubstitutions, + stripShellComments, + extractForLoops, + findBareForLoopSplits, +}; diff --git a/tests/helpers/pr-branch-filter.cjs b/tests/helpers/pr-branch-filter.cjs index ddf707aef..84fbe45d3 100644 --- a/tests/helpers/pr-branch-filter.cjs +++ b/tests/helpers/pr-branch-filter.cjs @@ -85,7 +85,7 @@ const readWorkflow = () => parseWorkflow(fs.readFileSync(WORKFLOW_PATH, 'utf-8') const BASH_FENCE_OPEN_RE = /^```bash\s*$/; const BASH_FENCE_CLOSE_RE = /^```\s*$/; -const PICK_LOOP_MARKER = 'for HASH in $INCLUDED_COMMITS'; +const PICK_LOOP_MARKER = 'for HASH in $(printf \'%s\' "$INCLUDED_COMMITS")'; // Scans `text` for fenced ```bash blocks and returns the verbatim body // (fence markers stripped, lines rejoined with '\n') of the single block @@ -119,7 +119,7 @@ const extractPickLoop = (text) => { } if (matches.length === 0) { - throw new Error('pr-branch.md: no create_pr_branch cherry-pick loop found (expected a bash block containing "for HASH in $INCLUDED_COMMITS")'); + throw new Error(`pr-branch.md: no create_pr_branch cherry-pick loop found (expected a bash block containing "${PICK_LOOP_MARKER}")`); } if (matches.length > 1) { throw new Error(`pr-branch.md: cherry-pick loop found in ${matches.length} bash blocks — the recipe must have exactly one canonical form`); diff --git a/tests/lint-workflow-shellcheck.test.cjs b/tests/lint-workflow-shellcheck.test.cjs new file mode 100644 index 000000000..cfc6fe8a6 --- /dev/null +++ b/tests/lint-workflow-shellcheck.test.cjs @@ -0,0 +1,277 @@ +'use strict'; + +/** + * lint-workflow-shellcheck.test.cjs — unit + property coverage for the + * hand-rolled shell-text parser/logic functions exported by + * scripts/lint-workflow-shellcheck.cjs (#4109 follow-up). + * + * Per this repo's CLAUDE.md: "Parsers, budget limits, and bijective + * contracts must include at least one fast-check (`fc`) property test." + * These functions ARE parsers (shell-text extraction/transformation over + * workflow-authored markdown), so the property-test requirement below is a + * binding gate, not optional polish. + * + * Note: scripts/lint-workflow-shellcheck.cjs guards its CLI entry point + * with `if (require.main === module) runMain(main);`, so requiring it here + * for its exported pure functions does not also trigger a live ShellCheck + * run against the real gsd-core/workflows/ tree. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { + substitutePlaceholders, + stripCommandSubstitutions, + stripShellComments, + extractForLoops, + findBareForLoopSplits, + findingKey, + partitionAgainstBaseline, +} = require(path.join(__dirname, '..', 'scripts', 'lint-workflow-shellcheck.cjs')); + +/** Minimal block shape findBareForLoopSplits expects (see the source's own + * extractBashBlocks for the real shape this stands in for). */ +function mkBlock(body, overrides = {}) { + return { file: 'w.md', blockIdx: 0, firstBodyLine: 1, body, ...overrides }; +} + +describe('stripCommandSubstitutions', () => { + test('empty string', () => { + assert.equal(stripCommandSubstitutions(''), ''); + }); + + test('no substitution present leaves the string intact', () => { + assert.equal(stripCommandSubstitutions('echo hello'), 'echo hello'); + }); + + test('substitution at the start', () => { + assert.equal(stripCommandSubstitutions('$(echo hi) world'), ' world'); + }); + + test('substitution in the middle', () => { + assert.equal(stripCommandSubstitutions('a $(echo hi) b'), 'a b'); + }); + + test('substitution at the end', () => { + assert.equal(stripCommandSubstitutions('a $(echo hi)'), 'a '); + }); + + test('nested command substitution — $(echo $(nested))', () => { + assert.equal(stripCommandSubstitutions('pre $(echo $(nested)) post'), 'pre post'); + }); + + test('unterminated substitution does not crash or hang, consumes to end of string', () => { + assert.equal(stripCommandSubstitutions('a $(echo hi'), 'a '); + }); + + test('malformed nested-unterminated input does not crash or hang', () => { + assert.equal(stripCommandSubstitutions('a $(echo $(nested'), 'a '); + }); +}); + +describe('stripShellComments', () => { + test('strips a full-line comment to spaces, preserving the newline', () => { + assert.equal(stripShellComments('# a comment\necho hi'), ' \necho hi'); + }); + + test('strips a trailing comment after code, preserving preceding code', () => { + assert.equal(stripShellComments('echo hi # trailing'), 'echo hi '); + }); + + test('does NOT strip ${VAR#pattern} parameter-expansion # (the false-positive this pass exists to avoid)', () => { + assert.equal(stripShellComments('sm_raw=${sm_raw#./}'), 'sm_raw=${sm_raw#./}'); + }); + + test('does not strip a # immediately following a non-whitespace identifier character', () => { + assert.equal(stripShellComments('x=${foo#bar} y=1'), 'x=${foo#bar} y=1'); + }); + + test('a # inside single quotes is preserved literally, not treated as a comment start', () => { + assert.equal(stripShellComments("echo 'a # b'"), "echo 'a # b'"); + }); +}); + +describe('substitutePlaceholders', () => { + test('substitutes a single-token placeholder ({run_dir}) to a shell-safe bareword', () => { + assert.equal(substitutePlaceholders('cd {run_dir}'), 'cd PLACEHOLDER_run_dir'); + }); + + test('substitutes a multi-word prose placeholder ({discovered test command})', () => { + assert.equal( + substitutePlaceholders('run {discovered test command}'), + 'run PLACEHOLDER_discovered_test_command', + ); + }); + + test('does NOT substitute real ${VAR} parameter expansion', () => { + assert.equal(substitutePlaceholders('echo ${VAR}'), 'echo ${VAR}'); + }); + + test('does NOT clobber {1..5} POSIX numeric brace expansion (pinning the corrected behavior)', () => { + assert.equal(substitutePlaceholders('echo {1..5}'), 'echo {1..5}'); + }); + + test('does NOT clobber {a,b,c} POSIX brace-expansion list (pinning the corrected behavior)', () => { + assert.equal(substitutePlaceholders('echo {a,b,c}'), 'echo {a,b,c}'); + }); + + test('does NOT clobber { cmd1; cmd2; } compound-command grouping (pinning the corrected behavior)', () => { + const input = '{ echo hi; echo bye; }'; + assert.equal(substitutePlaceholders(input), input); + }); +}); + +describe('extractForLoops', () => { + test('captures loopVar and listExpr for a simple for-header', () => { + const [loop] = extractForLoops('for x in $VAR; do\n echo "$x"\ndone\n'); + assert.equal(loop.loopVar, 'x'); + assert.equal(loop.listExpr, '$VAR'); + }); + + test('does not truncate the list-expr at a `;` nested inside $( ... )', () => { + const [loop] = extractForLoops('for x in $(echo a; echo b); do\n echo "$x"\ndone\n'); + assert.equal(loop.listExpr, '$(echo a; echo b)'); + }); + + test('finds multiple for-loops in one body', () => { + const loops = extractForLoops('for a in 1 2; do :; done\nfor b in $Y; do :; done\n'); + assert.equal(loops.length, 2); + assert.equal(loops[0].loopVar, 'a'); + assert.equal(loops[1].loopVar, 'b'); + }); +}); + +describe('findBareForLoopSplits — the core #4109 detector', () => { + test('POSITIVE: bare unquoted `for x in $VAR; do ... done` is flagged', () => { + const findings = findBareForLoopSplits([mkBlock('for x in $VAR; do\n echo "$x"\ndone\n')]); + assert.equal(findings.length, 1); + assert.equal(findings[0].loopVar, 'x'); + assert.equal(findings[0].varName, 'VAR'); + }); + + test('POSITIVE: bare unquoted `${VAR}` braced form is also flagged', () => { + const findings = findBareForLoopSplits([mkBlock('for x in ${VAR}; do\n echo "$x"\ndone\n')]); + assert.equal(findings.length, 1); + assert.equal(findings[0].varName, 'VAR'); + }); + + test('NEGATIVE: command-substitution-wrapped form (the actual #4109 fix pattern) is NOT flagged', () => { + const findings = findBareForLoopSplits([ + mkBlock('for x in $(printf \'%s\' "$VAR"); do\n echo "$x"\ndone\n'), + ]); + assert.equal(findings.length, 0); + }); + + test('NEGATIVE: quoted `"$VAR"` form is NOT flagged', () => { + const findings = findBareForLoopSplits([mkBlock('for x in "$VAR"; do\n echo "$x"\ndone\n')]); + assert.equal(findings.length, 0); + }); + + test('NEGATIVE: literal words (no variable at all) are NOT flagged', () => { + const findings = findBareForLoopSplits([mkBlock('for x in a b c; do\n echo "$x"\ndone\n')]); + assert.equal(findings.length, 0); + }); + + test('a for-loop shape quoted inside a `#` comment (prose referencing a past bug) is NOT flagged', () => { + const findings = findBareForLoopSplits([ + mkBlock('# old bug: for x in $VAR; do ... done\necho ok\n'), + ]); + assert.equal(findings.length, 0); + }); + + // --- fast-check property test (CLAUDE.md-mandated for parsers) --- + // + // Property: for any generated loop-var/var-name pair and any of the SAFE + // forms (quoted, command-substitution-wrapped, literal-words), the + // detector reports zero findings; for either UNSAFE bare form (bare $VAR + // or braced ${VAR}), it reports exactly one finding naming that variable. + const identArb = fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_]{0,6}$/); + const formArb = fc.constantFrom('bare', 'braced', 'quoted', 'substituted', 'literal'); + + function buildLoopBody(loopVar, varName, form) { + let listExpr; + switch (form) { + case 'bare': + listExpr = `$${varName}`; + break; + case 'braced': + listExpr = `\${${varName}}`; + break; + case 'quoted': + listExpr = `"$${varName}"`; + break; + case 'substituted': + listExpr = `$(printf '%s' "$${varName}")`; + break; + case 'literal': + listExpr = 'a b c'; + break; + default: + throw new Error(`unreachable form: ${form}`); + } + return `for ${loopVar} in ${listExpr}; do\n echo "$${loopVar}"\ndone\n`; + } + + test('property: bare/braced forms are flagged naming the variable; quoted/substituted/literal forms never are', () => { + fc.assert( + fc.property(identArb, identArb, formArb, (loopVar, varName, form) => { + const body = buildLoopBody(loopVar, varName, form); + const findings = findBareForLoopSplits([mkBlock(body)]); + if (form === 'bare' || form === 'braced') { + assert.equal(findings.length, 1); + assert.equal(findings[0].varName, varName); + } else { + assert.equal(findings.length, 0); + } + }), + ); + }); +}); + +describe('findingKey / partitionAgainstBaseline', () => { + test('findingKey matches on {file, code, message} only — ignores line number', () => { + const a = { file: 'w.md', code: '2086', message: 'msg', line: 10 }; + const b = { file: 'w.md', code: '2086', message: 'msg', line: 999 }; + assert.equal(findingKey(a), findingKey(b)); + }); + + test('a finding shifted to a different line, same file/code/message, still matches the baseline (drift tolerance)', () => { + const baseline = [{ file: 'w.md', code: '2086', message: 'msg' }]; + const current = [{ file: 'w.md', code: '2086', message: 'msg', line: 999 }]; + const { newFindings, baselinedFindings } = partitionAgainstBaseline(current, baseline); + assert.equal(newFindings.length, 0); + assert.equal(baselinedFindings.length, 1); + }); + + test('a THIRD occurrence of a message with only two accepted baseline instances is reported new (multiset semantics)', () => { + const baseline = [ + { file: 'w.md', code: '2086', message: 'msg' }, + { file: 'w.md', code: '2086', message: 'msg' }, + ]; + const current = [ + { file: 'w.md', code: '2086', message: 'msg', line: 1 }, + { file: 'w.md', code: '2086', message: 'msg', line: 2 }, + { file: 'w.md', code: '2086', message: 'msg', line: 3 }, + ]; + const { newFindings, baselinedFindings } = partitionAgainstBaseline(current, baseline); + assert.equal(newFindings.length, 1); + assert.equal(baselinedFindings.length, 2); + }); + + test('a finding with a different code (same file/message) is NOT matched against the baseline', () => { + const baseline = [{ file: 'w.md', code: '2086', message: 'msg' }]; + const current = [{ file: 'w.md', code: '2046', message: 'msg', line: 1 }]; + const { newFindings } = partitionAgainstBaseline(current, baseline); + assert.equal(newFindings.length, 1); + }); + + test('an empty baseline reports every current finding as new', () => { + const current = [{ file: 'w.md', code: '2086', message: 'msg', line: 1 }]; + const { newFindings, baselinedFindings } = partitionAgainstBaseline(current, []); + assert.equal(newFindings.length, 1); + assert.equal(baselinedFindings.length, 0); + }); +}); diff --git a/tests/review-build-prompt-optional-sections.test.cjs b/tests/review-build-prompt-optional-sections.test.cjs index 01c7e15db..ef2ece3c3 100644 --- a/tests/review-build-prompt-optional-sections.test.cjs +++ b/tests/review-build-prompt-optional-sections.test.cjs @@ -57,7 +57,18 @@ const STDIN_BOUND_MS = 5000; function detectShells() { const shells = [{ name: 'bash', cmd: 'bash' }]; const probe = spawnSync('zsh', ['-c', 'exit 0'], { timeout: PROBE_TIMEOUT_MS, windowsHide: true }); - if (!probe.error && probe.status === 0) shells.push({ name: 'zsh', cmd: 'zsh' }); + if (!probe.error && probe.status === 0) { + shells.push({ name: 'zsh', cmd: 'zsh' }); + } else { + // gsd-core#4109: a skipped zsh lane reads identically to a passing one in + // this suite's own output, which is exactly why the bash/zsh + // word-splitting bug class went undetected in CI as long as it did. Make + // the skip loud so a zsh-less run (e.g. some ubuntu CI images) reads as + // "zsh coverage unknown", not "all lanes green". + console.warn( + '[review-build-prompt-optional-sections.test.cjs] zsh not available — zsh-lane tests SKIPPED, coverage for this shell is UNKNOWN, not verified', + ); + } return shells; } const SHELLS = detectShells(); diff --git a/tests/review-plan-coverage-manifest.test.cjs b/tests/review-plan-coverage-manifest.test.cjs index bd4931b96..72f606eff 100644 --- a/tests/review-plan-coverage-manifest.test.cjs +++ b/tests/review-plan-coverage-manifest.test.cjs @@ -44,7 +44,18 @@ const REPO_ROOT = path.join(__dirname, '..'); function detectShells() { const shells = [{ name: 'bash', cmd: 'bash' }]; const probe = spawnSync('zsh', ['-c', 'exit 0'], { timeout: PROBE_TIMEOUT_MS, windowsHide: true }); - if (!probe.error && probe.status === 0) shells.push({ name: 'zsh', cmd: 'zsh' }); + if (!probe.error && probe.status === 0) { + shells.push({ name: 'zsh', cmd: 'zsh' }); + } else { + // gsd-core#4109: a skipped zsh lane reads identically to a passing one in + // this suite's own output, which is exactly why the bash/zsh + // word-splitting bug class went undetected in CI as long as it did. Make + // the skip loud so a zsh-less run (e.g. some ubuntu CI images) reads as + // "zsh coverage unknown", not "all lanes green". + console.warn( + '[review-plan-coverage-manifest.test.cjs] zsh not available — zsh-lane tests SKIPPED, coverage for this shell is UNKNOWN, not verified', + ); + } return shells; } const SHELLS = detectShells(); @@ -186,6 +197,101 @@ function readCoverageJson(runDir, slug) { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null; } +/** + * #4109 — extract the write_reviews gate-check block that counts dispatched + * vs skipped lanes and sets ALL_LANES_SKIPPED / TOTAL_LANE_FAILURE. Anchored + * on the JSONL variable at the top of that block (distinct from, and earlier + * than, the .plans-manifest.md anchor extractCoverageCheckBlock() uses). + */ +function extractGateCheckBlock() { + const content = readWorkflowCombined(REVIEW_WORKFLOW); + const anchorIdx = content.indexOf('JSONL="$RUN_DIR/gsd-review-lane-results.jsonl"'); + assert.notEqual(anchorIdx, -1, 'write_reviews must reference gsd-review-lane-results.jsonl — the gate-check block is missing'); + const before = content.slice(0, anchorIdx); + const fenceOpenRe = /```bash\r?\n/g; + let lastOpen = -1; + let m; + while ((m = fenceOpenRe.exec(before)) !== null) lastOpen = m.index + m[0].length; + assert.notEqual(lastOpen, -1, 'gsd-review-lane-results.jsonl reference is not inside a ```bash fence of write_reviews'); + const after = content.slice(lastOpen); + const closeIdx = after.indexOf('\n```'); + assert.notEqual(closeIdx, -1, 'unterminated ```bash fence around the gate-check block'); + const body = after.slice(0, closeIdx); + assert.ok( + body.includes('ALL_LANES_SKIPPED'), + 'extracted block references JSONL but not ALL_LANES_SKIPPED — wrong block', + ); + return body; +} + +/** + * #4109 — extract the invoke_reviewers dispatch + join loops, from the + * "Split ONCE, de-duplicated" comment (immediately before the DISPATCH_SLUGS + * accumulator at this site) through the block's own closing fence. This span + * references run_review_lane() and PARALLEL_LANES, both defined earlier in + * the SAME fence but out of scope for this extractor — callers must prepend + * a stub (see buildDispatchFixture / DISPATCH_JOIN_STUB below). + */ +function extractDispatchJoinBlock() { + const content = readWorkflowCombined(REVIEW_WORKFLOW); + const anchorIdx = content.indexOf('# Split ONCE, de-duplicated'); + assert.notEqual(anchorIdx, -1, 'invoke_reviewers must contain the "Split ONCE, de-duplicated" comment anchor'); + const after = content.slice(anchorIdx); + const closeIdx = after.indexOf('\n```'); + assert.notEqual(closeIdx, -1, 'unterminated ```bash fence after the dispatch/join anchor'); + const body = after.slice(0, closeIdx); + assert.ok(body.includes('DISPATCH_SLUGS='), 'extracted span does not include the DISPATCH_SLUGS accumulator — wrong anchor'); + assert.ok(body.includes('wait'), 'extracted span does not include the join `wait` — anchor did not reach the join loop'); + return body; +} + +const DISPATCH_JOIN_STUB = 'run_review_lane() { echo "$1" >> "$RUN_DIR/dispatch-log.txt"; }\nPARALLEL_LANES="false"\n'; + +/** Fixture for the gate-check block: a RUN_DIR with per-slug stub files, no aggregate JSONL. */ +function buildGateCheckFixture(slugs, skippedSlugs) { + const root = createTempDir('gsd-4109-gate-'); + const runDir = path.join(root, 'run'); + fs.mkdirSync(runDir); + for (const slug of slugs) { + if (skippedSlugs.includes(slug)) { + fs.writeFileSync( + path.join(runDir, `gsd-review-${slug}.md`), + `${slug} review skipped: prompt budget (500 tokens) too small for the minimum review set.\n`, + ); + } + } + return { + root, + runDir, + env: { SELECTED_REVIEWERS: slugs.join(',') }, + }; +} + +/** + * Fixture for the dispatch/join loops: a RUN_DIR and SELECTED_REVIEWERS. + * RUN_DIR is passed as an env var (not the `{run_dir}` placeholder) because + * extractDispatchJoinBlock() starts at the "Split ONCE" comment, AFTER the + * `RUN_DIR="{run_dir}"` assignment earlier in the same real fence — the + * aggregate/join loop still references `$RUN_DIR` directly, so it must come + * from the environment here. + */ +function buildDispatchFixture(selectedReviewersCsv) { + const root = createTempDir('gsd-4109-dispatch-'); + const runDir = path.join(root, 'run'); + fs.mkdirSync(runDir); + return { + root, + runDir, + env: { SELECTED_REVIEWERS: selectedReviewersCsv, RUN_DIR: runDir }, + }; +} + +function readDispatchLog(runDir) { + const content = readIfPresent(path.join(runDir, 'dispatch-log.txt')); + if (content === null) return []; + return content.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); +} + describe('#3301 build_prompt derives and appends a plan coverage manifest', () => { for (const shell of SHELLS) { test(`[${shell.name}] zero plans: manifest reports Total plans: 0 and no ids`, (t) => { @@ -357,6 +463,11 @@ describe('#3301 write_reviews grades each lane against the plan coverage manifes const offenders = extractAllBashBlocks().filter((b) => /\[\s*"\$SLUG"\s*=\s*"(?!coderabbit)/.test(b)); assert.deepEqual(offenders, [], 'only the coderabbit exemption may hardcode a slug comparison'); }); + + test('structural: no bare-unquoted DISPATCH_SLUGS consumption remains (#4109)', () => { + const offenders = extractAllBashBlocks().filter((b) => /for SLUG in \$DISPATCH_SLUGS;/.test(b)); + assert.deepEqual(offenders, [], 'DISPATCH_SLUGS must be quoted or word-split explicitly, not consumed bare — zsh does not IFS-split an unquoted scalar'); + }); }); describe('#3301 REVIEWS.md documents the plan_coverage frontmatter key', () => { @@ -370,3 +481,109 @@ describe('#3301 REVIEWS.md documents the plan_coverage frontmatter key', () => { ); }); }); + +/** + * #4109 — a zsh word-splitting bug: DISPATCH_SLUGS is built as a + * space-separated scalar accumulator and consumed via unquoted + * `for SLUG in $DISPATCH_SLUGS; do`. Bash IFS-splits an unquoted scalar by + * default; zsh does not, so the entire accumulator (leading space and all) + * collapses onto ONE bogus iteration under zsh whenever 2+ reviewers are + * selected. These rows extract the REAL shipped bash from the two remaining + * untested sites (write_reviews' gate-check block, and invoke_reviewers' + * dispatch + join loops) and execute it under both shells. + */ +describe('#4109 gate-check counts every dispatched reviewer under both shells', () => { + for (const shell of SHELLS) { + test(`[${shell.name}] 2 reviewers both skipped: dispatched=2 skipped=2 all_lanes_skipped=true`, (t) => { + const fx = buildGateCheckFixture(['claude', 'codex'], ['claude', 'codex']); + t.after(() => cleanup(fx.root)); + const body = extractGateCheckBlock() + + '\necho "{\\"dispatched_count\\":${DISPATCHED_COUNT:-0},\\"skipped_count\\":${SKIPPED_COUNT:-0},' + + '\\"all_lanes_skipped\\":\\"${ALL_LANES_SKIPPED:-false}\\",\\"total_lane_failure\\":\\"${TOTAL_LANE_FAILURE:-false}\\"}"\n'; + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const out = JSON.parse(res.stdout.trim()); + assert.strictEqual(out.dispatched_count, 2, `expected 2 dispatched slugs, got ${res.stdout}`); + assert.strictEqual(out.skipped_count, 2, `expected 2 skipped slugs, got ${res.stdout}`); + assert.strictEqual(out.all_lanes_skipped, 'true'); + assert.strictEqual(out.total_lane_failure, 'false'); + }); + + test(`[${shell.name}] mixed skip and total failure: dispatched=2 skipped=1 total_lane_failure=true`, (t) => { + const fx = buildGateCheckFixture(['claude', 'codex'], ['claude']); + t.after(() => cleanup(fx.root)); + const body = extractGateCheckBlock() + + '\necho "{\\"dispatched_count\\":${DISPATCHED_COUNT:-0},\\"skipped_count\\":${SKIPPED_COUNT:-0},' + + '\\"all_lanes_skipped\\":\\"${ALL_LANES_SKIPPED:-false}\\",\\"total_lane_failure\\":\\"${TOTAL_LANE_FAILURE:-false}\\"}"\n'; + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const out = JSON.parse(res.stdout.trim()); + assert.strictEqual(out.dispatched_count, 2, `expected 2 dispatched slugs, got ${res.stdout}`); + assert.strictEqual(out.skipped_count, 1, `expected 1 skipped slug, got ${res.stdout}`); + assert.strictEqual(out.all_lanes_skipped, 'false'); + assert.strictEqual(out.total_lane_failure, 'true'); + }); + + test(`[${shell.name}] single reviewer still counts correctly (boundary)`, (t) => { + const fx = buildGateCheckFixture(['claude'], ['claude']); + t.after(() => cleanup(fx.root)); + const body = extractGateCheckBlock() + + '\necho "{\\"dispatched_count\\":${DISPATCHED_COUNT:-0},\\"skipped_count\\":${SKIPPED_COUNT:-0},' + + '\\"all_lanes_skipped\\":\\"${ALL_LANES_SKIPPED:-false}\\",\\"total_lane_failure\\":\\"${TOTAL_LANE_FAILURE:-false}\\"}"\n'; + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const out = JSON.parse(res.stdout.trim()); + assert.strictEqual(out.dispatched_count, 1, `expected 1 dispatched slug, got ${res.stdout}`); + assert.strictEqual(out.skipped_count, 1, `expected 1 skipped slug, got ${res.stdout}`); + assert.strictEqual(out.all_lanes_skipped, 'true'); + }); + + test(`[${shell.name}] 3 reviewers all skipped (boundary)`, (t) => { + const fx = buildGateCheckFixture(['claude', 'codex', 'gemini'], ['claude', 'codex', 'gemini']); + t.after(() => cleanup(fx.root)); + const body = extractGateCheckBlock() + + '\necho "{\\"dispatched_count\\":${DISPATCHED_COUNT:-0},\\"skipped_count\\":${SKIPPED_COUNT:-0},' + + '\\"all_lanes_skipped\\":\\"${ALL_LANES_SKIPPED:-false}\\",\\"total_lane_failure\\":\\"${TOTAL_LANE_FAILURE:-false}\\"}"\n'; + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const out = JSON.parse(res.stdout.trim()); + assert.strictEqual(out.dispatched_count, 3, `expected 3 dispatched slugs, got ${res.stdout}`); + assert.strictEqual(out.skipped_count, 3, `expected 3 skipped slugs, got ${res.stdout}`); + assert.strictEqual(out.all_lanes_skipped, 'true'); + }); + } +}); + +describe('#4109 invoke_reviewers dispatches every deduped reviewer exactly once under both shells', () => { + for (const shell of SHELLS) { + test(`[${shell.name}] 2 reviewers each dispatched exactly once`, (t) => { + const fx = buildDispatchFixture('claude,codex'); + t.after(() => cleanup(fx.root)); + const body = DISPATCH_JOIN_STUB + '\n' + extractDispatchJoinBlock(); + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const lines = readDispatchLog(fx.runDir); + assert.deepEqual(lines, ['claude', 'codex']); + }); + + test(`[${shell.name}] duplicate slug in SELECTED_REVIEWERS deduped, dispatched once`, (t) => { + const fx = buildDispatchFixture('claude,codex,claude'); + t.after(() => cleanup(fx.root)); + const body = DISPATCH_JOIN_STUB + '\n' + extractDispatchJoinBlock(); + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const lines = readDispatchLog(fx.runDir); + assert.deepEqual(lines, ['claude', 'codex']); + }); + + test(`[${shell.name}] 3 reviewers each dispatched exactly once (boundary)`, (t) => { + const fx = buildDispatchFixture('claude,codex,gemini'); + t.after(() => cleanup(fx.root)); + const body = DISPATCH_JOIN_STUB + '\n' + extractDispatchJoinBlock(); + const res = runScript(shell, body, fx.root, fx.runDir, fx.env, REPO_ROOT); + assert.strictEqual(res.status, 0, `block exited ${res.status}: ${res.stderr}`); + const lines = readDispatchLog(fx.runDir); + assert.deepEqual(lines, ['claude', 'codex', 'gemini']); + }); + } +});