@intentius/chant 0.72.3 → 0.72.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/init.d.ts +1 -1
  3. package/dist/cli/commands/init.d.ts.map +1 -1
  4. package/dist/cli/commands/update.d.ts.map +1 -1
  5. package/dist/cli/handlers/init.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/server.d.ts +10 -0
  8. package/dist/cli/mcp/server.d.ts.map +1 -1
  9. package/dist/cli/mcp-config.d.ts +47 -0
  10. package/dist/cli/mcp-config.d.ts.map +1 -0
  11. package/dist/cli/registry.d.ts +6 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/declarable.d.ts +46 -1
  14. package/dist/declarable.d.ts.map +1 -1
  15. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/fork.d.ts +0 -5
  17. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  18. package/dist/fold/subset.d.ts +15 -3
  19. package/dist/fold/subset.d.ts.map +1 -1
  20. package/dist/observation.d.ts +0 -1
  21. package/dist/observation.d.ts.map +1 -1
  22. package/dist/op/activities/converge.d.ts +16 -0
  23. package/dist/op/activities/converge.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/commands/doctor.ts +18 -6
  26. package/src/cli/commands/init.ts +9 -68
  27. package/src/cli/commands/update.ts +9 -0
  28. package/src/cli/handlers/init.ts +1 -0
  29. package/src/cli/main.ts +5 -0
  30. package/src/cli/mcp/docs-parity.test.ts +133 -0
  31. package/src/cli/mcp/server.ts +5 -1
  32. package/src/cli/mcp-config.test.ts +168 -0
  33. package/src/cli/mcp-config.ts +76 -0
  34. package/src/cli/registry.ts +6 -0
  35. package/src/declarable-host-marker.test.ts +88 -0
  36. package/src/declarable.ts +72 -7
  37. package/src/discovery/sandbox/driver.ts +25 -0
  38. package/src/discovery/sandbox/fork-diagnostic.test.ts +125 -0
  39. package/src/discovery/sandbox/fork.ts +92 -6
  40. package/src/fold/subset.ts +15 -3
  41. package/src/observation.ts +27 -8
  42. package/src/op/activities/converge-push.test.ts +101 -0
  43. package/src/op/activities/converge.ts +31 -1
