@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,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace records new|amend|review` (#2670): each command validates
|
|
3
|
+
* before it writes, writes exactly one file or none, and refuses with a closed
|
|
4
|
+
* code. Run in a directory outside git holding a copy of the decision kind.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { parseFrontMatter, recordTextDigest } from "./records";
|
|
15
|
+
import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
|
|
16
|
+
|
|
17
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
18
|
+
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
19
|
+
const KIND = "decisions/decision.kind.mjs";
|
|
20
|
+
|
|
21
|
+
type Data = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
const SAMPLE = (() => {
|
|
24
|
+
const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
|
|
25
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
26
|
+
return fm.value;
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
/** A decided decision like ws-003, with `over` laid on top. */
|
|
30
|
+
function decision(over: Data = {}): Data {
|
|
31
|
+
return { ...structuredClone(SAMPLE), ...over };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A proposed decision: no choice, nobody decided. */
|
|
35
|
+
function proposal(over: Data = {}): Data {
|
|
36
|
+
return decision({ state: "proposed", choice: null, decided_by: null, decided_on: null, ...over });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let dir: string;
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-records-write-")));
|
|
42
|
+
mkdirSync(join(dir, "decisions"));
|
|
43
|
+
cpSync(join(DECISIONS, "decision.kind.mjs"), join(dir, "decisions", "decision.kind.mjs"));
|
|
44
|
+
cpSync(join(DECISIONS, "decision.schema.json"), join(dir, "decisions", "decision.schema.json"));
|
|
45
|
+
put("ws-001-one.md", decision({ id: "ws-001", title: "One" }));
|
|
46
|
+
put("ws-002-two.md", proposal({ id: "ws-002", title: "Two" }));
|
|
47
|
+
put("ws-003-three.md", decision({ id: "ws-003", title: "Three", state: "ratified" }));
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
50
|
+
|
|
51
|
+
function put(name: string, data: Data): void {
|
|
52
|
+
writeFileSync(join(dir, "decisions", name), renderRecord(data, `\n# ${String(data.title)}\n`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function text(name: string): string {
|
|
56
|
+
return readFileSync(join(dir, "decisions", name), "utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function data(name: string): Data {
|
|
60
|
+
const fm = parseFrontMatter(text(name));
|
|
61
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
62
|
+
return fm.value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every file under the directory, with the hash of its bytes. */
|
|
66
|
+
function snapshot(): Record<string, string> {
|
|
67
|
+
const out: Record<string, string> = {};
|
|
68
|
+
for (const f of readdirSync(join(dir, "decisions"))) out[f] = createHash("sha256").update(readFileSync(join(dir, "decisions", f))).digest("hex");
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The files that differ between two snapshots. */
|
|
73
|
+
function touched(a: Record<string, string>, b: Record<string, string>): string[] {
|
|
74
|
+
return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((f) => a[f] !== b[f]).sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fields = (d: Data) => JSON.stringify(d);
|
|
78
|
+
const code = (doc: object) => ("error" in doc ? (doc as { error: { code: string } }).error.code : "ok");
|
|
79
|
+
|
|
80
|
+
describe("rendering", () => {
|
|
81
|
+
test("every decision in this repository and the reference workspace renders back to its own bytes", () => {
|
|
82
|
+
const dirs = [DECISIONS, join(REPO, "reference-workspace", "decisions")];
|
|
83
|
+
let n = 0;
|
|
84
|
+
for (const d of dirs) {
|
|
85
|
+
for (const f of readdirSync(d).filter((x) => /^[a-z]+-\d{3}-.+\.md$/.test(x))) {
|
|
86
|
+
const t = readFileSync(join(d, f), "utf-8");
|
|
87
|
+
const fm = parseFrontMatter(t);
|
|
88
|
+
if (!fm.ok) throw new Error(`${f}: ${fm.message}`);
|
|
89
|
+
expect(renderRecord(fm.value, t.replace(/^---\n[\s\S]*?\n---\n/, "")), f).toBe(t);
|
|
90
|
+
n++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
expect(n).toBeGreaterThan(50);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("reads back to the same value, whatever the keys, nesting and empties", () => {
|
|
97
|
+
const value = {
|
|
98
|
+
plain: "a \"quoted\" line\nand another",
|
|
99
|
+
"needs quoting": 1,
|
|
100
|
+
true: false,
|
|
101
|
+
n: -2.5,
|
|
102
|
+
none: null,
|
|
103
|
+
list: [[], {}, ["x", ["y"]], { a: [], b: { c: "d" } }],
|
|
104
|
+
obj: {},
|
|
105
|
+
nested: { deeper: [{ k: "v", l: [1, 2] }] },
|
|
106
|
+
};
|
|
107
|
+
const fm = parseFrontMatter(`---\n${toYaml(value)}\n---\n`);
|
|
108
|
+
expect(fm).toEqual({ ok: true, value });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("the digest a review names (#2672)", () => {
|
|
113
|
+
test("records review changes only the reviews block, so recordTextDigest of the file is unchanged", async () => {
|
|
114
|
+
// A hand-laid file: comments, blank lines and a flow list the writer would never produce.
|
|
115
|
+
const hand = text("ws-001-one.md").replace('title: "One"', "title: \"One\" # set by hand").replace("reviews: []", "reviews: [] # none yet\n");
|
|
116
|
+
writeFileSync(join(dir, "decisions", "ws-001-one.md"), hand);
|
|
117
|
+
const judged = recordTextDigest(hand);
|
|
118
|
+
for (const [by, verdict, note] of [["alice", "agree", undefined], ["bob", "dissent", "Not yet."], ["carol", "abstain", undefined]] as const) {
|
|
119
|
+
const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict, by, note, cwd: dir });
|
|
120
|
+
expect(doc).toMatchObject({ review: { digest: judged } });
|
|
121
|
+
expect(recordTextDigest(text("ws-001-one.md")), by).toBe(judged);
|
|
122
|
+
}
|
|
123
|
+
const after = text("ws-001-one.md");
|
|
124
|
+
expect(after).toContain('title: "One" # set by hand');
|
|
125
|
+
const cut = (t: string) => t.replace(/\nreviews:[^\n]*(?:\n(?:[ \t#-][^\n]*|))*?(?=\n[a-z])/, "");
|
|
126
|
+
expect(cut(after)).toBe(cut(hand));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("an amendment outside the reviews moves the digest, and one inside them does not", async () => {
|
|
130
|
+
await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: "Why?", cwd: dir });
|
|
131
|
+
const judged = recordTextDigest(text("ws-001-one.md"));
|
|
132
|
+
const reviews = (data("ws-001-one.md").reviews as Data[]).map((r) => ({ ...r, addressed_by: "INTENTIUS/chant#2670" }));
|
|
133
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ reviews }), cwd: dir })).toMatchObject({ changed: ["reviews"] });
|
|
134
|
+
expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
|
|
135
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence: [] }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
|
|
136
|
+
expect(recordTextDigest(text("ws-001-one.md"))).not.toBe(judged);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an amendment rewrites only the fields it changes", async () => {
|
|
140
|
+
const hand = text("ws-002-two.md").replace('title: "Two"', "title: \"Two\" # kept");
|
|
141
|
+
writeFileSync(join(dir, "decisions", "ws-002-two.md"), hand);
|
|
142
|
+
await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ area: "D9" }), cwd: dir });
|
|
143
|
+
expect(text("ws-002-two.md")).toBe(hand.replace('area: "D4"', 'area: "D9"'));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("records new", () => {
|
|
148
|
+
const fresh = () => {
|
|
149
|
+
const { id: _id, ...rest } = decision({ title: "Four things", reviews: [] });
|
|
150
|
+
return rest;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
test("allocates the next id, writes one file named for it, and prints its path and id", async () => {
|
|
154
|
+
const before = snapshot();
|
|
155
|
+
const doc = await newRecord({ kind: KIND, fields: fields(fresh()), cwd: dir });
|
|
156
|
+
expect(doc).toMatchObject({ path: "decisions/ws-004-four-things.md", id: "ws-004", dryRun: false, warnings: [] });
|
|
157
|
+
expect(touched(before, snapshot())).toEqual(["ws-004-four-things.md"]);
|
|
158
|
+
expect(data("ws-004-four-things.md")).toEqual({ ...decision({ title: "Four things" }), id: "ws-004" });
|
|
159
|
+
expect(Object.keys(data("ws-004-four-things.md"))).toEqual(Object.keys(SAMPLE));
|
|
160
|
+
expect(text("ws-004-four-things.md")).toMatch(/\n---\n\n# Four things\n$/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("--dry-run prints the text and writes nothing", async () => {
|
|
164
|
+
const before = snapshot();
|
|
165
|
+
const doc = await newRecord({ kind: KIND, fields: fields(fresh()), dryRun: true, cwd: dir });
|
|
166
|
+
expect(doc).toMatchObject({ id: "ws-004", dryRun: true });
|
|
167
|
+
expect("text" in doc && doc.text).toContain('id: "ws-004"');
|
|
168
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("keeps a caller's id, and refuses one already used", async () => {
|
|
172
|
+
expect(await newRecord({ kind: KIND, fields: fields({ ...fresh(), id: "ws-010" }), cwd: dir })).toMatchObject({ id: "ws-010" });
|
|
173
|
+
const doc = await newRecord({ kind: KIND, fields: fields({ ...fresh(), id: "ws-002" }), cwd: dir });
|
|
174
|
+
expect(code(doc)).toBe("record-id-taken");
|
|
175
|
+
// An unreadable file still holds its id through its name.
|
|
176
|
+
writeFileSync(join(dir, "decisions", "ws-020-broken.md"), "no front matter\n");
|
|
177
|
+
expect(code(await newRecord({ kind: KIND, fields: fields({ ...fresh(), id: "ws-020" }), cwd: dir }))).toBe("record-id-taken");
|
|
178
|
+
expect(await newRecord({ kind: KIND, fields: fields(fresh()), dryRun: true, cwd: dir })).toMatchObject({ id: "ws-021" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("allocates under --prefix, and refuses to guess between prefixes", async () => {
|
|
182
|
+
put("ref-001-other.md", decision({ id: "ref-001", title: "Other" }));
|
|
183
|
+
expect(code(await newRecord({ kind: KIND, fields: fields(fresh()), cwd: dir }))).toBe("record-id-unallocatable");
|
|
184
|
+
expect(await newRecord({ kind: KIND, fields: fields(fresh()), prefix: "ref", dryRun: true, cwd: dir })).toMatchObject({ id: "ref-002" });
|
|
185
|
+
expect(await newRecord({ kind: KIND, fields: fields(fresh()), prefix: "adr", dryRun: true, cwd: dir })).toMatchObject({ id: "adr-001" });
|
|
186
|
+
expect(code(await newRecord({ kind: KIND, fields: fields(fresh()), prefix: "Bad!", cwd: dir }))).toBe("write-usage-invalid");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("keeps the width of the ids it follows", () => {
|
|
190
|
+
const entry = (id: string) => ({ id, path: `d/${id}-x.md`, state: null, valid: true, reasons: [], supersededBy: null, data: {}, digest: "", assets: [], warnings: [] });
|
|
191
|
+
expect(allocateId([entry("s-0009"), entry("s-0010")], undefined, "k")).toBe("s-0011");
|
|
192
|
+
expect(allocateId([entry("s-999")], undefined, "k")).toBe("s-1000");
|
|
193
|
+
// Case and padding come from the records (#2683).
|
|
194
|
+
expect(allocateId([entry("W-001"), entry("W-002")], undefined, "work")).toBe("W-003");
|
|
195
|
+
expect(allocateId([entry("W-0009")], undefined, "work")).toBe("W-0010");
|
|
196
|
+
expect(allocateId([], "W", "work")).toBe("W-001");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("refuses fields the schema refuses, and writes nothing", async () => {
|
|
200
|
+
const before = snapshot();
|
|
201
|
+
const doc = await newRecord({ kind: KIND, fields: fields({ ...fresh(), constrains: [] }), cwd: dir });
|
|
202
|
+
expect(code(doc)).toBe("record-schema-invalid");
|
|
203
|
+
expect("error" in doc && doc.error.message).toMatch(/constrains/);
|
|
204
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("refuses input that is not a JSON object", async () => {
|
|
208
|
+
for (const input of ["not json", "[1]", "null"]) expect(code(await newRecord({ kind: KIND, fields: input, cwd: dir })), input).toBe("write-input-invalid");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("refuses a supersedes link to no record, and one that would make another record invalid", async () => {
|
|
212
|
+
expect(code(await newRecord({ kind: KIND, fields: fields({ ...fresh(), supersedes: [{ decision: "ws-099" }] }), cwd: dir }))).toBe("record-supersedes-unknown");
|
|
213
|
+
// ws-005 ratified supersedes ws-001; a second ratified record claiming ws-001, sorting first, would make ws-005 conflict.
|
|
214
|
+
put("ws-005-five.md", decision({ id: "ws-005", title: "Five", state: "ratified", supersedes: [{ decision: "ws-001" }] }));
|
|
215
|
+
const doc = await newRecord({ kind: KIND, fields: fields({ ...fresh(), id: "ws-004", state: "ratified", supersedes: [{ decision: "ws-001" }] }), cwd: dir });
|
|
216
|
+
expect(code(doc)).toBe("record-supersedes-conflict");
|
|
217
|
+
expect("error" in doc && doc.error.message).toContain("ws-005-five.md");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("reports the written record's warnings without refusing it", async () => {
|
|
221
|
+
const doc = await newRecord({ kind: KIND, fields: fields({ ...fresh(), evidence: [] }), dryRun: true, cwd: dir });
|
|
222
|
+
expect("warnings" in doc && doc.warnings.map((w) => w.code)).toEqual(["record-no-evidence"]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a kind that can't be read is its read error", async () => {
|
|
226
|
+
expect(code(await newRecord({ kind: "missing.kind.mjs", fields: fields(fresh()), cwd: dir }))).toBe("kind-unreadable");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("records amend", () => {
|
|
231
|
+
test("a proposed record changes freely, and prints what changed", async () => {
|
|
232
|
+
const before = snapshot();
|
|
233
|
+
const doc = await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ question: "A new question?", area: "D9" }), cwd: dir });
|
|
234
|
+
expect(doc).toMatchObject({ path: "decisions/ws-002-two.md", id: "ws-002", changed: ["area", "question"] });
|
|
235
|
+
expect(touched(before, snapshot())).toEqual(["ws-002-two.md"]);
|
|
236
|
+
expect(data("ws-002-two.md")).toMatchObject({ question: "A new question?", area: "D9" });
|
|
237
|
+
expect(text("ws-002-two.md")).toMatch(/\n# Two\n$/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a proposed record can be decided, when the decision is complete", async () => {
|
|
241
|
+
const doc = await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ state: "decided", choice: SAMPLE.choice, decided_by: "lex00", decided_on: "2026-09-24" }), cwd: dir });
|
|
242
|
+
expect(doc).toMatchObject({ changed: ["state", "choice", "decided_by", "decided_on"] });
|
|
243
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ decided_by: null }), cwd: dir }))).toBe("amend-supersede-instead");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a decided record refuses a change to its substance and says to supersede it", async () => {
|
|
247
|
+
const before = snapshot();
|
|
248
|
+
const doc = await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ choice: { option: "b", reason: "changed my mind" } }), cwd: dir });
|
|
249
|
+
expect(code(doc)).toBe("amend-supersede-instead");
|
|
250
|
+
expect("error" in doc && doc.error.message).toMatch(/supersedes: \[\{"decision": "ws-001"\}\]/);
|
|
251
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a decided record may be ratified, re-pin its evidence and take reviews, but not move down", async () => {
|
|
255
|
+
const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
|
|
256
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
|
|
257
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
|
|
258
|
+
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
259
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a closed record never changes", async () => {
|
|
263
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-003", fields: fields({ reviews: [] , title: "x" }), cwd: dir }))).toBe("record-closed");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("an id never changes", async () => {
|
|
267
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ id: "ws-012" }), cwd: dir }))).toBe("amend-id-immutable");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("an amendment that changes nothing writes nothing", async () => {
|
|
271
|
+
const before = snapshot();
|
|
272
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ title: "One" }), cwd: dir })).toMatchObject({ changed: [] });
|
|
273
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("the result must still be valid", async () => {
|
|
277
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ options: [] }), cwd: dir }))).toBe("record-schema-invalid");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("an unknown or repeated id is refused", async () => {
|
|
281
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-404", fields: "{}", cwd: dir }))).toBe("record-not-found");
|
|
282
|
+
put("ws-002-again.md", proposal({ id: "ws-002", title: "Again" }));
|
|
283
|
+
expect(code(await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ area: "D1" }), cwd: dir }))).toBe("record-id-duplicate");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("--dry-run prints the text and writes nothing", async () => {
|
|
287
|
+
const before = snapshot();
|
|
288
|
+
const doc = await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ area: "D7" }), dryRun: true, cwd: dir });
|
|
289
|
+
expect("text" in doc && doc.text).toContain('area: "D7"');
|
|
290
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("records review", () => {
|
|
295
|
+
test("appends a dated verdict bound to the digest of the text it judged", async () => {
|
|
296
|
+
const judged = recordTextDigest(text("ws-001-one.md"));
|
|
297
|
+
const before = snapshot();
|
|
298
|
+
const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by: "alice", on: "2026-09-24", cwd: dir });
|
|
299
|
+
const review = { reviewer: "alice", verdict: "agree", on: "2026-09-24", digest: judged };
|
|
300
|
+
expect(doc).toMatchObject({ path: "decisions/ws-001-one.md", id: "ws-001", review, dryRun: false });
|
|
301
|
+
expect(touched(before, snapshot())).toEqual(["ws-001-one.md"]);
|
|
302
|
+
expect(data("ws-001-one.md").reviews).toEqual([review]);
|
|
303
|
+
// A review leaves the digest alone; an amendment moves it, so the verdict no longer matches the text.
|
|
304
|
+
expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
|
|
305
|
+
await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence: [] }), cwd: dir });
|
|
306
|
+
expect(recordTextDigest(text("ws-001-one.md"))).not.toBe(judged);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a dissent needs a note", async () => {
|
|
310
|
+
const before = snapshot();
|
|
311
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", cwd: dir }))).toBe("review-note-required");
|
|
312
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: " ", cwd: dir }))).toBe("review-note-required");
|
|
313
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
314
|
+
const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: "It leaves out X.", session: "S-0001", cwd: dir });
|
|
315
|
+
expect(doc).toMatchObject({ review: { reviewer: "bob", verdict: "dissent", note: "It leaves out X.", session: "S-0001" } });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("reviews accumulate on a proposal too", async () => {
|
|
319
|
+
await reviewRecord({ kind: KIND, id: "ws-002", verdict: "abstain", by: "carol", cwd: dir });
|
|
320
|
+
await reviewRecord({ kind: KIND, id: "ws-002", verdict: "agree", by: "dan", note: "fine", cwd: dir });
|
|
321
|
+
expect((data("ws-002-two.md").reviews as Data[]).map((r) => r.reviewer)).toEqual(["carol", "dan"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a closed record takes no review, and a bad verdict, reviewer or id is refused", async () => {
|
|
325
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-003", verdict: "agree", by: "a", cwd: dir }))).toBe("record-closed");
|
|
326
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "approve", by: "a", cwd: dir }))).toBe("write-usage-invalid");
|
|
327
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by: " ", cwd: dir }))).toBe("write-usage-invalid");
|
|
328
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-404", verdict: "agree", by: "a", cwd: dir }))).toBe("record-not-found");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a kind that declares no reviews takes none", async () => {
|
|
332
|
+
const kindFile = join(dir, "decisions", "decision.kind.mjs");
|
|
333
|
+
writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace(/^\s*reviews: \{[^}]*\},?\n/m, ""));
|
|
334
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by: "a", cwd: dir }))).toBe("review-unsupported");
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("the command line", () => {
|
|
339
|
+
const cli = (args: string[], input?: string) => {
|
|
340
|
+
const loader = pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href;
|
|
341
|
+
try {
|
|
342
|
+
const out = execFileSync(process.execPath, ["--import", loader, join(REPO, "packages", "core", "src", "cli", "main.ts"), "workspace", "records", ...args], {
|
|
343
|
+
cwd: dir,
|
|
344
|
+
input,
|
|
345
|
+
encoding: "utf-8",
|
|
346
|
+
env: { ...process.env, TSX_DISABLE_CACHE: "1" },
|
|
347
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
348
|
+
});
|
|
349
|
+
return { status: 0, doc: JSON.parse(out) };
|
|
350
|
+
} catch (err) {
|
|
351
|
+
const e = err as { status: number; stdout: string };
|
|
352
|
+
return { status: e.status, doc: JSON.parse(e.stdout) };
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
test(
|
|
357
|
+
"new reads fields from standard input, review appends, and a refusal exits 1 with its code",
|
|
358
|
+
() => {
|
|
359
|
+
const { id: _id, ...rest } = decision({ title: "From stdin" });
|
|
360
|
+
const made = cli(["new", KIND, "--from", "-"], JSON.stringify(rest));
|
|
361
|
+
expect(made).toMatchObject({ status: 0, doc: { id: "ws-004", path: "decisions/ws-004-from-stdin.md" } });
|
|
362
|
+
expect(existsSync(join(dir, "decisions", "ws-004-from-stdin.md"))).toBe(true);
|
|
363
|
+
const reviewed = cli(["review", "ws-004", "--kind", KIND, "--verdict", "agree", "--by", "alice"]);
|
|
364
|
+
expect(reviewed).toMatchObject({ status: 0, doc: { review: { reviewer: "alice", verdict: "agree" } } });
|
|
365
|
+
const refused = cli(["review", "ws-004", "--kind", KIND, "--verdict", "dissent", "--by", "bob"]);
|
|
366
|
+
expect(refused).toMatchObject({ status: 1, doc: { error: { code: "review-note-required" } } });
|
|
367
|
+
writeFileSync(join(dir, "patch.json"), JSON.stringify({ title: "Changed" }));
|
|
368
|
+
expect(cli(["amend", "ws-004", "--kind", KIND, "--set", "patch.json"])).toMatchObject({ status: 1, doc: { error: { code: "amend-supersede-instead" } } });
|
|
369
|
+
expect(cli(["amend", "ws-004", "--set", "patch.json"])).toMatchObject({ status: 1, doc: { error: { code: "write-usage-invalid" } } });
|
|
370
|
+
},
|
|
371
|
+
120_000,
|
|
372
|
+
);
|
|
373
|
+
});
|