@intentius/chant 0.100.0 → 0.101.0

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 (276) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-options.d.ts +2 -0
  4. package/dist/cli/build-options.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/import.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/workspace-tools.d.ts +8 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +22 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config.d.ts +11 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +24 -1
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/plan-digest.d.ts +26 -5
  19. package/dist/lifecycle/plan-digest.d.ts.map +1 -1
  20. package/dist/lint/config.d.ts +4 -4
  21. package/dist/op/activities/activity-contracts.d.ts +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activities/propose-upgrade.d.ts +2 -0
  24. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  25. package/dist/op/index.d.ts +1 -1
  26. package/dist/op/index.d.ts.map +1 -1
  27. package/dist/serializer.d.ts +8 -0
  28. package/dist/serializer.d.ts.map +1 -1
  29. package/dist/telemetry-attribution.d.ts +77 -0
  30. package/dist/telemetry-attribution.d.ts.map +1 -0
  31. package/dist/workspace/agent-cli.d.ts +83 -0
  32. package/dist/workspace/agent-cli.d.ts.map +1 -0
  33. package/dist/workspace/changes-cli.d.ts.map +1 -1
  34. package/dist/workspace/changes.d.ts +8 -1
  35. package/dist/workspace/changes.d.ts.map +1 -1
  36. package/dist/workspace/checks/links.d.ts +1 -0
  37. package/dist/workspace/checks/links.d.ts.map +1 -1
  38. package/dist/workspace/checks/live.d.ts +40 -0
  39. package/dist/workspace/checks/live.d.ts.map +1 -0
  40. package/dist/workspace/checks.d.ts +21 -2
  41. package/dist/workspace/checks.d.ts.map +1 -1
  42. package/dist/workspace/compose-graph.d.ts +63 -0
  43. package/dist/workspace/compose-graph.d.ts.map +1 -1
  44. package/dist/workspace/decide.d.ts +1 -1
  45. package/dist/workspace/decide.d.ts.map +1 -1
  46. package/dist/workspace/declaration.d.ts +32 -0
  47. package/dist/workspace/declaration.d.ts.map +1 -1
  48. package/dist/workspace/declaration.schema.json +138 -3
  49. package/dist/workspace/export-cli.d.ts +12 -0
  50. package/dist/workspace/export-cli.d.ts.map +1 -0
  51. package/dist/workspace/export.d.ts +145 -0
  52. package/dist/workspace/export.d.ts.map +1 -0
  53. package/dist/workspace/graph-cli.d.ts.map +1 -1
  54. package/dist/workspace/import.d.ts +73 -0
  55. package/dist/workspace/import.d.ts.map +1 -0
  56. package/dist/workspace/kinds.d.ts +6 -2
  57. package/dist/workspace/kinds.d.ts.map +1 -1
  58. package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
  59. package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
  60. package/dist/workspace/lineage-adopt.d.ts +106 -0
  61. package/dist/workspace/lineage-adopt.d.ts.map +1 -0
  62. package/dist/workspace/lineage-check.d.ts +9 -2
  63. package/dist/workspace/lineage-check.d.ts.map +1 -1
  64. package/dist/workspace/lineage-cli.d.ts +6 -1
  65. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  66. package/dist/workspace/lineage-hash-index.d.ts +110 -0
  67. package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
  68. package/dist/workspace/lineage-init.d.ts +10 -0
  69. package/dist/workspace/lineage-init.d.ts.map +1 -1
  70. package/dist/workspace/lineage-lock.d.ts +147 -0
  71. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  72. package/dist/workspace/lineage-migrations.d.ts +15 -3
  73. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  74. package/dist/workspace/lineage-provenance.d.ts +18 -0
  75. package/dist/workspace/lineage-provenance.d.ts.map +1 -0
  76. package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
  77. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  78. package/dist/workspace/lineage-upgrade.d.ts +30 -1
  79. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  80. package/dist/workspace/lineage-versions.d.ts +116 -0
  81. package/dist/workspace/lineage-versions.d.ts.map +1 -0
  82. package/dist/workspace/links.d.ts +31 -5
  83. package/dist/workspace/links.d.ts.map +1 -1
  84. package/dist/workspace/ls-generated.d.ts +37 -0
  85. package/dist/workspace/ls-generated.d.ts.map +1 -0
  86. package/dist/workspace/ls.d.ts +4 -0
  87. package/dist/workspace/ls.d.ts.map +1 -1
  88. package/dist/workspace/member-commands.d.ts.map +1 -1
  89. package/dist/workspace/nested-graph.d.ts +56 -0
  90. package/dist/workspace/nested-graph.d.ts.map +1 -0
  91. package/dist/workspace/nesting.d.ts +21 -0
  92. package/dist/workspace/nesting.d.ts.map +1 -0
  93. package/dist/workspace/pin-cli.d.ts +10 -0
  94. package/dist/workspace/pin-cli.d.ts.map +1 -0
  95. package/dist/workspace/pin-integrity.d.ts +51 -0
  96. package/dist/workspace/pin-integrity.d.ts.map +1 -0
  97. package/dist/workspace/reason-codes.d.ts +26 -2
  98. package/dist/workspace/reason-codes.d.ts.map +1 -1
  99. package/dist/workspace/record-sessions.d.ts +7 -11
  100. package/dist/workspace/record-sessions.d.ts.map +1 -1
  101. package/dist/workspace/records-cli.d.ts +30 -1
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-close.d.ts +5 -2
  104. package/dist/workspace/records-close.d.ts.map +1 -1
  105. package/dist/workspace/records-write.d.ts +22 -4
  106. package/dist/workspace/records-write.d.ts.map +1 -1
  107. package/dist/workspace/records.d.ts +43 -5
  108. package/dist/workspace/records.d.ts.map +1 -1
  109. package/dist/workspace/returns.d.ts +129 -0
  110. package/dist/workspace/returns.d.ts.map +1 -0
  111. package/dist/workspace/status-gates.d.ts.map +1 -1
  112. package/dist/workspace/template-manifest.d.ts +11 -3
  113. package/dist/workspace/template-manifest.d.ts.map +1 -1
  114. package/dist/workspace/trust/attestor.d.ts +8 -0
  115. package/dist/workspace/trust/attestor.d.ts.map +1 -1
  116. package/dist/workspace/trust/dsse.d.ts +58 -0
  117. package/dist/workspace/trust/dsse.d.ts.map +1 -0
  118. package/dist/workspace/trust/evidence-cli.d.ts +66 -0
  119. package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
  120. package/dist/workspace/trust/evidence.d.ts +93 -0
  121. package/dist/workspace/trust/evidence.d.ts.map +1 -0
  122. package/dist/workspace/trust/policy.d.ts +54 -1
  123. package/dist/workspace/trust/policy.d.ts.map +1 -1
  124. package/dist/workspace/trust/provenance.d.ts +21 -1
  125. package/dist/workspace/trust/provenance.d.ts.map +1 -1
  126. package/dist/workspace/trust/rotation.d.ts +132 -0
  127. package/dist/workspace/trust/rotation.d.ts.map +1 -0
  128. package/dist/workspace/trust/seal.d.ts.map +1 -1
  129. package/dist/workspace/trust/signers-cli.d.ts +49 -0
  130. package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
  131. package/dist/workspace/trust/ssh-commit.d.ts +12 -0
  132. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  133. package/dist/workspace/trust/test-repo.d.ts +13 -0
  134. package/dist/workspace/trust/test-repo.d.ts.map +1 -1
  135. package/dist/workspace/trust/verify.d.ts +15 -0
  136. package/dist/workspace/trust/verify.d.ts.map +1 -1
  137. package/dist/workspace/work-evidence.d.ts +1 -1
  138. package/dist/workspace/work-evidence.d.ts.map +1 -1
  139. package/dist/workspace/write-scope.d.ts +199 -0
  140. package/dist/workspace/write-scope.d.ts.map +1 -0
  141. package/package.json +1 -1
  142. package/src/build.ts +8 -0
  143. package/src/cli/build-options.ts +4 -1
  144. package/src/cli/commands/build.ts +2 -0
  145. package/src/cli/commands/import-live.test.ts +69 -1
  146. package/src/cli/commands/import.ts +48 -22
  147. package/src/cli/handlers/fan-out.test.ts +6 -6
  148. package/src/cli/handlers/fan-out.ts +2 -1
  149. package/src/cli/handlers/graph.test.ts +42 -0
  150. package/src/cli/handlers/graph.ts +22 -0
  151. package/src/cli/handlers/operator.ts +1 -1
  152. package/src/cli/main.test.ts +32 -0
  153. package/src/cli/main.ts +103 -6
  154. package/src/cli/mcp/workspace-tools.test.ts +1 -1
  155. package/src/cli/mcp/workspace-tools.ts +33 -2
  156. package/src/cli/registry.ts +22 -0
  157. package/src/cli/serve-mcp-workspace.test.ts +1 -1
  158. package/src/codegen/release-wiring.test.ts +5 -1
  159. package/src/components/fan-out-output.test.ts +1 -1
  160. package/src/components/fan-out.test.ts +1 -1
  161. package/src/components/promote.test.ts +1 -1
  162. package/src/config.ts +12 -0
  163. package/src/content-digest.test.ts +2 -2
  164. package/src/lexicon.ts +25 -1
  165. package/src/lifecycle/gate-ledger.test.ts +14 -0
  166. package/src/lifecycle/gate-ledger.ts +2 -1
  167. package/src/lifecycle/plan-digest.test.ts +54 -3
  168. package/src/lifecycle/plan-digest.ts +38 -8
  169. package/src/op/activities/activity-contracts.ts +1 -0
  170. package/src/op/activities/propose-upgrade.ts +8 -5
  171. package/src/op/gate-approval.test.ts +17 -0
  172. package/src/op/gate.ts +3 -3
  173. package/src/op/index.ts +1 -1
  174. package/src/serializer.ts +9 -0
  175. package/src/telemetry-attribution.test.ts +91 -0
  176. package/src/telemetry-attribution.ts +145 -0
  177. package/src/workspace/agent-cli.ts +134 -0
  178. package/src/workspace/agent.schema.json +356 -0
  179. package/src/workspace/behold-kinds.test.ts +1 -1
  180. package/src/workspace/changes-cli.ts +5 -0
  181. package/src/workspace/changes.schema.json +179 -1
  182. package/src/workspace/changes.ts +58 -4
  183. package/src/workspace/check-live.test.ts +192 -0
  184. package/src/workspace/check.schema.json +64 -0
  185. package/src/workspace/checks/links.ts +23 -2
  186. package/src/workspace/checks/live.ts +113 -0
  187. package/src/workspace/checks.test.ts +2 -2
  188. package/src/workspace/checks.ts +20 -3
  189. package/src/workspace/compose-graph.test.ts +47 -0
  190. package/src/workspace/compose-graph.ts +120 -3
  191. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
  192. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
  193. package/src/workspace/declaration.schema.json +138 -3
  194. package/src/workspace/declaration.ts +106 -1
  195. package/src/workspace/declared-kinds.test.ts +33 -0
  196. package/src/workspace/evidence.schema.json +279 -0
  197. package/src/workspace/export-cli.ts +180 -0
  198. package/src/workspace/export-import.test.ts +282 -0
  199. package/src/workspace/export.ts +486 -0
  200. package/src/workspace/graph-cli.ts +47 -1
  201. package/src/workspace/graph-contract.test.ts +89 -4
  202. package/src/workspace/graph.schema.json +206 -1
  203. package/src/workspace/import.ts +325 -0
  204. package/src/workspace/kinds.test.ts +5 -5
  205. package/src/workspace/kinds.ts +25 -3
  206. package/src/workspace/lineage-adopt-cli.ts +103 -0
  207. package/src/workspace/lineage-adopt.test.ts +552 -0
  208. package/src/workspace/lineage-adopt.ts +452 -0
  209. package/src/workspace/lineage-check.ts +26 -5
  210. package/src/workspace/lineage-cli.ts +12 -2
  211. package/src/workspace/lineage-hash-index.ts +305 -0
  212. package/src/workspace/lineage-init.test.ts +9 -0
  213. package/src/workspace/lineage-init.ts +29 -9
  214. package/src/workspace/lineage-lock.ts +54 -0
  215. package/src/workspace/lineage-migrations.test.ts +27 -0
  216. package/src/workspace/lineage-migrations.ts +44 -14
  217. package/src/workspace/lineage-provenance.ts +40 -0
  218. package/src/workspace/lineage-upgrade-cli.ts +9 -5
  219. package/src/workspace/lineage-upgrade.test.ts +92 -1
  220. package/src/workspace/lineage-upgrade.ts +111 -16
  221. package/src/workspace/lineage-versions.ts +348 -0
  222. package/src/workspace/links.test.ts +121 -2
  223. package/src/workspace/links.ts +97 -6
  224. package/src/workspace/ls-contract.test.ts +84 -1
  225. package/src/workspace/ls-generated.ts +111 -0
  226. package/src/workspace/ls.schema.json +19 -0
  227. package/src/workspace/ls.ts +8 -1
  228. package/src/workspace/member-commands.ts +3 -1
  229. package/src/workspace/nested-graph.test.ts +176 -0
  230. package/src/workspace/nested-graph.ts +169 -0
  231. package/src/workspace/nesting.ts +37 -0
  232. package/src/workspace/pin-cli.test.ts +71 -0
  233. package/src/workspace/pin-cli.ts +57 -0
  234. package/src/workspace/pin-integrity.test.ts +121 -0
  235. package/src/workspace/pin-integrity.ts +104 -0
  236. package/src/workspace/points-write.schema.json +1 -0
  237. package/src/workspace/read-contract.test.ts +24 -0
  238. package/src/workspace/reason-codes.test.ts +10 -0
  239. package/src/workspace/reason-codes.ts +29 -2
  240. package/src/workspace/record-sessions.ts +12 -14
  241. package/src/workspace/records-amend.schema.json +4 -0
  242. package/src/workspace/records-cli.ts +93 -12
  243. package/src/workspace/records-close.schema.json +6 -2
  244. package/src/workspace/records-close.ts +10 -2
  245. package/src/workspace/records-formats.test.ts +13 -5
  246. package/src/workspace/records-new.schema.json +4 -0
  247. package/src/workspace/records-review.schema.json +4 -0
  248. package/src/workspace/records-sessions.test.ts +23 -6
  249. package/src/workspace/records-write.test.ts +65 -8
  250. package/src/workspace/records-write.ts +70 -6
  251. package/src/workspace/records.schema.json +55 -3
  252. package/src/workspace/records.ts +96 -5
  253. package/src/workspace/returns.ts +328 -0
  254. package/src/workspace/signers.schema.json +206 -0
  255. package/src/workspace/status-gates.ts +2 -1
  256. package/src/workspace/template-manifest.ts +22 -4
  257. package/src/workspace/trust/attestor.ts +15 -0
  258. package/src/workspace/trust/dsse.ts +134 -0
  259. package/src/workspace/trust/evidence-cli.ts +195 -0
  260. package/src/workspace/trust/evidence.test.ts +241 -0
  261. package/src/workspace/trust/evidence.ts +207 -0
  262. package/src/workspace/trust/policy.ts +110 -3
  263. package/src/workspace/trust/provenance.ts +41 -4
  264. package/src/workspace/trust/record-seal.test.ts +1 -1
  265. package/src/workspace/trust/rotation.test.ts +258 -0
  266. package/src/workspace/trust/rotation.ts +336 -0
  267. package/src/workspace/trust/seal.ts +11 -0
  268. package/src/workspace/trust/signers-cli.ts +178 -0
  269. package/src/workspace/trust/ssh-commit.ts +53 -4
  270. package/src/workspace/trust/test-repo.ts +18 -0
  271. package/src/workspace/trust/trust.test.ts +8 -2
  272. package/src/workspace/trust/verify-cli.ts +1 -0
  273. package/src/workspace/trust/verify.ts +22 -0
  274. package/src/workspace/work-evidence.schema.json +4 -0
  275. package/src/workspace/write-scope.test.ts +340 -0
  276. package/src/workspace/write-scope.ts +448 -0
@@ -0,0 +1,486 @@
1
+ /**
2
+ * `chant workspace export`: write members of this workspace, with their
3
+ * lineage and records, into the workspace's export member as a workspace of
4
+ * their own (#2552, #2524 D10, requirements X1 to X3 and X6, ws-073).
5
+ *
6
+ * The export goes into its own member, found by role: a member of kind
7
+ * `workspace` with the role `export`. Nothing is written anywhere else, and a
8
+ * layout where the export would land inside another member, or another
9
+ * member inside the export, is refused. That member is a nested workspace
10
+ * (ws-071): the outer workspace reads it only through its own commands, and
11
+ * export is the one command that writes there.
12
+ *
13
+ * Only members that declare `travel: true` go (X6). With no members named,
14
+ * every such member goes, with the workspace's own record kinds and their
15
+ * records: that is an export of the workspace. Naming members exports just
16
+ * those, each with the record kinds it declares.
17
+ *
18
+ * The export keeps the workspace's layout: a member at `app/` is at
19
+ * `<export>/app/`. It gets a declaration listing the members that went, each
20
+ * entry copied as written, less the links, agents and pins that point at
21
+ * members that stayed. The lineage of each scope goes with it, filtered to
22
+ * the files that went. Records are copied byte for byte, so every seal on
23
+ * them stays as it was.
24
+ *
25
+ * Host-bound parameters (D9) switch on export (X3): `--param name=value`
26
+ * gives an exported lineage's host-bound parameter the value it has where the
27
+ * export goes. The files the lineage lists for it get the new value, the lock
28
+ * records it, and a file that had its recorded hash keeps a matching one. The
29
+ * host's values stay in `.chant/export.json`, where `import` reads them to
30
+ * switch back.
31
+ *
32
+ * `.chant/export.json` records where the export came from (the workspace's
33
+ * name and revision, ws-016), what went, and the hash each file had here and
34
+ * in the export. Import uses those as its merge base.
35
+ */
36
+
37
+ import { execFileSync } from "node:child_process";
38
+ import { createHash } from "node:crypto";
39
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
40
+ import { dirname, join, posix, relative, resolve } from "node:path";
41
+ import { z } from "zod";
42
+ import { DECLARATION_FILES, readDeclaration, type Declaration, type Member } from "./declaration";
43
+ import { parseJsonText } from "./jsonc";
44
+ import { fileHash, LOCK_FILE, LockError, parseLock, renderLock, type Lineage, type LineageLock } from "./lineage-lock";
45
+ import { loadRecordKind } from "./records";
46
+ import { workingTree } from "./tree";
47
+
48
+ /** The role that marks the member an export is written into. */
49
+ export const EXPORT_ROLE = "export";
50
+
51
+ /** The export manifest, relative to the export's root. */
52
+ export const EXPORT_MANIFEST = ".chant/export.json";
53
+
54
+ const Sha256 = z.string().regex(/^sha256:[0-9a-f]{64}$/);
55
+
56
+ const ManifestSchema = z
57
+ .object({
58
+ schema: z.literal(1),
59
+ /** `exp-<12 hex>`, from what the export holds. */
60
+ id: z.string().regex(/^exp-[0-9a-f]{12}$/),
61
+ from: z
62
+ .object({
63
+ /** The declaration's `name`. */
64
+ workspace: z.string().min(1),
65
+ /** HEAD of the repository when the export was written, or null outside git. */
66
+ revision: z.string().regex(/^[0-9a-f]{40}([0-9a-f]{24})?$/).nullable(),
67
+ /** Whether exported paths had uncommitted changes. */
68
+ dirty: z.boolean(),
69
+ })
70
+ .strict(),
71
+ /** The export member's name. */
72
+ member: z.string().min(1),
73
+ /** The members that went, by name. */
74
+ members: z.array(z.string().min(1)),
75
+ /** True when no members were named: the workspace's own record kinds went too. */
76
+ whole: z.boolean(),
77
+ /** Directories, relative to the workspace root, that went besides member directories: the workspace's own record kinds and records. */
78
+ dirs: z.array(z.string().min(1)),
79
+ /** Every exported file, relative to the workspace root, with its hash here and in the export. Locks are under `locks`. */
80
+ files: z.record(z.string(), z.object({ host: Sha256, export: Sha256 }).strict()),
81
+ /** Each lock that went, by its path relative to the workspace root, with each scope's digest here and in the export, and whether only part of it went. */
82
+ locks: z.record(z.string(), z.record(z.string(), z.object({ host: Sha256, export: Sha256, partial: z.boolean() }).strict())),
83
+ /** Host-bound values switched, by `<lock path>#<scope>`, then parameter: the value here and in the export. */
84
+ hostValues: z.record(z.string(), z.record(z.string(), z.object({ host: z.string(), export: z.string() }).strict())),
85
+ /** What the export's declaration leaves out, because it names members that stayed. */
86
+ dropped: z
87
+ .object({
88
+ links: z.array(z.object({ member: z.string(), to: z.string() }).strict()),
89
+ agents: z.array(z.string()),
90
+ pins: z.array(z.string()),
91
+ records: z.array(z.string()),
92
+ diagrams: z.array(z.string()),
93
+ })
94
+ .strict(),
95
+ })
96
+ .strict();
97
+ export type ExportManifest = z.infer<typeof ManifestSchema>;
98
+
99
+ export class ExportError extends LockError {
100
+ override name = "ExportError";
101
+ }
102
+
103
+ // ── Reading ──────────────────────────────────────────────────────────────────
104
+
105
+ export function parseManifest(text: string, label: string): ExportManifest {
106
+ let raw: unknown;
107
+ try {
108
+ raw = JSON.parse(text);
109
+ } catch (err) {
110
+ throw new ExportError(`${label} is not JSON: ${err instanceof Error ? err.message : String(err)}`);
111
+ }
112
+ const parsed = ManifestSchema.safeParse(raw);
113
+ if (!parsed.success) throw new ExportError(`${label} is not an export manifest: ${parsed.error.issues.map((i) => `${i.path.join(".") || "/"}: ${i.message}`).join("; ")}`);
114
+ return parsed.data;
115
+ }
116
+
117
+ export function readManifestAt(dir: string): ExportManifest | null {
118
+ const abs = join(dir, EXPORT_MANIFEST);
119
+ if (!existsSync(abs)) return null;
120
+ return parseManifest(readFileSync(abs, "utf-8"), abs);
121
+ }
122
+
123
+ // ── Shared helpers ───────────────────────────────────────────────────────────
124
+
125
+ /** `true` when `path` is `dir` or below it. Both relative, `/`-separated, `"."` for the root. */
126
+ export function under(path: string, dir: string): boolean {
127
+ return dir === "." || path === dir || path.startsWith(`${dir}/`);
128
+ }
129
+
130
+ function git(cwd: string, args: string[]): string {
131
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
132
+ }
133
+
134
+ export function gitTopOf(dir: string): string | undefined {
135
+ try {
136
+ return git(dir, ["rev-parse", "--show-toplevel"]).trim() || undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The files under `dir` (absolute) that git tracks or would track, relative
144
+ * to `dir`, that exist in the working tree. Outside git, every file, leaving
145
+ * out `.git` and `node_modules`.
146
+ */
147
+ export function listFiles(dir: string): string[] {
148
+ if (!existsSync(dir)) return [];
149
+ const top = gitTopOf(dir);
150
+ if (top) {
151
+ const out = git(dir, ["ls-files", "-z", "-co", "--exclude-standard", "--full-name", "--", "."]);
152
+ // git reports its top with symlinks resolved (/var is /private/var on macOS).
153
+ const prefix = relative(top, realpathSync(dir)).split("\\").join("/");
154
+ return [...new Set(out.split("\0").filter(Boolean))]
155
+ .map((p) => (prefix ? p.slice(prefix.length + 1) : p))
156
+ .filter((p) => {
157
+ try {
158
+ return statSync(join(dir, p)).isFile();
159
+ } catch {
160
+ return false;
161
+ }
162
+ })
163
+ .sort();
164
+ }
165
+ const out: string[] = [];
166
+ const walk = (rel: string) => {
167
+ for (const d of readdirSync(join(dir, rel), { withFileTypes: true })) {
168
+ if (d.name === ".git" || d.name === "node_modules") continue;
169
+ const p = rel ? `${rel}/${d.name}` : d.name;
170
+ if (d.isDirectory()) walk(p);
171
+ else if (d.isFile()) out.push(p);
172
+ }
173
+ };
174
+ walk("");
175
+ return out.sort();
176
+ }
177
+
178
+ /** A lock's path, relative to the workspace root, is a lock file: `.chant/workspace.lock.json` at some directory. */
179
+ export function isLockPath(path: string): boolean {
180
+ return path === LOCK_FILE || path.endsWith(`/${LOCK_FILE}`);
181
+ }
182
+
183
+ /** The directory, relative to the workspace root, a lock at `path` sits in. */
184
+ export function lockDir(path: string): string {
185
+ return path === LOCK_FILE ? "." : path.slice(0, -(LOCK_FILE.length + 1));
186
+ }
187
+
188
+ /** Join a lock directory, a scope and a file into a workspace-relative path. */
189
+ export function scopedPath(dir: string, scope: string, file: string): string {
190
+ return [dir, scope, file].filter((p) => p !== "." && p !== "").join("/");
191
+ }
192
+
193
+ /** The digest of a lineage, for telling whether it changed. */
194
+ export function lineageDigest(lineage: Lineage): string {
195
+ const lock: LineageLock = { lockVersion: 1, scopes: { s: lineage } };
196
+ return fileHash(renderLock(lock));
197
+ }
198
+
199
+ /**
200
+ * Switch host-bound values in a scope (#2552): for each parameter in
201
+ * `values`, the files the lineage lists for it get `to` in place of `from`,
202
+ * the lineage records `to`, and a file that had its recorded hash keeps a
203
+ * matching one. `files` holds the scope's files by workspace-relative path,
204
+ * and is changed in place. Returns the paths whose content changed.
205
+ */
206
+ export function switchHostValues(
207
+ lineage: Lineage,
208
+ dir: string,
209
+ scope: string,
210
+ values: Record<string, { from: string; to: string }>,
211
+ files: Map<string, Buffer>,
212
+ ): string[] {
213
+ const changed: string[] = [];
214
+ for (const [name, { from, to }] of Object.entries(values)) {
215
+ if (from === to) continue;
216
+ for (const f of lineage.hostBound?.[name] ?? []) {
217
+ const path = scopedPath(dir, scope, f);
218
+ const before = files.get(path);
219
+ if (!before) continue;
220
+ const after = Buffer.from(before.toString("utf-8").split(from).join(to), "utf-8");
221
+ if (after.equals(before)) continue;
222
+ files.set(path, after);
223
+ changed.push(path);
224
+ const entry = lineage.files[f];
225
+ if (entry && entry.sha256 === fileHash(before)) lineage.files[f] = { ...entry, sha256: fileHash(after) };
226
+ }
227
+ lineage.parameters = { ...lineage.parameters, [name]: to };
228
+ }
229
+ return [...new Set(changed)].sort();
230
+ }
231
+
232
+ /** The raw declaration object, read as JSON or JSONC. */
233
+ export function rawDeclaration(root: string): { file: string; obj: Record<string, unknown> } {
234
+ for (const name of DECLARATION_FILES) {
235
+ const abs = join(root, name);
236
+ if (!existsSync(abs)) continue;
237
+ const parsed = parseJsonText(readFileSync(abs, "utf-8"), { jsonc: name.endsWith(".jsonc") });
238
+ if (!parsed.ok) throw new ExportError(`${name} can't be read: ${parsed.message}`);
239
+ return { file: name, obj: parsed.value as Record<string, unknown> };
240
+ }
241
+ throw new ExportError(`no chant.workspace.json at ${root}`);
242
+ }
243
+
244
+ /** The export member: the one with the export role, or the one `to` names. */
245
+ export function exportMember(decl: Declaration, to?: string): Member {
246
+ const withRole = decl.members.filter((m) => m.roles.some((r) => r.name === EXPORT_ROLE));
247
+ let m: Member | undefined;
248
+ if (to !== undefined) {
249
+ m = decl.members.find((x) => x.name === to);
250
+ if (!m) throw new ExportError(`--to ${to}: the declaration has no member ${to}`);
251
+ if (!withRole.includes(m)) throw new ExportError(`--to ${to}: member ${to} does not have the role ${EXPORT_ROLE}; an export goes only into a member with that role`);
252
+ } else if (withRole.length === 1) {
253
+ m = withRole[0];
254
+ } else if (withRole.length === 0) {
255
+ throw new ExportError(`no member has the role ${EXPORT_ROLE}; declare one, of kind workspace, for the export to go into`);
256
+ } else {
257
+ throw new ExportError(`${withRole.length} members have the role ${EXPORT_ROLE} (${withRole.map((x) => x.name).join(", ")}); name one with --to <member>`);
258
+ }
259
+ if (m.kind !== "workspace") throw new ExportError(`member ${m.name} has kind ${m.kind}; an export is a workspace, so its member has kind workspace`);
260
+ if (m.dir === ".") throw new ExportError(`member ${m.name} is the workspace root; an export needs a directory of its own`);
261
+ return m;
262
+ }
263
+
264
+ // ── Export ───────────────────────────────────────────────────────────────────
265
+
266
+ export interface ExportOptions {
267
+ /** The workspace root, where `chant.workspace.json` is. */
268
+ root: string;
269
+ /** Members to export. Empty or absent: every member that travels, with the workspace's own records. */
270
+ members?: string[];
271
+ /** The export member's name, when more than one has the role. */
272
+ to?: string;
273
+ /** Host-bound parameter values for the export. */
274
+ params?: Record<string, string>;
275
+ dryRun?: boolean;
276
+ }
277
+
278
+ export interface ExportResult {
279
+ member: string;
280
+ /** The export's directory, relative to the workspace root. */
281
+ dir: string;
282
+ manifest: ExportManifest;
283
+ /** Files written, relative to the export's root, besides the declaration and the manifest. */
284
+ files: number;
285
+ /** Paths, relative to the workspace root, whose host-bound values were switched. */
286
+ switched: string[];
287
+ written: boolean;
288
+ }
289
+
290
+ /** Plan the export, and write it unless `dryRun`. Throws an {@link ExportError}. */
291
+ export async function exportWorkspace(o: ExportOptions): Promise<ExportResult> {
292
+ const root = resolve(o.root);
293
+ const decl = readDeclaration(workingTree(root));
294
+ const target = exportMember(decl, o.to);
295
+ const whole = !o.members || o.members.length === 0;
296
+
297
+ // What goes.
298
+ let members: Member[];
299
+ if (whole) {
300
+ members = decl.members.filter((m) => m.travel && m !== target);
301
+ if (members.length === 0) throw new ExportError(`no member sets travel to true, so nothing goes with an export; set it on each member that should`);
302
+ } else {
303
+ members = [];
304
+ for (const name of new Set(o.members)) {
305
+ const m = decl.members.find((x) => x.name === name);
306
+ if (!m) throw new ExportError(`the declaration has no member ${name}`);
307
+ if (m === target) throw new ExportError(`member ${name} is the export member itself`);
308
+ if (!m.travel) throw new ExportError(`member ${name} does not set travel to true, so it stays with the workspace`);
309
+ members.push(m);
310
+ }
311
+ }
312
+ if (members.some((m) => m.dir === ".")) throw new ExportError(`member ${members.find((m) => m.dir === ".")!.name} is the workspace root, and the root can't travel as a member`);
313
+
314
+ // Never into another member, never another member inside the export (D10).
315
+ for (const m of decl.members) {
316
+ if (m === target) continue;
317
+ if (m.dir !== "." && under(target.dir, m.dir)) throw new ExportError(`the export member ${target.name} (${target.dir}) is inside member ${m.name} (${m.dir}); an export never writes into another member`);
318
+ if (under(m.dir, target.dir)) throw new ExportError(`member ${m.name} (${m.dir}) is inside the export member ${target.name} (${target.dir}); an export would write over it`);
319
+ }
320
+
321
+ // The workspace's own record kinds, for a whole export.
322
+ const dirs: string[] = [];
323
+ const dropped: ExportManifest["dropped"] = { links: [], agents: [], pins: [], records: [], diagrams: [] };
324
+ if (whole) {
325
+ for (const k of decl.records) {
326
+ const loaded = await loadRecordKind(join(root, k.path), root);
327
+ for (const d of [posix.dirname(k.path), relative(root, loaded.dir).split("\\").join("/") || "."]) {
328
+ if (d === "." || d.startsWith("..")) throw new ExportError(`record kind ${k.kind} keeps its kind file or records at the workspace root; an export takes directories, so move them into one`);
329
+ if (!dirs.includes(d)) dirs.push(d);
330
+ }
331
+ }
332
+ } else {
333
+ dropped.records = decl.records.map((k) => k.kind);
334
+ }
335
+ for (const d of dirs) {
336
+ if (under(d, target.dir) || under(target.dir, d)) throw new ExportError(`the record directory ${d} overlaps the export member ${target.name}`);
337
+ }
338
+
339
+ // The files, read once. Another member's directory inside one that goes is left out.
340
+ const others = decl.members.filter((m) => !members.includes(m));
341
+ const files = new Map<string, Buffer>();
342
+ const locks = new Map<string, LineageLock>();
343
+ for (const d of [...members.map((m) => m.dir), ...dirs]) {
344
+ for (const f of listFiles(join(root, d))) {
345
+ const path = `${d}/${f}`;
346
+ if (others.some((m) => m.dir !== "." && under(path, m.dir))) continue;
347
+ if (isLockPath(path)) {
348
+ locks.set(path, parseLock(readFileSync(join(root, path), "utf-8")));
349
+ continue;
350
+ }
351
+ files.set(path, readFileSync(join(root, path)));
352
+ }
353
+ }
354
+ const hostFiles = new Map(files);
355
+
356
+ // The root lock goes, filtered to the files that went.
357
+ if (existsSync(join(root, LOCK_FILE))) locks.set(LOCK_FILE, parseLock(readFileSync(join(root, LOCK_FILE), "utf-8")));
358
+ const exportLocks = new Map<string, LineageLock>();
359
+ const lockDigests: ExportManifest["locks"] = {};
360
+ const hostValues: ExportManifest["hostValues"] = {};
361
+ const switched = new Set<string>();
362
+ const params = { ...(o.params ?? {}) };
363
+ const usedParams = new Set<string>();
364
+ for (const [path, lock] of locks) {
365
+ const dir = lockDir(path);
366
+ const out: LineageLock = { lockVersion: lock.lockVersion, scopes: {} };
367
+ for (const [scope, lineage] of Object.entries(lock.scopes)) {
368
+ const all = Object.keys(lineage.files);
369
+ const kept = all.filter((f) => files.has(scopedPath(dir, scope, f)) || (lineage.files[f].class === "generated" && members.some((m) => under(scopedPath(dir, scope, f), m.dir))));
370
+ const scopeDir = scopedPath(dir, scope, "") || ".";
371
+ const inside = members.some((m) => under(scopeDir, m.dir)) || dirs.some((d) => under(scopeDir, d));
372
+ if (kept.length === 0 && !inside) continue;
373
+ const copy: Lineage = JSON.parse(JSON.stringify(lineage));
374
+ copy.files = Object.fromEntries(kept.map((f) => [f, lineage.files[f]]));
375
+ copy.manualSteps = lineage.manualSteps.filter((s) => kept.includes(s.path));
376
+ if (copy.repinned) copy.repinned = copy.repinned.filter((r) => kept.includes(r.record));
377
+ // Host-bound values switch (X3).
378
+ const values: Record<string, { from: string; to: string }> = {};
379
+ for (const name of Object.keys(lineage.hostBound ?? {})) {
380
+ if (params[name] === undefined) continue;
381
+ usedParams.add(name);
382
+ const from = lineage.parameters[name];
383
+ if (typeof from !== "string") throw new ExportError(`${path} scope ${scope}: host-bound parameter ${name} has no recorded value to switch`);
384
+ const pinned = (lineage.repinned ?? []).filter((r) => r.paths.some((p) => (lineage.hostBound![name] ?? []).includes(p)));
385
+ if (pinned.length > 0) throw new ExportError(`${path} scope ${scope}: ${pinned[0].record} pins a file that carries host-bound ${name}; switching it would break the pin and the record's seal`);
386
+ values[name] = { from, to: params[name] };
387
+ }
388
+ if (Object.keys(values).length > 0) {
389
+ for (const p of switchHostValues(copy, dir, scope, values, files)) switched.add(p);
390
+ hostValues[`${path}#${scope}`] = Object.fromEntries(Object.entries(values).map(([n, v]) => [n, { host: v.from, export: v.to }]));
391
+ }
392
+ out.scopes[scope] = copy;
393
+ lockDigests[path] = { ...(lockDigests[path] ?? {}), [scope]: { host: lineageDigest(lineage), export: lineageDigest(copy), partial: kept.length < all.length } };
394
+ }
395
+ if (Object.keys(out.scopes).length > 0) exportLocks.set(path, out);
396
+ }
397
+ const unused = Object.keys(params).filter((p) => !usedParams.has(p)).sort();
398
+ if (unused.length > 0) throw new ExportError(`--param ${unused.join(", ")}: no exported lineage has a host-bound parameter by that name`);
399
+
400
+ // The export's declaration: the members that went, as written.
401
+ const raw = rawDeclaration(root).obj;
402
+ const names = new Set(members.map((m) => m.name));
403
+ const outDecl: Record<string, unknown> = {};
404
+ for (const [key, value] of Object.entries(raw)) {
405
+ if (key === "members") {
406
+ outDecl.members = (value as Record<string, unknown>[])
407
+ .filter((e) => typeof e.name === "string" && names.has(e.name))
408
+ .map((e) => {
409
+ const links = e.links as { member: string }[] | undefined;
410
+ if (!links) return e;
411
+ const keep = links.filter((l) => names.has(l.member));
412
+ for (const l of links) if (!names.has(l.member)) dropped.links.push({ member: e.name as string, to: l.member });
413
+ const { links: _links, ...rest } = e;
414
+ return keep.length > 0 ? { ...rest, links: keep } : rest;
415
+ });
416
+ } else if (key === "agents") {
417
+ const agents = value as { name: string; member: string }[];
418
+ for (const a of agents) if (!names.has(a.member)) dropped.agents.push(a.name);
419
+ outDecl.agents = agents.filter((a) => names.has(a.member));
420
+ } else if (key === "pins") {
421
+ const pins = value as { path?: string; package?: string }[];
422
+ for (const p of pins) if (p.path !== undefined) dropped.pins.push(p.path);
423
+ outDecl.pins = pins.filter((p) => p.path === undefined);
424
+ } else if (key === "records") {
425
+ if (whole) outDecl.records = value;
426
+ } else if (key === "diagrams") {
427
+ for (const d of value as { name: string }[]) dropped.diagrams.push(d.name);
428
+ } else {
429
+ outDecl[key] = value;
430
+ }
431
+ }
432
+
433
+ // The manifest.
434
+ const fileHashes: ExportManifest["files"] = {};
435
+ for (const path of [...files.keys()].sort()) fileHashes[path] = { host: fileHash(hostFiles.get(path)!), export: fileHash(files.get(path)!) };
436
+ const top = gitTopOf(root);
437
+ let revision: string | null = null;
438
+ let dirty = false;
439
+ if (top) {
440
+ try {
441
+ revision = git(root, ["rev-parse", "HEAD"]).trim();
442
+ } catch {
443
+ revision = null;
444
+ }
445
+ const exported = [...members.map((m) => m.dir), ...dirs];
446
+ dirty = git(root, ["status", "--porcelain", "--", ...exported]).trim() !== "";
447
+ }
448
+ const body = {
449
+ from: { workspace: decl.name, revision, dirty },
450
+ member: target.name,
451
+ members: members.map((m) => m.name),
452
+ whole,
453
+ dirs: [...dirs].sort(),
454
+ files: fileHashes,
455
+ locks: lockDigests,
456
+ hostValues,
457
+ dropped,
458
+ };
459
+ const id = `exp-${createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 12)}`;
460
+ const manifest = ManifestSchema.parse({ schema: 1, id, ...body });
461
+
462
+ const result: ExportResult = { member: target.name, dir: target.dir, manifest, files: files.size + exportLocks.size, switched: [...switched].sort(), written: false };
463
+ if (o.dryRun) return result;
464
+
465
+ // Write: the export member only, after clearing what an earlier export left.
466
+ const out = join(root, target.dir);
467
+ if (existsSync(out)) {
468
+ const present = readdirSync(out);
469
+ if (present.length > 0 && !existsSync(join(out, EXPORT_MANIFEST))) {
470
+ throw new ExportError(`${target.dir} holds files no export wrote (it has no ${EXPORT_MANIFEST}); export writes only into an empty export member or over an earlier export`);
471
+ }
472
+ for (const name of present) if (name !== ".git") rmSync(join(out, name), { recursive: true, force: true });
473
+ }
474
+ const put = (rel: string, data: Buffer | string) => {
475
+ const abs = resolve(out, rel);
476
+ if (relative(out, abs).startsWith("..")) throw new ExportError(`${rel} would land outside the export member`);
477
+ mkdirSync(dirname(abs), { recursive: true });
478
+ writeFileSync(abs, data);
479
+ };
480
+ for (const [path, data] of files) put(path, data);
481
+ for (const [path, lock] of exportLocks) put(path, renderLock(lock));
482
+ put("chant.workspace.json", JSON.stringify(outDecl, null, 2) + "\n");
483
+ put(EXPORT_MANIFEST, JSON.stringify(manifest, null, 2) + "\n");
484
+ result.written = true;
485
+ return result;
486
+ }
@@ -258,6 +258,8 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
258
258
  }
259
259
  // Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
260
260
  const graph = composeWorkspaceGraph({ name: declaration.name, root: located.root }, inputs, { declaration, kinds });
261
+ // Nested workspaces (#2551, ws-071): read-only, through their own chant workspace graph.
262
+ const nestedFailed = await expandNested(graph, declaration.members, query, located.rootOnDisk, located.at);
261
263
  let recordsFailed = false;
262
264
  if (query.kind !== undefined) {
263
265
  // Artifact relationships come from records (#2549): a record's pins and
@@ -274,7 +276,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
274
276
  query.onStderr?.(`${formatError({ message: `--kind ${query.kind}: ${err.code}: ${err.message}`, hint: USAGE })}\n`);
275
277
  }
276
278
  }
277
- return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed, ...(components ? { components } : {}) };
279
+ return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed || nestedFailed, ...(components ? { components } : {}) };
278
280
  } catch (err) {
279
281
  if (!(err instanceof WorkspaceReadError)) throw err;
280
282
  return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
@@ -284,6 +286,50 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
284
286
  }
285
287
  }
286
288
 
289
+ /**
290
+ * Expand every member of kind `workspace` the query covers, read-only (#2551,
291
+ * ws-071). Returns whether one could not be read.
292
+ */
293
+ async function expandNested(
294
+ graph: WorkspaceGraph,
295
+ members: readonly { name: string; dir: string; kind: string }[],
296
+ query: GraphQuery,
297
+ rootOnDisk: string,
298
+ at: string | null,
299
+ ): Promise<boolean> {
300
+ const nested = members.filter((m) => m.kind === "workspace" && (!query.members?.length || query.members.includes(m.name)));
301
+ if (nested.length === 0) return false;
302
+ const { foldNested, readNested } = await import("./nested-graph");
303
+ const reads = await Promise.all(
304
+ nested.map(async (m) => {
305
+ const abs = join(rootOnDisk, ...m.dir.split("/"));
306
+ if (!existsSync(abs)) return { m, read: null };
307
+ return { m, read: await readNested(abs, query.args ?? {}, at, GRAPH_OUTPUT_SCHEMA_ID, GRAPH_CONTRACT_VERSION, query.reader) };
308
+ }),
309
+ );
310
+ let failed = false;
311
+ for (const { m, read } of reads) {
312
+ const member = graph.members.find((x) => x.name === m.name);
313
+ if (!member) continue;
314
+ if (!read) {
315
+ failed = true;
316
+ member.status = "failed";
317
+ member.reason = { code: "dir-missing", message: `the nested workspace's directory ${m.dir} does not exist` };
318
+ continue;
319
+ }
320
+ if (read.stderr.trim() && query.onStderr) query.onStderr(read.stderr.endsWith("\n") ? read.stderr : `${read.stderr}\n`);
321
+ member.chant = read.chant;
322
+ if (read.ok) {
323
+ foldNested(graph, member, read.doc);
324
+ } else {
325
+ failed = true;
326
+ member.status = "failed";
327
+ member.reason = { code: read.code, message: read.message };
328
+ }
329
+ }
330
+ return failed;
331
+ }
332
+
287
333
  export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
