@intentius/chant 0.87.0 → 0.89.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 (79) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/handlers/serve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  7. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  8. package/dist/cli/registry.d.ts +6 -1
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/cli/version.d.ts +8 -0
  11. package/dist/cli/version.d.ts.map +1 -0
  12. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  13. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  14. package/dist/workspace/composites.d.ts +13 -0
  15. package/dist/workspace/composites.d.ts.map +1 -1
  16. package/dist/workspace/environments.d.ts +80 -0
  17. package/dist/workspace/environments.d.ts.map +1 -0
  18. package/dist/workspace/reason-codes.d.ts +14 -5
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/records-cli.d.ts +6 -2
  21. package/dist/workspace/records-cli.d.ts.map +1 -1
  22. package/dist/workspace/records-close.d.ts +41 -0
  23. package/dist/workspace/records-close.d.ts.map +1 -0
  24. package/dist/workspace/records-since.d.ts +40 -1
  25. package/dist/workspace/records-since.d.ts.map +1 -1
  26. package/dist/workspace/records-write.d.ts +109 -11
  27. package/dist/workspace/records-write.d.ts.map +1 -1
  28. package/dist/workspace/records.d.ts +40 -12
  29. package/dist/workspace/records.d.ts.map +1 -1
  30. package/dist/workspace/runtimes.d.ts +6 -0
  31. package/dist/workspace/runtimes.d.ts.map +1 -1
  32. package/dist/workspace/session-kinds.d.ts +28 -0
  33. package/dist/workspace/session-kinds.d.ts.map +1 -0
  34. package/dist/workspace/status.d.ts +2 -0
  35. package/dist/workspace/status.d.ts.map +1 -1
  36. package/dist/workspace/trust/seal.d.ts +42 -0
  37. package/dist/workspace/trust/seal.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/handlers/misc.ts +1 -9
  40. package/src/cli/handlers/serve.ts +10 -1
  41. package/src/cli/main.test.ts +7 -0
  42. package/src/cli/main.ts +60 -9
  43. package/src/cli/mcp/server.test.ts +14 -1
  44. package/src/cli/mcp/server.ts +13 -2
  45. package/src/cli/mcp/workspace-plugins.ts +123 -0
  46. package/src/cli/registry.ts +6 -1
  47. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  48. package/src/cli/version.ts +15 -0
  49. package/src/workspace/__fixtures__/sessions.ts +41 -0
  50. package/src/workspace/composites.schema.json +68 -3
  51. package/src/workspace/composites.test.ts +119 -5
  52. package/src/workspace/composites.ts +26 -7
  53. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  54. package/src/workspace/environments.ts +165 -0
  55. package/src/workspace/read-contract.test.ts +3 -0
  56. package/src/workspace/reason-codes.test.ts +8 -3
  57. package/src/workspace/reason-codes.ts +18 -6
  58. package/src/workspace/record-assets.test.ts +4 -3
  59. package/src/workspace/records-amend.schema.json +30 -1
  60. package/src/workspace/records-cli.ts +44 -7
  61. package/src/workspace/records-close.schema.json +192 -0
  62. package/src/workspace/records-close.ts +129 -0
  63. package/src/workspace/records-contract.test.ts +4 -3
  64. package/src/workspace/records-new.schema.json +25 -0
  65. package/src/workspace/records-review.schema.json +55 -2
  66. package/src/workspace/records-sessions-write.test.ts +274 -0
  67. package/src/workspace/records-since.schema.json +27 -2
  68. package/src/workspace/records-since.ts +120 -6
  69. package/src/workspace/records-write-contract.test.ts +5 -1
  70. package/src/workspace/records-write.test.ts +4 -2
  71. package/src/workspace/records-write.ts +307 -43
  72. package/src/workspace/records.schema.json +22 -3
  73. package/src/workspace/records.ts +73 -22
  74. package/src/workspace/runtimes.ts +12 -3
  75. package/src/workspace/session-kinds.ts +79 -0
  76. package/src/workspace/status.ts +1 -1
  77. package/src/workspace/trust/record-seal.test.ts +315 -0
  78. package/src/workspace/trust/seal.test.ts +4 -14
  79. package/src/workspace/trust/seal.ts +119 -25
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/composites/v1/composites.schema.json",
4
4
  "title": "chant workspace graph --composites output",
5
- "description": "What `chant workspace graph --composites` prints: each composite instance the workspace's members declare, joined to the components whose contract can deploy it (#2662). Each member of kind chant is read through its own `chant graph --format ir` and `chant graph --components --format ir`, under its own toolchain. An instance with no component is listed with an empty `components` array. chant supplies the rows and never picks one: each match says how it was made and how it crosses members. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2662; a chant without --composites refuses the flag. Each component lists the runtimes it can deploy on, read from its member's chant.config.ts (#2674). Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace graph --composites` prints: each composite instance the workspace's members declare, joined to the components whose contract can deploy it (#2662). Each member of kind chant is read through its own `chant graph --format ir` and `chant graph --components --format ir`, under its own toolchain. An instance with no component is listed with an empty `components` array. chant supplies the rows and never picks one: each match says how it was made and how it crosses members. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2662; a chant without --composites refuses the flag. Each component lists the runtimes it can deploy on, read from its member's chant.config.ts (#2674), and the environments it may deploy to, from that config and the member's release ledger on chant/lifecycle (#2695). Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -131,7 +131,8 @@
131
131
  "status",
