Skip to content

Commit fc12c7c

Browse files
jamesadevineCopilot
andcommitted
fix(ado-script): address PR review — drift-check fact deps; fail-closed unknown predicates
Closes review findings on #389: 1. FACT_DEPS drift gap: the fact dependency graph in policy.ts was a manually-maintained mirror of Fact::dependencies() in Rust, outside the schema drift check. Added `dependencies: Vec<String>` to FactSpec populated from Fact::dependencies(). Regenerated types.gen.ts. PolicyTracker now reads the dep graph from the spec at construction; the hardcoded FACT_DEPS constant is removed. The CI `git diff --exit-code` on types.gen.ts now covers the dep graph too. 2. Unknown predicate silent pass: the default arm of evaluatePredicate returned true (auto-pass), so a stale gate.js paired with a newer compiler that emits a new predicate would silently pass filters. Now: emits a task.logissue type=warning naming the unknown type and returns false (fail-closed). Added a regression test. 3. Stale "Python" doc on GateSpec: updated to "Node gate evaluator (scripts/gate.js)". Propagates through codegen. 4. scripts.zip ships TS source: added -x patterns for ado-script/src/, test/, package*.json, tsconfig.json, vitest.config*.ts, README.md, and .gitignore. Only gate.js (already at scripts/gate.js) and any future bundle artefacts are shipped. 5. globMatch bracket divergence: documented inline that bracket expressions [seq] are escaped to literal characters (Python fnmatch supported them). The IR currently never emits bracket patterns; if a future predicate needs them, the builder must be extended. 6. release.yml missing npm cache: added cache: "npm" and cache-dependency-path to match the CI workflow. Avoids re-downloading node_modules on every release run. 7. Fact::is_pipeline_var() #[cfg(test)] opacity: added a doc comment noting the runtime mirror is isPipelineVarFact in env-facts.ts, so future readers don't wonder why the helper exists only for tests. Validation: 172/172 vitest tests, full cargo test suite, clippy clean, e2e gate test passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1d96a89 commit fc12c7c

9 files changed

Lines changed: 100 additions & 25 deletions

File tree

.github/workflows/release.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ jobs:
5959
uses: actions/setup-node@v4
6060
with:
6161
node-version: "20"
62+
cache: "npm"
63+
cache-dependency-path: scripts/ado-script/package-lock.json
6264

6365
- name: Build ado-script TypeScript bundle (gate.js)
6466
working-directory: scripts/ado-script
@@ -73,10 +75,21 @@ jobs:
7375
run: |
7476
set -euo pipefail
7577
cd scripts
78+
# Only ship the bundled artefacts that pipelines actually
79+
# download and execute (currently: gate.js). The TS source,
80+
# tests, and tooling under ado-script/ are excluded — they
81+
# live in the repo for development and CI, not in the release.
7682
zip -r ../scripts.zip . \
7783
-x "ado-script/node_modules/*" \
7884
-x "ado-script/dist/*" \
79-
-x "ado-script/schema/*"
85+
-x "ado-script/schema/*" \
86+
-x "ado-script/src/*" \
87+
-x "ado-script/test/*" \
88+
-x "ado-script/package*.json" \
89+
-x "ado-script/tsconfig.json" \
90+
-x "ado-script/vitest.config*.ts" \
91+
-x "ado-script/README.md" \
92+
-x "ado-script/.gitignore"
8093
8194
- name: Upload release assets
8295
env:

