@intentius/chant 0.85.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 (121) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +11 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +66 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +40 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +65 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +164 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +202 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/work.d.ts +56 -0
  54. package/dist/workspace/work.d.ts.map +1 -0
  55. package/package.json +19 -1
  56. package/src/cli/main.ts +48 -3
  57. package/src/cli/registry.ts +11 -1
  58. package/src/lifecycle/gate-ledger.ts +14 -0
  59. package/src/workspace/__fixtures__/sessions.ts +66 -0
  60. package/src/workspace/checks/records.ts +19 -0
  61. package/src/workspace/checks.test.ts +2 -0
  62. package/src/workspace/checks.ts +7 -1
  63. package/src/workspace/composites.schema.json +65 -3
  64. package/src/workspace/composites.test.ts +95 -5
  65. package/src/workspace/composites.ts +28 -7
  66. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  67. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  68. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  69. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  70. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  71. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  72. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  73. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  74. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  75. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  76. package/src/workspace/conformance/conformance.test.ts +149 -0
  77. package/src/workspace/conformance/index.mjs +31 -0
  78. package/src/workspace/conformance/index.ts +453 -0
  79. package/src/workspace/conformance/vitest.ts +62 -0
  80. package/src/workspace/declaration.schema.json +40 -0
  81. package/src/workspace/declaration.ts +62 -0
  82. package/src/workspace/declared-kinds.test.ts +321 -0
  83. package/src/workspace/declared-kinds.ts +76 -0
  84. package/src/workspace/graph-cli.ts +8 -0
  85. package/src/workspace/intent-cli.ts +29 -6
  86. package/src/workspace/intent-joins.test.ts +60 -0
  87. package/src/workspace/intent-joins.ts +71 -19
  88. package/src/workspace/intent.schema.json +282 -7
  89. package/src/workspace/intent.test.ts +97 -0
  90. package/src/workspace/intent.ts +332 -45
  91. package/src/workspace/ls.schema.json +34 -0
  92. package/src/workspace/ls.ts +69 -4
  93. package/src/workspace/read-contract.test.ts +30 -9
  94. package/src/workspace/reason-codes.test.ts +15 -4
  95. package/src/workspace/reason-codes.ts +47 -4
  96. package/src/workspace/record-assets.test.ts +3 -1
  97. package/src/workspace/record-sessions.ts +105 -0
  98. package/src/workspace/record-source.ts +14 -5
  99. package/src/workspace/records-amend.schema.json +167 -0
  100. package/src/workspace/records-cli.ts +246 -19
  101. package/src/workspace/records-contract.test.ts +57 -2
  102. package/src/workspace/records-formats.test.ts +640 -0
  103. package/src/workspace/records-new.schema.json +158 -0
  104. package/src/workspace/records-quorum.test.ts +196 -0
  105. package/src/workspace/records-review.schema.json +202 -0
  106. package/src/workspace/records-sessions.test.ts +108 -0
  107. package/src/workspace/records-since.schema.json +193 -0
  108. package/src/workspace/records-since.test.ts +174 -0
  109. package/src/workspace/records-since.ts +259 -0
  110. package/src/workspace/records-write-contract.test.ts +125 -0
  111. package/src/workspace/records-write.test.ts +373 -0
  112. package/src/workspace/records-write.ts +736 -0
  113. package/src/workspace/records.schema.json +187 -9
  114. package/src/workspace/records.ts +631 -41
  115. package/src/workspace/runtimes.ts +107 -0
  116. package/src/workspace/status-contract.test.ts +163 -0
  117. package/src/workspace/status-gates.ts +215 -0
  118. package/src/workspace/status.schema.json +69 -3
  119. package/src/workspace/status.ts +35 -2
  120. package/src/workspace/work.test.ts +388 -0
  121. package/src/workspace/work.ts +163 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.85.0",
3
+ "version": "0.86.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -50,6 +50,16 @@
50
50
  "default": "./src/cli/index.ts"
