@intentius/chant 0.85.0 → 0.87.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 (129) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +13 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +71 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +47 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +71 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +171 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +244 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/trust/seal.d.ts +85 -0
  54. package/dist/workspace/trust/seal.d.ts.map +1 -0
  55. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  56. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  57. package/dist/workspace/work.d.ts +56 -0
  58. package/dist/workspace/work.d.ts.map +1 -0
  59. package/package.json +19 -1
  60. package/src/cli/main.ts +55 -3
  61. package/src/cli/registry.ts +13 -1
  62. package/src/lifecycle/gate-ledger.ts +14 -0
  63. package/src/workspace/__fixtures__/sessions.ts +66 -0
  64. package/src/workspace/checks/records.ts +19 -0
  65. package/src/workspace/checks.test.ts +2 -0
  66. package/src/workspace/checks.ts +7 -1
  67. package/src/workspace/composites.schema.json +65 -3
  68. package/src/workspace/composites.test.ts +95 -5
  69. package/src/workspace/composites.ts +28 -7
  70. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  71. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  72. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  73. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
  74. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  75. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  76. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  77. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  78. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  79. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  80. package/src/workspace/conformance/conformance.test.ts +149 -0
  81. package/src/workspace/conformance/index.mjs +31 -0
  82. package/src/workspace/conformance/index.ts +453 -0
  83. package/src/workspace/conformance/vitest.ts +62 -0
  84. package/src/workspace/declaration.schema.json +40 -0
  85. package/src/workspace/declaration.ts +62 -0
  86. package/src/workspace/declared-kinds.test.ts +321 -0
  87. package/src/workspace/declared-kinds.ts +76 -0
  88. package/src/workspace/graph-cli.ts +8 -0
  89. package/src/workspace/intent-cli.ts +29 -6
  90. package/src/workspace/intent-gaps.test.ts +217 -0
  91. package/src/workspace/intent-joins.test.ts +60 -0
  92. package/src/workspace/intent-joins.ts +71 -19
  93. package/src/workspace/intent.schema.json +304 -7
  94. package/src/workspace/intent.test.ts +99 -0
  95. package/src/workspace/intent.ts +365 -46
  96. package/src/workspace/ls.schema.json +34 -0
  97. package/src/workspace/ls.ts +69 -4
  98. package/src/workspace/read-contract.test.ts +30 -9
  99. package/src/workspace/reason-codes.test.ts +16 -4
  100. package/src/workspace/reason-codes.ts +55 -4
  101. package/src/workspace/record-assets.test.ts +3 -1
  102. package/src/workspace/record-sessions.ts +105 -0
  103. package/src/workspace/record-source.ts +14 -5
  104. package/src/workspace/records-amend.schema.json +167 -0
  105. package/src/workspace/records-cli.ts +308 -19
  106. package/src/workspace/records-contract.test.ts +57 -2
  107. package/src/workspace/records-formats.test.ts +640 -0
  108. package/src/workspace/records-new.schema.json +158 -0
  109. package/src/workspace/records-quorum.test.ts +196 -0
  110. package/src/workspace/records-review.schema.json +227 -0
  111. package/src/workspace/records-sessions.test.ts +108 -0
  112. package/src/workspace/records-since.schema.json +193 -0
  113. package/src/workspace/records-since.test.ts +174 -0
  114. package/src/workspace/records-since.ts +259 -0
  115. package/src/workspace/records-write-contract.test.ts +125 -0
  116. package/src/workspace/records-write.test.ts +373 -0
  117. package/src/workspace/records-write.ts +765 -0
  118. package/src/workspace/records.schema.json +202 -9
  119. package/src/workspace/records.ts +700 -41
  120. package/src/workspace/runtimes.ts +107 -0
  121. package/src/workspace/status-contract.test.ts +163 -0
  122. package/src/workspace/status-gates.ts +215 -0
  123. package/src/workspace/status.schema.json +69 -3
  124. package/src/workspace/status.ts +35 -2
  125. package/src/workspace/trust/seal.test.ts +232 -0
  126. package/src/workspace/trust/seal.ts +195 -0
  127. package/src/workspace/trust/ssh-commit.ts +2 -2
  128. package/src/workspace/work.test.ts +390 -0
  129. package/src/workspace/work.ts +163 -0
@@ -128,6 +128,23 @@ export interface LinkDeclaration {
128
128
  pointer: string;
129
129
  }
130
130
 