132
132
  "reason",
133
133
  "chant",
134
- "runtimeReasons"
134
+ "runtimeReasons",
135
+ "environmentReasons"
135
136
  ],
136
137
  "properties": {
137
138
  "name": {
@@ -210,6 +211,30 @@
210
211
  }
211
212
  }
212
213
  }
214
+ },
215
+ "environmentReasons": {
216
+ "description": "Why the environments of the member's components are only local, or leave out one its ledger has: its chant.config.ts could not be read (the same read and code as runtimeReasons), declares no environments, or doesn't cover an environment the ledger has, or the ledger couldn't be listed. Empty for a member not of kind chant, and when nothing needs saying.",
217
+ "type": "array",
218
+ "items": {
219
+ "type": "object",
220
+ "required": [
221
+ "code",
222
+ "message"
223
+ ],
224
+ "properties": {
225
+ "code": {
226
+ "enum": [
227
+ "runtimes-config-unreadable",
228
+ "environments-none-declared",
229
+ "environments-ledger-undeclared",
230
+ "environments-ledger-unreadable"
231
+ ]
232
+ },
233
+ "message": {
234
+ "type": "string"
235
+ }
236
+ }
237
+ }
213
238
  }
214
239
  },
215
240
  "if": {
@@ -351,7 +376,8 @@
351
376
  "archetype",
352
377
  "composites",
353
378
  "file",
354
- "runtimes"
379
+ "runtimes",
380
+ "environments"
355
381
  ],
356
382
  "properties": {
357
383
  "id": {
@@ -400,6 +426,14 @@
400
426
  "items": {
401
427
  "$ref": "#/$defs/runtime"
402
428
  }
429
+ },
430
+ "environments": {
431
+ "description": "The environments the component may deploy to with chant run --components --env in its member: local first, the default, then each name the member's chant.config.ts declares in environments (patterns left out), in config order, then each environment with a release ledger for the member on chant/lifecycle that the config covers, sorted. The component contract declares no environments.",
432
+ "type": "array",
433
+ "minItems": 1,
434
+ "items": {
435
+ "$ref": "#/$defs/environment"
436
+ }
403
437
  }
404
438
  }
405
439
  },
@@ -528,6 +562,37 @@
528
562
  "description": "The command that deploys the component on this runtime, run in the member's directory: chant run --components <name>, with --on <runtime> for any runtime but local."
529
563
  }
530
564
  }
565
+ },
566
+ "environment": {
567
+ "type": "object",
568
+ "required": [
569
+ "name",
570
+ "default",
571
+ "source",
572
+ "command"
573
+ ],
574
+ "properties": {
575
+ "name": {
576
+ "type": "string",
577
+ "description": "What --env takes."
578
+ },
579
+ "default": {
580
+ "type": "boolean",
581
+ "description": "True for the environment chant run --components deploys to without --env. That is local, since chant.config.ts names no default environment."
582
+ },
583
+ "source": {
584
+ "enum": [
585
+ "config",
586
+ "ledger",
587
+ "builtin"
588
+ ],
589
+ "description": "Where the name came from, the first of these that names it: config for the member's chant.config.ts environments, ledger for a release ledger on chant/lifecycle, builtin for local when neither names it."
590
+ },
591
+ "command": {
592
+ "type": "string",
593
+ "description": "The command that deploys the component to this environment on its default runtime, run in the member's directory: chant run --components <name>, with --on <runtime> when the default runtime isn't local, and --env <env> for any environment but the default."
594
+ }
595
+ }
531
596
  }
532
597
  }
533
598
  }
@@ -7,7 +7,8 @@
7
7
  * (`read-contract.test.ts`).
8
8
  */
9
9
 
10
- import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import { afterAll, describe, expect, test } from "vitest";
13
14
  import type { GraphIR, IRNode } from "../graph-ir";
