@intentius/chant 0.79.0 → 0.80.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 (162) hide show
  1. package/dist/audit/core.d.ts.map +1 -1
  2. package/dist/audit/discover.d.ts +38 -0
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/terraform-state.d.ts +14 -0
  5. package/dist/audit/terraform-state.d.ts.map +1 -1
  6. package/dist/cli/command-group.d.ts +6 -0
  7. package/dist/cli/command-group.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +27 -2
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  13. package/dist/cli/commands/init.d.ts +6 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/onboard.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/conflict-check.d.ts +6 -0
  19. package/dist/cli/conflict-check.d.ts.map +1 -1
  20. package/dist/cli/handlers/dev.d.ts.map +1 -1
  21. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  22. package/dist/cli/handlers/misc.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts.map +1 -1
  24. package/dist/cli/handlers/promote.d.ts +17 -0
  25. package/dist/cli/handlers/promote.d.ts.map +1 -0
  26. package/dist/cli/handlers/run-generate.d.ts +39 -0
  27. package/dist/cli/handlers/run-generate.d.ts.map +1 -0
  28. package/dist/cli/handlers/run.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  31. package/dist/cli/plugins.d.ts +9 -0
  32. package/dist/cli/plugins.d.ts.map +1 -1
  33. package/dist/cli/registry.d.ts +14 -1
  34. package/dist/cli/registry.d.ts.map +1 -1
  35. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  36. package/dist/components/cli-support.d.ts.map +1 -1
  37. package/dist/components/discover.d.ts.map +1 -1
  38. package/dist/components/driver.d.ts.map +1 -1
  39. package/dist/components/fan-out-support.d.ts.map +1 -1
  40. package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
  41. package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
  42. package/dist/components/presets/ecs-fargate.d.ts +10 -4
  43. package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
  44. package/dist/components/promote.d.ts +211 -0
  45. package/dist/components/promote.d.ts.map +1 -0
  46. package/dist/components/unbound-gate-approval.d.ts +24 -0
  47. package/dist/components/unbound-gate-approval.d.ts.map +1 -0
  48. package/dist/config.d.ts +82 -5
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/discovery/convergence.d.ts +97 -0
  51. package/dist/discovery/convergence.d.ts.map +1 -0
  52. package/dist/discovery/files.d.ts +41 -2
  53. package/dist/discovery/files.d.ts.map +1 -1
  54. package/dist/discovery/fold-import.d.ts +15 -0
  55. package/dist/discovery/fold-import.d.ts.map +1 -1
  56. package/dist/graph-ir.d.ts +13 -0
  57. package/dist/graph-ir.d.ts.map +1 -1
  58. package/dist/lexicon-module.d.ts +65 -0
  59. package/dist/lexicon-module.d.ts.map +1 -0
  60. package/dist/lexicon.d.ts +24 -0
  61. package/dist/lexicon.d.ts.map +1 -1
  62. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  63. package/dist/lifecycle/legacy-digest.d.ts +26 -0
  64. package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +25 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
  68. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
  69. package/dist/op/activity-contract-registry.d.ts.map +1 -1
  70. package/dist/op/activity-registry.d.ts.map +1 -1
  71. package/dist/op/discover.d.ts.map +1 -1
  72. package/dist/op/gate-approval.d.ts.map +1 -1
  73. package/dist/op/generate-pipeline.d.ts.map +1 -1
  74. package/dist/op/runtimes/local.d.ts.map +1 -1
  75. package/dist/workspace/record-source.d.ts +22 -0
  76. package/dist/workspace/record-source.d.ts.map +1 -0
  77. package/dist/workspace/records-cli.d.ts +57 -0
  78. package/dist/workspace/records-cli.d.ts.map +1 -0
  79. package/dist/workspace/records.d.ts +118 -0
  80. package/dist/workspace/records.d.ts.map +1 -0
  81. package/package.json +3 -3
  82. package/src/audit/core.ts +13 -2
  83. package/src/audit/discover.ts +90 -12
  84. package/src/audit/terraform-state.test.ts +86 -1
  85. package/src/audit/terraform-state.ts +26 -0
  86. package/src/cli/command-group.ts +7 -0
  87. package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
  88. package/src/cli/commands/audit.ts +93 -19
  89. package/src/cli/commands/check-lexicon-examples.ts +2 -1
  90. package/src/cli/commands/doctor.ts +3 -0
  91. package/src/cli/commands/import-agents.ts +11 -4
  92. package/src/cli/commands/init.ts +57 -25
  93. package/src/cli/commands/lint.test.ts +58 -1
  94. package/src/cli/commands/lint.ts +48 -18
  95. package/src/cli/commands/onboard.ts +15 -0
  96. package/src/cli/commands/update.ts +5 -1
  97. package/src/cli/conflict-check.test.ts +18 -1
  98. package/src/cli/conflict-check.ts +13 -0
  99. package/src/cli/discovery-skip.test.ts +168 -0
  100. package/src/cli/handlers/build.test.ts +11 -0
  101. package/src/cli/handlers/build.ts +1 -1
  102. package/src/cli/handlers/dev.ts +3 -0
  103. package/src/cli/handlers/graph.test.ts +20 -0
  104. package/src/cli/handlers/graph.ts +4 -2
  105. package/src/cli/handlers/lifecycle.ts +2 -1
  106. package/src/cli/handlers/misc.ts +16 -0
  107. package/src/cli/handlers/operator.ts +2 -1
  108. package/src/cli/handlers/promote.test.ts +261 -0
  109. package/src/cli/handlers/promote.ts +325 -0
  110. package/src/cli/handlers/run-generate.test.ts +160 -0
  111. package/src/cli/handlers/run-generate.ts +133 -0
  112. package/src/cli/handlers/run.ts +13 -1
  113. package/src/cli/main.test.ts +33 -1
  114. package/src/cli/main.ts +69 -16
  115. package/src/cli/mcp/op-tools.ts +2 -1
  116. package/src/cli/path-lexicon-messages.test.ts +152 -0
  117. package/src/cli/plugins.ts +44 -6
  118. package/src/cli/registry.ts +14 -1
  119. package/src/components/__fixtures__/alb-ecs-service.json +0 -8
  120. package/src/components/capability-plugin-loader.ts +3 -1
  121. package/src/components/cli-support.ts +3 -2
  122. package/src/components/discover.ts +26 -2
  123. package/src/components/driver.test.ts +12 -11
  124. package/src/components/driver.ts +2 -0
  125. package/src/components/fan-out-support.ts +2 -1
  126. package/src/components/pilots/README.md +1 -1
  127. package/src/components/pilots/alb-ecs.pilot.ts +7 -7
  128. package/src/components/presets/ecs-fargate.ts +10 -5
  129. package/src/components/promote.test.ts +371 -0
  130. package/src/components/promote.ts +537 -0
  131. package/src/components/unbound-gate-approval.test.ts +82 -0
  132. package/src/components/unbound-gate-approval.ts +51 -0
  133. package/src/config.test.ts +40 -0
  134. package/src/config.ts +128 -11
  135. package/src/discovery/convergence.test.ts +231 -0
  136. package/src/discovery/convergence.ts +399 -0
  137. package/src/discovery/files.test.ts +127 -1
  138. package/src/discovery/files.ts +97 -9
  139. package/src/discovery/fold-import.ts +83 -9
  140. package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
  141. package/src/graph-ir.ts +14 -0
  142. package/src/lexicon-module.test.ts +203 -0
  143. package/src/lexicon-module.ts +117 -0
  144. package/src/lexicon.ts +26 -0
  145. package/src/lifecycle/build-ledger-store.ts +11 -1
  146. package/src/lifecycle/legacy-digest.test.ts +114 -0
  147. package/src/lifecycle/legacy-digest.ts +62 -0
  148. package/src/lifecycle/release-ledger.ts +34 -0
  149. package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
  150. package/src/op/activity-contract-registry.ts +18 -4
  151. package/src/op/activity-registry.ts +26 -0
  152. package/src/op/discover-convergence.test.ts +70 -0
  153. package/src/op/discover.ts +4 -0
  154. package/src/op/gate-approval.ts +3 -1
  155. package/src/op/generate-pipeline.ts +2 -1
  156. package/src/op/runtimes/local.ts +2 -1
  157. package/src/workspace/record-source.ts +117 -0
  158. package/src/workspace/records-cli.ts +126 -0
  159. package/src/workspace/records-contract.test.ts +88 -0
  160. package/src/workspace/records.schema.json +120 -0
  161. package/src/workspace/records.test.ts +243 -0
  162. package/src/workspace/records.ts +383 -0
