feat(cige): run, classify, replay, and safely repair CIGE test cases
The problem
An e2e test written today mixes four things in one TypeScript body:
- where it runs
- why it exists
- what the agent must never do
- the path it takes
When the test goes red, nothing in it says which of these broke. A failed agent.act can mean the staging API is down, the flow moved, the product has a bug, or the agent found a shortcut that should never have counted as a pass. An agent asked to fix the test can't tell whether it may edit the steps, the setup, or the assertion. A fix that edits the assertion so the test goes green hides a product bug.
CIGE is a test case standard for agentic runners that separates those concerns. The paper reports the authors' experience on an industry codeless agentic testing platform: about 90% less maintenance effort, 38% fewer false positives, and 62% less execution time.
| Field | Says | Who may change it |
|---|---|---|
| Context | Where the test runs, and pointers to its spec, release notes, and expected output | A repair for environment drift, approved by a human |
| Intent | Why it exists and what a pass needs | A human. A repair may only add criteria or evidence |
| Guardrails | What the agent must never do | A repair may make them stricter, never looser |
| Execution | The first path to try | A repair for a changed flow |
e2e already has the pieces: atomic act goals, judges that never see system or the ledger, agentContext, onAction, blocked versus failed verdicts with a closed code table, tags, sessions, platforms, and per-test traces. This PR maps CIGE onto them in a new package and a consumer skill. It changes nothing in packages/e2e.
What CIGE adds
- Test cases structured for developers and agents: schema-validated JSON for people, run summaries and a skill for agents, for authoring and maintenance.
- A field-scoped self-healing workflow: a first failure classification in every run summary, and role-scoped guides (classify, recover, repair, escalate) that one agent or several can follow, gated by replay and a mechanical Intent check (
e2e-cige diff). - Pluggable ground truth for classification: spec, release notes, and expected output per build, fetched on request, with readiness checks before anything fails.
- An extensible implementation of the CIGE standard:
x-fields, plugins, and custom step kinds, with validation intact. - Per-test runtime guardrails: in every agent instruction, enforced after every action (replays included), extensible with plugin checks; the judges stay independent of them.
- Intent as identity: unique per case and the test title, so runs filter by it (
--grep), and by case id (--tag cige:<id>). - Evidence beyond the screen that fails closed, plus a
screen-only-evidencesignal for a pass that may be false. - Variants across data, platforms, personas, and sessions, under one Intent.
- No change to e2e core.
What this PR does not claim: the paper's measured gains (they come from another platform), or guardrails the runner blocks before an action (deny stops the step after it).
What a customer does
- Writes a case as JSON (
tests/cige/checkout.cige.json), with editor validation from the shippedcige-1schema. - Declares the project's ground truth one time, in
cige.config.ts: hooks, environments and how each resets, the build id from CI, and resolvers for the spec, release notes, and expected output. Thecigeskill asks for these before it writes the first case. - Runs
npx e2e run. Each case is one test per variant, taggedcige:<id>. Each result gets a run summary. - On a failure, the
cigeskill reads the summary, classifies it, makes only the repair that kind allows, replays the case, and proves withe2e-cige diffthat the Intent held.
{
"id": "checkout-discount",
"Context": { "system": "Shop web app", "environment": "staging", "open": "/cart", "specRef": "docs/specs/checkout.md", "buildRef": "releases/{build}.md" },
"Intent": {
"statement": "A shopper can check out with the discount code {code}",
"evidence": [{ "id": "order-record", "hook": "orderPersisted" }]
},
"Guardrails": ["Use only the test card", { "id": "no-refund", "rule": "Never refund an order", "deny": { "actions": ["tool:refund"] } }],
"Execution": [{ "id": "e1", "step": "apply the discount code {code}" }, { "id": "e2", "step": "pay with the test card" }],
"Variants": [{ "id": "save10", "params": { "code": "SAVE10" } }, { "id": "save20-mobile", "params": { "code": "SAVE20" }, "platforms": ["ios", "android"] }]
}
// cige.config.ts: read by cigeSuite(), the cige() reporter, and the e2e-cige command
export default defineCige({
cases: 'tests/cige/*.cige.json',
build: process.env.GITHUB_SHA,
hooks: { orderPersisted: async ({ screen }) => (await fetch(`/test-api/orders/${await screen.getByTestId('order-id').textContent()}`)).ok },
environments: { staging: { config: 'e2e.staging.config.ts', reset: resetStaging } },
refs: { build: ({ build }) => ({ url: `https://github.com/acme/shop/commit/${build}` }) },
});
flowchart LR
F[.cige.json + cige.config.ts] --> S[cigeSuite: one test per variant]
S --> E["Execution: agent.act + guardrails, deny via onAction"]
E --> V["Intent.evidence (hook must return true), then Intent judged"]
V --> R["cige(): .e2e/cige/id.run-summary.json"]
R --> K["cige skill: classify"]
K -->|infrastructure| ENV["e2e-cige env reset"]
K -->|stale path| EX["rewrite Execution"]
K -->|false pass| G["add guardrail / evidence"]
K -->|defect| D["e2e-cige refs: file bug or propose Intent"]
ENV --> RP["e2e-cige replay id"]
EX --> RP
G --> RP
RP --> DF["e2e-cige diff: blocks a weakened case"]
How CIGE maps onto e2e
| CIGE | e2e |
|---|---|
| Intent | The test title and the cige:<id> tag, then a final agent.assert. The judge never reads the guardrails or the acting agent's summaries, so a lenient prompt can't lower the bar |
| Evidence | A pass needs every Intent.evidence item: a screen judgment, or a hook in your code. A hook holds only when it returns true; a forgotten return fails. A hook that throws is ASSERTION_INCONCLUSIVE (a backend that's down is not a product defect), unless it threw an e2e error, which keeps its code |
| Context | agentContext, facts only. platforms, requires, session, and agent become the test's options. specRef, buildRef, and expectedRef never reach the model |
| Variants | One test per variant (cige:<id>/<variant>): its values fill {name} placeholders, as params for act steps and as text for judgments |
| Guardrails | Appended to each act instruction. A deny rule is checked in onAction after every action, replayed ones included, and stops the step with POLICY_DENIED |
| Execution | One agent.act per step, or agent.assert for a verify step. Ids give a failure a stable name such as e2 |
The reporter's first guess at a failure type (packages/cige/src/classify.ts) reads the error class and step status e2e already writes to report.json (error.category, a blocked step), so a new code in core needs no change here. Only the test-class path codes are named, typed as AgentError['code']:
| Where and what | failureType |
|---|---|
A precondition failed, or an error of class infrastructure or configuration (not POLICY_DENIED or REPLAY_STALE) | infrastructure |
An Execution step failed: LOCATOR_NOT_FOUND, LOCATOR_AMBIGUOUS, ACTION_FAILED, REPLAY_STALE | outdated-test-logic |
A verify, a piece of evidence, the Intent, or a plugin's check step was judged false | product-defect |
An act step whose goal the agent could not reach (ASSERTION_FAILED) | Absent, signal goal-unreachable: a changed flow or a defect |
A blocked test-class step (not APP_NOT_OPEN), a spent budget, the clock, no conclusion | Absent, signal automation-limit |
A guardrail violation, an agent's refusal citing one (guardrail-refused:<id>), an inconclusive judgment, anything else | Absent; signals names what it saw |
A false positive is never set by the reporter: the skill decides it from the screen-only-evidence signal and the trace.
The e2e-cige command
| Command | Does |
|---|---|
replay <id> [--variant v] | Runs the environment's reset (refuses an environment without one), then e2e run --config <env config> --tag cige:<id> --no-cache, and exits with e2e's code. Other cases' summaries and all refs stay |
env provision|reset|teardown <name> | Restores an environment without touching any case |
refs <id> | Runs the config's resolvers on request and writes the spec, release notes, and expected output to .e2e/cige/<id>.refs/. Never runs during a run |
check [--resolve] | Lists each case that lacks a spec or build ref, a build for {build}, or an environment with a reset, before anything fails. The reporter adds a CIGE gaps row under the run summary |
diff --base origin/main | From the merge base, exits 1 when a change weakens or redirects a case: Intent changed, a variant's data changed, a criterion, evidence item, guardrail, deny pattern, variant, or case removed, any Context change, or a change to the CIGE config or any module it imports. It finds those by loading the config in a child Node process, then parsing each module (Node's type stripping, then acorn) for lazy imports, so # aliases, workspace packages (one built to an ignored dist counts by its sources), symlinks, lazily imported hooks, and the resolution fields of the package.json that maps them count. A config it can't load or follow (a computed import()/require(), createRequire, eval) blocks as config-unverified. Loading runs the config's top-level code, so diff needs the run's env. App code the config doesn't import, data files a hook reads, and installed packages are out of its reach. Rewriting Execution and additions pass, so tightening after a false pass needs no human. --allow-intent is the human's override and records nothing |
It spawns the installed e2e bin; it uses no internal API.
Extending CIGE
The schema stays closed, so a misspelled key still fails to load. Three layers open it on purpose:
| Layer | How | Where it goes |
|---|---|---|
| Your own fields | x- keys on any object ("x-owner", "x-deny-host" on a guardrail), kept as extensions | Hooks and plugins read them. The model never sees them unless a plugin adds them. Top-level ones are copied into summaries. diff blocks an x- change under Intent or on a guardrail |
| Your own behavior | plugins in cige.config.ts: context, onAction, classify, summarize, readiness, steps | Context lines, guardrail checks driven by x- data, classification changes (classification.adjustedBy), summary fields under extensions.<plugin>, check gaps |
| Your own steps | { "id": "e4", "use": "order-api", "with": {...} } | Runs a plugin's step kind in Execution order; variants fill with; a failure names the step |
export const shop: CigePlugin = {
name: 'shop',
context: (testCase) => {
const flags = testCase.context.extensions?.['x-feature-flags'];
return Array.isArray(flags) ? `Feature flags on: ${flags.join(', ')}` : undefined;
},
steps: {
'order-api': async ({ step }) => {
const response = await fetch(new URL(String(step.with.path), 'http://localhost:3000'));
if (response.status >= 500) throw new AgentError('ENVIRONMENT_UNAVAILABLE', `GET ${String(step.with.path)} answered ${response.status}`);
if (response.status !== step.with.expect) throw new AgentError('ASSERTION_FAILED', `GET ${String(step.with.path)} answered ${response.status}`);
},
},
};
A new extension point is a plugin hook, never an open key (AGENTS.md says so).
The cige skill, in this repo
skills/cige/ (installed with npx skills add tester-army/e2e --skill cige, and in the e2e plugin) routes each job to one reference: authoring, classify, recover, repair, escalate. Each may write only its own field, replays with e2e-cige replay, and ends with e2e-cige diff --json in the hand-off. Authoring asks the user, one time per project, where specs, release notes, and expected output live and how each environment resets.
Cost to the repo
- No change to
packages/e2e. The package imports frome2eonly. Peere2e >=0.21.0 <1;zod,acorn, andoxc-parser(the versione2eships) as its dependencies; a patch changeset. cige-1is pinned bypackages/cige/schema/cige-1.schema.json, generated from the loader's zod schema at build (a test fails on a stale copy), with a valid and an invalid fixture.docs/cige.mdxunder Features, three typechecked examples indocs/examples/cige/, and links from the README,docs/coding-agents.mdx,docs/agents.mdx, anddocs/reference/agent.mdx. The shipped e2e skill doesn't mention it. The field reference lives inpackages/cige/README.md.- The testbed's weekly agent suite gains two CIGE cases (
apps/testbed/tests-agent/cige/): the wizard with two variants, and the todo list with adenyguardrail. Each pass is backed by a code check of the page. - 125 package tests, no model and no browser.
- The repo's
verifyskill gains a@e2e-dev/cigerow, so a later change to the package or the skill is proven on the testbed, not only by unit tests. - First release needs a maintainer: per CONTRIBUTING.md, a new public package is published by hand once and connected to its npm trusted publisher before
release.ymlcan publish it. The changeset queues@e2e-dev/cige, so that step has to happen first, or the changeset waits. - I'll maintain it and answer its issues.
Not in this PR
- Blocking an action before it runs.
onActionfires after the action, which the docs page says. Blocking needs the built-in agent exported as a wrappable executor, a core API question for an issue first. - Writing report
extensionsfrom a reporter. The summaries are a sidecar file. - YAML, and importers from Gherkin and existing
*.e2e.tsfiles.
Verified
Ran it locally: yes, on the built packages, with a scripted executor and live with gpt-6-luna.
Earlier review rounds (3bc72bc3; cubic's follow-ups in 528c1a28, c21b45dc, 4dd1f130; the second review in ba31fd9d and cubic's follow-ups in f6f65134 and 71faade4, on main as of 46437451). pnpm check green, 116 package tests, the e2e unit tests (3060 passed), the testbed suite (79 passed, 1 skipped). Each fix reverted on its own, rebuilt, and tested:
| Mutation | Tests |
|---|---|
context-changed not blocking | 2 fail |
config-changed not blocking | 2 fail |
config left out of diff | 2 fail |
| imported modules not followed | 1 fails |
deny.summary patterns skipped | 2 fail |
| setup categories ignored (classification) | 1 fails |
a blocked step not an automation limit | 1 fails |
| committed schema stale | 1 fails |
| config graph walked only in the working tree (a deleted config or module) | 2 fail |
a missing --config accepted | 1 fails |
POLICY_DENIED/REPLAY_STALE typed infrastructure by class | 1 fails |
a blocked APP_NOT_OPEN counted as an automation limit | 1 fails |
| guardrails diffed by text as a set (one of two same-text dropped) | 1 fails |
a plugin named x-… allowed | 1 fails |
| a refusal needs the word "guardrail" to cite one | 1 fails |
| a symlink at the merge base not followed (its target deleted) | 1 fails |
a directory accepted as --config | 1 fails |
the loaded graph dropped (a #hooks/order alias) | 1 fails |
a hook's lazy import('#hooks/lazy') not resolved | 1 fails |
config-unverified not blocking | 1 fails |
a computed import(name) not detected | 1 fails |
a package.json imports remap not checked | 1 fails |
Third review (fdcb362e, 7c6c9a36; cubic's follow-ups in ea6a3cb3 and 60d550c6). The regex scanner is gone: src/imports.ts strips types with module.stripTypeScriptTypes and parses with acorn. pnpm check green, 119 package tests. The review's cases, each a scratch git repo (commit, edit, diffSince('HEAD')), at 71faade4 and now:
| Case | 71faade4 | Now |
|---|---|---|
'orders/*' … '*/' around a lazy import('#hooks/check'), hook edited | blocking: 0 | config-changed: hooks/check.ts |
const sep = '//'; … import(name) | blocking: 0 | config-unverified: imports a module by a computed name |
createRequire result named req | blocking: 0 | config-unverified: calls createRequire |
@acme/hooks built to an ignored dist, src edited and rebuilt | blocking: 0 | config-changed: packages/hooks/src/index.ts |
tracked link behind #hooks/order retargeted to impl/loose.ts | blocking: 0 | config-changed: hooks/order.ts |
Each rule turned off on its own fails diff.test.ts:
| Mutation | Tests |
|---|---|
a non-literal import() accepted | 1 fails |
require read as a value accepted | 1 fails |
createRequire accepted | 1 fails |
eval accepted (globalThis.eval, (0, eval)) | 1 fails |
Function accepted (const make = Function) | 1 fails |
a require: key read as a use | 1 fails |
a destructure (const { require: r } = module) read as a key | 1 fails |
a computed member (module['require']) accepted | 1 fails |
| any string equal to a loader's name blocking | 1 fails |
a lazy require('./hook') resolved as ESM, not CommonJS | 1 fails |
a relative or # specifier that doesn't resolve accepted | 1 fails |
| a resolved relative file not checked to exist | 1 fails |
a bare . or .. not read as relative (unresolved check, merge-base walk) | 1 fails each |
| types not stripped before parsing | 1 fails |
an ignored dist not standing for its package's sources | 1 fails |
| an untracked file in the config's own package accepted | 1 fails |
| built output parsed (a bundler's computed import blocks) | 1 fails |
| listed symlinks not counted | 1 fails |
| the merge-base walk dropped | 2 fail |
| lazy specifiers not followed | 3 fail |
installed node_modules packages walked | 1 fails |
On the testbed with the built CLI, the real config: diff --base HEAD with nothing changed prints No CIGE case or config changed since HEAD. (exit 0); the workspaceCreated hook rewritten to return true gives config-changed: apps/testbed/cige.config.ts (exit 1). In this repo the testbed imports e2e and @e2e-dev/cige as workspace packages, so a branch that edits them now counts as config-changed there; a project that installs them from npm doesn't see that. No CI job runs e2e-cige diff. CI's Node 26 job is the first run of stripTypeScriptTypes on 26; locally only Node 24 ran it. If it's missing there, diff fails closed.
Fourth review (c793a4bc; cubic's follow-ups in 6e070e70; upstream main merged in 0718153f). pnpm check green, 122 package tests. The review's shapes, each a scratch git repo with the hook flipped to pass, at 60d550c6 and now:
| Case | 60d550c6 | Now |
|---|---|---|
A: Module._load(path) from node:module | blocking: 0 | config-unverified: imports node:module |
B: m['create' + 'Require'] on import * as m from 'node:module' | blocking: 0 | config-unverified: imports node:module |
C: vm.runInThisContext("…createRequire(…)('./check.cjs')") | blocking: 0 | config-unverified: imports node:vm |
D: new Worker("import('./hooks/check.mjs')", { eval: true }) | blocking: 0 | config-unverified: imports node:worker_threads |
@acme/hooks bundles @acme/shared; shared's src edited | blocking: 0 | config-changed: packages/shared/src/index.ts |
Shape E's globalThis['ev' + 'al'] still passes, as the review expects, and the README's limits say so. Its (async () => {}).constructor blocks since the fifth review. Each new rule turned off on its own fails diff.test.ts, and so does every earlier one (38 mutations in all):
| Mutation | Tests |
|---|---|
| an import of a loading builtin not blocking | 1 fails |
node: prefix required, or bare name required | 1 fails each |
vm, worker_threads, or inspector/promises left out | 1 fails each |
the builtin rule skipped for a re-export or a require | 1 fails each |
process.getBuiltinModule read accepted | 1 fails |
| a stand-in package's sources not walked (a module imported by path) | 1 fails |
| a stand-in's workspace dependencies not standing in | 1 fails |
| a stand-in's directory not counted (a deleted source) | 1 fails |
| the config's own package standing in (its cases read as config) | 1 fails |
| a stand-in source's untraceable import blocking | 1 fails |
| a file reached strictly after leniently not walked strictly | 1 fails |
| links not read from index modes, retyped paths dropped, deleted files listed | 1 fails each |
| an untracked file reached through a stand-in's sources accepted | 1 fails |
| a stand-in package.json that doesn't parse accepted | 1 fails |
On the testbed with the built CLI: diff --base HEAD prints No CIGE case or config changed since HEAD. (exit 0), and workspaceCreated rewritten to return true exits 1. Queueing a stand-in's sources under the strict rules, as first suggested, made that run config-unverified for good: it parsed all of packages/e2e and packages/cige, tests included, and hit the runner's computed config import, its node:module hooks, and TypeScript parameter properties that strip-only mode refuses. Node 26 dropped stripTypeScriptTypes's transform mode, so those can't be read either; the package.json dependencies cover a bundled package a source Node can't strip imports.
Fifth review (003692ab; cubic's follow-up in f7fd3377). pnpm check is green, and the 122 package tests pass on Node 24.22.0 and on 24.8.0, the engines.node floor CI's test (24.8.0) job runs. That job failed on 0718153f because Node 24.8.0's rmSync throws ERR_FS_EISDIR on a symlink to a directory, so the test helper now uses unlinkSync. The review's shapes, each a scratch git repo with the hook flipped to pass, at 0718153f and now:
| Case | 0718153f | Now |
|---|---|---|
process[`getBuiltinModule`]('module').createRequire(…)('./check.cjs') | blocking: 0 | config-unverified: reads process.getBuiltinModule |
globalThis[`eval`]("…createRequire(…)('./check.cjs')") | blocking: 0 | config-unverified: runs code from a string |
hooks/loader.cjs: module.constructor._load(__dirname + '/../check.cjs') | blocking: 0 | config-unverified: reads .constructor |
(() => {}).constructor("return process.getBuiltinModule('module')")() | blocking: 0 | config-unverified: reads .constructor |
import('data:text/javascript,export{createRequire}from"node:module"') | blocking: 0 | config-unverified: imports a data: URL |
run({ files: ['./check.cjs'], isolation: 'none' }) from node:test | blocking: 0 | config-unverified: imports node:test |
process.mainModule blocks too. It already blocked before, through the .require read in the repro. Stand-in sources stay lenient. With their refusals blocking, diff on the testbed reports config-unverified on every run and names about 90 files: the runner's computed config import, node:module in at least 12 files, and the web engine's Function. The README now names a computed load in a workspace package's sources as out of reach. Each rule turned off on its own fails diff.test.ts (45 mutations in all, every one fails a test):
| Mutation | Tests |
|---|---|
| a template-literal key read as text, a computed member key or a computed destructure key not checked | 1 fails each |
| a computed key in an object literal blocking | 1 fails |
.constructor or process.mainModule read accepted | 1 fails each |
a data: import accepted, an uppercase DATA: one accepted, or node:test left out | 1 fails each |
Sixth review (7b0346ae; upstream main merged in 431d3aea). pnpm check is green, and the 122 package tests pass on Node 24.22.0, 24.8.0, and 26. The review's shapes, each a scratch git repo with the hook flipped to pass, at 431d3aea and now:
| Case | 431d3aea | Now |
|---|---|---|
A stand-in's source re-exports local/loader.mjs (no package), which calls createRequire(…)('../hooks/check.cjs') | blocking: 0 | config-unverified |
The same, with a computed import(p) in loader.mjs | blocking: 0 | config-unverified |
A stand-in's source holds an enum and re-exports local/check.mjs; check.mjs flipped | blocking: 0 | config-changed: local/check.mjs |
process.binding('contextify') | blocking: 0 | config-unverified |
process.dlopen(…) | blocking: 0 | config-unverified |
import repl from 'node:repl' | blocking: 0 | config-unverified |
When Node's strip-only mode refuses a file, oxc-transform@0.155.0 now compiles it, the version e2e already ships. Node 26 rejects mode: 'transform' at runtime (ERR_INVALID_ARG_VALUE), so that's not an option. The lockfile differs from main only by cige's importer. (Not so: the @pnpm/exe entries were still missing at 7b0346ae; the seventh review restores them.) On the testbed, diff --base HEAD still reports nothing, and the flipped workspaceCreated hook exits 1.
Of 50 mutations, 49 fail a test. The survivor is equivalent: it makes the untracked-file rule strict-only, but a file reached leniently now always sits inside a stand-in's directory, so it never reaches that rule. The new ones:
| Mutation | Tests |
|---|---|
| a file outside every stand-in walked leniently | 1 fails |
| no oxc fallback for TypeScript Node can't strip | 1 fails |
process.binding or process.dlopen read accepted | 1 fails each |
repl left out | 1 fails |
Seventh review (71639603; upstream main merged in d37689db). The review's comments, sorted before fixing:
| Item | Bucket | Done |
|---|---|---|
binding and dlopen blocked as any name (cubic's P2 too) | fix | read only on process; process handed on blocks |
| A decorator hides a stand-in's imports | fix the cause | TypeScript parsed with oxc-parser; a stand-in source that doesn't parse blocks |
@pnpm/exe lock entries still missing (and the 7b0346ae message said they were back) | fix | restored; the lockfile adds 34 lines against main and removes none |
No test for the trailing /, or for the oxc error path | fix | packages/hooks-extra and an unparsable stand-in source |
compiled() copies e2e's lazy loader | fix | oxcParser() loads as e2e/src/config/typescript.ts does, and keeps a failed load |
| In-package loader refusals in stand-ins; accidental vs adversarial | maintainer | unchanged, documented |
test (24) failed in packages/acp | not this PR | the same test failed on main at b54646f7; packages/acp matches main |
The review's shapes, plus the ones a fresh-context review of this round found in a draft, each a scratch git repo with the hook flipped to pass or the tree left clean:
| Case | d37689db | Now |
|---|---|---|
const binding = { ok: true } in the config, clean tree | blocking: 1 | blocking: 0 |
A hook reads reply.binding, clean tree | blocking: 1 | blocking: 0 |
A hook destructures ({ dlopen }), clean tree | blocking: 1 | blocking: 0 |
A stand-in's source holds a decorator and re-exports local/check.mjs; check.mjs flipped | blocking: 0 | config-changed: local/check.mjs |
export * from 'node:process' | blocking: 0 | config-unverified |
export { default as proc } from 'node:process' | blocking: 0 | config-unverified |
A .js hook with JSX, clean tree | blocking: 1 | blocking: 0 |
this.constructor.name, clean tree | blocking: 1 | blocking: 0 |
const p = require('node:process'); p.binding, import p = require('process'), (await import('node:process')).binding, import('node:process').then(…), and (0, process).binding all block, before and now. Every shape from the fifth and sixth reviews still blocks.
The old and new walks over this repo's 1,192 source files (1,177 TypeScript):
- Every file the old walk could read gives the same import list.
- The 11
bindingfalse positives are gone. - The Angular example (
@Component) parses now. - 8 files block under the handed-on rule: 5 tests with
vi.spyOn(process, …), 2process[stream]reads, and 1routingSlot.get(process). A config or hook reaches none of them.
With every source readable, making in-package stand-in refusals strict would cost 45 refusals on the testbed, down from 98.
Mutations are now generated instead of picked: every ||/&&, ===/!==, negation, if, set or record entry, continue, return true|false, regex flag, and the / in startsWith, in imports.ts and config-graph.ts.
- First run, 437 mutants: 361 failed a test. The survivors led to:
- about 20 new tests for cases nothing covered:
{ process },export { process },() => process,global.process, a computedrequire(name),{ [eval]: 1 },{ run: eval }, an import cycle at the merge base, a dependency hoisted to the root'snode_modules, and a dependency installed nowhere (dropping its loop's exit hung the walk, and no test caught it). - removed guards that could never fail on what the parser returns.
- a fix for one over-block,
const x = (process, 1).
- about 20 new tests for cases nothing covered:
- Final run, 372 mutants: 355 fail a test. No test can tell the 17 survivors apart:
| Survivor | Why |
|---|---|
imports.ts asProgram guard | both parsers always return a program; lint requires the narrowing |
imports.ts: plain JavaScript through oxc instead of acorn | same tree; only needs oxc's native binding |
config-graph.ts usage check | diff.ts always passes both arguments |
base walk: seen check and missing-entry check | the stack overflow they cause lands in entry()'s catch, which ends the walk with the same result |
| base walk: bare and outside-repo specifiers (4) | the lookup at the base finds no such file |
inRepo: file: check, root, and other-drive guards (3) | fileURLToPath throws on other schemes, and Node never loads the root or another drive |
| an installed dependency as a stand-in | adds only files git ignores |
/ in the "package.json doesn't parse" message | changes the message only |
link loop: catch and non-link checks (3) | a file that isn't a link resolves to itself, already in the graph |
cubic on 71639603 (55577fd8). All four findings were valid. The probe found four sibling forms, marked †:
| Case | 71639603 | Now |
|---|---|---|
const g = globalThis; g.process.binding | blocking: 0 | config-unverified |
const { process: p } = globalThis † | blocking: 0 | config-unverified |
globalThis['pro' + 'cess'].binding † | blocking: 0 | config-unverified |
const { default: proc } = await import('node:process') | blocking: 0 | config-unverified |
import * as ns from 'node:process'; ns.default.binding † | blocking: 0 | config-unverified |
A lazy TS hook with import { type Script } from 'node:vm', clean tree | blocking: 1 | blocking: 0 |
A stand-in's App.tsx imports local/check.mjs; check.mjs flipped | blocking: 0 | config-changed |
The fourth sibling, globalThis.global.process.binding, is covered by a test.
What changed:
- The global object.
globalThisandglobalfollowprocess's rule. Destructuringprocessout of one binds it whole, while{ process: { env } }reads a field and passes. - A namespace's
default. Aprocessnamespace'sdefaultisprocess. - Type-only imports. An import whose specifiers are all
typebinds no loader. - JSX and TSX.
.tsxand.jsxsources are walked, and.tsxparses as TSX. - Walker bugs. A tree the walk doesn't expect fails closed, naming the error, instead of crashing the graph process.
Results:
- Corpus: import lists are identical wherever the old walk could parse the file, and about 64
.tsxfiles now parse. The global rule adds 20 refusals, all inside the runner and engine (packages/e2e,packages/web), one test fixture, and one docs snippet. - Testbed: strict in-package refusals would now number 67. The normal walk reports nothing.
- Mutations: 380 of 397 fail a test. The 17 survivors are the same 17 as in the table above. Every mutant of the new rules fails a test.
The second review's repro, in a scratch git repo with the built CLI: cige.config.ts imports #hooks/order (package.json imports), and hooks/order.ts is rewritten to return true. At 4dd1f130 that gave blocking: 0; now:
hooks/order.ts
needs approval config-changed: hooks/order.ts: the hooks, plugins, and environments a pass depends on
1 change can change what a pass means and needs a human's approval.
exit=1
Live on the testbed (gpt-6-luna, --no-cache), on this commit: the committed cases pass. orders-infra is infrastructure; its code is no longer named in the classifier. The guardrail refusal, the planted defect, the unreachable goal, and the plugin step classify as in the table below. The repair gate on the committed wizard-workspace case, against HEAD:
| Change | e2e-cige diff |
|---|---|
Execution only (e1 and e2 merged) | execution-changed, intentUnchanged: true, blocking: 0, exit 0; the live run of the repaired case passed |
Context.open changed | context-changed: open, blocking: 1, exit 1 |
The workspaceCreated evidence hook rewritten to return true | config-changed: apps/testbed/cige.config.ts, exit 1 |
cige.config.ts deleted | config-changed: apps/testbed/cige.config.ts, exit 1 |
packages/cige/tests/suite.test.tsruns the built runner on an in-memory engine with a scripted executor, one case per branch of the workflow, plus a two-variant case. It then runs the builte2e-cige refs,replay, andenvagainst that project.
| Case | What happened | Run summary |
|---|---|---|
| pass | every step passed, the evidence hook returned true | result: pass, refs, replay command |
| guardrail | the executor called a mutating refund tool | failedStep: e1, POLICY_DENIED, guardrail-violation:no-refund |
| stale | the second step failed with LOCATOR_NOT_FOUND | failedStep: e2, outdated-test-logic, replay: npx e2e-cige replay stale |
| defect | the Intent judgment failed | failedStep: intent, product-defect |
| evidence | every step passed, the evidence hook returned false | failedStep: order-record, product-defect |
| infrastructure | the precondition hook threw ENVIRONMENT_UNAVAILABLE | phase: precondition, infrastructure |
| variants | card passed, voucher failed | two summaries, variants.voucher: outdated-test-logic |
| plugin step | a use step's plugin threw on a 500 | failedStep: e2, ACTION_FAILED, no failureType, plugin-step-failed:e2, adjustedBy: [orders], extensions with the case's x-ticket and the plugin's fields |
| unreachable | an act step's goal couldn't be reached (ASSERTION_FAILED) | no failureType, goal-unreachable |
| refused | the agent refused, citing the no-delete guardrail (POLICY_DENIED) | guardrail-refused:no-delete |
- Live, testbed,
gpt-6-lunathrough@ai-sdk/openai(a local-only config, not committed): the two committed cases plus five local ones, nine tests in all,--no-cache. Final run:
| Case | What the agent did | Run summary |
|---|---|---|
todo-done (committed) | added and checked "Water the plants" | pass; the evidence hook read the checked box and "0 remaining" |
wizard-workspace atlas-pro / borealis-free (committed) | created each workspace on its plan | both pass; the hook matched Created "Atlas" on the Pro plan from the variant's params |
checkout-pay-total (the planted bug) | set the Notebook quantity to 3 | product-defect at intent: "The order summary shows $42.00, but the Pay button shows $18.00" |
orders-infra | none: its precondition threw | infrastructure, phase: precondition, ENVIRONMENT_UNAVAILABLE |
todo-clear | read the no-delete guardrail and refused before deleting | POLICY_DENIED, guardrail-refused:no-delete, no type |
wizard-billing (a step for a tab that doesn't exist) | found no Billing tab | ASSERTION_FAILED, goal-unreachable, no type |
wizard-extended ok / wrong-status | created the workspace; a plugin use step called the real /api/users | ok passed. wrong-status ("expect": "{status}" → the number 201) is product-defect at e2, with x-owner/x-ticket and plugin fields in extensions and adjustedBy: [playground] |
- With
--ai-trace, the plugin'sFeature flags on: wizard-v1context line reached the model 6 times;x-ownerandx-ticketreached it 0 times; every act instruction carried the guardrail block. - The first live run caught two misclassifications, fixed here: an unreachable act goal was typed
product-defect, and an agent's own guardrail refusal named no guardrail. e2e-cige replay orders-infraprintedresetting local-openai, rane2e run --config e2e.cige-local.config.ts --tag cige:orders-infra --no-cache, and exited with e2e's code.e2e-cige refs wizard-workspace --variant atlas-prowroteindex.jsonandbuild.md(the last five commits underapp/).e2e-cige check --resolveon the testbed:All 2 CIGE cases are ready to classify and replay.e2e-cige diff --base upstream/mainon the testbed reports the new cases ascase-addedand the newcige.config.tsasconfig-changed, which needs approval: adding hooks is a human's call.- Live repair loop, driven by
skills/cige(gpt-6-luna, on a throwaway local branch, deleted afterwards):
| Step | What happened |
|---|---|
| Baseline | The testbed wizard gained a Billing choice and a case "A visitor can create the workspace Lyra on the Pro plan" whose e2 was "choose the Pro plan with Monthly billing". It passed live |
| Product drift | Commit feat(wizard): drop the billing choice; every plan bills annually |
| Failure | e2 failed: "no Monthly billing option was available". Summary: ASSERTION_FAILED, goal-unreachable, no type |
Classify (classify.md) | It reached the system. goal-unreachable routes to step 2. e2e-cige refs: the release notes hold the drop-billing commit; the spec still has name, Pro plan, and Create. So outdated-test-logic |
Repair (repair.md, Mode A) | Read the failure screen (no billing step) and changed only e2, to "choose the Pro plan", keeping its id |
| Replay | e2e-cige replay workspace-pro: reset, then the case alone with no cache. Passed; the evidence hook read Created "Lyra" on the Pro plan |
| Prove | e2e-cige diff --json: execution-changed: steps e2, intentUnchanged: true, blocking: 0 |
| Shortcut check | Weakening the Intent and dropping its evidence instead: diff lists 3 changes that need approval and exits 1 |
- The loop caught one bug, fixed here: on a CIGE config other than
cige.config.ts, the summary'srefsandreplaycommands found no case. The newconfigFileoption passes--config. - The
e2eMCP server didn't connect in that session, so the repair read the failure screen the run captured instead of a live MCP session. - The weekly agent workflow runs the committed cases with the Gateway key.