288
334
  const { args } = ctx;
289
335
  const cwd = resolve(args.extraPositional ?? ".");
@@ -14,7 +14,7 @@
14
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
- import { cleanScratch, commitAll, contract, declaration, declaration as declaration_, FAKE_GRAPH_CHANT, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
17
+ import { cleanScratch, commitAll, contract, declaration, declaration as declaration_, FAKE_FILE_GRAPH_CHANT, FAKE_GRAPH_CHANT, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
18
18
  import type { GraphIR } from "../graph-ir";
19
19
  import { composeWorkspaceGraph, MEMBER_RUN_REASON_CODES } from "./compose-graph";
20
20
  import { parseDeclaration, WORKSPACE_ERROR_CODES } from "./declaration";
@@ -106,12 +106,97 @@ describe("the links section (#2539)", () => {
106
106
  });
107
107
  });
108
108
 
109
+ describe("the collectors section (#2559)", () => {
110
+ const topology = {
111
+ pipelines: [{ id: "traces", signal: "traces", receivers: ["otlp"], processors: [], exporters: ["otlphttp"] }],
112
+ components: [{ id: "otlphttp", kind: "exporter", type: "otlphttp", endpoints: ["https://collector.example:4318"], pipelines: ["traces"] }],
113
+ exporters: [{ id: "otlphttp", type: "otlphttp", endpoints: ["https://collector.example:4318"], pipelines: ["traces"], signals: ["traces"] }],
114
+ };
115
+ /** A member whose chant answers from ir.json, as one whose project declares a collector does. */
116
+ const collectorWorkspace = () =>
117
+ repo({
118
+ "chant.workspace.json": declaration([{ name: "ops", dir: "ops", kind: "chant" }, { name: "web", dir: "web", kind: "chant" }]),
119
+ "ops/chant.config.ts": "export default {};\n",
120
+ "ops/ir.json": JSON.stringify({ version: 1, nodes: [], edges: [], groups: {}, meta: { collector: topology } }),
121
+ "web/chant.config.ts": "export default {};\n",
122
+ "web/ir.json": JSON.stringify({ version: 1, nodes: [], edges: [], groups: {} }),
123
+ ".gitignore": "node_modules\n",
124
+ "node_modules/.bin/chant": { text: FAKE_FILE_GRAPH_CHANT, mode: 0o755 },
125
+ });
126
+
127
+ test("the schema requires collectors and describes each entry", () => {
128
+ const required = (schema.$defs.result as { required: string[] }).required;
129
+ expect(required).toContain("collectors");
130
+ expect((schema.$defs.collector as { required: string[] }).required).toEqual(["member", "pipelines", "components", "exporters"]);
131
+ });
132
+
133
+ test("a workspace whose member declares a collector lists its pipelines and exporters, and validates", async () => {
134
+ const { doc } = await workspaceGraph({ cwd: collectorWorkspace() });
135
+ const g = result(doc);
136
+ expectValid(g);
137
+ expect(g.collectors.map((c) => c.member)).toEqual(["ops"]);
138
+ expect(g.collectors[0]!.pipelines.map((p) => p.id)).toEqual(["traces"]);
139
+ expect(g.collectors[0]!.exporters).toEqual(topology.exporters);
140
+ expect(g.members.find((m) => m.name === "ops")!.meta).toBeUndefined();
141
+ });
142
+
143
+ test("telemetry links resolve against the collectors and validate (#2558)", async () => {
144
+ const withProtocols = {
145
+ pipelines: topology.pipelines,
146
+ components: [
147
+ { id: "otlp", kind: "receiver", type: "otlp", endpoints: ["0.0.0.0:4318"], protocols: ["http/protobuf", "http/json"], pipelines: ["traces"] },
148
+ { id: "otlphttp", kind: "exporter", type: "otlphttp", endpoints: ["https://collector.example:4318"], protocols: ["http/protobuf"], pipelines: ["traces"] },
149
+ ],
150
+ exporters: topology.exporters,
151
+ };
152
+ const cwd = repo({
153
+ "chant.workspace.json": declaration([
154
+ { name: "ops", dir: "ops", kind: "chant" },
155
+ {
156
+ name: "web",
157
+ dir: "web",
158
+ kind: "chant",
159
+ links: [
160
+ { member: "ops", output: "traces", kind: "telemetry", protocol: "http/protobuf" },
161
+ { member: "ops", output: "traces", kind: "telemetry", protocol: "grpc" },
162
+ { member: "ops", output: "logs", kind: "telemetry" },
163
+ ],
164
+ },
165
+ ]),
166
+ "ops/chant.config.ts": "export default {};\n",
167
+ "ops/ir.json": JSON.stringify({ version: 1, nodes: [], edges: [], groups: {}, meta: { collector: withProtocols } }),
168
+ "web/chant.config.ts": "export default {};\n",
169
+ "web/ir.json": JSON.stringify({ version: 1, nodes: [], edges: [], groups: {} }),
170
+ ".gitignore": "node_modules\n",
171
+ "node_modules/.bin/chant": { text: FAKE_FILE_GRAPH_CHANT, mode: 0o755 },
172
+ });
173
+ const { doc } = await workspaceGraph({ cwd });
174
+ const g = result(doc);
175
+ expectValid(g);
176
+ const rows = (g.links as LinkTableRow[]).filter((r) => r.kind === "telemetry").map((r) => (r.status === "ambiguous" ? "" : `${r.output} ${r.protocol ?? "-"} ${r.status} ${r.target ?? "-"}`));
177
+ expect(rows).toEqual(["traces http/protobuf resolved pipeline", "traces grpc invalid pipeline", "logs - missing -"]);
178
+ });
179
+
180
+ test("a workspace with no collector prints an empty list", async () => {
181
+ const { doc } = await workspaceGraph({ cwd: fakeWorkspace() });
182
+ const g = result(doc);
183
+ expectValid(g);
184
+ expect(g.collectors).toEqual([]);
185
+ });
186
+
187
+ test("a collector with no exporters list does not validate", () => {
188
+ const bad = { member: "ops", pipelines: [], components: [] };
189
+ expect(() => expectValid({ $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.81.0", at: null, ...composeWorkspaceGraph({ name: "x", root: "." }, []), collectors: [bad] })).toThrow();
190
+ });
191
+ });
192
+
109
193
  describe("chant workspace graph on the chant repo (#2557)", () => {
110
194
  // 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.
195
+ // real chant per read; kind-readers.e2e.test.ts covers that. The nested
196
+ // reference workspace runs its own chant workspace graph (#2551);
197
+ // read-contract.test.ts covers that. Here both are left out, so nothing runs.
113
198
  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")
199
+ .filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu" && m.kind !== "workspace")
115
200
  .map((m) => m.name);
116
201
 
117
202
  test("validates, and lists every member but the terraform one as skipped: none is kind chant", async () => {