Skip to content

TypeScript as Data

llms.txt lists every page for an agent

chant reads the resource objects your TypeScript exports. It supports a specific subset of the language, the part whose value is fully determined by literals, constants, and cross-resource references. chant build reduces your files directly to those objects without executing them; a file that steps outside the subset is imported and run instead. The subset exists so that either path has no side effects and always yields the same output. This page documents exactly which patterns are supported and which are not.

The core pattern: export a const binding initialized with a typed resource constructor.

import { StorageType } from "@intentius/chant-lexicon-<name>";
export const store = new StorageType({
name: "my-data",
versioned: true,
});

The evaluator extracts the export name (store), resolves the resource type from the lexicon, and evaluates the constructor argument.

Strings, numbers, booleans, null, arrays, and nested object literals are evaluated directly.

import { ServiceType } from "@intentius/chant-lexicon-<name>";
export const service = new ServiceType({
name: "handler",
timeout: 30,
memorySize: 128,
environment: {
variables: {
DEBUG: "false",
},
},
});

const bindings with literal initializers can be referenced by name. The evaluator traces the reference to its initializer and evaluates it.

import { StorageType } from "@intentius/chant-lexicon-<name>";
const tags = { project: "myapp", env: "prod" };
export const store = new StorageType({
name: "data",
tags: tags,
});

Only const bindings are supported. let and var are mutable and cannot be statically traced.

Object spread is supported when the source is a const binding with a known literal value.

import { ServiceType } from "@intentius/chant-lexicon-<name>";
const defaults = { timeout: 30, memorySize: 128 };
export const service = new ServiceType({
...defaults,
name: "handler",
});

Standard import statements are followed through the module graph. The evaluator resolves imported names to their definitions in other files.

import { StorageType } from "@intentius/chant-lexicon-<name>";
import { sharedTags } from "./shared";
export const store = new StorageType({
name: "data",
tags: sharedTags,
});

The evaluator resolves these specifier forms the way tsc does with bundler resolution:

SpecifierResolves to
./sharedThe first of ./shared.ts, .tsx, .js, .mjs, ./shared/index.ts and ./shared/index.js that exists
./shared.js./shared.ts if it exists, else ./shared.js; .mjs maps to .mts and .cjs to .cts
pkg, pkg/sub/xThe package’s exports target under the import, node or default condition, * patterns included, or main without exports
@app/configThe target of a matching paths entry in the nearest tsconfig.json or a config it extends

A paths target resolves against baseUrl if the config sets one, and against the directory of the config that declares paths otherwise. chant’s own package names are never mapped.

A file with an import the evaluator cannot resolve falls back to run, and its [fold:run] line gives the reason.

Import a resource from another file and access its attributes. The evaluator resolves the attribute through the lexicon’s attribute registry. The import can use any specifier form listed under Import and re-export. A specifier that paths maps names a project file, so it is folded like a relative import, even when its text is a package name.

import { ServiceType } from "@intentius/chant-lexicon-<name>";
import { dataTable } from "./table";
export const service = new ServiceType({
environment: {
variables: {
TABLE_ARN: dataTable.arn,
},
},
});

Lexicons can register tagged template literals for provider-specific intrinsics (e.g., string substitution with deploy-time values):

import { StorageType, Intrinsic, Params } from "@intentius/chant-lexicon-<name>";
export const store = new StorageType({
name: Intrinsic`${Params.StackName}-data`,
});

See your lexicon’s documentation for available intrinsics.

Most intrinsics are not tagged templates. AWS writes Ref(...), GetAtt(...), Join(...); Azure writes Concat(...), Reference(...). A lexicon can opt one of these into folding, one intrinsic at a time:

import { StorageType, Ref, Sub, Params } from "@intentius/chant-lexicon-<name>";
export const store = new StorageType({
name: Sub`${Params.StackName}-${Ref(environment)}-data`,
owner: Ref(environment),
});