@@ -15,6 +16,7 @@ import { cleanScratch, commitAll, contract, declaration, FAKE_FILE_GRAPH_CHANT,
15
16
  import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
16
17
  import {
17
18
  COMPOSITES_CONTRACT_VERSION,
19
+ COMPOSITES_ENVIRONMENT_REASON_CODES,
18
20
  COMPOSITES_ERROR_CODES,
19
21
  COMPOSITES_OUTPUT_SCHEMA_ID,
20
22
  COMPOSITES_REASON_CODES,
@@ -58,6 +60,9 @@ const componentIr = (components: { name: string; composites?: string[]; archetyp
58
60
  /** The built-in runtime every component has. */
59
61
  const local = (name: string) => ({ name: "local", lexicon: null, default: true, command: `chant run --components ${name}` });
60
62
 
63
+ /** The default environment, named by nothing but chant itself. */
64
+ const localEnv = (name: string) => ({ name: "local", default: true, source: "builtin", command: `chant run --components ${name}` });
65
+
61
66
  /**
62
67
  * app declares three instances and exports ImageUri; delivery links to it and
63
68
  * declares two components; jobs declares an instance and a component of its
@@ -111,6 +116,7 @@ describe("composites output schema", () => {
111
116
  expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
112
117
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
113
118
  expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
119
+ expect(schema.$defs.member.properties.environmentReasons.items.properties.code.enum).toEqual([...COMPOSITES_ENVIRONMENT_REASON_CODES]);
114
120
  });
115
121
  });
116
122
 
@@ -122,7 +128,7 @@ describe("the join", () => {
122
128
  { ...composite("a/billingTable", "Table", "a/billing"), member: "a" },
123
129
  ],
124
130
  });
125
- const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [] });
131
+ const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [], environments: [] });
126
132
 
127
133
  test("an instance carries every kind and lexicon of its nodes", () => {
128
134
  expect(instances.map((i) => [i.id, i.instance, i.kinds, i.lexicons])).toEqual([
@@ -165,9 +171,9 @@ describe("chant workspace graph --composites on a built workspace", () => {
165
171
  ["docs", "skipped", "kind-not-run"],
166
172
  ]);
167
173
  expect(g.components).toEqual([
168
- { id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts", runtimes: [local("edge")] },
169
- { id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts", runtimes: [local("loom-backend")] },
170
- { id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts", runtimes: [local("queue-runner")] },
174
+ { id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts", runtimes: [local("edge")], environments: [localEnv("edge")] },
175
+ { id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts", runtimes: [local("loom-backend")], environments: [localEnv("loom-backend")] },
176
+ { id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts", runtimes: [local("queue-runner")], environments: [localEnv("queue-runner")] },
171
177
  ]);
172
178
  const rows = Object.fromEntries(g.composites.map((c) => [c.id, c]));
173
179
  expect(Object.keys(rows)).toEqual(["app/backend", "app/cache", "app/site", "jobs/queue"]);
@@ -332,3 +338,111 @@ describe("the runtimes each component can deploy on (#2674)", () => {
332
338
  ]);
333
339
  });
334
340
  });
341
+
342
+ /**
343
+ * Write `files` as the whole tree of a `chant/lifecycle` commit, the way the
344
+ * lifecycle code stores ledgers, without touching the working tree or index.
345
+ */
346
+ function lifecycle(root: string, files: Record<string, string>): void {
347
+ const index = join(root, ".git", "composites-test-index");
348
+ const run = (args: string[], input?: string) =>
349
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", ...args], {
350
+ cwd: root,
351
+ encoding: "utf-8",
352
+ input,
353
+ env: { ...process.env, GIT_INDEX_FILE: index },
354
+ }).trim();
355
+ rmSync(index, { force: true });
356
+ for (const [path, text] of Object.entries(files)) run(["update-index", "--add", "--cacheinfo", `100644,${run(["hash-object", "-w", "--stdin"], text)},${path}`]);
357
+ run(["update-ref", "refs/heads/chant/lifecycle", run(["commit-tree", run(["write-tree"]), "-m", "ledger"])]);
358
+ rmSync(index, { force: true });
359
+ }
360
+
361
+ /** One release line; the environment list reads only which ledgers exist. */
362
+ const release = (component: string, env: string) => `${JSON.stringify({ version: 1, component, env, digest: `sha256:${"a".repeat(64)}`, gitSha: "b".repeat(40), runId: "r1", timestamp: "2026-09-24T00:00:00.000Z", actor: "t" })}\n`;
363
+
364
+ describe("the environments each component may deploy to (#2695)", () => {
365
+ /** jobs declares staging, prod and the pattern pr-*; its ledger has releases in staging, pr-42 and retired. */
366
+ function declared(): string {
367
+ const root = fixture();
368
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["staging", { name: "prod", endpoint: "https://prod.example" }, "pr-*"] };\n');
369
+ lifecycle(root, {
370
+ "_members/jobs/staging/releases.jsonl": release("queue-runner", "staging"),
371
+ "_members/jobs/pr-42/releases.jsonl": release("queue-runner", "pr-42"),
372
+ "_members/jobs/retired/releases.jsonl": release("queue-runner", "retired"),
373
+ "_members/jobs/_gates/queue-runner.jsonl": "",
374
+ "_members/jobs/notes/README.md": "not a ledger\n",
375
+ "staging/releases.jsonl": release("edge", "staging"),
376
+ });
377
+ return root;
378
+ }
379
+
380
+ test("local is the default, then the config's names in order, then the ledger's, each with its --env line", async () => {
381
+ const g = result((await workspaceComposites({ cwd: declared() })).doc);
382
+ expectValid(g);
383
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments).toEqual([
384
+ { name: "local", default: true, source: "builtin", command: "chant run --components queue-runner" },
385
+ { name: "staging", default: false, source: "config", command: "chant run --components queue-runner --env staging" },
386
+ { name: "prod", default: false, source: "config", command: "chant run --components queue-runner --env prod" },
387
+ // The pattern pr-* is no environment by itself, and it covers the ledger's pr-42.
388
+ { name: "pr-42", default: false, source: "ledger", command: "chant run --components queue-runner --env pr-42" },
389
+ ]);
390
+ const jobs = g.members.find((m) => m.name === "jobs")!;
391
+ expect(jobs.environmentReasons).toEqual([{ code: "environments-ledger-undeclared", message: expect.stringContaining("retired") }]);
392
+ expect(jobs.runtimeReasons).toEqual([]);
393
+ });
394
+
395
+ test("a member with no _members directory reads the flat ledger, and a config that declares none says so", async () => {
396
+ const g = result((await workspaceComposites({ cwd: declared() })).doc);
397
+ // delivery has no _members/delivery, so the flat staging ledger is its; its config declares nothing, so anything goes.
398
+ expect(g.components.find((c) => c.id === "delivery/edge")!.environments).toEqual([
399
+ localEnv("edge"),
400
+ { name: "staging", default: false, source: "ledger", command: "chant run --components edge --env staging" },
401
+ ]);
402
+ expect(g.members.find((m) => m.name === "delivery")!.environmentReasons.map((r) => r.code)).toEqual(["environments-none-declared"]);
403
+ expect(g.members.find((m) => m.name === "docs")!.environmentReasons).toEqual([]);
404
+ });
405
+
406
+ test("with no chant/lifecycle branch, the config's names are the list", async () => {
407
+ const root = fixture();
408
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["local", "prod"] };\n');
409
+ const g = result((await workspaceComposites({ cwd: root })).doc);
410
+ expectValid(g);
411
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments).toEqual([
412
+ { name: "local", default: true, source: "config", command: "chant run --components queue-runner" },
413
+ { name: "prod", default: false, source: "config", command: "chant run --components queue-runner --env prod" },
414
+ ]);
415
+ expect(g.members.find((m) => m.name === "jobs")!.environmentReasons).toEqual([]);
416
+ });
417
+
418
+ test("a config that can't be read lists local and the ledger's, with the runtimes' code", async () => {
419
+ const root = declared();
420
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'throw new Error("no config here");\nexport default {};\n');
421
+ const g = result((await workspaceComposites({ cwd: root })).doc);
422
+ expectValid(g);
423
+ const jobs = g.members.find((m) => m.name === "jobs")!;
424
+ expect(jobs.environmentReasons).toEqual([{ code: "runtimes-config-unreadable", message: expect.stringContaining("no config here") }]);
425
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments.map((e) => [e.name, e.source])).toEqual([
426
+ ["local", "builtin"],
427
+ ["pr-42", "ledger"],
428
+ ["retired", "ledger"],
429
+ ["staging", "ledger"],
430
+ ]);
431
+ });
432
+
433
+ test("a ledger that can't be listed says so, and the config's names are still listed", async () => {
434
+ const root = fixture();
435
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["prod"] };\n');
436
+ const g = result(
437
+ (
438
+ await workspaceComposites({
439
+ cwd: root,
440
+ readLedgerEnvironments: (m) => (m.name === "jobs" ? { envs: [], reason: { code: "environments-ledger-unreadable", message: "boom" } } : { envs: [], reason: null }),
441
+ })
442
+ ).doc,
443
+ );
444
+ expectValid(g);
445
+ expect(g.members.find((m) => m.name === "jobs")!.environmentReasons).toEqual([{ code: "environments-ledger-unreadable", message: "boom" }]);
446
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments.map((e) => e.name)).toEqual(["local", "prod"]);
447
+ });
448
+ });
@@ -32,6 +32,11 @@
32
32
  * Each component also lists the runtimes it can deploy on (#2674,
33
33
  * `runtimes.ts`): `local`, and each lexicon its member configures that hosts
