@intentius/chant 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) 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 +2 -0
  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/workspace/__fixtures__/contract-repo.d.ts +7 -0
  18. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  19. package/dist/workspace/composites.d.ts +139 -0
  20. package/dist/workspace/composites.d.ts.map +1 -0
  21. package/dist/workspace/graph-cli.d.ts +13 -2
  22. package/dist/workspace/graph-cli.d.ts.map +1 -1
  23. package/dist/workspace/intent-cli.d.ts +5 -1
  24. package/dist/workspace/intent-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-joins.d.ts +31 -3
  26. package/dist/workspace/intent-joins.d.ts.map +1 -1
  27. package/dist/workspace/intent.d.ts +27 -2
  28. package/dist/workspace/intent.d.ts.map +1 -1
  29. package/dist/workspace/member-commands.d.ts +7 -2
  30. package/dist/workspace/member-commands.d.ts.map +1 -1
  31. package/dist/workspace/reason-codes.d.ts +14 -0
  32. package/dist/workspace/reason-codes.d.ts.map +1 -1
  33. package/dist/workspace/records.d.ts +1 -1
  34. package/dist/workspace/records.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli/handlers/graph.ts +4 -0
  37. package/src/cli/main.test.ts +9 -0
  38. package/src/cli/main.ts +8 -0
  39. package/src/cli/mcp/resource-handlers.ts +17 -0
  40. package/src/cli/mcp/server.test.ts +140 -4
  41. package/src/cli/mcp/server.ts +5 -1
  42. package/src/cli/mcp/tools/composites.ts +98 -0
  43. package/src/cli/mcp/tools/search.ts +47 -5
  44. package/src/cli/registry.ts +2 -0
  45. package/src/components/cli-support.test.ts +16 -0
  46. package/src/components/cli-support.ts +8 -2
  47. package/src/composite.ts +9 -0
  48. package/src/lexicon.ts +47 -0
  49. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  50. package/src/workspace/composites.schema.json +471 -0
  51. package/src/workspace/composites.test.ts +244 -0
  52. package/src/workspace/composites.ts +295 -0
  53. package/src/workspace/graph-cli.ts +32 -4
  54. package/src/workspace/intent-cli.ts +26 -2
  55. package/src/workspace/intent-joins.ts +48 -3
  56. package/src/workspace/intent.schema.json +80 -17
  57. package/src/workspace/intent.test.ts +138 -20
  58. package/src/workspace/intent.ts +72 -14
  59. package/src/workspace/member-commands.ts +11 -5
  60. package/src/workspace/read-contract.test.ts +31 -3
  61. package/src/workspace/reason-codes.test.ts +34 -1
  62. package/src/workspace/reason-codes.ts +22 -0
  63. package/src/workspace/records-contract.test.ts +22 -2
  64. package/src/workspace/records.schema.json +2 -2
  65. package/src/workspace/records.test.ts +93 -0
  66. package/src/workspace/records.ts +54 -10
@@ -22,24 +22,28 @@
22
22
  * the graph pins it.
23
23
  *
24
24
  * Every gap the walk finds is a `finding` node with a closed code, never
