@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
@@ -216,10 +216,37 @@ export interface BoxDeclaration {
216
216
  * proposed with no choice until the person who answers it decides it.
217
217
  */
218
218
  intent: string | null;
219
+ /**
220
+ * The services the box runs under its supervisor (sprite-env on a sprite),
221
+ * in file order, or empty when the block declares none (#2880). The fly
222
+ * lexicon's `spriteServicesObserve`, `spriteServiceRestart` and
223
+ * `spriteApplyServices` read them with `box: true`.
224
+ */
225
+ services: BoxService[];
219
226
  /** The block's JSON Pointer in the file, for messages. */
220
227
  pointer: string;
221
228
  }
222
229
 
230
+ /** A service a box runs under its supervisor (#2880). */
231
+ export interface BoxService {
232
+ /** Unique in the box. */
233
+ name: string;
234
+ /** The command the supervisor runs, as written: `${VAR}` references are left for the applying process to expand. */
235
+ cmd: string;
236
+ /** Names of services in the same block that start first. Empty when none. */
237
+ needs: string[];
238
+ /** The port the supervisor routes the sprite's URL to, or null. At most one service of a box sets it. */
239
+ httpPort: number | null;
240
+ /** How long the service must stay up after a create or start, such as `3s` (`sprite-env services create --duration`), or null for the supervisor's default. */
241
+ duration: string | null;
242
+ /** A URL that answers 200 while the service works, or null when the supervisor's state decides. */
243
+ health: string | null;
244
+ /** True: an apply creates it only when named, and an observer skips it while the supervisor has no such service. */
245
+ optional: boolean;
246
+ /** The entry's JSON Pointer in the file, for messages. */
247
+ pointer: string;
248
+ }
249
+
223
250
  /** What a box declares about its isolation (#2727): its identity on a host, and the names of what it needs kept apart. */
224
251
  export interface BoxIsolationDeclaration {
225
252
  host: string;
@@ -629,6 +656,9 @@ export function parseDeclaration(text: string, file: string, reader: string = re
629
656
  if (first) throw new WorkspaceReadError("declaration-invalid", `member ${m.name}'s box lists the capability ${c.name} twice; the first is at ${first.pointer}`, at(`${c.pointer}/name`));
630
657
  seen.set(c.name, c);
631
658
  }
659
+ // Its services name each other only within the block, without a cycle (#2880).
660
+ const problem = m.box ? boxServicesProblem(m.name, m.box.services) : null;
661
+ if (problem) throw new WorkspaceReadError("declaration-invalid", problem.message, at(problem.pointer));
632
662
  }
633
663
 
634
664
  // A diagram name is given once across the declaration (#2764): a reader keys diagrams by name.
@@ -720,6 +750,7 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
720
750
  state?: Record<string, string>;
721
751
  cookies?: string[];
722
752
  intent?: string;
753
+ services?: { name: string; cmd: string; needs?: string[]; httpPort?: number; duration?: string; health?: string; optional?: boolean }[];
723
754
  };
724
755
  return {
725
756
  capabilities: (b.capabilities ?? []).map((c, i) => ({ name: c.name, broker: c.broker ?? null, scope: [...(c.scope ?? [])], pointer: `${pointer}/capabilities/${i}` })),
@@ -729,10 +760,83 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
729
760
  ? null
730
761
  : { host: b.host, slot: b.slot!, ports: { ...(b.ports ?? {}) }, state: { ...(b.state ?? {}) }, cookies: [...(b.cookies ?? [])] },
731
762
  intent: b.intent ?? null,
763
+ services: (b.services ?? []).map((s, i) => ({
764
+ name: s.name,
765
+ cmd: s.cmd,
766
+ needs: [...(s.needs ?? [])],
767
+ httpPort: s.httpPort ?? null,
768
+ duration: s.duration ?? null,
769
+ health: s.health ?? null,
770
+ optional: s.optional ?? false,
771
+ pointer: `${pointer}/services/${i}`,
772
+ })),
732
773
  pointer,
733
774
  };
734
775
  }
735
776
 