@@ -36,6 +36,7 @@
36
36
 
37
37
  import * as coreContracts from "./activities/activity-contracts";
38
38
  import { collectActivityContracts, type ActivityContract } from "./activity-contract";
39
+ import { importLexiconModule } from "../lexicon-module";
39
40
 
40
41
  /**
41
42
  * The shape {@link loadActivityContracts} reads off a plugin: an optional
@@ -74,16 +75,29 @@ export async function loadActivityContracts(
74
75
 
75
76
  for (const entry of lexicons) {
76
77
  const name = typeof entry === "string" ? entry : entry.name;
78
+ let contributor: LexiconActivityContractContributor | undefined = typeof entry === "string" ? undefined : entry;
77
79
  try {
78
- const spec = `@intentius/chant-lexicon-${name}/op/activity-contracts`;
79
- collectActivityContracts((await import(spec)) as Record<string, unknown>, contracts);
80
+ // chant #2520 — a lexicon declared by module path has no subpath to
81
+ // import. Its plugin's `activityContracts()` member stands in for it;
82
+ // given only the name, the plugin is read from the declared module.
83
+ const local = await importLexiconModule(name);
84
+ if (local !== undefined) {
85
+ contributor ??= Object.values(local).find(
86
+ (v): v is LexiconActivityContractContributor =>
87
+ typeof v === "object" && v !== null && (v as { name?: unknown }).name === name &&
88
+ typeof (v as { activityContracts?: unknown }).activityContracts === "function",
89
+ );
90
+ } else {
91
+ const spec = `@intentius/chant-lexicon-${name}/op/activity-contracts`;
92
+ collectActivityContracts((await import(spec)) as Record<string, unknown>, contracts);
93
+ }
80
94
  } catch {
81
95
  // Lexicon absent or declares no contracts at the conventional subpath.
82
96
  }
83
97
 
84
- if (typeof entry !== "string" && typeof entry.activityContracts === "function") {
98
+ if (contributor !== undefined && typeof contributor.activityContracts === "function") {
85
99
  try {
86
- for (const contract of entry.activityContracts()) contracts.set(contract.name, contract);
100
+ for (const contract of contributor.activityContracts()) contracts.set(contract.name, contract);
87
101
  } catch {
88
102
  // A plugin member that throws contributes nothing, the same as one that is absent.
89
103
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import * as baseActivities from "./activities";
14
14
  import { ACTIVITY_PROFILES, type ActivityProfile } from "./activity-profiles";
15
+ import { importLexiconModule } from "../lexicon-module";
15
16
 
16
17
  export type { ActivityProfile } from "./activity-profiles";
17
18
 
@@ -52,6 +53,16 @@ export async function loadActivities(lexicons: string[] = []): Promise<Map<strin
52
53
 
53
54
  for (const name of lexicons) {
54
55
  try {
56
+ // chant #2520 — a lexicon declared by module path has no subpath to
57
+ // import; its plugin's `activities()` member stands in for it.
58
+ const local = await importLexiconModule(name);
59
+ if (local !== undefined) {
60
+ const plugin = Object.values(local).find((v) => isActivityContributor(v) && v.name === name) as
61
+ | ActivityContributor
62
+ | undefined;
63
+ if (plugin) collectActivities(await plugin.activities(), activities);
64
+ continue;
65
+ }
55
66
  const spec = `@intentius/chant-lexicon-${name}/op/activities`;
56
67
  collectActivities((await import(spec)) as Record<string, unknown>, activities);
57
68
  } catch {
@@ -62,6 +73,21 @@ export async function loadActivities(lexicons: string[] = []): Promise<Map<strin
62
73
  return activities;
63
74
  }
64
75
 
76
+ /** A plugin-shaped export with an `activities()` member (`LexiconPlugin.activities`). Structural, so this layer does not import `../lexicon.ts`. */
77
+ interface ActivityContributor {
78
+ name: string;
79
+ activities(): Record<string, unknown> | Promise<Record<string, unknown>>;
80
+ }
81
+
82
+ function isActivityContributor(value: unknown): value is ActivityContributor {
83
+ return (
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ typeof (value as { name?: unknown }).name === "string" &&
87
+ typeof (value as { activities?: unknown }).activities === "function"
88
+ );
89
+ }
90
+
65
91
  /**
66
92
  * The built-in profile table. Kept async because it is awaited on the CLI's
67
93
  * run path alongside {@link loadActivities}, and because a hosted runtime may
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { discoverOps } from "./discover";
6
+ import { resetDiscoveryWarnings } from "../discovery/convergence";
7
+
8
+ /**
9
+ * chant#2527's warning release for Op discovery. The upward search for the
10
+ * Op root stays as it is; what changes next release is the downward walk,
11
+ * which stops at child projects. Today an Op in a child project is
12
+ * discovered, and it still is here, with a warning naming it.
13
+ */
14
+ let fakeGitRoot = "";
15
+ vi.mock("../runtime-adapter", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("../runtime-adapter")>()),
17
+ getRuntime: () => ({
18
+ spawn: async (cmd: string[]) =>
19
+ cmd[0] === "git" && cmd[1] === "rev-parse"
20
+ ? { stdout: fakeGitRoot, stderr: "", exitCode: 0 }
21
+ : { stdout: "", stderr: "", exitCode: 0 },
22
+ }),
23
+ }));
24
+
25
+ const OP_FILE = (name: string): string =>
26
+ `export default { props: { name: ${JSON.stringify(name)}, overview: "t", phases: [{ name: "Run", steps: [] }] } };\n`;
27
+
28
+ describe("discoverOps — an Op in a child project (#2527 warning release)", () => {
29
+ let root: string;
30
+ let stderr: string[];
31
+
32
+ beforeEach(() => {
33
+ root = realpathSync(mkdtempSync(join(tmpdir(), "chant-2527-ops-")));
34
+ fakeGitRoot = root;
35
+ resetDiscoveryWarnings();
36
+ stderr = [];
37
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => void stderr.push(args.join(" ")));
38
+ vi.spyOn(process, "cwd").mockReturnValue(root);
39
+ writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
40
+ mkdirSync(join(root, "ops"), { recursive: true });
41
+ writeFileSync(join(root, "ops", "mine.op.ts"), OP_FILE("mine"));
42
+ mkdirSync(join(root, "child", "ops"), { recursive: true });
43
+ writeFileSync(join(root, "child", "chant.config.json"), JSON.stringify({ lexicons: ["aws"] }));
44
+ writeFileSync(join(root, "child", "ops", "theirs.op.ts"), OP_FILE("theirs"));
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ rmSync(root, { recursive: true, force: true });
50
+ });
51
+
52
+ test("is still discovered, and the warning names it with the include glob", async () => {
53
+ const { ops, errors } = await discoverOps({ cwd: root });
54
+ expect(errors).toEqual([]);
55
+ expect([...ops.keys()].sort()).toEqual(["mine", "theirs"]);
56
+ expect(stderr).toEqual([
57
+ "warning: Op discovery under the current directory changes in the next release (chant#2527):\n" +
58
+ " child/ops/theirs.op.ts: child/ is a child project with its own chant.config. " +
59
+ "Discovery stops at child projects from the next release. " +
60
+ 'To keep reading it after the change, add "child" to include in chant.config.json, which the next release honours.',
61
+ ]);
62
+ });
63
+
64
+ test("with the glob in the config, nothing is printed", async () => {
65
+ writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["aws"], include: ["child"] }));
66
+ const { ops } = await discoverOps({ cwd: root });
67
+ expect([...ops.keys()].sort()).toEqual(["mine", "theirs"]);
68
+ expect(stderr).toEqual([]);
69
+ });
70
+ });
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import type { OpConfig } from "./types";
6
+ import { warnDiscoveryChanges } from "../discovery/convergence";
6
7
 
7
8
  export interface DiscoveredOp {
8
9
  config: OpConfig;
@@ -148,6 +149,9 @@ export async function discoverOps(opts?: { cwd?: string }): Promise<OpDiscoveryR
148
149
 
149
150
  const root = await findDiscoveryRoot(opts?.cwd);
150
151
  const files = await collectOpFiles(root);
152
+ // #2527's warning release: Op discovery stops at child projects below the
153
+ // root next release, and skips git-ignored files. `files` is unchanged.
154
+ await warnDiscoveryChanges({ walker: "ops", root, files });
151
155
 
152
156
  const nameToFile = new Map<string, string>();
153
157
 
@@ -32,6 +32,7 @@
32
32
  */
33
33
 
34
34
  import { createHash } from "node:crypto";
35
+ import { lexiconModulePath } from "../lexicon-module";
35
36
  import { isStepOutputRef, type StepOutputRef } from "./step-output-ref";
36
37
 
37
38
  /** What a policy's decision does to the gate. */
@@ -221,7 +222,8 @@ function isContextValue(value: unknown): boolean {
221
222
 
222
223
  /** Import `lexicon`'s evaluator. Throws a message that names the package when it is not installed or exports none. */
223
224
  export async function loadGatePolicyEvaluator(lexicon: string): Promise<GatePolicyEvaluator> {
224
- const spec = `@intentius/chant-lexicon-${lexicon}/gate-policy`;
225
+ // chant #2520 — a lexicon declared by module path exports its evaluator from that module.
226
+ const spec = lexiconModulePath(lexicon) ?? `@intentius/chant-lexicon-${lexicon}/gate-policy`;
225
227
  let mod: Partial<GatePolicyEvaluator>;
226
228
  try {
227
229
  mod = (await import(spec)) as Partial<GatePolicyEvaluator>;
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { discoverOps, type DiscoveredOp } from "./discover";
20
+ import { lexiconModulePath } from "../lexicon-module";
20
21
  import {
21
22
  isLexiconPlugin,
22
23
  type LexiconPlugin,
@@ -36,7 +37,7 @@ import {
36
37
  async function loadLexiconPlugin(name: string): Promise<LexiconPlugin | null> {
37
38
  let mod: Record<string, unknown>;
38
39
  try {
39
- mod = (await import(`@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
40
+ mod = (await import(lexiconModulePath(name) ?? `@intentius/chant-lexicon-${name}`)) as Record<string, unknown>;
40
41
  } catch {
41
42
  return null;
42
43
  }
@@ -18,6 +18,7 @@
18
18
  * answers.
19
19
  */
20
20
 
21
+ import { lexiconNames } from "../../lexicon-module";
21
22
  import { loadChantConfig } from "../../config";
22
23
  import { loadActivities, loadProfiles } from "../activity-registry";
23
24
  import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
@@ -129,7 +130,7 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
129
130
  // unreadable config just yields the base activities.
130
131
  let lexicons: string[] = [];
131
132
  try {
132
- lexicons = (await loadChantConfig(projectPath)).config.lexicons ?? [];
133
+ lexicons = lexiconNames((await loadChantConfig(projectPath)).config.lexicons ?? []);
133
134
  } catch {
134
135
  // No/invalid chant.config — base activities only.
135
136
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Where records are read from: the working tree, or a commit's git objects
3
+ * (`--at <rev>`, #2536). Reading at a revision needs no checkout and no
4
+ * network; it asks the local git object store only.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { RecordReadError } from "./records";
11
+
12
+ export interface RecordSource {
13
+ /** Appended to messages, such as " at 91c7547e". Empty for the working tree. */
14
+ label: string;
15
+ /** File names directly inside `dir` (relative to the root, `/`-separated), or undefined when `dir` is missing. */
16
+ list(dir: string): string[] | undefined;
17
+ /** The text of a file listed by `list`. */
18
+ read(path: string): string;
19
+ }
20
+
21
+ /** Records in the working tree under `root`. */
22
+ export function workingTreeSource(root: string): RecordSource {
23
+ return {
24
+ label: "",
25
+ list(dir) {
26
+ try {
27
+ return readdirSync(join(root, dir), { withFileTypes: true })
28
+ .filter((d) => d.isFile())
29
+ .map((d) => d.name);
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ },
34
+ read(path) {
35
+ return readFileSync(join(root, path), "utf-8");
36
+ },
37
+ };
38
+ }
39
+
40
+ function git(cwd: string, args: string[], input?: string): string {
41
+ return execFileSync("git", args, {
42
+ cwd,
43
+ encoding: "utf-8",
44
+ input,
45
+ stdio: ["pipe", "pipe", "pipe"],
46
+ maxBuffer: 256 * 1024 * 1024,
47
+ });
48
+ }
49
+
50
+ /** The top of the git repository holding `cwd`, or undefined outside one. */
51
+ export function gitRoot(cwd: string): string | undefined {
52
+ try {
53
+ return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ /** The full commit id `rev` names in the repository at `root`. */
60
+ export function resolveRevision(root: string, rev: string): string {
61
+ if (rev.startsWith("-")) throw new RecordReadError("revision-unknown", `--at ${rev} is not a revision`);
62
+ try {
63
+ return git(root, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).trim();
64
+ } catch {
65
+ throw new RecordReadError("revision-unknown", `--at ${rev} names no commit in this repository`);
66
+ }
67
+ }
68
+
69
+ /** Records as they were at `commit`, read from the object store of the repository at `root`. */
70
+ export function gitRevisionSource(root: string, commit: string): RecordSource {
71
+ const blobs = new Map<string, string>();
72
+ return {
73
+ label: ` at ${commit.slice(0, 8)}`,
74
+ list(dir) {
75
+ const treeish = dir === "." ? `${commit}^{tree}` : `${commit}:${dir}`;
76
+ try {
77
+ if (git(root, ["cat-file", "-t", treeish]).trim() !== "tree") return undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ const entries = git(root, ["ls-tree", "-z", treeish])
82
+ .split("\0")
83
+ .filter(Boolean)
84
+ .map((line) => {
85
+ const tab = line.indexOf("\t");
86
+ const [, type, oid] = line.slice(0, tab).split(" ");
87
+ return { type, oid, name: line.slice(tab + 1) };
88
+ })
89
+ .filter((e) => e.type === "blob");
90
+ // One `cat-file --batch` for the whole directory: records are small, and
91
+ // one process beats one per file.
92
+ if (entries.length > 0) {
93
+ const out = Buffer.from(
94
+ execFileSync("git", ["cat-file", "--batch"], {
95
+ cwd: root,
96
+ input: entries.map((e) => e.oid).join("\n") + "\n",
97
+ maxBuffer: 256 * 1024 * 1024,
98
+ }),
99
+ );
100
+ let at = 0;
101
+ for (const e of entries) {
102
+ const nl = out.indexOf(0x0a, at);
103
+ const size = Number(out.subarray(at, nl).toString("utf-8").split(" ")[2]);
104
+ const start = nl + 1;
105
+ blobs.set(dir === "." ? e.name : `${dir}/${e.name}`, out.subarray(start, start + size).toString("utf-8"));
106
+ at = start + size + 1;
107
+ }
108
+ }
109
+ return entries.map((e) => e.name);
110
+ },
111
+ read(path) {
112
+ const text = blobs.get(path);
113
+ if (text === undefined) throw new Error(`${path} was not listed at ${commit}`);
114
+ return text;
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `chant workspace records --kind <path> [--current] [--at <rev>] [--json]`,
3
+ * the first-test slice of the records query (#2546, #2536).
4
+ *
5
+ * It reads every record the kind file locates and prints them, with reason
6
+ * codes for any that are invalid. An invalid record never fails the command:
7
+ * the exit code is 0 whenever the read itself worked. Only a kind, schema or
8
+ * revision that cannot be read exits 1.
9
+ *
10
+ * It needs no `chant.workspace.json`. The kind is passed explicitly, so
11
+ * nothing is inferred (#2525 rule 1).
12
+ */
13
+
14
+ import { relative } from "node:path";
15
+ import { formatError } from "../cli/format";
16
+ import type { CommandContext } from "../cli/registry";
17
+ import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
18
+ import { loadRecordKind, readRecords, RecordReadError, type ReadErrorCode, type RecordEntry } from "./records";
19
+
20
+ /** The version of the `records` output this chant writes. */
21
+ export const RECORDS_CONTRACT_VERSION = 1;
22
+
23
+ /** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
24
+ export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
25
+
26
+ const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--json]";
27
+
28
+ export interface RecordsQuery {
29
+ kind: string;
30
+ current?: boolean;
31
+ at?: string;
32
+ /** Where `kind` is resolved from and the repository is found. */
33
+ cwd: string;
34
+ }
35
+
36
+ /** The `records` output: a result, or a failure with one error code. */
37
+ export type RecordsDocument =
38
+ | {
39
+ $schema: string;
40
+ contract: number;
41
+ kind: { name: string; schema: string; file: string };
42
+ at: string | null;
43
+ current: boolean;
44
+ records: RecordEntry[];
45
+ summary: { total: number; valid: number; invalid: number; superseded: number };
46
+ }
47
+ | { $schema: string; contract: number; error: { code: ReadErrorCode; message: string } };
48
+
49
+ /** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
50
+ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
51
+ const top = gitRoot(query.cwd);
52
+ const root = top ?? query.cwd;
53
+ try {
54
+ const loaded = await loadRecordKind(query.kind, query.cwd);
55
+ let at: string | null = null;
56
+ let source = workingTreeSource(root);
57
+ if (query.at !== undefined) {
58
+ if (!top) throw new RecordReadError("not-a-git-repository", "--at reads git objects, and this directory is not in a git repository");
59
+ at = resolveRevision(top, query.at);
60
+ source = gitRevisionSource(top, at);
61
+ }
62
+ const result = await readRecords(loaded, { root, source, current: !!query.current });
63
+ return {
64
+ $schema: RECORDS_OUTPUT_SCHEMA_ID,
65
+ contract: RECORDS_CONTRACT_VERSION,
66
+ kind: {
67
+ name: loaded.kind.name,
68
+ schema: loaded.kind.schema.id,
69
+ file: relative(root, loaded.file).split("\\").join("/"),
70
+ },
71
+ at,
72
+ current: !!query.current,
73
+ records: result.records,
74
+ summary: result.summary,
75
+ };
76
+ } catch (err) {
77
+ if (!(err instanceof RecordReadError)) throw err;
78
+ return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, error: { code: err.code, message: err.message } };
79
+ }
80
+ }
81
+
82
+ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
83
+ const { args } = ctx;
84
+ if (!args.kind) {
85
+ console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
86
+ return 1;
87
+ }
88
+ const doc = await queryRecords({ kind: args.kind, current: args.current, at: args.at, cwd: process.cwd() });
89
+ if (args.json) {
90
+ console.log(JSON.stringify(doc, null, 2));
91
+ } else if ("error" in doc) {
92
+ console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
93
+ } else {
94
+ console.log(formatRecords(doc.records, doc.summary, doc.at));
95
+ }
96
+ return "error" in doc ? 1 : 0;
97
+ }
98
+
99
+ function formatRecords(records: RecordEntry[], summary: { total: number; valid: number; invalid: number; superseded: number }, at: string | null): string {
100
+ const lines: string[] = [];
101
+ const idWidth = Math.max(2, ...records.map((r) => (r.id ?? "-").length));
102
+ const stateWidth = Math.max(5, ...records.map((r) => (r.state ?? "-").length));
103
+ for (const r of records) {
104
+ const title = typeof r.data?.title === "string" ? r.data.title : r.path;
105
+ const flag = r.valid ? "" : " INVALID";
106
+ const superseded = r.supersededBy ? ` superseded by ${r.supersededBy}` : "";
107
+ lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${flag}`);
108
+ for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
109
+ }
110
+ lines.push(
111
+ `${summary.total} records${at ? ` at ${at.slice(0, 8)}` : ""}: ${summary.valid} valid, ${summary.invalid} invalid, ${summary.superseded} superseded`,
112
+ );
113
+ return lines.join("\n");
114
+ }
115
+
116
+ /** `chant workspace <anything else>`: only `records` exists so far. */
117
+ export async function runWorkspaceUnknown(ctx: CommandContext): Promise<number> {
118
+ const sub = ctx.args.path && ctx.args.path !== "." ? ctx.args.path : "";
119
+ console.error(
120
+ formatError({
121
+ message: sub ? `Unknown workspace subcommand: ${sub}` : "chant workspace needs a subcommand",
122
+ hint: `Only records exists so far: ${USAGE}`,
123
+ }),
124
+ );
125
+ return 1;
126
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The read contract for `chant workspace records --json` (#2536): the output
3
+ * schema is a valid draft 2020-12 document, its closed code lists match the
4
+ * code, and real output validates against it. The reference workspace (#2543)
5
+ * doesn't exist yet, so the chant repo's own decision files stand in for it.
6
+ */
7
+
8
+ import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import Ajv2020 from "ajv/dist/2020";
12
+ import { afterAll, describe, expect, test } from "vitest";
13
+ import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
14
+ import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
15
+ import schema from "./records.schema.json";
16
+
17
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..");
18
+ const KIND = "docs/design/decisions/decision.kind.mjs";
19
+
20
+ const ajv = new Ajv2020({ strict: true, allErrors: true });
21
+ const validate = ajv.compile(schema);
22
+
23
+ function expectValid(doc: RecordsDocument): void {
24
+ const ok = validate(doc);
25
+ expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
26
+ }
27
+
28
+ const scratch: string[] = [];
29
+ afterAll(() => {
30
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
31
+ });
32
+
33
+ /** A directory holding a copy of the decisions, kind and schema, outside any git repository. */
34
+ function copyDecisions(): string {
35
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-records-contract-")));
36
+ scratch.push(root);
37
+ mkdirSync(join(root, "docs", "design"), { recursive: true });
38
+ cpSync(join(REPO, "docs", "design", "decisions"), join(root, "docs", "design", "decisions"), { recursive: true });
39
+ return root;
40
+ }
41
+
42
+ describe("records output schema", () => {
43
+ test("is a valid draft 2020-12 document with the published $id", () => {
44
+ expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
45
+ expect(ajv.validateSchema(schema)).toBe(true);
46
+ expect(schema.$id).toBe(RECORDS_OUTPUT_SCHEMA_ID);
47
+ expect(RECORDS_CONTRACT_VERSION).toBe(1);
48
+ });
49
+
50
+ test("lists exactly the reason and error codes the code can return", () => {
51
+ expect(schema.$defs.reason.properties.code.enum).toEqual([...RECORD_REASON_CODES]);
52
+ expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...READ_ERROR_CODES]);
53
+ });
54
+
55
+ test("the chant repo's decisions validate, current and not", async () => {
56
+ for (const current of [false, true]) {
57
+ const doc = await queryRecords({ kind: KIND, current, cwd: REPO });
58
+ expectValid(doc);
59
+ expect("error" in doc).toBe(false);
60
+ }
61
+ });
62
+
63
+ test("invalid records validate, each with its reason", async () => {
64
+ const root = copyDecisions();
65
+ const dir = join(root, "docs", "design", "decisions");
66
+ writeFileSync(join(dir, "ws-001-trust-root.md"), "no front matter\n");
67
+ writeFileSync(join(dir, "ws-900-extra.md"), "---\nid: \"ws-900\"\nstate: \"decided\"\n---\n");
68
+ const doc = await queryRecords({ kind: KIND, cwd: root });
69
+ expectValid(doc);
70
+ if ("error" in doc) throw new Error(doc.error.message);
71
+ expect(doc.at).toBeNull();
72
+ expect(doc.summary.invalid).toBe(2);
73
+ });
74
+
75
+ test("every failure validates with its code", async () => {
76
+ const root = copyDecisions();
77
+ const docs = [
78
+ await queryRecords({ kind: "missing.kind.mjs", cwd: root }),
79
+ await queryRecords({ kind: KIND, at: "HEAD", cwd: root }),
80
+ ];
81
+ for (const doc of docs) expectValid(doc);
82
+ expect(docs.map((d) => ("error" in d ? d.error.code : "ok"))).toEqual(["kind-unreadable", "not-a-git-repository"]);
83
+ });
84
+
85
+ test("a document mixing records and an error is refused", () => {
86
+ expect(validate({ $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: 1, error: { code: "revision-unknown", message: "x" }, records: [] })).toBe(false);
87
+ });
88
+ });