Skip to content

Evaluability Rules (EVL)

llms.txt lists every page for an agent

Evaluability rules flag TypeScript patterns that the chant evaluator cannot statically resolve. All evaluability rules have severity error.

EVL001 and EVL003 share one subset definition with fold(), the static AST reducer chant build uses by default (see TypeScript as Data). A shape either rule flags is, with the environment-dependent exceptions below, exactly a shape the folder rejects, at the same line. EVL reports it in your editor and in chant lint, before a build, and fold() enforces it. A file the folder rejects still builds. chant imports and runs it instead, and EVL keeps that run inside the deterministic subset.

Where EVL and fold() diverge. Both import the same classifier (fold/subset.ts) so they can’t drift on node kinds or operators, but several things depend on the environment and can’t be decided from shape alone:

  • Identifier resolution — EVL treats any bare name as valid; fold() alone resolves it against the file’s consts and rejects an unbound one. A false negative on EVL’s part, never the reverse.
  • Intrinsic tag registration — EVL has no lexicon registry at lint time, so it can’t tell a registered intrinsic tagged template from a made-up one; it treats any tagged-template interior as opaque. fold() has the registry and rejects an unregistered tag.
  • Intrinsic call-form registration — an intrinsic its lexicon opted into call-form folding (Ref(...), Concat(...)) folds, but only fold() is given the registry that says so. EVL has none, so it still flags such a call in a resource’s props even though --fold reduces it. This is the one place EVL is stricter than the folder. The classifier takes the registry as an optional parameter, so a tool that has one gets the exact answer; wiring it into the lint engine is not done today.
  • Spread-source runtime type — EVL (via EVL004) checks that a spread source is traceable to a const; fold() additionally rejects it if that const turns out not to be an object or array at all.
  • A nested new Type(...) used as a property value — shape-valid to EVL either way, but fold() rejects a nested resource constructor (as opposed to the top-level one being folded) as a value, because it can’t be represented without constructing it, so the file falls back to run.
  • Short-circuit laziness — fold() evaluates &&/||/??/? : lazily, so an unfoldable untaken branch doesn’t reject. EVL has no notion of “taken” and requires every branch to be shape-valid. That gives false positives relative to fold(), never the reverse.
  • Composite step access (chant #1544) — Checkout({...}).step and the same shape from any other single-action Composite() wrapper (a call immediately narrowed to .step) is the documented idiom for embedding one inline in a Job’s steps: array. EVL001 treats it as shape-valid; fold() still can’t fold it (a call is not a value it can reduce) and falls the file back to the run path, as documented. The fallback was always valid; EVL001 used to flag it by mistake.

In practice EVL catches the same code. These are the edge cases where code that passes lint can still fail to fold, or EVL flags code fold() would accept.


Resource property values must be statically evaluable. Function calls, method calls, and other dynamic expressions are not allowed.

// ❌ Triggers EVL001
export const bucket = new _.Resource({
bucketName: getName(),
tags: Object.assign({}, baseTags),
});
// ✅ Fixed
const name = "my-data-bucket";
const tags = { ...baseTags, env: "prod" };
export const bucket = new _.Resource({
bucketName: name,
tags: tags,
});
// ✅ Also allowed — a Composite() wrapper's `.step` output embedded inline
// (chant #1544; see "Where EVL and fold() diverge" above)
export const build = new Job({
"runs-on": "ubuntu-latest",
steps: [Checkout({}).step, new Step({ name: "Test", run: "npm test" })],
});

Resource instantiation cannot appear inside control flow blocks (if, for, while, switch, try). Resources must be declared at the top level.

// ❌ Triggers EVL002
if (env === "prod") {
export const bucket = new _.Resource({
bucketName: "prod-data",
});
}
// ✅ Fixed
export const bucket = new _.Resource({
bucketName: "my-data",
});

Computed property access with a non-literal key cannot be evaluated statically.

// ❌ Triggers EVL003
const region = config[key];
// ✅ Fixed
const region = config["production"];

The spread operator requires the source to be traceable to a const declaration. A binding imported from another module counts, so an exported const can be shared and spread across modules.

// ❌ Triggers EVL004
export const bucket = new _.Resource({
...getDefaults(),
});
// ✅ Fixed
const defaults = { versioning: true, encryption: "AES256" };
export const bucket = new _.Resource({
...defaults,
});

Callbacks passed to resource() must use expression body syntax. Block bodies with return statements cannot be statically evaluated.

// ❌ Triggers EVL005
resource(_.Resource, (props) => {
const name = props.name + "-data";
return { bucketName: name };
});
// ✅ Fixed — use expression body
resource(_.Resource, (props) => ({
bucketName: props.name + "-data",
}));

In composite resources, accessing siblings.key.attr where key does not exist in the resource map is an error.

// ❌ Triggers EVL007
resource(_.ServiceType, (props, siblings) => ({
role: siblings.typo.arn,
}));
// ✅ Fixed
resource(_.ServiceType, (props, siblings) => ({
role: siblings.executionRole.arn,
}));

chant