@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.
- package/dist/build.d.ts +6 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/build-options.d.ts +2 -0
- package/dist/cli/build-options.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +8 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon.d.ts +24 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-digest.d.ts +26 -5
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lint/config.d.ts +4 -4
- package/dist/op/activities/activity-contracts.d.ts +1 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +2 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/serializer.d.ts +8 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/telemetry-attribution.d.ts +77 -0
- package/dist/telemetry-attribution.d.ts.map +1 -0
- package/dist/workspace/agent-cli.d.ts +83 -0
- package/dist/workspace/agent-cli.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -1
- package/dist/workspace/changes.d.ts +8 -1
- package/dist/workspace/changes.d.ts.map +1 -1
- package/dist/workspace/checks/links.d.ts +1 -0
- package/dist/workspace/checks/links.d.ts.map +1 -1
- package/dist/workspace/checks/live.d.ts +40 -0
- package/dist/workspace/checks/live.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +21 -2
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +63 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +1 -1
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +32 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +138 -3
- package/dist/workspace/export-cli.d.ts +12 -0
- package/dist/workspace/export-cli.d.ts.map +1 -0
- package/dist/workspace/export.d.ts +145 -0
- package/dist/workspace/export.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/import.d.ts +73 -0
- package/dist/workspace/import.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +6 -2
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
- package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-adopt.d.ts +106 -0
- package/dist/workspace/lineage-adopt.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +9 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-cli.d.ts +6 -1
- package/dist/workspace/lineage-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-hash-index.d.ts +110 -0
- package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +10 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +147 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-migrations.d.ts +15 -3
- package/dist/workspace/lineage-migrations.d.ts.map +1 -1
- package/dist/workspace/lineage-provenance.d.ts +18 -0
- package/dist/workspace/lineage-provenance.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +30 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-versions.d.ts +116 -0
- package/dist/workspace/lineage-versions.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +31 -5
- package/dist/workspace/links.d.ts.map +1 -1
- package/dist/workspace/ls-generated.d.ts +37 -0
- package/dist/workspace/ls-generated.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +4 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/nested-graph.d.ts +56 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -0
- package/dist/workspace/nesting.d.ts +21 -0
- package/dist/workspace/nesting.d.ts.map +1 -0
- package/dist/workspace/pin-cli.d.ts +10 -0
- package/dist/workspace/pin-cli.d.ts.map +1 -0
- package/dist/workspace/pin-integrity.d.ts +51 -0
- package/dist/workspace/pin-integrity.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +7 -11
- package/dist/workspace/record-sessions.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +30 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +5 -2
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +22 -4
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +43 -5
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/returns.d.ts +129 -0
- package/dist/workspace/returns.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/template-manifest.d.ts +11 -3
- package/dist/workspace/template-manifest.d.ts.map +1 -1
- package/dist/workspace/trust/attestor.d.ts +8 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -1
- package/dist/workspace/trust/dsse.d.ts +58 -0
- package/dist/workspace/trust/dsse.d.ts.map +1 -0
- package/dist/workspace/trust/evidence-cli.d.ts +66 -0
- package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
- package/dist/workspace/trust/evidence.d.ts +93 -0
- package/dist/workspace/trust/evidence.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +54 -1
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/provenance.d.ts +21 -1
- package/dist/workspace/trust/provenance.d.ts.map +1 -1
- package/dist/workspace/trust/rotation.d.ts +132 -0
- package/dist/workspace/trust/rotation.d.ts.map +1 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/signers-cli.d.ts +49 -0
- package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +12 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/trust/test-repo.d.ts +13 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -1
- package/dist/workspace/trust/verify.d.ts +15 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +199 -0
- package/dist/workspace/write-scope.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/build.ts +8 -0
- package/src/cli/build-options.ts +4 -1
- package/src/cli/commands/build.ts +2 -0
- package/src/cli/commands/import-live.test.ts +69 -1
- package/src/cli/commands/import.ts +48 -22
- package/src/cli/handlers/fan-out.test.ts +6 -6
- package/src/cli/handlers/fan-out.ts +2 -1
- package/src/cli/handlers/graph.test.ts +42 -0
- package/src/cli/handlers/graph.ts +22 -0
- package/src/cli/handlers/operator.ts +1 -1
- package/src/cli/main.test.ts +32 -0
- package/src/cli/main.ts +103 -6
- package/src/cli/mcp/workspace-tools.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +33 -2
- package/src/cli/registry.ts +22 -0
- package/src/cli/serve-mcp-workspace.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +5 -1
- package/src/components/fan-out-output.test.ts +1 -1
- package/src/components/fan-out.test.ts +1 -1
- package/src/components/promote.test.ts +1 -1
- package/src/config.ts +12 -0
- package/src/content-digest.test.ts +2 -2
- package/src/lexicon.ts +25 -1
- package/src/lifecycle/gate-ledger.test.ts +14 -0
- package/src/lifecycle/gate-ledger.ts +2 -1
- package/src/lifecycle/plan-digest.test.ts +54 -3
- package/src/lifecycle/plan-digest.ts +38 -8
- package/src/op/activities/activity-contracts.ts +1 -0
- package/src/op/activities/propose-upgrade.ts +8 -5
- package/src/op/gate-approval.test.ts +17 -0
- package/src/op/gate.ts +3 -3
- package/src/op/index.ts +1 -1
- package/src/serializer.ts +9 -0
- package/src/telemetry-attribution.test.ts +91 -0
- package/src/telemetry-attribution.ts +145 -0
- package/src/workspace/agent-cli.ts +134 -0
- package/src/workspace/agent.schema.json +356 -0
- package/src/workspace/behold-kinds.test.ts +1 -1
- package/src/workspace/changes-cli.ts +5 -0
- package/src/workspace/changes.schema.json +179 -1
- package/src/workspace/changes.ts +58 -4
- package/src/workspace/check-live.test.ts +192 -0
- package/src/workspace/check.schema.json +64 -0
- package/src/workspace/checks/links.ts +23 -2
- package/src/workspace/checks/live.ts +113 -0
- package/src/workspace/checks.test.ts +2 -2
- package/src/workspace/checks.ts +20 -3
- package/src/workspace/compose-graph.test.ts +47 -0
- package/src/workspace/compose-graph.ts +120 -3
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
- package/src/workspace/declaration.schema.json +138 -3
- package/src/workspace/declaration.ts +106 -1
- package/src/workspace/declared-kinds.test.ts +33 -0
- package/src/workspace/evidence.schema.json +279 -0
- package/src/workspace/export-cli.ts +180 -0
- package/src/workspace/export-import.test.ts +282 -0
- package/src/workspace/export.ts +486 -0
- package/src/workspace/graph-cli.ts +47 -1
- package/src/workspace/graph-contract.test.ts +89 -4
- package/src/workspace/graph.schema.json +206 -1
- package/src/workspace/import.ts +325 -0
- package/src/workspace/kinds.test.ts +5 -5
- package/src/workspace/kinds.ts +25 -3
- package/src/workspace/lineage-adopt-cli.ts +103 -0
- package/src/workspace/lineage-adopt.test.ts +552 -0
- package/src/workspace/lineage-adopt.ts +452 -0
- package/src/workspace/lineage-check.ts +26 -5
- package/src/workspace/lineage-cli.ts +12 -2
- package/src/workspace/lineage-hash-index.ts +305 -0
- package/src/workspace/lineage-init.test.ts +9 -0
- package/src/workspace/lineage-init.ts +29 -9
- package/src/workspace/lineage-lock.ts +54 -0
- package/src/workspace/lineage-migrations.test.ts +27 -0
- package/src/workspace/lineage-migrations.ts +44 -14
- package/src/workspace/lineage-provenance.ts +40 -0
- package/src/workspace/lineage-upgrade-cli.ts +9 -5
- package/src/workspace/lineage-upgrade.test.ts +92 -1
- package/src/workspace/lineage-upgrade.ts +111 -16
- package/src/workspace/lineage-versions.ts +348 -0
- package/src/workspace/links.test.ts +121 -2
- package/src/workspace/links.ts +97 -6
- package/src/workspace/ls-contract.test.ts +84 -1
- package/src/workspace/ls-generated.ts +111 -0
- package/src/workspace/ls.schema.json +19 -0
- package/src/workspace/ls.ts +8 -1
- package/src/workspace/member-commands.ts +3 -1
- package/src/workspace/nested-graph.test.ts +176 -0
- package/src/workspace/nested-graph.ts +169 -0
- package/src/workspace/nesting.ts +37 -0
- package/src/workspace/pin-cli.test.ts +71 -0
- package/src/workspace/pin-cli.ts +57 -0
- package/src/workspace/pin-integrity.test.ts +121 -0
- package/src/workspace/pin-integrity.ts +104 -0
- package/src/workspace/points-write.schema.json +1 -0
- package/src/workspace/read-contract.test.ts +24 -0
- package/src/workspace/reason-codes.test.ts +10 -0
- package/src/workspace/reason-codes.ts +29 -2
- package/src/workspace/record-sessions.ts +12 -14
- package/src/workspace/records-amend.schema.json +4 -0
- package/src/workspace/records-cli.ts +93 -12
- package/src/workspace/records-close.schema.json +6 -2
- package/src/workspace/records-close.ts +10 -2
- package/src/workspace/records-formats.test.ts +13 -5
- package/src/workspace/records-new.schema.json +4 -0
- package/src/workspace/records-review.schema.json +4 -0
- package/src/workspace/records-sessions.test.ts +23 -6
- package/src/workspace/records-write.test.ts +65 -8
- package/src/workspace/records-write.ts +70 -6
- package/src/workspace/records.schema.json +55 -3
- package/src/workspace/records.ts +96 -5
- package/src/workspace/returns.ts +328 -0
- package/src/workspace/signers.schema.json +206 -0
- package/src/workspace/status-gates.ts +2 -1
- package/src/workspace/template-manifest.ts +22 -4
- package/src/workspace/trust/attestor.ts +15 -0
- package/src/workspace/trust/dsse.ts +134 -0
- package/src/workspace/trust/evidence-cli.ts +195 -0
- package/src/workspace/trust/evidence.test.ts +241 -0
- package/src/workspace/trust/evidence.ts +207 -0
- package/src/workspace/trust/policy.ts +110 -3
- package/src/workspace/trust/provenance.ts +41 -4
- package/src/workspace/trust/record-seal.test.ts +1 -1
- package/src/workspace/trust/rotation.test.ts +258 -0
- package/src/workspace/trust/rotation.ts +336 -0
- package/src/workspace/trust/seal.ts +11 -0
- package/src/workspace/trust/signers-cli.ts +178 -0
- package/src/workspace/trust/ssh-commit.ts +53 -4
- package/src/workspace/trust/test-repo.ts +18 -0
- package/src/workspace/trust/trust.test.ts +8 -2
- package/src/workspace/trust/verify-cli.ts +1 -0
- package/src/workspace/trust/verify.ts +22 -0
- package/src/workspace/work-evidence.schema.json +4 -0
- package/src/workspace/write-scope.test.ts +340 -0
- 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
|
|
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 {
|
|
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
|
|
42
|
+
/** The hash a plan digest is taken with. */
|
|
43
43
|
export const PLAN_DIGEST_ALGORITHM = "sha256";
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
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
|
|
70
|
-
* --plan` boundary, so a typo, a truncated copy-paste or a plan
|
|
71
|
-
* is refused before it is written into an immutable resolution
|
|
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.
|
|
95
|
-
`## Template upgrade: \`${staged.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|