This is the one place a call folds that isn’t chant’s own authoring helpers, and it is a closed list, not a rule about what calls look like. Three things must hold: the lexicon registered the intrinsic, the lexicon marked that intrinsic’s call form foldable (foldsAsCall — off unless written, never inferred), and the name is bound by this file’s own import. Your own function that happens to be called Ref is not the lexicon’s, so it does not fold. Neither does a method call (aws.Ref(...)), an array .map(...), or an intrinsic whose lexicon has not opted it in. None of those is a bare-identifier call, which is all this section is about. aws.Ref(...) specifically can’t fall through to the general method-call fold either, because aws names a lexicon package, and a namespace import of a package (unlike one of your own files) is never resolved into a real value. The receiver stays an unresolved identifier, so the whole call is rejected before any method name is even looked at.

Nothing about the value changes: the function that runs is the one your import names, called with statically-folded arguments, producing the same intrinsic object the run path would have produced. Your lexicon’s intrinsics page has a Folds? column saying which of its intrinsics are opted in.

Sub-resource properties can use typed constructors for autocompletion and validation:

import { EncryptionConfig, AccessConfig } from "@intentius/chant-lexicon-<name>";
export const config = new EncryptionConfig({
algorithm: "AES256",
});
export const access = new AccessConfig({
publicAccess: false,
});

The ?? operator is supported for providing default values, particularly useful in composite props.

timeout: props.timeout ?? 30,

A function call is not a value chant can evaluate statically, with one closed exception: chant’s own authoring helpers. phase(), gate(), activity(), stackOutput() and output() are pure functions of their arguments, and they are the API chant documents for writing components and outputs — source using them cannot avoid a call and still use the feature. The converge rule builders are registered on the same grounds. when() and the predicate and action builders it composes each return a plain record built from their arguments, and a ConvergeOp rule table cannot be written without them.

import { phase, stackOutput, type Component } from "@intentius/chant/components";
export const web: Component = {
name: "web",
dependsOn: ["shared-foundation"],
deploy: [
phase("Apply", [
{
kind: "cfn-deploy",
stack: "web",
inputs: { pVpcId: stackOutput("shared-foundation", "oVpcId") },
},
]),
],
};

The list is a hand-written allowlist in chant’s source (packages/core/src/fold/foldable-helpers.ts), not a rule about what calls look like. Two things must both hold before one folds: the callee is a bare identifier naming a registered helper, and that name is bound by an import from chant itself. A helper of your own that happens to share a name is not chant’s, so it does not fold — the file falls back to running, exactly as any other call does. Registering a name never causes chant to substitute its own reimplementation: the function that runs is the one your import names, so a folded call and a run call are the same call.

Helpers that read the environment are deliberately excluded. env() is a function call like the others, but its result depends on CHANT_ENV rather than on its arguments, so folding it would freeze one run’s environment into the output. Use build-time parameters where you need an external value in foldable source.