51
51
  },
52
52
  "./workspace/*.schema.json": "./src/workspace/*.schema.json",
53
+ "./workspace/conformance": {
54
+ "development": "./src/workspace/conformance/index.ts",
55
+ "types": "./dist/workspace/conformance/index.d.ts",
56
+ "default": "./src/workspace/conformance/index.mjs"
57
+ },
58
+ "./workspace/conformance/vitest": {
59
+ "development": "./src/workspace/conformance/vitest.ts",
60
+ "types": "./dist/workspace/conformance/vitest.d.ts",
61
+ "default": "./src/workspace/conformance/vitest.ts"
62
+ },
53
63
  "./cli/*": {
54
64
  "development": "./src/cli/*.ts",
55
65
  "types": "./dist/cli/*.d.ts",
@@ -104,6 +114,14 @@
104
114
  "devDependencies": {
105
115
  "@cdktn/hcl2json": "^0.24.0"
106
116
  },
117
+ "peerDependencies": {
118
+ "vitest": ">=2"
119
+ },
120
+ "peerDependenciesMeta": {
121
+ "vitest": {
122
+ "optional": true
123
+ }
124
+ },
107
125
  "overrides": {
108
126
  "esbuild": "^0.28.1"
109
127
  }
package/src/cli/main.ts CHANGED
@@ -375,6 +375,26 @@ export function parseArgs(args: string[]): ParsedArgs {
375
375
  if (!result.intent || result.intent.startsWith("-")) throw new Error("--intent needs a region: --intent <path[:start-end]>");
376
376
  } else if (arg === "--current") {
377
377
  result.current = true;
378
+ } else if (arg === "--set") {
379
+ // `chant workspace records amend <id> --set <file|->` (#2670)
380
+ result.set = args[++i];
381
+ if (!result.set || (result.set.startsWith("-") && result.set !== "-")) throw new Error("--set needs a JSON file, or - for standard input: --set <file|->");
382
+ } else if (arg === "--verdict") {
383
+ // `chant workspace records review <id> --verdict agree|dissent|abstain` (#2670)
384
+ result.verdict = args[++i];
385
+ if (!result.verdict || result.verdict.startsWith("-")) throw new Error("--verdict needs agree, dissent or abstain");
386
+ } else if (arg === "--by") {
387
+ // `chant workspace records review <id> --by <principal>` (#2670): whoever the caller says.
388
+ result.by = args[++i];
389
+ if (!result.by || result.by.startsWith("-")) throw new Error("--by needs the reviewer: --by <principal>");
390
+ } else if (arg === "--session") {
391
+ // `chant workspace records review <id> --session <id>` (#2670)
392
+ result.session = args[++i];
393
+ if (!result.session || result.session.startsWith("-")) throw new Error("--session needs a session id: --session <id>");
394
+ } else if (arg === "--prefix") {
395
+ // `chant workspace records new <kind> --prefix <prefix>` (#2670): the id prefix to allocate under.
396
+ result.prefix = args[++i];
397
+ if (!result.prefix || result.prefix.startsWith("-")) throw new Error("--prefix needs an id prefix: --prefix <prefix>");
378
398
  } else if (arg === "--require") {
379
399
  // `chant workspace records|verify --require attested` (#2547)
380
400
  result.require = args[++i];
@@ -721,8 +741,9 @@ Workspace (level 1, #2524):
721
741
  beside it and marks the members whose digests differ.
722
742
  Read only; never fetches. --json prints the
723
743
  read-contract document
724
- workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json]
725
- Read the records a record kind locates, validated
744
+ workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json]
745
+ Without --kind, every record kind the declaration
746
+ names. Read the records a record kind locates, validated
726
747
  against its schema, with reason codes for invalid
727
748
  ones. --current leaves out superseded records; --at
728
749
  reads a commit's git objects. Needs no workspace file.
@@ -731,9 +752,32 @@ Workspace (level 1, #2524):
731
752
  --require attested exits 2 if any record is not
732
753
  attested. A pinned file that changed is a warning,
733
754
  asset-drift or asset-missing
755
+ workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json]
756
+ What changed in the records between <rev> and --at
757
+ (default: the working tree): new and removed records,
758
+ state transitions, new verdicts, new supersessions
759
+ and changed pins
734
760
  workspace records pin <path>
735
761
  Print the path from the workspace root and the
736
762
  sha256 of a file, for a decision's evidence pin
763
+ workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--dry-run]
764
+ Write one new record in the kind's directory from
765
+ the JSON fields given, after validating them as
766
+ records would read them. Without a kind file, the one
767
+ kind the declaration names. Allocates the next id when
768
+ the fields hold none. Prints {path, id} as JSON and
769
+ never commits
770
+ workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]
771
+ Set top-level fields of one record. A closed record
772
+ never changes, and an approved one changes only its
773
+ state (upward), pins and reviews; anything else is
774
+ refused with amend-supersede-instead. Prints
775
+ {path, id, changed}
776
+ workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--dry-run]
777
+ Append a review to one record, dated and bound to
778
+ the digest of the record text. A dissent needs
779
+ --note. The principal is not checked; attestation is
780
+ the seal's job. Prints {path, id, review}
737
781
  workspace verify [--base <rev>] [--head <rev>] [--require attested]
