@intentius/chant 0.82.0 → 0.84.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 (73) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +4 -0
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/content-digest.d.ts +2 -0
  5. package/dist/content-digest.d.ts.map +1 -1
  6. package/dist/workspace/checks/records.d.ts +33 -0
  7. package/dist/workspace/checks/records.d.ts.map +1 -0
  8. package/dist/workspace/checks.d.ts +8 -0
  9. package/dist/workspace/checks.d.ts.map +1 -1
  10. package/dist/workspace/compose-graph.d.ts +22 -6
  11. package/dist/workspace/compose-graph.d.ts.map +1 -1
  12. package/dist/workspace/graph-cli.d.ts +15 -4
  13. package/dist/workspace/graph-cli.d.ts.map +1 -1
  14. package/dist/workspace/intent-cli.d.ts +17 -0
  15. package/dist/workspace/intent-cli.d.ts.map +1 -0
  16. package/dist/workspace/intent-joins.d.ts +93 -0
  17. package/dist/workspace/intent-joins.d.ts.map +1 -0
  18. package/dist/workspace/intent.d.ts +285 -0
  19. package/dist/workspace/intent.d.ts.map +1 -0
  20. package/dist/workspace/lineage-check.d.ts +5 -2
  21. package/dist/workspace/lineage-check.d.ts.map +1 -1
  22. package/dist/workspace/lineage-init.d.ts.map +1 -1
  23. package/dist/workspace/lineage-lock.d.ts +8 -0
  24. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  25. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  26. package/dist/workspace/reason-codes.d.ts +19 -0
  27. package/dist/workspace/reason-codes.d.ts.map +1 -1
  28. package/dist/workspace/record-assets.d.ts +108 -0
  29. package/dist/workspace/record-assets.d.ts.map +1 -0
  30. package/dist/workspace/records-cli.d.ts +32 -1
  31. package/dist/workspace/records-cli.d.ts.map +1 -1
  32. package/dist/workspace/records.d.ts +47 -0
  33. package/dist/workspace/records.d.ts.map +1 -1
  34. package/dist/workspace/template-pins.d.ts +33 -0
  35. package/dist/workspace/template-pins.d.ts.map +1 -0
  36. package/dist/workspace/tree.d.ts +5 -0
  37. package/dist/workspace/tree.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/main.test.ts +11 -0
  40. package/src/cli/main.ts +22 -6
  41. package/src/cli/registry.ts +4 -0
  42. package/src/content-digest.ts +5 -0
  43. package/src/workspace/checks/records.ts +83 -0
  44. package/src/workspace/checks.test.ts +2 -0
  45. package/src/workspace/checks.ts +13 -3
  46. package/src/workspace/compose-graph.test.ts +2 -1
  47. package/src/workspace/compose-graph.ts +23 -6
  48. package/src/workspace/graph-cli.ts +40 -6
  49. package/src/workspace/graph-contract.test.ts +2 -1
  50. package/src/workspace/graph.schema.json +131 -2
  51. package/src/workspace/intent-cli.ts +95 -0
  52. package/src/workspace/intent-joins.ts +165 -0
  53. package/src/workspace/intent.schema.json +1078 -0
  54. package/src/workspace/intent.test.ts +448 -0
  55. package/src/workspace/intent.ts +941 -0
  56. package/src/workspace/lineage-check.ts +22 -5
  57. package/src/workspace/lineage-init.ts +5 -1
  58. package/src/workspace/lineage-lock.ts +7 -0
  59. package/src/workspace/lineage-upgrade.test.ts +29 -0
  60. package/src/workspace/lineage-upgrade.ts +21 -3
  61. package/src/workspace/member-commands.ts +1 -1
  62. package/src/workspace/read-contract.test.ts +16 -1
  63. package/src/workspace/reason-codes.test.ts +7 -2
  64. package/src/workspace/reason-codes.ts +23 -0
  65. package/src/workspace/record-assets.test.ts +319 -0
  66. package/src/workspace/record-assets.ts +207 -0
  67. package/src/workspace/records-cli.ts +117 -14
  68. package/src/workspace/records.schema.json +33 -1
  69. package/src/workspace/records.test.ts +50 -0
  70. package/src/workspace/records.ts +116 -6
  71. package/src/workspace/template-pins.test.ts +69 -0
  72. package/src/workspace/template-pins.ts +117 -0
  73. package/src/workspace/tree.ts +12 -0
@@ -218,14 +218,17 @@
218
218
  },
219
219
  "links": {
220
220
  "type": "array",
221
- "description": "Member links (#2524 D6, #2539): the ones declared on a consumer and the ones inferred from join keys, resolved against the composed graph. A reader draws a row from its `from` node to its `to` node when both are set.",
221
+ "description": "Member links (#2524 D6, #2539): the ones declared on a consumer and the ones inferred from join keys, resolved against the composed graph. A reader draws a row from its `from` node to its `to` node when both are set. With --kind, the links of the records read follow (#2549): an `asset` row for each file a record pins by hash, and a `constrains` row for each member or path it governs. These rows have `record` and `target` instead of `consumer` and `producer`.",
222
222
  "items": {
223
223
  "$ref": "#/$defs/link"
224
224
  }
225
225
  },
226
226
  "records": {
227
227
  "type": "array",
228
- "description": "Record sections (#2524 D4). Empty until record kinds join the graph."
228
+ "description": "The records read with --kind (#2524 D4, #2549), sorted by path; empty without it. A record link in `links` names its record by id.",
229
+ "items": {
230
+ "$ref": "#/$defs/record"
231
+ }
229
232
  }
230
233
  }
231
234
  },
@@ -655,8 +658,134 @@
655
658
  }
656
659
  }
657
660
  }
661
+ },
662
+ {
663
+ "type": "object",
664
+ "description": "A record link (#2549): from a current record to a file it pins (kind asset) or to a member or path it constrains (kind constrains). Superseded records have none. A reader finds the artifacts behind a file by taking the decisions whose constrains cover it and then their asset rows.",
665
+ "required": [
666
+ "kind",
667
+ "origin",
668
+ "resolves",
669
+ "recordKind",
670
+ "record",
671
+ "recordPath",
672
+ "target",
673
+ "member",
674
+ "status",
675
+ "reason"
676
+ ],
677
+ "properties": {
678
+ "kind": {
679
+ "enum": [
680
+ "asset",
681
+ "constrains"
682
+ ]
683
+ },
684
+ "origin": {
685
+ "const": "declared"
686
+ },
687
+ "resolves": {
688
+ "const": "source"
689
+ },
690
+ "recordKind": {
691
+ "type": "string",
692
+ "description": "The record's kind, such as decision."
693
+ },
694
+ "record": {
695
+ "type": "string",
696
+ "description": "The record's id."
697
+ },
698
+ "recordPath": {
699
+ "type": "string",
700
+ "description": "The record file, from the repository root."
701
+ },
702
+ "target": {
703
+ "type": "string",
704
+ "description": "For asset, the pinned path from the workspace root. For constrains, the entry as written: member:<name> or path:<path>."
705
+ },
706
+ "member": {
707
+ "type": [
708
+ "string",
709
+ "null"
710
+ ],
711
+ "description": "The member the target names or sits in; null when no member does."
712
+ },
713
+ "status": {
714
+ "enum": [
715
+ "pinned",
716
+ "drifted",
717
+ "missing",
718
+ "stale",
719
+ "resolved"
720
+ ],
721
+ "description": "For asset: pinned, drifted, missing or stale, as records reports the pin. For constrains: resolved, or missing when the member is not declared or the path does not exist."
722
+ },
723
+ "reason": {
724
+ "type": [
725
+ "string",
726
+ "null"
727
+ ],
728
+ "description": "Why the row is not pinned or resolved, or null."
729
+ },
730
+ "sha256": {
731
+ "type": "string",
732
+ "pattern": "^[0-9a-f]{64}$",
733
+ "description": "For asset: the pinned hash."
734
+ },
735
+ "actual": {
736
+ "type": [
737
+ "string",
738
+ "null"
739
+ ],
740
+ "pattern": "^[0-9a-f]{64}$",
741
+ "description": "For asset: the hash of the file in the tree read, or null when it is missing."
742
+ }
743
+ }
658
744
  }
