diff --git a/.github/workflows/ado-script.yml b/.github/workflows/ado-script.yml new file mode 100644 index 00000000..3d56e4c0 --- /dev/null +++ b/.github/workflows/ado-script.yml @@ -0,0 +1,81 @@ +name: ado-script Workspace + +on: + pull_request: + paths: + - "scripts/ado-script/**" + - "src/compile/filter_ir.rs" + - "src/compile/extensions/trigger_filters.rs" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ado-script.yml" + # Also run on pushes to main so any drift that slips through (e.g. a + # merge that bypassed PR CI, or a force-push) is caught loudly the + # moment it lands. If this fails on main, file a fix-drift issue and + # land a PR to regenerate `src/shared/types.gen.ts` and re-bundle — + # the workflow itself does not auto-PR. + push: + branches: [main] + paths: + - "scripts/ado-script/**" + - "src/compile/filter_ir.rs" + - "src/compile/extensions/trigger_filters.rs" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ado-script.yml" + +env: + CARGO_TERM_COLOR: always + +jobs: + ado-script: + name: Build, Test & Drift-Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: scripts/ado-script/package-lock.json + + - name: Install workspace dependencies + working-directory: scripts/ado-script + run: npm ci + + - name: Regenerate types from Rust IR (codegen) + working-directory: scripts/ado-script + run: npm run codegen + + - name: Verify generated TypeScript is up to date + run: | + if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts; then + echo "" + echo "::error::types.gen.ts is out of date with the Rust IR." + echo "Run 'cd scripts/ado-script && npm run codegen' and commit the result." + exit 1 + fi + + - name: Run TypeScript tests + working-directory: scripts/ado-script + run: npm test + + - name: Type-check + working-directory: scripts/ado-script + run: npm run typecheck + + - name: Build bundle (gate.js) + working-directory: scripts/ado-script + run: npm run build + + - name: Smoke-test bundle + working-directory: scripts/ado-script + run: npx vitest run -c vitest.config.smoke.ts + + - name: E2E gate test + run: cargo test --test gate_e2e -- --ignored --nocapture diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c303d39..34f08aa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,11 +55,25 @@ jobs: set -euo pipefail cp target/release/ado-aw target/release/ado-aw-linux-x64 - - name: Package scripts bundle + - name: Set up Node.js for ado-script bundle + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: scripts/ado-script/package-lock.json + + - name: Build ado-script TypeScript bundle (gate.js) + working-directory: scripts/ado-script + run: | + npm ci + npm run build + # `npm run build` runs codegen + ncc and outputs dist/gate/index.js. + + - name: Package ado-script bundle run: | set -euo pipefail cd scripts - zip -r ../scripts.zip . + zip -r ../ado-script.zip ado-script/dist - name: Upload release assets env: @@ -68,7 +82,7 @@ jobs: TAG="${{ needs.release-please.outputs.tag_name || github.event.inputs.tag_name }}" gh release upload "$TAG" \ target/release/ado-aw-linux-x64 \ - scripts.zip \ + ado-script.zip \ --clobber build-windows: @@ -158,13 +172,13 @@ jobs: TAG="${{ needs.release-please.outputs.tag_name || github.event.inputs.tag_name }}" gh release download "$TAG" \ --pattern "ado-aw-*" \ - --pattern "scripts.zip" \ + --pattern "ado-script.zip" \ --repo "${{ github.repository }}" test -f ado-aw-linux-x64 || { echo "Missing ado-aw-linux-x64"; exit 1; } test -f ado-aw-windows-x64.exe || { echo "Missing ado-aw-windows-x64.exe"; exit 1; } test -f ado-aw-darwin-arm64 || { echo "Missing ado-aw-darwin-arm64"; exit 1; } - test -f scripts.zip || { echo "Missing scripts.zip"; exit 1; } - sha256sum ado-aw-linux-x64 ado-aw-windows-x64.exe ado-aw-darwin-arm64 scripts.zip > checksums.txt + test -f ado-script.zip || { echo "Missing ado-script.zip"; exit 1; } + sha256sum ado-aw-linux-x64 ado-aw-windows-x64.exe ado-aw-darwin-arm64 ado-script.zip > checksums.txt - name: Upload checksums env: diff --git a/.gitignore b/.gitignore index 05451379..78d855eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target examples/sample-agent.yml +scripts/gate.js *.pyc __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 298bcb53..44cc8c5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,8 +156,8 @@ Every compiled pipeline runs as three sequential jobs: │ ├── update-ado-agentic-workflow.md # Guide for modifying an existing agentic pipeline │ └── debug-ado-agentic-workflow.md # Guide for troubleshooting a failing agentic pipeline ├── scripts/ # Supporting scripts shipped as release artifacts -│ ├── gate-eval.py # Python gate evaluator (data-driven filter evaluation) -│ └── gate-spec.schema.json # JSON Schema for gate spec (generated from Rust types) +│ ├── ado-script/ # TypeScript workspace for bundled gate.js (and future bundles) +│ └── gate.js # Bundled gate evaluator (built from scripts/ado-script/, see docs/ado-script.md) ├── tests/ # Integration tests and fixtures ├── docs/ # Per-concept reference documentation (see index below) ├── Cargo.toml # Rust dependencies @@ -169,6 +169,7 @@ Every compiled pipeline runs as three sequential jobs: - **Language**: Rust (2024 edition) - Note: Rust 2024 edition exists and is the edition used by this project - **CLI Framework**: clap v4 with derive macros - **Error Handling**: anyhow for ergonomic error propagation +- **Bundled scripts**: TypeScript + ncc (`scripts/ado-script/`) — compiled gate evaluator and future internal helpers; see [`docs/ado-script.md`](docs/ado-script.md). - **Async Runtime**: tokio with full features - **YAML Parsing**: serde_yaml - **MCP Server**: rmcp with server and transport-io features @@ -235,6 +236,9 @@ index to jump to the right page. framework: detection-based transformations, automatic source rewrite on breaking-change updates, contributor workflow for adding codemods. +- [`docs/ado-script.md`](docs/ado-script.md) — `ado-script` workspace + (`scripts/ado-script/`): the bundled TypeScript runtime helpers (today: + `gate.js`), schemars-driven type codegen, and the A2 design decision. - [`docs/local-development.md`](docs/local-development.md) — local development setup notes. diff --git a/docs/ado-script.md b/docs/ado-script.md new file mode 100644 index 00000000..d363a06c --- /dev/null +++ b/docs/ado-script.md @@ -0,0 +1,314 @@ +# `ado-script`: bundled TypeScript runtime helpers + +`ado-script` is the umbrella name for the TypeScript workspace at +[`scripts/ado-script/`](../scripts/ado-script/). It produces small, +ncc-bundled Node programs that the **compiler injects into every emitted +pipeline** as runtime helpers. The first (and currently only) bundle is +`gate.js`, the trigger-filter gate evaluator. + +> **Internal-only.** `ado-script` is not a user-facing front-matter +> feature. Authors never write an `ado-script:` block in their agent +> markdown. The compiler decides when an `ado-script` bundle is needed +> and how to wire it. See [`docs/tools.md`](tools.md) for what *is* +> user-facing. + +## What `gate.js` does + +`gate.js` is a single-shot Node program that runs as a step in the +pipeline's **Setup** job and decides whether the downstream Agent / +SafeOutputs jobs should execute. It evaluates a declarative `GateSpec` +against runtime facts (PR title, labels, changed files, build reason, +etc.) and emits exactly one `##vso[task.setvariable]` line: + +``` +##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true (or false) +``` + +Downstream jobs gate themselves on that variable via a `condition:` +clause emitted by the compiler. + +The gate is a *data interpreter*, not a code evaluator. The `GateSpec` +is a typed JSON document; predicates are dispatched via a `switch` on a +discriminated union. There is no `eval`, no `Function`, no `vm` — a +compromised compiler cannot use the spec to run arbitrary code on the +pipeline runner. + +## End-to-end data flow + +``` + ┌──────────────────────┐ + │ Rust compiler │ + │ (filter_ir.rs) │ + └──────────┬───────────┘ + │ build_gate_spec(...) → GateSpec (JSON, base64) + ▼ + ┌──────────────────────┐ + │ Generated pipeline │ + │ Setup job: │ + │ 1. NodeTool@0 │ + │ 2. curl + sha256 │ downloads ado-script.zip + │ + unzip │ from the matching ado-aw release + │ 3. node gate/index │ reads GATE_SPEC env var + │ .js │ + └──────────┬───────────┘ + │ ##vso[task.setvariable variable=SHOULD_RUN;…] + ▼ + ┌──────────────────────┐ + │ Agent / SafeOutputs │ conditioned on SHOULD_RUN=true + │ jobs │ + └──────────────────────┘ +``` + +The same `GateSpec` shape is generated as a JSON Schema by +`cargo run -- export-gate-schema` and converted to TypeScript by +`json-schema-to-typescript` into `src/shared/types.gen.ts`. The TS +gate evaluator imports from `types.gen.ts`, never from a hand-written +mirror of the IR — so the spec contract cannot drift between compiler +and evaluator. CI enforces this with a `git diff --exit-code` step on +the codegen output. + +## Runtime stages inside `gate.js` + +`gate.js`'s entry point is `src/gate/index.ts`. It runs five stages, +all single-shot, all fail-closed on error: + +1. **Decode + size-cap** — base64-decode `GATE_SPEC`, reject if the + decoded JSON exceeds `MAX_SPEC_DECODED_BYTES` (256 KiB), then + `JSON.parse`. +2. **Pre-flight validation** — walk the predicate tree and throw on + any unknown `type` discriminant. This catches version drift between + a newer compiler and an older bundled `gate.js` before fact + acquisition runs, so the failure mode is "loud" rather than "silent + skip when the dependent fact is unavailable". Deliberately runs + **before** `runBypass` so a malformed spec fails fast regardless of + build reason. +3. **Bypass** — if `ADO_BUILD_REASON` does not match + `spec.context.build_reason` (e.g. spec is for `PullRequest` but the + build is `Manual`), auto-pass: emit `SHOULD_RUN=true`, tag the + build, complete `Succeeded`, exit. +4. **Fact acquisition** — for every `FactSpec` in the spec, either + read a pipeline env var (`isPipelineVarFact`) or call the ADO REST + API (`pr_metadata`, `pr_labels`, `changed_files`, …). Each per-fact + failure is recorded in the `PolicyTracker` and dispatched via that + fact's `failure_policy` (`fail_closed` / `fail_open` / + `skip_dependents`). +5. **Predicate evaluation** — for each `CheckSpec`, the + `PolicyTracker` decides whether the check is `evaluate`, `pass`, + `skip`, or `fail` based on which referenced facts are still + available. Evaluator dispatches the predicate via the `switch` in + `evaluatePredicate`. Failing checks emit `addBuildTag` and the + overall `SHOULD_RUN` is `true` iff every check is `pass` or `skip`. + +If `SHOULD_RUN` ends up `false`, `selfCancelIfRequested` issues a +best-effort `BuildStatus.Cancelling` PATCH so the pipeline run is +visibly cancelled in the ADO UI rather than just paused on a gated +job. + +## Runtime env-var contract + +The compiler injects these environment variables on the +`bash: node gate/index.js` step. `gate.js` reads them via +`process.env`: + +| Env var | Source | Purpose | +|---|---|---| +| `GATE_SPEC` | compiled inline (base64) | The full `GateSpec` JSON | +| `SYSTEM_ACCESSTOKEN` | `$(System.AccessToken)` | ADO REST auth | +| `ADO_COLLECTION_URI` | `$(System.CollectionUri)` | ADO org base URL | +| `ADO_BUILD_REASON` | `$(Build.Reason)` | Used by the bypass branch | +| `ADO_BUILD_ID` | `$(Build.BuildId)` | Used for `selfCancelIfRequested` | +| `ADO_PROJECT` / `ADO_REPO_ID` / `ADO_PR_ID` | compiler-injected | PR-derived facts | +| `ADO_*` (fact-specific) | `Fact::ado_exports()` in Rust | Per-fact pipeline-variable readers (e.g. `ADO_PR_TITLE`, `ADO_SOURCE_BRANCH`) | +| `ADO_API_TIMEOUT_MS` | optional override | Per-attempt timeout for every ADO REST call. Default 30 000. On timeout, the call is retried once; if the retry also times out, the gate falls back to the per-fact `FailurePolicy`. | + +The exact contract for pipeline-variable facts (which env var maps to +which `FactKind`) lives in **two places** that must stay in lockstep: + +- Rust: `Fact::ado_exports()` in `src/compile/filter_ir.rs` +- TS: `ENV_BY_FACT` plus the `FactKind` union in + `scripts/ado-script/src/shared/env-facts.ts` + +The codegen drift check only mirrors the `GateSpec` *shape*, not the +env-var mapping, so when adding a new pipeline-variable fact you must +update both sides by hand. `Fact::ado_exports()` carries a docstring +pointing at the TS mirror as a reminder. + +## Workspace layout + +``` +scripts/ado-script/ +├── package.json # type:module; dep: azure-devops-node-api (lazy-imported) +├── tsconfig.json # strict; noUncheckedIndexedAccess; NodeNext +├── src/ +│ ├── shared/ # Reusable across all bundles +│ │ ├── types.gen.ts # AUTO-GENERATED from Rust IR — do not edit +│ │ ├── auth.ts # WebApi factory; SDK is dynamic-imported here +│ │ ├── ado-client.ts # azure-devops-node-api wrapper + retry + timeout + pagination +│ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping +│ │ ├── policy.ts # PolicyTracker state machine +│ │ └── vso-logger.ts # ##vso[…] emitters with property/message escaping; complete() is idempotent +│ └── gate/ # gate.js entry point + per-concern modules +│ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit +│ ├── bypass.ts # build-reason auto-pass +│ ├── facts.ts # fact acquisition (env + REST) +│ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening +│ └── selfcancel.ts # best-effort build cancellation +├── test/ # End-to-end smoke tests +└── dist/gate/index.js # ncc bundle output (gitignored) +``` + +The release workflow (`.github/workflows/release.yml`) runs +`npm ci && npm run build`, then zips `scripts/ado-script/dist/` into +the `ado-script.zip` release asset. Pipelines download that asset at +runtime by URL pinned to the compiler's `CARGO_PKG_VERSION`, verify +its SHA-256 against the `checksums.txt` asset, then extract. + +## Schema codegen + +`types.gen.ts` is derived from the Rust IR via +[`schemars`](https://crates.io/crates/schemars) → +[`json-schema-to-typescript`](https://www.npmjs.com/package/json-schema-to-typescript): + +``` +┌──────────────────────────┐ schemars ┌──────────────────────────┐ +│ src/compile/filter_ir.rs │ ───────────► │ schema/gate-spec.schema │ +│ #[derive(JsonSchema)] │ │ .json │ +└──────────────────────────┘ └────────────┬─────────────┘ + │ json2ts + ▼ + ┌──────────────────────────────┐ + │ src/shared/types.gen.ts │ + │ (consumed by gate/*.ts) │ + └──────────────────────────────┘ +``` + +`npm run codegen` runs both stages. The CI workflow +(`.github/workflows/ado-script.yml`) regenerates the file and runs +`git diff --exit-code` to fail on drift, on both PRs and pushes to +`main`. If you change the IR shape in Rust, run +`cd scripts/ado-script && npm run codegen` and commit the regenerated +`types.gen.ts`. + +The Rust subcommand that emits the schema is intentionally hidden: + +```sh +cargo run -- export-gate-schema --output schema/gate-spec.schema.json +``` + +## How the gate bundle is wired into emitted pipelines + +`TriggerFiltersExtension` +(`src/compile/extensions/trigger_filters.rs`) injects three Setup-job +steps when any `filters:` block is active: + +1. **`NodeTool@0`** — installs Node 20.x LTS, capped at + `timeoutInMinutes: 5`. +2. **`curl` download + verify + extract** — fetches `checksums.txt` + and `ado-script.zip` from the `githubnext/ado-aw` release matching + `CARGO_PKG_VERSION`, verifies the zip's SHA-256, then + `unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/`. + Also capped at `timeoutInMinutes: 5`. +3. **`bash: node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'`** — + runs the gate with `GATE_SPEC` and the env-var contract above. + +The IR-to-bash codegen that produces these steps is +`compile_gate_step_external` in `src/compile/filter_ir.rs`. + +## Modifying `ado-script` + +### Add a new predicate + +1. Add a `Predicate` + `PredicateSpec` variant in + `src/compile/filter_ir.rs`. Run `cargo test` and update spec tests. +2. In `scripts/ado-script/`, run `npm run codegen` so `types.gen.ts` + picks up the new variant. +3. Add a `case` to the `switch` in + `src/gate/predicates.ts::evaluatePredicate`. +4. Add the new type name to `KNOWN_PREDICATE_TYPES` (right above the + `validatePredicateTree` function). **Both updates are required** — + the drift test + `KNOWN_PREDICATE_TYPES stays in sync with evaluatePredicate switch` + in `predicates.test.ts` will fail if you forget either. +5. Add a vitest case under + `src/gate/__tests__/ports/.test.ts`. + +### Add a new pipeline-variable fact + +1. Add a `Fact` variant in `src/compile/filter_ir.rs` and update + `Fact::ado_exports()`. (Its docstring reminds you about step 3.) +2. `npm run codegen` to regenerate types. +3. Add an entry to `ENV_BY_FACT` and extend the `FactKind` union in + `scripts/ado-script/src/shared/env-facts.ts`. Without this step the + gate silently treats the fact as missing. +4. If the fact value is ref-shaped (e.g. a branch name), add it to + the exported `BRANCH_FACTS` set so the read-time strip is applied. + +### Add a new bundle (e.g. `poll.js`) + +1. Create `src/poll/index.ts` and supporting modules under + `scripts/ado-script/src/poll/`. Reuse anything in `src/shared/`. +2. Add a build script to `package.json`: + ```json + "build:poll": "ncc build src/poll/index.ts -o dist/poll -m -t" + ``` + and extend `build` to also run it. +3. Add vitest tests under `src/poll/__tests__/`. +4. Wire from a new `CompilerExtension` (or extend an existing one) + that downloads `ado-script.zip` (already a release asset) and + invokes `node /tmp/ado-aw-scripts/ado-script/dist/poll/index.js` + as a runtime step. +5. No release-workflow change is needed — `zip -r ado-script/dist` + picks up the new bundle automatically. + +### Local development loop + +From `scripts/ado-script/`: + +```sh +npm ci # one-time +npm run codegen # regenerate types.gen.ts (compiles ado-aw first) +npm test # vitest unit tests +npm run typecheck # strict tsc --noEmit +npm run build # ncc-bundle to dist/gate/index.js +npm run test:smoke # build + smoke test the bundle end-to-end +``` + +The Rust-side E2E gate test compiles a real agent, extracts the +emitted `GATE_SPEC`, and shells out to the bundled `gate.js`: + +```sh +cargo test --test gate_e2e -- --ignored --nocapture +``` + +## Bundle-size budget + +Each bundled artifact must stay **under 5 MB**. The entry-point +chunk for `gate.js` is ~78 KB; the lazy-imported +`azure-devops-node-api` SDK lives in a separate ~2.7 MB chunk loaded +only when an ADO REST call is needed. Pipelines that bypass or rely +only on pipeline-variable facts never load the SDK. + +If a future bundle blows the budget: + +- First, check ncc's `--minify` and `--target` flags. +- If still too large, weigh dropping `azure-devops-node-api` in favor + of hand-rolled `fetch` for the hot endpoints. The retry / timeout / + pagination helpers in `src/shared/ado-client.ts` are written so + they could wrap either approach. + +## Out of scope (explicitly) + +- A user-facing `ado-script:` front-matter block. Letting authors run + arbitrary TypeScript at pipeline runtime would bypass the + safe-output trust boundary and require sandboxing the project does + not have. +- Migrating the safe-output executors (`src/safeoutputs/*.rs`) to + Node. Stage 3 keeps a Rust-only execution path. +- Migrating the agent-stats parser. It runs in-pipeline as part of + Stage 1 wrap-up and has no TypeScript dependency need. +- Bundling Node itself. Pipelines install Node via `NodeTool@0`. + +## See also + +- [`filter-ir.md`](filter-ir.md) — the IR consumed by `gate.js`. +- [`extending.md`](extending.md) — generic compiler-extension guide. diff --git a/docs/filter-ir.md b/docs/filter-ir.md index d1933c6e..8d14e266 100644 --- a/docs/filter-ir.md +++ b/docs/filter-ir.md @@ -113,14 +113,14 @@ supports these predicate types: | Predicate | Bash Shape | Example | |-----------|-----------|---------| -| `GlobMatch { fact, pattern }` | `fnmatch(value, pattern)` | Title matches `*[review]*` | +| `GlobMatch { fact, pattern }` | Simple glob (`*` any chars, `?` single char) | Title matches `*[review]*` | | `Equality { fact, value }` | `[ "$VAR" = "value" ]` | Draft is `false` | | `ValueInSet { fact, values, case_insensitive }` | `echo "$VAR" \| grep -q[i]E '^(a\|b)$'` | Author in allow-list | | `ValueNotInSet { fact, values, case_insensitive }` | Inverse of `ValueInSet` | Author not in block-list | | `NumericRange { fact, min, max }` | `[ "$VAR" -ge N ] && [ "$VAR" -le M ]` | Changed file count in range | | `TimeWindow { start, end }` | Arithmetic on `CURRENT_MINUTES` | Only during business hours | | `LabelSetMatch { any_of, all_of, none_of }` | `grep -qiF` per label | PR labels match criteria | -| `FileGlobMatch { include, exclude }` | python3 `fnmatch` | Changed files match globs | +| `FileGlobMatch { include, exclude }` | gate.js minimatch | Changed files match globs | | `And(Vec)` | All must pass | *(reserved for compound filters)* | | `Or(Vec)` | At least one must pass | *(reserved)* | | `Not(Box)` | Inner must fail | *(reserved)* | @@ -234,8 +234,9 @@ require heuristic analysis and could produce false positives. ### `compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String` Produces a complete ADO pipeline step (`- bash: |`) with a **data-driven -architecture**: bash is a thin ADO-macro shim, all filter logic lives in a -generic Python evaluator that reads a JSON gate spec. +architecture**: bash is a thin ADO-macro shim, all filter logic lives in +the bundled Node.js gate evaluator (`scripts/ado-script/dist/gate/index.js`) that reads a JSON +gate spec. #### Generated Step Structure @@ -255,10 +256,8 @@ generic Python evaluator that reads a JSON gate spec. # 3. Access token passthrough export ADO_SYSTEM_ACCESS_TOKEN="$SYSTEM_ACCESSTOKEN" - # 4. Embedded Python evaluator (heredoc — never modified) - python3 << 'GATE_EVAL_EOF' - ...evaluator source... - GATE_EVAL_EOF + # 4. Run the bundled Node evaluator (downloaded by the Setup job) + node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js' name: prGate displayName: "Evaluate PR filters" env: @@ -267,7 +266,7 @@ generic Python evaluator that reads a JSON gate spec. #### Gate Spec Format (JSON) -The spec is base64-encoded to prevent ADO macro expansion and heredoc +The spec is base64-encoded to prevent ADO macro expansion and shell quoting issues. Decoded, it contains: ```json @@ -299,21 +298,23 @@ quoting issues. Decoded, it contains: ``` The spec is declarative — it uses fact *kinds* (e.g., `"pr_title"`, -`"pr_metadata"`) not raw REST endpoints. The Python evaluator owns +`"pr_metadata"`) not raw REST endpoints. The Node evaluator owns acquisition logic. -#### Python Gate Evaluator (`scripts/gate-eval.py`) +#### Bundled Gate Evaluator (`scripts/ado-script/src/gate/`) -The evaluator is a self-contained Python script embedded via -`include_str!()`. It handles: +The evaluator is a TypeScript program ncc-bundled to a single +self-contained `scripts/ado-script/dist/gate/index.js` (~1.1 MB) that ships as part of the +`ado-script.zip` release asset. See [`ado-script.md`](ado-script.md) for the +full design and codegen pipeline. It handles: 1. **Bypass logic** — reads `ADO_BUILD_REASON` and exits early for non-matching trigger types 2. **Fact acquisition** — maps fact kinds to acquisition methods: - - Pipeline variables → `os.environ.get("ADO_*")` - - PR metadata → `urllib` call to ADO REST API + - Pipeline variables → `process.env["ADO_*"]` + - PR metadata → `azure-devops-node-api` REST call - Changed files → iteration API calls - - UTC time → `datetime.now(timezone.utc)` + - UTC time → `Date.now()` 3. **Failure policies** — `fail_closed`, `fail_open`, `skip_dependents` 4. **Predicate evaluation** — recursive evaluator supporting all predicate types 5. **Result reporting** — `##vso[...]` logging commands, build tags, self-cancel @@ -342,7 +343,7 @@ The bash shim exports only the ADO macros needed by the spec's facts: | `numeric_range` | `fact`, `min?`, `max?` | Integer range check | | `time_window` | `start`, `end` | UTC HH:MM window (overnight-aware) | | `label_set_match` | `fact`, `any_of?`, `all_of?`, `none_of?` | Label set predicates | -| `file_glob_match` | `fact`, `include?`, `exclude?` | Python `fnmatch` globs | +| `file_glob_match` | `fact`, `include?`, `exclude?` | Glob match against changed file paths | | `and` | `operands` | All must pass | | `or` | `operands` | At least one must pass | | `not` | `operand` | Inner must fail | @@ -355,12 +356,14 @@ When Tier 2/3 filters are configured, the `TriggerFiltersExtension` (`src/compile/extensions/trigger_filters.rs`) activates via `collect_extensions()`. It implements `CompilerExtension` and controls: -1. **Download step** — downloads `scripts.zip` from the ado-aw release +1. **Node install step** — emits a `NodeTool@0` step pinned to Node 20.x + LTS so `gate.js` has a runtime +2. **Download step** — fetches `ado-script.zip` from the ado-aw release artifacts, verifies its SHA256 checksum via `checksums.txt`, then - extracts `gate-eval.py` to `/tmp/ado-aw-scripts/gate-eval.py` -2. **Gate step** — calls `compile_gate_step_external()` to generate a step - that references the downloaded script (no inline heredoc) -3. **Validation** — runs `validate_pr_filters()` / `validate_pipeline_filters()` + extracts `gate.js` to `/tmp/ado-aw-scripts/ado-script/dist/gate/index.js` +3. **Gate step** — calls `compile_gate_step_external()` to generate a step + that runs `node /tmp/ado-aw-scripts/ado-script/dist/gate/index.js` (no inline heredoc) +4. **Validation** — runs `validate_pr_filters()` / `validate_pipeline_filters()` during compilation via the `validate()` trait method The extension uses the `setup_steps()` trait method (not `prepare_steps()`) @@ -371,7 +374,7 @@ because the gate must run in the **Setup job** (before the Execution job). When only Tier 1 filters are configured (pipeline variables — title, author, branch, commit-message, build-reason), the extension is NOT activated. `generate_pr_gate_step()` generates an inline bash gate step directly, with -no Python evaluator and no download step. +no Node evaluator and no download step. ### Gate Step Injection @@ -405,13 +408,20 @@ The `expression` escape hatch is also ANDed if present. ### Scripts Distribution -`gate-eval.py` lives at `scripts/gate-eval.py` in the repository and is -shipped inside a `scripts.zip` archive alongside the ado-aw binary. The -download URL is deterministic based on the ado-aw version: -`https://github.com/githubnext/ado-aw/releases/download/v{VERSION}/scripts.zip` +The `gate.js` bundle is built from the TypeScript workspace at +`scripts/ado-script/` (see [`ado-script.md`](ado-script.md)) and emitted to +`scripts/ado-script/dist/gate/index.js` by the release workflow's build step. It ships inside +the `ado-script.zip` release asset, alongside any future bundled helpers +(e.g. `poll.js`, `stats.js`). The download URL is deterministic based on +the ado-aw version: +`https://github.com/githubnext/ado-aw/releases/download/v{VERSION}/ado-script.zip` A `checksums.txt` file is also published at the same URL base and used to -verify the SHA256 integrity of `scripts.zip` before extraction. +verify the SHA256 integrity of `ado-script.zip` before extraction. + +The Setup-job download step pulls the zip, extracts `ado-script/dist/gate/index.js`, +and discards the rest. New per-use-site bundles follow the same pattern +(per-bundle ncc entry + per-bundle download step). ## Adding New Filter Types @@ -422,10 +432,10 @@ step-by-step guide. In summary: `ado_exports()`, `dependencies()`, `failure_policy()`) 2. Add a `Predicate` variant if a new test shape is needed 3. Add a `PredicateSpec` variant for serialization -4. Add an evaluator handler in `scripts/gate-eval.py` for the new predicate - type +4. Add an evaluator handler in `scripts/ado-script/src/gate/predicates.ts` + for the new predicate type, and add corresponding vitest cases in + `scripts/ado-script/src/gate/__tests__/` 5. Extend the lowering function (`lower_pr_filters` or `lower_pipeline_filters`) 6. Add validation rules if the new filter can conflict with existing ones 7. Write tests: lowering, validation, spec serialization, and evaluator - diff --git a/scripts/ado-script/.gitignore b/scripts/ado-script/.gitignore new file mode 100644 index 00000000..0e5d6d5f --- /dev/null +++ b/scripts/ado-script/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +schema +*.tsbuildinfo diff --git a/scripts/ado-script/README.md b/scripts/ado-script/README.md new file mode 100644 index 00000000..f8222091 --- /dev/null +++ b/scripts/ado-script/README.md @@ -0,0 +1,28 @@ +# @ado-aw/scripts + +Bundled TypeScript scripts shipped in `ado-script.zip` alongside the ado-aw release. + +## Bundles + +- `gate.js` — trigger filter gate evaluator (consumed by `TriggerFiltersExtension` in the Rust compiler) + +## Type generation + +Types in `src/shared/types.gen.ts` are auto-generated from the Rust IR via: + +```bash +npm run codegen +``` + +This invokes `cargo run -- export-gate-schema` to write the JSON Schema, then runs `json-schema-to-typescript`. CI verifies the generated file is up to date (drift check). If drift is detected, run `npm run codegen` and commit the result. + +## Layout + +- `src/shared/` — modules shared across all bundles (auth, ado-client, vso-logger, env-facts, policy state machine) +- `src/gate/` — gate evaluator entry point and per-concern modules +- `dist/` — ncc bundle output (gitignored); `npm run build` writes `dist/gate/index.js`, which ships in `ado-script.zip` + +## See also + +- Architecture and runtime contract: [`docs/ado-script.md`](../../docs/ado-script.md). +- Compiler integration: `src/compile/extensions/trigger_filters.rs`. diff --git a/scripts/ado-script/package-lock.json b/scripts/ado-script/package-lock.json new file mode 100644 index 00000000..de76e3db --- /dev/null +++ b/scripts/ado-script/package-lock.json @@ -0,0 +1,1786 @@ +{ + "name": "@ado-aw/scripts", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ado-aw/scripts", + "version": "0.0.0", + "dependencies": { + "azure-devops-node-api": "^14.1.0" + }, + "devDependencies": { + "@types/node": "^20.19.39", + "@vercel/ncc": "^0.38.4", + "json-schema-to-typescript": "^15.0.4", + "typescript": "^5.9.3", + "vitest": "^4.1.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/azure-devops-node-api": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-14.1.0.tgz", + "integrity": "sha512-QhpgjH1LQ+vgDJ7oBwcmsZ3+o4ZpjLVilw0D3oJQpYpRzN+L39lk5jZDLJ464hLUgsDzWn/Ksv7zLLMKLfoBzA==", + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "2.1.0" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/typed-rest-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.1.0.tgz", + "integrity": "sha512-Nel9aPbgSzRxfs1+4GoSB4wexCF+4Axlk7OSGVQCMa+4fWcyxIsN/YNmkp0xTT2iQzMD98h8yFLav/cNaULmRA==", + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "^6.10.3", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/scripts/ado-script/package.json b/scripts/ado-script/package.json new file mode 100644 index 00000000..12016b78 --- /dev/null +++ b/scripts/ado-script/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ado-aw/scripts", + "private": true, + "type": "module", + "version": "0.0.0", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "npm run codegen && npm run build:gate", + "build:gate": "ncc build src/gate/index.ts -o dist/gate -m -t", + "build:check": "ls -lh dist/gate/index.js && wc -c dist/gate/index.js", + "codegen": "mkdir -p schema && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"", + "test": "vitest run", + "test:smoke": "npm run build && vitest run -c vitest.config.smoke.ts", + "lint": "echo TODO", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "azure-devops-node-api": "^14.1.0" + }, + "devDependencies": { + "@types/node": "^20.19.39", + "@vercel/ncc": "^0.38.4", + "json-schema-to-typescript": "^15.0.4", + "typescript": "^5.9.3", + "vitest": "^4.1.6" + } +} diff --git a/scripts/ado-script/src/.gitkeep b/scripts/ado-script/src/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/ado-script/src/gate/__tests__/ports/INVENTORY.md b/scripts/ado-script/src/gate/__tests__/ports/INVENTORY.md new file mode 100644 index 00000000..9a555964 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/INVENTORY.md @@ -0,0 +1,127 @@ +# TypeScript gate test port inventory + +Source: `tests/gate_eval_tests.py` (deleted; this file documents the 1:1 +mapping done at port time, retained as a parity audit artifact). + +- Python `def test_...` cases inventoried: 45 +- `pytest.parametrize` decorators/cases found: 0 +- Python cases ported: 45 +- Python cases marked obsolete: 0 +- Extra TS parity guards added: 6 (`glob` DOTALL, label case-insensitivity, file-glob empty/include/exclude combinations, and one `evaluatePredicates`/`PolicyTracker` integration guard) + +## TestStripRefPrefix + +| Python test | TS port location | +| --- | --- | +| `test_refs_heads` | `ref-prefix.test.ts` / `TestStripRefPrefix` / `test refs heads` | +| `test_refs_tags` | `ref-prefix.test.ts` / `TestStripRefPrefix` / `test refs tags` | +| `test_refs_pull` | `ref-prefix.test.ts` / `TestStripRefPrefix` / `test refs pull` | +| `test_no_prefix` | `ref-prefix.test.ts` / `TestStripRefPrefix` / `test no prefix` | +| `test_pattern_stripping_in_glob` | `ref-prefix.test.ts` / `TestStripRefPrefix` / `test pattern stripping in glob` | + +## TestGlobMatch + +| Python test | TS port location | +| --- | --- | +| `test_match` | `glob.test.ts` / `TestGlobMatch` / `test match` | +| `test_no_match` | `glob.test.ts` / `TestGlobMatch` / `test no match` | +| `test_wildcard` | `glob.test.ts` / `TestGlobMatch` / `test wildcard` | +| `test_exact` | `glob.test.ts` / `TestGlobMatch` / `test exact` | +| `test_exact_no_match` | `glob.test.ts` / `TestGlobMatch` / `test exact no match` | +| `test_empty_value` | `glob.test.ts` / `TestGlobMatch` / `test empty value` | + +Additional guard: `glob.test.ts` / `TestGlobMatch` / `test dotall across newlines` verifies Python `_glob(..., flags=DOTALL)` parity. + +## TestEquals + +| Python test | TS port location | +| --- | --- | +| `test_match` | `equals.test.ts` / `TestEquals` / `test match` | +| `test_no_match` | `equals.test.ts` / `TestEquals` / `test no match` | +| `test_missing_fact` | `equals.test.ts` / `TestEquals` / `test missing fact` | + +## TestValueInSet + +| Python test | TS port location | +| --- | --- | +| `test_case_insensitive_match` | `value-set.test.ts` / `TestValueInSet` / `test case insensitive match` | +| `test_case_sensitive_no_match` | `value-set.test.ts` / `TestValueInSet` / `test case sensitive no match` | +| `test_not_in_set` | `value-set.test.ts` / `TestValueInSet` / `test not in set` | + +## TestValueNotInSet + +| Python test | TS port location | +| --- | --- | +| `test_not_in_set` | `value-set.test.ts` / `TestValueNotInSet` / `test not in set` | +| `test_in_set` | `value-set.test.ts` / `TestValueNotInSet` / `test in set` | + +## TestNumericRange + +| Python test | TS port location | +| --- | --- | +| `test_in_range` | `numeric-range.test.ts` / `TestNumericRange` / `test in range` | +| `test_below_min` | `numeric-range.test.ts` / `TestNumericRange` / `test below min` | +| `test_above_max` | `numeric-range.test.ts` / `TestNumericRange` / `test above max` | +| `test_min_only` | `numeric-range.test.ts` / `TestNumericRange` / `test min only` | +| `test_max_only` | `numeric-range.test.ts` / `TestNumericRange` / `test max only` | + +## TestTimeWindow + +| Python test | TS port location | +| --- | --- | +| `test_in_window` | `time-window.test.ts` / `TestTimeWindow` / `test in window` | +| `test_outside_window` | `time-window.test.ts` / `TestTimeWindow` / `test outside window` | +| `test_overnight_window_in` | `time-window.test.ts` / `TestTimeWindow` / `test overnight window in` | +| `test_overnight_window_out` | `time-window.test.ts` / `TestTimeWindow` / `test overnight window out` | + +## TestLabelSetMatch + +| Python test | TS port location | +| --- | --- | +| `test_any_of_match` | `label-set.test.ts` / `TestLabelSetMatch` / `test any of match` | +| `test_any_of_no_match` | `label-set.test.ts` / `TestLabelSetMatch` / `test any of no match` | +| `test_all_of_match` | `label-set.test.ts` / `TestLabelSetMatch` / `test all of match` | +| `test_all_of_missing` | `label-set.test.ts` / `TestLabelSetMatch` / `test all of missing` | +| `test_none_of_pass` | `label-set.test.ts` / `TestLabelSetMatch` / `test none of pass` | +| `test_none_of_fail` | `label-set.test.ts` / `TestLabelSetMatch` / `test none of fail` | +| `test_empty_labels` | `label-set.test.ts` / `TestLabelSetMatch` / `test empty labels` | + +Additional guard: `label-set.test.ts` / `TestLabelSetMatch` / `test case insensitive labels` verifies the Python lower-case label comparison behavior. + +## TestFileGlobMatch + +| Python test | TS port location | +| --- | --- | +| `test_include_match` | `file-glob.test.ts` / `TestFileGlobMatch` / `test include match` | +| `test_include_no_match` | `file-glob.test.ts` / `TestFileGlobMatch` / `test include no match` | +| `test_exclude` | `file-glob.test.ts` / `TestFileGlobMatch` / `test exclude` | + +Additional guards in `file-glob.test.ts` cover Python's empty-list include failure, exclude-only vacuous success, and include+exclude allowing a non-excluded match. + +## TestLogicalCombinators + +| Python test | TS port location | +| --- | --- | +| `test_and_all_pass` | `logical.test.ts` / `TestLogicalCombinators` / `test and all pass` | +| `test_and_one_fails` | `logical.test.ts` / `TestLogicalCombinators` / `test and one fails` | +| `test_or_one_passes` | `logical.test.ts` / `TestLogicalCombinators` / `test or one passes` | +| `test_not` | `logical.test.ts` / `TestLogicalCombinators` / `test not` | + +## TestPredicateFacts + +| Python test | TS port location | +| --- | --- | +| `test_simple` | `predicate-facts.test.ts` / `TestPredicateFacts` / `test simple` | +| `test_compound` | `predicate-facts.test.ts` / `TestPredicateFacts` / `test compound` | +| `test_not` | `predicate-facts.test.ts` / `TestPredicateFacts` / `test not` | + +## Full-loop / policy cases + +The deleted Python test suite had no full `main()`/`GateSpec` loop cases +and no policy state-machine cases. A small additional `integration.test.ts` +guard covers `evaluatePredicates(spec, facts, tracker)` with a real +`PolicyTracker`, but no Python case is mapped here. + +## Divergences + +No Python/TS behavioral divergences were found while porting the inventoried Python cases. No TS implementation changes were required. diff --git a/scripts/ado-script/src/gate/__tests__/ports/equals.test.ts b/scripts/ado-script/src/gate/__tests__/ports/equals.test.ts new file mode 100644 index 00000000..706571be --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/equals.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestEquals", () => { + it("test match", () => { + const pred = { type: "equals", fact: "pr_is_draft", value: "false" } satisfies PredicateSpec; + const facts = factMap({ pr_is_draft: "false" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test no match", () => { + const pred = { type: "equals", fact: "pr_is_draft", value: "false" } satisfies PredicateSpec; + const facts = factMap({ pr_is_draft: "true" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test missing fact", () => { + const pred = { type: "equals", fact: "missing", value: "x" } satisfies PredicateSpec; + expect(evaluatePredicate(pred, factMap({}))).toBe(false); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/file-glob.test.ts b/scripts/ado-script/src/gate/__tests__/ports/file-glob.test.ts new file mode 100644 index 00000000..016e57c7 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/file-glob.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestFileGlobMatch", () => { + it("test include match", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/*.rs"], + } as PredicateSpec; + const facts = factMap({ changed_files: ["src/main.rs", "src/lib.rs"] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test include no match", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/**/*.rs"], + } as PredicateSpec; + const facts = factMap({ changed_files: ["docs/readme.md"] }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test exclude", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/**/*.rs"], + exclude: ["src/test_*.rs"], + } as PredicateSpec; + const facts = factMap({ changed_files: ["src/test_main.rs"] }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test empty file list with include fails", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/*.rs"], + } as PredicateSpec; + const facts = factMap({ changed_files: [] }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test empty file list with exclude only passes", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + exclude: ["src/generated/*"], + } as PredicateSpec; + const facts = factMap({ changed_files: [] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test include and exclude allows non excluded match", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/*.rs"], + exclude: ["src/test_*.rs"], + } as PredicateSpec; + const facts = factMap({ changed_files: ["src/test_main.rs", "src/lib.rs"] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/glob.test.ts b/scripts/ado-script/src/gate/__tests__/ports/glob.test.ts new file mode 100644 index 00000000..87e08f27 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/glob.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestGlobMatch", () => { + it("test match", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "*[review]*" } satisfies PredicateSpec; + const facts = factMap({ pr_title: "feat: add feature [review]" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test no match", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "*[review]*" } satisfies PredicateSpec; + const facts = factMap({ pr_title: "feat: add feature" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test wildcard", () => { + const pred = { type: "glob_match", fact: "source_branch", pattern: "feature/*" } satisfies PredicateSpec; + const facts = factMap({ source_branch: "feature/my-branch" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test exact", () => { + const pred = { type: "glob_match", fact: "target_branch", pattern: "main" } satisfies PredicateSpec; + const facts = factMap({ target_branch: "main" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test exact no match", () => { + const pred = { type: "glob_match", fact: "target_branch", pattern: "main" } satisfies PredicateSpec; + const facts = factMap({ target_branch: "develop" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test empty value", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "*" } satisfies PredicateSpec; + const facts = factMap({ pr_title: "" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test dotall across newlines", () => { + const pred = { type: "glob_match", fact: "commit_message", pattern: "feat:*details" } satisfies PredicateSpec; + const facts = factMap({ commit_message: "feat: add thing\n\nbody details" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); +}); + +describe("globMatch hardening", () => { + it("rejects patterns over the length cap (fail-closed)", () => { + const longPattern = "a".repeat(2048); + const pred = { type: "glob_match", fact: "pr_title", pattern: longPattern } satisfies PredicateSpec; + const facts = factMap({ pr_title: longPattern }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("rejects patterns with too many '*' wildcards (fail-closed)", () => { + const wildcardBomb = "*".repeat(128); + const pred = { type: "glob_match", fact: "pr_title", pattern: wildcardBomb } satisfies PredicateSpec; + const facts = factMap({ pr_title: "anything" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("caches compiled regex across repeated invocations", async () => { + const { _resetGlobCacheForTesting } = await import("../../predicates.js"); + _resetGlobCacheForTesting(); + const pred = { type: "glob_match", fact: "pr_title", pattern: "feat/*" } satisfies PredicateSpec; + const facts = factMap({ pr_title: "feat/x" }); + + // First call compiles, subsequent calls hit the cache. We can't directly + // observe the cache from outside, so we just assert idempotency under + // a large number of repeated calls and that we don't blow the stack / + // hit any allocation cliff. (A negative perf assertion is unreliable in + // CI — this is a smoke test for the cache integration, not a perf gate.) + for (let i = 0; i < 5000; i++) { + expect(evaluatePredicate(pred, facts)).toBe(true); + } + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/helpers.ts b/scripts/ado-script/src/gate/__tests__/ports/helpers.ts new file mode 100644 index 00000000..d60bb743 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/helpers.ts @@ -0,0 +1,21 @@ +import type { GateSpec } from "../../../shared/types.gen.js"; + +export function factMap(values: Record): Map { + return new Map(Object.entries(values)); +} + +export function gateSpec( + checks: GateSpec["checks"], + facts: GateSpec["facts"] = [], +): GateSpec { + return { + checks, + facts, + context: { + build_reason: "PullRequest", + bypass_label: "run-agent", + step_name: "Gate", + tag_prefix: "gate", + }, + }; +} diff --git a/scripts/ado-script/src/gate/__tests__/ports/integration.test.ts b/scripts/ado-script/src/gate/__tests__/ports/integration.test.ts new file mode 100644 index 00000000..815df7e5 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/integration.test.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { PolicyTracker } from "../../../shared/policy.js"; +import { evaluatePredicates } from "../../predicates.js"; +import { factMap, gateSpec } from "./helpers.js"; + +describe("evaluatePredicates integration ports", () => { + let writes: string[]; + + beforeEach(() => { + writes = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("test evaluate predicates with policy tracker", () => { + const predicate = { type: "equals", fact: "pr_title", value: "ok" } satisfies PredicateSpec; + const spec = gateSpec( + [{ name: "title", tag_suffix: "title", predicate }], + [{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] }], + ); + const tracker = new PolicyTracker(spec.facts); + + expect(evaluatePredicates(spec, factMap({ pr_title: "bad" }), tracker)).toEqual(["fail"]); + expect(tracker.summary()).toEqual({ passed: 0, failed: 1, skipped: 0 }); + expect(writes).toContain("##vso[build.addbuildtag]gate:title\n"); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/label-set.test.ts b/scripts/ado-script/src/gate/__tests__/ports/label-set.test.ts new file mode 100644 index 00000000..6166d74f --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/label-set.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestLabelSetMatch", () => { + it("test any of match", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: ["run-agent", "needs-review"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["run-agent", "other"] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test any of no match", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: ["run-agent"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["other"] }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test all of match", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + all_of: ["approved", "tested"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["approved", "tested", "other"] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test all of missing", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + all_of: ["approved", "tested"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["approved"] }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test none of pass", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + none_of: ["do-not-run"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["run-agent"] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test none of fail", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + none_of: ["do-not-run"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["do-not-run", "other"] }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test empty labels", () => { + const pred = { type: "label_set_match", fact: "pr_labels" } as PredicateSpec; + const facts = factMap({ pr_labels: [] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test case insensitive labels", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: ["run-agent"], + } as PredicateSpec; + const facts = factMap({ pr_labels: ["Run-Agent", "other"] }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/logical.test.ts b/scripts/ado-script/src/gate/__tests__/ports/logical.test.ts new file mode 100644 index 00000000..99c31e39 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/logical.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestLogicalCombinators", () => { + it("test and all pass", () => { + const pred = { + type: "and", + operands: [ + { type: "equals", fact: "a", value: "1" }, + { type: "equals", fact: "b", value: "2" }, + ], + } satisfies PredicateSpec; + const facts = factMap({ a: "1", b: "2" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test and one fails", () => { + const pred = { + type: "and", + operands: [ + { type: "equals", fact: "a", value: "1" }, + { type: "equals", fact: "b", value: "3" }, + ], + } satisfies PredicateSpec; + const facts = factMap({ a: "1", b: "2" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test or one passes", () => { + const pred = { + type: "or", + operands: [ + { type: "equals", fact: "a", value: "wrong" }, + { type: "equals", fact: "b", value: "2" }, + ], + } satisfies PredicateSpec; + const facts = factMap({ a: "1", b: "2" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test not", () => { + const pred = { + type: "not", + operand: { type: "equals", fact: "a", value: "1" }, + } satisfies PredicateSpec; + const facts = factMap({ a: "2" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/numeric-range.test.ts b/scripts/ado-script/src/gate/__tests__/ports/numeric-range.test.ts new file mode 100644 index 00000000..9725e58b --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/numeric-range.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestNumericRange", () => { + it("test in range", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 5, max: 100 } satisfies PredicateSpec; + const facts = factMap({ changed_file_count: 50 }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test below min", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 5, max: 100 } satisfies PredicateSpec; + const facts = factMap({ changed_file_count: 2 }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test above max", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 5, max: 100 } satisfies PredicateSpec; + const facts = factMap({ changed_file_count: 200 }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test min only", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 3 } satisfies PredicateSpec; + const facts = factMap({ changed_file_count: 10 }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test max only", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", max: 50 } satisfies PredicateSpec; + const facts = factMap({ changed_file_count: 100 }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/predicate-facts.test.ts b/scripts/ado-script/src/gate/__tests__/ports/predicate-facts.test.ts new file mode 100644 index 00000000..4291678c --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/predicate-facts.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { predicateFacts } from "../../predicates.js"; + +describe("TestPredicateFacts", () => { + it("test simple", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "test" } satisfies PredicateSpec; + expect(predicateFacts(pred)).toEqual(["pr_title"]); + }); + + it("test compound", () => { + const pred = { + type: "and", + operands: [ + { type: "equals", fact: "a", value: "1" }, + { type: "glob_match", fact: "b", pattern: "x" }, + ], + } satisfies PredicateSpec; + expect(new Set(predicateFacts(pred))).toEqual(new Set(["a", "b"])); + }); + + it("test not", () => { + const pred = { + type: "not", + operand: { type: "equals", fact: "x", value: "1" }, + } satisfies PredicateSpec; + expect(predicateFacts(pred)).toEqual(["x"]); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/ref-prefix.test.ts b/scripts/ado-script/src/gate/__tests__/ports/ref-prefix.test.ts new file mode 100644 index 00000000..d54f3f10 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/ref-prefix.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { stripRefPrefix } from "../../../shared/env-facts.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestStripRefPrefix", () => { + it("test refs heads", () => { + expect(stripRefPrefix("refs/heads/feature/my-branch")).toBe("feature/my-branch"); + }); + + it("test refs tags", () => { + expect(stripRefPrefix("refs/tags/v1.0.0")).toBe("v1.0.0"); + }); + + it("test refs pull", () => { + expect(stripRefPrefix("refs/pull/42/merge")).toBe("42/merge"); + }); + + it("test no prefix", () => { + expect(stripRefPrefix("main")).toBe("main"); + }); + + it("test pattern stripping in glob", () => { + const pred = { + type: "glob_match", + fact: "source_branch", + pattern: "refs/heads/feature/*", + } satisfies PredicateSpec; + const facts = factMap({ source_branch: "feature/my-branch" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/time-window.test.ts b/scripts/ado-script/src/gate/__tests__/ports/time-window.test.ts new file mode 100644 index 00000000..7da31687 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/time-window.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestTimeWindow", () => { + it("test in window", () => { + const pred = { type: "time_window", start: "09:00", end: "17:00" } satisfies PredicateSpec; + const facts = factMap({ current_utc_minutes: 600 }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test outside window", () => { + const pred = { type: "time_window", start: "09:00", end: "17:00" } satisfies PredicateSpec; + const facts = factMap({ current_utc_minutes: 1200 }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test overnight window in", () => { + const pred = { type: "time_window", start: "22:00", end: "06:00" } satisfies PredicateSpec; + const facts = factMap({ current_utc_minutes: 1380 }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test overnight window out", () => { + const pred = { type: "time_window", start: "22:00", end: "06:00" } satisfies PredicateSpec; + const facts = factMap({ current_utc_minutes: 720 }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); +}); diff --git a/scripts/ado-script/src/gate/__tests__/ports/value-set.test.ts b/scripts/ado-script/src/gate/__tests__/ports/value-set.test.ts new file mode 100644 index 00000000..dc68f8c4 --- /dev/null +++ b/scripts/ado-script/src/gate/__tests__/ports/value-set.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { PredicateSpec } from "../../../shared/types.gen.js"; +import { evaluatePredicate } from "../../predicates.js"; +import { factMap } from "./helpers.js"; + +describe("TestValueInSet", () => { + it("test case insensitive match", () => { + const pred = { + type: "value_in_set", + fact: "author_email", + values: ["Alice@Corp.com"], + case_insensitive: true, + } satisfies PredicateSpec; + const facts = factMap({ author_email: "alice@corp.com" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test case sensitive no match", () => { + const pred = { + type: "value_in_set", + fact: "author_email", + values: ["Alice@Corp.com"], + case_insensitive: false, + } satisfies PredicateSpec; + const facts = factMap({ author_email: "alice@corp.com" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); + + it("test not in set", () => { + const pred = { + type: "value_in_set", + fact: "build_reason", + values: ["PullRequest", "Manual"], + case_insensitive: true, + } satisfies PredicateSpec; + const facts = factMap({ build_reason: "Schedule" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); +}); + +describe("TestValueNotInSet", () => { + it("test not in set", () => { + const pred = { + type: "value_not_in_set", + fact: "author_email", + values: ["bot@noreply.com"], + case_insensitive: true, + } satisfies PredicateSpec; + const facts = factMap({ author_email: "dev@corp.com" }); + expect(evaluatePredicate(pred, facts)).toBe(true); + }); + + it("test in set", () => { + const pred = { + type: "value_not_in_set", + fact: "author_email", + values: ["bot@noreply.com"], + case_insensitive: true, + } satisfies PredicateSpec; + const facts = factMap({ author_email: "bot@noreply.com" }); + expect(evaluatePredicate(pred, facts)).toBe(false); + }); +}); diff --git a/scripts/ado-script/src/gate/bypass.test.ts b/scripts/ado-script/src/gate/bypass.test.ts new file mode 100644 index 00000000..5409767c --- /dev/null +++ b/scripts/ado-script/src/gate/bypass.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runBypass } from "./bypass.js"; +import { _resetCompletedForTesting } from "../shared/vso-logger.js"; +import type { GateSpec } from "../shared/types.gen.js"; + +const baseSpec: GateSpec = { + context: { + build_reason: "PullRequest", + tag_prefix: "pr-gate", + step_name: "prGate", + bypass_label: "Pull Request", + }, + facts: [], + checks: [], +}; + +describe("runBypass", () => { + let writes: string[]; + beforeEach(() => { + writes = []; + _resetCompletedForTesting(); + vi.spyOn(process.stdout, "write").mockImplementation((c: any) => { + writes.push(typeof c === "string" ? c : c.toString()); + return true; + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("returns true and emits SHOULD_RUN=true when build reason mismatches", async () => { + process.env.ADO_BUILD_REASON = "Manual"; + const result = await runBypass(baseSpec); + expect(result).toBe(true); + const joined = writes.join(""); + expect(joined).toContain("Not a Pull Request build"); + expect(joined).toContain("setvariable variable=SHOULD_RUN;isOutput=true]true"); + expect(joined).toContain("##vso[build.addbuildtag]pr-gate:passed"); + expect(joined).toContain("##vso[task.complete result=Succeeded;]"); + }); + + it("returns false when build reason matches (no bypass)", async () => { + process.env.ADO_BUILD_REASON = "PullRequest"; + const result = await runBypass(baseSpec); + expect(result).toBe(false); + expect(writes.join("")).not.toContain("setvariable"); + }); + + it("returns true when ADO_BUILD_REASON is missing (treated as empty string, mismatches)", async () => { + delete process.env.ADO_BUILD_REASON; + const result = await runBypass(baseSpec); + expect(result).toBe(true); + }); + + it("escapes an adversarial bypass_label so it cannot smuggle vso commands", async () => { + process.env.ADO_BUILD_REASON = "Manual"; + const adversarial: GateSpec = { + ...baseSpec, + context: { + ...baseSpec.context, + bypass_label: "##vso[task.complete result=Failed;]X\nY", + }, + }; + const result = await runBypass(adversarial); + expect(result).toBe(true); + // The embedded newline must be encoded so it can't start a fresh + // ADO-interpreted line. The `##vso[` *inside* the label is allowed + // because it isn't at line-start (preceded by "Not a "), but we + // assert structurally that no second `##vso[task.complete result=Failed` + // command was emitted by the label itself — only the legitimate + // Succeeded complete from the bypass path. + const failedCompletes = writes.filter((w) => + w.startsWith("##vso[task.complete result=Failed"), + ); + expect(failedCompletes).toEqual([]); + expect(writes.join("")).toContain("%0A"); // embedded \n encoded + }); +}); diff --git a/scripts/ado-script/src/gate/bypass.ts b/scripts/ado-script/src/gate/bypass.ts new file mode 100644 index 00000000..a3c6adeb --- /dev/null +++ b/scripts/ado-script/src/gate/bypass.ts @@ -0,0 +1,22 @@ +/** + * Bypass logic: when ADO_BUILD_REASON does not match the spec's expected + * build reason (e.g. spec is for PullRequest but build was Manual), the + * gate auto-passes. + */ +import type { GateSpec } from "../shared/types.gen.js"; +import { setOutput, addBuildTag, complete, logInfo } from "../shared/vso-logger.js"; + +export async function runBypass(spec: GateSpec): Promise { + const buildReason = process.env.ADO_BUILD_REASON ?? ""; + if (buildReason !== spec.context.build_reason) { + // Routed through logInfo so the (compiler-controlled but theoretically + // template-influenceable) bypass_label cannot smuggle a `##vso[` prefix + // into the line. Mirrors the Python log line for parity. + logInfo(`Not a ${spec.context.bypass_label} build -- gate passes automatically`); + setOutput("SHOULD_RUN", "true"); + addBuildTag(`${spec.context.tag_prefix}:passed`); + complete("Succeeded"); + return true; + } + return false; +} diff --git a/scripts/ado-script/src/gate/facts.test.ts b/scripts/ado-script/src/gate/facts.test.ts new file mode 100644 index 00000000..1d2a71c6 --- /dev/null +++ b/scripts/ado-script/src/gate/facts.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { GateSpec, FactSpec } from "../shared/types.gen.js"; +import type { PolicyTracker } from "../shared/policy.js"; + +const { + mockReadEnvFact, + mockIsPipelineVarFact, + mockGetPullRequestById, + mockGetPullRequestIterations, + mockGetIterationChanges, +} = vi.hoisted(() => ({ + mockReadEnvFact: vi.fn(), + mockIsPipelineVarFact: vi.fn(), + mockGetPullRequestById: vi.fn(), + mockGetPullRequestIterations: vi.fn(), + mockGetIterationChanges: vi.fn(), +})); + +vi.mock("../shared/env-facts.js", () => ({ + readEnvFact: mockReadEnvFact, + isPipelineVarFact: mockIsPipelineVarFact, +})); + +vi.mock("../shared/ado-client.js", () => ({ + getPullRequestById: mockGetPullRequestById, + getPullRequestIterations: mockGetPullRequestIterations, + getIterationChanges: mockGetIterationChanges, +})); + +import { acquireFacts } from "./facts.js"; + +function fact(kind: string, failure_policy = "fail_closed"): FactSpec { + return { kind, failure_policy, dependencies: [] }; +} + +function gateSpec(facts: FactSpec[]): GateSpec { + return { + facts, + checks: [], + context: { + build_reason: "PullRequest", + bypass_label: "ado-aw:bypass", + step_name: "Gate", + tag_prefix: "ado-aw:gate", + }, + }; +} + +function makeTracker(initiallyUnavailable: string[] = []) { + const unavailable = new Set(initiallyUnavailable); + const recordFactFailure = vi.fn((kind: string) => { + unavailable.add(kind); + return "fail_closed"; + }); + const isUnavailableForAcquisition = vi.fn((kind: string) => unavailable.has(kind)); + const tracker = { + recordFactFailure, + isUnavailableForAcquisition, + } as unknown as PolicyTracker; + return { tracker, recordFactFailure, isUnavailableForAcquisition }; +} + +describe("acquireFacts", () => { + let savedEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + savedEnv = { ...process.env }; + process.env.ADO_PROJECT = "project"; + process.env.ADO_REPO_ID = "repo"; + process.env.ADO_PR_ID = "42"; + + mockReadEnvFact.mockReset(); + mockIsPipelineVarFact.mockReset().mockReturnValue(false); + mockGetPullRequestById.mockReset(); + mockGetPullRequestIterations.mockReset(); + mockGetIterationChanges.mockReset(); + }); + + afterEach(() => { + process.env = savedEnv; + vi.useRealTimers(); + }); + + it("acquires pipeline-variable facts via env-facts", async () => { + mockIsPipelineVarFact.mockImplementation((kind: string) => kind === "pr_title"); + mockReadEnvFact.mockReturnValue("Fix everything"); + const { tracker, recordFactFailure } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("pr_title")]), tracker); + + expect(mockReadEnvFact).toHaveBeenCalledWith("pr_title"); + expect(facts.get("pr_title")).toBe("Fix everything"); + expect(recordFactFailure).not.toHaveBeenCalled(); + }); + + it("records a fact failure when a pipeline-variable env value is missing", async () => { + mockIsPipelineVarFact.mockImplementation((kind: string) => kind === "author_email"); + mockReadEnvFact.mockReturnValue(undefined); + const { tracker, recordFactFailure } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("author_email")]), tracker); + + expect(facts.has("author_email")).toBe(false); + expect(recordFactFailure).toHaveBeenCalledWith( + "author_email", + "value undefined / missing env", + ); + }); + + it("acquires pr_metadata via the ADO client", async () => { + const pr = { pullRequestId: 42, isDraft: false }; + mockGetPullRequestById.mockResolvedValue(pr); + const { tracker, recordFactFailure } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("pr_metadata")]), tracker); + + expect(mockGetPullRequestById).toHaveBeenCalledWith("project", "repo", 42); + expect(facts.get("pr_metadata")).toBe(pr); + expect(recordFactFailure).not.toHaveBeenCalled(); + }); + + it("records a fact failure when pr_metadata acquisition throws", async () => { + mockGetPullRequestById.mockRejectedValue(new Error("SDK exploded")); + const { tracker, recordFactFailure } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("pr_metadata")]), tracker); + + expect(facts.has("pr_metadata")).toBe(false); + expect(recordFactFailure).toHaveBeenCalledWith("pr_metadata", "SDK exploded"); + }); + + it("derives pr_is_draft from cached pr_metadata as true", async () => { + mockGetPullRequestById.mockResolvedValue({ pullRequestId: 42, isDraft: true }); + const { tracker } = makeTracker(); + + const facts = await acquireFacts( + gateSpec([fact("pr_metadata"), fact("pr_is_draft")]), + tracker, + ); + + expect(facts.get("pr_is_draft")).toBe("true"); + }); + + it("derives pr_is_draft from cached pr_metadata as false", async () => { + mockGetPullRequestById.mockResolvedValue({ pullRequestId: 42, isDraft: false }); + const { tracker } = makeTracker(); + + const facts = await acquireFacts( + gateSpec([fact("pr_metadata"), fact("pr_is_draft")]), + tracker, + ); + + expect(facts.get("pr_is_draft")).toBe("false"); + }); + + it("skips pr_is_draft acquisition when dependency propagation marks it unavailable", async () => { + const { tracker, recordFactFailure, isUnavailableForAcquisition } = makeTracker([ + "pr_is_draft", + ]); + + const facts = await acquireFacts(gateSpec([fact("pr_is_draft")]), tracker); + + expect(isUnavailableForAcquisition).toHaveBeenCalledWith("pr_is_draft"); + expect(facts.has("pr_is_draft")).toBe(false); + expect(recordFactFailure).not.toHaveBeenCalled(); + expect(mockGetPullRequestById).not.toHaveBeenCalled(); + }); + + it("derives pr_labels from cached pr_metadata", async () => { + mockGetPullRequestById.mockResolvedValue({ + pullRequestId: 42, + labels: [{ name: "ready" }, { name: "security" }, {}], + }); + const { tracker } = makeTracker(); + + const facts = await acquireFacts( + gateSpec([fact("pr_metadata"), fact("pr_labels")]), + tracker, + ); + + expect(facts.get("pr_labels")).toEqual(["ready", "security", ""]); + }); + + it("acquires changed_files from the last PR iteration and strips leading slashes", async () => { + mockGetPullRequestIterations.mockResolvedValue([{ id: 3 }, { id: 7 }]); + mockGetIterationChanges.mockResolvedValue({ + changeEntries: [ + { item: { path: "/src/main.ts" } }, + { item: { path: "docs/readme.md" } }, + { item: { path: "" } }, + {}, + ], + }); + const { tracker } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("changed_files")]), tracker); + + expect(mockGetPullRequestIterations).toHaveBeenCalledWith("project", "repo", 42); + expect(mockGetIterationChanges).toHaveBeenCalledWith("project", "repo", 42, 7); + expect(facts.get("changed_files")).toEqual(["src/main.ts", "docs/readme.md"]); + }); + + it("returns an empty changed_files list when the PR has no iterations", async () => { + mockGetPullRequestIterations.mockResolvedValue([]); + const { tracker } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("changed_files")]), tracker); + + expect(facts.get("changed_files")).toEqual([]); + expect(mockGetIterationChanges).not.toHaveBeenCalled(); + }); + + it("derives changed_file_count from cached changed_files", async () => { + mockGetPullRequestIterations.mockResolvedValue([{ id: 1 }]); + mockGetIterationChanges.mockResolvedValue({ + changeEntries: [ + { item: { path: "/a.ts" } }, + { item: { path: "/b.ts" } }, + { item: { path: "/c.ts" } }, + ], + }); + const { tracker } = makeTracker(); + + const facts = await acquireFacts( + gateSpec([fact("changed_files"), fact("changed_file_count")]), + tracker, + ); + + expect(facts.get("changed_file_count")).toBe(3); + }); + + it("computes current_utc_minutes as a value in the current UTC day", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-02T12:34:56Z")); + const { tracker } = makeTracker(); + + const facts = await acquireFacts(gateSpec([fact("current_utc_minutes")]), tracker); + + const value = facts.get("current_utc_minutes"); + const now = new Date(Date.now()); + const expected = now.getUTCHours() * 60 + now.getUTCMinutes(); + expect(typeof value).toBe("number"); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1439); + expect(value).toBe(expected); + }); +}); diff --git a/scripts/ado-script/src/gate/facts.ts b/scripts/ado-script/src/gate/facts.ts new file mode 100644 index 00000000..031dc8cd --- /dev/null +++ b/scripts/ado-script/src/gate/facts.ts @@ -0,0 +1,120 @@ +/** + * Acquire runtime facts referenced by the gate spec. + * + * Pipeline-variable facts + * come from `process.env`; PR-derived facts come from the ADO REST API + * via `shared/ado-client.ts`. + */ +import type { GateSpec, FactSpec } from "../shared/types.gen.js"; +import type { PolicyTracker } from "../shared/policy.js"; +import { readEnvFact, isPipelineVarFact } from "../shared/env-facts.js"; +import * as adoClient from "../shared/ado-client.js"; + +export async function acquireFacts( + spec: GateSpec, + tracker: PolicyTracker, +): Promise> { + const facts = new Map(); + const project = process.env.ADO_PROJECT ?? ""; + const repoId = process.env.ADO_REPO_ID ?? ""; + const prIdRaw = process.env.ADO_PR_ID ?? ""; + const prId = prIdRaw ? Number(prIdRaw) : NaN; + + for (const fs of spec.facts) { + if (tracker.isUnavailableForAcquisition(fs.kind)) { + continue; + } + + try { + const value = await acquireOne(fs, facts, { project, repoId, prId }); + if (value === undefined) { + tracker.recordFactFailure(fs.kind, "value undefined / missing env"); + } else { + facts.set(fs.kind, value); + } + } catch (e) { + tracker.recordFactFailure(fs.kind, (e as Error).message); + } + } + + return facts; +} + +interface AdoCtx { + project: string; + repoId: string; + prId: number; +} + +async function acquireOne( + fs: FactSpec, + facts: Map, + ctx: AdoCtx, +): Promise { + const kind = fs.kind; + if (isPipelineVarFact(kind)) { + return readEnvFact(kind); + } + + switch (kind) { + case "pr_metadata": { + requireAdoCtx(ctx, "pr_metadata"); + return adoClient.getPullRequestById(ctx.project, ctx.repoId, ctx.prId); + } + case "pr_is_draft": { + const md = facts.get("pr_metadata") as { isDraft?: boolean } | undefined; + if (!md) return undefined; + return md.isDraft ? "true" : "false"; + } + case "pr_labels": { + const md = facts.get("pr_metadata") as + | { labels?: { name?: string }[] } + | undefined; + const labels = md?.labels ?? []; + return labels.map((l) => l.name ?? ""); + } + case "changed_files": { + requireAdoCtx(ctx, "changed_files"); + const iters = await adoClient.getPullRequestIterations( + ctx.project, + ctx.repoId, + ctx.prId, + ); + if (!iters || iters.length === 0) return []; + const last = iters[iters.length - 1]!; + const lastId = last.id; + if (typeof lastId !== "number") return []; + const changes = await adoClient.getIterationChanges( + ctx.project, + ctx.repoId, + ctx.prId, + lastId, + ); + const entries = + (changes as { changeEntries?: Array<{ item?: { path?: string } }> }) + .changeEntries ?? []; + return entries + .map((e) => e.item?.path ?? "") + .filter((p) => !!p) + .map((p) => p.replace(/^\/+/, "")); + } + case "changed_file_count": { + const cf = facts.get("changed_files"); + return Array.isArray(cf) ? cf.length : 0; + } + case "current_utc_minutes": { + const now = new Date(); + return now.getUTCHours() * 60 + now.getUTCMinutes(); + } + default: + throw new Error(`Unknown fact kind: ${kind}`); + } +} + +function requireAdoCtx(ctx: AdoCtx, kind: string): void { + if (!ctx.project || !ctx.repoId || !Number.isFinite(ctx.prId)) { + throw new Error( + `Missing ADO env vars (ADO_PROJECT/ADO_REPO_ID/ADO_PR_ID) required for fact '${kind}'`, + ); + } +} diff --git a/scripts/ado-script/src/gate/index.ts b/scripts/ado-script/src/gate/index.ts new file mode 100644 index 00000000..80395e29 --- /dev/null +++ b/scripts/ado-script/src/gate/index.ts @@ -0,0 +1,96 @@ +/** + * Gate evaluator entry point. + * + * Reads a base64-encoded `GateSpec` from `GATE_SPEC` env, runs the bypass + * logic, acquires runtime facts, evaluates predicates, and emits a single + * `SHOULD_RUN` setvariable. On failure to acquire facts or evaluate + * predicates, logs via the VSO logger and exits non-zero. + */ +import type { GateSpec } from "../shared/types.gen.js"; +import { runBypass } from "./bypass.js"; +import { acquireFacts } from "./facts.js"; +import { evaluatePredicates, validatePredicateTree } from "./predicates.js"; +import { selfCancelIfRequested } from "./selfcancel.js"; +import { PolicyTracker } from "../shared/policy.js"; +import { setOutput, complete, logError } from "../shared/vso-logger.js"; + +// Cap the decoded spec at 256 KiB. ADO pipeline env vars are bounded +// (typically <32 KiB), so a legitimate spec is two orders of magnitude +// smaller than this. The cap exists to short-circuit pathological payloads +// (e.g. a deeply nested or extremely long JSON blob) before they reach +// JSON.parse, which would otherwise allocate aggressively and could stall +// the gate step. +export const MAX_SPEC_DECODED_BYTES = 256 * 1024; + +async function main(): Promise { + const raw = process.env.GATE_SPEC; + if (!raw) { + logError("GATE_SPEC env var missing"); + complete("Failed"); + process.exit(1); + } + + let spec: GateSpec; + try { + const decoded = Buffer.from(raw, "base64"); + if (decoded.length > MAX_SPEC_DECODED_BYTES) { + logError( + `GATE_SPEC decoded size ${decoded.length} bytes exceeds cap of ${MAX_SPEC_DECODED_BYTES} bytes`, + ); + complete("Failed"); + process.exit(1); + } + spec = JSON.parse(decoded.toString("utf8")) as GateSpec; + } catch (e) { + logError(`Failed to decode GATE_SPEC: ${(e as Error).message}`); + complete("Failed"); + process.exit(1); + } + + // Pre-flight: walk the predicate tree and reject any unknown `type` + // discriminant *before* fact acquisition. Without this, an unknown + // predicate is only surfaced when evaluatePredicate is reached — and + // if the required fact is unavailable, evaluatePredicate is never + // called, masking the version drift. + // + // This deliberately runs BEFORE runBypass so a malformed spec fails fast + // regardless of build reason. A bypassed Manual build paying the (<10 ms) + // tree-walk cost is the right trade: a Manual build with a broken spec + // would otherwise mask the breakage until a subsequent PR run. + try { + for (const check of spec.checks ?? []) { + validatePredicateTree(check.predicate); + } + } catch (e) { + logError((e as Error).message); + complete("Failed"); + process.exit(1); + } + + if (await runBypass(spec)) { + return; // bypass handler set SHOULD_RUN and emitted complete + } + + const tracker = new PolicyTracker(spec.facts); + const facts = await acquireFacts(spec, tracker); + const results = evaluatePredicates(spec, facts, tracker); + + const shouldRun = results.every((r) => r === "pass" || r === "skip"); + setOutput("SHOULD_RUN", shouldRun ? "true" : "false"); + + if (!shouldRun) { + await selfCancelIfRequested(spec); + } + + const sm = tracker.summary(); + complete( + shouldRun ? "Succeeded" : "SucceededWithIssues", + `gate: passed=${sm.passed} failed=${sm.failed} skipped=${sm.skipped}`, + ); +} + +main().catch((e) => { + logError(`gate evaluator crashed: ${(e as Error).message}`); + complete("Failed"); + process.exit(1); +}); diff --git a/scripts/ado-script/src/gate/predicates.test.ts b/scripts/ado-script/src/gate/predicates.test.ts new file mode 100644 index 00000000..15e631e3 --- /dev/null +++ b/scripts/ado-script/src/gate/predicates.test.ts @@ -0,0 +1,564 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { GateSpec, PredicateSpec } from "../shared/types.gen.js"; +import { PolicyTracker } from "../shared/policy.js"; +import { evaluatePredicate, evaluatePredicates, predicateFacts } from "./predicates.js"; + +function factMap(values: Record): Map { + return new Map(Object.entries(values)); +} + +function evalWith(p: PredicateSpec, values: Record): boolean { + return evaluatePredicate(p, factMap(values)); +} + +function gateSpec(checks: GateSpec["checks"], facts: GateSpec["facts"] = []): GateSpec { + return { + checks, + facts, + context: { + build_reason: "PullRequest", + bypass_label: "run-anyway", + step_name: "Gate", + tag_prefix: "gate", + }, + }; +} + +describe("evaluatePredicate", () => { + describe("glob_match", () => { + it("matches a positive glob", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "*[review]*" } satisfies PredicateSpec; + expect(evalWith(pred, { pr_title: "feat: add feature [review]" })).toBe(true); + }); + + it("rejects a negative glob", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "*[review]*" } satisfies PredicateSpec; + expect(evalWith(pred, { pr_title: "feat: add feature" })).toBe(false); + }); + + it("strips ref prefixes from branch patterns", () => { + const pred = { + type: "glob_match", + fact: "source_branch", + pattern: "refs/heads/feature/*", + } satisfies PredicateSpec; + expect(evalWith(pred, { source_branch: "feature/my-branch" })).toBe(true); + }); + }); + + describe("equals", () => { + it("matches equal strings", () => { + const pred = { type: "equals", fact: "pr_is_draft", value: "false" } satisfies PredicateSpec; + expect(evalWith(pred, { pr_is_draft: "false" })).toBe(true); + }); + + it("rejects different strings", () => { + const pred = { type: "equals", fact: "pr_is_draft", value: "false" } satisfies PredicateSpec; + expect(evalWith(pred, { pr_is_draft: "true" })).toBe(false); + }); + }); + + describe("value_in_set", () => { + it("matches case-sensitive values", () => { + const pred = { + type: "value_in_set", + fact: "author_email", + values: ["Alice@Corp.com"], + case_insensitive: false, + } satisfies PredicateSpec; + expect(evalWith(pred, { author_email: "Alice@Corp.com" })).toBe(true); + }); + + it("rejects case-sensitive values with different case", () => { + const pred = { + type: "value_in_set", + fact: "author_email", + values: ["Alice@Corp.com"], + case_insensitive: false, + } satisfies PredicateSpec; + expect(evalWith(pred, { author_email: "alice@corp.com" })).toBe(false); + }); + + it("matches case-insensitive values", () => { + const pred = { + type: "value_in_set", + fact: "author_email", + values: ["Alice@Corp.com"], + case_insensitive: true, + } satisfies PredicateSpec; + expect(evalWith(pred, { author_email: "alice@corp.com" })).toBe(true); + }); + + it("rejects absent case-insensitive values", () => { + const pred = { + type: "value_in_set", + fact: "build_reason", + values: ["PullRequest", "Manual"], + case_insensitive: true, + } satisfies PredicateSpec; + expect(evalWith(pred, { build_reason: "Schedule" })).toBe(false); + }); + }); + + describe("value_not_in_set", () => { + it("passes when the value is not present", () => { + const pred = { + type: "value_not_in_set", + fact: "author_email", + values: ["bot@noreply.com"], + case_insensitive: true, + } satisfies PredicateSpec; + expect(evalWith(pred, { author_email: "dev@corp.com" })).toBe(true); + }); + + it("fails when the value is present", () => { + const pred = { + type: "value_not_in_set", + fact: "author_email", + values: ["bot@noreply.com"], + case_insensitive: true, + } satisfies PredicateSpec; + expect(evalWith(pred, { author_email: "BOT@noreply.com" })).toBe(false); + }); + }); + + describe("numeric_range", () => { + it("passes values in range", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 5, max: 100 } satisfies PredicateSpec; + expect(evalWith(pred, { changed_file_count: 50 })).toBe(true); + }); + + it("fails values below min", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 5, max: 100 } satisfies PredicateSpec; + expect(evalWith(pred, { changed_file_count: 2 })).toBe(false); + }); + + it("fails values above max", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 5, max: 100 } satisfies PredicateSpec; + expect(evalWith(pred, { changed_file_count: 200 })).toBe(false); + }); + + it("passes with no min", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", max: 50 } satisfies PredicateSpec; + expect(evalWith(pred, { changed_file_count: 10 })).toBe(true); + }); + + it("passes with no max", () => { + const pred = { type: "numeric_range", fact: "changed_file_count", min: 3 } satisfies PredicateSpec; + expect(evalWith(pred, { changed_file_count: 10 })).toBe(true); + }); + }); + + describe("time_window", () => { + it.each([ + ["passes inside a same-day window", 600, true], + ["fails outside a same-day window", 1200, false], + ["passes late in an overnight window", 1380, true], + ["passes early in an overnight window", 300, true], + ["fails midday in an overnight window", 720, false], + ["fails at the exclusive overnight end", 360, false], + ])("%s", (_name, current, expected) => { + const pred = + current === 600 || current === 1200 + ? ({ type: "time_window", start: "09:00", end: "17:00" } satisfies PredicateSpec) + : ({ type: "time_window", start: "22:00", end: "06:00" } satisfies PredicateSpec); + expect(evalWith(pred, { current_utc_minutes: current })).toBe(expected); + }); + }); + + describe("label_set_match", () => { + it("matches any_of case-insensitively", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: ["run-agent"], + all_of: [], + none_of: [], + } satisfies PredicateSpec; + expect(evalWith(pred, { pr_labels: ["Run-Agent", "other"] })).toBe(true); + }); + + it("requires all_of labels", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: [], + all_of: ["approved", "tested"], + none_of: [], + } satisfies PredicateSpec; + expect(evalWith(pred, { pr_labels: ["approved", "tested", "other"] })).toBe(true); + }); + + it("rejects none_of labels", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: [], + all_of: [], + none_of: ["do-not-run"], + } satisfies PredicateSpec; + expect(evalWith(pred, { pr_labels: ["Do-Not-Run", "other"] })).toBe(false); + }); + + it("supports mixed any_of, all_of, and none_of from newline strings", () => { + const pred = { + type: "label_set_match", + fact: "pr_labels", + any_of: ["run-agent"], + all_of: ["approved"], + none_of: ["blocked"], + } satisfies PredicateSpec; + expect(evalWith(pred, { pr_labels: "Run-Agent\napproved\nother" })).toBe(true); + }); + }); + + describe("file_glob_match", () => { + it("matches include-only filters", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/*.ts"], + exclude: [], + } satisfies PredicateSpec; + expect(evalWith(pred, { changed_files: ["src/main.ts", "docs/readme.md"] })).toBe(true); + }); + + it("passes exclude-only filters with an empty file list", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: [], + exclude: ["src/generated/*"], + } satisfies PredicateSpec; + expect(evalWith(pred, { changed_files: [] })).toBe(true); + }); + + it("matches mixed include and exclude filters", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/*.ts"], + exclude: ["src/*.test.ts"], + } satisfies PredicateSpec; + expect(evalWith(pred, { changed_files: ["src/main.test.ts", "src/main.ts"] })).toBe(true); + }); + + it("fails include filters with an empty file list", () => { + const pred = { + type: "file_glob_match", + fact: "changed_files", + include: ["src/*.ts"], + exclude: [], + } satisfies PredicateSpec; + expect(evalWith(pred, { changed_files: [] })).toBe(false); + }); + }); + + describe("and", () => { + it("passes when all operands pass", () => { + const pred = { + type: "and", + operands: [ + { type: "equals", fact: "a", value: "1" }, + { type: "equals", fact: "b", value: "2" }, + ], + } satisfies PredicateSpec; + expect(evalWith(pred, { a: "1", b: "2" })).toBe(true); + }); + + it("fails when one operand fails", () => { + const pred = { + type: "and", + operands: [ + { type: "equals", fact: "a", value: "1" }, + { type: "equals", fact: "b", value: "3" }, + ], + } satisfies PredicateSpec; + expect(evalWith(pred, { a: "1", b: "2" })).toBe(false); + }); + }); + + describe("or", () => { + it("passes when any operand passes", () => { + const pred = { + type: "or", + operands: [ + { type: "equals", fact: "a", value: "wrong" }, + { type: "equals", fact: "b", value: "2" }, + ], + } satisfies PredicateSpec; + expect(evalWith(pred, { a: "1", b: "2" })).toBe(true); + }); + + it("fails when all operands fail", () => { + const pred = { + type: "or", + operands: [ + { type: "equals", fact: "a", value: "wrong" }, + { type: "equals", fact: "b", value: "wrong" }, + ], + } satisfies PredicateSpec; + expect(evalWith(pred, { a: "1", b: "2" })).toBe(false); + }); + }); + + describe("not", () => { + it("inverts a passing operand", () => { + const pred = { + type: "not", + operand: { type: "equals", fact: "a", value: "1" }, + } satisfies PredicateSpec; + expect(evalWith(pred, { a: "1" })).toBe(false); + }); + + it("inverts a failing operand", () => { + const pred = { + type: "not", + operand: { type: "equals", fact: "a", value: "1" }, + } satisfies PredicateSpec; + expect(evalWith(pred, { a: "2" })).toBe(true); + }); + }); +}); + +describe("predicateFacts", () => { + it("collects a simple fact", () => { + const pred = { type: "glob_match", fact: "pr_title", pattern: "test" } satisfies PredicateSpec; + expect(predicateFacts(pred)).toEqual(["pr_title"]); + }); + + it("collects compound facts recursively", () => { + const pred = { + type: "and", + operands: [ + { type: "equals", fact: "a", value: "1" }, + { + type: "or", + operands: [ + { type: "glob_match", fact: "b", pattern: "x" }, + { type: "not", operand: { type: "equals", fact: "c", value: "3" } }, + ], + }, + ], + } satisfies PredicateSpec; + expect(new Set(predicateFacts(pred))).toEqual(new Set(["a", "b", "c"])); + }); + + it("adds current_utc_minutes for time windows", () => { + const pred = { type: "time_window", start: "09:00", end: "17:00" } satisfies PredicateSpec; + expect(predicateFacts(pred)).toEqual(["current_utc_minutes"]); + }); +}); + +describe("unknown predicate fallback", () => { + let stderrWrites: string[]; + + beforeEach(() => { + stderrWrites = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { + stderrWrites.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("fails closed and emits a warning when the predicate type is unknown", () => { + // Forge a spec a future compiler might emit but this gate.js does + // not recognise. The default branch should NOT silently auto-pass. + const pred = { type: "future_predicate", fact: "x" } as unknown as PredicateSpec; + const result = evaluatePredicate(pred, factMap({})); + expect(result).toBe(false); + expect( + stderrWrites.some( + (w) => + w.includes("##vso[task.logissue type=warning;]") && w.includes("future_predicate"), + ), + ).toBe(true); + }); +}); + +describe("evaluatePredicates", () => { + let writes: string[]; + + beforeEach(() => { + writes = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { + writes.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns one result per check, records summary, and emits tags for failures", () => { + const spec = gateSpec( + [ + { + name: "title", + tag_suffix: "title", + predicate: { type: "equals", fact: "pr_title", value: "ok" }, + }, + { + name: "reason", + tag_suffix: "reason", + predicate: { type: "equals", fact: "build_reason", value: "PullRequest" }, + }, + ], + [ + { kind: "pr_title", failure_policy: "fail_closed", dependencies: [] }, + { kind: "build_reason", failure_policy: "fail_closed", dependencies: [] }, + ], + ); + const tracker = new PolicyTracker(spec.facts); + + const results = evaluatePredicates( + spec, + factMap({ pr_title: "ok", build_reason: "Manual" }), + tracker, + ); + + expect(results).toEqual(["pass", "fail"]); + expect(tracker.summary()).toEqual({ passed: 1, failed: 1, skipped: 0 }); + expect(writes).toContain("##vso[build.addbuildtag]gate:reason\n"); + }); + + it("uses tracker verdicts for unavailable facts before evaluating predicates", () => { + const spec = gateSpec( + [ + { + name: "missing-title", + tag_suffix: "missing-title", + predicate: { type: "equals", fact: "pr_title", value: "ok" }, + }, + ], + [{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] }], + ); + const tracker = new PolicyTracker(spec.facts); + tracker.recordFactFailure("pr_title", "test failure"); + + const results = evaluatePredicates(spec, factMap({ pr_title: "ok" }), tracker); + + expect(results).toEqual(["fail"]); + expect(tracker.summary()).toEqual({ passed: 0, failed: 1, skipped: 0 }); + expect(writes).toContain("##vso[build.addbuildtag]gate:missing-title\n"); + }); +}); + +describe("validatePredicateTree", () => { + it("accepts every known predicate type at the root", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + const samples: PredicateSpec[] = [ + { type: "glob_match", fact: "pr_title", pattern: "*" }, + { type: "equals", fact: "pr_title", value: "ok" }, + { type: "value_in_set", fact: "pr_title", values: ["a"], case_insensitive: false }, + { type: "value_not_in_set", fact: "pr_title", values: ["a"], case_insensitive: false }, + { type: "numeric_range", fact: "current_utc_minutes", min: 0, max: 1440 }, + { type: "time_window", start: "09:00", end: "17:00" }, + { type: "label_set_match", fact: "pr_labels", any_of: ["x"], all_of: [], none_of: [] }, + { type: "file_glob_match", fact: "changed_files", include: ["**/*.rs"], exclude: [] }, + { type: "not", operand: { type: "equals", fact: "pr_title", value: "ok" } }, + ]; + for (const p of samples) { + expect(() => validatePredicateTree(p)).not.toThrow(); + } + }); + + it("rejects an unknown type at the root", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + expect(() => validatePredicateTree({ type: "bogus" } as unknown as PredicateSpec)).toThrow( + /Unknown predicate type 'bogus'/, + ); + }); + + it("rejects an unknown type nested under 'and'", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + const spec = { + type: "and", + operands: [ + { type: "equals", fact: "pr_title", value: "ok" }, + { type: "ref_glob_match", fact: "source_branch", pattern: "feature/*" }, + ], + } as unknown as PredicateSpec; + expect(() => validatePredicateTree(spec)).toThrow(/Unknown predicate type 'ref_glob_match'/); + }); + + it("rejects an unknown type nested under 'or'", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + const spec = { + type: "or", + operands: [{ type: "made_up_predicate", fact: "x", value: "y" }], + } as unknown as PredicateSpec; + expect(() => validatePredicateTree(spec)).toThrow(/Unknown predicate type 'made_up_predicate'/); + }); + + it("rejects an unknown type nested under 'not'", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + const spec = { + type: "not", + operand: { type: "future_thing", fact: "x", value: "y" }, + } as unknown as PredicateSpec; + expect(() => validatePredicateTree(spec)).toThrow(/Unknown predicate type 'future_thing'/); + }); + + it("rejects 'and' missing its operands array", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + expect(() => validatePredicateTree({ type: "and" } as unknown as PredicateSpec)).toThrow( + /missing required 'operands' array/, + ); + }); + + it("rejects 'not' missing its operand", async () => { + const { validatePredicateTree } = await import("./predicates.js"); + expect(() => validatePredicateTree({ type: "not" } as unknown as PredicateSpec)).toThrow( + /missing required 'operand'/, + ); + }); + + // Drift guard: every type accepted by validatePredicateTree must also be + // handled by the evaluatePredicate switch (and vice-versa). If a new + // predicate variant is added to one without the other, this test fails. + it("KNOWN_PREDICATE_TYPES stays in sync with evaluatePredicate switch", async () => { + const writes: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((s) => { + writes.push(String(s)); + return true; + }); + + // One representative for every known predicate type. If the union grows, + // add the new variant here too — that's the point of the test. + const samples: PredicateSpec[] = [ + { type: "glob_match", fact: "pr_title", pattern: "*" }, + { type: "equals", fact: "pr_title", value: "" }, + { type: "value_in_set", fact: "pr_title", values: ["x"], case_insensitive: false }, + { type: "value_not_in_set", fact: "pr_title", values: ["x"], case_insensitive: false }, + { type: "numeric_range", fact: "current_utc_minutes", min: 0, max: 1440 }, + { type: "time_window", start: "00:00", end: "23:59" }, + { type: "label_set_match", fact: "pr_labels", any_of: ["x"], all_of: [], none_of: [] }, + { type: "file_glob_match", fact: "changed_files", include: ["**/*.rs"], exclude: [] }, + { type: "and", operands: [{ type: "equals", fact: "pr_title", value: "" }] }, + { type: "or", operands: [{ type: "equals", fact: "pr_title", value: "" }] }, + { type: "not", operand: { type: "equals", fact: "pr_title", value: "" } }, + ]; + + const factsForEval = factMap({ + pr_title: "x", + pr_labels: ["x"], + current_utc_minutes: 720, + changed_files: ["foo.rs"], + }); + + for (const p of samples) { + // (a) pre-flight must accept it + expect(() => evaluatePredicate(p, factsForEval)).not.toThrow(); + // (b) at evaluation time, the fail-closed "unknown predicate type" + // warning must not appear — that warning is the unique signature + // of the default arm in evaluatePredicate. + } + + const unknownWarnings = writes.filter((w) => w.includes("Unknown predicate type")); + expect(unknownWarnings).toEqual([]); + }); +}); diff --git a/scripts/ado-script/src/gate/predicates.ts b/scripts/ado-script/src/gate/predicates.ts new file mode 100644 index 00000000..b054dd5c --- /dev/null +++ b/scripts/ado-script/src/gate/predicates.ts @@ -0,0 +1,327 @@ +/** + * Predicate evaluation. + * + * Each predicate variant + * is ported exactly; behavioral parity is verified by ports of the + * existing Python parametric tests. + */ +import type { GateSpec, PredicateSpec } from "../shared/types.gen.js"; +import type { PolicyTracker } from "../shared/policy.js"; +import { addBuildTag, logWarning } from "../shared/vso-logger.js"; +import { stripRefPrefix, BRANCH_FACTS } from "../shared/env-facts.js"; + +type CheckResult = "pass" | "fail" | "skip"; + +// Set.has(p.fact) is rejected because p.fact is `string`. The +// type-system narrowing isn't useful here — we just want runtime membership. +const isBranchFact = (fact: string): boolean => + (BRANCH_FACTS as ReadonlySet).has(fact); + +// BRANCH_FACTS is sourced from env-facts.ts so the read-time strip (in +// readEnvFact) and the match-time strip (here in glob_match below) cannot +// drift. Adding a new branch-shaped fact requires updating exactly one set. + +export function evaluatePredicates( + spec: GateSpec, + facts: Map, + tracker: PolicyTracker, +): CheckResult[] { + const results: CheckResult[] = []; + + for (const check of spec.checks) { + const refs = predicateFacts(check.predicate); + const policyVerdict = tracker.verdictForMissingFacts(refs); + let result: CheckResult; + + if (policyVerdict === "evaluate") { + result = evaluatePredicate(check.predicate, facts) ? "pass" : "fail"; + } else { + result = policyVerdict; + } + + if (result === "fail") { + addBuildTag(`${spec.context.tag_prefix}:${check.tag_suffix}`); + } + tracker.recordCheckResult(result); + results.push(result); + } + + return results; +} + +export function predicateFacts(p: PredicateSpec): string[] { + const out = new Set(); + collectFacts(p, out); + return [...out]; +} + +function collectFacts(p: PredicateSpec, out: Set): void { + const fact = (p as { fact?: unknown }).fact; + if (typeof fact === "string") { + out.add(fact); + } + + if (p.type === "time_window") { + out.add("current_utc_minutes"); + } + + if (p.type === "and" || p.type === "or") { + for (const sub of p.operands) collectFacts(sub, out); + } + + if (p.type === "not") { + collectFacts(p.operand, out); + } +} + +export function evaluatePredicate(p: PredicateSpec, facts: Map): boolean { + switch (p.type) { + case "glob_match": { + const value = String(facts.get(p.fact) ?? ""); + const pattern = isBranchFact(p.fact) ? stripRefPrefix(p.pattern) : p.pattern; + return globMatch(value, pattern); + } + case "equals": + return String(facts.get(p.fact) ?? "") === p.value; + case "value_in_set": { + const value = String(facts.get(p.fact) ?? ""); + if (p.case_insensitive) { + const lower = p.values.map((v) => v.toLowerCase()); + return lower.includes(value.toLowerCase()); + } + return p.values.includes(value); + } + case "value_not_in_set": { + const value = String(facts.get(p.fact) ?? ""); + if (p.case_insensitive) { + const lower = p.values.map((v) => v.toLowerCase()); + return !lower.includes(value.toLowerCase()); + } + return !p.values.includes(value); + } + case "numeric_range": { + const raw = facts.get(p.fact); + // Fail-closed if the fact is missing or non-numeric. The PolicyTracker + // normally short-circuits before evaluatePredicate is reached for a + // missing fact, but defending here independently means a future change + // to the policy gate can't silently cause a missing fact to satisfy a + // range that includes 0 (the previous `?? 0` default did exactly that). + if (raw === undefined || raw === null) return false; + const value = Number(raw); + if (!Number.isFinite(value)) return false; + if (p.min !== undefined && p.min !== null && value < p.min) return false; + if (p.max !== undefined && p.max !== null && value > p.max) return false; + return true; + } + case "time_window": { + const current = Number(facts.get("current_utc_minutes") ?? 0); + const start = parseHm(p.start); + const end = parseHm(p.end); + if (start <= end) return current >= start && current < end; + return current >= start || current < end; + } + case "label_set_match": { + const labels = stringsFromFact(facts.get(p.fact) ?? []); + const labelsLower = labels.map((l) => l.toLowerCase()); + const anyOf = p.any_of ?? []; + const allOf = p.all_of ?? []; + const noneOf = p.none_of ?? []; + if (anyOf.length > 0 && !anyOf.some((a) => labelsLower.includes(a.toLowerCase()))) { + return false; + } + if (allOf.length > 0 && !allOf.every((a) => labelsLower.includes(a.toLowerCase()))) { + return false; + } + if (noneOf.length > 0 && noneOf.some((n) => labelsLower.includes(n.toLowerCase()))) { + return false; + } + return true; + } + case "file_glob_match": { + const files = stringsFromFact(facts.get(p.fact) ?? []); + const includes = p.include ?? []; + const excludes = p.exclude ?? []; + if (files.length === 0) { + return includes.length === 0; + } + for (const f of files) { + const inc = includes.length === 0 || includes.some((pat) => globMatch(f, pat)); + const exc = excludes.some((pat) => globMatch(f, pat)); + if (inc && !exc) return true; + } + return false; + } + case "and": + return p.operands.every((sub) => evaluatePredicate(sub, facts)); + case "or": + return p.operands.some((sub) => evaluatePredicate(sub, facts)); + case "not": + return !evaluatePredicate(p.operand, facts); + default: { + // Unknown predicate type — likely a newer compiler emitted a spec + // a bundled gate.js doesn't recognise. Surface in pipeline logs; + // fail-closed so the missing logic doesn't silently auto-pass. + const unknownType = (p as { type?: unknown }).type; + logWarning( + `Unknown predicate type '${String(unknownType)}'; failing closed. ` + + "Update scripts/ado-script/dist/gate/index.js (or the bundled ado-script.zip) to a " + + "release that supports this predicate.", + ); + return false; + } + } +} + +function stringsFromFact(raw: unknown): string[] { + if (Array.isArray(raw)) return raw.map(String); + return String(raw) + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); +} + +// Glob-hardening caps. Patterns come from the Rust IR (the compiler is +// the trust boundary), so these are belt-and-braces caps that bound +// pathological worst-case behaviour rather than a defence against a +// realistic attacker. A 1024-char glob pattern is already nonsensical; +// 64 `*` wildcards in one pattern produces a regex that backtracks +// catastrophically against non-matching inputs. +const MAX_GLOB_PATTERN_LEN = 1024; +const MAX_GLOB_WILDCARDS = 64; +const MAX_GLOB_CACHE_ENTRIES = 1024; + +// Pre-compiled regex cache. The gate process is one-shot per pipeline run, +// so an unbounded cache would be fine for memory — we still cap defensively +// so a future caller in a longer-lived process doesn't bloat indefinitely. +// +// IMPORTANT: the cache key is `pattern` alone. The compiled RegExp uses the +// fixed `"s"` flag (dotall). If a future caller wants to vary flags (e.g. +// case-insensitive globs), it must change the cache key to include flags — +// e.g. `${pattern}|${flags}` — otherwise the cache will silently return a +// regex compiled with the wrong flags for the same pattern string. +const globRegexCache = new Map(); + +function compileGlobRegex(pattern: string): RegExp | null { + const cached = globRegexCache.get(pattern); + if (cached !== undefined) return cached; + + if (pattern.length > MAX_GLOB_PATTERN_LEN) { + logWarning( + `globMatch: pattern length ${pattern.length} exceeds cap ${MAX_GLOB_PATTERN_LEN}; rejecting (fail-closed)`, + ); + cacheGlobResult(pattern, null); + return null; + } + + let wildcardCount = 0; + for (let i = 0; i < pattern.length; i++) { + if (pattern.charCodeAt(i) === 42 /* '*' */) { + wildcardCount++; + if (wildcardCount > MAX_GLOB_WILDCARDS) { + logWarning( + `globMatch: pattern contains more than ${MAX_GLOB_WILDCARDS} '*' wildcards; rejecting (fail-closed)`, + ); + cacheGlobResult(pattern, null); + return null; + } + } + } + + if (/\[/.test(pattern)) { + logWarning( + `globMatch: pattern "${pattern}" contains "[" which is treated as a literal, not a character class`, + ); + } + + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const body = escaped.replace(/\\\*/g, ".*").replace(/\\\?/g, "."); + const compiled = new RegExp(`^${body}$`, "s"); + cacheGlobResult(pattern, compiled); + return compiled; +} + +function cacheGlobResult(pattern: string, compiled: RegExp | null): void { + if (globRegexCache.size >= MAX_GLOB_CACHE_ENTRIES) { + // Drop the oldest entry. Map iteration is insertion-ordered in JS, + // so .keys().next().value gives us the oldest. + const oldest = globRegexCache.keys().next().value; + if (oldest !== undefined) globRegexCache.delete(oldest); + } + globRegexCache.set(pattern, compiled); +} + +/** For tests only: reset the glob regex cache. */ +export function _resetGlobCacheForTesting(): void { + globRegexCache.clear(); +} + +function globMatch(value: string, pattern: string): boolean { + // Glob → regex: only `*` (any chars) and `?` (single char) are + // recognised. Bracket expressions like `[abc]` are treated as literals. + // The IR currently never emits bracket patterns; warn if one appears so + // a compiler/evaluator parity drift is caught early. + const regex = compileGlobRegex(pattern); + if (regex === null) return false; + return regex.test(value); +} + +function parseHm(s: string): number { + const [h, m] = s.split(":").map((n) => parseInt(n, 10)); + return (h ?? 0) * 60 + (m ?? 0); +} + +// Known predicate `type` discriminants. Kept in sync manually with the +// switch in evaluatePredicate; the colocation makes drift obvious in +// review. The codegen'd types.gen.ts is the source of truth for the +// type names — if it adds a variant, this set must too. +const KNOWN_PREDICATE_TYPES: ReadonlySet = new Set([ + "glob_match", + "equals", + "value_in_set", + "value_not_in_set", + "numeric_range", + "time_window", + "label_set_match", + "file_glob_match", + "and", + "or", + "not", +]); + +/** + * Recursively walk a predicate tree and throw on any unknown `type` + * discriminant. Run *before* fact acquisition so version drift between + * compiler and bundled evaluator surfaces as a fast, loud failure rather + * than a silent skip when the required fact happens to be unavailable + * (`PolicyTracker.verdictForMissingFacts` would otherwise short-circuit + * `evaluatePredicate` and the fail-closed default would never run). + * + * Throws `Error` with a clear message naming the offending type and + * pointing at the version-mismatch likely cause. Caller is expected to + * translate this into a `##vso[task.logissue type=error]` + Failed + * complete via the index.ts entry point. + */ +export function validatePredicateTree(p: PredicateSpec): void { + const node = p as { type?: unknown; operands?: unknown; operand?: unknown }; + const type = node.type; + if (typeof type !== "string" || !KNOWN_PREDICATE_TYPES.has(type)) { + throw new Error( + `Unknown predicate type '${String(type)}' encountered during pre-flight validation. ` + + "This usually indicates the bundled ado-script.zip is older than the ado-aw " + + "compiler that emitted this spec. Update the bundle to a release that supports " + + "this predicate, or pin the compiler to a matching version.", + ); + } + + if (type === "and" || type === "or") { + if (!Array.isArray(node.operands)) { + throw new Error(`Predicate '${type}' is missing required 'operands' array`); + } + for (const sub of node.operands) validatePredicateTree(sub as PredicateSpec); + } else if (type === "not") { + if (node.operand === undefined || node.operand === null) { + throw new Error("Predicate 'not' is missing required 'operand'"); + } + validatePredicateTree(node.operand as PredicateSpec); + } +} diff --git a/scripts/ado-script/src/gate/selfcancel.test.ts b/scripts/ado-script/src/gate/selfcancel.test.ts new file mode 100644 index 00000000..76e6e8fd --- /dev/null +++ b/scripts/ado-script/src/gate/selfcancel.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const { cancelBuildMock } = vi.hoisted(() => ({ + cancelBuildMock: vi.fn(), +})); +vi.mock("../shared/ado-client.js", () => ({ + cancelBuild: cancelBuildMock, +})); + +import { selfCancelIfRequested } from "./selfcancel.js"; +import type { GateSpec } from "../shared/types.gen.js"; + +const baseSpec: GateSpec = { + context: { + build_reason: "PullRequest", + tag_prefix: "pr-gate", + step_name: "prGate", + bypass_label: "Pull Request", + }, + facts: [], + checks: [], +}; + +describe("selfCancelIfRequested", () => { + let writes: string[]; + let originalProject: string | undefined; + let originalBuildId: string | undefined; + + beforeEach(() => { + cancelBuildMock.mockReset(); + writes = []; + originalProject = process.env.ADO_PROJECT; + originalBuildId = process.env.ADO_BUILD_ID; + vi.spyOn(process.stdout, "write").mockImplementation((c: any) => { + writes.push(typeof c === "string" ? c : c.toString()); + return true; + }); + }); + + afterEach(() => { + if (originalProject === undefined) { + delete process.env.ADO_PROJECT; + } else { + process.env.ADO_PROJECT = originalProject; + } + if (originalBuildId === undefined) { + delete process.env.ADO_BUILD_ID; + } else { + process.env.ADO_BUILD_ID = originalBuildId; + } + vi.restoreAllMocks(); + }); + + it("emits skipped build tag and calls cancelBuild with project/buildId", async () => { + process.env.ADO_PROJECT = "p"; + process.env.ADO_BUILD_ID = "42"; + cancelBuildMock.mockResolvedValue(undefined); + await selfCancelIfRequested(baseSpec); + expect(writes.join("")).toContain("##vso[build.addbuildtag]pr-gate:skipped"); + expect(cancelBuildMock).toHaveBeenCalledWith("p", 42); + }); + + it("logs warning and does NOT call cancelBuild when ADO_PROJECT missing", async () => { + delete process.env.ADO_PROJECT; + process.env.ADO_BUILD_ID = "42"; + await selfCancelIfRequested(baseSpec); + expect(cancelBuildMock).not.toHaveBeenCalled(); + expect(writes.join("")).toContain("Cannot self-cancel"); + }); + + it("logs warning and does NOT call cancelBuild when ADO_BUILD_ID missing", async () => { + process.env.ADO_PROJECT = "p"; + delete process.env.ADO_BUILD_ID; + await selfCancelIfRequested(baseSpec); + expect(cancelBuildMock).not.toHaveBeenCalled(); + expect(writes.join("")).toContain("Cannot self-cancel"); + }); + + it("swallows cancelBuild errors and emits warning", async () => { + process.env.ADO_PROJECT = "p"; + process.env.ADO_BUILD_ID = "42"; + cancelBuildMock.mockRejectedValue(new Error("api boom")); + await expect(selfCancelIfRequested(baseSpec)).resolves.toBeUndefined(); + expect(writes.join("")).toContain("Self-cancel failed: api boom"); + }); +}); diff --git a/scripts/ado-script/src/gate/selfcancel.ts b/scripts/ado-script/src/gate/selfcancel.ts new file mode 100644 index 00000000..2dfeb7e0 --- /dev/null +++ b/scripts/ado-script/src/gate/selfcancel.ts @@ -0,0 +1,26 @@ +/** + * Self-cancel the current build when the gate decides not to run the + * agent. Best-effort: missing env vars or API failures emit a warning + * but do not throw. + */ +import type { GateSpec } from "../shared/types.gen.js"; +import { cancelBuild } from "../shared/ado-client.js"; +import { logWarning, addBuildTag } from "../shared/vso-logger.js"; + +export async function selfCancelIfRequested(spec: GateSpec): Promise { + addBuildTag(`${spec.context.tag_prefix}:skipped`); + + const project = process.env.ADO_PROJECT ?? ""; + const buildIdRaw = process.env.ADO_BUILD_ID ?? ""; + const buildId = buildIdRaw ? Number(buildIdRaw) : NaN; + if (!project || !Number.isFinite(buildId)) { + logWarning("Cannot self-cancel: missing ADO_PROJECT or ADO_BUILD_ID env vars"); + return; + } + + try { + await cancelBuild(project, buildId); + } catch (e) { + logWarning(`Self-cancel failed: ${(e as Error).message}`); + } +} diff --git a/scripts/ado-script/src/shared/__tests__/ado-client.test.ts b/scripts/ado-script/src/shared/__tests__/ado-client.test.ts new file mode 100644 index 00000000..a75fc324 --- /dev/null +++ b/scripts/ado-script/src/shared/__tests__/ado-client.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { BuildStatus } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; + +// Mock the auth module before importing ado-client +const { mockGitApi, mockBuildApi, mockWebApi, mockGetWebApi } = vi.hoisted(() => { + const mockGitApi = { + getPullRequestById: vi.fn(), + getPullRequestIterations: vi.fn(), + getPullRequestIterationChanges: vi.fn(), + }; + const mockBuildApi = { + updateBuild: vi.fn(), + }; + const mockWebApi = { + getGitApi: vi.fn().mockResolvedValue(mockGitApi), + getBuildApi: vi.fn().mockResolvedValue(mockBuildApi), + }; + const mockGetWebApi = vi.fn().mockResolvedValue(mockWebApi); + return { mockGitApi, mockBuildApi, mockWebApi, mockGetWebApi }; +}); + +vi.mock("../auth.js", () => ({ + getWebApi: mockGetWebApi, + _resetCacheForTesting: vi.fn(), +})); + +import { + getPullRequestById, + getPullRequestIterations, + getIterationChanges, + cancelBuild, + withRetry, +} from "../ado-client.js"; + +describe("ado-client", () => { + beforeEach(() => { + mockGitApi.getPullRequestById.mockReset(); + mockGitApi.getPullRequestIterations.mockReset(); + mockGitApi.getPullRequestIterationChanges.mockReset(); + mockBuildApi.updateBuild.mockReset(); + mockWebApi.getGitApi.mockReset().mockResolvedValue(mockGitApi); + mockWebApi.getBuildApi.mockReset().mockResolvedValue(mockBuildApi); + mockGetWebApi.mockReset().mockResolvedValue(mockWebApi); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + afterEach(() => vi.restoreAllMocks()); + + it("getPullRequestById calls SDK with (prId, project)", async () => { + mockGitApi.getPullRequestById.mockResolvedValue({ pullRequestId: 42 }); + const result = await getPullRequestById("p", "r", 42); + expect(mockGitApi.getPullRequestById).toHaveBeenCalledWith(42, "p"); + expect(result).toEqual({ pullRequestId: 42 }); + }); + + it("getPullRequestIterations calls SDK with (repoId, prId, project)", async () => { + mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }]); + const result = await getPullRequestIterations("p", "r", 42); + expect(mockGitApi.getPullRequestIterations).toHaveBeenCalledWith("r", 42, "p"); + expect(result).toEqual([{ id: 1 }]); + }); + + it("getIterationChanges calls SDK with (repoId, prId, iterationId, project, top, skip) and returns concatenated entries", async () => { + mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: [] }); + const result = await getIterationChanges("p", "r", 42, 7); + expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("r", 42, 7, "p", 100, 0); + expect(result.changeEntries).toEqual([]); + }); + + it("getIterationChanges paginates with $skip until a short page is returned", async () => { + const page1 = { changeEntries: Array.from({ length: 100 }, (_, i) => ({ item: { path: `/f${i}` } })) }; + const page2 = { changeEntries: Array.from({ length: 100 }, (_, i) => ({ item: { path: `/g${i}` } })) }; + const page3 = { changeEntries: [{ item: { path: "/last" } }] }; // short page → terminate + mockGitApi.getPullRequestIterationChanges + .mockResolvedValueOnce(page1) + .mockResolvedValueOnce(page2) + .mockResolvedValueOnce(page3); + + const result = await getIterationChanges("p", "r", 42, 7); + expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledTimes(3); + expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenNthCalledWith(1, "r", 42, 7, "p", 100, 0); + expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenNthCalledWith(2, "r", 42, 7, "p", 100, 100); + expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenNthCalledWith(3, "r", 42, 7, "p", 100, 200); + expect(result.changeEntries).toHaveLength(201); + }); + + it("getIterationChanges terminates on an exactly empty page", async () => { + const fullPage = { changeEntries: Array.from({ length: 100 }, (_, i) => ({ item: { path: `/f${i}` } })) }; + const emptyPage = { changeEntries: [] }; + mockGitApi.getPullRequestIterationChanges + .mockResolvedValueOnce(fullPage) + .mockResolvedValueOnce(emptyPage); + + const result = await getIterationChanges("p", "r", 42, 7); + expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledTimes(2); + expect(result.changeEntries).toHaveLength(100); + }); + + it("cancelBuild calls updateBuild with status=Cancelling", async () => { + mockBuildApi.updateBuild.mockResolvedValue({}); + await cancelBuild("p", 99); + expect(mockBuildApi.updateBuild).toHaveBeenCalledTimes(1); + const [patch, project, buildId] = mockBuildApi.updateBuild.mock.calls[0]!; + expect(patch.status).toBe(BuildStatus.Cancelling); + expect(project).toBe("p"); + expect(buildId).toBe(99); + }); + + it("withRetry retries once on a 5xx error", async () => { + let calls = 0; + const fn = async () => { + calls++; + if (calls === 1) { + const err = new Error("server boom") as Error & { statusCode: number }; + err.statusCode = 503; + throw err; + } + return "ok"; + }; + const result = await withRetry("test", fn); + expect(result).toBe("ok"); + expect(calls).toBe(2); + }); + + it("withRetry does NOT retry non-transient errors", async () => { + let calls = 0; + const fn = async () => { + calls++; + const err = new Error("client") as Error & { statusCode: number }; + err.statusCode = 404; + throw err; + }; + await expect(withRetry("test", fn)).rejects.toThrow("client"); + expect(calls).toBe(1); + }); + + it("withRetry rethrows after the second failure", async () => { + const fn = async () => { + const err = new Error("still down") as Error & { statusCode: number }; + err.statusCode = 502; + throw err; + }; + await expect(withRetry("test", fn)).rejects.toThrow("still down"); + }); + + it("withRetry times out a hung call and treats it as transient", async () => { + process.env.ADO_API_TIMEOUT_MS = "50"; + try { + let calls = 0; + const fn = (): Promise => + new Promise((resolve) => { + calls++; + if (calls === 2) { + // second attempt resolves fast so the test doesn't hang on + // the retry path itself + setTimeout(() => resolve("ok"), 10); + return; + } + // never resolves on first attempt — the timeout should fire + }); + const result = await withRetry("hung", fn); + expect(result).toBe("ok"); + expect(calls).toBe(2); + } finally { + delete process.env.ADO_API_TIMEOUT_MS; + } + }); + + it("withRetry rejects when both attempts time out", async () => { + process.env.ADO_API_TIMEOUT_MS = "30"; + try { + const fn = (): Promise => new Promise(() => { /* never resolves */ }); + await expect(withRetry("forever", fn)).rejects.toThrow(/timed out after 30ms/); + } finally { + delete process.env.ADO_API_TIMEOUT_MS; + } + }); +}); diff --git a/scripts/ado-script/src/shared/__tests__/auth.test.ts b/scripts/ado-script/src/shared/__tests__/auth.test.ts new file mode 100644 index 00000000..555c98e3 --- /dev/null +++ b/scripts/ado-script/src/shared/__tests__/auth.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { getWebApi, _resetCacheForTesting } from "../auth.js"; + +describe("getWebApi", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + _resetCacheForTesting(); + process.env = { ...originalEnv }; + // Suppress vso-logger output during tests + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it("throws when SYSTEM_ACCESSTOKEN is missing", async () => { + process.env.ADO_COLLECTION_URI = "https://example/"; + delete process.env.SYSTEM_ACCESSTOKEN; + await expect(getWebApi()).rejects.toThrow(/SYSTEM_ACCESSTOKEN/); + }); + + it("throws when ADO_COLLECTION_URI is missing", async () => { + delete process.env.ADO_COLLECTION_URI; + process.env.SYSTEM_ACCESSTOKEN = "tok"; + await expect(getWebApi()).rejects.toThrow(/ADO_COLLECTION_URI/); + }); + + it("caches the WebApi across calls", async () => { + process.env.ADO_COLLECTION_URI = "https://example.visualstudio.com/"; + process.env.SYSTEM_ACCESSTOKEN = "tok"; + const a = await getWebApi(); + const b = await getWebApi(); + expect(a).toBe(b); + }, 30_000); + // ^ explicit 30 s timeout: the first call dynamically imports the + // ~2.7 MB azure-devops-node-api chunk (see shared/auth.ts comment), + // which can take a few seconds when 20 vitest workers race for disk + // I/O. Subsequent calls hit the cache and are fast. +}); diff --git a/scripts/ado-script/src/shared/__tests__/env-facts.test.ts b/scripts/ado-script/src/shared/__tests__/env-facts.test.ts new file mode 100644 index 00000000..65f0af93 --- /dev/null +++ b/scripts/ado-script/src/shared/__tests__/env-facts.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + readEnvFact, + stripRefPrefix, + isPipelineVarFact, + type FactKind, +} from "../env-facts.js"; + +const ALL: { fact: FactKind; env: string }[] = [ + { fact: "pr_title", env: "ADO_PR_TITLE" }, + { fact: "author_email", env: "ADO_AUTHOR_EMAIL" }, + { fact: "source_branch", env: "ADO_SOURCE_BRANCH" }, + { fact: "target_branch", env: "ADO_TARGET_BRANCH" }, + { fact: "commit_message", env: "ADO_COMMIT_MESSAGE" }, + { fact: "build_reason", env: "ADO_BUILD_REASON" }, + { fact: "triggered_by_pipeline", env: "ADO_TRIGGERED_BY_PIPELINE" }, + { fact: "triggering_branch", env: "ADO_TRIGGERING_BRANCH" }, +]; + +describe("env-facts", () => { + let saved: NodeJS.ProcessEnv; + beforeEach(() => { + saved = { ...process.env }; + for (const { env } of ALL) delete process.env[env]; + }); + afterEach(() => { + process.env = saved; + }); + + it.each(ALL)("readEnvFact returns the value for $fact", ({ fact, env }) => { + process.env[env] = "value-here"; + const expected = ["source_branch", "target_branch", "triggering_branch"].includes( + fact + ) + ? "value-here" + : "value-here"; + expect(readEnvFact(fact)).toBe(expected); + }); + + it.each(ALL)("readEnvFact returns undefined when $fact env is empty", ({ fact, env }) => { + process.env[env] = ""; + expect(readEnvFact(fact)).toBeUndefined(); + }); + + it.each(ALL)("readEnvFact returns undefined when $fact env unset", ({ fact }) => { + expect(readEnvFact(fact)).toBeUndefined(); + }); + + it("strips refs/heads/ from source_branch", () => { + process.env.ADO_SOURCE_BRANCH = "refs/heads/feature/x"; + expect(readEnvFact("source_branch")).toBe("feature/x"); + }); + + it("strips refs/tags/ from target_branch", () => { + process.env.ADO_TARGET_BRANCH = "refs/tags/v1.0"; + expect(readEnvFact("target_branch")).toBe("v1.0"); + }); + + it("strips refs/pull/ from triggering_branch", () => { + process.env.ADO_TRIGGERING_BRANCH = "refs/pull/42/merge"; + expect(readEnvFact("triggering_branch")).toBe("42/merge"); + }); + + it("does NOT strip refs/heads/ from non-branch facts", () => { + process.env.ADO_PR_TITLE = "refs/heads/title"; + expect(readEnvFact("pr_title")).toBe("refs/heads/title"); + }); + + it("stripRefPrefix is a no-op for non-prefixed values", () => { + expect(stripRefPrefix("main")).toBe("main"); + }); + + it("isPipelineVarFact returns true for known kinds", () => { + expect(isPipelineVarFact("pr_title")).toBe(true); + expect(isPipelineVarFact("not_a_fact")).toBe(false); + }); +}); diff --git a/scripts/ado-script/src/shared/__tests__/policy.test.ts b/scripts/ado-script/src/shared/__tests__/policy.test.ts new file mode 100644 index 00000000..5b285f8b --- /dev/null +++ b/scripts/ado-script/src/shared/__tests__/policy.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { PolicyTracker } from "../policy.js"; +import type { FactSpec } from "../types.gen.js"; + +// Mirror of the real Fact::dependencies() graph. Tests construct +// FactSpec objects with this map so the dep graph stays implicit at +// the call sites (matches how the compiler emits it in production). +const DEFAULT_DEPS: Record = { + pr_is_draft: ["pr_metadata"], + pr_labels: ["pr_metadata"], + changed_file_count: ["changed_files"], +}; + +function spec(kind: string, fp: string, dependencies?: string[]): FactSpec { + return { + kind, + failure_policy: fp, + dependencies: dependencies ?? [...(DEFAULT_DEPS[kind] ?? [])], + }; +} + +describe("PolicyTracker", () => { + beforeEach(() => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + afterEach(() => vi.restoreAllMocks()); + + it("returns 'evaluate' when no referenced facts are missing", () => { + const t = new PolicyTracker([spec("pr_title", "fail_closed")]); + expect(t.verdictForMissingFacts(["pr_title"])).toBe("evaluate"); + }); + + it("fail_closed missing fact → fail", () => { + const t = new PolicyTracker([spec("pr_title", "fail_closed")]); + t.recordFactFailure("pr_title", "missing env var"); + expect(t.verdictForMissingFacts(["pr_title"])).toBe("fail"); + }); + + it("fail_open missing fact → pass", () => { + const t = new PolicyTracker([spec("pr_labels", "fail_open")]); + t.recordFactFailure("pr_labels", "no metadata"); + expect(t.verdictForMissingFacts(["pr_labels"])).toBe("pass"); + }); + + it("skip_dependents missing fact directly referenced → skip", () => { + const t = new PolicyTracker([spec("pr_metadata", "skip_dependents")]); + t.recordFactFailure("pr_metadata", "API error"); + expect(t.verdictForMissingFacts(["pr_metadata"])).toBe("skip"); + }); + + it("transitive skip: pr_metadata fails skip_dependents → pr_is_draft skipped", () => { + const t = new PolicyTracker([ + spec("pr_metadata", "skip_dependents"), + spec("pr_is_draft", "fail_closed"), + ]); + t.recordFactFailure("pr_metadata", "API error"); + expect(t.verdictForMissingFacts(["pr_is_draft"])).toBe("skip"); + }); + + it("transitive skip: pr_metadata fails skip_dependents → pr_labels skipped", () => { + const t = new PolicyTracker([ + spec("pr_metadata", "skip_dependents"), + spec("pr_labels", "fail_open"), + ]); + t.recordFactFailure("pr_metadata", "API error"); + // Even though pr_labels is fail_open, the *skip* propagates because + // its dep failed with skip_dependents. The skip dominates. + expect(t.verdictForMissingFacts(["pr_labels"])).toBe("skip"); + }); + + it("transitive skip: changed_files fails skip_dependents → changed_file_count skipped", () => { + const t = new PolicyTracker([ + spec("changed_files", "skip_dependents"), + spec("changed_file_count", "fail_open"), + ]); + t.recordFactFailure("changed_files", "iter API error"); + expect(t.verdictForMissingFacts(["changed_file_count"])).toBe("skip"); + }); + + it("multiple missing facts: skip dominates fail_closed", () => { + const t = new PolicyTracker([ + spec("pr_metadata", "skip_dependents"), + spec("pr_title", "fail_closed"), + ]); + t.recordFactFailure("pr_metadata", "API error"); + t.recordFactFailure("pr_title", "missing"); + expect(t.verdictForMissingFacts(["pr_metadata", "pr_title"])).toBe("skip"); + }); + + it("multiple missing facts: fail_closed dominates fail_open", () => { + const t = new PolicyTracker([ + spec("pr_title", "fail_closed"), + spec("pr_labels", "fail_open"), + ]); + t.recordFactFailure("pr_title", "missing"); + t.recordFactFailure("pr_labels", "no md"); + expect(t.verdictForMissingFacts(["pr_title", "pr_labels"])).toBe("fail"); + }); + + it("recordFactFailure for skip_dependents only emits a warning", () => { + const writes: string[] = []; + const spyWrite = vi.spyOn(process.stdout, "write").mockImplementation((c: any) => { + writes.push(typeof c === "string" ? c : c.toString()); + return true; + }); + const t = new PolicyTracker([spec("pr_metadata", "skip_dependents")]); + t.recordFactFailure("pr_metadata", "test reason"); + expect(writes.some((w) => w.includes("logissue type=warning"))).toBe(true); + expect(writes.some((w) => w.includes("pr_metadata"))).toBe(true); + spyWrite.mockRestore(); + }); + + it("summary tallies recordCheckResult outcomes", () => { + const t = new PolicyTracker([]); + t.recordCheckResult("pass"); + t.recordCheckResult("pass"); + t.recordCheckResult("fail"); + t.recordCheckResult("skip"); + expect(t.summary()).toEqual({ passed: 2, failed: 1, skipped: 1 }); + }); + + it("unknown fact kind defaults to fail_closed", () => { + const t = new PolicyTracker([]); + t.recordFactFailure("nonexistent_fact", "test"); + expect(t.verdictForMissingFacts(["nonexistent_fact"])).toBe("fail"); + }); +}); diff --git a/scripts/ado-script/src/shared/__tests__/vso-logger.test.ts b/scripts/ado-script/src/shared/__tests__/vso-logger.test.ts new file mode 100644 index 00000000..1527af68 --- /dev/null +++ b/scripts/ado-script/src/shared/__tests__/vso-logger.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { setOutput, addBuildTag, logWarning, logError, complete, logInfo, _resetCompletedForTesting } from "../vso-logger.js"; + +describe("vso-logger", () => { + let writes: string[]; + + beforeEach(() => { + writes = []; + _resetCompletedForTesting(); + vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { + writes.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("setOutput emits a setvariable command with isOutput=true", () => { + setOutput("SHOULD_RUN", "true"); + expect(writes).toEqual([ + "##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true\n", + ]); + }); + + it("setOutput escapes ; in name and passes ] through in value", () => { + setOutput("a;b", "v]w"); + expect(writes[0]).toContain("variable=a%3Bb"); + // Value is in the message body (after closing ]), so ] is NOT escaped + expect(writes[0]).toContain("]v]w\n"); + }); + + it("addBuildTag emits a build tag command with message escaping", () => { + addBuildTag("gate%tag\r\npassed"); + expect(writes[0]).toBe("##vso[build.addbuildtag]gate%25tag%0D%0Apassed\n"); + }); + + it("logWarning escapes newlines in message", () => { + logWarning("line1\nline2"); + expect(writes[0]).toBe("##vso[task.logissue type=warning;]line1%0Aline2\n"); + }); + + it("logError escapes carriage returns", () => { + logError("line1\rline2"); + expect(writes[0]).toBe("##vso[task.logissue type=error;]line1%0Dline2\n"); + }); + + it("complete defaults message to 'done'", () => { + complete("Succeeded"); + expect(writes[0]).toBe("##vso[task.complete result=Succeeded;]done\n"); + }); + + it("complete passes through a custom message", () => { + complete("Failed", "boom"); + expect(writes[0]).toBe("##vso[task.complete result=Failed;]boom\n"); + }); + + it("complete escapes % in message", () => { + complete("SucceededWithIssues", "100% done"); + expect(writes[0]).toBe( + "##vso[task.complete result=SucceededWithIssues;]100%25 done\n", + ); + }); + + it("escapeProperty encodes = and space (latent-injection defense)", () => { + setOutput("name with space", "v"); + expect(writes[0]).toContain("variable=name%20with%20space"); + + writes.length = 0; + setOutput("a=b", "v"); + expect(writes[0]).toContain("variable=a%3Db"); + }); + + it("complete() is idempotent — second call is a no-op", () => { + complete("Succeeded", "first"); + complete("Failed", "second"); + expect(writes).toHaveLength(1); + expect(writes[0]).toContain("result=Succeeded"); + expect(writes[0]).toContain("first"); + }); + + it("logInfo writes an escaped non-vso line and neutralises a leading '#'", () => { + logInfo("hello world"); + expect(writes).toEqual(["hello world\n"]); + + // Leading `#` is encoded so an adversarial message cannot smuggle a + // `##vso[` command (ADO interprets that prefix only at line-start). + writes.length = 0; + logInfo("##vso[task.complete result=Failed;] line"); + expect(writes[0]!.startsWith("#")).toBe(false); + expect(writes[0]).toContain("%23#vso[task.complete result=Failed;] line"); + + // Embedded newlines are encoded so they can't break out either. + writes.length = 0; + logInfo("first\nsecond"); + expect(writes[0]).toBe("first%0Asecond\n"); + }); +}); diff --git a/scripts/ado-script/src/shared/ado-client.ts b/scripts/ado-script/src/shared/ado-client.ts new file mode 100644 index 00000000..2686582a --- /dev/null +++ b/scripts/ado-script/src/shared/ado-client.ts @@ -0,0 +1,172 @@ +/** + * Thin wrapper around `azure-devops-node-api` exposing only the methods + * the gate evaluator uses, with a one-shot retry on transient (5xx) errors + * and a hard timeout per attempt. + */ +import { getWebApi } from "./auth.js"; +import { logWarning } from "./vso-logger.js"; +import type { + GitPullRequest, + GitPullRequestIteration, + GitPullRequestIterationChanges, +} from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import { BuildStatus, type Build } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; + +const SLEEP_MS = 1000; +const DEFAULT_TIMEOUT_MS = 30_000; +// Per-page size when listing iteration changes. ADO's server-side default +// is 100; we pick the same and just paginate explicitly so a PR with +// >100 changed files is not silently truncated. +const ITERATION_CHANGES_PAGE_SIZE = 100; +// Safety cap on pages to avoid an unbounded loop if the API ever fails +// to advance the skip cursor. 100 pages × 100 entries = 10 000 changed +// files, which is well beyond any realistic PR. +const MAX_ITERATION_CHANGE_PAGES = 100; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +function timeoutMs(): number { + const raw = process.env.ADO_API_TIMEOUT_MS; + if (!raw) return DEFAULT_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_TIMEOUT_MS; + return parsed; +} + +class TimeoutError extends Error { + constructor(label: string, ms: number) { + super(`${label} timed out after ${ms}ms`); + this.name = "TimeoutError"; + } +} + +function withTimeout(label: string, ms: number, fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + const handle = setTimeout(() => reject(new TimeoutError(label, ms)), ms); + fn().then( + (v) => { + clearTimeout(handle); + resolve(v); + }, + (e) => { + clearTimeout(handle); + reject(e); + }, + ); + }); +} + +function isTransient(err: unknown): boolean { + if (err instanceof TimeoutError) return true; + if (err && typeof err === "object") { + const e = err as Record; + const sc = + typeof e.statusCode === "number" + ? e.statusCode + : typeof e.response?.status === "number" + ? e.response.status + : undefined; + if (typeof sc === "number" && sc >= 500 && sc < 600) return true; + } + return false; +} + +export async function withRetry(label: string, fn: () => Promise): Promise { + const ms = timeoutMs(); + try { + return await withTimeout(label, ms, fn); + } catch (err) { + if (!isTransient(err)) throw err; + logWarning(`${label} failed with transient error; retrying once in ${SLEEP_MS}ms`); + await sleep(SLEEP_MS); + return await withTimeout(label, ms, fn); + } +} + +export async function getPullRequestById( + project: string, + _repoId: string, + prId: number, +): Promise { + return withRetry("getPullRequestById", async () => { + const git = await (await getWebApi()).getGitApi(); + return git.getPullRequestById(prId, project); + }); +} + +/** + * Fetches all pull-request iterations. + * + * The ADO REST API does not paginate this endpoint (no `$top` / `$skip` / + * continuation token on `getPullRequestIterations`), and the SDK signature + * confirms it returns the full list in one call. Callers should still + * treat the result defensively — see `getIterationChanges` which DOES + * paginate. + */ +export async function getPullRequestIterations( + project: string, + repoId: string, + prId: number, +): Promise { + return withRetry("getPullRequestIterations", async () => { + const git = await (await getWebApi()).getGitApi(); + return git.getPullRequestIterations(repoId, prId, project); + }); +} + +/** + * Fetches all change entries for one PR iteration, transparently + * paginating via `$top` / `$skip`. ADO's default page size is 100; we + * pull pages until either an empty page is returned, the page is smaller + * than the page size (last page), or `MAX_ITERATION_CHANGE_PAGES` is + * reached (defensive cap to avoid unbounded loops on API misbehaviour). + * + * Returns a synthetic `GitPullRequestIterationChanges` whose + * `changeEntries` is the concatenation of every page. Other fields are + * inherited from the first page (callers in this codebase only read + * `changeEntries`). + */ +export async function getIterationChanges( + project: string, + repoId: string, + prId: number, + iterationId: number, +): Promise { + return withRetry("getIterationChanges", async () => { + const git = await (await getWebApi()).getGitApi(); + const allEntries: NonNullable = []; + let firstPage: GitPullRequestIterationChanges | undefined; + + for (let page = 0; page < MAX_ITERATION_CHANGE_PAGES; page++) { + const skip = page * ITERATION_CHANGES_PAGE_SIZE; + const result = await git.getPullRequestIterationChanges( + repoId, + prId, + iterationId, + project, + ITERATION_CHANGES_PAGE_SIZE, + skip, + ); + if (!firstPage) firstPage = result; + const entries = result.changeEntries ?? []; + allEntries.push(...entries); + if (entries.length < ITERATION_CHANGES_PAGE_SIZE) { + return { ...(firstPage ?? {}), changeEntries: allEntries }; + } + } + + logWarning( + `getIterationChanges: hit ${MAX_ITERATION_CHANGE_PAGES}-page cap (${MAX_ITERATION_CHANGE_PAGES * ITERATION_CHANGES_PAGE_SIZE} entries); list may be truncated`, + ); + return { ...(firstPage ?? {}), changeEntries: allEntries }; + }); +} + +export async function cancelBuild(project: string, buildId: number): Promise { + await withRetry("cancelBuild", async () => { + const build = await (await getWebApi()).getBuildApi(); + const patch: Build = { status: BuildStatus.Cancelling } as Build; + await build.updateBuild(patch, project, buildId); + }); +} diff --git a/scripts/ado-script/src/shared/auth.ts b/scripts/ado-script/src/shared/auth.ts new file mode 100644 index 00000000..8b26431f --- /dev/null +++ b/scripts/ado-script/src/shared/auth.ts @@ -0,0 +1,54 @@ +/** + * ADO authentication helper. + * + * Builds and caches an `azure-devops-node-api` `WebApi` from the + * `SYSTEM_ACCESSTOKEN` and `ADO_COLLECTION_URI` pipeline env vars via + * `getPersonalAccessTokenHandler`. + * + * The `azure-devops-node-api` package is heavy (~1 MB; includes + * `typed-rest-client` and `tunnel` transitive deps). Loading it eagerly + * adds ~50–100 ms of startup latency that is wasted whenever the gate + * is invoked for a code path that never touches the ADO REST API + * (e.g. a manual build that hits the bypass branch in `bypass.ts`, or + * a pipeline whose facts are all pipeline variables). The dynamic + * `import()` below is statically analysable by ncc, so the SDK is + * still bundled into `dist/gate/index.js` — only its module-evaluation + * cost is deferred until the first `getWebApi()` call. + * + * Env-var contract (set by the compiler in + * `src/compile/filter_ir.rs::compile_gate_step_external` / + * `collect_ado_exports`): + * - `SYSTEM_ACCESSTOKEN` ← `$(System.AccessToken)` + * - `ADO_COLLECTION_URI` ← `$(System.CollectionUri)` + */ +import type { WebApi } from "azure-devops-node-api"; +import { logError } from "./vso-logger.js"; + +let cached: WebApi | undefined; + +/** For tests only: clear the cached WebApi. */ +export function _resetCacheForTesting(): void { + cached = undefined; +} + +export async function getWebApi(): Promise { + if (cached) return cached; + + const orgUrl = process.env.ADO_COLLECTION_URI; + const token = process.env.SYSTEM_ACCESSTOKEN; + if (!orgUrl) { + const msg = "ADO_COLLECTION_URI env var is missing"; + logError(msg); + throw new Error(msg); + } + if (!token) { + const msg = "SYSTEM_ACCESSTOKEN env var is missing"; + logError(msg); + throw new Error(msg); + } + + const azdev = await import("azure-devops-node-api"); + const handler = azdev.getPersonalAccessTokenHandler(token); + cached = new azdev.WebApi(orgUrl, handler); + return cached; +} diff --git a/scripts/ado-script/src/shared/env-facts.ts b/scripts/ado-script/src/shared/env-facts.ts new file mode 100644 index 00000000..903693d7 --- /dev/null +++ b/scripts/ado-script/src/shared/env-facts.ts @@ -0,0 +1,65 @@ +/** + * Reads pipeline-variable facts from `process.env`. + * + * Mirrors the env-var → fact mapping defined by `Fact::ado_exports()` in + * `src/compile/filter_ir.rs`. Branch- + * shaped facts (`source_branch`, `target_branch`, `triggering_branch`) have + * the leading `refs/heads/`, `refs/tags/`, or `refs/pull/` prefix stripped so + * user patterns like `feature/*` match without the prefix. + * + * Env-var contract is set by the compiler in + * `src/compile/filter_ir.rs::collect_ado_exports` and + * `Fact::ado_exports`. + */ + +export type FactKind = + | "pr_title" + | "author_email" + | "source_branch" + | "target_branch" + | "commit_message" + | "build_reason" + | "triggered_by_pipeline" + | "triggering_branch"; + +const ENV_BY_FACT: Record = { + pr_title: "ADO_PR_TITLE", + author_email: "ADO_AUTHOR_EMAIL", + source_branch: "ADO_SOURCE_BRANCH", + target_branch: "ADO_TARGET_BRANCH", + commit_message: "ADO_COMMIT_MESSAGE", + build_reason: "ADO_BUILD_REASON", + triggered_by_pipeline: "ADO_TRIGGERED_BY_PIPELINE", + triggering_branch: "ADO_TRIGGERING_BRANCH", +}; + +export const REF_PREFIXES = ["refs/heads/", "refs/tags/", "refs/pull/"] as const; + +/** Facts whose values are ref-like (e.g. branch names) and need the + * `refs/...` prefix stripped before predicate evaluation. Exported so the + * predicate evaluator can apply the *same* stripping rules to user-supplied + * patterns — keeping read-time and match-time behaviour in sync. Both sides + * must reference this single set rather than duplicating it. */ +export const BRANCH_FACTS: ReadonlySet = new Set([ + "source_branch", + "target_branch", + "triggering_branch", +]); + +export function stripRefPrefix(value: string): string { + for (const p of REF_PREFIXES) { + if (value.startsWith(p)) return value.slice(p.length); + } + return value; +} + +export function isPipelineVarFact(kind: string): kind is FactKind { + return kind in ENV_BY_FACT; +} + +export function readEnvFact(fact: FactKind): string | undefined { + const envVar = ENV_BY_FACT[fact]; + const raw = process.env[envVar]; + if (raw === undefined || raw === "") return undefined; + return BRANCH_FACTS.has(fact) ? stripRefPrefix(raw) : raw; +} diff --git a/scripts/ado-script/src/shared/index.ts b/scripts/ado-script/src/shared/index.ts new file mode 100644 index 00000000..9cbae266 --- /dev/null +++ b/scripts/ado-script/src/shared/index.ts @@ -0,0 +1,5 @@ +export * as auth from "./auth.js"; +export * as vso from "./vso-logger.js"; +export * as envFacts from "./env-facts.js"; +export * as policy from "./policy.js"; +export * as adoClient from "./ado-client.js"; diff --git a/scripts/ado-script/src/shared/policy.ts b/scripts/ado-script/src/shared/policy.ts new file mode 100644 index 00000000..6c0466be --- /dev/null +++ b/scripts/ado-script/src/shared/policy.ts @@ -0,0 +1,128 @@ +import type { FactSpec } from "./types.gen.js"; +import { logWarning } from "./vso-logger.js"; + +export type FailurePolicy = "fail_closed" | "fail_open" | "skip_dependents"; + +export class PolicyTracker { + private readonly policyByKind = new Map(); + /** Fact dependency graph derived from the spec — no manual mirror. */ + private readonly depsByKind = new Map(); + private readonly failedFacts = new Set(); + private readonly skippedFacts = new Set(); + private readonly unavailablePoliciesByKind = new Map>(); + private passedChecks = 0; + private failedChecks = 0; + private skippedChecks = 0; + + constructor(facts: FactSpec[]) { + for (const f of facts) { + this.policyByKind.set(f.kind, this.parsePolicy(f.failure_policy)); + this.depsByKind.set(f.kind, f.dependencies ?? []); + } + } + + /** Record that a fact failed to acquire. Returns the policy that was applied. */ + recordFactFailure(factKind: string, reason: string): FailurePolicy { + const policy = this.policyByKind.get(factKind) ?? "fail_closed"; + this.failedFacts.add(factKind); + this.markUnavailableTransitive(factKind, policy); + + if (policy === "skip_dependents") { + logWarning(`Fact '${factKind}' failed (${reason}); dependent checks skipped`); + } else if (policy === "fail_open") { + logWarning(`Fact '${factKind}' failed (${reason}); fail-open: assuming pass`); + } else { + logWarning(`Fact '${factKind}' failed (${reason}); fail-closed: blocking`); + } + return policy; + } + + /** Determine the verdict for a check given a set of facts referenced by its predicate. + * - If any referenced fact has skip_dependents (or is transitively skipped) → "skip". + * - Otherwise, if any referenced fact failed with fail_closed → "fail" (caller may still + * run the predicate; this method is consulted ONLY when a referenced fact is missing). + * - If all referenced facts had fail_open → "pass". + * - Mixed: if any fail_closed dominates a "fail" outcome. + * Note: this method only handles the *missing-fact* case. Predicate evaluation + * proper is in `evaluatePredicates`; this returns "evaluate" when there are no + * missing facts so the caller can defer to the predicate evaluator. + */ + verdictForMissingFacts(referencedKinds: string[]): "pass" | "fail" | "skip" | "evaluate" { + const missing = referencedKinds.filter((k) => this.isUnavailable(k)); + if (missing.length === 0) return "evaluate"; + + if (missing.some((k) => this.skippedFacts.has(k))) return "skip"; + + let anyClosed = false; + let allOpen = true; + for (const k of missing) { + const policies = this.unavailablePoliciesByKind.get(k) ?? new Set([ + this.policyByKind.get(k) ?? "fail_closed", + ]); + const policyValues = [...policies]; + if (policyValues.includes("fail_closed")) anyClosed = true; + if (policyValues.some((p) => p !== "fail_open")) allOpen = false; + } + if (anyClosed) return "fail"; + if (allOpen) return "pass"; + return "fail"; + } + + recordCheckResult(result: "pass" | "fail" | "skip"): void { + if (result === "pass") this.passedChecks++; + else if (result === "fail") this.failedChecks++; + else this.skippedChecks++; + } + + summary(): { passed: number; failed: number; skipped: number } { + return { passed: this.passedChecks, failed: this.failedChecks, skipped: this.skippedChecks }; + } + + private parsePolicy(value: string): FailurePolicy { + if (value === "fail_closed" || value === "fail_open" || value === "skip_dependents") { + return value; + } + return "fail_closed"; + } + + public isUnavailableForAcquisition(factKind: string): boolean { + return this.isUnavailable(factKind); + } + + private isUnavailable(factKind: string): boolean { + return ( + this.failedFacts.has(factKind) || + this.skippedFacts.has(factKind) || + this.unavailablePoliciesByKind.has(factKind) + ); + } + + private markUnavailableTransitive(factKind: string, policy: FailurePolicy): void { + const unavailable = new Set([factKind]); + let changed = true; + while (changed) { + changed = false; + for (const [kind, deps] of this.depsByKind.entries()) { + if (unavailable.has(kind)) continue; + if (deps.some((dep) => unavailable.has(dep))) { + unavailable.add(kind); + changed = true; + } + } + } + + for (const kind of unavailable) { + this.addUnavailablePolicy(kind, policy); + if (policy === "skip_dependents") this.skippedFacts.add(kind); + } + } + + private addUnavailablePolicy(factKind: string, policy: FailurePolicy): void { + const existing = this.unavailablePoliciesByKind.get(factKind); + if (existing) { + existing.add(policy); + return; + } + this.unavailablePoliciesByKind.set(factKind, new Set([policy])); + } +} diff --git a/scripts/ado-script/src/shared/types.gen.ts b/scripts/ado-script/src/shared/types.gen.ts new file mode 100644 index 00000000..c674b8c9 --- /dev/null +++ b/scripts/ado-script/src/shared/types.gen.ts @@ -0,0 +1,119 @@ +// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen. + +/** + * Serialized predicate — the expression tree evaluated at runtime. + */ +export type PredicateSpec = + | { + fact: string; + pattern: string; + type: "glob_match"; + [k: string]: unknown; + } + | { + fact: string; + type: "equals"; + value: string; + [k: string]: unknown; + } + | { + case_insensitive: boolean; + fact: string; + type: "value_in_set"; + values: string[]; + [k: string]: unknown; + } + | { + case_insensitive: boolean; + fact: string; + type: "value_not_in_set"; + values: string[]; + [k: string]: unknown; + } + | { + fact: string; + max?: number | null; + min?: number | null; + type: "numeric_range"; + [k: string]: unknown; + } + | { + end: string; + start: string; + type: "time_window"; + [k: string]: unknown; + } + | { + all_of: string[]; + any_of: string[]; + fact: string; + none_of: string[]; + type: "label_set_match"; + [k: string]: unknown; + } + | { + exclude: string[]; + fact: string; + include: string[]; + type: "file_glob_match"; + [k: string]: unknown; + } + | { + operands: PredicateSpec[]; + type: "and"; + [k: string]: unknown; + } + | { + operands: PredicateSpec[]; + type: "or"; + [k: string]: unknown; + } + | { + operand: PredicateSpec; + type: "not"; + [k: string]: unknown; + }; + +/** + * Serializable gate specification — the JSON document consumed by the + * Node gate evaluator (`scripts/ado-script/dist/gate/index.js`) at pipeline runtime. + */ +export interface GateSpec { + checks: CheckSpec[]; + context: GateContextSpec; + facts: FactSpec[]; + [k: string]: unknown; +} +/** + * Serialized filter check. + */ +export interface CheckSpec { + name: string; + predicate: PredicateSpec; + tag_suffix: string; + [k: string]: unknown; +} +/** + * Serialized gate context. + */ +export interface GateContextSpec { + build_reason: string; + bypass_label: string; + step_name: string; + tag_prefix: string; + [k: string]: unknown; +} +/** + * Serialized fact acquisition descriptor. + */ +export interface FactSpec { + /** + * Kinds of other facts that must be acquired before this one. + * Mirrors `Fact::dependencies()`. Carried in the spec so the gate + * evaluator does not duplicate the dependency graph. + */ + dependencies: string[]; + failure_policy: string; + kind: string; + [k: string]: unknown; +} diff --git a/scripts/ado-script/src/shared/vso-logger.ts b/scripts/ado-script/src/shared/vso-logger.ts new file mode 100644 index 00000000..96c0196a --- /dev/null +++ b/scripts/ado-script/src/shared/vso-logger.ts @@ -0,0 +1,84 @@ +/** + * Typed emitters for ADO `##vso[...]` logging commands. + * + * Reference: https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands + * + * All emitters write a single line to stdout terminated by a newline. + * Escape semantics: `\r`, `\n`, `]`, `;` are encoded per ADO's + * logging-command parser so that user-controlled values cannot break + * out of the command. Property values additionally encode `=` and SPACE + * because ADO's command-header parser tokenises `key=value` pairs on + * whitespace and `=`; without this an adversarial property value + * containing either would smuggle a new key into the command header. + * The message body (after the closing `]`) escapes `%`, `\r`, and `\n`. + */ +function escapeProperty(value: string): string { + return value + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A") + .replace(/]/g, "%5D") + .replace(/;/g, "%3B") + .replace(/=/g, "%3D") + .replace(/ /g, "%20"); +} + +function escapeMessage(value: string): string { + return value + .replace(/%/g, "%25") + .replace(/\r/g, "%0D") + .replace(/\n/g, "%0A"); +} + +function emit(line: string): void { + process.stdout.write(line + "\n"); +} + +/** Generic emitter for callers that need to write something visible to the + * pipeline log without using one of the structured `task.logissue` or + * `task.complete` shapes. The message is escaped the same way as the body + * of a `##vso` command, AND a leading `#` is percent-encoded so an + * adversarial message cannot smuggle a `##vso[` command (ADO only + * interprets `##vso[` at line-start). */ +export function logInfo(msg: string): void { + const safe = escapeMessage(msg).replace(/^#/, "%23"); + emit(safe); +} + +export function setOutput(name: string, value: string): void { + const safeName = escapeProperty(name); + const safeValue = escapeMessage(value); + emit(`##vso[task.setvariable variable=${safeName};isOutput=true]${safeValue}`); +} + +export function addBuildTag(tag: string): void { + emit(`##vso[build.addbuildtag]${escapeMessage(tag)}`); +} + +export function logWarning(msg: string): void { + emit(`##vso[task.logissue type=warning;]${escapeMessage(msg)}`); +} + +export function logError(msg: string): void { + emit(`##vso[task.logissue type=error;]${escapeMessage(msg)}`); +} + +export type CompleteResult = "Succeeded" | "Failed" | "SucceededWithIssues"; + +// `complete()` is idempotent: ADO's behaviour on two consecutive +// `##vso[task.complete]` commands is undefined (some runners ignore the +// second, others let it override). We track first-call winning so the +// runtime contract is unambiguous regardless of caller composition +// (e.g. bypass returning early then main also reaching the final emit). +let completed = false; + +export function complete(result: CompleteResult, msg?: string): void { + if (completed) return; + completed = true; + emit(`##vso[task.complete result=${result};]${escapeMessage(msg ?? "done")}`); +} + +/** For tests only: clear the `complete()` latch between cases. */ +export function _resetCompletedForTesting(): void { + completed = false; +} diff --git a/scripts/ado-script/test/fixtures/gate-spec-pr-title-match.json b/scripts/ado-script/test/fixtures/gate-spec-pr-title-match.json new file mode 100644 index 00000000..1313bbe4 --- /dev/null +++ b/scripts/ado-script/test/fixtures/gate-spec-pr-title-match.json @@ -0,0 +1,22 @@ +{ + "context": { + "build_reason": "PullRequest", + "bypass_label": "Pull Request", + "step_name": "filter_gate", + "tag_prefix": "filter" + }, + "facts": [ + { "kind": "pr_title", "failure_policy": "fail_closed" } + ], + "checks": [ + { + "name": "title_starts_with_foo", + "tag_suffix": "title", + "predicate": { + "type": "glob_match", + "fact": "pr_title", + "pattern": "foo*" + } + } + ] +} diff --git a/scripts/ado-script/test/smoke.test.ts b/scripts/ado-script/test/smoke.test.ts new file mode 100644 index 00000000..e146bc90 --- /dev/null +++ b/scripts/ado-script/test/smoke.test.ts @@ -0,0 +1,67 @@ +/** + * End-to-end smoke test of the bundled gate.js. + * + * Spawns `node dist/gate/index.js` as a subprocess with a hand-rolled + * GateSpec fixture and a known set of pipeline-style env vars. Verifies + * the gate emits the expected `SHOULD_RUN` setvariable for both the + * pass and fail cases. This validates the bundle, the env-var contract, + * and the predicate evaluator end-to-end without touching the ADO REST + * API. + */ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const bundlePath = resolve(__dirname, "../dist/gate/index.js"); +const fixturePath = resolve( + __dirname, + "fixtures/gate-spec-pr-title-match.json", +); + +function runGate(extraEnv: Record): { + stdout: string; + stderr: string; + status: number | null; +} { + const fixture = readFileSync(fixturePath, "utf8"); + const gateSpec = Buffer.from(fixture).toString("base64"); + const result = spawnSync(process.execPath, [bundlePath], { + env: { + // Wipe the parent env so leaked CI/system vars don't influence the gate. + PATH: process.env.PATH ?? "", + GATE_SPEC: gateSpec, + ADO_BUILD_REASON: "PullRequest", + SYSTEM_ACCESSTOKEN: "dummy", + ADO_COLLECTION_URI: "https://example.invalid/", + ADO_PROJECT: "p", + ADO_BUILD_ID: "1", + ...extraEnv, + }, + encoding: "utf8", + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status, + }; +} + +describe("gate.js smoke", () => { + it("emits SHOULD_RUN=true when pr_title matches the glob", () => { + const { stdout, status } = runGate({ ADO_PR_TITLE: "fooBar" }); + expect(stdout).toContain( + "##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true", + ); + expect(status).toBe(0); + }); + + it("emits SHOULD_RUN=false when pr_title does not match the glob", () => { + const { stdout } = runGate({ ADO_PR_TITLE: "barBar" }); + expect(stdout).toContain( + "##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]false", + ); + }); +}); diff --git a/scripts/ado-script/tsconfig.json b/scripts/ado-script/tsconfig.json new file mode 100644 index 00000000..5c85dc9c --- /dev/null +++ b/scripts/ado-script/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": false, + "sourceMap": true, + "rootDir": "src", + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/scripts/ado-script/vitest.config.smoke.ts b/scripts/ado-script/vitest.config.smoke.ts new file mode 100644 index 00000000..0ea6f303 --- /dev/null +++ b/scripts/ado-script/vitest.config.smoke.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Smoke-test config: targets the bundled gate.js end-to-end. The + // suite must run AFTER `npm run build` produces dist/gate/index.js. + include: ["test/**/*.test.ts"], + }, +}); diff --git a/scripts/ado-script/vitest.config.ts b/scripts/ado-script/vitest.config.ts new file mode 100644 index 00000000..82034a4b --- /dev/null +++ b/scripts/ado-script/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Default suite covers source-side tests under src/. The smoke test + // under test/ depends on dist/gate/index.js existing, so it runs via + // a separate config — see vitest.config.smoke.ts and + // `npm run test:smoke`. + include: ["src/**/*.test.ts"], + }, +}); + + diff --git a/scripts/gate-eval.py b/scripts/gate-eval.py deleted file mode 100644 index 62afd528..00000000 --- a/scripts/gate-eval.py +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env python3 -"""ado-aw gate evaluator — data-driven trigger filter evaluation. - -Reads a base64-encoded JSON gate spec from the GATE_SPEC environment variable, -acquires runtime facts, evaluates filter predicates, and reports results via -ADO logging commands. - -This script is embedded by the ado-aw compiler into pipeline gate steps. -It should not be modified directly — changes belong in src/compile/filter_ir.rs. -""" -import base64, json, os, sys -from datetime import datetime, timezone - -# ─── Fact dependencies ─────────────────────────────────────────────────────── - -FACT_DEPS = { - "pr_is_draft": ["pr_metadata"], - "pr_labels": ["pr_metadata"], - "changed_file_count": ["changed_files"], -} - -# ADO branch variables return refs/heads/... or refs/pull/... prefixed values. -# Strip the prefix so user patterns like "feature/*" match naturally. -_REF_PREFIXES = ("refs/heads/", "refs/tags/", "refs/pull/") - -def _strip_ref_prefix(value): - """Strip refs/heads/ (or similar) prefix from a branch/ref value.""" - for prefix in _REF_PREFIXES: - if value.startswith(prefix): - return value[len(prefix):] - return value - -# ─── Fact acquisition ──────────────────────────────────────────────────────── - -def acquire_fact(kind, acquired): - """Acquire a fact value by kind. Returns the value or raises on failure.""" - # Pipeline variables (from ADO macro exports) - env_facts = { - "pr_title": "ADO_PR_TITLE", - "author_email": "ADO_AUTHOR_EMAIL", - "source_branch": "ADO_SOURCE_BRANCH", - "target_branch": "ADO_TARGET_BRANCH", - "commit_message": "ADO_COMMIT_MESSAGE", - "build_reason": "ADO_BUILD_REASON", - "triggered_by_pipeline": "ADO_TRIGGERED_BY_PIPELINE", - "triggering_branch": "ADO_TRIGGERING_BRANCH", - } - if kind in env_facts: - value = os.environ.get(env_facts[kind], "") - # ADO branch variables include refs/heads/ prefix — strip it - # so user patterns like "feature/*" match without the prefix. - # Also strip from the pattern side in glob_match (below). - if kind in ("source_branch", "target_branch", "triggering_branch"): - value = _strip_ref_prefix(value) - return value - - if kind == "pr_metadata": - return _fetch_pr_metadata() - - if kind == "pr_is_draft": - md = acquired.get("pr_metadata") - if md is None: - return "unknown" - data = json.loads(md) if isinstance(md, str) else md - return str(data.get("isDraft", False)).lower() - - if kind == "pr_labels": - md = acquired.get("pr_metadata") - if md is None: - return [] - data = json.loads(md) if isinstance(md, str) else md - return [l.get("name", "") for l in data.get("labels", [])] - - if kind == "changed_files": - return _fetch_changed_files() - - if kind == "changed_file_count": - files = acquired.get("changed_files", []) - return len(files) if isinstance(files, list) else 0 - - if kind == "current_utc_minutes": - now = datetime.now(timezone.utc) - return now.hour * 60 + now.minute - - raise ValueError(f"Unknown fact kind: {kind}") - - -def _fetch_pr_metadata(): - """Fetch PR metadata from ADO REST API.""" - from urllib.request import Request, urlopen - token = os.environ.get("SYSTEM_ACCESSTOKEN", "") - org_url = os.environ.get("ADO_COLLECTION_URI", "") - project = os.environ.get("ADO_PROJECT", "") - repo_id = os.environ.get("ADO_REPO_ID", "") - pr_id = os.environ.get("ADO_PR_ID", "") - if not all([token, org_url, project, repo_id, pr_id]): - raise RuntimeError("Missing ADO environment variables for PR metadata") - url = f"{org_url}{project}/_apis/git/repositories/{repo_id}/pullRequests/{pr_id}?api-version=7.1" - req = Request(url, headers={"Authorization": f"Bearer {token}"}) - with urlopen(req, timeout=30) as resp: - return json.loads(resp.read()) - - -def _fetch_changed_files(): - """Fetch changed files via PR iterations API. - - Returns the files changed in the *last iteration* (latest push) of the PR. - This reflects the current diff against the target branch, not the cumulative - history of all pushes. Files added in earlier iterations and later removed - will NOT appear in this list. - """ - from urllib.request import Request, urlopen - token = os.environ.get("SYSTEM_ACCESSTOKEN", "") - org_url = os.environ.get("ADO_COLLECTION_URI", "") - project = os.environ.get("ADO_PROJECT", "") - repo_id = os.environ.get("ADO_REPO_ID", "") - pr_id = os.environ.get("ADO_PR_ID", "") - if not all([token, org_url, project, repo_id, pr_id]): - raise RuntimeError("Missing ADO environment variables for changed files") - base = f"{org_url}{project}/_apis/git/repositories/{repo_id}/pullRequests/{pr_id}" - headers = {"Authorization": f"Bearer {token}"} - # Get iterations - req = Request(f"{base}/iterations?api-version=7.1", headers=headers) - with urlopen(req, timeout=30) as resp: - iters = json.loads(resp.read()).get("value", []) - if not iters: - return [] - last_iter = iters[-1]["id"] - # Get changes for last iteration - req = Request(f"{base}/iterations/{last_iter}/changes?api-version=7.1", headers=headers) - with urlopen(req, timeout=30) as resp: - changes = json.loads(resp.read()) - return [ - entry.get("item", {}).get("path", "").lstrip("/") - for entry in changes.get("changeEntries", []) - if entry.get("item", {}).get("path") - ] - - -# ─── Predicate evaluation ─────────────────────────────────────────────────── - -import re as _re - -def _glob(value, pattern): - """Match a value against a simple glob pattern. - - * matches any characters, ? matches a single character. - Brackets are literal (NOT character classes) — consistent across - all filter types (title, branch, changed-files, etc.). - """ - regex = _re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".") - return bool(_re.fullmatch(regex, value, flags=_re.DOTALL)) - -# Facts where ref prefixes should be stripped from patterns -_BRANCH_FACTS = {"source_branch", "target_branch", "triggering_branch"} - - -def evaluate(pred, facts): - """Evaluate a predicate against acquired facts. Returns True if passed.""" - t = pred["type"] - - if t == "glob_match": - value = str(facts.get(pred["fact"], "")) - pattern = pred["pattern"] - # Only strip refs/heads/ prefix from branch-related patterns - if pred["fact"] in _BRANCH_FACTS: - pattern = _strip_ref_prefix(pattern) - return _glob(value, pattern) - - if t == "equals": - value = str(facts.get(pred["fact"], "")) - return value == pred["value"] - - if t == "value_in_set": - value = str(facts.get(pred["fact"], "")) - values = pred["values"] - if pred.get("case_insensitive"): - return value.lower() in [v.lower() for v in values] - return value in values - - if t == "value_not_in_set": - value = str(facts.get(pred["fact"], "")) - values = pred["values"] - if pred.get("case_insensitive"): - return value.lower() not in [v.lower() for v in values] - return value not in values - - if t == "numeric_range": - value = int(facts.get(pred["fact"], 0)) - mn = pred.get("min") - mx = pred.get("max") - if mn is not None and value < mn: - return False - if mx is not None and value > mx: - return False - return True - - if t == "time_window": - current = int(facts.get("current_utc_minutes", 0)) - sh, sm = pred["start"].split(":") - eh, em = pred["end"].split(":") - start = int(sh) * 60 + int(sm) - end = int(eh) * 60 + int(em) - if start <= end: - return start <= current < end - else: # overnight window - return current >= start or current < end - - if t == "label_set_match": - labels = facts.get(pred["fact"]) or [] - if isinstance(labels, str): - labels = [l.strip() for l in labels.split("\n") if l.strip()] - labels_lower = [l.lower() for l in labels] - any_of = pred.get("any_of", []) - all_of = pred.get("all_of", []) - none_of = pred.get("none_of", []) - if any_of and not any(a.lower() in labels_lower for a in any_of): - return False - if all_of and not all(a.lower() in labels_lower for a in all_of): - return False - if none_of and any(n.lower() in labels_lower for n in none_of): - return False - return True - - if t == "file_glob_match": - files = facts.get(pred["fact"]) or [] - if isinstance(files, str): - files = [f.strip() for f in files.split("\n") if f.strip()] - includes = pred.get("include", []) - excludes = pred.get("exclude", []) - # Empty file list: exclude-only filters pass (no excluded files present), - # include filters fail (nothing to match against) - if not files: - if not includes: - return True # exclude-only: vacuously true (no bad files) - log(" (changed-files: no files in PR — filter will not match)") - return False - for f in files: - inc = not includes or any(_glob(f, p) for p in includes) - exc = any(_glob(f, p) for p in excludes) - if inc and not exc: - return True - return False - - if t == "and": - return all(evaluate(p, facts) for p in pred["operands"]) - - if t == "or": - return any(evaluate(p, facts) for p in pred["operands"]) - - if t == "not": - return not evaluate(pred["operand"], facts) - - log(f"##[warning]Unknown predicate type: {t}") - return True - - -def predicate_facts(pred): - """Collect fact IDs referenced by a predicate (for skip checking).""" - t = pred["type"] - result = set() - if "fact" in pred: - result.add(pred["fact"]) - if t in ("and", "or"): - for p in pred.get("operands", []): - result.update(predicate_facts(p)) - if t == "not": - result.update(predicate_facts(pred.get("operand", {}))) - return result - - -# ─── Helpers ───────────────────────────────────────────────────────────────── - -def log(msg): - print(msg, flush=True) - -def vso_output(name, value): - log(f"##vso[task.setvariable variable={name};isOutput=true]{value}") - -def vso_tag(tag): - log(f"##vso[build.addbuildtag]{tag}") - -def self_cancel(): - from urllib.request import Request, urlopen - token = os.environ.get("SYSTEM_ACCESSTOKEN", "") - org_url = os.environ.get("ADO_COLLECTION_URI", "") - project = os.environ.get("ADO_PROJECT", "") - build_id = os.environ.get("ADO_BUILD_ID", "") - if not all([token, org_url, project, build_id]): - log("##[warning]Cannot self-cancel: missing ADO environment variables") - return - url = f"{org_url}{project}/_apis/build/builds/{build_id}?api-version=7.1" - data = json.dumps({"status": "cancelling"}).encode() - req = Request(url, data=data, method="PATCH", headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - }) - try: - with urlopen(req, timeout=30) as resp: - resp.read() - except Exception as e: - log(f"##[warning]Self-cancel failed: {e}") - - -# ─── Main ──────────────────────────────────────────────────────────────────── - -def main(): - spec = json.loads(base64.b64decode(os.environ["GATE_SPEC"])) - ctx = spec["context"] - - # Bypass for non-matching trigger types - build_reason = os.environ.get("ADO_BUILD_REASON", "") - if build_reason != ctx["build_reason"]: - log(f"Not a {ctx['bypass_label']} build -- gate passes automatically") - vso_output("SHOULD_RUN", "true") - vso_tag(f"{ctx['tag_prefix']}:passed") - sys.exit(0) - - # Acquire facts (dependency-ordered) - facts = {} - skip_facts = set() - fail_open_facts = set() - should_run = True - for fact_spec in spec["facts"]: - kind = fact_spec["kind"] - policy = fact_spec.get("failure_policy", "fail_closed") - if policy not in ("fail_closed", "fail_open", "skip_dependents"): - raise ValueError(f"Unknown failure_policy '{policy}' for fact '{kind}'") - deps = FACT_DEPS.get(kind, []) - if any(d in skip_facts for d in deps): - skip_facts.add(kind) - log(f" Fact [{kind}]: skipped (dependency unavailable)") - continue - # Propagate fail-open from dependencies: if a dependency failed-open, - # this fact is also fail-open (e.g. changed_file_count when - # changed_files API failed) - if any(d in fail_open_facts for d in deps): - fail_open_facts.add(kind) - log(f" Fact [{kind}]: fail-open (dependency failed-open)") - continue - try: - facts[kind] = acquire_fact(kind, facts) - log(f" Fact [{kind}]: acquired") - except Exception as e: - log(f"##[warning]Fact [{kind}]: acquisition failed ({e})") - if policy == "skip_dependents": - skip_facts.add(kind) - elif policy == "fail_open": - facts[kind] = None - fail_open_facts.add(kind) - else: - # fail_closed: gate fails, skip dependent checks - facts[kind] = None - skip_facts.add(kind) - should_run = False - vso_tag(f"{ctx['tag_prefix']}:{kind}-unavailable") - - # Evaluate checks - for check in spec["checks"]: - name = check["name"] - required = predicate_facts(check["predicate"]) - if any(f in skip_facts for f in required): - log(f" Filter: {name} | Result: SKIPPED (dependency unavailable)") - continue - if any(f in fail_open_facts for f in required): - log(f" Filter: {name} | Result: PASS (fail-open)") - continue - passed = evaluate(check["predicate"], facts) - if passed: - log(f" Filter: {name} | Result: PASS") - else: - tag = f"{ctx['tag_prefix']}:{check['tag_suffix']}" - log(f"##[warning]Filter {name} did not match") - vso_tag(tag) - should_run = False - - # Report result - vso_output("SHOULD_RUN", str(should_run).lower()) - if should_run: - log("All filters passed -- agent will run") - vso_tag(f"{ctx['tag_prefix']}:passed") - else: - log("Filters not matched -- cancelling build") - vso_tag(f"{ctx['tag_prefix']}:skipped") - self_cancel() - -if __name__ == "__main__": - main() diff --git a/scripts/gate-spec.schema.json b/scripts/gate-spec.schema.json deleted file mode 100644 index 0c06d987..00000000 --- a/scripts/gate-spec.schema.json +++ /dev/null @@ -1,366 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "GateSpec", - "description": "Serializable gate specification — the JSON document consumed by the\nPython gate evaluator at pipeline runtime.", - "type": "object", - "properties": { - "checks": { - "type": "array", - "items": { - "$ref": "#/$defs/CheckSpec" - } - }, - "context": { - "$ref": "#/$defs/GateContextSpec" - }, - "facts": { - "type": "array", - "items": { - "$ref": "#/$defs/FactSpec" - } - } - }, - "required": [ - "context", - "facts", - "checks" - ], - "$defs": { - "CheckSpec": { - "description": "Serialized filter check.", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "predicate": { - "$ref": "#/$defs/PredicateSpec" - }, - "tag_suffix": { - "type": "string" - } - }, - "required": [ - "name", - "predicate", - "tag_suffix" - ] - }, - "FactSpec": { - "description": "Serialized fact acquisition descriptor.", - "type": "object", - "properties": { - "failure_policy": { - "type": "string" - }, - "id": { - "type": "string" - }, - "kind": { - "type": "string" - } - }, - "required": [ - "id", - "kind", - "failure_policy" - ] - }, - "GateContextSpec": { - "description": "Serialized gate context.", - "type": "object", - "properties": { - "build_reason": { - "type": "string" - }, - "bypass_label": { - "type": "string" - }, - "step_name": { - "type": "string" - }, - "tag_prefix": { - "type": "string" - } - }, - "required": [ - "build_reason", - "tag_prefix", - "step_name", - "bypass_label" - ] - }, - "PredicateSpec": { - "description": "Serialized predicate — the expression tree evaluated at runtime.", - "oneOf": [ - { - "type": "object", - "properties": { - "fact": { - "type": "string" - }, - "pattern": { - "type": "string" - }, - "type": { - "type": "string", - "const": "glob_match" - } - }, - "required": [ - "type", - "fact", - "pattern" - ] - }, - { - "type": "object", - "properties": { - "fact": { - "type": "string" - }, - "type": { - "type": "string", - "const": "equals" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "fact", - "value" - ] - }, - { - "type": "object", - "properties": { - "case_insensitive": { - "type": "boolean" - }, - "fact": { - "type": "string" - }, - "type": { - "type": "string", - "const": "value_in_set" - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "type", - "fact", - "values", - "case_insensitive" - ] - }, - { - "type": "object", - "properties": { - "case_insensitive": { - "type": "boolean" - }, - "fact": { - "type": "string" - }, - "type": { - "type": "string", - "const": "value_not_in_set" - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "type", - "fact", - "values", - "case_insensitive" - ] - }, - { - "type": "object", - "properties": { - "fact": { - "type": "string" - }, - "max": { - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "min": { - "type": [ - "integer", - "null" - ], - "format": "uint32", - "minimum": 0 - }, - "type": { - "type": "string", - "const": "numeric_range" - } - }, - "required": [ - "type", - "fact" - ] - }, - { - "type": "object", - "properties": { - "end": { - "type": "string" - }, - "start": { - "type": "string" - }, - "type": { - "type": "string", - "const": "time_window" - } - }, - "required": [ - "type", - "start", - "end" - ] - }, - { - "type": "object", - "properties": { - "all_of": { - "type": "array", - "items": { - "type": "string" - } - }, - "any_of": { - "type": "array", - "items": { - "type": "string" - } - }, - "fact": { - "type": "string" - }, - "none_of": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "type": "string", - "const": "label_set_match" - } - }, - "required": [ - "type", - "fact", - "any_of", - "all_of", - "none_of" - ] - }, - { - "type": "object", - "properties": { - "exclude": { - "type": "array", - "items": { - "type": "string" - } - }, - "fact": { - "type": "string" - }, - "include": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "type": "string", - "const": "file_glob_match" - } - }, - "required": [ - "type", - "fact", - "include", - "exclude" - ] - }, - { - "type": "object", - "properties": { - "operands": { - "type": "array", - "items": { - "$ref": "#/$defs/PredicateSpec" - } - }, - "type": { - "type": "string", - "const": "and" - } - }, - "required": [ - "type", - "operands" - ] - }, - { - "type": "object", - "properties": { - "operands": { - "type": "array", - "items": { - "$ref": "#/$defs/PredicateSpec" - } - }, - "type": { - "type": "string", - "const": "or" - } - }, - "required": [ - "type", - "operands" - ] - }, - { - "type": "object", - "properties": { - "operand": { - "$ref": "#/$defs/PredicateSpec" - }, - "type": { - "type": "string", - "const": "not" - } - }, - "required": [ - "type", - "operand" - ] - } - ] - } - } -} \ No newline at end of file diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 3d0ada5f..1d2964f3 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -668,7 +668,7 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { } // ── Trigger filters (ExtensionPhase::Tool) ── - // Activated when Tier 2/3 filters require the Python evaluator. + // Activated when filters require the gate evaluator (TypeScript gate.js). let pr_filters = front_matter.pr_filters().cloned(); let pipeline_filters = front_matter.pipeline_filters().cloned(); if TriggerFiltersExtension::is_needed( diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 1b0be9cc..2647a88b 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -1,24 +1,24 @@ //! Trigger filters compiler extension. //! //! Activates when any `filters:` configuration is present under `on.pr` -//! or `on.pipeline`. Injects into the Setup job: (1) a download step for -//! the gate evaluator scripts bundle and (2) the gate step that evaluates -//! the filter spec via the Python evaluator. +//! or `on.pipeline`. Injects into the Setup job: (1) a Node install step, +//! (2) a download step for the gate evaluator scripts bundle, and (3) the +//! gate step that evaluates the filter spec via the Node evaluator. //! -//! All filter types (simple and complex) are evaluated by the Python +//! All filter types (simple and complex) are evaluated by the Node //! evaluator — there is no inline bash codegen path. use anyhow::Result; use super::{CompileContext, CompilerExtension, ExtensionPhase}; use crate::compile::filter_ir::{ - compile_gate_step_external, lower_pipeline_filters, lower_pr_filters, - validate_pipeline_filters, validate_pr_filters, GateContext, Severity, + GateContext, Severity, compile_gate_step_external, lower_pipeline_filters, lower_pr_filters, + validate_pipeline_filters, validate_pr_filters, }; use crate::compile::types::{PipelineFilters, PrFilters}; /// The path where the gate evaluator is downloaded at pipeline runtime. -const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/gate-eval.py"; +const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js"; /// Base URL for ado-aw release artifacts. const RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download"; @@ -31,10 +31,7 @@ pub struct TriggerFiltersExtension { } impl TriggerFiltersExtension { - pub fn new( - pr_filters: Option, - pipeline_filters: Option, - ) -> Self { + pub fn new(pr_filters: Option, pipeline_filters: Option) -> Self { Self { pr_filters, pipeline_filters, @@ -99,15 +96,38 @@ impl CompilerExtension for TriggerFiltersExtension { } let mut steps = Vec::new(); + + // Install Node 20.x for the gate evaluator. Pin to LTS major; ado-aw + // only requires basic Node features, so any 20.x patch release is + // acceptable. NodeTool@0 is preinstalled on Microsoft-hosted and 1ES + // images. A 5-minute timeout caps the worst-case cold-image install + // — a hung Node install would otherwise block the entire pipeline + // until the agent-level job timeout (often hours) fires. + steps.push( + r#"- task: NodeTool@0 + inputs: + versionSpec: "20.x" + displayName: "Install Node.js 20.x for gate evaluator" + timeoutInMinutes: 5 + condition: succeeded()"# + .to_string(), + ); + + // Same rationale for the download/extract step: bound the + // curl + sha256sum + unzip pipeline so a stalled CDN response + // doesn't tie up the whole pipeline. The unzip command also + // passes `-d` explicitly as a belt-and-suspenders zip-slip + // hardening on top of the sha256 verification above. steps.push(format!( r#"- bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts curl -fsSL "{RELEASE_BASE_URL}/v{version}/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "{RELEASE_BASE_URL}/v{version}/scripts.zip" -o /tmp/ado-aw-scripts/scripts.zip - cd /tmp/ado-aw-scripts && grep "scripts.zip" checksums.txt | sha256sum -c - - cd /tmp/ado-aw-scripts && unzip -jo scripts.zip gate-eval.py + curl -fsSL "{RELEASE_BASE_URL}/v{version}/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - + unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ displayName: "Download ado-aw scripts (v{version})" + timeoutInMinutes: 5 condition: succeeded()"#, )); steps.extend(gate_steps); @@ -151,8 +171,8 @@ impl CompilerExtension for TriggerFiltersExtension { #[cfg(test)] mod tests { use super::*; - use crate::compile::types::*; use crate::compile::extensions::CompileContext; + use crate::compile::types::*; #[test] fn test_is_needed_any_filters() { @@ -213,35 +233,49 @@ mod tests { }), ..Default::default() }; - let ext = TriggerFiltersExtension::new( - Some(filters), - None, - ); + let ext = TriggerFiltersExtension::new(Some(filters), None); let yaml = "name: test\ndescription: test"; let fm: FrontMatter = serde_yaml::from_str(yaml).unwrap(); let ctx = CompileContext::for_test(&fm); let steps = ext.setup_steps(&ctx).unwrap(); - assert_eq!(steps.len(), 2, "should have download + gate step"); - assert!(steps[0].contains("curl"), "first step should download"); + assert_eq!( + steps.len(), + 3, + "should have Node install + download + gate step" + ); assert!( - steps[0].contains("scripts.zip"), - "should download scripts.zip" + steps[0].contains("NodeTool@0"), + "first step should install Node" ); + assert!(steps[0].contains("20.x"), "should install Node 20.x"); + assert!(steps[1].contains("curl"), "second step should download"); assert!( - steps[0].contains("checksums.txt"), + steps[1].contains("ado-script.zip"), + "should download ado-script.zip" + ); + assert!( + steps[1].contains("checksums.txt"), "should download checksums.txt" ); assert!( - steps[0].contains("sha256sum -c -"), - "should verify scripts.zip checksum" + steps[1].contains("sha256sum -c -"), + "should verify ado-script.zip checksum" + ); + assert!( + steps[1].contains("unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/"), + "should extract ado-script.zip into the explicit target dir" ); assert!( - steps[0].contains("unzip -jo scripts.zip gate-eval.py"), - "should extract only gate-eval.py" + steps[0].contains("timeoutInMinutes: 5"), + "Node install step should bound runtime" ); - assert!(steps[1].contains("prGate"), "second step should be PR gate"); assert!( - steps[1].contains("python3 '/tmp/ado-aw-scripts/gate-eval.py'"), + steps[1].contains("timeoutInMinutes: 5"), + "Download step should bound runtime" + ); + assert!(steps[2].contains("prGate"), "third step should be PR gate"); + assert!( + steps[2].contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), "gate step should reference external script" ); } @@ -260,10 +294,7 @@ mod tests { max_changes: Some(5), ..Default::default() }; - let ext = TriggerFiltersExtension::new( - Some(filters), - None, - ); + let ext = TriggerFiltersExtension::new(Some(filters), None); let yaml = r#" name: test description: test agent diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index a0740bd1..fe3cf419 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -136,6 +136,9 @@ impl Fact { } /// True if this fact is a free pipeline variable (no API/computation). + /// Only used for test assertions; the runtime evaluator has its own + /// mirror in `scripts/ado-script/src/shared/env-facts.ts::isPipelineVarFact`. + #[cfg(test)] pub fn is_pipeline_var(&self) -> bool { matches!( self, @@ -205,7 +208,7 @@ pub enum Predicate { none_of: Vec, }, - /// Changed file glob matching via python3 fnmatch. + /// Changed file glob matching via the external gate evaluator. FileGlobMatch { include: Vec, exclude: Vec, @@ -348,6 +351,11 @@ impl GateContext { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Severity { /// Informational — compilation continues. + /// Not yet produced by validation; reserved for future advisory + /// diagnostics that should appear in the compile log without + /// blocking or warning. Mirrors the And/Or/Not "reserved for future" + /// pattern at the top of `Predicate`. + #[allow(dead_code)] Info, /// Warning — compilation continues but user should review. Warning, @@ -385,9 +393,7 @@ impl fmt::Display for Diagnostic { // ─── Lowering (Filters → IR) ─────────────────────────────────────────────── /// Lower `PrFilters` into a list of `FilterCheck` IR nodes. -pub fn lower_pr_filters( - filters: &super::types::PrFilters, -) -> Vec { +pub fn lower_pr_filters(filters: &super::types::PrFilters) -> Vec { let mut checks = Vec::new(); // Tier 1: Pipeline variables @@ -552,9 +558,7 @@ pub fn lower_pr_filters( } /// Lower `PipelineFilters` into a list of `FilterCheck` IR nodes. -pub fn lower_pipeline_filters( - filters: &super::types::PipelineFilters, -) -> Vec { +pub fn lower_pipeline_filters(filters: &super::types::PipelineFilters) -> Vec { let mut checks = Vec::new(); if let Some(sp) = &filters.source_pipeline { @@ -647,20 +651,14 @@ pub fn validate_pr_filters(filters: &super::types::PrFilters) -> Vec diags.push(Diagnostic { severity: Severity::Error, filter: "time-window".into(), - message: format!( - "start '{}' is not valid HH:MM format", - tw.start - ), + message: format!("start '{}' is not valid HH:MM format", tw.start), }); } if !is_valid_time(tw.end.as_str()) { diags.push(Diagnostic { severity: Severity::Error, filter: "time-window".into(), - message: format!( - "end '{}' is not valid HH:MM format", - tw.end - ), + message: format!("end '{}' is not valid HH:MM format", tw.end), }); } if tw.start == tw.end { @@ -745,9 +743,7 @@ pub fn validate_pr_filters(filters: &super::types::PrFilters) -> Vec } /// Validate pipeline filter configuration for conflicts. -pub fn validate_pipeline_filters( - filters: &super::types::PipelineFilters, -) -> Vec { +pub fn validate_pipeline_filters(filters: &super::types::PipelineFilters) -> Vec { let mut diags = Vec::new(); if let Some(tw) = &filters.time_window { @@ -818,11 +814,11 @@ fn is_valid_time(s: &str) -> bool { // ─── Serializable Gate Spec ───────────────────────────────────────────────── -use serde::Serialize; use schemars::JsonSchema; +use serde::Serialize; /// Serializable gate specification — the JSON document consumed by the -/// Python gate evaluator at pipeline runtime. +/// Node gate evaluator (`scripts/ado-script/dist/gate/index.js`) at pipeline runtime. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct GateSpec { pub context: GateContextSpec, @@ -844,6 +840,10 @@ pub struct GateContextSpec { pub struct FactSpec { pub kind: String, pub failure_policy: String, + /// Kinds of other facts that must be acquired before this one. + /// Mirrors `Fact::dependencies()`. Carried in the spec so the gate + /// evaluator does not duplicate the dependency graph. + pub dependencies: Vec, } /// Serialized filter check. @@ -923,8 +923,8 @@ pub enum PredicateSpec { /// Generate the JSON Schema for the gate spec. /// /// This schema is the formal contract between the Rust compiler and the -/// Python evaluator. It should be shipped in `scripts/gate-spec.schema.json` -/// alongside the evaluator. +/// TypeScript gate evaluator. It is used to generate `types.gen.ts` in +/// the `scripts/ado-script` workspace. pub fn generate_gate_spec_schema() -> String { let schema = schemars::schema_for!(GateSpec); serde_json::to_string_pretty(&schema).expect("schema serialization") @@ -933,13 +933,24 @@ pub fn generate_gate_spec_schema() -> String { // ─── Codegen ──────────────────────────────────────────────────────────────── // The inline heredoc evaluator has been removed in favor of external script delivery. -// See TriggerFiltersExtension for the external path and compile_gate_step_inline for Tier 1. +// See TriggerFiltersExtension for the external path (bundled TypeScript gate.js). impl Fact { /// ADO macro exports required by this fact. /// - /// Returns `(env_var_name, ado_macro)` pairs that must be exported in - /// the bash shim for the Python evaluator to read. + /// Returns `(env_var_name, ado_macro)` pairs that must be set in the + /// step's `env:` block for the gate evaluator to read. + /// + /// **Drift note:** the TypeScript gate evaluator carries its own copy of + /// this mapping in `scripts/ado-script/src/shared/env-facts.ts` as the + /// `ENV_BY_FACT` table plus the `FactKind` type union. Those are *not* + /// covered by the `types.gen.ts` codegen drift check (which only mirrors + /// `GateSpec` shape), so when adding a new pipeline-variable `Fact` + /// variant here you **must also** add an entry to `ENV_BY_FACT` and + /// extend `FactKind`. Failing to do so produces a silent wrong-answer + /// bug at runtime: `readEnvFact` returns `undefined`, the fact's failure + /// policy decides the verdict, and a `fail_open` fact would let the gate + /// pass without ever checking the predicate. pub fn ado_exports(&self) -> Vec<(&'static str, &'static str)> { match self { Fact::PrTitle => vec![("ADO_PR_TITLE", "$(System.PullRequest.Title)")], @@ -1075,6 +1086,7 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> anyhow::Resu .map(|f| FactSpec { kind: f.kind().into(), failure_policy: f.failure_policy().as_str().into(), + dependencies: f.dependencies().iter().map(|d| d.kind().into()).collect(), }) .collect(); @@ -1111,7 +1123,7 @@ pub fn compile_gate_step_external( checks: &[FilterCheck], evaluator_path: &str, ) -> anyhow::Result { - use base64::{engine::general_purpose::STANDARD, Engine as _}; + use base64::{Engine as _, engine::general_purpose::STANDARD}; if checks.is_empty() { return Ok(String::new()); @@ -1124,12 +1136,9 @@ pub fn compile_gate_step_external( let exports = collect_ado_exports(checks)?; let mut step = String::new(); - step.push_str(&format!("- bash: python3 '{}'\n", evaluator_path)); + step.push_str(&format!("- bash: node '{}'\n", evaluator_path)); step.push_str(&format!(" name: {}\n", ctx.step_name())); - step.push_str(&format!( - " displayName: \"{}\"\n", - ctx.display_name() - )); + step.push_str(&format!(" displayName: \"{}\"\n", ctx.display_name())); step.push_str(" condition: succeeded()\n"); step.push_str(" env:\n"); // SYSTEM_ACCESSTOKEN is always needed for self-cancel (PATCH to builds API). @@ -1145,10 +1154,10 @@ pub fn compile_gate_step_external( Ok(step) } - - /// Collect ADO macro exports needed by the given checks. -fn collect_ado_exports(checks: &[FilterCheck]) -> anyhow::Result> { +fn collect_ado_exports( + checks: &[FilterCheck], +) -> anyhow::Result> { let facts_set = collect_ordered_facts(checks)?; let mut exports: Vec<(&str, &str)> = Vec::new(); let mut seen = BTreeSet::new(); @@ -1191,7 +1200,6 @@ fn collect_ado_exports(checks: &[FilterCheck]) -> anyhow::Result anyhow::Result> { while !remaining.is_empty() { let before = remaining.len(); remaining.retain(|fact| { - let deps_met = fact - .dependencies() - .iter() - .all(|dep| emitted.contains(dep)); + let deps_met = fact.dependencies().iter().all(|dep| emitted.contains(dep)); if deps_met { emitted.insert(*fact); ordered.push(*fact); @@ -1291,9 +1296,12 @@ mod tests { Fact::ChangedFileCount, Fact::CurrentUtcMinutes, ]; - let kinds: BTreeSet<&str> = - all_facts.iter().map(|f| f.kind()).collect(); - assert_eq!(kinds.len(), all_facts.len(), "fact kind strings must be unique"); + let kinds: BTreeSet<&str> = all_facts.iter().map(|f| f.kind()).collect(); + assert_eq!( + kinds.len(), + all_facts.len(), + "fact kind strings must be unique" + ); } // ─── Lowering tests ──────────────────────────────────────────────── @@ -1349,7 +1357,10 @@ mod tests { }; let checks = lower_pr_filters(&filters); assert_eq!(checks.len(), 1); - assert!(matches!(&checks[0].predicate, Predicate::LabelSetMatch { .. })); + assert!(matches!( + &checks[0].predicate, + Predicate::LabelSetMatch { .. } + )); } #[test] @@ -1363,7 +1374,11 @@ mod tests { assert_eq!(checks.len(), 1); assert!(matches!( &checks[0].predicate, - Predicate::NumericRange { min: Some(5), max: Some(100), .. } + Predicate::NumericRange { + min: Some(5), + max: Some(100), + .. + } )); } @@ -1396,8 +1411,11 @@ mod tests { ..Default::default() }; let diags = validate_pr_filters(&filters); - assert!(diags.iter().any(|d| d.severity == Severity::Error - && d.filter.contains("min-changes"))); + assert!( + diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter.contains("min-changes")) + ); } #[test] @@ -1410,9 +1428,11 @@ mod tests { ..Default::default() }; let diags = validate_pr_filters(&filters); - assert!(diags - .iter() - .any(|d| d.severity == Severity::Error && d.filter == "time-window")); + assert!( + diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "time-window") + ); } #[test] @@ -1425,9 +1445,11 @@ mod tests { ..Default::default() }; let diags = validate_pr_filters(&filters); - assert!(diags - .iter() - .any(|d| d.severity == Severity::Error && d.filter == "author")); + assert!( + diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "author") + ); } #[test] @@ -1441,9 +1463,11 @@ mod tests { ..Default::default() }; let diags = validate_pr_filters(&filters); - assert!(diags - .iter() - .any(|d| d.severity == Severity::Error && d.filter == "labels")); + assert!( + diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "labels") + ); } #[test] @@ -1457,9 +1481,11 @@ mod tests { ..Default::default() }; let diags = validate_pr_filters(&filters); - assert!(diags - .iter() - .any(|d| d.severity == Severity::Error && d.filter == "labels")); + assert!( + diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "labels") + ); } #[test] @@ -1472,9 +1498,11 @@ mod tests { ..Default::default() }; let diags = validate_pr_filters(&filters); - assert!(diags - .iter() - .any(|d| d.severity == Severity::Error && d.filter == "build-reason")); + assert!( + diags + .iter() + .any(|d| d.severity == Severity::Error && d.filter == "build-reason") + ); } #[test] @@ -1503,7 +1531,12 @@ mod tests { #[test] fn test_compile_gate_step_empty() { - let result = compile_gate_step_external(GateContext::PullRequest, &[], "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); + let result = compile_gate_step_external( + GateContext::PullRequest, + &[], + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); assert!(result.is_empty()); } @@ -1517,12 +1550,26 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); + let result = compile_gate_step_external( + GateContext::PullRequest, + &checks, + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); assert!(result.contains("- bash:"), "should be a bash step"); - assert!(result.contains("GATE_SPEC"), "should include base64 spec in env"); - assert!(result.contains("python3 '/tmp/ado-aw-scripts/gate-eval.py'"), "should reference external evaluator script"); + assert!( + result.contains("GATE_SPEC"), + "should include base64 spec in env" + ); + assert!( + result.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), + "should reference external evaluator script" + ); assert!(result.contains("name: prGate"), "should set step name"); - assert!(result.contains("SYSTEM_ACCESSTOKEN"), "should pass access token via env block"); + assert!( + result.contains("SYSTEM_ACCESSTOKEN"), + "should pass access token via env block" + ); } #[test] @@ -1535,10 +1582,21 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); - assert!(result.contains("ADO_BUILD_REASON"), "should export build reason"); + let result = compile_gate_step_external( + GateContext::PullRequest, + &checks, + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); + assert!( + result.contains("ADO_BUILD_REASON"), + "should export build reason" + ); assert!(result.contains("ADO_PR_TITLE"), "should export PR title"); - assert!(result.contains("$(System.PullRequest.Title)"), "should reference ADO macro"); + assert!( + result.contains("$(System.PullRequest.Title)"), + "should reference ADO macro" + ); } #[test] @@ -1551,10 +1609,24 @@ mod tests { }, build_tag_suffix: "source-pipeline-mismatch", }]; - let result = compile_gate_step_external(GateContext::PipelineCompletion, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); - assert!(result.contains("name: pipelineGate"), "should set pipeline gate name"); - assert!(result.contains("Evaluate pipeline filters"), "should set display name"); - assert!(result.contains("ADO_TRIGGERED_BY_PIPELINE"), "should export pipeline macro"); + let result = compile_gate_step_external( + GateContext::PipelineCompletion, + &checks, + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); + assert!( + result.contains("name: pipelineGate"), + "should set pipeline gate name" + ); + assert!( + result.contains("Evaluate pipeline filters"), + "should set display name" + ); + assert!( + result.contains("ADO_TRIGGERED_BY_PIPELINE"), + "should export pipeline macro" + ); } #[test] @@ -1567,9 +1639,20 @@ mod tests { }, build_tag_suffix: "draft-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); - assert!(result.contains("ADO_REPO_ID"), "should export repo ID for API calls"); - assert!(result.contains("ADO_PR_ID"), "should export PR ID for API calls"); + let result = compile_gate_step_external( + GateContext::PullRequest, + &checks, + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); + assert!( + result.contains("ADO_REPO_ID"), + "should export repo ID for API calls" + ); + assert!( + result.contains("ADO_PR_ID"), + "should export PR ID for API calls" + ); } #[test] @@ -1582,10 +1665,21 @@ mod tests { }, build_tag_suffix: "title-mismatch", }]; - let result = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); + let result = compile_gate_step_external( + GateContext::PullRequest, + &checks, + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); // Check export lines only (evaluator script always contains these strings) - assert!(!result.contains("ADO_REPO_ID:"), "should not export repo ID for title-only"); - assert!(!result.contains("ADO_PR_ID:"), "should not export PR ID for title-only"); + assert!( + !result.contains("ADO_REPO_ID:"), + "should not export repo ID for title-only" + ); + assert!( + !result.contains("ADO_PR_ID:"), + "should not export PR ID for title-only" + ); } #[test] @@ -1664,11 +1758,16 @@ mod tests { let diags = validate_pr_filters(&filters); assert!(diags.iter().all(|d| d.severity != Severity::Error)); - let step = compile_gate_step_external(GateContext::PullRequest, &checks, "/tmp/ado-aw-scripts/gate-eval.py").unwrap(); + let step = compile_gate_step_external( + GateContext::PullRequest, + &checks, + "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + ) + .unwrap(); // Step structure assert!(step.contains("ADO_PR_TITLE")); assert!(step.contains("ADO_REPO_ID")); // for API-derived facts - assert!(step.contains("python3")); + assert!(step.contains("node")); assert!(step.contains("prGate")); // Spec content @@ -1684,11 +1783,13 @@ mod tests { #[test] fn test_generate_schema_is_valid_json() { let schema = generate_gate_spec_schema(); - let parsed: serde_json::Value = serde_json::from_str(&schema) - .expect("schema should be valid JSON"); + let parsed: serde_json::Value = + serde_json::from_str(&schema).expect("schema should be valid JSON"); assert!(parsed.is_object()); - assert!(parsed.get("$schema").is_some() || parsed.get("type").is_some(), - "should be a JSON Schema document"); + assert!( + parsed.get("$schema").is_some() || parsed.get("type").is_some(), + "should be a JSON Schema document" + ); } #[test] @@ -1696,9 +1797,17 @@ mod tests { let schema = generate_gate_spec_schema(); // All predicate type discriminators should appear in the schema for pred_type in &[ - "glob_match", "equals", "value_in_set", "value_not_in_set", - "numeric_range", "time_window", "label_set_match", - "file_glob_match", "and", "or", "not", + "glob_match", + "equals", + "value_in_set", + "value_not_in_set", + "numeric_range", + "time_window", + "label_set_match", + "file_glob_match", + "and", + "or", + "not", ] { assert!( schema.contains(pred_type), @@ -1732,17 +1841,19 @@ mod tests { #[test] #[ignore] // Writes to source tree — run manually with `cargo test test_write_schema -- --ignored` fn test_write_schema_to_scripts() { - // Generate schema and write to scripts/ for distribution + // Generate schema and write to the canonical location for codegen let schema = generate_gate_spec_schema(); let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("scripts") + .join("ado-script") + .join("schema") .join("gate-spec.schema.json"); - std::fs::write(&schema_path, &schema) - .expect("should write schema file"); + std::fs::create_dir_all(schema_path.parent().unwrap()) + .expect("should create schema dir"); + std::fs::write(&schema_path, &schema).expect("should write schema file"); // Verify it's readable and valid let read_back = std::fs::read_to_string(&schema_path).unwrap(); let _: serde_json::Value = serde_json::from_str(&read_back).unwrap(); } } - diff --git a/src/main.rs b/src/main.rs index 96f1bb1f..faacb3db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,8 +19,8 @@ mod ndjson; mod remove; mod run; pub mod runtimes; -pub mod sanitize; mod safeoutputs; +pub mod sanitize; mod secrets; mod status; mod tools; @@ -384,6 +384,14 @@ enum Commands { #[arg(long)] dry_run: bool, }, + /// Export the gate spec JSON Schema (build-time tool for the + /// scripts/ado-script TypeScript workspace). + #[command(hide = true)] + ExportGateSchema { + /// Output path; if omitted, prints to stdout. + #[arg(short, long)] + output: Option, + }, } #[derive(Parser, Debug)] @@ -416,7 +424,9 @@ async fn run_compile( } match path { - Some(p) => compile::compile_pipeline(&p, output.as_deref(), skip_integrity, debug_pipeline).await, + Some(p) => { + compile::compile_pipeline(&p, output.as_deref(), skip_integrity, debug_pipeline).await + } None => { if output.is_some() { anyhow::bail!( @@ -618,8 +628,11 @@ async fn build_execution_context( Ok(stats) => { log::info!( "Agent stats: {} input / {} output tokens, {}s duration, {} tool calls, {} turns", - stats.input_tokens, stats.output_tokens, - stats.duration_seconds as u64, stats.tool_calls, stats.turns + stats.input_tokens, + stats.output_tokens, + stats.duration_seconds as u64, + stats.tool_calls, + stats.turns ); ctx.agent_stats = Some(stats); } @@ -706,7 +719,10 @@ async fn process_cache_memory( } fn print_execution_summary(results: &[crate::safeoutputs::ExecutionResult]) { - let success_count = results.iter().filter(|r| r.success && !r.is_warning()).count(); + let success_count = results + .iter() + .filter(|r| r.success && !r.is_warning()) + .count(); let warning_count = results.iter().filter(|r| r.is_warning()).count(); let failure_count = results.iter().filter(|r| !r.success).count(); @@ -740,6 +756,7 @@ async fn main() -> Result<()> { Some(Commands::List { .. }) => "list", Some(Commands::Status { .. }) => "status", Some(Commands::Run { .. }) => "run", + Some(Commands::ExportGateSchema { .. }) => "export-gate-schema", None => "ado-aw", }; @@ -787,7 +804,11 @@ async fn main() -> Result<()> { bounding_directory, enabled_tools, } => { - let filter = if enabled_tools.is_empty() { None } else { Some(enabled_tools) }; + let filter = if enabled_tools.is_empty() { + None + } else { + Some(enabled_tools) + }; mcp::run(&output_directory, &bounding_directory, filter.as_deref()).await?; } Commands::Execute { @@ -798,8 +819,15 @@ async fn main() -> Result<()> { ado_project, dry_run, } => { - run_execute(source, safe_output_dir, output_dir, ado_org_url, ado_project, dry_run) - .await?; + run_execute( + source, + safe_output_dir, + output_dir, + ado_org_url, + ado_project, + dry_run, + ) + .await?; } Commands::McpHttp { port, @@ -808,7 +836,11 @@ async fn main() -> Result<()> { bounding_directory, enabled_tools, } => { - let filter = if enabled_tools.is_empty() { None } else { Some(enabled_tools) }; + let filter = if enabled_tools.is_empty() { + None + } else { + Some(enabled_tools) + }; mcp::run_http( &output_directory, &bounding_directory, @@ -1034,6 +1066,21 @@ async fn main() -> Result<()> { }) .await?; } + Commands::ExportGateSchema { output } => { + let schema = compile::filter_ir::generate_gate_spec_schema(); + match output { + Some(path) => { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, &schema)?; + } + None => print!("{}", schema), + } + } } Ok(()) } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 0b1d203b..78d350a7 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -177,8 +177,7 @@ fn test_compiled_yaml_structure() { assert!(template_path.exists(), "Base template should exist"); - let content = - fs::read_to_string(&template_path).expect("Should be able to read base template"); + let content = fs::read_to_string(&template_path).expect("Should be able to read base template"); assert_required_markers(&content); assert_pool_config(&content); @@ -327,19 +326,13 @@ fn test_fixture_complete_agent() { content.contains("repos:"), "Should have repos" ); - assert!( - content.contains("mcp-servers:"), - "Should have mcp-servers" - ); + assert!(content.contains("mcp-servers:"), "Should have mcp-servers"); // Verify it has MCP configuration and custom MCPs assert!(content.contains("container:"), "Should have custom MCP"); // Verify permissions - assert!( - content.contains("permissions:"), - "Should have permissions" - ); + assert!(content.contains("permissions:"), "Should have permissions"); assert!( content.contains("read: my-read-arm-connection"), "Should have read service connection" @@ -367,7 +360,12 @@ fn test_compiled_output_no_unreplaced_markers() { // Run the compiler binary let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", fixture_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + fixture_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -563,7 +561,12 @@ Do something. let output_path = temp_dir.join("perms-agent.yml"); let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", test_input.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -638,7 +641,12 @@ Do something. let output_path = temp_dir.join("no-perms-agent.yml"); let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", test_input.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -704,7 +712,12 @@ Do something. let output_path = temp_dir.join("bad-perms-agent.yml"); let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", test_input.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -754,7 +767,12 @@ Do something. let output_path = temp_dir.join("good-perms-agent.yml"); let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", test_input.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -793,7 +811,12 @@ Do something. let output_path = temp_dir.join("read-only-agent.yml"); let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", test_input.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + test_input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -893,10 +916,8 @@ fn test_1es_compiled_output_no_unreplaced_markers() { /// Test that update-wiki-page requires a write service connection #[test] fn test_update_wiki_page_requires_write_sc() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-wiki-fail-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-wiki-fail-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("wiki-agent.md"); @@ -944,10 +965,8 @@ Update the wiki. /// Test that update-wiki-page compiles successfully when a write SC is present #[test] fn test_update_wiki_page_compiles_with_write_sc() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-wiki-pass-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-wiki-pass-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("wiki-agent.md"); @@ -1096,10 +1115,8 @@ Create new wiki pages. /// Test that update-work-item requires a write service connection #[test] fn test_update_work_item_requires_write_sc() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-uwi-fail-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-uwi-fail-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("uwi-agent.md"); @@ -1148,10 +1165,8 @@ Update existing work items. /// Test that update-work-item compiles successfully when a write SC is present #[test] fn test_update_work_item_compiles_with_write_sc() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-uwi-pass-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-uwi-pass-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("uwi-agent.md"); @@ -1258,10 +1273,8 @@ Comment on work items. /// Test that comment-on-work-item requires a write service connection #[test] fn test_comment_on_work_item_requires_write_sc() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-cwi-sc-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-cwi-sc-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("cwi-agent.md"); @@ -1308,10 +1321,8 @@ Comment on work items. /// Test that comment-on-work-item compiles successfully with proper config #[test] fn test_comment_on_work_item_compiles_with_target_and_write_sc() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-cwi-pass-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-cwi-pass-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("cwi-agent.md"); @@ -1481,10 +1492,8 @@ Update existing work items. /// Test that compiled output starts with the `@ado-aw` header comment for pipeline detection #[test] fn test_compiled_output_has_header_comment() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-header-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-header-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1583,10 +1592,7 @@ This agent tests the auto-discovery feature. // Step 1: Compile the source file to create the initial YAML with header let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args([ - "compile", - "agents/my-agent.md", - ]) + .args(["compile", "agents/my-agent.md"]) .current_dir(&temp_dir) .output() .expect("Failed to run initial compile"); @@ -1619,7 +1625,8 @@ This agent tests the auto-discovery feature. assert!( output.status.success(), "Auto-discover compile should succeed.\nstdout: {}\nstderr: {}", - stdout, stderr + stdout, + stderr ); assert!( @@ -1682,13 +1689,15 @@ fn test_compile_auto_discover_skips_missing_source() { assert!( output.status.success(), "Should succeed even with missing source.\nstdout: {}\nstderr: {}", - stdout, stderr + stdout, + stderr ); assert!( stderr.contains("not found") || stdout.contains("skipped"), "Should warn about missing source.\nstdout: {}\nstderr: {}", - stdout, stderr + stdout, + stderr ); let _ = fs::remove_dir_all(&temp_dir); @@ -1750,10 +1759,8 @@ Submit PR reviews. /// Test that submit-pr-review fails compilation when allowed-events is an empty list #[test] fn test_submit_pr_review_requires_nonempty_allowed_events() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-spr-empty-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-spr-empty-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("spr-agent.md"); @@ -1802,10 +1809,8 @@ Submit PR reviews. /// Test that submit-pr-review compiles successfully with proper config #[test] fn test_submit_pr_review_compiles_with_allowed_events() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-spr-pass-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-spr-pass-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("spr-agent.md"); @@ -1851,10 +1856,8 @@ Submit PR reviews. /// Test that update-pr fails compilation when vote is reachable but allowed-votes is missing #[test] fn test_update_pr_requires_allowed_votes_when_vote_reachable() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-uprvote-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-uprvote-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let test_input = temp_dir.join("upr-agent.md"); @@ -2000,7 +2003,6 @@ Vote on pull requests. let _ = fs::remove_dir_all(&temp_dir); } - /// Integration test: compiling a pipeline with safe-outputs produces --enabled-tools flags /// in the rendered YAML. This exercises standalone.rs wiring + generate_enabled_tools_args /// + template substitution end-to-end. @@ -2080,16 +2082,13 @@ Do something. let _ = fs::remove_dir_all(&temp_dir); } - // ==================== Azure DevOps MCP Integration Tests ==================== /// Test that the Azure DevOps MCP fixture compiles successfully with no unreplaced markers #[test] fn test_fixture_azure_devops_mcp_compiled_output() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-ado-mcp-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-ado-mcp-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -2194,11 +2193,20 @@ fn test_mcpg_config_container_based_mcp() { let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); @@ -2217,10 +2225,8 @@ fn test_mcpg_config_container_based_mcp() { /// Test that HTTP-based MCPs generate correct MCPG config JSON structure #[test] fn test_mcpg_config_http_based_mcp() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-mcpg-http-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-mcpg-http-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let input = "---\nname: \"HTTP MCP Test\"\ndescription: \"Tests HTTP MCP\"\nmcp-servers:\n remote-ado:\n url: \"https://mcp.dev.azure.com/myorg\"\n headers:\n X-MCP-Toolsets: \"repos,wit\"\n allowed:\n - wit_get_work_item\n---\n\n## Test\n"; @@ -2231,11 +2237,20 @@ fn test_mcpg_config_http_based_mcp() { let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); @@ -2250,10 +2265,8 @@ fn test_mcpg_config_http_based_mcp() { /// Test that env passthrough generates -e flags in MCPG Docker run #[test] fn test_mcpg_docker_env_passthrough() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-mcpg-env-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-mcpg-env-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let input = "---\nname: \"Env Test\"\ndescription: \"Tests env passthrough\"\npermissions:\n read: my-read-sc\n write: my-write-sc\nmcp-servers:\n my-tool:\n container: \"node:20-slim\"\n env:\n AZURE_DEVOPS_EXT_PAT: \"\"\n MY_TOKEN: \"\"\n STATIC_VAR: \"static-value\"\nsafe-outputs:\n create-work-item:\n work-item-type: Task\n---\n\n## Test\n"; @@ -2264,23 +2277,41 @@ fn test_mcpg_docker_env_passthrough() { let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); // AZURE_DEVOPS_EXT_PAT with "" is bare passthrough for user-configured MCPs // (only tools.azure-devops extension provides SC_READ_TOKEN mapping) - assert!(compiled.contains("-e AZURE_DEVOPS_EXT_PAT"), "Should forward AZURE_DEVOPS_EXT_PAT as passthrough"); + assert!( + compiled.contains("-e AZURE_DEVOPS_EXT_PAT"), + "Should forward AZURE_DEVOPS_EXT_PAT as passthrough" + ); // Should forward passthrough env var MY_TOKEN - assert!(compiled.contains("-e MY_TOKEN"), "Should forward passthrough env var"); + assert!( + compiled.contains("-e MY_TOKEN"), + "Should forward passthrough env var" + ); // Static var should be in config - assert!(compiled.contains("\"STATIC_VAR\": \"static-value\""), "Static env var should be in config"); + assert!( + compiled.contains("\"STATIC_VAR\": \"static-value\""), + "Static env var should be in config" + ); let _ = fs::remove_dir_all(&temp_dir); } @@ -2288,10 +2319,8 @@ fn test_mcpg_docker_env_passthrough() { /// Test that user-defined parameters are emitted in the compiled pipeline YAML #[test] fn test_parameters_in_compiled_output() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-params-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-params-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let input = r#"--- @@ -2322,26 +2351,62 @@ Do the thing. let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); // Verify parameters block is present - assert!(compiled.contains("parameters:"), "Should contain parameters: block"); - assert!(compiled.contains("name: verbose"), "Should contain verbose parameter"); - assert!(compiled.contains("name: region"), "Should contain region parameter"); - assert!(compiled.contains("displayName: Verbose output"), "Should contain displayName"); - assert!(compiled.contains("default: false"), "Should contain default for verbose"); - assert!(compiled.contains("default: us-east"), "Should contain default for region"); - assert!(compiled.contains("- us-east"), "Should contain values for region"); - assert!(compiled.contains("- eu-west"), "Should contain values for region"); + assert!( + compiled.contains("parameters:"), + "Should contain parameters: block" + ); + assert!( + compiled.contains("name: verbose"), + "Should contain verbose parameter" + ); + assert!( + compiled.contains("name: region"), + "Should contain region parameter" + ); + assert!( + compiled.contains("displayName: Verbose output"), + "Should contain displayName" + ); + assert!( + compiled.contains("default: false"), + "Should contain default for verbose" + ); + assert!( + compiled.contains("default: us-east"), + "Should contain default for region" + ); + assert!( + compiled.contains("- us-east"), + "Should contain values for region" + ); + assert!( + compiled.contains("- eu-west"), + "Should contain values for region" + ); // No clearMemory should be injected (no memory configured) - assert!(!compiled.contains("clearMemory"), "Should NOT contain clearMemory without memory"); + assert!( + !compiled.contains("clearMemory"), + "Should NOT contain clearMemory without memory" + ); let _ = fs::remove_dir_all(&temp_dir); } @@ -2375,17 +2440,32 @@ Do the thing. let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); // Verify clearMemory parameter is auto-injected - assert!(compiled.contains("name: clearMemory"), "Should auto-inject clearMemory parameter"); - assert!(compiled.contains("displayName: Clear agent memory"), "Should have displayName"); + assert!( + compiled.contains("name: clearMemory"), + "Should auto-inject clearMemory parameter" + ); + assert!( + compiled.contains("displayName: Clear agent memory"), + "Should have displayName" + ); assert!(compiled.contains("type: boolean"), "Should be boolean type"); // Verify memory download has condition @@ -2439,21 +2519,39 @@ Do the thing. let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); // Verify user's clearMemory is present (with their custom displayName and default) - assert!(compiled.contains("displayName: Reset memory"), "Should use user's displayName"); - assert!(compiled.contains("default: true"), "Should use user's default value"); + assert!( + compiled.contains("displayName: Reset memory"), + "Should use user's displayName" + ); + assert!( + compiled.contains("default: true"), + "Should use user's default value" + ); // Verify clearMemory only appears once (not duplicated) let count = compiled.matches("name: clearMemory").count(); - assert_eq!(count, 1, "clearMemory should appear exactly once, not duplicated"); + assert_eq!( + count, 1, + "clearMemory should appear exactly once, not duplicated" + ); let _ = fs::remove_dir_all(&temp_dir); } @@ -2489,11 +2587,20 @@ tools: let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); - assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "Compiler should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); let compiled = fs::read_to_string(&output_path).unwrap(); @@ -2537,7 +2644,12 @@ network: let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -2576,7 +2688,12 @@ network: let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -2620,7 +2737,12 @@ network: let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -2664,7 +2786,12 @@ network: let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -2691,10 +2818,8 @@ network: /// - No unreplaced `{{ }}` template markers #[test] fn test_lean_runtime_compiled_output() { - let temp_dir = std::env::temp_dir().join(format!( - "agentic-pipeline-lean-{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("agentic-pipeline-lean-{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); let input = r#"--- @@ -3351,7 +3476,12 @@ network: let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) - .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .args([ + "compile", + input_path.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) .output() .expect("Failed to run compiler"); @@ -3503,7 +3633,10 @@ fn test_1es_compiled_output_is_valid_yaml() { compiled.contains("ado-aw execute"), "1ES output should contain safe output executor step" ); - assert!(compiled.contains("job: Agent"), "1ES output should contain Agent job"); + assert!( + compiled.contains("job: Agent"), + "1ES output should contain Agent job" + ); assert!( compiled.contains("job: Detection"), "1ES output should contain Detection job" @@ -3742,7 +3875,10 @@ fn test_skip_integrity_and_debug_pipeline_combined() { "minimal-agent.md", &["--skip-integrity", "--debug-pipeline"], ); - assert_valid_yaml(&compiled, "minimal-agent.md (skip-integrity + debug-pipeline)"); + assert_valid_yaml( + &compiled, + "minimal-agent.md (skip-integrity + debug-pipeline)", + ); // Debug content present assert!( @@ -3827,7 +3963,6 @@ fn test_debug_pipeline_probe_step_indentation_1es() { } } - // ─── PR Filter Integration Tests ──────────────────────────────────────────── /// Tier 1 PR filter fixture produces valid YAML with inline gate step. @@ -3837,17 +3972,35 @@ fn test_pr_filter_tier1_compiled_output_is_valid_yaml() { assert_valid_yaml(&compiled, "pr-filter-tier1-agent.md"); } -/// Tier 1 PR filters now also use the Python evaluator via extension. +/// Tier 1 PR filters use the bundled Node evaluator via extension. #[test] fn test_pr_filter_tier1_has_evaluator_gate() { let compiled = compile_fixture("pr-filter-tier1-agent.md"); - assert!(compiled.contains("- job: Setup"), "Should create Setup job for PR filters"); - assert!(compiled.contains("name: prGate"), "Should include prGate step"); - assert!(compiled.contains("GATE_SPEC"), "Should include base64-encoded spec"); - assert!(compiled.contains("python3"), "Should invoke python evaluator"); - assert!(compiled.contains("scripts.zip"), "Should download scripts bundle"); - assert!(compiled.contains("Evaluate PR filters"), "Should have gate displayName"); + assert!( + compiled.contains("- job: Setup"), + "Should create Setup job for PR filters" + ); + assert!( + compiled.contains("name: prGate"), + "Should include prGate step" + ); + assert!( + compiled.contains("GATE_SPEC"), + "Should include base64-encoded spec" + ); + assert!( + compiled.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), + "Should invoke node gate evaluator" + ); + assert!( + compiled.contains("ado-script.zip"), + "Should download scripts bundle" + ); + assert!( + compiled.contains("Evaluate PR filters"), + "Should have gate displayName" + ); } /// Tier 2 PR filter fixture produces valid YAML. @@ -3862,10 +4015,22 @@ fn test_pr_filter_tier2_compiled_output_is_valid_yaml() { fn test_pr_filter_tier2_has_extension_gate() { let compiled = compile_fixture("pr-filter-tier2-agent.md"); - assert!(compiled.contains("- job: Setup"), "Should create Setup job for PR filters"); - assert!(compiled.contains("scripts.zip"), "Tier 2 should download scripts bundle"); - assert!(compiled.contains("GATE_SPEC"), "Tier 2 should include base64-encoded spec"); - assert!(compiled.contains("python3"), "Tier 2 should invoke python evaluator"); + assert!( + compiled.contains("- job: Setup"), + "Should create Setup job for PR filters" + ); + assert!( + compiled.contains("ado-script.zip"), + "Tier 2 should download scripts bundle" + ); + assert!( + compiled.contains("GATE_SPEC"), + "Tier 2 should include base64-encoded spec" + ); + assert!( + compiled.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), + "Tier 2 should invoke node gate evaluator" + ); assert!(compiled.contains("name: prGate"), "Should have prGate step"); } @@ -3881,10 +4046,19 @@ fn test_pipeline_filter_compiled_output_is_valid_yaml() { fn test_pipeline_filter_has_resources_and_gate() { let compiled = compile_fixture("pipeline-filter-agent.md"); - assert!(compiled.contains("pipelines:"), "Should have pipeline resource"); - assert!(compiled.contains("trigger: none"), "Should disable CI trigger"); + assert!( + compiled.contains("pipelines:"), + "Should have pipeline resource" + ); + assert!( + compiled.contains("trigger: none"), + "Should disable CI trigger" + ); assert!(compiled.contains("pr: none"), "Should disable PR trigger"); - assert!(compiled.contains("- job: Setup"), "Should create Setup job for pipeline filters"); + assert!( + compiled.contains("- job: Setup"), + "Should create Setup job for pipeline filters" + ); } /// Agent job depends on Setup when filters are active. @@ -3892,8 +4066,14 @@ fn test_pipeline_filter_has_resources_and_gate() { fn test_pr_filter_agent_depends_on_setup() { let compiled = compile_fixture("pr-filter-tier1-agent.md"); - assert!(compiled.contains("dependsOn: Setup"), "Agent job should depend on Setup"); - assert!(compiled.contains("prGate.SHOULD_RUN"), "Agent job condition should reference gate output"); + assert!( + compiled.contains("dependsOn: Setup"), + "Agent job should depend on Setup" + ); + assert!( + compiled.contains("prGate.SHOULD_RUN"), + "Agent job condition should reference gate output" + ); } /// Native ADO PR trigger block is emitted for branch/path filters. @@ -3902,7 +4082,10 @@ fn test_pr_filter_tier1_has_native_pr_trigger() { let compiled = compile_fixture("pr-filter-tier1-agent.md"); assert!(compiled.contains("pr:"), "Should have native pr: block"); - assert!(compiled.contains("branches:"), "Should have branches filter"); + assert!( + compiled.contains("branches:"), + "Should have branches filter" + ); assert!(compiled.contains("main"), "Should include main branch"); } @@ -3917,8 +4100,8 @@ fn test_pr_filter_gate_steps_nested_in_setup_job() { .skip_while(|line| line.starts_with('#') || line.is_empty()) .collect::>() .join("\n"); - let doc: serde_yaml::Value = serde_yaml::from_str(&yaml_content) - .expect("should parse as valid YAML"); + let doc: serde_yaml::Value = + serde_yaml::from_str(&yaml_content).expect("should parse as valid YAML"); // Find the Setup job in the jobs list let jobs = doc.get("jobs").expect("should have jobs key"); @@ -3952,7 +4135,10 @@ fn test_pr_filter_gate_steps_nested_in_setup_job() { .and_then(|v| v.as_str()) .is_some_and(|n| n == "prGate") }); - assert!(has_gate, "prGate step should be inside Setup job's steps list"); + assert!( + has_gate, + "prGate step should be inside Setup job's steps list" + ); // The download step should also be inside let has_download = steps.iter().any(|s| { @@ -3960,7 +4146,10 @@ fn test_pr_filter_gate_steps_nested_in_setup_job() { .and_then(|v| v.as_str()) .is_some_and(|n| n.contains("Download ado-aw scripts")) }); - assert!(has_download, "Download step should be inside Setup job's steps list"); + assert!( + has_download, + "Download step should be inside Setup job's steps list" + ); } /// Test that a pipeline without `permissions.write` does not emit a bare `env:` block diff --git a/tests/export_gate_schema.rs b/tests/export_gate_schema.rs new file mode 100644 index 00000000..96788ac2 --- /dev/null +++ b/tests/export_gate_schema.rs @@ -0,0 +1,30 @@ +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn export_gate_schema_writes_valid_json() { + let tmp_dir = std::env::var_os("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap().join("target")); + std::fs::create_dir_all(&tmp_dir).expect("failed to create test output dir"); + let tmp = tmp_dir.join("ado-aw-test-gate-schema.json"); + let _ = std::fs::remove_file(&tmp); + + let bin = env!("CARGO_BIN_EXE_ado-aw"); + let status = Command::new(bin) + .args(["export-gate-schema", "--output"]) + .arg(&tmp) + .status() + .expect("failed to spawn ado-aw"); + assert!(status.success(), "ado-aw export-gate-schema failed"); + + let content = std::fs::read_to_string(&tmp).expect("schema file missing"); + let parsed: serde_json::Value = + serde_json::from_str(&content).expect("schema is not valid JSON"); + let stringified = serde_json::to_string(&parsed).unwrap(); + assert!( + stringified.contains("GateSpec") || stringified.contains("PredicateSpec"), + "schema does not mention expected type names: {}", + &stringified[..stringified.len().min(500)] + ); +} diff --git a/tests/gate_e2e.rs b/tests/gate_e2e.rs new file mode 100644 index 00000000..4845f1e8 --- /dev/null +++ b/tests/gate_e2e.rs @@ -0,0 +1,147 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_yaml::{Mapping, Value}; + +fn ado_aw_bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_ado-aw")) +} + +fn string_field<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a str> { + let key = Value::String(key.to_owned()); + mapping.get(&key).and_then(Value::as_str) +} + +fn value_field<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> { + let key = Value::String(key.to_owned()); + mapping.get(&key) +} + +fn find_gate_spec(value: &Value) -> Option { + match value { + Value::Mapping(mapping) => { + let script = string_field(mapping, "bash").or_else(|| string_field(mapping, "script")); + if script.is_some_and(|script| script.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'")) { + let env = value_field(mapping, "env")?.as_mapping()?; + return string_field(env, "GATE_SPEC").map(str::to_owned); + } + + mapping.values().find_map(find_gate_spec) + } + Value::Sequence(values) => values.iter().find_map(find_gate_spec), + _ => None, + } +} + +fn run_gate(gate_js: &Path, gate_spec: &str, pr_title: &str) -> Output { + let path = std::env::var_os("PATH").unwrap_or_default(); + + Command::new("node") + .arg(gate_js) + .env_clear() + .env("PATH", path) + .env("GATE_SPEC", gate_spec) + .env("ADO_BUILD_REASON", "PullRequest") + .env("ADO_PR_TITLE", pr_title) + .env("SYSTEM_ACCESSTOKEN", "dummy") + .env("ADO_COLLECTION_URI", "https://example.invalid/") + .env("ADO_PROJECT", "p") + .env("ADO_BUILD_ID", "1") + .output() + .expect("failed to spawn node scripts/ado-script/dist/gate/index.js") +} + +fn assert_gate_output(gate_js: &Path, gate_spec: &str, pr_title: &str, expected: &str) { + let output = run_gate(gate_js, gate_spec, pr_title); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "gate.js should exit 0 for PR title {pr_title:?}; stdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let expected_output = + format!("##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]{expected}"); + assert!( + stdout.contains(&expected_output), + "expected stdout to contain {expected_output:?} for PR title {pr_title:?}; stdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + +/// Ignored because it requires the bundled gate evaluator to be built first. +/// Run with: `cargo test --test gate_e2e -- --ignored`. +#[test] +#[ignore] +fn gate_js_runs_against_compiled_pipeline() { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let gate_js = repo_root.join("scripts/ado-script/dist/gate/index.js"); + if !gate_js.exists() { + panic!("gate.js not built; run: cd scripts/ado-script && npm run build"); + } + + match Command::new("node").arg("--version").output() { + Ok(output) if output.status.success() => {} + Ok(output) => { + eprintln!( + "skipping gate_js_runs_against_compiled_pipeline: node --version failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + Err(err) => { + eprintln!( + "skipping gate_js_runs_against_compiled_pipeline: node is not on PATH: {err}" + ); + return; + } + } + + let temp_root = repo_root.join("target/gate-e2e"); + fs::create_dir_all(&temp_root).expect("failed to create gate e2e temp root"); + let temp_dir = tempfile::Builder::new() + .prefix("gate-js-compiled-pipeline-") + .tempdir_in(&temp_root) + .expect("failed to create temp dir under target/gate-e2e"); + + let agent_path = temp_dir.path().join("e2e-gate-test.md"); + fs::write( + &agent_path, + r#"--- +name: e2e-gate-test +description: e2e test +on: + pr: + filters: + title: "foo*" +--- +# E2E +run echo hi +"#, + ) + .expect("failed to write agent markdown"); + + let output_path = temp_dir.path().join("e2e-gate-test.yml"); + let compile_output = ado_aw_bin() + .args(["compile"]) + .arg(&agent_path) + .args(["-o"]) + .arg(&output_path) + .output() + .expect("failed to run ado-aw compile"); + + assert!( + compile_output.status.success(), + "ado-aw compile failed; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile_output.stdout), + String::from_utf8_lossy(&compile_output.stderr) + ); + + let compiled = fs::read_to_string(&output_path).expect("failed to read compiled YAML"); + let yaml: Value = serde_yaml::from_str(&compiled).expect("compiled output is not valid YAML"); + let gate_spec = find_gate_spec(&yaml).expect("compiled YAML did not contain prGate GATE_SPEC"); + + assert_gate_output(&gate_js, &gate_spec, "fooBar", "true"); + assert_gate_output(&gate_js, &gate_spec, "barBar", "false"); +} diff --git a/tests/gate_eval_tests.py b/tests/gate_eval_tests.py deleted file mode 100644 index 94dd12c9..00000000 --- a/tests/gate_eval_tests.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Unit tests for the ado-aw gate evaluator (scripts/gate-eval.py). - -Run with: uv run pytest tests/gate_eval_tests.py -v -""" -import base64 -import json -import os -import sys - -# Add scripts/ to path so we can import the evaluator module -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) - -# Import evaluator functions directly -import importlib.util -spec = importlib.util.spec_from_file_location( - "gate_eval", - os.path.join(os.path.dirname(__file__), "..", "scripts", "gate-eval.py"), -) -gate_eval = importlib.util.module_from_spec(spec) -spec.loader.exec_module(gate_eval) - -evaluate = gate_eval.evaluate -predicate_facts = gate_eval.predicate_facts -_strip_ref_prefix = gate_eval._strip_ref_prefix - - -# ─── Ref prefix stripping tests ───────────────────────────────────────────── - - -class TestStripRefPrefix: - def test_refs_heads(self): - assert _strip_ref_prefix("refs/heads/feature/my-branch") == "feature/my-branch" - - def test_refs_tags(self): - assert _strip_ref_prefix("refs/tags/v1.0.0") == "v1.0.0" - - def test_refs_pull(self): - assert _strip_ref_prefix("refs/pull/42/merge") == "42/merge" - - def test_no_prefix(self): - assert _strip_ref_prefix("main") == "main" - - def test_pattern_stripping_in_glob(self): - """User patterns like refs/heads/feature/* should match feature/my-branch""" - pred = {"type": "glob_match", "fact": "source_branch", "pattern": "refs/heads/feature/*"} - facts = {"source_branch": "feature/my-branch"} - assert evaluate(pred, facts) is True - - -# ─── Predicate evaluation tests ───────────────────────────────────────────── - - -class TestGlobMatch: - def test_match(self): - pred = {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"} - facts = {"pr_title": "feat: add feature [review]"} - assert evaluate(pred, facts) is True - - def test_no_match(self): - pred = {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"} - facts = {"pr_title": "feat: add feature"} - assert evaluate(pred, facts) is False - - def test_wildcard(self): - pred = {"type": "glob_match", "fact": "source_branch", "pattern": "feature/*"} - facts = {"source_branch": "feature/my-branch"} - assert evaluate(pred, facts) is True - - def test_exact(self): - pred = {"type": "glob_match", "fact": "target_branch", "pattern": "main"} - facts = {"target_branch": "main"} - assert evaluate(pred, facts) is True - - def test_exact_no_match(self): - pred = {"type": "glob_match", "fact": "target_branch", "pattern": "main"} - facts = {"target_branch": "develop"} - assert evaluate(pred, facts) is False - - def test_empty_value(self): - pred = {"type": "glob_match", "fact": "pr_title", "pattern": "*"} - facts = {"pr_title": ""} - assert evaluate(pred, facts) is True - - -class TestEquals: - def test_match(self): - pred = {"type": "equals", "fact": "pr_is_draft", "value": "false"} - facts = {"pr_is_draft": "false"} - assert evaluate(pred, facts) is True - - def test_no_match(self): - pred = {"type": "equals", "fact": "pr_is_draft", "value": "false"} - facts = {"pr_is_draft": "true"} - assert evaluate(pred, facts) is False - - def test_missing_fact(self): - pred = {"type": "equals", "fact": "missing", "value": "x"} - facts = {} - assert evaluate(pred, facts) is False - - -class TestValueInSet: - def test_case_insensitive_match(self): - pred = { - "type": "value_in_set", - "fact": "author_email", - "values": ["Alice@Corp.com"], - "case_insensitive": True, - } - facts = {"author_email": "alice@corp.com"} - assert evaluate(pred, facts) is True - - def test_case_sensitive_no_match(self): - pred = { - "type": "value_in_set", - "fact": "author_email", - "values": ["Alice@Corp.com"], - "case_insensitive": False, - } - facts = {"author_email": "alice@corp.com"} - assert evaluate(pred, facts) is False - - def test_not_in_set(self): - pred = { - "type": "value_in_set", - "fact": "build_reason", - "values": ["PullRequest", "Manual"], - "case_insensitive": True, - } - facts = {"build_reason": "Schedule"} - assert evaluate(pred, facts) is False - - -class TestValueNotInSet: - def test_not_in_set(self): - pred = { - "type": "value_not_in_set", - "fact": "author_email", - "values": ["bot@noreply.com"], - "case_insensitive": True, - } - facts = {"author_email": "dev@corp.com"} - assert evaluate(pred, facts) is True - - def test_in_set(self): - pred = { - "type": "value_not_in_set", - "fact": "author_email", - "values": ["bot@noreply.com"], - "case_insensitive": True, - } - facts = {"author_email": "bot@noreply.com"} - assert evaluate(pred, facts) is False - - -class TestNumericRange: - def test_in_range(self): - pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 5, "max": 100} - facts = {"changed_file_count": 50} - assert evaluate(pred, facts) is True - - def test_below_min(self): - pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 5, "max": 100} - facts = {"changed_file_count": 2} - assert evaluate(pred, facts) is False - - def test_above_max(self): - pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 5, "max": 100} - facts = {"changed_file_count": 200} - assert evaluate(pred, facts) is False - - def test_min_only(self): - pred = {"type": "numeric_range", "fact": "changed_file_count", "min": 3} - facts = {"changed_file_count": 10} - assert evaluate(pred, facts) is True - - def test_max_only(self): - pred = {"type": "numeric_range", "fact": "changed_file_count", "max": 50} - facts = {"changed_file_count": 100} - assert evaluate(pred, facts) is False - - -class TestTimeWindow: - def test_in_window(self): - pred = {"type": "time_window", "start": "09:00", "end": "17:00"} - facts = {"current_utc_minutes": 600} # 10:00 - assert evaluate(pred, facts) is True - - def test_outside_window(self): - pred = {"type": "time_window", "start": "09:00", "end": "17:00"} - facts = {"current_utc_minutes": 1200} # 20:00 - assert evaluate(pred, facts) is False - - def test_overnight_window_in(self): - pred = {"type": "time_window", "start": "22:00", "end": "06:00"} - facts = {"current_utc_minutes": 1380} # 23:00 - assert evaluate(pred, facts) is True - - def test_overnight_window_out(self): - pred = {"type": "time_window", "start": "22:00", "end": "06:00"} - facts = {"current_utc_minutes": 720} # 12:00 - assert evaluate(pred, facts) is False - - -class TestLabelSetMatch: - def test_any_of_match(self): - pred = { - "type": "label_set_match", - "fact": "pr_labels", - "any_of": ["run-agent", "needs-review"], - } - facts = {"pr_labels": ["run-agent", "other"]} - assert evaluate(pred, facts) is True - - def test_any_of_no_match(self): - pred = { - "type": "label_set_match", - "fact": "pr_labels", - "any_of": ["run-agent"], - } - facts = {"pr_labels": ["other"]} - assert evaluate(pred, facts) is False - - def test_all_of_match(self): - pred = { - "type": "label_set_match", - "fact": "pr_labels", - "all_of": ["approved", "tested"], - } - facts = {"pr_labels": ["approved", "tested", "other"]} - assert evaluate(pred, facts) is True - - def test_all_of_missing(self): - pred = { - "type": "label_set_match", - "fact": "pr_labels", - "all_of": ["approved", "tested"], - } - facts = {"pr_labels": ["approved"]} - assert evaluate(pred, facts) is False - - def test_none_of_pass(self): - pred = { - "type": "label_set_match", - "fact": "pr_labels", - "none_of": ["do-not-run"], - } - facts = {"pr_labels": ["run-agent"]} - assert evaluate(pred, facts) is True - - def test_none_of_fail(self): - pred = { - "type": "label_set_match", - "fact": "pr_labels", - "none_of": ["do-not-run"], - } - facts = {"pr_labels": ["do-not-run", "other"]} - assert evaluate(pred, facts) is False - - def test_empty_labels(self): - pred = {"type": "label_set_match", "fact": "pr_labels"} - facts = {"pr_labels": []} - assert evaluate(pred, facts) is True - - -class TestFileGlobMatch: - def test_include_match(self): - pred = { - "type": "file_glob_match", - "fact": "changed_files", - "include": ["src/*.rs"], - } - facts = {"changed_files": ["src/main.rs", "src/lib.rs"]} - assert evaluate(pred, facts) is True - - def test_include_no_match(self): - pred = { - "type": "file_glob_match", - "fact": "changed_files", - "include": ["src/**/*.rs"], - } - facts = {"changed_files": ["docs/readme.md"]} - assert evaluate(pred, facts) is False - - def test_exclude(self): - pred = { - "type": "file_glob_match", - "fact": "changed_files", - "include": ["src/**/*.rs"], - "exclude": ["src/test_*.rs"], - } - facts = {"changed_files": ["src/test_main.rs"]} - assert evaluate(pred, facts) is False - - -class TestLogicalCombinators: - def test_and_all_pass(self): - pred = { - "type": "and", - "operands": [ - {"type": "equals", "fact": "a", "value": "1"}, - {"type": "equals", "fact": "b", "value": "2"}, - ], - } - facts = {"a": "1", "b": "2"} - assert evaluate(pred, facts) is True - - def test_and_one_fails(self): - pred = { - "type": "and", - "operands": [ - {"type": "equals", "fact": "a", "value": "1"}, - {"type": "equals", "fact": "b", "value": "3"}, - ], - } - facts = {"a": "1", "b": "2"} - assert evaluate(pred, facts) is False - - def test_or_one_passes(self): - pred = { - "type": "or", - "operands": [ - {"type": "equals", "fact": "a", "value": "wrong"}, - {"type": "equals", "fact": "b", "value": "2"}, - ], - } - facts = {"a": "1", "b": "2"} - assert evaluate(pred, facts) is True - - def test_not(self): - pred = { - "type": "not", - "operand": {"type": "equals", "fact": "a", "value": "1"}, - } - facts = {"a": "2"} - assert evaluate(pred, facts) is True - - -# ─── predicate_facts helper tests ──────────────────────────────────────────── - - -class TestPredicateFacts: - def test_simple(self): - pred = {"type": "glob_match", "fact": "pr_title", "pattern": "test"} - assert predicate_facts(pred) == {"pr_title"} - - def test_compound(self): - pred = { - "type": "and", - "operands": [ - {"type": "equals", "fact": "a", "value": "1"}, - {"type": "glob_match", "fact": "b", "pattern": "x"}, - ], - } - assert predicate_facts(pred) == {"a", "b"} - - def test_not(self): - pred = {"type": "not", "operand": {"type": "equals", "fact": "x", "value": "1"}} - assert predicate_facts(pred) == {"x"}