A call to a function from one of your own files folds when the function’s body is itself inside the supported subset (#1373). This is the shape a parameter file takes when defaulting and validation live in one helper rather than inline at every use:

lib/target.ts
export function optionalAccountId(raw: string | undefined): string | undefined {
const trimmed = raw ?? "";
return trimmed === "" ? undefined : trimmed;
}
// params.ts
import { params } from "@intentius/chant/params";
import { optionalAccountId } from "./lib/target";
export const namingParams = {
prefix: "kmv",
accountId: optionalAccountId(params.accountId),
};

Nothing is imported or run. The arguments fold in the calling file, the parameters are bound, and the body folds in the defining file’s scope, so a module-level const or an import of lib/target.ts resolves exactly as it would have there. The callee can be a function declaration or a const bound to an arrow or function expression, exported or declared in the same file, and can call other functions of the same kind. Parameters may have defaults and may destructure an object with plain keys.

The body follows the same statement rules as an interpretable composite factory: a single expression, or const declarations followed by one return. No if, loop, throw, let/var, async, rest parameter, or early return. Inside it, three things that fold at a file’s top level do not: a new Type(...), a tagged-template intrinsic, and a registered helper or intrinsic call. Each of those produces an object that is built against the importing file’s own imports, which is not the scope the body was written in, so they are refused rather than built against the wrong file.

When a body does not fold, the calling file falls back to run, and the reason names the callee, the file and position inside it, and what stopped it:

[fold:run] params.ts — "namingParams" is not foldable: 7:14 - call to "optionalAccountId" (src/lib/target.ts)
is not foldable: src/lib/target.ts:3:17 - ambient "process" read is not foldable — declare a build-time parameter instead ...

The function is a callable, not a value. { resolver: optionalAccountId } does not fold, because nothing can serialize a function. Only project files produce a callable: a function imported from a package, chant’s own modules included, is still a call fold cannot evaluate and is invoked the way composite factories are, or falls back.

Since #1966, a method call folds when its receiver itself folds to a real value, not one of fold’s own symbolic placeholders, and the named property on that value is a function.

Since #4011, a method the JavaScript engine provides folds only when a fixed table lists it for the receiver’s type. The table keeps string methods such as slice and split and number formatting such as toFixed. For arrays it keeps only methods that take no callback, such as join. Names that reach Function or the host locale never fold on any receiver, and EVL001 flags them. constructor, call and the toLocale* methods are among them. So [].constructor.constructor("return Date.now()") falls back to run. A method written in a lexicon package (toString on a github Expression) is not in the table and folds as before. The table is provisional until the specification’s F-Eval-CallMethod rule matches it (typescript-as-data#264).

import { StorageType } from "@intentius/chant-lexicon-<name>";
const prefix = "acct";
export const store = new StorageType({
name: [prefix, "data"].join("-"),
});

This covers an array literal’s own methods ([...].join(",")), a method on a live cross-file reference (github.actor.toString()), and a method on the result of another fold (matrix("os").toString()) alike. Whatever produced the receiver, fold() only asks whether it is now a real value with that property. It does not cover a method through a package’s namespace import (see Registered intrinsic calls). It also does not make the method’s own arguments any more foldable than they’d otherwise be: list.map((n) => n.toUpperCase()) still refuses, since the arrow-function callback is a value fold rejects on its own, independent of .map being a method call.

The following patterns are caught by the evaluability lint rules (EVL). Your editor shows them as lint errors, and chant lint reports them in CI. chant build does not fail on them — a pattern below makes fold reject that one file, which then falls back to being imported and run, producing whatever JavaScript produces, correct or not. Run lint, or rely on your editor, to keep source inside the supported subset.

On the fold path (see Folded vs Run below), the same subset is what a file must stay inside to fold at all: a pattern below makes fold reject that one file and fall back to running it, logged with the reason. That fallback is not a build error either — it just means the file loses the no-execution property for this run.

// EVL001: Expression not statically evaluable
export const store = new StorageType({
name: getName(), // function call — not a literal
});

A function from a package and a function used as a value are never evaluated statically — the value would depend on running code. Three exceptions fold. Two are closed allowlists of names, checked against what your file actually imported: chant’s own registered authoring helpers, and a lexicon intrinsic whose call form its lexicon opted in. The third is open but local: a call to a function from one of your own project files whose body is itself in this subset. EVL001 still flags the call above because the lint rule sees one file at a time and cannot know what getName does; the fold decides by reading the body.

A method call (naming.name(...), list.join(",")) is not on this list. It folds on its own terms whenever its receiver is a real value and the named property is a function. What EVL001 above still flags is a call whose callee resolves to nothing chant can evaluate, whichever shape it takes.

An array method whose callback is a function value

Section titled “An array method whose callback is a function value”
const list = ["a", "b"];
export const store = new StorageType({
name: list.map((n) => n.toUpperCase()), // the callback, not the method call, is the problem
});

Fold reaches the callback argument before it looks .map up, and the callback is what it rejects. An arrow function used as a value is never foldable, whether called or not. list.join(",") above folds instead, because its argument is a plain string, not a function.

// EVL002: Resource inside control flow
if (env === "prod") {
export const store = new StorageType({...});
}

Resources must be top-level exports. For environment-specific configuration, use separate files or composites with different prop values.

// EVL003: Dynamic property access
const name = config[key];
// EVL004: Spread from non-const source
export const store = new StorageType({
...getDefaults(),
});
// Rejected at fold time: unregistered tagged template intrinsic: unknownTag`...`
export const store = new StorageType({
name: unknownTag`${prefix}-data`,
});

Not an EVL lint error. EVL has no intrinsic registry at lint time, so it treats every tagged template’s interior as opaque regardless of registration (see Intrinsic tagged templates), and this passes lint. It fails only when chant build folds the file and resolves the tag against the intrinsics the loaded lexicons actually registered. A plain, untagged template, `${prefix}-data` with no tag at all, folds like any other value in the subset; see Supported expressions. The tag is what’s unsupported here; template interpolation itself works.

  • let/var bindings — only const is statically traceable
  • Class declarations — class MyStore extends StorageType {...}
  • Computed property names — { [key]: value }
  • require() — only import statements
  • Top-level await
  • Decorators

For the full EVL rule reference with configuration options, see Evaluability Rules.

Folding is the default build path since #1134: instead of importing and running a file, chant reduces its AST directly to the resource spec, with zero module execution. A file that steps outside the fold subset falls back to being imported and run, on its own, and the evaluability rules (EVL, above) are what keep that fallback deterministic — they reject anything a running file could do besides read literals, constants, and cross-resource references, so running the supported subset has no side effects and no dependency on the environment.

Nothing changes about what you write to get folding — the supported subset above is the same subset that folds. What changes is how the build gets there:

Fold (default)Run
MechanismReduces the AST to a value — no executionImports and executes the file
What keeps it deterministicThe fold subset itself (a build-time construction)The evaluability rules (a lint-time discipline)
A pattern outside the subsetRejected for that file, which falls back to running it insteadStill runs; produces whatever the code produces
OutputResource objects — byte-identical serialized outputSame resource objects
How you get itOn by default since #1134--no-fold, build.fold: false, or a per-file fallback

Folding happens per file, not per project. A file either folds entirely or falls back to running entirely — chant never partially executes a file. chant build --verbose logs which path each file took, and why, in a real run (without the flag, the same decisions collapse to one line: fold: 1 files folded, 1 ran (--verbose for reasons)):

[fold:fold] tags.ts — 1 resource(s), no module execution
[fold:run] outputs.ts — "dataBucketArn" is not foldable: 4:37 - unresolved identifier: dataBucket

Fold covers more than a single leaf resource now:

  • Leaf resources — export const x = new Type({ ...literals, const refs, templates }) (#1021/#1026).
  • Composite factory calls — export const stack = MyStack({...}), propagate(MyStack({...}), {...}), member access on the result (web.deployment), and destructuring (export const { a, b } = MyStack({...}), or a local const destructured and re-exported by name) all fold (#1022). Two ways, tried in that order:
    • Interpreted (#1023) — when the composite is declared as export const MyStack = Composite((props) => …, "MyStack"), in one of your own project files or in the TypeScript source of a lexicon package the build loaded (#3247), and its factory body stays inside the interpretable subset, chant evaluates the body instead of calling it. The defining module is never imported, so nothing of yours runs at all — which is also what lets such a file fold under --sandbox instead of being demoted to the child.
    • Invoked — everything else: a factory whose body leaves the subset, a composite from a package that ships only compiled JavaScript, a wrapper function of your own. Chant resolves it through the file’s own imports and calls it for real with statically-folded arguments. The file still folds; the factory itself runs. Under --sandbox a project-owned callee is refused here and the file falls back to the sandboxed child.
  • Registered intrinsic tagged templates — a lexicon can register a tagged template (e.g. a string-substitution helper) as foldable; when it’s registered, an interpolation inside it folds like any other value in the subset. A tagged template whose tag isn’t registered falls outside the subset. See your lexicon’s intrinsics page for which of its intrinsics are tagged templates.
  • Registered intrinsic calls — the same, for an intrinsic authored as a plain call (Ref(...), Concat(...)), where its lexicon has opted that intrinsic in (#1044). Arguments fold by the ordinary subset rules; the real function is resolved through the file’s own imports and invoked, so the intrinsic object is the one the run path would have built. Opt-in is per intrinsic and off by default — see Registered intrinsic calls.
  • Nested constructions — a new Type(...) used as a value rather than as the file’s own top-level export folds too (#1169), whether it is written inline (image: new Image({ name: "node:22" }), a Container inside a pod spec, a Rule in a job’s rules array) or named once and referenced (const nodeImage = new Image({...}) then image: nodeImage in three jobs). The instance is built by the class your import names, so what the enclosing constructor receives is the object the run path would have handed it — the same prototype, the same toJSON, the same serialized shape. A named one is built exactly once per file, in source order, and every reference in that file reads the same object, so a resource referenced by name is the same entity chant registers.
  • Cross-file references — an imported const, or an imported resource referenced through an attribute, resolves through the module graph and folds in the defining file’s own scope, including re-export chains (#1020). Each cross-file export folds exactly once per build and every referrer shares that one instance, so a symbolic reference always points at the same entity the defining file produced. An import cycle is rejected with a located error rather than followed.
  • Project-local function calls — a call to a function declared in one of your own files (or in the file itself), when its body stays inside the subset, evaluates statically against the defining file’s scope (#1373). The defining module is never imported. A body that does not fold names the callee and the reason in the [fold:run] line. See Project-local function calls.
  • Lexicon package exports — the same resolution follows a bare import into a lexicon package, so a lexicon’s plain data exports fold as values: Azure’s and GCP’s pseudo-parameter namespaces (Azure.ResourceGroupLocation, GCP.ProjectId), AWS’s S3Actions, GitLab’s CI (#1063). Only lexicons this build actually loaded are followed, and only the package itself, never a subpath or an unrelated package — the boundary is the lexicon list your project already declares or chant already detected, not node_modules at large. The lexicon module is imported the same way the run path imports it, so what fold captures is the same object, not a copy.

What still forces a fallback to run, per file: a function call used as a value that is neither a registered authoring helper, an opted-in intrinsic, nor a project-local function with a foldable body — a package’s function, an intrinsic whose lexicon has not opted it in, a helper of your own whose body reads the environment or does I/O. Method calls are not automatically among them. Since #1966 a method call folds when its receiver is a real value and the named property is a function, so what actually blocks list.map((n) => ...) is its callback being a function used as a value, the same rule that blocks a bare function-as-value anywhere else, and not .map being a method call. One still falls back when its receiver doesn’t resolve to a real value, such as aws.Ref(...) through a lexicon’s namespace import (see Registered intrinsic calls). Also: a composite call nested as a value inside something else rather than being the file’s own top-level export, a new ns.Type(...) through a namespace import (the class has to be reachable through a named import to be constructed), a re-export, export default, let/var, and an exported class declaration.

A read whose object is null or undefined forces the same fallback (#2328). Running cfg.nett.vpcId where cfg has no nett key raises TypeError: Cannot read properties of undefined. Folding it to undefined would drop the property and let the build carry on, so the two paths would disagree about what the file means. Fold refuses it instead and the file runs, which puts the TypeError back on the line that caused it. The exception is optional chaining. cfg.net?.vpcId is defined to be undefined on a nullish object, and folds that way here too.

process.env.X and other environment-dependent references are rejected by the same mechanism, but not because of a gap left to close: an environment read is a value fold can only get by executing code and consulting whatever process happens to be running the build, which is exactly what folding is built to avoid. It’s an unresolved identifier like any other, and the file falls back to run by design — reported with a pointed message rather than the generic “unresolved identifier”, naming the alternative below.

If a project needs to vary a build (an environment name, a tier that changes which resources are even produced), the supported way is a build-time parameter (#1064), not process.env: declare it in chant.config.ts’s buildParams (type, optional default/enum/env mapping), supply it via chant build --param name=value / --params-file, and reference it in source as params.<name> (import { params } from "@intentius/chant/params"). Because the value is known at build invocation — before any file is imported or folded — params.<name> folds to a literal, exactly like a const, instead of falling back to run. Values are recorded on the build result (BuildResult.buildParams) so a build’s inputs are auditable, not read invisibly from whatever environment happened to be running it. See Build-Time Parameters.

Fold and run are also kept from ever disagreeing about a shared entity’s identity, in both directions. If file B imports file A and B falls back to run, A is forced back to run too, even if A would have folded on its own — otherwise B’s real import of A and A’s own folded copy would be two different objects. And if A is forced back to run for that reason, any file that already folded while capturing one of A’s objects (a cross-file resource reference, a resource passed to an intrinsic) is forced back too, since the instance it captured is no longer the instance the build collects.

None of this is something you opt into piecemeal or write differently for — it’s the existing supported subset, enforced by construction instead of by lint. Settable project-wide via chant.config.ts’s build.fold (default true); an explicit --fold/--no-fold flag always wins when passed.

Migrating from pre-#1134 builds: the one observable change of fold-by-default is that a folded file’s module-scope side effects (a console.log, a global registry mutation, an ambient read at import time) no longer run at build time — eliminating exactly that execution is what folding is. Files outside the subset still run, unchanged. If a build genuinely depends on module side effects, --no-fold (or build.fold: false) restores the pre-#1134 behavior wholesale, and each [fold:fold] log line under chant build --verbose tells you which files stopped executing.

A composite is interpreted rather than called when all of the following hold (#1023). Everything else keeps invoking, exactly as before — a factory outside this subset is not an error, it just runs.

  1. The calling file imports it from one of your own files (a relative or absolute specifier, or one a tsconfig paths entry maps), or from a lexicon package this build loaded, by its name or a subpath of it, when that import resolves to the package’s TypeScript source (#3247). A chant lexicon’s exports name the compiled dist/*.js it publishes, and the lexicon ships the src/*.ts each one was built from, so chant reads that source instead (#3981). import { EventsTable } from "@intentius/chant-lexicon-sql/clickhouse" qualifies. A package whose import resolves to a declaration file, or to compiled JavaScript with no source beside it, keeps invoking. Interpreting a package’s composite gains its fields’ provenance, which calling it cannot record.
  2. The module that defines it declares it as export const Name = Composite(<fn>, "Name"), with Composite imported from chant. The import may reach that module through barrels: export * from "./x", export { Name } from "./x" and export { Other as Name } from "./x" are followed, through relative specifiers only and at most eight deep. A plain helper function that returns a composite (export function SecureApi(props) { return LambdaApi({...}); }) is not a registered composite and stays on the invoking path.
  3. <fn> takes at most one parameter, bound as a plain identifier or a simple object pattern — no default, rest, or nested binding.
  4. Its body is a single expression, or const declarations followed by one return. No if, throw, loop, let/var, nested function declaration, or bare expression statement.
  5. Every expression in it is in the ordinary supported subset, extended with the two things a factory body exists for: new Type(...) in any value position (a member, a nested property object, an array element), and a call through a bare identifier (a nested composite, a registered helper, an opted-in intrinsic). A method call (naming.name(...), .map(...)) is a property-access callee and stays out. This is narrower than the top-level fold path. The admissibility check here (checkFactoryExpression in fold-import.ts) runs before anything is resolved, so it has no receiver value to test, and it rejects every property-access callee on sight instead of deciding case by case.

A body that references one of the defining module’s module-level resources also declines: that resource is a singleton the run path shares across every call, and interpretation would not.

A lexicon package’s composite and the trust boundary. Interpreting a package’s composite reads the package’s source and runs none of it that the run path would not. The boundary stays where --sandbox draws it, around project source. While chant evaluates the body, the package’s own directory (the nearest one whose package.json carries the package’s name) is trusted the way a subpath of that package already is: the CLI imported the package to load the lexicon, and any of its modules can be reached as a subpath. Within that body three things follow. A relative import inside the package resolves under --sandbox too. It is never folded as one of your project files. A plain data export it names, such as an action table, is read off the real module, so it is the run path’s own object. A relative import or re-export that leaves the package’s directory is not followed, and the composite is invoked instead. A lexicon the build did not load stays as untrusted as any other package, and the trust ends with the body: none of your own files is treated differently because a package’s composite was interpreted.

What you get is what running the factory produced. The resources are built by the lexicon’s own constructors, resolved through the defining module’s imports; a sibling reference inside the body (role.Arn) is a live attribute reference on the instance just built, not a symbolic placeholder; and the instance itself is assembled by chant’s own Composite(), so member validation, propagate() and expansion behave identically by construction rather than by resemblance.

chant build and chant build --no-fold produce byte-identical serialized output for every file that folds — same resource objects, same serialization, same lint. This is proven by a differential test across every shipped example (#1025); run it yourself with just fold-differential.

As of this measurement, 102 of 118 example projects in the corpus fold completely — every file in them reduces to a value with zero module execution. The remaining 16 have at least one file that falls back to running.

This is measured over the same corpus just fold-differential builds both ways: every tutorial under examples/*/src, plus each lexicon’s own build fixtures under lexicons/*/examples/*/src. It’s a count of chant’s own example suite, not a sample of real-world source — read it as “how much of chant’s examples fold today,” not as an estimate for any other codebase.

That corpus is also chant’s own maintained examples, and some of them are expected to be rewritten into foldable shapes over time, or dropped. Both changes move this number without the folder gaining or losing any actual capability: rewriting an example raises the count for a reason unrelated to what --fold can do, and adding or removing an example moves the denominator the same way. Treat a change here as “the corpus changed,” not as evidence either way about fold coverage of your own project.

As measured on the corpus after #1169 (per-file fallback reasons classified by their leading cause): a new Type(...) used as a value no longer blocks anything — that cause went from 64 files, the only blocker in 22 entries, to zero, and took the corpus from 55 entries to 76. Cross-file references into a lexicon package’s own exports had already gone to zero at #1063, from 116 files across 36 entries.

The largest remaining cause is an unresolved identifier: 69 files, though most of those are a chain — the identifier is unresolved because the file that defines it stopped on something else. Behind it is a function call used as a value that is neither a registered authoring helper nor an opted-in intrinsic: 41 files, and now the only blocker in 13 entries — the whole github corpus and forgejo’s, on Checkout(...); three helm examples on include/printf; gitlab/monorepo-pipeline on workspaces.map(...). Those are arbitrary JavaScript and a lexicon that has not been audited yet, in that order. Twelve more files fold in isolation and are held back only by the identity taint of a sibling that doesn’t; nine are process.env, refused on purpose.

Per lexicon, so the causes can be judged separately: k8s went from 5 of 10 to 10 of 10 and fly from 0 of 1 to 1 of 1 — both were capped entirely by nested constructions (new Container(...) inside a pod spec, new MachineConfig(...) inside a machine). GitLab went from 2 of 8 to 6 of 8, AWS from 13 of 19 to 16 of 19, and the root examples/ tutorials from 6 of 22 to 14 of 22. Azure holds at 13 of 13 and GCP at 8 of 8. GitHub stays at 0 of 8: every one of its examples calls Checkout(...), a lexicon composite whose call form nothing has opted in, so nested constructions were never what gated it. That is the third correction to what #1039 predicted — the intrinsics wiring was called “the single biggest lever” and moved one entry, the call form moved eight, lexicon package exports moved twenty-one, and constructions-as-values moved twenty-one more.

These counts are a snapshot, not refreshed by the mechanism above; they’ll drift from the corpus over time the same way the rest of this page would without #1062. Two gaps earlier snapshots named are closed: a resource constructor whose first argument isn’t the props object literal (#1082 — constructor arguments fold positionally, so the AWS Parameter’s new Parameter("String", {...}) folds like any other), and the nested construction above (#1169).

Folded files execute none of their own code, but a run-fallback file does — in the CLI’s own process by default. chant build --sandbox (#1045) runs every run-fallback file for a build together, isolated, in one child process with the filesystem, process-spawn, and environment access locked down, instead of trusting the file to behave. It composes with folding, leaving folded files alone (there is nothing to isolate) and sandboxing the run-fallback remainder — see Sandboxed Execution for exactly what that does and does not protect against.

Three counters cap the recursion the fold path can do instead of letting it run away. Here is what the specification’s F-Depth judgment says about them (J4 in spec/judgments.md).

An implementation bounds three recursions and may choose the values.

The rule also requires the values to be stated, and requires that exhausting one produce a fallback to the run path rather than a failure or a silent change of evaluation mode. The table below is chant’s statement of its three.

The values are chant’s to choose. F-Eval-CallLocal step 2 says so outright: “The value is the implementation’s; exhaustion is a fallback and never wrong output”. The numbers below are chant’s stated choice, which the rule requires it to make.

BoundValueGuardsWhere
MAX_FUNCTION_CALL_DEPTH32nested project-local function callspackages/core/src/fold/fold.ts
MAX_INTERPRETATION_DEPTH16nested interpretable composite factory bodiespackages/core/src/discovery/fold-import.ts
MAX_RESOLUTION_DEPTH200the cross-file resolution stackpackages/core/src/discovery/fold-import.ts

MAX_FUNCTION_CALL_DEPTH and MAX_RESOLUTION_DEPTH exhaustion both name themselves. A project-local call past 32 deep throws from inside fold() with a reason naming the bound (call depth exceeded — is it recursive?), forcing the calling file to run, exactly like the [fold:run] example under Project-local function calls above. A cross-file resolution past 200 deep is caught at the same stack that also detects an import cycle, with its own pointed reason (cross-file resolution depth exceeded 200 (...) — this is almost certainly a resolution bug (memoization not taking effect), not a genuinely thousand-file-deep import chain). This forces the target file to run and, per Folded vs Run above, cascades to any file that folded while depending on it.

MAX_INTERPRETATION_DEPTH guards a composite whose factory body calls itself, directly or through a member, entirely within one module. That is a shape no cross-file stack can see, so a separate counter exists for it. Past 16 levels chant stops trying to interpret and the file falls back to run with a reason naming the bound, the same as the other two. It did not always. Until chant #2370 it declined to interpret and invoked the composite for real instead, which still folds the file, so the bound changed how the estate was evaluated while reporting it as folded, and nothing said so. That was the one place F-Depth’s second half was unmet, since giving up is not the same fact as a body sitting outside the interpretable subset, and the two were sharing one answer. A composite genuinely nested this deep is not expected to occur. Sixteen is, in the implementation’s own words, “deliberately small: real composite nesting is 2-3 deep, and a chain past this is a bug, not a design.”

The supported subset is the set of TypeScript patterns whose value is fully determined by literals, known constants, and symbolic cross-resource references. chant build folds source inside that subset and imports and runs whatever falls outside it. The evaluability rules reject patterns that would make the result depend on anything else, so running the supported subset has no side effects either.

This makes synthesis fast, deterministic for conforming source (same source, same output), and auditable (every output value traces to a specific line in the source).

The static boundary also defines a natural division of labor with agentic workflows: agents resolve dynamic values (looking up VPC IDs, reading secrets, querying state), then write or populate chant source files. chant synthesizes the deterministic structure. Agents handle what changes; chant handles what shouldn’t.

For where each kind of value belongs — static data resolved at synthesis, a deploy-time input resolved at apply, or a lookup synthesis refuses — see Where Values Come From.

chant