34
34
  * `chant run --components`, with the command line for each.
35
+ *
36
+ * And each component lists the environments it may deploy to (#2695,
37
+ * `environments.ts`): `local`, the environments its member's config declares,
38
+ * and those with a release in the member's ledger on `chant/lifecycle`, with
39
+ * the command line for each.
35
40
  */
36
41
 
37
42
  import { formatError } from "../cli/format";
@@ -44,6 +49,7 @@ import type { LinkRow } from "./links";
44
49
  import { emitDocument, type UnitResult } from "./member-commands";
45
50
  import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
46
51
  import type { ReasonCode } from "./reason-codes";
52
+ import { componentEnvironments, ENVIRONMENT_REASON_CODES, memberEnvironments, readLedgerEnvironments, type ComponentEnvironment, type EnvironmentReason, type LedgerEnvironmentReader, type MemberEnvironments } from "./environments";
47
53
  import { componentRuntimes, readRuntimesIn, RUNTIME_REASON_CODES, type ComponentRuntime, type MemberRuntimes, type PluginLoader, type RuntimeReason } from "./runtimes";
48
54
 
49
55
  /** `$id` of the JSON Schema for the document, shipped beside this file. */
@@ -69,6 +75,9 @@ export type CompositesReasonCode = (typeof COMPOSITES_REASON_CODES)[number];
69
75
  /** Why a member's runtimes are only `local`, or leave out a lexicon its config lists (#2674). Closed. */
70
76
  export const COMPOSITES_RUNTIME_REASON_CODES = RUNTIME_REASON_CODES;
71
77
 