777
+ /**
778
+ * The rules the schema can't say about a box's services (#2880), as a
779
+ * message and the pointer to show, or null when they hold: each name is
780
+ * given once, every `needs` names a service of the same block, the `needs`
781
+ * form no cycle, and at most one service sets `httpPort`. The fly lexicon
782
+ * checks a list it is handed inline with the same rules.
783
+ */
784
+ export function boxServicesProblem(member: string, services: readonly BoxService[]): { message: string; pointer: string } | null {
785
+ const byName = new Map<string, BoxService>();
786
+ for (const s of services) {
787
+ const first = byName.get(s.name);
788
+ if (first) return { message: `member ${member}'s box declares the service ${s.name} twice; the first is at ${first.pointer}`, pointer: `${s.pointer}/name` };
789
+ byName.set(s.name, s);
790
+ }
791
+ let routed: BoxService | undefined;
792
+ for (const s of services) {
793
+ if (s.httpPort === null) continue;
794
+ if (routed) {
795
+ return {
796
+ message: `member ${member}'s box gives both ${routed.name} (port ${routed.httpPort}) and ${s.name} (port ${s.httpPort}) an httpPort, and the supervisor routes the sprite's URL to one service`,
797
+ pointer: `${s.pointer}/httpPort`,
798
+ };
799
+ }
800
+ routed = s;
801
+ }
802
+ for (const s of services) {
803
+ const j = s.needs.findIndex((n) => !byName.has(n));
804
+ if (j >= 0) {
805
+ const known = services.map((x) => x.name).join(", ");
806
+ return { message: `member ${member}'s box service ${s.name} needs ${JSON.stringify(s.needs[j])}, which the block does not declare; declared services: ${known}`, pointer: `${s.pointer}/needs/${j}` };
807
+ }
808
+ }
809
+ const cycle = serviceCycle(services);
810
+ if (cycle) {
811
+ const s = byName.get(cycle[0])!;
812
+ return { message: `member ${member}'s box services need each other in a cycle: ${cycle.join(" -> ")}`, pointer: `${s.pointer}/needs` };
813
+ }
814
+ return null;
815
+ }
816
+
817
+ /** The first cycle the services' `needs` form, as names ending where it starts, or null. */
818
+ function serviceCycle(services: readonly { name: string; needs: readonly string[] }[]): string[] | null {
819
+ const byName = new Map(services.map((s) => [s.name, s]));
820
+ const state = new Map<string, "visiting" | "done">();
821
+ const visit = (name: string, trail: string[]): string[] | null => {
822
+ const st = state.get(name);
823
+ if (st === "done") return null;
824
+ if (st === "visiting") return [...trail.slice(trail.indexOf(name)), name];
825
+ state.set(name, "visiting");
826
+ for (const dep of byName.get(name)?.needs ?? []) {
827
+ const found = visit(dep, [...trail, name]);
828
+ if (found) return found;
829
+ }
830
+ state.set(name, "done");
831
+ return null;
832
+ };
833
+ for (const s of services) {
834
+ const found = visit(s.name, []);
835
+ if (found) return found;
836
+ }
837
+ return null;
838
+ }
839
+
736
840
  /**
737
841
  * The hosts, already validated, with the rules the schema can't say (#2727):
738
842
  * host names are unique, a box's host is declared, its slot's block fits the
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The per-member cache behind `chant workspace graph` (#2876, ws-059): a
3
+ * member whose source, install, toolchain, command line and environment are
4
+ * unchanged is answered without starting its chant, and anything else reads.
5
+ *
6
+ * Members run under a fake chant that logs each `graph` it answers, so a test
7
+ * can count the processes a read started. Files are aged past the freshness
8
+ * window before a read that should be stored, since a read of a file younger
9
+ * than the window is never cached.
10
+ */
11
+
12
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { afterAll, describe, expect, test } from "vitest";
15
+ import { cleanScratch, commitAll, contract, declaration, repo } from "./__fixtures__/contract-repo";
16
+ import { planMembers } from "./member-commands";
17
+ import {
18
+ environmentStamp,
19
+ graphCacheDir,
20
+ isCacheableArgv,
21
+ memberStamp,
22
+ openGraphCache,
23
+ splitCached,
24
+ VOLATILE_ENV,
25
+ } from "./graph-cache";
26
+ import { workspaceGraph, type GraphDocument } from "./graph-cli";
27
+ import schema from "./graph.schema.json";
28
+ import type { ParsedArgs } from "../cli/registry";
29
+
30
+ afterAll(cleanScratch);
31
+
32
+ const { expectValid } = contract(schema);
33
+
34
+ function result(doc: GraphDocument): Extract<GraphDocument, { nodes: unknown }> {
35
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
36
+ return doc;
37
+ }
38
+
39
+ /**
40
+ * A chant older than member-run that logs every `graph` it answers to
41
+ * `<root>/spawns.log`, and touches `race.txt` mid-read when `race` exists.
42
+ */
43
+ function loggingChant(root: string): string {
44
+ return `#!/bin/sh
45
+ case "$1" in
46
+ graph)
47
+ echo "$PWD" >> "${root}/spawns.log"
48
+ [ -f race ] && echo moved > race.txt
49
+ [ -f fail ] && { echo "Error: broken" >&2; exit 1; }
50
+ printf '{"version":1,"nodes":['
51
+ sep=""
52
+ while read -r id; do printf '%s{"id":"%s","kind":"Thing","lexicon":"fake","attrs":{}}' "$sep" "$id"; sep=","; done < ids.txt
53
+ printf '],"edges":[],"groups":{}}\\n'
54
+ ;;
55
+ *) echo "Error: Unknown command: $1" >&2; exit 1 ;;
56
+ esac
57
+ `;
58
+ }
59
+
60
+ function workspace(): string {
61
+ const root = repo({
62
+ "chant.workspace.json": declaration([
63
+ { name: "api", dir: "services/api", kind: "chant" },
64
+ { name: "web", dir: "apps/web", kind: "chant" },
65
+ ]),
66
+ "services/api/chant.config.ts": "export default {};\n",
67
+ "services/api/ids.txt": "Queue\n",
68
+ "apps/web/chant.config.ts": "export default {};\n",
69
+ "apps/web/ids.txt": "Site\n",
70
+ ".gitignore": "node_modules\nspawns.log\n",
71
+ });
72
+ repoWrite(root, "node_modules/.bin/chant", loggingChant(root), 0o755);
73
+ age(root);
74
+ return root;
75
+ }
76
+
77
+ function repoWrite(root: string, path: string, text: string, mode?: number): void {
78
+ const full = join(root, path);
79
+ mkdirSync(join(full, ".."), { recursive: true });
80
+ writeFileSync(full, text);
81
+ if (mode !== undefined) chmodSync(full, mode);
82
+ }
83
+
84
+ /** Set every file's mtime to one fixed moment long past the freshness window. */
85
+ const THEN = new Date("2026-01-01T00:00:00Z");
86
+ function age(dir: string): void {
87
+ const then = THEN;
88
+ const walk = (at: string): void => {
89
+ for (const e of readdirSync(at, { withFileTypes: true })) {
90
+ if (e.name === ".git") continue;
91
+ const path = join(at, e.name);
92
+ if (e.isDirectory()) walk(path);
93
+ else if (e.isFile()) utimesSync(path, then, then);
94
+ }
95
+ };
96
+ walk(dir);
97
+ }
98
+
99
+ function spawns(root: string): string[] {
100
+ const log = join(root, "spawns.log");
101
+ return existsSync(log) ? readFileSync(log, "utf-8").trim().split("\n").filter(Boolean) : [];
102
+ }
103
+
104
+ describe("the per-member graph cache (#2876)", () => {
105
+ test("an unchanged member is served without starting its chant", async () => {
106
+ const root = workspace();
107
+ const first = result((await workspaceGraph({ cwd: root })).doc);
108
+ expectValid(first);
109
+ expect(first.members.map((m) => [m.name, m.cached])).toEqual([
110
+ ["api", false],
111
+ ["web", false],
112
+ ]);
113
+ expect(spawns(root)).toHaveLength(2);
114
+ expect(readdirSync(graphCacheDir(root))).toHaveLength(2);
115
+ // A read never changes the workspace it reads: the cache lives elsewhere.
116
+ expect(existsSync(join(root, ".chant"))).toBe(false);
117
+
118
+ const second = result((await workspaceGraph({ cwd: root })).doc);
119
+ expectValid(second);
120
+ expect(spawns(root)).toHaveLength(2);
121
+ expect(second.members.map((m) => [m.name, m.cached])).toEqual([
122
+ ["api", true],
123
+ ["web", true],
124
+ ]);
125
+ expect(second.nodes).toEqual(first.nodes);
126
+ expect(second.members.map((m) => m.stamp)).toEqual(first.members.map((m) => m.stamp));
127
+ expect(second.members[0].stamp).toMatch(/^sha256:[0-9a-f]{64}$/);
128
+ });
129
+
130
+ test("an edit to one member reads that member again, and only it", async () => {
131
+ const root = workspace();
132
+ await workspaceGraph({ cwd: root });
133
+ writeFileSync(join(root, "services", "api", "ids.txt"), "Queue\nTopic\n");
134
+ age(root);
135
+ const g = result((await workspaceGraph({ cwd: root })).doc);
136
+ expect(g.nodes.map((n) => n.id)).toEqual(["api/Queue", "api/Topic", "web/Site"]);
137
+ expect(g.members.map((m) => [m.name, m.cached])).toEqual([
138
+ ["api", false],
139
+ ["web", true],
140
+ ]);
141
+ expect(spawns(root).filter((p) => p.endsWith("services/api"))).toHaveLength(2);
142
+ expect(spawns(root).filter((p) => p.endsWith("apps/web"))).toHaveLength(1);
143
+ });
144
+
145
+ test("an install change reads every member again", async () => {
146
+ const root = workspace();
147
+ await workspaceGraph({ cwd: root });
148
+ writeFileSync(join(root, "node_modules", ".package-lock.json"), "{}\n");
149
+ const g = result((await workspaceGraph({ cwd: root })).doc);
150
+ expect(g.members.every((m) => m.cached === false)).toBe(true);
151
+ });
152
+
153
+ test("--no-cache reads every member and writes nothing", async () => {
154
+ const root = workspace();
155
+ const g = result((await workspaceGraph({ cwd: root, noCache: true })).doc);
156
+ expect(g.members.every((m) => m.cached === false)).toBe(true);
157
+ expect(existsSync(graphCacheDir(root))).toBe(false);
158
+ await workspaceGraph({ cwd: root });
159
+ await workspaceGraph({ cwd: root, noCache: true });
160
+ expect(spawns(root)).toHaveLength(6);
161
+ });
162
+
163
+ test("a read of a file younger than the freshness window is not stored", async () => {
164
+ const root = workspace();
165
+ writeFileSync(join(root, "apps", "web", "ids.txt"), "Site\n");
166
+ await workspaceGraph({ cwd: root });
167
+ const g = result((await workspaceGraph({ cwd: root })).doc);
168
+ expect(g.members.map((m) => [m.name, m.cached])).toEqual([
169
+ ["api", true],
170
+ ["web", false],
171
+ ]);
172
+ });
173
+
174
+ test("a stamp that moves during the read is not stored", async () => {
175
+ const root = workspace();
176
+ writeFileSync(join(root, "apps", "web", "race"), "");
177
+ age(root);
178
+ await workspaceGraph({ cwd: root });
179
+ age(root);
180
+ const g = result((await workspaceGraph({ cwd: root })).doc);
181
+ expect(g.members.find((m) => m.name === "web")?.cached).toBe(false);
182
+ expect(g.members.find((m) => m.name === "api")?.cached).toBe(true);
183
+ });
184
+
185
+ test("a failed member read is not stored", async () => {
186
+ const root = workspace();
187
+ writeFileSync(join(root, "apps", "web", "fail"), "");
188
+ age(root);
189
+ const one = result((await workspaceGraph({ cwd: root })).doc);
190
+ expect(one.members.find((m) => m.name === "web")).toMatchObject({ status: "failed", reason: { code: "command-failed" } });
191
+ unlinkSync(join(root, "apps", "web", "fail"));
192
+ age(root);
193
+ const two = result((await workspaceGraph({ cwd: root })).doc);
194
+ expect(two.members.find((m) => m.name === "web")).toMatchObject({ status: "composed", cached: false });
195
+ expect(two.members.find((m) => m.name === "api")?.cached).toBe(true);
196
+ });
197
+
198
+ test("--at reads are keyed on the commit, so an edit after it still hits", async () => {
199
+ const root = workspace();
200
+ const sha = commitAll(root, "one");
201
+ const first = result((await workspaceGraph({ cwd: root, at: sha })).doc);
202
+ expectValid(first);
203
+ expect(first.members.every((m) => m.cached === false)).toBe(true);
204
+ writeFileSync(join(root, "services", "api", "ids.txt"), "Changed\n");
205
+ const before = spawns(root).length;
206
+ const second = result((await workspaceGraph({ cwd: root, at: sha })).doc);
207
+ expect(spawns(root)).toHaveLength(before);
208
+ expect(second.members.every((m) => m.cached === true)).toBe(true);
209
+ expect(second.nodes.map((n) => n.id)).toEqual(["api/Queue", "web/Site"]);
210
+ // The working tree is its own key.
211
+ const now = result((await workspaceGraph({ cwd: root })).doc);
212
+ expect(now.nodes.map((n) => n.id)).toEqual(["api/Changed", "web/Site"]);
213
+ });
214
+
215
+ test("a live command line is never looked up or stamped", () => {
216
+ const root = workspace();
217
+ const plan = planMembers("graph", root);
218
+ const cache = openGraphCache(root);
219
+ const args = {} as ParsedArgs;
220
+ const live = splitCached(plan, args, cache, null, () => ["graph", ".", "--format", "ir", "--live"]);
221
+ expect(live.hits).toEqual([]);
222
+ expect(live.pending.size).toBe(0);
223
+ expect(live.stamps.size).toBe(0);
224
+ const source = splitCached(plan, args, cache, null);
225
+ expect(source.pending.size).toBe(2);
226
+ });
227
+ });
228
+
229
+ describe("the cache's pieces", () => {
230
+ test("only a source read is cacheable", () => {
231
+ expect(isCacheableArgv(["graph", ".", "--format", "ir"])).toBe(true);
232
+ expect(isCacheableArgv(["graph", ".", "--format", "ir", "--env", "prod"])).toBe(true);
233
+ for (const f of ["--live", "--overlay", "--traffic", "--traffic=peak"]) expect(isCacheableArgv(["graph", ".", f])).toBe(false);
234
+ });
235
+
236
+ test("the stamp skips node_modules, dist, .git and dot-directories, and member exclusions", () => {
237
+ const root = repo({ "a.ts": "", "sub/b.ts": "", "other/c.ts": "" });
238
+ const base = memberStamp(root, ["other"])!.value;
239
+ for (const path of ["node_modules/x.js", "dist/y.js", ".cache/z", "sub/node_modules/q.js", "other/c.ts"]) {
240
+ repoWrite(root, path, String(Math.random()));
241
+ expect(memberStamp(root, ["other"])!.value, path).toBe(base);
242
+ }
243
+ repoWrite(root, "sub/b.ts", "changed");
244
+ expect(memberStamp(root, ["other"])!.value).not.toBe(base);
245
+ expect(memberStamp(join(root, "missing"))).toBeUndefined();
246
+ });
247
+
248
+ test("the environment digest ignores the volatile variables and nothing else", () => {
249
+ const base = { PATH: "/bin", HOME: "/h" };
250
+ const s = environmentStamp(base);
251
+ expect(environmentStamp({ ...base, PWD: "/x", SHLVL: "3", TERM_PROGRAM: "y", npm_config_x: "1" })).toBe(s);
252
+ expect(environmentStamp({ ...base, CHANT_ENV: "prod" })).not.toBe(s);
253
+ expect(VOLATILE_ENV).toContain("PWD");
254
+ });
255
+
256
+ test("an entry is written whole: a torn or foreign file is a miss", () => {
257
+ const root = repo({});
258
+ const cache = openGraphCache(root);
259
+ cache.put({ format: 1, key: "k", member: "m", stamp: "s", chant: null, stdout: "{}" });
260
+ expect(cache.get("k")?.stdout).toBe("{}");
261
+ writeFileSync(join(cache.dir, "k.json"), '{"format":1,"key":"k"');
262
+ expect(cache.get("k")).toBeUndefined();
263
+ writeFileSync(join(cache.dir, "j.json"), JSON.stringify({ format: 1, key: "other", stdout: "{}" }));
264
+ expect(cache.get("j")).toBeUndefined();
265
+ expect(readdirSync(cache.dir).some((n) => n.endsWith(".tmp"))).toBe(false);
266
+ });
267
+
268
+ test("each workspace has its own directory under the cache dir", () => {
269
+ const a = repo({});
270
+ const b = repo({});
271
+ expect(graphCacheDir(a, { CHANT_CACHE_DIR: "/c" })).toMatch(/^\/c\/workspace-graph\/[0-9a-f]{16}$/);
272
+ expect(graphCacheDir(a, { CHANT_CACHE_DIR: "/c" })).not.toBe(graphCacheDir(b, { CHANT_CACHE_DIR: "/c" }));
273
+ expect(graphCacheDir(a, { XDG_CACHE_HOME: "/x" })).toMatch(/^\/x\/chant\/workspace-graph\//);
274
+ expect(environmentStamp({ PATH: "/bin", CHANT_CACHE_DIR: "/one" })).toBe(environmentStamp({ PATH: "/bin", CHANT_CACHE_DIR: "/two" }));
275
+ });
276
+ });
277
+
278
+ describe("a member read through a kind's reader project (#2874)", () => {
279
+ /** Logs every graph it answers, and prints one node naming the directory it ran in. */
280
+ function readerChant(root: string): string {
281
+ return `#!/bin/sh
282
+ [ "$1" = graph ] || { echo "Error: Unknown command: $1" >&2; exit 1; }
283
+ echo "$PWD" >> "${root}/spawns.log"
284
+ printf '{"version":1,"nodes":[{"id":"root/Thing","kind":"Thing","lexicon":"fake","attrs":{}}],"edges":[],"groups":{}}\\n'
285
+ `;
286
+ }
287
+
288
+ const KIND = {
289
+ name: "tf",
290
+ description: "a root",
291
+ precedence: 400,
292
+ probe: { anyFile: ["*.tf"] },
293
+ graph: { lexicon: "fake", config: { roots: { "{member}": { dir: "{dir}" } } } },
294
+ };
295
+
296
+ function readerWorkspace(): string {
297
+ const root = repo({
298
+ "chant.workspace.json": declaration([{ name: "net", dir: "estates/net", kind: "tf" }], { pins: [{ package: "@intentius/chant-lexicon-fake", version: "1.0.0" }] }),
299
+ "estates/net/main.tf": "",
300
+ ".gitignore": "node_modules\nspawns.log\n",
301
+ "node_modules/@intentius/chant-lexicon-fake/package.json": JSON.stringify({
302
+ name: "@intentius/chant-lexicon-fake",
303
+ version: "1.0.0",
304
+ exports: { ".": "./index.js", "./workspace-kinds": "./workspace-kinds.json" },
305
+ }),
306
+ "node_modules/@intentius/chant-lexicon-fake/workspace-kinds.json": JSON.stringify({ schema: 1, kinds: [KIND] }),
307
+ });
308
+ repoWrite(root, "node_modules/.bin/chant", readerChant(root), 0o755);
309
+ age(root);
310
+ return root;
311
+ }
312
+
313
+ test("is cached on the member's own directory, not the temporary reader project", async () => {
314
+ const root = readerWorkspace();
315
+ const first = result((await workspaceGraph({ cwd: root })).doc);
316
+ expectValid(first);
317
+ expect(first.members[0]).toMatchObject({ name: "net", status: "composed", cached: false });
318
+ // It ran in a reader project outside the workspace.
319
+ expect(spawns(root)[0].startsWith(root)).toBe(false);
320
+
321
+ const second = result((await workspaceGraph({ cwd: root })).doc);
322
+ expect(second.members[0]).toMatchObject({ cached: true, stamp: first.members[0].stamp });
323
+ expect(spawns(root)).toHaveLength(1);
324
+ expect(second.nodes.map((n) => n.id)).toEqual(["net/root/Thing"]);
325
+
326
+ // The stamp is the member's: an edit in its directory reads it again.
327
+ writeFileSync(join(root, "estates", "net", "main.tf"), 'resource "x" "y" {}\n');
328
+ age(root);
329
+ const third = result((await workspaceGraph({ cwd: root })).doc);
330
+ expect(third.members[0].cached).toBe(false);
331
+ expect(third.members[0].stamp).not.toBe(first.members[0].stamp);
332
+ });
333
+
334
+ test("a change to the kind's graph block reads the member again", async () => {
335
+ const root = readerWorkspace();
336
+ await workspaceGraph({ cwd: root });
337
+ const kinds = join(root, "node_modules", "@intentius", "chant-lexicon-fake", "workspace-kinds.json");
338
+ writeFileSync(kinds, JSON.stringify({ schema: 1, kinds: [{ ...KIND, graph: { lexicon: "fake", config: { callModuleType: "none", roots: { "{member}": { dir: "{dir}" } } } } }] }));
339
+ const g = result((await workspaceGraph({ cwd: root })).doc);
340
+ expect(g.members[0].cached).toBe(false);
341
+ expect(spawns(root)).toHaveLength(2);
342
+ });
343
+ });