scripts/ado-script/src/gate/__tests__/ports/integration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe("evaluatePredicates integration ports", () => {
2323
const predicate = { type: "equals", fact: "pr_title", value: "ok" } satisfies PredicateSpec;
2424
const spec = gateSpec(
2525
[{ name: "title", tag_suffix: "title", predicate }],
26-
[{ kind: "pr_title", failure_policy: "fail_closed" }],
26+
[{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] }],
2727
);
2828
const tracker = new PolicyTracker(spec.facts);
2929

scripts/ado-script/src/gate/facts.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ vi.mock("../shared/ado-client.js", () => ({
3030
import { acquireFacts } from "./facts.js";
3131

3232
function fact(kind: string, failure_policy = "fail_closed"): FactSpec {
33-
return { kind, failure_policy };
33+
return { kind, failure_policy, dependencies: [] };
3434
}
3535

3636
function gateSpec(facts: FactSpec[]): GateSpec {

scripts/ado-script/src/gate/predicates.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,34 @@ describe("predicateFacts", () => {
350350
});
351351
});
352352

353+
describe("unknown predicate fallback", () => {
354+
let stderrWrites: string[];
355+
356+
beforeEach(() => {
357+
stderrWrites = [];
358+
vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
359+
stderrWrites.push(typeof chunk === "string" ? chunk : chunk.toString());
360+
return true;
361+
});
362+
});
363+
364+
afterEach(() => vi.restoreAllMocks());
365+
366+
it("fails closed and emits a warning when the predicate type is unknown", () => {
367+
// Forge a spec a future compiler might emit but this gate.js does
368+
// not recognise. The default branch should NOT silently auto-pass.
369+
const pred = { type: "future_predicate", fact: "x" } as unknown as PredicateSpec;
370+
const result = evaluatePredicate(pred, factMap({}));
371+
expect(result).toBe(false);
372+
expect(
373+
stderrWrites.some(
374+
(w) =>
375+
w.includes("##vso[task.logissue type=warning;]") && w.includes("future_predicate"),
376+
),
377+
).toBe(true);
378+
});
379+
});
380+
353381
describe("evaluatePredicates", () => {
354382
let writes: string[];
355383

@@ -380,8 +408,8 @@ describe("evaluatePredicates", () => {
380408
},
381409
],
382410
[
383-
{ kind: "pr_title", failure_policy: "fail_closed" },
384-
{ kind: "build_reason", failure_policy: "fail_closed" },
411+
{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] },
412+
{ kind: "build_reason", failure_policy: "fail_closed", dependencies: [] },
385413
],
386414
);
387415
const tracker = new PolicyTracker(spec.facts);
@@ -406,7 +434,7 @@ describe("evaluatePredicates", () => {
406434
predicate: { type: "equals", fact: "pr_title", value: "ok" },
407435
},
408436
],
409-
[{ kind: "pr_title", failure_policy: "fail_closed" }],
437+
[{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] }],
410438
);
411439
const tracker = new PolicyTracker(spec.facts);
412440
tracker.recordFactFailure("pr_title", "test failure");

scripts/ado-script/src/gate/predicates.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import type { GateSpec, PredicateSpec } from "../shared/types.gen.js";
99
import type { PolicyTracker } from "../shared/policy.js";
10-
import { addBuildTag } from "../shared/vso-logger.js";
10+
import { addBuildTag, logWarning } from "../shared/vso-logger.js";
1111
import { stripRefPrefix } from "../shared/env-facts.js";
1212

1313
type CheckResult = "pass" | "fail" | "skip";
@@ -138,8 +138,18 @@ export function evaluatePredicate(p: PredicateSpec, facts: Map<string, unknown>)
138138
return p.operands.some((sub) => evaluatePredicate(sub, facts));
139139
case "not":
140140
return !evaluatePredicate(p.operand, facts);
141-
default:
142-
return true;
141+
default: {
142+
// Unknown predicate type — likely a newer compiler emitted a spec
143+
// a bundled gate.js doesn't recognise. Surface in pipeline logs;
144+
// fail-closed so the missing logic doesn't silently auto-pass.
145+
const unknownType = (p as { type?: unknown }).type;
146+
logWarning(
147+
`Unknown predicate type '${String(unknownType)}'; failing closed. ` +
148+
"Update scripts/gate.js (or the bundled scripts.zip) to a " +
149+
"release that supports this predicate.",
150+
);
151+
return false;
152+
}
143153
}
144154
}
145155

@@ -152,6 +162,12 @@ function stringsFromFact(raw: unknown): string[] {
152162
}
153163