738
782
  Check the commits in base..head against the signers
739
783
  and roles read from base. A change to the signers file
@@ -784,7 +828,8 @@ Workspace (level 1, #2524):
784
828
  workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]
785
829
  The intent graph over one region: the commits that
786
830
  touched it, the decisions whose constrains cover it,
787
- the artifacts they pin, and findings with closed codes
831
+ the artifacts they pin, and findings with closed codes.
832
+ Without --kind, every record kind the declaration names
788
833
 
789
834
  Lifecycle (alias: lc):
790
835
  lifecycle snapshot <env> Query API, save metadata to orphan branch
@@ -291,6 +291,16 @@ export interface ParsedArgs {
291
291
  intent?: string;
292
292
  /** `chant workspace records --current` (#2546): leave out records a closed record supersedes. */
293
293
  current?: boolean;
294
+ /** `chant workspace records amend <id> --set <file|->` (#2670): the JSON fields to set. */
295
+ set?: string;
296
+ /** `chant workspace records review <id> --verdict <v>` (#2670): agree, dissent or abstain. */
297
+ verdict?: string;
298
+ /** `chant workspace records review <id> --by <principal>` (#2670): the reviewer, as the caller names them. */
299
+ by?: string;
300
+ /** `chant workspace records review <id> --session <id>` (#2670): the review session the verdict was given in. */
301
+ session?: string;
302
+ /** `chant workspace records new <kind> --prefix <prefix>` (#2670): the id prefix to allocate under. */
303
+ prefix?: string;
294
304
  /** `chant workspace records|verify --require attested` (#2547): the provenance level a gate requires. */
295
305
  require?: string;
296
306
  /**
@@ -416,7 +426,7 @@ export interface ParsedArgs {
416
426
  plan?: string;
417
427
  /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
418
428
  op?: string;
419
- /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
429
+ /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. `chant workspace records --since <rev>` (#2673) — the revision to compare the records with. */
420
430
  since?: string;
421
431
  /** `chant operator log --limit <n>` (#2029) — keep only the newest n entries (still printed oldest-first). */
422
432
  limit?: number;
@@ -47,6 +47,9 @@ import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../
47
47
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
48
48
 
49
49
  const DIR = "_gates";
50
+
51
+ /** The directory on the `chant/lifecycle` branch that holds gate ledgers, under a member's prefix when it has one. */
52
+ export const GATES_DIR = DIR;
50
53
  const APPEND_RETRY_ATTEMPTS = 5;
51
54
 
52
55
  /**
@@ -350,7 +353,18 @@ export async function readGateLedger(
350
353
  ): Promise<{ resolutions: GateResolutionRecord[]; pending: PendingGateRecord[]; malformed: number }> {
351
354
  const content = await readBlobFromPath(DIR, filename(op), opts);
352
355
  if (!content) return { resolutions: [], pending: [], malformed: 0 };
356
+ return parseGateLedger(content);
357
+ }
353
358
 
359
+ /**
360
+ * Split the text of one gate ledger file into its two kinds of line, oldest
361
+ * first, the way {@link readGateLedger} reads it. Exported for a reader that
362
+ * fetches the file itself, such as `chant workspace status` (#2674), which
363
+ * reads every member's ledger by its full path from the workspace root.
364
+ */
365
+ export function parseGateLedger(
366
+ content: string,
367
+ ): { resolutions: GateResolutionRecord[]; pending: PendingGateRecord[]; malformed: number } {
354
368
  const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
355
369
  const resolutions: GateResolutionRecord[] = [];
356
370
  const pending: PendingGateRecord[] = [];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A repository with the reference workspace's decisions and design member,
3
+ * and helpers that write sessions and review entries into it, for the
4
+ * session and records --since tests (#2673).
5
+ */
6
+
7
+ import { cpSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { sessionSeal } from "../record-sessions";
10
+ import { commitAll, git, REPO, scratchDir } from "./contract-repo";
11
+
12
+ export const SESSIONS_KIND = "design/sessions/session.kind.mjs";
13
+ export const DECISIONS_KIND = "decisions/decision.kind.mjs";
14
+
15
+ /** A git repository holding a copy of the reference workspace's decisions and design member. */
16
+ export function sessionsRepo(): string {
17
+ const root = scratchDir("chant-sessions-");
18
+ for (const d of ["decisions", "design"]) cpSync(join(REPO, "reference-workspace", d), join(root, d), { recursive: true });
19
+ git(root, "init", "-q");
20
+ commitAll(root, "reference");
21
+ return root;
22
+ }
23
+
24
+ /** A session file's text, sealed when it is closed. */
25
+ export function sessionText(s: { id: string; state: "open" | "closed"; verdicts?: { record: string; principal: string; verdict: string }[]; agenda?: string[] }): string {
26
+ const lines = [
27
+ "---",
28
+ "schema: 1",
29
+ `id: "${s.id}"`,
30
+ `title: "Session ${s.id}"`,
31
+ `state: "${s.state}"`,
32
+ "agenda:",
33
+ ...(s.agenda ?? ["ref-001"]).map((r) => ` - record: "${r}"`),
34
+ "attendance:",
35
+ ' - principal: "lex00"',
36
+ ' class: "person"',
37
+ ' - principal: "alice"',
38
+ ' class: "person"',
39
+ ' - principal: "helper"',
40
+ ' class: "agent"',
41
+ 'opened: "2026-09-24T18:00:00Z"',
42
+ `closed: ${s.state === "closed" ? '"2026-09-24T19:00:00Z"' : "null"}`,
43
+ ...(s.verdicts?.length
44
+ ? ["verdicts:", ...s.verdicts.flatMap((v) => [` - record: "${v.record}"`, ` principal: "${v.principal}"`, ` verdict: "${v.verdict}"`])]
45
+ : ["verdicts: []"]),
46
+ ...(s.state === "closed" ? ['closed_digest: "SEAL"'] : []),
47
+ "---",
48
+ "",
49
+ `# Session ${s.id}`,
50
+ "",
51
+ ];
52
+ const text = lines.join("\n");
53
+ return s.state === "closed" ? text.replace('"SEAL"', `"${sessionSeal(text, "closed_digest")}"`) : text;
54
+ }
55
+
56
+ /** Rewrite ref-001 with the given review entries, each naming `session`. */
57
+ export function reviewed(root: string, reviewers: string[], session: string, state = "decided"): void {
58
+ const file = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
59
+ const reviews = reviewers.map((r) => ` - reviewer: "${r}"\n verdict: "agree"\n on: "2026-09-24"\n session: "${session}"`).join("\n");
60
+ writeFileSync(
61
+ file,
62
+ readFileSync(file, "utf-8")
63
+ .replace(/^state: .*$/m, `state: "${state}"`)
64
+ .replace(/^reviews: .*$/m, reviewers.length ? `reviews:\n${reviews}` : "reviews: []"),
65
+ );
66
+ }
@@ -13,6 +13,7 @@
13
13
  * | WSP112 | a pinned file that does not exist |
14
14
  * | WSP113 | a pinned file that a superseding record pins at the old hash: the artifact did not follow the decision |
15
15
  * | WSP114 | the records of `--kind` can't be read (fixed) |
16
+ * | WSP115 | a record kind the declaration names is missing or does not load as one (fixed, #2680) |
16
17
  */
17
18
 
18
19
  import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
@@ -80,4 +81,22 @@ export const RECORD_CHECKS: readonly WorkspaceCheck[] = [
80
81
  return [{ checkId: this.id, severity: this.severity, message: `--kind ${facts.kind}: ${facts.error.code}: ${facts.error.message}`, pointer: "" }];
81
82
  },
82
83
  },
84
+ {
85
+ id: "WSP115",
86
+ name: "record-kind-unloadable",
87
+ description: "Every record kind the declaration names is a file that exports a valid recordKind, with the schema it names.",
88
+ severity: "error",
89
+ configurable: false,
90
+ check(ctx) {
91
+ return (ctx.facts?.declaredKinds ?? [])
92
+ .filter((k) => k.reason !== null)
93
+ .map((k) => ({
94
+ checkId: this.id,
95
+ severity: this.severity,
96
+ message: `${k.declared.member === null ? "the workspace" : `member ${k.declared.member}`} declares the record kind ${k.declared.kind}, which can't be loaded: ${k.reason!.code}: ${k.reason!.message}`,
97
+ ...(k.declared.member !== null ? { entity: k.declared.member } : {}),
98
+ pointer: `${k.declared.pointer}/kind`,
99
+ }));
100
+ },
101
+ },
83
102
  ];
@@ -58,6 +58,8 @@ describe("the WSP catalog", () => {
58
58
  "link-kind-unknown",
59
59
  // --kind names a record kind, and records that can't be read check nothing (#2549).
60
60
  "records-unreadable",
61
+ // A declared record kind that doesn't load breaks every reader's default (#2680).
62
+ "record-kind-unloadable",
61
63
  ]);
62
64
  });
63
65
 
@@ -27,6 +27,7 @@
27
27
  * | WSP091 to WSP097 | member links (#2539) |
28
28
  * | WSP101 to WSP106 | generated files |
29
29
  * | WSP111 to WSP114 | records read with `--kind` (#2549) |
30
+ * | WSP115 | the record kinds the declaration names (#2680) |
30
31
  */
31
32
 
32
33
  import { realpathSync } from "node:fs";
@@ -44,6 +45,7 @@ import { gatherGeneratedFacts, GENERATED_CHECKS, type GeneratedFileFacts } from
44
45
  import { gatherLedgerFacts, LEDGER_CHECKS, type MemberLedgerFacts } from "./checks/ledgers";
45
46
  import { gatherPipelineFacts, PIPELINE_CHECKS, type MemberPipelineFacts } from "./checks/pipelines";
46
47
  import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
48
+ import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
47
49
 
48
50
  /**
49
51
  * What the checks beyond the declaration read, gathered from the checkout
@@ -59,6 +61,8 @@ export interface WorkspaceFacts {
59
61
  generated?: readonly GeneratedFileFacts[];
60
62
  /** The records of the kind named with `--kind`, with their pins checked (#2549). */
61
63
  records?: RecordFacts;
64
+ /** The record kinds the declaration names, each loaded, or only looked for under `--at` (#2680). */
65
+ declaredKinds?: readonly DeclaredKind[];
62
66
  }
63
67
 
64
68
  /** What every declaration check reads. */
@@ -444,7 +448,9 @@ export async function runDeclarationChecks(
444
448
  }
445
449
  const { registry, problems } = loadKindRegistry(declaration.pins, root);
446
450
  const gathered = options.gather === false || options.tree ? {} : await gatherWorkspaceFacts(root, declaration, options);
447
- const facts: WorkspaceFacts = options.records ? { ...gathered, records: options.records } : gathered;
451
+ // The declared record kinds load from the working tree; under --at only whether each exists at the revision is checked (#2680).
452
+ const declaredKinds = options.gather === false ? undefined : await loadDeclaredKinds(declaration, tree, root, { load: !options.tree });
453
+ const facts: WorkspaceFacts = { ...gathered, ...(options.records ? { records: options.records } : {}), ...(declaredKinds ? { declaredKinds } : {}) };
448
454
  const ctx: WorkspaceCheckContext = { declaration, tree, groups, kinds: registry, kindProblems: problems, facts };
449
455
  const findings = runWorkspaceChecks(ctx);
450
456
  const { active, suppressed } = applyCheckSettings(declaration, findings);
@@ -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. 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). Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -130,7 +130,8 @@
130
130
  "kind",
131
131
  "status",
132
132
  "reason",
133
- "chant"
133
+ "chant",
134
+ "runtimeReasons"
134
135
  ],
135
136
  "properties": {
136
137
  "name": {
@@ -187,6 +188,28 @@
187
188
  "null"
188
189
  ],
189
190
  "description": "The chant version the member's toolchain reported, or null when a chant older than member-run read it."
191
+ },
192
+ "runtimeReasons": {
193
+ "description": "Why the runtimes of the member's components leave something out: its chant.config.ts, or a lexicon it lists, could not be read. Empty for a member not of kind chant, and when nothing was left out.",
194
+ "type": "array",
195
+ "items": {
196
+ "type": "object",
197
+ "required": [
198
+ "code",
199
+ "message"
200
+ ],
201
+ "properties": {
202
+ "code": {
203
+ "enum": [
204
+ "runtimes-config-unreadable",
205
+ "runtimes-lexicon-unreadable"
206
+ ]
207
+ },
208
+ "message": {
209
+ "type": "string"
210
+ }
211
+ }
212
+ }
190
213
  }
191
214
  },
192
215
  "if": {
@@ -327,7 +350,8 @@
327
350
  "member",
328
351
  "archetype",
329
352
  "composites",
330
- "file"
353
+ "file",
354
+ "runtimes"
331
355
  ],
332
356
  "properties": {
333
357
  "id": {
@@ -368,6 +392,14 @@
368
392
  "null"
369
393
  ],
370
394
  "description": "The component's file from the workspace root, when the member's chant names it."
395
+ },
396
+ "runtimes": {
397
+ "description": "The runtimes the component can deploy on, the list chant run --components --on accepts in its member: local first, then each lexicon the member's chant.config.ts lists whose opRuntime hosts component runs, in config order.",
398
+ "type": "array",
399
+ "minItems": 1,
400
+ "items": {
401
+ "$ref": "#/$defs/runtime"
402
+ }
371
403
  }
372
404
  }
373
405
  },
@@ -466,6 +498,36 @@
466
498
  },
467
499
  "composites": false
468
500
  }
501
+ },
502
+ "runtime": {
503
+ "type": "object",
504
+ "required": [
505
+ "name",
506
+ "lexicon",
507
+ "default",
508
+ "command"
509
+ ],
510
+ "properties": {
511
+ "name": {
512
+ "type": "string",
513
+ "description": "What --on takes: local, or the lexicon's name."
514
+ },
515
+ "lexicon": {
516
+ "type": [
517
+ "string",
518
+ "null"
519
+ ],
520
+ "description": "The lexicon that hosts the runtime, or null for local."
521
+ },
522
+ "default": {
523
+ "type": "boolean",
524
+ "description": "True for the runtime chant run --components uses without --on. That is local until chant.config.ts can name a default."
525
+ },
526
+ "command": {
527
+ "type": "string",
528
+ "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
+ }
530
+ }
469
531
  }