131
+ /**
132
+ * A record kind the declaration names (#2680): the workspace's own, in the
133
+ * top-level `records`, or a member's, in that member's `records`.
134
+ */
135
+ export interface RecordKindDeclaration {
136
+ /** The kind file as the entry writes it: relative to the member's directory, or to the workspace root for the workspace's own. */
137
+ kind: string;
138
+ /** The kind file from the workspace root, with / separators. */
139
+ path: string;
140
+ /** The name the entry gives the kind, or null when readers use the kind file's own `recordKind.name`. */
141
+ name: string | null;
142
+ /** The member that declares it, or null for the workspace's own. */
143
+ member: string | null;
144
+ /** The entry's JSON Pointer in the file, for messages. */
145
+ pointer: string;
146
+ }
147
+
131
148
  export interface Member {
132
149
  type: "member";
133
150
  name: string;
@@ -141,6 +158,8 @@ export interface Member {
141
158
  outputs: string[] | null;
142
159
  /** The links this member states as a consumer, in file order (#2539). */
143
160
  links: LinkDeclaration[];
161
+ /** The record kinds this member declares, in file order (#2680). */
162
+ records: RecordKindDeclaration[];
144
163
  upstream: string | null;
145
164
  because: string | null;
146
165
  suppress: Suppression[];
@@ -179,6 +198,10 @@ export interface Declaration {
179
198
  pins: Pin[];
180
199
  /** Severities set for declaration checks, keyed by `WSP` id (#2535). */
181
200
  checks: Record<string, CheckSeverity>;
201
+ /** How many verdicts besides the decider's a record needs, or null when the declaration names none (#2671). */
202
+ quorum: number | null;
203
+ /** The workspace's own record kinds, from the top-level `records`, in file order (#2680). A member's are on the member. */
204
+ records: RecordKindDeclaration[];
182
205
  /** The file, relative to the workspace root's tree (`chant.workspace.json` or `.jsonc`). */
183
206
  file: string;
184
207
  }
@@ -390,6 +413,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
390
413
  kind: l.kind ?? null,
391
414
  pointer: `${pointer}/links/${j}`,
392
415
  }));
416
+ const records = recordKindsOf(e.records, e.dir as string, e.name as string, `${pointer}/records`);
393
417
  return {
394
418
  type: "member",
395
419
  name: e.name as string,
@@ -399,6 +423,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
399
423
  generated,
400
424
  outputs: e.outputs === undefined ? null : [...(e.outputs as string[])],
401
425
  links,
426
+ records,
402
427
  upstream: (e.upstream as string | undefined) ?? null,
403
428
  because: (e.because as string | undefined) ?? null,
404
429
  suppress,
@@ -468,6 +493,21 @@ export function parseDeclaration(text: string, file: string, reader: string = re
468
493
  }
469
494
  }
470
495
 