154164
function globMatch(value: string, pattern: string): boolean {
165+
// Glob → regex: only `*` (any chars) and `?` (single char) are
166+
// recognised. Bracket expressions like `[abc]` are escaped to literal
167+
// characters here. This is a deliberate divergence from Python's
168+
// `fnmatch.fnmatch`, which supports `[seq]` ranges. The IR currently
169+
// never emits bracket patterns, but if a future predicate needs them,
170+
// this builder must be extended (and the parity inventory updated).
155171
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
156172
const regex = `^${escaped.replace(/\\\*/g, ".*").replace(/\\\?/g, ".")}$`;
157173
return new RegExp(regex, "s").test(value);

scripts/ado-script/src/shared/__tests__/policy.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,21 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import { PolicyTracker } from "../policy.js";
33
import type { FactSpec } from "../types.gen.js";
44

5-
function spec(kind: string, fp: string): FactSpec {
6-
return { kind, failure_policy: fp };
5+
// Mirror of the real Fact::dependencies() graph. Tests construct
6+
// FactSpec objects with this map so the dep graph stays implicit at
7+
// the call sites (matches how the compiler emits it in production).
8+
const DEFAULT_DEPS: Record<string, readonly string[]> = {
9+
pr_is_draft: ["pr_metadata"],
10+
pr_labels: ["pr_metadata"],
11+
changed_file_count: ["changed_files"],
12+
};
13+
14+
function spec(kind: string, fp: string, dependencies?: string[]): FactSpec {
15+
return {
16+
kind,
17+
failure_policy: fp,
18+
dependencies: dependencies ?? [...(DEFAULT_DEPS[kind] ?? [])],
19+
};
720
}
821

922
describe("PolicyTracker", () => {

scripts/ado-script/src/shared/policy.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,10 @@ import { logWarning } from "./vso-logger.js";
33

44
export type FailurePolicy = "fail_closed" | "fail_open" | "skip_dependents";
55

6-
/** Hard-coded fact dependency graph — keep in sync with `Fact::dependencies` in
7-
* `src/compile/filter_ir.rs`. Encoded here because `FactSpec` does not carry
8-
* the dep graph; it's a property of the kind, not the spec instance. */
9-
const FACT_DEPS = {
10-
pr_is_draft: ["pr_metadata"],
11-
pr_labels: ["pr_metadata"],
12-
changed_file_count: ["changed_files"],
13-
} as const satisfies Record<string, readonly string[]>;
14-
156
export class PolicyTracker {
167
private readonly policyByKind = new Map<string, FailurePolicy>();
8+
/** Fact dependency graph derived from the spec — no manual mirror. */
9+
private readonly depsByKind = new Map<string, readonly string[]>();
1710
private readonly failedFacts = new Set<string>();
1811
private readonly skippedFacts = new Set<string>();
1912
private readonly unavailablePoliciesByKind = new Map<string, Set<FailurePolicy>>();
@@ -24,6 +17,7 @@ export class PolicyTracker {
2417
constructor(facts: FactSpec[]) {
2518
for (const f of facts) {
2619
this.policyByKind.set(f.kind, this.parsePolicy(f.failure_policy));
20+
this.depsByKind.set(f.kind, f.dependencies ?? []);
2721
}
2822
}
2923

@@ -108,7 +102,7 @@ export class PolicyTracker {
108102
let changed = true;
109103
while (changed) {
110104
changed = false;
111-
for (const [kind, deps] of Object.entries(FACT_DEPS)) {
105+
for (const [kind, deps] of this.depsByKind.entries()) {
112106
if (unavailable.has(kind)) continue;
113107
if (deps.some((dep) => unavailable.has(dep))) {
114108
unavailable.add(kind);
@@ -132,5 +126,3 @@ export class PolicyTracker {
132126
this.unavailablePoliciesByKind.set(factKind, new Set([policy]));
133127
}
134128
}
135-
136-
export const _internalForTest = { FACT_DEPS };

scripts/ado-script/src/shared/types.gen.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export type PredicateSpec =
7676

7777
/**
7878
* Serializable gate specification — the JSON document consumed by the
79-
* Python gate evaluator at pipeline runtime.
79+
* Node gate evaluator (`scripts/gate.js`) at pipeline runtime.
8080
*/
8181
export interface GateSpec {
8282
checks: CheckSpec[];
@@ -107,6 +107,12 @@ export interface GateContextSpec {
107107
* Serialized fact acquisition descriptor.
108108
*/
109109
export interface FactSpec {
110+
/**
111+
* Kinds of other facts that must be acquired before this one.
112+
* Mirrors `Fact::dependencies()`. Carried in the spec so the gate
113+
* evaluator does not duplicate the dependency graph.
114+
*/
115+
dependencies: string[];
110116
failure_policy: string;
111117
kind: string;
112118
[k: string]: unknown;

src/compile/filter_ir.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ impl Fact {
136136
}
137137

138138
/// True if this fact is a free pipeline variable (no API/computation).
139+
/// Only used for test assertions; the runtime evaluator has its own
140+
/// mirror in `scripts/ado-script/src/shared/env-facts.ts::isPipelineVarFact`.
139141
#[cfg(test)]
140142
pub fn is_pipeline_var(&self) -> bool {
141143
matches!(
@@ -812,7 +814,7 @@ use schemars::JsonSchema;
812814
use serde::Serialize;
813815

814816
/// Serializable gate specification — the JSON document consumed by the
815-
/// Python gate evaluator at pipeline runtime.
817+
/// Node gate evaluator (`scripts/gate.js`) at pipeline runtime.
816818
#[derive(Debug, Clone, Serialize, JsonSchema)]
817819
pub struct GateSpec {
818820
pub context: GateContextSpec,
@@ -834,6 +836,10 @@ pub struct GateContextSpec {
834836
pub struct FactSpec {
835837
pub kind: String,
836838
pub failure_policy: String,
839+
/// Kinds of other facts that must be acquired before this one.
840+
/// Mirrors `Fact::dependencies()`. Carried in the spec so the gate
841+
/// evaluator does not duplicate the dependency graph.
842+
pub dependencies: Vec<String>,
837843
}
838844

839845
/// Serialized filter check.
@@ -1065,6 +1071,7 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> anyhow::Resu
10651071
.map(|f| FactSpec {
10661072
kind: f.kind().into(),
10671073
failure_policy: f.failure_policy().as_str().into(),
1074+
dependencies: f.dependencies().iter().map(|d| d.kind().into()).collect(),
10681075
})
10691076
.collect();
10701077

0 commit comments

Comments
 (0)