@intentius/chant 0.72.2 → 0.72.4

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 (56) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/init.d.ts +1 -1
  3. package/dist/cli/commands/init.d.ts.map +1 -1
  4. package/dist/cli/commands/update.d.ts.map +1 -1
  5. package/dist/cli/handlers/init.d.ts.map +1 -1
  6. package/dist/cli/handlers/operator.d.ts +16 -0
  7. package/dist/cli/handlers/operator.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  10. package/dist/cli/mcp/server.d.ts +10 -0
  11. package/dist/cli/mcp/server.d.ts.map +1 -1
  12. package/dist/cli/mcp-config.d.ts +47 -0
  13. package/dist/cli/mcp-config.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +12 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/discovery/fold-import.d.ts +24 -1
  17. package/dist/discovery/fold-import.d.ts.map +1 -1
  18. package/dist/discovery/sandbox/fork.d.ts +0 -5
  19. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  20. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/gate-origin.d.ts +77 -0
  23. package/dist/lifecycle/gate-origin.d.ts.map +1 -0
  24. package/dist/observation.d.ts +0 -1
  25. package/dist/observation.d.ts.map +1 -1
  26. package/dist/op/activities/converge.d.ts +16 -0
  27. package/dist/op/activities/converge.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/audit/core.ts +1 -1
  30. package/src/cli/commands/doctor.ts +18 -6
  31. package/src/cli/commands/init.ts +9 -68
  32. package/src/cli/commands/update.ts +9 -0
  33. package/src/cli/handlers/init.ts +1 -0
  34. package/src/cli/handlers/operator.ts +48 -1
  35. package/src/cli/main.ts +9 -0
  36. package/src/cli/mcp/docs-parity.test.ts +133 -0
  37. package/src/cli/mcp/op-approve-origin.test.ts +70 -0
  38. package/src/cli/mcp/op-tools.ts +18 -4
  39. package/src/cli/mcp/server.ts +16 -1
  40. package/src/cli/mcp-config.test.ts +168 -0
  41. package/src/cli/mcp-config.ts +76 -0
  42. package/src/cli/registry.ts +12 -0
  43. package/src/discovery/fold-executing-mode.test.ts +115 -0
  44. package/src/discovery/fold-import.ts +87 -14
  45. package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
  46. package/src/discovery/sandbox/fork-diagnostic.test.ts +117 -0
  47. package/src/discovery/sandbox/fork.ts +92 -6
  48. package/src/graph-ir.ts +1 -1
  49. package/src/graph-layout.ts +0 -0
  50. package/src/lifecycle/gate-ledger.ts +39 -1
  51. package/src/lifecycle/gate-origin.test.ts +93 -0
  52. package/src/lifecycle/gate-origin.ts +113 -0
  53. package/src/meta/source-is-text.test.ts +72 -0
  54. package/src/observation.ts +27 -8
  55. package/src/op/activities/converge-push.test.ts +101 -0
  56. package/src/op/activities/converge.ts +31 -1
@@ -0,0 +1,168 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import { withTestDir } from "@intentius/chant-test-utils";
6
+ import { initCommand } from "./commands/init";
7
+ import { doctorCommand } from "./commands/doctor";
8
+ import { updateCommand } from "./commands/update";
9
+ import { parseArgs, commandRegistry } from "./main";
10
+ import { MCP_CONFIG_FILENAME, MCP_SETUP_COMMAND, mcpConfigPath } from "./mcp-config";
11
+
12
+ /**
13
+ * chant #2383. `chant init` used to write `mcp.json` into the user's home
14
+ * directory while `chant doctor` looked for `<project>/.mcp.json` and told
15
+ * you to run `chant agent setup`, a command the registry never had. Three
16
+ * sites, three answers, and a project chant had just scaffolded failed
17
+ * chant's own doctor with an unrunnable fix.
18
+ *
19
+ * These tests are written against the agreement rather than against today's
20
+ * string: the round trip below fails if init and doctor ever pick different
21
+ * paths again, and the registry check fails if the remediation ever names a
22
+ * command that is not registered. Neither can be satisfied by editing one
23
+ * site in isolation.
24
+ */
25
+ describe("MCP config: one location, three agreeing sites (#2383)", () => {
26
+ test("init then doctor round-trips: mcp-config passes with no manual step", async () => {
27
+ await withTestDir(async (testDir) => {
28
+ const result = await initCommand({
29
+ path: testDir,
30
+ lexicon: "aws",
31
+ skipInstall: true,
32
+ });
33
+ expect(result.success).toBe(true);
34
+
35
+ const report = await doctorCommand(testDir);
36
+ const check = report.checks.find((c) => c.name === "mcp-config");
37
+ expect(check).toBeDefined();
38
+ expect(check!.status).toBe("pass");
39
+ });
40
+ });
41
+
42
+ test("init writes the MCP config inside the project it was pointed at", async () => {
43
+ await withTestDir(async (testDir) => {
44
+ const result = await initCommand({
45
+ path: testDir,
46
+ lexicon: "aws",
47
+ skipInstall: true,
48
+ });
49
+
50
+ expect(existsSync(mcpConfigPath(testDir))).toBe(true);
51
+ expect(result.createdFiles).toContain(MCP_CONFIG_FILENAME);
52
+
53
+ const config = JSON.parse(readFileSync(mcpConfigPath(testDir), "utf-8"));
54
+ expect(config.mcpServers.chant.args).toEqual(["chant", "serve", "mcp"]);
55
+
56
+ // Every path init reports as created is relative to the project. A
57
+ // `~/` or absolute entry means init reached outside the directory it
58
+ // was handed, which is the half of #2383 worth not regressing.
59
+ for (const file of result.createdFiles) {
60
+ expect(file.startsWith("~"), `${file} escapes the project`).toBe(false);
61
+ expect(file.startsWith("/"), `${file} escapes the project`).toBe(false);
62
+ expect(file.includes(homedir()), `${file} escapes the project`).toBe(false);
63
+ }
64
+ });
65
+ });
66
+
67
+ test("--skip-mcp parses and suppresses the write", async () => {
68
+ // The flag was documented and typed long before anything parsed it, so
69
+ // assert the parse and the effect together — either one alone passed
70
+ // while `chant init --skip-mcp` still wrote the file.
71
+ expect(parseArgs(["init", ".", "--lexicon", "aws", "--skip-mcp"]).skipMcp).toBe(true);
72
+ expect(parseArgs(["init", ".", "--lexicon", "aws"]).skipMcp).toBeUndefined();
73
+
74
+ await withTestDir(async (testDir) => {
75
+ const result = await initCommand({
76
+ path: testDir,
77
+ lexicon: "aws",
78
+ skipMcp: true,
79
+ skipInstall: true,
80
+ });
81
+ expect(existsSync(mcpConfigPath(testDir))).toBe(false);
82
+ expect(result.createdFiles).not.toContain(MCP_CONFIG_FILENAME);
83
+ });
84
+ });
85
+
86
+ test("the doctor's remediation names a registered command", async () => {
87
+ // Reconstruct the warning the doctor actually emits, then check that the
88
+ // command inside it exists. `chant agent setup` satisfied neither.
89
+ await withTestDir(async (testDir) => {
90
+ writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
91
+ const report = await doctorCommand(testDir);
92
+ const check = report.checks.find((c) => c.name === "mcp-config");
93
+ expect(check!.status).toBe("warn");
94
+ expect(check!.message).toContain(MCP_SETUP_COMMAND);
95
+
96
+ const named = check!.message!.match(/run ([a-z][a-z -]*)/)?.[1].trim();
97
+ expect(named).toBeDefined();
98
+ expect(named!.startsWith("chant ")).toBe(true);
99
+ const registered = commandRegistry.map((c) => c.name);
100
+ expect(registered).toContain(named!.slice("chant ".length));
101
+ });
102
+ });
103
+
104
+ test("the remediation command restores a deleted config", async () => {
105
+ await withTestDir(async (testDir) => {
106
+ // A project that has everything except the MCP config: exactly the
107
+ // state the doctor warns about. `chant init` refuses this directory
108
+ // without --force, so the remediation has to be something else.
109
+ mkdirSync(join(testDir, "src"), { recursive: true });
110
+ writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
111
+ writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "p", type: "module" }));
112
+
113
+ expect(MCP_SETUP_COMMAND).toBe("chant update");
114
+ const result = await updateCommand({ path: testDir });
115
+ expect(result.success).toBe(true);
116
+
117
+ const report = await doctorCommand(testDir);
118
+ expect(report.checks.find((c) => c.name === "mcp-config")!.status).toBe("pass");
119
+ });
120
+ });
121
+
122
+ test("update leaves an existing config alone", async () => {
123
+ await withTestDir(async (testDir) => {
124
+ writeFileSync(join(testDir, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
125
+ writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "p", type: "module" }));
126
+ const hand = JSON.stringify({ mcpServers: { chant: { command: "custom" } } }, null, 2);
127
+ writeFileSync(mcpConfigPath(testDir), hand);
128
+
129
+ await updateCommand({ path: testDir });
130
+
131
+ expect(readFileSync(mcpConfigPath(testDir), "utf-8")).toBe(hand);
132
+ });
133
+ });
134
+
135
+ test("every chant command a doctor check tells you to run is registered", async () => {
136
+ // Broader than the mcp-config check that prompted #2383: whatever the
137
+ // doctor prints as `run chant <something>`, the registry has to have it.
138
+ // `chant agent setup` shipped as advice for a command that never existed;
139
+ // this fails the moment any check does that again.
140
+ const registered = commandRegistry.map((c) => c.name);
141
+ expect(registered).not.toContain("agent");
142
+ expect(registered).not.toContain("agent setup");
143
+
144
+ const messages: string[] = [];
145
+ await withTestDir(async (empty) => {
146
+ messages.push(...(await doctorCommand(empty)).checks.flatMap((c) => c.message ?? []));
147
+ });
148
+ await withTestDir(async (partial) => {
149
+ writeFileSync(join(partial, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
150
+ writeFileSync(join(partial, "package.json"), JSON.stringify({ name: "p" }));
151
+ mkdirSync(join(partial, "src"), { recursive: true });
152
+ messages.push(...(await doctorCommand(partial)).checks.flatMap((c) => c.message ?? []));
153
+ });
154
+
155
+ const named = messages.flatMap((m) => [...m.matchAll(/run `?(chant [a-z][a-z0-9 -]*)/g)].map((x) => x[1]));
156
+ expect(named.length).toBeGreaterThan(0);
157
+ for (const advice of named) {
158
+ // Drop flags and the trailing prose the regex may have swept up, then
159
+ // keep the longest registered name that prefixes what was advised.
160
+ const words = advice.slice("chant ".length).split(/\s+/).filter((w) => w && !w.startsWith("-"));
161
+ const match = registered.find((name) => {
162
+ const parts = name.split(" ");
163
+ return parts.every((p, i) => words[i] === p);
164
+ });
165
+ expect(match, `doctor advises "${advice}", which no registered command matches`).toBeDefined();
166
+ }
167
+ });
168
+ });
@@ -0,0 +1,76 @@
1
+ import { existsSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * The one place chant decides where a project's MCP server registration
6
+ * lives, what goes in it, and which command writes it (chant #2383).
7
+ *
8
+ * Before this module the three sites disagreed: `chant init` wrote
9
+ * `mcp.json` into the user's home directory (picking a harness directory
10
+ * that happened to exist), `chant doctor` looked for `<project>/.mcp.json`,
11
+ * and the doctor's remediation named `chant agent setup`, which was never
12
+ * registered in `./main.ts`. A project chant had just scaffolded therefore
13
+ * failed chant's own doctor, and the fix the warning named could not be run.
14
+ *
15
+ * Project scope wins. The file versions with the project it describes, it
16
+ * keeps `chant init <dir>` from writing outside `<dir>`, and it is already
17
+ * what `../agents/discover.ts` treats as the project-scope location when
18
+ * chant audits an agent installation it did not create. Every consumer must
19
+ * go through the constants here rather than rebuilding the path, so the
20
+ * three sites cannot drift apart again without the shared test in
21
+ * ./mcp-config.test.ts noticing.
22
+ */
23
+
24
+ /** Filename of the project-scoped MCP server registration. */
25
+ export const MCP_CONFIG_FILENAME = ".mcp.json";
26
+
27
+ /**
28
+ * The command a user runs to (re)write a missing {@link MCP_CONFIG_FILENAME}
29
+ * in an existing project. `chant init` also writes it, but init refuses a
30
+ * non-empty directory without `--force`, so the remediation an already
31
+ * scaffolded project needs is `chant update` — which is also what the
32
+ * doctor's neighbouring skills check tells you to run for the same reason.
33
+ * Kept as a constant so ./mcp-config.test.ts can assert it is a command
34
+ * `commandRegistry` actually registers.
35
+ */
36
+ export const MCP_SETUP_COMMAND = "chant update";
37
+
38
+ /** Absolute (or caller-relative) path to a project's MCP config. */
39
+ export function mcpConfigPath(projectDir: string): string {
40
+ return join(projectDir, MCP_CONFIG_FILENAME);
41
+ }
42
+
43
+ /**
44
+ * Detect whether a project uses bun or npm, from its lock file. Lives here
45
+ * because the package manager is the only variable in the generated config.
46
+ */
47
+ export function detectPackageManager(dir?: string): "bun" | "npm" {
48
+ if (dir && (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))) return "bun";
49
+ return "npm";
50
+ }
51
+
52
+ /** The MCP registration chant writes: one stdio server named `chant`. */
53
+ export function generateMcpConfig(pm: "bun" | "npm"): string {
54
+ const config = {
55
+ mcpServers: {
56
+ chant: {
57
+ command: pm === "bun" ? "bunx" : "npx",
58
+ args: ["chant", "serve", "mcp"],
59
+ },
60
+ },
61
+ };
62
+
63
+ return JSON.stringify(config, null, 2);
64
+ }
65
+
66
+ /**
67
+ * Write `<projectDir>/.mcp.json` unless one is already there. Returns the
68
+ * relative path when a file was created, `undefined` when an existing config
69
+ * was left alone — callers report that difference to the user.
70
+ */
71
+ export function writeProjectMcpConfig(projectDir: string): string | undefined {
72
+ const path = mcpConfigPath(projectDir);
73
+ if (existsSync(path)) return undefined;
74
+ writeFileSync(path, generateMcpConfig(detectPackageManager(projectDir)));
75
+ return MCP_CONFIG_FILENAME;
76
+ }
@@ -101,6 +101,12 @@ export interface ParsedArgs {
101
101
  reportFile?: string;
102
102
  /** `chant init --skill <name>` filter (added in #95 commit) */
103
103
  skill?: string;
104
+ /**
105
+ * `chant init --skip-mcp` (#2383) — scaffold without writing the project's
106
+ * `.mcp.json`. The option existed on `InitOptions` and was documented long
107
+ * before anything parsed it, so there was no way to decline the write.
108
+ */
109
+ skipMcp?: boolean;
104
110
  /** `chant import --type <ResourceType>` selector */
105
111
  selectType?: string;
106
112
  /** `chant import --name <name>` selector */
@@ -341,6 +347,12 @@ export interface ParsedArgs {
341
347
  note?: string;
342
348
  /** `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
349
  expire?: boolean;
350
+ /**
351
+ * `chant approve --allow-same-origin` (chant#2384) — record a resolution
352
+ * from the same channel that reached the gate, which is refused by default
353
+ * on a model-authored channel.
354
+ */
355
+ allowSameOrigin?: boolean;
344
356
  /** `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
357
  plan?: string;
346
358
  /** `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,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,24 +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
- // chant#2441 — `F-Div-Depth` is a refusal, not a failure to try hard
1463
- // enough. The fallback below imports the callee and invokes it, which is
1464
- // right when its body merely did not fold; here the folder has DECLINED,
1465
- // and invoking anyway produces exactly the envelope the specification says
1466
- // must not appear — a value the file's own declarators never produced.
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.
1467
1470
  //
1468
- // Every other `F-Div` row is a fallback and `divergence.md` says so
1469
- // outright, which is what makes the direction safe. This one is not, so it
1470
- // propagates and the file runs, which is what the reference does.
1471
- if (foldFailure instanceof FoldError && foldFailure.refusedAtDepth) throw foldFailure;
1472
- try {
1473
- return await resolveImportedCall(node, calleeName, binding, ctx);
1474
- } catch (err) {
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) {
1475
1513
  throw cheapError(
1476
- `${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)",
1477
1516
  );
1478
1517
  }
1518
+ throw foldFailure;
1479
1519
  }
1480
1520
 
1481
1521
  if (!binding) {
@@ -2356,6 +2396,7 @@ async function interpretCompositeFactory(
2356
2396
  resolvePathCache: ctx.resolvePathCache,
2357
2397
  lexiconPackages: ctx.lexiconPackages,
2358
2398
  sandbox: ctx.sandbox,
2399
+ executing: ctx.executing,
2359
2400
  session: ctx.session,
2360
2401
  interpretDepth: ctx.interpretDepth + 1,
2361
2402
  // chant #2161 — set unconditionally, and NOT inherited from `ctx`: a nested
@@ -3668,6 +3709,7 @@ async function tryFoldFileCore(file: string, session: FoldSession): Promise<Fold
3668
3709
  resolvePathCache: session.resolvePathCache,
3669
3710
  lexiconPackages: session.lexiconPackages,
3670
3711
  sandbox: session.sandbox,
3712
+ executing: session.executing,
3671
3713
  session,
3672
3714
  interpretDepth: 0,
3673
3715
  // chant#2423 — filled by the pre-build below, read by
@@ -4102,6 +4144,25 @@ export interface FoldProjectOptions {
4102
4144
  readonly buildParams?: Readonly<Record<string, BuildParamValue>>;
4103
4145
  /** chant #1093: this build asked for the sandbox, so fold may not reach outside the trusted allowlist. */
4104
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;
4105
4166
  /**
4106
4167
  * chant#2438 — package specifiers to follow a bare import into, verbatim,
4107
4168
  * alongside whatever `lexicons` names.
@@ -4158,12 +4219,24 @@ export async function foldProject(
4158
4219
  intrinsics: readonly IntrinsicDef[] = [],
4159
4220
  options: FoldProjectOptions = {},
4160
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
+
4161
4233
  const session = createFoldSession(
4162
4234
  intrinsics,
4163
4235
  options.buildParams,
4164
4236
  options.lexicons ?? [],
4165
4237
  options.sandbox ?? false,
4166
4238
  options.lexiconPackages ?? [],
4239
+ options.executing ?? false,
4167
4240
  );
4168
4241
  const attempts = new Map<string, FoldFileResult>();
4169
4242
  for (const file of files) attempts.set(file, await tryFoldFile(file, intrinsics, session));