@intentius/chant 0.87.0 → 0.88.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.
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +14 -5
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +6 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +109 -11
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +40 -12
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +42 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/main.ts +20 -8
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +3 -1
- package/src/cli/registry.ts +4 -1
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +8 -3
- package/src/workspace/reason-codes.ts +18 -6
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +44 -7
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +55 -2
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +307 -43
- package/src/workspace/records.schema.json +22 -3
- package/src/workspace/records.ts +73 -22
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +4 -14
- package/src/workspace/trust/seal.ts +119 -25
package/src/cli/main.ts
CHANGED
|
@@ -389,6 +389,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
389
389
|
if (!result.by || result.by.startsWith("-")) throw new Error("--by needs the reviewer: --by <principal>");
|
|
390
390
|
} else if (arg === "--sign") {
|
|
391
391
|
// `chant workspace records review <id> --sign [<key file>]` (#2687): seal the verdict.
|
|
392
|
+
// `records new` and `records amend` take it too, to seal the record's author (#2688).
|
|
392
393
|
// With no key file, git's user.signingkey, as `git commit -S` reads it.
|
|
393
394
|
const next = args[i + 1];
|
|
394
395
|
result.sign = next !== undefined && !next.startsWith("-") ? args[++i] : true;
|
|
@@ -757,34 +758,45 @@ Workspace (level 1, #2524):
|
|
|
757
758
|
--require attested exits 2 if any record is not
|
|
758
759
|
attested. A pinned file that changed is a warning,
|
|
759
760
|
asset-drift or asset-missing
|
|
760
|
-
workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json]
|
|
761
|
+
workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json]
|
|
761
762
|
What changed in the records between <rev> and --at
|
|
762
763
|
(default: the working tree): new and removed records,
|
|
763
764
|
state transitions, new verdicts, new supersessions
|
|
764
|
-
and changed pins
|
|
765
|
+
and changed pins. A session id compares the commits
|
|
766
|
+
the session opened and closed at
|
|
765
767
|
workspace records pin <path>
|
|
766
768
|
Print the path from the workspace root and the
|
|
767
769
|
sha256 of a file, for a decision's evidence pin
|
|
768
|
-
workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--dry-run]
|
|
770
|
+
workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]
|
|
769
771
|
Write one new record in the kind's directory from
|
|
770
772
|
the JSON fields given, after validating them as
|
|
771
773
|
records would read them. Without a kind file, the one
|
|
772
774
|
kind the declaration names. Allocates the next id when
|
|
773
|
-
the fields hold none.
|
|
774
|
-
|
|
775
|
-
|
|
775
|
+
the fields hold none. --sign seals the record's author
|
|
776
|
+
(decided_by for decisions) with an ssh key. Prints
|
|
777
|
+
{path, id} as JSON and never commits
|
|
778
|
+
workspace records amend <id> [--kind <kind file>] --set <file|-> [--sign [<key file>]] [--dry-run]
|
|
776
779
|
Set top-level fields of one record. A closed record
|
|
777
780
|
never changes, and an approved one changes only its
|
|
778
781
|
state (upward), pins and reviews; anything else is
|
|
779
|
-
refused with amend-supersede-instead.
|
|
782
|
+
refused with amend-supersede-instead. --sign seals
|
|
783
|
+
the author again; without it an amendment removes
|
|
784
|
+
the author seal and says so. Prints
|
|
780
785
|
{path, id, changed}
|
|
781
786
|
workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]
|
|
782
787
|
Append a review to one record, dated and bound to
|
|
783
788
|
the digest of the record text. A dissent needs
|
|
784
789
|
--note. --sign seals it with an ssh key (git's
|
|
785
790
|
user.signingkey without a file); under a signers
|
|
786
|
-
file at base only a sealed verdict counts.
|
|
791
|
+
file at base only a sealed verdict counts. With
|
|
792
|
+
--session, the session must be open, and the verdict
|
|
793
|
+
is appended to its verdicts too. Prints
|
|
787
794
|
{path, id, review}
|
|
795
|
+
workspace records close <session id> [--kind <session kind file>] [--dry-run]
|
|
796
|
+
Close an open review session: its state, close time,
|
|
797
|
+
closing commit and seal, in one write. Without
|
|
798
|
+
--kind, the one session kind the declaration names.
|
|
799
|
+
Prints {path, id, changed, seal, closedRev}
|
|
788
800
|
workspace verify [--base <rev>] [--head <rev>] [--require attested]
|
|
789
801
|
Check the commits in base..head against the signers
|
|
790
802
|
and roles read from base. A change to the signers file
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { McpServer } from "./server";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import type { LexiconPlugin } from "../../lexicon";
|
|
7
8
|
import type { Serializer } from "../../serializer";
|
|
8
9
|
|
|
10
|
+
/** The version in `packages/core/package.json`, read here apart from the code under test. */
|
|
11
|
+
const CORE_VERSION: string = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "..", "package.json"), "utf-8")).version;
|
|
12
|
+
|
|
9
13
|
function createMockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
|
|
10
14
|
return {
|
|
11
15
|
name: "mock",
|
|
@@ -51,7 +55,16 @@ describe("McpServer", () => {
|
|
|
51
55
|
expect(result.protocolVersion).toBe("2026-07-28");
|
|
52
56
|
expect(result.capabilities).toBeDefined();
|
|
53
57
|
expect((result.serverInfo as Record<string, unknown>).name).toBe("chant");
|
|
54
|
-
expect((result.serverInfo as Record<string, unknown>).version).toBe(
|
|
58
|
+
expect((result.serverInfo as Record<string, unknown>).version).toBe(CORE_VERSION);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("server info names the installed chant's version, from core's package.json (#2689)", async () => {
|
|
62
|
+
expect(CORE_VERSION).toMatch(/^\d+\.\d+\.\d+/);
|
|
63
|
+
expect(CORE_VERSION).not.toBe("0.1.0");
|
|
64
|
+
for (const params of [{}, { protocolVersion: "2024-11-05" }]) {
|
|
65
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "initialize", params });
|
|
66
|
+
expect(((response.result as Record<string, unknown>).serverInfo as Record<string, unknown>).version).toBe(CORE_VERSION);
|
|
67
|
+
}
|
|
55
68
|
});
|
|
56
69
|
|
|
57
70
|
test("capabilities include tools and resources", async () => {
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
|
|
|
13
13
|
import { setGateOrigin } from "../../lifecycle/gate-origin";
|
|
14
14
|
import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
|
|
15
15
|
import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
|
|
16
|
+
import { CHANT_VERSION } from "../version";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Protocol versions this server understands, newest first. `initialize` and
|
|
@@ -223,7 +224,8 @@ export class McpServer {
|
|
|
223
224
|
return {
|
|
224
225
|
protocolVersion: negotiateProtocolVersion(protocolVersion),
|
|
225
226
|
capabilities: { tools: {}, resources: {} },
|
|
226
|
-
|
|
227
|
+
// The installed chant's version, so a client can tell which chant it talks to (#2689).
|
|
228
|
+
serverInfo: { name: "chant", version: CHANT_VERSION },
|
|
227
229
|
};
|
|
228
230
|
}
|
|
229
231
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -297,7 +297,10 @@ export interface ParsedArgs {
|
|
|
297
297
|
verdict?: string;
|
|
298
298
|
/** `chant workspace records review <id> --by <principal>` (#2670): the reviewer, as the caller names them. */
|
|
299
299
|
by?: string;
|
|
300
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey.
|
|
302
|
+
* On `records new` and `records amend`, the key that seals the record's author (#2688).
|
|
303
|
+
*/
|
|
301
304
|
sign?: string | true;
|
|
302
305
|
/** `chant workspace records review <id> --session <id>` (#2670): the review session the verdict was given in. */
|
|
303
306
|
session?: string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The installed chant's version, read from `@intentius/chant`'s own
|
|
5
|
+
* package.json, or "0.0.0" when it can't be read. The path is the same from
|
|
6
|
+
* `src/cli/` and `dist/cli/`, so it holds whether chant runs from source or
|
|
7
|
+
* from its build.
|
|
8
|
+
*/
|
|
9
|
+
export const CHANT_VERSION: string = (() => {
|
|
10
|
+
try {
|
|
11
|
+
return (createRequire(import.meta.url)("../../package.json") as { version?: string }).version ?? "0.0.0";
|
|
12
|
+
} catch {
|
|
13
|
+
return "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
})();
|
|
@@ -64,3 +64,44 @@ export function reviewed(root: string, reviewers: string[], session: string, sta
|
|
|
64
64
|
.replace(/^reviews: .*$/m, reviewers.length ? `reviews:\n${reviews}` : "reviews: []"),
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Declare the fixture's two kinds in a chant.workspace.json (#2693): the
|
|
70
|
+
* decisions at the root and the session kind in the design member, as the
|
|
71
|
+
* reference workspace declares them, so review --session, close and
|
|
72
|
+
* --since <session id> find the session kind.
|
|
73
|
+
*/
|
|
74
|
+
export function declareSessions(root: string): void {
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(root, "chant.workspace.json"),
|
|
77
|
+
`${JSON.stringify(
|
|
78
|
+
{
|
|
79
|
+
name: "sessions",
|
|
80
|
+
schema: 1,
|
|
81
|
+
members: [{ name: "design", dir: "design", kind: "other", because: "the session fixture", records: [{ kind: "sessions/session.kind.mjs" }] }],
|
|
82
|
+
records: [{ kind: DECISIONS_KIND }],
|
|
83
|
+
pins: [],
|
|
84
|
+
},
|
|
85
|
+
null,
|
|
86
|
+
2,
|
|
87
|
+
)}\n`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The fields of a new open session, as a UI sends them to records new. */
|
|
92
|
+
export function newSessionFields(over: Record<string, unknown> = {}): string {
|
|
93
|
+
return JSON.stringify({
|
|
94
|
+
schema: 1,
|
|
95
|
+
title: "Second walk",
|
|
96
|
+
state: "open",
|
|
97
|
+
agenda: [{ record: "ref-001" }, { record: "ref-002" }],
|
|
98
|
+
attendance: [
|
|
99
|
+
{ principal: "lex00", class: "person" },
|
|
100
|
+
{ principal: "alice", class: "person" },
|
|
101
|
+
],
|
|
102
|
+
opened: "2026-09-25T09:00:00Z",
|
|
103
|
+
closed: null,
|
|
104
|
+
verdicts: [],
|
|
105
|
+
...over,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -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 {
|
|
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(
|
|
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,
|
|
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,
|
|
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",
|