@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
@@ -104,13 +104,13 @@ describe("other hashes use contentDigest over canonicalJson (#2514)", () => {
104
104
  test("a plan digest", () => {
105
105
  const subject = { b: 1, a: [true, null] };
106
106
  expect(computePlanDigest("lifecycle-diff", subject)).toBe(
107
- contentDigest(canonicalJson({ kind: "lifecycle-diff", subject })),
107
+ `jcs1-${contentDigest(canonicalJson({ kind: "lifecycle-diff", subject }))}`,
108
108
  );
109
109
  });
110
110
 
111
111
  test("a plan digest orders keys by code unit, not by locale", () => {
112
112
  // localeCompare puts "a" before "B"; canonicalJson puts "B" first.
113
- expect(computePlanDigest("k", { a: 1, B: 2 })).toBe(contentDigest('{"kind":"k","subject":{"B":2,"a":1}}'));
113
+ expect(computePlanDigest("k", { a: 1, B: 2 })).toBe(`jcs1-${contentDigest('{"kind":"k","subject":{"B":2,"a":1}}')}`);
114
114
  });
115
115
 
116
116
  test("a props hash, which drops what JSON cannot hold instead of throwing", () => {
package/src/lexicon.ts CHANGED
@@ -3,7 +3,7 @@ import type { Declarable } from "./declarable";
3
3
  import type { LintRule } from "./lint/rule";
4
4
  import type { RuleSpec } from "./lint/declarative";
5
5
  import type { PostSynthCheck } from "./lint/post-synth";
6
- import type { TemplateParser, TemplateIR } from "./import/parser";
6
+ import type { TemplateParser, TemplateIR, ParseContext } from "./import/parser";
7
7
  import type { TypeScriptGenerator } from "./import/generator";
8
8
  import type { EmbeddedContentImporter } from "./import/embedded";
9
9
  import type { AgentConfigImporter } from "./agents/importer";
@@ -1259,6 +1259,22 @@ export interface LexiconPlugin {
1259
1259
  */
1260
1260
  buildRoots?(ctx: BuildRootContext): Promise<BuildRootContribution>;
1261
1261
 
1262
+ /**
1263
+ * Facts about the whole read that this lexicon's entities imply, for the
1264
+ * graph IR's `meta` bag (#2559). The otel lexicon answers
1265
+ * `{ collector: <topology> }`, which `chant workspace graph` lifts into its
1266
+ * `collectors` section, so core carries the shape of the answer and never
1267
+ * imports the lexicon that computes it.
1268
+ *
1269
+ * Called by `chant graph --format ir` with the discovered entities of the
1270
+ * project, after the plugins are loaded. Return `undefined` or an empty
1271
+ * object when the project declares nothing this lexicon reports; the IR is
1272
+ * then unchanged. Keys must be unique to the lexicon (two lexicons writing
1273
+ * one key is a conflict the graph refuses). Omit for lexicons with nothing
1274
+ * to report.
1275
+ */
1276
+ graphMeta?(entities: ReadonlyMap<string, Declarable>): Record<string, unknown> | undefined;
1277
+
1262
1278
  // LSP
1263
1279
  /** Provide completions for LSP */
1264
1280
  completionProvider?(ctx: CompletionContext): CompletionItem[];
@@ -1989,6 +2005,14 @@ export interface ResourceSelector {
1989
2005
  export type ExportedTemplate = TemplateIR & {
1990
2006
  /** Phantom marker — never present at runtime. */
1991
2007
  readonly __fidelity?: "full-config";
2008
+ /**
2009
+ * The same export parsed again with a {@link ParseContext}, from the
2010
+ * objects already read: no second read of the live target. An exporter
2011
+ * that maps through its import parser offers this so `chant import --from`
2012
+ * can hand embedded content to the lexicon that owns it, as file import
2013
+ * does (#2995). Without it, embedded content is kept as written.
2014
+ */
2015
+ readonly reparse?: (context: ParseContext) => TemplateIR;
1992
2016
  };
1993
2017
 
1994
2018
  /**
@@ -298,6 +298,20 @@ describe("latestResolutionForPlan (#2300)", () => {
298
298
  expect(found.mismatched).toBeUndefined();
299
299
  });
300
300
 
301
+ // #2547: plan digests carry a version prefix now. An approval written under
302
+ // the bare prefix is still an approval of the same plan.
303
+ test("a resolution recorded under the bare sha256: prefix answers the same plan's jcs1 digest", () => {
304
+ const found = latestResolutionForPlan([resolution({ planDigest: PLAN_A })], "approve-live-apply", EPOCH, `jcs1-${PLAN_A}`);
305
+ expect(found.resolution?.resolvedBy).toBe("alex");
306
+ expect(found.mismatched).toBeUndefined();
307
+ });
308
+
309
+ test("the prefix never makes a different plan match", () => {
310
+ const found = latestResolutionForPlan([resolution({ planDigest: PLAN_B })], "approve-live-apply", EPOCH, `jcs1-${PLAN_A}`);
311
+ expect(found.resolution).toBeUndefined();
312
+ expect(found.mismatched?.planDigest).toBe(PLAN_B);
313
+ });
314
+
301
315
  test("a resolution for another plan does not, and comes back named", () => {
302
316
  const found = latestResolutionForPlan([resolution({ planDigest: PLAN_B })], "approve-live-apply", EPOCH, PLAN_A);
303
317
  expect(found.resolution).toBeUndefined();
@@ -41,6 +41,7 @@
41
41
  * (absent on the resolution lines written before #2119, which is why
42
42
  * `"resolution"` is the default reading).
43
43
  */
44
+ import { samePlanDigest } from "./plan-digest";
44
45
  import { sortedJsonReplacer } from "../utils";
45
46
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
46
47
  import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
@@ -513,7 +514,7 @@ export function latestResolutionForPlan(
513
514
  if (new Date(r.timestamp).getTime() < since) continue;
514
515
  const newest = (best: GateResolutionRecord | undefined) =>
515
516
  !best || new Date(r.timestamp).getTime() >= new Date(best.timestamp).getTime();
516
- if (r.planDigest === planDigest) {
517
+ if (samePlanDigest(r.planDigest, planDigest)) {
517
518
  if (newest(matched)) matched = r;
518
519
  } else if (newest(mismatched)) {
519
520
  mismatched = r;
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { computePlanDigest, isPlanDigest, describePlanDigest } from "./plan-digest";
2
+ import { canonicalJson } from "../effect-receipt";
3
+ import { contentDigest } from "../content-digest";
4
+ import { computePlanDigest, isPlanDigest, describePlanDigest, samePlanDigest, PLAN_DIGEST_PREFIX } from "./plan-digest";
3
5
 
4
6
  describe("computePlanDigest", () => {
5
7
  test("the same change set digests the same, whatever order its keys arrived in", () => {
@@ -23,19 +25,68 @@ describe("computePlanDigest", () => {
23
25
  );
24
26
  });
25
27
 
26
- test("it is a sha256 digest, in the shape isPlanDigest accepts", () => {
28
+ test("it is a versioned jcs1-sha256 digest, in the shape isPlanDigest accepts", () => {
27
29
  const digest = computePlanDigest("terraform-plan", {});
28
- expect(digest).toMatch(/^sha256:[0-9a-f]{64}$/);
30
+ expect(digest).toMatch(/^jcs1-sha256:[0-9a-f]{64}$/);
31
+ expect(digest.startsWith(PLAN_DIGEST_PREFIX)).toBe(true);
29
32
  expect(isPlanDigest(digest)).toBe(true);
30
33
  });
34
+
35
+ test("it hashes the same bytes the bare sha256: digest of chant before #2547 did", () => {
36
+ const subject = { b: [1, { y: 2, x: 1 }], a: "\u00e9" };
37
+ const legacy = contentDigest(canonicalJson({ kind: "k", subject }));
38
+ expect(computePlanDigest("k", subject)).toBe(`jcs1-${legacy}`);
39
+ });
40
+ });
41
+
42
+ describe("the canonical form is RFC 8785", () => {
43
+ test("it sorts keys by UTF-16 code unit, as RFC 8785 section 3.2.3 does", () => {
44
+ // The RFC's own example: these keys in this order.
45
+ const keys = ["\r", "1", "\u0080", "\u00f6", "\u20ac", "\ud83d\ude00", "\ufb33"];
46
+ const reversed = [...keys].reverse();
47
+ const value = Object.fromEntries(reversed.map((k) => [k, reversed.indexOf(k)]));
48
+ // Written out by hand because Object.keys puts "1" first, whatever order a key was added in.
49
+ expect(canonicalJson(value)).toBe(`{${keys.map((k) => `${JSON.stringify(k)}:${reversed.indexOf(k)}`).join(",")}}`);
50
+ });
51
+
52
+ test("it writes numbers as ECMAScript does, which is what RFC 8785 section 3.2.2 specifies", () => {
53
+ expect(canonicalJson([1e21, 1e-7, 0.000001, -0, 4.5, 333333333.33333329])).toBe("[1e+21,1e-7,0.000001,0,4.5,333333333.3333333]");
54
+ });
55
+ });
56
+
57
+ describe("samePlanDigest", () => {
58
+ const hex = "a".repeat(64);
59
+ test("a digest recorded under the bare prefix is the same plan as the jcs1 one", () => {
60
+ expect(samePlanDigest(`sha256:${hex}`, `jcs1-sha256:${hex}`)).toBe(true);
61
+ expect(samePlanDigest(`jcs1-sha256:${hex}`, `sha256:${hex}`)).toBe(true);
62
+ expect(samePlanDigest(`sha256:${hex}`, `sha256:${hex}`)).toBe(true);
63
+ });
64
+
65
+ test("a different hash is a different plan, in either prefix", () => {
66
+ expect(samePlanDigest(`sha256:${hex}`, `jcs1-sha256:${"b".repeat(64)}`)).toBe(false);
67
+ });
68
+
69
+ test("a value that is not a plan digest matches only itself", () => {
70
+ expect(samePlanDigest("anything", "anything")).toBe(true);
71
+ expect(samePlanDigest("anything", `sha256:${hex}`)).toBe(false);
72
+ expect(samePlanDigest(undefined, `sha256:${hex}`)).toBe(false);
73
+ expect(samePlanDigest(undefined, undefined)).toBe(true);
74
+ });
31
75
  });
32
76
 
33
77
  describe("isPlanDigest", () => {
78
+ test("accepts both prefixes, since gates recorded before #2547 carry the bare one", () => {
79
+ expect(isPlanDigest(`sha256:${"a".repeat(64)}`)).toBe(true);
80
+ expect(isPlanDigest(`jcs1-sha256:${"a".repeat(64)}`)).toBe(true);
81
+ });
82
+
34
83
  test("refuses everything a copy-paste or a path could be", () => {
35
84
  expect(isPlanDigest("chant.tfplan")).toBe(false);
36
85
  expect(isPlanDigest(`sha256:${"a".repeat(63)}`)).toBe(false);
37
86
  expect(isPlanDigest("a".repeat(64))).toBe(false);
38
87
  expect(isPlanDigest(`sha256:${"A".repeat(64)}`)).toBe(false);
88
+ expect(isPlanDigest(`jcs2-sha256:${"a".repeat(64)}`)).toBe(false);
89
+ expect(isPlanDigest(`jcs1-sha256:${"a".repeat(63)}`)).toBe(false);
39
90
  expect(isPlanDigest(undefined)).toBe(false);
40
91
  expect(isPlanDigest(12)).toBe(false);
41
92
  });
@@ -39,11 +39,22 @@
39
39
  import { canonicalJson } from "../effect-receipt";
40
40
  import { contentDigest } from "../content-digest";
41
41
 
42
- /** The hash a plan digest is taken with, and the prefix every digest carries. */
42
+ /** The hash a plan digest is taken with. */
43
43
  export const PLAN_DIGEST_ALGORITHM = "sha256";
44
44
 
45
- /** Shape of a well-formed digest: `sha256:` and 64 lowercase hex characters. */
46
- const PLAN_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
45
+ /**
46
+ * The version prefix a plan digest is written with since #2547: `jcs1` names
47
+ * the input form (the RFC 8785 canonical JSON of `{ kind, subject }`, see
48
+ * `canonicalJson` in ../effect-receipt.ts) and `sha256` the hash. A later
49
+ * input form gets a new prefix, so a digest always says how it was made.
50
+ */
51
+ export const PLAN_DIGEST_PREFIX = "jcs1-sha256:";
52
+
53
+ /** The bare prefix every digest written before #2547 carries. Still read, never written for a plan. */
54
+ export const LEGACY_PLAN_DIGEST_PREFIX = "sha256:";
55
+
56
+ /** Shape of a well-formed digest: either prefix, then 64 lowercase hex characters. */
57
+ const PLAN_DIGEST_PATTERN = /^(?:jcs1-)?sha256:([0-9a-f]{64})$/;
47
58
 
48
59
  /**
49
60
  * Hash a plan's change set into a stable identity.
@@ -60,21 +71,40 @@ const PLAN_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
60
71
  * versions, does not change the answer. It is the caller's job to
61
72
  * hand in a projection that already excludes the volatile fields this
62
73
  * module's doc comment lists.
74
+ *
75
+ * The result is `jcs1-sha256:<hex>` (#2547). Before that it was
76
+ * `sha256:<hex>` over the same bytes, so the hex is unchanged and
77
+ * {@link samePlanDigest} treats the two spellings as one digest: a gate
78
+ * recorded under the old prefix still matches the plan it was approved for.
63
79
  */
64
80
  export function computePlanDigest(kind: string, subject: unknown): string {
65
- return contentDigest(canonicalJson({ kind, subject }));
81
+ return PLAN_DIGEST_PREFIX + contentDigest(canonicalJson({ kind, subject })).slice(LEGACY_PLAN_DIGEST_PREFIX.length);
66
82
  }
67
83
 
68
84
  /**
69
- * Whether `raw` is a digest this code produced. Used at the `chant approve
70
- * --plan` boundary, so a typo, a truncated copy-paste or a plan *file* path
71
- * is refused before it is written into an immutable resolution that would
72
- * then never match anything.
85
+ * Whether `raw` is a digest this code produced, in either prefix. Used at the
86
+ * `chant approve --plan` boundary, so a typo, a truncated copy-paste or a plan
87
+ * *file* path is refused before it is written into an immutable resolution
88
+ * that would then never match anything.
73
89
  */
74
90
  export function isPlanDigest(raw: unknown): raw is string {
75
91
  return typeof raw === "string" && PLAN_DIGEST_PATTERN.test(raw);
76
92
  }
77
93
 
94
+ /**
95
+ * Whether two recorded or computed plan digests name the same plan. Two
96
+ * well-formed plan digests are the same when their hex is, whichever prefix
97
+ * each carries (read both, write new, #2547). Anything else, such as a
98
+ * release plan's own digest, is the same only when the strings are equal.
99
+ */
100
+ export function samePlanDigest(a: string | undefined, b: string | undefined): boolean {
101
+ if (a === b) return true;
102
+ if (a === undefined || b === undefined) return false;
103
+ const x = PLAN_DIGEST_PATTERN.exec(a);
104
+ const y = PLAN_DIGEST_PATTERN.exec(b);
105
+ return x !== null && y !== null && x[1] === y[1];
106
+ }
107
+
78
108
  /**
79
109
  * A digest as it reads in a message, and the one place that decides how an
80
110
  * absent one reads. Records written before #2300 carry no digest at all, and
@@ -153,6 +153,7 @@ export const proposeWorkspaceUpgradeContract = activityContract(
153
153
  base: z.string().optional(),
154
154
  remote: z.string().optional(),
155
155
  allowCode: z.boolean().optional(),
156
+ source: z.string().optional(),
156
157
  cwd: z.string().optional(),
157
158
  }),
158
159
  z.object({
@@ -45,6 +45,8 @@ export interface ProposeWorkspaceUpgradeArgs {
45
45
  remote?: string;
46
46
  /** Run code migrations. Default false. */
47
47
  allowCode?: boolean;
48
+ /** `<repo>[#<member>]`: move the scope to this template through its bridge migration (#2551). Needs `to`. */
49
+ source?: string;
48
50
  /** The directory holding the lineage lock. Default: the working directory. */
49
51
  cwd?: string;
50
52
  /** Replaces the git and gh child processes. For tests. */
@@ -91,8 +93,8 @@ export function proposalMarker(scope: string): string {
91
93
 
92
94
  function body(staged: StagedUpgrade, lines: string[]): string {
93
95
  return [
94
- proposalMarker(staged.scope),
95
- `## Template upgrade: \`${staged.scope}\``,
96
+ proposalMarker(staged.gate),
97
+ `## Template upgrade: \`${staged.gate}\``,
96
98
  "",
97
99
  `\`${staged.template}\` from \`${staged.from ?? "(no ref)"}\` to \`${staged.to ?? "(no ref)"}\`.`,
98
100
  "",
@@ -131,12 +133,13 @@ export async function proposeWorkspaceUpgrade(args: ProposeWorkspaceUpgradeArgs)
131
133
  scope: args.scope,
132
134
  to: args.to,
133
135
  allowCode: args.allowCode,
136
+ ...(args.source !== undefined ? { source: args.source } : {}),
134
137
  ...(args._runChant ? { runChant: args._runChant } : {}),
135
138
  });
136
139
  try {
137
140
  const lines = describeStaged(staged);
138
141
  const result: ProposeWorkspaceUpgradeResult = {
139
- scope: staged.scope,
142
+ scope: staged.gate,
140
143
  mode,
141
144
  changed: staged.changed,
142
145
  proposed: false,
@@ -150,7 +153,7 @@ export async function proposeWorkspaceUpgrade(args: ProposeWorkspaceUpgradeArgs)
150
153
  };
151
154
  if (!result.changed || !result.checksOk || mode === "report") return result;
152
155
 
153
- const branch = args.branch ?? proposalBranch(staged.scope);
156
+ const branch = args.branch ?? proposalBranch(staged.gate);
154
157
  const remoteDefault = await defaultBranch(run, staged.repo, remote);
155
158
  const base = args.base ?? remoteDefault;
156
159
  let current: string | null = null;
@@ -168,7 +171,7 @@ export async function proposeWorkspaceUpgrade(args: ProposeWorkspaceUpgradeArgs)
168
171
  throw new Error(`proposeWorkspaceUpgrade: cannot tell the default branch of "${remote}"; pass base`);
169
172
  }
170
173
 
171
- const title = `chore(upgrade): ${staged.template} ${staged.to ?? ""} (${staged.scope})`.replace(/\s+/g, " ").replace(" )", ")");
174
+ const title = `chore(upgrade): ${staged.template} ${staged.to ?? ""} (${staged.gate})`.replace(/\s+/g, " ").replace(" )", ")");
172
175
  result.commit = commitStagedUpgrade(staged, branch, `${title}\n\nPatch digest: ${staged.digest}\n`);
173
176
  result.branch = branch;
174
177
  result.proposed = true;
@@ -107,6 +107,23 @@ describe("gate approval — quorum (#2508)", () => {
107
107
  expect(port.appended).toHaveLength(0);
108
108
  });
109
109
 
110
+ // #2547: a gate still pending, and an approval given, under the bare sha256: prefix
111
+ // keep working once the run computes the same plan's digest as jcs1-sha256:.
112
+ test("a pending gate and an approval recorded under the bare prefix match the jcs1 digest of the same plan", async () => {
113
+ const jcs = `jcs1-${PLAN_A}`;
114
+ const block: ResolvedGateApproval = { quorum: { count: 1 }, mode: "log-only" };
115
+ const run = async (resolutions: GateResolutionRecord[]) => {
116
+ const port = memoryGateLedgerPort({ resolutions, pending: [{ ...PENDING, approval: block }] });
117
+ return { port, check: await evaluateGate(port, { op: "release", gate: "ship", planDigest: jcs, approval: block, now: NOW }) };
118
+ };
119
+ const approved = await run([approval({ resolvedBy: "alex", timestamp: "2026-09-01T01:00:00.000Z" })]);
120
+ expect(approved.check.satisfied).toBe(true);
121
+ // A standing fact is not rewritten just because the prefix moved.
122
+ const waiting = await run([]);
123
+ expect(waiting.check.satisfied).toBe(false);
124
+ expect(waiting.port.appended).toHaveLength(0);
125
+ });
126
+
110
127
  test("an approval older than the standing pending fact does not count", async () => {
111
128
  const { check } = await decide(
112
129
  [approval({ resolvedBy: "alex", timestamp: "2026-08-31T00:00:00.000Z" })],
package/src/op/gate.ts CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  type PendingGateInput,
45
45
  type PendingGateRecord,
46
46
  } from "../lifecycle/gate-ledger";
47
- import { describePlanDigest } from "../lifecycle/plan-digest";
47
+ import { describePlanDigest, samePlanDigest } from "../lifecycle/plan-digest";
48
48
  import { isModelAuthored } from "../lifecycle/gate-origin";
49
49
  import { sortedJsonReplacer } from "../utils";
50
50
  import type { GateApprover, ResolvedGateApproval } from "./gate-approval";
@@ -325,7 +325,7 @@ export function tallyGateApprovals(
325
325
  let mismatched: GateResolutionRecord | undefined;
326
326
  for (const r of records) {
327
327
  if (r.gate !== gate || at(r) < since) continue;
328
- if (planDigest !== undefined && r.planDigest !== planDigest) {
328
+ if (planDigest !== undefined && !samePlanDigest(r.planDigest, planDigest)) {
329
329
  if (!mismatched || at(r) >= at(mismatched)) mismatched = r;
330
330
  continue;
331
331
  }
@@ -447,7 +447,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
447
447
  // under another policy version or another context would have `chant
448
448
  // approve` evaluate the policy against something this run no longer has.
449
449
  if (
450
- standing && !isPendingGateExpired(standing, now) && standing.planDigest === input.planDigest &&
450
+ standing && !isPendingGateExpired(standing, now) && samePlanDigest(standing.planDigest, input.planDigest) &&
451
451
  sameApproval(standing.approval, input.approval)
452
452
  ) {
453
453
  return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
package/src/op/index.ts CHANGED
@@ -71,7 +71,7 @@ export type {
71
71
  GatePolicyEvaluator, GatePolicyRef, GatePolicyRequest, GateQuorum, ResolvedGateApproval,
72
72
  } from "./gate-approval";
73
73
  export {
74
- computePlanDigest, isPlanDigest, describePlanDigest, PLAN_DIGEST_ALGORITHM,
74
+ computePlanDigest, isPlanDigest, describePlanDigest, samePlanDigest, PLAN_DIGEST_ALGORITHM, PLAN_DIGEST_PREFIX,
75
75
  } from "../lifecycle/plan-digest";
76
76
  export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
77
77
  export type { GateNamed } from "./gate-name";
package/src/serializer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Declarable } from "./declarable";
2
2
  import type { LexiconOutput } from "./lexicon-output";
3
3
  import type { OwnershipMarker } from "./ownership";
4
+ import type { TelemetryAttribution } from "./telemetry-attribution";
4
5
 
5
6
  /**
6
7
  * Build-time context passed to a serializer. Optional — serializers that don't
@@ -32,6 +33,14 @@ export interface SerializeContext {
32
33
  * safe default.
33
34
  */
34
35
  receipts?: ReadonlyMap<string, Declarable>;
36
+
37
+ /**
38
+ * Telemetry attribution (#2558, D22): the facts a workload lexicon stamps
39
+ * as resource attributes. Set only inside a workspace, or when the project
40
+ * opts in with `telemetry.attribution: true`; a serializer that ignores it,
41
+ * and every serializer at level 0, is unchanged. See `./telemetry-attribution.ts`.
42
+ */
43
+ telemetry?: TelemetryAttribution;
35
44
  }
36
45
 
37
46
  /**
@@ -0,0 +1,91 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, describe, expect, test } from "vitest";
5
+ import { mergeResourceAttributes, resolveTelemetryAttribution, TELEMETRY_ATTRIBUTES, telemetryEnvironment } from "./telemetry-attribution";
6
+
7
+ const scratch = mkdtempSync(join(tmpdir(), "chant-2558-"));
8
+ afterAll(() => rmSync(scratch, { recursive: true, force: true }));
9
+
10
+ let n = 0;
11
+ /** A directory tree under the scratch directory, files keyed by relative path. */
12
+ function tree(files: Record<string, string>): string {
13
+ const root = join(scratch, `t${n++}`);
14
+ // The git root ends the search for a declaration, as in a real checkout.
15
+ mkdirSync(join(root, ".git"), { recursive: true });
16
+ for (const [path, text] of Object.entries(files)) {
17
+ mkdirSync(join(root, path, ".."), { recursive: true });
18
+ writeFileSync(join(root, path), text);
19
+ }
20
+ return root;
21
+ }
22
+
23
+ const declaration = JSON.stringify({
24
+ name: "acme",
25
+ schema: 1,
26
+ members: [
27
+ { name: "delivery", dir: "delivery", kind: "chant" },
28
+ { name: "docs", dir: "docs", kind: "other", because: "prose" },
29
+ ],
30
+ });
31
+
32
+ describe("resolveTelemetryAttribution", () => {
33
+ test("is nothing for a project with no workspace declaration above it", async () => {
34
+ const root = tree({ "app/chant.config.ts": "export default {};\n" });
35
+ expect(await resolveTelemetryAttribution(join(root, "app"), {}, "prod")).toBeUndefined();
36
+ });
37
+
38
+ test("is on for a project that opts in outside a workspace, with no workspace or member", async () => {
39
+ const root = tree({ "app/chant.config.ts": "export default {};\n" });
40
+ expect(await resolveTelemetryAttribution(join(root, "app"), { telemetry: { attribution: true } }, "prod")).toEqual({ environment: "prod" });
41
+ expect(await resolveTelemetryAttribution(join(root, "app"), { telemetry: { attribution: true } })).toEqual({});
42
+ });
43
+
44
+ test("names the workspace and the member inside a workspace", async () => {
45
+ const root = tree({ "chant.workspace.json": declaration, "delivery/chant.config.ts": "export default {};\n", "delivery/src/a.ts": "" });
46
+ expect(await resolveTelemetryAttribution(join(root, "delivery"), {})).toEqual({ workspace: "acme", member: "delivery" });
47
+ expect(await resolveTelemetryAttribution(join(root, "delivery", "src"), {}, "staging")).toEqual({ workspace: "acme", member: "delivery", environment: "staging" });
48
+ });
49
+
50
+ test("names no member for a directory no member owns", async () => {
51
+ const root = tree({ "chant.workspace.json": declaration, "scripts/x.ts": "" });
52
+ expect(await resolveTelemetryAttribution(join(root, "scripts"), {})).toEqual({ workspace: "acme" });
53
+ });
54
+
55
+ test("telemetry.attribution false turns it off inside a workspace", async () => {
56
+ const root = tree({ "chant.workspace.json": declaration, "delivery/x.ts": "" });
57
+ expect(await resolveTelemetryAttribution(join(root, "delivery"), { telemetry: { attribution: false } })).toBeUndefined();
58
+ });
59
+
60
+ test("a declaration that can't be read stamps the workspace-free attributes and does not throw", async () => {
61
+ const root = tree({ "chant.workspace.json": "{ not json", "delivery/x.ts": "" });
62
+ expect(await resolveTelemetryAttribution(join(root, "delivery"), {}, "prod")).toEqual({ environment: "prod" });
63
+ });
64
+ });
65
+
66
+ describe("telemetryEnvironment", () => {
67
+ test("lists the attributes in the table's order, percent-encoding values", () => {
68
+ const env = telemetryEnvironment({ workspace: "acme", member: "delivery", environment: "pr 12" }, { service: "api", decl: "apiService", version: "sha256:ab" });
69
+ expect(env).toEqual({
70
+ OTEL_SERVICE_NAME: "api",
71
+ OTEL_RESOURCE_ATTRIBUTES: "chant.workspace=acme,chant.member=delivery,chant.decl=apiService,deployment.environment.name=pr%2012,service.version=sha256%3Aab",
72
+ });
73
+ });
74
+
75
+ test("leaves out what it doesn't know", () => {
76
+ expect(telemetryEnvironment({}, { service: "api", decl: "api" }).OTEL_RESOURCE_ATTRIBUTES).toBe("chant.decl=api");
77
+ });
78
+
79
+ test("the table names every attribute the environment can carry, and marks release-time ones", () => {
80
+ const keys = TELEMETRY_ATTRIBUTES.map((a) => a.key);
81
+ expect(keys).toEqual(["service.name", "service.version", "deployment.environment.name", "vcs.ref.head.revision", "chant.workspace", "chant.member", "chant.decl"]);
82
+ expect(TELEMETRY_ATTRIBUTES.filter((a) => !a.stampedAtBuild).map((a) => a.key)).toEqual(["service.version", "vcs.ref.head.revision"]);
83
+ });
84
+ });
85
+
86
+ describe("mergeResourceAttributes", () => {
87
+ test("keeps keys already set and appends the rest", () => {
88
+ expect(mergeResourceAttributes("a=1,chant.member=mine", "chant.workspace=w,chant.member=m")).toBe("a=1,chant.member=mine,chant.workspace=w");
89
+ expect(mergeResourceAttributes("", "a=1")).toBe("a=1");
90
+ });
91
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Telemetry attribution (#2558, #2524 D22, ws-060).
3
+ *
4
+ * A workload's spans carry resource attributes that name the declaration and
5
+ * the release that produced them, so a reader (hud, behold) can join a span
6
+ * back to a member, a release and a node of `chant workspace graph`. They use
7
+ * OpenTelemetry semantic conventions where those exist and a `chant.*`
8
+ * namespace for the rest ({@link TELEMETRY_ATTRIBUTES}).
9
+ *
10
+ * A lexicon that declares workloads stamps them as environment variables
11
+ * (`OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES`, which every OTel SDK
12
+ * reads) when it builds inside a workspace. The build resolves the facts once
13
+ * ({@link resolveTelemetryAttribution}) and hands them to each serializer on
14
+ * `SerializeContext.telemetry`, and {@link telemetryEnvironment} turns them
15
+ * into the variables for one workload.
16
+ *
17
+ * Level 0 is untouched: a project with no `chant.workspace.json` above it,
18
+ * and no `telemetry.attribution: true` in its config, resolves to nothing and
19
+ * its output is the same bytes as before. The workspace declaration reader is
20
+ * imported only once a declaration has been found (#2525 rule 5, pinned by
21
+ * #2526's goldens), and this module's name keeps it off the workspace-module
22
+ * list.
23
+ */
24
+
25
+ import { relative, resolve, sep } from "node:path";
26
+ import { findWorkspaceRoot } from "./project-root";
27
+
28
+ /** What a build knows about where it runs, for the attributes it can stamp. */
29
+ export interface TelemetryAttribution {
30
+ /** The declaration's `name`, when the project is inside a workspace. */
31
+ workspace?: string;
32
+ /** The member that owns the project, when it is inside a workspace and a member owns it. */
33
+ member?: string;
34
+ /** The environment the build is for (`--env`, or the config's `ownership.env`), when it has one. */
35
+ environment?: string;
36
+ }
37
+
38
+ /** One attribute of the table in D22, as data, so docs and tests read the same list. */
39
+ export interface TelemetryAttributeSpec {
40
+ key: string;
41
+ /** Where the value comes from. */
42
+ source: string;
43
+ /** What a reader joins it to. */
44
+ joinsTo: string;
45
+ /** Whether a lexicon's build stamps it. The rest are set by the release step, which knows them. */
46
+ stampedAtBuild: boolean;
47
+ }
48
+
49
+ export const TELEMETRY_ATTRIBUTES: readonly TelemetryAttributeSpec[] = [
50
+ { key: "service.name", source: "the declaration's name", joinsTo: "the service", stampedAtBuild: true },
51
+ { key: "service.version", source: "the release's artifact digest", joinsTo: "the member's release ledger", stampedAtBuild: false },
52
+ { key: "deployment.environment.name", source: "the environment", joinsTo: "the ledger's environment", stampedAtBuild: true },
53
+ { key: "vcs.ref.head.revision", source: "the git SHA", joinsTo: "the workspace revision (D15)", stampedAtBuild: false },
54
+ { key: "chant.workspace", source: "the workspace declaration's `name`", joinsTo: "the workspace", stampedAtBuild: true },
55
+ { key: "chant.member", source: "the member name", joinsTo: "the member", stampedAtBuild: true },
56
+ { key: "chant.decl", source: "the declaration id", joinsTo: "the node in `chant workspace graph`", stampedAtBuild: true },
57
+ ];
58
+
59
+ /** The config's opt-in or opt-out, `telemetry.attribution`. */
60
+ function configured(config: Record<string, unknown> | undefined): boolean | undefined {
61
+ const telemetry = config?.telemetry;
62
+ if (typeof telemetry !== "object" || telemetry === null) return undefined;
63
+ const value = (telemetry as { attribution?: unknown }).attribution;
64
+ return typeof value === "boolean" ? value : undefined;
65
+ }
66
+
67
+ /**
68
+ * What a build at `projectDir` stamps, or `undefined` for nothing.
69
+ *
70
+ * Inside a workspace attribution is on, and `telemetry.attribution: false`
71
+ * turns it off. Outside one it is off, and `telemetry.attribution: true`
72
+ * turns it on, with the attributes that need no workspace (`chant.workspace`
73
+ * and `chant.member` are then absent).
74
+ *
75
+ * A declaration that can't be read stamps the workspace-free attributes only:
76
+ * `chant workspace check` is where a broken declaration is reported, and a
77
+ * build must not fail on it.
78
+ */
79
+ export async function resolveTelemetryAttribution(
80
+ projectDir: string,
81
+ config: Record<string, unknown> | undefined,
82
+ environment?: string,
83
+ ): Promise<TelemetryAttribution | undefined> {
84
+ const setting = configured(config);
85
+ if (setting === false) return undefined;
86
+ const found = findWorkspaceRoot(projectDir);
87
+ if (!found && setting !== true) return undefined;
88
+ const out: TelemetryAttribution = {};
89
+ if (found) {
90
+ // Only now does workspace code load.
91
+ try {
92
+ const { readDeclaration, resolveGroups, ownerOf } = await import("./workspace/declaration");
93
+ const { workingTree } = await import("./workspace/tree");
94
+ const tree = workingTree(found.dir);
95
+ const declaration = readDeclaration(tree);
96
+ out.workspace = declaration.name;
97
+ const rel = relative(found.dir, resolve(projectDir));
98
+ const owner = ownerOf(declaration, resolveGroups(declaration, tree), rel === "" ? "" : rel.split(sep).join("/"));
99
+ if (owner && "member" in owner) out.member = owner.member.name;
100
+ } catch {
101
+ // Left to `chant workspace check`.
102
+ }
103
+ }
104
+ if (environment) out.environment = environment;
105
+ return out;
106
+ }
107
+
108
+ /** What a workload adds to the attributes: its own identity. */
109
+ export interface TelemetryWorkload {
110
+ /** `service.name`: the workload's name in the declaration. */
111
+ service: string;
112
+ /** `chant.decl`: the declaration id, the graph node id inside the member. */
113
+ decl: string;
114
+ /** `service.version`, when the build knows the artifact digest (an image pinned by digest). */
115
+ version?: string;
116
+ }
117
+
118
+ /** The environment variables that carry one workload's attributes. Keys the build can't fill are left out. */
119
+ export function telemetryEnvironment(attribution: TelemetryAttribution, workload: TelemetryWorkload): { OTEL_SERVICE_NAME: string; OTEL_RESOURCE_ATTRIBUTES: string } {
120
+ const pairs: [string, string | undefined][] = [
121
+ ["chant.workspace", attribution.workspace],
122
+ ["chant.member", attribution.member],
123
+ ["chant.decl", workload.decl],
124
+ ["deployment.environment.name", attribution.environment],
125
+ ["service.version", workload.version],
126
+ ];
127
+ return {
128
+ OTEL_SERVICE_NAME: workload.service,
129
+ OTEL_RESOURCE_ATTRIBUTES: pairs
130
+ .filter((p): p is [string, string] => p[1] !== undefined && p[1] !== "")
131
+ .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
132
+ .join(","),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Add `extra` to a resource-attributes string a project already set. A key the
138
+ * project set keeps its value, so a team's own `chant.member` is never
139
+ * overwritten; a key it didn't set is appended.
140
+ */
141
+ export function mergeResourceAttributes(existing: string, extra: string): string {
142
+ const have = new Set(existing.split(",").map((kv) => kv.split("=")[0]!.trim()).filter(Boolean));
143
+ const added = extra.split(",").filter((kv) => kv !== "" && !have.has(kv.split("=")[0]!));
144
+ return [existing, ...added].filter((s) => s !== "").join(",");
145
+ }