@@ -0,0 +1,133 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { join } from "node:path";
5
+ import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from "./server";
6
+ import type { ToolDefinition, ResourceDefinition } from "./types";
7
+
8
+ /**
9
+ * The MCP docs say what this server offers; this test says they are right.
10
+ *
11
+ * `docs/src/content/docs/cli/mcp.mdx` is a hand-written list of tools,
12
+ * resources, methods and protocol revisions, and the server is the thing that
13
+ * actually registers them. Nothing connected the two, so the page fell two
14
+ * revisions behind twice over (#2385): it advertised six tools while the
15
+ * constructor registered thirteen, left `lifecycle-snapshot` and
16
+ * `lifecycle-diff` undocumented anywhere, omitted `server/discover` (#1194)
17
+ * and `chant://knowledge` (#1867), and claimed protocol version 2024-11-05
18
+ * long after #1194 made 2026-07-28 the preferred one.
19
+ *
20
+ * Each claim here is read back against the running server rather than against
21
+ * source text: the tool and resource lists come from a real `McpServer`
22
+ * answering `tools/list` and `resources/list`, which is what a client sees.
23
+ * The dispatch methods are the exception and are read out of `server.ts` by
24
+ * regex, because a switch statement has no listing API; that regex fails
25
+ * loudly if the switch is ever restructured.
26
+ *
27
+ * Same shape as `scripts/fold-depth-bound-claims.test.ts` (#2367) and
28
+ * `scripts/lexicon-count-claims.test.ts` (#2316): a doc claim pinned to
29
+ * ground truth, with a message that says what to edit.
30
+ */
31
+
32
+ const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
33
+ const MCP_DOC = join("docs", "src", "content", "docs", "cli", "mcp.mdx");
34
+ const GUIDE_DOC = join("docs", "src", "content", "docs", "guide", "agent-integration.mdx");
35
+ const SERVER_SRC = join("packages", "core", "src", "cli", "mcp", "server.ts");
36
+
37
+ const mcpDoc = readFileSync(join(repoRoot, MCP_DOC), "utf8");
38
+ const guideDoc = readFileSync(join(repoRoot, GUIDE_DOC), "utf8");
39
+ const serverSrc = readFileSync(join(repoRoot, SERVER_SRC), "utf8");
40
+
41
+ /** What a client is told when it asks, built from a server with no plugins loaded. */
42
+ async function servedListing(method: "tools/list" | "resources/list"): Promise<string[]> {
43
+ const response = await new McpServer().handleRequest({ jsonrpc: "2.0", id: 1, method });
44
+ const result = response.result as { tools?: ToolDefinition[]; resources?: ResourceDefinition[] };
45
+ const names = result.tools?.map((t) => t.name) ?? result.resources?.map((r) => r.uri);
46
+ if (!names || names.length === 0) {
47
+ throw new Error(`mcp docs parity: ${method} returned nothing to compare the docs against`);
48
+ }
49
+ return names;
50
+ }
51
+
52
+ /** The lines of one `##`/`###` section, up to the next heading at any level. */
53
+ function section(doc: string, heading: string, label: string): string {
54
+ const start = doc.indexOf(heading);
55
+ if (start < 0) throw new Error(`mcp docs parity: no "${heading}" heading in ${label} — was it renamed? Update this test to match.`);
56
+ const rest = doc.slice(start + heading.length);
57
+ const end = rest.search(/\n#{1,4} /);
58
+ return end < 0 ? rest : rest.slice(0, end);
59
+ }
60
+
61
+ /** Every `` | `thing` | `` first column of a markdown table in `text`. */
62
+ function firstColumnCells(text: string): string[] {
63
+ return [...text.matchAll(/^\| `([^`]+)` \|/gm)].map((m) => m[1]);
64
+ }
65
+
66
+ function sorted(values: readonly string[]): string[] {
67
+ return [...values].sort();
68
+ }
69
+
70
+ describe("the MCP docs describe the server that ships (#2385)", () => {
71
+ test("cli/mcp.mdx documents exactly the tools the server registers", async () => {
72
+ const registered = await servedListing("tools/list");
73
+ // Every core tool gets its own `### \`name\`` heading. The `#### ` heading
74
+ // under op-approve and the un-backticked "### Plugin Tools" are not tools.
75
+ const documented = [...mcpDoc.matchAll(/^### `([^`]+)`$/gm)].map((m) => m[1]);
76
+ expect(documented.length, `${MCP_DOC}: found no "### \`tool-name\`" headings — was the Tools section reformatted?`).toBeGreaterThan(0);
77
+ expect(
78
+ sorted(documented),
79
+ `${MCP_DOC} and the McpServer constructor disagree about the core tools. ` +
80
+ `Registered: ${sorted(registered).join(", ")}. Documented: ${sorted(documented).join(", ")}. ` +
81
+ `Give every registered tool a "### \`name\`" section on that page, and delete the sections for tools that no longer exist.`,
82
+ ).toEqual(sorted(registered));
83
+ });
84
+
85
+ test("guide/agent-integration.mdx lists exactly the tools the server registers", async () => {
86
+ const registered = await servedListing("tools/list");
87
+ const listed = firstColumnCells(section(guideDoc, "### Available Tools", GUIDE_DOC));
88
+ expect(
89
+ sorted(listed),
90
+ `${GUIDE_DOC}'s "Available Tools" table and the McpServer constructor disagree. ` +
91
+ `Registered: ${sorted(registered).join(", ")}. Listed: ${sorted(listed).join(", ")}. ` +
92
+ `Add or remove rows so the table matches; the parameter detail lives on the cli/mcp reference page.`,
93
+ ).toEqual(sorted(registered));
94
+ });
95
+
96
+ test("cli/mcp.mdx documents exactly the resources the server serves", async () => {
97
+ const served = await servedListing("resources/list");
98
+ // Rows of the Resources table: `| `chant://...` | `mime` | description |`.
99
+ const documented = [...mcpDoc.matchAll(/^\| `(chant:\/\/[^`]+)` \| `[^`]+` \|/gm)].map((m) => m[1]);
100
+ expect(
101
+ sorted(documented),
102
+ `${MCP_DOC}'s Resources table and the server's resources/list disagree. ` +
103
+ `Served: ${sorted(served).join(", ")}. Documented: ${sorted(documented).join(", ")}. ` +
104
+ `A URI the server reads but never lists (chant://examples/{name}) belongs in prose under the table, not in it.`,
105
+ ).toEqual(sorted(served));
106
+ });
107
+
108
+ test("cli/mcp.mdx states the protocol revisions the server negotiates, preferred first", () => {
109
+ const documented = [...mcpDoc.matchAll(/^\| `(\d{4}-\d{2}-\d{2})` \|/gm)].map((m) => m[1]);
110
+ expect(
111
+ documented,
112
+ `${MCP_DOC} claims protocol revisions ${documented.join(", ") || "(none found)"}, but server.ts ` +
113
+ `negotiates ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}. The first row is the preferred revision, ` +
114
+ `the one a client that names no version gets back.`,
115
+ ).toEqual([...SUPPORTED_PROTOCOL_VERSIONS]);
116
+ });
117
+
118
+ test("cli/mcp.mdx lists exactly the JSON-RPC methods dispatch answers", () => {
119
+ const dispatched = [...serverSrc.matchAll(/^ case "([^"]+)":$/gm)].map((m) => m[1]);
120
+ if (dispatched.length === 0) {
121
+ throw new Error(
122
+ `mcp docs parity: no \`case "method":\` labels found in ${SERVER_SRC} — the dispatch switch was ` +
123
+ `restructured, so this test can no longer read the method list out of it. Update the test to match.`,
124
+ );
125
+ }
126
+ const documented = firstColumnCells(section(mcpDoc, "### Supported Methods", MCP_DOC));
127
+ expect(
128
+ sorted(documented),
129
+ `${MCP_DOC}'s "Supported Methods" table and the dispatch switch in ${SERVER_SRC} disagree. ` +
130
+ `Dispatched: ${sorted(dispatched).join(", ")}. Documented: ${sorted(documented).join(", ")}.`,
131
+ ).toEqual(sorted(dispatched));
132
+ });
133
+ });
@@ -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,88 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { isDeclarable, DECLARABLE_MARKER } from "./declarable";
3
+ import { INTRINSIC_MARKER } from "./intrinsic";
4
+ import { STACK_OUTPUT_MARKER } from "./stack-output";
5
+ import { COMPOSITE_MARKER } from "./composite";
6
+
7
+ /**
8
+ * chant#2444 — `isDeclarable` accepts an entity a HOST built.
9
+ *
10
+ * `F-Host-Interface` item 1 says an entity carries a non-enumerable declarable
11
+ * marker, and a host supplies its own. Testing identity against chant's symbol
12
+ * alone refused those for not being chant's rather than for being malformed —
13
+ * which is what chant#2442 hit inside composite member validation, and what
14
+ * would otherwise keep biting in the entity tallies and the revival
15
+ * pass-through, where it fails silently rather than loudly.
16
+ *
17
+ * The interesting half of this test file is the second describe block. The
18
+ * reference implementation reads the same rule as "any own symbol or
19
+ * non-enumerable own property", which is right for a domain with one marker and
20
+ * wrong for chant, which has seven. Those cases pin the narrowing.
21
+ */
22
+ function marked(symbol: symbol, value: unknown = true): object {
23
+ const o = {};
24
+ Object.defineProperty(o, symbol, { value, enumerable: false });
25
+ return o;
26
+ }
27
+
28
+ describe("isDeclarable accepts a host's marker (chant#2444)", () => {
29
+ test("chant's own marker, which is every entity a chant build makes", () => {
30
+ expect(isDeclarable(marked(DECLARABLE_MARKER))).toBe(true);
31
+ });
32
+
33
+ test("the specification host's marker", () => {
34
+ // The exact symbol `@tsad/shapes` uses. This is the case chant#2442 hit.
35
+ expect(isDeclarable(marked(Symbol.for("tsad.conformance.declarable")))).toBe(true);
36
+ });
37
+
38
+ test("any implementation's, as long as the marker says declarable", () => {
39
+ expect(isDeclarable(marked(Symbol.for("some.other.tool.declarable")))).toBe(true);
40
+ });
41
+
42
+ test("a plain object is not one", () => {
43
+ expect(isDeclarable({})).toBe(false);
44
+ expect(isDeclarable({ entityType: "Bucket", props: {} })).toBe(false);
45
+ expect(isDeclarable(null)).toBe(false);
46
+ expect(isDeclarable("Bucket")).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("the narrowing, and why the reference's reading is unsafe here", () => {
51
+ test("chant's OTHER markers are not declarable markers", () => {
52
+ // The measurement that decided the implementation. Under "any own symbol",
53
+ // every one of these classifies as an entity and enters the tallies — a
54
+ // worse bug than the one chant#2444 fixes.
55
+ expect(isDeclarable(marked(INTRINSIC_MARKER))).toBe(false);
56
+ expect(isDeclarable(marked(STACK_OUTPUT_MARKER))).toBe(false);
57
+ expect(isDeclarable(marked(COMPOSITE_MARKER))).toBe(false);
58
+ });
59
+
60
+ test("a non-enumerable own NAME is not a marker either", () => {
61
+ // A real chant entity's non-enumerable own names are `lexicon`,
62
+ // `entityType`, `kind`, `props`, `attributes` and `Ref`. Reading "any
63
+ // non-enumerable own property" as the marker is close to "any chant object".
64
+ const o = {};
65
+ Object.defineProperty(o, "props", { value: {}, enumerable: false });
66
+ Object.defineProperty(o, "entityType", { value: "Bucket", enumerable: false });
67
+ expect(isDeclarable(o)).toBe(false);
68
+ });
69
+
70
+ test("an ENUMERABLE declarable symbol is refused", () => {
71
+ // F-Host-Interface item 1 says non-enumerable, and it matters: an
72
+ // enumerable marker travels through a spread and would reach an artifact.
73
+ const o = {};
74
+ Object.defineProperty(o, Symbol.for("x.declarable"), { value: true, enumerable: true });
75
+ expect(isDeclarable(o)).toBe(false);
76
+ });
77
+
78
+ test("a declarable symbol whose value is not true is refused", () => {
79
+ expect(isDeclarable(marked(Symbol.for("x.declarable"), false))).toBe(false);
80
+ expect(isDeclarable(marked(Symbol.for("x.declarable"), "yes"))).toBe(false);
81
+ });
82
+
83
+ test("a symbol that merely contains the word is refused", () => {
84
+ // Suffix, not substring: `declarable.thing` is not a declarable marker.
85
+ expect(isDeclarable(marked(Symbol.for("x.declarable.thing")))).toBe(false);
86
+ expect(isDeclarable(marked(Symbol.for("notdeclarable")))).toBe(false);
87
+ });
88
+ });
package/src/declarable.ts CHANGED
@@ -48,15 +48,80 @@ export interface CoreOutput extends Declarable {
48
48
  }
49
49
 
50
50
  /**
51
- * Type guard to check if a value is a Declarable
51
+ * The suffix a declarable marker's symbol description ends with.
52
+ *
53
+ * chant#2444 — what makes a marker mean "entity" rather than "some other kind
54
+ * of chant value". `Symbol.for("chant.declarable")` has it; so does the
55
+ * specification host's `Symbol.for("tsad.conformance.declarable")`.
56
+ */
57
+ const DECLARABLE_MARKER_SUFFIX = ".declarable";
58
+
59
+ /**
60
+ * Type guard to check if a value is a Declarable.
61
+ *
62
+ * chant#2444 — accepts an entity a HOST built, not only one chant built.
63
+ *
64
+ * `F-Host-Interface` item 1 says an entity carries a non-enumerable declarable
65
+ * marker, and a host supplies its own: the specification's conformance host
66
+ * marks with `Symbol.for("tsad.conformance.declarable")`. Testing identity
67
+ * against chant's symbol alone refused those for not being chant's rather than
68
+ * for being malformed, which is what chant#2442 hit inside composite member
69
+ * validation and what would otherwise keep biting in the entity tallies and the
70
+ * revival pass-through.
71
+ *
72
+ * ## Why NOT the reference implementation's reading
73
+ *
74
+ * The reference reads L6.1 as "any own symbol or non-enumerable own property",
75
+ * which is right in its domain and **unsafe in chant's**. Two measurements say
76
+ * so, and both were taken rather than assumed:
77
+ *
78
+ * - A real chant entity's non-enumerable own NAMES are `lexicon`,
79
+ * `entityType`, `kind`, `props`, `attributes`, `Ref`. "Any non-enumerable
80
+ * own property" is therefore close to "any chant object at all".
81
+ * - chant has SEVEN marker symbols, not one — `chant.intrinsic`,
82
+ * `chant.composite`, `chant.stackOutput`, `chant.lexiconOutput`,
83
+ * `chant.effect-receipt`, `chant.secret-declaration`, `chant.childProject`
84
+ * — each a non-enumerable own symbol set to `true`. Under "any own symbol"
85
+ * an Intrinsic and a StackOutput both classify as Declarable, which is a
86
+ * worse bug than the one being fixed: they would enter the entity tallies.
87
+ *
88
+ * The reference can read any symbol because its domain has one. chant
89
+ * distinguishes seven kinds BY symbol identity, so it needs the marker's name.
90
+ * Hence the suffix: the marker must say `declarable`, whoever owns it.
91
+ *
92
+ * ## What the suffix cannot do
93
+ *
94
+ * It is a convention, not a registry. A host that marks with, say,
95
+ * `Symbol.for("acme.entity")` carries a perfectly good non-enumerable marker
96
+ * and is refused here, because nothing in this function knows that package is
97
+ * one the caller named. The context that would settle it —
98
+ * `FoldProjectOptions.lexiconPackages`, chant#2438 — lives in the fold path and
99
+ * does not reach a type guard called from a dozen places.
100
+ *
101
+ * That is a deliberate trade, not an oversight: a convention that refuses an
102
+ * unknown-but-valid marker costs a host one symbol name, while a rule loose
103
+ * enough to accept any marker miscounts chant's own seven kinds on every build.
104
+ * If a real host ever needs the other side of it, the fix is to thread the
105
+ * named packages through rather than to widen the test.
52
106
  */
53
107
  export function isDeclarable(value: unknown): value is Declarable {
54
- return (
55
- typeof value === "object" &&
56
- value !== null &&
57
- DECLARABLE_MARKER in value &&
58
- (value as Record<symbol, unknown>)[DECLARABLE_MARKER] === true
59
- );
108
+ if (typeof value !== "object" || value === null) return false;
109
+ // chant's own, which is every entity a chant build makes.
110
+ if ((value as Record<symbol, unknown>)[DECLARABLE_MARKER] === true) return true;
111
+ return carriesForeignDeclarableMarker(value);
112
+ }
113
+
114
+ /** A declarable marker some other implementation owns — see {@link isDeclarable}. */
115
+ function carriesForeignDeclarableMarker(value: object): boolean {
116
+ for (const marker of Object.getOwnPropertySymbols(value)) {
117
+ const descriptor = Object.getOwnPropertyDescriptor(value, marker);
118
+ // Non-enumerable and `true`, per F-Host-Interface item 1. An enumerable
119
+ // symbol would travel through a spread and reach an artifact, which a
120
+ // marker must not.
121
+ if (descriptor === undefined || descriptor.enumerable || descriptor.value !== true) continue;
122
+ if (marker.description?.endsWith(DECLARABLE_MARKER_SUFFIX) === true) return true;
123
+ }
124
+ return false;
60
125
  }
61
126
 
62
127
  /**
@@ -85,6 +85,7 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
85
85
  `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
86
86
  `import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
87
87
  `import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
88
+ `import { writeSync } from "node:fs";`,
88
89
  ``,
89
90
  `const BUILD_ROOT = ${lit(buildRoot)};`,
90
91
  ``,
@@ -95,7 +96,29 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
95
96
  // them exactly. Bound before any project import below.
96
97
  `setBuildParams(${lit({ ...currentBuildParams })});`,
97
98
  ``,
99
+ // chant#2461 — which file was being imported when the loop drained.
100
+ //
101
+ // A project file whose module scope awaits something that never settles
102
+ // makes its `await import(...)` never complete. Nothing keeps the loop
103
+ // alive, so Node exits 0 having sent nothing, and `main().catch` never
104
+ // fires because an unsettled promise is not a rejection. The parent then
105
+ // knows only that the child vanished.
106
+ //
107
+ // The child DOES know, right up to the moment it exits. `process.on("exit")`
108
+ // runs synchronously, so it cannot `process.send` — that is asynchronous and
109
+ // the channel will never be serviced — but a synchronous `writeSync` to fd 2
110
+ // lands, and the parent already captures and forwards stderr.
111
+ `let importing;`,
112
+ `let reported = false;`,
113
+ `process.on("exit", () => {`,
114
+ ` if (reported || importing === undefined) return;`,
115
+ ` writeSync(2, "chant: this file's module scope never finished evaluating, so the build could not " +`,
116
+ ` "report anything: " + importing + "\\n A top-level \`await\` that never settles does this. " +`,
117
+ ` "Nothing is thrown, so nothing else can name it (chant#2461).\\n");`,
118
+ `});`,
119
+ ``,
98
120
  `function send(payload) {`,
121
+ ` reported = true;`,
99
122
  ` if (typeof process.send === "function") process.send(payload);`,
100
123
  ` else console.log(JSON.stringify(payload));`,
101
124
  `}`,
@@ -115,12 +138,14 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
115
138
 
116
139
  for (const file of files) {
117
140
  lines.push(
141
+ ` importing = ${lit(file)};`,
118
142
  ` try {`,
119
143
  ` const mod = await import(${lit(file)});`,
120
144
  ` modules.push({ file: ${lit(file)}, exports: mod });`,
121
145
  ` } catch (err) {`,
122
146
  ` errors.push(classifyChildError(${lit(file)}, err).toJSON());`,
123
147
  ` }`,
148
+ ` importing = undefined;`,
124
149
  );
125
150
  }
126
151