@intentius/chant 0.83.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.
@@ -5,6 +5,10 @@
5
5
  * document (`compose-graph.ts`). With `--kind`, the records of that kind and
6
6
  * their asset and constrains links join it (#2549, `record-assets.ts`).
7
7
  *
8
+ * With `--intent <path[:start-end]>` it prints the intent graph over one
9
+ * region instead (#2651, `intent.ts`), a document of its own in the read
10
+ * contract.
11
+ *
8
12
  * The document is part of the read contract, described by `graph.schema.json`
9
13
  * beside this file. It is printed for a failure too, with the error's reason
10
14
  * code, so a reader always has JSON to parse.
@@ -43,7 +47,8 @@ export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/worksp
43
47
  /** Why the graph couldn't be read at all: the declaration's codes, `--at`'s included. */
44
48
  export const GRAPH_ERROR_CODES = WORKSPACE_ERROR_CODES;
45
49
 
46
- const USAGE = "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]";
50
+ const USAGE =
51
+ "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
47
52
 
48
53
  interface Head {
49
54
  $schema: string;
@@ -216,6 +221,9 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
216
221
  const handed = await handToRootChant(cwd, args.at);
217
222
  if (handed !== undefined) return handed;
218
223
 
224
+ // The intent graph over one region (#2651) is its own document.
225
+ if (args.intent !== undefined) return (await import("./intent-cli")).runWorkspaceIntent(ctx, cwd);
226
+
219
227
  if (args.dryRun) {
220
228
  let plan: MemberPlan;
221
229
  try {
@@ -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
+ }