78
+ /** Why a member's environments are only `local`, or leave out one its ledger has (#2695). Closed. */
79
+ export const COMPOSITES_ENVIRONMENT_REASON_CODES = ENVIRONMENT_REASON_CODES;
80
+
72
81
  export interface CompositesMember {
73
82
  name: string;
74
83
  dir: string;
@@ -79,6 +88,8 @@ export interface CompositesMember {
79
88
  chant: string | null;
80
89
  /** Why its components' runtimes leave something out: its config, or a lexicon, couldn't be read. Empty for a member not of kind chant. */
81
90
  runtimeReasons: RuntimeReason[];
91
+ /** Why its components' environments are only `local`, or leave one out. Empty for a member not of kind chant. */
92
+ environmentReasons: EnvironmentReason[];
82
93
  }
83
94
 
84
95
  export interface ComponentEntry {
@@ -94,6 +105,8 @@ export interface ComponentEntry {
94
105
  file: string | null;
95
106
  /** The runtimes it can deploy on: `local` first, then each hosting lexicon its member configures. */
96
107
  runtimes: ComponentRuntime[];
108
+ /** The environments it may deploy to: `local` first, then the config's, then the ledger's. */
109
+ environments: ComponentEnvironment[];
97
110
  }
98
111
 
99
112
  export interface ComponentMatch {
@@ -145,7 +158,7 @@ export interface CompositesResult {
145
158
  }
146
159
 
147
160
  /** Read one member's component graph run into components, or the reason it can't be read. */
148
- function readComponents(member: string, dir: string, run: UnitResult, runtimes: MemberRuntimes | undefined): { components: ComponentEntry[] } | { reason: MemberReason } {
161
+ function readComponents(member: string, dir: string, run: UnitResult, runtimes: MemberRuntimes | undefined, environments: MemberEnvironments | undefined): { components: ComponentEntry[] } | { reason: MemberReason } {
149
162
  if (run.exitCode !== 0) {
150
163
  const tail = run.stderr.trim().split("\n").slice(-5).join("\n");
151
164
  return { reason: { code: "command-failed", message: `chant graph --components exited ${run.exitCode}${tail ? `: ${tail}` : ""}` } };
@@ -166,6 +179,7 @@ function readComponents(member: string, dir: string, run: UnitResult, runtimes:
166
179
  composites: composites && composites.length > 0 ? composites : null,
167
180
  file: file ? (dir === "." ? file : `${dir}/${file}`) : null,
168
181
  runtimes: componentRuntimes(n.id, runtimes),
182
+ environments: componentEnvironments(n.id, environments, runtimes?.default),
169
183
  });
170
184
  }
171
185
  return { components };
@@ -233,17 +247,19 @@ export function joinComponents(instances: CompositeInstanceRow[], components: Co
233
247
  });
234
248
  }
235
249
 
236
- function memberEntry(m: ComposedMember, componentReason: MemberReason | null, runtimes: MemberRuntimes | undefined): CompositesMember {
250
+ function memberEntry(m: ComposedMember, componentReason: MemberReason | null, runtimes: MemberRuntimes | undefined, environments: MemberEnvironments | undefined): CompositesMember {
237
251
  const reason = m.reason ?? componentReason;
238
252
  const status = m.status === "composed" ? (componentReason ? "failed" : "read") : m.status;
239
- return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant, runtimeReasons: runtimes?.reasons ?? [] };
253
+ return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant, runtimeReasons: runtimes?.reasons ?? [], environmentReasons: environments?.reasons ?? [] };
240
254
  }
241
255
 
242
256
  /** Read the workspace's composites and components, and build the document. Never throws a `WorkspaceReadError`. */
243
- export async function workspaceComposites(query: Omit<GraphQuery, "kind" | "components" | "inTree"> & { loadPlugin?: PluginLoader }): Promise<CompositesResult> {
257
+ export async function workspaceComposites(
258
+ query: Omit<GraphQuery, "kind" | "components" | "inTree"> & { loadPlugin?: PluginLoader; readLedgerEnvironments?: LedgerEnvironmentReader },
259
+ ): Promise<CompositesResult> {
244
260
  const head: Head = { $schema: COMPOSITES_OUTPUT_SCHEMA_ID, contract: COMPOSITES_CONTRACT_VERSION, chant: readerVersion() };
245
261
  let runtimes = new Map<string, MemberRuntimes>();
246
- const { loadPlugin, ...graphQuery } = query;
262
+ const { loadPlugin, readLedgerEnvironments: readLedger = readLedgerEnvironments, ...graphQuery } = query;
247
263
  const { doc: graph, failed, components: runs } = await workspaceGraph({
248
264
  ...graphQuery,
249
265
  components: true,
@@ -259,15 +275,18 @@ export async function workspaceComposites(query: Omit<GraphQuery, "kind" | "comp
259
275
  let componentsFailed = false;
260
276
  for (const m of graph.members) {
261
277
  const run = m.status === "composed" ? byMember.get(m.name) : undefined;
278
+ // The ledger is the local chant/lifecycle branch, read from the working tree even with --at, as workspace status reads it.
279
+ const memberRuntimes = runtimes.get(m.name);
280
+ const environments = memberRuntimes ? memberEnvironments(memberRuntimes, readLedger(m, query.cwd)) : undefined;
262
281
  let reason: MemberReason | null = null;
263
282
  if (run) {
264
- const read = readComponents(m.name, m.dir, run, runtimes.get(m.name));
283
+ const read = readComponents(m.name, m.dir, run, memberRuntimes, environments);
265
284
  if ("reason" in read) {
266
285
  reason = read.reason;
267
286
  componentsFailed = true;
268
287
  } else components.push(...read.components);
269
288
  }
270
- members.push(memberEntry(m, reason, runtimes.get(m.name)));
289
+ members.push(memberEntry(m, reason, memberRuntimes, environments));
271
290
  }
272
291
  components.sort((a, b) => a.id.localeCompare(b.id));
273
292
 
@@ -199,6 +199,17 @@
199
199
  }
200
200
  }
201
201
  },
202
+ "seal": {
203
+ "description": "The author's ssh signature over the record (#2688): the bytes <id>\\n<digest>\\n<decided_by>\\n<state>, in the ssh-keygen namespace chant-record, where digest is the record's digest, which leaves this field and reviews out. chant workspace records new --sign and amend --sign write it, and an amendment without --sign removes it. Optional: under a signers file at base, a record with decided_by and no seal that verifies is read with the warning record-unattested.",
204
+ "type": "object",
205
+ "additionalProperties": false,
206
+ "required": ["signer", "key", "signature"],
207
+ "properties": {
208
+ "signer": { "description": "Who signed: decided_by, as the signers file names them.", "type": "string", "minLength": 1 },
209
+ "key": { "description": "The signing key's fingerprint. Reported, never trusted: the signature is the proof.", "type": "string", "pattern": "^SHA256:[A-Za-z0-9+/]+=*$" },
210
+ "signature": { "description": "The armored ssh signature, as ssh-keygen -Y sign prints it.", "type": "string", "pattern": "^-----BEGIN SSH SIGNATURE-----\\n" }
211
+ }
212
+ },
202
213
  "constrains": {
203
214
  "description": "What this decision governs: issues (owner/repo#n), other decisions (their ids), workspace members (member:<name>) or files and directories in the workspace (path:<path>, the grammar of an evidence path, #2549). At least one entry: a decision that governs nothing is refused.",
204
215
  "type": "array",
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The environments a member's components may deploy to, for `chant workspace
3
+ * graph --composites` (#2695), so a reader such as hud can offer staging or
4
+ * prod beside the default without guessing a name.
5
+ *
6
+ * Each environment comes from one of three places, and says which:
7
+ *
8
+ * - `config`: a name the member's `chant.config.ts` declares in
9
+ * `environments`. A pattern such as `pr-*` names no environment by itself
10
+ * and is not listed, but it legalizes the ledger's `pr-42`.
11
+ * - `ledger`: an environment with a release ledger for the member on
12
+ * `chant/lifecycle` (`_members/<member>/<env>/releases.jsonl`, or the flat
13
+ * `<env>/releases.jsonl`, read the way `workspace status` reads it).
14
+ * - `builtin`: `local`, which `chant run --components` uses without `--env`
15
+ * (`../cli/handlers/run.ts`), when neither the config nor the ledger names
16
+ * it.
17
+ *
18
+ * The component contract declares no environments, so every component of a
19
+ * member lists the member's environments.
20
+ *
21
+ * `chant run --env` refuses a name the config's `environments` doesn't cover
22
+ * whenever it declares any (`unknownEnvError` in `../env.ts`), so a ledger
23
+ * environment the config no longer covers is left out, with a reason. The
24
+ * default is `local`: `chant.config.ts` has no default environment, and
25
+ * `chant run` without `--env` deploys to `local` even when the config's
26
+ * `environments` leaves it out.
27
+ *
28
+ * The config is the one `runtimes.ts` loads, in the working tree or the tree
29
+ * `--at` exported. The ledger is always the local `chant/lifecycle` branch,
30
+ * as for `workspace status`, and is never fetched.
31
+ */
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import { environmentName, isEnvironmentPattern, matchesDeclaredEnvironment } from "../config";
35
+ import type { ReasonCode } from "./reason-codes";
36
+ import { LOCAL_RUNTIME, type MemberRuntimes } from "./runtimes";
37
+ import { ENV_PATTERN, LIFECYCLE_REF, MEMBERS_DIR } from "./status";
38
+
39
+ /** The environment `chant run --components` deploys to without `--env`. */
40
+ export const LOCAL_ENVIRONMENT = "local";
41
+
42
+ /** Why a member's environments are only `local`, or leave out one the ledger has. Closed: part of the read contract. */
43
+ export const ENVIRONMENT_REASON_CODES = [
44
+ /** The member's chant.config.ts could not be read: the same read and the same code as its runtimes. */
45
+ "runtimes-config-unreadable",
46
+ /** The config declares no environments, so only local and the ledger's environments are listed. */
47
+ "environments-none-declared",
48
+ /** The ledger has releases in an environment the config's environments no longer cover, so chant run --env would refuse it. */
49
+ "environments-ledger-undeclared",
50
+ /** The chant/lifecycle branch exists and the member's ledger directories could not be listed. */
51
+ "environments-ledger-unreadable",
52
+ ] as const satisfies readonly ReasonCode[];
53
+ export type EnvironmentReasonCode = (typeof ENVIRONMENT_REASON_CODES)[number];
54
+
55
+ export interface EnvironmentReason {
56
+ code: EnvironmentReasonCode;
57
+ message: string;
58
+ }
59
+
60
+ /** One environment a component may deploy to. */
61
+ export interface ComponentEnvironment {
62
+ /** What `--env` takes. */
63
+ name: string;
64
+ /** True for the environment `chant run --components` deploys to without `--env`. */
65
+ default: boolean;
66
+ /** Where the name came from; the first of config, ledger and builtin that names it. */
67
+ source: "config" | "ledger" | "builtin";
68
+ /** The command that deploys the component there on the default runtime, run in the member's directory. */
69
+ command: string;
70
+ }
71
+
72
+ /** The environments a member's ledger has, or why they couldn't be listed. */
73
+ export interface LedgerEnvironments {
74
+ envs: string[];
75
+ reason: EnvironmentReason | null;
76
+ }
77
+
78
+ /** Lists one member's ledger environments; git unless a test swaps it. */
79
+ export type LedgerEnvironmentReader = (member: { name: string; dir: string }, cwd: string) => LedgerEnvironments;
80
+
81
+ /** What one member's config and ledger say about environments. */
82
+ export interface MemberEnvironments {
83
+ /** Each environment in list order, without the per-component command. */
84
+ environments: Omit<ComponentEnvironment, "command">[];
85
+ reasons: EnvironmentReason[];
86
+ }
87
+
88
+ function git(cwd: string, args: string[], input?: string): string {
89
+ return execFileSync("git", args, { cwd, encoding: "utf-8", input, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
90
+ }
91
+
92
+ function exists(cwd: string, spec: string): boolean {
93
+ try {
94
+ git(cwd, ["rev-parse", "--verify", "--quiet", spec]);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The environments with a release ledger for `member` on the local
103
+ * `chant/lifecycle` branch: the directories under `_members/<member>/` when it
104
+ * exists (the root member never has one), else the flat layout's top-level
105
+ * directories, that hold a `releases.jsonl`.
106
+ */
107
+ export const readLedgerEnvironments: LedgerEnvironmentReader = (member, cwd) => {
108
+ if (!exists(cwd, `refs/heads/${LIFECYCLE_REF}`)) return { envs: [], reason: null };
109
+ const own = member.dir !== "." && exists(cwd, `refs/heads/${LIFECYCLE_REF}:${MEMBERS_DIR}/${member.name}`);
110
+ const base = own ? `${MEMBERS_DIR}/${member.name}/` : "";
111
+ try {
112
+ const dirs = git(cwd, ["ls-tree", "--full-tree", `refs/heads/${LIFECYCLE_REF}:${base}`])
113
+ .split("\n")
114
+ .map((line) => line.match(/^\d+ tree [0-9a-f]+\t(.+)$/)?.[1])
115
+ .filter((name): name is string => !!name && ENV_PATTERN.test(name));
116
+ if (dirs.length === 0) return { envs: [], reason: null };
117
+ const checked = git(cwd, ["cat-file", "--batch-check"], dirs.map((d) => `refs/heads/${LIFECYCLE_REF}:${base}${d}/releases.jsonl\n`).join(""))
118
+ .split("\n")
119
+ .filter(Boolean);
120
+ return { envs: dirs.filter((_, i) => checked[i] !== undefined && !checked[i].endsWith(" missing")).sort(), reason: null };
121
+ } catch (err) {
122
+ const message = (err instanceof Error ? err.message : String(err)).split("\n")[0];
123
+ return { envs: [], reason: { code: "environments-ledger-unreadable", message: `${LIFECYCLE_REF}:${base || "."}: ${message}` } };
124
+ }
125
+ };
126
+
127
+ /** Join a member's config and ledger into its environment list, `local` first, then config order, then the ledger's. */
128
+ export function memberEnvironments(runtimes: MemberRuntimes | undefined, ledger: LedgerEnvironments | undefined): MemberEnvironments {
129
+ const reasons: EnvironmentReason[] = [];
130
+ const declared = runtimes?.environments ?? null;
131
+ if (runtimes && declared === null) {
132
+ const unreadable = runtimes.reasons.find((r) => r.code === "runtimes-config-unreadable");
133
+ reasons.push({ code: "runtimes-config-unreadable", message: unreadable?.message ?? "chant.config.ts could not be read" });
134
+ } else if (declared && declared.length === 0) {
135
+ reasons.push({ code: "environments-none-declared", message: "chant.config.ts declares no environments, so only local and the environments in the ledger are listed" });
136
+ }
137
+ if (ledger?.reason) reasons.push(ledger.reason);
138
+
139
+ const literals = (declared ?? []).map(environmentName).filter((n) => !isEnvironmentPattern(n));
140
+ const ledgerEnvs = ledger?.envs ?? [];
141
+ const covered = (env: string): boolean => !declared || declared.length === 0 || matchesDeclaredEnvironment(declared, env);
142
+ const undeclared = ledgerEnvs.filter((e) => e !== LOCAL_ENVIRONMENT && !covered(e));
143
+ if (undeclared.length > 0) {
144
+ reasons.push({
145
+ code: "environments-ledger-undeclared",
146
+ message: `the ledger has releases in ${undeclared.join(", ")}, which chant.config.ts's environments don't cover, so chant run --env refuses ${undeclared.length === 1 ? "it" : "them"}`,
147
+ });
148
+ }
149
+
150
+ const out: MemberEnvironments["environments"] = [];
151
+ const add = (name: string, source: ComponentEnvironment["source"]) => {
152
+ if (!out.some((e) => e.name === name)) out.push({ name, default: name === LOCAL_ENVIRONMENT, source });
153
+ };
154
+ add(LOCAL_ENVIRONMENT, literals.includes(LOCAL_ENVIRONMENT) ? "config" : ledgerEnvs.includes(LOCAL_ENVIRONMENT) ? "ledger" : "builtin");
155
+ for (const name of literals) add(name, "config");
156
+ for (const name of ledgerEnvs) if (covered(name)) add(name, "ledger");
157
+ return { environments: out, reasons };
158
+ }
159
+
160
+ /** The environments one component may deploy to, each with its command line on the member's default runtime. */
161
+ export function componentEnvironments(component: string, member: MemberEnvironments | undefined, runtime = LOCAL_RUNTIME): ComponentEnvironment[] {
162
+ const list = member?.environments ?? [{ name: LOCAL_ENVIRONMENT, default: true, source: "builtin" as const }];
163
+ const on = runtime === LOCAL_RUNTIME ? "" : ` --on ${runtime}`;
164
+ return list.map((e) => ({ ...e, command: `chant run --components ${component}${on}${e.default ? "" : ` --env ${e.name}`}` }));
165
+ }
@@ -160,6 +160,9 @@ describe("every schema against the reference workspace (#2543)", () => {
160
160
  // #2674: the reference config's lexicons host no component runs, so local is the only runtime, read at HEAD too.
161
161
  expect(doc.components[0].runtimes).toEqual([{ name: "local", lexicon: null, default: true, command: "chant run --components app" }]);
162
162
  expect(doc.members.find((m) => m.name === "delivery")!.runtimeReasons).toEqual([]);
163
+ // #2695: the reference config declares no environments, so the app deploys to local only.
164
+ expect(doc.components[0].environments).toEqual([{ name: "local", default: true, source: "builtin", command: "chant run --components app" }]);
165
+ expect(doc.members.find((m) => m.name === "delivery")!.environmentReasons.map((r) => r.code)).toEqual(["environments-none-declared"]);
163
166
  expect(doc.reasons).toEqual([]);
164
167
  }
165
168
  },
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { describe, expect, test } from "vitest";
11
11
  import { REPO } from "./__fixtures__/contract-repo";
12
12
  import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
13
- import { COMPOSITES_ERROR_CODES, COMPOSITES_REASON_CODES, COMPOSITES_RUNTIME_REASON_CODES } from "./composites";
13
+ import { COMPOSITES_ENVIRONMENT_REASON_CODES, COMPOSITES_ERROR_CODES, COMPOSITES_REASON_CODES, COMPOSITES_RUNTIME_REASON_CODES } from "./composites";
14
14
  import { WORKSPACE_ERROR_CODES } from "./declaration";
15
15
  import { GRAPH_ERROR_CODES } from "./graph-cli";
16
16
  import { INTENT_ERROR_CODES, INTENT_FINDING_CODES, INTENT_REASON_CODES } from "./intent";
@@ -19,9 +19,10 @@ import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
19
19
  import intentSchema from "./intent.schema.json";
20
20
  import { isPluginCode, isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
21
21
  import { contract } from "./__fixtures__/contract-repo";
22
- import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES, SEAL_REASON_CODES } from "./records";
22
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES, SEAL_REASON_CODES, SEAL_WARNING_CODES } from "./records";
23
23
  import { AMEND_ERROR_CODES, NEW_ERROR_CODES, REVIEW_ERROR_CODES } from "./records-write";
24
- import { RECORDS_SINCE_ERROR_CODES } from "./records-since";
24
+ import { CLOSE_ERROR_CODES } from "./records-close";
25
+ import { RECORDS_SINCE_ERROR_CODES, RECORDS_SINCE_REASON_CODES } from "./records-since";
25
26
  import { STATUS_ERROR_CODES, STATUS_GATE_REASON_CODES, STATUS_REASON_CODES } from "./status";
26
27
  import { WORK_WARNING_CODES } from "./work";
27
28
 
@@ -42,18 +43,22 @@ const PER_COMMAND: Record<string, readonly string[]> = {
42
43
  RECORD_WARNING_CODES,
43
44
  REVIEW_REASON_CODES,
44
45
  SEAL_REASON_CODES,
46
+ SEAL_WARNING_CODES,
45
47
  WORK_WARNING_CODES,
46
48
  READ_ERROR_CODES,
47
49
  NEW_ERROR_CODES,
48
50
  AMEND_ERROR_CODES,
49
51
  REVIEW_ERROR_CODES,
52
+ CLOSE_ERROR_CODES,
50
53
  RECORDS_SINCE_ERROR_CODES,
54
+ RECORDS_SINCE_REASON_CODES,
51
55
  INTENT_ERROR_CODES,
52
56
  INTENT_FINDING_CODES,
53
57
  INTENT_REASON_CODES,
54
58
  COMPOSITES_ERROR_CODES,
55
59
  COMPOSITES_REASON_CODES,
56
60
  COMPOSITES_RUNTIME_REASON_CODES,
61
+ COMPOSITES_ENVIRONMENT_REASON_CODES,
57
62
  };
58
63
 
59
64
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */