@intentius/chant 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +6 -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 +16 -1
  22. package/dist/workspace/graph-cli.d.ts.map +1 -1
  23. package/dist/workspace/intent-cli.d.ts +21 -0
  24. package/dist/workspace/intent-cli.d.ts.map +1 -0
  25. package/dist/workspace/intent-joins.d.ts +121 -0
  26. package/dist/workspace/intent-joins.d.ts.map +1 -0
  27. package/dist/workspace/intent.d.ts +310 -0
  28. package/dist/workspace/intent.d.ts.map +1 -0
  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 +29 -0
  32. package/dist/workspace/reason-codes.d.ts.map +1 -1
  33. package/dist/workspace/records.d.ts +7 -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 +20 -0
  38. package/src/cli/main.ts +18 -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 +6 -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 +39 -3
  54. package/src/workspace/intent-cli.ts +119 -0
  55. package/src/workspace/intent-joins.ts +210 -0
  56. package/src/workspace/intent.schema.json +1141 -0
  57. package/src/workspace/intent.test.ts +566 -0
  58. package/src/workspace/intent.ts +999 -0
  59. package/src/workspace/member-commands.ts +11 -5
  60. package/src/workspace/read-contract.test.ts +46 -3
  61. package/src/workspace/reason-codes.test.ts +39 -2
  62. package/src/workspace/reason-codes.ts +40 -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 +55 -11
@@ -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,8 +21,12 @@ 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";
28
+ import { intentGraph } from "./intent";
29
+ import intentSchema from "./intent.schema.json";
25
30
  import { runChecks } from "./lineage-check";
26
31
  import { listWorkspace } from "./ls";
27
32
  import lsSchema from "./ls.schema.json";
@@ -35,7 +40,7 @@ import statusSchema from "./status.schema.json";
35
40
  const FIXTURE = join(REPO, "reference-workspace");
36
41
  const TIMEOUT = 240_000;
37
42
 
38
- const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema };
43
+ const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema, composites: compositesSchema };
39
44
 
40
45
  /** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
41
46
  const reader: Toolchain = {
@@ -116,6 +121,44 @@ describe("every schema against the reference workspace (#2543)", () => {
116
121
  expect(doc.members.map((m) => m.name)).toEqual(["app", "delivery", "design-client", "design"]);
117
122
  });
118
123
 
124
+ test("graph --intent, in the working tree and at HEAD (#2651)", async () => {
125
+ const { expectValid } = contract(intentSchema);
126
+ for (const at of [undefined, "HEAD"]) {
127
+ const { doc, failed } = await intentGraph({ cwd: FIXTURE, region: "app/src/server.mjs:19", at, kinds: ["decisions/decision.kind.mjs"] });
128
+ expectValid(doc);
129
+ if ("error" in doc) throw new Error(doc.error.message);
130
+ expect(failed).toBe(false);
131
+ expect(doc.workspace).toEqual({ name: "reference", root: "reference-workspace" });
132
+ expect(doc.at).toBe(at ? head : null);
133
+ expect(doc.region).toBe("region:app/src/server.mjs:19");
134
+ }
135
+ });
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
+
119
162
  test("records, in the working tree and at HEAD", async () => {
120
163
  const { expectValid } = contract(recordsSchema);
121
164
  for (const at of [undefined, "HEAD"]) {
@@ -10,11 +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";
16
+ import { INTENT_ERROR_CODES, INTENT_FINDING_CODES, INTENT_REASON_CODES } from "./intent";
15
17
  import { CHECK_CODES, CHECK_ERROR_CODES } from "./lineage-check";
16
18
  import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
17
- 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";
18
22
  import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
19
23
  import { STATUS_ERROR_CODES, STATUS_REASON_CODES } from "./status";
20
24
 
@@ -33,6 +37,11 @@ const PER_COMMAND: Record<string, readonly string[]> = {
33
37
  RECORD_REASON_CODES,
34
38
  RECORD_WARNING_CODES,
35
39
  READ_ERROR_CODES,
40
+ INTENT_ERROR_CODES,
41
+ INTENT_FINDING_CODES,
42
+ INTENT_REASON_CODES,
43
+ COMPOSITES_ERROR_CODES,
44
+ COMPOSITES_REASON_CODES,
36
45
  };
37
46
 
38
47
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */
@@ -94,7 +103,7 @@ describe("the closed list of reason codes", () => {
94
103
  });
95
104
 