25
- * prose, so a reader can draw it and a test can assert it. chant emits the
25
+ * prose, so a reader can draw it and a test can assert it. A commit made
26
+ * inside a decision's window is not taken as that decision's work: unless
27
+ * the decision's own unit made it, it is `decided-by-window`, shown for the
28
+ * person to judge (#2656). A plugin's `commitJoins` may add findings of its
29
+ * own, in its own code namespace. chant emits the
26
30
  * graph and hud renders it (#2524 D8, D15). Git is read through a local
27
31
  * `git` subprocess only: no fetch, no network.
28
32
  */
29
33
 
30
34
  import { execFileSync } from "node:child_process";
31
35
  import { realpathSync } from "node:fs";
32
- import { relative, resolve, sep } from "node:path";
36
+ import { basename, relative, resolve, sep } from "node:path";
33
37
  import { readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
34
38
  import { classifyFile, declaredFilesUnder } from "./generated-files";
35
- import { hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity } from "./intent-joins";
39
+ import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
36
40
  import { loadKindRegistry } from "./kinds";
37
41
  import { resolveLinks, type LinkTableRow } from "./links";
38
42
  import { sourceMemberHandles } from "./member-handles";
39
43
  import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
40
44
  import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
41
45
  import { queryRecords, type RecordView } from "./records-cli";
42
- import type { ReasonCode } from "./reason-codes";
46
+ import type { PluginCode, ReasonCode } from "./reason-codes";
43
47
  import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
44
48
  import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
45
49
  import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
@@ -167,8 +171,18 @@ export interface CommitNode {
167
171
  signature: { level: ProvenanceLevel; reason: string; principal?: string };
168
172
  /** The line ranges the commit changed in the region, in the commit's own version of the file; null for a file or directory region. */
169
173
  lines: LineRange[] | null;
174
+ /**
175
+ * How the decisions constraining the region by path relate to the commit
176
+ * (#2656): `decided` when it falls inside a decision's window and that
177
+ * decision's own unit made it, `decided-by-window` when it only falls
178
+ * inside a window, `undecided` when it falls outside every window, and
179
+ * null when no record kind was read.
180
+ */
181
+ state: CommitState | null;
170
182
  }
171
183
 
184
+ export type CommitState = "decided" | "decided-by-window" | "undecided";
185
+
172
186
  export interface JoinedNode {
173
187
  id: string;
174
188
  kind: "unit" | "contract" | "evidence";
@@ -237,9 +251,14 @@ export interface LinkNode {
237
251
  export interface FindingNode {
238
252
  id: string;
239
253
  kind: "finding";
240
- code: IntentFindingCode;
254
+ /** A closed code, or a plugin's own `plugin:<name>:<code>` (#2656). */
255
+ code: IntentFindingCode | PluginCode;
241
256
  message: string;
242
257
  concerns: string[];
258
+ /** For a plugin's finding: the kind file that returned it. */
259
+ plugin?: string;
260
+ /** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
261
+ refs?: string[];
243
262
  }
244
263
 
245
264
  export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | ArtifactNode | LinkNode | FindingNode;
@@ -250,6 +269,7 @@ export type IntentEdge =
250
269
  | { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
251
270
  | { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
252
271
  | { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
272
+ | { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" }
253
273
  | { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links"; from: string; to: string };
254
274
 
255
275
  export interface IntentReason {
@@ -269,7 +289,7 @@ export type IntentDocument =
269
289
  workspace: { name: string; root: string };
270
290
  region: string;
271
291
  history: { rev: string | null; follows: "line-range" | "file" | "directory"; shallow: boolean };
272
- kinds: { file: string; records: string | null; joins: "function" | "data" | null }[];
292
+ kinds: { file: string; name: string; records: string | null; joins: "function" | "data" | null }[];
273
293
  nodes: IntentNode[];
274
294
  edges: IntentEdge[];
275
295
  reasons: IntentReason[];
@@ -486,6 +506,8 @@ interface LoadedKind {
486
506
  file: string;
487
507
  /** Relative to the repository root, for the document. */
488
508
  display: string;
509
+ /** The record kind's name, or the file's name without `.kind.mjs`: the namespace of its plugin findings. */
510
+ name: string;
489
511
  records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
490
512
  joins?: CommitJoins;
491
513
  }
@@ -503,12 +525,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
503
525
  }
504
526
  const joins = readCommitJoins(mod);
505
527
  if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
506
- const kind: LoadedKind = { file, display, ...(joins ? { joins } : {}) };
528
+ const kind: LoadedKind = { file, display, name: basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
507
529
  if (mod.recordKind !== undefined) {
508
530
  const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
509
531
  if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
510
532
  try {
511
533
  kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
534
+ kind.name = kind.records.loaded.kind.name;
512
535
  } catch (err) {
513
536
  if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
514
537
  throw err;
@@ -611,7 +634,15 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
611
634
  if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
612
635
  return located.tree.read(path);
613
636
  };
614
- const joined = new Map<string, { unit?: string; contracts: string[]; authorship: string[] }>();
637
+ interface Joined {
638
+ unit?: string;
639
+ contracts: string[];
640
+ authorship: string[];
641
+ /** The record ids the commit's units and contracts say they carry out. */
642
+ decisions: string[];
643
+ }
644
+ const joined = new Map<string, Joined>();
645
+ const pluginFindings: { commit: string; plugin: string; finding: PluginFinding }[] = [];
615
646
  const failedPlugins = new Set<string>();
616
647
  for (const t of touched) {
617
648
  const c = details.get(t.sha);
@@ -624,17 +655,17 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
624
655
  : { level: "unattested" as const, reason: policy.problems.length ? policy.problems.join("; ") : `no signers file (${policy.signersPath}) at base; attestation is off` };
625
656
  const pr = c.subject.match(/\(#([0-9]+)\)\s*$/);
626
657
  const cid = `commit:${c.sha}`;
627
- add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines });
658
+ add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines, state: null });
628
659
  edges.push({ kind: "touched-by", from: rid, to: cid, lines: t.lines });
629
660
 
630
661
  // 2. Each commit's origin, from the plugins.
631
- const entry = { contracts: [] as string[], authorship: [] as string[] } as { unit?: string; contracts: string[]; authorship: string[] };
662
+ const entry: Joined = { contracts: [], authorship: [], decisions: [] };
632
663
  joined.set(c.sha, entry);
633
664
  for (const k of kinds) {
634
665
  if (!k.joins) continue;
635
666
  let result;
636
667
  try {
637
- result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at });
668
+ result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at }, k.name);
638
669
  } catch (err) {
639
670
  const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
640
671
  if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
@@ -653,6 +684,8 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
653
684
  if (contractId) edges.push({ kind: "serves", from: unitId, to: contractId });
654
685
  }
655
686
  if (contractId) entry.contracts.push(result.contract!.id);
687
+ if (unitId) entry.decisions.push(...entityDecisions(result.unit), ...entityDecisions(result.contract));
688
+ for (const f of result.findings ?? []) pluginFindings.push({ commit: cid, plugin: k.display, finding: f });
656
689
  const evidence = result.evidence === undefined ? [] : Array.isArray(result.evidence) ? result.evidence : [result.evidence];
657
690
  for (const e of evidence) edges.push({ kind: "cites-evidence", from: unitId ?? contractId ?? cid, to: joinedNode("evidence", e) });
658
691
  entry.authorship.push(...(result.authorship ?? []));
@@ -859,11 +892,20 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
859
892
  return !!w && w.from.has(sha) && !w.until?.has(sha);
860
893
  });