496
+ // A kind file is declared once, and a name the declaration gives a kind is
497
+ // given once (#2680): readers key the kinds by both.
498
+ const ownRecords = recordKindsOf(obj.records, ".", null, "/records");
499
+ const byPath = new Map<string, RecordKindDeclaration>();
500
+ const byName = new Map<string, RecordKindDeclaration>();
501
+ for (const r of [...ownRecords, ...members.flatMap((m) => m.records)]) {
502
+ const first = byPath.get(r.path);
503
+ if (first) throw new WorkspaceReadError("declaration-invalid", `the record kind ${r.path} is already declared at ${first.pointer}`, at(`${r.pointer}/kind`));
504
+ byPath.set(r.path, r);
505
+ if (r.name === null) continue;
506
+ const named = byName.get(r.name);
507
+ if (named) throw new WorkspaceReadError("declaration-invalid", `the record kind name ${JSON.stringify(r.name)} is already given at ${named.pointer}`, at(`${r.pointer}/name`));
508
+ byName.set(r.name, r);
509
+ }
510
+
471
511
  const pins = ((obj.pins as Record<string, string>[] | undefined) ?? []).map((p) => ({
472
512
  package: p.package ?? null,
473
513
  version: p.version ?? null,
@@ -484,10 +524,32 @@ export function parseDeclaration(text: string, file: string, reader: string = re
484
524
  groups: entries.filter((e): e is Group => e.type === "group"),
485
525
  pins,
486
526
  checks: { ...((obj.checks as Record<string, CheckSeverity> | undefined) ?? {}) },
527
+ quorum: typeof obj.quorum === "number" ? obj.quorum : null,
528
+ records: ownRecords,
487
529
  file,
488
530
  };
489
531
  }
490
532
 
533
+ /** The `records` list at `pointer`, already validated, with each kind file's path from the workspace root. */
534
+ function recordKindsOf(raw: unknown, dir: string, member: string | null, pointer: string): RecordKindDeclaration[] {
535
+ return ((raw as { kind: string; name?: string }[] | undefined) ?? []).map((r, i) => ({
536
+ kind: r.kind,
537
+ path: dir === "." ? r.kind : `${dir}/${r.kind}`,
538
+ name: r.name ?? null,
539
+ member,
540
+ pointer: `${pointer}/${i}`,
541
+ }));
542
+ }
543
+
544
+ /**
545
+ * Every record kind the declaration names (#2680), in the order readers use
546
+ * them: the workspace's own first, then each member's, members in file order
547
+ * and each list in its own order.
548
+ */
549
+ export function declaredRecordKinds(declaration: Declaration): RecordKindDeclaration[] {
550
+ return [...declaration.records, ...declaration.members.flatMap((m) => m.records)];
551
+ }
552
+
491
553
  /** Whether `path` is `dir` or sits under it. Both are tree-relative; `"."` is the root. */
492
554
  export function isInside(path: string, dir: string): boolean {
493
555
  if (dir === ".") return true;
@@ -0,0 +1,321 @@
1
+ /**
2
+ * chant #2680: the declaration names its record kinds. The declaration reads
3
+ * them, `ls --json` lists them, `check` fails on one that doesn't load
4
+ * (WSP115), and `records` and `graph --intent` read every one of them when
5
+ * `--kind` is not given, while `--kind` still overrides.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
11
+ import { parseArgs } from "../cli/main";
12
+ import { cleanScratch, commitAll, contract, git, repo, writeFiles } from "./__fixtures__/contract-repo";
13
+ import { runDeclarationChecks } from "./checks";
14
+ import { declaredRecordKinds, parseDeclaration, WorkspaceReadError } from "./declaration";
15
+ import { intentGraph } from "./intent";
16
+ import intentSchema from "./intent.schema.json";
17
+ import { listWorkspace, listWorkspaceWithKinds, type LsDocument } from "./ls";
18
+ import lsSchema from "./ls.schema.json";
19
+ import { declaredKindFiles, queryDeclaredRecords, runWorkspaceRecords } from "./records-cli";
20
+ import recordsSchema from "./records.schema.json";
21
+ import recordsSinceSchema from "./records-since.schema.json";
22
+ import { runRecordsWrite } from "./records-write";
23
+
24
+ afterAll(cleanScratch);
25
+
26
+ const REF = join(import.meta.dirname, "..", "..", "..", "..", "reference-workspace", "decisions");
27
+ const DECISION_KIND = readFileSync(join(REF, "decision.kind.mjs"), "utf-8");
28
+ const DECISION_SCHEMA = readFileSync(join(REF, "decision.schema.json"), "utf-8");
29
+ const REF_001 = readFileSync(join(REF, "ref-001-how-the-app-is-deployed.md"), "utf-8");
30
+
31
+ /** The decision kind under another name, reading only `note-*.md`, so two kinds never read the same file. */
32
+ const NOTE_KIND = DECISION_KIND.replace('name: "decision"', 'name: "note"').replace(/match: "[^"]*"/, 'match: "^note-[0-9]+\\\\.md$"');
33
+
34
+ const members = (extra: Record<string, unknown> = {}) => [
35
+ { name: "app", dir: "app", kind: "other", because: "a plain Node server" },
36
+ { name: "design", dir: "design", kind: "other", because: "the design data member", ...extra },
37
+ ];
38
+
39
+ const decl = (m: unknown[], extra: Record<string, unknown> = {}) => JSON.stringify({ name: "studio", schema: 1, members: m, ...extra }, null, 2);
40
+
41
+ const BASE_FILES = {
42
+ "app/server.mjs": "export const port = 8080;\n",
43
+ "decisions/decision.kind.mjs": DECISION_KIND,
44
+ "decisions/decision.schema.json": DECISION_SCHEMA,
45
+ "decisions/ref-001-how-the-app-is-deployed.md": REF_001,
46
+ "design/notes/note.kind.mjs": NOTE_KIND,
47
+ "design/notes/decision.schema.json": DECISION_SCHEMA,
48
+ "design/notes/note-1.md": REF_001,
49
+ };
50
+
51
+ /** A workspace whose own records hold the decision kind, and whose design member holds the note kind. */
52
+ function workspace(extraFiles: Record<string, string> = {}, designRecords: unknown[] = [{ kind: "notes/note.kind.mjs", name: "notes" }]): string {
53
+ return repo(
54
+ {
55
+ "chant.workspace.json": decl(members({ records: designRecords }), { records: [{ kind: "decisions/decision.kind.mjs" }] }),
56
+ ...BASE_FILES,
57
+ ...extraFiles,
58
+ },
59
+ true,
60
+ );
61
+ }
62
+
63
+ describe("the declaration's records (#2680)", () => {
64
+ test("the workspace's own and each member's, with paths from the workspace root, in reading order", () => {
65
+ const d = parseDeclaration(
66
+ decl(members({ records: [{ kind: "notes/note.kind.mjs", name: "notes" }] }), { records: [{ kind: "decisions/decision.kind.mjs" }] }),
67
+ "chant.workspace.json",
68
+ );
69
+ expect(d.records).toEqual([{ kind: "decisions/decision.kind.mjs", path: "decisions/decision.kind.mjs", name: null, member: null, pointer: "/records/0" }]);
70
+ expect(d.members.find((m) => m.name === "design")!.records).toEqual([
71
+ { kind: "notes/note.kind.mjs", path: "design/notes/note.kind.mjs", name: "notes", member: "design", pointer: "/members/1/records/0" },
72
+ ]);
73
+ expect(d.members.find((m) => m.name === "app")!.records).toEqual([]);
74
+ expect(declaredRecordKinds(d).map((k) => k.path)).toEqual(["decisions/decision.kind.mjs", "design/notes/note.kind.mjs"]);
75
+ });
76
+
77
+ test("a path outside the member, a path declared twice, a name given twice and an unknown field are declaration-invalid", () => {
78
+ const refused = (text: string) => {
79
+ try {
80
+ parseDeclaration(text, "chant.workspace.json");
81
+ } catch (err) {
82
+ expect(err).toBeInstanceOf(WorkspaceReadError);
83
+ return `${(err as WorkspaceReadError).code}: ${(err as WorkspaceReadError).message}`;
84
+ }
85
+ throw new Error("read");
86
+ };
87
+ expect(refused(decl(members({ records: [{ kind: "../decisions/decision.kind.mjs" }] })))).toMatch(/^declaration-invalid: .* is not a path inside the member/);
88
+ expect(refused(decl([{ name: "root", dir: ".", kind: "other", because: "x", records: [{ kind: "decisions/d.kind.mjs" }] }], { records: [{ kind: "decisions/d.kind.mjs" }] }))).toBe(
89
+ "declaration-invalid: the record kind decisions/d.kind.mjs is already declared at /records/0",
90
+ );
91
+ expect(refused(decl(members({ records: [{ kind: "a.kind.mjs", name: "x" }] }), { records: [{ kind: "b.kind.mjs", name: "x" }] }))).toBe(
92
+ 'declaration-invalid: the record kind name "x" is already given at /records/0',
93
+ );
94
+ expect(refused(decl(members(), { records: [{ kind: "a.kind.mjs", file: "b" }] }))).toMatch(/^declaration-invalid: unknown field "file"/);
95
+ });
96
+ });
97
+
98
+ describe("workspace ls lists the declared kinds (#2680)", () => {
99
+ const { expectValid } = contract(lsSchema);
100
+ const result = (doc: LsDocument) => {
101
+ expectValid(doc);
102
+ if ("error" in doc) throw new Error(doc.error.message);
103
+ return doc;
104
+ };
105
+
106
+ test("with each kind file's name when it loads, and the reason when it doesn't", async () => {
107
+ const root = workspace({ "design/broken.kind.mjs": "export const nothing = 1;\n" }, [
108
+ { kind: "notes/note.kind.mjs", name: "notes" },
109
+ { kind: "gone.kind.mjs" },
110
+ { kind: "broken.kind.mjs" },
111
+ ]);
112
+ const doc = result(await listWorkspaceWithKinds({ cwd: root }));
113
+ expect(doc.contract).toBe(1);
114
+ expect(doc.workspace.records).toEqual([{ name: "decision", path: "decisions/decision.kind.mjs", kind: "decision", reason: null }]);
115
+ const design = doc.members.find((m) => m.name === "design")!;
116
+ expect(design.readable).toBe(true);
117
+ expect(design.records).toEqual([
118
+ { name: "notes", path: "design/notes/note.kind.mjs", kind: "note", reason: null },
119
+ { name: null, path: "design/gone.kind.mjs", kind: null, reason: { code: "kind-unreadable", message: "kind file design/gone.kind.mjs does not exist" } },
120
+ { name: null, path: "design/broken.kind.mjs", kind: null, reason: { code: "kind-invalid", message: "kind file design/broken.kind.mjs is not a record kind: it has no recordKind export" } },
121
+ ]);
122
+ expect(doc.members.find((m) => m.name === "app")!.records).toEqual([]);
123
+ });
124
+
125
+ test("listWorkspace lists them unloaded, and a workspace that names none lists empty arrays", async () => {
126
+ const doc = result(listWorkspace({ cwd: workspace() }));
127
+ expect(doc.workspace.records).toEqual([{ name: null, path: "decisions/decision.kind.mjs", kind: null, reason: null }]);
128
+ const none = result(await listWorkspaceWithKinds({ cwd: repo({ "chant.workspace.json": decl(members()), "app/x": "", "design/x": "" }) }));
129
+ expect(none.workspace.records).toEqual([]);
130
+ expect(none.members.map((m) => m.records)).toEqual([[], []]);
131
+ });
132
+
133
+ test("--at looks for the kind file in the revision", async () => {
134
+ const root = workspace();
135
+ const at = git(root, "rev-parse", "HEAD");
136
+ git(root, "rm", "-q", "design/notes/note.kind.mjs");
137
+ commitAll(root, "drop the note kind");
138
+ const now = result(await listWorkspaceWithKinds({ cwd: root }));
139
+ expect(now.members.find((m) => m.name === "design")!.records[0].reason?.code).toBe("kind-unreadable");
140
+ // The revision still has the file; it loads from the working tree, where it is gone.
141
+ const then = result(await listWorkspaceWithKinds({ cwd: root, at }));
142
+ expect(then.members.find((m) => m.name === "design")!.records[0].reason?.code).toBe("kind-unreadable");
143
+ expect(then.members.find((m) => m.name === "design")!.records[0].reason?.message).toContain("could not be loaded");
144
+ });
145
+ });
146
+
147
+ describe("WSP115: a declared kind that doesn't load (#2680)", () => {
148
+ test("fails check with the reason, pointing at the entry", async () => {
149
+ const root = workspace({ "design/broken.kind.mjs": "export const nothing = 1;\n" }, [{ kind: "gone.kind.mjs" }, { kind: "broken.kind.mjs" }]);
150
+ const report = await runDeclarationChecks(root);
151
+ const wsp115 = report.diagnostics.filter((d) => d.ruleId === "WSP115");
152
+ expect(wsp115.map((d) => [d.entity, d.message])).toEqual([
153
+ ["design", "member design declares the record kind gone.kind.mjs, which can't be loaded: kind-unreadable: kind file design/gone.kind.mjs does not exist"],
154
+ ["design", "member design declares the record kind broken.kind.mjs, which can't be loaded: kind-invalid: kind file design/broken.kind.mjs is not a record kind: it has no recordKind export"],
155
+ ]);
156
+ expect(wsp115.every((d) => d.severity === "error" && d.line > 1)).toBe(true);
157
+ expect(report.ok).toBe(false);
158
+ });
159
+
160
+ test("finds nothing when every declared kind loads, and the check can't be suppressed", async () => {
161
+ expect((await runDeclarationChecks(workspace())).diagnostics.filter((d) => d.ruleId === "WSP115")).toEqual([]);
162
+ const root = workspace({}, [{ kind: "gone.kind.mjs" }]);
163
+ writeFiles(root, {
164
+ "chant.workspace.json": decl(members({ records: [{ kind: "gone.kind.mjs" }], suppress: [{ check: "WSP115", because: "later" }] })),
165
+ });
166
+ const ids = (await runDeclarationChecks(root)).diagnostics.map((d) => d.ruleId);
167
+ expect(ids).toContain("WSP115");
168
+ expect(ids).toContain("WSP011");
169
+ });
170
+ });
171
+
172
+ describe("records without --kind reads every declared kind (#2680)", () => {
173
+ const { expectValid } = contract(recordsSchema);
174
+ let out: string[];
175
+ let err: string[];
176
+ beforeEach(() => {
177
+ out = [];
178
+ err = [];
179
+ vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
180
+ vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => void err.push(a.join(" ")));
181
+ });
182
+ afterEach(() => vi.restoreAllMocks());
183
+ const run = (cwd: string, ...argv: string[]) => {
184
+ vi.spyOn(process, "cwd").mockReturnValue(cwd);
185
+ return runWorkspaceRecords({ args: parseArgs(["workspace", "records", ...argv]), plugins: [] } as never);
186
+ };
187
+
188
+ test("one document per kind in the declaration's order, as a set that validates", async () => {
189
+ const root = workspace();
190
+ const set = await queryDeclaredRecords(declaredKindFiles(root), { cwd: root, current: true });
191
+ expectValid(set);
192
+ expect(set.kinds.map((k) => ["error" in k ? k.error.code : k.kind.name, k.declared])).toEqual([
193
+ ["decision", { member: null, path: "decisions/decision.kind.mjs", name: null }],
194
+ ["note", { member: "design", path: "design/notes/note.kind.mjs", name: "notes" }],
195
+ ]);
196
+ expect(await run(root, "--json")).toBe(0);
197
+ const printed = JSON.parse(out.join("\n"));
198
+ expectValid(printed);
199
+ expect(printed.kinds.map((k: { records: { id: string }[] }) => k.records.map((r) => r.id))).toEqual([["ref-001"], ["ref-001"]]);
200
+ });
201
+
202
+ test("a kind that can't be read is listed with its error, the others are read, and the exit code is 1", async () => {
203
+ const root = workspace({}, [{ kind: "gone.kind.mjs" }]);
204
+ expect(await run(root, "--json")).toBe(1);
205
+ const printed = JSON.parse(out.join("\n"));
206
+ expectValid(printed);
207
+ expect(printed.kinds.map((k: { error?: { code: string }; records?: unknown[] }) => k.error?.code ?? k.records!.length)).toEqual([1, "kind-unreadable"]);
208
+ });
209
+
210
+ test("--kind overrides, and with neither --kind nor declared kinds the message is today's", async () => {
211
+ const root = workspace();
212
+ expect(await run(root, "--kind", "design/notes/note.kind.mjs", "--json")).toBe(0);
213
+ const one = JSON.parse(out.join("\n"));
214
+ expect(one.kind.name).toBe("note");
215
+ expect(one.kinds).toBeUndefined();
216
+
217
+ const bare = repo({ "chant.workspace.json": decl(members()), "app/x": "", "design/x": "" });
218
+ expect(await run(bare)).toBe(1);
219
+ expect(err.join("\n")).toContain("--kind <kind file> is required");
220
+ err.length = 0;
221
+ expect(await run(repo({ "x.md": "" }))).toBe(1);
222
+ expect(err.join("\n")).toContain("--kind <kind file> is required");
223
+ });
224
+
225
+ test("--since compares every declared kind too, one records-since document per kind (#2673)", async () => {
226
+ const root = workspace();
227
+ writeFiles(root, { "decisions/ref-009-another.md": REF_001.replace('id: "ref-001"', 'id: "ref-009"') });
228
+ expect(await run(root, "--since", "HEAD", "--json")).toBe(0);
229
+ const printed = JSON.parse(out.join("\n"));
230
+ contract(recordsSinceSchema).expectValid(printed);
231
+ expect(printed.kinds.map((k: { kind: { name: string }; declared: { path: string }; changes: { change: string; id: string }[] }) => [k.declared.path, k.kind.name, k.changes.map((c) => `${c.change} ${c.id}`)])).toEqual([
232
+ ["decisions/decision.kind.mjs", "decision", ["new ref-009"]],
233
+ ["design/notes/note.kind.mjs", "note", []],
234
+ ]);
235
+ out.length = 0;
236
+ expect(await run(root, "--since", "HEAD", "--kind", "design/notes/note.kind.mjs", "--json")).toBe(0);
237
+ expect(JSON.parse(out.join("\n")).kind.name).toBe("note");
238
+ expect(await run(root, "--since", "HEAD", "--current")).toBe(1);
239
+ });
240
+
241
+ test("text output heads each kind with its name and file", async () => {
242
+ expect(await run(workspace(), "--current")).toBe(0);
243
+ expect(out.filter((l) => l.includes("("))).toEqual(["decision (decisions/decision.kind.mjs)", "notes (design/notes/note.kind.mjs)"]);
244
+ });
245
+ });
246
+
247
+ describe("records new, amend and review without --kind (#2680)", () => {
248
+ let out: string[];
249
+ beforeEach(() => {
250
+ out = [];
251
+ vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
252
+ });
253
+ afterEach(() => vi.restoreAllMocks());
254
+ const run = async (cwd: string, ...argv: string[]) => {
255
+ vi.spyOn(process, "cwd").mockReturnValue(cwd);
256
+ const status = await runRecordsWrite({ args: parseArgs(["workspace", "records", ...argv]), plugins: [] } as never);
257
+ return { status, doc: JSON.parse(out.join("\n")) };
258
+ };
259
+
260
+ test("write through the one kind the declaration names", async () => {
261
+ const root = workspace({}, []);
262
+ const reviewed = await run(root, "review", "ref-001", "--verdict", "agree", "--by", "bob", "--dry-run");
263
+ expect(reviewed).toMatchObject({ status: 0, doc: { path: expect.stringContaining("decisions/ref-001-"), review: { reviewer: "bob", verdict: "agree" }, dryRun: true } });
264
+ out.length = 0;
265
+ writeFiles(root, { "patch.json": JSON.stringify({ title: "Changed" }) });
266
+ const amended = await run(root, "amend", "ref-001", "--set", join(root, "patch.json"), "--dry-run");
267
+ expect(amended.doc.error?.code).not.toBe("write-usage-invalid");
268
+ });
269
+
270
+ test("are refused with the kinds named when the declaration names several, and --kind picks one", async () => {
271
+ const root = workspace();
272
+ const refused = await run(root, "review", "ref-001", "--verdict", "agree", "--by", "bob", "--dry-run");
273
+ expect(refused.status).toBe(1);
274
+ expect(refused.doc.error.code).toBe("write-usage-invalid");
275
+ expect(refused.doc.error.message).toContain("the declaration names 2 record kinds (decisions/decision.kind.mjs, design/notes/note.kind.mjs), so name the one to write with --kind");
276
+ out.length = 0;
277
+ const picked = await run(root, "review", "ref-001", "--kind", join(root, "decisions/decision.kind.mjs"), "--verdict", "agree", "--by", "bob", "--dry-run");
278
+ expect(picked.status).toBe(0);
279
+ out.length = 0;
280
+ const bare = repo({ "chant.workspace.json": decl(members()), "app/x": "", "design/x": "" });
281
+ const none = await run(bare, "review", "ref-001", "--verdict", "agree", "--by", "bob");
282
+ expect(none.doc.error.message).toMatch(/^--kind <kind file> is required/);
283
+ });
284
+ });
285
+
286
+ describe("graph --intent without --kind reads every declared kind (#2680)", () => {
287
+ const { expectValid } = contract(intentSchema);
288
+ /** Both kinds join a commit's Unit trailer: each to its own unit, and both to one contract. */
289
+ const joins = (unit: string, status: string) =>
290
+ `\nexport function commitJoins(commit) {\n const id = commit.trailers["Unit"]?.[0];\n if (!id) return undefined;\n return { unit: { id: "${unit}-" + id }, contract: { id: "C-1", status: "${status}" } };\n}\n`;
291
+
292
+ test("in the declaration's order, and when two kinds join one commit both joins apply and the first kind's node data wins", async () => {
293
+ const root = workspace({
294
+ "decisions/decision.kind.mjs": DECISION_KIND + joins("D", "from-decisions"),
295
+ "design/notes/note.kind.mjs": NOTE_KIND + joins("N", "from-notes"),
296
+ });
297
+ writeFiles(root, { "app/server.mjs": "export const port = 9090;\n" });
298
+ git(root, "add", "-A");
299
+ git(root, "commit", "-q", "-m", "move the port", "-m", "Unit: U-1");
300
+ const sha = git(root, "rev-parse", "HEAD");
301
+
302
+ const { doc } = await intentGraph({ cwd: root, region: "app/server.mjs" });
303
+ expectValid(doc);
304
+ if ("error" in doc) throw new Error(doc.error.message);
305
+ expect(doc.kinds).toEqual([
306
+ { file: "decisions/decision.kind.mjs", name: "decision", records: "decision", joins: "function" },
307
+ { file: "design/notes/note.kind.mjs", name: "note", records: "note", joins: "function" },
308
+ ]);
309
+ const produced = doc.edges.filter((e) => e.kind === "produced-by" && e.from === `commit:${sha}`).map((e) => e.to);
310
+ expect(produced).toEqual(["unit:D-U-1", "unit:N-U-1"]);
311
+ const contractNode = doc.nodes.find((n) => n.id === "contract:C-1") as unknown as { plugin: string; data: { status: string } };
312
+ expect(contractNode.plugin).toBe("decisions/decision.kind.mjs");
313
+ expect(contractNode.data.status).toBe("from-decisions");
314
+
315
+ // --kind overrides the declaration, and an empty list reads none.
316
+ const one = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, "design/notes/note.kind.mjs")] });
317
+ expect("error" in one.doc ? [] : one.doc.kinds.map((k) => k.name)).toEqual(["note"]);
318
+ const none = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [] });
319
+ expect("error" in none.doc ? null : none.doc.kinds).toEqual([]);
320
+ });
321
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The record kinds a declaration names, loaded (#2680).
3
+ *
4
+ * `chant.workspace.json` may name the workspace's record kinds, in the
5
+ * top-level `records` and in each member's `records`, so a reader never has to
6
+ * guess which kind files exist. `chant workspace ls` lists them, `check` fails
7
+ * on one that doesn't load (WSP115), and `records` and `graph --intent` read
8
+ * every one of them when `--kind` is not given.
9
+ *
10
+ * A kind file is looked for in the tree read, which is the revision under
11
+ * `--at`, and loaded from the working tree, as `records --kind` loads one.
12
+ * Loading imports the file, so it runs only where a command already runs a
13
+ * kind file.
14
+ */
15
+
16
+ import { join } from "node:path";
17
+ import { declaredRecordKinds, type Declaration, type RecordKindDeclaration } from "./declaration";
18
+ import type { ReasonCode } from "./reason-codes";
19
+ import type { WorkspaceTree } from "./tree";
20
+
21
+ /** Why a declared kind can't be loaded: the codes `loadRecordKind` fails with. Closed, like every list of codes. */
22
+ export const DECLARED_KIND_REASON_CODES = ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch"] as const satisfies readonly ReasonCode[];
23
+ export type DeclaredKindReasonCode = (typeof DECLARED_KIND_REASON_CODES)[number];
24
+
25
+ export interface DeclaredKind {
26
+ declared: RecordKindDeclaration;
27
+ /** The kind file on disk, absolute. */
28
+ file: string;
29
+ /** The kind file's `recordKind.name`, or null when it wasn't loaded. */
30
+ kind: string | null;
31
+ /** Why it can't be loaded, or null. */
32
+ reason: { code: DeclaredKindReasonCode; message: string } | null;
33
+ }
34
+
35
+ /** The kind file of `declared` on disk, under the workspace root `rootOnDisk`. */
36
+ export function declaredKindFile(declared: RecordKindDeclaration, rootOnDisk: string): string {
37
+ return join(rootOnDisk, ...declared.path.split("/"));
38
+ }
39
+
40
+ /**
41
+ * Every kind the declaration names, in {@link declaredRecordKinds} order, each
42
+ * with its name or the reason it can't be loaded. A kind file missing from
43
+ * `tree` is `kind-unreadable` without a load. With `load: false`, nothing is
44
+ * imported and only that is checked.
45
+ */
46
+ export async function loadDeclaredKinds(
47
+ declaration: Declaration,
48
+ tree: WorkspaceTree,
49
+ rootOnDisk: string,
50
+ options: { load?: boolean } = {},
51
+ ): Promise<DeclaredKind[]> {
52
+ const declared = declaredRecordKinds(declaration);
53
+ if (declared.length === 0) return [];
54
+ const records = options.load === false ? undefined : await import("./records");
55
+ const out: DeclaredKind[] = [];
56
+ for (const d of declared) {
57
+ const file = declaredKindFile(d, rootOnDisk);
58
+ if (tree.stat(d.path) !== "file") {
59
+ out.push({ declared: d, file, kind: null, reason: { code: "kind-unreadable", message: `kind file ${d.path} does not exist${tree.label}` } });
60
+ continue;
61
+ }
62
+ if (!records) {
63
+ out.push({ declared: d, file, kind: null, reason: null });
64
+ continue;
65
+ }
66
+ try {
67
+ const loaded = await records.loadRecordKind(file);
68
+ out.push({ declared: d, file, kind: loaded.kind.name, reason: null });
69
+ } catch (err) {
70
+ if (!(err instanceof records.RecordReadError)) throw err;
71
+ // loadRecordKind names the file as it was given, here an absolute path: name it from the workspace root instead.
72
+ out.push({ declared: d, file, kind: null, reason: { code: err.code as DeclaredKindReasonCode, message: err.message.split(file).join(d.path) } });
73
+ }
74
+ }
75
+ return out;
76
+ }
@@ -85,6 +85,13 @@ export interface GraphQuery {
85
85
  * in {@link GraphResult.components}. The document is unchanged.
86
86
  */
87
87
  components?: boolean;
88
+ /**
89
+ * Called once the members have run, with the directory they were read from
90
+ * (the exported tree for `--at`, before it is removed) and the declared
91
+ * members the read covers. `--composites` reads each member's runtimes
92
+ * here (#2674).
93
+ */
94
+ inTree?: (root: string, members: readonly { name: string; dir: string; kind: string }[]) => Promise<void>;
88
95
  }
89
96
 
90
97
  export interface GraphResult {
@@ -198,6 +205,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
198
205
  executePlan(plan, args),
199
206
  query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
200
207
  ]);
