@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.
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +16 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp-config.d.ts +47 -0
- package/dist/cli/mcp-config.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +24 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-origin.d.ts +77 -0
- package/dist/lifecycle/gate-origin.d.ts.map +1 -0
- package/dist/observation.d.ts +0 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/converge.d.ts +16 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +1 -1
- package/src/cli/commands/doctor.ts +18 -6
- package/src/cli/commands/init.ts +9 -68
- package/src/cli/commands/update.ts +9 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/handlers/operator.ts +48 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/docs-parity.test.ts +133 -0
- package/src/cli/mcp/op-approve-origin.test.ts +70 -0
- package/src/cli/mcp/op-tools.ts +18 -4
- package/src/cli/mcp/server.ts +16 -1
- package/src/cli/mcp-config.test.ts +168 -0
- package/src/cli/mcp-config.ts +76 -0
- package/src/cli/registry.ts +12 -0
- package/src/discovery/fold-executing-mode.test.ts +115 -0
- package/src/discovery/fold-import.ts +87 -14
- package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
- package/src/discovery/sandbox/fork-diagnostic.test.ts +117 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- package/src/graph-ir.ts +1 -1
- package/src/graph-layout.ts +0 -0
- package/src/lifecycle/gate-ledger.ts +39 -1
- package/src/lifecycle/gate-origin.test.ts +93 -0
- package/src/lifecycle/gate-origin.ts +113 -0
- package/src/meta/source-is-text.test.ts +72 -0
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- 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
|
+
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
-
|
|
1462
|
-
//
|
|
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
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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}
|
|
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));
|