From 409b79656bb59077adfedf8abb57743d78518f28 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 13 Aug 2026 20:09:01 +0000 Subject: [PATCH] fix(bash-ast): stop deparse from changing shell semantics Here-doc bodies are read as opaque text, `&` stays on the command it backgrounds, brace groups and subshells stay grouped, and NAME=value is an assignment only in assignment position. Comments can be kept, and parse takes a hard timeout so a pathological input fails instead of hanging. Closes constructive-io/constructive-planning#1646 --- packages/bash-ast/README.md | 33 ++ .../workflows/benchmark-generate-0066.sh | 28 ++ .../workflows/benchmark-generate-0132.sh | 9 + .../workflows/benchmark-generate-0155.sh | 2 + .../workflows/benchmark-generate-0175.sh | 2 + .../workflows/benchmark-generate-0180.sh | 5 + .../workflows/benchmark-generate-0189.sh | 14 + .../workflows/function-image-smoke-0056.sh | 2 + .../workflows/function-image-smoke-0077.sh | 3 + .../workflows/functions-test-0073.sh | 7 + .../workflows/functions-test-0130.sh | 5 + .../workflows/functions-test-0146.sh | 4 + .../workflows/functions-test-0179.sh | 6 + .../workflows/generate-all-0088.sh | 2 + .../workflows/generate-all-0134.sh | 2 + .../workflows/generate-all-0175.sh | 79 ++++ .../workflows/generate-all-0298.sh | 10 + .../workflows/image-freshness-0057.sh | 2 + .../workflows/image-freshness-0068.sh | 45 +++ .../workflows/integration-test-0077.sh | 2 + .../workflows/integration-test-0114.sh | 2 + .../workflows/knative-test-0086.sh | 37 ++ .../workflows/knative-test-0156.sh | 2 + .../workflows/knative-test-0195.sh | 6 + .../workflows/knative-test-0206.sh | 2 + .../workflows/knative-test-0211.sh | 2 + .../workflows/knative-test-0218.sh | 2 + .../workflows/knative-test-0227.sh | 2 + .../workflows/knative-test-0253.sh | 18 + .../workflows/knative-test-0276.sh | 2 + .../workflows/knative-test-0286.sh | 2 + .../workflows/knative-test-0294.sh | 2 + .../workflows/knative-test-0301.sh | 2 + .../workflows/knative-test-0308.sh | 2 + .../workflows/knative-test-0315.sh | 2 + .../workflows/knative-test-0365.sh | 2 + .../workflows/knative-test-0411.sh | 4 + .../workflows/knative-test-0427.sh | 2 + .../workflows/knative-test-0438.sh | 4 + .../workflows/knative-test-0454.sh | 3 + .../workflows/knative-test-0460.sh | 8 + .../workflows/knative-test-0487.sh | 3 + .../workflows/knative-test-0494.sh | 2 + .../workflows/knative-test-0499.sh | 2 + .../workflows/knative-test-0504.sh | 6 + .../workflows/knative-test-0513.sh | 4 + .../workflows/knative-test-0520.sh | 5 + .../workflows/knative-test-0528.sh | 4 + .../workflows/knative-test-0536.sh | 4 + .../workflows/knative-test-0544.sh | 5 + .../workflows/knative-test-0556.sh | 10 + .../workflows/knative-test-0569.sh | 3 + .../workflows/knative-test-0579.sh | 17 + .../workflows/knative-test-0626.sh | 4 + .../workflows/knative-test-0645.sh | 24 ++ .../workflows/knative-test-0674.sh | 30 ++ .../workflows/knative-test-0765.sh | 17 + .../workflows/knative-test-0785.sh | 4 + .../workflows/knative-test-0794.sh | 5 + .../workflows/knative-test-0803.sh | 4 + .../workflows/knative-test-0825.sh | 8 + .../__fixtures__/workflows/lint-0114.sh | 2 + .../workflows/platform-ui-tests-0060.sh | 2 + .../workflows/platform-ui-tests-0085.sh | 8 + .../workflows/platform-ui-tests-0166.sh | 2 + .../workflows/platform-ui-tests-0177.sh | 12 + .../workflows/platform-ui-tests-0231.sh | 8 + .../workflows/platform-ui-tests-0277.sh | 18 + .../workflows/proxy-e2e-tests-0071.sh | 2 + .../workflows/proxy-e2e-tests-0108.sh | 2 + .../workflows/proxy-e2e-tests-0118.sh | 1 + .../workflows/proxy-tests-0066.sh | 3 + .../workflows/proxy-tests-0072.sh | 5 + .../workflows/proxy-tests-0085.sh | 3 + .../workflows/publish-images-0097.sh | 11 + .../workflows/publish-images-0149.sh | 20 + .../workflows/publish-images-0173.sh | 2 + .../workflows/publish-images-0211.sh | 6 + .../workflows/regenerate-branch-0111.sh | 25 ++ .../workflows/regenerate-branch-0139.sh | 3 + .../workflows/regenerate-branch-0165.sh | 2 + .../workflows/regenerate-branch-0189.sh | 2 + .../workflows/regenerate-branch-0213.sh | 2 + .../workflows/regenerate-branch-0223.sh | 33 ++ .../workflows/regenerate-branch-0278.sh | 16 + .../__fixtures__/workflows/run-tests-0165.sh | 2 + .../__fixtures__/workflows/run-tests-0190.sh | 15 + .../__fixtures__/workflows/run-tests-0231.sh | 16 + .../__fixtures__/workflows/run-tests-0338.sh | 2 + .../__fixtures__/workflows/run-tests-0373.sh | 3 + .../__fixtures__/workflows/run-tests-0386.sh | 3 + .../__fixtures__/workflows/run-tests-0398.sh | 6 + .../__fixtures__/workflows/run-tests-0415.sh | 23 ++ .../__fixtures__/workflows/run-tests-0517.sh | 2 + .../__fixtures__/workflows/run-tests-0535.sh | 8 + .../__fixtures__/workflows/run-tests-0555.sh | 10 + .../__fixtures__/workflows/safegres-0087.sh | 2 + .../__fixtures__/workflows/safegres-0099.sh | 2 + .../__fixtures__/workflows/safegres-0107.sh | 2 + .../__fixtures__/workflows/safegres-0112.sh | 14 + .../workflows/schema-sdk-update-0058.sh | 2 + .../workflows/schema-sdk-update-0095.sh | 20 + .../workflows/schema-sdk-update-0122.sh | 41 ++ .../workflows/schema-sdk-update-0167.sh | 3 + .../workflows/validate-introspection-0056.sh | 3 + .../workflows/validate-introspection-0080.sh | 2 + .../workflows/validate-introspection-0108.sh | 2 + .../workflows/validate-introspection-0113.sh | 2 + .../workflows/validate-introspection-0123.sh | 2 + .../workflows/validate-introspection-0132.sh | 12 + .../workflows/validate-introspection-0173.sh | 3 + .../workflows/validate-introspection-0197.sh | 2 + .../workflows/validate-introspection-0225.sh | 2 + .../workflows/validate-introspection-0230.sh | 2 + .../workflows/validate-introspection-0235.sh | 9 + packages/bash-ast/__tests__/corpus.test.ts | 58 +++ packages/bash-ast/__tests__/semantics.test.ts | 183 +++++++++ packages/bash-ast/src/deparser.ts | 269 ++++++++----- packages/bash-ast/src/index.ts | 2 +- packages/bash-ast/src/lexer.ts | 358 ++++++++++++++++-- packages/bash-ast/src/parser.ts | 216 ++++++++--- packages/bash-ast/src/types.ts | 35 +- 122 files changed, 1887 insertions(+), 172 deletions(-) create mode 100644 packages/bash-ast/__fixtures__/workflows/benchmark-generate-0066.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/benchmark-generate-0132.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/benchmark-generate-0155.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/benchmark-generate-0175.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/benchmark-generate-0180.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/benchmark-generate-0189.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/function-image-smoke-0056.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/function-image-smoke-0077.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/functions-test-0073.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/functions-test-0130.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/functions-test-0146.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/functions-test-0179.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/generate-all-0088.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/generate-all-0134.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/generate-all-0175.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/generate-all-0298.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/image-freshness-0057.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/image-freshness-0068.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/integration-test-0077.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/integration-test-0114.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0086.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0156.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0195.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0206.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0211.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0218.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0227.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0253.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0276.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0286.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0294.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0301.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0308.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0315.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0365.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0411.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0427.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0438.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0454.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0460.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0487.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0494.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0499.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0504.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0513.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0520.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0528.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0536.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0544.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0556.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0569.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0579.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0626.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0645.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0674.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0765.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0785.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0794.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0803.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/knative-test-0825.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/lint-0114.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0060.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0085.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0166.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0177.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0231.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0277.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0071.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0108.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0118.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/proxy-tests-0066.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/proxy-tests-0072.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/proxy-tests-0085.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/publish-images-0097.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/publish-images-0149.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/publish-images-0173.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/publish-images-0211.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0111.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0139.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0165.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0189.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0213.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0223.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/regenerate-branch-0278.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0165.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0190.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0231.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0338.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0373.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0386.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0398.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0415.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0517.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0535.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/run-tests-0555.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/safegres-0087.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/safegres-0099.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/safegres-0107.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/safegres-0112.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0058.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0095.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0122.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0167.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0056.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0080.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0108.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0113.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0123.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0132.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0173.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0197.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0225.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0230.sh create mode 100644 packages/bash-ast/__fixtures__/workflows/validate-introspection-0235.sh create mode 100644 packages/bash-ast/__tests__/corpus.test.ts create mode 100644 packages/bash-ast/__tests__/semantics.test.ts diff --git a/packages/bash-ast/README.md b/packages/bash-ast/README.md index e0e7e01c..aab47e02 100644 --- a/packages/bash-ast/README.md +++ b/packages/bash-ast/README.md @@ -24,6 +24,17 @@ const ast = parse(source); console.log(JSON.stringify(ast, null, 2)); ``` +#### Options + +```typescript +parse(source, { + // keep comments as `Comment` nodes instead of dropping them (default: false) + keepComments: true, + // hard wall-clock budget; exceeding it throws instead of running unbounded + timeoutMs: 5000 +}); +``` + ### Deparsing ```typescript @@ -70,9 +81,13 @@ console.log(JSON.stringify(clean1) === JSON.stringify(clean2)); // true - Input (`<`) - Output (`>`, `>>`) - Here strings (`<<<`) +- Here-documents (`<&1`) ### Other +- Asynchronous commands (`cmd &`) +- Comments (with `keepComments`) +- Process substitution (`<(cmd)`, `>(cmd)`) - Variable assignments (`VAR=value`) - Function definitions - Quoted strings (single and double) @@ -80,6 +95,24 @@ console.log(JSON.stringify(clean1) === JSON.stringify(clean2)); // true - Command substitution (`$(cmd)`, `` `cmd` ``) - Arithmetic expansion (`$((expr))`) +## Round-tripping + +`deparse(parse(x))` is meant to be *semantically* equivalent to `x`, not +character-identical: layout and optional whitespace are normalized. The +properties the test suite enforces over a corpus +of real GitHub Actions `run:` blocks (`__fixtures__/workflows`) are: + +- `parse(deparse(parse(x)))` equals `parse(x)` once positions are stripped + (`cleanTree`); +- `deparse` is idempotent from the second pass on; +- the emitted text is accepted by `bash -n`. + +Grouping is what makes this safe: a brace group or subshell stays a single +command through pipelines and `&&`/`||`, `&` stays on the command it +backgrounds, and `NAME=value` is an assignment only in assignment position (a +command prefix, or an operand of `export`/`local`/`declare`/`readonly`/`typeset`) +— elsewhere it is an ordinary argument, as in `psql --set ON_ERROR_STOP=1`. + ## License MIT diff --git a/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0066.sh b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0066.sh new file mode 100644 index 00000000..532f62fe --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0066.sh @@ -0,0 +1,28 @@ +# variant -> "jit tuned work_mem". `tuned` mirrors what +# validate-introspection does (pgpm tune --yes), so `baseline` is the +# configuration the real CI job runs under. +# On a bench/** push there are no inputs, so fall back to the pair that +# answers the open question — does the runner's slow mode spill? +VARIANTS='${{ inputs.variants }}' +[ -z "$VARIANTS" ] && VARIANTS='baseline,work_mem' +REPEAT='${{ inputs.repeat }}' +[ -z "$REPEAT" ] && REPEAT=1 + +declare -A V=( + [baseline]="on yes default" + [work_mem]="on yes 256MB" + [jit_off]="off yes default" + [untuned]="on no default" +) +ITEMS=() +IFS=',' read -ra WANT <<< "$VARIANTS" +for v in "${WANT[@]}"; do + v=$(echo "$v" | tr -d '[:space:]') + [ -z "${V[$v]:-}" ] && { echo "::error::unknown variant $v"; exit 1; } + read -r jit tuned wm <<< "${V[$v]}" + for i in $(seq 1 "$REPEAT"); do + ITEMS+=("{\"name\":\"$v-$i\",\"jit\":\"$jit\",\"tuned\":\"$tuned\",\"work_mem\":\"$wm\"}") + done +done +printf 'matrix={"include":[%s]}\n' "$(IFS=,; echo "${ITEMS[*]}")" >> $GITHUB_OUTPUT +printf 'planned: %s\n' "${ITEMS[*]}" diff --git a/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0132.sh b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0132.sh new file mode 100644 index 00000000..fe35efbc --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0132.sh @@ -0,0 +1,9 @@ +# The runner itself is a variable in this experiment, so record it +# next to the numbers. +{ + echo "runner_name=$RUNNER_NAME" + echo "nproc=$(nproc)" + echo "mem_total_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo)" + grep -m1 'model name' /proc/cpuinfo + echo "postgres_image_digest=$(docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/constructive-io/docker/postgres-plus:18 2>/dev/null || echo unknown)" +} | tee runner-info.txt diff --git a/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0155.sh b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0155.sh new file mode 100644 index 00000000..5b02f1a6 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0155.sh @@ -0,0 +1,2 @@ +cd compute +npx tsx ../compute/fun/cli/src/generate-standalone.ts --packages-only diff --git a/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0175.sh b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0175.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0175.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0180.sh b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0180.sh new file mode 100644 index 00000000..1b666955 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0180.sh @@ -0,0 +1,5 @@ +ci/bench-generate.sh \ + "${{ matrix.name }}" \ + "${{ matrix.jit }}" \ + "${{ matrix.tuned }}" \ + "${{ matrix.work_mem }}" diff --git a/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0189.sh b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0189.sh new file mode 100644 index 00000000..1b6c568b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/benchmark-generate-0189.sh @@ -0,0 +1,14 @@ +{ + echo "## \`${{ matrix.name }}\` — jit=${{ matrix.jit }} tuned=${{ matrix.tuned }} work_mem=${{ matrix.work_mem }}" + echo + echo '```' + cat bench-results/${{ matrix.name }}/wall.txt 2>/dev/null + cat runner-info.txt + echo '```' + echo + echo '### Top statements by total_exec_time' + echo + echo '```' + head -13 bench-results/${{ matrix.name }}/pgss_top.csv 2>/dev/null | cut -c1-200 + echo '```' +} >> $GITHUB_STEP_SUMMARY diff --git a/packages/bash-ast/__fixtures__/workflows/function-image-smoke-0056.sh b/packages/bash-ast/__fixtures__/workflows/function-image-smoke-0056.sh new file mode 100644 index 00000000..f508a605 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/function-image-smoke-0056.sh @@ -0,0 +1,2 @@ +cd compute +echo "matrix=$(tsx ../compute/fun/cli/src/image-type-matrix.ts)" >> "$GITHUB_OUTPUT" diff --git a/packages/bash-ast/__fixtures__/workflows/function-image-smoke-0077.sh b/packages/bash-ast/__fixtures__/workflows/function-image-smoke-0077.sh new file mode 100644 index 00000000..76c264e7 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/function-image-smoke-0077.sh @@ -0,0 +1,3 @@ +cd compute +tsx ../compute/fun/cli/src/generate-standalone.ts --only=${{ matrix.handler }} +tsx ../compute/fun/cli/src/generate-dockerfiles.ts --only=${{ matrix.handler }} diff --git a/packages/bash-ast/__fixtures__/workflows/functions-test-0073.sh b/packages/bash-ast/__fixtures__/workflows/functions-test-0073.sh new file mode 100644 index 00000000..ae119547 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/functions-test-0073.sh @@ -0,0 +1,7 @@ +set -euo pipefail +plan=$([ "$ALL" = 'true' ] \ + && pnpm exec tsx ci/functions/plan.ts --all \ + || pnpm exec tsx ci/functions/plan.ts) +features=$(echo "$plan" | jq -c '.features') +echo "features=$features" >> "$GITHUB_OUTPUT" +echo "any=$([ "$features" = '[]' ] && echo false || echo true)" >> "$GITHUB_OUTPUT" diff --git a/packages/bash-ast/__fixtures__/workflows/functions-test-0130.sh b/packages/bash-ast/__fixtures__/workflows/functions-test-0130.sh new file mode 100644 index 00000000..c19faafc --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/functions-test-0130.sh @@ -0,0 +1,5 @@ +pnpm --filter '@constructive-functions/test-utils...' +--filter '@constructive-functions/seeds' +--filter '@constructive-db/compute-runtime...' +--filter '@constructive-db/agentic-metering...' +run build diff --git a/packages/bash-ast/__fixtures__/workflows/functions-test-0146.sh b/packages/bash-ast/__fixtures__/workflows/functions-test-0146.sh new file mode 100644 index 00000000..e1cf2362 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/functions-test-0146.sh @@ -0,0 +1,4 @@ +pgpm admin-users bootstrap --yes +# The stack grants CONNECT to app_user/app_admin, so a database it +# deploys into needs them to exist as well as the platform roles. +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/functions-test-0179.sh b/packages/bash-ast/__fixtures__/workflows/functions-test-0179.sh new file mode 100644 index 00000000..69158e2e --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/functions-test-0179.sh @@ -0,0 +1,6 @@ +set -euo pipefail +echo "plan=$PLAN features=$FEATURES (any=$ANY)" +[ "$PLAN" = 'success' ] +if [ "$ANY" = 'true' ]; then + [ "$FEATURES" = 'success' ] +fi diff --git a/packages/bash-ast/__fixtures__/workflows/generate-all-0088.sh b/packages/bash-ast/__fixtures__/workflows/generate-all-0088.sh new file mode 100644 index 00000000..4e411cf2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/generate-all-0088.sh @@ -0,0 +1,2 @@ +git config --global user.name "github-actions[bot]" +git config --global user.email "github-actions[bot]@users.noreply.github.com" diff --git a/packages/bash-ast/__fixtures__/workflows/generate-all-0134.sh b/packages/bash-ast/__fixtures__/workflows/generate-all-0134.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/generate-all-0134.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/generate-all-0175.sh b/packages/bash-ast/__fixtures__/workflows/generate-all-0175.sh new file mode 100644 index 00000000..92cb1165 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/generate-all-0175.sh @@ -0,0 +1,79 @@ +# Check introspection output changes (application/constructive/) +APP_UNTRACKED=$(git ls-files --others --exclude-standard application/ | head -20) +if git diff --quiet application/ && [ -z "$APP_UNTRACKED" ]; then + echo "has_introspection_changes=false" >> $GITHUB_OUTPUT + echo "No introspection changes detected" +else + echo "has_introspection_changes=true" >> $GITHUB_OUTPUT + echo "Introspection changes detected:" + git diff --stat application/ + if [ -n "$APP_UNTRACKED" ]; then + echo "New untracked files in application/:" + echo "$APP_UNTRACKED" + fi +fi + +# Check schema changes +if git diff --quiet sdk/constructive-schema/schemas/; then + echo "has_schema_changes=false" >> $GITHUB_OUTPUT + echo "No schema changes detected" +else + echo "has_schema_changes=true" >> $GITHUB_OUTPUT + echo "Schema changes detected:" + git diff --stat sdk/constructive-schema/schemas/ +fi + +# Check all SDK changes (tracked + untracked) +SDK_UNTRACKED=$(git ls-files --others --exclude-standard sdk/ | head -20) +if git diff --quiet sdk/ && [ -z "$SDK_UNTRACKED" ]; then + echo "has_sdk_changes=false" >> $GITHUB_OUTPUT + echo "No SDK changes detected" +else + echo "has_sdk_changes=true" >> $GITHUB_OUTPUT + echo "SDK changes detected:" + git diff --stat sdk/ + if [ -n "$SDK_UNTRACKED" ]; then + echo "New untracked files in sdk/:" + echo "$SDK_UNTRACKED" + fi +fi + +# Check .agents/skills changes (codegen generates skill reference docs) +AGENTS_UNTRACKED=$(git ls-files --others --exclude-standard .agents/ | head -20) +if git diff --quiet .agents/ && [ -z "$AGENTS_UNTRACKED" ]; then + echo "has_agents_changes=false" >> $GITHUB_OUTPUT + echo "No agents/skills changes detected" +else + echo "has_agents_changes=true" >> $GITHUB_OUTPUT + echo "Agents/skills changes detected:" + git diff --stat .agents/ + if [ -n "$AGENTS_UNTRACKED" ]; then + echo "New untracked files in .agents/:" + echo "$AGENTS_UNTRACKED" + fi +fi + +# Overall: any changes in sdk/ OR application/ OR .agents/? +SDK_CHANGED="false" +APP_CHANGED="false" +AGENTS_CHANGED="false" +if ! git diff --quiet sdk/ || [ -n "$SDK_UNTRACKED" ]; then + SDK_CHANGED="true" +fi +if ! git diff --quiet application/ || [ -n "$APP_UNTRACKED" ]; then + APP_CHANGED="true" +fi +if ! git diff --quiet .agents/ || [ -n "$AGENTS_UNTRACKED" ]; then + AGENTS_CHANGED="true" +fi +if [ "$SDK_CHANGED" = "true" ] || [ "$APP_CHANGED" = "true" ] || [ "$AGENTS_CHANGED" = "true" ]; then + echo "has_any_changes=true" >> $GITHUB_OUTPUT + echo "" + echo "=== SUMMARY ===" + echo "Introspection changed: $APP_CHANGED" + echo "SDK changed: $SDK_CHANGED" + echo "Agents changed: $AGENTS_CHANGED" +else + echo "has_any_changes=false" >> $GITHUB_OUTPUT + echo "All generated outputs are up-to-date." +fi diff --git a/packages/bash-ast/__fixtures__/workflows/generate-all-0298.sh b/packages/bash-ast/__fixtures__/workflows/generate-all-0298.sh new file mode 100644 index 00000000..90993e64 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/generate-all-0298.sh @@ -0,0 +1,10 @@ +echo "## Generation Results" >> $GITHUB_STEP_SUMMARY +echo "" >> $GITHUB_STEP_SUMMARY +echo "Source: \`${{ steps.refs.outputs.ref_sha }}\` (ref: \`${{ inputs.ref || 'main' }}\`)" >> $GITHUB_STEP_SUMMARY +echo "" >> $GITHUB_STEP_SUMMARY +echo "| Output | Changed |" >> $GITHUB_STEP_SUMMARY +echo "|--------|---------|" >> $GITHUB_STEP_SUMMARY +echo "| application/constructive/ (introspection) | ${{ steps.check_changes.outputs.has_introspection_changes }} |" >> $GITHUB_STEP_SUMMARY +echo "| sdk/constructive-schema/ (schemas) | ${{ steps.check_changes.outputs.has_schema_changes }} |" >> $GITHUB_STEP_SUMMARY +echo "| sdk/ (SDKs + CLI) | ${{ steps.check_changes.outputs.has_sdk_changes }} |" >> $GITHUB_STEP_SUMMARY +echo "| .agents/ (skills) | ${{ steps.check_changes.outputs.has_agents_changes }} |" >> $GITHUB_STEP_SUMMARY diff --git a/packages/bash-ast/__fixtures__/workflows/image-freshness-0057.sh b/packages/bash-ast/__fixtures__/workflows/image-freshness-0057.sh new file mode 100644 index 00000000..5deb1b9f --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/image-freshness-0057.sh @@ -0,0 +1,2 @@ +cd compute/fun/k8s +npx tsx scripts/check-image-freshness.ts --write | tee "$GITHUB_STEP_SUMMARY" diff --git a/packages/bash-ast/__fixtures__/workflows/image-freshness-0068.sh b/packages/bash-ast/__fixtures__/workflows/image-freshness-0068.sh new file mode 100644 index 00000000..68fda999 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/image-freshness-0068.sh @@ -0,0 +1,45 @@ +set -euo pipefail +BRANCH="chore/pin-constructive-$TAG" + +# A rerun before the last PR merges would otherwise fail on push. +if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "Branch $BRANCH already exists — the bump to $TAG is already open." + exit 0 +fi + +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" +git checkout -b "$BRANCH" +git add compute/fun/env/src/images.ts + +cat > /tmp/commit-msg.txt < /tmp/pr-body.md <> "$GITHUB_OUTPUT" + echo "::notice::${{ github.event_name }} run — cluster suites always run" + exit 0 +fi + +if [ "$LABELLED" = 'true' ]; then + echo "cluster=true" >> "$GITHUB_OUTPUT" + echo "::notice::needs-knative label present — cluster suites run" + exit 0 +fi + +changed=$(git diff --name-only "origin/${BASE_REF}...HEAD") +echo "Changed files:"; echo "$changed" + +plumbing=$(echo "$changed" | grep -vE \ + -e '^compute/system/' \ + -e '^compute/fixtures/' \ + -e '^compute/runtimes/node/' \ + -e '^compute/testkit/' \ + -e '^functions/' \ + -e '^compute/tests/' \ + -e '^compute/docs/' \ + -e '^ui/www/' \ + -e '^fbp/' \ + -e '\.md$' || true) + +if [ -n "$plumbing" ]; then + echo "cluster=true" >> "$GITHUB_OUTPUT" + echo "::notice::Plumbing changed — cluster suites run" + echo "$plumbing" +else + echo "cluster=false" >> "$GITHUB_OUTPUT" + echo "::notice::Handler/runtime-only change — skipping the cluster suites. Add the 'needs-knative' label to force them." +fi diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0156.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0156.sh new file mode 100644 index 00000000..19c6bbeb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0156.sh @@ -0,0 +1,2 @@ +git config --global user.name "CI Test User" +git config --global user.email "ci@example.com" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0195.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0195.sh new file mode 100644 index 00000000..c971204a --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0195.sh @@ -0,0 +1,6 @@ +pnpm +--filter "@constructive-functions/cli..." +--filter "@constructive-db/compute-worker..." +--filter "@constructive-db/functions-integration-tests..." +--filter "@constructive-functions/seeds..." +run build diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0206.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0206.sh new file mode 100644 index 00000000..7add81eb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0206.sh @@ -0,0 +1,2 @@ +cd compute/tests +pnpm typecheck diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0211.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0211.sh new file mode 100644 index 00000000..7cc71420 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0211.sh @@ -0,0 +1,2 @@ +cd compute/tests +pnpm jest --config jest.config.js --no-coverage --forceExit --verbose 'agentic-server|agent-context|agent-handlers|env-layering' 2>&1 diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0218.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0218.sh new file mode 100644 index 00000000..01500fcf --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0218.sh @@ -0,0 +1,2 @@ +cd compute/tests +pnpm test:worker diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0227.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0227.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0227.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0253.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0253.sh new file mode 100644 index 00000000..063a3327 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0253.sh @@ -0,0 +1,18 @@ +set -euo pipefail +mkdir -p ~/.ollama-models +docker run -d --name ollama \ + -v ~/.ollama-models:/root/.ollama \ + -p 11434:11434 \ + ollama/ollama:0.32.9 +for i in $(seq 1 30); do + curl -sf http://localhost:11434/api/tags >/dev/null && break + sleep 2 +done +curl -sf http://localhost:11434/api/tags >/dev/null +if curl -sf http://localhost:11434/api/tags | grep -q '"nomic-embed-text'; then + echo "model already cached" +else + curl -sf http://localhost:11434/api/pull \ + -d '{"name":"nomic-embed-text"}' -o /tmp/pull.log + grep -q '"status":"success"' /tmp/pull.log || { cat /tmp/pull.log; exit 1; } +fi diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0276.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0276.sh new file mode 100644 index 00000000..e92a6698 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0276.sh @@ -0,0 +1,2 @@ +cd compute/tests +pnpm jest --config jest.config.js --no-coverage --forceExit --verbose 'live-embed' diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0286.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0286.sh new file mode 100644 index 00000000..2f02b1a2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0286.sh @@ -0,0 +1,2 @@ +cd functions/embedding +pnpm jest --no-coverage --forceExit --verbose 'live-record-loop' diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0294.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0294.sh new file mode 100644 index 00000000..b58448ff --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0294.sh @@ -0,0 +1,2 @@ +cd compute/testkit/test-utils +pnpm test --forceExit diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0301.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0301.sh new file mode 100644 index 00000000..d4a7d6dc --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0301.sh @@ -0,0 +1,2 @@ +cd compute/fun/k8s +pnpm test diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0308.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0308.sh new file mode 100644 index 00000000..7c4aaad8 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0308.sh @@ -0,0 +1,2 @@ +cd compute/fun/cli +pnpm test diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0315.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0315.sh new file mode 100644 index 00000000..d7e63841 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0315.sh @@ -0,0 +1,2 @@ +cd compute/services/worker +pnpm test diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0365.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0365.sh new file mode 100644 index 00000000..19c6bbeb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0365.sh @@ -0,0 +1,2 @@ +git config --global user.name "CI Test User" +git config --global user.email "ci@example.com" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0411.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0411.sh new file mode 100644 index 00000000..2b547e62 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0411.sh @@ -0,0 +1,4 @@ +pnpm +--filter "@constructive-functions/cli..." +--filter "@constructive-db/compute-worker..." +run build diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0427.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0427.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0427.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0438.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0438.sh new file mode 100644 index 00000000..1bf37cc7 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0438.sh @@ -0,0 +1,4 @@ +psql --dbname postgres --set ON_ERROR_STOP=1 \ + --command "CREATE DATABASE ${KNATIVE_E2E_TEMPLATE_DB}" +cd application/app +pgpm deploy --fast --yes --database "${KNATIVE_E2E_TEMPLATE_DB}" --package app diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0454.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0454.sh new file mode 100644 index 00000000..eeaeca64 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0454.sh @@ -0,0 +1,3 @@ +cd compute +npx tsx ../compute/fun/cli/src/generate-standalone.ts --only=hello +npx tsx ../compute/fun/cli/src/generate-standalone.ts --only=hello-py diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0460.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0460.sh new file mode 100644 index 00000000..5bd6806a --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0460.sh @@ -0,0 +1,8 @@ +# --cwd is the platform root the CLI reads handlers and templates from, +# so it is absolute: a path relative to the CLI's own directory moves +# whenever the CLI does. +cd compute/fun/cli +platform="$GITHUB_WORKSPACE/compute" +npx tsx src/index.ts build-image --only=hello --registry=kind.local --tag=latest --cwd="$platform" +npx tsx src/index.ts build-image --only=hello-py --registry=kind.local --tag=latest --cwd="$platform" +npx tsx src/index.ts build-image --only=hello --registry=kind.local --tag=dev --target=dev --cwd="$platform" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0487.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0487.sh new file mode 100644 index 00000000..99e69049 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0487.sh @@ -0,0 +1,3 @@ +kind load docker-image kind.local/hello:latest --name knative-e2e +kind load docker-image kind.local/hello-py:latest --name knative-e2e +kind load docker-image kind.local/hello:dev --name knative-e2e diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0494.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0494.sh new file mode 100644 index 00000000..d4dadacd --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0494.sh @@ -0,0 +1,2 @@ +echo "Installing Knative Serving ${KNATIVE_VERSION} CRDs..." +kubectl apply -f "https://github.com/knative/serving/releases/download/knative-${KNATIVE_VERSION}/serving-crds.yaml" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0499.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0499.sh new file mode 100644 index 00000000..0d65dc2e --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0499.sh @@ -0,0 +1,2 @@ +echo "Installing Knative Serving ${KNATIVE_VERSION} core..." +kubectl apply -f "https://github.com/knative/serving/releases/download/knative-${KNATIVE_VERSION}/serving-core.yaml" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0504.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0504.sh new file mode 100644 index 00000000..8c011cb1 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0504.sh @@ -0,0 +1,6 @@ +echo "Installing Kourier networking layer..." +kubectl apply -f "https://github.com/knative/net-kourier/releases/download/knative-${KNATIVE_VERSION}/kourier.yaml" +kubectl patch configmap/config-network \ + --namespace knative-serving \ + --type merge \ + --patch '{"data":{"ingress-class":"kourier.ingress.networking.knative.dev"}}' diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0513.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0513.sh new file mode 100644 index 00000000..b713ca74 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0513.sh @@ -0,0 +1,4 @@ +kubectl patch configmap/config-deployment \ + --namespace knative-serving \ + --type merge \ + --patch '{"data":{"registries-skipping-tag-resolving":"kind.local,ko.local,dev.local,ghcr.io"}}' diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0520.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0520.sh new file mode 100644 index 00000000..3ebbdc85 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0520.sh @@ -0,0 +1,5 @@ +kubectl patch configmap/config-domain \ + --namespace knative-serving \ + --type merge \ + --patch '{"data":{"example.com":""}}' +echo "Knative domain set to example.com for Kourier routing" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0528.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0528.sh new file mode 100644 index 00000000..be14b210 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0528.sh @@ -0,0 +1,4 @@ +echo "Waiting for Knative Serving pods to be ready..." +kubectl wait --for=condition=Ready pods --all -n knative-serving --timeout=180s || true +echo "Knative Serving pod status:" +kubectl get pods -n knative-serving diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0536.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0536.sh new file mode 100644 index 00000000..a1167e43 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0536.sh @@ -0,0 +1,4 @@ +kubectl proxy --port=8001 & +sleep 2 +echo "kubectl proxy running on :8001" +curl -s http://localhost:8001/api/v1/namespaces/default | head -5 diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0544.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0544.sh new file mode 100644 index 00000000..ba38bd50 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0544.sh @@ -0,0 +1,5 @@ +echo "Waiting for Kourier gateway pods..." +kubectl wait --for=condition=Ready pods -l app=3scale-kourier-gateway -n kourier-system --timeout=120s || true +kubectl port-forward -n kourier-system svc/kourier 8080:80 & +sleep 2 +echo "Kourier gateway forwarded to localhost:8080" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0556.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0556.sh new file mode 100644 index 00000000..6baa729f --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0556.sh @@ -0,0 +1,10 @@ +helm repo add traefik https://traefik.github.io/charts +helm repo update +helm install traefik traefik/traefik \ + --namespace traefik --create-namespace \ + --version "${TRAEFIK_CHART_VERSION}" \ + --set service.type=ClusterIP \ + --set providers.kubernetesCRD.allowCrossNamespace=true \ + --set providers.kubernetesIngress.enabled=false \ + --wait --timeout 5m +kubectl get pods -n traefik diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0569.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0569.sh new file mode 100644 index 00000000..8de1a46d --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0569.sh @@ -0,0 +1,3 @@ +kubectl port-forward -n traefik svc/traefik 8090:80 & +sleep 2 +echo "Traefik web entrypoint forwarded to localhost:8090" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0579.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0579.sh new file mode 100644 index 00000000..8f5871c9 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0579.sh @@ -0,0 +1,17 @@ +# The Gateway field is not always populated on the IPAM config +# (docker only records it once something asks); fall back to the +# .1 address of the network's IPv4 subnet. An empty value would +# silently describe.skip the static-site suite, so fail loud. +KIND_HOST_IP=$(docker network inspect kind \ + -f '{{range .IPAM.Config}}{{.Gateway}} {{end}}' | tr ' ' '\n' | grep -v ':' | grep -m1 . || true) +if [ -z "${KIND_HOST_IP}" ]; then + SUBNET=$(docker network inspect kind \ + -f '{{range .IPAM.Config}}{{.Subnet}} {{end}}' | tr ' ' '\n' | grep -v ':' | grep -m1 .) + KIND_HOST_IP=$(echo "${SUBNET}" | sed -E 's#\.[0-9]+/[0-9]+$#.1#') +fi +if [ -z "${KIND_HOST_IP}" ]; then + echo "Failed to resolve Kind host IP" >&2 + exit 1 +fi +echo "KIND_HOST_IP=${KIND_HOST_IP}" >> "$GITHUB_ENV" +echo "Kind host IP: ${KIND_HOST_IP}" diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0626.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0626.sh new file mode 100644 index 00000000..2ed5e462 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0626.sh @@ -0,0 +1,4 @@ +cd compute/services/reconciler +pnpm jest --no-coverage --forceExit --verbose --runInBand \ + --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ + --testPathPattern='\.e2e\.test\.ts$' 2>&1 diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0645.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0645.sh new file mode 100644 index 00000000..8c6a0f0d --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0645.sh @@ -0,0 +1,24 @@ +node -e "require('./compute/fun/cli/dist/commands/down').uninstallKnativeViaKubectl()" +echo "=== Post-teardown residue (every count must be 0) ===" +residue=0 +check() { + local label="$1"; local count="$2" + echo " ${label}: ${count}" + if [ "${count}" -ne 0 ]; then residue=1; fi +} +check "knative CRDs" "$(kubectl get crd -o name 2>/dev/null | grep -c knative || true)" +check "knative mutatingwebhooks" "$(kubectl get mutatingwebhookconfiguration -o name 2>/dev/null | grep -c knative || true)" +check "knative validatingwebhooks" "$(kubectl get validatingwebhookconfiguration -o name 2>/dev/null | grep -c knative || true)" +check "knative/kourier clusterroles" "$(kubectl get clusterrole -o name 2>/dev/null | grep -cE 'knative|kourier' || true)" +check "knative/kourier clusterrolebindings" "$(kubectl get clusterrolebinding -o name 2>/dev/null | grep -cE 'knative|kourier' || true)" +# Anchor to the two infra namespaces uninstallKnativeViaKubectl removes; +# a loose `knative` substring also matches E2E tenant namespaces (e.g. +# knative-e2e-*) that this step deliberately does not tear down. +check "knative/kourier namespaces" "$(kubectl get ns -o name 2>/dev/null | grep -cE '^namespace/(knative-serving|kourier-system)$' || true)" +if [ "${residue}" -ne 0 ]; then + echo "Teardown left Knative/Kourier resources behind — see counts above." >&2 + kubectl get crd 2>/dev/null | grep knative || true + kubectl get ns 2>/dev/null | grep -E 'knative|kourier' || true + exit 1 +fi +echo "Cluster returned to base — no Knative/Kourier residue." diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0674.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0674.sh new file mode 100644 index 00000000..9a17b4f1 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0674.sh @@ -0,0 +1,30 @@ +echo "=== Namespaces ===" +kubectl get ns || true +echo "" +echo "=== Knative Services ===" +kubectl get ksvc -A || true +echo "" +echo "=== Deployments ===" +kubectl get deployments -A || true +echo "" +echo "=== Ingresses ===" +kubectl get ingress -A || true +echo "" +echo "=== Secrets ===" +kubectl get secrets -A | grep -v default-token || true +echo "" +echo "=== Events (last 50) ===" +kubectl get events -A --sort-by='.lastTimestamp' | tail -50 || true +echo "" +echo "=== Pod status ===" +kubectl get pods -A || true +echo "" +echo "=== Logs of pods that are not Running ===" +kubectl get pods -A --no-headers 2>/dev/null \ + | awk '$4 != "Running" && $4 != "Completed" { print $1, $2 }' \ + | while read -r ns pod; do + echo "--- $ns/$pod (current) ---" + kubectl logs -n "$ns" "$pod" --all-containers --tail=100 || true + echo "--- $ns/$pod (previous) ---" + kubectl logs -n "$ns" "$pod" --all-containers --previous --tail=100 || true + done diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0765.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0765.sh new file mode 100644 index 00000000..d5fdf93b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0765.sh @@ -0,0 +1,17 @@ +set -euo pipefail +helm repo add cilium https://helm.cilium.io/ +helm repo update +helm upgrade --install cilium cilium/cilium \ + --version "${CILIUM_VERSION}" \ + --namespace kube-system \ + --set ipam.mode=kubernetes \ + --set routingMode=native \ + --set ipv4NativeRoutingCIDR=10.244.0.0/16 \ + --set autoDirectNodeRoutes=true \ + --set bpf.masquerade=true \ + --set nodePort.enabled=true \ + --set image.pullPolicy=IfNotPresent \ + --set operator.replicas=1 \ + --wait --timeout 10m +kubectl -n kube-system rollout status ds/cilium --timeout=300s +kubectl get crd ciliumclusterwidenetworkpolicies.cilium.io diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0785.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0785.sh new file mode 100644 index 00000000..34e95e6d --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0785.sh @@ -0,0 +1,4 @@ +set -euo pipefail +kubectl apply -f compute/k8s/network/tenant-isolation.yaml +echo "=== Cluster-wide policies now in force ===" +kubectl get ciliumclusterwidenetworkpolicies diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0794.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0794.sh new file mode 100644 index 00000000..43b4d0af --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0794.sh @@ -0,0 +1,5 @@ +cd compute/fun/k8s +# Named explicitly, so a suite that skipped itself surfaces as "no +# tests found" rather than as a green job that asserted nothing. +pnpm jest --no-coverage --forceExit --verbose \ + --testPathPattern='tenant-isolation\.cluster\.test\.ts$' diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0803.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0803.sh new file mode 100644 index 00000000..4a2929c7 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0803.sh @@ -0,0 +1,4 @@ +kubectl get pods -A -o wide || true +kubectl get ciliumclusterwidenetworkpolicies -o yaml || true +kubectl get ns --show-labels || true +kubectl -n kube-system logs ds/cilium --tail=100 || true diff --git a/packages/bash-ast/__fixtures__/workflows/knative-test-0825.sh b/packages/bash-ast/__fixtures__/workflows/knative-test-0825.sh new file mode 100644 index 00000000..df80a85c --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/knative-test-0825.sh @@ -0,0 +1,8 @@ +set -euo pipefail +if [ "$CLUSTER" != 'true' ]; then + echo "Cluster suites skipped for this change (label 'needs-knative' to force them)." + exit 0 +fi +echo "knative-e2e result: $RESULT" +echo "tenant-isolation result: $ISOLATION" +[ "$RESULT" = 'success' ] && [ "$ISOLATION" = 'success' ] diff --git a/packages/bash-ast/__fixtures__/workflows/lint-0114.sh b/packages/bash-ast/__fixtures__/workflows/lint-0114.sh new file mode 100644 index 00000000..65a65dba --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/lint-0114.sh @@ -0,0 +1,2 @@ +pip install -r requirements.txt +pip install pytest pytest-asyncio diff --git a/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0060.sh b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0060.sh new file mode 100644 index 00000000..fd4b6d23 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0060.sh @@ -0,0 +1,2 @@ +pnpm -r --filter @constructive-functions/cli... run build +cd compute/fun/cli/dist && npm link diff --git a/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0085.sh b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0085.sh new file mode 100644 index 00000000..97fa5e32 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0085.sh @@ -0,0 +1,8 @@ +fun www & +for i in $(seq 1 30); do + if curl -sf http://localhost:5173/ > /dev/null 2>&1; then + echo "Vite ready" + break + fi + sleep 2 +done diff --git a/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0166.sh b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0166.sh new file mode 100644 index 00000000..fd4b6d23 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0166.sh @@ -0,0 +1,2 @@ +pnpm -r --filter @constructive-functions/cli... run build +cd compute/fun/cli/dist && npm link diff --git a/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0177.sh b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0177.sh new file mode 100644 index 00000000..14b3c6ad --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0177.sh @@ -0,0 +1,12 @@ +cat > /tmp/kind-config.yaml <<'KINDEOF' +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP +KINDEOF +echo "Kind config written:" +cat /tmp/kind-config.yaml diff --git a/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0231.sh b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0231.sh new file mode 100644 index 00000000..c71af181 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0231.sh @@ -0,0 +1,8 @@ +fun www --k8s & +for i in $(seq 1 30); do + if curl -sf http://localhost:5173/ > /dev/null 2>&1; then + echo "Vite ready" + break + fi + sleep 2 +done diff --git a/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0277.sh b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0277.sh new file mode 100644 index 00000000..9099e874 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/platform-ui-tests-0277.sh @@ -0,0 +1,18 @@ +echo "═══ Knative services ═══" +kubectl get ksvc -A -o wide || true +echo "═══ Knative revisions ═══" +kubectl get revisions -A -o wide || true +echo "═══ Pods ═══" +kubectl get pods -A -o wide || true +echo "═══ Recent events ═══" +kubectl get events -A --sort-by=.lastTimestamp | tail -150 || true +echo "═══ Knative service descriptions ═══" +for ns in $(kubectl get ksvc -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"\n"}{end}' | sort -u); do + kubectl describe ksvc -n "$ns" || true +done +echo "═══ compute-worker logs ═══" +kubectl logs -n constructive-functions deploy/compute-worker --tail=300 || true +echo "═══ DB-managed platform workloads ═══" +kubectl get all -n constructive-platform-default || true +kubectl logs -n constructive-platform-default deploy/graphql-public --tail=100 || true +kubectl logs -n constructive-platform-default deploy/graphql-private --tail=100 || true diff --git a/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0071.sh b/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0071.sh new file mode 100644 index 00000000..19c6bbeb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0071.sh @@ -0,0 +1,2 @@ +git config --global user.name "CI Test User" +git config --global user.email "ci@example.com" diff --git a/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0108.sh b/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0108.sh new file mode 100644 index 00000000..91ad8074 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0108.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --client --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0118.sh b/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0118.sh new file mode 100644 index 00000000..171db4ca --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/proxy-e2e-tests-0118.sh @@ -0,0 +1 @@ +pnpm --filter @constructive-db/pg-proxy-e2e run test diff --git a/packages/bash-ast/__fixtures__/workflows/proxy-tests-0066.sh b/packages/bash-ast/__fixtures__/workflows/proxy-tests-0066.sh new file mode 100644 index 00000000..70036184 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/proxy-tests-0066.sh @@ -0,0 +1,3 @@ +pnpm --filter @constructive-db/sql-filter run build +pnpm --filter @constructive-db/pg-wire-proxy run build +pnpm --filter @constructive-db/constructive-proxy-server run build diff --git a/packages/bash-ast/__fixtures__/workflows/proxy-tests-0072.sh b/packages/bash-ast/__fixtures__/workflows/proxy-tests-0072.sh new file mode 100644 index 00000000..ee8a5aa2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/proxy-tests-0072.sh @@ -0,0 +1,5 @@ +pnpm --filter @constructive-db/sql-filter run lint +pnpm --filter @constructive-db/pg-wire-proxy run lint +pnpm --filter @constructive-db/constructive-proxy-server run lint +pnpm --filter @constructive-db/sql-ast run lint +pnpm --filter @constructive-db/pg-proxy-e2e run lint diff --git a/packages/bash-ast/__fixtures__/workflows/proxy-tests-0085.sh b/packages/bash-ast/__fixtures__/workflows/proxy-tests-0085.sh new file mode 100644 index 00000000..906094fa --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/proxy-tests-0085.sh @@ -0,0 +1,3 @@ +pnpm --filter @constructive-db/sql-filter run test +pnpm --filter @constructive-db/pg-wire-proxy run test +pnpm --filter @constructive-db/sql-ast run test diff --git a/packages/bash-ast/__fixtures__/workflows/publish-images-0097.sh b/packages/bash-ast/__fixtures__/workflows/publish-images-0097.sh new file mode 100644 index 00000000..db1321c2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/publish-images-0097.sh @@ -0,0 +1,11 @@ +set -euo pipefail +plan=$([ "$ALL" = 'true' ] \ + && pnpm exec tsx ci/functions/plan.ts --all --images \ + || pnpm exec tsx ci/functions/plan.ts --images) +images=$(echo "$plan" | jq -c '.images') +if [ -n "$ONLY" ]; then + images=$(jq -c --arg only "$ONLY" \ + '[.[] | select(.name == $only or .dir == "functions/" + $only)]' <<<"$images") +fi +echo "matrix=$images" >> "$GITHUB_OUTPUT" +echo "any=$([ "$images" = '[]' ] && echo false || echo true)" >> "$GITHUB_OUTPUT" diff --git a/packages/bash-ast/__fixtures__/workflows/publish-images-0149.sh b/packages/bash-ast/__fixtures__/workflows/publish-images-0149.sh new file mode 100644 index 00000000..26ec1974 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/publish-images-0149.sh @@ -0,0 +1,20 @@ +set -euo pipefail +content=$(git ls-tree -r HEAD "${{ matrix.spec.dir }}" | sha256sum | cut -c1-64) +platform=$(git rev-parse 'HEAD:compute') +tag=$(printf '%s\n%s\n' "$content" "$platform" | sha256sum | cut -c1-16) +image="${REGISTRY}/${ORG}/${{ matrix.spec.image }}" +ref="${image}:${tag}" +echo "ref=$ref" >> "$GITHUB_OUTPUT" +# `latest` moves only on the default branch; the content tag is the +# immutable identity a deployment pins. +if [ "$GITHUB_REF" = 'refs/heads/main' ]; then + printf 'tags=%s,%s:latest\n' "$ref" "$image" >> "$GITHUB_OUTPUT" +else + printf 'tags=%s\n' "$ref" >> "$GITHUB_OUTPUT" +fi +if [ "$FORCE" != "true" ] && docker manifest inspect "$ref" >/dev/null 2>&1; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "::notice::$ref is already published — nothing to build." +else + echo "published=false" >> "$GITHUB_OUTPUT" +fi diff --git a/packages/bash-ast/__fixtures__/workflows/publish-images-0173.sh b/packages/bash-ast/__fixtures__/workflows/publish-images-0173.sh new file mode 100644 index 00000000..efe52cd1 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/publish-images-0173.sh @@ -0,0 +1,2 @@ +cd compute && npx tsx ../compute/fun/cli/src/generate-standalone.ts --packages-only +cd .. && pnpm install --frozen-lockfile --filter @constructive-functions/cli diff --git a/packages/bash-ast/__fixtures__/workflows/publish-images-0211.sh b/packages/bash-ast/__fixtures__/workflows/publish-images-0211.sh new file mode 100644 index 00000000..589a0eed --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/publish-images-0211.sh @@ -0,0 +1,6 @@ +set -euo pipefail +echo "plan=$PLAN publish=$PUBLISH (any=$ANY)" +[ "$PLAN" = 'success' ] +if [ "$ANY" = 'true' ]; then + [ "$PUBLISH" = 'success' ] +fi diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0111.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0111.sh new file mode 100644 index 00000000..4082bab8 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0111.sh @@ -0,0 +1,25 @@ +if [ "$EVENT_NAME" = 'push' ]; then + branch="$REF_NAME" + case "$branch" in + *-regen-all) constructive=true; platform=true ;; + *-regen-platform) constructive=false; platform=true ;; + *-regen) constructive=true; platform=false ;; + *) + echo "::error::$branch ends in no known -regen suffix." + exit 1 + ;; + esac +else + branch="$INPUT_BRANCH" + constructive="$INPUT_CONSTRUCTIVE" + platform="$INPUT_PLATFORM" + if [ "$constructive" != 'true' ] && [ "$platform" != 'true' ]; then + echo "::error::Tick at least one of generate_constructive / generate_platform." + exit 1 + fi +fi + +echo "branch=$branch" >> "$GITHUB_OUTPUT" +echo "constructive=$constructive" >> "$GITHUB_OUTPUT" +echo "platform=$platform" >> "$GITHUB_OUTPUT" +echo "Regenerating $branch — constructive=$constructive platform=$platform" diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0139.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0139.sh new file mode 100644 index 00000000..7de53648 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0139.sh @@ -0,0 +1,3 @@ +git config --global user.name "github-actions[bot]" +git config --global user.email "github-actions[bot]@users.noreply.github.com" +git config --global --add safe.directory /__w/constructive-db/constructive-db diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0165.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0165.sh new file mode 100644 index 00000000..5b02f1a6 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0165.sh @@ -0,0 +1,2 @@ +cd compute +npx tsx ../compute/fun/cli/src/generate-standalone.ts --packages-only diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0189.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0189.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0189.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0213.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0213.sh new file mode 100644 index 00000000..738a29e2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0213.sh @@ -0,0 +1,2 @@ +cd application/constructive +pgpm package diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0223.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0223.sh new file mode 100644 index 00000000..35574b88 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0223.sh @@ -0,0 +1,33 @@ +PATHS="" +if [ "$DO_CONSTRUCTIVE" = 'true' ]; then + # generate:constructive also rewrites the meta-export manifest (it + # enumerates the module config tables), so a new module leaves it + # dirty; without it here the rebase below aborts on unstaged changes. + # `application/constructive` covers both halves: deploy/ from the + # generator, sql/ from the packaging step above. + PATHS="$PATHS application/constructive sdk/constructive-schema/meta-export-tables.json" +fi +if [ "$DO_PLATFORM" = 'true' ]; then + PATHS="$PATHS platform-schema" +fi + +git add -A -- $PATHS + +if git diff --cached --quiet; then + echo "No changes — generated output already matches $BRANCH." + echo 'changed=false' >> "$GITHUB_OUTPUT" + exit 0 +fi +echo 'changed=true' >> "$GITHUB_OUTPUT" + +git diff --cached --stat + +TARGETS="" +[ "$DO_CONSTRUCTIVE" = 'true' ] && TARGETS="constructive" +if [ "$DO_PLATFORM" = 'true' ]; then + [ -n "$TARGETS" ] && TARGETS="$TARGETS + platform" || TARGETS="platform" +fi + +git commit \ + -m "chore: regenerate $TARGETS" \ + -m "Generated by the Regenerate on a branch workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0278.sh b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0278.sh new file mode 100644 index 00000000..5b0d3e8f --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/regenerate-branch-0278.sh @@ -0,0 +1,16 @@ +# The branch may have moved while generation ran; rebase our single +# generated commit onto its tip rather than clobbering someone's push. +# Autostash: generation also touches paths that are deliberately not +# committed here, and any one left dirty aborts the rebase. +git pull --rebase --autostash origin "$BRANCH" + +if ! git push origin HEAD:"$BRANCH"; then + echo "::error::Push refused. The generated output is in the" \ + "regenerated-patch artifact on this run — apply it with" \ + "'gh run download ${{ github.run_id }} -n regenerated-patch" \ + "&& git am regenerate.patch'. To make the push work, set" \ + "Settings -> Actions -> General -> Workflow permissions to" \ + "'Read and write permissions', or add a GH_SUBMODULE_PAT secret" \ + "with contents: write." + exit 1 +fi diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0165.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0165.sh new file mode 100644 index 00000000..19c6bbeb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0165.sh @@ -0,0 +1,2 @@ +git config --global user.name "CI Test User" +git config --global user.email "ci@example.com" diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0190.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0190.sh new file mode 100644 index 00000000..161b49ba --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0190.sh @@ -0,0 +1,15 @@ +mode=full +base=origin/develop +case "${{ github.event_name }}" in + workflow_dispatch) + mode="${{ github.event.inputs.mode }}" + ;; + pull_request) + base_ref="${{ github.event.pull_request.base.ref }}" + base="origin/$base_ref" + [ "$base_ref" = "main" ] && mode=full || mode=affected + ;; +esac +echo "mode=$mode" >> "$GITHUB_OUTPUT" +echo "base=$base" >> "$GITHUB_OUTPUT" +echo "test scope: mode=$mode base=$base" diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0231.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0231.sh new file mode 100644 index 00000000..2ad58076 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0231.sh @@ -0,0 +1,16 @@ +mode="${{ steps.scope.outputs.mode }}" +base="${{ steps.scope.outputs.base }}" +# Affected needs a resolvable base to diff against. Until `develop` +# exists (or if a base branch is ever renamed/deleted) fall back to +# the full matrix rather than failing or silently diffing everything. +if [ "$mode" = affected ] && ! git rev-parse --verify --quiet "$base" >/dev/null; then + echo "::warning::affected base '$base' not found — running the full matrix" + mode=full +fi +if [ "$mode" = affected ]; then + matrix=$(node ci/shard-plan.cjs --affected --base "$base" --json) +else + matrix=$(node ci/shard-plan.cjs --json) +fi +echo "matrix=$matrix" >> "$GITHUB_OUTPUT" +echo "$matrix" | node -e 'const m=JSON.parse(require("fs").readFileSync(0,"utf8"));console.error(`${m.length} shard(s) selected`)' diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0338.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0338.sh new file mode 100644 index 00000000..19c6bbeb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0338.sh @@ -0,0 +1,2 @@ +git config --global user.name "CI Test User" +git config --global user.email "ci@example.com" diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0373.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0373.sh new file mode 100644 index 00000000..718c0985 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0373.sh @@ -0,0 +1,3 @@ +docker exec ${{ job.services.pg_db.id }} psql -U postgres -c "ALTER SYSTEM SET max_locks_per_transaction = 256" +docker restart ${{ job.services.pg_db.id }} +timeout 60 bash -c 'until docker exec ${{ job.services.pg_db.id }} pg_isready -U postgres; do sleep 1; done' diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0386.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0386.sh new file mode 100644 index 00000000..8ebcc0d9 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0386.sh @@ -0,0 +1,3 @@ +pgpm admin-users bootstrap --yes +# optional, but added to avoid concurrent attempts to add user +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0398.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0398.sh new file mode 100644 index 00000000..c3d0a464 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0398.sh @@ -0,0 +1,6 @@ +found=$(pnpm exec jest --listTests "${{ matrix.test_pattern }}" 2>/dev/null | grep -c '\.test\.ts$') +echo "selected $found file(s), expected ${{ matrix.expected_files }}" +if [ "$found" -ne "${{ matrix.expected_files }}" ]; then + echo "::error::shard ${{ matrix.shard_name }} selected $found files but expects ${{ matrix.expected_files }}; check 'node ci/shard-plan.cjs'" + exit 1 +fi diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0415.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0415.sh new file mode 100644 index 00000000..6d84b27a --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0415.sh @@ -0,0 +1,23 @@ +if [ -n "${{ matrix.batch_packages }}" ]; then + # Batched lightweight packages — run sequentially + for pkg in ${{ matrix.batch_packages }}; do + echo "::group::Testing $pkg" + (cd ./$pkg && pnpm exec jest --maxWorkers="$JEST_MAX_WORKERS") + echo "::endgroup::" + done +else + cd ./${{ matrix.package }} + if [ -n "${{ matrix.test_pattern }}" ]; then + if [ -n "${{ matrix.test_name_pattern }}" ]; then + # Use `pnpm exec jest` (not `pnpm test`) so pnpm doesn't inject a + # literal `--` into the jest argv. With `--` injected, Jest treats + # every subsequent token (including `-t` and its value) as a + # positional testPathPattern, which silently disables the filter. + pnpm exec jest --maxWorkers="$JEST_MAX_WORKERS" "${{ matrix.test_pattern }}" -t "${{ matrix.test_name_pattern }}" + else + pnpm exec jest --maxWorkers="$JEST_MAX_WORKERS" "${{ matrix.test_pattern }}" + fi + else + pnpm exec jest --maxWorkers="$JEST_MAX_WORKERS" + fi +fi diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0517.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0517.sh new file mode 100644 index 00000000..0995db5e --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0517.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +psql -c "DO \$\$ BEGIN CREATE ROLE app_admin NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END \$\$" diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0535.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0535.sh new file mode 100644 index 00000000..2070cfbe --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0535.sh @@ -0,0 +1,8 @@ +# -R explicitly: the workspace is unpacked from an artifact, so there +# is no .git for gh to infer the repo from. +run_id=$(gh run list -R "$GITHUB_REPOSITORY" --workflow run-tests.yaml \ + --branch "$BASE" --status success --limit 1 --json databaseId -q '.[0].databaseId') +[ -n "$run_id" ] || { echo "no successful $BASE run to compare against"; exit 0; } +gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n safegres-reports -D previous +echo "SAFEGRES_COMPARE=$PWD/previous/safegres.json" >> "$GITHUB_ENV" +echo "SAFEGRES_COMPARE_REF=$BASE" >> "$GITHUB_ENV" diff --git a/packages/bash-ast/__fixtures__/workflows/run-tests-0555.sh b/packages/bash-ast/__fixtures__/workflows/run-tests-0555.sh new file mode 100644 index 00000000..23112304 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/run-tests-0555.sh @@ -0,0 +1,10 @@ +body=$(mktemp) +cat safegres-reports/summary.md > "$body" +printf '\n\n' >> "$body" +existing=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" --paginate \ + -q '.[] | select(.body | contains("")) | .id' | head -1) +if [ -n "$existing" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$existing" -F body=@"$body" +else + gh api -X POST "repos/${{ github.repository }}/issues/$PR/comments" -F body=@"$body" +fi diff --git a/packages/bash-ast/__fixtures__/workflows/safegres-0087.sh b/packages/bash-ast/__fixtures__/workflows/safegres-0087.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/safegres-0087.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/safegres-0099.sh b/packages/bash-ast/__fixtures__/workflows/safegres-0099.sh new file mode 100644 index 00000000..17d0f354 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/safegres-0099.sh @@ -0,0 +1,2 @@ +psql -v ON_ERROR_STOP=1 -d constructivedb \ + -f services/constructive-bootstrap/verify/platform-bootstrap-principal.sql diff --git a/packages/bash-ast/__fixtures__/workflows/safegres-0107.sh b/packages/bash-ast/__fixtures__/workflows/safegres-0107.sh new file mode 100644 index 00000000..a94acfbc --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/safegres-0107.sh @@ -0,0 +1,2 @@ +psql -v ON_ERROR_STOP=1 -d constructivedb \ + -f services/constructive-bootstrap/verify/constructive-org-database-limit.sql diff --git a/packages/bash-ast/__fixtures__/workflows/safegres-0112.sh b/packages/bash-ast/__fixtures__/workflows/safegres-0112.sh new file mode 100644 index 00000000..cd0ef80f --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/safegres-0112.sh @@ -0,0 +1,14 @@ +# Report-only for now — add `--fail-on-grade B` to gate merges, and +# `--fail-on-new-boundaries` to gate on new call-graph trust +# boundaries (diffed against the committed .safegres-callgraph.json; +# re-baseline with `safegres audit --write-baseline` to accept). +# Job summary: score + counts + call-graph stats + baseline diff. +# Full report goes to the build log. +safegres audit --database constructivedb --summary --baseline .safegres-callgraph.json | tee summary.txt +safegres audit --database constructivedb --no-color --call-graph +{ + echo '## Safegres security audit' + echo '```' + cat summary.txt + echo '```' +} >> "$GITHUB_STEP_SUMMARY" diff --git a/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0058.sh b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0058.sh new file mode 100644 index 00000000..4e411cf2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0058.sh @@ -0,0 +1,2 @@ +git config --global user.name "github-actions[bot]" +git config --global user.email "github-actions[bot]@users.noreply.github.com" diff --git a/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0095.sh b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0095.sh new file mode 100644 index 00000000..b52b92ca --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0095.sh @@ -0,0 +1,20 @@ +SDK_CHANGED="false" +APP_CHANGED="false" +AGENTS_CHANGED="false" +SDK_UNTRACKED=$(git ls-files --others --exclude-standard sdk/ | head -5) +APP_UNTRACKED=$(git ls-files --others --exclude-standard application/ | head -5) +AGENTS_UNTRACKED=$(git ls-files --others --exclude-standard .agents/ | head -5) +if ! git diff --quiet sdk/ || [ -n "$SDK_UNTRACKED" ]; then + SDK_CHANGED="true" +fi +if ! git diff --quiet application/ || [ -n "$APP_UNTRACKED" ]; then + APP_CHANGED="true" +fi +if ! git diff --quiet .agents/ || [ -n "$AGENTS_UNTRACKED" ]; then + AGENTS_CHANGED="true" +fi +if [ "$SDK_CHANGED" = "true" ] || [ "$APP_CHANGED" = "true" ] || [ "$AGENTS_CHANGED" = "true" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT +else + echo "has_changes=false" >> $GITHUB_OUTPUT +fi diff --git a/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0122.sh b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0122.sh new file mode 100644 index 00000000..b116403d --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0122.sh @@ -0,0 +1,41 @@ +BRANCH_NAME="schema-sdk-update/$(date -u +%Y%m%d-%H%M%S)" +git checkout -b "$BRANCH_NAME" +git add sdk/ application/ .agents/ +git commit -m "chore: regenerate schemas, SDKs, and introspection output + +Auto-generated by schema-sdk-update workflow. +Source: ${{ needs.generate.outputs.ref_sha }} (ref: ${{ inputs.ref || 'main' }})" + +git push origin "$BRANCH_NAME" + +PR_URL=$(gh pr create \ + --title "chore: regenerate schemas, SDKs, and introspection output" \ + --body "## Automated Schema Regeneration + +This PR was created by the [schema-sdk-update workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/schema-sdk-update.yml). + +### What was regenerated +- \`application/constructive/\` — introspection output +- \`sdk/constructive-schema/schemas/\` — GraphQL schema files + introspection JSON +- \`sdk/sdk/\` — new SDK +- \`sdk/constructive-cli/\` — CLI +- \`sdk/constructive-sdk/\` — legacy SDK +- \`.agents/\` — skill reference docs + +### Details +| Output | Changed | +|--------|---------| +| Introspection | ${{ needs.generate.outputs.has_introspection_changes }} | +| Schemas | ${{ needs.generate.outputs.has_schema_changes }} | +| SDKs + CLI | ${{ needs.generate.outputs.has_sdk_changes }} | +| Skills | ${{ needs.generate.outputs.has_agents_changes }} | + +Source commit: \`${{ needs.generate.outputs.ref_sha }}\` + +--- +*Auto-generated by constructive-db schema-sdk-update workflow*" \ + --base "${{ inputs.ref || 'main' }}" \ + --head "$BRANCH_NAME") + +echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT +echo "Created PR: $PR_URL" diff --git a/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0167.sh b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0167.sh new file mode 100644 index 00000000..668ecf0f --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/schema-sdk-update-0167.sh @@ -0,0 +1,3 @@ +echo "## Schema Update PR Created" >> $GITHUB_STEP_SUMMARY +echo "" >> $GITHUB_STEP_SUMMARY +echo "PR: ${{ steps.create_pr.outputs.pr_url }}" >> $GITHUB_STEP_SUMMARY diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0056.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0056.sh new file mode 100644 index 00000000..c54ff5fb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0056.sh @@ -0,0 +1,3 @@ +git config --global user.name "GitHub Actions" +git config --global user.email "actions@github.com" +git config --global --add safe.directory /__w/constructive-db/constructive-db diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0080.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0080.sh new file mode 100644 index 00000000..5b02f1a6 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0080.sh @@ -0,0 +1,2 @@ +cd compute +npx tsx ../compute/fun/cli/src/generate-standalone.ts --packages-only diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0108.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0108.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0108.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0113.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0113.sh new file mode 100644 index 00000000..10271ffb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0113.sh @@ -0,0 +1,2 @@ +cd packages/introspection +pnpm run generate:constructive diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0123.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0123.sh new file mode 100644 index 00000000..738a29e2 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0123.sh @@ -0,0 +1,2 @@ +cd application/constructive +pgpm package diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0132.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0132.sh new file mode 100644 index 00000000..03ec4142 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0132.sh @@ -0,0 +1,12 @@ +PATHS='application/constructive sdk/constructive-schema/meta-export-tables.json' +UNTRACKED=$(git ls-files --others --exclude-standard -- $PATHS) +if git diff --quiet -- $PATHS && [ -z "$UNTRACKED" ]; then + echo 'application/constructive matches its generators.' + exit 0 +fi +echo "::error::application/constructive is out of date. Regenerate it" \ + "on a runner — push a branch named '-regen' (see" \ + "AGENTS.md, 'Regenerate on CI, not locally') and commit the result." +git status --short -- $PATHS +git diff --stat -- $PATHS +exit 1 diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0173.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0173.sh new file mode 100644 index 00000000..c54ff5fb --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0173.sh @@ -0,0 +1,3 @@ +git config --global user.name "GitHub Actions" +git config --global user.email "actions@github.com" +git config --global --add safe.directory /__w/constructive-db/constructive-db diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0197.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0197.sh new file mode 100644 index 00000000..5b02f1a6 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0197.sh @@ -0,0 +1,2 @@ +cd compute +npx tsx ../compute/fun/cli/src/generate-standalone.ts --packages-only diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0225.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0225.sh new file mode 100644 index 00000000..e370ee0b --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0225.sh @@ -0,0 +1,2 @@ +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0230.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0230.sh new file mode 100644 index 00000000..70d9e320 --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0230.sh @@ -0,0 +1,2 @@ +cd packages/introspection +pnpm run generate:platform diff --git a/packages/bash-ast/__fixtures__/workflows/validate-introspection-0235.sh b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0235.sh new file mode 100644 index 00000000..6a15918d --- /dev/null +++ b/packages/bash-ast/__fixtures__/workflows/validate-introspection-0235.sh @@ -0,0 +1,9 @@ +UNTRACKED=$(git ls-files --others --exclude-standard platform-schema) +if git diff --quiet platform-schema && [ -z "$UNTRACKED" ]; then + echo "No drift in platform-schema" +else + echo "::error::published platform modules are out of date. Run 'pnpm run generate:platform' and commit the result." + git status --short platform-schema + git diff --stat platform-schema + exit 1 +fi diff --git a/packages/bash-ast/__tests__/corpus.test.ts b/packages/bash-ast/__tests__/corpus.test.ts new file mode 100644 index 00000000..333e9dbb --- /dev/null +++ b/packages/bash-ast/__tests__/corpus.test.ts @@ -0,0 +1,58 @@ +import { execFileSync } from 'child_process'; +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + +import { cleanTree, deparse, parse } from '../src'; + +/** + * Real `run:` blocks extracted from the GitHub Actions workflows of + * constructive-io/constructive-db. Every one of them is valid Bash + * (`bash -n`), so a parse failure or a changed AST is a bug in this package. + */ +const CORPUS_DIR = join(__dirname, '..', '__fixtures__', 'workflows'); + +/** + * Hard budget per parse: a pathological input must fail, not hang. + */ +const TIMEOUT_MS = 5000; + +const files = readdirSync(CORPUS_DIR).filter(file => file.endsWith('.sh')).sort(); + +/** + * `bash -n` is the only impartial judge of whether emitted text is still + * shell. Where bash is unavailable that check is skipped rather than faked. + */ +const hasBash = (() => { + try { + execFileSync('bash', ['-n'], { input: ':\n', stdio: ['pipe', 'ignore', 'ignore'] }); + return true; + } catch { + return false; + } +})(); + +function expectValidBash(source: string): void { + if (!hasBash) return; + execFileSync('bash', ['-n'], { input: source, stdio: ['pipe', 'ignore', 'pipe'] }); +} + +describe('workflow corpus', () => { + it('has the extracted blocks', () => { + expect(files.length).toBeGreaterThan(100); + }); + + describe.each(files)('%s', file => { + const source = readFileSync(join(CORPUS_DIR, file), 'utf8'); + + it('round trips without changing the AST, and deparses idempotently', () => { + const first = parse(source, { keepComments: true, timeoutMs: TIMEOUT_MS }); + const deparsed = deparse(first); + expectValidBash(deparsed); + + const second = parse(deparsed, { keepComments: true, timeoutMs: TIMEOUT_MS }); + + expect(cleanTree(second)).toEqual(cleanTree(first)); + expect(deparse(second)).toEqual(deparsed); + }); + }); +}); diff --git a/packages/bash-ast/__tests__/semantics.test.ts b/packages/bash-ast/__tests__/semantics.test.ts new file mode 100644 index 00000000..5589030a --- /dev/null +++ b/packages/bash-ast/__tests__/semantics.test.ts @@ -0,0 +1,183 @@ +import { execFileSync } from 'child_process'; + +import { cleanTree, deparse, parse } from '../src'; +import { BraceGroup, Pipeline, Script, SimpleCommand } from '../src/types'; + +const TIMEOUT_MS = 5000; + +function ast(source: string): Script { + return parse(source, { keepComments: true, timeoutMs: TIMEOUT_MS }); +} + +function roundTrip(source: string): string { + const first = ast(source); + const emitted = deparse(first); + const second = ast(emitted); + + expect(cleanTree(second)).toEqual(cleanTree(first)); + expect(deparse(second)).toEqual(emitted); + + return emitted; +} + +/** + * Run a script and its deparsed form, and require identical behaviour. This is + * the property that matters: the AST is only a means to it. + */ +function expectSameBehaviour(source: string): void { + const emitted = roundTrip(source); + + const run = (script: string) => + execFileSync('bash', ['-c', script], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + + expect(run(emitted)).toEqual(run(source)); +} + +describe('here-documents', () => { + it('treats the body as opaque text rather than shell syntax', () => { + const script = ast('cat < item.type === 'Redirect'); + + expect(redirect).toMatchObject({ + op: '<<', + heredoc: { delimiter: 'EOF', content: '|\n' } + }); + }); + + it('round trips a body full of shell metacharacters', () => { + expectSameBehaviour('cat < { + const emitted = roundTrip("cat <<'EOF'\n$HOME\nEOF\n"); + expect(emitted).toContain("<<'EOF'"); + expectSameBehaviour("cat <<'EOF'\n$HOME\nEOF\n"); + }); + + it('keeps the body attached when the redirection is not last', () => { + expectSameBehaviour('cat < { + expect(() => ast('cat < { + expect(() => parse('echo hi\n', { timeoutMs: -1 })).toThrow(/exceeded/); + }); +}); + +describe('asynchronous commands', () => { + it('keeps `&`, which is what stops the command from blocking the script', () => { + const emitted = roundTrip('kubectl proxy --port=8001 &\nsleep 3\n'); + expect(emitted).toEqual('kubectl proxy --port=8001 &\nsleep 3'); + expect(ast(emitted).commands[0].async).toBe(true); + }); + + it('marks the command, not the list', () => { + const script = ast('a &\nb\n'); + expect(script.commands.map(command => command.async)).toEqual([true, undefined]); + }); + + it('keeps `&` on a subshell and on a pipeline', () => { + expect(roundTrip('(cd dir && make) &\n')).toEqual('(cd dir && make) &'); + expect(roundTrip('a | b &\n')).toEqual('a | b &'); + }); +}); + +describe('grouping', () => { + it('keeps a brace group after `||` grouped', () => { + const source = '[ -n "$id" ] || { echo "nothing to compare"; exit 0; }\necho reached\n'; + const emitted = roundTrip(source); + + expect(emitted).toContain('|| { echo "nothing to compare"; exit 0; }'); + expectSameBehaviour(source); + }); + + it('keeps a brace group as one command in a pipeline', () => { + const source = '{\n echo a\n echo b\n} | tee /dev/null\n'; + const script = ast(source); + const pipeline = script.commands[0] as Pipeline; + + expect(pipeline.type).toBe('Pipeline'); + expect(pipeline.commands[0].type).toBe('BraceGroup'); + expect((pipeline.commands[0] as BraceGroup).list.commands).toHaveLength(2); + + expectSameBehaviour(source); + }); + + it('keeps redirections attached to the group', () => { + expect(roundTrip('{ echo a; echo b; } > /dev/null\n')).toEqual('{ echo a; echo b; } >/dev/null'); + }); + + it('keeps a subshell grouped', () => { + expectSameBehaviour('(cd /tmp && pwd) | cat\n'); + }); +}); + +describe('assignment position', () => { + it('leaves `NAME=value` as an argument when it is not in assignment position', () => { + const script = ast('psql --dbname postgres \\\n --set ON_ERROR_STOP=1 \\\n -f file.sql\n'); + + expect(script.commands).toHaveLength(1); + + const command = script.commands[0] as SimpleCommand; + expect(command.name?.text).toBe('psql'); + expect(command.suffix?.map(item => (item.type === 'Word' ? item.text : item.type))) + .toEqual(['--dbname', 'postgres', '--set', 'ON_ERROR_STOP=1', '-f', 'file.sql']); + }); + + it('parses an assignment inside a function body', () => { + const emitted = roundTrip('check() {\n local label="$1"\n}\n'); + expect(emitted).toEqual('check() { local label="$1"; }'); + }); + + it('treats operands of the declaration builtins as assignments', () => { + const command = ast('export FOO=bar BAZ=qux\n').commands[0] as SimpleCommand; + expect(command.suffix?.map(item => item.type)).toEqual(['AssignmentWord', 'AssignmentWord']); + }); + + it('keeps an assignment prefix on the command it applies to', () => { + const command = ast('FOO=bar run it\n').commands[0] as SimpleCommand; + expect(command.prefix?.map(item => item.text)).toEqual(['FOO=bar']); + expect(command.name?.text).toBe('run'); + }); + + it('keeps an assignment at the start of a case item body', () => { + expectSameBehaviour('case x in\n x)\n mode=full\n echo "$mode"\n ;;\nesac\n'); + }); +}); + +describe('words the lexer used to split', () => { + it('keeps a process substitution whole', () => { + const command = ast('diff <(echo a) <(echo b)\n').commands[0] as SimpleCommand; + expect(command.suffix?.map(item => (item.type === 'Word' ? item.text : item.type))) + .toEqual(['<(echo a)', '<(echo b)']); + + expectSameBehaviour('diff <(echo a) <(echo b) || true\n'); + }); + + it('keeps a braced argument whole', () => { + const command = ast('kubectl get ns -o jsonpath={.metadata.labels}\n').commands[0] as SimpleCommand; + expect(command.suffix?.map(item => (item.type === 'Word' ? item.text : item.type))) + .toEqual(['get', 'ns', '-o', 'jsonpath={.metadata.labels}']); + }); +}); + +describe('comments', () => { + it('drops comments by default, preserving the old behaviour', () => { + expect(parse('# why this exists\necho hi\n').commands.map(command => command.type)) + .toEqual(['SimpleCommand']); + }); + + it('retains comments when asked', () => { + const emitted = roundTrip('# why this exists\necho hi\n'); + expect(emitted).toEqual('# why this exists\necho hi'); + }); + + it('retains a comment inside a body', () => { + const emitted = roundTrip('if true; then\n # explain\n echo hi\nfi\n'); + expect(emitted).toEqual('if true; then\n# explain\necho hi\nfi'); + }); +}); diff --git a/packages/bash-ast/src/deparser.ts b/packages/bash-ast/src/deparser.ts index 85b739d2..cdf58c1b 100644 --- a/packages/bash-ast/src/deparser.ts +++ b/packages/bash-ast/src/deparser.ts @@ -1,11 +1,14 @@ import { AssignmentWord, + BraceGroup, CaseClause, CaseItem, Command, + Comment, CompoundList, ForClause, FunctionDefinition, + HereDoc, IfClause, LogicalExpression, Node, @@ -27,12 +30,27 @@ export interface DeparserOptions { indent?: string; } +/** + * A deparsed list of commands. `multiline` is set when the list cannot be + * collapsed onto one line — because a statement is backgrounded, carries a + * here-document body, or is a comment. + */ +interface DeparsedList { + text: string; + multiline: boolean; +} + /** * Bash Deparser - converts AST back to source */ export class Deparser { private options: DeparserOptions; - private indentLevel: number = 0; + + /** + * Here-document bodies owed by the statement currently being deparsed. They + * are emitted after the line that opened them. + */ + private heredocs: string[] = []; constructor(options: DeparserOptions = {}) { this.options = { @@ -50,57 +68,54 @@ export class Deparser { switch (node.type) { case 'Script': return this.deparseScript(node as Script); - case 'SimpleCommand': - return this.deparseSimpleCommand(node as SimpleCommand); - case 'Pipeline': - return this.deparsePipeline(node as Pipeline); - case 'LogicalExpression': - return this.deparseLogicalExpression(node as LogicalExpression); - case 'Subshell': - return this.deparseSubshell(node as Subshell); - case 'CompoundList': - return this.deparseCompoundList(node as CompoundList); - case 'IfClause': - return this.deparseIf(node as IfClause); - case 'WhileClause': - return this.deparseWhile(node as WhileClause); - case 'UntilClause': - return this.deparseUntil(node as UntilClause); - case 'ForClause': - return this.deparseFor(node as ForClause); - case 'CaseClause': - return this.deparseCase(node as CaseClause); - case 'FunctionDefinition': - return this.deparseFunction(node as FunctionDefinition); case 'Word': return this.deparseWord(node as Word); case 'AssignmentWord': return (node as AssignmentWord).text; case 'Redirect': return this.deparseRedirect(node as Redirect); + case 'CaseItem': + return this.deparseCaseItem(node as CaseItem); + case 'HereDoc': { + const heredoc = node as HereDoc; + return heredoc.content + heredoc.delimiter; + } default: - throw new Error(`Unknown node type: ${(node as any).type}`); + return this.emitStatement(node as Command); } } throw new Error('Invalid node'); } - /** - * Get current indentation - */ - private getIndent(): string { - return this.options.indent!.repeat(this.indentLevel); - } - /** * Deparse script */ private deparseScript(script: Script): string { return script.commands - .map(cmd => this.deparseCommand(cmd)) + .map(cmd => this.emitStatement(cmd)) .join(this.options.newline); } + /** + * Deparse one statement: the command itself, its `&`, and the bodies of any + * here-documents it opened. + */ + private emitStatement(command: Command): string { + const owed = this.heredocs; + this.heredocs = []; + + let text = this.deparseCommand(command); + if (command.async) { + text += ' &'; + } + if (this.heredocs.length > 0) { + text += '\n' + this.heredocs.join('\n'); + } + + this.heredocs = owed; + return text; + } + /** * Deparse any command */ @@ -114,8 +129,12 @@ export class Deparser { return this.deparseLogicalExpression(command); case 'Subshell': return this.deparseSubshell(command); + case 'BraceGroup': + return this.deparseBraceGroup(command); case 'CompoundList': - return this.deparseCompoundList(command); + return this.deparseList(command).text; + case 'Comment': + return this.deparseComment(command); case 'IfClause': return this.deparseIf(command); case 'WhileClause': @@ -129,10 +148,23 @@ export class Deparser { case 'FunctionDefinition': return this.deparseFunction(command); default: - throw new Error(`Unknown command type: ${(command as any).type}`); + throw new Error(`Unknown command type: ${(command as { type: string }).type}`); } } + /** + * Deparse a list of commands, keeping it on one line when that is safe + */ + private deparseList(list: CompoundList): DeparsedList { + const parts = list.commands.map(cmd => this.emitStatement(cmd)); + const multiline = parts.some(part => part.includes('\n') || part.endsWith('&')) || + list.commands.some(cmd => cmd.type === 'Comment'); + return { + text: parts.join(multiline ? '\n' : '; '), + multiline + }; + } + /** * Deparse simple command */ @@ -154,8 +186,8 @@ export class Deparser { // Suffix (arguments and redirections) if (cmd.suffix) { for (const item of cmd.suffix) { - if (item.type === 'Word') { - parts.push(this.deparseWord(item)); + if (item.type === 'Word' || item.type === 'AssignmentWord') { + parts.push(item.text); } else if (item.type === 'Redirect') { parts.push(this.deparseRedirect(item)); } @@ -173,7 +205,15 @@ export class Deparser { } /** - * Deparse redirect + * Deparse comment + */ + private deparseComment(comment: Comment): string { + return `#${comment.text}`; + } + + /** + * Deparse redirect. A here-document's body is owed to the end of the + * statement, so it is buffered rather than returned. */ private deparseRedirect(redirect: Redirect): string { let result = ''; @@ -186,11 +226,29 @@ export class Deparser { if (redirect.file) { result += this.deparseWord(redirect.file); + } else if (redirect.heredoc) { + result += redirect.heredoc.quoted + ? `'${redirect.heredoc.delimiter}'` + : redirect.heredoc.delimiter; + } + + if (redirect.heredoc) { + this.heredocs.push(redirect.heredoc.content + redirect.heredoc.delimiter); } return result; } + /** + * Deparse the redirections attached to a compound command + */ + private deparseRedirects(redirects?: Redirect[]): string { + if (!redirects || redirects.length === 0) { + return ''; + } + return ' ' + redirects.map(redirect => this.deparseRedirect(redirect)).join(' '); + } + /** * Deparse pipeline */ @@ -223,124 +281,151 @@ export class Deparser { * Deparse subshell */ private deparseSubshell(subshell: Subshell): string { - const list = this.deparseCompoundListInline(subshell.list); - return `(${list})`; + const list = this.deparseList(subshell.list); + const body = list.multiline ? `(\n${list.text}\n)` : `(${list.text})`; + return body + this.deparseRedirects(subshell.redirects); } /** - * Deparse compound list + * Deparse brace group. The braces are always emitted: they are what keeps + * the group a single command inside a pipeline or after `&&`/`||`. */ - private deparseCompoundList(list: CompoundList): string { - return list.commands - .map(cmd => this.deparseCommand(cmd)) - .join('; '); + private deparseBraceGroup(group: BraceGroup): string { + const list = this.deparseList(group.list); + const body = list.multiline ? `{\n${list.text}\n}` : `{ ${list.text}; }`; + return body + this.deparseRedirects(group.redirects); } /** - * Deparse compound list inline (for subshells, etc.) + * Render ` `, breaking the line when + * the list cannot be inlined */ - private deparseCompoundListInline(list: CompoundList): string { - return list.commands - .map(cmd => this.deparseCommand(cmd)) - .join('; '); + private clause(keyword: string, list: DeparsedList, terminator: string): string { + if (list.multiline) { + return `${keyword} ${list.text}\n${terminator}`; + } + return `${keyword} ${list.text}; ${terminator}`; } /** * Deparse if clause */ - private deparseIf(ifClause: IfClause): string { - const parts: string[] = []; + private deparseIf(ifClause: IfClause, keyword: string = 'if'): string { + const condition = this.deparseList(ifClause.condition); + const then = this.deparseList(ifClause.then); - parts.push('if'); - parts.push(this.deparseCompoundListInline(ifClause.condition)); - parts.push('; then'); - parts.push(this.deparseCompoundListInline(ifClause.then)); + let elseText: string | undefined; + let elseMultiline = false; if (ifClause.else) { if (ifClause.else.type === 'IfClause') { - // elif - parts.push('; el' + this.deparseIf(ifClause.else).substring(0)); - return parts.join(' ').replace('; elif', '; elif').replace('; fi; fi', '; fi'); + elseText = this.deparseIf(ifClause.else, 'elif'); + elseMultiline = elseText.includes('\n'); } else { - parts.push('; else'); - parts.push(this.deparseCompoundListInline(ifClause.else)); + const elseList = this.deparseList(ifClause.else); + elseText = elseList.text; + elseMultiline = elseList.multiline; } } - parts.push('; fi'); + const multiline = condition.multiline || then.multiline || elseMultiline; + const isElif = keyword === 'elif'; - return parts.join(' '); + if (!multiline) { + const parts = [`${this.clause(keyword, condition, 'then')} ${then.text}`]; + if (elseText !== undefined) { + parts.push(ifClause.else!.type === 'IfClause' ? elseText : `else ${elseText}`); + } + const body = parts.join('; '); + return isElif ? body : `${body}; fi`; + } + + const parts = [`${this.clause(keyword, condition, 'then')}\n${then.text}`]; + if (elseText !== undefined) { + parts.push(ifClause.else!.type === 'IfClause' ? elseText : `else\n${elseText}`); + } + const body = parts.join('\n'); + return isElif ? body : `${body}\nfi`; } /** * Deparse while clause */ private deparseWhile(whileClause: WhileClause): string { - const condition = this.deparseCompoundListInline(whileClause.condition); - const body = this.deparseCompoundListInline(whileClause.body); - return `while ${condition}; do ${body}; done`; + return this.deparseLoop('while', whileClause.condition, whileClause.body) + + this.deparseRedirects(whileClause.redirects); } /** * Deparse until clause */ private deparseUntil(untilClause: UntilClause): string { - const condition = this.deparseCompoundListInline(untilClause.condition); - const body = this.deparseCompoundListInline(untilClause.body); - return `until ${condition}; do ${body}; done`; + return this.deparseLoop('until', untilClause.condition, untilClause.body) + + this.deparseRedirects(untilClause.redirects); + } + + /** + * Deparse `while`/`until` + */ + private deparseLoop(keyword: string, conditionList: CompoundList, bodyList: CompoundList): string { + const condition = this.deparseList(conditionList); + const body = this.deparseList(bodyList); + + if (!condition.multiline && !body.multiline) { + return `${keyword} ${condition.text}; do ${body.text}; done`; + } + + return `${this.clause(keyword, condition, 'do')}\n${body.text}\ndone`; } /** * Deparse for clause */ private deparseFor(forClause: ForClause): string { - const parts: string[] = ['for', forClause.name]; + let head = `for ${forClause.name}`; if (forClause.wordlist && forClause.wordlist.length > 0) { - parts.push('in'); - parts.push(forClause.wordlist.map(w => this.deparseWord(w)).join(' ')); + head += ` in ${forClause.wordlist.map(word => this.deparseWord(word)).join(' ')}`; } - parts.push(';'); - parts.push('do'); - parts.push(this.deparseCompoundListInline(forClause.body)); - parts.push(';'); - parts.push('done'); + const body = this.deparseList(forClause.body); + const text = body.multiline + ? `${head}; do\n${body.text}\ndone` + : `${head}; do ${body.text}; done`; - return parts.join(' '); + return text + this.deparseRedirects(forClause.redirects); } /** * Deparse case clause */ private deparseCase(caseClause: CaseClause): string { - const parts: string[] = []; + const items = caseClause.cases.map(item => this.deparseCaseItem(item)); + const multiline = items.some(item => item.includes('\n')); + const head = `case ${this.deparseWord(caseClause.word)} in`; - parts.push('case'); - parts.push(this.deparseWord(caseClause.word)); - parts.push('in'); + const text = multiline + ? `${head}\n${items.join('\n')}\nesac` + : [head, ...items, 'esac'].join(' '); - for (const caseItem of caseClause.cases) { - parts.push(this.deparseCaseItem(caseItem)); - } - - parts.push('esac'); - - return parts.join(' '); + return text + this.deparseRedirects(caseClause.redirects); } /** * Deparse case item */ private deparseCaseItem(caseItem: CaseItem): string { - const pattern = caseItem.pattern.map(w => this.deparseWord(w)).join('|'); + const pattern = caseItem.pattern.map(word => this.deparseWord(word)).join('|'); let result = `${pattern})`; if (caseItem.body) { - result += ' ' + this.deparseCompoundListInline(caseItem.body); + const body = this.deparseList(caseItem.body); + result += body.multiline ? `\n${body.text}\n` : ` ${body.text} `; + } else { + result += ' '; } - result += ' ;;'; + result += ';;'; return result; } @@ -351,7 +436,7 @@ export class Deparser { private deparseFunction(func: FunctionDefinition): string { const body = func.body.type === 'Subshell' ? this.deparseSubshell(func.body) - : `{ ${this.deparseCompoundListInline(func.body)}; }`; + : this.deparseBraceGroup(func.body); return `${func.name}() ${body}`; } diff --git a/packages/bash-ast/src/index.ts b/packages/bash-ast/src/index.ts index d0717ee8..bc096cab 100644 --- a/packages/bash-ast/src/index.ts +++ b/packages/bash-ast/src/index.ts @@ -2,7 +2,7 @@ export * from './types'; // Lexer -export { Lexer, Token, TokenType } from './lexer'; +export { Lexer, LexerOptions, Token, TokenType } from './lexer'; // Parser export { parse,Parser, ParserOptions } from './parser'; diff --git a/packages/bash-ast/src/lexer.ts b/packages/bash-ast/src/lexer.ts index e4c9ab6e..34e5fb9f 100644 --- a/packages/bash-ast/src/lexer.ts +++ b/packages/bash-ast/src/lexer.ts @@ -1,4 +1,4 @@ -import { Position, Range } from './types'; +import { HereDoc, Position, Range } from './types'; /** * Token types for bash lexer @@ -55,6 +55,7 @@ export enum TokenType { // Special IO_NUMBER = 'IO_NUMBER', + COMMENT = 'COMMENT', EOF = 'EOF', WHITESPACE = 'WHITESPACE', } @@ -66,6 +67,27 @@ export interface Token { type: TokenType; value: string; range: Range; + /** + * Body of the here-document introduced by this token. Only ever set on + * `<<` / `<<-` tokens, and only once the delimiter line has been read. + */ + heredoc?: HereDoc; +} + +/** + * Lexer options + */ +export interface LexerOptions { + /** + * Emit COMMENT tokens instead of discarding comments. + */ + keepComments?: boolean; + + /** + * Wall-clock budget for tokenizing, in milliseconds. Exceeding it throws + * instead of letting a pathological input run unbounded. + */ + timeoutMs?: number; } /** @@ -89,6 +111,31 @@ const RESERVED_WORDS: Record = { '!': TokenType.BANG, }; +/** + * Builtins whose operands are assignments (`export FOO=bar`) + */ +const DECLARATION_BUILTINS = new Set([ + 'export', + 'local', + 'declare', + 'readonly', + 'typeset', +]); + +const ASSIGNMENT_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(\[[^\]]*\])?\+?=/; + +/** + * A here-document whose delimiter has been seen but whose body has not been + * read yet. The body starts on the line following the operator, so the token + * is kept around and filled in once the newline is consumed. + */ +interface PendingHereDoc { + token: Token; + dash: boolean; + delimiter?: string; + quoted?: boolean; +} + /** * Bash Lexer */ @@ -97,9 +144,24 @@ export class Lexer { private pos: number = 0; private line: number = 1; private column: number = 0; + private options: LexerOptions; + + /** + * True when the next word would be the command name, i.e. when a leading + * `NAME=value` word is an assignment rather than an ordinary argument. + */ + private commandPosition: boolean = true; - constructor(input: string) { + /** + * True while lexing the arguments of `export`/`local`/`declare`/`readonly`. + */ + private declarationCommand: boolean = false; + + private pendingHereDocs: PendingHereDoc[] = []; + + constructor(input: string, options: LexerOptions = {}) { this.input = input; + this.options = options; } /** @@ -146,7 +208,7 @@ export class Lexer { * Check if character is whitespace (not newline) */ private isWhitespace(char: string): boolean { - return char === ' ' || char === '\t'; + return char === ' ' || char === '\t' || char === '\r'; } /** @@ -157,7 +219,7 @@ export class Lexer { } /** - * Check if character can start a word + * Check if character can be part of a word */ private isWordChar(char: string): boolean { return char !== '' && @@ -169,17 +231,40 @@ export class Lexer { char !== '(' && char !== ')' && char !== '<' && - char !== '>' && - char !== '{' && - char !== '}'; + char !== '>'; + } + + /** + * Check if a character terminates a token. `{` and `}` are reserved words + * only when they stand alone; elsewhere they are ordinary word characters + * (`--set jsonpath={.metadata.name}`, `{1..3}`). + */ + private isTokenDelimiter(char: string): boolean { + return char === '' || + this.isWhitespace(char) || + char === '\n' || + char === ';' || + char === '&' || + char === '|' || + char === '(' || + char === ')' || + char === '<' || + char === '>'; } /** - * Skip whitespace (not newlines) + * Skip whitespace and line continuations (not newlines) */ private skipWhitespace(): void { - while (!this.isAtEnd() && this.isWhitespace(this.peek())) { - this.advance(); + while (!this.isAtEnd()) { + if (this.isWhitespace(this.peek())) { + this.advance(); + } else if (this.peek() === '\\' && this.peek(1) === '\n') { + this.advance(); + this.advance(); + } else { + break; + } } } @@ -230,6 +315,12 @@ export class Lexer { } else if (char === '"') { result += this.readDoubleQuoted(); } else if (char === '\\') { + if (this.peek(1) === '\n') { + // line continuation: removed, the word continues on the next line + this.advance(); + this.advance(); + continue; + } result += this.advance(); // backslash if (!this.isAtEnd()) { result += this.advance(); // escaped char @@ -238,6 +329,8 @@ export class Lexer { result += this.readExpansion(); } else if (char === '`') { result += this.readBacktickSubstitution(); + } else if ((char === '{' || char === '}') && result === '' && this.isTokenDelimiter(this.peek(1))) { + break; } else { result += this.advance(); } @@ -245,6 +338,29 @@ export class Lexer { return result; } + /** + * Read a balanced parenthesised run, including the parens + */ + private readBalancedParens(): string { + let result = this.advance(); // ( + let depth = 1; + while (!this.isAtEnd() && depth > 0) { + const char = this.peek(); + if (char === "'") { + result += this.readSingleQuoted(); + continue; + } + if (char === '"') { + result += this.readDoubleQuoted(); + continue; + } + if (char === '(') depth++; + if (char === ')') depth--; + result += this.advance(); + } + return result; + } + /** * Read an expansion ($VAR, ${VAR}, $(cmd), $((expr))) */ @@ -263,13 +379,7 @@ export class Lexer { } } else { // Command substitution $(cmd) - result += this.advance(); // ( - let depth = 1; - while (!this.isAtEnd() && depth > 0) { - if (this.peek() === '(') depth++; - if (this.peek() === ')') depth--; - result += this.advance(); - } + result += this.readBalancedParens(); } } else if (this.peek() === '{') { // Parameter expansion ${VAR} @@ -311,18 +421,134 @@ export class Lexer { } /** - * Read a comment + * Read a comment, excluding the leading `#` */ - private readComment(): void { + private readComment(): string { + this.advance(); // # + let result = ''; + while (!this.isAtEnd() && this.peek() !== '\n') { + result += this.advance(); + } + return result; + } + + /** + * Strip one level of quoting from a here-document delimiter + */ + private unquoteDelimiter(word: string): { delimiter: string; quoted: boolean } { + if (word.length >= 2 && ((word.startsWith("'") && word.endsWith("'")) || (word.startsWith('"') && word.endsWith('"')))) { + return { delimiter: word.slice(1, -1), quoted: true }; + } + if (word.includes('\\')) { + return { delimiter: word.replace(/\\(.)/g, '$1'), quoted: true }; + } + return { delimiter: word, quoted: false }; + } + + /** + * Read one raw line, consuming its terminating newline + */ + private readRawLine(): { text: string; terminated: boolean } { + let text = ''; while (!this.isAtEnd() && this.peek() !== '\n') { + text += this.advance(); + } + const terminated = this.peek() === '\n'; + if (terminated) { this.advance(); } + return { text, terminated }; + } + + /** + * Read the bodies of every here-document pending on the line just ended. + * A body is opaque text: it is never fed through the command lexer. + */ + private readPendingHereDocBodies(): void { + const pending = this.pendingHereDocs; + this.pendingHereDocs = []; + + for (const entry of pending) { + if (entry.delimiter === undefined) continue; + + let content = ''; + for (;;) { + if (this.isAtEnd()) break; + const startOfLine = this.pos; + const { text, terminated } = this.readRawLine(); + const candidate = entry.dash ? text.replace(/^[\t]+/, '') : text; + if (candidate === entry.delimiter) { + break; + } + if (!terminated && this.isAtEnd() && startOfLine === this.pos) { + break; + } + content += text + '\n'; + if (!terminated) break; + } + + entry.token.heredoc = { + type: 'HereDoc', + delimiter: entry.delimiter, + content, + quoted: entry.quoted || undefined + }; + } + } + + /** + * Track whether the next word sits in command (assignment) position. `)` is + * in the list because it closes a case pattern, whose body starts a command. + */ + private updateContext(token: Token): void { + switch (token.type) { + case TokenType.NEWLINE: + case TokenType.SEMI: + case TokenType.DSEMI: + case TokenType.AMP: + case TokenType.PIPE: + case TokenType.AND_IF: + case TokenType.OR_IF: + case TokenType.LBRACE: + case TokenType.LPAREN: + case TokenType.RPAREN: + case TokenType.BANG: + case TokenType.IF: + case TokenType.THEN: + case TokenType.ELSE: + case TokenType.ELIF: + case TokenType.WHILE: + case TokenType.UNTIL: + case TokenType.DO: + this.commandPosition = true; + this.declarationCommand = false; + break; + case TokenType.ASSIGNMENT_WORD: + case TokenType.COMMENT: + break; + case TokenType.WORD: + if (this.commandPosition && DECLARATION_BUILTINS.has(token.value)) { + this.declarationCommand = true; + } + this.commandPosition = false; + break; + default: + this.commandPosition = false; + this.declarationCommand = false; + break; + } } /** * Get next token */ nextToken(): Token { + const token = this.scanToken(); + this.updateContext(token); + return token; + } + + private scanToken(): Token { this.skipWhitespace(); if (this.isAtEnd()) { @@ -338,18 +564,29 @@ export class Lexer { // Comment if (char === '#') { - this.readComment(); - return this.nextToken(); + const text = this.readComment(); + if (this.options.keepComments) { + return { + type: TokenType.COMMENT, + value: text, + range: { start, end: this.getPosition() } + }; + } + return this.scanToken(); } - // Newline + // Newline — here-document bodies start right after it if (char === '\n') { this.advance(); - return { + const token: Token = { type: TokenType.NEWLINE, value: '\n', range: { start, end: this.getPosition() } }; + if (this.pendingHereDocs.length > 0) { + this.readPendingHereDocBodies(); + } + return token; } // Operators @@ -368,6 +605,15 @@ export class Lexer { this.advance(); return { type: TokenType.AND_IF, value: '&&', range: { start, end: this.getPosition() } }; } + if (this.peek() === '>') { + // `&>file` — redirect both streams; lexed as a word-carrying redirect + this.advance(); + if (this.peek() === '>') { + this.advance(); + return { type: TokenType.WORD, value: '&>>', range: { start, end: this.getPosition() } }; + } + return { type: TokenType.WORD, value: '&>', range: { start, end: this.getPosition() } }; + } return { type: TokenType.AMP, value: '&', range: { start, end: this.getPosition() } }; } @@ -380,6 +626,13 @@ export class Lexer { return { type: TokenType.SEMI, value: ';', range: { start, end: this.getPosition() } }; } + // Process substitution — a word, not a redirection + if ((char === '<' || char === '>') && this.peek(1) === '(') { + const op = this.advance(); + const body = this.readBalancedParens(); + return { type: TokenType.WORD, value: op + body, range: { start, end: this.getPosition() } }; + } + // Redirections if (char === '<') { this.advance(); @@ -387,13 +640,17 @@ export class Lexer { this.advance(); if (this.peek() === '-') { this.advance(); - return { type: TokenType.DLESSDASH, value: '<<-', range: { start, end: this.getPosition() } }; + const token: Token = { type: TokenType.DLESSDASH, value: '<<-', range: { start, end: this.getPosition() } }; + this.pendingHereDocs.push({ token, dash: true }); + return token; } if (this.peek() === '<') { this.advance(); return { type: TokenType.TLESS, value: '<<<', range: { start, end: this.getPosition() } }; } - return { type: TokenType.DLESS, value: '<<', range: { start, end: this.getPosition() } }; + const token: Token = { type: TokenType.DLESS, value: '<<', range: { start, end: this.getPosition() } }; + this.pendingHereDocs.push({ token, dash: false }); + return token; } if (this.peek() === '&') { this.advance(); @@ -423,6 +680,20 @@ export class Lexer { return { type: TokenType.GREAT, value: '>', range: { start, end: this.getPosition() } }; } + // Arithmetic command `((expr))` — a word, not two subshells + if (char === '(' && this.peek(1) === '(') { + const text = this.readBalancedParens(); + if (text.startsWith('((') && text.endsWith('))')) { + return { type: TokenType.WORD, value: text, range: { start, end: this.getPosition() } }; + } + // not an arithmetic command after all: re-lex what we consumed + this.pos = start.offset; + this.line = start.line; + this.column = start.column; + this.advance(); + return { type: TokenType.LPAREN, value: '(', range: { start, end: this.getPosition() } }; + } + // Grouping if (char === '(') { this.advance(); @@ -434,12 +705,12 @@ export class Lexer { return { type: TokenType.RPAREN, value: ')', range: { start, end: this.getPosition() } }; } - if (char === '{') { + if (char === '{' && this.isTokenDelimiter(this.peek(1))) { this.advance(); return { type: TokenType.LBRACE, value: '{', range: { start, end: this.getPosition() } }; } - if (char === '}') { + if (char === '}' && this.isTokenDelimiter(this.peek(1))) { this.advance(); return { type: TokenType.RBRACE, value: '}', range: { start, end: this.getPosition() } }; } @@ -451,17 +722,31 @@ export class Lexer { } // Word - const word = this.readWord(); - const end = this.getPosition(); + let word = this.readWord(); // Check for reserved words if (word in RESERVED_WORDS) { - return { type: RESERVED_WORDS[word], value: word, range: { start, end } }; + return { type: RESERVED_WORDS[word], value: word, range: { start, end: this.getPosition() } }; } - // Check for assignment word (NAME=VALUE) - if (word.includes('=') && /^[a-zA-Z_][a-zA-Z0-9_]*=/.test(word)) { - return { type: TokenType.ASSIGNMENT_WORD, value: word, range: { start, end } }; + // Assignment words only exist in assignment position: before the command + // name, or as an operand of export/local/declare/readonly. + if ((this.commandPosition || this.declarationCommand) && ASSIGNMENT_RE.test(word)) { + if (word.endsWith('=') && this.peek() === '(') { + // array assignment: arr=(a b c) + word += this.readBalancedParens(); + } + return { type: TokenType.ASSIGNMENT_WORD, value: word, range: { start, end: this.getPosition() } }; + } + + const end = this.getPosition(); + + // A here-document delimiter is the word right after the operator + const awaiting = this.pendingHereDocs.find(entry => entry.delimiter === undefined); + if (awaiting) { + const { delimiter, quoted } = this.unquoteDelimiter(word); + awaiting.delimiter = delimiter; + awaiting.quoted = quoted; } return { type: TokenType.WORD, value: word, range: { start, end } }; @@ -471,9 +756,16 @@ export class Lexer { * Tokenize entire input */ tokenize(): Token[] { + const deadline = this.options.timeoutMs === undefined + ? undefined + : Date.now() + this.options.timeoutMs; + const tokens: Token[] = []; let token: Token; do { + if (deadline !== undefined && Date.now() > deadline) { + throw new Error(`bash-ast: tokenizing exceeded ${this.options.timeoutMs}ms at line ${this.line}, column ${this.column}`); + } token = this.nextToken(); tokens.push(token); } while (token.type !== TokenType.EOF); diff --git a/packages/bash-ast/src/parser.ts b/packages/bash-ast/src/parser.ts index 7c205906..46687225 100644 --- a/packages/bash-ast/src/parser.ts +++ b/packages/bash-ast/src/parser.ts @@ -1,9 +1,11 @@ import { Lexer, Token, TokenType } from './lexer'; import { AssignmentWord, + BraceGroup, CaseClause, CaseItem, Command, + Comment, CompoundList, ForClause, FunctionDefinition, @@ -22,7 +24,16 @@ import { * Parser options */ export interface ParserOptions { - // Reserved for future options + /** + * Keep comments in the AST as `Comment` nodes instead of discarding them. + */ + keepComments?: boolean; + + /** + * Wall-clock budget for a single `parse()`, in milliseconds. Exceeding it + * throws, so a pathological input is a loud failure rather than a hang. + */ + timeoutMs?: number; } /** @@ -32,6 +43,7 @@ export class Parser { private tokens: Token[] = []; private pos: number = 0; private options: ParserOptions; + private deadline?: number; constructor(options: ParserOptions = {}) { this.options = options; @@ -41,20 +53,41 @@ export class Parser { * Parse bash source into AST */ parse(source: string): Script { - const lexer = new Lexer(source); + const lexer = new Lexer(source, { + keepComments: this.options.keepComments, + timeoutMs: this.options.timeoutMs + }); this.tokens = lexer.tokenize(); this.pos = 0; + this.deadline = this.options.timeoutMs === undefined + ? undefined + : Date.now() + this.options.timeoutMs; const commands: Command[] = []; this.skipNewlines(); while (!this.isAtEnd()) { - const command = this.parseCommand(); - if (command) { - commands.push(command); + this.checkDeadline(); + const before = this.pos; + + if (this.check(TokenType.COMMENT)) { + commands.push(this.parseComment()); + } else { + const command = this.parseCommand(); + if (command) { + this.applyAsync(command); + commands.push(command); + } } + this.skipNewlinesAndSeparators(); + + if (this.pos === before) { + // Nothing was consumed: the token cannot start a command. Fail loudly + // rather than spinning forever. + throw new Error(this.unexpected()); + } } return { @@ -63,6 +96,23 @@ export class Parser { }; } + /** + * Fail if the parse budget is spent + */ + private checkDeadline(): void { + if (this.deadline !== undefined && Date.now() > this.deadline) { + throw new Error(`bash-ast: parsing exceeded ${this.options.timeoutMs}ms at ${this.unexpected()}`); + } + } + + /** + * Error message for the current token + */ + private unexpected(): string { + const token = this.peek(); + return `Unexpected ${token.type}${token.value ? ` (${JSON.stringify(token.value)})` : ''} at line ${token.range.start.line}, column ${token.range.start.column}`; + } + /** * Check if at end of tokens */ @@ -105,11 +155,11 @@ export class Parser { if (this.check(type)) { return this.advance(); } - throw new Error(`Expected ${type}, got ${this.peek().type} at position ${this.pos}`); + throw new Error(`Expected ${type}, got ${this.peek().type} at line ${this.peek().range.start.line}, column ${this.peek().range.start.column}`); } /** - * Skip newlines + * Skip newlines (and comments the parser was told to drop) */ private skipNewlines(): void { while (this.check(TokenType.NEWLINE)) { @@ -126,6 +176,30 @@ export class Parser { } } + /** + * Mark a command as asynchronous when it is terminated by `&` + */ + private applyAsync(command: Command): boolean { + if (this.check(TokenType.AMP)) { + this.advance(); + command.async = true; + return true; + } + return false; + } + + /** + * Parse a comment node + */ + private parseComment(): Comment { + const token = this.expect(TokenType.COMMENT); + return { + type: 'Comment', + text: token.value, + range: token.range + }; + } + /** * Parse a command (handles logical operators) */ @@ -227,19 +301,19 @@ export class Parser { switch (token.type) { case TokenType.IF: - return this.parseIf(); + return this.withRedirects(this.parseIf()); case TokenType.WHILE: - return this.parseWhile(); + return this.withRedirects(this.parseWhile()); case TokenType.UNTIL: - return this.parseUntil(); + return this.withRedirects(this.parseUntil()); case TokenType.FOR: - return this.parseFor(); + return this.withRedirects(this.parseFor()); case TokenType.CASE: - return this.parseCase(); + return this.withRedirects(this.parseCase()); case TokenType.LPAREN: - return this.parseSubshell(); + return this.withRedirects(this.parseSubshell()); case TokenType.LBRACE: - return this.parseBraceGroup(); + return this.withRedirects(this.parseBraceGroup()); case TokenType.FUNCTION: return this.parseFunction(); default: @@ -251,12 +325,28 @@ export class Parser { } } + /** + * Attach the redirections that follow a compound command (`{ … } > file`) + */ + private withRedirects(command: T): T { + const redirects: Redirect[] = []; + while (this.isRedirectOp()) { + const redirect = this.parseRedirect(); + if (!redirect) break; + redirects.push(redirect); + } + if (redirects.length > 0) { + command.redirects = redirects; + } + return command; + } + /** * Parse simple command */ private parseSimpleCommand(): SimpleCommand | null { const prefix: AssignmentWord[] = []; - const suffix: (Word | Redirect)[] = []; + const suffix: (Word | AssignmentWord | Redirect)[] = []; let name: Word | undefined; // Parse prefix (assignments before command name) @@ -297,6 +387,14 @@ export class Parser { text: token.value, range: token.range }); + } else if (this.check(TokenType.ASSIGNMENT_WORD)) { + // operand of export/local/declare/readonly + const token = this.advance(); + suffix.push({ + type: 'AssignmentWord', + text: token.value, + range: token.range + }); } else { break; } @@ -383,28 +481,30 @@ export class Parser { this.advance(); - // Parse file/word - if (this.check(TokenType.WORD)) { - const fileToken = this.advance(); - return { - type: 'Redirect', - op, - file: { - type: 'Word', - text: fileToken.value, - range: fileToken.range - }, - numberIo, - range: opToken.range - }; - } - - return { + const redirect: Redirect = { type: 'Redirect', op, numberIo, range: opToken.range }; + + // The body of a here-document is lexed as opaque text and hangs off the + // operator token. + if (opToken.heredoc) { + redirect.heredoc = opToken.heredoc; + } + + // Parse file/word (the delimiter, for a here-document) + if (this.check(TokenType.WORD)) { + const fileToken = this.advance(); + redirect.file = { + type: 'Word', + text: fileToken.value, + range: fileToken.range + }; + } + + return redirect; } /** @@ -585,11 +685,27 @@ export class Parser { const cases: CaseItem[] = []; while (!this.check(TokenType.ESAC)) { + if (this.isAtEnd()) { + throw new Error('Expected esac, got end of input'); + } + + const before = this.pos; + + if (this.check(TokenType.COMMENT)) { + // a comment between case items has nowhere to live on a CaseClause + this.advance(); + continue; + } + const caseItem = this.parseCaseItem(); if (caseItem) { cases.push(caseItem); } this.skipNewlines(); + + if (this.pos === before) { + throw new Error(this.unexpected()); + } } this.expect(TokenType.ESAC); @@ -672,17 +788,22 @@ export class Parser { } /** - * Parse brace group + * Parse brace group. A brace group is one compound command: it keeps its + * grouping through pipelines, `&&`/`||`, `&` and redirection. */ - private parseBraceGroup(): CompoundList { - this.expect(TokenType.LBRACE); + private parseBraceGroup(): BraceGroup { + const start = this.expect(TokenType.LBRACE); this.skipNewlines(); const list = this.parseCompoundList(); this.expect(TokenType.RBRACE); - return list; + return { + type: 'BraceGroup', + list, + range: start.range + }; } /** @@ -737,12 +858,12 @@ export class Parser { /** * Parse function body */ - private parseFunctionBody(): CompoundList | Subshell { + private parseFunctionBody(): BraceGroup | Subshell { if (this.check(TokenType.LPAREN)) { - return this.parseSubshell(); + return this.withRedirects(this.parseSubshell()); } - return this.parseBraceGroup(); + return this.withRedirects(this.parseBraceGroup()); } /** @@ -752,6 +873,7 @@ export class Parser { const commands: Command[] = []; while (true) { + this.checkDeadline(); this.skipNewlines(); // Check for terminators @@ -769,17 +891,23 @@ export class Parser { break; } + if (this.check(TokenType.COMMENT)) { + commands.push(this.parseComment()); + continue; + } + const command = this.parseCommand(); - if (command) { - commands.push(command); - } else { + if (!command) { break; } - // Check for separator + // `cmd &` backgrounds cmd and also separates it from what follows + const separated = this.applyAsync(command); + commands.push(command); + if (this.check(TokenType.SEMI) || this.check(TokenType.AMP) || this.check(TokenType.NEWLINE)) { this.advance(); - } else { + } else if (!separated) { break; } } diff --git a/packages/bash-ast/src/types.ts b/packages/bash-ast/src/types.ts index 207a609d..64758c96 100644 --- a/packages/bash-ast/src/types.ts +++ b/packages/bash-ast/src/types.ts @@ -21,6 +21,11 @@ export interface Range { export interface BaseNode { type: string; range?: Range; + /** + * Set when the command is terminated by `&` and therefore runs in the + * background. Only meaningful on command nodes. + */ + async?: boolean; } /** @@ -39,7 +44,9 @@ export type Command = | Pipeline | LogicalExpression | Subshell + | BraceGroup | CompoundList + | Comment | IfClause | WhileClause | UntilClause @@ -54,7 +61,15 @@ export interface SimpleCommand extends BaseNode { type: 'SimpleCommand'; name?: Word; prefix?: AssignmentWord[]; - suffix?: (Word | Redirect)[]; + suffix?: (Word | AssignmentWord | Redirect)[]; +} + +/** + * Comment (`# ...`), retained only when the parser runs with `keepComments` + */ +export interface Comment extends BaseNode { + type: 'Comment'; + text: string; } /** @@ -172,6 +187,17 @@ export interface LogicalExpression extends BaseNode { export interface Subshell extends BaseNode { type: 'Subshell'; list: CompoundList; + redirects?: Redirect[]; +} + +/** + * Brace group (e.g., `{ cmd1; cmd2; }`) — a single compound command, so it + * survives pipelines, `&&`/`||`, `&` and redirection as one unit + */ +export interface BraceGroup extends BaseNode { + type: 'BraceGroup'; + list: CompoundList; + redirects?: Redirect[]; } /** @@ -190,6 +216,7 @@ export interface IfClause extends BaseNode { condition: CompoundList; then: CompoundList; else?: CompoundList | IfClause; + redirects?: Redirect[]; } /** @@ -199,6 +226,7 @@ export interface WhileClause extends BaseNode { type: 'WhileClause'; condition: CompoundList; body: CompoundList; + redirects?: Redirect[]; } /** @@ -208,6 +236,7 @@ export interface UntilClause extends BaseNode { type: 'UntilClause'; condition: CompoundList; body: CompoundList; + redirects?: Redirect[]; } /** @@ -218,6 +247,7 @@ export interface ForClause extends BaseNode { name: string; wordlist?: Word[]; body: CompoundList; + redirects?: Redirect[]; } /** @@ -227,6 +257,7 @@ export interface CaseClause extends BaseNode { type: 'CaseClause'; word: Word; cases: CaseItem[]; + redirects?: Redirect[]; } /** @@ -244,7 +275,7 @@ export interface CaseItem extends BaseNode { export interface FunctionDefinition extends BaseNode { type: 'FunctionDefinition'; name: string; - body: CompoundList | Subshell; + body: BraceGroup | Subshell; } /**