861
894
  const readsRecords = kinds.some((k) => k.records);
895
+ // A decision's own work: a commit whose unit, or that unit's contract, names
896
+ // the decision, or whose unit serves a contract the decision constrains.
897
+ const ownWork = (j: Joined, d: DecisionNode) =>
898
+ !!j.unit && (j.decisions.some((x) => x === d.record || x === `${d.recordKind}/${d.record}`) || d.constrains.some((x) => x.granularity === "contract" && j.contracts.includes(x.entry)));
862
899
  for (const t of touched) {
863
900
  const c = nodes.get(`commit:${t.sha}`) as CommitNode | undefined;
864
901
  if (!c) continue;
865
902
  const j = joined.get(t.sha)!;
866
- if (readsRecords && coveredAt(t.sha, ["path"]).length === 0) {
903
+ if (readsRecords) {
904
+ const inWindow = coveredAt(t.sha, ["path"]);
905
+ for (const w of inWindow) edges.push({ kind: "within", from: c.id, to: w.node.id, state: ownWork(j, w.node) ? "decided" : "decided-by-window" });
906
+ c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
907
+ }
908
+ if (readsRecords && c.state === "undecided") {
867
909
  find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
868
910
  }
869
911
  if (readsRecords && !j.unit && c.pullRequest === null && coveredAt(t.sha, ["path", "member", "contract", "issue"]).length === 0) {
@@ -915,7 +957,23 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
915
957
  }
916
958
 
917
959
  // Findings last, in the order of the code list, so the same walk always prints the same way.
918
- findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code) - INTENT_FINDING_CODES.indexOf(y.code));
960
+ findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code as IntentFindingCode) - INTENT_FINDING_CODES.indexOf(y.code as IntentFindingCode));
961
+ // Then the plugins' findings, in commit order, each about its commit and the nodes its refs name.
962
+ const resolveRef = (ref: string): string | undefined => {
963
+ if (nodes.has(ref)) return ref;
964
+ for (const prefix of ["commit", "unit", "contract", "evidence", "artifact", "file", "member"]) if (nodes.has(`${prefix}:${ref}`)) return `${prefix}:${ref}`;
965
+ for (const n of nodes.values()) {
966
+ if (n.kind === "decision" && (ref === n.record || ref === `${n.recordKind}/${n.record}`)) return n.id;
967
+ if (n.kind === "commit" && /^[0-9a-f]{7,}$/.test(ref) && n.sha.startsWith(ref)) return n.id;
968
+ }
969
+ return undefined;
970
+ };
971
+ for (const { commit, plugin, finding } of pluginFindings) {
972
+ const code = finding.code as PluginCode;
973
+ const refs = finding.refs ?? [];
974
+ const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
975
+ findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
976
+ }
919
977
  for (const f of findings) nodes.set(f.id, f);
920
978
  const all = [...nodes.values()];
921
979
  return {
@@ -925,7 +983,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
925
983
  workspace: { name: declaration.name, root: located.root },
926
984
  region: rid,
927
985
  history: { rev, follows: region.lines ? "line-range" : type === "file" ? "file" : "directory", shallow },
928
- kinds: kinds.map((k) => ({ file: k.display, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
986
+ kinds: kinds.map((k) => ({ file: k.display, name: k.name, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
929
987
  nodes: all,
930
988
  edges,
931
989
  reasons,
@@ -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` and `records`, in
10
+ * the working tree and, for `ls`, `graph` and `check`, at `HEAD` through
11
+ * `--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";
@@ -37,7 +40,7 @@ import statusSchema from "./status.schema.json";
37
40
  const FIXTURE = join(REPO, "reference-workspace");
38
41
  const TIMEOUT = 240_000;
39
42
 
40
- const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema };
43
+ const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema, composites: compositesSchema };
41
44
 
42
45
  /** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
43
46
  const reader: Toolchain = {
@@ -131,6 +134,31 @@ describe("every schema against the reference workspace (#2543)", () => {
131
134
  }
132
135
  });
133
136
 
137
+ test(
138
+ "graph --composites runs delivery's own component graph, and says why the list is empty (#2662)",
139
+ async () => {
140
+ const { expectValid } = contract(compositesSchema);
141
+ for (const at of [undefined, "HEAD"]) {
142
+ const { doc, failed } = await workspaceComposites({ cwd: FIXTURE, at, reader });
143
+ expectValid(doc);
144
+ if ("error" in doc) throw new Error(doc.error.message);
145
+ expect(failed, JSON.stringify(doc.members)).toBe(false);
146
+ expect(doc.at).toBe(at ? head : null);
147
+ expect(doc.members.map((m) => [m.name, m.status])).toEqual([
148
+ ["app", "skipped"],
149
+ ["delivery", "read"],
150
+ ["design-client", "skipped"],
151
+ ["design", "skipped"],
152
+ ]);
153
+ // delivery has a docker Service and no composite or component.
154
+ expect(doc.composites).toEqual([]);
155
+ expect(doc.components).toEqual([]);
156
+ expect(doc.reasons.map((r) => r.code)).toEqual(["composites-none-declared", "composites-no-component"]);
157
+ }
158
+ },
159
+ TIMEOUT,
160
+ );
161
+
134
162
  test("records, in the working tree and at HEAD", async () => {
135
163
  const { expectValid } = contract(recordsSchema);
136
164
  for (const at of [undefined, "HEAD"]) {
@@ -10,12 +10,15 @@ 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 } 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 intentSchema from "./intent.schema.json";
20
+ import { isPluginCode, isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
21
+ import { contract } from "./__fixtures__/contract-repo";
19
22
  import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
20
23
  import { STATUS_ERROR_CODES, STATUS_REASON_CODES } from "./status";
21
24
 
@@ -37,6 +40,8 @@ const PER_COMMAND: Record<string, readonly string[]> = {
37
40
  INTENT_ERROR_CODES,
38
41
  INTENT_FINDING_CODES,
39
42
  INTENT_REASON_CODES,
43
+ COMPOSITES_ERROR_CODES,
44
+ COMPOSITES_REASON_CODES,
40
45
  };
41
46
 
42
47
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */
@@ -111,6 +116,34 @@ describe("the closed list of reason codes", () => {
111
116
  expect(seen).toBeGreaterThan(30);
112
117
  });
113
118
 
119
+ test("a plugin's finding codes are in its own namespace, outside the list, and the intent schema accepts them (#2656)", () => {
120
+ expect(isPluginCode("plugin:chud:contract-criteria-changed")).toBe(true);
121
+ expect(isPluginCode("plugin:chud:contract-criteria-changed", "chud")).toBe(true);
122
+ expect(isPluginCode("plugin:chud:contract-criteria-changed", "units")).toBe(false);
123
+ 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);
124
+ expect(isReasonCode("plugin:chud:contract-criteria-changed")).toBe(false);
125
+ const { validate } = contract(intentSchema);
126
+ const finding = (code: string) => ({ id: `finding:${code}:1`, kind: "finding", code, message: "m", concerns: [] });
127
+ const doc = (code: string) => ({
128
+ $schema: intentSchema.$id,
129
+ contract: 1,
130
+ chant: "0.0.0",
131
+ at: null,
132
+ workspace: { name: "w", root: "." },
133
+ region: "region:.",
134
+ history: { rev: null, follows: "directory", shallow: false },
135
+ kinds: [],
136
+ nodes: [finding(code)],
137
+ edges: [],
138
+ reasons: [],
139
+ summary: { commits: 0, decisions: 0, artifacts: 0, findings: 1 },
140
+ });
141
+ expect(validate(doc("plugin:chud:contract-criteria-changed"))).toBe(true);
142
+ expect(validate(doc("intent-commit-bare"))).toBe(true);
143
+ expect(validate(doc("plugin:chud:Nope"))).toBe(false);
144
+ expect(validate(doc("made-up-code"))).toBe(false);
145
+ });
146
+
114
147
  test("the read-contract page documents every code", () => {
115
148
  const page = readFileSync(join(REPO, "docs", "src", "content", "docs", "reference", "workspace-read-contract.mdx"), "utf-8");
116
149
  for (const c of REASON_CODES) expect(page, c).toContain(`| \`${c}\` |`);
@@ -64,6 +64,7 @@ export const REASONS = {
64
64
  "asset-missing": "A file the record pins by hash does not exist in the tree read.",
65
65
  "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
66
  "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.",
67
+ "record-no-evidence": "The record's evidence list is empty: it cites nothing and pins no file. Information for a reviewer, never an error.",
67
68
  // A records read that fails (records).
68
69
  "kind-unreadable": "The record kind file is missing or could not be imported.",
69
70
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -89,6 +90,10 @@ export const REASONS = {
89
90
  "intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
90
91
  "intent-trailer-unverified": "A commit carries a trailer a plugin says claims authorship, and the commit is not attested.",
91
92
  "intent-region-unconstrained": "No decision constrains the region at any granularity.",
93
+ // Composite instances joined to components (graph --composites, #2662): why the list is empty or has no component.
94
+ "composites-no-chant-member": "No member of kind chant was read, so nothing declares a composite instance or a component.",
95
+ "composites-none-declared": "The members read declare no composite instance.",
96
+ "composites-no-component": "The members read declare no component, so no composite instance has one.",
92
97
  // The lineage lock (check).
93
98
  "lock-invalid": "The lineage lock can't be read.",
94
99
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
@@ -102,3 +107,20 @@ export const REASON_CODES = Object.keys(REASONS) as ReasonCode[];
102
107
  export function isReasonCode(value: unknown): value is ReasonCode {
103
108
  return typeof value === "string" && Object.prototype.hasOwnProperty.call(REASONS, value);
104
109
  }
110
+
111
+ /**
112
+ * A finding code a plugin contributes to the intent graph through its
113
+ * `commitJoins` (#2656): `plugin:<name>:<code>`, where `<name>` is the kind's
114
+ * name and `<code>` is lower case words joined by dashes. These are outside the
115
+ * closed list: the plugin owns its namespace, and core only carries them.
116
+ */
117
+ export type PluginCode = `plugin:${string}:${string}`;
118
+
119
+ export const PLUGIN_CODE = /^plugin:([^:\s]+):([a-z0-9]+(?:-[a-z0-9]+)*)$/;
120
+
121
+ /** Whether `value` is a plugin code, and, given `name`, one in that kind's namespace. */
122
+ export function isPluginCode(value: unknown, name?: string): value is PluginCode {
123
+ if (typeof value !== "string") return false;
124
+ const m = value.match(PLUGIN_CODE);
125
+ return !!m && (name === undefined || m[1] === name);
126
+ }
@@ -5,13 +5,13 @@
5
5
  * doesn't exist yet, so the chant repo's own decision files stand in for it.
6
6
  */
7
7
 
8
- import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import Ajv2020 from "ajv/dist/2020";
12
12
  import { afterAll, describe, expect, test } from "vitest";
13
13
  import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
14
- import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
14
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
15
15
  import schema from "./records.schema.json";
16
16
  import { PROVENANCE_LEVELS } from "./trust/attestor";
17
17
 
@@ -77,6 +77,26 @@ describe("records output schema", () => {
77
77
  expect(doc.summary.invalid).toBe(2);
78
78
  });
79
79
 
80
+ test("a workspace-sourced decision with no evidence validates, with its warning (#2654)", async () => {
81
+ const root = copyDecisions();
82
+ const dir = join(root, "docs", "design", "decisions");
83
+ const text = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8")
84
+ .replace(/^id: .*$/m, 'id: "ws-900"')
85
+ .replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
86
+ .replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n");
87
+ writeFileSync(join(dir, "ws-900-extra.md"), text);
88
+ const doc = await queryRecords({ kind: KIND, current: true, cwd: root });
89
+ expectValid(doc);
90
+ if ("error" in doc) throw new Error(doc.error.message);
91
+ const r = doc.records.find((x) => x.id === "ws-900");
92
+ expect(r?.valid).toBe(true);
93
+ expect(r?.warnings?.map((w) => w.code)).toEqual(["record-no-evidence"]);
94
+ });
95
+
96
+ test("lists exactly the warning codes the code can return", () => {
97
+ expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES]);
98
+ });
99
+
80
100
  test("every failure validates with its code", async () => {
81
101
  const root = copyDecisions();
82
102
  const docs = [
@@ -74,7 +74,7 @@
74
74
  "items": { "$ref": "#/$defs/asset" }
75
75
  },
76
76
  "warnings": {
77
- "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, or a supersedes link that has no effect yet. valid and --current do not look at them.",
77
+ "description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, or (added by #2654) an evidence list that is empty. valid and --current do not look at them.",
78
78
  "type": "array",
79
79
  "items": { "$ref": "#/$defs/warning" }
80
80
  }
@@ -101,7 +101,7 @@
101
101
  "type": "object",
102
102
  "required": ["code", "message"],
103
103
  "properties": {
104
- "code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending"] },
104
+ "code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending", "record-no-evidence"] },
105
105
  "message": { "type": "string" }
106
106
  }
107
107
  },
@@ -93,6 +93,49 @@ describe("readRecords", () => {
93
93
  expect(r.data).not.toBeNull();
94
94
  });
95
95
 
96
+ describe("a dissent needs a reason (#2652)", () => {
97
+ const withReviews = (reviews: string) => decision("ws-001").replace(/^reviews: \[\]$/m, `reviews:\n${reviews}`);
98
+ const review = (verdict: string, extra = "") => ` - reviewer: "ana"\n verdict: "${verdict}"\n on: "2026-09-24"\n${extra}`;
99
+
100
+ for (const [label, note] of [
101
+ ["null", " note: null\n"],
102
+ ["empty", ' note: ""\n'],
103
+ ["blank", ' note: " "\n'],
104
+ ["missing", ""],
105
+ ] as const) {
106
+ test(`a dissent with a ${label} note is record-schema-invalid, naming the reviewer`, async () => {
107
+ write("ws-001-a.md", withReviews(review("dissent", note)));
108
+ const [r] = (await read()).records;
109
+ expect(r.valid).toBe(false);
110
+ expect(codes(r)).toEqual(["record-schema-invalid"]);
111
+ expect(r.reasons[0].message).toBe("/reviews/0 A dissent needs a reason: the dissent by ana has no note.");
112
+ });
113
+ }
114
+
115
+ test("a dissent with a note is valid, and so are agree and abstain without one", async () => {
116
+ write(
117
+ "ws-001-a.md",
118
+ withReviews(
119
+ review("dissent", ' note: "the seal should cover the lockfile too"\n') +
120
+ review("agree") +
121
+ review("abstain", " note: null\n"),
122
+ ),
123
+ );
124
+ const [r] = (await read()).records;
125
+ expect(r.reasons).toEqual([]);
126
+ expect(r.valid).toBe(true);
127
+ });
128
+
129
+ test("a dissent carries its concern's lifecycle; another verdict cannot", async () => {
130
+ write("ws-001-a.md", withReviews(review("dissent", ' note: "why"\n addressed_by: "INTENTIUS/chant#2652"\n withdrawn_on: "2026-09-25"\n')));
131
+ expect((await read()).records[0].valid).toBe(true);
132
+ write("ws-001-a.md", withReviews(review("agree", ' addressed_by: "ws-002"\n')));
133
+ const [r] = (await read()).records;
134
+ expect(codes(r)).toEqual(["record-schema-invalid"]);
135
+ expect(r.reasons[0].message).toBe("/reviews/0 Only a dissent is a concern: the agree by ana cannot carry addressed_by or withdrawn_on.");
136
+ });
137
+ });
138
+
96
139
  test("a supersedes link to a missing id is record-supersedes-unknown", async () => {
97
140
  write("ws-002-b.md", decision("ws-002", "decided", ["ws-404"]));
98
141
  const [r] = (await read()).records;
@@ -199,6 +242,56 @@ describe("readRecords", () => {
199
242
  });
200
243
  });
201
244
 
245
+ /**
246
+ * A decision made in the workspace (#2654): the workspace source form with no
247
+ * issue, and no evidence. `constrains` replaces ws-003's.
248
+ */
249
+ function workspaceDecision(id: string, constrains: string[] = ["member:app"]): string {
250
+ const list = constrains.length === 0 ? "constrains: []\n" : `constrains:\n${constrains.map((c) => ` - "${c}"\n`).join("")}`;
251
+ return decision(id)
252
+ .replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
253
+ .replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n")
254
+ .replace(/^constrains:\n(?: .*\n)*/m, list);
255
+ }
256
+
257
+ describe("a decision that originates in the workspace (#2654)", () => {
258
+ test("validates with no issue and no evidence, is current, and carries record-no-evidence", async () => {
259
+ write("ws-001-a.md", workspaceDecision("ws-001"));
260
+ const current = await read({ current: true });
261
+ expect(current.records.map((r) => [r.id, r.valid, r.reasons, r.warnings.map((w) => w.code)])).toEqual([
262
+ ["ws-001", true, [], ["record-no-evidence"]],
263
+ ]);
264
+ expect(current.records[0].data?.source).toEqual({ kind: "workspace", member: "app" });
265
+ });
266
+
267
+ test("takes a session and an issue, and nothing else", async () => {
268
+ const base = workspaceDecision("ws-001");
269
+ write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "jhgaylor/chud#77"\n'));
270
+ write("ws-002-b.md", base.replace('id: "ws-001"', 'id: "ws-002"').replace(' member: "app"\n', ' member: "app"\n session: null\n'));
271
+ write("ws-003-c.md", base.replace('id: "ws-001"', 'id: "ws-003"').replace(' member: "app"\n', ' member: "app"\n row: "Sort order"\n'));
272
+ write("ws-004-d.md", base.replace('id: "ws-001"', 'id: "ws-004"').replace(' member: "app"\n', ""));
273
+ const records = (await read()).records;
274
+ expect(records.map((r) => [r.id, codes(r)])).toEqual([
275
+ ["ws-001", []],
276
+ ["ws-002", []],
277
+ ["ws-003", ["record-schema-invalid"]],
278
+ ["ws-004", ["record-schema-invalid"]],
279
+ ]);
280
+ });
281
+
282
+ test("a record with evidence carries no record-no-evidence warning", async () => {
283
+ write("ws-001-a.md", decision("ws-001"));
284
+ expect((await read()).records[0].warnings).toEqual([]);
285
+ });
286
+
287
+ test("a record that constrains nothing is refused", async () => {
288
+ write("ws-001-a.md", workspaceDecision("ws-001", []));
289
+ const [r] = (await read()).records;
290
+ expect(codes(r)).toEqual(["record-schema-invalid"]);
291
+ expect(r.reasons[0].message).toMatch(/constrains/);
292
+ });
293
+ });
294
+
202
295
  describe("loadRecordKind", () => {
203
296
  const load = () => loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
204
297