@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.
Files changed (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -43,6 +43,11 @@
43
43
  "integrity": { "type": ["string", "null"] }
44
44
  }
45
45
  }
46
+ },
47
+ "records": {
48
+ "description": "Added in contract 1 by #2680. The workspace's own record kinds, from the declaration's top-level records, in file order.",
49
+ "type": "array",
50
+ "items": { "$ref": "#/$defs/recordKind" }
46
51
  }
47
52
  }
48
53
  },
@@ -89,6 +94,11 @@
89
94
  "upstream": { "type": ["string", "null"] },
90
95
  "because": { "type": ["string", "null"] },
91
96
  "readable": { "type": "boolean", "description": "True exactly when reason is null." },
97
+ "records": {
98
+ "description": "Added in contract 1 by #2680. The record kinds the member declares, in file order. A kind that can't be loaded does not make the member unreadable.",
99
+ "type": "array",
100
+ "items": { "$ref": "#/$defs/recordKind" }
101
+ },
92
102
  "reason": {
93
103
  "oneOf": [
94
104
  { "type": "null" },
@@ -107,6 +117,30 @@
107
117
  "then": { "type": "object", "properties": { "reason": { "type": "null" } } },
108
118
  "else": { "type": "object", "properties": { "reason": { "type": "object" } } }
109
119
  },
120
+ "recordKind": {
121
+ "description": "A record kind the declaration names (#2680). chant workspace records and graph --intent read every one of them when --kind is not given.",
122
+ "type": "object",
123
+ "required": ["name", "path", "kind", "reason"],
124
+ "properties": {
125
+ "name": { "type": ["string", "null"], "description": "The name the declaration gives the kind, else the kind file's own recordKind.name, else null when the file can't be loaded." },
126
+ "path": { "type": "string", "description": "The kind file, relative to the workspace root, with / separators." },
127
+ "kind": { "type": ["string", "null"], "description": "The kind file's recordKind.name, or null when it can't be loaded." },
128
+ "reason": {
129
+ "description": "Why the kind file can't be loaded, or null. The file is looked for in the tree read, the revision under --at, and loaded from the working tree.",
130
+ "oneOf": [
131
+ { "type": "null" },
132
+ {
133
+ "type": "object",
134
+ "required": ["code", "message"],
135
+ "properties": {
136
+ "code": { "enum": ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch"] },
137
+ "message": { "type": "string" }
138
+ }
139
+ }
140
+ ]
141
+ }
142
+ }
143
+ },
110
144
  "group": {
111
145
  "type": "object",
112
146
  "required": ["name", "kind", "glob", "matches", "skipped", "reason"],
@@ -8,6 +8,11 @@
8
8
  *
9
9
  * The `--json` output is part of the read contract, versioned like
10
10
  * `workspace records` and described by `ls.schema.json` beside this file.
11
+ *
12
+ * The record kinds the declaration names are listed too, the workspace's own
13
+ * and each member's, with each kind file's own name when it loads (#2680).
14
+ * Loading one imports it, so {@link listWorkspace} lists them unloaded and
15
+ * {@link listWorkspaceWithKinds}, which the command runs, loads them.
11
16
  */
12
17
 
13
18
  import { existsSync } from "node:fs";
@@ -28,6 +33,7 @@ import type { ReasonCode } from "./reason-codes";
28
33
  import { loadKindRegistry, probeKind, type KindRegistry } from "./kinds";
29
34
  import type { WorkspaceTree } from "./tree";
30
35
  import { handToRootChant, locateWorkspace } from "./which-chant";
36
+ import type { DeclaredKindReasonCode } from "./declared-kinds";
31
37
 
32
38
  /** The version of the `ls` output this chant writes. */
33
39
  export const LS_CONTRACT_VERSION = 1;
@@ -58,6 +64,18 @@ export const GROUP_REASON_CODES = [
58
64
  ] as const satisfies readonly ReasonCode[];
59
65
  export type GroupReasonCode = (typeof GROUP_REASON_CODES)[number];
60
66
 
67
+ /** A record kind the declaration names (#2680). */
68
+ export interface LsRecordKind {
69
+ /** The name the declaration gives it, else the kind file's own, else null when the file wasn't loaded. */
70
+ name: string | null;
71
+ /** The kind file from the workspace root. */
72
+ path: string;
73
+ /** The kind file's `recordKind.name`, or null when it wasn't loaded. */
74
+ kind: string | null;
75
+ /** Why the kind file can't be loaded, or null. */
76
+ reason: { code: DeclaredKindReasonCode; message: string } | null;
77
+ }
78
+
61
79
  export interface LsMember {
62
80
  name: string;
63
81
  dir: string;
@@ -67,6 +85,8 @@ export interface LsMember {
67
85
  because: string | null;
68
86
  readable: boolean;
69
87
  reason: { code: MemberReasonCode; message: string } | null;
88
+ /** The record kinds the member declares, in file order (#2680). */
89
+ records: LsRecordKind[];
70
90
  }
71
91
 
72
92
  export interface LsGroup {
@@ -92,6 +112,8 @@ export type LsDocument =
92
112
  schema: number;
93
113
  minReader: string | null;
94
114
  pins: Declaration["pins"];
115
+ /** The workspace's own record kinds, from the top-level `records` (#2680). */
116
+ records: LsRecordKind[];
95
117
  };
96
118
  members: LsMember[];
97
119
  groups: LsGroup[];
@@ -126,8 +148,36 @@ export function memberReason(member: Member, tree: WorkspaceTree, kinds: KindReg
126
148
  return null;
127
149
  }
128
150
 
129
- /** Find the workspace, read it and build the document `--json` prints. Never throws a {@link WorkspaceReadError}. */
151
+ /**
152
+ * Find the workspace, read it and build the document `--json` prints, with
153
+ * the declared record kinds unloaded: `kind` null and no reason. Never throws
154
+ * a {@link WorkspaceReadError}.
155
+ */
130
156
  export function listWorkspace(query: LsQuery): LsDocument {
157
+ return readListing(query).doc;
158
+ }
159
+
160
+ /** {@link listWorkspace}, with each declared record kind loaded for its name, or the reason it can't be (#2680). */
161
+ export async function listWorkspaceWithKinds(query: LsQuery): Promise<LsDocument> {
162
+ const { doc, declaration, tree, rootOnDisk } = readListing(query);
163
+ if (!declaration || "error" in doc) return doc;
164
+ const { loadDeclaredKinds } = await import("./declared-kinds");
165
+ const loaded = await loadDeclaredKinds(declaration, tree!, rootOnDisk!);
166
+ const byPath = new Map(loaded.map((k) => [k.declared.path, k]));
167
+ const fill = (r: LsRecordKind): LsRecordKind => {
168
+ const k = byPath.get(r.path)!;
169
+ return { ...r, name: r.name ?? k.kind, kind: k.kind, reason: k.reason };
170
+ };
171
+ return {
172
+ ...doc,
173
+ workspace: { ...doc.workspace, records: doc.workspace.records.map(fill) },
174
+ members: doc.members.map((m) => ({ ...m, records: m.records.map(fill) })),
175
+ };
176
+ }
177
+
178
+ const unloaded = (records: Declaration["records"]): LsRecordKind[] => records.map((r) => ({ name: r.name, path: r.path, kind: null, reason: null }));
179
+
180
+ function readListing(query: LsQuery): { doc: LsDocument; declaration?: Declaration; tree?: WorkspaceTree; rootOnDisk?: string } {
131
181
  const chant = readerVersion();
132
182
  const head = { $schema: LS_OUTPUT_SCHEMA_ID, contract: LS_CONTRACT_VERSION, chant };
133
183
  try {
@@ -149,6 +199,7 @@ export function listWorkspace(query: LsQuery): LsDocument {
149
199
  because: m.because,
150
200
  readable: reason === null,
151
201
  reason,
202
+ records: unloaded(m.records),
152
203
  };
153
204
  });
154
205
  const lsGroups: LsGroup[] = groups.map((g) => ({
@@ -162,7 +213,7 @@ export function listWorkspace(query: LsQuery): LsDocument {
162
213
  ? { code: "no-matches", message: `no directory ${g.group.globs.join(" or ")} matches${tree.label} holds a chant project` }
163
214
  : null,
164
215
  }));
165
- return {
216
+ const doc: LsDocument = {
166
217
  ...head,
167
218
  at,
168
219
  workspace: {
@@ -172,6 +223,7 @@ export function listWorkspace(query: LsQuery): LsDocument {
172
223
  schema: declaration.schema,
173
224
  minReader: declaration.minReader,
174
225
  pins: declaration.pins,
226
+ records: unloaded(declaration.records),
175
227
  },
176
228
  members,
177
229
  groups: lsGroups,
@@ -182,9 +234,10 @@ export function listWorkspace(query: LsQuery): LsDocument {
182
234
  matches: lsGroups.reduce((n, g) => n + g.matches.length, 0),
183
235
  },
184
236
  };
237
+ return { doc, declaration, tree, rootOnDisk };
185
238
  } catch (err) {
186
239
  if (!(err instanceof WorkspaceReadError)) throw err;
187
- return { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } };
240
+ return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } } };
188
241
  }
189
242
  }
190
243
 
@@ -198,7 +251,7 @@ export async function runWorkspaceLs(ctx: CommandContext): Promise<number> {
198
251
  // The root's chant reads the declaration (ws-021).
199
252
  const handed = await handToRootChant(cwd, args.at);
200
253
  if (handed !== undefined) return handed;
201
- const doc = listWorkspace({ cwd, at: args.at });
254
+ const doc = await listWorkspaceWithKinds({ cwd, at: args.at });
202
255
  if (args.json) {
203
256
  console.log(JSON.stringify(doc, null, 2));
204
257
  } else if ("error" in doc) {
@@ -233,6 +286,18 @@ function formatLs(doc: Extract<LsDocument, { members: unknown }>): string {
233
286
  if (m.reason) lines.push(` ${m.reason.code}: ${m.reason.message}`);
234
287
  });
235
288
  }
289
+ const kinds = [...w.records.map((r) => ({ owner: "(workspace)", r })), ...doc.members.flatMap((m) => m.records.map((r) => ({ owner: m.name, r })))];
290
+ if (kinds.length > 0) {
291
+ lines.push("");
292
+ const rows = [["RECORDS", "KIND", "MEMBER", "FILE"]];
293
+ for (const { owner, r } of kinds) rows.push([r.name ?? "-", r.kind ?? "-", owner, r.path]);
294
+ const rendered = table(rows);
295
+ lines.push(rendered[0]);
296
+ kinds.forEach(({ r }, i) => {
297
+ lines.push(rendered[i + 1]);
298
+ if (r.reason) lines.push(` ${r.reason.code}: ${r.reason.message}`);
299
+ });
300
+ }
236
301
  if (doc.groups.length > 0) {
237
302
  lines.push("");
238
303
  const rows = [["GROUP", "KIND", "GLOB", "PROJECTS"]];
@@ -333,9 +333,12 @@ export function parseMemberRunOutput(stdout: string): { chant: string; results:
333
333
  return header ? { chant: header.chant, results, stray: stray.join("\n") } : undefined;
334
334
  }
335
335
 
336
- async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string, args: ParsedArgs): Promise<UnitResult[]> {
336
+ /** A command line to run in each member instead of the verb's own, such as `graph --components` (#2662). */
337
+ export type MemberArgv = (unit: RunUnit) => string[];
338
+
339
+ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string, args: ParsedArgs, argvFor?: MemberArgv): Promise<UnitResult[]> {
337
340
  const { toolchain, units } = group;
338
- const argvs = new Map(units.map((u) => [u.id, memberArgv(verb, u, args)]));
341
+ const argvs = new Map(units.map((u) => [u.id, argvFor ? argvFor(u) : memberArgv(verb, u, args)]));
339
342
  for (const u of units) {
340
343
  const o = argvs.get(u.id)!;
341
344
  const i = o.indexOf("--output");
@@ -365,9 +368,12 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
365
368
  return out;
366
369
  }
367
370
 
368
- /** Run every group of the plan, one process per toolchain at a time each, and return the results in plan order. */
369
- export async function executePlan(plan: MemberPlan, args: ParsedArgs): Promise<UnitResult[]> {
370
- const perGroup = await Promise.all(plan.groups.map((g) => runGroup(plan.verb, g, plan.workspace.root, args)));
371
+ /**
372
+ * Run every group of the plan, one process per toolchain at a time each, and
373
+ * return the results in plan order. `argvFor` replaces the verb's command line.
374
+ */
375
+ export async function executePlan(plan: MemberPlan, args: ParsedArgs, argvFor?: MemberArgv): Promise<UnitResult[]> {
376
+ const perGroup = await Promise.all(plan.groups.map((g) => runGroup(plan.verb, g, plan.workspace.root, args, argvFor)));
371
377
  const byId = new Map(perGroup.flat().map((r) => [r.id, r]));
372
378
  const order = plan.groups.flatMap((g) => g.units);
373
379
  return order.map((u) => byId.get(u.id)!);
@@ -6,8 +6,9 @@
6
6
  * `chant` member (`delivery`), three `other` members and decision records.
7
7
  * Each read-contract command reads it here, and its output must validate
8
8
  * against the command's schema: `ls`, `graph` (running delivery's real
9
- * `chant graph`), `check`, `status` and `records`, in the working tree and,
10
- * for `ls`, `graph` and `check`, at `HEAD` through `--at`.
9
+ * `chant graph`), `graph --composites`, `check`, `status`, `records` and
10
+ * `records --since`, in the working tree and, for `ls`, `graph` and `check`,
11
+ * at `HEAD` through `--at`.
11
12
  *
12
13
  * The schemas themselves are checked here too: each is a draft 2020-12
13
14
  * document under `https://intentius.io/chant/schemas/workspace/<command>/v1/`,
@@ -20,6 +21,8 @@ import { pathToFileURL } from "node:url";
20
21
  import { describe, expect, test } from "vitest";
21
22
  import { contract, git, REPO, validSchema } from "./__fixtures__/contract-repo";
22
23
  import checkSchema from "./check.schema.json";
24
+ import { workspaceComposites } from "./composites";
25
+ import compositesSchema from "./composites.schema.json";
23
26
  import { workspaceGraph } from "./graph-cli";
24
27
  import graphSchema from "./graph.schema.json";
25
28
  import { intentGraph } from "./intent";
@@ -31,13 +34,15 @@ import { readerBin, type Toolchain } from "./member-commands";
31
34
  import { READ_CONTRACT_FLOOR, READ_CONTRACT_VERSION } from "./reason-codes";
32
35
  import { queryRecords } from "./records-cli";
33
36
  import recordsSchema from "./records.schema.json";
37
+ import { queryRecordsSince } from "./records-since";
38
+ import recordsSinceSchema from "./records-since.schema.json";
34
39
  import { workspaceStatus } from "./status";
35
40
  import statusSchema from "./status.schema.json";
36
41
 
37
42
  const FIXTURE = join(REPO, "reference-workspace");
38
43
  const TIMEOUT = 240_000;
39
44
 
40
- const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema };
45
+ const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, "records-since": recordsSinceSchema, intent: intentSchema, composites: compositesSchema };
41
46
 
42
47
  /** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
43
48
  const reader: Toolchain = {
@@ -131,6 +136,36 @@ describe("every schema against the reference workspace (#2543)", () => {
131
136
  }
132
137
  });
133
138
 
139
+ test(
140
+ "graph --composites runs delivery's own component graph, and lists its app with the component that deploys it (#2662)",
141
+ async () => {
142
+ const { expectValid } = contract(compositesSchema);
143
+ for (const at of [undefined, "HEAD"]) {
144
+ const { doc, failed } = await workspaceComposites({ cwd: FIXTURE, at, reader });
145
+ expectValid(doc);
146
+ if ("error" in doc) throw new Error(doc.error.message);
147
+ expect(failed, JSON.stringify(doc.members)).toBe(false);
148
+ expect(doc.at).toBe(at ? head : null);
149
+ expect(doc.members.map((m) => [m.name, m.status])).toEqual([
150
+ ["app", "skipped"],
151
+ ["delivery", "read"],
152
+ ["design-client", "skipped"],
153
+ ["design", "skipped"],
154
+ ]);
155
+ // delivery declares the app as a DockerWebService, and its app component names that kind.
156
+ expect(doc.composites.map((c) => [c.id, c.kinds, c.components.map((m) => [m.component, m.by, m.via])])).toEqual([
157
+ ["delivery/app", ["DockerWebService"], [["delivery/app", "composites", "member"]]],
158
+ ]);
159
+ expect(doc.components.map((c) => [c.id, c.archetype])).toEqual([["delivery/app", "service"]]);
160
+ // #2674: the reference config's lexicons host no component runs, so local is the only runtime, read at HEAD too.
161
+ expect(doc.components[0].runtimes).toEqual([{ name: "local", lexicon: null, default: true, command: "chant run --components app" }]);
162
+ expect(doc.members.find((m) => m.name === "delivery")!.runtimeReasons).toEqual([]);
163
+ expect(doc.reasons).toEqual([]);
164
+ }
165
+ },
166
+ TIMEOUT,
167
+ );
168
+
134
169
  test("records, in the working tree and at HEAD", async () => {
135
170
  const { expectValid } = contract(recordsSchema);
136
171
  for (const at of [undefined, "HEAD"]) {
@@ -140,5 +175,19 @@ describe("every schema against the reference workspace (#2543)", () => {
140
175
  expect(doc.summary.invalid).toBe(0);
141
176
  expect(doc.records.map((r) => r.id)).toContain("ref-001");
142
177
  }
178
+ const sessions = await queryRecords({ kind: "design/sessions/session.kind.mjs", cwd: FIXTURE });
179
+ expectValid(sessions);
180
+ if ("error" in sessions) throw new Error(sessions.error.message);
181
+ expect(sessions.summary.invalid).toBe(0);
182
+ });
183
+
184
+ test("records --since HEAD, for decisions and sessions (#2673)", async () => {
185
+ const { expectValid } = contract(recordsSinceSchema);
186
+ for (const kind of ["decisions/decision.kind.mjs", "design/sessions/session.kind.mjs"]) {
187
+ const doc = await queryRecordsSince({ kind, since: "HEAD", cwd: FIXTURE });
188
+ expectValid(doc);
189
+ if ("error" in doc) throw new Error(doc.error.message);
190
+ expect(doc.since).toBe(head);
191
+ }
143
192
  });
144
193
  });
@@ -10,14 +10,20 @@ import { join } from "node:path";
10
10
  import { describe, expect, test } from "vitest";
11
11
  import { REPO } from "./__fixtures__/contract-repo";
12
12
  import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
13
+ import { COMPOSITES_ERROR_CODES, COMPOSITES_REASON_CODES, COMPOSITES_RUNTIME_REASON_CODES } from "./composites";
13
14
  import { WORKSPACE_ERROR_CODES } from "./declaration";
14
15
  import { GRAPH_ERROR_CODES } from "./graph-cli";
15
16
  import { INTENT_ERROR_CODES, INTENT_FINDING_CODES, INTENT_REASON_CODES } from "./intent";
16
17
  import { CHECK_CODES, CHECK_ERROR_CODES } from "./lineage-check";
17
18
  import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
18
- import { isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
19
- import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
20
- import { STATUS_ERROR_CODES, STATUS_REASON_CODES } from "./status";
19
+ import intentSchema from "./intent.schema.json";
20
+ import { isPluginCode, isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
21
+ import { contract } from "./__fixtures__/contract-repo";
22
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES } from "./records";
23
+ import { AMEND_ERROR_CODES, NEW_ERROR_CODES, REVIEW_ERROR_CODES } from "./records-write";
24
+ import { RECORDS_SINCE_ERROR_CODES } from "./records-since";
25
+ import { STATUS_ERROR_CODES, STATUS_GATE_REASON_CODES, STATUS_REASON_CODES } from "./status";
26
+ import { WORK_WARNING_CODES } from "./work";
21
27
 
22
28
  const HERE = import.meta.dirname;
23
29
 
@@ -31,12 +37,22 @@ const PER_COMMAND: Record<string, readonly string[]> = {
31
37
  CHECK_ERROR_CODES,
32
38
  STATUS_REASON_CODES,
33
39
  STATUS_ERROR_CODES,
40
+ STATUS_GATE_REASON_CODES,
34
41
  RECORD_REASON_CODES,
35
42
  RECORD_WARNING_CODES,
43
+ REVIEW_REASON_CODES,
44
+ WORK_WARNING_CODES,
36
45
  READ_ERROR_CODES,
46
+ NEW_ERROR_CODES,
47
+ AMEND_ERROR_CODES,
48
+ REVIEW_ERROR_CODES,
49
+ RECORDS_SINCE_ERROR_CODES,
37
50
  INTENT_ERROR_CODES,
38
51
  INTENT_FINDING_CODES,
39
52
  INTENT_REASON_CODES,
53
+ COMPOSITES_ERROR_CODES,
54
+ COMPOSITES_REASON_CODES,
55
+ COMPOSITES_RUNTIME_REASON_CODES,
40
56
  };
41
57
 
42
58
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */
@@ -98,7 +114,7 @@ describe("the closed list of reason codes", () => {
98
114
  });
99
115
 
100
116
  test("no source file emits a code outside the list", () => {
101
- const emitted = /(?:\bcode:\s*|(?:WorkspaceReadError|RecordReadError|StatusError|IntentError)\(\s*)"([a-z0-9-]+)"/g;
117
+ const emitted = /(?:\bcode:\s*|(?:WorkspaceReadError|RecordReadError|RecordWriteError|StatusError|IntentError)\(\s*)"([a-z0-9-]+)"/g;
102
118
  let seen = 0;
103
119
  for (const file of sourceFiles(HERE)) {
104
120
  const text = readFileSync(file, "utf-8");
@@ -111,6 +127,34 @@ describe("the closed list of reason codes", () => {
111
127
  expect(seen).toBeGreaterThan(30);
112
128
  });
113
129
 
130
+ test("a plugin's finding codes are in its own namespace, outside the list, and the intent schema accepts them (#2656)", () => {
131
+ expect(isPluginCode("plugin:chud:contract-criteria-changed")).toBe(true);
132
+ expect(isPluginCode("plugin:chud:contract-criteria-changed", "chud")).toBe(true);
133
+ expect(isPluginCode("plugin:chud:contract-criteria-changed", "units")).toBe(false);
134
+ for (const bad of ["plugin:chud", "plugin::x", "plugin:chud:Upper", "plugin:chud:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
135
+ expect(isReasonCode("plugin:chud:contract-criteria-changed")).toBe(false);
136
+ const { validate } = contract(intentSchema);
137
+ const finding = (code: string) => ({ id: `finding:${code}:1`, kind: "finding", code, message: "m", concerns: [] });
138
+ const doc = (code: string) => ({
139
+ $schema: intentSchema.$id,
140
+ contract: 1,
141
+ chant: "0.0.0",
142
+ at: null,
143
+ workspace: { name: "w", root: "." },
144
+ region: "region:.",
145
+ history: { rev: null, follows: "directory", shallow: false },
146
+ kinds: [],
147
+ nodes: [finding(code)],
148
+ edges: [],
149
+ reasons: [],
150
+ summary: { commits: 0, decisions: 0, artifacts: 0, findings: 1 },
151
+ });
152
+ expect(validate(doc("plugin:chud:contract-criteria-changed"))).toBe(true);
153
+ expect(validate(doc("intent-commit-bare"))).toBe(true);
154
+ expect(validate(doc("plugin:chud:Nope"))).toBe(false);
155
+ expect(validate(doc("made-up-code"))).toBe(false);
156
+ });
157
+
114
158
  test("the read-contract page documents every code", () => {
115
159
  const page = readFileSync(join(REPO, "docs", "src", "content", "docs", "reference", "workspace-read-contract.mdx"), "utf-8");
116
160
  for (const c of REASON_CODES) expect(page, c).toContain(`| \`${c}\` |`);
@@ -53,17 +53,40 @@ export const REASONS = {
53
53
  // A ledger status can't fully read.
54
54
  "ledger-unreadable": "Reading the ledger failed, so nothing from it is listed.",
55
55
  "ledger-malformed": "Some lines of the ledger aren't release records; the rest are listed.",
56
+ // A member's gates status can't list (status, #2674).
57
+ "gates-no-ledger": "The checkout has no chant/lifecycle branch, so there is no gate ledger to read.",
58
+ "gates-no-gate-ledger": "The branch has no gate ledger for the member: no run of it has reached a gate.",
59
+ "gates-ledger-unreadable": "Reading the member's gate ledger failed, so no gate is listed.",
56
60
  // A record that isn't valid (records).
57
- "record-unparseable": "No front matter, a YAML error, or a value outside the JSON subset of YAML.",
58
- "record-schema-invalid": "The front matter does not match the kind's schema.",
61
+ "record-unparseable": "No front matter, a YAML error or a value outside the JSON subset of YAML, or for a JSON kind a file that is not one object or repeats a member name.",
62
+ "record-schema-invalid": "The record's front matter, or its JSON object, does not match the kind's schema.",
59
63
  "record-id-duplicate": "Another record earlier in path order has the same id.",
60
64
  "record-supersedes-unknown": "A supersedes link names an id no record has.",
61
65
  "record-supersedes-conflict": "A second closed record supersedes a record another one already superseded.",
66
+ // A review session that isn't valid (records, #2673).
67
+ "session-seal-mismatch": "A closed session's seal is not the digest of its text: the session changed after it closed.",
68
+ "session-verdict-unknown-record": "A session's verdict names a record that none of the session kind's subject records has.",
62
69
  // A record that is valid but warned about (records, #2549).
63
70
  "asset-drift": "A file the record pins by hash has changed: its bytes no longer hash to the pinned sha256.",
64
71
  "asset-missing": "A file the record pins by hash does not exist in the tree read.",
65
72
  "asset-stale": "A file the record pins is unchanged at the hash a record it supersedes pinned: the decision changed and the artifact did not follow.",
66
73
  "record-supersedes-pending": "A supersedes link from a record whose state is weaker than the record it names, so the link has no effect yet.",
74
+ "record-no-evidence": "The record's evidence list is empty: it cites nothing and pins no file. Information for a reviewer, never an error.",
75
+ "review-undigested": "A verdict names no digest of the text it judged. It still counts, and an amendment does not stop it counting.",
76
+ // A work record that is valid but warned about (records and graph --intent, #2683).
77
+ "work-needs-unknown": "A work record's needs list names a work id no record has, so the item stays blocked.",
78
+ "work-implements-unknown": "A work record's implements list names a decision id no decision has.",
79
+ "work-needs-cycle": "A work record needs itself through its needs links, so it can never be ready.",
80
+ "work-implements-undecided": "A work record implements a decision whose state is not approved, such as proposed.",
81
+ "work-done-unpinned": "A work record is done and its evidence list is empty: nothing shows the work was done.",
82
+ "work-closed-without-date": "A work record is done or dropped and has no closing date.",
83
+ "work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. Only graph --intent raises it.",
84
+ // A verdict the quorum does not count (records, #2671, #2672).
85
+ "review-decider": "The verdict is the decider's own, and the quorum counts verdicts besides the decider's.",
86
+ "review-agent": "The reviewer holds the agent role in the trust policy at base.",
87
+ "review-duplicate": "A later verdict by the same principal replaces this one. Names are compared after NFKC, trimming and lower-casing.",
88
+ "review-older-digest": "The verdict names a digest other than the record's text now: the record changed after the verdict.",
89
+ "review-unattested": "An attestation policy is active at base, and the verdict carries no seal.",
67
90
  // A records read that fails (records).
68
91
  "kind-unreadable": "The record kind file is missing or could not be imported.",
69
92
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -71,6 +94,20 @@ export const REASONS = {
71
94
  "schema-id-mismatch": "The schema's $id differs from the id the record kind names.",
72
95
  "schema-invalid": "The record schema itself does not compile.",
73
96
  "location-missing": "The records directory does not exist, in the tree or at the revision.",
97
+ // A records write that is refused (records new, amend and review, #2670). Nothing is written.
98
+ "write-usage-invalid": "The command line lacks a value the write needs, or gives one it does not take.",
99
+ "write-input-invalid": "The fields given with --from or --set can't be read, are not JSON, or are not a JSON object.",
100
+ "record-not-found": "No record of the kind has the id given.",
101
+ "record-id-taken": "The id given for a new record is already used, by a record or a file name.",
102
+ "record-id-unallocatable": "No id was given and none can be allocated: the records share no single prefix and --prefix names none.",
103
+ "record-path-unmatched": "The file name made from the record's id and title does not match the kind's location.",
104
+ "record-closed": "The record is in a closed state, so nothing in it changes; a new record supersedes it instead.",
105
+ "amend-id-immutable": "An amendment changes the record's id, and ids are never renumbered.",
106
+ "amend-supersede-instead": "The record is approved, and the amendment changes a field the approval rule does not let change in place; a new record supersedes it instead.",
107
+ "review-unsupported": "The kind's schema has no reviews field, so its records take no review.",
108
+ "review-note-required": "A dissent was given with no note: a dissent needs a reason.",
109
+ // records --since that fails (#2673).
110
+ "since-rev-unknown": "--since names no commit.",
74
111
  // The intent graph (graph --intent, #2651): a read that fails.
75
112
  "intent-region-invalid": "The region's path, or its line range, does not exist in the tree read.",
76
113
  // The intent graph: part of the walk that can't be read. The document is still printed.
@@ -89,6 +126,16 @@ export const REASONS = {
89
126
  "intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
90
127
  "intent-trailer-unverified": "A commit carries a trailer a plugin says claims authorship, and the commit is not attested.",
91
128
  "intent-region-unconstrained": "No decision constrains the region at any granularity.",
129
+ "intent-decision-unimplemented": "A decided decision constrains the region, no work item that is not dropped implements it, and no commit falls in its window.",
130
+ "intent-work-blocked": "A work item constraining the region has commits in its window while a work item it needs is not done.",
131
+ "intent-work-open-decided-code": "Commits in the region are a decision's own work while the work item implementing that decision is still open.",
132
+ // Composite instances joined to components (graph --composites, #2662): why the list is empty or has no component.
133
+ "composites-no-chant-member": "No member of kind chant was read, so nothing declares a composite instance or a component.",
134
+ "composites-none-declared": "The members read declare no composite instance.",
135
+ "composites-no-component": "The members read declare no component, so no composite instance has one.",
136
+ // The runtimes a member's components can deploy on (graph --composites, #2674).
137
+ "runtimes-config-unreadable": "The member's chant.config.ts could not be read, so only the built-in local runtime is listed.",
138
+ "runtimes-lexicon-unreadable": "A lexicon the member's config lists could not be loaded, so it is not listed as a runtime.",
92
139
  // The lineage lock (check).
93
140
  "lock-invalid": "The lineage lock can't be read.",
94
141
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
@@ -102,3 +149,21 @@ export const REASON_CODES = Object.keys(REASONS) as ReasonCode[];
102
149
  export function isReasonCode(value: unknown): value is ReasonCode {
103
150
  return typeof value === "string" && Object.prototype.hasOwnProperty.call(REASONS, value);
104
151
  }
152
+
153
+ /**
154
+ * A finding code a plugin contributes to the intent graph through its
155
+ * `commitJoins` (#2656): `plugin:<name>:<code>`, where `<name>` is the kind's
156
+ * name, or the kind file's `commitJoinsName` when it has one (#2663), and
157
+ * `<code>` is lower case words joined by dashes. These are outside the
158
+ * closed list: the plugin owns its namespace, and core only carries them.
159
+ */
160
+ export type PluginCode = `plugin:${string}:${string}`;
161
+
162
+ export const PLUGIN_CODE = /^plugin:([^:\s]+):([a-z0-9]+(?:-[a-z0-9]+)*)$/;
163
+
164
+ /** Whether `value` is a plugin code, and, given `name` (a kind's name or its `commitJoinsName`), one in that namespace. */
165
+ export function isPluginCode(value: unknown, name?: string): value is PluginCode {
166
+ if (typeof value !== "string") return false;
167
+ const m = value.match(PLUGIN_CODE);
168
+ return !!m && (name === undefined || m[1] === name);
169
+ }
@@ -17,6 +17,7 @@ import { runChecks } from "./lineage-check";
17
17
  import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTERN } from "./record-assets";
18
18
  import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
19
19
  import { parseFrontMatter, RECORD_WARNING_CODES } from "./records";
20
+ import { WORK_WARNING_CODES } from "./work";
20
21
  import { workspaceGraph } from "./graph-cli";
21
22
  import graphSchema from "./graph.schema.json";
22
23
  import recordsSchema from "./records.schema.json";
@@ -168,7 +169,8 @@ describe("chant workspace records checks each pin", () => {
168
169
  });
169
170
 
170
171
  test("the schema lists exactly the warning codes", () => {
171
- expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES]);
172
+ // A work kind's records carry the work warnings too, except work-done-gap-open, which only graph --intent raises (#2683).
173
+ expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES.filter((c) => c !== "work-done-gap-open")]);
172
174
  });
173
175
 
174
176
  test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {