From 67c3865a797d3eed7570eccd518bb7cb9799bf93 Mon Sep 17 00:00:00 2001 From: Abhitej John Date: Fri, 17 Jul 2026 16:36:41 -0700 Subject: [PATCH] Add eval-review skill/eval quality checker (eng/vally-graders) Adds a standalone, repo-local checker that reviews skill+eval quality before merge: four free static graders (eval-deleading, assertion-hardening, meta-commentary, negative-scenario) plus one opt-in real-model LLM grader (eval-review) for the judgment dimensions. Enforcement runs via the review.mjs runner, gated on PRs as free static-only steps in the existing skill-check.yml check job (changed units only). The graders are also exported as Vally-compatible Grader objects via registerGraders for future use; Vally does not currently enforce custom graders (documented, not a regression). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 365dd5fb-86a9-4628-9b14-7e6ec7053072 --- .github/workflows/skill-check.yml | 43 ++ eng/vally-graders/README.md | 205 +++++++++ .../graders/assertion-hardening.mjs | 187 +++++++++ eng/vally-graders/graders/eval-deleading.mjs | 181 ++++++++ eng/vally-graders/graders/eval-review.mjs | 394 ++++++++++++++++++ eng/vally-graders/graders/meta-commentary.mjs | 118 ++++++ .../graders/negative-scenario.mjs | 101 +++++ eng/vally-graders/index.mjs | 55 +++ eng/vally-graders/lib/grader-base.mjs | 65 +++ eng/vally-graders/lib/llm-client.mjs | 174 ++++++++ eng/vally-graders/lib/locate.mjs | 324 ++++++++++++++ eng/vally-graders/lib/report.mjs | 128 ++++++ eng/vally-graders/package-lock.json | 247 +++++++++++ eng/vally-graders/package.json | 27 ++ eng/vally-graders/review.mjs | 279 +++++++++++++ .../plugins/demo/skills/clean-skill/SKILL.md | 13 + .../plugins/demo/skills/leaky-skill/SKILL.md | 15 + .../repo/tests/demo/clean-skill/eval.yaml | 25 ++ .../tests/demo/clean-skill/fixtures/Thing.cs | 6 + .../repo/tests/demo/leaky-skill/eval.yaml | 14 + .../leaky-skill/fixtures/WidgetProcessor.cs | 5 + eng/vally-graders/tests/graders.test.mjs | 368 ++++++++++++++++ 22 files changed, 2974 insertions(+) create mode 100644 eng/vally-graders/README.md create mode 100644 eng/vally-graders/graders/assertion-hardening.mjs create mode 100644 eng/vally-graders/graders/eval-deleading.mjs create mode 100644 eng/vally-graders/graders/eval-review.mjs create mode 100644 eng/vally-graders/graders/meta-commentary.mjs create mode 100644 eng/vally-graders/graders/negative-scenario.mjs create mode 100644 eng/vally-graders/index.mjs create mode 100644 eng/vally-graders/lib/grader-base.mjs create mode 100644 eng/vally-graders/lib/llm-client.mjs create mode 100644 eng/vally-graders/lib/locate.mjs create mode 100644 eng/vally-graders/lib/report.mjs create mode 100644 eng/vally-graders/package-lock.json create mode 100644 eng/vally-graders/package.json create mode 100644 eng/vally-graders/review.mjs create mode 100644 eng/vally-graders/tests/fixtures/repo/plugins/demo/skills/clean-skill/SKILL.md create mode 100644 eng/vally-graders/tests/fixtures/repo/plugins/demo/skills/leaky-skill/SKILL.md create mode 100644 eng/vally-graders/tests/fixtures/repo/tests/demo/clean-skill/eval.yaml create mode 100644 eng/vally-graders/tests/fixtures/repo/tests/demo/clean-skill/fixtures/Thing.cs create mode 100644 eng/vally-graders/tests/fixtures/repo/tests/demo/leaky-skill/eval.yaml create mode 100644 eng/vally-graders/tests/fixtures/repo/tests/demo/leaky-skill/fixtures/WidgetProcessor.cs create mode 100644 eng/vally-graders/tests/graders.test.mjs diff --git a/.github/workflows/skill-check.yml b/.github/workflows/skill-check.yml index c444826777..7d11c6393f 100644 --- a/.github/workflows/skill-check.yml +++ b/.github/workflows/skill-check.yml @@ -17,6 +17,8 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: + # Full history so eval-review can diff against the PR base ref. + fetch-depth: 0 persist-credentials: false - name: Cache validator archive @@ -54,3 +56,44 @@ jobs: --allowed-external-deps eng/allowed-external-deps.txt \ --known-domains eng/known-domains.txt + # ----------------------------------------------------------------------- + # eval-review: free, no-model quality gate for skill+eval units, run as + # steps in this same job (no separate workflow). + # + # Enforcement runs via the standalone runner (eng/vally-graders/review.mjs), + # NOT via Vally: `vally lint --grader-plugin` does not execute custom static + # graders against skills, and `vally experiment run` (this repo's eval path) + # can't load grader plugins. See eng/vally-graders/README.md. + # + # Static-only and free: the paid, opt-in LLM grader (review.mjs --llm) is + # deliberately NOT run here. assertion-hardening and meta-commentary block; + # the noisier eval-deleading / negative-scenario dimensions are report-only + # (dimension ratchet — tighten by dropping --report-only as the backlog is + # burned down). Only PR-touched units are evaluated (changed-files default); + # set EVAL_REVIEW_ALL=1 to scan every unit (manual/nightly run-all path). + # ----------------------------------------------------------------------- + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + + - name: Install eval-review dependencies + working-directory: eng/vally-graders + run: npm ci --omit=optional + + - name: Unit-test the graders + working-directory: eng/vally-graders + run: npm test + + - name: Run eval-review gate + run: | + if [ "${EVAL_REVIEW_ALL:-}" = "1" ]; then + scope="--all" + else + scope="--base origin/${{ github.base_ref }}" + fi + node eng/vally-graders/review.mjs \ + $scope \ + --report-only eval-deleading \ + --report-only negative-scenario + diff --git a/eng/vally-graders/README.md b/eng/vally-graders/README.md new file mode 100644 index 0000000000..d1bf15a0fb --- /dev/null +++ b/eng/vally-graders/README.md @@ -0,0 +1,205 @@ +# eval-review — repo-local skill/eval quality graders + +Static graders (free, no model) that check the **quality of skills and their +evals** before they merge, plus an **opt-in LLM grader** for the judgment +dimensions. They encode a skill/eval review: de-leading, assertion hardening, +provenance hygiene, negative-scenario coverage (all static/free), and — behind an +explicit `--llm` flag — fixture honesty, design balance, and domain-correctness. + +> **Enforcement is standalone, not via Vally.** These checks run in CI through the +> `review.mjs` runner in this package — **not** through Vally. We verified that +> `vally lint --grader-plugin` does **not** execute custom static graders against +> skills (its lint registry only holds the three built-ins; the plugin registry is +> used only to *validate* grader `type`s referenced in an eval spec), and that +> `vally experiment run` — this repo's actual eval path — has no `--grader-plugin` +> option at all. The graders are *also* exported as Vally `Grader` objects via +> `registerGraders(registry)` so they can be loaded by `vally eval`/`grade` and +> tested with Vally's APIs if the repo ever adopts those paths, but that is a +> future/compatibility layer, not the current gate. See "Vally compatibility" below. + +## What it checks + +| Grader | Determinism | Checklist | What it flags | +|---|---|---|---| +| `eval-deleading` | static | A | Fixture comments / eval prompts that leak the answer (e.g. *"the right fix is …"*, a fixture that names the exact edit). High-confidence leaks only; generic smells stay MINOR. | +| `assertion-hardening` | complex-static | B | A **mutating** scenario (edit/fix/migrate provided files) whose assertions are prose/`output_*`-only, with no result gate (`run_command_and_assert` / `file_contains` / `exit_success`). A wrong edit can still pass. | +| `meta-commentary` | static | D5 | Unsupported provenance/empirical asides in shipped skill text (*"learned from telemetry"*, *"grounded in N PRs"*). Never *requires* provenance. | +| `negative-scenario` | static | D4 | A real `DO NOT USE FOR` exclusion with **no** eval scenario asserting non-activation (`expect_activation: false`). | +| `eval-review` | llm | C / D / DC | **Opt-in, paid.** A real judge-model call assessing fixture honesty (C), skill-design balance (D1–D3), and domain-correctness & safety (DC). Emits the same `[SEVERITY]/PROOF/WHY/FIX` findings. Off by default and **not** in the free CI gate; run with `review.mjs --llm`. | + +Findings use the review report shape: + +``` +[SEVERITY] dimension (rule) — file:line + PROOF: "" + WHY: + FIX: +``` + +Severities: **BLOCKER > MAJOR > MINOR > INFO**. Verdict axis: **Rework** +(any BLOCKER) / **Fix-then-ship** (any MAJOR/MINOR) / **Ship**. + +### Severity philosophy + +MAJOR is reserved for high-confidence, evidence-backed defects so reviewers never +learn to ignore it: + +- **Evidence rule.** A MAJOR/BLOCKER with no quoted `file:line` proof is + automatically downgraded to MINOR (`lib/report.mjs`). No proof → no failing + severity. +- **Intent-first classification.** `assertion-hardening` classifies each scenario + *before* demanding a result gate. It reads the scenario **name** first (the + author's one-line intent) and only treats **base-form imperative** verbs as + agent instructions — so *"the project generates a file"* (a description) is not + read as *"generate a file"* (an instruction). Only **editing existing provided + files** (fix/migrate/convert/refactor) earns MAJOR; *producing new* content + checked by keywords is under-asserted → MINOR. Advisory/diagnostic scenarios + (diagnose/explain/recommend, or "do not modify") are never flagged. +- **Routing-risk tiering.** `negative-scenario` is MAJOR only when the exclusion + hands off to a **sibling skill** (`use some-other-skill`) — an untested routing + boundary that can misroute. A generic exclusion with no negative scenario is a + coverage gap → MINOR. + +## Usage + +```bash +# One-time: install the runtime deps (js-yaml, argparse). The free static gate +# only needs these; add the optional @github/copilot-sdk + zod (drop --omit) for --llm. +cd eng/vally-graders && npm ci --omit=optional + +# From the repo root — evaluate only the skills touched by the PR (default): +node eng/vally-graders/review.mjs + +# Evaluate everything: +node eng/vally-graders/review.mjs --all + +# Focus on one skill; emit JSON: +node eng/vally-graders/review.mjs --unit dotnet-test-migration/migrate-mstest-v3-to-v4 --json + +# Report-only a noisy dimension (prints, never blocks): +node eng/vally-graders/review.mjs --report-only eval-deleading --report-only negative-scenario + +# Opt into the paid LLM grader (needs a Copilot token + optional deps installed): +node eng/vally-graders/review.mjs --llm --unit dotnet-test-migration/migrate-mstest-v3-to-v4 +``` + +Key flags: `--all`, `--base ` (default `GITHUB_BASE_REF` or `main`), +`--unit ` (repeatable), `--report-only ` (repeatable), +`--fail-on BLOCKER|MAJOR|MINOR` (default `MAJOR`), `--repo-root `, `--json`, +`--llm` (opt into the LLM grader), `--model ` (judge model for `--llm`). +Exit code is non-zero when a blocking finding is present. Under GitHub Actions it +also emits `::error`/`::warning` annotations. + +### LLM review (opt-in, paid — `--llm`) + +The `eval-review` grader makes a **real judge-model call** to assess the judgment +dimensions the static graders can't: fixture honesty (C), skill-design balance +(D1–D3), and domain-correctness & safety (DC). It is **off by default** and +deliberately **excluded from the free CI gate** because it costs money/latency. + +- **Enable:** `review.mjs --llm`. Select the model with `--model ` or the + `EVAL_REVIEW_MODEL` env var (default `claude-opus-4.6`, matching the repo's + judge-model default in `eng/vally-adapter/adapt.mjs`). +- **Auth + deps:** provisions the same client Vally's CLI uses via the optional + `@github/copilot-sdk` (+ `zod`) dependency; install with `npm ci` (i.e. **without** + `--omit=optional`). Needs a Copilot-scoped token in `GITHUB_COPILOT_API_TOKEN`, + `GITHUB_TOKEN`, or `GH_TOKEN`. +- **Evidence discipline / anti-fabrication:** every model finding is routed through + the same evidence rule as the static graders — a MAJOR/BLOCKER with no quoted + `file:line` proof is downgraded, cited files must exist in the unit, and quoted + proof is verified against the unit's actual text (unverifiable quotes are + dropped). The grader **never** emits a BLOCKER on error. +- **Graceful failure:** any SDK/token/model/timeout failure yields an + **inconclusive** NOTE (not a finding) and is excluded from the verdict, so a + missing token or offline run can never produce a false BLOCKER. +- **Vally-compat:** still registered as a `determinism: "llm"` grader + (`experimental: true`) so `vally eval`/`grade --grader-plugin` can load it. + +### Suppression + +Add an inline `eval-review-ignore: ` comment on or next to a +flagged line to suppress a specific de-leading finding when a fixture *must* +contain the smell (e.g. a fixture that deliberately models bad code). + +## CI wiring + +The gate runs in `.github/workflows/skill-check.yml` as **steps in the existing +`check` job** (`on: pull_request`) — no separate workflow file — as a free, +no-model check: + +1. `npm ci --omit=optional` + `npm test` — validates the graders themselves + (the paid LLM optional deps are skipped; the suite runs without them). +2. `node eng/vally-graders/review.mjs --base origin/ --report-only + eval-deleading --report-only negative-scenario` — enforces the crisp + `assertion-hardening` / `meta-commentary` MAJORs while the noisier dimensions + surface as warnings during rollout. The paid `--llm` grader is **not** run here. + +**Scope.** The PR gate runs **changed units only** (the `--base origin/` +default). A **run-all** path is available for manual/nightly use via +`review.mjs --all`, exposed in CI through the `EVAL_REVIEW_ALL=1` env toggle. + +**Dimension ratchet.** Tighten enforcement over time by removing `--report-only` +flags as each dimension's backlog is burned down. + +> **Note — not added to the eval infra-glob.** `eng/vally-graders/` is intentionally +> *not* in the `is_infra` change globs in `evaluation.yml`. Those globs trigger a +> full model-eval smoke run, but the graders are not part of Vally's eval path, so a +> grader change would burn eval budget for zero signal. Grader changes are instead +> covered by `npm test` in the `check` job. + +## Vally compatibility (future layer, not the gate) + +`index.mjs` exports `registerGraders(registry)` (the `GraderPluginEntry` +contract), so `eng/vally-graders` is loadable as a local grader plugin: + +```bash +vally eval --grader-plugin ./eng/vally-graders ... +vally grade --grader-plugin ./eng/vally-graders ... +``` + +Custom graders execute in `eval`/`grade` (per trajectory), **not** in `lint` +against skills, and **not** in `experiment`. To reference a grader `type` from an +`eval.vally.yaml`, run via `eval`/`grade` with `--grader-plugin`. + +### Version pin (important) + +`--grader-plugin` availability was confirmed on the installed +`@microsoft/vally-cli@0.6.0` (present on `lint`/`eval`/`grade`/`compare`/`export`, +**absent on `experiment`**). The repo's Vally workflow installs the CLI **unpinned** +(`latest`). If the repo ever routes these graders through Vally, pin +`@microsoft/vally-cli` to a version known to support the intended flags and add a +smoke test that fails loudly if `experiment` still can't load plugins. + +## Layout + +``` +index.mjs # registerGraders(registry) + exports (staticModules, graders) +review.mjs # standalone CLI gate (the real enforcement path) +graders/ + eval-deleading.mjs # A + assertion-hardening.mjs # B + meta-commentary.mjs # D5 + negative-scenario.mjs # D4 + eval-review.mjs # llm — opt-in judge-model grader (C/D/DC) +lib/ + locate.mjs # skill<->eval discovery + eval.yaml/SKILL.md parsing + report.mjs # finding model, [SEVERITY]/PROOF/WHY/FIX formatting, verdict + grader-base.mjs # wraps a { metadata, review } module as a Vally Grader + llm-client.mjs # @github/copilot-sdk provisioner for the --llm path +tests/ + graders.test.mjs # node:test suite (run with `npm test`) + fixtures/repo/ # synthetic leaky + clean skill/eval pair +``` + +## Design notes + +- **Plain Node ESM `.mjs`**, matching the `eng/` convention (no TypeScript build). +- **`js-yaml` dependency** is a deliberate deviation from `eng/`'s dependency-free + style — Node has no built-in YAML parser and eval specs are YAML. It (plus its + transitive `argparse`) is the only *required* runtime dependency; `npm ci + --omit=optional` installs it in CI. The `--llm` path additionally uses the + **optional** `@github/copilot-sdk` + `zod` deps (installed by a plain `npm ci`), + which the free static gate skips. +- Dimensions **3 (full fixture right-vs-wrong discrimination)** and **5 (empirical + A/B value)** are out of scope for v1 static graders and are *not* claimed as + covered. diff --git a/eng/vally-graders/graders/assertion-hardening.mjs b/eng/vally-graders/graders/assertion-hardening.mjs new file mode 100644 index 0000000000..3e59cebc12 --- /dev/null +++ b/eng/vally-graders/graders/assertion-hardening.mjs @@ -0,0 +1,187 @@ +// assertion-hardening.mjs — CHECKLIST B: does the eval verify the RESULT? +// +// Classifies each scenario's intent BEFORE demanding a hard gate (per the +// cross-family review — don't punish advisory/diagnostic evals): +// - mutating (edits source: implement/fix/add/tag/refactor/...) -> a hard +// result gate is REQUIRED; prose/output_* only -> MAJOR (B1/B4). +// - advisory (diagnose/explain/review/audit, or "without modifying") -> no +// gate demanded; we don't flag. +// - uncertain (neither clear) -> MINOR if no gate (report, don't block hard). +// +// A "hard gate" is run_command_and_assert / file_contains / file_not_contains / +// file_exists / exit_success. output_contains / output_matches / rubric are NOT +// hard gates on their own. + +import { finding, SEVERITY } from "../lib/report.mjs"; +import { isHardGate } from "../lib/locate.mjs"; + +export const metadata = { + name: "assertion-hardening", + description: "Requires mutating eval scenarios to assert the RESULT (build/test/file gate), not just prose or output matches.", + behavior: { execution: "single" }, + determinism: "complex-static", + portability: "t1-universal", + reference: "reference-free", + temporalScope: "point-in-time", + costProfile: "free", +}; + +const DIMENSION = "assertion-hardening"; + +// Verbs that imply the agent must EDIT/PRODUCE code, matched only in IMPERATIVE +// position (base form, at a clause boundary or after please/then/now). English +// imperatives use the base form ("generate a class"); descriptive clauses use +// 3rd-person ("the project generates a file") — matching base form only avoids +// misreading a description of the codebase as an instruction to the agent. +const MUT_VERBS = + "implement|fix|add|create|write|modify|update|refactor|rename|migrate|convert|replace|remove|delete|tag|generate|edit|apply|insert|scaffold|annotate|port|resolve|wire up|fill in|make"; +// Verbs that specifically EDIT EXISTING provided files (as opposed to producing +// a new artifact shown in the answer). Only these, when the scenario supplies +// files on disk, earn a MAJOR: a wrong edit to real project files can pass a +// keyword-only assertion. "generate/write/create" produce new content and are +// treated as the lower-severity under-assertion case. +const EDIT_EXISTING = + /\b(fix|modify|update|refactor|rename|migrate|convert|replace|remove|delete|edit|port|annotate|tag|resolve|upgrade|downgrade|apply)\b/i; +// Request wrappers that precede an agent-directed instruction ("help me fix", +// "can you migrate", "I need you to update"). These let us catch mutating asks +// that aren't at a raw clause boundary while still ignoring 3rd-person +// descriptions of the codebase ("the project generates a file"). +const REQUEST = + "please|can you|could you|would you|help me|i need you to|i want you to|i'?d like you to|i need to|we need to|need to|let'?s|make sure to|ensure"; +const IMPERATIVE_MUTATING = new RegExp( + `(?:^|[.!?\\n]\\s*|["\`]|(?:${REQUEST})\\s+|then\\s+|now\\s+|and\\s+|,\\s+|-\\s+|how do i\\s+|how to\\s+)(?:${MUT_VERBS})\\b`, + "i", +); + +// Verbs whose deliverable is analysis/advice (advisory), not a code change. +const ADVISORY = + /\b(diagnose|diagnos(e|is|ing)|explain|describe|review|analy(z|s)e|analysis|recommend|compare|report|audit|summar(y|ize|ise)|identify|investigate|symbolicate|assess|evaluate|which|why does|why is|how does|tell me|should i|what should)\b/i; + +// Explicit "do not modify" signals force advisory classification. +const NO_MODIFY = + /\b(without modifying|don'?t modify|do not modify|analysis only|report[- ]only|just give me|do not (edit|change)|without (editing|changing))\b/i; + +function classify(prompt) { + if (!prompt) return "uncertain"; + if (NO_MODIFY.test(prompt)) return "advisory"; + const mutating = IMPERATIVE_MUTATING.test(prompt); + const advisory = ADVISORY.test(prompt); + if (mutating && !advisory) return "mutating"; + // A prompt that both instructs a change AND asks for explanation is a + // "diagnose-and-fix / show-me-and-explain" task. Whether it needs a hard gate + // depends on whether it edits provided files (decided in review()). + if (mutating && advisory) return "mixed"; + if (advisory) return "advisory"; + return "uncertain"; +} + +// The scenario NAME is the author's own one-line intent summary and is a +// stronger signal than the prose prompt (which may quote a user asking for the +// "wrong" thing that the skill should push back on). Prefer an unambiguous name +// signal; otherwise fall back to the prompt. +function classifyScenario(sc) { + const name = sc?.name ?? ""; + if (name) { + const nameMutating = IMPERATIVE_MUTATING.test(name); + const nameAdvisory = ADVISORY.test(name); + if (nameMutating && !nameAdvisory) return "mutating"; + if (nameAdvisory && !nameMutating) return "advisory"; + } + return classify(sc?.prompt ?? ""); +} + +function scenarioLine(evalText, name) { + if (!evalText || !name) return null; + const lines = evalText.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(name)) return i + 1; + } + return null; +} + +function review(unit) { + const findings = []; + + // Prefer the native eval.yaml (authored source of truth); fall back to any. + const evalFile = + unit.evalFiles.find((e) => e.parsed.format === "native") ?? unit.evalFiles[0]; + if (!evalFile) return findings; + const { parsed } = evalFile; + const evalText = parsed.text ?? ""; + + for (const sc of parsed.scenarios) { + // Non-activation scenarios: the agent should NOT act, so a result gate is + // not applicable here (negative-scenario grader owns that dimension). + if (sc.hasExpectActivationFalse) continue; + + const hardGates = sc.assertionTypes.filter((t) => isHardGate(t)); + const intent = classifyScenario(sc); + const line = scenarioLine(evalText, sc.name); + const proof = line ? textAt(evalText, line) : `scenario "${sc.name}"`; + const typesLabel = sc.assertionTypes.length ? sc.assertionTypes.join(", ") : "none"; + + if (hardGates.length > 0) continue; // has a real result gate — good. + + // Does the scenario hand the agent existing project files to edit on disk? + // If so, an output-only assertion is a genuine, high-severity gap: a wrong + // edit to the provided files still passes. If instead the agent is asked to + // *produce* code shown in its answer (no setup files), output matching is + // under-asserted but lower risk -> MINOR, not MAJOR (don't cry wolf). + const editsProvidedFiles = + (Array.isArray(sc.setupFiles) && sc.setupFiles.length > 0) || sc.copyTestFiles === true; + // Is the agent editing existing provided files (highest risk) vs. producing + // new content? Only edit-existing on provided files earns a MAJOR. + const editsExisting = + EDIT_EXISTING.test(sc.name ?? "") || EDIT_EXISTING.test(sc.prompt ?? ""); + + if ((intent === "mutating" || intent === "mixed") && editsProvidedFiles && editsExisting) { + findings.push( + finding({ + severity: SEVERITY.MAJOR, + dimension: DIMENSION, + rule: "B1", + file: evalFile.path, + line, + proof, + why: `Scenario sets up project files and asks the agent to change them, but only has weak assertions (${typesLabel}) and/or a prose rubric. A wrong or half-done edit to the provided files can still pass — the eval doesn't verify the RESULT.`, + fix: "Add a hard gate: run_command_and_assert (dotnet build / dotnet test) and/or file_contains / file_not_contains asserting the edited file's shape.", + }), + ); + } else if (intent === "mutating" || intent === "mixed") { + findings.push( + finding({ + severity: SEVERITY.MINOR, + dimension: DIMENSION, + rule: "B4", + file: evalFile.path, + line, + proof, + why: `Scenario asks the agent to produce code but only asserts on output text (${typesLabel}). Keyword matches can pass on code that does not compile or is subtly wrong (checklist B4: assert the RESULT, not the presence of keywords).`, + fix: "Have the eval write the produced code to a file and gate it: run_command_and_assert (dotnet build) or file_contains on the compiled/expected shape, instead of relying on output_contains alone.", + }), + ); + } else if (intent === "uncertain") { + findings.push( + finding({ + severity: SEVERITY.MINOR, + dimension: DIMENSION, + rule: "B1", + file: evalFile.path, + line, + proof, + why: `Scenario intent is ambiguous and it has no hard result gate (assertions: ${typesLabel}). If it expects a code change, it can be passed without producing a correct one.`, + fix: "If the scenario mutates code, add run_command_and_assert / file_contains. If it is advisory, tighten output_matches to a specific domain fact so it isn't trivially satisfiable.", + }), + ); + } + // advisory + no gate -> intentionally no finding. + } + + return findings; +} + +function textAt(text, line) { + return (text.split("\n")[line - 1] ?? "").trim(); +} + +export { review, classify, classifyScenario }; diff --git a/eng/vally-graders/graders/eval-deleading.mjs b/eng/vally-graders/graders/eval-deleading.mjs new file mode 100644 index 0000000000..524b328c70 --- /dev/null +++ b/eng/vally-graders/graders/eval-deleading.mjs @@ -0,0 +1,181 @@ +// eval-deleading.mjs — CHECKLIST A: does the eval leak the answer? +// +// Conservative + tiered on purpose (avoid crying wolf): +// - High-confidence leaks (a comment/prompt that pre-states the fix, the trap, +// or a status the model should deduce) -> MAJOR. +// - Generic "smell" comments -> MINOR/INFO for human triage. +// An allowlist (A7) keeps legitimate domain metadata (, +// back-compat notes, API-stability asides) from being flagged. +// +// Suppression: put `eval-review-ignore: A? — ` on the offending +// line or the line directly above it. + +import { finding, SEVERITY } from "../lib/report.mjs"; +import { readRepoFile } from "../lib/locate.mjs"; + +export const metadata = { + name: "eval-deleading", + description: "Flags eval fixtures/prompts that leak the answer (pre-labeled fixes, announced traps).", + behavior: { execution: "single" }, + determinism: "static", + portability: "t1-universal", + reference: "reference-free", + temporalScope: "point-in-time", + costProfile: "free", +}; + +const DIMENSION = "eval-deleading"; + +// High-confidence leak patterns (MAJOR). Applied to comment lines in fixtures +// and to eval prompts. +const HIGH_CONFIDENCE = [ + { rule: "A2", re: /\bthe\s+(right|correct|proper|intended)\s+fix\s+(is\b|was\b|edits?\b|involves?\b|means?\b|requires?\b|would be\b|here\b|:)/i, why: "The fixture/prompt states the fix the eval is supposed to test the model for discovering." }, + { rule: "A2", re: /\bmust be reflected in (every|all|each)\b/i, why: "Comment spells out the trap (the change must propagate), removing the thing the eval measures." }, + { rule: "A2", re: /breaking change.{0,40}(won'?t|will not|can'?t|cannot)\s+(be\s+)?(caught|catch)/i, why: "Comment names the exact trap a green run would miss." }, + { rule: "A3", re: /\bA SHIPPED\b/, why: "Doc-summary asserts a status (shipped/public API) the model is meant to deduce." }, + { rule: "A1", re: /\b(should|must|need(s)? to)\s+(remove|delete|inline|rename|extract|consolidate|merge|deduplicate)\b/i, why: "Comment pre-labels the refactoring the eval wants the model to choose." }, +]; + +// Prompt-only high-confidence: pre-announcing the hidden surface (A4). +const PROMPT_HIGH_CONFIDENCE = [ + { rule: "A4", re: /\bis a partial class\b/i, why: "Prompt pre-announces the hidden surface (partial class) the skill is meant to discover." }, + { rule: "A4", re: /(must|should|needs? to)\s+(work|compile|build|run).{0,40}(every|all|each)\s+(target ?)?framework/i, why: "Prompt pre-announces the multi-TFM surface the skill is meant to discover." }, +]; + +// Generic smells (MINOR) — comment lines in fixtures only. +const SMELLS = [ + { rule: "A1", re: /\b(duplicated block|duplicate block|near-identical|candidate to inline|rename target|poor name|copy[- ]paste|dead code)\b/i, why: "Comment reads like a pre-labeled refactoring cue rather than realistic code." }, +]; + +// A7 allowlist: lines that look benign or are legitimate domain metadata. +const ALLOWLIST = [ + / re.test(line)); +} + +function isCommentLine(line) { + const t = line.trim(); + return ( + t.startsWith("//") || + t.startsWith("///") || + t.startsWith("#") || + t.startsWith("*") || + t.startsWith("