@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
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Workspace reader conformance (#2657, ws-052; published by #2679).
3
+ *
4
+ * A reader of a workspace, such as hud or behold, reads only through the
5
+ * read contract (#2524 D15, ws-017) and writes nothing. This module holds a
6
+ * reader to that, for each read-contract command it lists:
7
+ *
8
+ * 1. The reader's `read(command, args)` returns a document that validates
9
+ * against that command's output schema, with the contract version this
10
+ * chant writes.
11
+ * 2. Each read makes exactly one chant call, and that call is the contract
12
+ * command with the suite's arguments and the command's JSON flag, and
13
+ * nothing else.
14
+ * 3. What the reader returns is the document chant printed, unchanged.
15
+ * 4. The workspace's files are byte for byte the same after every read.
16
+ *
17
+ * How "touched nothing else" is checked: the reader is built by calling
18
+ * `reader(chant)`, where `chant` is a recording transport. The reader is
19
+ * never given the workspace's path, so the transport is its only way to the
20
+ * workspace, and every call through it is recorded and compared with the
21
+ * contract command (points 2 and 3). Every file under the workspace is
22
+ * hashed before the first read and after the last one (point 4), which
23
+ * catches a write by any route, the transport included.
24
+ *
25
+ * This module ships in `@intentius/chant` as the subpath
26
+ * `@intentius/chant/workspace/conformance` and imports no test runner.
27
+ * {@link runWorkspaceReaderConformance} returns the problems for `node:test`
28
+ * or any runner, {@link checkReaderRead} checks one read, and
29
+ * `@intentius/chant/workspace/conformance/vitest` wraps the same checks in
30
+ * `describe` and `it`.
31
+ *
32
+ * The workspace: inside the chant repository it is `reference-workspace/`.
33
+ * Anywhere else the suite generates one in a temporary directory from the
34
+ * `__fixture__/` this package ships beside this module (a decision kind and record, an
35
+ * app, and a chant member with one composite and one component), declared
36
+ * by `chant workspace init --yes`, so {@link REFERENCE_READS} resolve in
37
+ * both. `workspaceDir` names another workspace with the same files.
38
+ */
39
+
40
+ import { execFileSync, spawn } from "node:child_process";
41
+ import { createHash } from "node:crypto";
42
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
43
+ import { createRequire } from "node:module";
44
+ import { tmpdir } from "node:os";
45
+ import { dirname, join, resolve } from "node:path";
46
+ import { fileURLToPath, pathToFileURL } from "node:url";
47
+ import { isDeepStrictEqual } from "node:util";
48
+ import { READ_CONTRACT_VERSION } from "../reason-codes";
49
+
50
+ /** The read-contract commands, as a reader names them. */
51
+ export const READ_CONTRACT_COMMANDS = ["ls", "graph", "check", "status", "records", "graph --intent", "graph --composites"] as const;
52
+ export type ReadContractCommand = (typeof READ_CONTRACT_COMMANDS)[number];
53
+
54
+ /**
55
+ * Each command's output schema, beside this module's parent in `src/workspace/`.
56
+ * The write commands' schemas (`records-new`, `records-amend`,
57
+ * `records-review`, #2675) are not here: a reader never runs them.
58
+ */
59
+ export const READ_CONTRACT_SCHEMAS: Record<ReadContractCommand, string> = {
60
+ ls: "ls.schema.json",
61
+ graph: "graph.schema.json",
62
+ check: "check.schema.json",
63
+ status: "status.schema.json",
64
+ records: "records.schema.json",
65
+ "graph --intent": "intent.schema.json",
66
+ "graph --composites": "composites.schema.json",
67
+ };
68
+
69
+ /** The flags that ask each command for its JSON document. A reader adds one of these and nothing else. */
70
+ export const READ_CONTRACT_JSON_FLAGS: Record<ReadContractCommand, readonly (readonly string[])[]> = {
71
+ ls: [["--json"]],
72
+ graph: [[], ["--json"]],
73
+ check: [["--format", "json"]],
74
+ status: [["--json"]],
75
+ records: [["--json"]],
76
+ "graph --intent": [["--json"]],
77
+ "graph --composites": [[], ["--json"]],
78
+ };
79
+
80
+ /** The arguments the suite passes each read, on the reference workspace or the generated one. */
81
+ export const REFERENCE_READS: Record<ReadContractCommand, string[]> = {
82
+ ls: [],
83
+ graph: ["--kind", "decisions/decision.kind.mjs"],
84
+ check: [],
85
+ status: ["dev"],
86
+ records: ["--kind", "decisions/decision.kind.mjs"],
87
+ "graph --intent": ["app/src/server.mjs:19", "--kind", "decisions/decision.kind.mjs"],
88
+ "graph --composites": [],
89
+ };
90
+
91
+ /** One chant run: the arguments after `chant`, and what it printed. */
92
+ export interface ChantRun {
93
+ argv: string[];
94
+ status: number | null;
95
+ stdout: string;
96
+ stderr: string;
97
+ }
98
+
99
+ /** How a reader runs chant. It runs in the workspace; the reader never sees the path. */
100
+ export interface ChantTransport {
101
+ run(argv: string[]): Promise<ChantRun>;
102
+ }
103
+
104
+ export interface WorkspaceReader {
105
+ /** Run one read-contract command with `args`, and return the parsed JSON document. */
106
+ read(command: ReadContractCommand, args: string[]): Promise<unknown> | unknown;
107
+ }
108
+
109
+ /** Build the reader over the transport the suite gives it. */
110
+ export type WorkspaceReaderFactory = (chant: ChantTransport) => WorkspaceReader;
111
+
112
+ export interface WorkspaceReaderConformanceOptions {
113
+ /**
114
+ * The commands the reader reads. Only these are exercised; any other is
115
+ * not applicable and is reported as skipped. Defaults to every command in
116
+ * {@link READ_CONTRACT_COMMANDS}.
117
+ */
118
+ commands?: readonly ReadContractCommand[];
119
+ /**
120
+ * The workspace to read. Defaults to the chant repository's
121
+ * reference-workspace inside that repository, and to a workspace generated
122
+ * from this package's conformance fixture anywhere else.
123
+ */
124
+ workspaceDir?: string;
125
+ /**
126
+ * The chant to run, as a command and its leading arguments. Defaults to the
127
+ * checkout's CLI through tsx inside the chant repository, and anywhere else
128
+ * to the first `node_modules/.bin/chant` above the current directory, then
129
+ * this package's own `bin/chant`, then `chant` on PATH.
130
+ */
131
+ chantCommand?: string[];
132
+ /** How long one chant run may take, in milliseconds. Default 120000. */
133
+ timeoutMs?: number;
134
+ }
135
+
136
+ export interface WorkspaceReaderConformanceConfig extends WorkspaceReaderConformanceOptions {
137
+ /** Short label, used in the suite name. */
138
+ name: string;
139
+ reader: WorkspaceReaderFactory;
140
+ }
141
+
142
+ /** What one read found. */
143
+ export interface ReaderReadResult {
144
+ command: ReadContractCommand;
145
+ args: string[];
146
+ problems: string[];
147
+ }
148
+
149
+ /** What {@link runWorkspaceReaderConformance} found. The reader conforms when `problems` is empty. */
150
+ export interface WorkspaceReaderConformanceReport {
151
+ /** Every problem, each starting with the command it concerns, or `workspace:` for a changed file. */
152
+ problems: string[];
153
+ /** The commands read, in contract order. */
154
+ checked: ReadContractCommand[];
155
+ /** The commands not in `commands`, so not applicable to this reader. */
156
+ skipped: ReadContractCommand[];
157
+ results: ReaderReadResult[];
158
+ /** The workspace that was read. A generated one is removed before the report is returned. */
159
+ workspaceDir: string;
160
+ }
161
+
162
+ const here = dirname(fileURLToPath(import.meta.url));
163
+ /** The package this module ships in: `packages/core` in the repository, `node_modules/@intentius/chant` when installed. */
164
+ const packageRoot = resolve(here, "..", "..", "..");
165
+ const workspaceSrc = resolve(here, "..");
166
+ /**
167
+ * The fixture the generated workspace is copied from, shipped under `src/`.
168
+ * Its `__fixture__` name keeps `chant workspace init` on the chant repository
169
+ * from proposing its projects as members.
170
+ */
171
+ export const CONFORMANCE_FIXTURE_DIR = join(here, "__fixture__");
172
+
173
+ /** The chant repository this module is part of, when it is not an installed copy. */
174
+ function chantCheckout(): string | undefined {
175
+ const root = resolve(packageRoot, "..", "..");
176
+ const inCheckout =
177
+ existsSync(join(root, "reference-workspace", "chant.workspace.json")) &&
178
+ existsSync(join(packageRoot, "src", "cli", "main.ts")) &&
179
+ existsSync(join(root, "node_modules", "tsx", "dist", "loader.mjs"));
180
+ return inCheckout ? root : undefined;
181
+ }
182
+
183
+ /** The chant the suite runs when `chantCommand` is not given. */
184
+ export function defaultChantCommand(cwd: string = process.cwd()): string[] {
185
+ const checkout = chantCheckout();
186
+ if (checkout) {
187
+ return [process.execPath, "--import", pathToFileURL(join(checkout, "node_modules", "tsx", "dist", "loader.mjs")).href, join(packageRoot, "src", "cli", "main.ts")];
188
+ }
189
+ for (let at = resolve(cwd); ; at = dirname(at)) {
190
+ const bin = join(at, "node_modules", ".bin", "chant");
191
+ if (existsSync(bin)) return [bin];
192
+ if (dirname(at) === at) break;
193
+ }
194
+ // The chant this module shipped with, so the suite and the chant agree on the contract.
195
+ const own = join(packageRoot, "bin", "chant");
196
+ return [existsSync(own) ? own : "chant"];
197
+ }
198
+
199
+ /** The reference workspace in the chant repository, or undefined in an installed copy. */
200
+ export function referenceWorkspaceDir(): string | undefined {
201
+ const checkout = chantCheckout();
202
+ return checkout ? join(checkout, "reference-workspace") : undefined;
203
+ }
204
+
205
+ type Validate = ((d: unknown) => boolean) & { errors?: unknown };
206
+ const validators = new Map<ReadContractCommand, { validate: Validate; id: string }>();
207
+
208
+ /** A command's output schema and a draft 2020-12 validator for it, from this package's own ajv 8. */
209
+ export function readContractSchema(command: ReadContractCommand): { schema: { $id: string }; validate: Validate } {
210
+ const schema = JSON.parse(readFileSync(join(workspaceSrc, READ_CONTRACT_SCHEMAS[command]), "utf-8")) as { $id: string };
211
+ let v = validators.get(command);
212
+ if (!v) {
213
+ const mod = createRequire(import.meta.url)("ajv/dist/2020") as { default?: unknown };
214
+ const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): Validate };
215
+ v = { validate: new Ajv({ strict: true, allErrors: true }).compile(schema), id: schema.$id };
216
+ validators.set(command, v);
217
+ }
218
+ return { schema, validate: v.validate };
219
+ }
220
+
221
+ /** A digest of every file under `dir`, skipping node_modules. */
222
+ export function treeDigest(dir: string): Record<string, string> {
223
+ const out: Record<string, string> = {};
224
+ const walk = (at: string, prefix: string) => {
225
+ for (const e of readdirSync(at, { withFileTypes: true })) {
226
+ if (e.name === "node_modules") continue;
227
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
228
+ if (e.isDirectory()) walk(join(at, e.name), rel);
229
+ else if (e.isFile()) out[rel] = createHash("sha256").update(readFileSync(join(at, e.name))).digest("hex");
230
+ }
231
+ };
232
+ walk(dir, "");
233
+ return out;
234
+ }
235
+
236
+ /** The files that differ between two {@link treeDigest}s: added, removed or changed. */
237
+ export function treeChanges(before: Record<string, string>, after: Record<string, string>): string[] {
238
+ const out: string[] = [];
239
+ for (const [f, h] of Object.entries(after)) if (before[f] === undefined) out.push(`${f} (added)`);
240
+ else if (before[f] !== h) out.push(`${f} (changed)`);
241
+ for (const f of Object.keys(before)) if (after[f] === undefined) out.push(`${f} (removed)`);
242
+ return out.sort();
243
+ }
244
+
245
+ /**
246
+ * What is wrong with the chant calls one read made: anything but exactly one
247
+ * call, of `workspace <command>` with `args` in order and the command's JSON
248
+ * flag, and nothing else. Empty when the calls conform.
249
+ */
250
+ export function readerCallProblems(command: ReadContractCommand, args: string[], calls: string[][]): string[] {
251
+ if (calls.length !== 1) return [`${command}: made ${calls.length} chant calls (${calls.map((c) => c.join(" ")).join("; ")}), expected exactly one`];
252
+ const argv = calls[0];
253
+ const prefix = ["workspace", ...command.split(" ")];
254
+ if (prefix.some((t, i) => argv[i] !== t)) return [`${command}: ran chant ${argv.join(" ")}, which is not workspace ${command}`];
255
+ const rest = argv.slice(prefix.length);
256
+ const at = rest.findIndex((_, i) => args.every((a, j) => rest[i + j] === a));
257
+ if (args.length > 0 && at === -1) return [`${command}: ran chant ${argv.join(" ")}, which does not pass ${args.join(" ")} in order`];
258
+ const extra = args.length > 0 ? [...rest.slice(0, at), ...rest.slice(at + args.length)] : rest;
259
+ const allowed = READ_CONTRACT_JSON_FLAGS[command];
260
+ if (!allowed.some((flags) => flags.length === extra.length && flags.every((f, i) => extra[i] === f))) {
261
+ return [`${command}: ran chant ${argv.join(" ")}; beyond the command and its arguments it may add only ${allowed.map((f) => (f.length ? f.join(" ") : "nothing")).join(" or ")}, and it added ${extra.join(" ") || "nothing"}`];
262
+ }
263
+ return [];
264
+ }
265
+
266
+ /**
267
+ * Everything wrong with one read: the chant calls it made (`calls`, and
268
+ * `printed`, what each printed), and the document the reader returned
269
+ * (`doc`). Checks points 1 to 3 of the suite; empty when the read conforms.
270
+ */
271
+ export function checkReaderRead(command: ReadContractCommand, args: string[], calls: string[][], printed: ChantRun[], doc: unknown): string[] {
272
+ const problems = readerCallProblems(command, args, calls);
273
+ if (problems.length > 0) return problems;
274
+ const run = printed[0];
275
+ if (!run || run.stdout.trim() === "") return [`${command}: chant ${calls[0].join(" ")} printed nothing${run?.stderr ? `; stderr: ${run.stderr.trim()}` : ""}`];
276
+ let parsed: unknown;
277
+ try {
278
+ parsed = JSON.parse(run.stdout);
279
+ } catch (e) {
280
+ return [`${command}: chant ${calls[0].join(" ")} printed something that is not JSON (${(e as Error).message}); stderr: ${run.stderr.trim()}`];
281
+ }
282
+ if (!isDeepStrictEqual(doc, parsed)) problems.push(`${command}: the reader must return the document chant printed, unchanged, and it returned something else`);
283
+ const { schema, validate } = readContractSchema(command);
284
+ if (!validate(doc)) problems.push(`${command}: the document does not validate against ${READ_CONTRACT_SCHEMAS[command]}: ${JSON.stringify(validate.errors)}`);
285
+ const head = (doc ?? {}) as { $schema?: unknown; contract?: unknown };
286
+ if (head.$schema !== schema.$id) problems.push(`${command}: $schema is ${JSON.stringify(head.$schema)}, expected ${schema.$id}`);
287
+ if (head.contract !== READ_CONTRACT_VERSION) problems.push(`${command}: contract is ${JSON.stringify(head.contract)}, expected ${READ_CONTRACT_VERSION}`);
288
+ return problems;
289
+ }
290
+
291
+ /** The commands to exercise and the ones skipped, from `commands`. Throws on a name that is not a contract command. */
292
+ export function selectCommands(commands?: readonly ReadContractCommand[]): { checked: ReadContractCommand[]; skipped: ReadContractCommand[] } {
293
+ if (commands === undefined) return { checked: [...READ_CONTRACT_COMMANDS], skipped: [] };
294
+ const unknown = commands.filter((c) => !(READ_CONTRACT_COMMANDS as readonly string[]).includes(c));
295
+ if (unknown.length > 0) throw new Error(`not read-contract commands: ${unknown.join(", ")}; the commands are ${READ_CONTRACT_COMMANDS.join(", ")}`);
296
+ if (commands.length === 0) throw new Error("commands is empty; list at least one read-contract command");
297
+ return { checked: READ_CONTRACT_COMMANDS.filter((c) => commands.includes(c)), skipped: READ_CONTRACT_COMMANDS.filter((c) => !commands.includes(c)) };
298
+ }
299
+
300
+ const GIT_ENV = { GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
301
+
302
+ function git(cwd: string, ...args: string[]): void {
303
+ execFileSync("git", ["-c", "user.name=chant", "-c", "user.email=chant@localhost", "-c", "commit.gpgsign=false", ...args], {
304
+ cwd,
305
+ env: { ...process.env, ...GIT_ENV },
306
+ stdio: ["ignore", "pipe", "pipe"],
307
+ });
308
+ }
309
+
310
+ /** A workspace generated for a conformance run, and how to remove it. */
311
+ export interface ConformanceWorkspace {
312
+ dir: string;
313
+ dispose(): void;
314
+ }
315
+
316
+ /**
317
+ * Generate the conformance workspace in a temporary directory: copy
318
+ * `__fixture__/`, link this package in as `node_modules/@intentius/chant`
319
+ * so the chant member resolves it, declare the workspace with
320
+ * `chant workspace init --yes --name conformance` and commit it all to a new
321
+ * git repository on `main`.
322
+ */
323
+ export function createConformanceWorkspace(options: { chantCommand?: string[]; timeoutMs?: number } = {}): ConformanceWorkspace {
324
+ const chant = options.chantCommand ?? defaultChantCommand();
325
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "chant-reader-conformance-")));
326
+ const dispose = () => rmSync(scratch, { recursive: true, force: true });
327
+ try {
328
+ const dir = join(scratch, "ws");
329
+ cpSync(CONFORMANCE_FIXTURE_DIR, dir, { recursive: true });
330
+ // npm leaves .gitignore out of a package, so it is written here.
331
+ writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
332
+ mkdirSync(join(dir, "node_modules", "@intentius"), { recursive: true });
333
+ symlinkSync(packageRoot, join(dir, "node_modules", "@intentius", "chant"), "junction");
334
+ git(dir, "init", "--quiet", "--initial-branch=main");
335
+ execFileSync(chant[0], [...chant.slice(1), "workspace", "init", "--yes", "--name", "conformance"], {
336
+ cwd: dir,
337
+ env: { ...process.env, ...GIT_ENV, NO_COLOR: "1" },
338
+ stdio: ["ignore", "pipe", "pipe"],
339
+ timeout: options.timeoutMs ?? 120_000,
340
+ });
341
+ git(dir, "add", "-A");
342
+ git(dir, "commit", "--quiet", "-m", "the reader conformance workspace");
343
+ return { dir, dispose };
344
+ } catch (e) {
345
+ dispose();
346
+ const err = e as Error & { stderr?: Buffer | string };
347
+ throw new Error(`could not generate the reader conformance workspace: ${err.message}${err.stderr ? `\n${String(err.stderr)}` : ""}`);
348
+ }
349
+ }
350
+
351
+ /** The workspace and chant a run uses, from its options. `dispose` removes a generated workspace. */
352
+ export function conformanceTarget(options: WorkspaceReaderConformanceOptions = {}): { workspaceDir: string; chantCommand: string[]; dispose(): void } {
353
+ const chantCommand = options.chantCommand ?? defaultChantCommand();
354
+ const given = options.workspaceDir ?? referenceWorkspaceDir();
355
+ if (given) return { workspaceDir: given, chantCommand, dispose: () => {} };
356
+ const ws = createConformanceWorkspace({ chantCommand, timeoutMs: options.timeoutMs });
357
+ return { workspaceDir: ws.dir, chantCommand, dispose: ws.dispose };
358
+ }
359
+
360
+ /** Run `command` in `cwd`, collecting what it printed. Resolves, never rejects. */
361
+ function runChant(command: string[], argv: string[], cwd: string, timeoutMs: number): Promise<ChantRun> {
362
+ return new Promise((settle) => {
363
+ const child = spawn(command[0], [...command.slice(1), ...argv], { cwd, env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
364
+ let stdout = "";
365
+ let stderr = "";
366
+ child.stdout.setEncoding("utf-8").on("data", (s: string) => (stdout += s));
367
+ child.stderr.setEncoding("utf-8").on("data", (s: string) => (stderr += s));
368
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
369
+ child.on("error", (e) => {
370
+ clearTimeout(timer);
371
+ settle({ argv: [...argv], status: null, stdout, stderr: `${stderr}${e.message}` });
372
+ });
373
+ child.on("close", (status) => {
374
+ clearTimeout(timer);
375
+ settle({ argv: [...argv], status, stdout, stderr });
376
+ });
377
+ });
378
+ }
379
+
380
+ /**
381
+ * A transport that runs chant in the workspace `target()` names and records
382
+ * every call and what it printed. `reset()` clears the record before a read.
383
+ */
384
+ export function recordingTransport(target: () => { workspaceDir: string; chantCommand: string[] }, timeoutMs = 120_000) {
385
+ const calls: string[][] = [];
386
+ const printed: ChantRun[] = [];
387
+ const transport: ChantTransport = {
388
+ async run(argv) {
389
+ calls.push([...argv]);
390
+ const t = target();
391
+ const run = await runChant(t.chantCommand, argv, t.workspaceDir, timeoutMs);
392
+ printed.push(run);
393
+ return run;
394
+ },
395
+ };
396
+ return {
397
+ transport,
398
+ calls,
399
+ printed,
400
+ reset() {
401
+ calls.length = 0;
402
+ printed.length = 0;
403
+ },
404
+ };
405
+ }
406
+
407
+ /** Make one read through a recording transport and check it. A reader that throws is a problem, not an error. */
408
+ export async function readAndCheck(reader: WorkspaceReader, recorder: ReturnType<typeof recordingTransport>, command: ReadContractCommand): Promise<ReaderReadResult> {
409
+ const args = [...REFERENCE_READS[command]];
410
+ recorder.reset();
411
+ let doc: unknown;
412
+ try {
413
+ doc = await reader.read(command, [...args]);
414
+ } catch (e) {
415
+ const stderr = recorder.printed[0]?.stderr.trim();
416
+ return { command, args, problems: [`${command}: the reader threw: ${(e as Error).message}${stderr ? `; chant's stderr: ${stderr}` : ""}`] };
417
+ }
418
+ return { command, args, problems: checkReaderRead(command, args, recorder.calls, recorder.printed, doc) };
419
+ }
420
+
421
+ /**
422
+ * Hold a reader to the read contract, with no test runner: read each listed
423
+ * command once through a recording transport, check the four points, and
424
+ * return what is wrong. The reader conforms when `problems` is empty.
425
+ *
426
+ * ```js
427
+ * import { test } from "node:test";
428
+ * import assert from "node:assert/strict";
429
+ * import { runWorkspaceReaderConformance } from "@intentius/chant/workspace/conformance";
430
+ *
431
+ * test("my reader reads only through the contract", { timeout: 600_000 }, async () => {
432
+ * const report = await runWorkspaceReaderConformance(myReader, { commands: ["ls", "status"] });
433
+ * assert.deepEqual(report.problems, []);
434
+ * });
435
+ * ```
436
+ */
437
+ export async function runWorkspaceReaderConformance(reader: WorkspaceReaderFactory, options: WorkspaceReaderConformanceOptions = {}): Promise<WorkspaceReaderConformanceReport> {
438
+ const { checked, skipped } = selectCommands(options.commands);
439
+ const target = conformanceTarget(options);
440
+ try {
441
+ const recorder = recordingTransport(() => target, options.timeoutMs);
442
+ const built = reader(recorder.transport);
443
+ const before = treeDigest(target.workspaceDir);
444
+ const results: ReaderReadResult[] = [];
445
+ for (const command of checked) results.push(await readAndCheck(built, recorder, command));
446
+ const changed = treeChanges(before, treeDigest(target.workspaceDir));
447
+ const problems = results.flatMap((r) => r.problems);
448
+ if (changed.length > 0) problems.push(`workspace: the reads changed files: ${changed.join(", ")}`);
449
+ return { problems, checked, skipped, results, workspaceDir: target.workspaceDir };
450
+ } finally {
451
+ target.dispose();
452
+ }
453
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The workspace reader conformance suite for vitest (#2657, #2679):
3
+ * {@link describeWorkspaceReaderConformance} wraps the runner-neutral checks
4
+ * of `./index` in `describe` and `it`, one test per listed command and one
5
+ * for the workspace's files. This is the only module of the suite that
6
+ * imports vitest; `@intentius/chant/workspace/conformance` imports no runner.
7
+ */
8
+
9
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
10
+ import {
11
+ conformanceTarget,
12
+ READ_CONTRACT_COMMANDS,
13
+ READ_CONTRACT_SCHEMAS,
14
+ readAndCheck,
15
+ recordingTransport,
16
+ selectCommands,
17
+ treeChanges,
18
+ treeDigest,
19
+ type WorkspaceReaderConformanceConfig,
20
+ } from "./index";
21
+
22
+ export function describeWorkspaceReaderConformance(config: WorkspaceReaderConformanceConfig): void {
23
+ const { checked } = selectCommands(config.commands);
24
+ let target: ReturnType<typeof conformanceTarget> | undefined;
25
+ const recorder = recordingTransport(() => {
26
+ if (!target) throw new Error("the conformance workspace is not ready");
27
+ return target;
28
+ }, config.timeoutMs);
29
+
30
+ describe(`workspace reader conformance (#2657): ${config.name}`, () => {
31
+ let before: Record<string, string> | undefined;
32
+ const reader = config.reader(recorder.transport);
33
+
34
+ beforeAll(() => {
35
+ target = conformanceTarget(config);
36
+ }, 300_000);
37
+ afterAll(() => target?.dispose());
38
+
39
+ for (const command of READ_CONTRACT_COMMANDS) {
40
+ if (!checked.includes(command)) {
41
+ it.skip(`${command}: not applicable, the reader does not list it in commands`, () => {});
42
+ continue;
43
+ }
44
+ it(
45
+ `${command}: reads through chant workspace ${command} alone, and returns a document that validates against ${READ_CONTRACT_SCHEMAS[command]}`,
46
+ async () => {
47
+ before ??= treeDigest(target!.workspaceDir);
48
+ const result = await readAndCheck(reader, recorder, command);
49
+ expect(result.problems).toEqual([]);
50
+ },
51
+ 120_000,
52
+ );
53
+ }
54
+
55
+ it("leaves every file in the workspace as it was", () => {
56
+ expect(before, "no read ran").toBeDefined();
57
+ expect(treeChanges(before!, treeDigest(target!.workspaceDir))).toEqual([]);
58
+ });
59
+ });
60
+ }
61
+
62
+ export * from "./index";
@@ -44,6 +44,18 @@
44
44
  "$ref": "#/$defs/pin"
45
45
  }
46
46
  },
47
+ "quorum": {
48
+ "type": "integer",
49
+ "minimum": 0,
50
+ "description": "How many agree verdicts besides the decider's a record needs, for every record kind with a reviews list (#2671). chant workspace records reports each record's quorum against it. Two when absent. Added in schema 1 by chant 0.86.0, so a declaration that sets it sets minReader to 0.86.0 or newer. A per-kind quorum comes later (#2650 C14)."
51
+ },
52
+ "records": {
53
+ "type": "array",
54
+ "description": "The workspace's own record kinds, each a kind file relative to the workspace root (#2680): the kinds whose records belong to no one member, such as a decisions directory at the root. A member names its own in its records. chant workspace ls lists them, and chant workspace records and graph --intent read every declared kind when --kind is not given. Added in schema 1 by chant 0.86.0, so a declaration that uses it sets minReader to 0.86.0 or newer.",
55
+ "items": {
56
+ "$ref": "#/$defs/recordKind"
57
+ }
58
+ },
47
59
  "checks": {
48
60
  "type": "object",
49
61
  "description": "Severity for the declaration checks that allow it, keyed by WSP id (#2535, ws-028): error, warning, info or off. chant workspace check refuses an id it does not know, and one whose severity is fixed.",
@@ -250,6 +262,13 @@
250
262
  "$ref": "#/$defs/link"
251
263
  }
252
264
  },
265
+ "records": {
266
+ "type": "array",
267
+ "description": "The record kinds this member holds, each a kind file relative to the member's directory (#2680). chant workspace ls lists them, and chant workspace records and graph --intent read every declared kind when --kind is not given. Added in schema 1 by chant 0.86.0, so a declaration that uses it sets minReader to 0.86.0 or newer.",
268
+ "items": {
269
+ "$ref": "#/$defs/recordKind"
270
+ }
271
+ },
253
272
  "upstream": {
254
273
  "type": "string",
255
274
  "minLength": 1,
@@ -335,6 +354,27 @@
335
354
  "description": "A file or directory path with / separators: no leading /, no . or .. segments and no trailing /.",
336
355
  "pattern": "^(?!\\.\\.?(/|$))[^/\\\\]+(/(?!\\.\\.?(/|$))[^/\\\\]+)*$"
337
356
  },
357
+ "recordKind": {
358
+ "type": "object",
359
+ "description": "A record kind the workspace declares (#2680): a kind file that exports recordKind. chant workspace check fails (WSP115) when the file is missing or does not load as a record kind.",
360
+ "required": [
361
+ "kind"
362
+ ],
363
+ "properties": {
364
+ "kind": {
365
+ "$ref": "#/$defs/path",
366
+ "description": "The kind file, relative to the member's directory, or to the workspace root for the workspace's own records. A path is listed once across the declaration."
367
+ },
368
+ "name": {
369
+ "$ref": "#/$defs/kindName",
370
+ "description": "The name readers show for the kind. Without it, the kind file's own recordKind.name. Unique across the declaration."
371
+ }
372
+ },
373
+ "patternProperties": {
374
+ "^x-": true
375
+ },
376
+ "additionalProperties": false
377
+ },
338
378
  "generatedFile": {
339
379
  "type": "object",
340
380
  "description": "One generated file of a member (#2524 D14, #2541).",