@intentius/chant 0.84.0 → 0.86.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 +13 -1
- 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/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.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/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +152 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +24 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +6 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +74 -9
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +90 -6
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- 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 +52 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +65 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +164 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +202 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +9 -0
- package/src/cli/main.ts +56 -3
- 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 +13 -1
- 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/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +533 -0
- package/src/workspace/composites.test.ts +334 -0
- package/src/workspace/composites.ts +316 -0
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +40 -4
- package/src/workspace/intent-cli.ts +54 -7
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +117 -20
- package/src/workspace/intent.schema.json +357 -19
- package/src/workspace/intent.test.ts +235 -20
- package/src/workspace/intent.ts +396 -51
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +52 -3
- package/src/workspace/reason-codes.test.ts +48 -4
- package/src/workspace/reason-codes.ts +67 -2
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +246 -19
- package/src/workspace/records-contract.test.ts +77 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +202 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +736 -0
- package/src/workspace/records.schema.json +187 -9
- package/src/workspace/records.test.ts +93 -0
- package/src/workspace/records.ts +683 -49
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/work.test.ts +388 -0
- package/src/workspace/work.ts +163 -0
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* With `--intent <path[:start-end]>` it prints the intent graph over one
|
|
9
9
|
* region instead (#2651, `intent.ts`), a document of its own in the read
|
|
10
|
-
* contract.
|
|
10
|
+
* contract. With `--composites` it prints each composite instance with the
|
|
11
|
+
* components that can deploy it (#2662, `composites.ts`), another.
|
|
11
12
|
*
|
|
12
13
|
* The document is part of the read contract, described by `graph.schema.json`
|
|
13
14
|
* beside this file. It is printed for a failure too, with the error's reason
|
|
@@ -48,7 +49,7 @@ export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/worksp
|
|
|
48
49
|
export const GRAPH_ERROR_CODES = WORKSPACE_ERROR_CODES;
|
|
49
50
|
|
|
50
51
|
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]";
|
|
52
|
+
"chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
|
|
52
53
|
|
|
53
54
|
interface Head {
|
|
54
55
|
$schema: string;
|
|
@@ -78,12 +79,32 @@ export interface GraphQuery {
|
|
|
78
79
|
* rows of `links`.
|
|
79
80
|
*/
|
|
80
81
|
kind?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Also run each member's `chant graph --components --format ir` (#2662),
|
|
84
|
+
* under the same toolchain and at the same revision, and return the answers
|
|
85
|
+
* in {@link GraphResult.components}. The document is unchanged.
|
|
86
|
+
*/
|
|
87
|
+
components?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Called once the members have run, with the directory they were read from
|
|
90
|
+
* (the exported tree for `--at`, before it is removed) and the declared
|
|
91
|
+
* members the read covers. `--composites` reads each member's runtimes
|
|
92
|
+
* here (#2674).
|
|
93
|
+
*/
|
|
94
|
+
inTree?: (root: string, members: readonly { name: string; dir: string; kind: string }[]) => Promise<void>;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
export interface GraphResult {
|
|
84
98
|
doc: GraphDocument;
|
|
85
99
|
/** A member failed or couldn't be read, or the declaration couldn't be read. */
|
|
86
100
|
failed: boolean;
|
|
101
|
+
/** With {@link GraphQuery.components}: each member's component graph run, in plan order. */
|
|
102
|
+
components?: UnitResult[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The command line a member runs for its component graph (#2662). */
|
|
106
|
+
export function componentGraphArgv(args: Partial<ParsedArgs>): string[] {
|
|
107
|
+
return ["graph", ".", "--components", "--format", "ir", ...(args.env ? ["--env", args.env] : [])];
|
|
87
108
|
}
|
|
88
109
|
|
|
89
110
|
/**
|
|
@@ -179,7 +200,13 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
|
|
|
179
200
|
exported = exportRevision(located, plan.groups.flatMap((g) => g.units.map((u) => u.dir)));
|
|
180
201
|
plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported) });
|
|
181
202
|
}
|
|
182
|
-
const
|
|
203
|
+
const args = (query.args ?? {}) as ParsedArgs;
|
|
204
|
+
const [results, components] = await Promise.all([
|
|
205
|
+
executePlan(plan, args),
|
|
206
|
+
query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
|
|
207
|
+
]);
|
|
208
|
+
if (query.inTree) await query.inTree(exported ?? located.rootOnDisk, declaration.members.filter((m) => !query.members?.length || query.members.includes(m.name)));
|
|
209
|
+
for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
183
210
|
for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
184
211
|
const { inputs, failed } = compose(plan, results, query.members, declaration.members);
|
|
185
212
|
// Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
|
|
@@ -201,7 +228,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
|
|
|
201
228
|
query.onStderr?.(`${formatError({ message: `--kind ${query.kind}: ${err.code}: ${err.message}`, hint: USAGE })}\n`);
|
|
202
229
|
}
|
|
203
230
|
}
|
|
204
|
-
return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed };
|
|
231
|
+
return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed, ...(components ? { components } : {}) };
|
|
205
232
|
} catch (err) {
|
|
206
233
|
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
207
234
|
return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
|
|
@@ -221,6 +248,15 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
|
|
|
221
248
|
const handed = await handToRootChant(cwd, args.at);
|
|
222
249
|
if (handed !== undefined) return handed;
|
|
223
250
|
|
|
251
|
+
// Composite instances joined to components (#2662) are their own document.
|
|
252
|
+
if (args.composites) {
|
|
253
|
+
if (args.intent !== undefined || args.kind !== undefined) {
|
|
254
|
+
console.error(formatError({ message: "--composites takes neither --intent nor --kind", hint: USAGE }));
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
if (!args.dryRun) return (await import("./composites")).runWorkspaceComposites(ctx, cwd);
|
|
258
|
+
}
|
|
259
|
+
|
|
224
260
|
// The intent graph over one region (#2651) is its own document.
|
|
225
261
|
if (args.intent !== undefined) return (await import("./intent-cli")).runWorkspaceIntent(ctx, cwd);
|
|
226
262
|
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
* (#2651): the intent graph over one region (`intent.ts`), printed as JSON
|
|
4
4
|
* with `--json` or as a walk, one line per node, in the order of #2650
|
|
5
5
|
* section B: the region, its decisions, their artifacts, the commits, and the
|
|
6
|
-
* findings.
|
|
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). Without `--kind`, the walk reads every
|
|
9
|
+
* record kind the declaration names (#2680).
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import { resolve } from "node:path";
|
|
10
13
|
import { formatError } from "../cli/format";
|
|
11
14
|
import type { CommandContext } from "../cli/registry";
|
|
12
|
-
import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
|
|
15
|
+
import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode, type WorkNode } from "./intent";
|
|
13
16
|
|
|
14
17
|
const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
|
|
15
18
|
|
|
@@ -25,6 +28,28 @@ function ofKind<K extends IntentNode["kind"]>(doc: Result, kind: K): Extract<Int
|
|
|
25
28
|
|
|
26
29
|
const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 8) : "none");
|
|
27
30
|
|
|
31
|
+
/** 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). */
|
|
32
|
+
export const IN_WINDOW_QUESTION = "is this drift, a superseding decision nobody wrote down, or the decision being wrong?";
|
|
33
|
+
|
|
34
|
+
/** The commits inside a decision's window, under its line, then the question once when any is not its own work. */
|
|
35
|
+
function withinLines(doc: Result, d: DecisionNode): string[] {
|
|
36
|
+
const within = doc.edges.filter((e): e is Extract<IntentEdge, { kind: "within" }> => e.kind === "within" && e.to === d.id);
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const e of within) {
|
|
39
|
+
const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
|
|
40
|
+
if (!c) continue;
|
|
41
|
+
const unit = edgesFrom(doc, c.id, "produced-by")
|
|
42
|
+
.map((p) => doc.nodes.find((n) => n.id === p.to))
|
|
43
|
+
.map((n) => (n && "ref" in n ? n.ref : undefined))
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
const by = unit.length > 0 ? `unit ${unit.join(", ")}` : "no unit";
|
|
46
|
+
const label = e.state === "decided" ? "decided " : "within ";
|
|
47
|
+
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`}`);
|
|
48
|
+
}
|
|
49
|
+
if (within.some((e) => e.state === "decided-by-window")) out.push(` ask ${IN_WINDOW_QUESTION}`);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
function decisionLine(d: DecisionNode): string {
|
|
29
54
|
const via = d.constrains.length > 0 ? d.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through supersession only";
|
|
30
55
|
const by = d.decided_by ? `, decided by ${d.decided_by}${d.decided_on ? ` on ${d.decided_on}` : ""}` : "";
|
|
@@ -33,6 +58,22 @@ function decisionLine(d: DecisionNode): string {
|
|
|
33
58
|
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
59
|
}
|
|
35
60
|
|
|
61
|
+
/** A work item, then the commits made inside its window (#2683). */
|
|
62
|
+
function workLines(doc: Result, w: WorkNode): string[] {
|
|
63
|
+
const via = w.constrains.length > 0 ? w.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through a link only";
|
|
64
|
+
const implemented = w.implements.length > 0 ? `; implements ${w.implements.map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ")}` : "";
|
|
65
|
+
const readiness = w.ready ? "; ready" : w.blockedBy.length > 0 ? `; blocked by ${w.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ")}` : "";
|
|
66
|
+
const owner = w.owner ? `, owned by ${w.owner}` : "";
|
|
67
|
+
const from = w.source ? `; from ${w.source.finding} on ${w.source.region}` : "";
|
|
68
|
+
const out = [`work ${w.record} ${w.state ?? "stateless"}${owner}: ${w.title ?? w.path}; constrains ${via}${implemented}${readiness}${from}`];
|
|
69
|
+
for (const e of doc.edges.filter((x) => x.kind === "within" && x.to === w.id)) {
|
|
70
|
+
const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
|
|
71
|
+
if (c) out.push(` worked ${short(c.sha)} ${c.subject}; in ${w.record}'s window`);
|
|
72
|
+
}
|
|
73
|
+
for (const x of w.warnings) out.push(` warning ${x.code}: ${x.message}`);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
function artifactLine(doc: Result, a: ArtifactNode): string {
|
|
37
78
|
const by = doc.edges
|
|
38
79
|
.filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
|
|
@@ -63,17 +104,22 @@ export function formatIntent(doc: Result): string {
|
|
|
63
104
|
}
|
|
64
105
|
const files = ofKind(doc, "file");
|
|
65
106
|
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));
|
|
107
|
+
for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
|
|
108
|
+
for (const w of ofKind(doc, "work")) out.push(...workLines(doc, w));
|
|
67
109
|
for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
|
|
68
110
|
for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
|
|
69
111
|
for (const l of ofKind(doc, "link")) {
|
|
70
112
|
const r = l.row;
|
|
71
113
|
out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
|
|
72
114
|
}
|
|
73
|
-
for (const f of ofKind(doc, "finding"))
|
|
115
|
+
for (const f of ofKind(doc, "finding")) {
|
|
116
|
+
const by = f.addressedBy && f.addressedBy.length > 0 ? `; addressed by ${f.addressedBy.map((w) => `${w.id} (${w.state ?? "unknown"})`).join(", ")}` : "";
|
|
117
|
+
out.push(`finding ${f.code}: ${f.message}${by}`);
|
|
118
|
+
}
|
|
74
119
|
for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
|
|
75
120
|
const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
|
|
76
|
-
|
|
121
|
+
const work = ofKind(doc, "work").length;
|
|
122
|
+
out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${work > 0 ? `${work} work ${work === 1 ? "item" : "items"}, ` : ""}${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
|
|
77
123
|
return out.join("\n");
|
|
78
124
|
}
|
|
79
125
|
|
|
@@ -83,8 +129,9 @@ export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Prom
|
|
|
83
129
|
console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
|
|
84
130
|
return 1;
|
|
85
131
|
}
|
|
86
|
-
|
|
87
|
-
const
|
|
132
|
+
// No --kind: undefined, so the walk reads the kinds the declaration names (#2680).
|
|
133
|
+
const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : undefined);
|
|
134
|
+
const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds?.map((k) => resolve(k)) });
|
|
88
135
|
if (args.json) console.log(JSON.stringify(doc, null, 2));
|
|
89
136
|
if ("error" in doc) {
|
|
90
137
|
console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit-join hook's data form and its export checks, with no git
|
|
3
|
+
* (#2651, #2663).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { joinByData, readCommitJoins, trailerValue, trailerValues, type CommitJoinContext, type IntentCommit } from "./intent-joins";
|
|
8
|
+
|
|
9
|
+
const commit = (trailers: Record<string, string[]>): IntentCommit => ({ sha: "a".repeat(40), subject: "s", body: "", author: { name: "t", email: "t@example.com" }, date: "2026-09-24T00:00:00Z", trailers });
|
|
10
|
+
|
|
11
|
+
const files: Record<string, string> = {
|
|
12
|
+
"evidence/E-1.json": JSON.stringify({ id: "ignored", kind: "test-run" }),
|
|
13
|
+
"evidence/E-3.json": JSON.stringify({ kind: "review" }),
|
|
14
|
+
"units/U-1.json": JSON.stringify({ role: "implement" }),
|
|
15
|
+
};
|
|
16
|
+
const context: CommitJoinContext = { read: (p) => files[p], list: () => undefined, at: null };
|
|
17
|
+
|
|
18
|
+
describe("the data form reads every trailer value (#2663)", () => {
|
|
19
|
+
test("each value of the evidence trailer, under any case of its key, is one piece of evidence", () => {
|
|
20
|
+
const data = { trailers: { unit: "Unit", evidence: "Evidence" }, records: { unit: "units/{id}.json", evidence: "evidence/{id}.json" } };
|
|
21
|
+
const out = joinByData(data, commit({ Unit: ["U-1"], Evidence: ["E-1", " E-2 ", "E-1"], evidence: ["E-3", ""] }), context);
|
|
22
|
+
expect(out).toEqual({
|
|
23
|
+
unit: { id: "U-1", role: "implement" },
|
|
24
|
+
evidence: [
|
|
25
|
+
{ id: "E-1", kind: "test-run" },
|
|
26
|
+
{ id: "E-2" },
|
|
27
|
+
{ id: "E-3", kind: "review" },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a unit or contract trailer gives its first value, since a commit has one of each", () => {
|
|
33
|
+
const out = joinByData({ trailers: { unit: "Unit", contract: "Contract" } }, commit({ Unit: ["U-1", "U-2"], contract: ["C-1"], Contract: ["C-2"] }), context);
|
|
34
|
+
expect(out.unit).toEqual({ id: "U-1" });
|
|
35
|
+
expect(out.contract?.id).toBe(trailerValue({ contract: ["C-1"], Contract: ["C-2"] }, "Contract"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("trailerValues keeps git's order, trims, and drops empty values and repeats", () => {
|
|
39
|
+
expect(trailerValues({ "Chud-Evidence": ["h1", "h2"], "chud-evidence": ["h2", " h3", " "] }, "CHUD-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
|
|
40
|
+
expect(trailerValues({}, "X")).toEqual([]);
|
|
41
|
+
expect(trailerValue({ X: [" "] }, "x")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("commitJoinsName (#2663)", () => {
|
|
46
|
+
test("names the findings of either form, and leaves the data form's own keys alone", () => {
|
|
47
|
+
const join = () => undefined;
|
|
48
|
+
expect(readCommitJoins({ commitJoins: join, commitJoinsName: "chud" })).toEqual({ form: "function", join, name: "chud" });
|
|
49
|
+
expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "chud" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "chud" });
|
|
50
|
+
expect(readCommitJoins({ commitJoins: join })).toEqual({ form: "function", join });
|
|
51
|
+
// A name inside the data form is still a key the data form does not have.
|
|
52
|
+
expect(readCommitJoins({ commitJoins: { name: "chud", trailers: {} } })).toMatch(/Unrecognized key/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a name that can't be a plugin:<name>: segment, or one with no joins, is refused", () => {
|
|
56
|
+
for (const bad of ["a:b", "a b", "", 7]) expect(readCommitJoins({ commitJoins: () => undefined, commitJoinsName: bad }), String(bad)).toMatch(/^commitJoinsName:/);
|
|
57
|
+
expect(readCommitJoins({ commitJoinsName: "chud" })).toMatch(/no commitJoins/);
|
|
58
|
+
expect(readCommitJoins({})).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -9,18 +9,41 @@
|
|
|
9
9
|
*
|
|
10
10
|
* - A function `commitJoins(commit, context)` that returns the unit, contract
|
|
11
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`
|
|
13
|
-
*
|
|
12
|
+
* subject, body, author, date and trailers, and a context whose `read(path)`
|
|
13
|
+
* returns a file and whose `list(dir)` lists a directory, both in the tree
|
|
14
|
+
* read (#2663). With `list` a plugin can find the record that names a
|
|
15
|
+
* commit, such as a unit record whose `result.commit` is the commit's sha,
|
|
16
|
+
* so the commit needs no trailer at all.
|
|
14
17
|
* - Data, which core interprets with no plugin code: the trailer keys that
|
|
15
18
|
* 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.
|
|
19
|
+
* (`units/{id}.json`), and the trailer keys that claim authorship. Every
|
|
20
|
+
* value of the evidence trailer is read; a unit or contract trailer gives
|
|
21
|
+
* its first value, since a commit has one unit and one contract.
|
|
17
22
|
*
|
|
18
23
|
* Either way core never parses a plugin's own trailer or record format: it
|
|
19
24
|
* reads the trailers git reports and hands them over, and a key means
|
|
20
25
|
* something only because a kind file said so.
|
|
26
|
+
*
|
|
27
|
+
* Two parts of a join's answer carry meaning core acts on (#2656). A unit or
|
|
28
|
+
* contract may list `decisions`, the ids of the decision records it carries
|
|
29
|
+
* out; a commit whose unit or contract names a decision is that decision's own
|
|
30
|
+
* work. The function form may also return `findings`, each a code in the
|
|
31
|
+
* kind's own namespace (`plugin:<name>:<code>`), a message and the refs it is
|
|
32
|
+
* about, which the graph carries as finding nodes. That is how a plugin says
|
|
33
|
+
* what it knows and core does not, such as a contract whose criteria changed
|
|
34
|
+
* in a commit that names no decision.
|
|
35
|
+
*
|
|
36
|
+
* `<name>` is the kind's name by default: its record kind's name, or the
|
|
37
|
+
* file's name without `.kind.mjs`. A kind file may name its findings itself
|
|
38
|
+
* with a sibling export, `commitJoinsName` (#2663), so joins that live beside
|
|
39
|
+
* a record kind called `decision` can still report `plugin:chud:<code>`. It is
|
|
40
|
+
* a sibling export rather than a property because the data form is a closed
|
|
41
|
+
* object whose keys are all joins, and a function's `name` property is
|
|
42
|
+
* already its own name.
|
|
21
43
|
*/
|
|
22
44
|
|
|
23
45
|
import { z } from "zod";
|
|
46
|
+
import { isPluginCode } from "./reason-codes";
|
|
24
47
|
|
|
25
48
|
/** A commit as the hook sees it. */
|
|
26
49
|
export interface IntentCommit {
|
|
@@ -37,16 +60,41 @@ export interface IntentCommit {
|
|
|
37
60
|
export interface CommitJoinContext {
|
|
38
61
|
/** The text of a file from the workspace root, in the tree read; undefined when it is not a file there. */
|
|
39
62
|
read(path: string): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The entries directly inside directory `dir`, in the same tree as `read`
|
|
65
|
+
* (#2663): paths from the workspace root, sorted, a directory's with a
|
|
66
|
+
* trailing `/`. `""` or `"."` is the workspace root. Undefined when `dir` is
|
|
67
|
+
* not a directory there.
|
|
68
|
+
*
|
|
69
|
+
* The tree is the one the graph reads, the commit `--at` names or the
|
|
70
|
+
* working tree, and not each joined commit's own tree: a record that names
|
|
71
|
+
* a commit's sha was written after that commit, so it is only in a later
|
|
72
|
+
* tree.
|
|
73
|
+
*/
|
|
74
|
+
list(dir: string): string[] | undefined;
|
|
40
75
|
/** The full commit id read with `--at`, or null for the working tree. */
|
|
41
76
|
at: string | null;
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* A plugin's unit, contract or evidence: an id and whatever fields the plugin
|
|
81
|
+
* records. On a unit or contract, `decisions`, a list of record ids, names the
|
|
82
|
+
* decisions it carries out (#2656).
|
|
83
|
+
*/
|
|
45
84
|
export interface JoinedEntity {
|
|
46
85
|
id: string;
|
|
47
86
|
[field: string]: unknown;
|
|
48
87
|
}
|
|
49
88
|
|
|
89
|
+
/** A finding a plugin contributes for one commit (#2656). */
|
|
90
|
+
export interface PluginFinding {
|
|
91
|
+
/** `plugin:<name>:<code>`, where `<name>` is the kind file's `commitJoinsName`, or else the kind's name. */
|
|
92
|
+
code: string;
|
|
93
|
+
message: string;
|
|
94
|
+
/** What the finding is about: node ids, commit shas, record ids, unit, contract or evidence ids, or paths. */
|
|
95
|
+
refs?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
/** What a join says about one commit. Every part is optional. */
|
|
51
99
|
export interface CommitJoin {
|
|
52
100
|
/** The unit of work that produced the commit. */
|
|
@@ -57,6 +105,8 @@ export interface CommitJoin {
|
|
|
57
105
|
evidence?: JoinedEntity | JoinedEntity[];
|
|
58
106
|
/** Trailer keys on this commit that claim who wrote it, which the plugin vouches for. */
|
|
59
107
|
authorship?: string[];
|
|
108
|
+
/** Findings about this commit, in the kind's own code namespace. Function form only. */
|
|
109
|
+
findings?: PluginFinding[];
|
|
60
110
|
}
|
|
61
111
|
|
|
62
112
|
export type CommitJoinsFunction = (commit: IntentCommit, context: CommitJoinContext) => CommitJoin | null | undefined | Promise<CommitJoin | null | undefined>;
|
|
@@ -78,24 +128,49 @@ export const commitJoinsDataSchema = z
|
|
|
78
128
|
|
|
79
129
|
export type CommitJoinsData = z.infer<typeof commitJoinsDataSchema>;
|
|
80
130
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
131
|
+
/**
|
|
132
|
+
* A kind file's `commitJoins` export, checked: a function, or data. `name` is
|
|
133
|
+
* its `commitJoinsName` export, when it has one: the namespace of its findings.
|
|
134
|
+
*/
|
|
135
|
+
export type CommitJoins = ({ form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData }) & { name?: string };
|
|
136
|
+
|
|
137
|
+
/** What a `commitJoinsName` may be: the `<name>` of `plugin:<name>:<code>`. */
|
|
138
|
+
const JOINS_NAME = /^[^:\s]+$/;
|
|
83
139
|
|
|
84
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Read a kind module's `commitJoins` export and its `commitJoinsName`.
|
|
142
|
+
* Undefined when it has no `commitJoins`; a string when either is malformed,
|
|
143
|
+
* or when it names findings it has no joins for.
|
|
144
|
+
*/
|
|
85
145
|
export function readCommitJoins(mod: Record<string, unknown>): CommitJoins | string | undefined {
|
|
86
146
|
const value = mod.commitJoins;
|
|
87
|
-
|
|
88
|
-
if (
|
|
147
|
+
const name = mod.commitJoinsName;
|
|
148
|
+
if (value === undefined) return name === undefined ? undefined : "commitJoinsName: a kind file with no commitJoins has no findings to name";
|
|
149
|
+
if (name !== undefined && (typeof name !== "string" || !JOINS_NAME.test(name))) return "commitJoinsName: a string with no colon or space, the <name> of plugin:<name>:<code>";
|
|
150
|
+
const named = name === undefined ? {} : { name: name as string };
|
|
151
|
+
if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction, ...named };
|
|
89
152
|
const parsed = commitJoinsDataSchema.safeParse(value);
|
|
90
153
|
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 };
|
|
154
|
+
return { form: "data", data: parsed.data, ...named };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every value of trailer `key`, trimmed, without empty ones or repeats, compared without case as git does. */
|
|
158
|
+
export function trailerValues(trailers: Record<string, string[]>, key: string): string[] {
|
|
159
|
+
const want = key.toLowerCase();
|
|
160
|
+
const out: string[] = [];
|
|
161
|
+
for (const [k, values] of Object.entries(trailers)) {
|
|
162
|
+
if (k.toLowerCase() !== want) continue;
|
|
163
|
+
for (const v of values) {
|
|
164
|
+
const t = v.trim();
|
|
165
|
+
if (t && !out.includes(t)) out.push(t);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
92
169
|
}
|
|
93
170
|
|
|
94
171
|
/** The first value of trailer `key`, compared without case as git does. */
|
|
95
172
|
export function trailerValue(trailers: Record<string, string[]>, key: string): string | undefined {
|
|
96
|
-
|
|
97
|
-
for (const [k, values] of Object.entries(trailers)) if (k.toLowerCase() === want && values.length > 0) return values[0].trim() || undefined;
|
|
98
|
-
return undefined;
|
|
173
|
+
return trailerValues(trailers, key)[0];
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
/** Whether the commit carries trailer `key`. */
|
|
@@ -122,21 +197,32 @@ function recordFields(template: string | undefined, id: string, context: CommitJ
|
|
|
122
197
|
/** Interpret the data form for one commit. Throws when a named record can't be read as JSON. */
|
|
123
198
|
export function joinByData(data: CommitJoinsData, commit: IntentCommit, context: CommitJoinContext): CommitJoin {
|
|
124
199
|
const out: CommitJoin = {};
|
|
125
|
-
|
|
200
|
+
const entity = (part: "unit" | "contract" | "evidence", id: string): JoinedEntity => ({ ...recordFields(data.records?.[part], id, context), id });
|
|
201
|
+
for (const part of ["unit", "contract"] as const) {
|
|
126
202
|
const key = data.trailers[part];
|
|
127
203
|
const id = key ? trailerValue(commit.trailers, key) : undefined;
|
|
128
|
-
if (
|
|
129
|
-
const entity: JoinedEntity = { ...recordFields(data.records?.[part], id, context), id };
|
|
130
|
-
if (part === "evidence") out.evidence = entity;
|
|
131
|
-
else out[part] = entity;
|
|
204
|
+
if (id) out[part] = entity(part, id);
|
|
132
205
|
}
|
|
206
|
+
// Each value of the evidence trailer is one piece of evidence (#2663).
|
|
207
|
+
const evidence = data.trailers.evidence ? trailerValues(commit.trailers, data.trailers.evidence) : [];
|
|
208
|
+
if (evidence.length > 0) out.evidence = evidence.map((id) => entity("evidence", id));
|
|
133
209
|
const claimed = (data.authorship ?? []).filter((k) => hasTrailer(commit.trailers, k));
|
|
134
210
|
if (claimed.length > 0) out.authorship = claimed;
|
|
135
211
|
return out;
|
|
136
212
|
}
|
|
137
213
|
|
|
138
|
-
/**
|
|
139
|
-
export
|
|
214
|
+
/** The record ids a unit or contract says it carries out: its `decisions` field, when that is a list of strings. */
|
|
215
|
+
export function entityDecisions(entity: JoinedEntity | undefined): string[] {
|
|
216
|
+
const list = entity?.decisions;
|
|
217
|
+
return Array.isArray(list) ? list.filter((d): d is string => typeof d === "string" && d !== "") : [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run a kind's joins for one commit, checking what a function returns.
|
|
222
|
+
* `name` is the namespace its findings' codes must use: the kind file's
|
|
223
|
+
* `commitJoinsName`, or else the kind's name.
|
|
224
|
+
*/
|
|
225
|
+
export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext, name: string): Promise<CommitJoin> {
|
|
140
226
|
if (joins.form === "data") return joinByData(joins.data, commit, context);
|
|
141
227
|
const result = await joins.join(commit, context);
|
|
142
228
|
if (result === null || result === undefined) return {};
|
|
@@ -161,5 +247,16 @@ export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, c
|
|
|
161
247
|
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
248
|
out.authorship = result.authorship;
|
|
163
249
|
}
|
|
250
|
+
if (result.findings !== undefined && result.findings !== null) {
|
|
251
|
+
if (!Array.isArray(result.findings)) throw new Error("commitJoins returned findings that are not a list");
|
|
252
|
+
out.findings = result.findings.map((f: unknown) => {
|
|
253
|
+
if (f === null || typeof f !== "object" || Array.isArray(f)) throw new Error("commitJoins returned a finding that is not an object");
|
|
254
|
+
const { code, message, refs } = f as Record<string, unknown>;
|
|
255
|
+
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`);
|
|
256
|
+
if (typeof message !== "string" || message === "") throw new Error(`commitJoins returned the finding ${code} with no message`);
|
|
257
|
+
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`);
|
|
258
|
+
return { code, message, refs: (refs as string[] | undefined) ?? [] };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
164
261
|
return out;
|
|
165
262
|
}
|