@intentius/chant 0.83.0 → 0.85.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/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts +2 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +1 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/composites.d.ts +44 -0
- package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
- package/dist/cli/mcp/tools/search.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +139 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +16 -1
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +21 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -0
- package/dist/workspace/intent-joins.d.ts +121 -0
- package/dist/workspace/intent-joins.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +310 -0
- package/dist/workspace/intent.d.ts.map +1 -0
- package/dist/workspace/member-commands.d.ts +7 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +29 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +7 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +20 -0
- package/src/cli/main.ts +18 -0
- package/src/cli/mcp/resource-handlers.ts +17 -0
- package/src/cli/mcp/server.test.ts +140 -4
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp/tools/composites.ts +98 -0
- package/src/cli/mcp/tools/search.ts +47 -5
- package/src/cli/registry.ts +6 -0
- package/src/components/cli-support.test.ts +16 -0
- package/src/components/cli-support.ts +8 -2
- package/src/composite.ts +9 -0
- package/src/lexicon.ts +47 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -0
- package/src/workspace/composites.schema.json +471 -0
- package/src/workspace/composites.test.ts +244 -0
- package/src/workspace/composites.ts +295 -0
- package/src/workspace/graph-cli.ts +39 -3
- package/src/workspace/intent-cli.ts +119 -0
- package/src/workspace/intent-joins.ts +210 -0
- package/src/workspace/intent.schema.json +1141 -0
- package/src/workspace/intent.test.ts +566 -0
- package/src/workspace/intent.ts +999 -0
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +46 -3
- package/src/workspace/reason-codes.test.ts +39 -2
- package/src/workspace/reason-codes.ts +40 -0
- package/src/workspace/records-contract.test.ts +22 -2
- package/src/workspace/records.schema.json +2 -2
- package/src/workspace/records.test.ts +93 -0
- package/src/workspace/records.ts +55 -11
|
@@ -0,0 +1,119 @@
|
|
|
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. Under each decision come the commits made inside its window, and
|
|
7
|
+
* when any of them is not the decision's own work, the question #2650 B puts
|
|
8
|
+
* to the person about it (#2656).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { formatError } from "../cli/format";
|
|
13
|
+
import type { CommandContext } from "../cli/registry";
|
|
14
|
+
import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
|
|
15
|
+
|
|
16
|
+
const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
|
|
17
|
+
|
|
18
|
+
type Result = Exclude<IntentDocument, { error: unknown }>;
|
|
19
|
+
|
|
20
|
+
function edgesFrom(doc: Result, from: string, kind: IntentEdge["kind"]): IntentEdge[] {
|
|
21
|
+
return doc.edges.filter((e) => e.from === from && e.kind === kind);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ofKind<K extends IntentNode["kind"]>(doc: Result, kind: K): Extract<IntentNode, { kind: K }>[] {
|
|
25
|
+
return doc.nodes.filter((n): n is Extract<IntentNode, { kind: K }> => n.kind === kind);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 8) : "none");
|
|
29
|
+
|
|
30
|
+
/** What the walk asks about a commit made inside a decision's window that is not the decision's own work (#2650 B4, step 5). */
|
|
31
|
+
export const IN_WINDOW_QUESTION = "is this drift, a superseding decision nobody wrote down, or the decision being wrong?";
|
|
32
|
+
|
|
33
|
+
/** The commits inside a decision's window, under its line, then the question once when any is not its own work. */
|
|
34
|
+
function withinLines(doc: Result, d: DecisionNode): string[] {
|
|
35
|
+
const within = doc.edges.filter((e): e is Extract<IntentEdge, { kind: "within" }> => e.kind === "within" && e.to === d.id);
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
for (const e of within) {
|
|
38
|
+
const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
|
|
39
|
+
if (!c) continue;
|
|
40
|
+
const unit = edgesFrom(doc, c.id, "produced-by")
|
|
41
|
+
.map((p) => doc.nodes.find((n) => n.id === p.to))
|
|
42
|
+
.map((n) => (n && "ref" in n ? n.ref : undefined))
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
const by = unit.length > 0 ? `unit ${unit.join(", ")}` : "no unit";
|
|
45
|
+
const label = e.state === "decided" ? "decided " : "within ";
|
|
46
|
+
out.push(` ${label} ${short(c.sha)} ${c.subject}; ${by}${e.state === "decided" ? `, ${d.record}'s own work` : `, in ${d.record}'s window and not its work`}`);
|
|
47
|
+
}
|
|
48
|
+
if (within.some((e) => e.state === "decided-by-window")) out.push(` ask ${IN_WINDOW_QUESTION}`);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decisionLine(d: DecisionNode): string {
|
|
53
|
+
const via = d.constrains.length > 0 ? d.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through supersession only";
|
|
54
|
+
const by = d.decided_by ? `, decided by ${d.decided_by}${d.decided_on ? ` on ${d.decided_on}` : ""}` : "";
|
|
55
|
+
const reviews = `${d.reviews.agree} agree, ${d.reviews.dissent} dissent, ${d.reviews.abstain} abstain`;
|
|
56
|
+
const superseded = d.supersededBy ? `, superseded by ${d.supersededBy}` : "";
|
|
57
|
+
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(", ")}`}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function artifactLine(doc: Result, a: ArtifactNode): string {
|
|
61
|
+
const by = doc.edges
|
|
62
|
+
.filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
|
|
63
|
+
.map((e) => `${e.from.slice(e.from.indexOf("/") + 1)} at ${short(e.pinnedSha256)} (${e.pinState})`);
|
|
64
|
+
return `artifact ${a.path} ${a.pinState}; pinned by ${by.join(", ")}; now ${short(a.currentSha256)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function commitLine(doc: Result, c: CommitNode): string[] {
|
|
68
|
+
const lines = c.lines && c.lines.length > 0 ? `, lines ${c.lines.map((l) => (l.start === l.end ? `${l.start}` : `${l.start}-${l.end}`)).join(", ")}` : "";
|
|
69
|
+
const out = [`commit ${short(c.sha)} ${c.date.slice(0, 10)} ${c.author.name}: ${c.subject}${lines}; ${c.signature.level}`];
|
|
70
|
+
for (const e of edgesFrom(doc, c.id, "produced-by")) {
|
|
71
|
+
const unit = doc.nodes.find((n) => n.id === e.to);
|
|
72
|
+
out.push(` unit ${unit && "ref" in unit ? unit.ref : e.to}`);
|
|
73
|
+
for (const s of edgesFrom(doc, e.to, "serves")) out.push(` contract ${s.to.slice("contract:".length)}`);
|
|
74
|
+
for (const s of edgesFrom(doc, e.to, "cites-evidence")) out.push(` evidence ${s.to.slice("evidence:".length)}`);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The walk as text, one line each, in the order of #2650 section B. */
|
|
80
|
+
export function formatIntent(doc: Result): string {
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
const region = doc.nodes.find((n) => n.id === doc.region);
|
|
83
|
+
if (region?.kind === "region") {
|
|
84
|
+
const lines = region.lines ? `:${region.lines.start}${region.lines.end === region.lines.start ? "" : `-${region.lines.end}`}` : "";
|
|
85
|
+
const where = doc.at ? `at ${short(doc.at)}` : "in the working tree";
|
|
86
|
+
out.push(`region ${region.path}${lines} (${region.type}, member ${region.member ?? "none"}${region.generated ? ", generated" : ""}) ${where}${region.node ? `, from node ${region.node}` : ""}`);
|
|
87
|
+
}
|
|
88
|
+
const files = ofKind(doc, "file");
|
|
89
|
+
if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
|
|
90
|
+
for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
|
|
91
|
+
for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
|
|
92
|
+
for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
|
|
93
|
+
for (const l of ofKind(doc, "link")) {
|
|
94
|
+
const r = l.row;
|
|
95
|
+
out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
|
|
96
|
+
}
|
|
97
|
+
for (const f of ofKind(doc, "finding")) out.push(`finding ${f.code}: ${f.message}`);
|
|
98
|
+
for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
|
|
99
|
+
const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
|
|
100
|
+
out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
|
|
101
|
+
return out.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Promise<number> {
|
|
105
|
+
const { args } = ctx;
|
|
106
|
+
if (!args.intent) {
|
|
107
|
+
console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : []);
|
|
111
|
+
const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds.map((k) => resolve(k)) });
|
|
112
|
+
if (args.json) console.log(JSON.stringify(doc, null, 2));
|
|
113
|
+
if ("error" in doc) {
|
|
114
|
+
console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
if (!args.json) console.log(formatIntent(doc));
|
|
118
|
+
return failed ? 1 : 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
* Two parts of a join's answer carry meaning core acts on (#2656). A unit or
|
|
23
|
+
* contract may list `decisions`, the ids of the decision records it carries
|
|
24
|
+
* out; a commit whose unit or contract names a decision is that decision's own
|
|
25
|
+
* work. The function form may also return `findings`, each a code in the
|
|
26
|
+
* kind's own namespace (`plugin:<name>:<code>`), a message and the refs it is
|
|
27
|
+
* about, which the graph carries as finding nodes. That is how a plugin says
|
|
28
|
+
* what it knows and core does not, such as a contract whose criteria changed
|
|
29
|
+
* in a commit that names no decision.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { isPluginCode } from "./reason-codes";
|
|
34
|
+
|
|
35
|
+
/** A commit as the hook sees it. */
|
|
36
|
+
export interface IntentCommit {
|
|
37
|
+
sha: string;
|
|
38
|
+
subject: string;
|
|
39
|
+
body: string;
|
|
40
|
+
author: { name: string; email: string };
|
|
41
|
+
/** Author date, ISO 8601. */
|
|
42
|
+
date: string;
|
|
43
|
+
/** Every trailer git parses from the message, keyed as written, with each value in order. */
|
|
44
|
+
trailers: Record<string, string[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CommitJoinContext {
|
|
48
|
+
/** The text of a file from the workspace root, in the tree read; undefined when it is not a file there. */
|
|
49
|
+
read(path: string): string | undefined;
|
|
50
|
+
/** The full commit id read with `--at`, or null for the working tree. */
|
|
51
|
+
at: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A plugin's unit, contract or evidence: an id and whatever fields the plugin
|
|
56
|
+
* records. On a unit or contract, `decisions`, a list of record ids, names the
|
|
57
|
+
* decisions it carries out (#2656).
|
|
58
|
+
*/
|
|
59
|
+
export interface JoinedEntity {
|
|
60
|
+
id: string;
|
|
61
|
+
[field: string]: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A finding a plugin contributes for one commit (#2656). */
|
|
65
|
+
export interface PluginFinding {
|
|
66
|
+
/** `plugin:<name>:<code>`, where `<name>` is the kind's name. */
|
|
67
|
+
code: string;
|
|
68
|
+
message: string;
|
|
69
|
+
/** What the finding is about: node ids, commit shas, record ids, unit, contract or evidence ids, or paths. */
|
|
70
|
+
refs?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a join says about one commit. Every part is optional. */
|
|
74
|
+
export interface CommitJoin {
|
|
75
|
+
/** The unit of work that produced the commit. */
|
|
76
|
+
unit?: JoinedEntity;
|
|
77
|
+
/** The contract the unit served. */
|
|
78
|
+
contract?: JoinedEntity;
|
|
79
|
+
/** The evidence the unit or contract cites. */
|
|
80
|
+
evidence?: JoinedEntity | JoinedEntity[];
|
|
81
|
+
/** Trailer keys on this commit that claim who wrote it, which the plugin vouches for. */
|
|
82
|
+
authorship?: string[];
|
|
83
|
+
/** Findings about this commit, in the kind's own code namespace. Function form only. */
|
|
84
|
+
findings?: PluginFinding[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type CommitJoinsFunction = (commit: IntentCommit, context: CommitJoinContext) => CommitJoin | null | undefined | Promise<CommitJoin | null | undefined>;
|
|
88
|
+
|
|
89
|
+
const trailerKey = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9-]*$/, "a trailer key holds letters, digits and dashes");
|
|
90
|
+
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}");
|
|
91
|
+
|
|
92
|
+
/** The data form of `commitJoins`. */
|
|
93
|
+
export const commitJoinsDataSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
/** The trailer whose value is the id of the unit, contract or evidence. */
|
|
96
|
+
trailers: z.object({ unit: trailerKey.optional(), contract: trailerKey.optional(), evidence: trailerKey.optional() }).strict(),
|
|
97
|
+
/** Where each is recorded, from the workspace root, with `{id}` for the id. A JSON file there adds its fields. */
|
|
98
|
+
records: z.object({ unit: recordPath.optional(), contract: recordPath.optional(), evidence: recordPath.optional() }).strict().optional(),
|
|
99
|
+
/** Trailer keys that claim authorship, checked against the commit's provenance. */
|
|
100
|
+
authorship: z.array(trailerKey).optional(),
|
|
101
|
+
})
|
|
102
|
+
.strict();
|
|
103
|
+
|
|
104
|
+
export type CommitJoinsData = z.infer<typeof commitJoinsDataSchema>;
|
|
105
|
+
|
|
106
|
+
/** A kind file's `commitJoins` export, checked: a function, or data. */
|
|
107
|
+
export type CommitJoins = { form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData };
|
|
108
|
+
|
|
109
|
+
/** Read a kind module's `commitJoins` export. Undefined when it has none; a string when it is malformed. */
|
|
110
|
+
export function readCommitJoins(mod: Record<string, unknown>): CommitJoins | string | undefined {
|
|
111
|
+
const value = mod.commitJoins;
|
|
112
|
+
if (value === undefined) return undefined;
|
|
113
|
+
if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction };
|
|
114
|
+
const parsed = commitJoinsDataSchema.safeParse(value);
|
|
115
|
+
if (!parsed.success) return parsed.error.issues.map((i) => `commitJoins${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`).join("; ");
|
|
116
|
+
return { form: "data", data: parsed.data };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The first value of trailer `key`, compared without case as git does. */
|
|
120
|
+
export function trailerValue(trailers: Record<string, string[]>, key: string): string | undefined {
|
|
121
|
+
const want = key.toLowerCase();
|
|
122
|
+
for (const [k, values] of Object.entries(trailers)) if (k.toLowerCase() === want && values.length > 0) return values[0].trim() || undefined;
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether the commit carries trailer `key`. */
|
|
127
|
+
export function hasTrailer(trailers: Record<string, string[]>, key: string): boolean {
|
|
128
|
+
const want = key.toLowerCase();
|
|
129
|
+
return Object.keys(trailers).some((k) => k.toLowerCase() === want);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function recordFields(template: string | undefined, id: string, context: CommitJoinContext): Record<string, unknown> {
|
|
133
|
+
if (!template) return {};
|
|
134
|
+
const text = context.read(template.split("{id}").join(id));
|
|
135
|
+
if (text === undefined) return {};
|
|
136
|
+
let value: unknown;
|
|
137
|
+
try {
|
|
138
|
+
value = JSON.parse(text);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error(`${template.split("{id}").join(id)} is not JSON`);
|
|
141
|
+
}
|
|
142
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
|
|
143
|
+
const { id: _id, ...rest } = value as Record<string, unknown>;
|
|
144
|
+
return rest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Interpret the data form for one commit. Throws when a named record can't be read as JSON. */
|
|
148
|
+
export function joinByData(data: CommitJoinsData, commit: IntentCommit, context: CommitJoinContext): CommitJoin {
|
|
149
|
+
const out: CommitJoin = {};
|
|
150
|
+
for (const part of ["unit", "contract", "evidence"] as const) {
|
|
151
|
+
const key = data.trailers[part];
|
|
152
|
+
const id = key ? trailerValue(commit.trailers, key) : undefined;
|
|
153
|
+
if (!id) continue;
|
|
154
|
+
const entity: JoinedEntity = { ...recordFields(data.records?.[part], id, context), id };
|
|
155
|
+
if (part === "evidence") out.evidence = entity;
|
|
156
|
+
else out[part] = entity;
|
|
157
|
+
}
|
|
158
|
+
const claimed = (data.authorship ?? []).filter((k) => hasTrailer(commit.trailers, k));
|
|
159
|
+
if (claimed.length > 0) out.authorship = claimed;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The record ids a unit or contract says it carries out: its `decisions` field, when that is a list of strings. */
|
|
164
|
+
export function entityDecisions(entity: JoinedEntity | undefined): string[] {
|
|
165
|
+
const list = entity?.decisions;
|
|
166
|
+
return Array.isArray(list) ? list.filter((d): d is string => typeof d === "string" && d !== "") : [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run a kind's joins for one commit, checking what a function returns.
|
|
171
|
+
* `name` is the kind's name, the namespace its findings' codes must use.
|
|
172
|
+
*/
|
|
173
|
+
export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext, name: string): Promise<CommitJoin> {
|
|
174
|
+
if (joins.form === "data") return joinByData(joins.data, commit, context);
|
|
175
|
+
const result = await joins.join(commit, context);
|
|
176
|
+
if (result === null || result === undefined) return {};
|
|
177
|
+
if (typeof result !== "object") throw new Error("commitJoins returned something that is not an object");
|
|
178
|
+
const entity = (v: unknown, what: string): JoinedEntity | undefined => {
|
|
179
|
+
if (v === undefined || v === null) return undefined;
|
|
180
|
+
if (typeof v !== "object" || Array.isArray(v) || typeof (v as JoinedEntity).id !== "string" || (v as JoinedEntity).id === "") {
|
|
181
|
+
throw new Error(`commitJoins returned a ${what} with no string id`);
|
|
182
|
+
}
|
|
183
|
+
return v as JoinedEntity;
|
|
184
|
+
};
|
|
185
|
+
const out: CommitJoin = {};
|
|
186
|
+
const unit = entity(result.unit, "unit");
|
|
187
|
+
const contract = entity(result.contract, "contract");
|
|
188
|
+
if (unit) out.unit = unit;
|
|
189
|
+
if (contract) out.contract = contract;
|
|
190
|
+
if (result.evidence !== undefined && result.evidence !== null) {
|
|
191
|
+
const list = Array.isArray(result.evidence) ? result.evidence : [result.evidence];
|
|
192
|
+
out.evidence = list.map((e) => entity(e, "evidence")!).filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
if (result.authorship !== undefined) {
|
|
195
|
+
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");
|
|
196
|
+
out.authorship = result.authorship;
|
|
197
|
+
}
|
|
198
|
+
if (result.findings !== undefined && result.findings !== null) {
|
|
199
|
+
if (!Array.isArray(result.findings)) throw new Error("commitJoins returned findings that are not a list");
|
|
200
|
+
out.findings = result.findings.map((f: unknown) => {
|
|
201
|
+
if (f === null || typeof f !== "object" || Array.isArray(f)) throw new Error("commitJoins returned a finding that is not an object");
|
|
202
|
+
const { code, message, refs } = f as Record<string, unknown>;
|
|
203
|
+
if (!isPluginCode(code, name)) throw new Error(`commitJoins returned the finding code ${JSON.stringify(code)}, and a plugin's codes are plugin:${name}:<code>, with <code> in lower case words joined by dashes`);
|
|
204
|
+
if (typeof message !== "string" || message === "") throw new Error(`commitJoins returned the finding ${code} with no message`);
|
|
205
|
+
if (refs !== undefined && (!Array.isArray(refs) || !refs.every((r) => typeof r === "string"))) throw new Error(`commitJoins returned the finding ${code} with refs that are not a list of strings`);
|
|
206
|
+
return { code, message, refs: (refs as string[] | undefined) ?? [] };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|