659
745
  ]
746
+ },
747
+ "record": {
748
+ "type": "object",
749
+ "required": [
750
+ "kind",
751
+ "id",
752
+ "path",
753
+ "state",
754
+ "valid",
755
+ "supersededBy"
756
+ ],
757
+ "properties": {
758
+ "kind": {
759
+ "type": "string",
760
+ "description": "The record kind's name, such as decision."
761
+ },
762
+ "id": {
763
+ "type": [
764
+ "string",
765
+ "null"
766
+ ]
767
+ },
768
+ "path": {
769
+ "type": "string",
770
+ "description": "The record file, from the repository root, with / separators."
771
+ },
772
+ "state": {
773
+ "type": [
774
+ "string",
775
+ "null"
776
+ ]
777
+ },
778
+ "valid": {
779
+ "type": "boolean",
780
+ "description": "As chant workspace records reports it."
781
+ },
782
+ "supersededBy": {
783
+ "type": [
784
+ "string",
785
+ "null"
786
+ ]
787
+ }
788
+ }
660
789
  }
661
790
  }
662
791
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]`
3
+ * (#2651): the intent graph over one region (`intent.ts`), printed as JSON
4
+ * with `--json` or as a walk, one line per node, in the order of #2650
5
+ * section B: the region, its decisions, their artifacts, the commits, and the
6
+ * findings.
7
+ */
8
+
9
+ import { resolve } from "node:path";
10
+ import { formatError } from "../cli/format";
11
+ import type { CommandContext } from "../cli/registry";
12
+ import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
13
+
14
+ const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
15
+
16
+ type Result = Exclude<IntentDocument, { error: unknown }>;
17
+
18
+ function edgesFrom(doc: Result, from: string, kind: IntentEdge["kind"]): IntentEdge[] {
19
+ return doc.edges.filter((e) => e.from === from && e.kind === kind);
20
+ }
21
+
22
+ function ofKind<K extends IntentNode["kind"]>(doc: Result, kind: K): Extract<IntentNode, { kind: K }>[] {
23
+ return doc.nodes.filter((n): n is Extract<IntentNode, { kind: K }> => n.kind === kind);
24
+ }
25
+
26
+ const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 8) : "none");
27
+
28
+ function decisionLine(d: DecisionNode): string {
29
+ const via = d.constrains.length > 0 ? d.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through supersession only";
30
+ const by = d.decided_by ? `, decided by ${d.decided_by}${d.decided_on ? ` on ${d.decided_on}` : ""}` : "";
31
+ const reviews = `${d.reviews.agree} agree, ${d.reviews.dissent} dissent, ${d.reviews.abstain} abstain`;
32
+ const superseded = d.supersededBy ? `, superseded by ${d.supersededBy}` : "";
33
+ return `decision ${d.record} ${d.state ?? "stateless"}${superseded}: ${d.title ?? d.path}; constrains ${via}${by}; ${reviews}; ${d.provenance.level}${d.valid ? "" : `; invalid: ${d.reasons.map((r) => r.code).join(", ")}`}`;
34
+ }
35
+
36
+ function artifactLine(doc: Result, a: ArtifactNode): string {
37
+ const by = doc.edges
38
+ .filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
39
+ .map((e) => `${e.from.slice(e.from.indexOf("/") + 1)} at ${short(e.pinnedSha256)} (${e.pinState})`);
40
+ return `artifact ${a.path} ${a.pinState}; pinned by ${by.join(", ")}; now ${short(a.currentSha256)}`;
41
+ }
42
+
43
+ function commitLine(doc: Result, c: CommitNode): string[] {
44
+ const lines = c.lines && c.lines.length > 0 ? `, lines ${c.lines.map((l) => (l.start === l.end ? `${l.start}` : `${l.start}-${l.end}`)).join(", ")}` : "";
45
+ const out = [`commit ${short(c.sha)} ${c.date.slice(0, 10)} ${c.author.name}: ${c.subject}${lines}; ${c.signature.level}`];
46
+ for (const e of edgesFrom(doc, c.id, "produced-by")) {
47
+ const unit = doc.nodes.find((n) => n.id === e.to);
48
+ out.push(` unit ${unit && "ref" in unit ? unit.ref : e.to}`);
49
+ for (const s of edgesFrom(doc, e.to, "serves")) out.push(` contract ${s.to.slice("contract:".length)}`);
50
+ for (const s of edgesFrom(doc, e.to, "cites-evidence")) out.push(` evidence ${s.to.slice("evidence:".length)}`);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** The walk as text, one line each, in the order of #2650 section B. */
56
+ export function formatIntent(doc: Result): string {
57
+ const out: string[] = [];
58
+ const region = doc.nodes.find((n) => n.id === doc.region);
59
+ if (region?.kind === "region") {
60
+ const lines = region.lines ? `:${region.lines.start}${region.lines.end === region.lines.start ? "" : `-${region.lines.end}`}` : "";
61
+ const where = doc.at ? `at ${short(doc.at)}` : "in the working tree";
62
+ out.push(`region ${region.path}${lines} (${region.type}, member ${region.member ?? "none"}${region.generated ? ", generated" : ""}) ${where}${region.node ? `, from node ${region.node}` : ""}`);
63
+ }
64
+ const files = ofKind(doc, "file");
65
+ if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
66
+ for (const d of ofKind(doc, "decision")) out.push(decisionLine(d));
67
+ for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
68
+ for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
69
+ for (const l of ofKind(doc, "link")) {
70
+ const r = l.row;
71
+ out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
72
+ }
73
+ for (const f of ofKind(doc, "finding")) out.push(`finding ${f.code}: ${f.message}`);
74
+ for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
75
+ const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
76
+ out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
77
+ return out.join("\n");
78
+ }
79
+
80
+ export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Promise<number> {
81
+ const { args } = ctx;
82
+ if (!args.intent) {
83
+ console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
84
+ return 1;
85
+ }
86
+ const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : []);
87
+ const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds.map((k) => resolve(k)) });
88
+ if (args.json) console.log(JSON.stringify(doc, null, 2));
89
+ if ("error" in doc) {
90
+ console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
91
+ return 1;
92
+ }
93
+ if (!args.json) console.log(formatIntent(doc));
94
+ return failed ? 1 : 0;
95
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The commit-join hook of the intent graph (#2651; #2650 C1 and C13).
3
+ *
4
+ * Commits, decisions and artifacts come from core. Units of work, contracts
5
+ * and evidence come from a plugin, such as chud's development model, because
6
+ * core ships no model of them (#2555, "Core ships no decision kind"). A kind
7
+ * file passed to `chant workspace graph --intent --kind <file>` supplies them
8
+ * through one export, `commitJoins`, in one of two forms:
9
+ *
10
+ * - A function `commitJoins(commit, context)` that returns the unit, contract
11
+ * and evidence for one commit, or nothing. It is given the commit's sha,
12
+ * subject, body, author, date and trailers, and a `read` function for files
13
+ * at the revision read.
14
+ * - Data, which core interprets with no plugin code: the trailer keys that
15
+ * name a unit, a contract or evidence, the record paths to read for each
16
+ * (`units/{id}.json`), and the trailer keys that claim authorship.
17
+ *
18
+ * Either way core never parses a plugin's own trailer or record format: it
19
+ * reads the trailers git reports and hands them over, and a key means
20
+ * something only because a kind file said so.
21
+ */
22
+
23
+ import { z } from "zod";
24
+
25
+ /** A commit as the hook sees it. */
26
+ export interface IntentCommit {
27
+ sha: string;
28
+ subject: string;
29
+ body: string;
30
+ author: { name: string; email: string };
31
+ /** Author date, ISO 8601. */
32
+ date: string;
33
+ /** Every trailer git parses from the message, keyed as written, with each value in order. */
34
+ trailers: Record<string, string[]>;
35
+ }
36
+
37
+ export interface CommitJoinContext {
38
+ /** The text of a file from the workspace root, in the tree read; undefined when it is not a file there. */
39
+ read(path: string): string | undefined;
40
+ /** The full commit id read with `--at`, or null for the working tree. */
41
+ at: string | null;
42
+ }
43
+
44
+ /** A plugin's unit, contract or evidence: an id and whatever fields the plugin records. */
45
+ export interface JoinedEntity {
46
+ id: string;
47
+ [field: string]: unknown;
48
+ }
49
+
50
+ /** What a join says about one commit. Every part is optional. */
51
+ export interface CommitJoin {
52
+ /** The unit of work that produced the commit. */
53
+ unit?: JoinedEntity;
54
+ /** The contract the unit served. */
55
+ contract?: JoinedEntity;
56
+ /** The evidence the unit or contract cites. */
57
+ evidence?: JoinedEntity | JoinedEntity[];
58
+ /** Trailer keys on this commit that claim who wrote it, which the plugin vouches for. */
59
+ authorship?: string[];
60
+ }
61
+
62
+ export type CommitJoinsFunction = (commit: IntentCommit, context: CommitJoinContext) => CommitJoin | null | undefined | Promise<CommitJoin | null | undefined>;
63
+
64
+ const trailerKey = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/, "a trailer key holds letters, digits and dashes");
65
+ const recordPath = z.string().min(1).refine((p) => p.includes("{id}") && !p.startsWith("/") && !p.split("/").includes(".."), "a record path is relative to the workspace root and holds {id}");
66
+
67
+ /** The data form of `commitJoins`. */
68
+ export const commitJoinsDataSchema = z
69
+ .object({
70
+ /** The trailer whose value is the id of the unit, contract or evidence. */
71
+ trailers: z.object({ unit: trailerKey.optional(), contract: trailerKey.optional(), evidence: trailerKey.optional() }).strict(),
72
+ /** Where each is recorded, from the workspace root, with `{id}` for the id. A JSON file there adds its fields. */
73
+ records: z.object({ unit: recordPath.optional(), contract: recordPath.optional(), evidence: recordPath.optional() }).strict().optional(),
74
+ /** Trailer keys that claim authorship, checked against the commit's provenance. */
75
+ authorship: z.array(trailerKey).optional(),
76
+ })
77
+ .strict();
78
+
79
+ export type CommitJoinsData = z.infer<typeof commitJoinsDataSchema>;
80
+
81
+ /** A kind file's `commitJoins` export, checked: a function, or data. */
82
+ export type CommitJoins = { form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData };
83
+
84
+ /** Read a kind module's `commitJoins` export. Undefined when it has none; a string when it is malformed. */
85
+ export function readCommitJoins(mod: Record<string, unknown>): CommitJoins | string | undefined {
86
+ const value = mod.commitJoins;
87
+ if (value === undefined) return undefined;
88
+ if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction };
89
+ const parsed = commitJoinsDataSchema.safeParse(value);
90
+ if (!parsed.success) return parsed.error.issues.map((i) => `commitJoins${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`).join("; ");
91
+ return { form: "data", data: parsed.data };
92
+ }
93
+
94
+ /** The first value of trailer `key`, compared without case as git does. */
95
+ export function trailerValue(trailers: Record<string, string[]>, key: string): string | undefined {
96
+ const want = key.toLowerCase();
97
+ for (const [k, values] of Object.entries(trailers)) if (k.toLowerCase() === want && values.length > 0) return values[0].trim() || undefined;
98
+ return undefined;
99
+ }
100
+
101
+ /** Whether the commit carries trailer `key`. */
102
+ export function hasTrailer(trailers: Record<string, string[]>, key: string): boolean {
103
+ const want = key.toLowerCase();
104
+ return Object.keys(trailers).some((k) => k.toLowerCase() === want);
105
+ }
106
+
107
+ function recordFields(template: string | undefined, id: string, context: CommitJoinContext): Record<string, unknown> {
108
+ if (!template) return {};
109
+ const text = context.read(template.split("{id}").join(id));
110
+ if (text === undefined) return {};
111
+ let value: unknown;
112
+ try {
113
+ value = JSON.parse(text);
114
+ } catch {
115
+ throw new Error(`${template.split("{id}").join(id)} is not JSON`);
116
+ }
117
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
118
+ const { id: _id, ...rest } = value as Record<string, unknown>;
119
+ return rest;
120
+ }
121
+
122
+ /** Interpret the data form for one commit. Throws when a named record can't be read as JSON. */
123
+ export function joinByData(data: CommitJoinsData, commit: IntentCommit, context: CommitJoinContext): CommitJoin {
124
+ const out: CommitJoin = {};
125
+ for (const part of ["unit", "contract", "evidence"] as const) {
126
+ const key = data.trailers[part];
127
+ const id = key ? trailerValue(commit.trailers, key) : undefined;
128
+ if (!id) continue;
129
+ const entity: JoinedEntity = { ...recordFields(data.records?.[part], id, context), id };
130
+ if (part === "evidence") out.evidence = entity;
131
+ else out[part] = entity;
132
+ }
133
+ const claimed = (data.authorship ?? []).filter((k) => hasTrailer(commit.trailers, k));
134
+ if (claimed.length > 0) out.authorship = claimed;
135
+ return out;
136
+ }
137
+
138
+ /** Run a kind's joins for one commit, checking what a function returns. */
139
+ export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext): Promise<CommitJoin> {
140
+ if (joins.form === "data") return joinByData(joins.data, commit, context);
141
+ const result = await joins.join(commit, context);
142
+ if (result === null || result === undefined) return {};
143
+ if (typeof result !== "object") throw new Error("commitJoins returned something that is not an object");
144
+ const entity = (v: unknown, what: string): JoinedEntity | undefined => {
145
+ if (v === undefined || v === null) return undefined;
146
+ if (typeof v !== "object" || Array.isArray(v) || typeof (v as JoinedEntity).id !== "string" || (v as JoinedEntity).id === "") {
147
+ throw new Error(`commitJoins returned a ${what} with no string id`);
148
+ }
149
+ return v as JoinedEntity;
150
+ };
151
+ const out: CommitJoin = {};
152
+ const unit = entity(result.unit, "unit");
153
+ const contract = entity(result.contract, "contract");
154
+ if (unit) out.unit = unit;
155
+ if (contract) out.contract = contract;
156
+ if (result.evidence !== undefined && result.evidence !== null) {
157
+ const list = Array.isArray(result.evidence) ? result.evidence : [result.evidence];
158
+ out.evidence = list.map((e) => entity(e, "evidence")!).filter(Boolean);
159
+ }
160
+ if (result.authorship !== undefined) {
161
+ if (!Array.isArray(result.authorship) || !result.authorship.every((k) => typeof k === "string")) throw new Error("commitJoins returned authorship that is not a list of trailer keys");
162
+ out.authorship = result.authorship;
163
+ }
164
+ return out;
165
+ }