470
532
  }
471
533
  }
@@ -7,7 +7,7 @@
7
7
  * (`read-contract.test.ts`).
8
8
  */
9
9
 
10
- import { writeFileSync } from "node:fs";
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { afterAll, describe, expect, test } from "vitest";
13
13
  import type { GraphIR, IRNode } from "../graph-ir";
@@ -18,6 +18,7 @@ import {
18
18
  COMPOSITES_ERROR_CODES,
19
19
  COMPOSITES_OUTPUT_SCHEMA_ID,
20
20
  COMPOSITES_REASON_CODES,
21
+ COMPOSITES_RUNTIME_REASON_CODES,
21
22
  compositeInstances,
22
23
  joinComponents,
23
24
  workspaceComposites,
@@ -54,6 +55,9 @@ const componentIr = (components: { name: string; composites?: string[]; archetyp
54
55
  { groups: { byWave: { "wave-1": components.map((c) => c.name) } } },
55
56
  );
56
57
 
58
+ /** The built-in runtime every component has. */
59
+ const local = (name: string) => ({ name: "local", lexicon: null, default: true, command: `chant run --components ${name}` });
60
+
57
61
  /**
58
62
  * app declares three instances and exports ImageUri; delivery links to it and
59
63
  * declares two components; jobs declares an instance and a component of its
@@ -106,6 +110,7 @@ describe("composites output schema", () => {
106
110
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...COMPOSITES_ERROR_CODES]);
107
111
  expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
108
112
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
113
+ expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
109
114
  });
110
115
  });
111
116
 
@@ -117,7 +122,7 @@ describe("the join", () => {
117
122
  { ...composite("a/billingTable", "Table", "a/billing"), member: "a" },
118
123
  ],
119
124
  });
120
- const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null });
125
+ const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [] });
121
126
 
122
127
  test("an instance carries every kind and lexicon of its nodes", () => {
123
128
  expect(instances.map((i) => [i.id, i.instance, i.kinds, i.lexicons])).toEqual([
@@ -160,9 +165,9 @@ describe("chant workspace graph --composites on a built workspace", () => {
160
165
  ["docs", "skipped", "kind-not-run"],
161
166
  ]);
162
167
  expect(g.components).toEqual([
163
- { id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts" },
164
- { id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts" },
165
- { id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts" },
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")] },
166
171
  ]);
167
172
  const rows = Object.fromEntries(g.composites.map((c) => [c.id, c]));
168
173
  expect(Object.keys(rows)).toEqual(["app/backend", "app/cache", "app/site", "jobs/queue"]);
@@ -242,3 +247,88 @@ describe("why the list is empty", () => {
242
247
  expect("error" in doc && doc.error.code).toBe("declaration-missing");
243
248
  });
244
249
  });
250
+
251
+ /** A lexicon module: a LexiconPlugin export, with `opRuntime` when `runtime` says what it hosts. */
252
+ function lexiconModule(name: string, runtime: "components" | "ops" | "none"): string {
253
+ const opRuntime =
254
+ runtime === "none"
255
+ ? ""
256
+ : `opRuntime: { name: ${JSON.stringify(name)}, start: async () => { throw new Error("stub"); }${runtime === "components" ? ", runComponents: async () => ({ success: true })" : ""} },`;
257
+ return `const noop = async () => {};
258
+ export const plugin = { name: ${JSON.stringify(name)}, serializer: {}, generate: noop, validate: noop, coverage: noop, package: noop, ${opRuntime} };
259
+ `;
260
+ }
261
+
262
+ describe("the runtimes each component can deploy on (#2674)", () => {
263
+ /** delivery configures four lexicons: one hosts component runs, one hosts only Op runs, one hosts nothing, one can't load. */
264
+ function hosting(): string {
265
+ const root = fixture();
266
+ writeFileSync(
267
+ join(root, "delivery", "chant.config.ts"),
268
+ `export default {
269
+ lexicons: [
270
+ { name: "fleet", module: "./lexicons/fleet.ts" },
271
+ { name: "oponly", module: "./lexicons/oponly.ts" },
272
+ { name: "plain", module: "./lexicons/plain.ts" },
273
+ { name: "gone", module: "./lexicons/gone.ts" },
274
+ ],
275
+ };
276
+ `,
277
+ );
278
+ mkdirSync(join(root, "delivery", "lexicons"));
279
+ writeFileSync(join(root, "delivery", "lexicons", "fleet.ts"), lexiconModule("fleet", "components"));
280
+ writeFileSync(join(root, "delivery", "lexicons", "oponly.ts"), lexiconModule("oponly", "ops"));
281
+ writeFileSync(join(root, "delivery", "lexicons", "plain.ts"), lexiconModule("plain", "none"));
282
+ return root;
283
+ }
284
+
285
+ test("local is the default, and a configured lexicon whose opRuntime hosts component runs is listed with its --on line", async () => {
286
+ const g = result((await workspaceComposites({ cwd: hosting() })).doc);
287
+ expectValid(g);
288
+ const edge = g.components.find((c) => c.id === "delivery/edge")!;
289
+ expect(edge.runtimes).toEqual([
290
+ { name: "local", lexicon: null, default: true, command: "chant run --components edge" },
291
+ { name: "fleet", lexicon: "fleet", default: false, command: "chant run --components edge --on fleet" },
292
+ ]);
293
+ expect(g.components.find((c) => c.id === "delivery/loom-backend")!.runtimes.map((r) => r.command)).toEqual([
294
+ "chant run --components loom-backend",
295
+ "chant run --components loom-backend --on fleet",
296
+ ]);
297
+ // jobs configures no lexicon, so only local.
298
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.runtimes).toEqual([local("queue-runner")]);
299
+ const delivery = g.members.find((m) => m.name === "delivery")!;
300
+ expect(delivery.runtimeReasons.map((r) => r.code)).toEqual(["runtimes-lexicon-unreadable"]);
301
+ expect(delivery.runtimeReasons[0].message).toContain('lexicon "gone"');
302
+ expect(g.members.find((m) => m.name === "docs")!.runtimeReasons).toEqual([]);
303
+ });
304
+
305
+ test("a member whose config can't be read lists local only, with a reason", async () => {
306
+ const root = fixture();
307
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'throw new Error("no config here");\nexport default {};\n');
308
+ const { doc, failed } = await workspaceComposites({ cwd: root, loadPlugin: async () => { throw new Error("not called"); } });
309
+ const g = result(doc);
310
+ expectValid(g);
311
+ expect(failed).toBe(false);
312
+ const jobs = g.members.find((m) => m.name === "jobs")!;
313
+ expect(jobs.runtimeReasons).toEqual([{ code: "runtimes-config-unreadable", message: expect.stringContaining("no config here") }]);
314
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.runtimes).toEqual([local("queue-runner")]);
315
+ });
316
+
317
+ test("the plugin loader decides from opRuntime.runComponents alone", async () => {
318
+ const root = fixture();
319
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { lexicons: ["hosted", "bare"] };\n');
320
+ const g = result(
321
+ (
322
+ await workspaceComposites({
323
+ cwd: root,
324
+ loadPlugin: async (name) => (name === "hosted" ? { opRuntime: { runComponents: () => undefined } } : {}),
325
+ })
326
+ ).doc,
327
+ );
328
+ expectValid(g);
329
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.runtimes.map((r) => [r.name, r.lexicon, r.default])).toEqual([
330
+ ["local", null, true],
331
+ ["hosted", "hosted", false],
332
+ ]);
333
+ });
334
+ });