@intentius/chant 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The reader conformance suite as a reader outside the chant repository runs
3
+ * it (#2679): on the workspace generated from `__fixture__/`, with no
4
+ * test runner, for a subset of the commands, and from the files the package
5
+ * ships.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
+ import {
12
+ checkReaderRead,
13
+ CONFORMANCE_FIXTURE_DIR,
14
+ createConformanceWorkspace,
15
+ READ_CONTRACT_COMMANDS,
16
+ REFERENCE_READS,
17
+ runWorkspaceReaderConformance,
18
+ selectCommands,
19
+ type ChantRun,
20
+ type ChantTransport,
21
+ type ConformanceWorkspace,
22
+ type ReadContractCommand,
23
+ } from "./index";
24
+ import * as suite from "./index";
25
+
26
+ const coreRoot = resolve(import.meta.dirname, "..", "..", "..");
27
+ const repoRoot = resolve(coreRoot, "..", "..");
28
+
29
+ const JSON_FLAG: Record<ReadContractCommand, string[]> = {
30
+ ls: ["--json"],
31
+ graph: [],
32
+ check: ["--format", "json"],
33
+ status: ["--json"],
34
+ records: ["--json"],
35
+ "graph --intent": ["--json"],
36
+ "graph --composites": ["--json"],
37
+ };
38
+
39
+ /** The smallest reader that conforms, with a hook to misbehave. */
40
+ function reader(twist: (doc: Record<string, unknown>, chant: ChantTransport) => unknown = (doc) => doc) {
41
+ return (chant: ChantTransport) => ({
42
+ async read(command: ReadContractCommand, args: string[]) {
43
+ const run = await chant.run(["workspace", ...command.split(" "), ...args, ...JSON_FLAG[command]]);
44
+ return twist(JSON.parse(run.stdout) as Record<string, unknown>, chant);
45
+ },
46
+ });
47
+ }
48
+
49
+ let ws: ConformanceWorkspace;
50
+ beforeAll(() => {
51
+ ws = createConformanceWorkspace();
52
+ }, 300_000);
53
+ afterAll(() => ws?.dispose());
54
+
55
+ describe("the generated conformance workspace (#2679)", () => {
56
+ test("a conforming reader passes every command, graph --composites included, with nothing skipped", async () => {
57
+ const report = await runWorkspaceReaderConformance(reader(), { workspaceDir: ws.dir });
58
+ expect(report.problems).toEqual([]);
59
+ expect(report.checked).toEqual([...READ_CONTRACT_COMMANDS]);
60
+ expect(report.skipped).toEqual([]);
61
+ }, 300_000);
62
+
63
+ test("graph --composites joins the fixture's composite to its component", async () => {
64
+ const docs: Record<string, unknown>[] = [];
65
+ await runWorkspaceReaderConformance(reader((doc) => (docs.push(doc), doc)), { workspaceDir: ws.dir, commands: ["graph --composites"] });
66
+ expect(docs[0]).toMatchObject({
67
+ composites: [{ id: "delivery/app", kinds: ["WebService"], components: [{ component: "delivery/app", by: "composites", via: "member" }] }],
68
+ summary: { composites: 1, withComponent: 1 },
69
+ });
70
+ }, 300_000);
71
+
72
+ test("the decision record and the region the intent read names are in the workspace", async () => {
73
+ const docs: Record<string, unknown>[] = [];
74
+ await runWorkspaceReaderConformance(reader((doc) => (docs.push(doc), doc)), { workspaceDir: ws.dir, commands: ["records", "graph --intent"] });
75
+ expect((docs[0].records as { id: string }[]).map((r) => r.id)).toEqual(["fix-001"]);
76
+ expect(docs[1].region).toBe(`region:${REFERENCE_READS["graph --intent"][0]}`);
77
+ }, 300_000);
78
+ });
79
+
80
+ describe("commands (#2679)", () => {
81
+ test("only the listed commands are read, and the report names the rest as skipped", async () => {
82
+ const read: string[] = [];
83
+ const report = await runWorkspaceReaderConformance(
84
+ (chant) => ({ read: (command, args) => (read.push(command), reader()(chant).read(command, args)) }),
85
+ { workspaceDir: ws.dir, commands: ["status", "ls", "graph --composites"] },
86
+ );
87
+ expect(report.problems).toEqual([]);
88
+ expect(read).toEqual(["ls", "status", "graph --composites"]);
89
+ expect(report.checked).toEqual(["ls", "status", "graph --composites"]);
90
+ expect(report.skipped).toEqual(["graph", "check", "records", "graph --intent"]);
91
+ }, 300_000);
92
+
93
+ test("a name that is not a contract command, or an empty list, is refused", () => {
94
+ expect(() => selectCommands(["graph --kind" as ReadContractCommand])).toThrow(/not read-contract commands: graph --kind/);
95
+ expect(() => selectCommands([])).toThrow(/commands is empty/);
96
+ });
97
+ });
98
+
99
+ describe("what the runner-neutral suite catches (#2679)", () => {
100
+ test("a changed document, a reader that throws and a write to the workspace", async () => {
101
+ const changed = await runWorkspaceReaderConformance(reader((doc) => ({ ...doc, extra: 1 })), { workspaceDir: ws.dir, commands: ["ls"] });
102
+ expect(changed.problems.join("\n")).toMatch(/ls: the reader must return the document chant printed, unchanged/);
103
+
104
+ const thrown = await runWorkspaceReaderConformance(() => ({ read: () => { throw new Error("no chant"); } }), { workspaceDir: ws.dir, commands: ["status"] });
105
+ expect(thrown.problems).toEqual(["status: the reader threw: no chant"]);
106
+
107
+ const wrote = await runWorkspaceReaderConformance(
108
+ reader((doc) => (writeFileSync(join(ws.dir, "cache.json"), "{}"), doc)),
109
+ { workspaceDir: ws.dir, commands: ["ls"] },
110
+ );
111
+ expect(wrote.problems).toEqual(["workspace: the reads changed files: cache.json (added)"]);
112
+ }, 300_000);
113
+
114
+ test("checkReaderRead: the schema, the $schema id and the contract version", () => {
115
+ const argv = ["workspace", "ls", "--json"];
116
+ const doc = { $schema: "https://intentius.io/chant/schemas/workspace/ls/v1/ls.schema.json", contract: 2 };
117
+ const printed: ChantRun[] = [{ argv, status: 0, stdout: JSON.stringify(doc), stderr: "" }];
118
+ const problems = checkReaderRead("ls", [], [argv], printed, doc);
119
+ expect(problems.join("\n")).toMatch(/ls: the document does not validate against ls.schema.json/);
120
+ expect(problems.join("\n")).toMatch(/ls: contract is 2, expected 1/);
121
+ expect(checkReaderRead("ls", [], [argv], [{ ...printed[0], stdout: "" }], doc)).toEqual(["ls: chant workspace ls --json printed nothing"]);
122
+ });
123
+ });
124
+
125
+ describe("what the package ships (#2679)", () => {
126
+ const pkg = JSON.parse(readFileSync(join(coreRoot, "package.json"), "utf-8")) as { files: string[]; exports: Record<string, Record<string, string>> };
127
+
128
+ test("the fixture and both entries are in the package's files and exports", () => {
129
+ expect(pkg.files).toContain("src/");
130
+ for (const key of ["./workspace/conformance", "./workspace/conformance/vitest"]) {
131
+ const entry = pkg.exports[key];
132
+ for (const cond of ["development", "default"]) expect(existsSync(join(coreRoot, entry[cond])), `${key} ${cond}`).toBe(true);
133
+ expect(entry.types).toMatch(/^\.\/dist\/workspace\/conformance\/.+\.d\.ts$/);
134
+ }
135
+ expect(CONFORMANCE_FIXTURE_DIR).toBe(join(coreRoot, "src", "workspace", "conformance", "__fixture__"));
136
+ });
137
+
138
+ test("the plain-Node entry re-exports every runtime export of the suite", () => {
139
+ const text = readFileSync(join(import.meta.dirname, "index.mjs"), "utf-8");
140
+ const listed = [.../export const \{([^}]+)\}/.exec(text)![1].matchAll(/[A-Za-z_]+/g)].map((m) => m[0]).sort();
141
+ expect(listed).toEqual(Object.keys(suite).sort());
142
+ });
143
+
144
+ test("the fixture's decision kind and schema are the reference workspace's", () => {
145
+ for (const f of ["decision.kind.mjs", "decision.schema.json"]) {
146
+ expect(readFileSync(join(CONFORMANCE_FIXTURE_DIR, "decisions", f), "utf-8"), f).toBe(readFileSync(join(repoRoot, "reference-workspace", "decisions", f), "utf-8"));
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,31 @@
1
+ // The entry of `@intentius/chant/workspace/conformance` for plain Node
2
+ // (#2679). The suite is written in TypeScript like the rest of the package,
3
+ // and Node does not strip types under node_modules, so this file loads it
4
+ // through tsx, a dependency of this package. `node --test` runs a reader's
5
+ // test file that imports it with no loader flag. Types come from
6
+ // dist/workspace/conformance/index.d.ts.
7
+
8
+ import { tsImport } from "tsx/esm/api";
9
+
10
+ const suite = await tsImport("./index.ts", import.meta.url);
11
+
12
+ export const {
13
+ READ_CONTRACT_COMMANDS,
14
+ READ_CONTRACT_SCHEMAS,
15
+ READ_CONTRACT_JSON_FLAGS,
16
+ REFERENCE_READS,
17
+ CONFORMANCE_FIXTURE_DIR,
18
+ defaultChantCommand,
19
+ referenceWorkspaceDir,
20
+ readContractSchema,
21
+ treeDigest,
22
+ treeChanges,
23
+ readerCallProblems,
24
+ checkReaderRead,
25
+ selectCommands,
26
+ createConformanceWorkspace,
27
+ conformanceTarget,
28
+ recordingTransport,
29
+ readAndCheck,
30
+ runWorkspaceReaderConformance,
31
+ } = suite;
@@ -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
+ }