@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,348 @@
1
+ /**
2
+ * `chant workspace versions [<dir>] [--template <id>] [--json]` (#2551, D9,
3
+ * requirement T9).
4
+ *
5
+ * Reports, for a family of workspaces, which template version and which chant
6
+ * and lexicon versions each one is on. A family is the set of workspaces made
7
+ * from the same template: each lineage scope belongs to the family of its
8
+ * template id. The command finds every `.chant/workspace.lock.json` under
9
+ * `<dir>` (the working directory by default), so pointing it at a directory of
10
+ * checkouts compares them all. Without `--available` nothing is fetched and no
11
+ * code runs: it reads lock files and `package.json` files only.
12
+ *
13
+ * With `--available`, each family whose template is a git source also lists
14
+ * the template's version tags (`git ls-remote --tags`, catalogued in
15
+ * `test/egress-catalogue.ts`), and each member says how many are newer than
16
+ * the version its lock sits at. That is the list `chant workspace upgrade
17
+ * --to <tag>` chooses from.
18
+ *
19
+ * Per D9 the word "drift" keeps its existing meanings, so a workspace on an
20
+ * older version than the newest one in its family is reported as `behind`.
21
+ */
22
+
23
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { dirname, join, relative, resolve, sep } from "node:path";
25
+ import { formatError } from "../cli/format";
26
+ import type { CommandContext } from "../cli/registry";
27
+ import { TemplateTags } from "./lineage-hash-index";
28
+ import { LockError, LOCK_FILE, readLock, type LineageSource } from "./lineage-lock";
29
+ import { lineageProvenance } from "./lineage-provenance";
30
+ import { compareVersions, formatVersion, parseVersion } from "./lineage-version";
31
+
32
+ /** How deep under `<dir>` a lock is looked for. */
33
+ export const MAX_DEPTH = 8;
34
+
35
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".chant"]);
36
+
37
+ export interface ScopeVersion {
38
+ scope: string;
39
+ kind: "template" | "vendor";
40
+ template: string;
41
+ ref: string | null;
42
+ /** The version the ref names, or null when it names none (a branch, a commit). */
43
+ version: string | null;
44
+ commit: string | null;
45
+ migrations: number;
46
+ /** `files` or `lineage` when adopt-lineage recorded the lineage (#2551), otherwise null. */
47
+ adoption: "files" | "lineage" | null;
48
+ /** D5's level: `adopted` once the policy at base admits the adopted range, `unattested` otherwise. */
49
+ provenance: "adopted" | "unattested";
50
+ /** Where the template is fetched from: the source type, and for git the URL as the lock records it. */
51
+ source: { type: LineageSource["type"]; url?: string };
52
+ }
53
+
54
+ export interface PluginVersion {
55
+ name: string;
56
+ /** The range `package.json` declares. */
57
+ declared: string;
58
+ /** The version installed where the workspace resolves it, or null when it is not installed. */
59
+ installed: string | null;
60
+ }
61
+
62
+ export interface WorkspaceVersions {
63
+ /** Relative to `<dir>`, `.` for `<dir>` itself. */
64
+ path: string;
65
+ scopes: ScopeVersion[];
66
+ plugins: PluginVersion[];
67
+ /** Set when the lock could not be read; the workspace then has no scopes. */
68
+ error?: string;
69
+ }
70
+
71
+ export interface FamilyMember {
72
+ path: string;
73
+ scope: string;
74
+ ref: string | null;
75
+ version: string | null;
76
+ /** Older than the newest version in the family. */
77
+ behind: boolean;
78
+ /** With `--available`: how many of the template's version tags are newer than this member's version, or null when unknown. */
79
+ newer?: number | null;
80
+ }
81
+
82
+ export interface AvailableVersions {
83
+ /** The template's version tags, oldest first, narrowed by `--tags`. */
84
+ tags: string[];
85
+ /** The newest of them, or null when there are none. */
86
+ newest: string | null;
87
+ /** Why the tags could not be listed (not a git template, or the fetch failed). */
88
+ error?: string;
89
+ }
90
+
91
+ export interface Family {
92
+ template: string;
93
+ /** The newest version any member is on, or null when no member's ref is a version. */
94
+ newest: string | null;
95
+ members: FamilyMember[];
96
+ /** With `--available`: the template's own version tags. */
97
+ available?: AvailableVersions;
98
+ }
99
+
100
+ export interface VersionsReport {
101
+ root: string;
102
+ workspaces: WorkspaceVersions[];
103
+ families: Family[];
104
+ /** chant and lexicon packages that members of one family have at different installed versions. */
105
+ pluginSpread: Array<{ template: string; name: string; versions: Record<string, string[]> }>;
106
+ }
107
+
108
+ /** Every directory under `dir` that holds a lineage lock, nearest first. */
109
+ export function findLocks(dir: string, depth = MAX_DEPTH): string[] {
110
+ const found: string[] = [];
111
+ const walk = (at: string, level: number): void => {
112
+ if (existsSync(join(at, LOCK_FILE))) found.push(at);
113
+ if (level >= depth) return;
114
+ let names: string[];
115
+ try {
116
+ names = readdirSync(at);
117
+ } catch {
118
+ return;
119
+ }
120
+ for (const name of names.sort()) {
121
+ if (SKIP_DIRS.has(name) || (name.startsWith(".") && name !== ".")) continue;
122
+ const child = join(at, name);
123
+ try {
124
+ if (!statSync(child).isDirectory()) continue;
125
+ } catch {
126
+ continue;
127
+ }
128
+ walk(child, level + 1);
129
+ }
130
+ };
131
+ walk(dir, 0);
132
+ return found;
133
+ }
134
+
135
+ function isPlugin(name: string): boolean {
136
+ return name === "@intentius/chant" || name.startsWith("@intentius/chant-lexicon-");
137
+ }
138
+
139
+ /** The version of `pkg` that node would resolve from `dir`: the nearest `node_modules/<pkg>` going up. */
140
+ function installedVersion(pkg: string, dir: string): string | null {
141
+ let at = dir;
142
+ for (;;) {
143
+ const manifest = join(at, "node_modules", pkg, "package.json");
144
+ if (existsSync(manifest)) {
145
+ try {
146
+ return (JSON.parse(readFileSync(manifest, "utf-8")) as { version?: string }).version ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ const up = dirname(at);
152
+ if (up === at) return null;
153
+ at = up;
154
+ }
155
+ }
156
+
157
+ /** chant and its lexicons, as the workspace's `package.json` declares them. */
158
+ export function pluginVersions(dir: string): PluginVersion[] {
159
+ const manifest = join(dir, "package.json");
160
+ if (!existsSync(manifest)) return [];
161
+ let json: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
162
+ try {
163
+ json = JSON.parse(readFileSync(manifest, "utf-8"));
164
+ } catch {
165
+ return [];
166
+ }
167
+ const declared = { ...(json.devDependencies ?? {}), ...(json.dependencies ?? {}) };
168
+ return Object.keys(declared)
169
+ .filter(isPlugin)
170
+ .sort()
171
+ .map((name) => ({ name, declared: declared[name], installed: installedVersion(name, dir) }));
172
+ }
173
+
174
+ export interface VersionsOptions {
175
+ /** Only this template's family. */
176
+ template?: string;
177
+ /** List each git template's version tags. */
178
+ available?: boolean;
179
+ /** Narrow those tags with a glob, such as `kit-v*`. */
180
+ tags?: string;
181
+ /** Lists a template's version tags; replaced in tests. */
182
+ listTags?: (url: string, member: string | undefined, pattern?: string) => string[];
183
+ }
184
+
185
+ /** The default lister: `git ls-remote --tags` through ./lineage-hash-index.ts. */
186
+ function remoteTags(url: string, member: string | undefined, pattern?: string): string[] {
187
+ const tags = new TemplateTags(url, member, url);
188
+ try {
189
+ return tags.listTags(pattern).map((t) => t.tag);
190
+ } finally {
191
+ tags.dispose();
192
+ }
193
+ }
194
+
195
+ /** Build the report for every workspace under `dir`, narrowed to one template's family when `template` is given. */
196
+ export function workspaceVersions(dir: string, templateOrOptions?: string | VersionsOptions): VersionsReport {
197
+ const options: VersionsOptions = typeof templateOrOptions === "string" ? { template: templateOrOptions } : (templateOrOptions ?? {});
198
+ const template = options.template;
199
+ const root = resolve(dir);
200
+ const workspaces: WorkspaceVersions[] = [];
201
+ for (const at of findLocks(root)) {
202
+ const path = relative(root, at).split(sep).join("/") || ".";
203
+ let scopes: ScopeVersion[] = [];
204
+ let error: string | undefined;
205
+ try {
206
+ const lock = readLock(at)!;
207
+ scopes = Object.entries(lock.scopes).map(([scope, l]) => {
208
+ const v = parseVersion(l.ref);
209
+ return {
210
+ scope,
211
+ kind: l.kind,
212
+ template: l.template,
213
+ ref: l.ref ?? null,
214
+ version: v ? formatVersion(v) : null,
215
+ commit: l.address?.commit ?? null,
216
+ migrations: l.migrations.length,
217
+ adoption: l.adoption?.by ?? null,
218
+ provenance: lineageProvenance(at, l)?.level ?? "unattested",
219
+ source: l.source.type === "git" ? { type: "git", url: l.source.url.startsWith(".") ? resolve(at, l.source.url) : l.source.url } : { type: l.source.type },
220
+ };
221
+ });
222
+ } catch (err) {
223
+ if (!(err instanceof LockError)) throw err;
224
+ error = err.message;
225
+ }
226
+ if (template !== undefined && !error && !scopes.some((s) => s.template === template)) continue;
227
+ workspaces.push({ path, scopes, plugins: pluginVersions(at), ...(error ? { error } : {}) });
228
+ }
229
+
230
+ const byTemplate = new Map<string, FamilyMember[]>();
231
+ for (const w of workspaces) {
232
+ for (const s of w.scopes) {
233
+ if (template !== undefined && s.template !== template) continue;
234
+ const list = byTemplate.get(s.template) ?? [];
235
+ list.push({ path: w.path, scope: s.scope, ref: s.ref, version: s.version, behind: false });
236
+ byTemplate.set(s.template, list);
237
+ }
238
+ }
239
+ const families: Family[] = [];
240
+ const pluginSpread: VersionsReport["pluginSpread"] = [];
241
+ for (const [id, members] of [...byTemplate.entries()].sort(([a], [b]) => a.localeCompare(b))) {
242
+ const versions = members.map((m) => (m.version ? parseVersion(m.version)! : null)).filter((v) => v !== null);
243
+ const newest = versions.length > 0 ? versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b)) : null;
244
+ for (const m of members) {
245
+ m.behind = newest !== null && m.version !== null && compareVersions(parseVersion(m.version)!, newest) < 0;
246
+ }
247
+ const family: Family = { template: id, newest: newest ? formatVersion(newest) : null, members };
248
+ if (options.available) family.available = available(id, workspaces, members, options);
249
+ families.push(family);
250
+
251
+ const paths = new Set(members.map((m) => m.path));
252
+ const seen = new Map<string, Record<string, string[]>>();
253
+ for (const w of workspaces.filter((w) => paths.has(w.path))) {
254
+ for (const p of w.plugins) {
255
+ const v = p.installed ?? `not installed (${p.declared})`;
256
+ const entry = seen.get(p.name) ?? {};
257
+ (entry[v] ??= []).push(w.path);
258
+ seen.set(p.name, entry);
259
+ }
260
+ }
261
+ for (const [name, spread] of [...seen.entries()].sort(([a], [b]) => a.localeCompare(b))) {
262
+ if (Object.keys(spread).length > 1) pluginSpread.push({ template: id, name, versions: spread });
263
+ }
264
+ }
265
+ return { root, workspaces, families, pluginSpread };
266
+ }
267
+
268
+ /** The version tags of a family's template, and how far behind them each member is. */
269
+ function available(id: string, workspaces: WorkspaceVersions[], members: FamilyMember[], options: VersionsOptions): AvailableVersions {
270
+ const scope = workspaces.flatMap((w) => w.scopes).find((s) => s.template === id);
271
+ if (!scope || scope.source.type !== "git" || !scope.source.url) {
272
+ for (const m of members) m.newer = null;
273
+ return { tags: [], newest: null, error: `a ${scope?.source.type ?? "unknown"} source has no version tags to list` };
274
+ }
275
+ const member = id.includes("#") ? id.slice(id.lastIndexOf("#") + 1) : undefined;
276
+ let tags: string[];
277
+ try {
278
+ tags = (options.listTags ?? remoteTags)(scope.source.url, member, options.tags);
279
+ } catch (err) {
280
+ for (const m of members) m.newer = null;
281
+ return { tags: [], newest: null, error: err instanceof Error ? err.message : String(err) };
282
+ }
283
+ const versions = tags.map((t) => parseVersion(t)).filter((v) => v !== null);
284
+ for (const m of members) {
285
+ const v = m.version ? parseVersion(m.version) : null;
286
+ m.newer = v ? versions.filter((t) => compareVersions(t, v) > 0).length : null;
287
+ }
288
+ return { tags, newest: tags.length > 0 ? tags[tags.length - 1] : null };
289
+ }
290
+
291
+ /** The report as terminal lines. */
292
+ export function describeVersions(report: VersionsReport): string[] {
293
+ const lines: string[] = [];
294
+ for (const w of report.workspaces) {
295
+ lines.push(w.path);
296
+ if (w.error) lines.push(` unreadable: ${w.error}`);
297
+ for (const s of w.scopes) {
298
+ const at = s.commit ? ` (${s.commit.slice(0, 12)})` : "";
299
+ const adopted = s.adoption === "files" && s.provenance !== "adopted" ? ", adopted range not yet at base" : s.adoption === "lineage" ? ", moved from a directory lineage" : "";
300
+ lines.push(` ${s.scope} ${s.template}@${s.ref ?? "(no ref)"}${at} ${s.provenance}${adopted}${s.migrations > 0 ? `, ${s.migrations} migration(s)` : ""}`);
301
+ }
302
+ if (w.plugins.length > 0) {
303
+ lines.push(` plugins: ${w.plugins.map((p) => `${p.name} ${p.installed ?? `not installed (${p.declared})`}`).join(", ")}`);
304
+ }
305
+ }
306
+ for (const f of report.families) {
307
+ const behind = f.members.filter((m) => m.behind);
308
+ lines.push(
309
+ `family ${f.template}: ${f.members.length} scope(s), newest ${f.newest ?? "(no versions)"}${behind.length > 0 ? `; behind: ${behind.map((m) => `${m.path}${m.scope === "." ? "" : `/${m.scope}`} (${m.ref})`).join(", ")}` : ""}`,
310
+ );
311
+ if (f.available) {
312
+ if (f.available.error) lines.push(` available: unknown (${f.available.error})`);
313
+ else {
314
+ lines.push(` available: ${f.available.tags.length} version tag(s), newest ${f.available.newest ?? "(none)"}`);
315
+ for (const m of f.members) {
316
+ if (m.newer === undefined || m.newer === null) continue;
317
+ lines.push(` ${m.path}${m.scope === "." ? "" : `/${m.scope}`} at ${m.ref}: ${m.newer === 0 ? "on the newest" : `${m.newer} newer`}`);
318
+ }
319
+ }
320
+ }
321
+ for (const s of report.pluginSpread.filter((p) => p.template === f.template)) {
322
+ lines.push(` ${s.name} differs: ${Object.entries(s.versions).map(([v, paths]) => `${v} in ${paths.join(", ")}`).join("; ")}`);
323
+ }
324
+ }
325
+ if (report.workspaces.length === 0) lines.push(`no ${LOCK_FILE} under ${report.root}`);
326
+ return lines;
327
+ }
328
+
329
+ export async function runWorkspaceVersions(ctx: CommandContext): Promise<number> {
330
+ const { args } = ctx;
331
+ if (args.extraPositional2) {
332
+ console.error(formatError({ message: `unexpected argument: ${args.extraPositional2}`, hint: "chant workspace versions [<dir>] [--template <id>] [--available [--tags <glob>]] [--json]" }));
333
+ return 1;
334
+ }
335
+ const dir = resolve(args.extraPositional ?? ".");
336
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
337
+ console.error(formatError({ message: `${dir} is not a directory` }));
338
+ return 1;
339
+ }
340
+ const report = workspaceVersions(dir, {
341
+ template: args.template,
342
+ available: args.available,
343
+ tags: args.tags,
344
+ });
345
+ if (args.json) console.log(JSON.stringify(report, null, 2));
346
+ else console.log(describeVersions(report).join("\n"));
347
+ return 0;
348
+ }
@@ -321,8 +321,8 @@ describe("the declaration's link fields", () => {
321
321
  expect(d.members[0].outputs).toEqual(["One", "Two"]);
322
322
  expect(d.members[1].outputs).toBeNull();
323
323
  expect(d.members[1].links).toEqual([
324
- { member: "a", output: "One", kind: null, pointer: "/members/1/links/0" },
325
- { member: "a", output: "Two", kind: "output", pointer: "/members/1/links/1" },
324
+ { member: "a", output: "One", kind: null, protocol: null, pointer: "/members/1/links/0" },
325
+ { member: "a", output: "Two", kind: "output", protocol: null, pointer: "/members/1/links/1" },
326
326
  ]);
327
327
  });
328
328
 
@@ -335,6 +335,125 @@ describe("the declaration's link fields", () => {
335
335
  });
336
336
  });
337
337
 
338
+ describe("telemetry links (#2558)", () => {
339
+ const collector = {
340
+ member: "ops",
341
+ pipelines: [
342
+ { id: "traces", signal: "traces", receivers: ["otlp"], processors: [], exporters: ["otlphttp/backend"] },
343
+ { id: "metrics", signal: "metrics", receivers: ["custom"], processors: [], exporters: ["otlphttp/backend"] },
344
+ ],
345
+ components: [
346
+ { id: "otlp", kind: "receiver", type: "otlp", endpoints: ["0.0.0.0:4318"], protocols: ["http/protobuf", "http/json"], pipelines: ["traces"] },
347
+ { id: "custom", kind: "receiver", type: "custom", endpoints: [], protocols: [], pipelines: ["metrics"] },
348
+ { id: "otlphttp/backend", kind: "exporter", type: "otlphttp", endpoints: ["https://b"], protocols: ["http/protobuf"], pipelines: ["traces", "metrics"] },
349
+ ],
350
+ exporters: [{ id: "otlphttp/backend", type: "otlphttp", endpoints: ["https://b"], pipelines: ["traces", "metrics"], signals: ["traces", "metrics"] }],
351
+ };
352
+ const decl = (link: Record<string, unknown>) =>
353
+ parseDeclaration(
354
+ declaration([
355
+ { name: "ops", dir: "ops", kind: "chant" },
356
+ { name: "web", dir: "web", kind: "chant" },
357
+ { name: "docs", dir: "docs", kind: "other", because: "prose" },
358
+ { name: "app", dir: "app", kind: "other", because: "x", links: [{ kind: "telemetry", ...link }] },
359
+ ]),
360
+ "chant.workspace.json",
361
+ );
362
+ const targets = (collectors: (typeof collector)[], composed = ["ops", "web"]) => ({
363
+ collectors: new Map(collectors.map((c) => [c.member, c])),
364
+ composed: new Set(composed),
365
+ });
366
+ const rowFor = (link: Record<string, unknown>, t?: ReturnType<typeof targets>) => resolveLinks(decl(link), new Map(), t)[0] as Extract<LinkTableRow, { producer: string }>;
367
+
368
+ test("resolves to a pipeline of the producer's collector, and says it is a pipeline", () => {
369
+ const row = rowFor({ member: "ops", output: "traces" }, targets([collector]));
370
+ expect(row).toMatchObject({ kind: "telemetry", status: "resolved", reason: null, target: "pipeline", output: "traces", producer: "ops" });
371
+ });
372
+
373
+ test("resolves to an exporter when no pipeline has the name", () => {
374
+ expect(rowFor({ member: "ops", output: "otlphttp/backend" }, targets([collector]))).toMatchObject({ status: "resolved", target: "exporter" });
375
+ });
376
+
377
+ test("is missing when the collector has no such pipeline or exporter, and lists what it has", () => {
378
+ const row = rowFor({ member: "ops", output: "logs" }, targets([collector]));
379
+ expect(row.status).toBe("missing");
380
+ expect(row.reason).toBe("ops's collector has no pipeline or exporter logs; pipelines: traces, metrics; exporters: otlphttp/backend");
381
+ expect(row.target).toBeUndefined();
382
+ });
383
+
384
+ test("is missing when a composed producer declares no collector, and unresolved when it was not composed", () => {
385
+ expect(rowFor({ member: "web", output: "traces" }, targets([collector]))).toMatchObject({ status: "missing", reason: expect.stringContaining("web declares no collector") });
386
+ expect(rowFor({ member: "docs", output: "traces" }, targets([collector]))).toMatchObject({ status: "unresolved", reason: expect.stringContaining("docs was not composed") });
387
+ });
388
+
389
+ test("checks the protocol against a pipeline's receivers", () => {
390
+ expect(rowFor({ member: "ops", output: "traces", protocol: "http/protobuf" }, targets([collector]))).toMatchObject({ status: "resolved", protocol: "http/protobuf" });
391
+ const bad = rowFor({ member: "ops", output: "traces", protocol: "grpc" }, targets([collector]));
392
+ expect(bad).toMatchObject({ status: "invalid", target: "pipeline" });
393
+ expect(bad.reason).toBe("the receivers of pipeline traces speak http/protobuf, http/json, and the link sends grpc");
394
+ });
395
+
396
+ test("checks the protocol against an exporter's own", () => {
397
+ expect(rowFor({ member: "ops", output: "otlphttp/backend", protocol: "http/protobuf" }, targets([collector])).status).toBe("resolved");
398
+ expect(rowFor({ member: "ops", output: "otlphttp/backend", protocol: "grpc" }, targets([collector])).status).toBe("invalid");
399
+ });
400
+
401
+ test("leaves the link unresolved when the target's definitions state no protocols", () => {
402
+ const row = rowFor({ member: "ops", output: "metrics", protocol: "grpc" }, targets([collector]));
403
+ expect(row).toMatchObject({ status: "unresolved", reason: "the receivers of pipeline metrics state no protocols, so grpc could not be confirmed" });
404
+ });
405
+
406
+ test("a link with no protocol checks presence only", () => {
407
+ expect(rowFor({ member: "ops", output: "metrics" }, targets([collector])).status).toBe("resolved");
408
+ });
409
+
410
+ test("without collectors, as in chant workspace check, a telemetry link is kept unresolved", () => {
411
+ const row = rowFor({ member: "ops", output: "traces" });
412
+ expect(row).toMatchObject({ status: "unresolved", reason: expect.stringContaining("read by chant workspace graph") });
413
+ });
414
+
415
+ test("a protocol on a link that isn't a telemetry link is invalid", () => {
416
+ const d = parseDeclaration(
417
+ declaration([{ name: "ops", dir: "ops", kind: "chant" }, { name: "app", dir: "app", kind: "other", because: "x", links: [{ member: "ops", output: "Url", protocol: "grpc" }] }]),
418
+ "chant.workspace.json",
419
+ );
420
+ const row = resolveLinks(d, new Map())[0] as Extract<LinkTableRow, { producer: string }>;
421
+ expect(row).toMatchObject({ status: "invalid", reason: "protocol applies to telemetry links; this link has kind output" });
422
+ });
423
+
424
+ test("graphLinks resolves a telemetry link from the composed collectors, and drops the pointer", () => {
425
+ const rows = graphLinks(decl({ member: "ops", output: "traces", protocol: "http/protobuf" }), { composed: ["ops", "web"], collectors: [collector], exports: [], imports: [] });
426
+ expect(rows).toEqual([
427
+ expect.objectContaining({ consumer: "app", producer: "ops", output: "traces", kind: "telemetry", status: "resolved", protocol: "http/protobuf", target: "pipeline" }),
428
+ ]);
429
+ expect("pointer" in rows[0]!).toBe(false);
430
+ });
431
+
432
+ test("the declaration refuses an unknown protocol, and reads a telemetry link's protocol", () => {
433
+ expect(() => decl({ member: "ops", output: "traces", protocol: "carrier-pigeon" })).toThrow(WorkspaceReadError);
434
+ expect(decl({ member: "ops", output: "traces", protocol: "grpc" }).members[3]!.links[0]).toMatchObject({ kind: "telemetry", protocol: "grpc" });
435
+ });
436
+
437
+ test("WSP098 flags a protocol on a link of another kind, and WSP092 accepts telemetry", async () => {
438
+ const root = repo({
439
+ "chant.workspace.json": declaration([
440
+ { name: "ops", dir: "ops", kind: "chant" },
441
+ { name: "app", dir: "app", kind: "other", because: "x", links: [{ member: "ops", output: "Url", protocol: "grpc" }, { member: "ops", output: "traces", kind: "telemetry", protocol: "grpc" }] },
442
+ ]),
443
+ "ops/chant.config.ts": "export default {};\n",
444
+ "app/README.md": "",
445
+ });
446
+ const report = await runDeclarationChecks(root);
447
+ const ids = report.diagnostics.map((d) => d.ruleId);
448
+ expect(ids).toContain("WSP098");
449
+ expect(ids).not.toContain("WSP092");
450
+ expect(report.diagnostics.find((d) => d.ruleId === "WSP098")!.message).toContain("states protocol grpc");
451
+ expect(report.diagnostics.filter((d) => d.ruleId === "WSP098")).toHaveLength(1);
452
+ // The telemetry link is kept unresolved here: check reads source.
453
+ expect(report.diagnostics.find((d) => d.ruleId === "WSP094")!.message).toContain("read by chant workspace graph");
454
+ });
455
+ });
456
+
338
457
  describe("the chant repository's own declaration (#2557)", () => {
339
458
  test("has no link finding", async () => {
340
459
  const report = await runDeclarationChecks(resolve(import.meta.dirname, "../../../.."));
@@ -16,7 +16,7 @@
16
16
  * is promoted.
17
17
  *
18
18
  * Every row says how it was found (`declared` or `inferred:joinKey`), where it
19
- * resolved (`source`; `check --live` comes later) and its status. A link that
19
+ * resolved (`source`, or `live` for the rows of `check --live`, #2549) and its status. A link that
20
20
  * can't be resolved is kept, with the reason.
21
21
  *
22
22
  * {@link resolveLinks} takes the members' handles as plain data, so the same
@@ -29,11 +29,23 @@
29
29
  import { joinKey, joinLabel, type JoinLabel } from "../join-key";
30
30
  import type { Declaration, LinkDeclaration, Member } from "./declaration";
31
31
  import type { KindRegistry, MemberKind } from "./kinds";
32
+ import type { MemberCollector } from "./compose-graph";
32
33
 
33
- /** The link kinds chant knows. Closed: a link of any other kind fails closed. */
34
- export const LINK_KINDS = ["output"] as const;
34
+ /**
35
+ * The link kinds chant knows. Closed: a link of any other kind fails closed.
36
+ * `output` reads a named output of the producer. `telemetry` (#2558, D22)
37
+ * sends the consumer's telemetry to a pipeline or exporter of the producer's
38
+ * collector.
39
+ */
40
+ export const LINK_KINDS = ["output", "telemetry"] as const;
35
41
  export const DEFAULT_LINK_KIND = "output";
36
42
 
43
+ /** The kind a link has when it names a collector target (#2558). */
44
+ export const TELEMETRY_LINK_KIND = "telemetry";
45
+
46
+ /** The OTLP protocols a telemetry link may state, as `OTEL_EXPORTER_OTLP_PROTOCOL` names them. */
47
+ export const TELEMETRY_PROTOCOLS = ["grpc", "http/protobuf", "http/json"] as const;
48
+
37
49
  /** How a row was found: written down, or inferred by the named rule. */
38
50
  export type LinkOrigin = "declared" | "inferred:joinKey";
39
51
 
@@ -65,7 +77,7 @@ interface RowBase {
65
77
  consumer: string;
66
78
  kind: string;
67
79
  origin: LinkOrigin;
68
- resolves: "source";
80
+ resolves: "source" | "live";
69
81
  /** Why the row is not `resolved`, or null. */
70
82
  reason: string | null;
71
83
  }
@@ -84,6 +96,21 @@ export interface LinkRow extends RowBase {
84
96
  to?: string;
85
97
  /** The declared link's JSON Pointer in the declaration. */
86
98
  pointer?: string;
99
+ /** A telemetry link: the OTLP protocol it states (#2558). */
100
+ protocol?: string;
101
+ /** A telemetry link that found its target: what `output` named, a `pipeline` or an `exporter` of the producer's collector. */
102
+ target?: "pipeline" | "exporter";
103
+ }
104
+
105
+ /**
106
+ * What a telemetry link reads from the producers (#2558): each composed
107
+ * member's collector, as `chant workspace graph` lists it, and which members
108
+ * were composed. `chant workspace check` reads source and runs no member, so
109
+ * it passes none and a telemetry link stays unresolved there.
110
+ */
111
+ export interface TelemetryTargets {
112
+ collectors: ReadonlyMap<string, MemberCollector>;
113
+ composed: ReadonlySet<string>;
87
114
  }
88
115
 
89
116
  /** A parameter that joins outputs of two or more producers (or two outputs of one). */
@@ -145,7 +172,7 @@ function nearNames(name: string, outputs: JoinHandle[]): string[] {
145
172
  * missing from it is unresolved. Declared rows come first, in declaration
146
173
  * order; inferred and ambiguous rows follow, sorted by consumer and parameter.
147
174
  */
148
- export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<string, MemberHandles>): LinkTableRow[] {
175
+ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<string, MemberHandles>, telemetry?: TelemetryTargets): LinkTableRow[] {
149
176
  const rows: LinkTableRow[] = [];
150
177
  const declaredTargets = new Map<string, Set<string>>();
151
178
 
@@ -164,6 +191,7 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
164
191
  status: "resolved",
165
192
  reason: null,
166
193
  pointer: link.pointer,
194
+ ...(link.protocol ? { protocol: link.protocol } : {}),
167
195
  };
168
196
  rows.push(row);
169
197
  const targetProblem = linkTargetProblem(declaration, consumer, link);
@@ -178,6 +206,15 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
178
206
  row.reason = `the link kind ${kind} is not one chant knows; known link kinds: ${LINK_KINDS.join(", ")}`;
179
207
  continue;
180
208
  }
209
+ if (kind === TELEMETRY_LINK_KIND) {
210
+ resolveTelemetryLink(row, telemetry);
211
+ continue;
212
+ }
213
+ if (link.protocol) {
214
+ row.status = "invalid";
215
+ row.reason = `protocol applies to telemetry links; this link has kind ${kind}`;
216
+ continue;
217
+ }
181
218
  const producer = handles.get(link.member);
182
219
  const found = producer?.outputs.find((o) => o.name === link.output);
183
220
  if (found) {
@@ -260,6 +297,54 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
260
297
  return [...rows, ...inferred];
261
298
  }
262
299
 
300
+ /**
301
+ * Resolve a telemetry link against the producer's collector (#2558): the
302
+ * target exists (presence), and when the link states a protocol, the target
303
+ * speaks it. A pipeline speaks what its receivers speak; an exporter speaks
304
+ * what its definition says. A target whose protocols the collector's
305
+ * definitions don't state leaves the link unresolved rather than failed.
306
+ */
307
+ function resolveTelemetryLink(row: LinkRow, telemetry: TelemetryTargets | undefined): void {
308
+ if (!telemetry) {
309
+ row.status = "unresolved";
310
+ row.reason = `the collector of ${row.producer} is read by chant workspace graph; chant workspace check reads source and runs no member`;
311
+ return;
312
+ }
313
+ const collector = telemetry.collectors.get(row.producer);
314
+ if (!collector) {
315
+ if (!telemetry.composed.has(row.producer)) {
316
+ row.status = "unresolved";
317
+ row.reason = `the collector of ${row.producer} was not read: ${row.producer} was not composed`;
318
+ } else {
319
+ row.status = "missing";
320
+ row.reason = `${row.producer} declares no collector: its build reports no collector pipelines or components`;
321
+ }
322
+ return;
323
+ }
324
+ const pipeline = collector.pipelines.find((p) => p.id === row.output);
325
+ const exporter = pipeline ? undefined : collector.exporters.find((e) => e.id === row.output);
326
+ if (!pipeline && !exporter) {
327
+ row.status = "missing";
328
+ row.reason =
329
+ `${row.producer}'s collector has no pipeline or exporter ${row.output}` +
330
+ `; pipelines: ${collector.pipelines.map((p) => p.id).join(", ") || "none"}` +
331
+ `; exporters: ${collector.exporters.map((e) => e.id).join(", ") || "none"}`;
332
+ return;
333
+ }
334
+ row.target = pipeline ? "pipeline" : "exporter";
335
+ if (!row.protocol) return;
336
+ const ids = pipeline ? pipeline.receivers : [exporter!.id];
337
+ const speaks = [...new Set(collector.components.filter((c) => ids.includes(c.id)).flatMap((c) => c.protocols))];
338
+ const what = pipeline ? `the receivers of pipeline ${row.output}` : `exporter ${row.output}`;
339
+ if (speaks.length === 0) {
340
+ row.status = "unresolved";
341
+ row.reason = `${what} state no protocols, so ${row.protocol} could not be confirmed`;
342
+ } else if (!speaks.includes(row.protocol)) {
343
+ row.status = "invalid";
344
+ row.reason = `${what} speak ${speaks.join(", ")}, and the link sends ${row.protocol}`;
345
+ }
346
+ }
347
+
263
348
  // ── Where the handles come from ──────────────────────────────────────────────
264
349
 
265
350
  /**
@@ -282,6 +367,8 @@ export function kindHandles(m: Member, kind: MemberKind): MemberHandles | undefi
282
367
  export interface ComposedHandles {
283
368
  /** Members whose IR was composed. */
284
369
  composed: readonly string[];
370
+ /** The collectors those members report (#2558); a telemetry link resolves against them. */
371
+ collectors?: readonly MemberCollector[];
285
372
  exports: readonly { member: string; name: string; node?: string }[];
286
373
  imports: readonly { member: string; name: string; node: string }[];
287
374
  }
@@ -322,7 +409,11 @@ export function graphLinks(declaration: Declaration, graph: ComposedHandles, kin
322
409
  const listed = kindHandles(m, kind);
323
410
  if (listed) handles.set(m.name, listed);
324
411
  }
325
- return resolveLinks(declaration, handles).map((row) => {
412
+ const telemetry: TelemetryTargets = {
413
+ collectors: new Map((graph.collectors ?? []).map((c) => [c.member, c])),
414
+ composed: new Set(graph.composed),
415
+ };
416
+ return resolveLinks(declaration, handles, telemetry).map((row) => {
326
417
  if (row.status === "ambiguous" || row.origin !== "declared") return row;
327
418
  // The declaration's pointer means nothing to a graph reader.
328
419
  const { pointer: _pointer, ...rest } = row;