208
+ if (query.inTree) await query.inTree(exported ?? located.rootOnDisk, declaration.members.filter((m) => !query.members?.length || query.members.includes(m.name)));
201
209
  for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
202
210
  for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
203
211
  const { inputs, failed } = compose(plan, results, query.members, declaration.members);
@@ -5,13 +5,14 @@
5
5
  * section B: the region, its decisions, their artifacts, the commits, and the
6
6
  * findings. Under each decision come the commits made inside its window, and
7
7
  * when any of them is not the decision's own work, the question #2650 B puts
8
- * to the person about it (#2656).
8
+ * to the person about it (#2656). Without `--kind`, the walk reads every
9
+ * record kind the declaration names (#2680).
9
10
  */
10
11
 
11
12
  import { resolve } from "node:path";
12
13
  import { formatError } from "../cli/format";
13
14
  import type { CommandContext } from "../cli/registry";
14
- import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
15
+ import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode, type WorkNode } from "./intent";
15
16
 
16
17
  const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
17
18
 
@@ -57,6 +58,22 @@ function decisionLine(d: DecisionNode): string {
57
58
  return `decision ${d.record} ${d.state ?? "stateless"}${superseded}: ${d.title ?? d.path}; constrains ${via}${by}; ${reviews}; ${d.provenance.level}${d.valid ? "" : `; invalid: ${d.reasons.map((r) => r.code).join(", ")}`}`;
58
59
  }
59
60
 
61
+ /** A work item, then the commits made inside its window (#2683). */
62
+ function workLines(doc: Result, w: WorkNode): string[] {
63
+ const via = w.constrains.length > 0 ? w.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through a link only";
64
+ const implemented = w.implements.length > 0 ? `; implements ${w.implements.map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ")}` : "";
65
+ const readiness = w.ready ? "; ready" : w.blockedBy.length > 0 ? `; blocked by ${w.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ")}` : "";
66
+ const owner = w.owner ? `, owned by ${w.owner}` : "";
67
+ const from = w.source ? `; from ${w.source.finding} on ${w.source.region}` : "";
68
+ const out = [`work ${w.record} ${w.state ?? "stateless"}${owner}: ${w.title ?? w.path}; constrains ${via}${implemented}${readiness}${from}`];
69
+ for (const e of doc.edges.filter((x) => x.kind === "within" && x.to === w.id)) {
70
+ const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
71
+ if (c) out.push(` worked ${short(c.sha)} ${c.subject}; in ${w.record}'s window`);
72
+ }
73
+ for (const x of w.warnings) out.push(` warning ${x.code}: ${x.message}`);
74
+ return out;
75
+ }
76
+
60
77
  function artifactLine(doc: Result, a: ArtifactNode): string {
61
78
  const by = doc.edges
62
79
  .filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
@@ -88,16 +105,21 @@ export function formatIntent(doc: Result): string {
88
105
  const files = ofKind(doc, "file");
89
106
  if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
90
107
  for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
108
+ for (const w of ofKind(doc, "work")) out.push(...workLines(doc, w));
91
109
  for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
92
110
  for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
93
111
  for (const l of ofKind(doc, "link")) {
94
112
  const r = l.row;
95
113
  out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
96
114
  }
97
- for (const f of ofKind(doc, "finding")) out.push(`finding ${f.code}: ${f.message}`);
115
+ for (const f of ofKind(doc, "finding")) {
116
+ const by = f.addressedBy && f.addressedBy.length > 0 ? `; addressed by ${f.addressedBy.map((w) => `${w.id} (${w.state ?? "unknown"})`).join(", ")}` : "";
117
+ out.push(`finding ${f.code}: ${f.message}${by}`);
118
+ }
98
119
  for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
99
120
  const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
100
- out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
121
+ const work = ofKind(doc, "work").length;
122
+ out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${work > 0 ? `${work} work ${work === 1 ? "item" : "items"}, ` : ""}${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
101
123
  return out.join("\n");
102
124
  }
103
125
 
@@ -107,8 +129,9 @@ export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Prom
107
129
  console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
108
130
  return 1;
109
131
  }
110
- const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : []);
111
- const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds.map((k) => resolve(k)) });
132
+ // No --kind: undefined, so the walk reads the kinds the declaration names (#2680).
133
+ const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : undefined);
134
+ const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds?.map((k) => resolve(k)) });
112
135
  if (args.json) console.log(JSON.stringify(doc, null, 2));
113
136
  if ("error" in doc) {
114
137
  console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));