@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.
- 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.map +1 -1
- package/dist/cli/registry.d.ts +6 -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/fold/fold.d.ts +15 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +38 -1
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.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/package.json +1 -1
- package/src/audit/core.ts +1 -1
- package/src/cli/handlers/operator.ts +48 -1
- package/src/cli/main.ts +4 -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 +11 -0
- package/src/cli/registry.ts +6 -0
- package/src/discovery/fold-counters-public.test.ts +67 -0
- package/src/discovery/fold-div-depth.test.ts +104 -0
- package/src/discovery/fold-executing-mode.test.ts +115 -0
- package/src/discovery/fold-import.ts +88 -5
- package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
- package/src/fold/fold.ts +23 -4
- package/src/fold/subset.ts +38 -1
- package/src/graph-ir.ts +1 -1
- package/src/graph-layout.ts +0 -0
- package/src/index.ts +10 -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
|
@@ -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
|
+
});
|
package/src/cli/mcp/op-tools.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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)
|
|
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
|
};
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -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,
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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}
|
|
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));
|