@intentius/chant 0.85.0 → 0.87.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/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +71 -7
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +47 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +71 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/main.ts +55 -3
- package/src/cli/registry.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +16 -4
- package/src/workspace/reason-codes.ts +55 -4
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +308 -19
- package/src/workspace/records-contract.test.ts +57 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +227 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -41
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json",
|
|
4
|
+
"title": "chant workspace records new output",
|
|
5
|
+
"description": "What `chant workspace records new <kind file> --from <file|->` prints (#2670): the path and id of the record it wrote, or would write with --dry-run, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{
|
|
8
|
+
"$ref": "#/$defs/result"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"$ref": "#/$defs/failure"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"$defs": {
|
|
15
|
+
"result": {
|
|
16
|
+
"description": "The record was written, or would be with --dry-run. Exit code 0.",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": [
|
|
19
|
+
"$schema",
|
|
20
|
+
"contract",
|
|
21
|
+
"kind",
|
|
22
|
+
"path",
|
|
23
|
+
"id",
|
|
24
|
+
"dryRun",
|
|
25
|
+
"warnings"
|
|
26
|
+
],
|
|
27
|
+
"properties": {
|
|
28
|
+
"$schema": {
|
|
29
|
+
"const": "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json"
|
|
30
|
+
},
|
|
31
|
+
"contract": {
|
|
32
|
+
"const": 1
|
|
33
|
+
},
|
|
34
|
+
"kind": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"required": [
|
|
37
|
+
"name",
|
|
38
|
+
"schema",
|
|
39
|
+
"file"
|
|
40
|
+
],
|
|
41
|
+
"properties": {
|
|
42
|
+
"name": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "The kind's name, such as \"decision\"."
|
|
45
|
+
},
|
|
46
|
+
"schema": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "The `$id` of the schema the record was validated against."
|
|
49
|
+
},
|
|
50
|
+
"file": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "The kind file, relative to the repository root, with / separators."
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"path": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "The record file, relative to the repository root (the working directory outside git), with / separators."
|
|
59
|
+
},
|
|
60
|
+
"id": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "The record's id."
|
|
63
|
+
},
|
|
64
|
+
"dryRun": {
|
|
65
|
+
"type": "boolean",
|
|
66
|
+
"description": "True when --dry-run was given and nothing was written."
|
|
67
|
+
},
|
|
68
|
+
"warnings": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"description": "The written record's warnings, as chant workspace records reports them. A warning never refuses a write.",
|
|
71
|
+
"items": {
|
|
72
|
+
"$ref": "#/$defs/warning"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"text": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "With --dry-run only: the whole text of the file the command would write."
|
|
78
|
+
},
|
|
79
|
+
"error": false
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"warning": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": [
|
|
85
|
+
"code",
|
|
86
|
+
"message"
|
|
87
|
+
],
|
|
88
|
+
"properties": {
|
|
89
|
+
"code": {
|
|
90
|
+
"enum": [
|
|
91
|
+
"asset-drift",
|
|
92
|
+
"asset-missing",
|
|
93
|
+
"asset-stale",
|
|
94
|
+
"record-supersedes-pending",
|
|
95
|
+
"record-no-evidence",
|
|
96
|
+
"review-undigested"
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"message": {
|
|
100
|
+
"type": "string"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"failure": {
|
|
105
|
+
"description": "Nothing was written. Exit code 1.",
|
|
106
|
+
"type": "object",
|
|
107
|
+
"required": [
|
|
108
|
+
"$schema",
|
|
109
|
+
"contract",
|
|
110
|
+
"error"
|
|
111
|
+
],
|
|
112
|
+
"properties": {
|
|
113
|
+
"$schema": {
|
|
114
|
+
"const": "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json"
|
|
115
|
+
},
|
|
116
|
+
"contract": {
|
|
117
|
+
"const": 1
|
|
118
|
+
},
|
|
119
|
+
"error": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"required": [
|
|
122
|
+
"code",
|
|
123
|
+
"message"
|
|
124
|
+
],
|
|
125
|
+
"properties": {
|
|
126
|
+
"code": {
|
|
127
|
+
"enum": [
|
|
128
|
+
"kind-unreadable",
|
|
129
|
+
"kind-invalid",
|
|
130
|
+
"schema-unreadable",
|
|
131
|
+
"schema-id-mismatch",
|
|
132
|
+
"schema-invalid",
|
|
133
|
+
"location-missing",
|
|
134
|
+
"write-usage-invalid",
|
|
135
|
+
"write-input-invalid",
|
|
136
|
+
"record-id-taken",
|
|
137
|
+
"record-id-unallocatable",
|
|
138
|
+
"record-path-unmatched",
|
|
139
|
+
"record-unparseable",
|
|
140
|
+
"record-schema-invalid",
|
|
141
|
+
"record-id-duplicate",
|
|
142
|
+
"record-supersedes-unknown",
|
|
143
|
+
"record-supersedes-conflict",
|
|
144
|
+
"session-seal-mismatch",
|
|
145
|
+
"session-verdict-unknown-record"
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
"message": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"description": "What was wrong, and what to do instead when there is something, such as writing a new record that supersedes this one."
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"path": false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A record's digest and its quorum (#2671, #2672).
|
|
3
|
+
*
|
|
4
|
+
* `recordTextDigest` hashes a record's text without its reviews block, so a
|
|
5
|
+
* verdict can name the text it judged and stop counting after an amendment.
|
|
6
|
+
* `computeQuorum` lists which verdicts count toward the quorum and why the
|
|
7
|
+
* others don't. The end-to-end reads through `records --json` are in
|
|
8
|
+
* records-contract.test.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
16
|
+
import { workingTreeSource } from "./record-source";
|
|
17
|
+
import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest, type QuorumOptions, type RecordEntry } from "./records";
|
|
18
|
+
|
|
19
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
20
|
+
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
21
|
+
const SAMPLE = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
|
|
22
|
+
|
|
23
|
+
/** ws-003 with its reviews list replaced. `reviews` is the YAML under `reviews:`, or null for `reviews: []`. */
|
|
24
|
+
function withReviews(reviews: string | null, text = SAMPLE): string {
|
|
25
|
+
return text.replace(/^reviews: \[\]$/m, reviews === null ? "reviews: []" : `reviews:\n${reviews.replace(/\n$/, "")}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One review entry as YAML. */
|
|
29
|
+
function review(reviewer: string, verdict: string, extra: Record<string, string> = {}): string {
|
|
30
|
+
const note = verdict === "dissent" && !extra.note ? { note: "it misses a case" } : {};
|
|
31
|
+
const fields = { reviewer, verdict, on: "2026-09-24", ...note, ...extra };
|
|
32
|
+
return Object.entries(fields)
|
|
33
|
+
.map(([k, v], i) => `${i === 0 ? " - " : " "}${k}: ${JSON.stringify(v)}`)
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BARE = recordTextDigest(SAMPLE);
|
|
38
|
+
|
|
39
|
+
describe("recordTextDigest", () => {
|
|
40
|
+
test("is the sha256 hex of the text with the reviews block taken out", () => {
|
|
41
|
+
expect(BARE).toMatch(/^[0-9a-f]{64}$/);
|
|
42
|
+
const lines = SAMPLE.split("\n");
|
|
43
|
+
const without = lines.filter((l) => l !== "reviews: []").join("\n");
|
|
44
|
+
expect(recordTextDigest(SAMPLE)).toBe(recordTextDigest(without, null));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not move when a verdict is added, changed or removed", () => {
|
|
48
|
+
const one = withReviews(review("alice", "agree"));
|
|
49
|
+
const two = withReviews(`${review("alice", "agree", { digest: BARE })}\n${review("bob", "dissent", { note: "no" })}`);
|
|
50
|
+
expect(recordTextDigest(one)).toBe(BARE);
|
|
51
|
+
expect(recordTextDigest(two)).toBe(BARE);
|
|
52
|
+
// The compact form, with the list at column 0, and comments and blank lines inside the block.
|
|
53
|
+
const compact = SAMPLE.replace(/^reviews: \[\]$/m, `reviews:\n# first review\n- reviewer: "alice"\n verdict: "agree"\n\n on: "2026-09-24"`);
|
|
54
|
+
expect(recordTextDigest(compact)).toBe(BARE);
|
|
55
|
+
// A quoted key is the same key.
|
|
56
|
+
expect(recordTextDigest(SAMPLE.replace(/^reviews: \[\]$/m, `"reviews": []`))).toBe(BARE);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("moves with any other edit, in the front matter or the body", () => {
|
|
60
|
+
expect(recordTextDigest(SAMPLE.replace(/^title: .*$/m, 'title: "Seal scope, amended"'))).not.toBe(BARE);
|
|
61
|
+
expect(recordTextDigest(`${SAMPLE}\nOne more line.\n`)).not.toBe(BARE);
|
|
62
|
+
// The line after the block is kept: removal stops at the next key.
|
|
63
|
+
expect(recordTextDigest(SAMPLE.replace(/^constrains:$/m, "constrains: # the scope"))).not.toBe(BARE);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reads CRLF and LF alike", () => {
|
|
67
|
+
expect(recordTextDigest(SAMPLE.replace(/\n/g, "\r\n"))).toBe(BARE);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("hashes text with no front matter as it is", () => {
|
|
71
|
+
expect(recordTextDigest("reviews: []\nbody\n")).toBe(recordTextDigest("reviews: []\nbody\n", null));
|
|
72
|
+
expect(recordTextDigest("---\nreviews: []\nno closing line\n")).toBe(recordTextDigest("---\nreviews: []\nno closing line\n", null));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a hand-editor gets the same digest with awk and sha256sum", () => {
|
|
76
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-digest-")));
|
|
77
|
+
try {
|
|
78
|
+
const file = join(dir, "ws-003.md");
|
|
79
|
+
writeFileSync(file, withReviews(`${review("alice", "agree")}\n${review("bob", "dissent")}`));
|
|
80
|
+
// The recipe in docs/src/content/docs/cli/workspace-records.mdx.
|
|
81
|
+
const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^reviews[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
|
|
82
|
+
const out = execFileSync("sh", ["-c", awk], { encoding: "utf-8" });
|
|
83
|
+
expect(out.split(" ")[0]).toBe(BARE);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("normalisePrincipal", () => {
|
|
91
|
+
test("trims and case-folds", () => {
|
|
92
|
+
expect(normalisePrincipal("Alice ")).toBe("alice");
|
|
93
|
+
expect(normalisePrincipal(" ALICE")).toBe(normalisePrincipal("alice"));
|
|
94
|
+
expect(normalisePrincipal("alice")).toBe("alice");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("computeQuorum", () => {
|
|
99
|
+
let dir: string;
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-quorum-")));
|
|
102
|
+
mkdirSync(join(dir, "decisions"));
|
|
103
|
+
cpSync(join(DECISIONS, "decision.kind.mjs"), join(dir, "decisions", "decision.kind.mjs"));
|
|
104
|
+
cpSync(join(DECISIONS, "decision.schema.json"), join(dir, "decisions", "decision.schema.json"));
|
|
105
|
+
});
|
|
106
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
107
|
+
|
|
108
|
+
const OPTIONS: QuorumOptions = { need: DEFAULT_QUORUM, needFrom: "default", agents: new Set(), attestation: false };
|
|
109
|
+
|
|
110
|
+
async function quorumOf(text: string, options: Partial<QuorumOptions> = {}) {
|
|
111
|
+
writeFileSync(join(dir, "decisions", "ws-003-seal-scope.md"), text);
|
|
112
|
+
const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
113
|
+
const { records } = await readRecords(loaded, { root: dir, source: workingTreeSource(dir) });
|
|
114
|
+
const record = records[0] as RecordEntry;
|
|
115
|
+
expect(record.reasons).toEqual([]);
|
|
116
|
+
return { record, quorum: computeQuorum(loaded.kind, record, { ...OPTIONS, ...options })! };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const principals = (list: Array<{ principal: string; reason?: { code: string } }>) => list.map((v) => (v.reason ? `${v.principal}:${v.reason.code}` : v.principal));
|
|
120
|
+
|
|
121
|
+
test("alice and 'Alice ' count once: the later verdict stands, the earlier is a duplicate", async () => {
|
|
122
|
+
const { quorum } = await quorumOf(withReviews(`${review("alice", "agree", { digest: BARE })}\n${review("Alice ", "agree", { digest: BARE })}`));
|
|
123
|
+
expect(principals(quorum.counted)).toEqual(["alice"]);
|
|
124
|
+
expect(quorum.counted[0].reviewer).toBe("Alice ");
|
|
125
|
+
expect(principals(quorum.notCounted)).toEqual(["alice:review-duplicate"]);
|
|
126
|
+
expect(quorum).toMatchObject({ need: 2, agreed: 1, met: false, metWithObjections: false });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a review by the decider is listed as not counted", async () => {
|
|
130
|
+
const { quorum } = await quorumOf(withReviews(`${review("Lex00", "agree", { digest: BARE })}\n${review("bob", "agree", { digest: BARE })}`));
|
|
131
|
+
expect(principals(quorum.notCounted)).toEqual(["lex00:review-decider"]);
|
|
132
|
+
expect(principals(quorum.counted)).toEqual(["bob"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("amending the record after one agree drops the count from 1 to 0, with review-older-digest", async () => {
|
|
136
|
+
const reviewed = withReviews(review("alice", "agree", { digest: BARE }));
|
|
137
|
+
const before = await quorumOf(reviewed);
|
|
138
|
+
expect(before.quorum.agreed).toBe(1);
|
|
139
|
+
const after = await quorumOf(reviewed.replace(/^question: .*$/m, 'question: "What does a seal cover, now?"'));
|
|
140
|
+
expect(after.record.digest).not.toBe(BARE);
|
|
141
|
+
expect(after.quorum.counted).toEqual([]);
|
|
142
|
+
expect(after.quorum.agreed).toBe(0);
|
|
143
|
+
expect(principals(after.quorum.notCounted)).toEqual(["alice:review-older-digest"]);
|
|
144
|
+
expect(after.quorum.notCounted[0].reason!.message).toContain(BARE.slice(0, 12));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a verdict with no digest counts, and the record carries review-undigested", async () => {
|
|
148
|
+
const { record, quorum } = await quorumOf(withReviews(`${review("alice", "agree")}\n${review("bob", "agree", { digest: BARE })}`));
|
|
149
|
+
expect(principals(quorum.counted)).toEqual(["alice", "bob"]);
|
|
150
|
+
expect(quorum.counted[0].digest).toBeNull();
|
|
151
|
+
expect(quorum).toMatchObject({ agreed: 2, met: true, metWithObjections: false });
|
|
152
|
+
expect(record.warnings.map((w) => w.code)).toEqual(["review-undigested"]);
|
|
153
|
+
expect(record.warnings[0].message).toContain("alice");
|
|
154
|
+
expect(record.warnings[0].message).not.toContain("bob");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a met count with an open concern is met with objections, never consensus", async () => {
|
|
158
|
+
const reviews = [review("alice", "agree", { digest: BARE }), review("bob", "agree", { digest: BARE }), review("carol", "dissent", { digest: BARE, proposes: "ws-900" })];
|
|
159
|
+
const { quorum } = await quorumOf(withReviews(reviews.join("\n")));
|
|
160
|
+
expect(quorum).toMatchObject({ agreed: 2, met: true, metWithObjections: true });
|
|
161
|
+
expect(quorum.openConcerns).toEqual([{ index: 2, principal: "carol", reviewer: "carol", note: "it misses a case", proposes: "ws-900" }]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an addressed or withdrawn dissent is no open concern", async () => {
|
|
165
|
+
const reviews = [
|
|
166
|
+
review("alice", "agree", { digest: BARE }),
|
|
167
|
+
review("bob", "agree", { digest: BARE }),
|
|
168
|
+
review("carol", "dissent", { digest: BARE, addressed_by: "ws-001" }),
|
|
169
|
+
review("dan", "dissent", { digest: BARE, withdrawn_on: "2026-09-25" }),
|
|
170
|
+
];
|
|
171
|
+
const { quorum } = await quorumOf(withReviews(reviews.join("\n")));
|
|
172
|
+
expect(quorum.openConcerns).toEqual([]);
|
|
173
|
+
expect(quorum).toMatchObject({ met: true, metWithObjections: false });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("an agent's verdict is not counted, and under an attestation policy an unsealed one is not either", async () => {
|
|
177
|
+
const text = withReviews(`${review("Bot-1", "agree", { digest: BARE })}\n${review("alice", "agree", { digest: BARE })}`);
|
|
178
|
+
const agents = await quorumOf(text, { agents: new Set(["bot-1"]) });
|
|
179
|
+
expect(principals(agents.quorum.notCounted)).toEqual(["bot-1:review-agent"]);
|
|
180
|
+
const attested = await quorumOf(text, { attestation: true });
|
|
181
|
+
expect(principals(attested.quorum.notCounted)).toEqual(["bot-1:review-unattested", "alice:review-unattested"]);
|
|
182
|
+
expect(attested.quorum.counted).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("the declared need applies, and a need of 0 is met with no verdict", async () => {
|
|
186
|
+
const { quorum } = await quorumOf(SAMPLE, { need: 0, needFrom: "declaration" });
|
|
187
|
+
expect(quorum).toMatchObject({ need: 0, needFrom: "declaration", agreed: 0, met: true, counted: [], notCounted: [], openConcerns: [] });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a kind with no reviews list has no quorum", async () => {
|
|
191
|
+
const loaded = await loadRecordKind(join(DECISIONS, "decision.kind.mjs"));
|
|
192
|
+
const { reviews: _drop, ...kind } = loaded.kind;
|
|
193
|
+
expect(computeQuorum(kind, { data: {}, digest: BARE }, OPTIONS)).toBeNull();
|
|
194
|
+
expect(computeQuorum(loaded.kind, { data: null, digest: BARE }, OPTIONS)).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json",
|
|
4
|
+
"title": "chant workspace records review output",
|
|
5
|
+
"description": "What `chant workspace records review <id> --kind <kind file> --verdict <verdict> --by <principal>` prints (#2670): the record's path and id and the review entry appended, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{
|
|
8
|
+
"$ref": "#/$defs/result"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"$ref": "#/$defs/failure"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"$defs": {
|
|
15
|
+
"result": {
|
|
16
|
+
"description": "The record was written, or would be with --dry-run. Exit code 0.",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": [
|
|
19
|
+
"$schema",
|
|
20
|
+
"contract",
|
|
21
|
+
"kind",
|
|
22
|
+
"path",
|
|
23
|
+
"id",
|
|
24
|
+
"review",
|
|
25
|
+
"dryRun",
|
|
26
|
+
"warnings"
|
|
27
|
+
],
|
|
28
|
+
"properties": {
|
|
29
|
+
"$schema": {
|
|
30
|
+
"const": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json"
|
|
31
|
+
},
|
|
32
|
+
"contract": {
|
|
33
|
+
"const": 1
|
|
34
|
+
},
|
|
35
|
+
"kind": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"required": [
|
|
38
|
+
"name",
|
|
39
|
+
"schema",
|
|
40
|
+
"file"
|
|
41
|
+
],
|
|
42
|
+
"properties": {
|
|
43
|
+
"name": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "The kind's name, such as \"decision\"."
|
|
46
|
+
},
|
|
47
|
+
"schema": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "The `$id` of the schema the record was validated against."
|
|
50
|
+
},
|
|
51
|
+
"file": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "The kind file, relative to the repository root, with / separators."
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"path": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "The record file, relative to the repository root (the working directory outside git), with / separators."
|
|
60
|
+
},
|
|
61
|
+
"id": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"description": "The record's id."
|
|
64
|
+
},
|
|
65
|
+
"review": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"description": "The entry appended to the record's reviews, as written.",
|
|
68
|
+
"required": [
|
|
69
|
+
"reviewer",
|
|
70
|
+
"verdict",
|
|
71
|
+
"on",
|
|
72
|
+
"digest"
|
|
73
|
+
],
|
|
74
|
+
"properties": {
|
|
75
|
+
"reviewer": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"minLength": 1,
|
|
78
|
+
"description": "The principal given with --by, as given. The write does not check who it is. With --sign, the seal lets chant workspace records check it against the signers file at base (#2687)."
|
|
79
|
+
},
|
|
80
|
+
"verdict": {
|
|
81
|
+
"enum": [
|
|
82
|
+
"agree",
|
|
83
|
+
"dissent",
|
|
84
|
+
"abstain"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"note": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "The note given with --note. Absent when none was given; a dissent always has one."
|
|
90
|
+
},
|
|
91
|
+
"on": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
|
|
94
|
+
"description": "The date of the verdict, in UTC."
|
|
95
|
+
},
|
|
96
|
+
"digest": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
99
|
+
"description": "The digest of the record text the verdict judged: the lowercase hex SHA-256 of the file with LF line endings and without its reviews block (#2672), as records prints it for the record. The command changes only the reviews block, so the record keeps this digest."
|
|
100
|
+
},
|
|
101
|
+
"session": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"minLength": 1,
|
|
104
|
+
"description": "The session given with --session. Absent when none was given."
|
|
105
|
+
},
|
|
106
|
+
"seal": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"description": "Added in contract 1 by #2687. With --sign only: an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<verdict>\\n<reviewer>\\n<on>, in the ssh-keygen namespace chant-review. The write does not check the key against the signers file; chant workspace records reports whether the seal counts.",
|
|
109
|
+
"required": [
|
|
110
|
+
"signer",
|
|
111
|
+
"key",
|
|
112
|
+
"signature"
|
|
113
|
+
],
|
|
114
|
+
"properties": {
|
|
115
|
+
"signer": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"description": "The reviewer given with --by."
|
|
118
|
+
},
|
|
119
|
+
"key": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
|
|
122
|
+
"description": "The fingerprint of the key that signed, read back from the signature."
|
|
123
|
+
},
|
|
124
|
+
"signature": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "The armored ssh signature."
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"dryRun": {
|
|
133
|
+
"type": "boolean",
|
|
134
|
+
"description": "True when --dry-run was given and nothing was written."
|
|
135
|
+
},
|
|
136
|
+
"warnings": {
|
|
137
|
+
"type": "array",
|
|
138
|
+
"description": "The written record's warnings, as chant workspace records reports them. A warning never refuses a write.",
|
|
139
|
+
"items": {
|
|
140
|
+
"$ref": "#/$defs/warning"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"text": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"description": "With --dry-run only: the whole text of the file the command would write."
|
|
146
|
+
},
|
|
147
|
+
"error": false
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"warning": {
|
|
151
|
+
"type": "object",
|
|
152
|
+
"required": [
|
|
153
|
+
"code",
|
|
154
|
+
"message"
|
|
155
|
+
],
|
|
156
|
+
"properties": {
|
|
157
|
+
"code": {
|
|
158
|
+
"enum": [
|
|
159
|
+
"asset-drift",
|
|
160
|
+
"asset-missing",
|
|
161
|
+
"asset-stale",
|
|
162
|
+
"record-supersedes-pending",
|
|
163
|
+
"record-no-evidence",
|
|
164
|
+
"review-undigested"
|
|
165
|
+
]
|
|
166
|
+
},
|
|
167
|
+
"message": {
|
|
168
|
+
"type": "string"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
"failure": {
|
|
173
|
+
"description": "Nothing was written. Exit code 1.",
|
|
174
|
+
"type": "object",
|
|
175
|
+
"required": [
|
|
176
|
+
"$schema",
|
|
177
|
+
"contract",
|
|
178
|
+
"error"
|
|
179
|
+
],
|
|
180
|
+
"properties": {
|
|
181
|
+
"$schema": {
|
|
182
|
+
"const": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json"
|
|
183
|
+
},
|
|
184
|
+
"contract": {
|
|
185
|
+
"const": 1
|
|
186
|
+
},
|
|
187
|
+
"error": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"required": [
|
|
190
|
+
"code",
|
|
191
|
+
"message"
|
|
192
|
+
],
|
|
193
|
+
"properties": {
|
|
194
|
+
"code": {
|
|
195
|
+
"enum": [
|
|
196
|
+
"kind-unreadable",
|
|
197
|
+
"kind-invalid",
|
|
198
|
+
"schema-unreadable",
|
|
199
|
+
"schema-id-mismatch",
|
|
200
|
+
"schema-invalid",
|
|
201
|
+
"location-missing",
|
|
202
|
+
"write-usage-invalid",
|
|
203
|
+
"record-not-found",
|
|
204
|
+
"review-unsupported",
|
|
205
|
+
"record-closed",
|
|
206
|
+
"review-note-required",
|
|
207
|
+
"review-sign-failed",
|
|
208
|
+
"record-unparseable",
|
|
209
|
+
"record-schema-invalid",
|
|
210
|
+
"record-id-duplicate",
|
|
211
|
+
"record-supersedes-unknown",
|
|
212
|
+
"record-supersedes-conflict",
|
|
213
|
+
"session-seal-mismatch",
|
|
214
|
+
"session-verdict-unknown-record"
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
"message": {
|
|
218
|
+
"type": "string",
|
|
219
|
+
"description": "What was wrong, and what to do instead when there is something, such as writing a new record that supersedes this one."
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
"path": false
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|