@intentius/chant 0.72.3 → 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.
@@ -17,8 +17,12 @@ import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
17
17
  * Protocol versions this server understands, newest first. `initialize` and
18
18
  * `server/discover` both negotiate against this list rather than assuming
19
19
  * the client's revision (#1194).
20
+ *
21
+ * Exported because `docs-parity.test.ts` reads it: `cli/mcp.mdx` states these
22
+ * revisions in prose, and stating them twice is how the page came to claim
23
+ * 2024-11-05 for two releases after this list moved past it (#2385).
20
24
  */
21
- const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
25
+ export const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
22
26
  const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
23
27
 
24
28
  /**
@@ -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 */
@@ -0,0 +1,117 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { fork } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { runFallbackFilesSandboxed } from "./run";
7
+
8
+ /**
9
+ * chant#2461 — a child that ends without a usable result says which way it did.
10
+ *
11
+ * The parent had one sentence for three unrelated situations: `child exited
12
+ * before reporting results (code N, signal S)`. The one seen in the wild is the
13
+ * hardest to read — exit code 0, empty stderr, no message — because it reads as
14
+ * if the child was cut off, and it was not. It ran to completion and sent
15
+ * nothing.
16
+ *
17
+ * These tests pin the two mechanisms that produce that signature, against real
18
+ * forked children rather than a mock, because the whole question is what Node
19
+ * actually does:
20
+ *
21
+ * - an `await` that never settles, which drains the loop and exits 0
22
+ * - a payload the parent's `isResponse` refuses, which used to be dropped in
23
+ * silence and then reported as though nothing had been sent
24
+ *
25
+ * A third mechanism was proposed in the issue and is NOT covered, because it
26
+ * was measured and does not happen: `process.send` racing the child's reap so
27
+ * that `exit` is dispatched before an already-queued `message`. With the parent
28
+ * blocked so both were certainly pending, `message` won 60 times out of 60. The
29
+ * live IPC channel keeps the child alive until the payload flushes, and the
30
+ * driver has no `process.exit()` to cut that short. The last test here records
31
+ * that, so the disproof is not lost with the transcript.
32
+ */
33
+ function runChild(source: string): Promise<{ code: number | null; message: unknown; stderr: string }> {
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-fork-diag-"));
35
+ const file = join(dir, "child.mjs");
36
+ writeFileSync(file, source);
37
+ return new Promise((resolve) => {
38
+ const child = fork(file, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
39
+ let stderr = "";
40
+ let message: unknown;
41
+ child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
42
+ child.on("message", (m) => { message = m; });
43
+ child.on("exit", (code) => {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ resolve({ code, message, stderr });
46
+ });
47
+ });
48
+ }
49
+
50
+ describe("why a sandboxed child ends without a result (chant#2461)", () => {
51
+ test("an await that never settles exits 0, silently, having sent nothing", async () => {
52
+ // The observed signature, reproduced. `main().catch(...)` catches a
53
+ // REJECTION; a promise that never settles is not one, so the driver's own
54
+ // fatal-payload path never runs either.
55
+ const r = await runChild(
56
+ 'async function main() { await new Promise(() => {}); process.send({ ok: true }); }\n' +
57
+ "main().catch((e) => process.send({ fatal: String(e) }));\n",
58
+ );
59
+
60
+ expect(r.code).toBe(0);
61
+ expect(r.message).toBeUndefined();
62
+ expect(r.stderr.trim()).toBe("");
63
+ });
64
+
65
+ test("a child that sends and falls off the end always gets its message through", async () => {
66
+ // The disproof, kept as a test. If this ever fails, the race the issue
67
+ // proposed is real after all and the diagnostic's wording needs revisiting.
68
+ const results = await Promise.all(
69
+ Array.from({ length: 12 }, () => runChild("process.send({ ok: true });\n")),
70
+ );
71
+
72
+ for (const r of results) {
73
+ expect(r.code).toBe(0);
74
+ expect(r.message).toEqual({ ok: true });
75
+ }
76
+ });
77
+
78
+ test("a rejection still reaches the parent, so the fatal path is not what broke", async () => {
79
+ // Establishes the contrast: the driver's catch works. It is specifically an
80
+ // unsettled promise that escapes it.
81
+ const r = await runChild(
82
+ 'async function main() { throw new Error("boom"); }\n' +
83
+ "main().catch((e) => process.send({ fatal: String(e) }));\n",
84
+ );
85
+
86
+ expect(r.message).toEqual({ fatal: "Error: boom" });
87
+ });
88
+
89
+ test("a project file with an unsettled top-level await reproduces it end to end", async () => {
90
+ // The run path's real mechanism, not a stand-in. `main()` does
91
+ // `await import(<project file>)` per run-fallback file, module scope is
92
+ // arbitrary project source, and a top level that awaits something which
93
+ // never settles makes that import never complete. Nothing keeps the loop
94
+ // alive, so Node exits 0 having sent nothing.
95
+ //
96
+ // This is what the diagnostic's wording is checked against. Before
97
+ // chant#2461 it said only "child exited before reporting results", which
98
+ // gave a reader nothing to look at.
99
+ const root = mkdtempSync(join(tmpdir(), "chant-tla-"));
100
+ try {
101
+ mkdirSync(join(root, "src"), { recursive: true });
102
+ writeFileSync(
103
+ join(root, "src", "hangs.ts"),
104
+ "await new Promise<void>(() => {});\nexport const never = { reached: true };\n",
105
+ );
106
+
107
+ const result = await runFallbackFilesSandboxed([join(root, "src", "hangs.ts")], root);
108
+
109
+ const message = result.errors.map((e) => e.message).join("\n");
110
+ expect(message).toContain("child exited before reporting results (code 0, signal null)");
111
+ expect(message).toContain("drained its loop without sending");
112
+ expect(message).toContain("top-level");
113
+ } finally {
114
+ rmSync(root, { recursive: true, force: true });
115
+ }
116
+ }, 120_000);
117
+ });
@@ -125,6 +125,86 @@ function lineBuffered(emit: (line: string) => void) {
125
125
  * resolve with the first IPC message that satisfies `isResponse` (or reject
126
126
  * on crash / timeout / fork error).
127
127
  */
128
+ /**
129
+ * Why a child ended without a usable result, in terms a reader can act on.
130
+ *
131
+ * chant#2461 — this used to be one sentence, `child exited before reporting
132
+ * results (code N, signal S)`, for three unrelated situations. The one that
133
+ * was observed in the wild is the hardest to read: exit code 0, empty stderr,
134
+ * no message. That reads like the child was cut off, and it was not — it ran
135
+ * to completion and sent nothing.
136
+ *
137
+ * Two mechanisms produce it, and the driver decides which is possible.
138
+ *
139
+ * The first is an `await` that never settles. `main().catch(...)` catches a
140
+ * REJECTION, and a promise that never settles is not one, so if nothing keeps
141
+ * the loop alive Node drains it and exits 0 having sent nothing and written
142
+ * nothing. Nothing else in the process reports that.
143
+ *
144
+ * On the RUN path that is not hypothetical, and it is reproducible: the driver
145
+ * does `await import(<project file>)` for each run-fallback file, module scope
146
+ * is arbitrary project source, and a file whose top level awaits something that
147
+ * never settles makes that import never complete. A fixture doing exactly that
148
+ * yields this error, which is how the wording here was checked rather than
149
+ * guessed.
150
+ *
151
+ * The second is the payload being lost between `process.send` and exit.
152
+ *
153
+ * For the CONFIG driver the first is impossible, which is worth stating because
154
+ * it was the working hypothesis until the bundle was read. Its `await
155
+ * import(configPath)` bundles to `await Promise.resolve().then(() =>
156
+ * (init_chant_config(), chant_config_exports))` — one microtask over
157
+ * synchronous code — and the bundle has no runtime imports, no dynamic
158
+ * `import(`, and exactly one `process.send`. There is nothing there to hang on.
159
+ * So a config child that exits 0 with nothing sent DID send, and the payload
160
+ * did not arrive.
161
+ *
162
+ * The exit/message race originally proposed in chant#2461 is a third thing and
163
+ * is not it: with the parent blocked so a queued payload and the reap were both
164
+ * pending, `message` was dispatched first 60 times out of 60, because the live
165
+ * IPC channel keeps the child alive until the payload flushes.
166
+ */
167
+ function describeSilentExit(
168
+ label: string,
169
+ code: number | null,
170
+ signal: NodeJS.Signals | null,
171
+ stderrBuf: string,
172
+ unrecognised: readonly unknown[],
173
+ ): string {
174
+ const stderr = stderrBuf.trim();
175
+ const head = `${label}: child exited before reporting results (code ${code}, signal ${signal})`;
176
+
177
+ if (unrecognised.length > 0) {
178
+ // It DID send. The parent refused the shape, which is a bug in one of them
179
+ // and not the child dying early.
180
+ const shapes = unrecognised
181
+ .map((m) => (m && typeof m === "object" ? `{${Object.keys(m as object).join(", ")}}` : typeof m))
182
+ .join(", ");
183
+ return (
184
+ `${head}. It sent ${unrecognised.length} message(s) the parent did not recognise (${shapes}), ` +
185
+ `so the payload shape and the parent's check disagree` +
186
+ (stderr ? `: ${stderr}` : "")
187
+ );
188
+ }
189
+
190
+ if (stderr) return `${head}: ${stderr}`;
191
+ if (code !== 0 || signal !== null) return head;
192
+
193
+ // Exit 0, nothing on stderr, nothing sent. The child finished normally and
194
+ // the parent has nothing. Two mechanisms produce exactly this, and which one
195
+ // it is depends on the driver — see this function's doc.
196
+ return (
197
+ `${head}. It exited cleanly with nothing on stderr and sent no message, so the child drained ` +
198
+ `its loop without sending: something it awaited never settled. On the run path the usual ` +
199
+ `cause is a project file with a top-level \`await\` that does not settle — module scope is ` +
200
+ `arbitrary project source, and an import of such a file never completes. A promise that ` +
201
+ `never settles is not a rejection, so the driver's own \`main().catch\` does not see it ` +
202
+ `either, which is why nothing is written anywhere. The config driver bundles to one ` +
203
+ `microtask over synchronous code with no runtime I/O, so on THAT path nothing can hang and ` +
204
+ `the payload was lost between \`process.send\` and exit instead (chant#2461).`
205
+ );
206
+ }
207
+
128
208
  export function forkSandboxed<T>(
129
209
  options: SandboxForkOptions,
130
210
  isResponse: (value: unknown) => value is T,
@@ -144,6 +224,8 @@ export function forkSandboxed<T>(
144
224
 
145
225
  let settled = false;
146
226
  let stderrBuf = "";
227
+ /** Messages the child sent that `isResponse` refused — see the `message` handler. */
228
+ const unrecognised: unknown[] = [];
147
229
 
148
230
  const timeout = setTimeout(() => {
149
231
  if (settled) return;
@@ -185,7 +267,15 @@ export function forkSandboxed<T>(
185
267
  child.stderr?.on("end", () => stderrForwarder.flush());
186
268
 
187
269
  child.on("message", (msg: unknown) => {
188
- if (settled || !isResponse(msg)) return;
270
+ if (settled) return;
271
+ if (!isResponse(msg)) {
272
+ // chant#2461 — remember it rather than dropping it. A child that sent
273
+ // something the parent does not recognise is a different failure from
274
+ // a child that sent nothing, and both used to arrive as "exited before
275
+ // reporting results" with no way to tell them apart.
276
+ unrecognised.push(msg);
277
+ return;
278
+ }
189
279
  settled = true;
190
280
  clearTimeout(timeout);
191
281
  // chant #1131 — the child's entire job is to send this one message, so
@@ -212,11 +302,7 @@ export function forkSandboxed<T>(
212
302
  if (settled) return;
213
303
  settled = true;
214
304
  clearTimeout(timeout);
215
- reject(
216
- new Error(
217
- `${label}: child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
218
- ),
219
- );
305
+ reject(new Error(describeSilentExit(label, code, signal, stderrBuf, unrecognised)));
220
306
  });
221
307
  });
222
308
  }
@@ -57,14 +57,33 @@ export type UnobservedReason =
57
57
  | "unsupported-kind"
58
58
  | "filtered";
59
59
 
60
- /** Every legal {@link UnobservedReason}, for validation and conformance checks. */
61
- export const UNOBSERVED_REASONS: readonly UnobservedReason[] = [
62
- "read-failed",
63
- "no-credentials",
64
- "no-binding",
65
- "unsupported-kind",
66
- "filtered",
67
- ];
60
+ /**
61
+ * Every legal {@link UnobservedReason}, for validation and conformance checks.
62
+ *
63
+ * Derived from a witness keyed off the union rather than written out beside it
64
+ * (chant#2366). A hand-maintained array is only ever checked for holding legal
65
+ * members, never for holding ALL of them, so a reason added to the type left
66
+ * the array silently short — `tsc` clean, every observation test green, and a
67
+ * value the type permits that `observation-conformance.ts` refuses. A lexicon
68
+ * could construct a value its own suite rejected.
69
+ *
70
+ * Keying a `Record` off the union makes the omission a compile error at the
71
+ * point of the omission. This is the construction #2365 applied to all four
72
+ * closed sets in `./behaviour.ts`; `BEHAVIOUR_UNPREDICTED_REASONS` derives from
73
+ * this very type, so that module was already protected against a change here
74
+ * while this module was not.
75
+ */
76
+ const UNOBSERVED_REASON_WITNESS: Record<UnobservedReason, true> = {
77
+ "read-failed": true,
78
+ "no-credentials": true,
79
+ "no-binding": true,
80
+ "unsupported-kind": true,
81
+ filtered: true,
82
+ };
83
+
84
+ export const UNOBSERVED_REASONS: readonly UnobservedReason[] = Object.keys(
85
+ UNOBSERVED_REASON_WITNESS,
86
+ ) as UnobservedReason[];
68
87
 
69
88
  /** True when `value` is a legal {@link UnobservedReason}. */
70
89
  export function isUnobservedReason(value: unknown): value is UnobservedReason {
@@ -0,0 +1,101 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ /**
4
+ * chant#2337 — a converge tick says whether its record reached the remote.
5
+ *
6
+ * `convergeTick` ended with `await pushLifecycle().catch(() => undefined)`, the
7
+ * last instance of the idiom #2310 was filed about. The append is local-first
8
+ * and always lands, so the tick's own result was correct either way — what was
9
+ * missing is whether anyone else can see it.
10
+ *
11
+ * Softer than the gate's version of the same bug, which #2336 fixed: a lost
12
+ * tick record is an informational log rather than an approval, so nobody is
13
+ * left waiting on a fact they cannot see. It is still not success, and a
14
+ * converge loop reporting a clean tick while its ledger never leaves the
15
+ * machine is telling an operator something untrue.
16
+ */
17
+ const execMock = vi.fn();
18
+ vi.mock("node:child_process", () => ({
19
+ exec: (cmd: string, _opts: unknown, cb: (e: Error | null, r: { stdout: string; stderr: string }) => void) =>
20
+ cb(null, { stdout: execMock(cmd) as string, stderr: "" }),
21
+ }));
22
+
23
+ const pushLifecycle = vi.fn();
24
+ vi.mock("../../lifecycle/git", () => ({
25
+ fetchLifecycle: vi.fn(async () => undefined),
26
+ pushLifecycle: (...args: unknown[]) => pushLifecycle(...args) as Promise<boolean>,
27
+ }));
28
+
29
+ vi.mock("../../lifecycle/converge-ledger", async (orig) => {
30
+ const actual = (await orig()) as Record<string, unknown>;
31
+ return {
32
+ ...actual,
33
+ readConvergeLedger: vi.fn(async () => ({ records: [] })),
34
+ appendConvergeRecord: vi.fn(async (record: Record<string, unknown>) => ({
35
+ record: { ...record, id: "tick-1" },
36
+ })),
37
+ };
38
+ });
39
+
40
+ const { convergeTick } = await import("./converge");
41
+
42
+ /** A tick with nothing to do: no drift, no rules, so only the ledger write matters. */
43
+ async function tick(): Promise<Awaited<ReturnType<typeof convergeTick>>> {
44
+ return convergeTick({ opName: "demo", env: "prod", rules: [], dial: "report" } as never);
45
+ }
46
+
47
+ describe("a converge tick reports its push outcome (chant#2337)", () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ execMock.mockImplementation((cmd: string) =>
51
+ cmd.includes("lifecycle plan")
52
+ ? JSON.stringify({ env: "prod", entries: [] })
53
+ : JSON.stringify([]),
54
+ );
55
+ });
56
+
57
+ test("a push that lands reports pushed, with no warning", async () => {
58
+ pushLifecycle.mockResolvedValue(true);
59
+
60
+ const result = await tick();
61
+
62
+ expect(result.pushed).toBe(true);
63
+ expect(result.pushWarning).toBeUndefined();
64
+ });
65
+
66
+ test("a REJECTED push is reported, not swallowed", async () => {
67
+ // Red before chant#2337: `.catch(() => undefined)` meant the tick returned
68
+ // the same shape whether or not the record left the machine.
69
+ pushLifecycle.mockRejectedValue(new Error("remote rejected: chant/lifecycle has moved"));
70
+
71
+ const result = await tick();
72
+
73
+ expect(result.pushed).toBe(false);
74
+ expect(result.pushWarning).toContain("remote rejected");
75
+ });
76
+
77
+ test("no remote configured is reported too, and says which it is", async () => {
78
+ // `pushLifecycle` returns false rather than throwing when there is no
79
+ // remote. That is not an error, but it is not a push either, and the two
80
+ // reasons are worth telling apart in the warning.
81
+ pushLifecycle.mockResolvedValue(false);
82
+
83
+ const result = await tick();
84
+
85
+ expect(result.pushed).toBe(false);
86
+ expect(result.pushWarning).toMatch(/no remote/i);
87
+ });
88
+
89
+ test("the tick's own findings are unaffected by a failed push", async () => {
90
+ // The append is local-first and always lands. A push failure must not make
91
+ // the tick misreport what it observed, or the fix would have traded one
92
+ // wrong answer for another.
93
+ pushLifecycle.mockRejectedValue(new Error("nope"));
94
+
95
+ const result = await tick();
96
+
97
+ expect(result.id).toBe("tick-1");
98
+ expect(result.drifted).toBe(false);
99
+ expect(typeof result.log).toBe("string");
100
+ });
101
+ });