@intentius/chant 0.72.1 → 0.72.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli/handlers/operator.d.ts +16 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +6 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/discovery/fold-import.d.ts +24 -1
  9. package/dist/discovery/fold-import.d.ts.map +1 -1
  10. package/dist/fold/fold.d.ts +15 -1
  11. package/dist/fold/fold.d.ts.map +1 -1
  12. package/dist/fold/subset.d.ts +38 -1
  13. package/dist/fold/subset.d.ts.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/gate-origin.d.ts +77 -0
  19. package/dist/lifecycle/gate-origin.d.ts.map +1 -0
  20. package/package.json +1 -1
  21. package/src/audit/core.ts +1 -1
  22. package/src/cli/handlers/operator.ts +48 -1
  23. package/src/cli/main.ts +4 -0
  24. package/src/cli/mcp/op-approve-origin.test.ts +70 -0
  25. package/src/cli/mcp/op-tools.ts +18 -4
  26. package/src/cli/mcp/server.ts +11 -0
  27. package/src/cli/registry.ts +6 -0
  28. package/src/discovery/fold-counters-public.test.ts +67 -0
  29. package/src/discovery/fold-div-depth.test.ts +104 -0
  30. package/src/discovery/fold-executing-mode.test.ts +115 -0
  31. package/src/discovery/fold-import.ts +88 -5
  32. package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
  33. package/src/fold/fold.ts +23 -4
  34. package/src/fold/subset.ts +38 -1
  35. package/src/graph-ir.ts +1 -1
  36. package/src/graph-layout.ts +0 -0
  37. package/src/index.ts +10 -0
  38. package/src/lifecycle/gate-ledger.ts +39 -1
  39. package/src/lifecycle/gate-origin.test.ts +93 -0
  40. package/src/lifecycle/gate-origin.ts +113 -0
  41. package/src/meta/source-is-text.test.ts +72 -0
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect, afterEach } from "vitest";
2
+ import { createOpApproveTool } from "./op-tools";
3
+ import { McpServer } from "./server";
4
+ import { readFileSync } from "node:fs";
5
+ import { resetGateOrigin, currentGateOrigin } from "../../lifecycle/gate-origin";
6
+
7
+ /**
8
+ * chant#2384 — the MCP surface cannot both produce a gate and resolve it.
9
+ *
10
+ * `op-run` executes the Op in-process and returns the gate it stopped on;
11
+ * `op-approve` then wrote the resolution with the approver taken from the
12
+ * request. So the agent that produced a pending gate resolved it under any
13
+ * name it liked, and #2300's plan binding did not close it: the digest comes
14
+ * off the standing pending fact, which is the plan the same caller produced one
15
+ * tool call earlier. The approval was for the right plan and meant nothing.
16
+ *
17
+ * These assert the two halves of the fix that are visible without a ledger: the
18
+ * tool no longer offers a name it cannot verify, and constructing the server
19
+ * declares the channel. The rule itself is exercised in
20
+ * `../../lifecycle/gate-origin.test.ts`.
21
+ */
22
+ describe("op-approve on the MCP channel (chant#2384)", () => {
23
+ afterEach(() => resetGateOrigin());
24
+
25
+ test("the tool no longer accepts a free-text approver", () => {
26
+ const schema = createOpApproveTool().definition.inputSchema as {
27
+ properties: Record<string, unknown>;
28
+ required: string[];
29
+ };
30
+
31
+ // The hole, stated as a test: `approver` was free text on a channel that
32
+ // cannot verify one, and the ledger recorded it indistinguishably from a
33
+ // name a person gave. Recording "unattested" is more honest than recording
34
+ // a name the model chose.
35
+ expect(Object.keys(schema.properties)).not.toContain("approver");
36
+
37
+ // The rest of the tool is unchanged — this narrows one field, it does not
38
+ // remove the tool. The narrower alternative in the issue was to drop
39
+ // op-approve entirely; this keeps it useful for the cross-channel case.
40
+ expect(Object.keys(schema.properties).sort()).toEqual(["gate", "name", "note", "runtime", "url"]);
41
+ expect(schema.required.sort()).toEqual(["gate", "name"]);
42
+ });
43
+
44
+ test("the description tells the caller where an approval has to come from", () => {
45
+ // A model reads this string and nothing else. If it does not say that the
46
+ // gate must be approved elsewhere, the model's only signal is a refusal it
47
+ // cannot act on.
48
+ const description = createOpApproveTool().definition.description;
49
+ expect(description).toMatch(/refuses a gate this same channel reached/i);
50
+ expect(description).toContain("chant approve");
51
+ expect(description).toMatch(/unattested/i);
52
+ });
53
+
54
+ test("merely constructing a server does not change what the process is", () => {
55
+ // Deliberate: the channel is declared in `start()`, not the constructor.
56
+ // Building a server object in a test must not make every later gate fact in
57
+ // that worker read as model-authored — which is exactly the contamination
58
+ // the first version of this caused.
59
+ new McpServer();
60
+ expect(currentGateOrigin()).toBe("cli");
61
+ });
62
+
63
+ test("op-approve names its own channel, so it holds even on a server that never started", () => {
64
+ // The ambient value covers the pending fact written during `op-run`. The
65
+ // resolution does not rely on it: the tool passes `origin: "mcp"` outright,
66
+ // so the rule holds regardless of what the process thinks it is.
67
+ const source = readFileSync(new URL("./op-tools.ts", import.meta.url), "utf8");
68
+ expect(source).toContain('origin: "mcp"');
69
+ });
70
+ });
@@ -181,13 +181,18 @@ export function createOpApproveTool(): ToolRegistration {
181
181
  definition: {
182
182
  name: "op-approve",
183
183
  description:
184
- "Record a gate's resolution on the gate ledger and wake the runtime hosting the gated run. The rename of op-signal: a gate is resolved by recording the fact, not by sending a message.",
184
+ "Record a gate's resolution on the gate ledger and wake the runtime hosting the gated run. " +
185
+ "Refuses a gate this same channel reached: a run started with op-run must be approved from " +
186
+ "somewhere else, normally `chant approve <op> <gate>` at a shell. The approver is recorded as " +
187
+ "unattested, because this channel cannot verify a name.",
185
188
  inputSchema: {
186
189
  type: "object",
187
190
  properties: {
188
191
  name: { type: "string", description: "Op name (e.g. alb-deploy)" },
189
192
  gate: { type: "string", description: "Gate name (e.g. gate-dns-delegation)" },
190
- approver: { type: "string", description: "Who approved; defaults to the CI or shell identity" },
193
+ // chant#2384 — no `approver`. It was free text on a channel that
194
+ // cannot verify one, so the model named itself whatever it liked and
195
+ // the ledger recorded it indistinguishably from a name a person gave.
191
196
  note: { type: "string", description: "Free-text context recorded on the resolution" },
192
197
  url: { type: "string", description: "Absolute http/https URL this resolution happened at" },
193
198
  runtime: RUNTIME_PARAM,
@@ -202,11 +207,19 @@ export function createOpApproveTool(): ToolRegistration {
202
207
 
203
208
  const runtime = await runtimeFor(params.runtime);
204
209
  const outcome = await recordGateApproval(name, gate, {
205
- actor: params.approver as string | undefined,
206
210
  note: params.note as string | undefined,
207
211
  url: params.url as string | undefined,
212
+ // Named rather than left to the ambient value, so this tool states its
213
+ // own channel even if it is ever registered on a server that did not.
214
+ origin: "mcp",
208
215
  });
209
- if (!outcome.ok) throw new Error(`Gate "${gate}" on "${name}" was not recorded`);
216
+ if (!outcome.ok) {
217
+ throw new Error(
218
+ `Gate "${gate}" on "${name}" was not recorded. If the gate was reached over MCP, it cannot ` +
219
+ `also be resolved over MCP — approve it from another channel, normally ` +
220
+ `\`chant approve ${name} ${gate}\` at a shell.`,
221
+ );
222
+ }
210
223
 
211
224
  if (runtime.resolveGate) await runtime.resolveGate(name, gate, outcome.record);
212
225
 
@@ -214,6 +227,7 @@ export function createOpApproveTool(): ToolRegistration {
214
227
  op: name,
215
228
  gate,
216
229
  resolvedBy: outcome.record.resolvedBy,
230
+ origin: outcome.record.origin,
217
231
  timestamp: outcome.record.timestamp,
218
232
  runtimeNotified: Boolean(runtime.resolveGate),
219
233
  };
@@ -9,6 +9,7 @@ import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import type { LexiconPlugin } from "../../lexicon";
10
10
  import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
11
  import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
12
+ import { setGateOrigin } from "../../lifecycle/gate-origin";
12
13
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
13
14
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
14
15
 
@@ -302,6 +303,16 @@ export class McpServer {
302
303
  * Start the MCP server on stdio
303
304
  */
304
305
  async start(): Promise<void> {
306
+ // chant#2384 — declared here rather than in the constructor, because this
307
+ // is where the PROCESS becomes an MCP server. Constructing the object does
308
+ // not change what the process is, and a test that builds one should not
309
+ // silently make every later gate fact in that worker read as model-authored.
310
+ //
311
+ // From here on a person's only act was launching this; every call is the
312
+ // model's. So every gate fact written records the channel it came through,
313
+ // and a gate reached here cannot also be resolved here.
314
+ setGateOrigin("mcp");
315
+
305
316
  const rl = createInterface({
306
317
  input: process.stdin,
307
318
  output: process.stdout,
@@ -341,6 +341,12 @@ export interface ParsedArgs {
341
341
  note?: string;
342
342
  /** `chant approve <op> <gate> --expire` (#2119) — clear the gate's standing pending fact instead of approving it, so the next run decides the gate from scratch and records a fresh one. Writes no resolution: nothing is approved, the wait is only restarted. */
343
343
  expire?: boolean;
344
+ /**
345
+ * `chant approve --allow-same-origin` (chant#2384) — record a resolution
346
+ * from the same channel that reached the gate, which is refused by default
347
+ * on a model-authored channel.
348
+ */
349
+ allowSameOrigin?: boolean;
344
350
  /** `chant approve <op> <gate> --plan <digest>` (#2300) — the plan this approval is for, as `sha256:<64 hex>`. Omitted, the digest is taken from the gate's standing pending fact, which is the plan the run that stopped at the gate actually produced; pass it to approve a plan explicitly, or to approve one before any run has recorded a pending fact. */
345
351
  plan?: string;
346
352
  /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
@@ -0,0 +1,67 @@
1
+ import { describe, test, expect, beforeEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ foldProject,
7
+ foldExecutionCounts,
8
+ resetFoldExecutionCounts,
9
+ type FoldExecutionCounts,
10
+ } from "../index";
11
+
12
+ /**
13
+ * chant#2446 — F-Obs-Counters, reachable from the public entry.
14
+ *
15
+ * Both functions were already exported from `discovery/fold-import`, but the
16
+ * public entry re-exports that module by name rather than wholesale, so a
17
+ * consumer importing `@intentius/chant` could not reach them. The
18
+ * specification's harness reports the three integers per build
19
+ * (INTENTIUS/typescript-as-data#121) and had to hold the fixture out for that
20
+ * reason alone.
21
+ *
22
+ * This is a gate against the export being dropped, not a test of the counting
23
+ * itself, which `fold-import.test.ts` already covers. It imports from `../index`
24
+ * on purpose: importing from the module would still pass with the entry broken,
25
+ * which is exactly the failure it exists to catch.
26
+ */
27
+ describe("the fold execution counters are on the public entry (chant#2446)", () => {
28
+ let root: string;
29
+
30
+ beforeEach(() => {
31
+ root = mkdtempSync(join(tmpdir(), "chant-counters-"));
32
+ resetFoldExecutionCounts();
33
+ });
34
+
35
+ test("both functions are exported and the snapshot has F-Obs-Counters' three integers", () => {
36
+ const counts = foldExecutionCounts();
37
+ expect(Object.keys(counts).sort()).toEqual([
38
+ "factoryInterpretations",
39
+ "factoryInvocations",
40
+ "projectFactoryInvocations",
41
+ ]);
42
+ for (const v of Object.values(counts)) expect(typeof v).toBe("number");
43
+ });
44
+
45
+ test("reset zeroes them, which is what makes a per-build figure possible", async () => {
46
+ const file = join(root, "app.ts");
47
+ writeFileSync(file, 'export const n = 1 + 1;\n');
48
+ await foldProject([file], []);
49
+
50
+ resetFoldExecutionCounts();
51
+
52
+ // The counters are process-wide and monotonic, so "per build" means
53
+ // zeroing first. A caller that could not reset would read this process's
54
+ // whole history and call it one build.
55
+ expect(foldExecutionCounts()).toEqual<FoldExecutionCounts>({
56
+ factoryInvocations: 0,
57
+ projectFactoryInvocations: 0,
58
+ factoryInterpretations: 0,
59
+ });
60
+ });
61
+
62
+ test("a snapshot is a copy, so a caller cannot move the counters by holding one", () => {
63
+ const snapshot = foldExecutionCounts() as FoldExecutionCounts;
64
+ snapshot.factoryInvocations = 9999;
65
+ expect(foldExecutionCounts().factoryInvocations).not.toBe(9999);
66
+ });
67
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { foldProject } from "../index";
6
+ import type { IntrinsicDef } from "../lexicon";
7
+
8
+ /**
9
+ * chant#2441 — `F-Div-Depth` is a refusal, and a refusal must not be
10
+ * papered over by invoking instead.
11
+ *
12
+ * `divergence.md`'s L3.16 says a `new`, tagged template, helper call,
13
+ * intrinsic call or `.step` is not foldable inside a folded function body.
14
+ * `fold()` has always guarded this, and the guard fires: `callFoldableFunction`
15
+ * raises `functionBodyDepth` and `insideFunctionBody` throws.
16
+ *
17
+ * What went wrong was downstream. `resolveCallExpression` catches a fold
18
+ * failure on an imported callee and falls back to importing and invoking it —
19
+ * right when the body merely did not fold, wrong here, because the folder has
20
+ * DECLINED and invoking produces the envelope the fixture's own note warns
21
+ * about: a value the file's own declarators never produced.
22
+ *
23
+ * That direction is the whole point. Every other `F-Div` row is a fallback and
24
+ * `divergence.md` says so outright, which is what makes the direction safe.
25
+ * This one was not: chant produced a value where the specification says it must
26
+ * decline, the one outcome the fold path exists never to have.
27
+ */
28
+ describe("a refusal at depth is not rescued by invoking (chant#2441)", () => {
29
+ let root: string;
30
+ const ref: IntrinsicDef = { name: "ref", lexicon: "shapes", foldsAsCall: true } as unknown as IntrinsicDef;
31
+
32
+ beforeEach(() => {
33
+ root = mkdtempSync(join(tmpdir(), "chant-div-depth-"));
34
+ const pkg = join(root, "node_modules", "@tsad", "shapes");
35
+ mkdirSync(pkg, { recursive: true });
36
+ writeFileSync(
37
+ join(pkg, "package.json"),
38
+ JSON.stringify({ name: "@tsad/shapes", version: "0.0.0", type: "module", main: "index.js" }),
39
+ );
40
+ writeFileSync(join(pkg, "index.js"), 'export function ref(x) { return { Ref: x }; }\n');
41
+ });
42
+
43
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
44
+
45
+ const write = (name: string, source: string): string => {
46
+ const p = join(root, name);
47
+ writeFileSync(p, source);
48
+ return p;
49
+ };
50
+
51
+ test("an intrinsic call inside a project-local function refuses, rather than folding to a revived envelope", async () => {
52
+ const fn = write("fn.ts", 'import { ref } from "@tsad/shapes";\nexport function r() { return ref("x"); }\n');
53
+ const app = write("app.ts", 'import { r } from "./fn";\nexport const v = r();\n');
54
+
55
+ const verdicts = await foldProject([app, fn], [ref], { lexiconPackages: ["@tsad/shapes"] });
56
+
57
+ // Before the fix this was `fold` with v = {"Ref":"x"} — the host's `ref`
58
+ // actually invoked, its result revived, and the envelope surfacing in a
59
+ // file whose own declarators never produced one.
60
+ expect(verdicts.get(app)!.verdict).toBe("run");
61
+ expect(verdicts.get(app)!.reason).toMatch(/inside a folded function body is not foldable/);
62
+ });
63
+
64
+ test("the reason names the callee and the position inside it, not just the call site", async () => {
65
+ const fn = write("fn.ts", 'import { ref } from "@tsad/shapes";\nexport function r() { return ref("x"); }\n');
66
+ const app = write("app.ts", 'import { r } from "./fn";\nexport const v = r();\n');
67
+
68
+ const reason = (await foldProject([app, fn], [ref], { lexiconPackages: ["@tsad/shapes"] })).get(app)!.reason!;
69
+
70
+ // The point of the re-anchoring in `callFoldableFunction`: the `[fold:run]`
71
+ // line says which function to fix and where, not merely that this call
72
+ // failed.
73
+ expect(reason).toContain('call to "r"');
74
+ expect(reason).toContain("fn.ts");
75
+ expect(reason).toContain("intrinsic call `ref(...)`");
76
+ });
77
+
78
+ test("the same intrinsic call at the top level still folds, so the guard is about depth and nothing else", async () => {
79
+ // The refusal must not spread to the direct case, which is the ordinary way
80
+ // an intrinsic reaches a declarator and is exactly what should keep working.
81
+ const app = write("app.ts", 'import { ref } from "@tsad/shapes";\nexport const v = ref("x");\n');
82
+
83
+ const verdict = (await foldProject([app], [ref], { lexiconPackages: ["@tsad/shapes"] })).get(app)!;
84
+
85
+ expect(verdict.verdict).toBe("fold");
86
+ expect(JSON.parse(JSON.stringify(Object.fromEntries(verdict.exports!)))).toEqual({ v: { Ref: "x" } });
87
+ });
88
+
89
+ test("an ordinary fold failure in an imported callee still falls back to invoking it", async () => {
90
+ // The fallback that #2441 narrowed is otherwise load-bearing: a helper whose
91
+ // body simply does not fold keeps folding by invocation, as it did before.
92
+ // Narrowing it to depth refusals only is the whole of the change.
93
+ const fn = write(
94
+ "fn.ts",
95
+ 'export function r() {\n const parts = ["a", "b"];\n return { joined: parts.join("-") };\n}\n',
96
+ );
97
+ const app = write("app.ts", 'import { r } from "./fn";\nexport const v = r();\n');
98
+
99
+ const verdict = (await foldProject([app, fn], [], {})).get(app)!;
100
+
101
+ expect(verdict.verdict).toBe("fold");
102
+ expect(JSON.parse(JSON.stringify(Object.fromEntries(verdict.exports!)))).toEqual({ v: { joined: "a-b" } });
103
+ });
104
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { foldProject, foldExecutionCounts, resetFoldExecutionCounts } from "../index";
6
+
7
+ /**
8
+ * chant#2455 — `ι = executing`, the third isolation mode (spec `1.8`).
9
+ *
10
+ * chant#2453 made the default strict: a declarator call to a declared project
11
+ * function whose body cannot fold refuses the file rather than importing and
12
+ * invoking it. That closed a real leak — the fold of a file reading
13
+ * `process.env` carried the folding shell's environment, under a `fold`
14
+ * verdict that read as "determined statically".
15
+ *
16
+ * The old behaviour is worth having when the caller means it, so it comes back
17
+ * as a mode. What it cannot be is implicit. `open` is the default and is
18
+ * strict; a build that wants what a run would compute has to ask, and the
19
+ * asking is what makes the environment dependence visible.
20
+ */
21
+ describe("the executing isolation mode (chant#2455)", () => {
22
+ let root: string;
23
+ let app: string;
24
+ let params: string;
25
+
26
+ beforeEach(() => {
27
+ root = mkdtempSync(join(tmpdir(), "chant-executing-"));
28
+ params = join(root, "params.ts");
29
+ app = join(root, "app.ts");
30
+ writeFileSync(
31
+ params,
32
+ 'export function fromEnv() {\n return { prefix: process.env.CHANT_TEST_MODE ?? "fallback" };\n}\n',
33
+ );
34
+ writeFileSync(app, 'import { fromEnv } from "./params";\nexport const v = fromEnv();\n');
35
+ resetFoldExecutionCounts();
36
+ process.env.CHANT_TEST_MODE = "from-the-environment";
37
+ });
38
+
39
+ afterEach(() => {
40
+ delete process.env.CHANT_TEST_MODE;
41
+ rmSync(root, { recursive: true, force: true });
42
+ });
43
+
44
+ test("the default refuses, and nothing of the project runs", async () => {
45
+ const verdict = (await foldProject([app, params], [], {})).get(app)!;
46
+
47
+ expect(verdict.verdict).toBe("run");
48
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
49
+ });
50
+
51
+ test("executing invokes it, and the fold carries what a run would compute", async () => {
52
+ const verdict = (await foldProject([app, params], [], { executing: true })).get(app)!;
53
+
54
+ expect(verdict.verdict).toBe("fold");
55
+ expect(JSON.parse(JSON.stringify(Object.fromEntries(verdict.exports!)))).toEqual({
56
+ v: { prefix: "from-the-environment" },
57
+ });
58
+ // The counter is how the mode is observable from outside: this is a
59
+ // project-owned invocation, which is what F-Call step 6 counts.
60
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(1);
61
+ });
62
+
63
+ test("the environment dependence is real, which is why the mode has to be asked for", async () => {
64
+ process.env.CHANT_TEST_MODE = "one";
65
+ const first = (await foldProject([app, params], [], { executing: true })).get(app)!;
66
+ process.env.CHANT_TEST_MODE = "two";
67
+ const second = (await foldProject([app, params], [], { executing: true })).get(app)!;
68
+
69
+ // Under `executing` the same source folds to different values in different
70
+ // environments. That is the mode behaving as specified, not a defect — and
71
+ // it is exactly what must not happen by default.
72
+ expect(JSON.stringify([...first.exports!])).not.toBe(JSON.stringify([...second.exports!]));
73
+ });
74
+
75
+ test("sandbox still refuses, and is unchanged by the new mode", async () => {
76
+ const verdict = (await foldProject([app, params], [], { sandbox: true })).get(app)!;
77
+
78
+ expect(verdict.verdict).toBe("run");
79
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
80
+ });
81
+
82
+ test("asking for both sandbox and executing is refused rather than resolved", async () => {
83
+ // Not a preference between two readings — a contradiction. One refuses to
84
+ // import project code, the other exists to invoke it, and silently picking
85
+ // either would make the fold's meaning depend on which.
86
+ await expect(foldProject([app, params], [], { sandbox: true, executing: true })).rejects.toThrow(
87
+ /mutually exclusive/,
88
+ );
89
+ });
90
+
91
+ test("the default's reason names the mode that would have folded it", async () => {
92
+ const reason = (await foldProject([app, params], [], {})).get(app)!.reason!;
93
+
94
+ // The body's own diagnostic is kept — it is the actionable half, and here
95
+ // it says to use a build parameter rather than reach for the new mode.
96
+ expect(reason).toContain('ambient "process" read is not foldable');
97
+ expect(reason).toContain("executing");
98
+ });
99
+
100
+ test("a function whose body folds is untouched by the mode", async () => {
101
+ const helper = join(root, "helper.ts");
102
+ writeFileSync(helper, 'export function j() {\n return { j: ["a", "b"].join("-") };\n}\n');
103
+ const caller = join(root, "caller.ts");
104
+ writeFileSync(caller, 'import { j } from "./helper";\nexport const v = j();\n');
105
+
106
+ for (const options of [{}, { executing: true }]) {
107
+ resetFoldExecutionCounts();
108
+ const verdict = (await foldProject([caller, helper], [], options)).get(caller)!;
109
+ expect(verdict.verdict).toBe("fold");
110
+ // Interpreted in both modes: `executing` is a fallback for a body that
111
+ // did NOT fold, not a shortcut past interpretation.
112
+ expect(foldExecutionCounts().projectFactoryInvocations).toBe(0);
113
+ }
114
+ });
115
+ });
@@ -261,6 +261,8 @@ export interface FoldSession {
261
261
  * only the fold half would buy nothing there.
262
262
  */
263
263
  readonly sandbox: boolean;
264
+ /** chant#2455 — `ι = executing`: invoke a declared project function whose body did not fold. */
265
+ readonly executing: boolean;
264
266
  /**
265
267
  * chant #1023 — per-build memo for {@link readFactoryModule}, keyed by the
266
268
  * resolved absolute path of a module that DEFINES a composite. A composite
@@ -322,6 +324,8 @@ export function createFoldSession(
322
324
  * named as a specifier rather than only as a lexicon.
323
325
  */
324
326
  lexiconPackages: readonly string[] = [],
327
+ /** chant#2455 — `ι = executing`. Mutually exclusive with `sandbox`. */
328
+ executing = false,
325
329
  ): FoldSession {
326
330
  return {
327
331
  intrinsics,
@@ -332,6 +336,7 @@ export function createFoldSession(
332
336
  buildParams,
333
337
  lexiconPackages: new Set([...lexicons.map(lexiconPackageName), ...lexiconPackages]),
334
338
  sandbox,
339
+ executing,
335
340
  factoryModules: new Map(),
336
341
  };
337
342
  }
@@ -1275,6 +1280,8 @@ interface ResolveCtx {
1275
1280
  lexiconPackages: ReadonlySet<string>;
1276
1281
  /** chant #1093 — see {@link FoldSession.sandbox}. */
1277
1282
  sandbox: boolean;
1283
+ /** chant#2455 — see {@link FoldSession.executing}. */
1284
+ executing: boolean;
1278
1285
  /**
1279
1286
  * chant #1023 — the whole build session, for the two things composite-factory
1280
1287
  * interpretation needs that a per-file context cannot carry: the
@@ -1458,14 +1465,57 @@ async function resolveCallExpression(node: ts.CallExpression, ctx: ResolveCtx):
1458
1465
  if (!(err instanceof Error)) throw err;
1459
1466
  foldFailure = err;
1460
1467
  }
1461
- if (!binding) throw foldFailure;
1462
- try {
1463
- return await resolveImportedCall(node, calleeName, binding, ctx);
1464
- } catch (err) {
1468
+ // chant#2453 — a DECLARED function is judged by its body, and that is the
1469
+ // whole answer. There is no invocation arm here any more.
1470
+ //
1471
+ // There used to be: when the body did not fold, an imported callee was
1472
+ // imported and invoked instead. chant#2441 narrowed that to stop it
1473
+ // rescuing a depth refusal. The external corpus then showed what the rest
1474
+ // of it does, in a project nobody here maintains
1475
+ // (jhgaylor/infisical-chant, INTENTIUS/typescript-as-data#129):
1476
+ //
1477
+ // export const namingParams = namingParamsFromEnv();
1478
+ //
1479
+ // whose body reads `process.env`. `F-Eval-Ident` step 4 rejects `process`,
1480
+ // so the body does not fold — and chant imported the module and ran it,
1481
+ // folding the file to whatever the FOLDING PROCESS's environment held. The
1482
+ // file reported `fold`, which reads as "determined statically". Two people
1483
+ // folding the same source got different output and nothing said so.
1484
+ //
1485
+ // That is wrong in chant's own terms before it is wrong in J1's: `--fold`
1486
+ // is the value you would get by running, WITHOUT running, and this ran. So
1487
+ // the rejection propagates and the file runs, carrying the reason
1488
+ // `F-Eval-CallLocal` gives.
1489
+ //
1490
+ // `F-Call` step 6's invocation is for a binding that is neither a declared
1491
+ // function nor an interpretable composite, and it still happens — below,
1492
+ // in the arm this one is not. A composite factory is not a
1493
+ // `FoldableFunction`, so `resolveImportedCall` still interprets it and
1494
+ // still invokes it when interpretation declines.
1495
+ //
1496
+ // chant#2455 — unless the caller asked for `ι = executing` (spec 1.8),
1497
+ // which is this behaviour as a MODE rather than as a silent default. The
1498
+ // value is then what a run would compute in this process, environment and
1499
+ // all, which is exactly why it cannot be implicit.
1500
+ if (ctx.executing && binding) {
1501
+ try {
1502
+ return await resolveImportedCall(node, calleeName, binding, ctx);
1503
+ } catch (err) {
1504
+ throw cheapError(
1505
+ `${foldFailure.message}; invoking it instead failed: ${err instanceof Error ? err.message : String(err)}`,
1506
+ );
1507
+ }
1508
+ }
1509
+ // Name the mode that would have folded it, so the reason is actionable
1510
+ // rather than only correct. Not under `sandbox`, where `executing` is not
1511
+ // an option the caller could take.
1512
+ if (binding && !ctx.sandbox) {
1465
1513
  throw cheapError(
1466
- `${foldFailure.message}; invoking it instead failed: ${err instanceof Error ? err.message : String(err)}`,
1514
+ `${foldFailure.message} (the default mode does not invoke a declared project function; ` +
1515
+ "`executing` would run it and fold what it returns)",
1467
1516
  );
1468
1517
  }
1518
+ throw foldFailure;
1469
1519
  }
1470
1520
 
1471
1521
  if (!binding) {
@@ -2346,6 +2396,7 @@ async function interpretCompositeFactory(
2346
2396
  resolvePathCache: ctx.resolvePathCache,
2347
2397
  lexiconPackages: ctx.lexiconPackages,
2348
2398
  sandbox: ctx.sandbox,
2399
+ executing: ctx.executing,
2349
2400
  session: ctx.session,
2350
2401
  interpretDepth: ctx.interpretDepth + 1,
2351
2402
  // chant #2161 — set unconditionally, and NOT inherited from `ctx`: a nested
@@ -3658,6 +3709,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
3658
3709
  resolvePathCache: session.resolvePathCache,
3659
3710
  lexiconPackages: session.lexiconPackages,
3660
3711
  sandbox: session.sandbox,
3712
+ executing: session.executing,
3661
3713
  session,
3662
3714
  interpretDepth: 0,
3663
3715
  // chant#2423 — filled by the pre-build below, read by
@@ -4092,6 +4144,25 @@ export interface FoldProjectOptions {
4092
4144
  readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
4093
4145
  /** chant #1093: this build asked for the sandbox, so fold may not reach outside the trusted allowlist. */
4094
4146
  readonly sandbox?: boolean;
4147
+ /**
4148
+ * chant#2455 — opt in to `ι = executing`, the third isolation mode
4149
+ * (typescript-as-data spec `1.8`).
4150
+ *
4151
+ * Under it, a declarator call to a declared project function whose body
4152
+ * cannot fold continues at `F-Call` step 6 and is invoked, so the fold's
4153
+ * value is what a run would compute **in the folding process's
4154
+ * environment**. That is the behaviour chant had before #2453, where it was
4155
+ * the silent default and leaked the folding shell's `process.env` into the
4156
+ * output of a file reported as `fold`.
4157
+ *
4158
+ * It is here as a mode rather than gone, because the value is real when the
4159
+ * caller means it. What it cannot be is implicit: `open` is the default and
4160
+ * is strict, so a build that wants a run's answer has to say so.
4161
+ *
4162
+ * Mutually exclusive with {@link sandbox}, which refuses to import project
4163
+ * code at all. Asking for both is a contradiction and refuses.
4164
+ */
4165
+ readonly executing?: boolean;
4095
4166
  /**
4096
4167
  * chant#2438 — package specifiers to follow a bare import into, verbatim,
4097
4168
  * alongside whatever `lexicons` names.
@@ -4148,12 +4219,24 @@ export async function foldProject(
4148
4219
  intrinsics: readonly IntrinsicDef[] = [],
4149
4220
  options: FoldProjectOptions = {},
4150
4221
  ): Promise<Map<string, FoldProjectVerdict>> {
4222
+ // chant#2455 — `sandbox` refuses to import project code at all and
4223
+ // `executing` exists to invoke it. Asking for both is not a preference
4224
+ // between two readings, it is a contradiction, and silently picking one would
4225
+ // make the fold's meaning depend on which.
4226
+ if (options.sandbox === true && options.executing === true) {
4227
+ throw new Error(
4228
+ "foldProject: `sandbox` and `executing` are mutually exclusive — the first refuses to import " +
4229
+ "project code and the second exists to invoke it. Pass one.",
4230
+ );
4231
+ }
4232
+
4151
4233
  const session = createFoldSession(
4152
4234
  intrinsics,
4153
4235
  options.buildParams,
4154
4236
  options.lexicons ?? [],
4155
4237
  options.sandbox ?? false,
4156
4238
  options.lexiconPackages ?? [],
4239
+ options.executing ?? false,
4157
4240
  );
4158
4241
  const attempts = new Map<string, FoldFileResult>();
4159
4242
  for (const file of files) attempts.set(file, await tryFoldFile(file, intrinsics, session));