@intentius/chant 0.94.0 → 0.95.1

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 (114) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -4
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/op/builders.d.ts +14 -3
  9. package/dist/op/builders.d.ts.map +1 -1
  10. package/dist/op/index.d.ts +6 -3
  11. package/dist/op/index.d.ts.map +1 -1
  12. package/dist/op/operator.d.ts +90 -0
  13. package/dist/op/operator.d.ts.map +1 -1
  14. package/dist/op/steward-beside.d.ts +84 -0
  15. package/dist/op/steward-beside.d.ts.map +1 -0
  16. package/dist/op/steward.d.ts +87 -2
  17. package/dist/op/steward.d.ts.map +1 -1
  18. package/dist/workspace/box-services.d.ts +31 -0
  19. package/dist/workspace/box-services.d.ts.map +1 -0
  20. package/dist/workspace/compose-graph.d.ts +11 -0
  21. package/dist/workspace/compose-graph.d.ts.map +1 -1
  22. package/dist/workspace/composites.d.ts +5 -1
  23. package/dist/workspace/composites.d.ts.map +1 -1
  24. package/dist/workspace/declaration.d.ts +37 -0
  25. package/dist/workspace/declaration.d.ts.map +1 -1
  26. package/dist/workspace/declaration.schema.json +57 -1
  27. package/dist/workspace/graph-cache.d.ts +168 -0
  28. package/dist/workspace/graph-cache.d.ts.map +1 -0
  29. package/dist/workspace/graph-cli.d.ts +11 -5
  30. package/dist/workspace/graph-cli.d.ts.map +1 -1
  31. package/dist/workspace/kind-readers.d.ts +39 -0
  32. package/dist/workspace/kind-readers.d.ts.map +1 -0
  33. package/dist/workspace/kinds.d.ts +29 -0
  34. package/dist/workspace/kinds.d.ts.map +1 -1
  35. package/dist/workspace/member-commands.d.ts +15 -1
  36. package/dist/workspace/member-commands.d.ts.map +1 -1
  37. package/dist/workspace/member-run.d.ts +2 -0
  38. package/dist/workspace/member-run.d.ts.map +1 -1
  39. package/dist/workspace/reason-codes.d.ts +3 -1
  40. package/dist/workspace/reason-codes.d.ts.map +1 -1
  41. package/dist/workspace/records-cli.d.ts +10 -1
  42. package/dist/workspace/records-cli.d.ts.map +1 -1
  43. package/dist/workspace/records-write.d.ts +4 -2
  44. package/dist/workspace/records-write.d.ts.map +1 -1
  45. package/dist/workspace/records.d.ts +8 -3
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/status-stewards.d.ts +23 -7
  48. package/dist/workspace/status-stewards.d.ts.map +1 -1
  49. package/dist/workspace/status.d.ts +12 -0
  50. package/dist/workspace/status.d.ts.map +1 -1
  51. package/dist/workspace/work-evidence.d.ts +1 -1
  52. package/dist/workspace/work-evidence.d.ts.map +1 -1
  53. package/dist/workspace/workspace-kinds.schema.json +26 -0
  54. package/package.json +1 -1
  55. package/src/cli/commands/carve-bridge.test.ts +7 -3
  56. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  57. package/src/cli/handlers/operator.ts +53 -11
  58. package/src/cli/handlers/run.test.ts +71 -0
  59. package/src/cli/handlers/run.ts +65 -7
  60. package/src/cli/main.ts +17 -6
  61. package/src/cli/mcp/server.test.ts +14 -0
  62. package/src/cli/mcp/server.ts +10 -4
  63. package/src/cli/mcp/workspace-tools.ts +1 -1
  64. package/src/cli/registry.ts +2 -0
  65. package/src/cli/static-config-read.test.ts +8 -2
  66. package/src/meta/source-is-text.test.ts +21 -3
  67. package/src/okf.test.ts +6 -1
  68. package/src/op/builders.ts +14 -3
  69. package/src/op/index.ts +8 -2
  70. package/src/op/operator.ts +264 -16
  71. package/src/op/steward-beside.test.ts +267 -0
  72. package/src/op/steward-beside.ts +219 -0
  73. package/src/op/steward-points.test.ts +61 -1
  74. package/src/op/steward.ts +135 -3
  75. package/src/workspace/box-services.test.ts +129 -0
  76. package/src/workspace/box-services.ts +51 -0
  77. package/src/workspace/checks/boxes.test.ts +1 -0
  78. package/src/workspace/compose-graph.test.ts +1 -0
  79. package/src/workspace/compose-graph.ts +11 -0
  80. package/src/workspace/composites.test.ts +1 -1
  81. package/src/workspace/composites.ts +12 -5
  82. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  83. package/src/workspace/declaration.schema.json +57 -1
  84. package/src/workspace/declaration.ts +104 -0
  85. package/src/workspace/graph-cache.test.ts +343 -0
  86. package/src/workspace/graph-cache.ts +409 -0
  87. package/src/workspace/graph-cli.ts +93 -26
  88. package/src/workspace/graph-contract.test.ts +129 -6
  89. package/src/workspace/graph.schema.json +21 -1
  90. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  91. package/src/workspace/kind-readers.test.ts +134 -0
  92. package/src/workspace/kind-readers.ts +111 -0
  93. package/src/workspace/kinds.test.ts +49 -0
  94. package/src/workspace/kinds.ts +59 -2
  95. package/src/workspace/member-commands.test.ts +15 -0
  96. package/src/workspace/member-commands.ts +47 -7
  97. package/src/workspace/member-run.ts +10 -2
  98. package/src/workspace/reason-codes.ts +3 -1
  99. package/src/workspace/records-amend.schema.json +1 -0
  100. package/src/workspace/records-cli.ts +20 -12
  101. package/src/workspace/records-contract.test.ts +2 -1
  102. package/src/workspace/records-new.schema.json +1 -0
  103. package/src/workspace/records-quorum.test.ts +10 -3
  104. package/src/workspace/records-sessions-write.test.ts +2 -1
  105. package/src/workspace/records-write.test.ts +101 -1
  106. package/src/workspace/records-write.ts +50 -6
  107. package/src/workspace/records.ts +22 -5
  108. package/src/workspace/status-contract.test.ts +31 -1
  109. package/src/workspace/status-stewards.ts +39 -6
  110. package/src/workspace/status.schema.json +49 -4
  111. package/src/workspace/status.ts +22 -0
  112. package/src/workspace/trust/record-seal.test.ts +26 -2
  113. package/src/workspace/work-evidence.schema.json +1 -0
  114. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -11,7 +11,7 @@
11
11
  * reference workspace runs a real one (`read-contract.test.ts`).
12
12
  */
13
13
 
14
- import { rmSync, writeFileSync } from "node:fs";
14
+ import { existsSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { afterAll, describe, expect, test } from "vitest";
17
17
  import { cleanScratch, commitAll, contract, declaration, declaration as declaration_, FAKE_GRAPH_CHANT, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
@@ -60,7 +60,7 @@ describe("graph output schema", () => {
60
60
  test("lists exactly the reason and error codes the code can return", () => {
61
61
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
62
62
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...GRAPH_ERROR_CODES]);
63
- expect(GRAPH_ERROR_CODES).toEqual(WORKSPACE_ERROR_CODES);
63
+ expect(GRAPH_ERROR_CODES).toEqual([...WORKSPACE_ERROR_CODES, "live-at-revision"]);
64
64
  });
65
65
  });
66
66
 
@@ -89,7 +89,7 @@ describe("the links section (#2539)", () => {
89
89
  ]),
90
90
  "chant.workspace.json",
91
91
  );
92
- const member = (name: string) => ({ name, dir: name, kind: "chant", status: "composed" as const, reason: null, chant: "0.81.0", irVersion: 1 });
92
+ const member = (name: string) => ({ name, dir: name, kind: "chant", status: "composed" as const, reason: null, chant: "0.81.0", irVersion: 1, live: false });
93
93
  const graph = composeWorkspaceGraph(
94
94
  { name: "acme", root: "." },
95
95
  [
@@ -107,8 +107,15 @@ describe("the links section (#2539)", () => {
107
107
  });
108
108
 
109
109
  describe("chant workspace graph on the chant repo (#2557)", () => {
110
- test("validates, and lists every member as skipped: none is kind chant", async () => {
111
- const { doc, failed } = await workspaceGraph({ cwd: join(REPO, "packages", "core") });
110
+ // The one terraform member runs through the terraform lexicon (#2874), a
111
+ // real chant per read; kind-readers.e2e.test.ts covers that. Here it is
112
+ // left out, so nothing runs.
113
+ const notRun = (JSON.parse(readFileSync(join(REPO, "chant.workspace.json"), "utf-8")) as { members: { name: string; kind: string }[] }).members
114
+ .filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu")
115
+ .map((m) => m.name);
116
+
117
+ test("validates, and lists every member but the terraform one as skipped: none is kind chant", async () => {
118
+ const { doc, failed } = await workspaceGraph({ cwd: join(REPO, "packages", "core"), members: notRun });
112
119
  const g = result(doc);
113
120
  expectValid(g);
114
121
  expect(failed).toBe(false);
@@ -119,7 +126,7 @@ describe("chant workspace graph on the chant repo (#2557)", () => {
119
126
 
120
127
  test("--at HEAD reads from git objects and exports nothing when no member runs", async () => {
121
128
  const head = git(REPO, "rev-parse", "HEAD");
122
- const { doc } = await workspaceGraph({ cwd: REPO, at: "HEAD" });
129
+ const { doc } = await workspaceGraph({ cwd: REPO, at: "HEAD", members: notRun });
123
130
  const g = result(doc);
124
131
  expectValid(g);
125
132
  expect(g.at).toBe(head);
@@ -203,3 +210,119 @@ describe("chant workspace graph on built workspaces", () => {
203
210
  ]);
204
211
  });
205
212
  });
213
+
214
+ /**
215
+ * A chant older than member-run that records its command line in argv.txt
216
+ * beside the member, outside its directory so the member cache's stamp holds,
217
+ * and prints ir.json, or live.json when asked for --live (#2875).
218
+ */
219
+ const FAKE_LIVE_CHANT = `#!/bin/sh
220
+ [ "$1" = graph ] || { echo "Error: Unknown command: $1" >&2; exit 1; }
221
+ printf '%s\\n' "$*" > ../argv.txt
222
+ case " $* " in
223
+ *" --live "*) cat live.json ;;
224
+ *) cat ir.json ;;
225
+ esac
226
+ `;
227
+
228
+ /** Every file under `dir` an hour old, past the member cache's freshness window. */
229
+ function age(dir: string): void {
230
+ const then = new Date(Date.now() - 3_600_000);
231
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
232
+ const path = join(dir, e.name);
233
+ if (e.isDirectory()) age(path);
234
+ else if (e.isFile()) utimesSync(path, then, then);
235
+ }
236
+ }
237
+
238
+ describe("the live read (#2875)", () => {
239
+ const source = { version: 1, nodes: [{ id: "Queue", kind: "Thing", lexicon: "fake", attrs: {} }], edges: [], groups: {} };
240
+ const live = {
241
+ version: 1,
242
+ nodes: [
243
+ { id: "Queue", kind: "Thing", lexicon: "fake", attrs: { _status: "good", _drift: { fields: ["size"] }, _behaviour: { cost: { perHour: 1.5, currency: "USD" } } } },
244
+ { id: "Stray", kind: "Thing", lexicon: "fake", attrs: { _status: "warn", _overlay: "foreign", peer: { $ref: "Queue" } } },
245
+ ],
246
+ edges: [],
247
+ groups: {},
248
+ meta: { _behaviour: { engine: "fixture", traffic: "100 rps" } },
249
+ };
250
+ const liveWorkspace = () =>
251
+ repo({
252
+ "chant.workspace.json": declaration([
253
+ { name: "api", dir: "api", kind: "chant" },
254
+ { name: "docs", dir: "docs", kind: "other", because: "prose" },
255
+ ]),
256
+ "api/chant.config.ts": "export default {};\n",
257
+ "api/ir.json": JSON.stringify(source),
258
+ "api/live.json": JSON.stringify(live),
259
+ "docs/README.md": "",
260
+ ".gitignore": "node_modules\n",
261
+ "node_modules/.bin/chant": { text: FAKE_LIVE_CHANT, mode: 0o755 },
262
+ });
263
+
264
+ test("hands --live, --overlay and --traffic to every member, and marks each member read live", async () => {
265
+ const root = liveWorkspace();
266
+ const before = Date.now();
267
+ const { doc, failed } = await workspaceGraph({ cwd: root, args: { env: "prod", live: true, overlay: true, traffic: "100 rps" } });
268
+ const g = result(doc);
269
+ expectValid(g);
270
+ expect(failed).toBe(false);
271
+ expect(readFileSync(join(root, "argv.txt"), "utf-8").trim()).toBe("graph . --format ir --env prod --live --overlay --traffic 100 rps");
272
+ const api = g.members.find((m) => m.name === "api")!;
273
+ expect(api).toMatchObject({ status: "composed", live: true });
274
+ expect(Date.parse(api.readAt!)).toBeGreaterThanOrEqual(before - 1000);
275
+ expect(g.members.find((m) => m.name === "docs")).toMatchObject({ status: "skipped", live: false });
276
+ expect(g.members.find((m) => m.name === "docs")!.readAt).toBeUndefined();
277
+ });
278
+
279
+ test("the drift, overlay and behaviour a member's chant wrote pass through composition untouched", async () => {
280
+ const { doc } = await workspaceGraph({ cwd: liveWorkspace(), args: { env: "prod", live: true, overlay: true } });
281
+ const g = result(doc);
282
+ expect(g.nodes.find((n) => n.id === "api/Queue")!.attrs).toEqual(live.nodes[0].attrs);
283
+ // Only $ref values are prefixed; every other attribute is as the member printed it.
284
+ expect(g.nodes.find((n) => n.id === "api/Stray")!.attrs).toEqual({ _status: "warn", _overlay: "foreign", peer: { $ref: "api/Queue" } });
285
+ expect(g.members.find((m) => m.name === "api")!.meta).toEqual(live.meta);
286
+ });
287
+
288
+ test("a source read marks every member not live, with no read time", async () => {
289
+ const { doc } = await workspaceGraph({ cwd: liveWorkspace() });
290
+ const g = result(doc);
291
+ expectValid(g);
292
+ expect(g.members.map((m) => [m.name, m.live, m.readAt ?? null])).toEqual([["api", false, null], ["docs", false, null]]);
293
+ expect(g.nodes.map((n) => n.id)).toEqual(["api/Queue"]);
294
+ });
295
+
296
+ test("a live read bypasses the member cache: it is never served from it and never stored (#2876)", async () => {
297
+ const root = liveWorkspace();
298
+ // Past the cache's freshness window, so a source read is stored.
299
+ age(root);
300
+ // A source read fills the cache; the live read after it still runs the member.
301
+ await workspaceGraph({ cwd: root });
302
+ const argv = join(root, "argv.txt");
303
+ rmSync(argv);
304
+ const live1 = result((await workspaceGraph({ cwd: root, args: { env: "prod", live: true, overlay: true } })).doc);
305
+ expect(existsSync(argv)).toBe(true);
306
+ expect(live1.members.find((m) => m.name === "api")).toMatchObject({ live: true, cached: false, stamp: null });
307
+ expect(live1.nodes.map((n) => n.id)).toEqual(["api/Queue", "api/Stray"]);
308
+ // A second live read runs again: nothing from the first was stored.
309
+ rmSync(argv);
310
+ const live2 = result((await workspaceGraph({ cwd: root, args: { env: "prod", live: true, overlay: true } })).doc);
311
+ expect(existsSync(argv)).toBe(true);
312
+ expect(live2.members.find((m) => m.name === "api")).toMatchObject({ live: true, cached: false });
313
+ // And the source read is still served as before, with no live attributes.
314
+ const again = result((await workspaceGraph({ cwd: root })).doc);
315
+ expect(again.members.find((m) => m.name === "api")).toMatchObject({ live: false, cached: true });
316
+ expect(again.nodes.map((n) => n.id)).toEqual(["api/Queue"]);
317
+ });
318
+
319
+ test("--live with --at is refused with live-at-revision, and runs no member", async () => {
320
+ const root = liveWorkspace();
321
+ commitAll(root);
322
+ const { doc, failed } = await workspaceGraph({ cwd: root, at: "HEAD", args: { env: "prod", live: true } });
323
+ expectValid(doc);
324
+ expect(failed).toBe(true);
325
+ expect("error" in doc && doc.error.code).toBe("live-at-revision");
326
+ expect(existsSync(join(root, "argv.txt"))).toBe(false);
327
+ });
328
+ });
@@ -305,6 +305,25 @@
305
305
  ],
306
306
  "description": "The IR version the member printed, or null when it printed none and was read as version 1."
307
307
  },
308
+ "live": {
309
+ "type": "boolean",
310
+ "description": "Whether the member was read with --live (#2875): its graph is the account as it stands, and its nodes carry whatever _drift, _overlay and _behaviour attributes its chant wrote. false for a source read and for a member that did not run. A document from a chant before #2875 has no live field, and a reader may take that as false."
311
+ },
312
+ "readAt": {
313
+ "type": "string",
314
+ "description": "For a live read, as an ISO 8601 time: when the member's read finished. A live member of a toolchain whose member-run names no time gets the time its toolchain's process ended."
315
+ },
316
+ "cached": {
317
+ "type": "boolean",
318
+ "description": "Whether chant's per-member cache answered this read (#2876). Present on composed members. A cached read is the output the member's own chant printed for the same stamp, toolchain, command line and environment."
319
+ },
320
+ "stamp": {
321
+ "type": [
322
+ "string",
323
+ "null"
324
+ ],
325
+ "description": "What the per-member cache keys this read on (#2876): sha256 over the member's files and install state, or over the commit id with --at. Present on composed members; null for a live read or when no stamp could be taken. Two reads of a member with equal stamps read the same source."
326
+ },
308
327
  "meta": {
309
328
  "type": "object",
310
329
  "description": "The member IR's whole-read facts."
@@ -460,7 +479,8 @@
460
479
  "reader-too-old",
461
480
  "root-chant-required",
462
481
  "not-a-git-repository",
463
- "revision-unknown"
482
+ "revision-unknown",
483
+ "live-at-revision"
464
484
  ]
465
485
  },
466
486
  "message": {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `chant workspace graph` reads `terraform` and `choudoufu` members through
3
+ * the terraform lexicon (#2874), end to end: the lexicon's own kinds file,
4
+ * this checkout's chant as the members' toolchain, and a real HCL parse.
5
+ *
6
+ * The workspace links this checkout's `node_modules`, where the lexicon is
7
+ * installed, and pins it at its version. Its `net` root calls
8
+ * `../modules/subnets`, which lies outside the member but inside the
9
+ * workspace, so the module's resources are only read when the reader
10
+ * project's `moduleRoot` is the workspace root.
11
+ */
12
+
13
+ import { readFileSync, symlinkSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { afterAll, describe, expect, test } from "vitest";
16
+ import { cleanScratch, contract, declaration, REPO, repo } from "./__fixtures__/contract-repo";
17
+ import { workspaceGraph, type GraphDocument } from "./graph-cli";
18
+ import schema from "./graph.schema.json";
19
+
20
+ afterAll(cleanScratch);
21
+
22
+ const { expectValid } = contract(schema);
23
+ const TIMEOUT = 240_000;
24
+ const LEXICON = "@intentius/chant-lexicon-terraform";
25
+ const VERSION = (JSON.parse(readFileSync(join(REPO, "lexicons", "terraform", "package.json"), "utf-8")) as { version: string }).version;
26
+
27
+ function result(doc: GraphDocument): Extract<GraphDocument, { nodes: unknown }> {
28
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
29
+ return doc;
30
+ }
31
+
32
+ describe("terraform and choudoufu members through the terraform lexicon (#2874)", () => {
33
+ test(
34
+ "compose with <member>/<root>/<address> ids, their local modules included",
35
+ async () => {
36
+ const root = repo({
37
+ "chant.workspace.json": declaration(
38
+ [
39
+ { name: "net", dir: "estates/net", kind: "terraform" },
40
+ { name: "shop", dir: "estates/shop", kind: "choudoufu" },
41
+ ],
42
+ { pins: [{ package: LEXICON, version: VERSION }] },
43
+ ),
44
+ "estates/net/main.tf": 'resource "aws_vpc" "main" {\n cidr_block = "10.0.0.0/16"\n}\n\nmodule "subnets" {\n source = "../modules/subnets"\n vpc_id = aws_vpc.main.id\n}\n',
45
+ "estates/modules/subnets/main.tf": 'variable "vpc_id" {}\n\nresource "aws_subnet" "a" {\n vpc_id = var.vpc_id\n cidr_block = "10.0.1.0/24"\n}\n',
46
+ "estates/shop/estate.chdf.hcl": 'estate = "shop"\n',
47
+ "estates/shop/main.tf": 'resource "aws_sns_topic" "orders" {\n name = "orders"\n}\n',
48
+ ".gitignore": "node_modules\n",
49
+ });
50
+ symlinkSync(join(REPO, "node_modules"), join(root, "node_modules"), "dir");
51
+
52
+ const { doc, failed } = await workspaceGraph({ cwd: root });
53
+ const g = result(doc);
54
+ expectValid(g);
55
+ expect(g.members.map((m) => [m.name, m.kind, m.status, m.reason?.message ?? null])).toEqual([
56
+ ["net", "terraform", "composed", null],
57
+ ["shop", "choudoufu", "composed", null],
58
+ ]);
59
+ expect(failed).toBe(false);
60
+ const ids = g.nodes.map((n) => n.id);
61
+ expect(ids).toContain("net/net/aws_vpc.main");
62
+ expect(ids).toContain("net/net/module.subnets/aws_subnet.a");
63
+ expect(ids).toContain("shop/shop/aws_sns_topic.orders");
64
+ expect(g.groups.byMember.net).toContain("net/net/module.subnets/aws_subnet.a");
65
+ },
66
+ TIMEOUT,
67
+ );
68
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Members of a package's kind read through a reader project (#2874).
3
+ *
4
+ * The workspace pins a fake lexicon package whose kinds file carries a
5
+ * `graph` block, and its members run under a fake chant installed in the
6
+ * workspace's `node_modules/.bin`. That chant answers `chant graph` in the
7
+ * reader project with one node carrying what it found there: the config
8
+ * chant wrote, whether the lexicon resolves through `node_modules`, and its
9
+ * own directory, so a test can check the project is gone afterwards. The
10
+ * real terraform lexicon runs in `kind-readers.e2e.test.ts`.
11
+ */
12
+
13
+ import { existsSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { afterAll, describe, expect, test } from "vitest";
16
+ import { cleanScratch, commitAll, contract, declaration, repo } from "./__fixtures__/contract-repo";
17
+ import { workspaceGraph, type GraphDocument } from "./graph-cli";
18
+ import { planMembers } from "./member-commands";
19
+ import { loadKindRegistry } from "./kinds";
20
+ import schema from "./graph.schema.json";
21
+
22
+ afterAll(cleanScratch);
23
+
24
+ const { expectValid } = contract(schema);
25
+
26
+ function result(doc: GraphDocument): Extract<GraphDocument, { nodes: unknown }> {
27
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
28
+ return doc;
29
+ }
30
+
31
+ /** Prints one node whose attrs hold the reader project's config, whether the lexicon resolves, the argv, and the directory. */
32
+ const READER_CHANT = `#!/bin/sh
33
+ [ "$1" = graph ] || { echo "Error: Unknown command: $1" >&2; exit 1; }
34
+ if [ -f node_modules/@intentius/chant-lexicon-fake/package.json ]; then found=true; else found=false; fi
35
+ printf '{"version":1,"nodes":[{"id":"root/Thing","kind":"Thing","lexicon":"fake","attrs":{"found":%s,"pwd":"%s","argv":"%s","config":' "$found" "$(pwd -P)" "$*"
36
+ cat chant.config.json
37
+ printf '}}],"edges":[],"groups":{"byLexicon":{"fake":["root/Thing"]}}}\\n'
38
+ `;
39
+
40
+ const GRAPH = { lexicon: "fake", config: { moduleRoot: "{workspace}", roots: { "{member}": { dir: "{dir}" } } } };
41
+
42
+ function lexiconPackage(name: string, kinds: unknown[]): Record<string, string> {
43
+ return {
44
+ [`node_modules/${name}/package.json`]: JSON.stringify({ name, version: "1.0.0", exports: { ".": "./index.js", "./workspace-kinds": "./workspace-kinds.json" } }),
45
+ [`node_modules/${name}/workspace-kinds.json`]: JSON.stringify({ schema: 1, kinds }),
46
+ };
47
+ }
48
+
49
+ const TF_KIND = { name: "tf", description: "a root", precedence: 400, probe: { anyFile: ["*.tf"] }, graph: GRAPH };
50
+
51
+ function workspace(extra: Record<string, string | { text: string; mode: number }> = {}, pins = [{ package: "@intentius/chant-lexicon-fake", version: "1.0.0" }]): string {
52
+ return repo({
53
+ "chant.workspace.json": declaration(
54
+ [
55
+ { name: "net", dir: "estates/net", kind: "tf" },
56
+ { name: "docs", dir: "docs", kind: "other", because: "prose" },
57
+ ],
58
+ { pins },
59
+ ),
60
+ "estates/net/main.tf": 'module "vpc" { source = "../modules/vpc" }\n',
61
+ "estates/modules/vpc/main.tf": "",
62
+ "docs/README.md": "",
63
+ ".gitignore": "node_modules\n",
64
+ "node_modules/.bin/chant": { text: READER_CHANT, mode: 0o755 },
65
+ ...lexiconPackage("@intentius/chant-lexicon-fake", [TF_KIND]),
66
+ ...extra,
67
+ });
68
+ }
69
+
70
+ describe("workspace graph reads a package kind with a graph block (#2874)", () => {
71
+ test("in a reader project of its own, with the kind's config substituted and the pinned lexicon linked", async () => {
72
+ const root = workspace();
73
+ const { doc, failed } = await workspaceGraph({ cwd: root, args: { env: "prod" } });
74
+ const g = result(doc);
75
+ expectValid(g);
76
+ expect(failed).toBe(false);
77
+ expect(g.members.map((m) => [m.name, m.kind, m.status, m.reason?.code ?? null])).toEqual([
78
+ ["net", "tf", "composed", null],
79
+ ["docs", "other", "skipped", "kind-not-run"],
80
+ ]);
81
+ expect(g.nodes.map((n) => n.id)).toEqual(["net/root/Thing"]);
82
+ const attrs = g.nodes[0].attrs as { found: boolean; pwd: string; argv: string; config: Record<string, unknown> };
83
+ expect(attrs.config).toEqual({ lexicons: ["fake"], fake: { moduleRoot: root, roots: { net: { dir: `${root}/estates/net` } } } });
84
+ expect(attrs.found).toBe(true);
85
+ // A reader project declares no environments, so --env stays with the chant members.
86
+ expect(attrs.argv).toBe("graph . --format ir");
87
+ expect(attrs.pwd.startsWith(root)).toBe(false);
88
+ expect(existsSync(attrs.pwd)).toBe(false);
89
+ });
90
+
91
+ test("--at points the reader at the revision's tree", async () => {
92
+ const root = workspace();
93
+ const sha = commitAll(root);
94
+ const g = result((await workspaceGraph({ cwd: root, at: sha })).doc);
95
+ expectValid(g);
96
+ const config = (g.nodes[0].attrs as { config: { fake: { moduleRoot: string; roots: { net: { dir: string } } } } }).config.fake;
97
+ expect(config.moduleRoot).not.toBe(root);
98
+ expect(config.roots.net.dir).toBe(`${config.moduleRoot}/estates/net`);
99
+ });
100
+
101
+ test("a lexicon found somewhere other than its pin fails the member with command-failed, and the rest still run", async () => {
102
+ const root = workspace(
103
+ { "plugins/fake/package.json": JSON.stringify({ name: "@intentius/chant-lexicon-fake", version: "1.0.0", exports: { "./workspace-kinds": "./workspace-kinds.json" } }), "plugins/fake/workspace-kinds.json": JSON.stringify({ schema: 1, kinds: [TF_KIND] }) },
104
+ [{ path: "plugins/fake" } as never],
105
+ );
106
+ const { doc, failed } = await workspaceGraph({ cwd: root });
107
+ const g = result(doc);
108
+ expectValid(g);
109
+ expect(failed).toBe(true);
110
+ const net = g.members.find((m) => m.name === "net")!;
111
+ expect(net).toMatchObject({ status: "failed", reason: { code: "command-failed" } });
112
+ expect(net.reason!.message).toMatch(/install the pinned package/);
113
+ expect(g.members.find((m) => m.name === "docs")).toMatchObject({ status: "skipped" });
114
+ });
115
+
116
+ test("a member whose directory the kind's probe doesn't claim is kind-probe-failed, and nothing runs", async () => {
117
+ const root = workspace();
118
+ rmSync(join(root, "estates", "net", "main.tf"));
119
+ const g = result((await workspaceGraph({ cwd: root })).doc);
120
+ expect(g.members.find((m) => m.name === "net")).toMatchObject({ status: "failed", reason: { code: "kind-probe-failed" } });
121
+ expect(g.nodes).toEqual([]);
122
+ });
123
+
124
+ test("build, lint and audit still skip the kind with kind-not-run", () => {
125
+ const root = workspace();
126
+ const { registry } = loadKindRegistry([{ package: "@intentius/chant-lexicon-fake", version: "1.0.0", path: null }], root);
127
+ for (const verb of ["build", "lint", "audit"] as const) {
128
+ const plan = planMembers(verb, root, { kinds: registry });
129
+ expect(plan.groups).toEqual([]);
130
+ expect(plan.skipped.find((s) => s.name === "net")?.reason.code).toBe("kind-not-run");
131
+ }
132
+ expect(planMembers("graph", root, { kinds: registry }).groups[0].units[0].reader).toMatchObject({ lexicon: "fake" });
133
+ });
134
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Reader projects for members of a package's kind (#2874).
3
+ *
4
+ * `chant workspace graph` runs `chant graph` in each member. A `terraform` or
5
+ * `choudoufu` member is a directory of `.tf` files with no chant config, so
6
+ * there is nothing for it to run. The kind's `graph` block in the package's
7
+ * kinds file says how to read one instead: a lexicon and that lexicon's
8
+ * config namespace. For each such member chant writes a reader project of its
9
+ * own, outside the workspace, and the member runs there:
10
+ *
11
+ * - `chant.config.json` declares the lexicon, and puts the kind's config under
12
+ * the lexicon's key with `{member}`, `{dir}` and `{workspace}` substituted
13
+ * (`substituteGraphConfig`). The terraform lexicon's config names one root
14
+ * after the member, at the member's directory, with `moduleRoot` at the
15
+ * workspace root, so a member that calls `../modules/x` keeps its modules.
16
+ * - `node_modules` links to the `node_modules` directory holding the lexicon
17
+ * package, found from the workspace root as the kind's pin was. The lexicon
18
+ * chant loads is then the one the declaration pins.
19
+ *
20
+ * Nothing is written under the workspace, and the projects are removed when
21
+ * the read ends. A lexicon that can't be found where its pin says fails the
22
+ * member with `command-failed` and says what to install; the other members
23
+ * still run.
24
+ */
25
+
26
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { findInstalledPackage, lexiconPackageName, substituteGraphConfig } from "./kinds";
30
+ import type { MemberPlan, RunUnit, SkippedEntry } from "./member-commands";
31
+
32
+ export interface PreparedReaders {
33
+ /** Members whose reader project couldn't be written, with the reason. They fail the run. */
34
+ failed: SkippedEntry[];
35
+ /** Remove every reader project. Safe to call more than once. */
36
+ cleanup(): void;
37
+ }
38
+
39
+ /** The `node_modules` directory an installed package sits in. */
40
+ function nodeModulesOf(packageDir: string, name: string): string {
41
+ let dir = packageDir;
42
+ for (let i = 0; i < name.split("/").length; i++) dir = join(dir, "..");
43
+ return dir;
44
+ }
45
+
46
+ function real(path: string): string {
47
+ try {
48
+ return realpathSync(path);
49
+ } catch {
50
+ return path;
51
+ }
52
+ }
53
+
54
+ /** Why a unit's lexicon can't be loaded from `root`, or the `node_modules` to link. */
55
+ function lexiconModules(unit: RunUnit, root: string): { modules: string } | { message: string } {
56
+ const reader = unit.reader!;
57
+ const name = lexiconPackageName(reader.lexicon);
58
+ const installed = findInstalledPackage(name, root);
59
+ if (!installed) {
60
+ return {
61
+ message: `kind ${unit.kind} is read by the ${reader.lexicon} lexicon, and ${name} is not installed in a node_modules at or above ${root}; install it there to read this member`,
62
+ };
63
+ }
64
+ if (real(installed) !== real(reader.packageDir)) {
65
+ return {
66
+ message: `kind ${unit.kind} comes from ${reader.packageDir}, and the ${name} found from ${root} is ${real(installed)}; install the pinned package where the workspace resolves it`,
67
+ };
68
+ }
69
+ return { modules: nodeModulesOf(installed, name) };
70
+ }
71
+
72
+ /**
73
+ * Write a reader project for every unit of `plan` that has a `reader`, and
74
+ * point the unit's `abs` at it. Units that can't get one leave the plan and
75
+ * are returned in `failed`. `root` is the directory the plan was made from,
76
+ * the exported tree for `--at`.
77
+ */
78
+ export function prepareKindReaders(plan: MemberPlan, root: string): PreparedReaders {
79
+ const failed: SkippedEntry[] = [];
80
+ let scratch: string | undefined;
81
+ const cleanup = () => {
82
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
83
+ scratch = undefined;
84
+ };
85
+ try {
86
+ for (const group of plan.groups) {
87
+ group.units = group.units.filter((unit) => {
88
+ if (!unit.reader) return true;
89
+ const found = lexiconModules(unit, root);
90
+ if ("message" in found) {
91
+ failed.push({ name: unit.member, dir: unit.dir, kind: unit.kind, reason: { code: "command-failed", message: found.message } });
92
+ return false;
93
+ }
94
+ scratch ??= mkdtempSync(join(tmpdir(), "chant-ws-reader-"));
95
+ const project = join(scratch, unit.id);
96
+ mkdirSync(project, { recursive: true });
97
+ const config = substituteGraphConfig(unit.reader.config, { member: unit.member, dir: unit.abs, workspace: root });
98
+ writeFileSync(join(project, "chant.config.json"), `${JSON.stringify({ lexicons: [unit.reader.lexicon], [unit.reader.lexicon]: config }, null, 2)}\n`);
99
+ symlinkSync(found.modules, join(project, "node_modules"), "dir");
100
+ unit.abs = project;
101
+ return true;
102
+ });
103
+ }
104
+ plan.groups = plan.groups.filter((g) => g.units.length > 0);
105
+ } catch (err) {
106
+ cleanup();
107
+ throw err;
108
+ }
109
+ plan.unreadable.push(...failed);
110
+ return { failed, cleanup };
111
+ }
@@ -17,6 +17,7 @@ import {
17
17
  probeKind,
18
18
  readPackageKinds,
19
19
  resolveKind,
20
+ substituteGraphConfig,
20
21
  type MemberKind,
21
22
  } from "./kinds";
22
23
  import schema from "./workspace-kinds.schema.json";
@@ -114,6 +115,54 @@ describe("parseKindData", () => {
114
115
  });
115
116
  });
116
117
 
118
+ describe("a kind's graph block (#2874)", () => {
119
+ const graph = { lexicon: "tf", config: { moduleRoot: "{workspace}", roots: { "{member}": { dir: "{dir}" } } } };
120
+
121
+ test("is read as data, and refuses a field it doesn't know", () => {
122
+ const data = parseKindData(kindsFile([{ ...tf, graph }]), "p");
123
+ expect(data.problems).toEqual([]);
124
+ expect(data.kinds[0].graph).toEqual(graph);
125
+ expect(parseKindData(kindsFile([{ ...tf, graph: { ...graph, run: "x" } }]), "p").problems[0]).toMatch(/additional properties/);
126
+ expect(parseKindData(kindsFile([{ ...tf, graph: { lexicon: "Bad Name", config: {} } }]), "p").problems[0]).toMatch(/lexicon/);
127
+ expect(parseKindData(kindsFile([{ ...tf, graph: { lexicon: "tf" } }]), "p").problems[0]).toMatch(/config/);
128
+ });
129
+
130
+ test("substitutes {member}, {dir} and {workspace} in keys and values, and nothing else", () => {
131
+ const out = substituteGraphConfig(
132
+ { moduleRoot: "{workspace}", roots: { "{member}": { dir: "{dir}", note: "{other} at {dir}/x" } }, list: ["{member}", 3, true, null] },
133
+ { member: "net", dir: "/ws/estates/net", workspace: "/ws" },
134
+ );
135
+ expect(out).toEqual({ moduleRoot: "/ws", roots: { net: { dir: "/ws/estates/net", note: "{other} at /ws/estates/net/x" } }, list: ["net", 3, true, null] });
136
+ });
137
+
138
+ test("records the supplying package, and is dropped with a problem when the lexicon is not that package", () => {
139
+ const root = dir({
140
+ ...pkg("@intentius/chant-lexicon-tf", "1.0.0", [{ ...tf, graph }]),
141
+ ...pkg("@acme/other", "1.0.0", [{ ...tf, name: "other-tf", graph }]),
142
+ });
143
+ const good = loadKindRegistry([{ package: "@intentius/chant-lexicon-tf", version: "1.0.0", path: null }], root);
144
+ expect(good.problems).toEqual([]);
145
+ expect(good.registry.get("terraform")).toMatchObject({ graph, packageDir: join(root, "node_modules/@intentius/chant-lexicon-tf") });
146
+
147
+ const bad = loadKindRegistry([{ package: "@acme/other", version: "1.0.0", path: null }], root);
148
+ expect(bad.problems.map((p) => p.message)).toEqual([expect.stringMatching(/other-tf reads its members with lexicon tf, which is @intentius\/chant-lexicon-tf, and this package is @acme\/other/)]);
149
+ expect(bad.registry.get("other-tf")?.graph).toBeUndefined();
150
+ expect(bad.registry.get("other-tf")).toBeDefined();
151
+ });
152
+
153
+ test("the terraform lexicon's kinds read their members with it, named for the member at the workspace root", () => {
154
+ const lexicon = join(import.meta.dirname, "..", "..", "..", "..", "lexicons", "terraform");
155
+ const read = readPackageKinds(lexicon);
156
+ expect(read.problems).toEqual([]);
157
+ for (const name of ["terraform", "choudoufu"]) {
158
+ const k = read.kinds.find((x) => x.name === name)!;
159
+ expect(k.graph?.lexicon).toBe("terraform");
160
+ expect(substituteGraphConfig(k.graph!.config, { member: "net", dir: "/ws/net", workspace: "/ws" })).toMatchObject({ moduleRoot: "/ws", roots: { net: { dir: "/ws/net" } } });
161
+ }
162
+ expect(read.kinds.find((x) => x.name === "choudoufu")!.graph!.config.binary).toBe("choudoufu");
163
+ });
164
+ });
165
+
117
166
  describe("reading a package's kinds", () => {
118
167
  test("follows only the literal ./workspace-kinds key, and only to a JSON file inside the package", () => {
119
168
  expect(kindsExportTarget({ exports: { "./*": "./src/*.ts" } })).toBeUndefined();