96
105
  test("no source file emits a code outside the list", () => {
97
- const emitted = /(?:\bcode:\s*|(?:WorkspaceReadError|RecordReadError|StatusError)\(\s*)"([a-z0-9-]+)"/g;
106
+ const emitted = /(?:\bcode:\s*|(?:WorkspaceReadError|RecordReadError|StatusError|IntentError)\(\s*)"([a-z0-9-]+)"/g;
98
107
  let seen = 0;
99
108
  for (const file of sourceFiles(HERE)) {
100
109
  const text = readFileSync(file, "utf-8");
@@ -107,6 +116,34 @@ describe("the closed list of reason codes", () => {
107
116
  expect(seen).toBeGreaterThan(30);
108
117
  });
109
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
+
110
147
  test("the read-contract page documents every code", () => {
111
148
  const page = readFileSync(join(REPO, "docs", "src", "content", "docs", "reference", "workspace-read-contract.mdx"), "utf-8");
112
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.",
@@ -71,6 +72,28 @@ export const REASONS = {
71
72
  "schema-id-mismatch": "The schema's $id differs from the id the record kind names.",
72
73
  "schema-invalid": "The record schema itself does not compile.",
73
74
  "location-missing": "The records directory does not exist, in the tree or at the revision.",
75
+ // The intent graph (graph --intent, #2651): a read that fails.
76
+ "intent-region-invalid": "The region's path, or its line range, does not exist in the tree read.",
77
+ // The intent graph: part of the walk that can't be read. The document is still printed.
78
+ "intent-history-shallow": "The repository is a shallow clone, so the region's history stops at the clone's boundary.",
79
+ "intent-plugin-failed": "A kind file's commitJoins, which joins commits to units, contracts and evidence, failed for a commit.",
80
+ // The intent graph: findings, each a node in the graph.
81
+ "intent-commit-undecided": "A commit changed the region when no decision constrained it at path granularity.",
82
+ "intent-commit-bare": "A commit names no unit, no pull request and no decision covering the region at its time.",
83
+ "intent-pin-drifted": "A decision's pinned artifact no longer hashes to the pin.",
84
+ "intent-pin-missing": "A decision's pinned artifact does not exist in the tree read.",
85
+ "intent-artifact-unpinned": "An artifact decisions in the graph pinned, which no current decision pins.",
86
+ "intent-decision-superseded-live": "Every decision constraining the region is superseded.",
87
+ "intent-decision-provisional": "The current decisions constraining the region are all in states their kind does not close, such as decided.",
88
+ "intent-constraint-coarse": "The region is constrained only through its member, not by path.",
89
+ "intent-constraint-lost": "A decision's path constraint names a path that does not exist in the tree read.",
90
+ "intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
91
+ "intent-trailer-unverified": "A commit carries a trailer a plugin says claims authorship, and the commit is not attested.",
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.",
74
97
  // The lineage lock (check).
75
98
  "lock-invalid": "The lineage lock can't be read.",
76
99
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
@@ -84,3 +107,20 @@ export const REASON_CODES = Object.keys(REASONS) as ReasonCode[];
84
107
  export function isReasonCode(value: unknown): value is ReasonCode {
85
108
  return typeof value === "string" && Object.prototype.hasOwnProperty.call(REASONS, value);
86
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
 
@@ -62,6 +62,12 @@ export const RECORD_WARNING_CODES = [
62
62
  "asset-stale",
63
63
  /** A supersedes link from a record whose state is weaker than the one it names, so it has no effect yet (#2524 D4). */
64
64
  "record-supersedes-pending",
65
+ /**
66
+ * The kind's pins field is an empty list: the record cites no evidence and
67
+ * pins no file. Information for a reviewer, such as a decision made in a
68
+ * product's own design flow with nothing to cite (#2654).
69
+ */
70
+ "record-no-evidence",
65
71
  ] as const satisfies readonly ReasonCode[];
66
72
  export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
67
73
 
@@ -187,7 +193,7 @@ export interface LoadedRecordKind {
187
193
  * The kind is registered under a name no lexicon package can have (npm names
188
194
  * hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
189
195
  */
190
- async function importKindModule(path: string): Promise<Record<string, unknown>> {
196
+ export async function importKindModule(path: string): Promise<Record<string, unknown>> {
191
197
  const name = `record-kind:${path}`;
192
198
  registerLexiconDeclarations([{ name, module: path }], dirname(path));
193
199
  try {
@@ -344,22 +350,54 @@ export interface ReadRecordsResult {
344
350
 
345
351
  type Validator = (data: unknown) => { ok: true } | { ok: false; errors: string[] };
346
352
 
353
+ /** One ajv error, compiled with `verbose` so the failing schema and data come with it. */
354
+ interface SchemaError {
355
+ instancePath: string;
356
+ schemaPath: string;
357
+ keyword: string;
358
+ message?: string;
359
+ params?: { failingKeyword?: string };
360
+ parentSchema?: Record<string, unknown>;
361
+ data?: unknown;
362
+ }
363
+
364
+ /**
365
+ * ajv's errors as `<path> <message>` lines. A failed `if` whose `then` or
366
+ * `else` branch has a `description` is reported by that description alone, in
367
+ * place of the branch's own errors, with each `{field}` filled from the value
368
+ * the branch checked. The decision schema words its dissent rule this way, so
369
+ * the message names the reviewer (#2652). The schema stays plain JSON Schema,
370
+ * with no keyword a strict validator would refuse.
371
+ */
372
+ function renderSchemaErrors(errors: readonly SchemaError[]): string[] {
373
+ const replaced: string[] = [];
374
+ const described = new Map<SchemaError, string>();
375
+ for (const e of errors) {
376
+ const branch = e.keyword === "if" ? e.params?.failingKeyword : undefined;
377
+ const text = branch ? (e.parentSchema?.[branch] as { description?: unknown } | undefined)?.description : undefined;
378
+ if (!branch || typeof text !== "string") continue;
379
+ const at = e.data !== null && typeof e.data === "object" ? (e.data as Record<string, unknown>) : {};
380
+ described.set(e, text.replace(/\{([A-Za-z0-9_]+)\}/g, (all, key: string) => (typeof at[key] === "string" ? (at[key] as string) : all)));
381
+ replaced.push(`${e.schemaPath.replace(/\/if$/, "")}/${branch}/`);
382
+ }
383
+ return errors
384
+ .filter((e) => described.has(e) || !replaced.some((prefix) => e.schemaPath.startsWith(prefix)))
385
+ .map((e) => `${e.instancePath || "/"} ${described.get(e) ?? e.message ?? "is invalid"}`);
386
+ }
387
+
347
388
  async function compileSchema(schema: Record<string, unknown>): Promise<Validator> {
348
389
  const mod = (await import("ajv")) as unknown as { default: unknown };
349
390
  // ajv is CommonJS; its class is the default export, or that export's own default.
350
391
  const Ajv = ((mod.default as { default?: unknown }).default ?? mod.default) as new (opts: object) => {
351
- compile(s: object): ((d: unknown) => boolean) & { errors?: Array<{ instancePath: string; message?: string }> | null };
392
+ compile(s: object): ((d: unknown) => boolean) & { errors?: SchemaError[] | null };
352
393
  };
353
394
  let validate: ReturnType<InstanceType<typeof Ajv>["compile"]>;
354
395
  try {
355
- validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
396
+ validate = new Ajv({ allErrors: true, strict: false, verbose: true }).compile(schema);
356
397
  } catch (err) {
357
398
  throw new RecordReadError("schema-invalid", `the kind's schema does not compile: ${message(err)}`);
358
399
  }
359
- return (data) =>
360
- validate(data)
361
- ? { ok: true }
362
- : { ok: false, errors: (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`) };
400
+ return (data) => (validate(data) ? { ok: true } : { ok: false, errors: renderSchemaErrors(validate.errors ?? []) });
363
401
  }
364
402
 
365
403
  /** Read every record `loaded` locates, through `options.source`. */
@@ -392,10 +430,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
392
430
  if (!result.ok) {
393
431
  entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
394
432
  }
395
- if (kind.pins && options.assets) {
396
- const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
397
- entry.assets = checked.assets;
398
- entry.warnings = checked.warnings;
433
+ if (kind.pins) {
434
+ const cited = fm.value[kind.pins.field];
435
+ if (Array.isArray(cited) && cited.length === 0) {
436
+ entry.warnings.push({ code: "record-no-evidence", message: `${kind.pins.field} is empty: the record cites nothing and pins no file` });
437
+ }
438
+ if (options.assets) {
439
+ const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
440
+ entry.assets = checked.assets;
441
+ entry.warnings.push(...checked.warnings);
442
+ }
399
443
  }
400
444
  }
401
445